diff --git a/internal/runtime/executor/codex_executor_auth.go b/internal/runtime/executor/codex_executor_auth.go index 6d89241c..a4e59439 100644 --- a/internal/runtime/executor/codex_executor_auth.go +++ b/internal/runtime/executor/codex_executor_auth.go @@ -2,6 +2,11 @@ package executor import ( "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" "strconv" "strings" "time" @@ -14,79 +19,487 @@ import ( log "github.com/sirupsen/logrus" ) +const defaultOpenAISIWCBaseURL = "https://api.openai.com/v1" + +func codexUseSIWC(auth *cliproxyauth.Auth) bool { + if auth == nil { + return false + } + enabled := false + if len(auth.Attributes) > 0 { + if raw := strings.TrimSpace(auth.Attributes["use_siwc"]); raw != "" { + if parsed, errParse := strconv.ParseBool(raw); errParse == nil { + enabled = parsed + } + } + } + if !enabled && auth.Metadata != nil { + switch v := auth.Metadata["use_siwc"].(type) { + case bool: + enabled = v + case string: + if parsed, errParse := strconv.ParseBool(strings.TrimSpace(v)); errParse == nil { + enabled = parsed + } + } + } + return enabled +} + +func codexUseSIWCForRequest(ctx context.Context, auth *cliproxyauth.Auth, clientHeaders ...http.Header) bool { + return cliproxyauth.IsCodexUseSIWCForRequest(ctx, auth, clientHeaders...) +} + +func ensureCodexSIWCCredentials(auth *cliproxyauth.Auth, apiKey string) error { + return ensureCodexSIWCCredentialsForRequest(context.Background(), auth, apiKey) +} + +func ensureCodexSIWCCredentialsForRequest(ctx context.Context, auth *cliproxyauth.Auth, apiKey string, clientHeaders ...http.Header) error { + if !codexUseSIWC(auth) { + return nil + } + if cliproxyauth.IsCodexClientRequest(ctx, clientHeaders...) { + if strings.TrimSpace(apiKey) == "" { + return statusErr{code: 401, msg: "codex executor: original OAuth access_token is required for Codex client requests"} + } + return nil + } + if strings.TrimSpace(apiKey) == "" { + return statusErr{code: 401, msg: "codex executor: use_siwc is enabled but siwc access_token is missing"} + } + return nil +} + +func codexSIWCAccessToken(auth *cliproxyauth.Auth) string { + if auth == nil || auth.Metadata == nil { + return "" + } + siwcMap, ok := auth.Metadata["siwc"].(map[string]any) + if !ok || len(siwcMap) == 0 { + return "" + } + for _, key := range []string{"access_token", "access"} { + if tok, okTok := siwcMap[key].(string); okTok && strings.TrimSpace(tok) != "" { + return strings.TrimSpace(tok) + } + } + return "" +} + +func cloneSIWCMap(src map[string]any) map[string]any { + if src == nil { + return nil + } + dst := make(map[string]any, len(src)) + for k, v := range src { + switch typed := v.(type) { + case []string: + s := make([]string, len(typed)) + copy(s, typed) + dst[k] = s + case []any: + s := make([]any, len(typed)) + copy(s, typed) + dst[k] = s + default: + dst[k] = v + } + } + return dst +} + +func resolveCodexAuthDiskPath(cfg *config.Config, auth *cliproxyauth.Auth) (string, error) { + if auth == nil { + return "", nil + } + var candidates []string + if len(auth.Attributes) > 0 { + if p := strings.TrimSpace(auth.Attributes[cliproxyauth.AttributePath]); p != "" { + candidates = append(candidates, p) + } + if s := strings.TrimSpace(auth.Attributes[cliproxyauth.AttributeSource]); s != "" && !strings.Contains(s, ":") { + candidates = append(candidates, s) + } + } + for _, name := range []string{strings.TrimSpace(auth.FileName), strings.TrimSpace(auth.ID)} { + if name == "" { + continue + } + if filepath.IsAbs(name) { + candidates = append(candidates, name) + } else if cfg != nil && strings.TrimSpace(cfg.AuthDir) != "" { + candidates = append(candidates, filepath.Join(strings.TrimSpace(cfg.AuthDir), name)) + } + } + for _, candidate := range candidates { + info, errStat := os.Stat(candidate) + if errStat != nil { + if os.IsNotExist(errStat) { + continue + } + return "", fmt.Errorf("codex executor: stat credential file %s: %w", candidate, errStat) + } + if !info.IsDir() { + return candidate, nil + } + } + return "", nil +} + func (e *CodexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { log.Debugf("codex executor: refresh called") - if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled { - return refreshed, err + var cfg *config.Config + if e != nil { + cfg = e.cfg } + homeEnabled := cfg != nil && cfg.Home.Enabled if auth == nil { + if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, cfg, auth); handled { + return refreshed, err + } return nil, statusErr{code: 500, msg: "codex executor: auth is nil"} } - var refreshToken string - if auth.Metadata != nil { - if v, ok := auth.Metadata["refresh_token"].(string); ok && v != "" { - refreshToken = v - } + + diskPath, errPath := resolveCodexAuthDiskPath(cfg, auth) + if errPath != nil { + return nil, errPath } - if refreshToken == "" { - return auth, nil + if diskPath != "" { + var outAuth *cliproxyauth.Auth + errLock := codexauth.WithFileLock(diskPath, func() error { + var errInner error + outAuth, errInner = e.refreshLocked(ctx, auth, diskPath, homeEnabled) + return errInner + }) + return outAuth, errLock } - svc := codexauth.NewCodexAuthWithProxyURL(e.cfg, auth.ProxyURL) - td, err := svc.RefreshTokensWithRetry(ctx, refreshToken, 3) - if err != nil { - return nil, err + return e.refreshLocked(ctx, auth, "", homeEnabled) +} + +func (e *CodexExecutor) refreshLocked(ctx context.Context, auth *cliproxyauth.Auth, diskPath string, homeEnabled bool) (*cliproxyauth.Auth, error) { + var cfg *config.Config + if e != nil { + cfg = e.cfg } + beforeRefresh := auth.Clone() + auth.CodexRefreshPatch = nil if auth.Metadata == nil { auth.Metadata = make(map[string]any) } - auth.Metadata["id_token"] = td.IDToken - auth.Metadata["access_token"] = td.AccessToken - if td.RefreshToken != "" { - auth.Metadata["refresh_token"] = td.RefreshToken + + now := time.Now() + _, _, initNeedOuter := cliproxyauth.CodexOuterNeedsRefresh(auth, now) + _, _, initNeedSIWC := cliproxyauth.CodexSIWCNeedsRefresh(auth, now) + explicitUnscheduled := !initNeedOuter && !initNeedSIWC + + diskRotatedOuter := false + diskRotatedSIWC := false + var diskBaseline map[string]any + if diskPath != "" { + existingBytes, errRead := os.ReadFile(diskPath) + if errRead != nil { + return nil, fmt.Errorf("codex executor: read credential file %s: %w", diskPath, errRead) + } + var diskMap map[string]any + if errUnmarshal := json.Unmarshal(existingBytes, &diskMap); errUnmarshal != nil || len(diskMap) == 0 { + return nil, fmt.Errorf("codex executor: invalid credential JSON in %s: %w", diskPath, errUnmarshal) + } + diskBaseline = cloneSIWCMap(diskMap) + if siwcSub, okSub := diskMap["siwc"].(map[string]any); okSub { + diskBaseline["siwc"] = cloneSIWCMap(siwcSub) + } + inOuterRef, _ := auth.Metadata["refresh_token"].(string) + inOuterAcc, _ := auth.Metadata["access_token"].(string) + diskOuterRef, _ := diskMap["refresh_token"].(string) + diskOuterAcc, _ := diskMap["access_token"].(string) + if (strings.TrimSpace(diskOuterRef) != "" && strings.TrimSpace(diskOuterRef) != strings.TrimSpace(inOuterRef)) || + (strings.TrimSpace(diskOuterAcc) != "" && strings.TrimSpace(diskOuterAcc) != strings.TrimSpace(inOuterAcc)) { + diskRotatedOuter = true + for _, key := range []string{"id_token", "access_token", "refresh_token", "account_id", "last_refresh", "email", "expired", "plan_type", "type"} { + if val, exists := diskMap[key]; exists { + auth.Metadata[key] = val + } + } + } + diskSIWC, _ := diskMap["siwc"].(map[string]any) + inSIWC, _ := auth.Metadata["siwc"].(map[string]any) + if len(diskSIWC) > 0 { + inSIWCRef, _ := inSIWC["refresh_token"].(string) + inSIWCAcc, _ := inSIWC["access_token"].(string) + diskSIWCRef, _ := diskSIWC["refresh_token"].(string) + diskSIWCAcc, _ := diskSIWC["access_token"].(string) + if (strings.TrimSpace(diskSIWCRef) != "" && strings.TrimSpace(diskSIWCRef) != strings.TrimSpace(inSIWCRef)) || + (strings.TrimSpace(diskSIWCAcc) != "" && strings.TrimSpace(diskSIWCAcc) != strings.TrimSpace(inSIWCAcc)) { + diskRotatedSIWC = true + auth.Metadata["siwc"] = cloneSIWCMap(diskSIWC) + } + } + if diskUse, hasDiskUse := diskMap["use_siwc"]; hasDiskUse { + auth.Metadata["use_siwc"] = diskUse + } + } + + var outerRefreshToken string + var existingSIWC map[string]any + var siwcRefreshToken, siwcClientID, siwcDeviceID string + var preservedUseSIWC any + hasPreservedUseSIWC := false + + if v, ok := auth.Metadata["refresh_token"].(string); ok && strings.TrimSpace(v) != "" { + outerRefreshToken = strings.TrimSpace(v) + } + if v, exists := auth.Metadata["use_siwc"]; exists { + preservedUseSIWC = v + hasPreservedUseSIWC = true + } + if siwcRaw, ok := auth.Metadata["siwc"].(map[string]any); ok && len(siwcRaw) > 0 { + existingSIWC = cloneSIWCMap(siwcRaw) + if v, okStr := existingSIWC["refresh_token"].(string); okStr { + siwcRefreshToken = strings.TrimSpace(v) + } + if v, okStr := existingSIWC["client_id"].(string); okStr { + siwcClientID = strings.TrimSpace(v) + } + if v, okStr := existingSIWC["device_id"].(string); okStr { + siwcDeviceID = strings.TrimSpace(v) + } + } + + _, hasOuter, needOuter := cliproxyauth.CodexOuterNeedsRefresh(auth, now) + if outerRefreshToken == "" && !homeEnabled { + hasOuter = false + needOuter = false + } else if homeEnabled && !hasOuter { + hasOuter = true + if outerExp, okExp := auth.OuterExpirationTime(); okExp && !outerExp.IsZero() && !outerExp.After(now.Add(cliproxyauth.CodexOuterRefreshLead)) { + needOuter = true + } + } + _, hasSIWC, needSIWC := cliproxyauth.CodexSIWCNeedsRefresh(auth, now) + if siwcRefreshToken == "" || siwcClientID == "" { + hasSIWC = false + needSIWC = false + } + + if cliproxyauth.IsForceRefreshContext(ctx) { + needOuter = hasOuter + needSIWC = hasSIWC + } else if explicitUnscheduled { + if !diskRotatedOuter { + needOuter = hasOuter + } + if !diskRotatedSIWC { + needSIWC = hasSIWC + } + } + + if !needOuter && !needSIWC { + if diskRotatedOuter || diskRotatedSIWC { + patch := codexauth.NewCredentialRefreshPatchWithDiskBaseline(beforeRefresh.Metadata, diskBaseline, auth.Metadata, diskRotatedOuter, diskRotatedSIWC) + auth.CodexRefreshPatch = patch + if storage, ok := auth.Storage.(*codexauth.CodexTokenStorage); ok && storage != nil { + clonedStorage := *storage + clonedStorage.RefreshPatch = patch + auth.Storage = &clonedStorage + } + } + return auth, nil + } + + svc := codexauth.NewCodexAuthWithProxyURL(cfg, auth.ProxyURL) + + outerRefreshed := false + var outerErr error + siwcRefreshed := false + var siwcErr error + + // Execute refreshes sequentially so outer and SIWC never race on the same credential state. + if needOuter { + if homeEnabled { + if refreshed, handled, errHome := helps.RefreshAuthViaHome(ctx, cfg, auth); handled { + if errHome != nil { + outerErr = errHome + } else if refreshed != nil { + outerRefreshed = true + if refreshed.ID != "" { + auth.ID = refreshed.ID + } + if refreshed.Provider != "" { + auth.Provider = refreshed.Provider + } + auth.Index = refreshed.Index + if refreshed.Metadata != nil { + for k, val := range refreshed.Metadata { + if k == "siwc" || k == "use_siwc" { + continue + } + auth.Metadata[k] = val + } + if homeSIWC, okSIWC := refreshed.Metadata["siwc"].(map[string]any); okSIWC && len(homeSIWC) > 0 { + if codexSIWCAccessToken(refreshed) != "" && codexSIWCAccessToken(refreshed) != codexSIWCAccessToken(beforeRefresh) { + existingSIWC = cloneSIWCMap(homeSIWC) + siwcRefreshed = true + needSIWC = false + } + } + } + if len(refreshed.Attributes) > 0 { + clonedAttributes := make(map[string]string, len(auth.Attributes)+len(refreshed.Attributes)) + for k, v := range auth.Attributes { + clonedAttributes[k] = v + } + for k, v := range refreshed.Attributes { + clonedAttributes[k] = v + } + auth.Attributes = clonedAttributes + } + } + } + } else { + td, errOuter := svc.RefreshTokensWithRetry(ctx, outerRefreshToken, 3) + if errOuter != nil { + outerErr = errOuter + } else if td != nil { + outerRefreshed = true + auth.Metadata["id_token"] = td.IDToken + auth.Metadata["access_token"] = td.AccessToken + if td.RefreshToken != "" { + auth.Metadata["refresh_token"] = td.RefreshToken + } + if td.AccountID != "" { + auth.Metadata["account_id"] = td.AccountID + } + auth.Metadata["email"] = td.Email + auth.Metadata["expired"] = td.Expire + auth.Metadata["type"] = "codex" + auth.Metadata["last_refresh"] = time.Now().Format(time.RFC3339) + + planType := strings.TrimSpace(td.PlanType) + if planType == "" && td.IDToken != "" { + if claims, errParse := codexauth.ParseJWTToken(td.IDToken); errParse == nil && claims != nil { + planType = claims.GetPlanType() + } + } + if planType == "" { + planType = codexauth.DefaultPlanType + } + auth.Metadata["plan_type"] = planType + clonedAttributes := make(map[string]string, len(auth.Attributes)+1) + for k, v := range auth.Attributes { + clonedAttributes[k] = v + } + clonedAttributes["plan_type"] = planType + auth.Attributes = clonedAttributes + if storage, ok := auth.Storage.(*codexauth.CodexTokenStorage); ok && storage != nil { + clonedStorage := *storage + svc.UpdateTokenStorage(&clonedStorage, td) + clonedStorage.PlanType = planType + auth.Storage = &clonedStorage + } + } + } } - if td.AccountID != "" { - auth.Metadata["account_id"] = td.AccountID + + if needSIWC { + siwcTD, errSIWC := svc.RefreshSIWCTokensWithRetry(ctx, siwcRefreshToken, siwcClientID, siwcDeviceID, 3) + if errSIWC != nil { + siwcErr = errSIWC + } else if siwcTD != nil { + siwcRefreshed = true + mergedSIWC := cloneSIWCMap(existingSIWC) + if mergedSIWC == nil { + mergedSIWC = make(map[string]any) + } + delete(mergedSIWC, "expires_at") + for k, v := range siwcTD.ToMap() { + mergedSIWC[k] = v + } + existingSIWC = mergedSIWC + } } - auth.Metadata["email"] = td.Email - // Use unified key in files - auth.Metadata["expired"] = td.Expire - auth.Metadata["type"] = "codex" - now := time.Now().Format(time.RFC3339) - auth.Metadata["last_refresh"] = now - planType := strings.TrimSpace(td.PlanType) - if planType == "" && td.IDToken != "" { - if claims, errParse := codexauth.ParseJWTToken(td.IDToken); errParse == nil && claims != nil { - planType = claims.GetPlanType() + // Always preserve use_siwc and siwc fields on both Metadata and Storage. + if hasPreservedUseSIWC { + auth.Metadata["use_siwc"] = preservedUseSIWC + } + if len(existingSIWC) > 0 { + auth.Metadata["siwc"] = cloneSIWCMap(existingSIWC) + } + if storage, ok := auth.Storage.(*codexauth.CodexTokenStorage); ok && storage != nil { + clonedStorage := *storage + if hasPreservedUseSIWC { + if b, okBool := preservedUseSIWC.(bool); okBool { + clonedStorage.UseSIWC = &b + } } + if len(existingSIWC) > 0 { + clonedStorage.SIWC = cloneSIWCMap(existingSIWC) + } + auth.Storage = &clonedStorage } - if planType == "" { - planType = codexauth.DefaultPlanType + + if !outerRefreshed && !siwcRefreshed && !diskRotatedOuter && !diskRotatedSIWC { + if outerErr != nil { + return nil, outerErr + } + if siwcErr != nil { + return nil, siwcErr + } + return nil, fmt.Errorf("codex executor: refresh failed") + } + if outerErr != nil { + log.Warnf("codex executor: SIWC token refreshed but outer token refresh failed for %s: %v", auth.ID, outerErr) } - auth.Metadata["plan_type"] = planType - clonedAttributes := make(map[string]string, len(auth.Attributes)+1) - for k, v := range auth.Attributes { - clonedAttributes[k] = v + if siwcErr != nil { + log.Warnf("codex executor: outer token refreshed but SIWC token refresh failed for %s: %v", auth.ID, siwcErr) } - clonedAttributes["plan_type"] = planType - auth.Attributes = clonedAttributes + + patch := codexauth.NewCredentialRefreshPatchWithDiskBaseline(beforeRefresh.Metadata, diskBaseline, auth.Metadata, outerRefreshed || diskRotatedOuter, siwcRefreshed || diskRotatedSIWC) + auth.CodexRefreshPatch = patch if storage, ok := auth.Storage.(*codexauth.CodexTokenStorage); ok && storage != nil { clonedStorage := *storage - svc.UpdateTokenStorage(&clonedStorage, td) - clonedStorage.PlanType = planType + clonedStorage.RefreshPatch = patch auth.Storage = &clonedStorage } + if diskPath != "" { + latestBytes, errReadLatest := os.ReadFile(diskPath) + if errReadLatest != nil { + return auth, fmt.Errorf("codex executor: re-read credential file %s before write: %w", diskPath, errReadLatest) + } + var latestDisk map[string]any + if errUnmarshal := json.Unmarshal(latestBytes, &latestDisk); errUnmarshal != nil || len(latestDisk) == 0 { + return auth, fmt.Errorf("codex executor: invalid credential JSON in %s before write: %w", diskPath, errUnmarshal) + } + mergedDisk := patch.Apply(latestDisk) + if errWrite := codexauth.WriteJSONFileAtomic(diskPath, mergedDisk); errWrite != nil { + return auth, fmt.Errorf("codex executor: persist refreshed credentials to %s: %w", diskPath, errWrite) + } + auth.Metadata = mergedDisk + } return auth, nil } func codexCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { + return codexCredsForRequest(context.Background(), a) +} + +func codexCredsForRequest(ctx context.Context, a *cliproxyauth.Auth, clientHeaders ...http.Header) (apiKey, baseURL string) { if a == nil { return "", "" } if a.Attributes != nil { - apiKey = a.Attributes["api_key"] baseURL = a.Attributes["base_url"] } + if codexUseSIWCForRequest(ctx, a, clientHeaders...) { + apiKey = codexSIWCAccessToken(a) + if strings.TrimSpace(baseURL) == "" { + baseURL = defaultOpenAISIWCBaseURL + } + return + } + if a.Attributes != nil { + apiKey = a.Attributes["api_key"] + } if apiKey == "" && a.Metadata != nil { if v, ok := a.Metadata["access_token"].(string); ok { apiKey = v diff --git a/internal/runtime/executor/codex_executor_execute.go b/internal/runtime/executor/codex_executor_execute.go index 9bc1b6a4..c6e86d54 100644 --- a/internal/runtime/executor/codex_executor_execute.go +++ b/internal/runtime/executor/codex_executor_execute.go @@ -28,7 +28,10 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re } baseModel := thinking.ParseSuffix(req.Model).ModelName - apiKey, baseURL := codexCreds(auth) + apiKey, baseURL := codexCredsForRequest(ctx, auth, opts.Headers) + if errSIWC := ensureCodexSIWCCredentialsForRequest(ctx, auth, apiKey, opts.Headers); errSIWC != nil { + return resp, errSIWC + } if baseURL == "" { baseURL = "https://chatgpt.com/backend-api/codex" } @@ -45,16 +48,20 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re } originalPayload := originalPayloadSource isCompat := e.resolveCodexModelIsCompat(auth, req, baseModel) - originalTranslated, body, updatesChanged := translateCodexRequestPairWithUpdateIntent(from, to, baseModel, originalPayload, req.Payload, false, isCompat) + targetModel := resolveResponsesSignatureTargetModel(baseModel, req, opts) + originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntent(from, to, targetModel, originalPayload, req.Payload, false, isCompat) + if err != nil { + return resp, err + } + originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel) + body = helps.SetStringIfDifferent(body, "model", baseModel) body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged) if err != nil { return resp, err } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequestForExecutor(e.cfg, e.Identifier(), baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) + ctx = helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslated, req, opts)) body = helps.SetStringIfDifferent(body, "model", baseModel) body = helps.SetBoolIfDifferent(body, "stream", true) body, _ = sjson.DeleteBytes(body, "previous_response_id") @@ -62,11 +69,17 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re body, _ = sjson.DeleteBytes(body, "prompt_cache_retention") body, _ = sjson.DeleteBytes(body, "safety_identifier") body, _ = sjson.DeleteBytes(body, "stream_options") - body = normalizeCodexInstructions(body, helps.IsNativeCodexRequest(req.Payload, opts)) - if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { - body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers) + useSIWC := codexUseSIWCForRequest(ctx, auth, opts.Headers) + hasExplicitInstructions := gjson.GetBytes(req.Payload, "instructions").Exists() && strings.TrimSpace(gjson.GetBytes(req.Payload, "instructions").String()) != "" + body = ensureWebSearchToolForHistory(body, opts.Headers) + if !useSIWC { + body = normalizeCodexInstructions(body, helps.IsNativeCodexRequest(req.Payload, opts)) + if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { + body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers) + } + } else { + body = applyCodexSIWCRequestBodyRules(body, hasExplicitInstructions, cliproxyexecutor.Options{KeepTopLevelEffort: thinking.HasThinkingSuffix(req.Model)}) } - body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompat(ctx, "codex executor", body, isCompat) body = normalizeCodexParallelToolCalls(body, opts.Headers) body = helps.NormalizeCodexToolSchemas(body) body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, isCompat) @@ -74,16 +87,21 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re if errReplay != nil { return resp, errReplay } - reporter.SetTranslatedReasoningEffort(body, to.String()) + body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx, "codex executor", body, isCompat, targetModel, baseURL) url := strings.TrimSuffix(baseURL, "/") + "/responses" - httpReq, upstreamBody, err := e.cacheHelper(ctx, from, url, req, body, opts.Headers) + httpReq, upstreamBody, err := e.cacheHelper(ctx, from, url, req, body, auth, opts.Headers) if err != nil { return resp, err } - applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg, opts.Headers) - applyCodexRoutingHint(ctx, httpReq.Header, auth, baseModel, upstreamBody, opts.Headers) - applyModelHeaderOverrides(httpReq.Header, baseModel) + reporter.SetTranslatedReasoningEffort(upstreamBody, to.String()) + prepareHeaders := func(request *http.Request) { + applyCodexHeaders(request, auth, apiKey, true, e.cfg, opts.Headers) + applyCodexRoutingHint(ctx, request.Header, auth, baseModel, upstreamBody, opts.Headers) + applyModelHeaderOverrides(request.Header, baseModel) + alignCodexContentEncoding(request) + } + prepareHeaders(httpReq) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID @@ -103,7 +121,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re }) httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0) httpClient = reporter.TrackHTTPClient(httpClient) - httpResp, err := httpClient.Do(httpReq) + httpResp, err := doCodexRequest(httpClient, httpReq, upstreamBody, prepareHeaders, nil) if err != nil { helps.RecordAPIResponseError(ctx, e.cfg, err) return resp, err @@ -127,18 +145,19 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re data, errRead := io.ReadAll(httpResp.Body) helps.AppendAPIResponseChunk(ctx, e.cfg, data) - lines := bytes.Split(data, []byte("\n")) + events := helps.NewCodexSSEScanner(ctx, bytes.NewReader(data), len(data)+1) outputItemsByIndex := make(map[int64][]byte) var outputItemsFallback [][]byte sawOutputDelta := false - for _, line := range lines { + for events.Scan() { + line := events.Bytes() if !bytes.HasPrefix(line, dataTag) { continue } eventData := bytes.TrimSpace(line[5:]) eventData = helps.RestoreCodexMultiAgentV2Response(eventData, optimizeMultiAgentV2) - reporter.ObserveCodexResponseModel(eventData) + reporter.ObserveResponseModel(eventData) eventType := gjson.GetBytes(eventData, "type").String() if helps.HasMeaningfulCodexOutputDelta(eventData) { @@ -211,7 +230,10 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { baseModel := thinking.ParseSuffix(req.Model).ModelName - apiKey, baseURL := codexCreds(auth) + apiKey, baseURL := codexCredsForRequest(ctx, auth, opts.Headers) + if errSIWC := ensureCodexSIWCCredentialsForRequest(ctx, auth, apiKey, opts.Headers); errSIWC != nil { + return resp, errSIWC + } if baseURL == "" { baseURL = "https://chatgpt.com/backend-api/codex" } @@ -228,33 +250,46 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A } originalPayload := originalPayloadSource isCompat := e.resolveCodexModelIsCompat(auth, req, baseModel) - originalTranslated, body, updatesChanged := translateCodexRequestPairWithUpdateIntent(from, to, baseModel, originalPayload, req.Payload, false, isCompat) + targetModel := resolveResponsesSignatureTargetModel(baseModel, req, opts) + originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntent(from, to, targetModel, originalPayload, req.Payload, false, isCompat) + if err != nil { + return resp, err + } + originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel) body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged) if err != nil { return resp, err } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequestForExecutor(e.cfg, e.Identifier(), baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) + ctx = helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslated, req, opts)) body = helps.SetStringIfDifferent(body, "model", baseModel) body, _ = sjson.DeleteBytes(body, "stream") - body = normalizeCodexInstructions(body, helps.IsNativeCodexRequest(req.Payload, opts)) - body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompat(ctx, "codex executor", body, isCompat) + if !codexUseSIWCForRequest(ctx, auth, opts.Headers) { + body = normalizeCodexInstructions(body, helps.IsNativeCodexRequest(req.Payload, opts)) + } else { + hasExplicitInstructions := gjson.GetBytes(req.Payload, "instructions").Exists() && strings.TrimSpace(gjson.GetBytes(req.Payload, "instructions").String()) != "" + body = applyCodexSIWCCompactBodyRules(body, hasExplicitInstructions, cliproxyexecutor.Options{KeepTopLevelEffort: thinking.HasThinkingSuffix(req.Model)}) + } + body = ensureWebSearchToolForHistory(body, opts.Headers) + body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx, "codex executor", body, isCompat, resolveResponsesSignatureTargetModel(baseModel, req, opts), baseURL) body = normalizeCodexParallelToolCalls(body, opts.Headers) body = helps.NormalizeCodexToolSchemas(body) body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, isCompat) - reporter.SetTranslatedReasoningEffort(body, to.String()) url := strings.TrimSuffix(baseURL, "/") + "/responses/compact" - httpReq, upstreamBody, err := e.cacheHelper(ctx, from, url, req, body, opts.Headers) + httpReq, upstreamBody, err := e.cacheHelper(ctx, from, url, req, body, auth, opts.Headers) if err != nil { return resp, err } - applyCodexHeaders(httpReq, auth, apiKey, false, e.cfg, opts.Headers) - applyCodexRoutingHint(ctx, httpReq.Header, auth, baseModel, upstreamBody, opts.Headers) - applyModelHeaderOverrides(httpReq.Header, baseModel) + reporter.SetTranslatedReasoningEffort(upstreamBody, to.String()) + prepareHeaders := func(request *http.Request) { + applyCodexHeaders(request, auth, apiKey, false, e.cfg, opts.Headers) + applyCodexRoutingHint(ctx, request.Header, auth, baseModel, upstreamBody, opts.Headers) + applyModelHeaderOverrides(request.Header, baseModel) + alignCodexContentEncoding(request) + } + prepareHeaders(httpReq) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID @@ -274,7 +309,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A }) httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0) httpClient = reporter.TrackHTTPClient(httpClient) - httpResp, err := httpClient.Do(httpReq) + httpResp, err := doCodexRequest(httpClient, httpReq, upstreamBody, prepareHeaders, nil) if err != nil { helps.RecordAPIResponseError(ctx, e.cfg, err) return resp, err diff --git a/internal/runtime/executor/codex_executor_reasoning.go b/internal/runtime/executor/codex_executor_reasoning.go index 7bf5f67e..825b36e9 100644 --- a/internal/runtime/executor/codex_executor_reasoning.go +++ b/internal/runtime/executor/codex_executor_reasoning.go @@ -199,7 +199,8 @@ func codexInputHasValidReasoningEncryptedContent(body []byte) bool { if encryptedContent.Type != gjson.String { continue } - if _, err := signature.InspectGPTReasoningSignature(encryptedContent.String()); err == nil { + rawSig := signature.SignaturePayloadWithoutProviderPrefix(encryptedContent.String()) + if signature.IsValidGPTReasoningSignature(rawSig) || signature.IsValidMetaReasoningSignature(rawSig) || signature.IsValidGrokEncryptedContent(rawSig) { return true } } @@ -361,6 +362,20 @@ func codexReasoningReplayTurnAnchorIndex(inputItems []gjson.Result, turn codexRe return 0, false } +func canonicalCodexReasoningReplaySignatureKey(rawSignature string) string { + sig := signature.SignaturePayloadWithoutProviderPrefix(strings.TrimSpace(rawSignature)) + if gptInfo, err := signature.InspectGPTReasoningSignature(sig); err == nil && gptInfo.RouterSuffix != "" { + return stripRouterSuffix(sig, gptInfo.RouterSuffix) + } + if metaInfo, err := signature.InspectMetaReasoningSignature(sig); err == nil && metaInfo.RouterSuffix != "" { + return stripRouterSuffix(sig, metaInfo.RouterSuffix) + } + if grokInfo, err := signature.InspectGrokEncryptedContent(sig); err == nil && grokInfo.RouterSuffix != "" { + return stripRouterSuffix(sig, grokInfo.RouterSuffix) + } + return sig +} + func filterCodexReasoningReplayTurnItems(inputItems []gjson.Result, items [][]byte) [][]byte { existingReasoning := make(map[string]bool) existingCalls := make(map[string]bool) @@ -369,7 +384,7 @@ func filterCodexReasoningReplayTurnItems(inputItems []gjson.Result, items [][]by itemType := strings.TrimSpace(inputItem.Get("type").String()) switch itemType { case "reasoning": - if encryptedContent := strings.TrimSpace(inputItem.Get("encrypted_content").String()); encryptedContent != "" { + if encryptedContent := canonicalCodexReasoningReplaySignatureKey(inputItem.Get("encrypted_content").String()); encryptedContent != "" { existingReasoning[encryptedContent] = true } case "function_call_output", "custom_tool_call_output": @@ -387,7 +402,7 @@ func filterCodexReasoningReplayTurnItems(inputItems []gjson.Result, items [][]by itemResult := gjson.ParseBytes(item) switch strings.TrimSpace(itemResult.Get("type").String()) { case "reasoning": - if existingReasoning[strings.TrimSpace(itemResult.Get("encrypted_content").String())] { + if existingReasoning[canonicalCodexReasoningReplaySignatureKey(itemResult.Get("encrypted_content").String())] { continue } case "function_call", "custom_tool_call": diff --git a/internal/runtime/executor/codex_executor_request.go b/internal/runtime/executor/codex_executor_request.go index d300bda2..22613546 100644 --- a/internal/runtime/executor/codex_executor_request.go +++ b/internal/runtime/executor/codex_executor_request.go @@ -4,12 +4,16 @@ import ( "bytes" "context" "fmt" + "io" "net/http" + neturl "net/url" + "runtime" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/klauspost/compress/zstd" "github.com/router-for-me/CLIProxyAPI/v8/internal/config" "github.com/router-for-me/CLIProxyAPI/v8/internal/misc" "github.com/router-for-me/CLIProxyAPI/v8/internal/registry" @@ -19,6 +23,7 @@ import ( cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" + log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -33,27 +38,28 @@ const ( var dataTag = []byte("data:") func translateCodexRequestPair(from, to sdktranslator.Format, model string, originalPayload, payload []byte, stream bool, preserveEmptyThinkingBlocks ...bool) ([]byte, []byte) { - original, body, _ := translateCodexRequestPairWithUpdateIntent(from, to, model, originalPayload, payload, stream, preserveEmptyThinkingBlocks...) + original, body, _, _ := translateCodexRequestPairWithUpdateIntent(from, to, model, originalPayload, payload, stream, preserveEmptyThinkingBlocks...) return original, body } -func translateCodexRequestPairWithUpdateIntent(from, to sdktranslator.Format, model string, originalPayload, payload []byte, stream bool, preserveEmptyThinkingBlocks ...bool) ([]byte, []byte, bool) { +func translateCodexRequestPairWithUpdateIntent(from, to sdktranslator.Format, model string, originalPayload, payload []byte, stream bool, preserveEmptyThinkingBlocks ...bool) ([]byte, []byte, bool, error) { isCompat := len(preserveEmptyThinkingBlocks) > 0 && preserveEmptyThinkingBlocks[0] ctx := context.Background() - translate := func(raw []byte) ([]byte, bool) { + translate := func(raw []byte) ([]byte, bool, error) { if isCompat && from == sdktranslator.FormatClaude && to == sdktranslator.FormatCodex { - return helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, nil, nil, from, to, model, raw, stream, true), false + body, err := helps.TranslateRequestReturningError(ctx, nil, nil, from, to, model, raw, stream, true) + return body, false, err } translated := sdktranslator.TranslateRequestEnvelope(ctx, from, to, sdktranslator.RequestEnvelope{Format: from, Model: model, Stream: stream, Body: raw}) - return translated.Body, translated.ConfigurationUpdatesChanged + return translated.Body, translated.ConfigurationUpdatesChanged, translated.Err } if bytes.Equal(originalPayload, payload) { - body, changed := translate(payload) - return body, body, changed + body, changed, err := translate(payload) + return body, body, changed, err } - originalTranslated, _ := translate(originalPayload) - body, changed := translate(payload) - return originalTranslated, body, changed + originalTranslated, _, _ := translate(originalPayload) + body, changed, err := translate(payload) + return originalTranslated, body, changed, err } // PrepareRequest injects Codex credentials into the outgoing HTTP request. @@ -61,7 +67,17 @@ func (e *CodexExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Aut if req == nil { return nil } - apiKey, _ := codexCreds(auth) + apiKey, _ := codexCredsForRequest(req.Context(), auth, req.Header) + if codexUseSIWCForRequest(req.Context(), auth, req.Header) && req.URL != nil && req.URL.Scheme == "https" && + req.URL.Host == "chatgpt.com" && req.URL.Path == "/backend-api/codex/alpha/search" { + // ChatGPT Alpha Search requires the original OAuth partition, not SIWC. + apiKey, _ = auth.Metadata["access_token"].(string) + if strings.TrimSpace(apiKey) == "" { + return statusErr{code: http.StatusUnauthorized, msg: "codex executor: original OAuth access_token is required for ChatGPT Alpha Search"} + } + } else if errSIWC := ensureCodexSIWCCredentialsForRequest(req.Context(), auth, apiKey, req.Header); errSIWC != nil { + return errSIWC + } if strings.TrimSpace(apiKey) != "" { req.Header.Set("Authorization", "Bearer "+apiKey) } else { @@ -72,6 +88,7 @@ func (e *CodexExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Aut attrs = auth.Attributes } util.ApplyCustomHeadersFromAttrs(req, attrs) + alignCodexContentEncoding(req) return nil } @@ -91,7 +108,7 @@ func (e *CodexExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth return httpClient.Do(httpReq) } -func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Format, url string, req cliproxyexecutor.Request, rawJSON []byte, headerSets ...http.Header) (*http.Request, []byte, error) { +func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Format, url string, req cliproxyexecutor.Request, rawJSON []byte, auth *cliproxyauth.Auth, headerSets ...http.Header) (*http.Request, []byte, error) { var headers http.Header if len(headerSets) > 0 { headers = headerSets[0] @@ -135,16 +152,166 @@ func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Form rawJSON = helps.SetStringIfDifferent(rawJSON, "prompt_cache_key", cache.ID) } rawJSON = helps.SanitizeCodexInputItemIDs(rawJSON) - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(rawJSON)) + rawJSON = helps.FinalizePayload(ctx, rawJSON) + wireBody := rawJSON + if e.cfg != nil && e.cfg.Codex.UpstreamRequestCompression && auth != nil && + !codexAuthUsesAPIKey(auth) && !codexUseSIWCForRequest(ctx, auth, headers) && + len(rawJSON) >= e.cfg.Codex.RequestCompressionMinBytes() { + parsedURL, errParse := neturl.Parse(url) + if errParse == nil && strings.EqualFold(parsedURL.Hostname(), "chatgpt.com") && strings.HasPrefix(parsedURL.Path, "/backend-api/codex/") { + encoder, errNewWriter := zstd.NewWriter(nil, zstd.WithEncoderLevel(zstd.SpeedDefault)) + if errNewWriter == nil { + compressed := encoder.EncodeAll(rawJSON, nil) + if errClose := encoder.Close(); errClose != nil { + log.Errorf("codex executor: close request encoder error: %v", errClose) + } else if len(compressed) < len(rawJSON) { + wireBody = compressed + } + } + } + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(wireBody)) if err != nil { return nil, nil, err } + if len(wireBody) < len(rawJSON) { + httpReq.Header.Set("Content-Encoding", "zstd") + } + alignCodexContentEncoding(httpReq) + if httpReq.Header.Get("Accept-Encoding") == "" { + httpReq.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") + } if cache.ID != "" { httpReq.Header.Set("Session-Id", cache.ID) } return httpReq, rawJSON, nil } +// alignCodexContentEncoding inspects a replay copy without consuming the live request body. +func alignCodexContentEncoding(req *http.Request) { + if req == nil || req.GetBody == nil { + return + } + body, errGetBody := req.GetBody() + if errGetBody != nil || body == nil { + return + } + defer func() { + if errClose := body.Close(); errClose != nil { + log.Errorf("codex executor: close request body copy error: %v", errClose) + } + }() + prefix, errRead := io.ReadAll(io.LimitReader(body, 512)) + if errRead != nil { + return + } + if req.Header == nil { + req.Header = make(http.Header) + } + plain := bytes.TrimSpace(prefix) + jsonPlaintext := len(plain) > 0 && (plain[0] == '{' || plain[0] == '[') + multipartPlaintext := strings.HasPrefix(strings.ToLower(strings.TrimSpace(req.Header.Get("Content-Type"))), "multipart/") && bytes.HasPrefix(plain, []byte("--")) + if jsonPlaintext || multipartPlaintext { + req.Header.Del("Content-Encoding") + return + } + encodingCount := 0 + for _, value := range req.Header.Values("Content-Encoding") { + for _, token := range strings.Split(value, ",") { + token = strings.TrimSpace(token) + if token != "" && !strings.EqualFold(token, "identity") { + encodingCount++ + } + } + } + // Magic identifies only the outermost layer, so trust declared encoding chains. + if encodingCount > 1 { + return + } + switch { + case bytes.HasPrefix(prefix, []byte{0x28, 0xb5, 0x2f, 0xfd}): + req.Header.Set("Content-Encoding", "zstd") + case bytes.HasPrefix(prefix, []byte{0x1f, 0x8b}): + req.Header.Set("Content-Encoding", "gzip") + default: + // Our zstd encoder always emits its magic; preserve opaque non-zstd encodings. + var remaining []string + removedZstd := false + for _, value := range req.Header.Values("Content-Encoding") { + for _, token := range strings.Split(value, ",") { + token = strings.TrimSpace(token) + if strings.EqualFold(token, "zstd") { + removedZstd = true + } else if token != "" { + remaining = append(remaining, token) + } + } + } + if removedZstd { + req.Header.Del("Content-Encoding") + if len(remaining) > 0 { + req.Header.Set("Content-Encoding", strings.Join(remaining, ", ")) + } + } + } +} + +func decodeCodexUpstreamResponse(resp *http.Response) error { + if resp == nil || resp.Body == nil { + return nil + } + decoded, errDecode := helps.DecodeResponseBody(resp.Body, resp.Header) + if errDecode != nil { + return fmt.Errorf("codex executor: decode upstream response: %w", errDecode) + } + resp.Body = decoded + resp.Header.Del("Content-Encoding") + resp.Header.Del("Content-Length") + resp.ContentLength = -1 + return nil +} + +// doCodexRequest retries a rejected zstd body once without rerunning payload rules. +func doCodexRequest(client *http.Client, req *http.Request, upstreamBody []byte, prepareHeaders func(*http.Request), responseReady func(*http.Response)) (*http.Response, error) { + alignCodexContentEncoding(req) + compressed := req.Header.Get("Content-Encoding") == "zstd" + resp, errDo := client.Do(req) + if errDo != nil { + return nil, errDo + } + if responseReady != nil { + responseReady(resp) + } + if errDecode := decodeCodexUpstreamResponse(resp); errDecode != nil { + return nil, errDecode + } + if !compressed || (resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusUnsupportedMediaType) { + return resp, nil + } + if errClose := resp.Body.Close(); errClose != nil { + log.Errorf("codex executor: close rejected response body error: %v", errClose) + } + plainReq, errNewRequest := http.NewRequestWithContext(req.Context(), req.Method, req.URL.String(), bytes.NewReader(upstreamBody)) + if errNewRequest != nil { + return nil, errNewRequest + } + plainReq.Header = req.Header.Clone() + plainReq.Header.Del("Content-Encoding") + prepareHeaders(plainReq) + alignCodexContentEncoding(plainReq) + resp, errDo = client.Do(plainReq) + if errDo != nil { + return nil, errDo + } + if responseReady != nil { + responseReady(resp) + } + if errDecode := decodeCodexUpstreamResponse(resp); errDecode != nil { + return nil, errDecode + } + return resp, nil +} + func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config, clientHeaders ...http.Header) { var ginHeaders http.Header if len(clientHeaders) > 0 && clientHeaders[0] != nil { @@ -152,7 +319,7 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s } else if ginCtx, ok := r.Context().Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil { ginHeaders = ginCtx.Request.Header } - applyCodexHeadersFromSources(r, auth, token, stream, cfg, ginHeaders) + applyCodexHeadersWithSIWC(r, auth, codexUseSIWCForRequest(r.Context(), auth, ginHeaders), token, stream, cfg, ginHeaders) } // applyModelHeaderOverrides forces models.json config.override_header onto upstream headers. @@ -175,6 +342,7 @@ func applyModelHeaderOverrides(headers http.Header, modelName string) { // applyCodexDirectImageHeaders sets Codex upstream headers for direct /images/* calls. // Downstream client User-Agent values are not forwarded to reduce Cloudflare 1010 blocks. func applyCodexDirectImageHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config, clientHeaders ...http.Header) { + useSIWC := codexUseSIWCForRequest(r.Context(), auth, clientHeaders...) var ginHeaders http.Header if len(clientHeaders) > 0 && clientHeaders[0] != nil { ginHeaders = clientHeaders[0].Clone() @@ -183,11 +351,89 @@ func applyCodexDirectImageHeaders(r *http.Request, auth *cliproxyauth.Auth, toke ginHeaders = ginCtx.Request.Header.Clone() ginHeaders.Del("User-Agent") } - applyCodexHeadersFromSources(r, auth, token, stream, cfg, ginHeaders) + applyCodexHeadersWithSIWC(r, auth, useSIWC, token, stream, cfg, ginHeaders) +} + +func codexSIWCUserAgent(cfg *config.Config) string { + agentName := config.DefaultSignInWithChatGPTIdentifier + if cfg != nil { + agentName = cfg.Codex.EffectiveSignInWithChatGPTIdentifier() + } + return fmt.Sprintf("%s (%s; %s)", agentName, runtime.GOOS, runtime.GOARCH) +} + +func applyCodexSIWCRequestBodyRules(body []byte, hasExplicitInstructions bool, opts ...cliproxyexecutor.Options) []byte { + body = helps.SetBoolIfDifferent(body, "store", false) + body = helps.SetBoolIfDifferent(body, "stream", true) + return stripCodexSIWCUnsupportedFields(body, hasExplicitInstructions, opts...) +} + +func applyCodexSIWCWebsocketBodyRules(body []byte, hasExplicitInstructions bool, opts ...cliproxyexecutor.Options) []byte { + body = helps.SetBoolIfDifferent(body, "store", false) + body, _ = sjson.DeleteBytes(body, "stream") + body, _ = sjson.DeleteBytes(body, "background") + return stripCodexSIWCUnsupportedFields(body, hasExplicitInstructions, opts...) +} + +func applyCodexSIWCCompactBodyRules(body []byte, hasExplicitInstructions bool, opts ...cliproxyexecutor.Options) []byte { + body, _ = sjson.DeleteBytes(body, "stream") + body, _ = sjson.DeleteBytes(body, "store") + return stripCodexSIWCUnsupportedFields(body, hasExplicitInstructions, opts...) +} + +func stripCodexSIWCUnsupportedFields(body []byte, hasExplicitInstructions bool, opts ...cliproxyexecutor.Options) []byte { + // SIWC subscriptions reject configuration_update input items, so degrade + // them to the classic top-level reasoning.effort and accept the cache loss. + // An explicit model thinking suffix is an operator override: the thinking + // layer has already resolved it into the top-level effort, so only the + // unsupported items are removed in that case. + body = thinking.CollapseConfigurationUpdates(body, len(opts) > 0 && opts[0].KeepTopLevelEffort) + body, _ = sjson.DeleteBytes(body, "prompt_cache_retention") + body, _ = sjson.DeleteBytes(body, "prompt_cache_options") + body, _ = sjson.DeleteBytes(body, "max_output_tokens") + body, _ = sjson.DeleteBytes(body, "max_completion_tokens") + body, _ = sjson.DeleteBytes(body, "temperature") + if !hasExplicitInstructions { + if inst := gjson.GetBytes(body, "instructions"); inst.Exists() && strings.TrimSpace(inst.String()) == "" { + body, _ = sjson.DeleteBytes(body, "instructions") + } + } + return body } func applyCodexHeadersFromSources(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config, ginHeaders http.Header) { + applyCodexHeadersWithSIWC(r, auth, codexUseSIWCForRequest(r.Context(), auth, ginHeaders), token, stream, cfg, ginHeaders) +} + +func applyCodexHeadersWithSIWC(r *http.Request, auth *cliproxyauth.Auth, useSIWC bool, token string, stream bool, cfg *config.Config, ginHeaders http.Header) { r.Header.Set("Content-Type", "application/json") + if strings.TrimSpace(token) != "" { + r.Header.Set("Authorization", "Bearer "+token) + } else { + r.Header.Del("Authorization") + } + + if useSIWC { + if sessionID := strings.TrimSpace(r.Header.Get("Session-Id")); sessionID != "" { + r.Header.Del("Session-Id") + setHeaderCasePreserved(r.Header, "session_id", sessionID) + r.Header.Set("X-Client-Request-Id", sessionID) + } + r.Header.Set("User-Agent", codexSIWCUserAgent(cfg)) + if stream { + r.Header.Set("Accept", "text/event-stream") + } else { + r.Header.Set("Accept", "application/json") + } + r.Header.Set("Connection", "Keep-Alive") + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(r, attrs, ginHeaders) + return + } + if ginHeaders != nil && ginHeaders.Get("X-Codex-Beta-Features") != "" { r.Header.Set("X-Codex-Beta-Features", ginHeaders.Get("X-Codex-Beta-Features")) } @@ -263,7 +509,7 @@ const codexRoutingHintHeader = "X-Codex-Routing-Hint" // API-key requests are not touched, matching native Codex, which sends no hint // to API-key providers. func applyCodexRoutingHint(ctx context.Context, headers http.Header, auth *cliproxyauth.Auth, baseModel string, upstreamBody []byte, clientHeaders http.Header) { - if codexAuthUsesAPIKey(auth) { + if codexAuthUsesAPIKey(auth) || codexUseSIWCForRequest(ctx, auth, clientHeaders) { return } deleteHeaderCaseInsensitive(headers, codexRoutingHintHeader) @@ -337,6 +583,81 @@ func normalizeCodexInstructions(body []byte, nativeRequest ...bool) []byte { var imageGenToolJSON = []byte(`{"type":"image_generation","output_format":"png"}`) var imageGenToolArrayJSON = []byte(`[{"type":"image_generation","output_format":"png"}]`) +var webSearchToolJSON = []byte(`{"type":"web_search"}`) +var webSearchToolArrayJSON = []byte(`[{"type":"web_search"}]`) + +func isCodexWebSearchToolType(toolType string) bool { + switch strings.TrimSpace(toolType) { + case "web_search", "web_search_preview", "web_search_preview_2025_03_11": + return true + default: + return false + } +} + +// ensureWebSearchToolForHistory reconciles replayed web_search_call input items +// with the request's tool declarations so upstream Codex does not fail with +// "response protection is unavailable". Standard Responses requests receive a +// {"type":"web_search"} tool declaration when missing, while Responses Lite +// requests (which omit top-level hosted tools) strip the replayed search items. +func ensureWebSearchToolForHistory(body []byte, headers http.Header) []byte { + if !bytes.Contains(body, []byte("web_search_call")) { + return body + } + input := util.GetGJSONBytesNoCopy(body, "input") + if !input.IsArray() { + return body + } + hasWebSearchCall := false + input.ForEach(func(_, item gjson.Result) bool { + if strings.TrimSpace(item.Get("type").String()) == "web_search_call" { + hasWebSearchCall = true + return false + } + return true + }) + if !hasWebSearchCall { + return body + } + + tools := util.GetGJSONBytesNoCopy(body, "tools") + if tools.IsArray() { + for _, t := range tools.Array() { + if isCodexWebSearchToolType(t.Get("type").String()) { + return body + } + } + } + + if util.IsCodexResponsesLiteRequest(body, headers) { + items := input.Array() + rebuilt := make([]string, 0, len(items)) + for _, item := range items { + if strings.TrimSpace(item.Get("type").String()) == "web_search_call" { + continue + } + rebuilt = append(rebuilt, item.Raw) + } + updated, errSet := sjson.SetRawBytes(body, "input", []byte("["+strings.Join(rebuilt, ",")+"]")) + if errSet != nil { + return body + } + return updated + } + + if !tools.Exists() || !tools.IsArray() { + updated, errSet := sjson.SetRawBytes(body, "tools", webSearchToolArrayJSON) + if errSet != nil { + return body + } + return updated + } + updated, errSet := sjson.SetRawBytes(body, "tools.-1", webSearchToolJSON) + if errSet != nil { + return body + } + return updated +} func isCodexFreePlanAuth(auth *cliproxyauth.Auth) bool { if auth == nil || auth.Attributes == nil { diff --git a/internal/runtime/executor/codex_executor_stream.go b/internal/runtime/executor/codex_executor_stream.go index bae496f6..35dbda91 100644 --- a/internal/runtime/executor/codex_executor_stream.go +++ b/internal/runtime/executor/codex_executor_stream.go @@ -1,7 +1,6 @@ package executor import ( - "bufio" "bytes" "context" "io" @@ -31,7 +30,10 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au } baseModel := thinking.ParseSuffix(req.Model).ModelName - apiKey, baseURL := codexCreds(auth) + apiKey, baseURL := codexCredsForRequest(ctx, auth, opts.Headers) + if errSIWC := ensureCodexSIWCCredentialsForRequest(ctx, auth, apiKey, opts.Headers); errSIWC != nil { + return nil, errSIWC + } if baseURL == "" { baseURL = "https://chatgpt.com/backend-api/codex" } @@ -50,16 +52,20 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au } originalPayload := originalPayloadSource isCompat := e.resolveCodexModelIsCompat(auth, req, baseModel) - originalTranslated, body, updatesChanged := translateCodexRequestPairWithUpdateIntent(from, to, baseModel, originalPayload, req.Payload, true, isCompat) + targetModel := resolveResponsesSignatureTargetModel(baseModel, req, opts) + originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntent(from, to, targetModel, originalPayload, req.Payload, true, isCompat) + if err != nil { + return nil, err + } + originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel) + body = helps.SetStringIfDifferent(body, "model", baseModel) body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged) if err != nil { return nil, err } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequestForExecutor(e.cfg, e.Identifier(), baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) + ctx = helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslated, req, opts)) body, _ = sjson.DeleteBytes(body, "previous_response_id") body, _ = sjson.DeleteBytes(body, "generate") body, _ = sjson.DeleteBytes(body, "prompt_cache_retention") @@ -70,11 +76,17 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au body, _ = sjson.SetBytes(body, "stream_options.reasoning_summary_delivery", reasoningSummaryDelivery.Value()) } body = helps.SetStringIfDifferent(body, "model", baseModel) - body = normalizeCodexInstructions(body, preserveNativeOutput) - if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { - body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers) + useSIWC := codexUseSIWCForRequest(ctx, auth, opts.Headers) + hasExplicitInstructions := gjson.GetBytes(req.Payload, "instructions").Exists() && strings.TrimSpace(gjson.GetBytes(req.Payload, "instructions").String()) != "" + body = ensureWebSearchToolForHistory(body, opts.Headers) + if !useSIWC { + body = normalizeCodexInstructions(body, preserveNativeOutput) + if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { + body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers) + } + } else { + body = applyCodexSIWCRequestBodyRules(body, hasExplicitInstructions, cliproxyexecutor.Options{KeepTopLevelEffort: thinking.HasThinkingSuffix(req.Model)}) } - body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompat(ctx, "codex executor", body, isCompat) body = normalizeCodexParallelToolCalls(body, opts.Headers) body = helps.NormalizeCodexToolSchemas(body) body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, isCompat) @@ -82,16 +94,21 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au if errReplay != nil { return nil, errReplay } - reporter.SetTranslatedReasoningEffort(body, to.String()) + body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx, "codex executor", body, isCompat, targetModel, baseURL) url := strings.TrimSuffix(baseURL, "/") + "/responses" - httpReq, upstreamBody, err := e.cacheHelper(ctx, from, url, req, body, opts.Headers) + httpReq, upstreamBody, err := e.cacheHelper(ctx, from, url, req, body, auth, opts.Headers) if err != nil { return nil, err } - applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg, opts.Headers) - applyCodexRoutingHint(ctx, httpReq.Header, auth, baseModel, upstreamBody, opts.Headers) - applyModelHeaderOverrides(httpReq.Header, baseModel) + reporter.SetTranslatedReasoningEffort(upstreamBody, to.String()) + prepareHeaders := func(request *http.Request) { + applyCodexHeaders(request, auth, apiKey, true, e.cfg, opts.Headers) + applyCodexRoutingHint(ctx, request.Header, auth, baseModel, upstreamBody, opts.Headers) + applyModelHeaderOverrides(request.Header, baseModel) + alignCodexContentEncoding(request) + } + prepareHeaders(httpReq) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID @@ -112,7 +129,19 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0) httpClient = reporter.TrackHTTPClientRoundTripOnly(httpClient) - httpResp, err := httpClient.Do(httpReq) + buffering := e.cfg != nil && e.cfg.Codex.StreamBootstrapBuffering + var bootstrapTimeout time.Duration + var bootstrapStart time.Time + if buffering { + bootstrapTimeout = e.cfg.Codex.StreamBootstrapTimeoutDuration() + } + // Start the bootstrap clock at response headers, before decoding may read body bytes. + responseReady := func(response *http.Response) { + if buffering && response.StatusCode >= 200 && response.StatusCode < 300 { + bootstrapStart = nowCodexBootstrap() + } + } + httpResp, err := doCodexRequest(httpClient, httpReq, upstreamBody, prepareHeaders, responseReady) if err != nil { helps.RecordAPIResponseError(ctx, e.cfg, err) return nil, err @@ -136,35 +165,42 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au return nil, err } - buffering := e.cfg != nil && e.cfg.Codex.StreamBootstrapBuffering - var bootstrapTimeout time.Duration - var bootstrapStart time.Time + bufferedFrames := 0 + bufferedBytes := 0 + budgetExhausted := false + exhaustedReason := "" + + scanner := helps.NewCodexSSEScanner(ctx, httpResp.Body, 52_428_800) // 50MB + scanner.SetOnLine(func(rawLine []byte) { + helps.AppendAPIResponseChunk(ctx, e.cfg, rawLine) + }) if buffering { - bootstrapTimeout = e.cfg.Codex.StreamBootstrapTimeoutDuration() - bootstrapStart = nowCodexBootstrap() + scanner.SetBudgetChecker(func(line []byte, heldLines, heldBytes int) bool { + timeSinceStart := nowCodexBootstrap().Sub(bootstrapStart) + if bootstrapTimeout > 0 && timeSinceStart >= bootstrapTimeout { + budgetExhausted = true + exhaustedReason = "time budget" + return true + } + if bufferedFrames+heldLines >= codexBootstrapMaxBufferedFrames { + budgetExhausted = true + exhaustedReason = "frame budget" + return true + } + if bufferedBytes+heldBytes >= codexBootstrapMaxBufferedBytes { + budgetExhausted = true + exhaustedReason = "byte budget" + return true + } + return false + }) } - - scanner := bufio.NewScanner(httpResp.Body) - scanner.Buffer(nil, 52_428_800) // 50MB claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload) var param any outputItemsByIndex := make(map[int64][]byte) var outputItemsFallback [][]byte var bufferedChunks [][]byte - // bufferedFrames counts the scanned lines this loop holds and bufferedBytes sums each line - // together with the chunks it translates into. Every iteration either holds the line or leaves - // the loop, so this is one unit per line read. Counting only the chunks would bound nothing for - // a downstream format that renders a frame as zero chunks, and counting only some line kinds - // would let the upstream's choice of framing decide whether the bound advances at all. The cost - // is that a verbose framing spends the budget faster: the three-line event:/data:/blank shape - // protects roughly a third as many events as a stream of bare ": keepalive" comments does. - // - // In addition to the frame and byte budgets, bootstrapTimeout bounds how long trickled - // frames may hold the downstream headers. A peer that never terminates a line is bounded - // by the caller's request context. - bufferedFrames := 0 - bufferedBytes := 0 var initialChunks [][]byte streamStarted := false immediateTerminal := false @@ -183,7 +219,12 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au if buffering { for scanner.Scan() { line := scanner.Bytes() - helps.AppendAPIResponseChunk(ctx, e.cfg, line) + if budgetExhausted { + helps.LogWithRequestID(ctx).Debugf("codex executor: bootstrap %s exhausted after %d lines / %d bytes / %v, releasing stream without overload probing", exhaustedReason, bufferedFrames, bufferedBytes, nowCodexBootstrap().Sub(bootstrapStart)) + initialChunks = nil + streamStarted = true + break + } var translatedLine []byte isHandshake := false terminalSuccess := false @@ -272,14 +313,17 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, originalPayload, body, translatedLine, ¶m, claudeInputTokens) if isHandshake && !terminalSuccess { - frameBytes := len(line) + rawLineCount := scanner.RawLines() + frameBytes := scanner.RawBytes() for i := range chunks { frameBytes += len(chunks[i]) } timeSinceStart := nowCodexBootstrap().Sub(bootstrapStart) timeoutReached := bootstrapTimeout > 0 && timeSinceStart >= bootstrapTimeout - if !timeoutReached && bufferedFrames < codexBootstrapMaxBufferedFrames && bufferedBytes+frameBytes <= codexBootstrapMaxBufferedBytes { - bufferedFrames++ + totalFrames := bufferedFrames + scanner.HeldLines() + rawLineCount + totalBytes := bufferedBytes + scanner.HeldBytes() + frameBytes + if !timeoutReached && totalFrames <= codexBootstrapMaxBufferedFrames && totalBytes <= codexBootstrapMaxBufferedBytes { + bufferedFrames += rawLineCount bufferedBytes += frameBytes bufferedChunks = append(bufferedChunks, chunks...) continue @@ -287,7 +331,9 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au exhausted := "frame budget" if timeoutReached { exhausted = "time budget" - } else if bufferedFrames < codexBootstrapMaxBufferedFrames { + } else if totalFrames > codexBootstrapMaxBufferedFrames { + exhausted = "frame budget" + } else { exhausted = "byte budget" } helps.LogWithRequestID(ctx).Debugf("codex executor: bootstrap %s exhausted after %d lines / %d bytes / %v, releasing stream without overload probing", exhausted, bufferedFrames, bufferedBytes, timeSinceStart) @@ -300,6 +346,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au } break } + scanner.SetBudgetChecker(nil) if !streamStarted && bootstrapTerminalErr == nil { closeBootstrapBody() @@ -375,7 +422,6 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au }() for scanner.Scan() { line := scanner.Bytes() - helps.AppendAPIResponseChunk(ctx, e.cfg, line) var translatedLine []byte terminalSuccess := false diff --git a/internal/runtime/executor/codex_executor_terminal.go b/internal/runtime/executor/codex_executor_terminal.go index 01d4b378..76a137ed 100644 --- a/internal/runtime/executor/codex_executor_terminal.go +++ b/internal/runtime/executor/codex_executor_terminal.go @@ -513,7 +513,10 @@ func isCodexBootstrapBufferableEvent(eventType string, payload []byte) bool { return true } switch eventType { - case "response.created", "response.in_progress", "codex.rate_limits", "codex.response.metadata", "keepalive": + // response.metadata carries prompt-moderation flags, not generated output. Upstream now sends it + // between response.in_progress and a server_is_overloaded rejection, so releasing on it leaked + // the rejection to the client instead of failing over (issue #6856). + case "response.created", "response.in_progress", "codex.rate_limits", "codex.response.metadata", "response.metadata", "keepalive": return true case "response.output_item.added": return isCodexBufferableOutputItem(payload) @@ -528,8 +531,7 @@ func isCodexBootstrapBufferableEvent(eventType string, payload []byte) bool { // isCodexBufferableOutputItem reports whether an announced output item is one the model produces by // itself and has not started producing, so nothing is running upstream yet. The emptiness checks -// follow the ones IsResponsesTokenEvent applies to response.output_item.done, extended to the -// reasoning summary, which that helper has no case for. Every other item type +// cover message and reasoning text, tool arguments, and encrypted reasoning. Every other item type // is released, which covers the server-side operations that may already have been dispatched - a // web_search_call is announced with status "in_progress" and its searching event follows // immediately, and failing the attempt over after one would run it again on another credential - and @@ -595,7 +597,7 @@ func isCodexEmptyPart(payload []byte) bool { // token event, and records the model the upstream reports serving. func observeCodexTokenEvent(reporter *helps.UsageReporter, payload []byte) { helps.ObserveResponsesTokenEvent(reporter, payload) - reporter.ObserveCodexResponseModel(payload) + reporter.ObserveResponseModel(payload) } // newCodexBootstrapOverloadErr reports a buffered overload rejection with its real status. diff --git a/internal/runtime/executor/codex_executor_tokens.go b/internal/runtime/executor/codex_executor_tokens.go index 96db686a..5d8f70be 100644 --- a/internal/runtime/executor/codex_executor_tokens.go +++ b/internal/runtime/executor/codex_executor_tokens.go @@ -21,9 +21,16 @@ func (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth from := opts.SourceFormat responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) to := sdktranslator.FromString("codex") - body, updatesChanged := helps.TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntentForExecutor(ctx, opts.Headers, e.cfg, e.Identifier(), from, to, baseModel, req.Payload, false, helps.APIKeyModelIsCompat(req)) + body, updatesChanged, err := helps.TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntentForExecutor(ctx, opts.Headers, e.cfg, e.Identifier(), from, to, baseModel, req.Payload, false, helps.APIKeyModelIsCompat(req)) + if err != nil { + return cliproxyexecutor.Response{}, err + } + originalTranslatedForPayload := append([]byte(nil), body...) + if len(opts.OriginalRequest) > 0 { + originalTranslatedForPayload, _, _ = helps.TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntentForExecutor(ctx, opts.Headers, e.cfg, e.Identifier(), from, to, baseModel, opts.OriginalRequest, false, helps.APIKeyModelIsCompat(req)) + } - body, err := helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged) + body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged) if err != nil { return cliproxyexecutor.Response{}, err } @@ -37,6 +44,7 @@ func (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth body = helps.SetBoolIfDifferent(body, "stream", false) body = normalizeCodexInstructions(body, helps.IsNativeCodexRequest(req.Payload, opts)) + body = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(body) enc, err := tokenizerForCodexModel(baseModel) if err != nil { return cliproxyexecutor.Response{}, fmt.Errorf("codex executor: tokenizer init failed: %w", err) diff --git a/internal/runtime/executor/codex_openai_images.go b/internal/runtime/executor/codex_openai_images.go index 65797a3a..c3de985e 100644 --- a/internal/runtime/executor/codex_openai_images.go +++ b/internal/runtime/executor/codex_openai_images.go @@ -85,8 +85,14 @@ func (e *CodexExecutor) resolveGPTImage2BaseModel() string { } func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { - if directEndpoint := codexDirectOpenAIImageEndpoint(req, opts); directEndpoint != "" { - return e.executeDirectOpenAIImage(ctx, auth, req, opts, directEndpoint) + apiKey, baseURL := codexCredsForRequest(ctx, auth, opts.Headers) + if errSIWC := ensureCodexSIWCCredentialsForRequest(ctx, auth, apiKey, opts.Headers); errSIWC != nil { + return resp, errSIWC + } + if !codexUseSIWCForRequest(ctx, auth, opts.Headers) { + if directEndpoint := codexDirectOpenAIImageEndpoint(req, opts); directEndpoint != "" { + return e.executeDirectOpenAIImage(ctx, auth, req, opts, directEndpoint) + } } prepared, errPrepare := codexPrepareOpenAIImageRequest(req, opts) @@ -94,7 +100,6 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau return resp, errPrepare } - apiKey, baseURL := codexCreds(auth) if baseURL == "" { baseURL = "https://chatgpt.com/backend-api/codex" } @@ -103,24 +108,31 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau reporter := helps.NewExecutorUsageReporter(ctx, e, mainModel, auth) defer reporter.TrackFailure(ctx, &err) - body, errBuild := e.prepareCodexOpenAIImageBody(prepared.Body, req, opts, mainModel) + body, errBuild := e.prepareCodexOpenAIImageBody(ctx, auth, prepared.Body, req, opts, mainModel) if errBuild != nil { return resp, errBuild } - reporter.SetTranslatedReasoningEffort(body, "codex") url := strings.TrimSuffix(baseURL, "/") + "/responses" - httpReq, body, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, body) + payloadOpts := opts + payloadOpts.SourceFormat = sdktranslator.FromString(codexOpenAIImageSourceFormat) + ctx = helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), mainModel, "codex", "", prepared.Body, req, payloadOpts)) + httpReq, body, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, body, auth, opts.Headers) if errCache != nil { return resp, errCache } - applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg, opts.Headers) - applyModelHeaderOverrides(httpReq.Header, mainModel) + reporter.SetTranslatedReasoningEffort(body, "codex") + prepareHeaders := func(request *http.Request) { + applyCodexHeaders(request, auth, apiKey, true, e.cfg, opts.Headers) + applyModelHeaderOverrides(request.Header, mainModel) + alignCodexContentEncoding(request) + } + prepareHeaders(httpReq) recordCodexOpenAIImageRequest(ctx, e.cfg, e.Identifier(), auth, url, httpReq.Header.Clone(), body) httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) httpClient = reporter.TrackHTTPClient(httpClient) - httpResp, errDo := httpClient.Do(httpReq) + httpResp, errDo := doCodexRequest(httpClient, httpReq, body, prepareHeaders, nil) if errDo != nil { helps.RecordAPIResponseError(ctx, e.cfg, errDo) return resp, errDo @@ -151,7 +163,7 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau continue } eventData := bytes.TrimSpace(line[len(dataTag):]) - reporter.ObserveCodexResponseModel(eventData) + reporter.ObserveResponseModel(eventData) switch gjson.GetBytes(eventData, "type").String() { case "response.output_item.done": collectCodexOutputItemDone(eventData, outputItemsByIndex, &outputItemsFallback) @@ -180,8 +192,14 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau } func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) { - if directEndpoint := codexDirectOpenAIImageEndpoint(req, opts); directEndpoint != "" { - return e.executeDirectOpenAIImageStream(ctx, auth, req, opts, directEndpoint) + apiKey, baseURL := codexCredsForRequest(ctx, auth, opts.Headers) + if errSIWC := ensureCodexSIWCCredentialsForRequest(ctx, auth, apiKey, opts.Headers); errSIWC != nil { + return nil, errSIWC + } + if !codexUseSIWCForRequest(ctx, auth, opts.Headers) { + if directEndpoint := codexDirectOpenAIImageEndpoint(req, opts); directEndpoint != "" { + return e.executeDirectOpenAIImageStream(ctx, auth, req, opts, directEndpoint) + } } prepared, errPrepare := codexPrepareOpenAIImageRequest(req, opts) @@ -189,7 +207,6 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip return nil, errPrepare } - apiKey, baseURL := codexCreds(auth) if baseURL == "" { baseURL = "https://chatgpt.com/backend-api/codex" } @@ -198,24 +215,31 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip reporter := helps.NewExecutorUsageReporter(ctx, e, mainModel, auth) defer reporter.TrackFailure(ctx, &err) - body, errBuild := e.prepareCodexOpenAIImageBody(prepared.Body, req, opts, mainModel) + body, errBuild := e.prepareCodexOpenAIImageBody(ctx, auth, prepared.Body, req, opts, mainModel) if errBuild != nil { return nil, errBuild } - reporter.SetTranslatedReasoningEffort(body, "codex") url := strings.TrimSuffix(baseURL, "/") + "/responses" - httpReq, body, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, body) + payloadOpts := opts + payloadOpts.SourceFormat = sdktranslator.FromString(codexOpenAIImageSourceFormat) + ctx = helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), mainModel, "codex", "", prepared.Body, req, payloadOpts)) + httpReq, body, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, body, auth, opts.Headers) if errCache != nil { return nil, errCache } - applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg, opts.Headers) - applyModelHeaderOverrides(httpReq.Header, mainModel) + reporter.SetTranslatedReasoningEffort(body, "codex") + prepareHeaders := func(request *http.Request) { + applyCodexHeaders(request, auth, apiKey, true, e.cfg, opts.Headers) + applyModelHeaderOverrides(request.Header, mainModel) + alignCodexContentEncoding(request) + } + prepareHeaders(httpReq) recordCodexOpenAIImageRequest(ctx, e.cfg, e.Identifier(), auth, url, httpReq.Header.Clone(), body) httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) httpClient = reporter.TrackHTTPClient(httpClient) - httpResp, errDo := httpClient.Do(httpReq) + httpResp, errDo := doCodexRequest(httpClient, httpReq, body, prepareHeaders, nil) if errDo != nil { helps.RecordAPIResponseError(ctx, e.cfg, errDo) return nil, errDo @@ -273,7 +297,7 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip continue } eventData := bytes.TrimSpace(line[len(dataTag):]) - reporter.ObserveCodexResponseModel(eventData) + reporter.ObserveResponseModel(eventData) switch gjson.GetBytes(eventData, "type").String() { case "response.output_item.done": collectCodexOutputItemDone(eventData, outputItemsByIndex, &outputItemsFallback) @@ -315,12 +339,12 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip } func (e *CodexExecutor) executeDirectOpenAIImage(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, endpointPath string) (resp cliproxyexecutor.Response, err error) { - body, contentType, model, errPrepare := codexPrepareDirectOpenAIImageBody(req, opts, false) + body, contentType, model, errPrepare := e.prepareDirectOpenAIImageBody(auth, req, opts, false) if errPrepare != nil { return resp, errPrepare } - apiKey, baseURL := codexCreds(auth) + apiKey, baseURL := codexCredsForRequest(ctx, auth, opts.Headers) if baseURL == "" { baseURL = "https://chatgpt.com/backend-api/codex" } @@ -330,20 +354,34 @@ func (e *CodexExecutor) executeDirectOpenAIImage(ctx context.Context, auth *clip reporter.SetTranslatedReasoningEffort(body, "openai") url := strings.TrimSuffix(baseURL, "/") + endpointPath - httpReq, body, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, body) + httpReq, body, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, body, auth, opts.Headers) if errCache != nil { return resp, errCache } - applyCodexDirectImageHeaders(httpReq, auth, apiKey, false, e.cfg) - applyModelHeaderOverrides(httpReq.Header, model) - if contentType != "" { - httpReq.Header.Set("Content-Type", contentType) + payloadOpts := opts + payloadOpts.SourceFormat = sdktranslator.FromString(codexOpenAIImageSourceFormat) + body, contentType, errPrepare = helps.ApplyMediaPayloadConfig(e.cfg, e.Identifier(), model, "openai", body, contentType, req, payloadOpts) + if errPrepare != nil { + return resp, errPrepare + } + httpReq.Body = io.NopCloser(bytes.NewReader(body)) + httpReq.ContentLength = int64(len(body)) + httpReq.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil } + httpReq.Header.Del("Content-Encoding") + prepareHeaders := func(request *http.Request) { + applyCodexDirectImageHeaders(request, auth, apiKey, false, e.cfg, opts.Headers) + applyModelHeaderOverrides(request.Header, model) + if contentType != "" { + request.Header.Set("Content-Type", contentType) + } + alignCodexContentEncoding(request) } + prepareHeaders(httpReq) recordCodexOpenAIImageRequest(ctx, e.cfg, e.Identifier(), auth, url, httpReq.Header.Clone(), body) httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) httpClient = reporter.TrackHTTPClient(httpClient) - httpResp, errDo := httpClient.Do(httpReq) + httpResp, errDo := doCodexRequest(httpClient, httpReq, body, prepareHeaders, nil) if errDo != nil { helps.RecordAPIResponseError(ctx, e.cfg, errDo) return resp, errDo @@ -373,12 +411,12 @@ func (e *CodexExecutor) executeDirectOpenAIImage(ctx context.Context, auth *clip } func (e *CodexExecutor) executeDirectOpenAIImageStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, endpointPath string) (_ *cliproxyexecutor.StreamResult, err error) { - body, contentType, model, errPrepare := codexPrepareDirectOpenAIImageBody(req, opts, true) + body, contentType, model, errPrepare := e.prepareDirectOpenAIImageBody(auth, req, opts, true) if errPrepare != nil { return nil, errPrepare } - apiKey, baseURL := codexCreds(auth) + apiKey, baseURL := codexCredsForRequest(ctx, auth, opts.Headers) if baseURL == "" { baseURL = "https://chatgpt.com/backend-api/codex" } @@ -388,20 +426,34 @@ func (e *CodexExecutor) executeDirectOpenAIImageStream(ctx context.Context, auth reporter.SetTranslatedReasoningEffort(body, "openai") url := strings.TrimSuffix(baseURL, "/") + endpointPath - httpReq, body, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, body) + httpReq, body, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, body, auth, opts.Headers) if errCache != nil { return nil, errCache } - applyCodexDirectImageHeaders(httpReq, auth, apiKey, true, e.cfg) - applyModelHeaderOverrides(httpReq.Header, model) - if contentType != "" { - httpReq.Header.Set("Content-Type", contentType) + payloadOpts := opts + payloadOpts.SourceFormat = sdktranslator.FromString(codexOpenAIImageSourceFormat) + body, contentType, errPrepare = helps.ApplyMediaPayloadConfig(e.cfg, e.Identifier(), model, "openai", body, contentType, req, payloadOpts) + if errPrepare != nil { + return nil, errPrepare } + httpReq.Body = io.NopCloser(bytes.NewReader(body)) + httpReq.ContentLength = int64(len(body)) + httpReq.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil } + httpReq.Header.Del("Content-Encoding") + prepareHeaders := func(request *http.Request) { + applyCodexDirectImageHeaders(request, auth, apiKey, true, e.cfg, opts.Headers) + applyModelHeaderOverrides(request.Header, model) + if contentType != "" { + request.Header.Set("Content-Type", contentType) + } + alignCodexContentEncoding(request) + } + prepareHeaders(httpReq) recordCodexOpenAIImageRequest(ctx, e.cfg, e.Identifier(), auth, url, httpReq.Header.Clone(), body) httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) httpClient = reporter.TrackHTTPClient(httpClient) - httpResp, errDo := httpClient.Do(httpReq) + httpResp, errDo := doCodexRequest(httpClient, httpReq, body, prepareHeaders, nil) if errDo != nil { helps.RecordAPIResponseError(ctx, e.cfg, errDo) return nil, errDo @@ -466,7 +518,7 @@ func (e *CodexExecutor) executeDirectOpenAIImageStream(ctx context.Context, auth } func codexDirectOpenAIImageEndpoint(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) string { - if codexDirectOpenAIImageModel(req) == "" { + if codexDirectOpenAIImageRouteModel(req, opts) == "" { return "" } path := helps.PayloadRequestPath(opts) @@ -479,8 +531,8 @@ func codexDirectOpenAIImageEndpoint(req cliproxyexecutor.Request, opts cliproxye return "" } -func codexPrepareDirectOpenAIImageBody(req cliproxyexecutor.Request, opts cliproxyexecutor.Options, stream bool) ([]byte, string, string, error) { - model := codexDirectOpenAIImageModel(req) +func (e *CodexExecutor) prepareDirectOpenAIImageBody(auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, stream bool) ([]byte, string, string, error) { + model := e.directOpenAIImageModel(auth, req, opts) if model == "" { return nil, "", "", fmt.Errorf("unsupported direct OpenAI image model %q", req.Model) } @@ -632,8 +684,14 @@ func codexOpenAIImageEditFormJSONPath(key string) string { } } -func codexDirectOpenAIImageModel(req cliproxyexecutor.Request) string { - for _, model := range []string{gjson.GetBytes(req.Payload, "model").String(), req.Model} { +func codexDirectOpenAIImageRouteModel(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) string { + // Multipart handlers supply the requested model separately from the raw form. + requestedModel := helps.PayloadRequestedModel(opts, "") + payloadModel := "" + if json.Valid(req.Payload) { + payloadModel = gjson.GetBytes(req.Payload, "model").String() + } + for _, model := range []string{requestedModel, payloadModel, req.Model} { baseModel := codexOpenAIImageBaseModel(model) if codexIsDirectOpenAIImageModel(baseModel) { return baseModel @@ -642,6 +700,35 @@ func codexDirectOpenAIImageModel(req cliproxyexecutor.Request) string { return "" } +func (e *CodexExecutor) directOpenAIImageModel(auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) string { + routeModel := codexDirectOpenAIImageRouteModel(req, opts) + if routeModel == "" { + return "" + } + model := strings.TrimSpace(thinking.ParseSuffix(req.Model).ModelName) + if model == "" { + return routeModel + } + + // Explicit upstream names are not routing prefixes, even when their base is builtin. + if entry := e.resolveCodexConfig(auth); entry != nil { + for _, configured := range entry.Models { + name := strings.TrimSpace(configured.Name) + if name == "" { + name = strings.TrimSpace(configured.Alias) + } + if strings.EqualFold(strings.TrimSpace(thinking.ParseSuffix(name).ModelName), model) { + return model + } + } + } + // Preserve legacy builtin route normalization only without an explicit upstream. + if baseModel := codexOpenAIImageBaseModel(model); codexIsDirectOpenAIImageModel(baseModel) { + return baseModel + } + return model +} + func codexOpenAIImageBaseModel(model string) string { model = strings.TrimSpace(thinking.ParseSuffix(model).ModelName) if idx := strings.LastIndex(model, "/"); idx >= 0 && idx < len(model)-1 { @@ -659,7 +746,7 @@ func codexIsDirectOpenAIImageModel(model string) bool { } } -func (e *CodexExecutor) prepareCodexOpenAIImageBody(body []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, mainModel string) ([]byte, error) { +func (e *CodexExecutor) prepareCodexOpenAIImageBody(ctx context.Context, auth *cliproxyauth.Auth, body []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, mainModel string) ([]byte, error) { out := body mainModel = strings.TrimSpace(mainModel) if mainModel == "" { @@ -671,15 +758,15 @@ func (e *CodexExecutor) prepareCodexOpenAIImageBody(body []byte, req cliproxyexe return nil, errThinking } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - out = helps.ApplyPayloadConfigWithRequest(e.cfg, mainModel, "codex", codexOpenAIImageSourceFormat, "", out, body, requestedModel, requestPath, opts.Headers) out = helps.SetStringIfDifferent(out, "model", mainModel) out = helps.SetBoolIfDifferent(out, "stream", true) out, _ = sjson.DeleteBytes(out, "previous_response_id") out, _ = sjson.DeleteBytes(out, "prompt_cache_retention") out, _ = sjson.DeleteBytes(out, "safety_identifier") out, _ = sjson.DeleteBytes(out, "stream_options") + if codexUseSIWCForRequest(ctx, auth, opts.Headers) { + return applyCodexSIWCRequestBodyRules(out, false), nil + } return normalizeCodexInstructions(out), nil } diff --git a/internal/runtime/executor/codex_websockets_connection.go b/internal/runtime/executor/codex_websockets_connection.go index b9d06112..5af93548 100644 --- a/internal/runtime/executor/codex_websockets_connection.go +++ b/internal/runtime/executor/codex_websockets_connection.go @@ -1,6 +1,7 @@ package executor import ( + "compress/flate" "context" "errors" "fmt" @@ -41,9 +42,14 @@ func (e *CodexWebsocketsExecutor) dialCodexWebsocket(ctx context.Context, auth * } closer := newWebsocketConnectionCloser(conn) if conn != nil { - // Avoid gorilla/websocket flate tail validation issues on some upstreams/Go versions. - // Negotiating permessage-deflate is fine; we just don't compress outbound messages. - conn.EnableWriteCompression(false) + enabled := e.cfg == nil || e.cfg.Codex.WebsocketCompressionEnabled() + conn.EnableWriteCompression(enabled) + if enabled { + // DefaultCompression avoids the fast flate tail path; configuration remains an escape hatch. + if errSetCompressionLevel := conn.SetCompressionLevel(flate.DefaultCompression); errSetCompressionLevel != nil { + log.Errorf("set Codex websocket compression level: %v", errSetCompressionLevel) + } + } } return conn, closer, resp, err } @@ -132,14 +138,14 @@ func normalizeCodexWebsocketParallelToolCalls(body []byte, headers http.Header) } func buildCodexWebsocketRequestBody(body []byte) []byte { + return frameCodexWebsocketRequestBody(helps.SanitizeCodexInputItemIDs(body)) +} + +// Framing must not normalize the configured business payload. +func frameCodexWebsocketRequestBody(body []byte) []byte { if len(body) == 0 { return nil } - - // Match codex-rs websocket v2 semantics: every request is `response.create`. - // Incremental follow-up turns continue on the same websocket using - // `previous_response_id` + incremental `input`, not `response.append`. - body = helps.SanitizeCodexInputItemIDs(body) wsReqBody, errSet := sjson.SetBytes(body, "type", "response.create") if errSet == nil && len(wsReqBody) > 0 { return wsReqBody diff --git a/internal/runtime/executor/codex_websockets_duplex.go b/internal/runtime/executor/codex_websockets_duplex.go index 132e4169..de15c1b8 100644 --- a/internal/runtime/executor/codex_websockets_duplex.go +++ b/internal/runtime/executor/codex_websockets_duplex.go @@ -12,6 +12,7 @@ import ( "github.com/gorilla/websocket" "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps" + "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" log "github.com/sirupsen/logrus" @@ -135,7 +136,41 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex( return false } } + initialModel := "" + if initial != nil { + initialModel = strings.TrimSpace(gjson.GetBytes(initial.originalPayload, "model").String()) + } + boundModel := helps.PayloadRequestedModel(opts, initialModel) + if boundModel == "" { + boundModel = req.Model + } + authorizePayload := func(payload []byte, forceBoundModel bool) (allowed bool, keepOpen bool) { + authPayload := payload + explicitModel := strings.TrimSpace(gjson.GetBytes(authPayload, "model").String()) + if boundModel != "" && json.Valid(authPayload) && (forceBoundModel || explicitModel == "" || explicitModel == req.Model) { + if withModel, errSet := sjson.SetBytes(bytes.Clone(authPayload), "model", boundModel); errSet == nil { + authPayload = withModel + } + } + errPayload, errAuth := cliproxyexecutor.AuthorizeWebsocketTurn(streamCtx, authPayload) + if errAuth != nil { + fail(errAuth) + return false, false + } + if len(errPayload) > 0 { + select { + case out <- cliproxyexecutor.StreamChunk{Payload: errPayload}: + return false, true + case <-streamCtx.Done(): + return false, false + } + } + return true, true + } processCreatePayload := func(payload []byte) bool { + if allowed, keepOpen := authorizePayload(payload, false); !allowed { + return keepOpen + } metadataMu.Lock() isAppend := gjson.GetBytes(payload, "type").String() == "response.append" prevID := strings.TrimSpace(gjson.GetBytes(payload, "previous_response_id").String()) @@ -152,14 +187,14 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex( return true } model := strings.TrimSpace(gjson.GetBytes(payload, "model").String()) - if model != "" && model != req.Model && model != gjson.GetBytes(initial.originalPayload, "model").String() { + if model != "" && model != req.Model && model != boundModel { fail(cliproxyexecutor.NewUpstreamWebsocketReplayRequiredError()) return false } if model == "" { model = req.Model if model == "" { - model = strings.TrimSpace(gjson.GetBytes(initial.originalPayload, "model").String()) + model = boundModel } payload, _ = sjson.SetBytes(payload, "model", model) } @@ -200,7 +235,7 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex( fail(cliproxyexecutor.NewUpstreamWebsocketReplayRequiredError()) return false } - payload = buildCodexWebsocketRequestBody(prepared.clientBody) + payload = frameCodexWebsocketRequestBody(prepared.clientBody) metadataMu.Lock() if len(pending) >= 16 { metadataMu.Unlock() @@ -277,6 +312,20 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex( } switch gjson.GetBytes(payload, "type").String() { case "response.steer": + if allowed, keepOpen := authorizePayload(payload, true); !allowed { + if !keepOpen { + return + } + continue + } + // Steering carries business input but must not inherit response.create defaults. + steerReq := req + steerReq.Payload = payload + if codexUseSIWCForRequest(ctx, auth, opts.Headers) { + payload = thinking.StripConfigurationUpdates(payload) + } + payload = helps.NewPayloadFinalizer(e.cfg, "codex-websockets", thinking.ParseSuffix(req.Model).ModelName, "codex", "", payload, steerReq, opts)(payload) + payload, _ = sjson.SetBytes(payload, "type", "response.steer") parent := gjson.GetBytes(payload, "previous_response_id").String() metadataMu.Lock() settings := responseSettings[parent] @@ -295,8 +344,9 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex( steeringSettings[parent] = settings } metadataMu.Unlock() - // Control frames bypass ALL response.create translations and defaults. - // Unknown fields and unsupported input are left to upstream validation. + // Control frames bypass response.create translations and built-in defaults. + // Except for SIWC configuration_update stripping, unknown fields and unsupported + // input are left to upstream validation. if !cliproxyexecutor.WebsocketAuthEnabled(streamCtx, auth.ID) { fail(fmt.Errorf("websocket credential is no longer enabled")) return @@ -421,6 +471,9 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex( if inst := gjson.GetBytes(current.clientBody, "instructions"); inst.Exists() { snapshot.clientBody, _ = sjson.SetRawBytes(snapshot.clientBody, "instructions", []byte(inst.Raw)) } + if serviceTier := gjson.GetBytes(current.clientBody, "service_tier"); serviceTier.Exists() { + snapshot.clientBody, _ = sjson.SetRawBytes(snapshot.clientBody, "service_tier", []byte(serviceTier.Raw)) + } responseSettings[responseID] = &snapshot responseOrder = append(responseOrder, responseID) if len(responseOrder) > 16 { diff --git a/internal/runtime/executor/codex_websockets_execute.go b/internal/runtime/executor/codex_websockets_execute.go index 7c6eca05..b52f5861 100644 --- a/internal/runtime/executor/codex_websockets_execute.go +++ b/internal/runtime/executor/codex_websockets_execute.go @@ -27,9 +27,17 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut } baseModel := thinking.ParseSuffix(req.Model).ModelName - apiKey, baseURL := codexCreds(auth) + apiKey, baseURL := codexCredsForRequest(ctx, auth, opts.Headers) + if errSIWC := ensureCodexSIWCCredentialsForRequest(ctx, auth, apiKey, opts.Headers); errSIWC != nil { + return resp, errSIWC + } + useSIWC := codexUseSIWCForRequest(ctx, auth, opts.Headers) if baseURL == "" { - baseURL = "https://chatgpt.com/backend-api/codex" + if useSIWC { + baseURL = defaultOpenAISIWCBaseURL + } else { + baseURL = "https://chatgpt.com/backend-api/codex" + } } reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth) @@ -45,25 +53,32 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut } originalPayload := originalPayloadSource isCompat := e.resolveCodexModelIsCompat(auth, req, baseModel) - originalTranslated, body, updatesChanged := translateCodexRequestPairWithUpdateIntent(from, to, baseModel, originalPayload, req.Payload, false, isCompat) + targetModel := resolveResponsesSignatureTargetModel(baseModel, req, opts) + originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntent(from, to, targetModel, originalPayload, req.Payload, false, isCompat) + if err != nil { + return resp, err + } + originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel) + body = helps.SetStringIfDifferent(body, "model", baseModel) body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged) if err != nil { return resp, err } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequestForExecutor(e.cfg, "codex-websockets", baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) + hasExplicitInstructions := gjson.GetBytes(req.Payload, "instructions").Exists() && strings.TrimSpace(gjson.GetBytes(req.Payload, "instructions").String()) != "" + finalizePayload := helps.NewPayloadFinalizer(e.cfg, "codex-websockets", baseModel, to.String(), "", originalTranslated, req, opts) body = helps.SetStringIfDifferent(body, "model", baseModel) body = helps.SetBoolIfDifferent(body, "stream", true) body, _ = sjson.DeleteBytes(body, "prompt_cache_retention") body, _ = sjson.DeleteBytes(body, "safety_identifier") - body = normalizeCodexInstructions(body, nativeRequest) - if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { - body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers) + body = ensureWebSearchToolForHistory(body, opts.Headers) + if !useSIWC { + body = normalizeCodexInstructions(body, nativeRequest) + if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { + body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers) + } } - body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompat(ctx, "codex websockets executor", body, isCompat) body = normalizeCodexWebsocketParallelToolCalls(body, opts.Headers) body = helps.NormalizeCodexToolSchemas(body) multiAgentV2Conflict := helps.HasCodexMultiAgentV2NamespaceConflict(body) @@ -72,6 +87,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut if errReplay != nil { return resp, errReplay } + body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx, "codex websockets executor", body, isCompat, targetModel, baseURL) httpURL := strings.TrimSuffix(baseURL, "/") + "/responses" wsURL, err := buildCodexResponsesWebsocketURL(httpURL) @@ -83,6 +99,11 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut if errPromptCache != nil { return resp, errPromptCache } + body = helps.SanitizeCodexInputItemIDs(body) + if useSIWC { + body = applyCodexSIWCWebsocketBodyRules(body, hasExplicitInstructions, cliproxyexecutor.Options{KeepTopLevelEffort: thinking.HasThinkingSuffix(req.Model)}) + } + body = finalizePayload(body) reporter.SetTranslatedReasoningEffort(body, to.String()) wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg, nativeRequest, opts.Headers) applyCodexRoutingHint(ctx, wsHeaders, auth, baseModel, body, opts.Headers) @@ -115,7 +136,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut sess = newEphemeralCodexWebsocketSession() } - wsReqBody := buildCodexWebsocketRequestBody(body) + wsReqBody := frameCodexWebsocketRequestBody(body) wsReqLog := helps.UpstreamRequestLog{ URL: wsURL, Method: "WEBSOCKET", @@ -135,13 +156,13 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut var errDial error dialCtx := ctx if cliproxyexecutor.RequiredUpstreamWebsocket(ctx) { - conn, closer = existingWebsocketSessionConn(sess, authID, wsURL, executionProxyURL(ctx, e.cfg, auth)) + conn, closer = existingWebsocketSessionConn(sess, authID, wsURL, executionProxyURL(ctx, sess.effectiveConfig(authID, e.cfg), auth), useSIWC) if conn == nil { return resp, cliproxyexecutor.NewUpstreamWebsocketReplayRequiredError() } } else { dialCtx = cliproxyexecutor.WithUpstreamAttemptTracker(ctx) - conn, closer, respHS, errDial = e.ensureUpstreamConn(dialCtx, auth, sess, authID, wsURL, wsHeaders) + conn, closer, respHS, errDial = e.ensureUpstreamConnWithSIWC(dialCtx, auth, sess, authID, wsURL, wsHeaders, useSIWC) } if errDial != nil { bodyErr := websocketHandshakeBody(respHS) @@ -213,7 +234,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut // Retry once with a fresh websocket connection. This is mainly to handle // upstream closing the socket between sequential requests within the same // execution session. - connRetry, closerRetry, respHSRetry, errDialRetry := e.ensureUpstreamConn(ctx, auth, sess, authID, wsURL, wsHeaders) + connRetry, closerRetry, respHSRetry, errDialRetry := e.ensureUpstreamConnWithSIWC(ctx, auth, sess, authID, wsURL, wsHeaders, useSIWC) if errDialRetry == nil && connRetry != nil { previousConn, previousReadCh := conn, readCh conn = connRetry @@ -226,7 +247,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut } readCh = sess.activate(conn) restoreMultiAgentV2 = !multiAgentV2Conflict && (optimizeMultiAgentV2 || sess.isMultiAgentV2Optimized(conn)) - wsReqBodyRetry := buildCodexWebsocketRequestBody(body) + wsReqBodyRetry := frameCodexWebsocketRequestBody(body) helps.RecordAPIWebsocketRequest(ctx, e.cfg, helps.UpstreamRequestLog{ URL: wsURL, Method: "WEBSOCKET", diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go index e6ae2cc5..65ec499c 100644 --- a/internal/runtime/executor/codex_websockets_executor.go +++ b/internal/runtime/executor/codex_websockets_executor.go @@ -12,6 +12,8 @@ import ( "github.com/router-for-me/CLIProxyAPI/v8/internal/config" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + log "github.com/sirupsen/logrus" + "github.com/tidwall/gjson" ) // CodexWebsocketsExecutor executes Codex Responses requests using a WebSocket transport. @@ -50,6 +52,11 @@ func NewCodexAutoExecutor(cfg *config.Config) *CodexAutoExecutor { func (e *CodexAutoExecutor) Identifier() string { return "codex" } +// UsesConfig reports whether the executor was created for cfg. +func (e *CodexAutoExecutor) UsesConfig(cfg *config.Config) bool { + return e != nil && e.httpExec != nil && e.httpExec.cfg == cfg +} + func (e *CodexAutoExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { if e == nil || e.httpExec == nil { return nil @@ -111,6 +118,20 @@ func (e *CodexAutoExecutor) CloseExecutionSession(sessionID string) { e.wsExec.CloseExecutionSession(sessionID) } +func (e *CodexAutoExecutor) SharesExecutionSessionsWith(other cliproxyauth.ProviderExecutor) bool { + if e == nil || e.wsExec == nil { + return false + } + return e.wsExec.SharesExecutionSessionsWith(other) +} + +func (e *CodexAutoExecutor) CloseAuthExecutionSessions(authID string) { + if e == nil || e.wsExec == nil { + return + } + e.wsExec.CloseAuthExecutionSessions(authID) +} + func (e *CodexAutoExecutor) UpstreamDisconnectChan(sessionID string) <-chan error { if e == nil || e.wsExec == nil { return nil @@ -154,3 +175,87 @@ func codexWebsocketsEnabled(auth *cliproxyauth.Auth) bool { func (e *CodexAutoExecutor) SupportsApplyPatch() bool { return e != nil && e.httpExec != nil && e.wsExec != nil && e.httpExec.SupportsApplyPatch() && e.wsExec.SupportsApplyPatch() } + +// InterruptExecutionSession forwards a response.interrupt control frame on the +// current upstream socket. It does not dial, replay, or select another credential. +func (e *CodexAutoExecutor) InterruptExecutionSession(ctx context.Context, sessionID string, payload []byte) error { + if e == nil || e.wsExec == nil { + return cliproxyexecutor.ErrNoActiveUpstreamWebsocket + } + return e.wsExec.InterruptExecutionSession(ctx, sessionID, payload) +} + +// InterruptExecutionSession writes the original interrupt payload to the session +// socket captured for this execution. Payload rules and response.create defaults +// must not rewrite response_id, mode, or extension fields. +func (e *CodexWebsocketsExecutor) InterruptExecutionSession(ctx context.Context, sessionID string, payload []byte) error { + if e == nil { + return cliproxyexecutor.ErrNoActiveUpstreamWebsocket + } + if ctx != nil { + if errCtx := ctx.Err(); errCtx != nil { + return errCtx + } + } + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return cliproxyexecutor.ErrNoActiveUpstreamWebsocket + } + store := e.store + if store == nil { + store = globalCodexWebsocketSessionStore + } + store.mu.Lock() + sess := store.sessions[sessionID] + store.mu.Unlock() + if sess == nil { + return cliproxyexecutor.ErrNoActiveUpstreamWebsocket + } + sess.connMu.Lock() + conn := sess.conn + authID := sess.authID + sess.connMu.Unlock() + if conn == nil { + return cliproxyexecutor.ErrNoActiveUpstreamWebsocket + } + // Account checks may call back into the auth manager; do not hold session + // locks across them. Take the lifecycle snapshot only after they finish. + authEnabled := cliproxyexecutor.WebsocketAuthEnabled(ctx, authID) + sess.connMu.Lock() + if sess.conn != conn { + sess.connMu.Unlock() + return cliproxyexecutor.ErrNoActiveUpstreamWebsocket + } + sess.activeMu.Lock() + _, alreadyTerminal := sess.terminalResponseIDs[gjson.GetBytes(payload, "response_id").String()] + alreadyTerminal = alreadyTerminal && sess.responseStateConn == conn + active := sess.activeConn == conn && sess.activeCh != nil + var disconnectErr error + if sess.terminalConn == conn { + disconnectErr = sess.terminalErr + } + // Terminal state and the active channel must belong to one snapshot: + // completion can otherwise land between the checks and produce a stale 400. + sess.activeMu.Unlock() + sess.connMu.Unlock() + if disconnectErr != nil { + return disconnectErr + } + if alreadyTerminal { + log.WithField("outcome", "already_terminal").Debug("codex websockets: response.interrupt handled") + return nil + } + // A retained socket from an earlier turn is not the current upstream. + // HTTP turns must fall through to local cancellation instead. + if !active { + return cliproxyexecutor.ErrNoActiveUpstreamWebsocket + } + if !authEnabled { + return fmt.Errorf("websocket credential is no longer enabled") + } + if errWrite := writeCodexWebsocketMessage(sess, conn, payload); errWrite != nil { + return errWrite + } + log.WithField("outcome", "forwarded").Debug("codex websockets: response.interrupt handled") + return nil +} diff --git a/internal/runtime/executor/codex_websockets_request.go b/internal/runtime/executor/codex_websockets_request.go index cd7bd5f3..e2b95b26 100644 --- a/internal/runtime/executor/codex_websockets_request.go +++ b/internal/runtime/executor/codex_websockets_request.go @@ -81,6 +81,32 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth * ginHeaders = ginCtx.Request.Header.Clone() } + if codexUseSIWCForRequest(ctx, auth, clientHeaders...) { + deleteHeaderCaseInsensitive(headers, "conversation_id") + ensureCodexWebsocketSessionHeader(headers, ginHeaders, "") + if sessionID := codexSessionHeaderValue(headers); sessionID != "" { + headers.Set("X-Client-Request-Id", sessionID) + } else { + misc.EnsureHeader(headers, ginHeaders, "X-Client-Request-Id", "") + } + headers.Set("User-Agent", codexSIWCUserAgent(cfg)) + betaHeader := strings.TrimSpace(headers.Get("OpenAI-Beta")) + if betaHeader == "" && ginHeaders != nil { + betaHeader = strings.TrimSpace(ginHeaders.Get("OpenAI-Beta")) + } + if betaHeader == "" || !strings.Contains(betaHeader, "responses_websockets=") { + betaHeader = codexResponsesWebsocketBetaHeaderValue + } + headers.Set("OpenAI-Beta", betaHeader) + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + req := (&http.Request{Header: headers}).WithContext(ctx) + util.ApplyCustomHeadersFromAttrs(req, attrs, ginHeaders) + return headers + } + isAPIKey := codexAuthUsesAPIKey(auth) cfgUserAgent, cfgBetaFeatures := codexHeaderDefaults(cfg, auth) ensureHeaderWithPriority(headers, ginHeaders, "x-codex-beta-features", cfgBetaFeatures, "") diff --git a/internal/runtime/executor/codex_websockets_session.go b/internal/runtime/executor/codex_websockets_session.go index f42deba7..4e6dbf2a 100644 --- a/internal/runtime/executor/codex_websockets_session.go +++ b/internal/runtime/executor/codex_websockets_session.go @@ -10,6 +10,7 @@ import ( "time" "github.com/gorilla/websocket" + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" log "github.com/sirupsen/logrus" @@ -59,6 +60,9 @@ type codexWebsocketSession struct { wsURL string authID string proxyURL string + useSIWC bool + connCfg *config.Config + hasConnCfg bool multiAgentV2OptimizedConn *websocket.Conn lifecycleBindMu sync.Mutex lifecycle cliproxyexecutor.ExecutionLifecycle @@ -71,6 +75,11 @@ type codexWebsocketSession struct { activeCh chan codexWebsocketRead activeDone <-chan struct{} activeCancel context.CancelFunc + terminalConn *websocket.Conn + terminalErr error + + responseStateConn *websocket.Conn + terminalResponseIDs map[string]struct{} readerConn *websocket.Conn @@ -97,6 +106,11 @@ func (s *codexWebsocketSession) setActive(conn *websocket.Conn, ch chan codexWeb return } s.activeMu.Lock() + s.setActiveLocked(conn, ch) + s.activeMu.Unlock() +} + +func (s *codexWebsocketSession) setActiveLocked(conn *websocket.Conn, ch chan codexWebsocketRead) { if s.activeCancel != nil { s.activeCancel() s.activeCancel = nil @@ -109,7 +123,6 @@ func (s *codexWebsocketSession) setActive(conn *websocket.Conn, ch chan codexWeb s.activeDone = activeCtx.Done() s.activeCancel = activeCancel } - s.activeMu.Unlock() } func (s *codexWebsocketSession) activate(conn *websocket.Conn) chan codexWebsocketRead { @@ -117,10 +130,48 @@ func (s *codexWebsocketSession) activate(conn *websocket.Conn) chan codexWebsock return nil } ch := make(chan codexWebsocketRead, 4096) - s.setActive(conn, ch) + s.activeMu.Lock() + defer s.activeMu.Unlock() + if s.terminalConn == conn && s.terminalErr != nil { + ch <- codexWebsocketRead{conn: conn, err: s.terminalErr} + close(ch) + return ch + } + s.setActiveLocked(conn, ch) return ch } +func (s *codexWebsocketSession) resetTerminalError(conn *websocket.Conn) { + if s == nil || conn == nil { + return + } + s.activeMu.Lock() + s.terminalConn = conn + s.terminalErr = nil + if s.responseStateConn != conn { + s.responseStateConn = conn + s.terminalResponseIDs = make(map[string]struct{}) + } + s.activeMu.Unlock() +} + +func (s *codexWebsocketSession) markTerminalError(conn *websocket.Conn, err error) bool { + if s == nil || conn == nil || err == nil { + return false + } + s.connMu.Lock() + if s.conn != conn { + s.connMu.Unlock() + return false + } + s.activeMu.Lock() + s.terminalConn = conn + s.terminalErr = err + s.activeMu.Unlock() + s.connMu.Unlock() + return true +} + func (s *codexWebsocketSession) activeForConn(conn *websocket.Conn) (chan codexWebsocketRead, <-chan struct{}) { if s == nil || conn == nil { return nil, nil @@ -252,6 +303,7 @@ func (s *codexWebsocketSession) configureConn(conn *websocket.Conn) { if s == nil || conn == nil { return } + s.resetTerminalError(conn) s.resetUpstreamDisconnectError(conn) conn.SetPingHandler(func(appData string) error { sessionID := "" @@ -349,6 +401,8 @@ func (s *codexWebsocketSession) detachConnection(conn *websocket.Conn, lifecycle closer = s.connCloser s.conn = nil s.connCloser = nil + s.connCfg = nil + s.hasConnCfg = false s.multiAgentV2OptimizedConn = nil if s.readerConn == conn { s.readerConn = nil @@ -374,7 +428,7 @@ func closeWebsocketAfterBindFailure(sess *codexWebsocketSession, conn *websocket } } -func websocketSessionTargetChanged(sess *codexWebsocketSession, authID string, wsURL string, proxyURL string) bool { +func websocketSessionTargetChanged(sess *codexWebsocketSession, authID string, wsURL string, proxyURL string, useSIWC ...bool) bool { if sess == nil { return false } @@ -384,17 +438,17 @@ func websocketSessionTargetChanged(sess *codexWebsocketSession, authID string, w if strings.TrimSpace(sess.authID) == "" && strings.TrimSpace(sess.wsURL) == "" { return false } - return !websocketSessionTargetMatches(sess, authID, wsURL, proxyURL) + return !websocketSessionTargetMatches(sess, authID, wsURL, proxyURL, useSIWC...) } -func existingWebsocketSessionConn(sess *codexWebsocketSession, authID string, wsURL string, proxyURL string) (*websocket.Conn, *websocketConnectionCloser) { +func existingWebsocketSessionConn(sess *codexWebsocketSession, authID string, wsURL string, proxyURL string, useSIWC ...bool) (*websocket.Conn, *websocketConnectionCloser) { if sess == nil { return nil, nil } sess.connMu.Lock() conn := sess.conn closer := sess.connCloser - matches := conn != nil && closer != nil && websocketSessionTargetMatches(sess, authID, wsURL, proxyURL) + matches := conn != nil && closer != nil && websocketSessionTargetMatches(sess, authID, wsURL, proxyURL, useSIWC...) sess.connMu.Unlock() if !matches || sess.upstreamDisconnectError(conn) != nil { return nil, nil @@ -402,13 +456,48 @@ func existingWebsocketSessionConn(sess *codexWebsocketSession, authID string, ws return conn, closer } -func websocketSessionTargetMatches(sess *codexWebsocketSession, authID string, wsURL string, proxyURL string) bool { +func (s *codexWebsocketSession) effectiveConfig(authID string, fallback *config.Config) *config.Config { + if s == nil { + return fallback + } + s.connMu.Lock() + sameConn := s.conn != nil && s.hasConnCfg && strings.TrimSpace(s.authID) == strings.TrimSpace(authID) + cfg := s.connCfg + s.connMu.Unlock() + if sameConn { + return cfg + } + return fallback +} + +func websocketSessionTargetMatches(sess *codexWebsocketSession, authID string, wsURL string, proxyURL string, useSIWC ...bool) bool { + if len(useSIWC) > 0 && sess.useSIWC != useSIWC[0] { + return false + } return strings.TrimSpace(sess.authID) == strings.TrimSpace(authID) && strings.TrimSpace(sess.wsURL) == strings.TrimSpace(wsURL) && strings.TrimSpace(sess.proxyURL) == strings.TrimSpace(proxyURL) } -func detachMismatchedWebsocketSessionConn(sess *codexWebsocketSession, authID string, wsURL string, proxyURL string) (*websocket.Conn, *websocketConnectionCloser, string, string, cliproxyexecutor.ExecutionLifecycle) { +func codexWebsocketRequestUsesSIWC(ctx context.Context, auth *cliproxyauth.Auth, headers http.Header, explicitUseSIWC ...bool) bool { + if len(explicitUseSIWC) > 0 { + return explicitUseSIWC[0] + } + if !codexUseSIWC(auth) { + return false + } + if headers != nil { + if authHeader := strings.TrimSpace(headers.Get("Authorization")); strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { + tok := strings.TrimSpace(authHeader[len("Bearer "):]) + if siwcTok := codexSIWCAccessToken(auth); siwcTok != "" && tok != "" { + return tok == siwcTok + } + } + } + return codexUseSIWCForRequest(ctx, auth, headers) +} + +func detachMismatchedWebsocketSessionConn(sess *codexWebsocketSession, authID string, wsURL string, proxyURL string, useSIWC ...bool) (*websocket.Conn, *websocketConnectionCloser, string, string, cliproxyexecutor.ExecutionLifecycle) { if sess == nil { return nil, nil, "", "", nil } @@ -416,7 +505,7 @@ func detachMismatchedWebsocketSessionConn(sess *codexWebsocketSession, authID st sess.connMu.Lock() defer sess.connMu.Unlock() conn := sess.conn - if conn == nil || websocketSessionTargetMatches(sess, authID, wsURL, proxyURL) { + if conn == nil || websocketSessionTargetMatches(sess, authID, wsURL, proxyURL, useSIWC...) { return nil, nil, "", "", nil } @@ -428,6 +517,9 @@ func detachMismatchedWebsocketSessionConn(sess *codexWebsocketSession, authID st sess.lifecycleModel = "" sess.conn = nil sess.connCloser = nil + sess.connCfg = nil + sess.hasConnCfg = false + sess.useSIWC = false sess.multiAgentV2OptimizedConn = nil if sess.readerConn == conn { sess.readerConn = nil @@ -592,6 +684,10 @@ func (e *CodexWebsocketsExecutor) UpstreamDisconnectChan(sessionID string) <-cha } func (e *CodexWebsocketsExecutor) ensureUpstreamConn(ctx context.Context, auth *cliproxyauth.Auth, sess *codexWebsocketSession, authID string, wsURL string, headers http.Header) (*websocket.Conn, *websocketConnectionCloser, *http.Response, error) { + return e.ensureUpstreamConnWithSIWC(ctx, auth, sess, authID, wsURL, headers, codexWebsocketRequestUsesSIWC(ctx, auth, headers)) +} + +func (e *CodexWebsocketsExecutor) ensureUpstreamConnWithSIWC(ctx context.Context, auth *cliproxyauth.Auth, sess *codexWebsocketSession, authID string, wsURL string, headers http.Header, useSIWC bool) (*websocket.Conn, *websocketConnectionCloser, *http.Response, error) { if sess == nil { conn, closer, resp, err := e.dialCodexWebsocket(ctx, auth, wsURL, headers) if conn != nil { @@ -599,9 +695,8 @@ func (e *CodexWebsocketsExecutor) ensureUpstreamConn(ctx context.Context, auth * } return conn, closer, resp, err } - - proxyURL := executionProxyURL(ctx, e.cfg, auth) - if staleConn, staleCloser, staleAuthID, staleWSURL, staleLifecycle := detachMismatchedWebsocketSessionConn(sess, authID, wsURL, proxyURL); staleConn != nil { + proxyURL := executionProxyURL(ctx, sess.effectiveConfig(authID, e.cfg), auth) + if staleConn, staleCloser, staleAuthID, staleWSURL, staleLifecycle := detachMismatchedWebsocketSessionConn(sess, authID, wsURL, proxyURL, useSIWC); staleConn != nil { staleLastEvent := sess.getLastEventType(staleConn) logCodexWebsocketDisconnectedWithLastEvent(sess, sess.sessionID, staleAuthID, staleWSURL, "target_changed", staleLastEvent, nil) if staleCloser != nil { @@ -631,13 +726,14 @@ func (e *CodexWebsocketsExecutor) ensureUpstreamConn(ctx context.Context, auth * return conn, closer, nil, nil } + dialProxyURL := executionProxyURL(ctx, e.cfg, auth) conn, closer, resp, errDial := e.dialCodexWebsocket(ctx, auth, wsURL, headers) if errDial != nil { return nil, closer, resp, errDial } sess.connMu.Lock() - if sess.conn != nil { + if sess.conn != nil && websocketSessionTargetMatches(sess, authID, wsURL, dialProxyURL, useSIWC) { previous := sess.conn previousCloser := sess.connCloser sess.connMu.Unlock() @@ -649,10 +745,13 @@ func (e *CodexWebsocketsExecutor) ensureUpstreamConn(ctx context.Context, auth * } sess.conn = conn sess.connCloser = closer + sess.connCfg = e.cfg + sess.hasConnCfg = true sess.multiAgentV2OptimizedConn = nil sess.wsURL = wsURL sess.authID = authID - sess.proxyURL = proxyURL + sess.proxyURL = dialProxyURL + sess.useSIWC = useSIWC sess.readerConn = conn sess.connMu.Unlock() @@ -670,6 +769,7 @@ func (e *CodexWebsocketsExecutor) readUpstreamLoop(sess *codexWebsocketSession, _ = conn.SetReadDeadline(time.Now().Add(codexResponsesWebsocketIdleTimeout)) msgType, payload, errRead := conn.ReadMessage() if errRead != nil { + sess.markTerminalError(conn, errRead) invalidate := func() { e.invalidateUpstreamConn(sess, conn, "upstream_disconnected", errRead) } @@ -690,6 +790,7 @@ func (e *CodexWebsocketsExecutor) readUpstreamLoop(sess *codexWebsocketSession, if msgType != websocket.TextMessage { if msgType == websocket.BinaryMessage { errBinary := fmt.Errorf("codex websockets executor: unexpected binary message") + sess.markTerminalError(conn, errBinary) invalidate := func() { e.invalidateUpstreamConn(sess, conn, "unexpected_binary", errBinary) } @@ -715,6 +816,16 @@ func (e *CodexWebsocketsExecutor) readUpstreamLoop(sess *codexWebsocketSession, if eventType != "" { sess.setLastEventType(conn, eventType) } + // Record terminal IDs before delivery: downstream may still be + // processing this turn after its active read channel is cleared. + responseID := gjson.GetBytes(payload, "response.id") + if isTerminalEvent(eventType) && eventType != "error" && responseID.Type == gjson.String && responseID.String() != "" { + sess.activeMu.Lock() + if sess.responseStateConn == conn { + sess.terminalResponseIDs[responseID.String()] = struct{}{} + } + sess.activeMu.Unlock() + } } ch, done := sess.activeForConn(conn) @@ -756,6 +867,8 @@ func (e *CodexWebsocketsExecutor) invalidateUpstreamConnWithNotify(sess *codexWe sess.lifecycleModel = "" sess.conn = nil sess.connCloser = nil + sess.connCfg = nil + sess.hasConnCfg = false sess.multiAgentV2OptimizedConn = nil if sess.readerConn == conn { sess.readerConn = nil @@ -777,6 +890,41 @@ func (e *CodexWebsocketsExecutor) invalidateUpstreamConnWithNotify(sess *codexWe } } +func (e *CodexWebsocketsExecutor) sessionStore() *codexWebsocketSessionStore { + if e == nil { + return nil + } + if e.store != nil { + return e.store + } + return globalCodexWebsocketSessionStore +} + +func (e *CodexWebsocketsExecutor) SharesExecutionSessionsWith(other cliproxyauth.ProviderExecutor) bool { + if e == nil || other == nil { + return false + } + store := e.sessionStore() + if store == nil { + return false + } + switch next := other.(type) { + case *CodexWebsocketsExecutor: + return next != nil && store == next.sessionStore() + case *CodexAutoExecutor: + return next != nil && next.wsExec != nil && store == next.wsExec.sessionStore() + default: + return false + } +} + +func (e *CodexWebsocketsExecutor) CloseAuthExecutionSessions(authID string) { + if e == nil { + return + } + closeCodexWebsocketSessionsInStoreForAuthID(e.sessionStore(), authID, "auth_removed") +} + func (e *CodexWebsocketsExecutor) CloseExecutionSession(sessionID string) { sessionID = strings.TrimSpace(sessionID) if e == nil { @@ -790,10 +938,7 @@ func (e *CodexWebsocketsExecutor) CloseExecutionSession(sessionID string) { return } - store := e.store - if store == nil { - store = globalCodexWebsocketSessionStore - } + store := e.sessionStore() store.mu.Lock() sess := store.sessions[sessionID] delete(store.sessions, sessionID) @@ -849,6 +994,8 @@ func closeCodexWebsocketSession(sess *codexWebsocketSession, reason string) { sess.lifecycleModel = "" sess.conn = nil sess.connCloser = nil + sess.connCfg = nil + sess.hasConnCfg = false sess.multiAgentV2OptimizedConn = nil if sess.readerConn == conn { sess.readerConn = nil @@ -929,8 +1076,12 @@ func logCodexWebsocketDisconnectedWithLastEvent(sess *codexWebsocketSession, ses // CloseCodexWebsocketSessionsForAuthID closes all active Codex upstream websocket sessions // associated with the supplied auth ID. func CloseCodexWebsocketSessionsForAuthID(authID string, reason string) { + closeCodexWebsocketSessionsInStoreForAuthID(globalCodexWebsocketSessionStore, authID, reason) +} + +func closeCodexWebsocketSessionsInStoreForAuthID(store *codexWebsocketSessionStore, authID string, reason string) { authID = strings.TrimSpace(authID) - if authID == "" { + if authID == "" || store == nil { return } reason = strings.TrimSpace(reason) @@ -938,11 +1089,6 @@ func CloseCodexWebsocketSessionsForAuthID(authID string, reason string) { reason = "auth_removed" } - store := globalCodexWebsocketSessionStore - if store == nil { - return - } - type sessionItem struct { sessionID string sess *codexWebsocketSession diff --git a/internal/runtime/executor/codex_websockets_stream.go b/internal/runtime/executor/codex_websockets_stream.go index b6bcb49e..a3151db2 100644 --- a/internal/runtime/executor/codex_websockets_stream.go +++ b/internal/runtime/executor/codex_websockets_stream.go @@ -45,6 +45,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr clientBody := prepared.clientBody wsURL := prepared.wsURL wsHeaders := prepared.wsHeaders + useSIWC := prepared.useSIWC replayScope := prepared.replayScope optimizeMultiAgentV2 := prepared.optimizeMultiAgentV2 multiAgentV2Conflict := prepared.multiAgentV2Conflict @@ -75,7 +76,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr } } - wsReqBody := buildCodexWebsocketRequestBody(clientBody) + wsReqBody := frameCodexWebsocketRequestBody(clientBody) wsReqLog := helps.UpstreamRequestLog{ URL: wsURL, Method: "WEBSOCKET", @@ -95,14 +96,14 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr var errDial error dialCtx := ctx if cliproxyexecutor.RequiredUpstreamWebsocket(ctx) { - conn, closer = existingWebsocketSessionConn(sess, authID, wsURL, executionProxyURL(ctx, e.cfg, auth)) + conn, closer = existingWebsocketSessionConn(sess, authID, wsURL, executionProxyURL(ctx, sess.effectiveConfig(authID, e.cfg), auth), useSIWC) if conn == nil { unlockStreamSession() return nil, cliproxyexecutor.NewUpstreamWebsocketReplayRequiredError() } } else { dialCtx = cliproxyexecutor.WithUpstreamAttemptTracker(ctx) - conn, closer, respHS, errDial = e.ensureUpstreamConn(dialCtx, auth, sess, authID, wsURL, wsHeaders) + conn, closer, respHS, errDial = e.ensureUpstreamConnWithSIWC(dialCtx, auth, sess, authID, wsURL, wsHeaders, useSIWC) } var upstreamHeaders http.Header if respHS != nil { @@ -174,7 +175,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr } // Retry once with a new websocket connection for the same execution session. - connRetry, closerRetry, respHSRetry, errDialRetry := e.ensureUpstreamConn(ctx, auth, sess, authID, wsURL, wsHeaders) + connRetry, closerRetry, respHSRetry, errDialRetry := e.ensureUpstreamConnWithSIWC(ctx, auth, sess, authID, wsURL, wsHeaders, useSIWC) if errDialRetry != nil || connRetry == nil { closeHTTPResponseBody(respHSRetry, "codex websockets executor: close handshake response body error") helps.RecordAPIWebsocketError(ctx, e.cfg, "dial_retry", errDialRetry) @@ -193,7 +194,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr } readCh = sess.activate(conn) restoreMultiAgentV2 = !multiAgentV2Conflict && (optimizeMultiAgentV2 || sess.isMultiAgentV2Optimized(conn)) - wsReqBodyRetry := buildCodexWebsocketRequestBody(clientBody) + wsReqBodyRetry := frameCodexWebsocketRequestBody(clientBody) helps.RecordAPIWebsocketRequest(ctx, e.cfg, helps.UpstreamRequestLog{ URL: wsURL, Method: "WEBSOCKET", @@ -623,38 +624,47 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr if wsErr, ok := parseCodexWebsocketErrorWithCooling(payload, e.modelLevelCooling()); ok { terminateReason = "upstream_error" terminateErr = wsErr + // The disconnect notification makes the downstream handler close the client and + // cancel the request context, so it must follow delivery of the error to the + // conductor; otherwise the cancellation can drop the error and its cooldown. if sess != nil { - e.invalidateUpstreamConn(sess, conn, "upstream_error", wsErr) + e.invalidateUpstreamConnWithoutDisconnectNotify(sess, conn, "upstream_error", wsErr) } if errClearReplay := clearCodexReasoningReplayOnWebsocketError(ctx, replayScope, payload); errClearReplay != nil { terminateErr = errClearReplay helps.RecordAPIWebsocketError(ctx, e.cfg, "replay_clear_error", errClearReplay) reporter.PublishFailure(ctx, errClearReplay) _ = send(cliproxyexecutor.StreamChunk{Err: errClearReplay}) + sess.notifyUpstreamDisconnect(wsErr) return } helps.RecordAPIWebsocketError(ctx, e.cfg, "upstream_error", wsErr) reporter.PublishFailure(ctx, wsErr) _ = send(cliproxyexecutor.StreamChunk{Err: wsErr}) + sess.notifyUpstreamDisconnect(wsErr) return } if streamErr, terminalBody, ok := codexTerminalFailureErrWithCooling(payload, e.modelLevelCooling()); ok { terminateReason = "upstream_error" terminateErr = streamErr + // Publish the downstream disconnect only after the error was delivered, see the + // upstream error branch above. if sess != nil { unlockStreamSession() - e.invalidateUpstreamConn(sess, conn, "terminal_failure", streamErr) + e.invalidateUpstreamConnWithoutDisconnectNotify(sess, conn, "terminal_failure", streamErr) } if errClearReplay := clearCodexReasoningReplayOnInvalidSignature(ctx, replayScope, streamErr.StatusCode(), terminalBody); errClearReplay != nil { terminateErr = errClearReplay helps.RecordAPIWebsocketError(ctx, e.cfg, "replay_clear_error", errClearReplay) reporter.PublishFailure(ctx, errClearReplay) _ = send(cliproxyexecutor.StreamChunk{Err: errClearReplay}) + sess.notifyUpstreamDisconnect(streamErr) return } helps.RecordAPIWebsocketError(ctx, e.cfg, "upstream_error", streamErr) reporter.PublishFailure(ctx, streamErr) _ = send(cliproxyexecutor.StreamChunk{Err: streamErr}) + sess.notifyUpstreamDisconnect(streamErr) return } @@ -745,6 +755,7 @@ type codexWebsocketPrepared struct { clientBody []byte wsURL string wsHeaders http.Header + useSIWC bool replayScope codexReasoningReplayScope optimizeMultiAgentV2 bool multiAgentV2Conflict bool @@ -752,9 +763,17 @@ type codexWebsocketPrepared struct { func (e *CodexWebsocketsExecutor) prepareCodexWebsocketStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*codexWebsocketPrepared, error) { baseModel := thinking.ParseSuffix(req.Model).ModelName - apiKey, baseURL := codexCreds(auth) + apiKey, baseURL := codexCredsForRequest(ctx, auth, opts.Headers) + if errSIWC := ensureCodexSIWCCredentialsForRequest(ctx, auth, apiKey, opts.Headers); errSIWC != nil { + return nil, errSIWC + } + useSIWC := codexUseSIWCForRequest(ctx, auth, opts.Headers) if baseURL == "" { - baseURL = "https://chatgpt.com/backend-api/codex" + if useSIWC { + baseURL = defaultOpenAISIWCBaseURL + } else { + baseURL = "https://chatgpt.com/backend-api/codex" + } } var err error from := opts.SourceFormat @@ -767,22 +786,29 @@ func (e *CodexWebsocketsExecutor) prepareCodexWebsocketStream(ctx context.Contex } originalPayload := originalPayloadSource isCompat := e.resolveCodexModelIsCompat(auth, req, baseModel) - originalTranslated, body, updatesChanged := translateCodexRequestPairWithUpdateIntent(from, to, baseModel, originalPayload, req.Payload, true, isCompat) + targetModel := resolveResponsesSignatureTargetModel(baseModel, req, opts) + originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntent(from, to, targetModel, originalPayload, req.Payload, true, isCompat) + if err != nil { + return nil, err + } + originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel) + body = helps.SetStringIfDifferent(body, "model", baseModel) body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged) if err != nil { return nil, err } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequestForExecutor(e.cfg, "codex-websockets", baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) + hasExplicitInstructions := gjson.GetBytes(req.Payload, "instructions").Exists() && strings.TrimSpace(gjson.GetBytes(req.Payload, "instructions").String()) != "" + finalizePayload := helps.NewPayloadFinalizer(e.cfg, "codex-websockets", baseModel, to.String(), "", originalTranslated, req, opts) body = helps.SetStringIfDifferent(body, "model", baseModel) - body = normalizeCodexInstructions(body, preserveNativeOutput) - if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { - body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers) + body = ensureWebSearchToolForHistory(body, opts.Headers) + if !useSIWC { + body = normalizeCodexInstructions(body, preserveNativeOutput) + if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { + body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers) + } } - body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompat(ctx, "codex websockets executor", body, isCompat) body = normalizeCodexWebsocketParallelToolCalls(body, opts.Headers) body = helps.NormalizeCodexToolSchemas(body) multiAgentV2Conflict := helps.HasCodexMultiAgentV2NamespaceConflict(body) @@ -791,6 +817,7 @@ func (e *CodexWebsocketsExecutor) prepareCodexWebsocketStream(ctx context.Contex if errReplay != nil { return nil, errReplay } + body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx, "codex websockets executor", body, isCompat, targetModel, baseURL) httpURL := strings.TrimSuffix(baseURL, "/") + "/responses" wsURL, err := buildCodexResponsesWebsocketURL(httpURL) @@ -802,6 +829,11 @@ func (e *CodexWebsocketsExecutor) prepareCodexWebsocketStream(ctx context.Contex if errPromptCache != nil { return nil, errPromptCache } + body = helps.SanitizeCodexInputItemIDs(body) + if useSIWC { + body = applyCodexSIWCWebsocketBodyRules(body, hasExplicitInstructions, cliproxyexecutor.Options{KeepTopLevelEffort: thinking.HasThinkingSuffix(req.Model)}) + } + body = finalizePayload(body) wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg, preserveNativeOutput, opts.Headers) applyCodexRoutingHint(ctx, wsHeaders, auth, baseModel, body, opts.Headers) applyModelHeaderOverrides(wsHeaders, baseModel) @@ -815,6 +847,7 @@ func (e *CodexWebsocketsExecutor) prepareCodexWebsocketStream(ctx context.Contex clientBody: body, wsURL: wsURL, wsHeaders: wsHeaders, + useSIWC: useSIWC, replayScope: replayScope, optimizeMultiAgentV2: optimizeMultiAgentV2, multiAgentV2Conflict: multiAgentV2Conflict, diff --git a/internal/runtime/executor/helps/apply_patch.go b/internal/runtime/executor/helps/apply_patch.go index bd0c8e9e..62180785 100644 --- a/internal/runtime/executor/helps/apply_patch.go +++ b/internal/runtime/executor/helps/apply_patch.go @@ -1,16 +1,101 @@ package helps import ( + "bytes" "context" "github.com/router-for-me/CLIProxyAPI/v8/internal/util" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" + "github.com/tidwall/gjson" ) // ApplyPatchUpstreamErrorMessage deliberately excludes upstream JSON and patch text. const ApplyPatchUpstreamErrorMessage = "Invalid apply_patch tool arguments received from upstream." +// TranslatedClaudeErrorPayload returns the raw JSON payload when a non-streaming +// translation to Claude format produced a terminal error object instead of a message. +func TranslatedClaudeErrorPayload(responseFormat sdktranslator.Format, out []byte) (string, bool) { + if responseFormat != sdktranslator.FormatClaude || len(out) == 0 { + return "", false + } + if gjson.GetBytes(out, "type").String() != "error" { + return "", false + } + return string(out), true +} + +// TranslatedClaudeStreamErrorPayload returns the raw JSON data payload when a translated +// Claude SSE chunk contains an error event. +func TranslatedClaudeStreamErrorPayload(responseFormat sdktranslator.Format, lines [][]byte) (string, bool) { + if responseFormat != sdktranslator.FormatClaude { + return "", false + } + for _, chunk := range lines { + for _, line := range bytes.Split(chunk, []byte("\n")) { + payload := JSONPayload(line) + if len(payload) == 0 { + continue + } + if gjson.GetBytes(payload, "type").String() == "error" { + return string(payload), true + } + } + } + return "", false +} + +// CaptureTranslatedClaudeStreamError retains the first SSE error event payload +// so failure usage and terminal StreamChunk.Err can be emitted after any trailing usage chunk. +func CaptureTranslatedClaudeStreamError(responseFormat sdktranslator.Format, lines [][]byte, retainedErr *error, buildErr func(string) error) { + if retainedErr == nil || *retainedErr != nil { + return + } + errPayload, okErr := TranslatedClaudeStreamErrorPayload(responseFormat, lines) + if !okErr { + return + } + if buildErr != nil { + *retainedErr = buildErr(errPayload) + } +} + +// PublishTranslatedClaudeStreamFailure publishes failure usage with buffered stream usage +// when a translated Claude stream retained an SSE error event. +func PublishTranslatedClaudeStreamFailure(ctx context.Context, retainedErr error, reporter *UsageReporter, streamUsage *StreamUsageBuffer) bool { + if retainedErr == nil { + return false + } + if streamUsage != nil && streamUsage.PublishFailure(ctx, reporter, retainedErr) { + return true + } + if reporter != nil { + reporter.PublishFailure(ctx, retainedErr) + } + return true +} + +// StopTranslatedClaudeStream publishes failure usage and propagates a terminal StreamChunk.Err +// once after a translated Claude stream emitted an SSE error event. +func StopTranslatedClaudeStream(ctx context.Context, retainedErr *error, reporter *UsageReporter, streamUsage *StreamUsageBuffer, out chan<- cliproxyexecutor.StreamChunk) bool { + if retainedErr == nil || *retainedErr == nil { + return false + } + err := *retainedErr + *retainedErr = nil + PublishTranslatedClaudeStreamFailure(ctx, err, reporter, streamUsage) + select { + case out <- cliproxyexecutor.StreamChunk{Err: err}: + return true + default: + } + select { + case out <- cliproxyexecutor.StreamChunk{Err: err}: + case <-ctx.Done(): + } + return true +} + // ApplyPatchTranslationError consumes only the optional translator error state. func ApplyPatchTranslationError(param any) error { state, okState := param.(interface{ ToolInputError() error }) @@ -30,11 +115,25 @@ func FinalizeApplyPatchStream(param any) [][]byte { return state.FinalizeToolInput() } +// CanFinalizeResponseStream reports whether EOF can safely synthesize the source terminator. +func CanFinalizeResponseStream(param any) bool { + state, okState := param.(interface{ CanFinalizeResponseStream() bool }) + return okState && state.CanFinalizeResponseStream() +} + // RecordApplyPatchStreamFailure records validation before delivery can be canceled. func RecordApplyPatchStreamFailure(ctx context.Context, param any, reporter *UsageReporter, gatewayErr error) bool { + return RecordApplyPatchStreamFailureWithUsage(ctx, param, reporter, nil, gatewayErr) +} + +// RecordApplyPatchStreamFailureWithUsage records validation with buffered upstream usage. +func RecordApplyPatchStreamFailureWithUsage(ctx context.Context, param any, reporter *UsageReporter, streamUsage *StreamUsageBuffer, gatewayErr error) bool { if ApplyPatchTranslationError(param) == nil { return false } + if streamUsage != nil && streamUsage.PublishFailure(ctx, reporter, gatewayErr) { + return true + } reporter.PublishFailure(ctx, gatewayErr) return true } @@ -42,7 +141,12 @@ func RecordApplyPatchStreamFailure(ctx context.Context, param any, reporter *Usa // StopApplyPatchStream propagates a retained failure after its one translated frame. // The caller supplies its existing sanitized gateway status error. func StopApplyPatchStream(ctx context.Context, param any, reporter *UsageReporter, out chan<- cliproxyexecutor.StreamChunk, gatewayErr error) bool { - if !RecordApplyPatchStreamFailure(ctx, param, reporter, gatewayErr) { + return StopApplyPatchStreamWithUsage(ctx, param, reporter, nil, out, gatewayErr) +} + +// StopApplyPatchStreamWithUsage propagates a retained failure with buffered upstream usage. +func StopApplyPatchStreamWithUsage(ctx context.Context, param any, reporter *UsageReporter, streamUsage *StreamUsageBuffer, out chan<- cliproxyexecutor.StreamChunk, gatewayErr error) bool { + if !RecordApplyPatchStreamFailureWithUsage(ctx, param, reporter, streamUsage, gatewayErr) { return false } select { diff --git a/internal/runtime/executor/helps/codex_multi_agent_v2.go b/internal/runtime/executor/helps/codex_multi_agent_v2.go index c4d15e11..6794a42d 100644 --- a/internal/runtime/executor/helps/codex_multi_agent_v2.go +++ b/internal/runtime/executor/helps/codex_multi_agent_v2.go @@ -65,22 +65,28 @@ func TranslateRequestEnvelopeWithCodexMultiAgentV2(ctx context.Context, headers // extra pass over payloads that can reach tens of megabytes. func TranslateRequestPairWithCodexMultiAgentV2(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, model string, originalPayload, requestPayload []byte, stream bool) (original, working []byte) { req := sdktranslator.RequestEnvelope{Format: from, Model: model, Stream: stream} - return TranslateRequestEnvelopePairWithCodexMultiAgentV2(ctx, headers, cfg, from, to, req, originalPayload, requestPayload) + original, working, _ = TranslateRequestEnvelopePairWithCodexMultiAgentV2(ctx, headers, cfg, from, to, req, originalPayload, requestPayload) + return original, working } // TranslateRequestEnvelopePairWithCodexMultiAgentV2 translates the baseline and -// working payload while preserving request-scoped metadata in req. -func TranslateRequestEnvelopePairWithCodexMultiAgentV2(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, req sdktranslator.RequestEnvelope, originalPayload, requestPayload []byte) (original, working []byte) { +// working payload while preserving request-scoped metadata in req. The returned +// error is the one for the working payload, which is the payload sent upstream; +// a failure of the baseline alone never rejects a working payload that translated. +func TranslateRequestEnvelopePairWithCodexMultiAgentV2(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, req sdktranslator.RequestEnvelope, originalPayload, requestPayload []byte) (original, working []byte, err error) { originalReq := req originalReq.Body = originalPayload - original = TranslateRequestEnvelopeWithCodexMultiAgentV2(ctx, headers, cfg, from, to, originalReq).Body + originalTranslated := TranslateRequestEnvelopeWithCodexMultiAgentV2(ctx, headers, cfg, from, to, originalReq) + original = originalTranslated.Body if sameByteSlice(originalPayload, requestPayload) { // The caller mutates the working copy, so it must not share the baseline array. - return original, append([]byte(nil), original...) + // A single translation serves both payloads, so its error is the working error. + return original, append([]byte(nil), original...), originalTranslated.Err } workingReq := req workingReq.Body = requestPayload - return original, TranslateRequestEnvelopeWithCodexMultiAgentV2(ctx, headers, cfg, from, to, workingReq).Body + workingTranslated := TranslateRequestEnvelopeWithCodexMultiAgentV2(ctx, headers, cfg, from, to, workingReq) + return original, workingTranslated.Body, workingTranslated.Err } // sameByteSlice reports whether both slices describe the same bytes of the same @@ -99,40 +105,55 @@ func sameByteSlice(a, b []byte) bool { // TranslateRequestPairWithAPIKeyModelCompatibility avoids translating identical // inputs twice while retaining separate buffers and invoking plugins once per input. func TranslateRequestPairWithAPIKeyModelCompatibility(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, model string, originalPayload, requestPayload []byte, stream, isCompat bool) (original, working []byte) { - original, working, _ = TranslateRequestPairWithAPIKeyModelCompatibilityAndUpdateIntent(ctx, headers, cfg, from, to, model, originalPayload, requestPayload, stream, isCompat) + original, working, _ = translateRequestPairWithAPIKeyModelCompatibility(ctx, headers, cfg, from, to, model, originalPayload, requestPayload, stream, isCompat) return original, working } +// TranslateRequestPairReturningError reports a content part the target cannot represent. +func TranslateRequestPairReturningError(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, model string, originalPayload, requestPayload []byte, stream, isCompat bool) ([]byte, []byte, error) { + return translateRequestPairWithAPIKeyModelCompatibility(ctx, headers, cfg, from, to, model, originalPayload, requestPayload, stream, isCompat) +} + +func translateRequestPairWithAPIKeyModelCompatibility(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, model string, originalPayload, requestPayload []byte, stream, isCompat bool) (original, working []byte, err error) { + original, working, _, err = TranslateRequestPairWithAPIKeyModelCompatibilityAndUpdateIntent(ctx, headers, cfg, from, to, model, originalPayload, requestPayload, stream, isCompat) + return original, working, err +} + // TranslateRequestPairWithAPIKeyModelCompatibilityAndUpdateIntent returns the -// normalizer decision for the working payload, not for the baseline payload. -func TranslateRequestPairWithAPIKeyModelCompatibilityAndUpdateIntent(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, model string, originalPayload, requestPayload []byte, stream, isCompat bool) (original, working []byte, updatesChanged bool) { - original, updatesChanged = TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntent(ctx, headers, cfg, from, to, model, originalPayload, stream, isCompat) +// normalizer decision for the working payload, not for the baseline payload, +// and the working payload's error for a content part the target cannot +// represent. A baseline failure alone is ignored. +func TranslateRequestPairWithAPIKeyModelCompatibilityAndUpdateIntent(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, model string, originalPayload, requestPayload []byte, stream, isCompat bool) (original, working []byte, updatesChanged bool, err error) { + original, updatesChanged, err = TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntent(ctx, headers, cfg, from, to, model, originalPayload, stream, isCompat) if sameByteSlice(originalPayload, requestPayload) { - return original, append([]byte(nil), original...), updatesChanged + // A single translation serves both payloads, so its error is the working error. + return original, append([]byte(nil), original...), updatesChanged, err } - working, updatesChanged = TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntent(ctx, headers, cfg, from, to, model, requestPayload, stream, isCompat) - return original, working, updatesChanged + // Only the working payload is sent upstream, so only its error counts. + working, updatesChanged, err = TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntent(ctx, headers, cfg, from, to, model, requestPayload, stream, isCompat) + return original, working, updatesChanged, err } // TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntent returns the // plugin normalizer's request-scoped update decision for Responses targets. -func TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntent(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, model string, payload []byte, stream, isCompat bool) ([]byte, bool) { +func TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntent(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, model string, payload []byte, stream, isCompat bool) ([]byte, bool, error) { return TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntentForExecutor(ctx, headers, cfg, "", from, to, model, payload, stream, isCompat) } // TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntentForExecutor // translates a request while applying client schema compatibility for the // actual target executor. -func TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntentForExecutor(ctx context.Context, headers http.Header, cfg *config.Config, targetExecutor string, from, to sdktranslator.Format, model string, payload []byte, stream, isCompat bool) ([]byte, bool) { +func TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntentForExecutor(ctx context.Context, headers http.Header, cfg *config.Config, targetExecutor string, from, to sdktranslator.Format, model string, payload []byte, stream, isCompat bool) ([]byte, bool, error) { if !isCompat || (to == sdktranslator.FormatCodex && from != sdktranslator.FormatClaude) { translated := sdktranslator.RequestEnvelope{Format: from, Model: model, Stream: stream, Body: payload} if IsCodexUserAgent(headers) && !isCodexTargetExecutor(targetExecutor) { translated.Body = NormalizeCodexToolIntegerTypes(translated.Body, headers) } translated = multiagentv2.TranslateRequestEnvelopeWithCodexMultiAgentV2(ctx, headers, cfg, from, to, translated) - return translated.Body, translated.ConfigurationUpdatesChanged + return translated.Body, translated.ConfigurationUpdatesChanged, translated.Err } - return TranslateRequestWithAPIKeyModelCompatibilityForExecutor(ctx, headers, cfg, targetExecutor, from, to, model, payload, stream, isCompat), false + body, err := translateRequestWithAPIKeyModelCompatibilityForExecutor(ctx, headers, cfg, targetExecutor, from, to, model, payload, stream, isCompat) + return body, false, err } // TranslateRequestWithAPIKeyModelCompatibility applies compatibility-aware @@ -144,11 +165,21 @@ func TranslateRequestWithAPIKeyModelCompatibility(ctx context.Context, headers h // TranslateRequestWithAPIKeyModelCompatibilityForExecutor applies compatibility-aware // request translation while preserving the actual target executor's schema policy. func TranslateRequestWithAPIKeyModelCompatibilityForExecutor(ctx context.Context, headers http.Header, cfg *config.Config, targetExecutor string, from, to sdktranslator.Format, model string, payload []byte, stream, isCompat bool) []byte { + body, _ := translateRequestWithAPIKeyModelCompatibilityForExecutor(ctx, headers, cfg, targetExecutor, from, to, model, payload, stream, isCompat) + return body +} + +// TranslateRequestReturningError reports a content part the target cannot represent. +func TranslateRequestReturningError(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, model string, payload []byte, stream, isCompat bool) ([]byte, error) { + return translateRequestWithAPIKeyModelCompatibilityForExecutor(ctx, headers, cfg, "", from, to, model, payload, stream, isCompat) +} + +func translateRequestWithAPIKeyModelCompatibilityForExecutor(ctx context.Context, headers http.Header, cfg *config.Config, targetExecutor string, from, to sdktranslator.Format, model string, payload []byte, stream, isCompat bool) ([]byte, error) { if IsCodexUserAgent(headers) && !isCodexTargetExecutor(targetExecutor) { payload = NormalizeCodexToolIntegerTypes(payload, headers) } if !isCompat { - return TranslateRequestWithCodexMultiAgentV2ForExecutor(ctx, headers, cfg, targetExecutor, from, to, model, payload, stream) + return translateRequestWithCodexMultiAgentV2Checked(ctx, headers, cfg, from, to, model, payload, stream) } if from == sdktranslator.FormatOpenAIResponse { payload = RewriteCodexOrphanDelegationInput(ctx, headers, payload, cfg) @@ -158,26 +189,27 @@ func TranslateRequestWithAPIKeyModelCompatibilityForExecutor(ctx context.Context } var translated []byte + var convertErr error switch { case from == sdktranslator.FormatClaude && to == sdktranslator.FormatCodex: - translated = codexclaude.ConvertClaudeRequestToCodexWithCompat(model, payload, stream) + translated, convertErr = codexclaude.ConvertClaudeRequestToCodexWithCompat(model, payload, stream) case from == sdktranslator.FormatClaude && to == sdktranslator.FormatGemini: - translated = geminiclaude.ConvertClaudeRequestToGeminiWithCompat(model, payload, stream) + translated, convertErr = geminiclaude.ConvertClaudeRequestToGeminiWithCompat(model, payload, stream) case from == sdktranslator.FormatClaude && to == sdktranslator.FormatInteractions: - translated = interactionsclaude.ConvertClaudeRequestToInteractionsWithCompat(model, payload, stream) + translated, convertErr = interactionsclaude.ConvertClaudeRequestToInteractionsWithCompat(model, payload, stream) case from == sdktranslator.FormatClaude && to == sdktranslator.FormatOpenAI: - translated = openaiclaude.ConvertClaudeRequestToOpenAIWithCompat(model, payload, stream) + translated, convertErr = openaiclaude.ConvertClaudeRequestToOpenAIWithCompat(model, payload, stream) case from == sdktranslator.FormatOpenAI && to == sdktranslator.FormatClaude: - translated = openaichatclaude.ConvertOpenAIRequestToClaudeWithCompat(model, payload, stream) + translated, convertErr = openaichatclaude.ConvertOpenAIRequestToClaudeWithCompat(model, payload, stream) case from == sdktranslator.FormatOpenAIResponse && to == sdktranslator.FormatClaude: - translated = responsesclaude.ConvertOpenAIResponsesRequestToClaudeWithCompat(model, payload, stream) + translated, convertErr = responsesclaude.ConvertOpenAIResponsesRequestToClaudeWithCompatContext(ctx, model, payload, stream) default: - return TranslateRequestWithCodexMultiAgentV2ForExecutor(ctx, headers, cfg, targetExecutor, from, to, model, payload, stream) + return translateRequestWithCodexMultiAgentV2Checked(ctx, headers, cfg, from, to, model, payload, stream) } summaryConfig := thinking.ExtractTranslatedSummaryConfig(payload, from.String(), to.String()) translated = thinking.ApplySummaryConfigForModel(translated, to.String(), model, summaryConfig) - return sdktranslator.NormalizeRequest(ctx, from, to, model, translated, stream) + return sdktranslator.NormalizeRequest(ctx, from, to, model, translated, stream), convertErr } // HasCodexMultiAgentV2NamespaceConflict reports whether the request defines @@ -213,3 +245,10 @@ func OptimizeCodexMultiAgentV2RequestForAuth(ctx context.Context, headers http.H func RestoreCodexMultiAgentV2Response(payload []byte, optimized bool) []byte { return multiagentv2.RestoreCodexMultiAgentV2Response(payload, optimized) } + +// translateRequestWithCodexMultiAgentV2Checked is the multi-agent translation that +// keeps the envelope failure instead of dropping it with the body. +func translateRequestWithCodexMultiAgentV2Checked(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, model string, payload []byte, stream bool) ([]byte, error) { + translated := multiagentv2.TranslateRequestEnvelopeWithCodexMultiAgentV2(ctx, headers, cfg, from, to, sdktranslator.RequestEnvelope{Format: from, Model: model, Stream: stream, Body: payload}) + return translated.Body, translated.Err +} diff --git a/internal/runtime/executor/helps/codex_sse_scanner.go b/internal/runtime/executor/helps/codex_sse_scanner.go new file mode 100644 index 00000000..35f55329 --- /dev/null +++ b/internal/runtime/executor/helps/codex_sse_scanner.go @@ -0,0 +1,330 @@ +package helps + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "io" + "strconv" + "strings" + + log "github.com/sirupsen/logrus" +) + +// codexSSEDiagnosticLines caps how many data lines one invalid-event diagnostic describes. +const codexSSEDiagnosticLines = 8 + +var codexSSEDataPrefix = []byte("data:") + +type codexSSEQueuedItem struct { + payload []byte + rawLines int + rawBytes int +} + +// CodexSSEScanner reads an upstream Responses SSE stream line by line, as bufio.Scanner does, but +// delivers an event whose data the upstream split across several "data:" lines as one "data:" line. +// In SSE an event's data may span several data lines, joined with "\n". Taking each data line as a +// whole event instead cuts such an event: on a split response.completed the stream ends at the +// first fragment and the rest is never read. +// +// Every event on this path is one JSON value. A data line that is complete JSON on its own, or +// cannot start JSON at all (an empty value, [DONE]), is delivered byte-for-byte as read with no +// lookahead, which keeps upstreams that omit the blank line between events working as before. A +// data line that opens a JSON object or array without completing it starts an event that collects +// the following data lines until one of these: +// - their joined value closes its outermost object or array; +// - a blank line ends the event; +// - the stream ends; +// - the collected bytes would exceed the line limit. +// +// A completed event is delivered compacted onto one line. One that never becomes valid JSON is +// delivered as the same multi-line data field, so it still fails downstream. The first such event +// in a stream gets one warning that describes its framing without any of its bytes. +// +// The scanner merges data lines and nothing else. It holds only the data lines of an event being +// collected and delivers every other line the moment it reads it, in the order read. A comment, +// id: or retry: line inside such an event therefore comes before the event's data instead of +// between its pieces, which SSE treats alike: an event's fields take effect when it is dispatched. +// The blank line or over-limit line that ends a collection follows the event's data. +// +// SetAssembly(false) makes the scanner read exactly as bufio.Scanner does, for a caller that +// budgets every upstream line while it holds a stream. +type CodexSSEScanner struct { + ctx context.Context + lines *bufio.Scanner + maxBytes int + // stock is set while assembly is off. + stock bool + // held is the event being collected: its data lines as read, joined with "\n" in one buffer, so + // its memory follows its bytes, not its line count. count is how many lines it holds and size + // their bytes. joined is their joined value, and nesting follows it. + held []byte + count int + size int + joined []byte + nesting codexJSONNesting + queue []codexSSEQueuedItem + current codexSSEQueuedItem + // warned is set once the stream has had its invalid-event warning. + warned bool + onLine func(line []byte) + budgetCheck func(line []byte, heldLines int, heldBytes int) bool +} + +// NewCodexSSEScanner returns a CodexSSEScanner over r, with assembly on. maxBytes bounds one line, +// as bufio.Scanner.Buffer does, and also the data lines collected for one event. +func NewCodexSSEScanner(ctx context.Context, r io.Reader, maxBytes int) *CodexSSEScanner { + lines := bufio.NewScanner(r) + lines.Buffer(nil, maxBytes) + return &CodexSSEScanner{ctx: ctx, lines: lines, maxBytes: maxBytes} +} + +// SetOnLine sets a callback invoked for every physical line read from upstream. +func (s *CodexSSEScanner) SetOnLine(fn func(line []byte)) { + s.onLine = fn +} + +// HeldLines returns how many data lines are currently held in an incomplete event. +func (s *CodexSSEScanner) HeldLines() int { + return s.count +} + +// HeldBytes returns how many bytes are currently held in an incomplete event. +func (s *CodexSSEScanner) HeldBytes() int { + return s.size +} + +// SetBudgetChecker sets an optional callback invoked on each raw line read while an event is being held. +// If it returns true, the scanner yields from Scan() with an empty payload to signal that the +// caller should release buffering, while preserving the held assembly state for subsequent Scan() calls. +func (s *CodexSSEScanner) SetBudgetChecker(fn func(line []byte, heldLines int, heldBytes int) bool) { + s.budgetCheck = fn +} + +// SetAssembly turns event assembly on or off. While it is off, no data line starts a collection and +// every line is delivered as read, exactly as bufio.Scanner delivers it. An event already being +// collected is still completed. +func (s *CodexSSEScanner) SetAssembly(on bool) { s.stock = !on } + +// Scan advances to the next line, or to the next assembled event data line. +func (s *CodexSSEScanner) Scan() bool { + if len(s.queue) > 0 { + s.current, s.queue = s.queue[0], s.queue[1:] + return true + } + for s.lines.Scan() { + line := s.lines.Bytes() + if s.onLine != nil { + s.onLine(line) + } + if !bytes.HasPrefix(line, codexSSEDataPrefix) { + if len(line) == 0 && s.count > 0 { + s.release([]byte{}) + return s.Scan() + } + s.current = codexSSEQueuedItem{payload: line, rawLines: 1, rawBytes: len(line)} + return true + } + if s.count == 0 { + if s.stock || !codexSSEIncompleteJSON(bytes.TrimSpace(line[len(codexSSEDataPrefix):])) { + s.current = codexSSEQueuedItem{payload: line, rawLines: 1, rawBytes: len(line)} + return true + } + } else if s.size+len(line) > s.maxBytes { + // Bounded like a single line: stop collecting and let this line be read on its own. + s.release(bytes.Clone(line)) + return s.Scan() + } + + closed := s.hold(line) + if !closed && s.budgetCheck != nil && s.budgetCheck(line, s.count, s.size) { + s.budgetCheck = nil + s.current = codexSSEQueuedItem{payload: nil, rawLines: 0, rawBytes: 0} + return true + } + if closed { + s.release() + return s.Scan() + } + } + if s.count > 0 { + s.release() + return s.Scan() + } + return false +} + +// hold adds a data line to the event being collected and reports whether its data has closed. +func (s *CodexSSEScanner) hold(line []byte) bool { + value := codexSSEValue(line) + if s.count > 0 { + s.held = append(s.held, '\n') + s.joined = append(s.joined, '\n') + s.nesting.feed([]byte{'\n'}) + } + s.held = append(s.held, line...) + s.joined = append(s.joined, value...) + s.count++ + s.size += len(line) + return s.nesting.feed(value) +} + +// release ends the collection and queues the event as one line, followed by the line that ended +// it, if any. +func (s *CodexSSEScanner) release(end ...[]byte) { + assembled := s.assemble(s.held, s.count, s.joined) + s.queue = append(s.queue, codexSSEQueuedItem{ + payload: assembled, + rawLines: s.count, + rawBytes: s.size, + }) + for _, e := range end { + s.queue = append(s.queue, codexSSEQueuedItem{ + payload: e, + rawLines: 1, + rawBytes: len(e), + }) + } + s.held, s.count, s.size, s.joined, s.nesting = nil, 0, 0, nil, codexJSONNesting{} +} + +// codexSSEValue is one data line's value: the text after "data:" without the one leading space. +func codexSSEValue(line []byte) []byte { + return bytes.TrimPrefix(line[len(codexSSEDataPrefix):], []byte(" ")) +} + +// codexSSEClosed reports whether joined event data is complete JSON. Only a value that now ends +// with a closing bracket can have become complete, so other values are not re-validated. +func codexSSEClosed(joined []byte) bool { + trimmed := bytes.TrimRight(joined, " \t\r\n") + if len(trimmed) == 0 || (trimmed[len(trimmed)-1] != '}' && trimmed[len(trimmed)-1] != ']') { + return false + } + return json.Valid(joined) +} + +// codexSSEIncompleteJSON reports whether one data line's value starts a JSON object or array that +// it does not complete. Values that cannot start JSON, such as an empty value or [DONE], are not. +func codexSSEIncompleteJSON(value []byte) bool { + if len(value) == 0 || (value[0] != '{' && value[0] != '[') || bytes.Equal(value, []byte("[DONE]")) { + return false + } + return !codexSSEClosed(value) +} + +// codexJSONNesting follows the nesting of a JSON text fed in pieces, so an event split across many +// data lines is validated once, when its outermost object or array closes, not after every piece. +type codexJSONNesting struct { + depth int + inString bool + escaped bool +} + +// feed consumes p and reports whether the outermost object or array closed within it. +func (n *codexJSONNesting) feed(p []byte) bool { + for _, c := range p { + switch { + case n.inString: + switch { + case n.escaped: + n.escaped = false + case c == '\\': + n.escaped = true + case c == '"': + n.inString = false + } + case c == '"': + n.inString = true + case c == '{' || c == '[': + n.depth++ + case c == '}' || c == ']': + n.depth-- + if n.depth <= 0 { + return true + } + } + } + return false +} + +// Bytes returns the current line. It stays valid until the next call to Scan. +func (s *CodexSSEScanner) Bytes() []byte { return s.current.payload } + +// RawLines returns the count of physical upstream lines consumed to produce the current event. +func (s *CodexSSEScanner) RawLines() int { + if s.current.rawLines == 0 { + return 1 + } + return s.current.rawLines +} + +// RawBytes returns the total bytes of physical upstream lines consumed to produce the current event. +func (s *CodexSSEScanner) RawBytes() int { + if s.current.rawBytes == 0 { + return len(s.current.payload) + } + return s.current.rawBytes +} + +// Err returns the error that stopped the scan, as bufio.Scanner does. +func (s *CodexSSEScanner) Err() error { return s.lines.Err() } + +// assemble returns the event held as lines data lines: compacted onto one line when its data is +// valid JSON, otherwise as held. +func (s *CodexSSEScanner) assemble(held []byte, lines int, joined []byte) []byte { + if lines == 1 { + s.diagnose(held, lines, bytes.TrimSpace(joined)) + return held + } + if json.Valid(joined) { + var compacted bytes.Buffer + compacted.Grow(len(codexSSEDataPrefix) + 1 + len(joined)) + compacted.Write(codexSSEDataPrefix) + compacted.WriteByte(' ') + if errCompact := json.Compact(&compacted, joined); errCompact == nil { + return compacted.Bytes() + } + } + s.diagnose(held, lines, joined) + return held +} + +// diagnose logs one bounded warning for the first event in the stream whose data is not valid +// JSON, so a broken upstream cannot turn every event into a log line. It describes how many data +// lines carried it, each line's length and whether it is valid on its own, and where the joined +// data fails to parse. It never logs data bytes. +func (s *CodexSSEScanner) diagnose(held []byte, lines int, data []byte) { + if s.warned || len(data) == 0 || bytes.Equal(data, []byte("[DONE]")) || json.Valid(data) { + return + } + s.warned = true + offset := int64(-1) + var syntaxErr *json.SyntaxError + if errors.As(json.Unmarshal(data, new(json.RawMessage)), &syntaxErr) { + offset = syntaxErr.Offset + } + shown := min(lines, codexSSEDiagnosticLines) + lengths := make([]string, shown) + valid := make([]string, shown) + for i := range shown { + var line []byte + line, held, _ = bytes.Cut(held, []byte("\n")) + value := bytes.TrimSpace(line[len(codexSSEDataPrefix):]) + lengths[i] = strconv.Itoa(len(value)) + valid[i] = strconv.FormatBool(json.Valid(value)) + } + more := "" + if lines > shown { + more = ",+" + strconv.Itoa(lines-shown) + } + LogWithRequestID(s.ctx).WithFields(log.Fields{ + "data_lines": lines, + "line_bytes": strings.Join(lengths, ",") + more, + "lines_valid": strings.Join(valid, ",") + more, + "data_bytes": len(data), + "json_error_offset": offset, + }).Warn("codex executor: upstream SSE event data is not valid JSON") +} diff --git a/internal/runtime/executor/helps/codex_tool_schema.go b/internal/runtime/executor/helps/codex_tool_schema.go index 7742302f..d97e5e51 100644 --- a/internal/runtime/executor/helps/codex_tool_schema.go +++ b/internal/runtime/executor/helps/codex_tool_schema.go @@ -3,9 +3,11 @@ package helps import ( "bytes" "encoding/json" + "errors" "io" "math/big" "net/http" + "strconv" "strings" log "github.com/sirupsen/logrus" @@ -22,6 +24,15 @@ const ( codexComplexUnionBranchThreshold = 8 ) +// codexNumericBoundKeywords lists the JSON Schema numeric bound keywords that +// the Codex backend rejects with HTTP 500 when set to an integer outside int64. +var codexNumericBoundKeywords = [...]string{ + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", +} + // IsCodexUserAgent reports whether headers contain a User-Agent indicating a Codex client. func IsCodexUserAgent(headers http.Header) bool { return toolschema.IsCodexUserAgent(headers) @@ -151,16 +162,18 @@ func normalizeCodexParameters(params gjson.Result) ([]byte, bool) { // stripIncompatiblePatternsFromJSON recursively removes pattern attributes that strict // upstream validators reject: unsupported Unicode property escapes (\p{...} / \P{...}) -// and the octal NUL escape (\0). See util.HasUnsupportedUnicodePropertyEscape for the -// predicate and the upstream errors behind each one. +// and the octal NUL escape (\0), as well as integer numeric bounds (minimum, maximum, +// exclusiveMinimum, exclusiveMaximum) outside the signed 64-bit integer range (#6579). +// See util.HasUnsupportedUnicodePropertyEscape for the regex predicate and upstream errors. // It is schema-aware: only subschemas under known JSON Schema keyword locations are visited, -// preventing accidental deletion of 'pattern' keys inside user data (e.g. description, default, enum). +// preventing accidental deletion of keys inside user data (e.g. description, default, enum). func stripIncompatiblePatternsFromJSON(raw []byte) ([]byte, bool) { rawStr := string(raw) - // The fast path avoids parsing when no candidate escape is present. Patterns - // arrive JSON-escaped, so a literal backslash before '0' is `\\0` in the raw bytes. + // The fast path avoids parsing when no candidate escape or numeric bound is present. + // Patterns arrive JSON-escaped, so a literal backslash before '0' is `\\0` in the raw bytes. + // "inimum" and "aximum" match minimum, maximum, exclusiveMinimum, and exclusiveMaximum. if !strings.Contains(rawStr, `\p{`) && !strings.Contains(rawStr, `\P{`) && !strings.Contains(rawStr, `\u`) && - !strings.Contains(rawStr, `\\0`) { + !strings.Contains(rawStr, `\\0`) && !strings.Contains(rawStr, "inimum") && !strings.Contains(rawStr, "aximum") { return raw, false } var root any @@ -186,6 +199,92 @@ func stripIncompatiblePatternsFromJSON(raw []byte) ([]byte, bool) { return bytes.TrimSpace(buf.Bytes()), true } +func isNumericBoundOutOfInt64(number json.Number) bool { + raw := strings.TrimSpace(number.String()) + if raw == "" { + return false + } + if !strings.ContainsAny(raw, ".eE") { + _, errParse := strconv.ParseInt(raw, 10, 64) + return errors.Is(errParse, strconv.ErrRange) + } + + s := raw + negative := false + if strings.HasPrefix(s, "-") { + negative = true + s = s[1:] + } else if strings.HasPrefix(s, "+") { + s = s[1:] + } + + mantissa := s + var exp int64 + if idx := strings.IndexAny(s, "eE"); idx >= 0 { + mantissa = s[:idx] + expStr := s[idx+1:] + parsedExp, errExp := strconv.ParseInt(expStr, 10, 64) + if errExp != nil { + if errors.Is(errExp, strconv.ErrRange) { + return !strings.HasPrefix(expStr, "-") && strings.Trim(mantissa, "0.") != "" + } + return false + } + exp = parsedExp + } + + intPart := mantissa + fracPart := "" + if dotIdx := strings.IndexByte(mantissa, '.'); dotIdx >= 0 { + intPart = mantissa[:dotIdx] + fracPart = mantissa[dotIdx+1:] + if strings.IndexByte(fracPart, '.') >= 0 { + return false + } + } + if intPart == "" && fracPart == "" { + return false + } + for i := 0; i < len(intPart); i++ { + if intPart[i] < '0' || intPart[i] > '9' { + return false + } + } + for i := 0; i < len(fracPart); i++ { + if fracPart[i] < '0' || fracPart[i] > '9' { + return false + } + } + + allDigits := intPart + fracPart + sig := strings.TrimLeft(allDigits, "0") + if sig == "" { + return false + } + if exp < -int64(len(raw)) { + return false + } + if exp > int64(len(raw))+20 { + return true + } + trimmed := strings.TrimRight(sig, "0") + trailingZeros := int64(len(sig) - len(trimmed)) + effectiveExp := exp - int64(len(fracPart)) + trailingZeros + if effectiveExp < 0 { + return false + } + if effectiveExp > 19 || int64(len(trimmed))+effectiveExp > 19 { + return true + } + + intStr := trimmed + strings.Repeat("0", int(effectiveExp)) + if negative { + intStr = "-" + intStr + } + _, errParse := strconv.ParseInt(intStr, 10, 64) + return errors.Is(errParse, strconv.ErrRange) +} + func stripIncompatiblePatterns(v any) bool { changed := false switch schema := v.(type) { @@ -194,6 +293,12 @@ func stripIncompatiblePatterns(v any) bool { delete(schema, "pattern") changed = true } + for _, boundKey := range codexNumericBoundKeywords { + if boundVal, ok := schema[boundKey].(json.Number); ok && isNumericBoundOutOfInt64(boundVal) { + delete(schema, boundKey) + changed = true + } + } // Inspect regex keys under patternProperties if patternProps, ok := schema["patternProperties"].(map[string]any); ok { diff --git a/internal/runtime/executor/helps/devin_models.go b/internal/runtime/executor/helps/devin_models.go index cdd41b95..b9701fc0 100644 --- a/internal/runtime/executor/helps/devin_models.go +++ b/internal/runtime/executor/helps/devin_models.go @@ -33,6 +33,13 @@ var knownDevinSuffixes = []string{ "-thinking", "-max-1m", "-none-1m", + "-minimal-lightning", + "-low-lightning", + "-medium-lightning", + "-high-lightning", + "-xhigh-lightning", + "-max-lightning", + "-none-lightning", "_none", "_minimal", "_low", @@ -211,12 +218,28 @@ func ResolveDevinChatModelUID(rawModel string, thinkingLevel string, budgetToken return canonicalBase } - // 8. If model has no thinking levels defined in catalog, treat as bare model + // 8. Lightning tier: upstream UIDs keep the effort before the suffix + // (swe-2-high-lightning), unlike every other model which appends effort. + if strings.HasSuffix(canonicalBase, "-lightning") { + stem := strings.TrimSuffix(canonicalBase, "-lightning") + if HasDevinEffortSuffix(stem) { + // Already a full upstream UID such as swe-2-high-lightning + return canonicalBase + } + if len(allowedLevels) == 0 { + return canonicalBase + } + defaultEffort := selectDefaultDevinEffort(stem, allowedLevels) + clamped := clampEffort(effort, allowedLevels, defaultEffort) + return stem + "-" + clamped + "-lightning" + } + + // 9. If model has no thinking levels defined in catalog, treat as bare model if len(allowedLevels) == 0 { return canonicalBase } - // 9. Model has thinking levels: determine default effort and clamp + // 10. Model has thinking levels: determine default effort and clamp defaultEffort := selectDefaultDevinEffort(canonicalBase, allowedLevels) clamped := clampEffort(effort, allowedLevels, defaultEffort) return canonicalBase + "-" + clamped diff --git a/internal/runtime/executor/helps/devin_payload.go b/internal/runtime/executor/helps/devin_payload.go new file mode 100644 index 00000000..19d2b75f --- /dev/null +++ b/internal/runtime/executor/helps/devin_payload.go @@ -0,0 +1,227 @@ +package helps + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "math" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "google.golang.org/protobuf/encoding/protowire" +) + +// Devin payload rules address the native business fields, never credentials or +// Connect framing. Encoding after the barrier does not inject defaults or sanitize. +type devinPayloadField struct { + name string + kind protowire.Type + repeated bool + binary bool + jsonValue bool + children map[protowire.Number]devinPayloadField +} + +var devinPayloadFields = map[protowire.Number]devinPayloadField{ + 2: {name: "system_prompt", kind: protowire.BytesType}, + 3: {name: "prompts", kind: protowire.BytesType, repeated: true, children: map[protowire.Number]devinPayloadField{ + 1: {name: "id", kind: protowire.BytesType}, + 2: {name: "source", kind: protowire.VarintType}, + 3: {name: "content", kind: protowire.BytesType}, + 6: {name: "tool_calls", kind: protowire.BytesType, repeated: true, children: map[protowire.Number]devinPayloadField{ + 1: {name: "id", kind: protowire.BytesType}, + 2: {name: "name", kind: protowire.BytesType}, + 3: {name: "arguments", kind: protowire.BytesType}, + }}, + 7: {name: "tool_call_id", kind: protowire.BytesType}, + 10: {name: "images", kind: protowire.BytesType, repeated: true, children: map[protowire.Number]devinPayloadField{ + 1: {name: "data", kind: protowire.BytesType}, + 2: {name: "mime_type", kind: protowire.BytesType}, + }}, + 11: {name: "thinking", kind: protowire.BytesType}, + 12: {name: "signature", kind: protowire.BytesType, binary: true}, + 18: {name: "signature_type", kind: protowire.BytesType}, + }}, + 8: {name: "completion_config", kind: protowire.BytesType, children: map[protowire.Number]devinPayloadField{ + 1: {name: "enabled", kind: protowire.VarintType}, + 2: {name: "max_tokens", kind: protowire.VarintType}, + 3: {name: "parameter_3", kind: protowire.VarintType}, + 5: {name: "temperature", kind: protowire.Fixed64Type}, + 7: {name: "top_k", kind: protowire.VarintType}, + 8: {name: "top_p", kind: protowire.Fixed64Type}, + }}, + 10: {name: "tools", kind: protowire.BytesType, repeated: true, children: map[protowire.Number]devinPayloadField{ + 1: {name: "name", kind: protowire.BytesType}, + 2: {name: "description", kind: protowire.BytesType}, + 3: {name: "parameters", kind: protowire.BytesType, jsonValue: true}, + }}, + 16: {name: "cascade_id", kind: protowire.BytesType}, + 21: {name: "model", kind: protowire.BytesType}, +} + +// DevinPayloadDefaultsSource maps caller field presence to the native business +// view so defaults can replace built-in values without replacing caller values. +func DevinPayloadDefaultsSource(native, interactions []byte) []byte { + out := []byte(`{}`) + for target, sources := range map[string][]string{ + "model": {"model"}, + "system_prompt": {"system_instruction", "systemInstruction"}, + "prompts": {"input"}, + "tools": {"tools"}, + "cascade_id": {"session_id", "sessionId", "conversation_id", "previous_interaction_id"}, + "completion_config.max_tokens": {"generation_config.max_output_tokens", "generationConfig.max_output_tokens"}, + "completion_config.temperature": {"generation_config.temperature", "generationConfig.temperature", "temperature"}, + "completion_config.top_k": {"generation_config.top_k", "generationConfig.top_k"}, + "completion_config.top_p": {"generation_config.top_p", "generationConfig.top_p"}, + } { + var value gjson.Result + for _, source := range sources { + value = gjson.GetBytes(interactions, source) + if value.Exists() { + break + } + } + if !value.Exists() { + continue + } + if target == "prompts" || target == "tools" { + value = gjson.GetBytes(native, target) + } + if value.Exists() { + out, _ = sjson.SetRawBytes(out, target, []byte(value.Raw)) + } + } + return out +} + +// FinalizeDevinPayload projects the already-normalized protobuf business fields +// to JSON for user rules, then replaces only those fields in the wire message. +func FinalizeDevinPayload(wire []byte, finalize PayloadFinalizer) ([]byte, []byte, error) { + view, errDecode := decodeDevinPayload(wire, devinPayloadFields) + if errDecode != nil { + return nil, nil, errDecode + } + body, errMarshal := json.Marshal(view) + if errMarshal != nil { + return nil, nil, errMarshal + } + configured := finalize(body) + if bytes.Equal(configured, body) { + return wire, configured, nil + } + body = configured + out, errEncode := encodeDevinPayload(wire, gjson.ParseBytes(body), devinPayloadFields) + return out, body, errEncode +} + +func decodeDevinPayload(wire []byte, fields map[protowire.Number]devinPayloadField) (map[string]any, error) { + out := make(map[string]any) + for len(wire) > 0 { + num, kind, tagLen := protowire.ConsumeTag(wire) + if tagLen < 0 { + return nil, protowire.ParseError(tagLen) + } + wire = wire[tagLen:] + n := protowire.ConsumeFieldValue(num, kind, wire) + if n < 0 { + return nil, protowire.ParseError(n) + } + field, ok := fields[num] + if ok { + var value any + switch kind { + case protowire.BytesType: + data, _ := protowire.ConsumeBytes(wire) + switch { + case field.children != nil: + decoded, errDecode := decodeDevinPayload(data, field.children) + if errDecode != nil { + return nil, errDecode + } + value = decoded + case field.binary: + value = base64.StdEncoding.EncodeToString(data) + case field.jsonValue && json.Valid(data): + value = json.RawMessage(data) + default: + value = string(data) + } + case protowire.VarintType: + value, _ = protowire.ConsumeVarint(wire) + case protowire.Fixed64Type: + bits, _ := protowire.ConsumeFixed64(wire) + value = math.Float64frombits(bits) + } + if field.repeated { + values, _ := out[field.name].([]any) + out[field.name] = append(values, value) + } else { + out[field.name] = value + } + } + wire = wire[n:] + } + return out, nil +} + +func encodeDevinPayload(original []byte, body gjson.Result, fields map[protowire.Number]devinPayloadField) ([]byte, error) { + var out []byte + // Credentials, device metadata, thread ordinals, and protocol flags stay opaque. + for len(original) > 0 { + num, kind, tagLen := protowire.ConsumeTag(original) + if tagLen < 0 { + return nil, protowire.ParseError(tagLen) + } + n := protowire.ConsumeFieldValue(num, kind, original[tagLen:]) + if n < 0 { + return nil, protowire.ParseError(n) + } + if _, ok := fields[num]; !ok { + out = append(out, original[:tagLen+n]...) + } + original = original[tagLen+n:] + } + for num := protowire.Number(1); num <= 21; num++ { + field, ok := fields[num] + if !ok { + continue + } + value := body.Get(field.name) + if !value.Exists() || value.Type == gjson.Null { + continue + } + values := []gjson.Result{value} + if field.repeated { + values = value.Array() + } + for _, item := range values { + out = protowire.AppendTag(out, num, field.kind) + switch field.kind { + case protowire.BytesType: + data := []byte(item.String()) + if field.children != nil { + var errEncode error + data, errEncode = encodeDevinPayload(nil, item, field.children) + if errEncode != nil { + return nil, errEncode + } + } else if field.binary { + var errDecode error + data, errDecode = base64.StdEncoding.DecodeString(item.String()) + if errDecode != nil { + return nil, fmt.Errorf("Devin payload %s: %w", field.name, errDecode) + } + } else if field.jsonValue { + data = []byte(item.Raw) + } + out = protowire.AppendBytes(out, data) + case protowire.VarintType: + out = protowire.AppendVarint(out, item.Uint()) + case protowire.Fixed64Type: + out = protowire.AppendFixed64(out, math.Float64bits(item.Float())) + } + } + } + return out, nil +} diff --git a/internal/runtime/executor/helps/devin_quota.go b/internal/runtime/executor/helps/devin_quota.go new file mode 100644 index 00000000..d6153e86 --- /dev/null +++ b/internal/runtime/executor/helps/devin_quota.go @@ -0,0 +1,63 @@ +package helps + +import ( + "strings" + "time" +) + +// DevinQuotaPeriod identifies an account-wide Devin usage quota window. +type DevinQuotaPeriod int + +const ( + // DevinQuotaPeriodNone means the error is not an account-wide + // daily/weekly usage quota exhaustion. + DevinQuotaPeriodNone DevinQuotaPeriod = iota + // DevinQuotaPeriodDaily marks daily usage quota exhaustion. + DevinQuotaPeriodDaily + // DevinQuotaPeriodWeekly marks weekly usage quota exhaustion. + DevinQuotaPeriodWeekly +) + +// devinQuotaResetMaxDelay bounds how far in the future an observed quota +// reset timestamp may lie before it is treated as unusable. A weekly window +// plus slack covers a just-rolled-over observation; anything further out is +// implausible for a daily/weekly quota and must not pin the credential. +const devinQuotaResetMaxDelay = 8 * 24 * time.Hour + +// DevinUsageQuotaExhaustionPeriod reports whether the upstream error +// message explicitly says the account-wide daily or weekly usage quota has +// been exhausted (issue #6690). Matching is deliberately narrow: the period +// word, "quota", and "exhausted" must all be present, so monthly ACU +// messages, generic rate limits, and transient capacity errors never match +// and stay on the short per-model backoff path. If the message somehow +// names both periods, weekly wins because it is the longer window. +func DevinUsageQuotaExhaustionPeriod(message string) DevinQuotaPeriod { + lower := strings.ToLower(message) + if !strings.Contains(lower, "quota") || !strings.Contains(lower, "exhausted") { + return DevinQuotaPeriodNone + } + if strings.Contains(lower, "weekly") { + return DevinQuotaPeriodWeekly + } + if strings.Contains(lower, "daily") { + return DevinQuotaPeriodDaily + } + return DevinQuotaPeriodNone +} + +// DevinQuotaRetryAfter converts an observed quota reset timestamp (stored +// in auth Quota.Signals by the executor's Refresh path) into a retry delay. +// It returns nil unless the signal parses and is strictly in the future +// within a plausible daily/weekly horizon, in which case the caller should +// leave the cooldown duration to the conductor's credential backoff. +func DevinQuotaRetryAfter(resetAtSignal string, now time.Time) *time.Duration { + resetAt, errParse := time.Parse(time.RFC3339, strings.TrimSpace(resetAtSignal)) + if errParse != nil { + return nil + } + delay := resetAt.Sub(now) + if delay <= 0 || delay > devinQuotaResetMaxDelay { + return nil + } + return &delay +} diff --git a/internal/runtime/executor/helps/devin_user_turn.go b/internal/runtime/executor/helps/devin_user_turn.go new file mode 100644 index 00000000..10891755 --- /dev/null +++ b/internal/runtime/executor/helps/devin_user_turn.go @@ -0,0 +1,55 @@ +package helps + +import ( + "strings" + + translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common" +) + +// CheckDevinUserTurns applies the shared user-turn policy to the parsed prompts. +// Devin can only receive inline images, so a user prompt that dropped media it +// cannot fetch or carry (a remote uri, audio, video, a document) is refused with +// an unsupported content part error when its whole user turn has nothing else to +// send. A tool result or real text in the same turn keeps the turn alive; the +// emptied prompt is then removed so that no empty user prompt, and no placeholder +// standing in for the media, reaches upstream. +func CheckDevinUserTurns(prompts []DevinPrompt) ([]DevinPrompt, error) { + var run translatorcommon.UserRun + for _, prompt := range prompts { + switch { + case prompt.Source == 2: + run.End() + case prompt.Source == 1 && !prompt.IsOrphanedTool: + if prompt.DroppedPart != "" { + run.Drop(prompt.DroppedPart) + } + if devinPromptHasContent(prompt) { + run.Add() + } + default: + // A tool result is content the model reads, so it keeps the surrounding user turn. + run.Add() + } + } + run.End() + if errRun := run.Err(); errRun != nil { + return nil, errRun + } + + kept := make([]DevinPrompt, 0, len(prompts)) + for _, prompt := range prompts { + if prompt.Source == 1 && !prompt.IsOrphanedTool && prompt.DroppedPart != "" && !devinPromptHasContent(prompt) { + continue + } + kept = append(kept, prompt) + } + if len(kept) == len(prompts) { + return prompts, nil + } + return kept, nil +} + +// devinPromptHasContent reports whether a user prompt carries non-blank text or an image. +func devinPromptHasContent(prompt DevinPrompt) bool { + return strings.TrimSpace(prompt.Content) != "" || len(prompt.Images) > 0 +} diff --git a/internal/runtime/executor/helps/devin_wire.go b/internal/runtime/executor/helps/devin_wire.go index 2d708c5f..f8274cee 100644 --- a/internal/runtime/executor/helps/devin_wire.go +++ b/internal/runtime/executor/helps/devin_wire.go @@ -92,6 +92,7 @@ type DevinPrompt struct { ToolCallID string // For source=4 (tool result) OriginalToolCallID string // Retained when downgraded from source=4 to source=1 IsOrphanedTool bool // Explicit flag marking downgraded tool results + DroppedPart string // Type of a user media part Devin cannot send; never put on the wire Thinking string Signature []byte SignatureType string diff --git a/internal/runtime/executor/helps/model_capabilities.go b/internal/runtime/executor/helps/model_capabilities.go index df2d06a6..d1785d6f 100644 --- a/internal/runtime/executor/helps/model_capabilities.go +++ b/internal/runtime/executor/helps/model_capabilities.go @@ -1,6 +1,7 @@ package helps import ( + "github.com/router-for-me/CLIProxyAPI/v8/internal/registry" "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" @@ -16,6 +17,13 @@ func APIKeyModelIsCompat(req cliproxyexecutor.Request) bool { // ApplyRequestThinking preserves the registry lookup path unless the auth // manager bound authoritative model capabilities to this execution attempt. func ApplyRequestThinking(body []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, fromFormat, toFormat, provider string, normalizedUpdatesChanged ...bool) ([]byte, error) { + return ApplyRequestThinkingWithModelInfo(body, req, opts, fromFormat, toFormat, provider, nil, normalizedUpdatesChanged...) +} + +// ApplyRequestThinkingWithModelInfo applies request thinking and falls back to +// fallbackModelInfo when neither request metadata nor the global registry +// resolves a model definition. +func ApplyRequestThinkingWithModelInfo(body []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, fromFormat, toFormat, provider string, fallbackModelInfo *registry.ModelInfo, normalizedUpdatesChanged ...bool) ([]byte, error) { originalSource := opts.OriginalRequest if len(originalSource) == 0 { originalSource = req.Payload @@ -28,5 +36,5 @@ func ApplyRequestThinking(body []byte, req cliproxyexecutor.Request, opts clipro if modelInfo, ok := cliproxyauth.ResolvedModelInfo(req); ok { return thinking.ApplyThinkingWithModelInfoAndSummary(body, source, req.Model, fromFormat, toFormat, provider, modelInfo, summaryConfig, normalizedUpdatesChanged...) } - return thinking.ApplyThinkingWithSourceAndSummary(body, source, req.Model, fromFormat, toFormat, provider, summaryConfig, normalizedUpdatesChanged...) + return thinking.ApplyThinkingWithFallbackModelInfoAndSummary(body, source, req.Model, fromFormat, toFormat, provider, fallbackModelInfo, summaryConfig, normalizedUpdatesChanged...) } diff --git a/internal/runtime/executor/helps/payload_finalizer.go b/internal/runtime/executor/helps/payload_finalizer.go new file mode 100644 index 00000000..68bb79c0 --- /dev/null +++ b/internal/runtime/executor/helps/payload_finalizer.go @@ -0,0 +1,38 @@ +package helps + +import ( + "context" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" +) + +// PayloadFinalizer applies user rules to a fully prepared business payload. +// Each rebuilt attempt must start from the unconfigured body, not a previous result. +type PayloadFinalizer func([]byte) []byte + +// NewPayloadFinalizer snapshots matching context before built-in request mutations. +func NewPayloadFinalizer(cfg *config.Config, executor, model, protocol, root string, original []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) PayloadFinalizer { + requestedModel := PayloadRequestedModel(opts, req.Model) + requestPath := PayloadRequestPath(opts) + headers := opts.Headers.Clone() + source := append([]byte(nil), original...) + return func(body []byte) []byte { + return ApplyPayloadConfigWithRequestForExecutor(cfg, executor, model, protocol, opts.SourceFormat.String(), root, body, source, requestedModel, requestPath, headers) + } +} + +type payloadFinalizerKey struct{} + +// WithPayloadFinalizer carries the final barrier through shared request builders. +func WithPayloadFinalizer(ctx context.Context, finalize PayloadFinalizer) context.Context { + return context.WithValue(ctx, payloadFinalizerKey{}, finalize) +} + +// FinalizePayload runs immediately before serialization or transport framing. +func FinalizePayload(ctx context.Context, body []byte) []byte { + if finalize, ok := ctx.Value(payloadFinalizerKey{}).(PayloadFinalizer); ok && finalize != nil { + return finalize(body) + } + return body +} diff --git a/internal/runtime/executor/helps/payload_media.go b/internal/runtime/executor/helps/payload_media.go new file mode 100644 index 00000000..47bbd95c --- /dev/null +++ b/internal/runtime/executor/helps/payload_media.go @@ -0,0 +1,120 @@ +package helps + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "mime" + "mime/multipart" + "net/textproto" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + "github.com/tidwall/gjson" +) + +// ApplyMediaPayloadConfig exposes multipart fields as JSON business values. File +// parts use {filename, content_type, data} with base64 data; framing stays opaque. +func ApplyMediaPayloadConfig(cfg *config.Config, executor, model, protocol string, body []byte, contentType string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) ([]byte, string, error) { + view, multipartBody, errDecode := mediaPayloadJSON(body, contentType) + if errDecode != nil { + return nil, "", errDecode + } + original := req.Payload + if len(opts.OriginalRequest) > 0 { + original = opts.OriginalRequest + } + originalView, _, errOriginal := mediaPayloadJSON(original, opts.Headers.Get("Content-Type")) + if errOriginal != nil { + return nil, "", errOriginal + } + out := NewPayloadFinalizer(cfg, executor, model, protocol, "", originalView, req, opts)(view) + if !multipartBody || bytes.Equal(out, view) { + return outOrBody(out, body, multipartBody), contentType, nil + } + var buffer bytes.Buffer + writer := multipart.NewWriter(&buffer) + for key, value := range gjson.ParseBytes(out).Map() { + values := []gjson.Result{value} + if value.IsArray() { + values = value.Array() + } + for _, item := range values { + if item.IsObject() && item.Get("filename").Exists() { + data, errData := base64.StdEncoding.DecodeString(item.Get("data").String()) + if errData != nil { + return nil, "", fmt.Errorf("multipart payload %s: %w", key, errData) + } + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", mime.FormatMediaType("form-data", map[string]string{"name": key, "filename": item.Get("filename").String()})) + header.Set("Content-Type", item.Get("content_type").String()) + part, errPart := writer.CreatePart(header) + if errPart != nil { + return nil, "", errPart + } + if _, errWrite := part.Write(data); errWrite != nil { + return nil, "", errWrite + } + } else if errWrite := writer.WriteField(key, item.String()); errWrite != nil { + return nil, "", errWrite + } + } + } + if errClose := writer.Close(); errClose != nil { + return nil, "", errClose + } + return buffer.Bytes(), writer.FormDataContentType(), nil +} + +func outOrBody(out, body []byte, multipartBody bool) []byte { + if multipartBody { + return body + } + return out +} + +func mediaPayloadJSON(body []byte, contentType string) ([]byte, bool, error) { + if json.Valid(body) { + return body, false, nil + } + mediaType, params, errParse := mime.ParseMediaType(contentType) + if errParse != nil || mediaType != "multipart/form-data" { + return body, false, nil + } + reader := multipart.NewReader(bytes.NewReader(body), params["boundary"]) + fields := make(map[string]any) + for { + part, errNext := reader.NextPart() + if errNext == io.EOF { + break + } + if errNext != nil { + return nil, false, errNext + } + data, errRead := io.ReadAll(part) + if errClose := part.Close(); errClose != nil { + return nil, false, errClose + } + if errRead != nil { + return nil, false, errRead + } + var value any = string(data) + if part.FileName() != "" { + value = map[string]string{"filename": part.FileName(), "content_type": part.Header.Get("Content-Type"), "data": base64.StdEncoding.EncodeToString(data)} + } + key := part.FormName() + if previous, ok := fields[key]; ok { + values, isArray := previous.([]any) + if !isArray { + values = []any{previous} + } + fields[key] = append(values, value) + } else { + fields[key] = value + } + } + view, errMarshal := json.Marshal(fields) + return view, true, errMarshal +} diff --git a/internal/runtime/executor/helps/plugin_executor_usage.go b/internal/runtime/executor/helps/plugin_executor_usage.go index ed55e1d2..27dcf04a 100644 --- a/internal/runtime/executor/helps/plugin_executor_usage.go +++ b/internal/runtime/executor/helps/plugin_executor_usage.go @@ -184,6 +184,56 @@ func MergeStreamUsageDetail(existing, update usage.Detail) usage.Detail { if merged.ResponseServiceTier == "" { merged.ResponseServiceTier = existing.ResponseServiceTier } + + outputExcludesReasoning, knownOutput := detailOutputExcludesReasoning(update) + if !knownOutput { + outputExcludesReasoning, knownOutput = detailOutputExcludesReasoning(existing) + } + inputIncludesCache, knownInput := detailInputIncludesCache(update) + if !knownInput { + inputIncludesCache, knownInput = detailInputIncludesCache(existing) + } + + if outputExcludesReasoning || (inputIncludesCache && !knownOutput) { + calculatedTotal, okTotal := safeUsageTokenSum(merged.InputTokens, merged.OutputTokens, merged.ReasoningTokens) + if !okTotal { + merged.TokenBreakdown = invalidUsageTokenBreakdown(merged.TotalTokens) + return merged + } + if merged.TotalTokens == 0 || merged.TotalTokens < calculatedTotal { + merged.TotalTokens = calculatedTotal + } + merged.TokenBreakdown = usage.NewSeparateReasoningTokenBreakdown( + merged.InputTokens, + merged.CacheReadTokens, + merged.CacheCreationTokens, + merged.OutputTokens, + merged.ReasoningTokens, + merged.TotalTokens, + ) + return merged + } + + if inputIncludesCache { + calculatedTotal, okTotal := safeUsageTokenSum(merged.InputTokens, merged.OutputTokens) + if !okTotal { + merged.TokenBreakdown = invalidUsageTokenBreakdown(merged.TotalTokens) + return merged + } + if merged.TotalTokens == 0 || merged.TotalTokens < calculatedTotal { + merged.TotalTokens = calculatedTotal + } + merged.TokenBreakdown = usage.NewSubsetTokenBreakdown( + merged.InputTokens, + merged.CacheReadTokens, + merged.CacheCreationTokens, + merged.OutputTokens, + merged.ReasoningTokens, + merged.TotalTokens, + ) + return merged + } + cached := merged.CacheReadTokens + merged.CacheCreationTokens if cached == 0 { cached = merged.CachedTokens @@ -207,6 +257,56 @@ func MergeStreamUsageDetail(existing, update usage.Detail) usage.Detail { return merged } +func detailInputIncludesCache(detail usage.Detail) (bool, bool) { + cache, okCache := safeUsageTokenSum(detail.CacheReadTokens, detail.CacheCreationTokens) + if !okCache || (cache == 0 && detail.CachedTokens < 0) { + return false, false + } + if cache == 0 { + cache = detail.CachedTokens + } + if cache <= 0 || !detail.TokenBreakdown.Valid() || detail.TokenBreakdown.Quality != usage.TokenAccountingQualityComplete { + return false, false + } + if detail.TokenBreakdown.Input.TotalTokens == detail.InputTokens && + detail.TokenBreakdown.Input.UncachedTokens == detail.InputTokens-cache { + return true, true + } + if independentTotal, okTotal := safeUsageTokenSum(detail.InputTokens, cache); okTotal && + detail.TokenBreakdown.Input.TotalTokens == independentTotal && + detail.TokenBreakdown.Input.UncachedTokens == detail.InputTokens { + return false, true + } + return false, false +} + +func detailOutputExcludesReasoning(detail usage.Detail) (bool, bool) { + if detail.ReasoningTokens <= 0 || !detail.TokenBreakdown.Valid() { + return false, false + } + if detail.TokenBreakdown.Quality != usage.TokenAccountingQualityComplete || + detail.TokenBreakdown.Output.ReasoningTokens != detail.ReasoningTokens { + return false, false + } + if detail.OutputTokens > 0 { + if separateTotal, okTotal := safeUsageTokenSum(detail.OutputTokens, detail.ReasoningTokens); okTotal && + detail.TokenBreakdown.Output.TotalTokens == separateTotal && + detail.TokenBreakdown.Output.NonReasoningTokens == detail.OutputTokens { + return true, true + } + if detail.TokenBreakdown.Output.TotalTokens == detail.OutputTokens && + detail.TokenBreakdown.Output.NonReasoningTokens == detail.OutputTokens-detail.ReasoningTokens { + return false, true + } + return false, false + } + if detail.OutputTokens == 0 && detail.TokenBreakdown.Output.TotalTokens == detail.ReasoningTokens && + detail.TokenBreakdown.Output.NonReasoningTokens == 0 && detail.TotalTokens == detail.TokenBreakdown.TotalTokens { + return true, true + } + return false, false +} + // IterateStreamLines splits payload by newline and invokes fn for non-empty lines. func IterateStreamLines(payload []byte, fn func(line []byte)) { for _, line := range bytes.Split(payload, []byte("\n")) { diff --git a/internal/runtime/executor/helps/proxy_helpers.go b/internal/runtime/executor/helps/proxy_helpers.go index 0c5f6c95..d98f7d49 100644 --- a/internal/runtime/executor/helps/proxy_helpers.go +++ b/internal/runtime/executor/helps/proxy_helpers.go @@ -10,6 +10,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v8/internal/config" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/httptransport" "github.com/router-for-me/CLIProxyAPI/v8/sdk/proxyutil" log "github.com/sirupsen/logrus" ) @@ -28,7 +29,8 @@ import ( // // Returns: // - *http.Client: An HTTP client with configured proxy or transport -func NewProxyAwareHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client { +func NewProxyAwareHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) (clientOut *http.Client) { + defer func() { clientOut = httptransport.Decorate(ctx, clientOut) }() httpClient := &http.Client{} if timeout > 0 { httpClient.Timeout = timeout @@ -60,7 +62,8 @@ var devinTransportCache = NewTransportCache[string](DefaultTransportCacheCapacit // NewDevinHTTPClient creates an HTTP client customized for Devin Connect-RPC upstream. // Suppresses automatic Accept-Encoding: gzip while preserving connection reuse across requests. -func NewDevinHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client { +func NewDevinHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) (clientOut *http.Client) { + defer func() { clientOut = httptransport.Decorate(ctx, clientOut) }() // A request proxy replaces both the injected round tripper and credential/global proxy. // Respect explicitly injected context RoundTripper only when no request override is set. if cliproxyexecutor.RequestProxyURL(ctx) == "" && ctx != nil { diff --git a/internal/runtime/executor/helps/response_decode.go b/internal/runtime/executor/helps/response_decode.go new file mode 100644 index 00000000..09373dac --- /dev/null +++ b/internal/runtime/executor/helps/response_decode.go @@ -0,0 +1,181 @@ +package helps + +import ( + "bufio" + "compress/flate" + "compress/gzip" + "compress/zlib" + "fmt" + "io" + "net/http" + "strings" + + "github.com/andybalholm/brotli" + "github.com/klauspost/compress/zstd" + log "github.com/sirupsen/logrus" +) + +type compositeReadCloser struct { + io.Reader + closers []func() error +} + +func (c *compositeReadCloser) Close() error { + var firstErr error + for i := range c.closers { + if c.closers[i] == nil { + continue + } + if errClose := c.closers[i](); errClose != nil && firstErr == nil { + firstErr = errClose + } + } + return firstErr +} + +// peekableBody wraps a bufio.Reader around the original ReadCloser so that +// magic bytes can be inspected without consuming them from the stream. +type peekableBody struct { + *bufio.Reader + closer io.Closer +} + +func (p *peekableBody) Close() error { + return p.closer.Close() +} + +// DecodeResponseBody decodes declared encodings in reverse order, or sniffs gzip/zstd without a header. +// Closing the returned body also closes all decoders and the original body, including on setup errors. +func DecodeResponseBody(body io.ReadCloser, header http.Header) (io.ReadCloser, error) { + contentEncoding := strings.Join(header.Values("Content-Encoding"), ",") + if body == nil { + return nil, fmt.Errorf("response body is nil") + } + if contentEncoding == "" { + // No Content-Encoding header. Attempt best-effort magic-byte detection to + // handle misbehaving upstreams that compress without setting the header. + // Only gzip (1f 8b) and zstd (28 b5 2f fd) have reliable magic sequences; + // br and deflate have none and are left as-is. + // The bufio wrapper preserves unread bytes so callers always see the full + // stream regardless of whether decompression was applied. + pb := &peekableBody{Reader: bufio.NewReader(body), closer: body} + magic, peekErr := pb.Peek(4) + if peekErr == nil || (peekErr == io.EOF && len(magic) >= 2) { + switch { + case len(magic) >= 2 && magic[0] == 0x1f && magic[1] == 0x8b: + gzipReader, gzErr := gzip.NewReader(pb) + if gzErr != nil { + if errClose := pb.Close(); errClose != nil { + log.Errorf("response decoder: close body error: %v", errClose) + } + return nil, fmt.Errorf("magic-byte gzip: failed to create reader: %w", gzErr) + } + return &compositeReadCloser{ + Reader: gzipReader, + closers: []func() error{ + gzipReader.Close, + pb.Close, + }, + }, nil + case len(magic) >= 4 && magic[0] == 0x28 && magic[1] == 0xb5 && magic[2] == 0x2f && magic[3] == 0xfd: + decoder, zdErr := zstd.NewReader(pb) + if zdErr != nil { + if errClose := pb.Close(); errClose != nil { + log.Errorf("response decoder: close body error: %v", errClose) + } + return nil, fmt.Errorf("magic-byte zstd: failed to create reader: %w", zdErr) + } + return &compositeReadCloser{ + Reader: decoder, + closers: []func() error{ + func() error { decoder.Close(); return nil }, + pb.Close, + }, + }, nil + } + } + return pb, nil + } + encodings := strings.Split(contentEncoding, ",") + reader := io.Reader(body) + decoderClosers := make([]func() error, 0, len(encodings)) + cleanup := func() { + for i := len(decoderClosers) - 1; i >= 0; i-- { + if errClose := decoderClosers[i](); errClose != nil { + log.Errorf("response decoder: close decoder error: %v", errClose) + } + } + if errClose := body.Close(); errClose != nil { + log.Errorf("response decoder: close body error: %v", errClose) + } + } + for index := len(encodings) - 1; index >= 0; index-- { + encoding := strings.TrimSpace(strings.ToLower(encodings[index])) + switch encoding { + case "", "identity": + continue + case "gzip": + gzipReader, errGzip := gzip.NewReader(reader) + if errGzip != nil { + cleanup() + return nil, fmt.Errorf("failed to create gzip reader: %w", errGzip) + } + reader = gzipReader + decoderClosers = append(decoderClosers, gzipReader.Close) + case "deflate": + deflateReader, errDeflate := newResponseDeflateReader(reader) + if errDeflate != nil { + cleanup() + return nil, errDeflate + } + reader = deflateReader + decoderClosers = append(decoderClosers, deflateReader.Close) + case "br": + reader = brotli.NewReader(reader) + case "zstd": + decoder, errZstd := zstd.NewReader(reader) + if errZstd != nil { + cleanup() + return nil, fmt.Errorf("failed to create zstd reader: %w", errZstd) + } + reader = decoder + decoderClosers = append(decoderClosers, func() error { + decoder.Close() + return nil + }) + default: + cleanup() + return nil, fmt.Errorf("unsupported content encoding %q", encoding) + } + } + if len(decoderClosers) == 0 && reader == body { + return body, nil + } + closers := make([]func() error, 0, len(decoderClosers)+1) + for index := len(decoderClosers) - 1; index >= 0; index-- { + closers = append(closers, decoderClosers[index]) + } + closers = append(closers, body.Close) + return &compositeReadCloser{Reader: reader, closers: closers}, nil +} + +func newResponseDeflateReader(reader io.Reader) (io.ReadCloser, error) { + buffered := bufio.NewReader(reader) + header, errPeek := buffered.Peek(2) + if errPeek == nil && isZlibHeader(header) { + zlibReader, errZlib := zlib.NewReader(buffered) + if errZlib != nil { + return nil, fmt.Errorf("failed to create zlib deflate reader: %w", errZlib) + } + return zlibReader, nil + } + return flate.NewReader(buffered), nil +} + +func isZlibHeader(header []byte) bool { + if len(header) < 2 { + return false + } + cmf, flg := header[0], header[1] + return cmf&0x0f == 8 && cmf>>4 <= 7 && (uint16(cmf)<<8|uint16(flg))%31 == 0 +} diff --git a/internal/runtime/executor/helps/response_model.go b/internal/runtime/executor/helps/response_model.go index c0138e6b..ec2753d0 100644 --- a/internal/runtime/executor/helps/response_model.go +++ b/internal/runtime/executor/helps/response_model.go @@ -2,8 +2,6 @@ package helps import ( "strings" - "sync" - "time" "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking" "github.com/tidwall/gjson" @@ -12,18 +10,7 @@ import ( const ( // maxResponseModelLength is a defensive bound on an upstream-controlled string // reaching logs and usage records; known model ids stay under ~128 bytes. - maxResponseModelLength = 128 - maxCodexResponseModelLength = maxResponseModelLength - - // modelSubstitutionWarnWindow bounds how often one credential and model pair - // warns: on an affected credential every request is substituted. - modelSubstitutionWarnWindow = 10 * time.Minute - codexModelSubstitutionWarnWindow = modelSubstitutionWarnWindow - - // modelSubstitutionWarnMaxEntries caps the throttle state, naturally bounded by - // credentials times models; memory safety wins over perfect throttling. - modelSubstitutionWarnMaxEntries = 1024 - codexModelSubstitutionWarnMaxEntries = modelSubstitutionWarnMaxEntries + maxResponseModelLength = 128 ) // extractResponseModelEvent returns the model an upstream reports serving, read @@ -116,6 +103,11 @@ func extractGeminiResponseModelEvent(data []byte) (model string, terminal bool) cand = gjson.GetBytes(data, "response.candidates.0.finishReason") } terminal = cand.Exists() && cand.String() != "" + if !terminal { + objectType := gjson.GetBytes(data, "object").String() + finishReason := gjson.GetBytes(data, "choices.0.finish_reason").String() + terminal = objectType == "chat.completion" || finishReason != "" + } if !terminal { eventType := gjson.GetBytes(data, "event_type").String() if eventType == "" { @@ -228,7 +220,7 @@ func extractCodexResponseModelEvent(payload []byte) (model string, terminal bool return "", terminal } model = strings.TrimSpace(modelResult.String()) - if len(model) > maxCodexResponseModelLength { + if len(model) > maxResponseModelLength { return "", terminal } return model, terminal @@ -253,12 +245,6 @@ func normalizeModelName(model string) string { return strings.TrimSpace(thinking.ParseSuffix(strings.ToLower(strings.TrimSpace(model))).ModelName) } -// normalizeCodexModelName lower-cases a model id and drops its thinking suffix, -// which never reaches the upstream request body. -func normalizeCodexModelName(model string) string { - return normalizeModelName(model) -} - func stripModelProviderPrefix(model string) string { if idx := strings.LastIndex(model, "/"); idx >= 0 && idx < len(model)-1 { return model[idx+1:] @@ -266,12 +252,6 @@ func stripModelProviderPrefix(model string) string { return model } -// IsCodexModelSubstituted reports whether the upstream served a model other than the -// requested one; a dated alias pins a snapshot of the same model and is accepted. -func IsCodexModelSubstituted(requested, served string) bool { - return IsModelSubstituted(requested, served) -} - // IsModelSubstituted reports whether the upstream served a model other than the // requested one for any provider; dated aliases and snapshot pins are accepted. func IsModelSubstituted(requested, served string) bool { @@ -321,12 +301,6 @@ func isDatedModelAlias(base, dated string) bool { return isModelDateSuffix(suffix) || isModelNumericVersionSuffix(suffix) } -// isCodexDatedModelAlias reports whether dated is base plus a release date suffix, -// which upstreams use to pin the exact snapshot of the same model. -func isCodexDatedModelAlias(base, dated string) bool { - return isDatedModelAlias(base, dated) -} - func isModelDateSuffix(suffix string) bool { switch len(suffix) { case len("YYYY-MM-DD"): @@ -341,11 +315,6 @@ func isModelDateSuffix(suffix string) bool { } } -// isCodexModelDateSuffix reports whether suffix is a YYYY-MM-DD or YYYYMMDD date. -func isCodexModelDateSuffix(suffix string) bool { - return isModelDateSuffix(suffix) -} - func isModelNumericVersionSuffix(suffix string) bool { if len(suffix) == 3 && isModelDigits(suffix) { return true @@ -364,62 +333,3 @@ func isModelDigits(value string) bool { } return true } - -// isCodexModelDigits reports whether value is a non-empty run of ASCII digits. -func isCodexModelDigits(value string) bool { - return isModelDigits(value) -} - -type codexModelSubstitutionKey struct { - provider string - authID string - requested string - served string -} - -// codexModelSubstitutionThrottle records the last warning per key; nowFunc is -// injectable so tests can advance the window without sleeping. -type codexModelSubstitutionThrottle struct { - mu sync.Mutex - nowFunc func() time.Time - lastWarn map[codexModelSubstitutionKey]time.Time -} - -func newCodexModelSubstitutionThrottle(nowFunc func() time.Time) *codexModelSubstitutionThrottle { - if nowFunc == nil { - nowFunc = time.Now - } - return &codexModelSubstitutionThrottle{ - nowFunc: nowFunc, - lastWarn: make(map[codexModelSubstitutionKey]time.Time), - } -} - -// allow reports whether the key may emit a warning now and records the decision. -// Suppressed repeats stay silent instead of moving to a lower level. -func (t *codexModelSubstitutionThrottle) allow(key codexModelSubstitutionKey) bool { - if t == nil { - return true - } - t.mu.Lock() - defer t.mu.Unlock() - now := t.nowFunc() - if last, ok := t.lastWarn[key]; ok && now.Sub(last) < codexModelSubstitutionWarnWindow { - return false - } - if len(t.lastWarn) >= codexModelSubstitutionWarnMaxEntries { - for storedKey, storedAt := range t.lastWarn { - if now.Sub(storedAt) >= codexModelSubstitutionWarnWindow { - delete(t.lastWarn, storedKey) - } - } - if len(t.lastWarn) >= codexModelSubstitutionWarnMaxEntries { - clear(t.lastWarn) - } - } - t.lastWarn[key] = now - return true -} - -// codexModelSubstitutionWarns throttles substitution warnings process-wide. -var codexModelSubstitutionWarns = newCodexModelSubstitutionThrottle(time.Now) diff --git a/internal/runtime/executor/helps/responses_tool_search.go b/internal/runtime/executor/helps/responses_tool_search.go new file mode 100644 index 00000000..064ef193 --- /dev/null +++ b/internal/runtime/executor/helps/responses_tool_search.go @@ -0,0 +1,109 @@ +package helps + +import ( + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const ( + responsesToolSearchType = "tool_search" + responsesToolSearchCallType = "tool_search_call" + responsesToolSearchOutputType = "tool_search_output" +) + +// ResponsesHasToolSearch reports whether a Responses payload carries a client +// tool_search declaration or tool_search history items. +func ResponsesHasToolSearch(payload []byte) bool { + if hasClientToolSearchDeclaration(gjson.GetBytes(payload, "tools")) { + return true + } + found := false + gjson.GetBytes(payload, "input").ForEach(func(_, item gjson.Result) bool { + switch strings.TrimSpace(item.Get("type").String()) { + case responsesToolSearchCallType, responsesToolSearchOutputType: + found = true + case "additional_tools": + found = hasClientToolSearchDeclaration(item.Get("tools")) + } + return !found + }) + return found +} + +// StripResponsesToolSearch removes client tool_search declarations and every +// tool_search_call/tool_search_output input item, so the Claude translator +// produces the same output it did before tool_search support. Hosted +// declarations are left for the translator. An unchanged payload is returned +// as is. +func StripResponsesToolSearch(payload []byte) []byte { + out := payload + if tools := gjson.GetBytes(out, "tools"); tools.IsArray() { + if kept, changed := withoutClientToolSearch(tools); changed { + out, _ = sjson.SetRawBytes(out, "tools", kept) + } + } + input := gjson.GetBytes(out, "input") + if !input.IsArray() { + return out + } + changed := false + items := make([]string, 0, len(input.Array())) + input.ForEach(func(_, item gjson.Result) bool { + switch strings.TrimSpace(item.Get("type").String()) { + case responsesToolSearchCallType, responsesToolSearchOutputType: + changed = true + return true + case "additional_tools": + if kept, keptChanged := withoutClientToolSearch(item.Get("tools")); keptChanged { + updated, errSet := sjson.SetRawBytes([]byte(item.Raw), "tools", kept) + if errSet == nil { + items = append(items, string(updated)) + changed = true + return true + } + } + } + items = append(items, item.Raw) + return true + }) + if !changed { + return out + } + out, _ = sjson.SetRawBytes(out, "input", []byte("["+strings.Join(items, ",")+"]")) + return out +} + +func isClientToolSearchDeclaration(tool gjson.Result) bool { + return strings.TrimSpace(tool.Get("type").String()) == responsesToolSearchType && + strings.TrimSpace(tool.Get("execution").String()) == "client" +} + +func hasClientToolSearchDeclaration(tools gjson.Result) bool { + found := false + tools.ForEach(func(_, tool gjson.Result) bool { + found = isClientToolSearchDeclaration(tool) + return !found + }) + return found +} + +// withoutClientToolSearch rebuilds a tools array without client tool_search +// declarations and reports whether anything was removed. +func withoutClientToolSearch(tools gjson.Result) ([]byte, bool) { + if !tools.IsArray() { + return nil, false + } + changed := false + kept := make([]string, 0, len(tools.Array())) + tools.ForEach(func(_, tool gjson.Result) bool { + if isClientToolSearchDeclaration(tool) { + changed = true + return true + } + kept = append(kept, tool.Raw) + return true + }) + return []byte("[" + strings.Join(kept, ",") + "]"), changed +} diff --git a/internal/runtime/executor/helps/responses_ttft_helpers.go b/internal/runtime/executor/helps/responses_ttft_helpers.go index 96eb9fa5..f7dc1a63 100644 --- a/internal/runtime/executor/helps/responses_ttft_helpers.go +++ b/internal/runtime/executor/helps/responses_ttft_helpers.go @@ -81,6 +81,15 @@ func IsResponsesTokenEvent(payload []byte) bool { return len(gjson.GetBytes(payload, "item.arguments").String()) > 0 case "custom_tool_call": return len(gjson.GetBytes(payload, "item.input").String()) > 0 + case "reasoning": + for _, field := range []string{"item.summary", "item.content"} { + for _, part := range gjson.GetBytes(payload, field).Array() { + text := part.Get("text") + if text.Type == gjson.String && text.String() != "" { + return true + } + } + } case "message": for _, content := range gjson.GetBytes(payload, "item.content").Array() { if len(content.Get("text").String()) > 0 || len(content.Get("refusal").String()) > 0 { diff --git a/internal/runtime/executor/helps/tool_call_ids.go b/internal/runtime/executor/helps/tool_call_ids.go new file mode 100644 index 00000000..6a63b38f --- /dev/null +++ b/internal/runtime/executor/helps/tool_call_ids.go @@ -0,0 +1,123 @@ +package helps + +import ( + "encoding/base64" + "strconv" + "strings" + "unicode/utf8" + + "github.com/tidwall/gjson" +) + +const devinToolIDPrefix = "cpa_tid_v1_" + +func isPortableToolCallID(id string) bool { + if id == "" { + return false + } + for i := 0; i < len(id); i++ { + c := id[i] + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' { + continue + } + return false + } + return true +} + +// EncodeDevinToolCallID reserves a namespace so native IDs cannot collide with +// encoded IDs. Empty deltas remain empty until upstream supplies the call ID. +func EncodeDevinToolCallID(id string) string { + if id == "" || (isPortableToolCallID(id) && !strings.HasPrefix(id, devinToolIDPrefix)) { + return id + } + return devinToolIDPrefix + base64.RawURLEncoding.EncodeToString([]byte(id)) +} + +// DecodeDevinToolCallID removes exactly one canonical encoding layer. Ordinary +// legal IDs, malformed encodings and arbitrary base64-looking IDs stay intact. +func DecodeDevinToolCallID(id string) string { + if !strings.HasPrefix(id, devinToolIDPrefix) { + return id + } + decoded, errDecode := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(id, devinToolIDPrefix)) + if errDecode != nil || !utf8.Valid(decoded) || len(decoded) == 0 { + return id + } + original := string(decoded) + if EncodeDevinToolCallID(original) != id { + return id + } + return original +} + +// RestoreDevinToolCallIDs runs before protobuf construction and user payload +// rules, keeping assistant calls and tool results in the upstream namespace. +func RestoreDevinToolCallIDs(prompts []DevinPrompt) { + for i := range prompts { + for j := range prompts[i].ToolCalls { + prompts[i].ToolCalls[j].ID = DecodeDevinToolCallID(prompts[i].ToolCalls[j].ID) + } + prompts[i].ToolCallID = DecodeDevinToolCallID(prompts[i].ToolCallID) + prompts[i].OriginalToolCallID = DecodeDevinToolCallID(prompts[i].OriginalToolCallID) + } +} + +// NormalizeClaudeToolCallIDs repairs only client tool ID fields. Existing legal +// IDs (including Devin encodings) are never re-encoded. Legacy collisions get +// a deterministic underscore-prefixed alias, without a process-wide ledger. +func NormalizeClaudeToolCallIDs(body []byte) []byte { + type idField struct { + start int + end int + id string + } + var fields []idField + used := make(map[string]bool) + for _, message := range gjson.GetBytes(body, "messages").Array() { + for _, block := range message.Get("content").Array() { + field := "" + switch block.Get("type").String() { + case "tool_use": + field = "id" + case "tool_result": + field = "tool_use_id" + default: + continue + } + value := block.Get(field) + if value.Type != gjson.String || value.String() == "" { + continue + } + id := value.String() + if isPortableToolCallID(id) { + used[id] = true + } else { + fields = append(fields, idField{start: value.Index, end: value.Index + len(value.Raw), id: id}) + } + } + } + if len(fields) == 0 { + return body + } + mapped := make(map[string]string) + // Field offsets are in document order. Copy unchanged spans only once so + // repairing large transcripts does not copy the whole payload per ID. + out := make([]byte, 0, len(body)) + offset := 0 + for _, field := range fields { + id := mapped[field.id] + if id == "" { + id = EncodeDevinToolCallID(field.id) + for used[id] { + id = "_" + id + } + mapped[field.id] = id + used[id] = true + } + out = append(out, body[offset:field.start]...) + out = strconv.AppendQuote(out, id) + offset = field.end + } + return append(out, body[offset:]...) +} diff --git a/internal/runtime/executor/helps/usage_helpers.go b/internal/runtime/executor/helps/usage_helpers.go index 266012a4..845d16e4 100644 --- a/internal/runtime/executor/helps/usage_helpers.go +++ b/internal/runtime/executor/helps/usage_helpers.go @@ -26,34 +26,38 @@ import ( ) type UsageReporter struct { - requestID string - traceID string - provider string - baseURL string - executorType string - model string - alias string - authID string - authIndex string - authMu sync.RWMutex - accessTokenHash string - authType string - apiKey string - sessionID string - parentSessionID string - source string - reasoning string - serviceTier string - generate bool - stream bool - requestedAt time.Time - ttftMu sync.RWMutex - ttft time.Duration - firstPacketDuration time.Duration - firstPacketSet bool - ttftStart time.Time - ttftSet bool - once sync.Once + requestID string + traceID string + selectionID string + provider string + baseURL string + executorType string + model string + alias string + authID string + authIndex string + authMu sync.RWMutex + accessTokenHash string + authType string + apiKey string + accessProvider string + isNativeKey bool + sessionID string + parentSessionID string + source string + reasoning string + serviceTier string + effectiveServiceTier string + generate bool + stream bool + requestedAt time.Time + ttftMu sync.RWMutex + ttft time.Duration + firstPacketDuration time.Duration + firstPacketSet bool + ttftStart time.Time + ttftSet bool + once sync.Once responseModelMu sync.RWMutex // responseModel holds the latest model name reported by the upstream response. @@ -84,6 +88,13 @@ func NewExecutorUsageReporter(ctx context.Context, executor usageExecutor, model func NewUsageReporter(ctx context.Context, provider, model string, auth *cliproxyauth.Auth) *UsageReporter { apiKey := APIKeyFromContext(ctx) + accessProvider := AccessProviderFromContext(ctx) + isNativeKey := false + if nativeExplicit, ok := usage.IsNativeKeyFromContext(ctx); ok { + isNativeKey = nativeExplicit + } else if accessProvider != "" { + isNativeKey = usage.IsNativeAccessProvider(accessProvider) + } alias := usage.RequestedModelAliasFromContext(ctx) if alias == "" { alias = model @@ -116,12 +127,15 @@ func NewUsageReporter(ctx context.Context, provider, model string, auth *cliprox reporter := &UsageReporter{ requestID: uuid.NewString(), traceID: traceID, + selectionID: usage.SelectionIDFromContext(ctx), provider: provider, baseURL: baseURL, model: model, alias: strings.TrimSpace(alias), requestedAt: time.Now(), apiKey: apiKey, + accessProvider: accessProvider, + isNativeKey: isNativeKey, sessionID: sessionID, parentSessionID: parentSessionID, source: resolveUsageSource(auth, apiKey), @@ -222,12 +236,6 @@ func (r *UsageReporter) ObserveResponseModel(payload []byte) { } } -// ObserveCodexResponseModel stores the model reported by a codex upstream event and -// ignores payloads without one; the substitution warning is emitted at publish time. -func (r *UsageReporter) ObserveCodexResponseModel(payload []byte) { - r.ObserveResponseModel(payload) -} - // SetResponseModel sets the reported model directly if valid and not already marked final. func (r *UsageReporter) SetResponseModel(model string) { if r == nil || r.responseModelFinal.Load() { @@ -270,8 +278,8 @@ func (r *UsageReporter) IsResponseModelFinal() bool { return r != nil && r.responseModelFinal.Load() } -// warnModelSubstitution warns about a silent upstream model swap, throttled per -// credential and model pair, and labels the credential by index only, never by account. +// warnModelSubstitution warns about a silent upstream model swap, and labels the +// credential by index only, never by account. func (r *UsageReporter) warnModelSubstitution(ctx context.Context) { if r == nil { return @@ -287,31 +295,13 @@ func (r *UsageReporter) warnModelSubstitution(ctx context.Context) { if r.model != "" && !IsModelSubstituted(r.model, served) { return } - // The throttle key uses the same normalized names as the substitution check, so - // aliases of one pair share a window instead of each warning on its own. - requested := normalizeModelName(expectedModel) - servedNormalized := normalizeModelName(served) - providerName := r.provider + providerName := strings.TrimSpace(r.provider) if providerName == "" { - providerName = "codex" - } - if !codexModelSubstitutionWarns.allow(codexModelSubstitutionKey{ - provider: providerName, - authID: r.authID, - requested: requested, - served: servedNormalized, - }) { - return + providerName = "unknown" } LogWithRequestID(ctx).Warnf("%s executor: upstream served model %q for requested model %q (auth_index=%s)", providerName, served, r.model, r.authIndexForLog()) } -// warnCodexModelSubstitution warns about a silent upstream model swap, throttled per -// credential and model pair, and labels the credential by index only, never by account. -func (r *UsageReporter) warnCodexModelSubstitution(ctx context.Context) { - r.warnModelSubstitution(ctx) -} - // authIndexForLog labels the credential without exposing its file name or account. func (r *UsageReporter) authIndexForLog() string { if r == nil { @@ -356,11 +346,34 @@ func (r *UsageReporter) PublishAdditionalModel(ctx context.Context, model string r.publishRecord(ctx, record) } -func (r *UsageReporter) SetTranslatedReasoningEffort(payload []byte, format string) { +// SetTranslatedRequestMetadata captures usage-relevant values from the final +// outbound payload after translation and payload defaults or overrides. +func (r *UsageReporter) SetTranslatedRequestMetadata(payload []byte, format string) { if r == nil { return } r.reasoning = thinking.ExtractTranslatedReasoningEffort(payload, format) + r.effectiveServiceTier = extractEffectiveServiceTier(payload) +} + +// SetTranslatedReasoningEffort is retained for existing executor call sites. +// It captures all post-translation request metadata used by usage records. +func (r *UsageReporter) SetTranslatedReasoningEffort(payload []byte, format string) { + r.SetTranslatedRequestMetadata(payload, format) +} + +func extractEffectiveServiceTier(payload []byte) string { + if len(payload) == 0 || !gjson.ValidBytes(payload) { + return "" + } + for _, path := range []string{"service_tier", "request.service_tier", "interaction.service_tier"} { + if node := gjson.GetBytes(payload, path); node.Type == gjson.String { + if tier := strings.TrimSpace(node.String()); tier != "" { + return tier + } + } + } + return "" } func (r *UsageReporter) TrackHTTPClient(client *http.Client) *http.Client { @@ -612,6 +625,30 @@ func (r *UsageReporter) TraceID() string { return r.traceID } +// SelectionID returns the scheduler selection ID for this reporter. +func (r *UsageReporter) SelectionID() string { + if r == nil { + return "" + } + return r.selectionID +} + +// IsNativeKey reports whether the client API key was authenticated by CPA's native config provider. +func (r *UsageReporter) IsNativeKey() bool { + if r == nil { + return false + } + return r.isNativeKey +} + +// AccessProvider identifies the client request authentication provider. +func (r *UsageReporter) AccessProvider() string { + if r == nil { + return "" + } + return r.accessProvider +} + func (r *UsageReporter) buildRecord(detail usage.Detail, failed bool, failures ...usage.Failure) usage.Record { var fail usage.Failure if len(failures) > 0 { @@ -634,33 +671,37 @@ func (r *UsageReporter) buildRecordForModel(model string, detail usage.Detail, f responseModel = r.ResponseModel() } return usage.Record{ - RequestID: r.requestID, - TraceID: r.traceID, - Provider: r.provider, - BaseURL: r.baseURL, - ExecutorType: r.executorType, - Model: model, - Alias: r.alias, - Source: r.source, - APIKey: r.apiKey, - SessionID: r.sessionID, - ParentSessionID: r.parentSessionID, - AuthID: r.authID, - AuthIndex: r.authIndex, - AccessTokenSHA256: r.accessTokenFingerprint(), - AuthType: r.authType, - ReasoningEffort: r.reasoning, - ServiceTier: r.serviceTier, - ResponseServiceTier: strings.TrimSpace(detail.ResponseServiceTier), - ResponseModel: responseModel, - Generate: usage.GenerateFlag(r.generate), - Stream: r.stream, - RequestedAt: r.requestedAt, - Latency: r.latency(), - TTFT: r.ttftDuration(), - Failed: failed, - Fail: fail, - Detail: detail, + RequestID: r.requestID, + TraceID: r.traceID, + SelectionID: r.selectionID, + Provider: r.provider, + BaseURL: r.baseURL, + ExecutorType: r.executorType, + Model: model, + Alias: r.alias, + Source: r.source, + APIKey: r.apiKey, + IsNativeKey: r.isNativeKey, + AccessProvider: r.accessProvider, + SessionID: r.sessionID, + ParentSessionID: r.parentSessionID, + AuthID: r.authID, + AuthIndex: r.authIndex, + AccessTokenSHA256: r.accessTokenFingerprint(), + AuthType: r.authType, + ReasoningEffort: r.reasoning, + ServiceTier: r.serviceTier, + EffectiveServiceTier: r.effectiveServiceTier, + ResponseServiceTier: strings.TrimSpace(detail.ResponseServiceTier), + ResponseModel: responseModel, + Generate: usage.GenerateFlag(r.generate), + Stream: r.stream, + RequestedAt: r.requestedAt, + Latency: r.latency(), + TTFT: r.ttftDuration(), + Failed: failed, + Fail: fail, + Detail: detail, } } @@ -679,10 +720,14 @@ func failFromErrors(errs ...error) usage.Failure { body = string(responseBody) } } - return usage.Failure{ + fail := usage.Failure{ Body: body, StatusCode: clienterror.HTTPStatusFromError(err), } + if truncatedErr, ok := errors.AsType[cliproxyexecutor.StreamTruncatedError](err); ok && truncatedErr != nil && truncatedErr.IsStreamTruncated() { + fail.Code = usage.FailureStreamTruncatedCode + } + return fail } return usage.Failure{} } @@ -790,6 +835,31 @@ func APIKeyFromContext(ctx context.Context) string { return "" } +// AccessProviderFromContext extracts the client authentication provider identifier from the context. +func AccessProviderFromContext(ctx context.Context) string { + if provider := usage.AccessProviderFromContext(ctx); provider != "" { + return provider + } + if ctx == nil { + return "" + } + ginCtx, ok := ctx.Value("gin").(*gin.Context) + if !ok || ginCtx == nil { + return "" + } + if v, exists := ginCtx.Get("accessProvider"); exists { + switch value := v.(type) { + case string: + return strings.TrimSpace(value) + case fmt.Stringer: + return strings.TrimSpace(value.String()) + default: + return strings.TrimSpace(fmt.Sprintf("%v", value)) + } + } + return "" +} + func resolveUsageSource(auth *cliproxyauth.Auth, ctxAPIKey string) string { if auth != nil { provider := strings.TrimSpace(auth.Provider) @@ -1016,7 +1086,8 @@ func hasOpenAIStyleUsageBucketFields(usageNode gjson.Result) bool { usageNode.Get("input_tokens_details.cache_write_tokens").Exists() || usageNode.Get("input_tokens_details.cache_creation_tokens").Exists() || usageNode.Get("completion_tokens_details.reasoning_tokens").Exists() || - usageNode.Get("output_tokens_details.reasoning_tokens").Exists() + usageNode.Get("output_tokens_details.reasoning_tokens").Exists() || + usageNode.Get("reasoning_tokens").Exists() } func parseOpenAIStyleUsageNode(usageNode gjson.Result) usage.Detail { @@ -1055,6 +1126,10 @@ func parseOpenAIStyleUsageNode(usageNode gjson.Result) usage.Detail { if !reasoning.Exists() { reasoning = usageNode.Get("output_tokens_details.reasoning_tokens") } + if !reasoning.Exists() { + // Flat dialect (e.g. SGLang): usage.reasoning_tokens at top level. + reasoning = usageNode.Get("reasoning_tokens") + } if reasoning.Exists() { detail.ReasoningTokens = reasoning.Int() } @@ -1171,6 +1246,13 @@ func parseClaudeUsageNode(usageNode gjson.Result) usage.Detail { CacheReadTokens: cacheReadTokens, CacheCreationTokens: cacheCreationTokens, } + // Anthropic reports the speed a response was served and billed at in + // usage.speed ("fast" or "standard"): fast mode costs more, and a + // fast request on a model without it runs, and is billed, at standard. + // Carried as the response tier so usage sinks can price it. + if speed := strings.TrimSpace(usageNode.Get("speed").String()); speed != "" { + detail.ResponseServiceTier = speed + } if detail.CachedTokens == 0 { detail.CachedTokens = detail.CacheCreationTokens } diff --git a/internal/runtime/executor/helps/utls_client.go b/internal/runtime/executor/helps/utls_client.go index f832748b..1b497861 100644 --- a/internal/runtime/executor/helps/utls_client.go +++ b/internal/runtime/executor/helps/utls_client.go @@ -2,6 +2,7 @@ package helps import ( "context" + "crypto/x509" "errors" "fmt" "io" @@ -16,6 +17,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v8/internal/config" "github.com/router-for-me/CLIProxyAPI/v8/internal/httpwire" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/httptransport" "github.com/router-for-me/CLIProxyAPI/v8/sdk/proxyutil" log "github.com/sirupsen/logrus" "golang.org/x/net/http2" @@ -80,7 +82,10 @@ func (t *utlsRoundTripper) createConnection(ctx context.Context, host, addr stri tlsConfig := &tls.Config{ServerName: host} tlsConn := tls.UClient(conn, tlsConfig, tls.HelloChrome_Auto) - if errHandshake := tlsConn.HandshakeContext(ctx); errHandshake != nil { + handshakeCtx, cancelHandshake := handshakeContext(ctx) + defer cancelHandshake() + if errHandshake := tlsConn.HandshakeContext(handshakeCtx); errHandshake != nil { + errHandshake = classifyHandshakeError(ctx, handshakeCtx, errHandshake) if errors.Is(errHandshake, context.Canceled) || errors.Is(errHandshake, context.DeadlineExceeded) { return nil, fmt.Errorf("utls: TLS handshake: %w", errHandshake) } @@ -178,8 +183,13 @@ const claudeCodeSessionCacheCapacity = 32 // native handshake. PreferSkipResumptionOnNilExtension turns uTLS's HelloCustom // "resume without the matching extension" panic into a skipped resumption. func newClaudeCodeTLSConfig(host string, sessionCache tls.ClientSessionCache) *tls.Config { + return newClaudeCodeTLSConfigWithRoots(host, sessionCache, nil) +} + +func newClaudeCodeTLSConfigWithRoots(host string, sessionCache tls.ClientSessionCache, roots *x509.CertPool) *tls.Config { return &tls.Config{ ServerName: host, + RootCAs: roots, ClientSessionCache: sessionCache, OmitEmptyPsk: true, PreferSkipResumptionOnNilExtension: true, @@ -318,9 +328,6 @@ func cachedClaudeCodeRoundTripper(proxyURL string) http.RoundTripper { } func newClaudeCodeRoundTripper(proxyURL string) http.RoundTripper { - // The cache is scoped to this round tripper, which is already keyed by proxy, - // so resumption never crosses proxy boundaries. - sessionCache := tls.NewLRUClientSessionCache(claudeCodeSessionCacheCapacity) var dialer proxy.Dialer = proxy.Direct if proxyURL != "" { proxyDialer, mode, errBuild := proxyutil.BuildDialer(proxyURL) @@ -330,6 +337,20 @@ func newClaudeCodeRoundTripper(proxyURL string) http.RoundTripper { dialer = proxyDialer } } + return newClaudeCodeRoundTripperForDialer(dialer) +} + +// newClaudeCodeRoundTripperForDialer builds the Claude Code transport on top +// of an already-resolved dialer. It is split out from +// newClaudeCodeRoundTripper so tests can inject in-memory connections. +func newClaudeCodeRoundTripperForDialer(dialer proxy.Dialer) *http.Transport { + return newClaudeCodeRoundTripperForDialerAndRoots(dialer, nil) +} + +func newClaudeCodeRoundTripperForDialerAndRoots(dialer proxy.Dialer, roots *x509.CertPool) *http.Transport { + // The cache is scoped to this round tripper, which is already keyed by proxy, + // so resumption never crosses proxy boundaries. + sessionCache := tls.NewLRUClientSessionCache(claudeCodeSessionCacheCapacity) transport := &http.Transport{ ForceAttemptHTTP2: false, @@ -354,14 +375,17 @@ func newClaudeCodeRoundTripper(proxyURL string) http.RoundTripper { } return nil, fmt.Errorf("claude tls: split upstream address: %w", errSplit) } - tlsConn := tls.UClient(conn, newClaudeCodeTLSConfig(host, sessionCache), tls.HelloCustom) + tlsConn := tls.UClient(conn, newClaudeCodeTLSConfigWithRoots(host, sessionCache, roots), tls.HelloCustom) if errPreset := tlsConn.ApplyPreset(claudeCodeTLSClientHelloSpec()); errPreset != nil { if errClose := tlsConn.Close(); errClose != nil { log.Debugf("claude tls: close connection after preset failure: %v", errClose) } return nil, fmt.Errorf("claude tls: apply Claude Code ClientHello: %w", errPreset) } - if errHandshake := tlsConn.HandshakeContext(ctx); errHandshake != nil { + handshakeCtx, cancelHandshake := handshakeContext(ctx) + defer cancelHandshake() + if errHandshake := tlsConn.HandshakeContext(handshakeCtx); errHandshake != nil { + errHandshake = classifyHandshakeError(ctx, handshakeCtx, errHandshake) if errClose := tlsConn.Close(); errClose != nil { log.Debugf("claude tls: close connection after handshake failure: %v", errClose) } @@ -395,7 +419,24 @@ func (f *fallbackRoundTripper) RoundTrip(req *http.Request) (*http.Response, err // fingerprints for protected hosts. It uses Claude Code's Node/OpenSSL profile // for Anthropic and a Chrome profile for ChatGPT, with a standard-transport // fallback for other hosts. -func NewUtlsHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client { +func NewUtlsHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) (clientOut *http.Client) { + // Carry the opt-in TLS establishment bound (requests + // tls-handshake-timeout-seconds) on a request-scoped wrapper. This + // client is shared, so the bound intentionally reaches both the Claude + // Code Anthropic dial path and the Chrome ChatGPT/Codex dial path; the + // standard fallback transport ignores the context value. The uTLS dial + // paths read it per dial, so cached per-proxy transports need no rebuild + // when the value changes. A disabled bound (0) leaves requests untouched. + var handshakeTimeout time.Duration + if cfg != nil { + handshakeTimeout = cfg.TLSHandshakeTimeout() + } + defer func() { + clientOut = httptransport.Decorate(ctx, clientOut) + if clientOut != nil && handshakeTimeout > 0 { + clientOut.Transport = &tlsHandshakeTimeoutRoundTripper{base: clientOut.Transport, timeout: handshakeTimeout} + } + }() proxyURL := effectiveProxyURL(ctx, cfg, auth) var ctxRoundTripper http.RoundTripper diff --git a/internal/runtime/executor/helps/utls_handshake_timeout.go b/internal/runtime/executor/helps/utls_handshake_timeout.go new file mode 100644 index 00000000..94f1f7a9 --- /dev/null +++ b/internal/runtime/executor/helps/utls_handshake_timeout.go @@ -0,0 +1,95 @@ +package helps + +import ( + "context" + "errors" + "net/http" + "time" + + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" +) + +// tlsHandshakeTimeoutContextKey carries the opt-in TLS establishment bound +// through request contexts. The Claude Code round tripper is cached per +// proxy URL, so the bound cannot live on the transport itself: it is read +// per dial instead, which also lets config hot reloads take effect +// without rebuilding cached transports. +type tlsHandshakeTimeoutContextKey struct{} + +// withTLSHandshakeTimeout returns a context carrying the TLS establishment +// bound. A non-positive timeout leaves the context unchanged (bound +// disabled), matching the config contract where 0 means off. +func withTLSHandshakeTimeout(ctx context.Context, timeout time.Duration) context.Context { + if ctx == nil || timeout <= 0 { + return ctx + } + return context.WithValue(ctx, tlsHandshakeTimeoutContextKey{}, timeout) +} + +// tlsHandshakeTimeoutFromContext returns the configured establishment +// bound, or 0 when the bound is disabled. +func tlsHandshakeTimeoutFromContext(ctx context.Context) time.Duration { + if ctx == nil { + return 0 + } + timeout, _ := ctx.Value(tlsHandshakeTimeoutContextKey{}).(time.Duration) + if timeout <= 0 { + return 0 + } + return timeout +} + +// tlsHandshakeTimeoutRoundTripper stamps the configured establishment +// bound onto each outgoing request context so the uTLS dial paths can +// read it per dial. It only ever annotates the context; request routing, +// headers, and bodies pass through unchanged. +type tlsHandshakeTimeoutRoundTripper struct { + base http.RoundTripper + timeout time.Duration +} + +func (rt *tlsHandshakeTimeoutRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if rt.timeout > 0 { + req = req.WithContext(withTLSHandshakeTimeout(req.Context(), rt.timeout)) + } + return rt.base.RoundTrip(req) +} + +// handshakeContext bounds the TLS handshake when a positive timeout is +// configured on ctx. It starts when the caller invokes it, after dialing +// and proxy CONNECT have returned. It returns the context to hand to +// HandshakeContext plus its cancel function. The bound exists only for +// the duration of the handshake call: crypto/tls (and uTLS) close the raw +// connection when the handshake context expires, and once the handshake +// succeeds the context plays no further role, so inference and +// long-running streams are never time-limited and no connection deadline +// is left behind. When the bound is disabled the original context is +// returned unchanged with a no-op cancel, preserving the previous +// behavior exactly. +func handshakeContext(ctx context.Context) (context.Context, context.CancelFunc) { + timeout := tlsHandshakeTimeoutFromContext(ctx) + if timeout <= 0 { + return ctx, func() {} + } + return context.WithTimeout(ctx, timeout) +} + +// classifyHandshakeError annotates only a deadline produced by the +// configured internal handshake bound. The parent context is checked so a +// caller cancellation or caller-owned deadline racing the internal timer +// retains its original error and non-retryable classification. +func classifyHandshakeError(parentCtx, handshakeCtx context.Context, err error) error { + if err == nil || tlsHandshakeTimeoutFromContext(parentCtx) <= 0 { + return err + } + if parentCtx != nil && parentCtx.Err() != nil { + return err + } + if handshakeCtx == nil || !errors.Is(handshakeCtx.Err(), context.DeadlineExceeded) { + return err + } + if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return err + } + return cliproxyexecutor.NewConfiguredTLSHandshakeTimeoutError(err) +}