From 8f9e6622b029164991c6f97b67562f940da327bd Mon Sep 17 00:00:00 2001 From: muzhi1991 <2101044+muzhi1991@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:45:37 +0800 Subject: [PATCH 01/30] fix(util): forward custom Host header to upstream Custom headers configured under openai-compatibility (and any other provider passing through applyCustomHeaders) were silently dropped for the Host key, because Go's net/http reads the wire Host from req.Host, not req.Header["Host"]. As a result, virtual-host routed upstreams (e.g. LiteLLM behind an ingress) saw the base-url's host instead of the user-configured override and returned 404. Detect the Host key with http.CanonicalHeaderKey and assign it to req.Host so it is actually written on the wire. Other headers continue to use Header.Set as before. Fixes #2833 --- internal/util/header_helpers.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/util/header_helpers.go b/internal/util/header_helpers.go index c53c291f10..967903fce5 100644 --- a/internal/util/header_helpers.go +++ b/internal/util/header_helpers.go @@ -47,6 +47,14 @@ func applyCustomHeaders(r *http.Request, headers map[string]string) { if k == "" || v == "" { continue } + // Host is read from req.Host (not req.Header) by net/http when + // writing the request; setting it via Header.Set is silently + // dropped on the wire. Handle it explicitly so user-configured + // virtual-host overrides actually take effect upstream. + if http.CanonicalHeaderKey(k) == "Host" { + r.Host = v + continue + } r.Header.Set(k, v) } } From d9a3b3e5f33ca103f7d349b63a5b5bfea86234a0 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Fri, 17 Apr 2026 08:32:07 +0800 Subject: [PATCH 02/30] fix(tests): update model lookup references and enhance Claude executor tests --- .../registry/model_registry_safety_test.go | 4 +- .../runtime/executor/claude_executor_test.go | 76 ++++++++++++++----- test/thinking_conversion_test.go | 1 - 3 files changed, 58 insertions(+), 23 deletions(-) diff --git a/internal/registry/model_registry_safety_test.go b/internal/registry/model_registry_safety_test.go index 5f4f65d298..be5bf7908c 100644 --- a/internal/registry/model_registry_safety_test.go +++ b/internal/registry/model_registry_safety_test.go @@ -136,13 +136,13 @@ func TestGetAvailableModelsReturnsClonedSupportedParameters(t *testing.T) { } func TestLookupModelInfoReturnsCloneForStaticDefinitions(t *testing.T) { - first := LookupModelInfo("glm-4.6") + first := LookupModelInfo("claude-sonnet-4-6") if first == nil || first.Thinking == nil || len(first.Thinking.Levels) == 0 { t.Fatalf("expected static model with thinking levels, got %+v", first) } first.Thinking.Levels[0] = "mutated" - second := LookupModelInfo("glm-4.6") + second := LookupModelInfo("claude-sonnet-4-6") if second == nil || second.Thinking == nil || len(second.Thinking.Levels) == 0 || second.Thinking.Levels[0] == "mutated" { t.Fatalf("expected static lookup clone, got %+v", second) } diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index f456064dc6..c1ce8fc088 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -1714,7 +1714,27 @@ func TestClaudeExecutor_ExecuteStream_AcceptEncodingOverrideCannotBypassIdentity } } -// Test case 1: String system prompt is preserved and converted to a content block +func expectedClaudeCodeStaticPrompt() string { + return strings.Join([]string{ + helps.ClaudeCodeIntro, + helps.ClaudeCodeSystem, + helps.ClaudeCodeDoingTasks, + helps.ClaudeCodeToneAndStyle, + helps.ClaudeCodeOutputEfficiency, + }, "\n\n") +} + +func expectedForwardedSystemReminder(text string) string { + return fmt.Sprintf(` +As you answer the user's questions, you can use the following context from the system: +%s + +IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task. + +`, text) +} + +// Test case 1: String system prompt is preserved by forwarding it to the first user message func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) { payload := []byte(`{"system":"You are a helpful assistant.","messages":[{"role":"user","content":"hi"}]}`) @@ -1733,42 +1753,52 @@ func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) { if !strings.HasPrefix(blocks[0].Get("text").String(), "x-anthropic-billing-header:") { t.Fatalf("blocks[0] should be billing header, got %q", blocks[0].Get("text").String()) } - if blocks[1].Get("text").String() != "You are a Claude agent, built on Anthropic's Claude Agent SDK." { + if blocks[1].Get("text").String() != "You are Claude Code, Anthropic's official CLI for Claude." { t.Fatalf("blocks[1] should be agent block, got %q", blocks[1].Get("text").String()) } - if blocks[2].Get("text").String() != "You are a helpful assistant." { - t.Fatalf("blocks[2] should be user system prompt, got %q", blocks[2].Get("text").String()) + if blocks[2].Get("text").String() != expectedClaudeCodeStaticPrompt() { + t.Fatalf("blocks[2] should be static Claude Code prompt, got %q", blocks[2].Get("text").String()) + } + if blocks[2].Get("cache_control").Exists() { + t.Fatalf("blocks[2] should not have cache_control, got %s", blocks[2].Get("cache_control").Raw) } - if blocks[2].Get("cache_control.type").String() != "ephemeral" { - t.Fatalf("blocks[2] should have cache_control.type=ephemeral") + + if got := gjson.GetBytes(out, "messages.0.content").String(); got != expectedForwardedSystemReminder("You are a helpful assistant.")+"hi" { + t.Fatalf("messages[0].content should include forwarded system prompt, got %q", got) } } -// Test case 2: Strict mode drops the string system prompt +// Test case 2: Strict mode keeps only the injected Claude Code system blocks func TestCheckSystemInstructionsWithMode_StringSystemStrict(t *testing.T) { payload := []byte(`{"system":"You are a helpful assistant.","messages":[{"role":"user","content":"hi"}]}`) out := checkSystemInstructionsWithMode(payload, true) blocks := gjson.GetBytes(out, "system").Array() - if len(blocks) != 2 { - t.Fatalf("strict mode should produce 2 blocks, got %d", len(blocks)) + if len(blocks) != 3 { + t.Fatalf("strict mode should produce 3 injected blocks, got %d", len(blocks)) + } + if got := gjson.GetBytes(out, "messages.0.content").String(); got != "hi" { + t.Fatalf("strict mode should not forward system prompt into messages, got %q", got) } } -// Test case 3: Empty string system prompt does not produce a spurious block +// Test case 3: Empty string system prompt does not alter the first user message func TestCheckSystemInstructionsWithMode_EmptyStringSystemIgnored(t *testing.T) { payload := []byte(`{"system":"","messages":[{"role":"user","content":"hi"}]}`) out := checkSystemInstructionsWithMode(payload, false) blocks := gjson.GetBytes(out, "system").Array() - if len(blocks) != 2 { - t.Fatalf("empty string system should produce 2 blocks, got %d", len(blocks)) + if len(blocks) != 3 { + t.Fatalf("empty string system should still produce 3 injected blocks, got %d", len(blocks)) + } + if got := gjson.GetBytes(out, "messages.0.content").String(); got != "hi" { + t.Fatalf("empty string system should not alter messages, got %q", got) } } -// Test case 4: Array system prompt is unaffected by the string handling +// Test case 4: Array system prompt is forwarded to the first user message func TestCheckSystemInstructionsWithMode_ArraySystemStillWorks(t *testing.T) { payload := []byte(`{"system":[{"type":"text","text":"Be concise."}],"messages":[{"role":"user","content":"hi"}]}`) @@ -1778,12 +1808,15 @@ func TestCheckSystemInstructionsWithMode_ArraySystemStillWorks(t *testing.T) { if len(blocks) != 3 { t.Fatalf("expected 3 system blocks, got %d", len(blocks)) } - if blocks[2].Get("text").String() != "Be concise." { - t.Fatalf("blocks[2] should be user system prompt, got %q", blocks[2].Get("text").String()) + if blocks[2].Get("text").String() != expectedClaudeCodeStaticPrompt() { + t.Fatalf("blocks[2] should be static Claude Code prompt, got %q", blocks[2].Get("text").String()) + } + if got := gjson.GetBytes(out, "messages.0.content").String(); got != expectedForwardedSystemReminder("Be concise.")+"hi" { + t.Fatalf("messages[0].content should include forwarded array system prompt, got %q", got) } } -// Test case 5: Special characters in string system prompt survive conversion +// Test case 5: Special characters in string system prompt survive forwarding func TestCheckSystemInstructionsWithMode_StringWithSpecialChars(t *testing.T) { payload := []byte(`{"system":"Use tags & \"quotes\" in output.","messages":[{"role":"user","content":"hi"}]}`) @@ -1793,8 +1826,8 @@ func TestCheckSystemInstructionsWithMode_StringWithSpecialChars(t *testing.T) { if len(blocks) != 3 { t.Fatalf("expected 3 system blocks, got %d", len(blocks)) } - if blocks[2].Get("text").String() != `Use tags & "quotes" in output.` { - t.Fatalf("blocks[2] text mangled, got %q", blocks[2].Get("text").String()) + if got := gjson.GetBytes(out, "messages.0.content").String(); got != expectedForwardedSystemReminder(`Use tags & "quotes" in output.`)+"hi" { + t.Fatalf("forwarded system prompt text mangled, got %q", got) } } @@ -1902,8 +1935,11 @@ func TestApplyCloaking_PreservesConfiguredStrictModeAndSensitiveWordsWhenModeOmi out := applyCloaking(context.Background(), cfg, auth, payload, "claude-3-5-sonnet-20241022", "key-123") blocks := gjson.GetBytes(out, "system").Array() - if len(blocks) != 2 { - t.Fatalf("expected strict mode to keep only injected system blocks, got %d", len(blocks)) + if len(blocks) != 3 { + t.Fatalf("expected strict mode to keep the 3 injected Claude Code system blocks, got %d", len(blocks)) + } + if got := gjson.GetBytes(out, "messages.0.content.#").Int(); got != 1 { + t.Fatalf("strict mode should not prepend a forwarded system reminder block, got %d content blocks", got) } if got := gjson.GetBytes(out, "messages.0.content.0.text").String(); !strings.Contains(got, "\u200B") { t.Fatalf("expected configured sensitive word obfuscation to apply, got %q", got) diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index c6ade7b2a6..c76b1da101 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -2,7 +2,6 @@ package test import ( "fmt" - "strings" "testing" "time" From da43f63735f747266f8245e8aa2cc328c99c0fad Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Fri, 17 Apr 2026 08:43:19 +0800 Subject: [PATCH 03/30] fix(tests): update Gemini family test case numbers for consistency --- test/thinking_conversion_test.go | 52 ++++++++++++++++---------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index c76b1da101..51671a9c5f 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -1065,12 +1065,12 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { expectErr: false, }, - // Gemini Family Cross-Channel Consistency (Cases 106-114) + // Gemini Family Cross-Channel Consistency (Cases 90-95) // Tests that gemini/gemini-cli/antigravity as same API family should have consistent validation behavior - // Case 106: Gemini to Antigravity, budget 64000 (suffix) → clamped to Max + // Case 90: Gemini to Antigravity, budget 64000 (suffix) → clamped to Max { - name: "106", + name: "90", from: "gemini", to: "antigravity", model: "gemini-budget-model(64000)", @@ -1080,9 +1080,9 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { includeThoughts: "true", expectErr: false, }, - // Case 107: Gemini to Gemini-CLI, budget 64000 (suffix) → clamped to Max + // Case 91: Gemini to Gemini-CLI, budget 64000 (suffix) → clamped to Max { - name: "107", + name: "91", from: "gemini", to: "gemini-cli", model: "gemini-budget-model(64000)", @@ -1092,9 +1092,9 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { includeThoughts: "true", expectErr: false, }, - // Case 108: Gemini-CLI to Antigravity, budget 64000 (suffix) → clamped to Max + // Case 92: Gemini-CLI to Antigravity, budget 64000 (suffix) → clamped to Max { - name: "108", + name: "92", from: "gemini-cli", to: "antigravity", model: "gemini-budget-model(64000)", @@ -1104,9 +1104,9 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { includeThoughts: "true", expectErr: false, }, - // Case 109: Gemini-CLI to Gemini, budget 64000 (suffix) → clamped to Max + // Case 93: Gemini-CLI to Gemini, budget 64000 (suffix) → clamped to Max { - name: "109", + name: "93", from: "gemini-cli", to: "gemini", model: "gemini-budget-model(64000)", @@ -1116,9 +1116,9 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { includeThoughts: "true", expectErr: false, }, - // Case 110: Gemini to Antigravity, budget 8192 → passthrough (normal value) + // Case 94: Gemini to Antigravity, budget 8192 → passthrough (normal value) { - name: "110", + name: "94", from: "gemini", to: "antigravity", model: "gemini-budget-model(8192)", @@ -1128,9 +1128,9 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { includeThoughts: "true", expectErr: false, }, - // Case 111: Gemini-CLI to Antigravity, budget 8192 → passthrough (normal value) + // Case 95: Gemini-CLI to Antigravity, budget 8192 → passthrough (normal value) { - name: "111", + name: "95", from: "gemini-cli", to: "antigravity", model: "gemini-budget-model(8192)", @@ -2166,12 +2166,12 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { expectErr: true, }, - // Gemini Family Cross-Channel Consistency (Cases 106-114) + // Gemini Family Cross-Channel Consistency (Cases 90-95) // Tests that gemini/gemini-cli/antigravity as same API family should have consistent validation behavior - // Case 106: Gemini to Antigravity, thinkingBudget=64000 → exceeds Max error (same family strict validation) + // Case 90: Gemini to Antigravity, thinkingBudget=64000 → exceeds Max error (same family strict validation) { - name: "106", + name: "90", from: "gemini", to: "antigravity", model: "gemini-budget-model", @@ -2179,9 +2179,9 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { expectField: "", expectErr: true, }, - // Case 107: Gemini to Gemini-CLI, thinkingBudget=64000 → exceeds Max error (same family strict validation) + // Case 91: Gemini to Gemini-CLI, thinkingBudget=64000 → exceeds Max error (same family strict validation) { - name: "107", + name: "91", from: "gemini", to: "gemini-cli", model: "gemini-budget-model", @@ -2189,9 +2189,9 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { expectField: "", expectErr: true, }, - // Case 108: Gemini-CLI to Antigravity, thinkingBudget=64000 → exceeds Max error (same family strict validation) + // Case 92: Gemini-CLI to Antigravity, thinkingBudget=64000 → exceeds Max error (same family strict validation) { - name: "108", + name: "92", from: "gemini-cli", to: "antigravity", model: "gemini-budget-model", @@ -2199,9 +2199,9 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { expectField: "", expectErr: true, }, - // Case 109: Gemini-CLI to Gemini, thinkingBudget=64000 → exceeds Max error (same family strict validation) + // Case 93: Gemini-CLI to Gemini, thinkingBudget=64000 → exceeds Max error (same family strict validation) { - name: "109", + name: "93", from: "gemini-cli", to: "gemini", model: "gemini-budget-model", @@ -2209,9 +2209,9 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { expectField: "", expectErr: true, }, - // Case 110: Gemini to Antigravity, thinkingBudget=8192 → passthrough (normal value) + // Case 94: Gemini to Antigravity, thinkingBudget=8192 → passthrough (normal value) { - name: "110", + name: "94", from: "gemini", to: "antigravity", model: "gemini-budget-model", @@ -2221,9 +2221,9 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { includeThoughts: "true", expectErr: false, }, - // Case 111: Gemini-CLI to Antigravity, thinkingBudget=8192 → passthrough (normal value) + // Case 95: Gemini-CLI to Antigravity, thinkingBudget=8192 → passthrough (normal value) { - name: "111", + name: "95", from: "gemini-cli", to: "antigravity", model: "gemini-budget-model", From eba561bf6f08916a966cef698a5c53c7e273b375 Mon Sep 17 00:00:00 2001 From: muzhi1991 <2101044+muzhi1991@users.noreply.github.com> Date: Fri, 17 Apr 2026 09:28:59 +0800 Subject: [PATCH 04/30] fix(util): also keep Host in header map for synthetic requests Addressing the P1 note from the Codex reviewer: applyCustomHeaders is also called with a synthetic &http.Request{Header: ...} from the websockets executors (aistudio_executor.go, codex_websockets_executor.go), which forward only the header map. The previous continue meant a custom Host was dropped from that map, regressing virtual-host overrides on those flows. Mirror the value to both r.Host (for real net/http) and r.Header (for header-map-only consumers). --- internal/util/header_helpers.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/util/header_helpers.go b/internal/util/header_helpers.go index 967903fce5..0b8d72bcb4 100644 --- a/internal/util/header_helpers.go +++ b/internal/util/header_helpers.go @@ -47,13 +47,13 @@ func applyCustomHeaders(r *http.Request, headers map[string]string) { if k == "" || v == "" { continue } - // Host is read from req.Host (not req.Header) by net/http when - // writing the request; setting it via Header.Set is silently - // dropped on the wire. Handle it explicitly so user-configured - // virtual-host overrides actually take effect upstream. + // net/http reads Host from req.Host (not req.Header) when writing + // a real request, so we must mirror it there. Some callers pass + // synthetic requests (e.g. &http.Request{Header: ...}) and only + // consume r.Header afterwards, so keep the value in the header + // map too. if http.CanonicalHeaderKey(k) == "Host" { r.Host = v - continue } r.Header.Set(k, v) } From 894baad829f5f5a53411edc0d1af4f1af9f9d60d Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sat, 18 Apr 2026 16:44:33 +0800 Subject: [PATCH 05/30] feat(api): integrate auth index into key retrieval endpoints for Gemini, Claude, Codex, OpenAI, and Vertex --- .../handlers/management/config_auth_index.go | 245 ++++++++++++++++++ .../api/handlers/management/config_lists.go | 10 +- 2 files changed, 250 insertions(+), 5 deletions(-) create mode 100644 internal/api/handlers/management/config_auth_index.go diff --git a/internal/api/handlers/management/config_auth_index.go b/internal/api/handlers/management/config_auth_index.go new file mode 100644 index 0000000000..51f71aacf9 --- /dev/null +++ b/internal/api/handlers/management/config_auth_index.go @@ -0,0 +1,245 @@ +package management + +import ( + "strings" + "time" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/synthesizer" +) + +type configAuthIndexViews struct { + gemini []string + claude []string + codex []string + vertex []string + openAIEntries [][]string + openAIFallback []string +} + +type geminiKeyWithAuthIndex struct { + config.GeminiKey + AuthIndex string `json:"auth-index,omitempty"` +} + +type claudeKeyWithAuthIndex struct { + config.ClaudeKey + AuthIndex string `json:"auth-index,omitempty"` +} + +type codexKeyWithAuthIndex struct { + config.CodexKey + AuthIndex string `json:"auth-index,omitempty"` +} + +type vertexCompatKeyWithAuthIndex struct { + config.VertexCompatKey + AuthIndex string `json:"auth-index,omitempty"` +} + +type openAICompatibilityAPIKeyWithAuthIndex struct { + config.OpenAICompatibilityAPIKey + AuthIndex string `json:"auth-index,omitempty"` +} + +type openAICompatibilityWithAuthIndex struct { + Name string `json:"name"` + Priority int `json:"priority,omitempty"` + Prefix string `json:"prefix,omitempty"` + BaseURL string `json:"base-url"` + APIKeyEntries []openAICompatibilityAPIKeyWithAuthIndex `json:"api-key-entries,omitempty"` + Models []config.OpenAICompatibilityModel `json:"models,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + AuthIndex string `json:"auth-index,omitempty"` +} + +func (h *Handler) buildConfigAuthIndexViews() configAuthIndexViews { + cfg := h.cfg + if cfg == nil { + return configAuthIndexViews{} + } + + liveIndexByID := map[string]string{} + if h != nil && h.authManager != nil { + for _, auth := range h.authManager.List() { + if auth == nil || strings.TrimSpace(auth.ID) == "" { + continue + } + auth.EnsureIndex() + if auth.Index == "" { + continue + } + liveIndexByID[auth.ID] = auth.Index + } + } + + views := configAuthIndexViews{ + gemini: make([]string, len(cfg.GeminiKey)), + claude: make([]string, len(cfg.ClaudeKey)), + codex: make([]string, len(cfg.CodexKey)), + vertex: make([]string, len(cfg.VertexCompatAPIKey)), + openAIEntries: make([][]string, len(cfg.OpenAICompatibility)), + openAIFallback: make([]string, len(cfg.OpenAICompatibility)), + } + + auths, errSynthesize := synthesizer.NewConfigSynthesizer().Synthesize(&synthesizer.SynthesisContext{ + Config: cfg, + Now: time.Now(), + IDGenerator: synthesizer.NewStableIDGenerator(), + }) + if errSynthesize != nil { + return views + } + + cursor := 0 + nextAuthIndex := func() string { + if cursor >= len(auths) { + return "" + } + auth := auths[cursor] + cursor++ + if auth == nil || strings.TrimSpace(auth.ID) == "" { + return "" + } + // Do not expose an auth-index until it is present in the live auth manager. + // API tools resolve auth_index against h.authManager.List(), so returning + // config-only indexes can temporarily break tool calls around config edits. + return liveIndexByID[auth.ID] + } + + for i := range cfg.GeminiKey { + if strings.TrimSpace(cfg.GeminiKey[i].APIKey) == "" { + continue + } + views.gemini[i] = nextAuthIndex() + } + for i := range cfg.ClaudeKey { + if strings.TrimSpace(cfg.ClaudeKey[i].APIKey) == "" { + continue + } + views.claude[i] = nextAuthIndex() + } + for i := range cfg.CodexKey { + if strings.TrimSpace(cfg.CodexKey[i].APIKey) == "" { + continue + } + views.codex[i] = nextAuthIndex() + } + for i := range cfg.OpenAICompatibility { + entries := cfg.OpenAICompatibility[i].APIKeyEntries + if len(entries) == 0 { + views.openAIFallback[i] = nextAuthIndex() + continue + } + + views.openAIEntries[i] = make([]string, len(entries)) + for j := range entries { + views.openAIEntries[i][j] = nextAuthIndex() + } + } + for i := range cfg.VertexCompatAPIKey { + if strings.TrimSpace(cfg.VertexCompatAPIKey[i].APIKey) == "" { + continue + } + views.vertex[i] = nextAuthIndex() + } + + return views +} + +func (h *Handler) geminiKeysWithAuthIndex() []geminiKeyWithAuthIndex { + if h == nil || h.cfg == nil { + return nil + } + views := h.buildConfigAuthIndexViews() + out := make([]geminiKeyWithAuthIndex, len(h.cfg.GeminiKey)) + for i := range h.cfg.GeminiKey { + out[i] = geminiKeyWithAuthIndex{ + GeminiKey: h.cfg.GeminiKey[i], + AuthIndex: views.gemini[i], + } + } + return out +} + +func (h *Handler) claudeKeysWithAuthIndex() []claudeKeyWithAuthIndex { + if h == nil || h.cfg == nil { + return nil + } + views := h.buildConfigAuthIndexViews() + out := make([]claudeKeyWithAuthIndex, len(h.cfg.ClaudeKey)) + for i := range h.cfg.ClaudeKey { + out[i] = claudeKeyWithAuthIndex{ + ClaudeKey: h.cfg.ClaudeKey[i], + AuthIndex: views.claude[i], + } + } + return out +} + +func (h *Handler) codexKeysWithAuthIndex() []codexKeyWithAuthIndex { + if h == nil || h.cfg == nil { + return nil + } + views := h.buildConfigAuthIndexViews() + out := make([]codexKeyWithAuthIndex, len(h.cfg.CodexKey)) + for i := range h.cfg.CodexKey { + out[i] = codexKeyWithAuthIndex{ + CodexKey: h.cfg.CodexKey[i], + AuthIndex: views.codex[i], + } + } + return out +} + +func (h *Handler) vertexCompatKeysWithAuthIndex() []vertexCompatKeyWithAuthIndex { + if h == nil || h.cfg == nil { + return nil + } + views := h.buildConfigAuthIndexViews() + out := make([]vertexCompatKeyWithAuthIndex, len(h.cfg.VertexCompatAPIKey)) + for i := range h.cfg.VertexCompatAPIKey { + out[i] = vertexCompatKeyWithAuthIndex{ + VertexCompatKey: h.cfg.VertexCompatAPIKey[i], + AuthIndex: views.vertex[i], + } + } + return out +} + +func (h *Handler) openAICompatibilityWithAuthIndex() []openAICompatibilityWithAuthIndex { + if h == nil || h.cfg == nil { + return nil + } + + views := h.buildConfigAuthIndexViews() + normalized := normalizedOpenAICompatibilityEntries(h.cfg.OpenAICompatibility) + out := make([]openAICompatibilityWithAuthIndex, len(normalized)) + for i := range normalized { + entry := normalized[i] + response := openAICompatibilityWithAuthIndex{ + Name: entry.Name, + Priority: entry.Priority, + Prefix: entry.Prefix, + BaseURL: entry.BaseURL, + Models: entry.Models, + Headers: entry.Headers, + AuthIndex: views.openAIFallback[i], + } + if len(entry.APIKeyEntries) > 0 { + response.APIKeyEntries = make([]openAICompatibilityAPIKeyWithAuthIndex, len(entry.APIKeyEntries)) + for j := range entry.APIKeyEntries { + authIndex := "" + if i < len(views.openAIEntries) && j < len(views.openAIEntries[i]) { + authIndex = views.openAIEntries[i][j] + } + response.APIKeyEntries[j] = openAICompatibilityAPIKeyWithAuthIndex{ + OpenAICompatibilityAPIKey: entry.APIKeyEntries[j], + AuthIndex: authIndex, + } + } + } + out[i] = response + } + return out +} diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index fbaad956e0..8d3841335a 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -120,7 +120,7 @@ func (h *Handler) DeleteAPIKeys(c *gin.Context) { // gemini-api-key: []GeminiKey func (h *Handler) GetGeminiKeys(c *gin.Context) { - c.JSON(200, gin.H{"gemini-api-key": h.cfg.GeminiKey}) + c.JSON(200, gin.H{"gemini-api-key": h.geminiKeysWithAuthIndex()}) } func (h *Handler) PutGeminiKeys(c *gin.Context) { data, err := c.GetRawData() @@ -270,7 +270,7 @@ func (h *Handler) DeleteGeminiKey(c *gin.Context) { // claude-api-key: []ClaudeKey func (h *Handler) GetClaudeKeys(c *gin.Context) { - c.JSON(200, gin.H{"claude-api-key": h.cfg.ClaudeKey}) + c.JSON(200, gin.H{"claude-api-key": h.claudeKeysWithAuthIndex()}) } func (h *Handler) PutClaudeKeys(c *gin.Context) { data, err := c.GetRawData() @@ -414,7 +414,7 @@ func (h *Handler) DeleteClaudeKey(c *gin.Context) { // openai-compatibility: []OpenAICompatibility func (h *Handler) GetOpenAICompat(c *gin.Context) { - c.JSON(200, gin.H{"openai-compatibility": normalizedOpenAICompatibilityEntries(h.cfg.OpenAICompatibility)}) + c.JSON(200, gin.H{"openai-compatibility": h.openAICompatibilityWithAuthIndex()}) } func (h *Handler) PutOpenAICompat(c *gin.Context) { data, err := c.GetRawData() @@ -540,7 +540,7 @@ func (h *Handler) DeleteOpenAICompat(c *gin.Context) { // vertex-api-key: []VertexCompatKey func (h *Handler) GetVertexCompatKeys(c *gin.Context) { - c.JSON(200, gin.H{"vertex-api-key": h.cfg.VertexCompatAPIKey}) + c.JSON(200, gin.H{"vertex-api-key": h.vertexCompatKeysWithAuthIndex()}) } func (h *Handler) PutVertexCompatKeys(c *gin.Context) { data, err := c.GetRawData() @@ -886,7 +886,7 @@ func (h *Handler) DeleteOAuthModelAlias(c *gin.Context) { // codex-api-key: []CodexKey func (h *Handler) GetCodexKeys(c *gin.Context) { - c.JSON(200, gin.H{"codex-api-key": h.cfg.CodexKey}) + c.JSON(200, gin.H{"codex-api-key": h.codexKeysWithAuthIndex()}) } func (h *Handler) PutCodexKeys(c *gin.Context) { data, err := c.GetRawData() From c26936e2e61778ed6be40282c8577428c96d8aa4 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sat, 18 Apr 2026 17:12:14 +0800 Subject: [PATCH 06/30] fix(management): stabilize auth-index mapping --- .../handlers/management/config_auth_index.go | 232 ++++++++-------- .../management/config_auth_index_test.go | 250 ++++++++++++++++++ .../api/handlers/management/config_lists.go | 93 +++++-- internal/api/handlers/management/handler.go | 24 +- 4 files changed, 450 insertions(+), 149 deletions(-) create mode 100644 internal/api/handlers/management/config_auth_index_test.go diff --git a/internal/api/handlers/management/config_auth_index.go b/internal/api/handlers/management/config_auth_index.go index 51f71aacf9..ed0b3ec42d 100644 --- a/internal/api/handlers/management/config_auth_index.go +++ b/internal/api/handlers/management/config_auth_index.go @@ -1,22 +1,13 @@ package management import ( + "fmt" "strings" - "time" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/synthesizer" ) -type configAuthIndexViews struct { - gemini []string - claude []string - codex []string - vertex []string - openAIEntries [][]string - openAIFallback []string -} - type geminiKeyWithAuthIndex struct { config.GeminiKey AuthIndex string `json:"auth-index,omitempty"` @@ -53,170 +44,174 @@ type openAICompatibilityWithAuthIndex struct { AuthIndex string `json:"auth-index,omitempty"` } -func (h *Handler) buildConfigAuthIndexViews() configAuthIndexViews { - cfg := h.cfg - if cfg == nil { - return configAuthIndexViews{} - } - - liveIndexByID := map[string]string{} - if h != nil && h.authManager != nil { - for _, auth := range h.authManager.List() { - if auth == nil || strings.TrimSpace(auth.ID) == "" { - continue - } - auth.EnsureIndex() - if auth.Index == "" { - continue - } - liveIndexByID[auth.ID] = auth.Index - } - } - - views := configAuthIndexViews{ - gemini: make([]string, len(cfg.GeminiKey)), - claude: make([]string, len(cfg.ClaudeKey)), - codex: make([]string, len(cfg.CodexKey)), - vertex: make([]string, len(cfg.VertexCompatAPIKey)), - openAIEntries: make([][]string, len(cfg.OpenAICompatibility)), - openAIFallback: make([]string, len(cfg.OpenAICompatibility)), - } - - auths, errSynthesize := synthesizer.NewConfigSynthesizer().Synthesize(&synthesizer.SynthesisContext{ - Config: cfg, - Now: time.Now(), - IDGenerator: synthesizer.NewStableIDGenerator(), - }) - if errSynthesize != nil { - return views - } - - cursor := 0 - nextAuthIndex := func() string { - if cursor >= len(auths) { - return "" - } - auth := auths[cursor] - cursor++ - if auth == nil || strings.TrimSpace(auth.ID) == "" { - return "" - } - // Do not expose an auth-index until it is present in the live auth manager. - // API tools resolve auth_index against h.authManager.List(), so returning - // config-only indexes can temporarily break tool calls around config edits. - return liveIndexByID[auth.ID] +func (h *Handler) liveAuthIndexByID() map[string]string { + out := map[string]string{} + if h == nil { + return out } - - for i := range cfg.GeminiKey { - if strings.TrimSpace(cfg.GeminiKey[i].APIKey) == "" { - continue - } - views.gemini[i] = nextAuthIndex() + h.mu.Lock() + manager := h.authManager + h.mu.Unlock() + if manager == nil { + return out } - for i := range cfg.ClaudeKey { - if strings.TrimSpace(cfg.ClaudeKey[i].APIKey) == "" { + // authManager.List() returns clones, so EnsureIndex only affects these copies. + for _, auth := range manager.List() { + if auth == nil { continue } - views.claude[i] = nextAuthIndex() - } - for i := range cfg.CodexKey { - if strings.TrimSpace(cfg.CodexKey[i].APIKey) == "" { + id := strings.TrimSpace(auth.ID) + if id == "" { continue } - views.codex[i] = nextAuthIndex() - } - for i := range cfg.OpenAICompatibility { - entries := cfg.OpenAICompatibility[i].APIKeyEntries - if len(entries) == 0 { - views.openAIFallback[i] = nextAuthIndex() - continue - } - - views.openAIEntries[i] = make([]string, len(entries)) - for j := range entries { - views.openAIEntries[i][j] = nextAuthIndex() + idx := strings.TrimSpace(auth.Index) + if idx == "" { + idx = auth.EnsureIndex() } - } - for i := range cfg.VertexCompatAPIKey { - if strings.TrimSpace(cfg.VertexCompatAPIKey[i].APIKey) == "" { + if idx == "" { continue } - views.vertex[i] = nextAuthIndex() + out[id] = idx } - - return views + return out } func (h *Handler) geminiKeysWithAuthIndex() []geminiKeyWithAuthIndex { - if h == nil || h.cfg == nil { + if h == nil { return nil } - views := h.buildConfigAuthIndexViews() + liveIndexByID := h.liveAuthIndexByID() + + h.mu.Lock() + defer h.mu.Unlock() + if h.cfg == nil { + return nil + } + + idGen := synthesizer.NewStableIDGenerator() out := make([]geminiKeyWithAuthIndex, len(h.cfg.GeminiKey)) for i := range h.cfg.GeminiKey { + entry := h.cfg.GeminiKey[i] + authIndex := "" + if key := strings.TrimSpace(entry.APIKey); key != "" { + id, _ := idGen.Next("gemini:apikey", key, entry.BaseURL) + authIndex = liveIndexByID[id] + } out[i] = geminiKeyWithAuthIndex{ - GeminiKey: h.cfg.GeminiKey[i], - AuthIndex: views.gemini[i], + GeminiKey: entry, + AuthIndex: authIndex, } } return out } func (h *Handler) claudeKeysWithAuthIndex() []claudeKeyWithAuthIndex { - if h == nil || h.cfg == nil { + if h == nil { return nil } - views := h.buildConfigAuthIndexViews() + liveIndexByID := h.liveAuthIndexByID() + + h.mu.Lock() + defer h.mu.Unlock() + if h.cfg == nil { + return nil + } + + idGen := synthesizer.NewStableIDGenerator() out := make([]claudeKeyWithAuthIndex, len(h.cfg.ClaudeKey)) for i := range h.cfg.ClaudeKey { + entry := h.cfg.ClaudeKey[i] + authIndex := "" + if key := strings.TrimSpace(entry.APIKey); key != "" { + id, _ := idGen.Next("claude:apikey", key, entry.BaseURL) + authIndex = liveIndexByID[id] + } out[i] = claudeKeyWithAuthIndex{ - ClaudeKey: h.cfg.ClaudeKey[i], - AuthIndex: views.claude[i], + ClaudeKey: entry, + AuthIndex: authIndex, } } return out } func (h *Handler) codexKeysWithAuthIndex() []codexKeyWithAuthIndex { - if h == nil || h.cfg == nil { + if h == nil { + return nil + } + liveIndexByID := h.liveAuthIndexByID() + + h.mu.Lock() + defer h.mu.Unlock() + if h.cfg == nil { return nil } - views := h.buildConfigAuthIndexViews() + + idGen := synthesizer.NewStableIDGenerator() out := make([]codexKeyWithAuthIndex, len(h.cfg.CodexKey)) for i := range h.cfg.CodexKey { + entry := h.cfg.CodexKey[i] + authIndex := "" + if key := strings.TrimSpace(entry.APIKey); key != "" { + id, _ := idGen.Next("codex:apikey", key, entry.BaseURL) + authIndex = liveIndexByID[id] + } out[i] = codexKeyWithAuthIndex{ - CodexKey: h.cfg.CodexKey[i], - AuthIndex: views.codex[i], + CodexKey: entry, + AuthIndex: authIndex, } } return out } func (h *Handler) vertexCompatKeysWithAuthIndex() []vertexCompatKeyWithAuthIndex { - if h == nil || h.cfg == nil { + if h == nil { + return nil + } + liveIndexByID := h.liveAuthIndexByID() + + h.mu.Lock() + defer h.mu.Unlock() + if h.cfg == nil { return nil } - views := h.buildConfigAuthIndexViews() + + idGen := synthesizer.NewStableIDGenerator() out := make([]vertexCompatKeyWithAuthIndex, len(h.cfg.VertexCompatAPIKey)) for i := range h.cfg.VertexCompatAPIKey { + entry := h.cfg.VertexCompatAPIKey[i] + id, _ := idGen.Next("vertex:apikey", entry.APIKey, entry.BaseURL, entry.ProxyURL) + authIndex := liveIndexByID[id] out[i] = vertexCompatKeyWithAuthIndex{ - VertexCompatKey: h.cfg.VertexCompatAPIKey[i], - AuthIndex: views.vertex[i], + VertexCompatKey: entry, + AuthIndex: authIndex, } } return out } func (h *Handler) openAICompatibilityWithAuthIndex() []openAICompatibilityWithAuthIndex { - if h == nil || h.cfg == nil { + if h == nil { + return nil + } + liveIndexByID := h.liveAuthIndexByID() + + h.mu.Lock() + defer h.mu.Unlock() + if h.cfg == nil { return nil } - views := h.buildConfigAuthIndexViews() normalized := normalizedOpenAICompatibilityEntries(h.cfg.OpenAICompatibility) out := make([]openAICompatibilityWithAuthIndex, len(normalized)) + idGen := synthesizer.NewStableIDGenerator() for i := range normalized { entry := normalized[i] + providerName := strings.ToLower(strings.TrimSpace(entry.Name)) + if providerName == "" { + providerName = "openai-compatibility" + } + idKind := fmt.Sprintf("openai-compatibility:%s", providerName) + response := openAICompatibilityWithAuthIndex{ Name: entry.Name, Priority: entry.Priority, @@ -224,18 +219,19 @@ func (h *Handler) openAICompatibilityWithAuthIndex() []openAICompatibilityWithAu BaseURL: entry.BaseURL, Models: entry.Models, Headers: entry.Headers, - AuthIndex: views.openAIFallback[i], + AuthIndex: "", } - if len(entry.APIKeyEntries) > 0 { + if len(entry.APIKeyEntries) == 0 { + id, _ := idGen.Next(idKind, entry.BaseURL) + response.AuthIndex = liveIndexByID[id] + } else { response.APIKeyEntries = make([]openAICompatibilityAPIKeyWithAuthIndex, len(entry.APIKeyEntries)) for j := range entry.APIKeyEntries { - authIndex := "" - if i < len(views.openAIEntries) && j < len(views.openAIEntries[i]) { - authIndex = views.openAIEntries[i][j] - } + apiKeyEntry := entry.APIKeyEntries[j] + id, _ := idGen.Next(idKind, apiKeyEntry.APIKey, entry.BaseURL, apiKeyEntry.ProxyURL) response.APIKeyEntries[j] = openAICompatibilityAPIKeyWithAuthIndex{ - OpenAICompatibilityAPIKey: entry.APIKeyEntries[j], - AuthIndex: authIndex, + OpenAICompatibilityAPIKey: apiKeyEntry, + AuthIndex: liveIndexByID[id], } } } diff --git a/internal/api/handlers/management/config_auth_index_test.go b/internal/api/handlers/management/config_auth_index_test.go new file mode 100644 index 0000000000..b7c9809011 --- /dev/null +++ b/internal/api/handlers/management/config_auth_index_test.go @@ -0,0 +1,250 @@ +package management + +import ( + "context" + "testing" + "time" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/synthesizer" + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" +) + +func synthesizeConfigAuths(t *testing.T, cfg *config.Config) []*coreauth.Auth { + t.Helper() + + auths, errSynthesize := synthesizer.NewConfigSynthesizer().Synthesize(&synthesizer.SynthesisContext{ + Config: cfg, + Now: time.Unix(0, 0), + IDGenerator: synthesizer.NewStableIDGenerator(), + }) + if errSynthesize != nil { + t.Fatalf("synthesize config auths: %v", errSynthesize) + } + return auths +} + +func findAuth(t *testing.T, auths []*coreauth.Auth, predicate func(*coreauth.Auth) bool) *coreauth.Auth { + t.Helper() + for _, auth := range auths { + if predicate(auth) { + return auth + } + } + return nil +} + +func TestConfigAuthIndexResolvesLiveIndexes(t *testing.T) { + t.Parallel() + + cfg := &config.Config{ + GeminiKey: []config.GeminiKey{ + {APIKey: "shared-key", BaseURL: "https://a.example.com"}, + {APIKey: "shared-key", BaseURL: "https://b.example.com"}, + }, + ClaudeKey: []config.ClaudeKey{ + {APIKey: "claude-key", BaseURL: "https://claude.example.com"}, + }, + CodexKey: []config.CodexKey{ + {APIKey: "codex-key", BaseURL: "https://codex.example.com/v1"}, + }, + VertexCompatAPIKey: []config.VertexCompatKey{ + {APIKey: "vertex-key", BaseURL: "https://vertex.example.com", ProxyURL: "http://proxy.example.com:8080"}, + }, + OpenAICompatibility: []config.OpenAICompatibility{ + { + Name: "bohe", + BaseURL: "https://bohe.example.com/v1", + APIKeyEntries: []config.OpenAICompatibilityAPIKey{ + {APIKey: "compat-key"}, + }, + }, + }, + } + + auths := synthesizeConfigAuths(t, cfg) + manager := coreauth.NewManager(nil, nil, nil) + for _, auth := range auths { + if auth == nil { + continue + } + if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil { + t.Fatalf("register auth %q: %v", auth.ID, errRegister) + } + } + + h := &Handler{cfg: cfg, authManager: manager} + + geminiAuthA := findAuth(t, auths, func(auth *coreauth.Auth) bool { + if auth == nil { + return false + } + return auth.Provider == "gemini" && auth.Attributes["api_key"] == "shared-key" && auth.Attributes["base_url"] == "https://a.example.com" + }) + if geminiAuthA == nil { + t.Fatal("expected synthesized gemini auth (base a)") + } + geminiAuthB := findAuth(t, auths, func(auth *coreauth.Auth) bool { + if auth == nil { + return false + } + return auth.Provider == "gemini" && auth.Attributes["api_key"] == "shared-key" && auth.Attributes["base_url"] == "https://b.example.com" + }) + if geminiAuthB == nil { + t.Fatal("expected synthesized gemini auth (base b)") + } + + gemini := h.geminiKeysWithAuthIndex() + if len(gemini) != 2 { + t.Fatalf("gemini keys = %d, want 2", len(gemini)) + } + if got, want := gemini[0].AuthIndex, geminiAuthA.EnsureIndex(); got != want { + t.Fatalf("gemini[0] auth-index = %q, want %q", got, want) + } + if got, want := gemini[1].AuthIndex, geminiAuthB.EnsureIndex(); got != want { + t.Fatalf("gemini[1] auth-index = %q, want %q", got, want) + } + if gemini[0].AuthIndex == gemini[1].AuthIndex { + t.Fatalf("duplicate gemini entries returned the same auth-index %q", gemini[0].AuthIndex) + } + + claudeAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool { + if auth == nil { + return false + } + return auth.Provider == "claude" && auth.Attributes["api_key"] == "claude-key" + }) + if claudeAuth == nil { + t.Fatal("expected synthesized claude auth") + } + + claude := h.claudeKeysWithAuthIndex() + if len(claude) != 1 { + t.Fatalf("claude keys = %d, want 1", len(claude)) + } + if got, want := claude[0].AuthIndex, claudeAuth.EnsureIndex(); got != want { + t.Fatalf("claude auth-index = %q, want %q", got, want) + } + + codexAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool { + if auth == nil { + return false + } + return auth.Provider == "codex" && auth.Attributes["api_key"] == "codex-key" + }) + if codexAuth == nil { + t.Fatal("expected synthesized codex auth") + } + + codex := h.codexKeysWithAuthIndex() + if len(codex) != 1 { + t.Fatalf("codex keys = %d, want 1", len(codex)) + } + if got, want := codex[0].AuthIndex, codexAuth.EnsureIndex(); got != want { + t.Fatalf("codex auth-index = %q, want %q", got, want) + } + + vertexAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool { + if auth == nil { + return false + } + return auth.Provider == "vertex" && auth.Attributes["api_key"] == "vertex-key" + }) + if vertexAuth == nil { + t.Fatal("expected synthesized vertex auth") + } + + vertex := h.vertexCompatKeysWithAuthIndex() + if len(vertex) != 1 { + t.Fatalf("vertex keys = %d, want 1", len(vertex)) + } + if got, want := vertex[0].AuthIndex, vertexAuth.EnsureIndex(); got != want { + t.Fatalf("vertex auth-index = %q, want %q", got, want) + } + + compatAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool { + if auth == nil { + return false + } + if auth.Provider != "bohe" { + return false + } + if auth.Attributes["provider_key"] != "bohe" || auth.Attributes["compat_name"] != "bohe" { + return false + } + return auth.Attributes["api_key"] == "compat-key" + }) + if compatAuth == nil { + t.Fatal("expected synthesized openai-compat auth") + } + + compat := h.openAICompatibilityWithAuthIndex() + if len(compat) != 1 { + t.Fatalf("openai-compat providers = %d, want 1", len(compat)) + } + if len(compat[0].APIKeyEntries) != 1 { + t.Fatalf("openai-compat api-key-entries = %d, want 1", len(compat[0].APIKeyEntries)) + } + if compat[0].AuthIndex != "" { + t.Fatalf("provider-level auth-index should be empty when api-key-entries exist, got %q", compat[0].AuthIndex) + } + if got, want := compat[0].APIKeyEntries[0].AuthIndex, compatAuth.EnsureIndex(); got != want { + t.Fatalf("openai-compat auth-index = %q, want %q", got, want) + } +} + +func TestConfigAuthIndexOmitsIndexesNotInManager(t *testing.T) { + t.Parallel() + + cfg := &config.Config{ + GeminiKey: []config.GeminiKey{ + {APIKey: "gemini-key", BaseURL: "https://a.example.com"}, + }, + OpenAICompatibility: []config.OpenAICompatibility{ + { + Name: "bohe", + BaseURL: "https://bohe.example.com/v1", + APIKeyEntries: []config.OpenAICompatibilityAPIKey{ + {APIKey: "compat-key"}, + }, + }, + }, + } + + auths := synthesizeConfigAuths(t, cfg) + geminiAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool { + if auth == nil { + return false + } + return auth.Provider == "gemini" && auth.Attributes["api_key"] == "gemini-key" + }) + if geminiAuth == nil { + t.Fatal("expected synthesized gemini auth") + } + + manager := coreauth.NewManager(nil, nil, nil) + if _, errRegister := manager.Register(context.Background(), geminiAuth); errRegister != nil { + t.Fatalf("register gemini auth: %v", errRegister) + } + + h := &Handler{cfg: cfg, authManager: manager} + + gemini := h.geminiKeysWithAuthIndex() + if len(gemini) != 1 { + t.Fatalf("gemini keys = %d, want 1", len(gemini)) + } + if gemini[0].AuthIndex == "" { + t.Fatal("expected gemini auth-index to be set") + } + + compat := h.openAICompatibilityWithAuthIndex() + if len(compat) != 1 { + t.Fatalf("openai-compat providers = %d, want 1", len(compat)) + } + if len(compat[0].APIKeyEntries) != 1 { + t.Fatalf("openai-compat api-key-entries = %d, want 1", len(compat[0].APIKeyEntries)) + } + if compat[0].APIKeyEntries[0].AuthIndex != "" { + t.Fatalf("openai-compat auth-index = %q, want empty", compat[0].APIKeyEntries[0].AuthIndex) + } +} diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index 8d3841335a..ee3a4714b8 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -139,9 +139,11 @@ func (h *Handler) PutGeminiKeys(c *gin.Context) { } arr = obj.Items } + h.mu.Lock() + defer h.mu.Unlock() h.cfg.GeminiKey = append([]config.GeminiKey(nil), arr...) h.cfg.SanitizeGeminiKeys() - h.persist(c) + h.persistLocked(c) } func (h *Handler) PatchGeminiKey(c *gin.Context) { type geminiKeyPatch struct { @@ -161,6 +163,9 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) { c.JSON(400, gin.H{"error": "invalid body"}) return } + + h.mu.Lock() + defer h.mu.Unlock() targetIndex := -1 if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.GeminiKey) { targetIndex = *body.Index @@ -187,7 +192,7 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) { if trimmed == "" { h.cfg.GeminiKey = append(h.cfg.GeminiKey[:targetIndex], h.cfg.GeminiKey[targetIndex+1:]...) h.cfg.SanitizeGeminiKeys() - h.persist(c) + h.persistLocked(c) return } entry.APIKey = trimmed @@ -209,10 +214,12 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) { } h.cfg.GeminiKey[targetIndex] = entry h.cfg.SanitizeGeminiKeys() - h.persist(c) + h.persistLocked(c) } func (h *Handler) DeleteGeminiKey(c *gin.Context) { + h.mu.Lock() + defer h.mu.Unlock() if val := strings.TrimSpace(c.Query("api-key")); val != "" { if baseRaw, okBase := c.GetQuery("base-url"); okBase { base := strings.TrimSpace(baseRaw) @@ -226,7 +233,7 @@ func (h *Handler) DeleteGeminiKey(c *gin.Context) { if len(out) != len(h.cfg.GeminiKey) { h.cfg.GeminiKey = out h.cfg.SanitizeGeminiKeys() - h.persist(c) + h.persistLocked(c) } else { c.JSON(404, gin.H{"error": "item not found"}) } @@ -253,7 +260,7 @@ func (h *Handler) DeleteGeminiKey(c *gin.Context) { } h.cfg.GeminiKey = append(h.cfg.GeminiKey[:matchIndex], h.cfg.GeminiKey[matchIndex+1:]...) h.cfg.SanitizeGeminiKeys() - h.persist(c) + h.persistLocked(c) return } if idxStr := c.Query("index"); idxStr != "" { @@ -261,7 +268,7 @@ func (h *Handler) DeleteGeminiKey(c *gin.Context) { if _, err := fmt.Sscanf(idxStr, "%d", &idx); err == nil && idx >= 0 && idx < len(h.cfg.GeminiKey) { h.cfg.GeminiKey = append(h.cfg.GeminiKey[:idx], h.cfg.GeminiKey[idx+1:]...) h.cfg.SanitizeGeminiKeys() - h.persist(c) + h.persistLocked(c) return } } @@ -292,9 +299,11 @@ func (h *Handler) PutClaudeKeys(c *gin.Context) { for i := range arr { normalizeClaudeKey(&arr[i]) } + h.mu.Lock() + defer h.mu.Unlock() h.cfg.ClaudeKey = arr h.cfg.SanitizeClaudeKeys() - h.persist(c) + h.persistLocked(c) } func (h *Handler) PatchClaudeKey(c *gin.Context) { type claudeKeyPatch struct { @@ -315,6 +324,9 @@ func (h *Handler) PatchClaudeKey(c *gin.Context) { c.JSON(400, gin.H{"error": "invalid body"}) return } + + h.mu.Lock() + defer h.mu.Unlock() targetIndex := -1 if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.ClaudeKey) { targetIndex = *body.Index @@ -358,10 +370,12 @@ func (h *Handler) PatchClaudeKey(c *gin.Context) { normalizeClaudeKey(&entry) h.cfg.ClaudeKey[targetIndex] = entry h.cfg.SanitizeClaudeKeys() - h.persist(c) + h.persistLocked(c) } func (h *Handler) DeleteClaudeKey(c *gin.Context) { + h.mu.Lock() + defer h.mu.Unlock() if val := strings.TrimSpace(c.Query("api-key")); val != "" { if baseRaw, okBase := c.GetQuery("base-url"); okBase { base := strings.TrimSpace(baseRaw) @@ -374,7 +388,7 @@ func (h *Handler) DeleteClaudeKey(c *gin.Context) { } h.cfg.ClaudeKey = out h.cfg.SanitizeClaudeKeys() - h.persist(c) + h.persistLocked(c) return } @@ -396,7 +410,7 @@ func (h *Handler) DeleteClaudeKey(c *gin.Context) { h.cfg.ClaudeKey = append(h.cfg.ClaudeKey[:matchIndex], h.cfg.ClaudeKey[matchIndex+1:]...) } h.cfg.SanitizeClaudeKeys() - h.persist(c) + h.persistLocked(c) return } if idxStr := c.Query("index"); idxStr != "" { @@ -405,7 +419,7 @@ func (h *Handler) DeleteClaudeKey(c *gin.Context) { if err == nil && idx >= 0 && idx < len(h.cfg.ClaudeKey) { h.cfg.ClaudeKey = append(h.cfg.ClaudeKey[:idx], h.cfg.ClaudeKey[idx+1:]...) h.cfg.SanitizeClaudeKeys() - h.persist(c) + h.persistLocked(c) return } } @@ -440,9 +454,11 @@ func (h *Handler) PutOpenAICompat(c *gin.Context) { filtered = append(filtered, arr[i]) } } + h.mu.Lock() + defer h.mu.Unlock() h.cfg.OpenAICompatibility = filtered h.cfg.SanitizeOpenAICompatibility() - h.persist(c) + h.persistLocked(c) } func (h *Handler) PatchOpenAICompat(c *gin.Context) { type openAICompatPatch struct { @@ -462,6 +478,9 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) { c.JSON(400, gin.H{"error": "invalid body"}) return } + + h.mu.Lock() + defer h.mu.Unlock() targetIndex := -1 if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) { targetIndex = *body.Index @@ -492,7 +511,7 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) { if trimmed == "" { h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:targetIndex], h.cfg.OpenAICompatibility[targetIndex+1:]...) h.cfg.SanitizeOpenAICompatibility() - h.persist(c) + h.persistLocked(c) return } entry.BaseURL = trimmed @@ -509,10 +528,12 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) { normalizeOpenAICompatibilityEntry(&entry) h.cfg.OpenAICompatibility[targetIndex] = entry h.cfg.SanitizeOpenAICompatibility() - h.persist(c) + h.persistLocked(c) } func (h *Handler) DeleteOpenAICompat(c *gin.Context) { + h.mu.Lock() + defer h.mu.Unlock() if name := c.Query("name"); name != "" { out := make([]config.OpenAICompatibility, 0, len(h.cfg.OpenAICompatibility)) for _, v := range h.cfg.OpenAICompatibility { @@ -522,7 +543,7 @@ func (h *Handler) DeleteOpenAICompat(c *gin.Context) { } h.cfg.OpenAICompatibility = out h.cfg.SanitizeOpenAICompatibility() - h.persist(c) + h.persistLocked(c) return } if idxStr := c.Query("index"); idxStr != "" { @@ -531,7 +552,7 @@ func (h *Handler) DeleteOpenAICompat(c *gin.Context) { if err == nil && idx >= 0 && idx < len(h.cfg.OpenAICompatibility) { h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:idx], h.cfg.OpenAICompatibility[idx+1:]...) h.cfg.SanitizeOpenAICompatibility() - h.persist(c) + h.persistLocked(c) return } } @@ -566,9 +587,11 @@ func (h *Handler) PutVertexCompatKeys(c *gin.Context) { return } } + h.mu.Lock() + defer h.mu.Unlock() h.cfg.VertexCompatAPIKey = append([]config.VertexCompatKey(nil), arr...) h.cfg.SanitizeVertexCompatKeys() - h.persist(c) + h.persistLocked(c) } func (h *Handler) PatchVertexCompatKey(c *gin.Context) { type vertexCompatPatch struct { @@ -589,6 +612,9 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) { c.JSON(400, gin.H{"error": "invalid body"}) return } + + h.mu.Lock() + defer h.mu.Unlock() targetIndex := -1 if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.VertexCompatAPIKey) { targetIndex = *body.Index @@ -615,7 +641,7 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) { if trimmed == "" { h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:targetIndex], h.cfg.VertexCompatAPIKey[targetIndex+1:]...) h.cfg.SanitizeVertexCompatKeys() - h.persist(c) + h.persistLocked(c) return } entry.APIKey = trimmed @@ -628,7 +654,7 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) { if trimmed == "" { h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:targetIndex], h.cfg.VertexCompatAPIKey[targetIndex+1:]...) h.cfg.SanitizeVertexCompatKeys() - h.persist(c) + h.persistLocked(c) return } entry.BaseURL = trimmed @@ -648,10 +674,12 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) { normalizeVertexCompatKey(&entry) h.cfg.VertexCompatAPIKey[targetIndex] = entry h.cfg.SanitizeVertexCompatKeys() - h.persist(c) + h.persistLocked(c) } func (h *Handler) DeleteVertexCompatKey(c *gin.Context) { + h.mu.Lock() + defer h.mu.Unlock() if val := strings.TrimSpace(c.Query("api-key")); val != "" { if baseRaw, okBase := c.GetQuery("base-url"); okBase { base := strings.TrimSpace(baseRaw) @@ -664,7 +692,7 @@ func (h *Handler) DeleteVertexCompatKey(c *gin.Context) { } h.cfg.VertexCompatAPIKey = out h.cfg.SanitizeVertexCompatKeys() - h.persist(c) + h.persistLocked(c) return } @@ -686,7 +714,7 @@ func (h *Handler) DeleteVertexCompatKey(c *gin.Context) { h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:matchIndex], h.cfg.VertexCompatAPIKey[matchIndex+1:]...) } h.cfg.SanitizeVertexCompatKeys() - h.persist(c) + h.persistLocked(c) return } if idxStr := c.Query("index"); idxStr != "" { @@ -695,7 +723,7 @@ func (h *Handler) DeleteVertexCompatKey(c *gin.Context) { if errScan == nil && idx >= 0 && idx < len(h.cfg.VertexCompatAPIKey) { h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:idx], h.cfg.VertexCompatAPIKey[idx+1:]...) h.cfg.SanitizeVertexCompatKeys() - h.persist(c) + h.persistLocked(c) return } } @@ -915,9 +943,11 @@ func (h *Handler) PutCodexKeys(c *gin.Context) { } filtered = append(filtered, entry) } + h.mu.Lock() + defer h.mu.Unlock() h.cfg.CodexKey = filtered h.cfg.SanitizeCodexKeys() - h.persist(c) + h.persistLocked(c) } func (h *Handler) PatchCodexKey(c *gin.Context) { type codexKeyPatch struct { @@ -938,6 +968,9 @@ func (h *Handler) PatchCodexKey(c *gin.Context) { c.JSON(400, gin.H{"error": "invalid body"}) return } + + h.mu.Lock() + defer h.mu.Unlock() targetIndex := -1 if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) { targetIndex = *body.Index @@ -968,7 +1001,7 @@ func (h *Handler) PatchCodexKey(c *gin.Context) { if trimmed == "" { h.cfg.CodexKey = append(h.cfg.CodexKey[:targetIndex], h.cfg.CodexKey[targetIndex+1:]...) h.cfg.SanitizeCodexKeys() - h.persist(c) + h.persistLocked(c) return } entry.BaseURL = trimmed @@ -988,10 +1021,12 @@ func (h *Handler) PatchCodexKey(c *gin.Context) { normalizeCodexKey(&entry) h.cfg.CodexKey[targetIndex] = entry h.cfg.SanitizeCodexKeys() - h.persist(c) + h.persistLocked(c) } func (h *Handler) DeleteCodexKey(c *gin.Context) { + h.mu.Lock() + defer h.mu.Unlock() if val := strings.TrimSpace(c.Query("api-key")); val != "" { if baseRaw, okBase := c.GetQuery("base-url"); okBase { base := strings.TrimSpace(baseRaw) @@ -1004,7 +1039,7 @@ func (h *Handler) DeleteCodexKey(c *gin.Context) { } h.cfg.CodexKey = out h.cfg.SanitizeCodexKeys() - h.persist(c) + h.persistLocked(c) return } @@ -1026,7 +1061,7 @@ func (h *Handler) DeleteCodexKey(c *gin.Context) { h.cfg.CodexKey = append(h.cfg.CodexKey[:matchIndex], h.cfg.CodexKey[matchIndex+1:]...) } h.cfg.SanitizeCodexKeys() - h.persist(c) + h.persistLocked(c) return } if idxStr := c.Query("index"); idxStr != "" { @@ -1035,7 +1070,7 @@ func (h *Handler) DeleteCodexKey(c *gin.Context) { if err == nil && idx >= 0 && idx < len(h.cfg.CodexKey) { h.cfg.CodexKey = append(h.cfg.CodexKey[:idx], h.cfg.CodexKey[idx+1:]...) h.cfg.SanitizeCodexKeys() - h.persist(c) + h.persistLocked(c) return } } diff --git a/internal/api/handlers/management/handler.go b/internal/api/handlers/management/handler.go index 45786b9d3e..30cc973817 100644 --- a/internal/api/handlers/management/handler.go +++ b/internal/api/handlers/management/handler.go @@ -105,10 +105,24 @@ func NewHandlerWithoutConfigFilePath(cfg *config.Config, manager *coreauth.Manag } // SetConfig updates the in-memory config reference when the server hot-reloads. -func (h *Handler) SetConfig(cfg *config.Config) { h.cfg = cfg } +func (h *Handler) SetConfig(cfg *config.Config) { + if h == nil { + return + } + h.mu.Lock() + h.cfg = cfg + h.mu.Unlock() +} // SetAuthManager updates the auth manager reference used by management endpoints. -func (h *Handler) SetAuthManager(manager *coreauth.Manager) { h.authManager = manager } +func (h *Handler) SetAuthManager(manager *coreauth.Manager) { + if h == nil { + return + } + h.mu.Lock() + h.authManager = manager + h.mu.Unlock() +} // SetUsageStatistics allows replacing the usage statistics reference. func (h *Handler) SetUsageStatistics(stats *usage.RequestStatistics) { h.usageStats = stats } @@ -276,6 +290,12 @@ func (h *Handler) Middleware() gin.HandlerFunc { func (h *Handler) persist(c *gin.Context) bool { h.mu.Lock() defer h.mu.Unlock() + return h.persistLocked(c) +} + +// persistLocked saves the current in-memory config to disk. +// It expects the caller to hold h.mu. +func (h *Handler) persistLocked(c *gin.Context) bool { // Preserve comments when writing if err := config.SaveConfigPreserveComments(h.configFilePath, h.cfg); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save config: %v", err)}) From a64141a9a6a7628db3b994eed9762aaf6f770727 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sat, 18 Apr 2026 17:22:16 +0800 Subject: [PATCH 07/30] fix(tests): remove obsolete config_auth_index_test file --- .../management/config_auth_index_test.go | 250 ------------------ 1 file changed, 250 deletions(-) delete mode 100644 internal/api/handlers/management/config_auth_index_test.go diff --git a/internal/api/handlers/management/config_auth_index_test.go b/internal/api/handlers/management/config_auth_index_test.go deleted file mode 100644 index b7c9809011..0000000000 --- a/internal/api/handlers/management/config_auth_index_test.go +++ /dev/null @@ -1,250 +0,0 @@ -package management - -import ( - "context" - "testing" - "time" - - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/synthesizer" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" -) - -func synthesizeConfigAuths(t *testing.T, cfg *config.Config) []*coreauth.Auth { - t.Helper() - - auths, errSynthesize := synthesizer.NewConfigSynthesizer().Synthesize(&synthesizer.SynthesisContext{ - Config: cfg, - Now: time.Unix(0, 0), - IDGenerator: synthesizer.NewStableIDGenerator(), - }) - if errSynthesize != nil { - t.Fatalf("synthesize config auths: %v", errSynthesize) - } - return auths -} - -func findAuth(t *testing.T, auths []*coreauth.Auth, predicate func(*coreauth.Auth) bool) *coreauth.Auth { - t.Helper() - for _, auth := range auths { - if predicate(auth) { - return auth - } - } - return nil -} - -func TestConfigAuthIndexResolvesLiveIndexes(t *testing.T) { - t.Parallel() - - cfg := &config.Config{ - GeminiKey: []config.GeminiKey{ - {APIKey: "shared-key", BaseURL: "https://a.example.com"}, - {APIKey: "shared-key", BaseURL: "https://b.example.com"}, - }, - ClaudeKey: []config.ClaudeKey{ - {APIKey: "claude-key", BaseURL: "https://claude.example.com"}, - }, - CodexKey: []config.CodexKey{ - {APIKey: "codex-key", BaseURL: "https://codex.example.com/v1"}, - }, - VertexCompatAPIKey: []config.VertexCompatKey{ - {APIKey: "vertex-key", BaseURL: "https://vertex.example.com", ProxyURL: "http://proxy.example.com:8080"}, - }, - OpenAICompatibility: []config.OpenAICompatibility{ - { - Name: "bohe", - BaseURL: "https://bohe.example.com/v1", - APIKeyEntries: []config.OpenAICompatibilityAPIKey{ - {APIKey: "compat-key"}, - }, - }, - }, - } - - auths := synthesizeConfigAuths(t, cfg) - manager := coreauth.NewManager(nil, nil, nil) - for _, auth := range auths { - if auth == nil { - continue - } - if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil { - t.Fatalf("register auth %q: %v", auth.ID, errRegister) - } - } - - h := &Handler{cfg: cfg, authManager: manager} - - geminiAuthA := findAuth(t, auths, func(auth *coreauth.Auth) bool { - if auth == nil { - return false - } - return auth.Provider == "gemini" && auth.Attributes["api_key"] == "shared-key" && auth.Attributes["base_url"] == "https://a.example.com" - }) - if geminiAuthA == nil { - t.Fatal("expected synthesized gemini auth (base a)") - } - geminiAuthB := findAuth(t, auths, func(auth *coreauth.Auth) bool { - if auth == nil { - return false - } - return auth.Provider == "gemini" && auth.Attributes["api_key"] == "shared-key" && auth.Attributes["base_url"] == "https://b.example.com" - }) - if geminiAuthB == nil { - t.Fatal("expected synthesized gemini auth (base b)") - } - - gemini := h.geminiKeysWithAuthIndex() - if len(gemini) != 2 { - t.Fatalf("gemini keys = %d, want 2", len(gemini)) - } - if got, want := gemini[0].AuthIndex, geminiAuthA.EnsureIndex(); got != want { - t.Fatalf("gemini[0] auth-index = %q, want %q", got, want) - } - if got, want := gemini[1].AuthIndex, geminiAuthB.EnsureIndex(); got != want { - t.Fatalf("gemini[1] auth-index = %q, want %q", got, want) - } - if gemini[0].AuthIndex == gemini[1].AuthIndex { - t.Fatalf("duplicate gemini entries returned the same auth-index %q", gemini[0].AuthIndex) - } - - claudeAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool { - if auth == nil { - return false - } - return auth.Provider == "claude" && auth.Attributes["api_key"] == "claude-key" - }) - if claudeAuth == nil { - t.Fatal("expected synthesized claude auth") - } - - claude := h.claudeKeysWithAuthIndex() - if len(claude) != 1 { - t.Fatalf("claude keys = %d, want 1", len(claude)) - } - if got, want := claude[0].AuthIndex, claudeAuth.EnsureIndex(); got != want { - t.Fatalf("claude auth-index = %q, want %q", got, want) - } - - codexAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool { - if auth == nil { - return false - } - return auth.Provider == "codex" && auth.Attributes["api_key"] == "codex-key" - }) - if codexAuth == nil { - t.Fatal("expected synthesized codex auth") - } - - codex := h.codexKeysWithAuthIndex() - if len(codex) != 1 { - t.Fatalf("codex keys = %d, want 1", len(codex)) - } - if got, want := codex[0].AuthIndex, codexAuth.EnsureIndex(); got != want { - t.Fatalf("codex auth-index = %q, want %q", got, want) - } - - vertexAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool { - if auth == nil { - return false - } - return auth.Provider == "vertex" && auth.Attributes["api_key"] == "vertex-key" - }) - if vertexAuth == nil { - t.Fatal("expected synthesized vertex auth") - } - - vertex := h.vertexCompatKeysWithAuthIndex() - if len(vertex) != 1 { - t.Fatalf("vertex keys = %d, want 1", len(vertex)) - } - if got, want := vertex[0].AuthIndex, vertexAuth.EnsureIndex(); got != want { - t.Fatalf("vertex auth-index = %q, want %q", got, want) - } - - compatAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool { - if auth == nil { - return false - } - if auth.Provider != "bohe" { - return false - } - if auth.Attributes["provider_key"] != "bohe" || auth.Attributes["compat_name"] != "bohe" { - return false - } - return auth.Attributes["api_key"] == "compat-key" - }) - if compatAuth == nil { - t.Fatal("expected synthesized openai-compat auth") - } - - compat := h.openAICompatibilityWithAuthIndex() - if len(compat) != 1 { - t.Fatalf("openai-compat providers = %d, want 1", len(compat)) - } - if len(compat[0].APIKeyEntries) != 1 { - t.Fatalf("openai-compat api-key-entries = %d, want 1", len(compat[0].APIKeyEntries)) - } - if compat[0].AuthIndex != "" { - t.Fatalf("provider-level auth-index should be empty when api-key-entries exist, got %q", compat[0].AuthIndex) - } - if got, want := compat[0].APIKeyEntries[0].AuthIndex, compatAuth.EnsureIndex(); got != want { - t.Fatalf("openai-compat auth-index = %q, want %q", got, want) - } -} - -func TestConfigAuthIndexOmitsIndexesNotInManager(t *testing.T) { - t.Parallel() - - cfg := &config.Config{ - GeminiKey: []config.GeminiKey{ - {APIKey: "gemini-key", BaseURL: "https://a.example.com"}, - }, - OpenAICompatibility: []config.OpenAICompatibility{ - { - Name: "bohe", - BaseURL: "https://bohe.example.com/v1", - APIKeyEntries: []config.OpenAICompatibilityAPIKey{ - {APIKey: "compat-key"}, - }, - }, - }, - } - - auths := synthesizeConfigAuths(t, cfg) - geminiAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool { - if auth == nil { - return false - } - return auth.Provider == "gemini" && auth.Attributes["api_key"] == "gemini-key" - }) - if geminiAuth == nil { - t.Fatal("expected synthesized gemini auth") - } - - manager := coreauth.NewManager(nil, nil, nil) - if _, errRegister := manager.Register(context.Background(), geminiAuth); errRegister != nil { - t.Fatalf("register gemini auth: %v", errRegister) - } - - h := &Handler{cfg: cfg, authManager: manager} - - gemini := h.geminiKeysWithAuthIndex() - if len(gemini) != 1 { - t.Fatalf("gemini keys = %d, want 1", len(gemini)) - } - if gemini[0].AuthIndex == "" { - t.Fatal("expected gemini auth-index to be set") - } - - compat := h.openAICompatibilityWithAuthIndex() - if len(compat) != 1 { - t.Fatalf("openai-compat providers = %d, want 1", len(compat)) - } - if len(compat[0].APIKeyEntries) != 1 { - t.Fatalf("openai-compat api-key-entries = %d, want 1", len(compat[0].APIKeyEntries)) - } - if compat[0].APIKeyEntries[0].AuthIndex != "" { - t.Fatalf("openai-compat auth-index = %q, want empty", compat[0].APIKeyEntries[0].AuthIndex) - } -} From 86c856f56f43bba2d3016a21c3d619f38fba196a Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 19 Apr 2026 03:21:59 +0800 Subject: [PATCH 08/30] feat(translator): add partial and full image generation support in Codex-GPT and Codex-Gemini flows - Introduced `LastImageHashByItemID` in Codex-GPT and `LastImageHashByID` in Codex-Gemini for deduplication of generated images. - Added support for handling `partial_image` and `image_generation_call` types, with inline data embedding for Gemini and URL payload conversion for GPT. - Extended unit tests to verify image handling in both streaming and non-streaming modes. --- .../codex/gemini/codex_gemini_response.go | 92 +++++++++++++ .../gemini/codex_gemini_response_test.go | 76 +++++++++++ .../chat-completions/codex_openai_response.go | 125 +++++++++++++++++- .../codex_openai_response_test.go | 59 +++++++++ 4 files changed, 351 insertions(+), 1 deletion(-) diff --git a/internal/translator/codex/gemini/codex_gemini_response.go b/internal/translator/codex/gemini/codex_gemini_response.go index f6ef87710a..a2e4e20ea2 100644 --- a/internal/translator/codex/gemini/codex_gemini_response.go +++ b/internal/translator/codex/gemini/codex_gemini_response.go @@ -7,6 +7,8 @@ package gemini import ( "bytes" "context" + "crypto/sha256" + "strings" "time" translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common" @@ -25,6 +27,7 @@ type ConvertCodexResponseToGeminiParams struct { ResponseID string LastStorageOutput []byte HasOutputTextDelta bool + LastImageHashByID map[string][32]byte } // ConvertCodexResponseToGemini converts Codex streaming response format to Gemini format. @@ -48,6 +51,7 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR ResponseID: "", LastStorageOutput: nil, HasOutputTextDelta: false, + LastImageHashByID: make(map[string][32]byte), } } @@ -74,10 +78,63 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR template, _ = sjson.SetBytes(template, "responseId", params.ResponseID) } + if typeStr == "response.image_generation_call.partial_image" { + itemID := rootResult.Get("item_id").String() + b64 := rootResult.Get("partial_image_b64").String() + if b64 == "" { + return [][]byte{} + } + if itemID != "" { + if params.LastImageHashByID == nil { + params.LastImageHashByID = make(map[string][32]byte) + } + hash := sha256.Sum256([]byte(b64)) + if last, ok := params.LastImageHashByID[itemID]; ok && last == hash { + return [][]byte{} + } + params.LastImageHashByID[itemID] = hash + } + + outputFormat := rootResult.Get("output_format").String() + mimeType := mimeTypeFromCodexOutputFormat(outputFormat) + + part := []byte(`{"inlineData":{"data":"","mimeType":""}}`) + part, _ = sjson.SetBytes(part, "inlineData.data", b64) + part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType) + template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part) + return [][]byte{template} + } + // Handle function call completion if typeStr == "response.output_item.done" { itemResult := rootResult.Get("item") itemType := itemResult.Get("type").String() + if itemType == "image_generation_call" { + itemID := itemResult.Get("id").String() + b64 := itemResult.Get("result").String() + if b64 == "" { + return [][]byte{} + } + if itemID != "" { + if params.LastImageHashByID == nil { + params.LastImageHashByID = make(map[string][32]byte) + } + hash := sha256.Sum256([]byte(b64)) + if last, ok := params.LastImageHashByID[itemID]; ok && last == hash { + return [][]byte{} + } + params.LastImageHashByID[itemID] = hash + } + + outputFormat := itemResult.Get("output_format").String() + mimeType := mimeTypeFromCodexOutputFormat(outputFormat) + + part := []byte(`{"inlineData":{"data":"","mimeType":""}}`) + part, _ = sjson.SetBytes(part, "inlineData.data", b64) + part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType) + template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part) + return [][]byte{template} + } if itemType == "function_call" { // Create function call part functionCall := []byte(`{"functionCall":{"name":"","args":{}}}`) @@ -270,6 +327,20 @@ func ConvertCodexResponseToGeminiNonStream(_ context.Context, modelName string, }) } + case "image_generation_call": + flushPendingFunctionCalls() + b64 := value.Get("result").String() + if b64 == "" { + break + } + outputFormat := value.Get("output_format").String() + mimeType := mimeTypeFromCodexOutputFormat(outputFormat) + + part := []byte(`{"inlineData":{"data":"","mimeType":""}}`) + part, _ = sjson.SetBytes(part, "inlineData.data", b64) + part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType) + template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part) + case "function_call": // Collect function call for potential merging with consecutive ones hasToolCall = true @@ -342,3 +413,24 @@ func buildReverseMapFromGeminiOriginal(original []byte) map[string]string { func GeminiTokenCount(ctx context.Context, count int64) []byte { return translatorcommon.GeminiTokenCountJSON(count) } + +func mimeTypeFromCodexOutputFormat(outputFormat string) string { + if outputFormat == "" { + return "image/png" + } + if strings.Contains(outputFormat, "/") { + return outputFormat + } + switch strings.ToLower(outputFormat) { + case "png": + return "image/png" + case "jpg", "jpeg": + return "image/jpeg" + case "webp": + return "image/webp" + case "gif": + return "image/gif" + default: + return "image/png" + } +} diff --git a/internal/translator/codex/gemini/codex_gemini_response_test.go b/internal/translator/codex/gemini/codex_gemini_response_test.go index b8f227beb5..547ee84715 100644 --- a/internal/translator/codex/gemini/codex_gemini_response_test.go +++ b/internal/translator/codex/gemini/codex_gemini_response_test.go @@ -33,3 +33,79 @@ func TestConvertCodexResponseToGemini_StreamEmptyOutputUsesOutputItemDoneMessage t.Fatalf("expected fallback content from response.output_item.done message; outputs=%q", outputs) } } + +func TestConvertCodexResponseToGemini_StreamPartialImageEmitsInlineData(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"tools":[]}`) + var param any + + chunk := []byte(`data: {"type":"response.image_generation_call.partial_image","item_id":"ig_123","output_format":"png","partial_image_b64":"aGVsbG8=","partial_image_index":0}`) + out := ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, chunk, ¶m) + if len(out) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(out)) + } + + got := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.data").String() + if got != "aGVsbG8=" { + t.Fatalf("expected inlineData.data %q, got %q; chunk=%s", "aGVsbG8=", got, string(out[0])) + } + + gotMime := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.mimeType").String() + if gotMime != "image/png" { + t.Fatalf("expected inlineData.mimeType %q, got %q; chunk=%s", "image/png", gotMime, string(out[0])) + } + + out = ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, chunk, ¶m) + if len(out) != 0 { + t.Fatalf("expected duplicate image chunk to be suppressed, got %d", len(out)) + } +} + +func TestConvertCodexResponseToGemini_StreamImageGenerationCallDoneEmitsInlineData(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"tools":[]}`) + var param any + + out := ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, []byte(`data: {"type":"response.image_generation_call.partial_image","item_id":"ig_123","output_format":"png","partial_image_b64":"aGVsbG8=","partial_image_index":0}`), ¶m) + if len(out) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(out)) + } + + out = ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, []byte(`data: {"type":"response.output_item.done","item":{"id":"ig_123","type":"image_generation_call","output_format":"png","result":"aGVsbG8="}}`), ¶m) + if len(out) != 0 { + t.Fatalf("expected output_item.done to be suppressed when identical to last partial image, got %d", len(out)) + } + + out = ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, []byte(`data: {"type":"response.output_item.done","item":{"id":"ig_123","type":"image_generation_call","output_format":"jpeg","result":"Ymll"}}`), ¶m) + if len(out) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(out)) + } + + got := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.data").String() + if got != "Ymll" { + t.Fatalf("expected inlineData.data %q, got %q; chunk=%s", "Ymll", got, string(out[0])) + } + + gotMime := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.mimeType").String() + if gotMime != "image/jpeg" { + t.Fatalf("expected inlineData.mimeType %q, got %q; chunk=%s", "image/jpeg", gotMime, string(out[0])) + } +} + +func TestConvertCodexResponseToGemini_NonStreamImageGenerationCallAddsInlineDataPart(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"tools":[]}`) + + raw := []byte(`{"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"usage":{"input_tokens":1,"output_tokens":1},"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]},{"type":"image_generation_call","output_format":"png","result":"aGVsbG8="}]}}`) + out := ConvertCodexResponseToGeminiNonStream(ctx, "gemini-2.5-pro", originalRequest, nil, raw, nil) + + got := gjson.GetBytes(out, "candidates.0.content.parts.1.inlineData.data").String() + if got != "aGVsbG8=" { + t.Fatalf("expected inlineData.data %q, got %q; chunk=%s", "aGVsbG8=", got, string(out)) + } + + gotMime := gjson.GetBytes(out, "candidates.0.content.parts.1.inlineData.mimeType").String() + if gotMime != "image/png" { + t.Fatalf("expected inlineData.mimeType %q, got %q; chunk=%s", "image/png", gotMime, string(out)) + } +} diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_response.go b/internal/translator/codex/openai/chat-completions/codex_openai_response.go index afae35d48d..75b5b848b3 100644 --- a/internal/translator/codex/openai/chat-completions/codex_openai_response.go +++ b/internal/translator/codex/openai/chat-completions/codex_openai_response.go @@ -8,6 +8,8 @@ package chat_completions import ( "bytes" "context" + "crypto/sha256" + "strings" "time" "github.com/tidwall/gjson" @@ -26,6 +28,7 @@ type ConvertCliToOpenAIParams struct { FunctionCallIndex int HasReceivedArgumentsDelta bool HasToolCallAnnounced bool + LastImageHashByItemID map[string][32]byte } // ConvertCodexResponseToOpenAI translates a single chunk of a streaming response from the @@ -51,6 +54,7 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR FunctionCallIndex: -1, HasReceivedArgumentsDelta: false, HasToolCallAnnounced: false, + LastImageHashByItemID: make(map[string][32]byte), } } @@ -70,6 +74,9 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR (*param).(*ConvertCliToOpenAIParams).ResponseID = rootResult.Get("response.id").String() (*param).(*ConvertCliToOpenAIParams).CreatedAt = rootResult.Get("response.created_at").Int() (*param).(*ConvertCliToOpenAIParams).Model = rootResult.Get("response.model").String() + if (*param).(*ConvertCliToOpenAIParams).LastImageHashByItemID == nil { + (*param).(*ConvertCliToOpenAIParams).LastImageHashByItemID = make(map[string][32]byte) + } return [][]byte{} } @@ -120,6 +127,39 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant") template, _ = sjson.SetBytes(template, "choices.0.delta.content", deltaResult.String()) } + } else if dataType == "response.image_generation_call.partial_image" { + itemID := rootResult.Get("item_id").String() + b64 := rootResult.Get("partial_image_b64").String() + if b64 == "" { + return [][]byte{} + } + if itemID != "" { + p := (*param).(*ConvertCliToOpenAIParams) + if p.LastImageHashByItemID == nil { + p.LastImageHashByItemID = make(map[string][32]byte) + } + hash := sha256.Sum256([]byte(b64)) + if last, ok := p.LastImageHashByItemID[itemID]; ok && last == hash { + return [][]byte{} + } + p.LastImageHashByItemID[itemID] = hash + } + + outputFormat := rootResult.Get("output_format").String() + mimeType := mimeTypeFromCodexOutputFormat(outputFormat) + imageURL := "data:" + mimeType + ";base64," + b64 + + imagesResult := gjson.GetBytes(template, "choices.0.delta.images") + if !imagesResult.Exists() || !imagesResult.IsArray() { + template, _ = sjson.SetRawBytes(template, "choices.0.delta.images", []byte(`[]`)) + } + imageIndex := len(gjson.GetBytes(template, "choices.0.delta.images").Array()) + imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`) + imagePayload, _ = sjson.SetBytes(imagePayload, "index", imageIndex) + imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL) + + template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant") + template, _ = sjson.SetRawBytes(template, "choices.0.delta.images.-1", imagePayload) } else if dataType == "response.completed" { finishReason := "stop" if (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex != -1 { @@ -183,7 +223,46 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR } else if dataType == "response.output_item.done" { itemResult := rootResult.Get("item") - if !itemResult.Exists() || itemResult.Get("type").String() != "function_call" { + if !itemResult.Exists() { + return [][]byte{} + } + itemType := itemResult.Get("type").String() + if itemType == "image_generation_call" { + itemID := itemResult.Get("id").String() + b64 := itemResult.Get("result").String() + if b64 == "" { + return [][]byte{} + } + if itemID != "" { + p := (*param).(*ConvertCliToOpenAIParams) + if p.LastImageHashByItemID == nil { + p.LastImageHashByItemID = make(map[string][32]byte) + } + hash := sha256.Sum256([]byte(b64)) + if last, ok := p.LastImageHashByItemID[itemID]; ok && last == hash { + return [][]byte{} + } + p.LastImageHashByItemID[itemID] = hash + } + + outputFormat := itemResult.Get("output_format").String() + mimeType := mimeTypeFromCodexOutputFormat(outputFormat) + imageURL := "data:" + mimeType + ";base64," + b64 + + imagesResult := gjson.GetBytes(template, "choices.0.delta.images") + if !imagesResult.Exists() || !imagesResult.IsArray() { + template, _ = sjson.SetRawBytes(template, "choices.0.delta.images", []byte(`[]`)) + } + imageIndex := len(gjson.GetBytes(template, "choices.0.delta.images").Array()) + imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`) + imagePayload, _ = sjson.SetBytes(imagePayload, "index", imageIndex) + imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL) + + template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant") + template, _ = sjson.SetRawBytes(template, "choices.0.delta.images.-1", imagePayload) + return [][]byte{template} + } + if itemType != "function_call" { return [][]byte{} } @@ -285,6 +364,7 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original // Process the output array for content and function calls var toolCalls [][]byte + var images [][]byte outputResult := responseResult.Get("output") if outputResult.IsArray() { outputArray := outputResult.Array() @@ -339,6 +419,19 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original } toolCalls = append(toolCalls, functionCallTemplate) + case "image_generation_call": + b64 := outputItem.Get("result").String() + if b64 == "" { + break + } + outputFormat := outputItem.Get("output_format").String() + mimeType := mimeTypeFromCodexOutputFormat(outputFormat) + imageURL := "data:" + mimeType + ";base64," + b64 + + imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`) + imagePayload, _ = sjson.SetBytes(imagePayload, "index", len(images)) + imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL) + images = append(images, imagePayload) } } @@ -361,6 +454,15 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original } template, _ = sjson.SetBytes(template, "choices.0.message.role", "assistant") } + + // Add images if any + if len(images) > 0 { + template, _ = sjson.SetRawBytes(template, "choices.0.message.images", []byte(`[]`)) + for _, image := range images { + template, _ = sjson.SetRawBytes(template, "choices.0.message.images.-1", image) + } + template, _ = sjson.SetBytes(template, "choices.0.message.role", "assistant") + } } // Extract and set the finish reason based on status @@ -409,3 +511,24 @@ func buildReverseMapFromOriginalOpenAI(original []byte) map[string]string { } return rev } + +func mimeTypeFromCodexOutputFormat(outputFormat string) string { + if outputFormat == "" { + return "image/png" + } + if strings.Contains(outputFormat, "/") { + return outputFormat + } + switch strings.ToLower(outputFormat) { + case "png": + return "image/png" + case "jpg", "jpeg": + return "image/jpeg" + case "webp": + return "image/webp" + case "gif": + return "image/gif" + default: + return "image/png" + } +} diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go b/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go index 534884c229..a6bb486fdf 100644 --- a/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go +++ b/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go @@ -90,3 +90,62 @@ func TestConvertCodexResponseToOpenAI_ToolCallArgumentsDeltaOmitsNullContentFiel t.Fatalf("expected tool call arguments delta to exist, got %s", string(out[0])) } } + +func TestConvertCodexResponseToOpenAI_StreamPartialImageEmitsDeltaImages(t *testing.T) { + ctx := context.Background() + var param any + + chunk := []byte(`data: {"type":"response.image_generation_call.partial_image","item_id":"ig_123","output_format":"png","partial_image_b64":"aGVsbG8=","partial_image_index":0}`) + + out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, chunk, ¶m) + if len(out) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(out)) + } + + gotURL := gjson.GetBytes(out[0], "choices.0.delta.images.0.image_url.url").String() + if gotURL != "data:image/png;base64,aGVsbG8=" { + t.Fatalf("expected image url %q, got %q; chunk=%s", "data:image/png;base64,aGVsbG8=", gotURL, string(out[0])) + } + + out = ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, chunk, ¶m) + if len(out) != 0 { + t.Fatalf("expected duplicate image chunk to be suppressed, got %d", len(out)) + } +} + +func TestConvertCodexResponseToOpenAI_StreamImageGenerationCallDoneEmitsDeltaImages(t *testing.T) { + ctx := context.Background() + var param any + + out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, []byte(`data: {"type":"response.image_generation_call.partial_image","item_id":"ig_123","output_format":"png","partial_image_b64":"aGVsbG8=","partial_image_index":0}`), ¶m) + if len(out) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(out)) + } + + out = ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, []byte(`data: {"type":"response.output_item.done","item":{"id":"ig_123","type":"image_generation_call","output_format":"png","result":"aGVsbG8="}}`), ¶m) + if len(out) != 0 { + t.Fatalf("expected output_item.done to be suppressed when identical to last partial image, got %d", len(out)) + } + + out = ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, []byte(`data: {"type":"response.output_item.done","item":{"id":"ig_123","type":"image_generation_call","output_format":"jpeg","result":"Ymll"}}`), ¶m) + if len(out) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(out)) + } + + gotURL := gjson.GetBytes(out[0], "choices.0.delta.images.0.image_url.url").String() + if gotURL != "data:image/jpeg;base64,Ymll" { + t.Fatalf("expected image url %q, got %q; chunk=%s", "data:image/jpeg;base64,Ymll", gotURL, string(out[0])) + } +} + +func TestConvertCodexResponseToOpenAI_NonStreamImageGenerationCallAddsMessageImages(t *testing.T) { + ctx := context.Background() + + raw := []byte(`{"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"model":"gpt-5.4","status":"completed","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]},{"type":"image_generation_call","output_format":"png","result":"aGVsbG8="}]}}`) + out := ConvertCodexResponseToOpenAINonStream(ctx, "gpt-5.4", nil, nil, raw, nil) + + gotURL := gjson.GetBytes(out, "choices.0.message.images.0.image_url.url").String() + if gotURL != "data:image/png;base64,aGVsbG8=" { + t.Fatalf("expected image url %q, got %q; chunk=%s", "data:image/png;base64,aGVsbG8=", gotURL, string(out)) + } +} From f4eb16102b7f5e5a6b83fb7b74d220f4714c82d5 Mon Sep 17 00:00:00 2001 From: octo-patch Date: Sun, 19 Apr 2026 10:38:16 +0800 Subject: [PATCH 09/30] fix(executor): drop obsolete context-1m-2025-08-07 beta header (fixes #2866) Anthropic has moved the 1M-context-window feature to General Availability, so the context-1m-2025-08-07 beta flag is no longer accepted and now causes 400 Bad Request errors when forwarded upstream. Remove the X-CPA-CLAUDE-1M detection and the corresponding injection of the now-invalid beta header. Also drop the unused net/textproto import that was only needed for the header-key lookup. --- internal/runtime/executor/claude_executor.go | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 0311827bae..235db1f3b2 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -11,7 +11,6 @@ import ( "fmt" "io" "net/http" - "net/textproto" "strings" "time" @@ -911,15 +910,8 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, baseBetas += ",interleaved-thinking-2025-05-14" } - hasClaude1MHeader := false - if ginHeaders != nil { - if _, ok := ginHeaders[textproto.CanonicalMIMEHeaderKey("X-CPA-CLAUDE-1M")]; ok { - hasClaude1MHeader = true - } - } - // Merge extra betas from request body and request flags. - if len(extraBetas) > 0 || hasClaude1MHeader { + if len(extraBetas) > 0 { existingSet := make(map[string]bool) for _, b := range strings.Split(baseBetas, ",") { betaName := strings.TrimSpace(b) @@ -934,9 +926,6 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, existingSet[beta] = true } } - if hasClaude1MHeader && !existingSet["context-1m-2025-08-07"] { - baseBetas += ",context-1m-2025-08-07" - } } r.Header.Set("Anthropic-Beta", baseBetas) From 8f4a4eabfc8688e73eb21effe1e077e83494a8b5 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 19 Apr 2026 23:00:09 +0800 Subject: [PATCH 10/30] feat(docs): add VisionCoder sponsorship details and optimize external links - Added VisionCoder sponsorship information to `README.md`, `README_CN.md`, and `README_JA.md`. - Updated external links to include `target="_blank"` for improved user experience. - Added new logo asset `visioncoder.png` for README use. --- README.md | 6 ++++++ README_CN.md | 16 +++++++++++----- README_JA.md | 4 ++++ assets/visioncoder.png | Bin 0 -> 155590 bytes 4 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 assets/visioncoder.png diff --git a/README.md b/README.md index 53acdd5178..77b8667b2f 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,12 @@ Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB PoixeAI Thanks to Poixe AI for sponsoring this project! Poixe AI provides reliable LLM API services. You can leverage the platform's API endpoints to seamlessly build AI-powered products. Additionally, you can become a vendor by providing AI API resources to the platform and earn revenue. Register through the exclusive CLIProxyAPI referral link and receive a bonus of $5 USD on your first top-up. + +VisionCoder +Thanks to VisionCoder for supporting this project. VisionCoder Developer Platform is a reliable and efficient API relay service provider, offering access to mainstream AI models such as Claude Code, Codex, and Gemini. It helps developers and teams integrate AI capabilities more easily and improve productivity. +

+VisionCoder is also offering our users a limited-time Token Plan promotion: buy 1 month and get 1 month free. + diff --git a/README_CN.md b/README_CN.md index 86ea954209..75d50e7ac1 100644 --- a/README_CN.md +++ b/README_CN.md @@ -24,23 +24,29 @@ GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元 PackyCode -感谢 PackyCode 对本项目的赞助!PackyCode 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。PackyCode 为本软件用户提供了特别优惠:使用此链接注册,并在充值时输入 "cliproxyapi" 优惠码即可享受九折优惠。 +感谢 PackyCode 对本项目的赞助!PackyCode 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。PackyCode 为本软件用户提供了特别优惠:使用此链接注册,并在充值时输入 "cliproxyapi" 优惠码即可享受九折优惠。 AICodeMirror -感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。 Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 CLIProxyAPI 的用户提供了特别福利,通过此链接注册的用户,可享受首充8折,企业客户最高可享 7.5 折! +感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。 Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 CLIProxyAPI 的用户提供了特别福利,通过此链接注册的用户,可享受首充8折,企业客户最高可享 7.5 折! BmoPlus -感谢 BmoPlus 赞助了本项目!BmoPlus 是一家专为AI订阅重度用户打造的可靠 AI 账号代充服务商,提供稳定的 ChatGPT Plus / ChatGPT Pro(全程质保) / Claude Pro / Super Grok / Gemini Pro 的官方代充&成品账号。 通过BmoPlus AI成品号专卖/代充注册下单的用户,可享GPT 官网订阅一折 的震撼价格! +感谢 BmoPlus 赞助了本项目!BmoPlus 是一家专为AI订阅重度用户打造的可靠 AI 账号代充服务商,提供稳定的 ChatGPT Plus / ChatGPT Pro(全程质保) / Claude Pro / Super Grok / Gemini Pro 的官方代充&成品账号。 通过BmoPlus AI成品号专卖/代充注册下单的用户,可享GPT 官网订阅一折 的震撼价格! LingtrueAPI -感谢 LingtrueAPI 对本项目的赞助!LingtrueAPI 是一家全球大模型API中转服务平台,提供Claude Code、Codex、Gemini 等多种顶级模型API调用服务,致力于让用户以低成本、高稳定性链接全球AI能力。LingtrueAPI为本软件用户提供了特别优惠:使用此链接注册,并在首次充值时输入 "LingtrueAPI" 优惠码即可享受9折优惠。 +感谢 LingtrueAPI 对本项目的赞助!LingtrueAPI 是一家全球大模型API中转服务平台,提供Claude Code、Codex、Gemini 等多种顶级模型API调用服务,致力于让用户以低成本、高稳定性链接全球AI能力。LingtrueAPI为本软件用户提供了特别优惠:使用此链接注册,并在首次充值时输入 "LingtrueAPI" 优惠码即可享受9折优惠。 PoixeAI -感谢 Poixe AI 对本项目的赞助!Poixe AI 提供可靠的 AI 模型接口服务,您可以使用平台提供的 LLM API 接口轻松构建 AI 产品,同时也可以成为供应商,为平台提供大模型资源以赚取收益。通过 CLIProxyAPI 专属链接注册,充值额外赠送 $5 美金 +感谢 Poixe AI 对本项目的赞助!Poixe AI 提供可靠的 AI 模型接口服务,您可以使用平台提供的 LLM API 接口轻松构建 AI 产品,同时也可以成为供应商,为平台提供大模型资源以赚取收益。通过 CLIProxyAPI 专属链接注册,充值额外赠送 $5 美金 + + +VisionCoder +感谢 VisionCoder 对本项目的支持。VisionCoder 开发平台 是一个可靠高效的 API 中继服务提供商,提供 Claude Code、Codex、Gemini 等主流 AI 模型,帮助开发者和团队更轻松地集成 AI 功能,提升工作效率。 +

+VisionCoder 还为我们的用户提供 Token Plan 限时活动:购买 1 个月,赠送 1 个月。 diff --git a/README_JA.md b/README_JA.md index 8c34325b49..cf8a0f77d8 100644 --- a/README_JA.md +++ b/README_JA.md @@ -42,6 +42,10 @@ GLM CODING PLANを10%割引で取得:https://z.ai/subscribe?ic=8JVLJQFSKB PoixeAI Poixe AIのスポンサーシップに感謝します!Poixe AIは信頼できるAIモデルAPIサービスを提供しており、プラットフォームが提供するLLM APIを使って簡単にAI製品を構築できます。また、サプライヤーとしてプラットフォームに大規模モデルのリソースを提供し、収益を得ることも可能です。CLIProxyAPIの専用リンクから登録すると、チャージ時に追加で$5が付与されます。 + +VisionCoder +VisionCoderのご支援に感謝します!VisionCoder 開発プラットフォーム は、信頼性が高く効率的なAPIリレーサービスプロバイダーで、Claude Code、Codex、Geminiなどの主要AIモデルを提供し、開発者やチームがより簡単にAI機能を統合して生産性を向上できるよう支援します。さらに、VisionCoderはユーザー向けに Token Plan の期間限定キャンペーン(1か月購入で1か月分プレゼント)も提供しています。 + diff --git a/assets/visioncoder.png b/assets/visioncoder.png new file mode 100644 index 0000000000000000000000000000000000000000..24b1760ce5afe51d7cedbac1985211c3c4ca78bf GIT binary patch literal 155590 zcmYIvWmFtX*YyB{I|L`VyK8XQ!2^K=cMb0D?ry=|-7UCVaCZ;x^X5MH=KFrs>RR2k zdUaPF-DjULB}FM@1bhSl0DvqbEv^CpKym{B5XEpXf9}W?IotiYh#PClm?$U!=>E*% z01zSO0N|e)#GirxX8-_Dxex%TKUau|Z_Bf2 zvJxbjK%l=o*X2gB&-M9dFBB%FD{ zf8R??M)^Ct`}Q*1=l5c};Kk$p>Njkc4F`3?sp4W@CTdqcF4)g3Tq#g)DB*LghPFF` zkm4{R<7lP@CKRpe*Ncpo-Q0%}&8K^NRp!y!H9jXq*+O2Yj_ayE?|aZw??Kc){UPg+ zqA~ZS)ptMC>07k^_cNz{ls_4L9(6MhMmi5&U9UL4w%k%5cw9cKt-06>9&|boWOo|$ z9oN5n3Zg{;*pS$;Kn-_7g#saH{YfN3Z!+=V;UV$lt?^p-1A(Li_rMh*eSYF?X{k}3 zHu~01hl3gD%$08TY{w^X*7vJjkB7~>#TTa4?we)X=hu9!+lssP1q7Qvd-`W%{i}X$ ze-(N>XHI8cbGkDDj|$ZY@IEnYKWecW_OA&66xoYZX)~ik`{&;!DIlmIM1+Nb6fAIs zAJC;KfJ*S_5C$;P5Wc>-RB`gGGSI?>P3WJxfa61CO+TKjb`5D-lB!5Kn^Lo5F`_zs5xgVq;F8u}r~ zmqK6E7m`L9l8l4~Xy6gp;ac9b>cO3QAm_&OW<~V%)vx6`e3R!D(dW^0>MhFB*5el2 zv2sYZa?9i&VLOG_c<{Uce_wdLcL6u%yKFquW=?k8hIf`{Lw?-ZaYR_+k1c}VN*qNp zqe>HnLHsK2j|YT?EXDXC?k}qh3?Cx}C?&=s8_)y-luK;o{1esCOwVKtyyQB4fQXtw zAAXRCBk=KM-$%!w-$<;5@Pqz*oKF_4skb$_b?R|&%;#(lOk8C(iP@xKSO3pB-2rL5 zMnF03=ceGBo87MSP5##1$ryMY=Y`V#Vj#Eacl#Ptds*Q`6G&iOz;{yET}ZeRp;{x_ z3E|k$6(x%Bz(g8xQ8rb$V5K4g3=A}JNWDc%G7>5=QGZA%r974dp>)6iq&!e8wupo! zjGB7>x4`twS029B!|Zd?jb@jL{p!`7S!B@5@{|wru;+&qpGv8gzn{iawJLO|01|rM z*L@1`XnlZgxp|BMxes>+<^)mq3$*)Yz zr#8D7dORN`pNn|zIv!jNWj{Ok93LvjT2v0X{XN0&nlA7Ax~>PW4&e7Ow_h{GL<8!~ z3Rz-AnV_?*WU_!X>G8;1aRR@fpmcp!20H0p`)O%$kq=ny_0k}qpSVAnG>Luyi|>xb zNd_WZl2mc*ATaeY?ykaHyN3l1+WsVVURsu}1`9|LBf(-M#MFU3>iM3nlI!iV-s^XjoLF z+z+G+05maR7pJBAE66WAum!p|*If#8NF}QD;<$j&;X{AErKI1rkywF9cq>Wkd6Ks|G0!o)NS`60!P>TQ{5L+ z$4p(`_57F(e$NvPSWtK*@_=FNqMNKUe)p}l6?!U45&8=3TpTB=85bS2bRi&4Z)Mx^ zZMd3AP@;i{5>Y9#roZSIZ(&UAzym?>X$id4bEF3%uI%6P?xezzeA|cE;2Sg zH?`33+}2-u-J#w7<>I(64bfI+@Yknv@Jpf40Q3G#zenSudx6D&c8xgC>8AK~OsLyZBb`<2XG@4|2=ebb)zcpOsL`qu_1I<$ov_sg`90OkXh(#S8#~cuS0LTlUc1HCb<+A3yAG-XcglDe=(%9*WB(!Al zrqAY9%6w>Wx1XB3XGCUaXX3lPCu}sT;=7mN-S}sLH=)=49QVEIy)URvf0ae7V&i7? zvc&@2wCfYwOx$@gzM*c~Y?@8+vn#bn{JSI*I`bfK9ZI6rv=k4G5|02K*cYG5i1Jt-Idmo1e}#&m00?m~7}Ry9^W{B-csY`x|pBo{VibE@*7*0oBhGNanL7wNopvmUbGu*Ki`+g z_#g-?r{&|TWYrt-xHGDAlwEquh?N#OIQGD!H3A>)PHT=&%4)hi01|llKoxNpRjhP6 zlb^B0sg#Sp!Qo>N{i$@cHS!Z?8Z=u+1i4fqY7L56F_j`k@t%=Z#Hbh;tP^-}B)%_~ z2pbP5ds^?CQop{wxBhzHJA7RvcX9gVPV?8n5kop1nu2qtIxY{re_L{|s^)mrfC;Vc zr;Lw9he(GeNWv3KSOi;QWJZ>zkJ*6-&WNkq{&`%Z<6{hB;}~ndK1?yh0?5@kVrV}C zfS8bTy}ej6m?)4G!enV#YO47haiaY+A&|1je8a*k6!OCz==#zPH6Te)Fb`UvrhKqw z@8Xz&w1YkIbFOdOvA})crvt~!sE&~4cS*tHYw%rL&XZiVk2v$~7gU+QQo;I-yZbfA zhwQ?AlbhHo`*!=2>*8uTohCOYg{9fzm<1pbid!_aDV&=?(cu2&dLA@I4vrRu4yD9HAZGpsE!_e z6oUJJK@$JdO^K8gPq~LWq)1IkTK>f(y+KBllwItj9XU|xTIK#QMO<9(TY84`wZ&{d zCq2&4)1aUnnYxa`d<`stKf!c?N7?8^rmV?n3q2qg-inn&7YOIl<51+fiZ`?4x=q9dV0XLpD9S&+(EXW|0J6}>xxl7qYa{H4Ost}L*;eSjo+O3r0m{aZ`G0GYL17P%h-lDT!x{L zf6T-~2ozjko%=kxKZaZlD>5kg$RJL+bbtg)+JcJZDL|XO+;4wffGIzhZ~6MT#EHe# zE&MLd-y>APtm`59c<1MX4j-S_VT#a8rS4mL$P1C?X+SyFTE#yOL_kA!D~kB8(#P!b z;-&lHLzS0BwijQZmI@i-sR*SlPS%iVX(clgzOXvNur^= zU)yRMnixh96Cp={AN%vm>s@+|(pNQ3wB8H+#J&##^I?@-js3;|h{LPzw^ZAH2=VWT zJj%ow*4Q@vC_}OG$q7_nT+19kqM9r%*AlUy(;h z4T%ro(cHK9M=@N1yOG)Od0+;mxDM(1OtynBxV^4$yemWU|H?Q`O4nW`Z1ekGMwiv4 z;OqMFtpjSahbikZP;>>_iV3o$$TBt^rohioSJs=A@QA2r)vTLQ?HCKZcVUZ zx75i2697ruNLk3(9=HRMtamCt;XbczQcJ2>aNJ}gG7?)kyxv}Tfl$qL!$<0vhwPli zW)U_)VzFdt)@O>o{z+o&%FGBe-;G|DFa$a%uvNvbZf5pq*uWHF(8M-^@!L|8FNN0`7lD9 zwRUUQo0N86B_DNOma{Y!Xm2F)@B+AIS?1+_@@q+9*v&gD+SW#bqZrz!5i*tDr0AyK zEwXhcOt|D|tm&fy&~t8sf+&m%C%B78R&uBUM3`{3@g6IcIzu{Pe2g}vo|?;T1Z|cG zC(D}qyE2hZ^>?9gBdT-jd_|LTMWH<%z2lkJTNF;mli&`Y&9n%g%QV}!*;5|^)~3aL zoxg@M@#NRH+ve&v3-3{ahv=$j!|w+p^SyZb8+8g*VK$zV`1xDjRImum5-znrnbyTL zb=a<@OG-Sy_C8x1&8KD#$Ch2k4&disIzC>Uo7Hzmzy6w@Aor<=uKTiypKq7# z3{@Wco(FhoaeyYQ(qCT6W>fMpAxNP9jbo!cCtMhu#h> znn@rGX0S2xV7+48)&%`N;x@kG%_E6N2_4zOxrmeUS(>oWvgzCi!?cD(B*m@S8AHbD z^ueAE+lMrs)TXUBUu=msrNIgZ52WcA+a<4tDzgrb88mAY;^Oh2n0Pc~tyOPdqxsUF?0})SpnEX-Lod?qEYwL5O6gphe=+@-utHS^Q38xfGJCX zHGqZ*TKEUX9nYxq!iRn(cSJ(bL!m@HRWpjTEEusS?x6OG^$)z3EW~I(b(RuV-sJ#v zmUW_MuUlqIAFO>y<12i3CJ|xUTsi~^eOJrnvdTTB$l@MK(vRV@;cv@qwU2P!tbBbT6R7rLqAQe89lN14tJ=tA@>+AyWsNrvcI= zdhNvL=ed)`OsY!vuZ-xJu?1>7YV?{@nl%<&m1A)i2EWPT`%xTz5#u&O=YzSQy54$~ zipBXY)cx?Y`e`g?>%3v{Qsu9%1fJY~-nfcC9(?iM3!iWhe15!=8C~;FcNmPU8wbQ@ zxSC}LhWoSFK*0(ql@?oO9GgWKB>1g*s(h6K9JvapU9FSIZ?&|al(D6s=n-=1l1n3C z4U~l%%@0G*vCt(;=~L3YPA1Y##ZYBD>*jw~f>%}6AeJDFxjkfogyXW^pC`&G=j3nt zNDGkY#DVN7P@*s~Q{oBl<5gyXtt@VQm6X%neM1Htd8ojQ#;B%1(6Syfy1GIue*APf zzp?)Cww@t$>>;$X-IcQV-1#>mjS=oV2YF4gUkkW&Y}*T-_s5`C#~t{Sm!^qR^|y1( z?99W(*KZjVPh5oz)4HAOlTz%dWvwBHj@n}eldUIywFW5&h?cZ z3Q`Coh&O&r3Txq7wrEdQLjUOyTMI>1CZG*zVCqh(wAYVe%H4GN`(s4j{&yHCud~1{ zbwcE=-BcxST;~=A8L2wQy~(>game-CK#k2dk+FlVCe$HF%0%)_(_@88ChI0`yirjN zwlORtMoQ;Q*>kATLSCf60tRQZj$A>z0n}(?HQ0@%!F2--(hd&4#+i+d)`u()7u(Gz z%#fVxkoSzS?ibYcCzdr8%QwNl3Kdd&;r{c%bNp4i`TeHfa}@DUVwRkWAuPG7b7ZYb zl4tNZ+Ka$8!B#WvzlCCr!1!@t{NmYJI0oDT>dOV&n^ zRBH?8XIUjvplJ5$eTT!85D}J<0<$CEQtA+WWs`>phvr6ptIsLKCvKUCHA_v6hsm9E|U%e6yxU03^M zr&9R1)cj#!5*WztTL@v13t8mbiV>Vuc=+w&8YU$qRf{WqTasyd00v5d6X+7})&8+W zLEvQVTW%yxqQBCeBYn69LZLaagp1K(55IA&vOYvxXS!e4>=O&Y*gzr_-<9FxJKx-XbBjEybn+sb-H)HrNZmEEdVGSw>5D?b}ToO z+q}?&w#Y*aYj+I8;Kb7Tkyg{Ax|S2LgT>CFE_0>FIk^tK!@r0f%Y0?d?MEL2_g?~= zb|+hIQv!S)qG60wn!$Cq^M)ubd{BoHjWy^zAID4r}ETC65mNARNz8!#cEa! z^SH*$o;0z#(K#tSn3h!I+y@Hqr*bt?^x&m0X!8;x=5qQJRf>8l>PO>i!(;H^*(5Qe z$peeU)EwC?^ajY1miyR&4K!-Q(4)Q8BeEDA<@rqvI4}Nw>Uy6Jt7?!?ii$FSpZi@LP%x09w}G72y@@D19A%}q_;PecP3c%g0wm+NmZ2x9ka|GBVShT@uZl3(OMJUGb6U_5$2Mx^=BxtIB$H7Ix@k@zu_`Ps8*%MH!%PFFAteQKm$t z9(I*~lL8~Be&lqUcc{(Z&0O5;FWs5EuJ7tnu%AVFB*}u(d(~V;?pFe*VNwY_gX+D; zybK*I+vwq81X@{O_&r>fC7!e9tMyHZ%2)G3EncDxRQs$a?phb4BSA|V`27AWo%tn- zKTbGNL4_6OD<&K(cPdv5uhmp#1^6G->ucO~u($6iN!6ex7kF&bgs_snBE@1EB{&1s z`B2atr-_AIQbu#*!Wph+^LfVlzlw>Urq*k*Ll*GFy=UW-$7w}v>9c4KX4GSaRdHg^TEQ?qnNP+s}79ml7A&%|t(D^hE$O;o%c2U3*FZvQNZ>1oCjCx`W zr+!ztPZKOfiv*`ePD7)|L?T8;-`b)t4MFx}W%zWxfJ975LD)3RxZ>!?fPs@g4(6AF z`is=-95#5x#d~q-IheVbk8Q3G)ob>S_qXNQPXyrgZjaE*jz;g0FKPclgmt%H&ev~e ztHF!M7oK<98#k!-U^*5IbU@tRz{g6H!<_vd#yX0hGv^d-hFU%J=AYG% zGL1V1sb-L&b?2Y^?R5rM+|6sLlgvIVZn(>+t;kVt#*tXWo4_Pz;)f-)BD&pUeu=UO z8ZJik;bNq88gi-h7h2Vf0&=9F8AHmhmm4Dt<)UTIoT%Y)m^_(=&ynWpO_b)66dm*` z%4|QW!lWC?sSCTO4=DPQS%otx;co%BlBax2{Em6^d<-(CH`IAN4xmBHI(+WCww|-6 zIX5gD;g+p`lM<2nDW4h32b)(Z-G`NaxfHM7_iZ^*k7D%9xZ#p81bL&C%bBheB02Lp zDPdzXYsP{|azCZ`Vc90A7G;vK^!9mG9Bd>AUwNL>B1ZPftq5ZV=Q=4kS8vR*E6Zgm zKoNcsKwRVfDu?BcfNl!X0pv|^!q>3t+ zD0UYUwqAx6j-;?~uFi%dwTm6@wL3?}?w+ ztWcPM^rNP^T@VT;0|k&ypH!%_aT_EuD?-+vU)f6^qj%Vi`Si zc12+2VJ8+@4Yt_t>-9V`abHfCxA+cKTcj!T(Yz&7%g3`aYrt#p_tzG?&FXJF-q*_@ z@5K!8DDx+^hwU$5&Oz|hw8 zxWo#-xZNz4evdW>BU!fZFp&TYIvKc+&4`x##*yfj3CO)SKQwL4iWvZxjr#dkLB|bf zTm+;+F~x=0P8U_`Ki*{tEUX89)0^#9hvg9|n&ZSQ7hpu>2}cQ`BxjyE`uPe|WlrhX z##hGSVKBK?TCMou5~_Piy_p(qP~bnp$B89tPk#k#E9}UZ6n}SDuqq%@?c?`h5lAwi z3O~oIypVJl2c}Wa_U`j^wqB1PH1T(#tRHnR_WzBtp8==ApVwK|9p{+e=GdtZ1^+me zVR4Kl|54y9B*^*~VXd3-IRKtQV7bjadBrdz%#Pea%bBv3H*{u>lYR>~`;*qF+HM4c znh0blyZ|r^VO2IX&RX>xw4jJg4P%M#FOQryZ{tpqg7UjaYHe^qyESSy=}6UHx!?CW z_*on}q`ah<_lZXd+Y)fBB$`9&q5_B#hmlB_==Htu`TW$pA4lnFI= zsjeTX!4Iy3)Y;JC+Ad4{!N6&{te36jmN0+4Z$$((+R27PxCg>^yMY6tJjSt{$9d!Pj3KouiTPPv3@asH#S4biI0zXfOA8u($&emXed}mgZy)B% zMOOzl%(MC4D$Ki1ySnX9abBm5QP0;~HU1{*8&hrD>gkinhhO`;~k< zm4U$}VUM}0tPX*`pMrs(&T>;@oBk9}z)VM~cr91a$WblZ#$s5`k#vc5_!;JCEDgzv zW^0GYNlnl`TxGw~^4ZJK#TI@2;}>QjV?L9v)?2;AOkeEZPlSd!qSQH;l>1Bmc-DMU z8-2wWHbXG(g@^I(w zw1sMBVjcICOy#JoMUg5~PSxF0hq^EgW1tf5W*0SSUG-%4HXh}JUEcbrU;1C@&?!WD zg$bT3*LAQF1Sfrehfd3wGX@@@;N-EM&kn#y?Z#&mdWIipzQJxit$rfXTvzZOM1nlr?^Kr~4dw3YA4?>zHg-Wj)*dZyP_llN4- z=Z#*Q?^YI;KzOVKe{kY)vNCf3eN?zl4M4*am0S!_Q{3Vqh=-<-(8s&uU_*KFzoRaY zV9qJ+uAB`(h;m-hCvNUdNsW4z&Bt_z)kVV)h%BqJ=OdW;5!w(X9Y7GELOTE)#2WRu z1edvZj|zD#ih1N$L09}&AAhJkt?m}wctY2GI0@J-e_w4mqwMQ|9hV|!fLR|i$}i&& z=i%D5At~}5LFa7Lt;720d?8;exX(+KW`P13-IP|U_!2@FU)_+i5;DeK=PC;5&Jhk| zJBy>mtgB>MROE}QHqmyA7u$bXq1ToVhR|?-K;c@EMak>0p;!Qi^BGWnN?o1R;R=G1 z^rJ9g{3!6H;dj(J*Ga_=(Vo^*e_5+)kU&G_B}aJVmv);o8)B7ekb$1EE1JnZI$yKq z8Po}H1~4#v+6H0rLzGs-g*Z8hIJ*A$Y#-c2CP+7~*ztn$#YN{eQfi0DJ8wZYfB45bai%!#mV$C-A8IA)2oQPbo4@ni`N_49x*CJGk^N1>h_eX}la# zWaq0-aJ0ZEY(w^R)cVn0GHqh!GziL7Ue%OnKpYt)okuF$R_AJ1K|reZ!qmuL@1Vut zki%TBj}CV4QtEWI(soL-=DS{M?O)*?jW0#ObG~T>Q>p@}>uuk(I6(rK`&n#;#ejff zHs-a&)dZJykKg7~p36?)chLHy$a;yTK(5BYzcdIJdcWqkA>G&2IWNz<_hTHzR%B&r zQJ1YHTZrMUAYO4&lxmlsf7vlWQ3B9T8G&qMgC*-|efk6K_Z?9?zK%Gpe;Hu^Okpr-czwIT-js2ZvAXdjBpB#&{zdb@ zlK15oZtoozpGnWvpzcka)kaA^j?rRN>3;KO*b zsjs=50v-};(a8l&s=oQqh$En+1W(pc=zAQ-2CS6T8k+g5U*$Lh8@k6gLnstJxvHZq z>7?7Ovlf@7pG747KM+oF7U@6tVBF zfS3f7Dx|a~BxzzSm{2xtbVw{oJB>Wf9(!u-C2|i5s}&)`MY%F`8Sxe!Vj?Nx$+hMq zVAipGvQ_c%0%JO{T*zSA;J!m0(*X*ehm~ndbO|JN=eqh>LPQtN@0$szsIhOOI83d%s!BJ9`yFuiAMry(D&{xPo0j)zsnOQDz2mNSc1PW+GDzo?Kf zpnUB<^=m`2E-57gc3Z_J8jqx+>2pzMar>Od<&*r9sUW@n&OX* z8*}=-lf~#jN3JN`O3)pEl9409R8fBw1Fwgnj&d0wC02|nqJYw?zqmAl;U&q;Bk#r_ zr|Bc`3t-P0YtUX5@(@yxDxhoOA0>Wrmb1k+TK8##f!L7}LM0t2V$SXSb*SKjO(#y? zVmLBseNFBPc!2h!)zYaUxeBAC^bbh|Ytm~MeN(p(6k6X^EdnCJu$P-&`i(m+q4)Wa zcN=em#s5%q=aV9#=T2gNaItnbSgqrzEup`h(pv8Ax7Z`@2ebojD_K8-p)|jp54rec zO1p)%a~2H1dhKQp15q3grJ_-Oojw^Tnx3Yw%blvL-97#c%3|F=Mh_c_gKhSS$VtsZ zkolg1gptZet`{6cg#X6PI)*+&yACXD5E~k{4XLKn$R8xEvF0>9$1ur9 zLIfII7n@Wjb$9e{^?ZDUrjo-(AW?SvNlBUvE)=InaDG~Wt=}~wx++1-AEHm36%WQ% z48~4}Q^QG*D+nAWvj`S8Mn}TU|5(=O9dW*~{E1&+|L5-<@7*NS-{-G^eftb^YojY{j+Cs3PpyluiBH-M=_nAf|1x|WMy&e-u{XM6WasKwgBl5q(T1Y zgx`y6Nrk_l)sipnOt5UT5#DpV2eG)v6}Afbakh~9(ID1Qz+Q4(Dl1}6p*PzE{}w7i zrWcP=E|J-f?e{vJKSs(78rNGbt(@n0*Q1;o*HWd;XGQ)JcsinJJc6wxaF!j=Q>62Z ztVN_ZB&-T2jvMBCgJ>l@d^N>s~8W?HdS8=bE_z@1*xuI-7e^HMwc-tQkoitA7eW2}5YfW=Q9m>;qVQVF0w_y2(l$fFXTVZt^H zUdhr*dsU?l1-(5kTH#;<6Jz(_49mg!o$SW8 z=)-K|8WG>QO{6@O5ZxU!xJ>}6`?+Svf%qGSu0`mS#;$e=YNS5qQFH|;y*G<;k}kUS zb55tO`Rdp%^?)p3=!xuQ0+COU`G{AWMh8&1x1-20!71-jqAhTu$kdS?4*3~~fH zuI1)uH_6D?k@hpn?N6~tsSe^0Xp61CsX@wRh^{ncU}cyFg#U)gx9)4;f`+3tLE4d$ zpJ$P6(og39hTFLNbCoeDQ!79H3=KmTWYIZFOw(em)32&c%d(u&oCa(1ox@xMcHC&f z-3)&O0K&Q;hx}m`Pfsfm70)6ZkUB%OMl&zPE^4)kN=l}1j-*EdioytmN3gH&%n3<} zF3Nvm(n-H;b!Zo8^U(OV)BAotcVNdp#n)JTPHWM2pqAPB>3(v@2&|^Z2W=;MV8!wo zZ4`=gdX8--l**&QJwi+2ZYcyS2(+#WGR&4V^#<9mVGVkWFt)m&%>Cr?Ii&1>>;d1` zY`n(Y{7bXC3#`xv@1cH8?tY)VTxx%~E#3(~jHnHp*ExfW(i7JPv1ZQ~hlhyF5wYWI z1fuODz%wAh3)Hr8lt&goCwecXxCq-iW7olns+LO;{ZM#OSSrFUd9Ab>)EA#_aWXv{ z1@sVw>$ypU8%>K1@diuHA4FJv34xH!#BNX~R3?dNM(-&_^J{NfX}6qK<-^eCF<4Sv zmq)9(Lx#2e*wvpD=dhaK5C`iJkje$gw7s=s-Qj4zy8bZr@OIYG{j{J8@>+>G>)tZ- zz9_qNe-~)~rSr?uVJzltn*Oruu1Kia#eOKF_~k+HH(H8e*IB8y6-w?CkjCO({}{WMvlkU zAoJ|;koJPChGqRkP+XxivLHqbgHx9# z7HE1-<*v3)ljG(%-%x?`uWd;Q=FM@Sr{uGwXS*bYiw$3yPADAd03P0dopsRQ}voqU;#3z4s)ni0@4rCk+ zRA40;6ClqWlvGkHeiS{gXOuz@8pgI6tr>t3^2f{Hho-Wu<2%LCmh0uZ?}Mln$+Y_E zv5EaK)!Y^JS>TR7=QYgd*yr$cLrCdn^>py_>+r(d>iZ3Gmdd7`-AP;dfhuSwbI+)g z#<&XU$oWn&%_K@LrY*QuGmg}wbjVKSwh%{V+y>VYXYTrI0@uxW$IIu7w%b|D?nfD+ z_df^Fn>qL8-}p3JxX|Z%F+jWfZSy^AqVlz@lYMG^?f@+;!JQ_?$ffg9W{o45RGRZa zPaM1wvwL90t+J{hYr%vkhDC9hU3{NyQo=18A4|zX8v_0*wWfm<7(1sGe$umZkGr9l zZpcPqQE$;LYzC(D-JV158=qxiAV_svy~}tanfP4bm(2Z)GHH0DjSH!*=AzsHqS;#3 zxr06*781-@+?r0*$jS*781z)`xH<1&zI;7C|HH*|n0k!rzTF7f*=Tv|-@N+XcJod! zDgW!!?(8!4l(77ORj)E45-q}DD?k5dI`N{_9cxdn`d@rR!aN>ro!m|q>yzIYj}o{Z zI

hoVN*buH}60`&dlT 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

NS-own{b-@DQN*}Xzo^`N_RT-{imAg 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�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 B6q5h| literal 0 HcmV?d00001 From e6866ff19c26599b0a5df48c0a0c3a3cfd3145dd Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 20 Apr 2026 15:40:43 +0800 Subject: [PATCH 11/30] feat(auth): add refresh backoff for ineffective token updates - Introduced `refreshIneffectiveBackoff` to prevent tight-looping in auto-refresh when token refresh fails to update expiry. - Adjusted refresh logic to apply backoff when `shouldRefresh` evaluates true. Closes: #2830 --- sdk/cliproxy/auth/conductor.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index f58722039c..0a9c157b0a 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -64,8 +64,13 @@ const ( refreshMaxConcurrency = 16 refreshPendingBackoff = time.Minute refreshFailureBackoff = 5 * time.Minute - quotaBackoffBase = time.Second - quotaBackoffMax = 30 * time.Minute + // refreshIneffectiveBackoff throttles refresh attempts when an executor returns + // success but the auth still evaluates as needing refresh (e.g. token expiry + // wasn't updated). Without this guard, the auto-refresh loop can tight-loop and + // burn CPU at idle. + refreshIneffectiveBackoff = 30 * time.Second + quotaBackoffBase = time.Second + quotaBackoffMax = 30 * time.Minute ) var quotaCooldownDisabled atomic.Bool @@ -3240,6 +3245,9 @@ func (m *Manager) refreshAuth(ctx context.Context, id string) { updated.NextRefreshAfter = time.Time{} updated.LastError = nil updated.UpdatedAt = now + if m.shouldRefresh(updated, now) { + updated.NextRefreshAfter = now.Add(refreshIneffectiveBackoff) + } _, _ = m.Update(ctx, updated) } From bb8408cef591cd292ecb41ff4ff805d11a489315 Mon Sep 17 00:00:00 2001 From: stringer07 <1742292793@qq.com> Date: Tue, 21 Apr 2026 16:03:56 +0800 Subject: [PATCH 12/30] fix(codex): backfill streaming response output --- internal/runtime/executor/codex_executor.go | 54 ++++++++++++++++++- .../codex_executor_stream_output_test.go | 51 ++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 41b1c32527..bceeeb6c9d 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -36,6 +36,48 @@ const ( var dataTag = []byte("data:") +// Streamed Codex responses may emit response.output_item.done events while leaving +// response.completed.response.output empty. Keep the stream path aligned with the +// already-patched non-stream path by reconstructing response.output from those items. +func collectCodexOutputItemDone(eventData []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback *[][]byte) { + itemResult := gjson.GetBytes(eventData, "item") + if !itemResult.Exists() || itemResult.Type != gjson.JSON { + return + } + outputIndexResult := gjson.GetBytes(eventData, "output_index") + if outputIndexResult.Exists() { + outputItemsByIndex[outputIndexResult.Int()] = []byte(itemResult.Raw) + return + } + *outputItemsFallback = append(*outputItemsFallback, []byte(itemResult.Raw)) +} + +func patchCodexCompletedOutput(eventData []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback [][]byte) []byte { + outputResult := gjson.GetBytes(eventData, "response.output") + shouldPatchOutput := (!outputResult.Exists() || !outputResult.IsArray() || len(outputResult.Array()) == 0) && (len(outputItemsByIndex) > 0 || len(outputItemsFallback) > 0) + if !shouldPatchOutput { + return eventData + } + + completedDataPatched := eventData + completedDataPatched, _ = sjson.SetRawBytes(completedDataPatched, "response.output", []byte(`[]`)) + + indexes := make([]int64, 0, len(outputItemsByIndex)) + for idx := range outputItemsByIndex { + indexes = append(indexes, idx) + } + sort.Slice(indexes, func(i, j int) bool { + return indexes[i] < indexes[j] + }) + for _, idx := range indexes { + completedDataPatched, _ = sjson.SetRawBytes(completedDataPatched, "response.output.-1", outputItemsByIndex[idx]) + } + for _, item := range outputItemsFallback { + completedDataPatched, _ = sjson.SetRawBytes(completedDataPatched, "response.output.-1", item) + } + return completedDataPatched +} + // CodexExecutor is a stateless executor for Codex (OpenAI Responses API entrypoint). // If api_key is unavailable on auth, it falls back to legacy via ClientAdapter. type CodexExecutor struct { @@ -414,20 +456,28 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au scanner := bufio.NewScanner(httpResp.Body) scanner.Buffer(nil, 52_428_800) // 50MB var param any + outputItemsByIndex := make(map[int64][]byte) + var outputItemsFallback [][]byte for scanner.Scan() { line := scanner.Bytes() helps.AppendAPIResponseChunk(ctx, e.cfg, line) + translatedLine := bytes.Clone(line) if bytes.HasPrefix(line, dataTag) { data := bytes.TrimSpace(line[5:]) - if gjson.GetBytes(data, "type").String() == "response.completed" { + switch gjson.GetBytes(data, "type").String() { + case "response.output_item.done": + collectCodexOutputItemDone(data, outputItemsByIndex, &outputItemsFallback) + case "response.completed": if detail, ok := helps.ParseCodexUsage(data); ok { reporter.Publish(ctx, detail) } + data = patchCodexCompletedOutput(data, outputItemsByIndex, outputItemsFallback) + translatedLine = append([]byte("data: "), data...) } } - chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, body, bytes.Clone(line), ¶m) + chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, body, translatedLine, ¶m) for i := range chunks { out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]} } diff --git a/internal/runtime/executor/codex_executor_stream_output_test.go b/internal/runtime/executor/codex_executor_stream_output_test.go index 91d9b0761c..a2da45e199 100644 --- a/internal/runtime/executor/codex_executor_stream_output_test.go +++ b/internal/runtime/executor/codex_executor_stream_output_test.go @@ -1,6 +1,7 @@ package executor import ( + "bytes" "context" "net/http" "net/http/httptest" @@ -44,3 +45,53 @@ func TestCodexExecutorExecute_EmptyStreamCompletionOutputUsesOutputItemDone(t *t t.Fatalf("choices.0.message.content = %q, want %q; payload=%s", gotContent, "ok", string(resp.Payload)) } } + +func TestCodexExecutorExecuteStream_EmptyStreamCompletionOutputUsesOutputItemDone(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]},\"output_index\":0}\n")) + _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"created_at\":1775555723,\"status\":\"completed\",\"model\":\"gpt-5.4-mini-2026-03-17\",\"output\":[],\"usage\":{\"input_tokens\":8,\"output_tokens\":28,\"total_tokens\":36}}}\n\n")) + })) + defer server.Close() + + executor := NewCodexExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "base_url": server.URL, + "api_key": "test", + }} + + result, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ + Model: "gpt-5.4-mini", + Payload: []byte(`{"model":"gpt-5.4-mini","input":"Say ok"}`), + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai-response"), + Stream: true, + }) + if err != nil { + t.Fatalf("ExecuteStream error: %v", err) + } + + var completed []byte + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatalf("stream chunk error: %v", chunk.Err) + } + payload := bytes.TrimSpace(chunk.Payload) + if !bytes.HasPrefix(payload, []byte("data:")) { + continue + } + data := bytes.TrimSpace(payload[5:]) + if gjson.GetBytes(data, "type").String() == "response.completed" { + completed = append([]byte(nil), data...) + } + } + + if len(completed) == 0 { + t.Fatal("missing response.completed chunk") + } + + gotContent := gjson.GetBytes(completed, "response.output.0.content.0.text").String() + if gotContent != "ok" { + t.Fatalf("response.output[0].content[0].text = %q, want %q; completed=%s", gotContent, "ok", string(completed)) + } +} From b6781d69bedae8693fe156d369b9bf69b98eb7b3 Mon Sep 17 00:00:00 2001 From: stringer07 <1742292793@qq.com> Date: Tue, 21 Apr 2026 16:29:54 +0800 Subject: [PATCH 13/30] perf(codex): avoid repeated output patch writes --- internal/runtime/executor/codex_executor.go | 33 +++++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index bceeeb6c9d..7d4d3edf89 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -59,9 +59,6 @@ func patchCodexCompletedOutput(eventData []byte, outputItemsByIndex map[int64][] return eventData } - completedDataPatched := eventData - completedDataPatched, _ = sjson.SetRawBytes(completedDataPatched, "response.output", []byte(`[]`)) - indexes := make([]int64, 0, len(outputItemsByIndex)) for idx := range outputItemsByIndex { indexes = append(indexes, idx) @@ -69,12 +66,36 @@ func patchCodexCompletedOutput(eventData []byte, outputItemsByIndex map[int64][] sort.Slice(indexes, func(i, j int) bool { return indexes[i] < indexes[j] }) + + items := make([][]byte, 0, len(outputItemsByIndex)+len(outputItemsFallback)) for _, idx := range indexes { - completedDataPatched, _ = sjson.SetRawBytes(completedDataPatched, "response.output.-1", outputItemsByIndex[idx]) + items = append(items, outputItemsByIndex[idx]) } - for _, item := range outputItemsFallback { - completedDataPatched, _ = sjson.SetRawBytes(completedDataPatched, "response.output.-1", item) + items = append(items, outputItemsFallback...) + + outputArray := []byte("[]") + if len(items) > 0 { + var buf bytes.Buffer + totalLen := 2 + for _, item := range items { + totalLen += len(item) + } + if len(items) > 1 { + totalLen += len(items) - 1 + } + buf.Grow(totalLen) + buf.WriteByte('[') + for i, item := range items { + if i > 0 { + buf.WriteByte(',') + } + buf.Write(item) + } + buf.WriteByte(']') + outputArray = buf.Bytes() } + + completedDataPatched, _ := sjson.SetRawBytes(eventData, "response.output", outputArray) return completedDataPatched } From 1716a845eb78a9d2ee39dddc0941d3981bf543ab Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Tue, 21 Apr 2026 20:16:18 +0800 Subject: [PATCH 14/30] feat(api): add support for `HEAD` requests to `/healthz` endpoint - Refactored `/healthz` handler to support `HEAD` requests alongside `GET`. - Updated tests to include validation for `HEAD` requests with expected status and empty body. Closes: #2929 --- internal/api/server.go | 11 +++++++-- internal/api/server_test.go | 45 ++++++++++++++++++++++++------------- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index 075455ba83..9b7452555b 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -319,9 +319,16 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk // setupRoutes configures the API routes for the server. // It defines the endpoints and associates them with their respective handlers. func (s *Server) setupRoutes() { - s.engine.GET("/healthz", func(c *gin.Context) { + healthzHandler := func(c *gin.Context) { + if c.Request.Method == http.MethodHead { + c.Status(http.StatusOK) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) - }) + } + s.engine.GET("/healthz", healthzHandler) + s.engine.HEAD("/healthz", healthzHandler) s.engine.GET("/management.html", s.serveManagementControlPanel) openaiHandlers := openai.NewOpenAIAPIHandler(s.handlers) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index dbc2cd5a83..db1ef27d17 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -50,23 +50,38 @@ func newTestServer(t *testing.T) *Server { func TestHealthz(t *testing.T) { server := newTestServer(t) - req := httptest.NewRequest(http.MethodGet, "/healthz", nil) - rr := httptest.NewRecorder() - server.engine.ServeHTTP(rr, req) + t.Run("GET", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rr := httptest.NewRecorder() + server.engine.ServeHTTP(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("unexpected status code: got %d want %d; body=%s", rr.Code, http.StatusOK, rr.Body.String()) - } + if rr.Code != http.StatusOK { + t.Fatalf("unexpected status code: got %d want %d; body=%s", rr.Code, http.StatusOK, rr.Body.String()) + } - var resp struct { - Status string `json:"status"` - } - if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { - t.Fatalf("failed to parse response JSON: %v; body=%s", err, rr.Body.String()) - } - if resp.Status != "ok" { - t.Fatalf("unexpected response status: got %q want %q", resp.Status, "ok") - } + var resp struct { + Status string `json:"status"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response JSON: %v; body=%s", err, rr.Body.String()) + } + if resp.Status != "ok" { + t.Fatalf("unexpected response status: got %q want %q", resp.Status, "ok") + } + }) + + t.Run("HEAD", func(t *testing.T) { + req := httptest.NewRequest(http.MethodHead, "/healthz", nil) + rr := httptest.NewRecorder() + server.engine.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("unexpected status code: got %d want %d; body=%s", rr.Code, http.StatusOK, rr.Body.String()) + } + if rr.Body.Len() != 0 { + t.Fatalf("expected empty body for HEAD request, got %q", rr.Body.String()) + } + }) } func TestAmpProviderModelRoutes(t *testing.T) { From 4fc2c619fb350a2b7bea371a5bd15f6e41dcc8e7 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Tue, 21 Apr 2026 20:53:03 +0800 Subject: [PATCH 15/30] feat(models): add Kimi K2.6 model entry to registry JSON --- internal/registry/models/models.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json index 65d8325169..24b96ca95f 100644 --- a/internal/registry/models/models.json +++ b/internal/registry/models/models.json @@ -1670,6 +1670,23 @@ "zero_allowed": true, "dynamic_allowed": true } + }, + { + "id": "kimi-k2.6", + "object": "model", + "created": 1776729600, + "owned_by": "moonshot", + "type": "kimi", + "display_name": "Kimi K2.6", + "description": "Kimi K2.6 - Latest Moonshot AI coding model with improved capabilities", + "context_length": 262144, + "max_completion_tokens": 65536, + "thinking": { + "min": 1024, + "max": 32000, + "zero_allowed": true, + "dynamic_allowed": true + } } ], "antigravity": [ From e935196df43cb9af478fea377571873d07c9a39b Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 22 Apr 2026 20:51:13 +0800 Subject: [PATCH 16/30] feat(models): add hardcoded GPT-Image-2 model support in Codex - Added `GPT-Image-2` as a built-in model to avoid dependency on remote updates for Codex. - Updated model tier functions (`CodexFree`, `CodexTeam`, etc.) to include built-in models via `WithCodexBuiltins`. - Introduced new handlers for image generation and edit operations under `OpenAIAPIHandler`. - Extended tests to validate 503 response for unsupported image model requests. --- internal/api/server.go | 2 + internal/registry/model_definitions.go | 75 +- sdk/api/handlers/handlers.go | 7 + .../handlers/handlers_request_details_test.go | 21 + .../handlers/openai/openai_images_handlers.go | 904 ++++++++++++++++++ sdk/cliproxy/service.go | 2 +- 6 files changed, 1006 insertions(+), 5 deletions(-) create mode 100644 sdk/api/handlers/openai/openai_images_handlers.go diff --git a/internal/api/server.go b/internal/api/server.go index 9b7452555b..7c571e23cf 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -344,6 +344,8 @@ func (s *Server) setupRoutes() { v1.GET("/models", s.unifiedModelsHandler(openaiHandlers, claudeCodeHandlers)) v1.POST("/chat/completions", openaiHandlers.ChatCompletions) v1.POST("/completions", openaiHandlers.Completions) + v1.POST("/images/generations", openaiHandlers.ImagesGenerations) + v1.POST("/images/edits", openaiHandlers.ImagesEdits) v1.POST("/messages", claudeCodeHandlers.ClaudeMessages) v1.POST("/messages/count_tokens", claudeCodeHandlers.ClaudeCountTokens) v1.GET("/responses", openaiResponsesHandlers.ResponsesWebsocket) diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index ab7258f845..7ac6b469ac 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -6,6 +6,8 @@ import ( "strings" ) +const codexBuiltinImageModelID = "gpt-image-2" + // staticModelsJSON mirrors the top-level structure of models.json. type staticModelsJSON struct { Claude []*ModelInfo `json:"claude"` @@ -48,22 +50,22 @@ func GetAIStudioModels() []*ModelInfo { // GetCodexFreeModels returns model definitions for the Codex free plan tier. func GetCodexFreeModels() []*ModelInfo { - return cloneModelInfos(getModels().CodexFree) + return WithCodexBuiltins(cloneModelInfos(getModels().CodexFree)) } // GetCodexTeamModels returns model definitions for the Codex team plan tier. func GetCodexTeamModels() []*ModelInfo { - return cloneModelInfos(getModels().CodexTeam) + return WithCodexBuiltins(cloneModelInfos(getModels().CodexTeam)) } // GetCodexPlusModels returns model definitions for the Codex plus plan tier. func GetCodexPlusModels() []*ModelInfo { - return cloneModelInfos(getModels().CodexPlus) + return WithCodexBuiltins(cloneModelInfos(getModels().CodexPlus)) } // GetCodexProModels returns model definitions for the Codex pro plan tier. func GetCodexProModels() []*ModelInfo { - return cloneModelInfos(getModels().CodexPro) + return WithCodexBuiltins(cloneModelInfos(getModels().CodexPro)) } // GetKimiModels returns the standard Kimi (Moonshot AI) model definitions. @@ -76,6 +78,71 @@ func GetAntigravityModels() []*ModelInfo { return cloneModelInfos(getModels().Antigravity) } +// WithCodexBuiltins injects hard-coded Codex-only model definitions that should +// not depend on remote models.json updates. Built-ins replace any matching IDs +// already present in the provided slice. +func WithCodexBuiltins(models []*ModelInfo) []*ModelInfo { + return upsertModelInfos(models, codexBuiltinImageModelInfo()) +} + +func codexBuiltinImageModelInfo() *ModelInfo { + return &ModelInfo{ + ID: codexBuiltinImageModelID, + Object: "model", + Created: 1704067200, // 2024-01-01 + OwnedBy: "openai", + Type: "openai", + DisplayName: "GPT Image 2", + Version: codexBuiltinImageModelID, + } +} + +func upsertModelInfos(models []*ModelInfo, extras ...*ModelInfo) []*ModelInfo { + if len(extras) == 0 { + return models + } + + extraIDs := make(map[string]struct{}, len(extras)) + extraList := make([]*ModelInfo, 0, len(extras)) + for _, extra := range extras { + if extra == nil { + continue + } + id := strings.TrimSpace(extra.ID) + if id == "" { + continue + } + key := strings.ToLower(id) + if _, exists := extraIDs[key]; exists { + continue + } + extraIDs[key] = struct{}{} + extraList = append(extraList, cloneModelInfo(extra)) + } + + if len(extraList) == 0 { + return models + } + + filtered := make([]*ModelInfo, 0, len(models)+len(extraList)) + for _, model := range models { + if model == nil { + continue + } + id := strings.TrimSpace(model.ID) + if id == "" { + continue + } + if _, exists := extraIDs[strings.ToLower(id)]; exists { + continue + } + filtered = append(filtered, model) + } + + filtered = append(filtered, extraList...) + return filtered +} + // cloneModelInfos returns a shallow copy of the slice with each element deep-cloned. func cloneModelInfos(models []*ModelInfo) []*ModelInfo { if len(models) == 0 { diff --git a/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go index 49e73d4637..1fda8f49f0 100644 --- a/sdk/api/handlers/handlers.go +++ b/sdk/api/handlers/handlers.go @@ -795,6 +795,13 @@ func (h *BaseAPIHandler) getRequestDetails(modelName string) (providers []string parsed := thinking.ParseSuffix(resolvedModelName) baseModel := strings.TrimSpace(parsed.ModelName) + if strings.EqualFold(baseModel, "gpt-image-2") { + return nil, "", &interfaces.ErrorMessage{ + StatusCode: http.StatusServiceUnavailable, + Error: fmt.Errorf("model %s is only supported on /v1/images/generations and /v1/images/edits", baseModel), + } + } + providers = util.GetProviderName(baseModel) // Fallback: if baseModel has no provider but differs from resolvedModelName, // try using the full model name. This handles edge cases where custom models diff --git a/sdk/api/handlers/handlers_request_details_test.go b/sdk/api/handlers/handlers_request_details_test.go index b0f6b13262..c98580f224 100644 --- a/sdk/api/handlers/handlers_request_details_test.go +++ b/sdk/api/handlers/handlers_request_details_test.go @@ -1,7 +1,9 @@ package handlers import ( + "net/http" "reflect" + "strings" "testing" "time" @@ -116,3 +118,22 @@ func TestGetRequestDetails_PreservesSuffix(t *testing.T) { }) } } + +func TestGetRequestDetails_ImageModelReturns503(t *testing.T) { + handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, coreauth.NewManager(nil, nil, nil)) + + _, _, errMsg := handler.getRequestDetails("gpt-image-2") + if errMsg == nil { + t.Fatalf("expected error for gpt-image-2, got nil") + } + if errMsg.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("unexpected status code: got %d want %d", errMsg.StatusCode, http.StatusServiceUnavailable) + } + if errMsg.Error == nil { + t.Fatalf("expected error message, got nil") + } + msg := errMsg.Error.Error() + if !strings.Contains(msg, "/v1/images/generations") || !strings.Contains(msg, "/v1/images/edits") { + t.Fatalf("unexpected error message: %q", msg) + } +} diff --git a/sdk/api/handlers/openai/openai_images_handlers.go b/sdk/api/handlers/openai/openai_images_handlers.go new file mode 100644 index 0000000000..586354dedb --- /dev/null +++ b/sdk/api/handlers/openai/openai_images_handlers.go @@ -0,0 +1,904 @@ +package openai + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" + log "github.com/sirupsen/logrus" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const ( + defaultImagesMainModel = "gpt-5.4-mini" + defaultImagesToolModel = "gpt-image-2" +) + +type imageCallResult struct { + Result string + RevisedPrompt string + OutputFormat string + Size string + Background string + Quality string +} + +type sseFrameAccumulator struct { + pending []byte +} + +func (a *sseFrameAccumulator) AddChunk(chunk []byte) [][]byte { + if len(chunk) == 0 { + return nil + } + + if responsesSSENeedsLineBreak(a.pending, chunk) { + a.pending = append(a.pending, '\n') + } + a.pending = append(a.pending, chunk...) + + var frames [][]byte + for { + frameLen := responsesSSEFrameLen(a.pending) + if frameLen == 0 { + break + } + frames = append(frames, a.pending[:frameLen]) + copy(a.pending, a.pending[frameLen:]) + a.pending = a.pending[:len(a.pending)-frameLen] + } + + if len(bytes.TrimSpace(a.pending)) == 0 { + a.pending = a.pending[:0] + return frames + } + if len(a.pending) == 0 || !responsesSSECanEmitWithoutDelimiter(a.pending) { + return frames + } + frames = append(frames, a.pending) + a.pending = a.pending[:0] + return frames +} + +func (a *sseFrameAccumulator) Flush() [][]byte { + if len(a.pending) == 0 { + return nil + } + + var frames [][]byte + for { + frameLen := responsesSSEFrameLen(a.pending) + if frameLen == 0 { + break + } + frames = append(frames, a.pending[:frameLen]) + copy(a.pending, a.pending[frameLen:]) + a.pending = a.pending[:len(a.pending)-frameLen] + } + + if len(bytes.TrimSpace(a.pending)) == 0 { + a.pending = nil + return frames + } + if responsesSSECanEmitWithoutDelimiter(a.pending) { + frames = append(frames, a.pending) + } + a.pending = nil + return frames +} + +func mimeTypeFromOutputFormat(outputFormat string) string { + if outputFormat == "" { + return "image/png" + } + if strings.Contains(outputFormat, "/") { + return outputFormat + } + switch strings.ToLower(strings.TrimSpace(outputFormat)) { + case "png": + return "image/png" + case "jpg", "jpeg": + return "image/jpeg" + case "webp": + return "image/webp" + default: + return "image/png" + } +} + +func multipartFileToDataURL(fileHeader *multipart.FileHeader) (string, error) { + if fileHeader == nil { + return "", fmt.Errorf("upload file is nil") + } + f, err := fileHeader.Open() + if err != nil { + return "", fmt.Errorf("open upload file failed: %w", err) + } + defer func() { + if errClose := f.Close(); errClose != nil { + log.Errorf("openai images: close upload file error: %v", errClose) + } + }() + + data, err := io.ReadAll(f) + if err != nil { + return "", fmt.Errorf("read upload file failed: %w", err) + } + + mediaType := strings.TrimSpace(fileHeader.Header.Get("Content-Type")) + if mediaType == "" { + mediaType = http.DetectContentType(data) + } + + b64 := base64.StdEncoding.EncodeToString(data) + return "data:" + mediaType + ";base64," + b64, nil +} + +func parseIntField(raw string, fallback int64) int64 { + raw = strings.TrimSpace(raw) + if raw == "" { + return fallback + } + v, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return fallback + } + return v +} + +func parseBoolField(raw string, fallback bool) bool { + raw = strings.TrimSpace(strings.ToLower(raw)) + if raw == "" { + return fallback + } + switch raw { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + return fallback + } +} + +func (h *OpenAIAPIHandler) ImagesGenerations(c *gin.Context) { + rawJSON, err := c.GetRawData() + if err != nil { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: fmt.Sprintf("Invalid request: %v", err), + Type: "invalid_request_error", + }, + }) + return + } + if !json.Valid(rawJSON) { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: "Invalid request: body must be valid JSON", + Type: "invalid_request_error", + }, + }) + return + } + + prompt := strings.TrimSpace(gjson.GetBytes(rawJSON, "prompt").String()) + if prompt == "" { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: "Invalid request: prompt is required", + Type: "invalid_request_error", + }, + }) + return + } + + imageModel := strings.TrimSpace(gjson.GetBytes(rawJSON, "model").String()) + if imageModel == "" { + imageModel = defaultImagesToolModel + } + responseFormat := strings.TrimSpace(gjson.GetBytes(rawJSON, "response_format").String()) + if responseFormat == "" { + responseFormat = "b64_json" + } + stream := gjson.GetBytes(rawJSON, "stream").Bool() + + tool := []byte(`{"type":"image_generation","action":"generate"}`) + tool, _ = sjson.SetBytes(tool, "model", imageModel) + + if v := strings.TrimSpace(gjson.GetBytes(rawJSON, "size").String()); v != "" { + tool, _ = sjson.SetBytes(tool, "size", v) + } + if v := strings.TrimSpace(gjson.GetBytes(rawJSON, "quality").String()); v != "" { + tool, _ = sjson.SetBytes(tool, "quality", v) + } + if v := strings.TrimSpace(gjson.GetBytes(rawJSON, "background").String()); v != "" { + tool, _ = sjson.SetBytes(tool, "background", v) + } + if v := strings.TrimSpace(gjson.GetBytes(rawJSON, "output_format").String()); v != "" { + tool, _ = sjson.SetBytes(tool, "output_format", v) + } + if v := gjson.GetBytes(rawJSON, "output_compression"); v.Exists() { + if v.Type == gjson.Number { + tool, _ = sjson.SetBytes(tool, "output_compression", v.Int()) + } + } + if v := gjson.GetBytes(rawJSON, "partial_images"); v.Exists() { + if v.Type == gjson.Number { + tool, _ = sjson.SetBytes(tool, "partial_images", v.Int()) + } + } + if v := gjson.GetBytes(rawJSON, "n"); v.Exists() { + if v.Type == gjson.Number { + tool, _ = sjson.SetBytes(tool, "n", v.Int()) + } + } + if v := strings.TrimSpace(gjson.GetBytes(rawJSON, "moderation").String()); v != "" { + tool, _ = sjson.SetBytes(tool, "moderation", v) + } + + responsesReq := buildImagesResponsesRequest(prompt, nil, tool) + if stream { + h.streamImagesFromResponses(c, responsesReq, responseFormat, "image_generation") + return + } + h.collectImagesFromResponses(c, responsesReq, responseFormat) +} + +func (h *OpenAIAPIHandler) ImagesEdits(c *gin.Context) { + contentType := strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))) + if strings.HasPrefix(contentType, "application/json") { + h.imagesEditsFromJSON(c) + return + } + if strings.HasPrefix(contentType, "multipart/form-data") || contentType == "" { + h.imagesEditsFromMultipart(c) + return + } + + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: fmt.Sprintf("Invalid request: unsupported Content-Type %q", contentType), + Type: "invalid_request_error", + }, + }) +} + +func (h *OpenAIAPIHandler) imagesEditsFromMultipart(c *gin.Context) { + form, err := c.MultipartForm() + if err != nil { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: fmt.Sprintf("Invalid request: %v", err), + Type: "invalid_request_error", + }, + }) + return + } + + prompt := strings.TrimSpace(c.PostForm("prompt")) + if prompt == "" { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: "Invalid request: prompt is required", + Type: "invalid_request_error", + }, + }) + return + } + + var imageFiles []*multipart.FileHeader + if files := form.File["image[]"]; len(files) > 0 { + imageFiles = files + } else if files := form.File["image"]; len(files) > 0 { + imageFiles = files + } + if len(imageFiles) == 0 { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: "Invalid request: image is required", + Type: "invalid_request_error", + }, + }) + return + } + + images := make([]string, 0, len(imageFiles)) + for _, fh := range imageFiles { + dataURL, err := multipartFileToDataURL(fh) + if err != nil { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: fmt.Sprintf("Invalid request: %v", err), + Type: "invalid_request_error", + }, + }) + return + } + images = append(images, dataURL) + } + + var maskDataURL *string + if maskFiles := form.File["mask"]; len(maskFiles) > 0 && maskFiles[0] != nil { + dataURL, err := multipartFileToDataURL(maskFiles[0]) + if err != nil { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: fmt.Sprintf("Invalid request: %v", err), + Type: "invalid_request_error", + }, + }) + return + } + maskDataURL = &dataURL + } + + imageModel := strings.TrimSpace(c.PostForm("model")) + if imageModel == "" { + imageModel = defaultImagesToolModel + } + responseFormat := strings.TrimSpace(c.PostForm("response_format")) + if responseFormat == "" { + responseFormat = "b64_json" + } + stream := parseBoolField(c.PostForm("stream"), false) + + tool := []byte(`{"type":"image_generation","action":"edit"}`) + tool, _ = sjson.SetBytes(tool, "model", imageModel) + + if v := strings.TrimSpace(c.PostForm("size")); v != "" { + tool, _ = sjson.SetBytes(tool, "size", v) + } + if v := strings.TrimSpace(c.PostForm("quality")); v != "" { + tool, _ = sjson.SetBytes(tool, "quality", v) + } + if v := strings.TrimSpace(c.PostForm("background")); v != "" { + tool, _ = sjson.SetBytes(tool, "background", v) + } + if v := strings.TrimSpace(c.PostForm("output_format")); v != "" { + tool, _ = sjson.SetBytes(tool, "output_format", v) + } + if v := strings.TrimSpace(c.PostForm("input_fidelity")); v != "" { + tool, _ = sjson.SetBytes(tool, "input_fidelity", v) + } + if v := strings.TrimSpace(c.PostForm("moderation")); v != "" { + tool, _ = sjson.SetBytes(tool, "moderation", v) + } + + if v := strings.TrimSpace(c.PostForm("output_compression")); v != "" { + tool, _ = sjson.SetBytes(tool, "output_compression", parseIntField(v, 0)) + } + if v := strings.TrimSpace(c.PostForm("partial_images")); v != "" { + tool, _ = sjson.SetBytes(tool, "partial_images", parseIntField(v, 0)) + } + if v := strings.TrimSpace(c.PostForm("n")); v != "" { + tool, _ = sjson.SetBytes(tool, "n", parseIntField(v, 0)) + } + + if maskDataURL != nil && strings.TrimSpace(*maskDataURL) != "" { + tool, _ = sjson.SetBytes(tool, "input_image_mask.image_url", strings.TrimSpace(*maskDataURL)) + } + + responsesReq := buildImagesResponsesRequest(prompt, images, tool) + if stream { + h.streamImagesFromResponses(c, responsesReq, responseFormat, "image_edit") + return + } + h.collectImagesFromResponses(c, responsesReq, responseFormat) +} + +func (h *OpenAIAPIHandler) imagesEditsFromJSON(c *gin.Context) { + rawJSON, err := c.GetRawData() + if err != nil { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: fmt.Sprintf("Invalid request: %v", err), + Type: "invalid_request_error", + }, + }) + return + } + if !json.Valid(rawJSON) { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: "Invalid request: body must be valid JSON", + Type: "invalid_request_error", + }, + }) + return + } + + prompt := strings.TrimSpace(gjson.GetBytes(rawJSON, "prompt").String()) + if prompt == "" { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: "Invalid request: prompt is required", + Type: "invalid_request_error", + }, + }) + return + } + + var images []string + imagesResult := gjson.GetBytes(rawJSON, "images") + if imagesResult.IsArray() { + for _, img := range imagesResult.Array() { + url := strings.TrimSpace(img.Get("image_url").String()) + if url == "" { + continue + } + images = append(images, url) + } + } + if len(images) == 0 { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: "Invalid request: images[].image_url is required (file_id is not supported)", + Type: "invalid_request_error", + }, + }) + return + } + + var maskDataURL *string + if mask := gjson.GetBytes(rawJSON, "mask.image_url"); mask.Exists() { + url := strings.TrimSpace(mask.String()) + if url != "" { + maskDataURL = &url + } + } else if mask := gjson.GetBytes(rawJSON, "mask.file_id"); mask.Exists() { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: "Invalid request: mask.file_id is not supported (use mask.image_url instead)", + Type: "invalid_request_error", + }, + }) + return + } + + imageModel := strings.TrimSpace(gjson.GetBytes(rawJSON, "model").String()) + if imageModel == "" { + imageModel = defaultImagesToolModel + } + responseFormat := strings.TrimSpace(gjson.GetBytes(rawJSON, "response_format").String()) + if responseFormat == "" { + responseFormat = "b64_json" + } + stream := gjson.GetBytes(rawJSON, "stream").Bool() + + tool := []byte(`{"type":"image_generation","action":"edit"}`) + tool, _ = sjson.SetBytes(tool, "model", imageModel) + + for _, field := range []string{"size", "quality", "background", "output_format", "input_fidelity", "moderation"} { + if v := strings.TrimSpace(gjson.GetBytes(rawJSON, field).String()); v != "" { + tool, _ = sjson.SetBytes(tool, field, v) + } + } + + for _, field := range []string{"output_compression", "partial_images", "n"} { + if v := gjson.GetBytes(rawJSON, field); v.Exists() && v.Type == gjson.Number { + tool, _ = sjson.SetBytes(tool, field, v.Int()) + } + } + + if maskDataURL != nil && strings.TrimSpace(*maskDataURL) != "" { + tool, _ = sjson.SetBytes(tool, "input_image_mask.image_url", strings.TrimSpace(*maskDataURL)) + } + + responsesReq := buildImagesResponsesRequest(prompt, images, tool) + if stream { + h.streamImagesFromResponses(c, responsesReq, responseFormat, "image_edit") + return + } + h.collectImagesFromResponses(c, responsesReq, responseFormat) +} + +func buildImagesResponsesRequest(prompt string, images []string, toolJSON []byte) []byte { + req := []byte(`{"instructions":"","stream":true,"reasoning":{"effort":"medium","summary":"auto"},"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"model":"","store":false,"tool_choice":{"type":"image_generation"}}`) + req, _ = sjson.SetBytes(req, "model", defaultImagesMainModel) + + input := []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`) + input, _ = sjson.SetBytes(input, "0.content.0.text", prompt) + contentIndex := 1 + for _, img := range images { + if strings.TrimSpace(img) == "" { + continue + } + part := []byte(`{"type":"input_image","image_url":""}`) + part, _ = sjson.SetBytes(part, "image_url", img) + path := fmt.Sprintf("0.content.%d", contentIndex) + input, _ = sjson.SetRawBytes(input, path, part) + contentIndex++ + } + req, _ = sjson.SetRawBytes(req, "input", input) + + req, _ = sjson.SetRawBytes(req, "tools", []byte(`[]`)) + if len(toolJSON) > 0 && json.Valid(toolJSON) { + req, _ = sjson.SetRawBytes(req, "tools.-1", toolJSON) + } + return req +} + +func (h *OpenAIAPIHandler) collectImagesFromResponses(c *gin.Context, responsesReq []byte, responseFormat string) { + c.Header("Content-Type", "application/json") + + cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background()) + stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx) + + dataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, "openai-response", defaultImagesMainModel, responsesReq, "") + + out, errMsg := collectImagesFromResponsesStream(cliCtx, dataChan, errChan, responseFormat) + stopKeepAlive() + if errMsg != nil { + h.WriteErrorResponse(c, errMsg) + if errMsg.Error != nil { + cliCancel(errMsg.Error) + } else { + cliCancel(nil) + } + return + } + handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders) + _, _ = c.Writer.Write(out) + cliCancel() +} + +func collectImagesFromResponsesStream(ctx context.Context, data <-chan []byte, errs <-chan *interfaces.ErrorMessage, responseFormat string) ([]byte, *interfaces.ErrorMessage) { + acc := &sseFrameAccumulator{} + + processFrame := func(frame []byte) ([]byte, bool, *interfaces.ErrorMessage) { + for _, line := range bytes.Split(frame, []byte("\n")) { + trimmed := bytes.TrimSpace(bytes.TrimRight(line, "\r")) + if len(trimmed) == 0 { + continue + } + if !bytes.HasPrefix(trimmed, []byte("data:")) { + continue + } + payload := bytes.TrimSpace(trimmed[len("data:"):]) + if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) { + continue + } + if !json.Valid(payload) { + return nil, false, &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: fmt.Errorf("invalid SSE data JSON")} + } + + if gjson.GetBytes(payload, "type").String() != "response.completed" { + continue + } + + results, createdAt, usageRaw, firstMeta, err := extractImagesFromResponsesCompleted(payload) + if err != nil { + return nil, false, &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: err} + } + if len(results) == 0 { + return nil, false, &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: fmt.Errorf("upstream did not return image output")} + } + out, err := buildImagesAPIResponse(results, createdAt, usageRaw, firstMeta, responseFormat) + if err != nil { + return nil, false, &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: err} + } + return out, true, nil + } + return nil, false, nil + } + + for { + select { + case <-ctx.Done(): + return nil, &interfaces.ErrorMessage{StatusCode: http.StatusRequestTimeout, Error: ctx.Err()} + case errMsg, ok := <-errs: + if ok && errMsg != nil { + return nil, errMsg + } + errs = nil + case chunk, ok := <-data: + if !ok { + for _, frame := range acc.Flush() { + if out, done, errMsg := processFrame(frame); errMsg != nil { + return nil, errMsg + } else if done { + return out, nil + } + } + return nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: fmt.Errorf("stream disconnected before completion")} + } + for _, frame := range acc.AddChunk(chunk) { + if out, done, errMsg := processFrame(frame); errMsg != nil { + return nil, errMsg + } else if done { + return out, nil + } + } + } + } +} + +func extractImagesFromResponsesCompleted(payload []byte) (results []imageCallResult, createdAt int64, usageRaw []byte, firstMeta imageCallResult, err error) { + if gjson.GetBytes(payload, "type").String() != "response.completed" { + return nil, 0, nil, imageCallResult{}, fmt.Errorf("unexpected event type") + } + + createdAt = gjson.GetBytes(payload, "response.created_at").Int() + if createdAt <= 0 { + createdAt = time.Now().Unix() + } + + output := gjson.GetBytes(payload, "response.output") + if output.IsArray() { + for _, item := range output.Array() { + if item.Get("type").String() != "image_generation_call" { + continue + } + res := strings.TrimSpace(item.Get("result").String()) + if res == "" { + continue + } + entry := imageCallResult{ + Result: res, + RevisedPrompt: strings.TrimSpace(item.Get("revised_prompt").String()), + OutputFormat: strings.TrimSpace(item.Get("output_format").String()), + Size: strings.TrimSpace(item.Get("size").String()), + Background: strings.TrimSpace(item.Get("background").String()), + Quality: strings.TrimSpace(item.Get("quality").String()), + } + if len(results) == 0 { + firstMeta = entry + } + results = append(results, entry) + } + } + + if usage := gjson.GetBytes(payload, "response.tool_usage.image_gen"); usage.Exists() && usage.IsObject() { + usageRaw = []byte(usage.Raw) + } + + return results, createdAt, usageRaw, firstMeta, nil +} + +func buildImagesAPIResponse(results []imageCallResult, createdAt int64, usageRaw []byte, firstMeta imageCallResult, responseFormat string) ([]byte, error) { + out := []byte(`{"created":0,"data":[]}`) + out, _ = sjson.SetBytes(out, "created", createdAt) + + responseFormat = strings.ToLower(strings.TrimSpace(responseFormat)) + if responseFormat == "" { + responseFormat = "b64_json" + } + + for _, img := range results { + item := []byte(`{}`) + if responseFormat == "url" { + mt := mimeTypeFromOutputFormat(img.OutputFormat) + item, _ = sjson.SetBytes(item, "url", "data:"+mt+";base64,"+img.Result) + } else { + item, _ = sjson.SetBytes(item, "b64_json", img.Result) + } + if img.RevisedPrompt != "" { + item, _ = sjson.SetBytes(item, "revised_prompt", img.RevisedPrompt) + } + out, _ = sjson.SetRawBytes(out, "data.-1", item) + } + + if firstMeta.Background != "" { + out, _ = sjson.SetBytes(out, "background", firstMeta.Background) + } + if firstMeta.OutputFormat != "" { + out, _ = sjson.SetBytes(out, "output_format", firstMeta.OutputFormat) + } + if firstMeta.Quality != "" { + out, _ = sjson.SetBytes(out, "quality", firstMeta.Quality) + } + if firstMeta.Size != "" { + out, _ = sjson.SetBytes(out, "size", firstMeta.Size) + } + + if len(usageRaw) > 0 && json.Valid(usageRaw) { + out, _ = sjson.SetRawBytes(out, "usage", usageRaw) + } + + return out, nil +} + +func (h *OpenAIAPIHandler) streamImagesFromResponses(c *gin.Context, responsesReq []byte, responseFormat string, streamPrefix string) { + flusher, ok := c.Writer.(http.Flusher) + if !ok { + c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: "Streaming not supported", + Type: "server_error", + }, + }) + return + } + + cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background()) + dataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, "openai-response", defaultImagesMainModel, responsesReq, "") + + setSSEHeaders := func() { + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("Access-Control-Allow-Origin", "*") + } + + writeEvent := func(eventName string, dataJSON []byte) { + if strings.TrimSpace(eventName) != "" { + _, _ = fmt.Fprintf(c.Writer, "event: %s\n", eventName) + } + _, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", string(dataJSON)) + flusher.Flush() + } + + // Peek for first chunk/error so we can still return a JSON error body. + for { + select { + case <-c.Request.Context().Done(): + cliCancel(c.Request.Context().Err()) + return + case errMsg, ok := <-errChan: + if !ok { + errChan = nil + continue + } + h.WriteErrorResponse(c, errMsg) + if errMsg != nil { + cliCancel(errMsg.Error) + } else { + cliCancel(nil) + } + return + case chunk, ok := <-dataChan: + if !ok { + setSSEHeaders() + handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders) + _, _ = c.Writer.Write([]byte("\n")) + flusher.Flush() + cliCancel(nil) + return + } + + setSSEHeaders() + handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders) + + h.forwardImagesStream(cliCtx, c, flusher, func(err error) { cliCancel(err) }, dataChan, errChan, chunk, responseFormat, streamPrefix, writeEvent) + return + } + } +} + +func (h *OpenAIAPIHandler) forwardImagesStream(ctx context.Context, c *gin.Context, flusher http.Flusher, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage, firstChunk []byte, responseFormat string, streamPrefix string, writeEvent func(string, []byte)) { + acc := &sseFrameAccumulator{} + + responseFormat = strings.ToLower(strings.TrimSpace(responseFormat)) + if responseFormat == "" { + responseFormat = "b64_json" + } + + emitError := func(errMsg *interfaces.ErrorMessage) { + if errMsg == nil { + return + } + status := http.StatusInternalServerError + if errMsg.StatusCode > 0 { + status = errMsg.StatusCode + } + errText := http.StatusText(status) + if errMsg.Error != nil && strings.TrimSpace(errMsg.Error.Error()) != "" { + errText = errMsg.Error.Error() + } + body := handlers.BuildErrorResponseBody(status, errText) + writeEvent("error", body) + } + + processFrame := func(frame []byte) (done bool) { + for _, line := range bytes.Split(frame, []byte("\n")) { + trimmed := bytes.TrimSpace(bytes.TrimRight(line, "\r")) + if len(trimmed) == 0 || !bytes.HasPrefix(trimmed, []byte("data:")) { + continue + } + payload := bytes.TrimSpace(trimmed[len("data:"):]) + if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) || !json.Valid(payload) { + continue + } + + switch gjson.GetBytes(payload, "type").String() { + case "response.image_generation_call.partial_image": + b64 := strings.TrimSpace(gjson.GetBytes(payload, "partial_image_b64").String()) + if b64 == "" { + continue + } + outputFormat := strings.TrimSpace(gjson.GetBytes(payload, "output_format").String()) + index := gjson.GetBytes(payload, "partial_image_index").Int() + eventName := streamPrefix + ".partial_image" + data := []byte(`{"type":"","partial_image_index":0}`) + data, _ = sjson.SetBytes(data, "type", eventName) + data, _ = sjson.SetBytes(data, "partial_image_index", index) + if responseFormat == "url" { + mt := mimeTypeFromOutputFormat(outputFormat) + data, _ = sjson.SetBytes(data, "url", "data:"+mt+";base64,"+b64) + } else { + data, _ = sjson.SetBytes(data, "b64_json", b64) + } + writeEvent(eventName, data) + case "response.completed": + results, _, usageRaw, _, err := extractImagesFromResponsesCompleted(payload) + if err != nil { + emitError(&interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: err}) + return true + } + if len(results) == 0 { + emitError(&interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: fmt.Errorf("upstream did not return image output")}) + return true + } + eventName := streamPrefix + ".completed" + for _, img := range results { + data := []byte(`{"type":""}`) + data, _ = sjson.SetBytes(data, "type", eventName) + if responseFormat == "url" { + mt := mimeTypeFromOutputFormat(img.OutputFormat) + data, _ = sjson.SetBytes(data, "url", "data:"+mt+";base64,"+img.Result) + } else { + data, _ = sjson.SetBytes(data, "b64_json", img.Result) + } + if len(usageRaw) > 0 && json.Valid(usageRaw) { + data, _ = sjson.SetRawBytes(data, "usage", usageRaw) + } + writeEvent(eventName, data) + } + return true + } + } + return false + } + + for _, frame := range acc.AddChunk(firstChunk) { + if processFrame(frame) { + cancel(nil) + return + } + } + + for { + select { + case <-c.Request.Context().Done(): + cancel(c.Request.Context().Err()) + return + case errMsg, ok := <-errs: + if ok && errMsg != nil { + emitError(errMsg) + cancel(errMsg.Error) + return + } + errs = nil + case chunk, ok := <-data: + if !ok { + for _, frame := range acc.Flush() { + if processFrame(frame) { + cancel(nil) + return + } + } + cancel(nil) + return + } + for _, frame := range acc.AddChunk(chunk) { + if processFrame(frame) { + cancel(nil) + return + } + } + } + } +} diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 5e873d370b..fa0d8a0aa7 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -1410,7 +1410,7 @@ func buildCodexConfigModels(entry *config.CodexKey) []*ModelInfo { if entry == nil { return nil } - return buildConfigModels(entry.Models, "openai", "openai") + return registry.WithCodexBuiltins(buildConfigModels(entry.Models, "openai", "openai")) } func rewriteModelInfoName(name, oldID, newID string) string { From fd71960c3eecf8a56a075dfd83eb275bcd93d9b1 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 22 Apr 2026 21:12:50 +0800 Subject: [PATCH 17/30] fix(handlers): remove handling of unsupported `n` parameter in OpenAI image handlers --- sdk/api/handlers/openai/openai_images_handlers.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/sdk/api/handlers/openai/openai_images_handlers.go b/sdk/api/handlers/openai/openai_images_handlers.go index 586354dedb..7c96ae88cb 100644 --- a/sdk/api/handlers/openai/openai_images_handlers.go +++ b/sdk/api/handlers/openai/openai_images_handlers.go @@ -383,9 +383,11 @@ func (h *OpenAIAPIHandler) imagesEditsFromMultipart(c *gin.Context) { if v := strings.TrimSpace(c.PostForm("partial_images")); v != "" { tool, _ = sjson.SetBytes(tool, "partial_images", parseIntField(v, 0)) } - if v := strings.TrimSpace(c.PostForm("n")); v != "" { - tool, _ = sjson.SetBytes(tool, "n", parseIntField(v, 0)) - } + + // Unsupported parameter + // if v := strings.TrimSpace(c.PostForm("n")); v != "" { + // tool, _ = sjson.SetBytes(tool, "n", parseIntField(v, 0)) + // } if maskDataURL != nil && strings.TrimSpace(*maskDataURL) != "" { tool, _ = sjson.SetBytes(tool, "input_image_mask.image_url", strings.TrimSpace(*maskDataURL)) From a188159632429b3400d5dadd2b0322afba60de3c Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 22 Apr 2026 21:28:17 +0800 Subject: [PATCH 18/30] fix(handlers): remove references to unsupported `n` parameter in OpenAI image handlers --- sdk/api/handlers/openai/openai_images_handlers.go | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/sdk/api/handlers/openai/openai_images_handlers.go b/sdk/api/handlers/openai/openai_images_handlers.go index 7c96ae88cb..93d45460d0 100644 --- a/sdk/api/handlers/openai/openai_images_handlers.go +++ b/sdk/api/handlers/openai/openai_images_handlers.go @@ -240,11 +240,6 @@ func (h *OpenAIAPIHandler) ImagesGenerations(c *gin.Context) { tool, _ = sjson.SetBytes(tool, "partial_images", v.Int()) } } - if v := gjson.GetBytes(rawJSON, "n"); v.Exists() { - if v.Type == gjson.Number { - tool, _ = sjson.SetBytes(tool, "n", v.Int()) - } - } if v := strings.TrimSpace(gjson.GetBytes(rawJSON, "moderation").String()); v != "" { tool, _ = sjson.SetBytes(tool, "moderation", v) } @@ -384,11 +379,6 @@ func (h *OpenAIAPIHandler) imagesEditsFromMultipart(c *gin.Context) { tool, _ = sjson.SetBytes(tool, "partial_images", parseIntField(v, 0)) } - // Unsupported parameter - // if v := strings.TrimSpace(c.PostForm("n")); v != "" { - // tool, _ = sjson.SetBytes(tool, "n", parseIntField(v, 0)) - // } - if maskDataURL != nil && strings.TrimSpace(*maskDataURL) != "" { tool, _ = sjson.SetBytes(tool, "input_image_mask.image_url", strings.TrimSpace(*maskDataURL)) } @@ -489,7 +479,7 @@ func (h *OpenAIAPIHandler) imagesEditsFromJSON(c *gin.Context) { } } - for _, field := range []string{"output_compression", "partial_images", "n"} { + for _, field := range []string{"output_compression", "partial_images"} { if v := gjson.GetBytes(rawJSON, field); v.Exists() && v.Type == gjson.Number { tool, _ = sjson.SetBytes(tool, field, v.Int()) } From 31934ae04c04dd6cda7e32c3308a8e7291da3721 Mon Sep 17 00:00:00 2001 From: MoYeRanQianZhi Date: Thu, 23 Apr 2026 01:15:47 +0800 Subject: [PATCH 19/30] feat(codex): enable image generation for all Codex upstream requests Codex CLI gates the built-in image_generation tool behind AuthMode::Chatgpt (OAuth only). When clients connect via API key auth through CPA, the tool is absent from requests, making image generation unavailable through the reverse proxy. Changes: 1. Inject image_generation tool (codex_executor.go): Add ensureImageGenerationTool() that appends {"type":"image_generation","output_format":"png"} to the tools array if not already present. Applied to all three execution paths: Execute, executeCompact, and ExecuteStream. 2. Route aliases for Codex CLI direct access (server.go): Add /backend-api/codex/responses routes that map to the same OpenAI Responses API handlers as /v1/responses. This allows Codex CLI to connect via chatgpt_base_url config while keeping AuthMode::Chatgpt, which enables the built-in image_generation tool on the client side. 3. Unit tests (codex_executor_imagegen_test.go): Cover no-tools, existing tools, already-present, empty array, and mixed built-in tool scenarios. --- internal/api/server.go | 9 ++ internal/runtime/executor/codex_executor.go | 21 +++++ .../executor/codex_executor_imagegen_test.go | 89 +++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 internal/runtime/executor/codex_executor_imagegen_test.go diff --git a/internal/api/server.go b/internal/api/server.go index 7c571e23cf..32ae3164fd 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -353,6 +353,15 @@ func (s *Server) setupRoutes() { v1.POST("/responses/compact", openaiResponsesHandlers.Compact) } + // Codex CLI direct route aliases (chatgpt_base_url compatible) + codexDirect := s.engine.Group("/backend-api/codex") + codexDirect.Use(AuthMiddleware(s.accessManager)) + { + codexDirect.GET("/responses", openaiResponsesHandlers.ResponsesWebsocket) + codexDirect.POST("/responses", openaiResponsesHandlers.Responses) + codexDirect.POST("/responses/compact", openaiResponsesHandlers.Compact) + } + // Gemini compatible API routes v1beta := s.engine.Group("/v1beta") v1beta.Use(AuthMiddleware(s.accessManager)) diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 7d4d3edf89..543e2c2779 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -180,6 +180,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re body, _ = sjson.DeleteBytes(body, "safety_identifier") body, _ = sjson.DeleteBytes(body, "stream_options") body = normalizeCodexInstructions(body) + body = ensureImageGenerationTool(body) url := strings.TrimSuffix(baseURL, "/") + "/responses" httpReq, err := e.cacheHelper(ctx, from, url, req, body) @@ -326,6 +327,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A body, _ = sjson.SetBytes(body, "model", baseModel) body, _ = sjson.DeleteBytes(body, "stream") body = normalizeCodexInstructions(body) + body = ensureImageGenerationTool(body) url := strings.TrimSuffix(baseURL, "/") + "/responses/compact" httpReq, err := e.cacheHelper(ctx, from, url, req, body) @@ -420,6 +422,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au body, _ = sjson.DeleteBytes(body, "stream_options") body, _ = sjson.SetBytes(body, "model", baseModel) body = normalizeCodexInstructions(body) + body = ensureImageGenerationTool(body) url := strings.TrimSuffix(baseURL, "/") + "/responses" httpReq, err := e.cacheHelper(ctx, from, url, req, body) @@ -821,6 +824,24 @@ func normalizeCodexInstructions(body []byte) []byte { return body } +var imageGenToolJSON = []byte(`{"type":"image_generation","output_format":"png"}`) +var imageGenToolArrayJSON = []byte(`[{"type":"image_generation","output_format":"png"}]`) + +func ensureImageGenerationTool(body []byte) []byte { + tools := gjson.GetBytes(body, "tools") + if !tools.Exists() || !tools.IsArray() { + body, _ = sjson.SetRawBytes(body, "tools", imageGenToolArrayJSON) + return body + } + for _, t := range tools.Array() { + if t.Get("type").String() == "image_generation" { + return body + } + } + body, _ = sjson.SetRawBytes(body, "tools.-1", imageGenToolJSON) + return body +} + func isCodexModelCapacityError(errorBody []byte) bool { if len(errorBody) == 0 { return false diff --git a/internal/runtime/executor/codex_executor_imagegen_test.go b/internal/runtime/executor/codex_executor_imagegen_test.go new file mode 100644 index 0000000000..43f42adee8 --- /dev/null +++ b/internal/runtime/executor/codex_executor_imagegen_test.go @@ -0,0 +1,89 @@ +package executor + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestEnsureImageGenerationTool_NoTools(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","input":"draw a cat"}`) + result := ensureImageGenerationTool(body) + + tools := gjson.GetBytes(result, "tools") + if !tools.IsArray() { + t.Fatalf("expected tools array, got %v", tools.Type) + } + arr := tools.Array() + if len(arr) != 1 { + t.Fatalf("expected 1 tool, got %d", len(arr)) + } + if arr[0].Get("type").String() != "image_generation" { + t.Fatalf("expected type=image_generation, got %s", arr[0].Get("type").String()) + } + if arr[0].Get("output_format").String() != "png" { + t.Fatalf("expected output_format=png, got %s", arr[0].Get("output_format").String()) + } +} + +func TestEnsureImageGenerationTool_ExistingToolsWithoutImageGen(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","tools":[{"type":"function","name":"get_weather","parameters":{}}]}`) + result := ensureImageGenerationTool(body) + + tools := gjson.GetBytes(result, "tools") + arr := tools.Array() + if len(arr) != 2 { + t.Fatalf("expected 2 tools, got %d", len(arr)) + } + if arr[0].Get("type").String() != "function" { + t.Fatalf("expected first tool type=function, got %s", arr[0].Get("type").String()) + } + if arr[1].Get("type").String() != "image_generation" { + t.Fatalf("expected second tool type=image_generation, got %s", arr[1].Get("type").String()) + } +} + +func TestEnsureImageGenerationTool_AlreadyPresent(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation","output_format":"webp"},{"type":"function","name":"f1"}]}`) + result := ensureImageGenerationTool(body) + + tools := gjson.GetBytes(result, "tools") + arr := tools.Array() + if len(arr) != 2 { + t.Fatalf("expected 2 tools (no duplicate), got %d", len(arr)) + } + if arr[0].Get("output_format").String() != "webp" { + t.Fatalf("expected original output_format=webp preserved, got %s", arr[0].Get("output_format").String()) + } +} + +func TestEnsureImageGenerationTool_EmptyToolsArray(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","tools":[]}`) + result := ensureImageGenerationTool(body) + + tools := gjson.GetBytes(result, "tools") + arr := tools.Array() + if len(arr) != 1 { + t.Fatalf("expected 1 tool, got %d", len(arr)) + } + if arr[0].Get("type").String() != "image_generation" { + t.Fatalf("expected type=image_generation, got %s", arr[0].Get("type").String()) + } +} + +func TestEnsureImageGenerationTool_WebSearchAndImageGen(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","tools":[{"type":"web_search"}]}`) + result := ensureImageGenerationTool(body) + + tools := gjson.GetBytes(result, "tools") + arr := tools.Array() + if len(arr) != 2 { + t.Fatalf("expected 2 tools, got %d", len(arr)) + } + if arr[0].Get("type").String() != "web_search" { + t.Fatalf("expected first tool type=web_search, got %s", arr[0].Get("type").String()) + } + if arr[1].Get("type").String() != "image_generation" { + t.Fatalf("expected second tool type=image_generation, got %s", arr[1].Get("type").String()) + } +} From 14d46a0a5dedb338d5e64bd8660d4ac7f2909bcd Mon Sep 17 00:00:00 2001 From: sususu98 Date: Thu, 23 Apr 2026 13:44:20 +0800 Subject: [PATCH 20/30] feat(antigravity): conductor-level credits fallback for Claude models Move credits handling from executor-level retry to conductor-level orchestration. When all free-tier auths are exhausted (429/503), the conductor discovers auths with available Google One AI credits and retries with enabledCreditTypes injected via context flag. Key changes: - Add AntigravityCreditsHint system for tracking per-auth credits state - Conductor tries credits fallback after all auths fail (Execute/Stream/Count) - Executor injects enabledCreditTypes only when conductor sets context flag - Credits fallback respects provider scope (requires antigravity in providers) - Add context cancellation check in credits fallback to avoid wasted requests - Remove executor-level attemptCreditsFallback and preferCredits machinery - Restructure 429 decision logic (parse details first, keyword fallback) - Expand shouldAbort to cover INVALID_ARGUMENT/FAILED_PRECONDITION/500+UNKNOWN - Support human-readable retry delay parsing (e.g. "1h43m56s") --- config.example.yaml | 2 +- internal/config/config.go | 5 +- .../runtime/executor/antigravity_executor.go | 644 +++++++----------- .../antigravity_executor_credits_test.go | 459 ++++++------- .../runtime/executor/gemini_cli_executor.go | 9 +- .../runtime/executor/helps/logging_helpers.go | 130 +++- sdk/cliproxy/auth/antigravity_credits.go | 90 +++ sdk/cliproxy/auth/antigravity_credits_test.go | 62 ++ sdk/cliproxy/auth/conductor.go | 218 +++++- 9 files changed, 957 insertions(+), 662 deletions(-) create mode 100644 sdk/cliproxy/auth/antigravity_credits.go create mode 100644 sdk/cliproxy/auth/antigravity_credits_test.go diff --git a/config.example.yaml b/config.example.yaml index 734dd7d522..13042b78d3 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -98,7 +98,7 @@ disable-cooling: false quota-exceeded: switch-project: true # Whether to automatically switch to another project when a quota is exceeded switch-preview-model: true # Whether to automatically switch to a preview model when a quota is exceeded - antigravity-credits: true # Whether to retry Antigravity quota_exhausted 429s once with enabledCreditTypes=["GOOGLE_ONE_AI"] + antigravity-credits: true # Whether to use credits as last-resort fallback when all free-tier auths are exhausted for Claude models # Routing strategy for selecting credentials when multiple match. routing: diff --git a/internal/config/config.go b/internal/config/config.go index 760d43ec4a..1ebbb460c0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -206,8 +206,9 @@ type QuotaExceeded struct { // SwitchPreviewModel indicates whether to automatically switch to a preview model when a quota is exceeded. SwitchPreviewModel bool `yaml:"switch-preview-model" json:"switch-preview-model"` - // AntigravityCredits indicates whether to retry Antigravity quota_exhausted 429s once - // on the same credential with enabledCreditTypes=["GOOGLE_ONE_AI"]. + // AntigravityCredits enables credits-based last-resort fallback for Claude models. + // When all free-tier auths are exhausted (429/503), the conductor retries with + // an auth that has available Google One AI credits. AntigravityCredits bool `yaml:"antigravity-credits" json:"antigravity-credits"` } diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 163b2d9279..633373d29c 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -52,8 +52,6 @@ const ( defaultAntigravityAgent = "antigravity/1.21.9 darwin/arm64" // fallback only; overridden at runtime by misc.AntigravityUserAgent() antigravityAuthType = "antigravity" refreshSkew = 3000 * time.Second - antigravityCreditsRetryTTL = 5 * time.Hour - antigravityCreditsAutoDisableDuration = 5 * time.Hour antigravityShortQuotaCooldownThreshold = 5 * time.Minute antigravityInstantRetryThreshold = 3 * time.Second // systemInstruction = "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**" @@ -62,8 +60,6 @@ const ( type antigravity429Category string type antigravityCreditsFailureState struct { - Count int - DisabledUntil time.Time PermanentlyDisabled bool ExplicitBalanceExhausted bool } @@ -91,28 +87,79 @@ var ( randSource = rand.New(rand.NewSource(time.Now().UnixNano())) randSourceMutex sync.Mutex antigravityCreditsFailureByAuth sync.Map - antigravityPreferCreditsByModel sync.Map antigravityShortCooldownByAuth sync.Map + antigravityCreditsBalanceByAuth sync.Map // auth.ID → antigravityCreditsBalance antigravityQuotaExhaustedKeywords = []string{ "quota_exhausted", "quota exhausted", } - antigravityCreditsExhaustedKeywords = []string{ - "google_one_ai", - "insufficient credit", - "insufficient credits", - "not enough credit", - "not enough credits", - "credit exhausted", - "credits exhausted", - "credit balance", - "minimumcreditamountforusage", - "minimum credit amount for usage", - "minimum credit", - "resource has been exhausted", - } ) +type antigravityCreditsBalance struct { + CreditAmount float64 + MinCreditAmount float64 + PaidTierID string + Known bool +} + +func antigravityAuthHasCredits(auth *cliproxyauth.Auth) bool { + if auth == nil || strings.TrimSpace(auth.ID) == "" { + return false + } + if hint, ok := cliproxyauth.GetAntigravityCreditsHint(auth.ID); ok && hint.Known { + return hint.Available + } + val, ok := antigravityCreditsBalanceByAuth.Load(strings.TrimSpace(auth.ID)) + if !ok { + return true // optimistic: assume credits available when balance unknown + } + bal, valid := val.(antigravityCreditsBalance) + if !valid { + antigravityCreditsBalanceByAuth.Delete(strings.TrimSpace(auth.ID)) + return false + } + if !bal.Known { + return false + } + available := bal.CreditAmount >= bal.MinCreditAmount + cliproxyauth.SetAntigravityCreditsHint(strings.TrimSpace(auth.ID), cliproxyauth.AntigravityCreditsHint{ + Known: true, + Available: available, + CreditAmount: bal.CreditAmount, + MinCreditAmount: bal.MinCreditAmount, + PaidTierID: bal.PaidTierID, + UpdatedAt: time.Now(), + }) + return available +} + +// parseMetaFloat extracts a float64 from auth.Metadata (handles string and numeric types). +func parseMetaFloat(metadata map[string]any, key string) (float64, bool) { + v, ok := metadata[key] + if !ok { + return 0, false + } + switch typed := v.(type) { + case float64: + return typed, true + case int: + return float64(typed), true + case int64: + return float64(typed), true + case uint64: + return float64(typed), true + case json.Number: + if f, err := typed.Float64(); err == nil { + return f, true + } + case string: + if f, err := strconv.ParseFloat(strings.TrimSpace(typed), 64); err == nil { + return f, true + } + } + return 0, false +} + // AntigravityExecutor proxies requests to the antigravity upstream. type AntigravityExecutor struct { cfg *config.Config @@ -189,7 +236,7 @@ func validateAntigravityRequestSignatures(from sdktranslator.Format, rawJSON []b if from.String() != "claude" { return rawJSON, nil } - // Always strip thinking blocks with empty signatures (proxy-generated). + // Always strip thinking blocks with invalid signatures (empty or non-Claude-format). rawJSON = antigravityclaude.StripEmptySignatureThinkingBlocks(rawJSON) if cache.SignatureCacheEnabled() { return rawJSON, nil @@ -298,49 +345,46 @@ func decideAntigravity429(body []byte) antigravity429Decision { decision.retryAfter = retryAfter } - lowerBody := strings.ToLower(string(body)) - for _, keyword := range antigravityQuotaExhaustedKeywords { - if strings.Contains(lowerBody, keyword) { - decision.kind = antigravity429DecisionFullQuotaExhausted - decision.reason = "quota_exhausted" - return decision - } - } - status := strings.TrimSpace(gjson.GetBytes(body, "error.status").String()) if !strings.EqualFold(status, "RESOURCE_EXHAUSTED") { return decision } details := gjson.GetBytes(body, "error.details") - if !details.Exists() || !details.IsArray() { - decision.kind = antigravity429DecisionSoftRetry - return decision - } - - for _, detail := range details.Array() { - if detail.Get("@type").String() != "type.googleapis.com/google.rpc.ErrorInfo" { - continue - } - reason := strings.TrimSpace(detail.Get("reason").String()) - decision.reason = reason - switch { - case strings.EqualFold(reason, "QUOTA_EXHAUSTED"): - decision.kind = antigravity429DecisionFullQuotaExhausted - return decision - case strings.EqualFold(reason, "RATE_LIMIT_EXCEEDED"): - if decision.retryAfter == nil { - decision.kind = antigravity429DecisionSoftRetry - return decision + if details.Exists() && details.IsArray() { + for _, detail := range details.Array() { + if detail.Get("@type").String() != "type.googleapis.com/google.rpc.ErrorInfo" { + continue } + reason := strings.TrimSpace(detail.Get("reason").String()) + decision.reason = reason switch { - case *decision.retryAfter < antigravityInstantRetryThreshold: - decision.kind = antigravity429DecisionInstantRetrySameAuth - case *decision.retryAfter < antigravityShortQuotaCooldownThreshold: - decision.kind = antigravity429DecisionShortCooldownSwitchAuth - default: + case strings.EqualFold(reason, "QUOTA_EXHAUSTED"): decision.kind = antigravity429DecisionFullQuotaExhausted + return decision + case strings.EqualFold(reason, "RATE_LIMIT_EXCEEDED"): + if decision.retryAfter == nil { + decision.kind = antigravity429DecisionSoftRetry + return decision + } + switch { + case *decision.retryAfter < antigravityInstantRetryThreshold: + decision.kind = antigravity429DecisionInstantRetrySameAuth + case *decision.retryAfter < antigravityShortQuotaCooldownThreshold: + decision.kind = antigravity429DecisionShortCooldownSwitchAuth + default: + decision.kind = antigravity429DecisionFullQuotaExhausted + } + return decision } + } + } + + lowerBody := strings.ToLower(string(body)) + for _, keyword := range antigravityQuotaExhaustedKeywords { + if strings.Contains(lowerBody, keyword) { + decision.kind = antigravity429DecisionFullQuotaExhausted + decision.reason = "quota_exhausted" return decision } } @@ -349,81 +393,10 @@ func decideAntigravity429(body []byte) antigravity429Decision { return decision } -func antigravityHasQuotaResetDelayOrModelInfo(body []byte) bool { - if len(body) == 0 { - return false - } - details := gjson.GetBytes(body, "error.details") - if !details.Exists() || !details.IsArray() { - return false - } - for _, detail := range details.Array() { - if detail.Get("@type").String() != "type.googleapis.com/google.rpc.ErrorInfo" { - continue - } - if strings.TrimSpace(detail.Get("metadata.quotaResetDelay").String()) != "" { - return true - } - if strings.TrimSpace(detail.Get("metadata.model").String()) != "" { - return true - } - } - return false -} - func antigravityCreditsRetryEnabled(cfg *config.Config) bool { return cfg != nil && cfg.QuotaExceeded.AntigravityCredits } -func antigravityCreditsFailureStateForAuth(auth *cliproxyauth.Auth) (string, antigravityCreditsFailureState, bool) { - if auth == nil || strings.TrimSpace(auth.ID) == "" { - return "", antigravityCreditsFailureState{}, false - } - authID := strings.TrimSpace(auth.ID) - value, ok := antigravityCreditsFailureByAuth.Load(authID) - if !ok { - return authID, antigravityCreditsFailureState{}, true - } - state, ok := value.(antigravityCreditsFailureState) - if !ok { - antigravityCreditsFailureByAuth.Delete(authID) - return authID, antigravityCreditsFailureState{}, true - } - return authID, state, true -} - -func antigravityCreditsDisabled(auth *cliproxyauth.Auth, now time.Time) bool { - authID, state, ok := antigravityCreditsFailureStateForAuth(auth) - if !ok { - return false - } - if state.PermanentlyDisabled { - return true - } - if state.DisabledUntil.IsZero() { - return false - } - if state.DisabledUntil.After(now) { - return true - } - antigravityCreditsFailureByAuth.Delete(authID) - return false -} - -func recordAntigravityCreditsFailure(auth *cliproxyauth.Auth, now time.Time) { - authID, state, ok := antigravityCreditsFailureStateForAuth(auth) - if !ok { - return - } - if state.PermanentlyDisabled { - antigravityCreditsFailureByAuth.Store(authID, state) - return - } - state.Count++ - state.DisabledUntil = now.Add(antigravityCreditsAutoDisableDuration) - antigravityCreditsFailureByAuth.Store(authID, state) -} - func clearAntigravityCreditsFailureState(auth *cliproxyauth.Auth) { if auth == nil || strings.TrimSpace(auth.ID) == "" { return @@ -440,6 +413,25 @@ func markAntigravityCreditsPermanentlyDisabled(auth *cliproxyauth.Auth) { ExplicitBalanceExhausted: true, } antigravityCreditsFailureByAuth.Store(authID, state) + antigravityCreditsBalanceByAuth.Store(authID, antigravityCreditsBalance{ + CreditAmount: 0, + MinCreditAmount: 1, + Known: true, + }) + cliproxyauth.SetAntigravityCreditsHint(authID, cliproxyauth.AntigravityCreditsHint{ + Known: true, + Available: false, + CreditAmount: 0, + MinCreditAmount: 1, + UpdatedAt: time.Now(), + }) +} + +func clearAntigravityCreditsPermanentlyDisabled(auth *cliproxyauth.Auth) { + if auth == nil || strings.TrimSpace(auth.ID) == "" { + return + } + antigravityCreditsFailureByAuth.Delete(strings.TrimSpace(auth.ID)) } func antigravityHasExplicitCreditsBalanceExhaustedReason(body []byte) bool { @@ -462,81 +454,6 @@ func antigravityHasExplicitCreditsBalanceExhaustedReason(body []byte) bool { return false } -func antigravityPreferCreditsKey(auth *cliproxyauth.Auth, modelName string) string { - if auth == nil { - return "" - } - authID := strings.TrimSpace(auth.ID) - modelName = strings.TrimSpace(modelName) - if authID == "" || modelName == "" { - return "" - } - return authID + "|" + modelName -} - -func antigravityShouldPreferCredits(auth *cliproxyauth.Auth, modelName string, now time.Time) bool { - key := antigravityPreferCreditsKey(auth, modelName) - if key == "" { - return false - } - value, ok := antigravityPreferCreditsByModel.Load(key) - if !ok { - return false - } - until, ok := value.(time.Time) - if !ok || until.IsZero() { - antigravityPreferCreditsByModel.Delete(key) - return false - } - if !until.After(now) { - antigravityPreferCreditsByModel.Delete(key) - return false - } - return true -} - -func markAntigravityPreferCredits(auth *cliproxyauth.Auth, modelName string, now time.Time, retryAfter *time.Duration) { - key := antigravityPreferCreditsKey(auth, modelName) - if key == "" { - return - } - until := now.Add(antigravityCreditsRetryTTL) - if retryAfter != nil && *retryAfter > 0 { - until = now.Add(*retryAfter) - } - antigravityPreferCreditsByModel.Store(key, until) -} - -func clearAntigravityPreferCredits(auth *cliproxyauth.Auth, modelName string) { - key := antigravityPreferCreditsKey(auth, modelName) - if key == "" { - return - } - antigravityPreferCreditsByModel.Delete(key) -} - -func shouldMarkAntigravityCreditsExhausted(statusCode int, body []byte, reqErr error) bool { - if reqErr != nil || statusCode == 0 { - return false - } - if statusCode >= http.StatusInternalServerError || statusCode == http.StatusRequestTimeout { - return false - } - lowerBody := strings.ToLower(string(body)) - for _, keyword := range antigravityCreditsExhaustedKeywords { - if strings.Contains(lowerBody, keyword) { - if keyword == "resource has been exhausted" && - statusCode == http.StatusTooManyRequests && - decideAntigravity429(body).kind == antigravity429DecisionSoftRetry && - !antigravityHasQuotaResetDelayOrModelInfo(body) { - return false - } - return true - } - } - return false -} - func newAntigravityStatusErr(statusCode int, body []byte) statusErr { err := statusErr{code: statusCode, msg: string(body)} if statusCode == http.StatusTooManyRequests { @@ -547,129 +464,6 @@ func newAntigravityStatusErr(statusCode int, body []byte) statusErr { return err } -func (e *AntigravityExecutor) attemptCreditsFallback( - ctx context.Context, - auth *cliproxyauth.Auth, - httpClient *http.Client, - token string, - modelName string, - payload []byte, - stream bool, - alt string, - baseURL string, - originalBody []byte, -) (*http.Response, bool) { - if !antigravityCreditsRetryEnabled(e.cfg) { - return nil, false - } - if decideAntigravity429(originalBody).kind != antigravity429DecisionFullQuotaExhausted { - return nil, false - } - now := time.Now() - if shouldForcePermanentDisableCredits(originalBody) { - clearAntigravityPreferCredits(auth, modelName) - markAntigravityCreditsPermanentlyDisabled(auth) - return nil, false - } - - if antigravityHasExplicitCreditsBalanceExhaustedReason(originalBody) { - clearAntigravityPreferCredits(auth, modelName) - markAntigravityCreditsPermanentlyDisabled(auth) - return nil, false - } - - if antigravityCreditsDisabled(auth, now) { - return nil, false - } - creditsPayload := injectEnabledCreditTypes(payload) - if len(creditsPayload) == 0 { - return nil, false - } - - httpReq, errReq := e.buildRequest(ctx, auth, token, modelName, creditsPayload, stream, alt, baseURL) - if errReq != nil { - helps.RecordAPIResponseError(ctx, e.cfg, errReq) - clearAntigravityPreferCredits(auth, modelName) - recordAntigravityCreditsFailure(auth, now) - return nil, true - } - httpResp, errDo := httpClient.Do(httpReq) - if errDo != nil { - helps.RecordAPIResponseError(ctx, e.cfg, errDo) - clearAntigravityPreferCredits(auth, modelName) - recordAntigravityCreditsFailure(auth, now) - return nil, true - } - if httpResp.StatusCode >= http.StatusOK && httpResp.StatusCode < http.StatusMultipleChoices { - retryAfter, _ := parseRetryDelay(originalBody) - markAntigravityPreferCredits(auth, modelName, now, retryAfter) - clearAntigravityCreditsFailureState(auth) - return httpResp, true - } - - helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) - bodyBytes, errRead := io.ReadAll(httpResp.Body) - if errClose := httpResp.Body.Close(); errClose != nil { - log.Errorf("antigravity executor: close credits fallback response body error: %v", errClose) - } - if errRead != nil { - helps.RecordAPIResponseError(ctx, e.cfg, errRead) - clearAntigravityPreferCredits(auth, modelName) - recordAntigravityCreditsFailure(auth, now) - return nil, true - } - helps.AppendAPIResponseChunk(ctx, e.cfg, bodyBytes) - if shouldForcePermanentDisableCredits(bodyBytes) { - clearAntigravityPreferCredits(auth, modelName) - markAntigravityCreditsPermanentlyDisabled(auth) - return nil, true - } - - if antigravityHasExplicitCreditsBalanceExhaustedReason(bodyBytes) { - clearAntigravityPreferCredits(auth, modelName) - markAntigravityCreditsPermanentlyDisabled(auth) - return nil, true - } - - clearAntigravityPreferCredits(auth, modelName) - recordAntigravityCreditsFailure(auth, now) - return nil, true -} - -func (e *AntigravityExecutor) handleDirectCreditsFailure(ctx context.Context, auth *cliproxyauth.Auth, modelName string, reqErr error) { - if reqErr != nil { - if shouldForcePermanentDisableCredits(reqErrBody(reqErr)) { - clearAntigravityPreferCredits(auth, modelName) - markAntigravityCreditsPermanentlyDisabled(auth) - return - } - - if antigravityHasExplicitCreditsBalanceExhaustedReason(reqErrBody(reqErr)) { - clearAntigravityPreferCredits(auth, modelName) - markAntigravityCreditsPermanentlyDisabled(auth) - return - } - - helps.RecordAPIResponseError(ctx, e.cfg, reqErr) - } - clearAntigravityPreferCredits(auth, modelName) - recordAntigravityCreditsFailure(auth, time.Now()) -} -func reqErrBody(reqErr error) []byte { - if reqErr == nil { - return nil - } - msg := reqErr.Error() - if strings.TrimSpace(msg) == "" { - return nil - } - return []byte(msg) -} - -func shouldForcePermanentDisableCredits(body []byte) bool { - return antigravityHasExplicitCreditsBalanceExhaustedReason(body) -} - // Execute performs a non-streaming request to the Antigravity API. func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { if opts.Alt == "responses/compact" { @@ -721,6 +515,8 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au requestedModel := helps.PayloadRequestedModel(opts, req.Model) translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel) + useCredits := cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(e.cfg) + baseURLs := antigravityBaseURLFallbackOrder(auth) httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0) attempts := antigravityRetryAttempts(auth, e.cfg) @@ -733,11 +529,10 @@ attemptLoop: for idx, baseURL := range baseURLs { requestPayload := translated - usedCreditsDirect := false - if antigravityCreditsRetryEnabled(e.cfg) && antigravityShouldPreferCredits(auth, baseModel, time.Now()) { - if creditsPayload := injectEnabledCreditTypes(translated); len(creditsPayload) > 0 { - requestPayload = creditsPayload - usedCreditsDirect = true + if useCredits { + if cp := injectEnabledCreditTypes(translated); len(cp) > 0 { + requestPayload = cp + helps.MarkCreditsUsed(ctx) } } @@ -785,7 +580,6 @@ attemptLoop: wait := antigravityInstantRetryDelay(*decision.retryAfter) log.Debugf("antigravity executor: instant retry for model %s, waiting %s", baseModel, wait) if errWait := antigravityWait(ctx, wait); errWait != nil { - return resp, errWait } } @@ -794,34 +588,13 @@ attemptLoop: case antigravity429DecisionShortCooldownSwitchAuth: if decision.retryAfter != nil && *decision.retryAfter > 0 { markAntigravityShortCooldown(auth, baseModel, time.Now(), *decision.retryAfter) - log.Debugf("antigravity executor: short quota cooldown (%s) for model %s, recorded cooldown and skipping credits fallback", *decision.retryAfter, baseModel) + log.Debugf("antigravity executor: short quota cooldown (%s) for model %s, recorded cooldown", *decision.retryAfter, baseModel) } case antigravity429DecisionFullQuotaExhausted: - if usedCreditsDirect { - clearAntigravityPreferCredits(auth, baseModel) - recordAntigravityCreditsFailure(auth, time.Now()) - } else { - creditsResp, _ := e.attemptCreditsFallback(ctx, auth, httpClient, token, baseModel, translated, false, opts.Alt, baseURL, bodyBytes) - if creditsResp != nil { - helps.RecordAPIResponseMetadata(ctx, e.cfg, creditsResp.StatusCode, creditsResp.Header.Clone()) - creditsBody, errCreditsRead := io.ReadAll(creditsResp.Body) - if errClose := creditsResp.Body.Close(); errClose != nil { - log.Errorf("antigravity executor: close credits success response body error: %v", errClose) - } - if errCreditsRead != nil { - helps.RecordAPIResponseError(ctx, e.cfg, errCreditsRead) - err = errCreditsRead - return resp, err - } - helps.AppendAPIResponseChunk(ctx, e.cfg, creditsBody) - reporter.Publish(ctx, helps.ParseAntigravityUsage(creditsBody)) - var param any - converted := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, creditsBody, ¶m) - resp = cliproxyexecutor.Response{Payload: converted, Headers: creditsResp.Header.Clone()} - reporter.EnsurePublished(ctx) - return resp, nil - } + if useCredits && antigravityHasExplicitCreditsBalanceExhaustedReason(bodyBytes) { + markAntigravityCreditsPermanentlyDisabled(auth) } + // No credits logic - just fall through to error return below } } @@ -870,6 +643,10 @@ attemptLoop: return resp, err } + // Success + if useCredits { + clearAntigravityCreditsFailureState(auth) + } reporter.Publish(ctx, helps.ParseAntigravityUsage(bodyBytes)) var param any converted := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bodyBytes, ¶m) @@ -935,6 +712,8 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * requestedModel := helps.PayloadRequestedModel(opts, req.Model) translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel) + useCredits := cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(e.cfg) + baseURLs := antigravityBaseURLFallbackOrder(auth) httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0) @@ -948,11 +727,10 @@ attemptLoop: for idx, baseURL := range baseURLs { requestPayload := translated - usedCreditsDirect := false - if antigravityCreditsRetryEnabled(e.cfg) && antigravityShouldPreferCredits(auth, baseModel, time.Now()) { - if creditsPayload := injectEnabledCreditTypes(translated); len(creditsPayload) > 0 { - requestPayload = creditsPayload - usedCreditsDirect = true + if useCredits { + if cp := injectEnabledCreditTypes(translated); len(cp) > 0 { + requestPayload = cp + helps.MarkCreditsUsed(ctx) } } httpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, requestPayload, true, opts.Alt, baseURL) @@ -1014,7 +792,6 @@ attemptLoop: wait := antigravityInstantRetryDelay(*decision.retryAfter) log.Debugf("antigravity executor: instant retry for model %s, waiting %s", baseModel, wait) if errWait := antigravityWait(ctx, wait); errWait != nil { - return resp, errWait } } @@ -1023,25 +800,16 @@ attemptLoop: case antigravity429DecisionShortCooldownSwitchAuth: if decision.retryAfter != nil && *decision.retryAfter > 0 { markAntigravityShortCooldown(auth, baseModel, time.Now(), *decision.retryAfter) - log.Debugf("antigravity executor: short quota cooldown (%s) for model %s, recorded cooldown and skipping credits fallback", *decision.retryAfter, baseModel) + log.Debugf("antigravity executor: short quota cooldown (%s) for model %s, recorded cooldown", *decision.retryAfter, baseModel) } case antigravity429DecisionFullQuotaExhausted: - if usedCreditsDirect { - clearAntigravityPreferCredits(auth, baseModel) - recordAntigravityCreditsFailure(auth, time.Now()) - } else { - creditsResp, _ := e.attemptCreditsFallback(ctx, auth, httpClient, token, baseModel, translated, true, opts.Alt, baseURL, bodyBytes) - if creditsResp != nil { - httpResp = creditsResp - helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) - } + if useCredits && antigravityHasExplicitCreditsBalanceExhaustedReason(bodyBytes) { + markAntigravityCreditsPermanentlyDisabled(auth) } + // No credits logic - just fall through to error return below } } - if httpResp.StatusCode >= http.StatusOK && httpResp.StatusCode < http.StatusMultipleChoices { - goto streamSuccessClaudeNonStream - } lastStatus = httpResp.StatusCode lastBody = append([]byte(nil), bodyBytes...) lastErr = nil @@ -1085,7 +853,10 @@ attemptLoop: return resp, err } - streamSuccessClaudeNonStream: + // Stream success + if useCredits { + clearAntigravityCreditsFailureState(auth) + } out := make(chan cliproxyexecutor.StreamChunk) go func(resp *http.Response) { defer close(out) @@ -1389,6 +1160,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya if updatedAuth != nil { auth = updatedAuth } + originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true) translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true) @@ -1400,6 +1172,8 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya requestedModel := helps.PayloadRequestedModel(opts, req.Model) translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel) + useCredits := cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(e.cfg) + baseURLs := antigravityBaseURLFallbackOrder(auth) httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0) @@ -1413,11 +1187,10 @@ attemptLoop: for idx, baseURL := range baseURLs { requestPayload := translated - usedCreditsDirect := false - if antigravityCreditsRetryEnabled(e.cfg) && antigravityShouldPreferCredits(auth, baseModel, time.Now()) { - if creditsPayload := injectEnabledCreditTypes(translated); len(creditsPayload) > 0 { - requestPayload = creditsPayload - usedCreditsDirect = true + if useCredits { + if cp := injectEnabledCreditTypes(translated); len(cp) > 0 { + requestPayload = cp + helps.MarkCreditsUsed(ctx) } } httpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, requestPayload, true, opts.Alt, baseURL) @@ -1478,7 +1251,6 @@ attemptLoop: wait := antigravityInstantRetryDelay(*decision.retryAfter) log.Debugf("antigravity executor: instant retry for model %s, waiting %s", baseModel, wait) if errWait := antigravityWait(ctx, wait); errWait != nil { - return nil, errWait } } @@ -1487,25 +1259,16 @@ attemptLoop: case antigravity429DecisionShortCooldownSwitchAuth: if decision.retryAfter != nil && *decision.retryAfter > 0 { markAntigravityShortCooldown(auth, baseModel, time.Now(), *decision.retryAfter) - log.Debugf("antigravity executor: short quota cooldown (%s) for model %s, recorded cooldown and skipping credits fallback", *decision.retryAfter, baseModel) + log.Debugf("antigravity executor: short quota cooldown (%s) for model %s recorded", *decision.retryAfter, baseModel) } case antigravity429DecisionFullQuotaExhausted: - if usedCreditsDirect { - clearAntigravityPreferCredits(auth, baseModel) - recordAntigravityCreditsFailure(auth, time.Now()) - } else { - creditsResp, _ := e.attemptCreditsFallback(ctx, auth, httpClient, token, baseModel, translated, true, opts.Alt, baseURL, bodyBytes) - if creditsResp != nil { - httpResp = creditsResp - helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) - } + if useCredits && antigravityHasExplicitCreditsBalanceExhaustedReason(bodyBytes) { + markAntigravityCreditsPermanentlyDisabled(auth) } + // No credits logic - just fall through to error return below } } - if httpResp.StatusCode >= http.StatusOK && httpResp.StatusCode < http.StatusMultipleChoices { - goto streamSuccessExecuteStream - } lastStatus = httpResp.StatusCode lastBody = append([]byte(nil), bodyBytes...) lastErr = nil @@ -1549,7 +1312,10 @@ attemptLoop: return nil, err } - streamSuccessExecuteStream: + // Stream success + if useCredits { + clearAntigravityCreditsFailureState(auth) + } out := make(chan cliproxyexecutor.StreamChunk) go func(resp *http.Response) { defer close(out) @@ -1792,6 +1558,9 @@ func (e *AntigravityExecutor) ensureAccessToken(ctx context.Context, auth *clipr accessToken := metaStringValue(auth.Metadata, "access_token") expiry := tokenExpiry(auth.Metadata) if accessToken != "" && expiry.After(time.Now().Add(refreshSkew)) { + if !cliproxyauth.HasKnownAntigravityCreditsHint(auth.ID) { + e.updateAntigravityCreditsBalance(ctx, auth, accessToken) + } return accessToken, nil, nil } refreshCtx := context.Background() @@ -1882,6 +1651,7 @@ func (e *AntigravityExecutor) refreshToken(ctx context.Context, auth *cliproxyau if errProject := e.ensureAntigravityProjectID(ctx, auth, tokenResp.AccessToken); errProject != nil { log.Warnf("antigravity executor: ensure project id failed: %v", errProject) } + e.updateAntigravityCreditsBalance(ctx, auth, tokenResp.AccessToken) return auth, nil } @@ -1918,6 +1688,94 @@ func (e *AntigravityExecutor) ensureAntigravityProjectID(ctx context.Context, au return nil } +func (e *AntigravityExecutor) updateAntigravityCreditsBalance(ctx context.Context, auth *cliproxyauth.Auth, accessToken string) { + if auth == nil || strings.TrimSpace(auth.ID) == "" { + return + } + token := strings.TrimSpace(accessToken) + if token == "" { + token = metaStringValue(auth.Metadata, "access_token") + } + if token == "" { + return + } + + loadReqBody := `{"metadata":{"ideType":"ANTIGRAVITY","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}}` + endpointURL := "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" + httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, endpointURL, strings.NewReader(loadReqBody)) + if errReq != nil { + log.Debugf("antigravity executor: create loadCodeAssist request error: %v", errReq) + return + } + httpReq.Header.Set("Authorization", "Bearer "+token) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("User-Agent", "google-api-nodejs-client/9.15.1") + + httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0) + httpResp, errDo := httpClient.Do(httpReq) + if errDo != nil { + log.Debugf("antigravity executor: loadCodeAssist request error: %v", errDo) + return + } + defer func() { + if errClose := httpResp.Body.Close(); errClose != nil { + log.Errorf("antigravity executor: close loadCodeAssist response body error: %v", errClose) + } + }() + + bodyBytes, errRead := io.ReadAll(httpResp.Body) + if errRead != nil || httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices { + log.Debugf("antigravity executor: loadCodeAssist returned status %d, err=%v", httpResp.StatusCode, errRead) + return + } + + authID := strings.TrimSpace(auth.ID) + paidTierID := strings.TrimSpace(gjson.GetBytes(bodyBytes, "paidTier.id").String()) + + credits := gjson.GetBytes(bodyBytes, "paidTier.availableCredits") + if !credits.IsArray() { + cliproxyauth.SetAntigravityCreditsHint(authID, cliproxyauth.AntigravityCreditsHint{ + Known: true, + Available: false, + PaidTierID: paidTierID, + UpdatedAt: time.Now(), + }) + return + } + for _, credit := range credits.Array() { + if !strings.EqualFold(credit.Get("creditType").String(), "GOOGLE_ONE_AI") { + continue + } + creditAmount, errCA := strconv.ParseFloat(strings.TrimSpace(credit.Get("creditAmount").String()), 64) + if errCA != nil { + continue + } + minAmount, errMA := strconv.ParseFloat(strings.TrimSpace(credit.Get("minimumCreditAmountForUsage").String()), 64) + if errMA != nil { + continue + } + bal := antigravityCreditsBalance{ + CreditAmount: creditAmount, + MinCreditAmount: minAmount, + PaidTierID: paidTierID, + Known: true, + } + antigravityCreditsBalanceByAuth.Store(authID, bal) + cliproxyauth.SetAntigravityCreditsHint(authID, cliproxyauth.AntigravityCreditsHint{ + Known: true, + Available: creditAmount >= minAmount, + CreditAmount: creditAmount, + MinCreditAmount: minAmount, + PaidTierID: paidTierID, + UpdatedAt: time.Now(), + }) + if creditAmount >= minAmount { + clearAntigravityCreditsPermanentlyDisabled(auth) + } + return + } +} + func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyauth.Auth, token, modelName string, payload []byte, stream bool, alt, baseURL string) (*http.Request, error) { if token == "" { return nil, statusErr{code: http.StatusUnauthorized, msg: "missing access token"} diff --git a/internal/runtime/executor/antigravity_executor_credits_test.go b/internal/runtime/executor/antigravity_executor_credits_test.go index cf968ac794..b9c7a91fd8 100644 --- a/internal/runtime/executor/antigravity_executor_credits_test.go +++ b/internal/runtime/executor/antigravity_executor_credits_test.go @@ -18,8 +18,8 @@ import ( func resetAntigravityCreditsRetryState() { antigravityCreditsFailureByAuth = sync.Map{} - antigravityPreferCreditsByModel = sync.Map{} antigravityShortCooldownByAuth = sync.Map{} + antigravityCreditsBalanceByAuth = sync.Map{} } func TestClassifyAntigravity429(t *testing.T) { @@ -30,6 +30,43 @@ func TestClassifyAntigravity429(t *testing.T) { } }) + t.Run("standard antigravity rate limit with ui message stays rate limited", func(t *testing.T) { + body := []byte(`{ + "error": { + "code": 429, + "message": "You have exhausted your capacity on this model. Your quota will reset after 0s.", + "status": "RESOURCE_EXHAUSTED", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "reason": "RATE_LIMIT_EXCEEDED", + "domain": "cloudcode-pa.googleapis.com", + "metadata": { + "model": "claude-opus-4-6-thinking", + "quotaResetDelay": "479.417207ms", + "quotaResetTimeStamp": "2026-04-20T09:19:49Z", + "uiMessage": "true" + } + }, + { + "@type": "type.googleapis.com/google.rpc.RetryInfo", + "retryDelay": "0.479417207s" + } + ] + } + }`) + if got := classifyAntigravity429(body); got != antigravity429RateLimited { + t.Fatalf("classifyAntigravity429() = %q, want %q", got, antigravity429RateLimited) + } + decision := decideAntigravity429(body) + if decision.kind != antigravity429DecisionInstantRetrySameAuth { + t.Fatalf("decideAntigravity429().kind = %q, want %q", decision.kind, antigravity429DecisionInstantRetrySameAuth) + } + if decision.retryAfter == nil { + t.Fatal("decideAntigravity429().retryAfter = nil") + } + }) + t.Run("structured rate limit", func(t *testing.T) { body := []byte(`{ "error": { @@ -67,8 +104,32 @@ func TestClassifyAntigravity429(t *testing.T) { }) } +func TestAntigravityShouldRetryNoCapacity_Standard503(t *testing.T) { + body := []byte(`{ + "error": { + "code": 503, + "message": "No capacity available for model gemini-3.1-flash-image on the server", + "status": "UNAVAILABLE", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "reason": "MODEL_CAPACITY_EXHAUSTED", + "domain": "cloudcode-pa.googleapis.com", + "metadata": { + "model": "gemini-3.1-flash-image" + } + } + ] + } + }`) + if !antigravityShouldRetryNoCapacity(http.StatusServiceUnavailable, body) { + t.Fatal("antigravityShouldRetryNoCapacity() = false, want true") + } +} + + func TestInjectEnabledCreditTypes(t *testing.T) { - body := []byte(`{"model":"gemini-2.5-flash","request":{}}`) + body := []byte(`{"model":"claude-sonnet-4-6","request":{}}`) got := injectEnabledCreditTypes(body) if got == nil { t.Fatal("injectEnabledCreditTypes() returned nil") @@ -82,37 +143,22 @@ func TestInjectEnabledCreditTypes(t *testing.T) { } } -func TestShouldMarkAntigravityCreditsExhausted(t *testing.T) { - t.Run("credit errors are marked", func(t *testing.T) { - for _, body := range [][]byte{ - []byte(`{"error":{"message":"Insufficient GOOGLE_ONE_AI credits"}}`), - []byte(`{"error":{"message":"minimumCreditAmountForUsage requirement not met"}}`), - } { - if !shouldMarkAntigravityCreditsExhausted(http.StatusForbidden, body, nil) { - t.Fatalf("shouldMarkAntigravityCreditsExhausted(%s) = false, want true", string(body)) - } - } - }) - - t.Run("transient 429 resource exhausted is not marked", func(t *testing.T) { - body := []byte(`{"error":{"code":429,"message":"Resource has been exhausted (e.g. check quota).","status":"RESOURCE_EXHAUSTED"}}`) - if shouldMarkAntigravityCreditsExhausted(http.StatusTooManyRequests, body, nil) { - t.Fatalf("shouldMarkAntigravityCreditsExhausted(%s) = true, want false", string(body)) - } - }) - - t.Run("resource exhausted with quota metadata is still marked", func(t *testing.T) { - body := []byte(`{"error":{"code":429,"message":"Resource has been exhausted","status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","metadata":{"quotaResetDelay":"1h","model":"claude-sonnet-4-6"}}]}}`) - if !shouldMarkAntigravityCreditsExhausted(http.StatusTooManyRequests, body, nil) { - t.Fatalf("shouldMarkAntigravityCreditsExhausted(%s) = false, want true", string(body)) - } - }) - - if shouldMarkAntigravityCreditsExhausted(http.StatusServiceUnavailable, []byte(`{"error":{"message":"credits exhausted"}}`), nil) { - t.Fatal("shouldMarkAntigravityCreditsExhausted() = true for 5xx, want false") +func TestParseRetryDelay_HumanReadableDuration(t *testing.T) { + body := []byte(`{"error":{"message":"You have exhausted your capacity on this model. Your quota will reset after 1h43m56s."}}`) + retryAfter, err := parseRetryDelay(body) + if err != nil { + t.Fatalf("parseRetryDelay() error = %v", err) + } + if retryAfter == nil { + t.Fatal("parseRetryDelay() returned nil") + } + want := time.Hour + 43*time.Minute + 56*time.Second + if *retryAfter != want { + t.Fatalf("parseRetryDelay() = %v, want %v", *retryAfter, want) } } + func TestAntigravityExecute_RetriesTransient429ResourceExhausted(t *testing.T) { resetAntigravityCreditsRetryState() t.Cleanup(resetAntigravityCreditsRetryState) @@ -147,7 +193,7 @@ func TestAntigravityExecute_RetriesTransient429ResourceExhausted(t *testing.T) { } resp, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{ - Model: "gemini-2.5-flash", + Model: "claude-sonnet-4-6", Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`), }, cliproxyexecutor.Options{ SourceFormat: sdktranslator.FormatAntigravity, @@ -163,32 +209,18 @@ func TestAntigravityExecute_RetriesTransient429ResourceExhausted(t *testing.T) { } } -func TestAntigravityExecute_RetriesQuotaExhaustedWithCredits(t *testing.T) { +func TestAntigravityExecute_CreditsInjectedWhenConductorRequests(t *testing.T) { resetAntigravityCreditsRetryState() t.Cleanup(resetAntigravityCreditsRetryState) - var ( - mu sync.Mutex - requestBodies []string - ) - + var requestBodies []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) _ = r.Body.Close() - - mu.Lock() requestBodies = append(requestBodies, string(body)) - reqNum := len(requestBodies) - mu.Unlock() - - if reqNum == 1 { - w.WriteHeader(http.StatusTooManyRequests) - _, _ = w.Write([]byte(`{"error":{"status":"RESOURCE_EXHAUSTED","message":"QUOTA_EXHAUSTED"}}`)) - return - } if !strings.Contains(string(body), `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) { - t.Fatalf("second request body missing enabledCreditTypes: %s", string(body)) + t.Fatalf("request body missing enabledCreditTypes: %s", string(body)) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]}}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}}`)) @@ -199,7 +231,7 @@ func TestAntigravityExecute_RetriesQuotaExhaustedWithCredits(t *testing.T) { QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true}, }) auth := &cliproxyauth.Auth{ - ID: "auth-credits-ok", + ID: "auth-credits-conductor", Attributes: map[string]string{ "base_url": server.URL, }, @@ -210,8 +242,11 @@ func TestAntigravityExecute_RetriesQuotaExhaustedWithCredits(t *testing.T) { }, } - resp, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{ - Model: "gemini-2.5-flash", + // Simulate conductor setting credits requested flag in context + ctx := cliproxyauth.WithAntigravityCredits(context.Background()) + + resp, err := exec.Execute(ctx, auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-4-6", Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`), }, cliproxyexecutor.Options{ SourceFormat: sdktranslator.FormatAntigravity, @@ -222,21 +257,20 @@ func TestAntigravityExecute_RetriesQuotaExhaustedWithCredits(t *testing.T) { if len(resp.Payload) == 0 { t.Fatal("Execute() returned empty payload") } - - mu.Lock() - defer mu.Unlock() - if len(requestBodies) != 2 { - t.Fatalf("request count = %d, want 2", len(requestBodies)) + if len(requestBodies) != 1 { + t.Fatalf("request count = %d, want 1", len(requestBodies)) } } -func TestAntigravityExecute_SkipsCreditsRetryWhenAlreadyExhausted(t *testing.T) { +func TestAntigravityExecute_NoCreditsWithoutConductorFlag(t *testing.T) { resetAntigravityCreditsRetryState() t.Cleanup(resetAntigravityCreditsRetryState) - var requestCount int + var requestBodies []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requestCount++ + body, _ := io.ReadAll(r.Body) + _ = r.Body.Close() + requestBodies = append(requestBodies, string(body)) w.WriteHeader(http.StatusTooManyRequests) _, _ = w.Write([]byte(`{"error":{"status":"RESOURCE_EXHAUSTED","message":"QUOTA_EXHAUSTED"}}`)) })) @@ -246,7 +280,7 @@ func TestAntigravityExecute_SkipsCreditsRetryWhenAlreadyExhausted(t *testing.T) QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true}, }) auth := &cliproxyauth.Auth{ - ID: "auth-credits-exhausted", + ID: "auth-no-conductor-flag", Attributes: map[string]string{ "base_url": server.URL, }, @@ -256,10 +290,10 @@ func TestAntigravityExecute_SkipsCreditsRetryWhenAlreadyExhausted(t *testing.T) "expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339), }, } - recordAntigravityCreditsFailure(auth, time.Now()) + // No conductor credits flag set in context _, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{ - Model: "gemini-2.5-flash", + Model: "claude-sonnet-4-6", Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`), }, cliproxyexecutor.Options{ SourceFormat: sdktranslator.FormatAntigravity, @@ -267,224 +301,153 @@ func TestAntigravityExecute_SkipsCreditsRetryWhenAlreadyExhausted(t *testing.T) if err == nil { t.Fatal("Execute() error = nil, want 429") } - sErr, ok := err.(statusErr) - if !ok { - t.Fatalf("Execute() error type = %T, want statusErr", err) - } - if got := sErr.StatusCode(); got != http.StatusTooManyRequests { - t.Fatalf("Execute() status code = %d, want %d", got, http.StatusTooManyRequests) + if len(requestBodies) != 1 { + t.Fatalf("request count = %d, want 1", len(requestBodies)) } - if requestCount != 1 { - t.Fatalf("request count = %d, want 1", requestCount) + // Should NOT contain credits since conductor didn't request them + if strings.Contains(requestBodies[0], `"enabledCreditTypes"`) { + t.Fatalf("request should not contain enabledCreditTypes without conductor flag: %s", requestBodies[0]) } } -func TestAntigravityExecute_PrefersCreditsAfterSuccessfulFallback(t *testing.T) { - resetAntigravityCreditsRetryState() - t.Cleanup(resetAntigravityCreditsRetryState) - - var ( - mu sync.Mutex - requestBodies []string - ) +func TestAntigravityAuthHasCredits(t *testing.T) { + t.Run("sufficient balance", func(t *testing.T) { + resetAntigravityCreditsRetryState() + auth := &cliproxyauth.Auth{ID: "test-sufficient"} + antigravityCreditsBalanceByAuth.Store("test-sufficient", antigravityCreditsBalance{ + CreditAmount: 25000, + MinCreditAmount: 50, + Known: true, + }) + if !antigravityAuthHasCredits(auth) { + t.Fatal("antigravityAuthHasCredits() = false, want true") + } + }) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - _ = r.Body.Close() + t.Run("insufficient balance", func(t *testing.T) { + resetAntigravityCreditsRetryState() + auth := &cliproxyauth.Auth{ID: "test-insufficient"} + antigravityCreditsBalanceByAuth.Store("test-insufficient", antigravityCreditsBalance{ + CreditAmount: 30, + MinCreditAmount: 50, + Known: true, + }) + if antigravityAuthHasCredits(auth) { + t.Fatal("antigravityAuthHasCredits() = true, want false") + } + }) - mu.Lock() - requestBodies = append(requestBodies, string(body)) - reqNum := len(requestBodies) - mu.Unlock() + t.Run("no balance stored returns true (optimistic)", func(t *testing.T) { + resetAntigravityCreditsRetryState() + auth := &cliproxyauth.Auth{ID: "test-no-balance"} + if !antigravityAuthHasCredits(auth) { + t.Fatal("antigravityAuthHasCredits() = false with no balance stored, want true (optimistic default)") + } + }) - switch reqNum { - case 1: - w.WriteHeader(http.StatusTooManyRequests) - _, _ = w.Write([]byte(`{"error":{"status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"QUOTA_EXHAUSTED"},{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"10s"}]}}`)) - case 2, 3: - if !strings.Contains(string(body), `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) { - t.Fatalf("request %d body missing enabledCreditTypes: %s", reqNum, string(body)) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"OK"}]}}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}}`)) - default: - t.Fatalf("unexpected request count %d", reqNum) + t.Run("nil auth returns false", func(t *testing.T) { + if antigravityAuthHasCredits(nil) { + t.Fatal("antigravityAuthHasCredits(nil) = true, want false") } - })) - defer server.Close() + }) - exec := NewAntigravityExecutor(&config.Config{ - QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true}, + t.Run("empty ID returns false", func(t *testing.T) { + auth := &cliproxyauth.Auth{} + if antigravityAuthHasCredits(auth) { + t.Fatal("antigravityAuthHasCredits(empty ID) = true, want false") + } }) - auth := &cliproxyauth.Auth{ - ID: "auth-prefer-credits", - Attributes: map[string]string{ - "base_url": server.URL, - }, - Metadata: map[string]any{ - "access_token": "token", - "project_id": "project-1", - "expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339), - }, - } - request := cliproxyexecutor.Request{ - Model: "gemini-2.5-flash", - Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`), - } - opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatAntigravity} + t.Run("unknown balance returns false", func(t *testing.T) { + resetAntigravityCreditsRetryState() + auth := &cliproxyauth.Auth{ID: "test-unknown"} + antigravityCreditsBalanceByAuth.Store("test-unknown", antigravityCreditsBalance{ + Known: false, + }) + if antigravityAuthHasCredits(auth) { + t.Fatal("antigravityAuthHasCredits() = true for unknown balance, want false") + } + }) +} - if _, err := exec.Execute(context.Background(), auth, request, opts); err != nil { - t.Fatalf("first Execute() error = %v", err) - } - if _, err := exec.Execute(context.Background(), auth, request, opts); err != nil { - t.Fatalf("second Execute() error = %v", err) - } +type roundTripperFunc func(*http.Request) (*http.Response, error) - mu.Lock() - defer mu.Unlock() - if len(requestBodies) != 3 { - t.Fatalf("request count = %d, want 3", len(requestBodies)) - } - if strings.Contains(requestBodies[0], `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) { - t.Fatalf("first request unexpectedly used credits: %s", requestBodies[0]) - } - if !strings.Contains(requestBodies[1], `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) { - t.Fatalf("fallback request missing credits: %s", requestBodies[1]) - } - if !strings.Contains(requestBodies[2], `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) { - t.Fatalf("preferred request missing credits: %s", requestBodies[2]) - } +func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) } -func TestAntigravityExecute_PreservesBaseURLFallbackAfterCreditsRetryFailure(t *testing.T) { +func TestEnsureAccessToken_WarmTokenLoadsCreditsHint(t *testing.T) { resetAntigravityCreditsRetryState() t.Cleanup(resetAntigravityCreditsRetryState) - var ( - mu sync.Mutex - firstCount int - secondCount int - ) - - firstServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - _ = r.Body.Close() - - mu.Lock() - firstCount++ - reqNum := firstCount - mu.Unlock() - - switch reqNum { - case 1: - w.WriteHeader(http.StatusTooManyRequests) - _, _ = w.Write([]byte(`{"error":{"status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"QUOTA_EXHAUSTED"}]}}`)) - case 2: - if !strings.Contains(string(body), `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) { - t.Fatalf("credits retry missing enabledCreditTypes: %s", string(body)) - } - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"error":{"message":"permission denied"}}`)) - default: - t.Fatalf("unexpected first server request count %d", reqNum) - } - })) - defer firstServer.Close() - - secondServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mu.Lock() - secondCount++ - mu.Unlock() - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]}}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}}`)) - })) - defer secondServer.Close() - - exec := NewAntigravityExecutor(&config.Config{ - QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true}, - }) + exec := NewAntigravityExecutor(&config.Config{}) auth := &cliproxyauth.Auth{ - ID: "auth-baseurl-fallback", - Attributes: map[string]string{ - "base_url": firstServer.URL, - }, + ID: "auth-warm-token-credits", Metadata: map[string]any{ "access_token": "token", - "project_id": "project-1", "expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339), }, } + ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", roundTripperFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.String() != "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" { + t.Fatalf("unexpected request url %s", req.URL.String()) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"paidTier":{"id":"tier-1","availableCredits":[{"creditType":"GOOGLE_ONE_AI","creditAmount":"25000","minimumCreditAmountForUsage":"50"}]}}`)), + }, nil + })) - originalOrder := antigravityBaseURLFallbackOrder - defer func() { antigravityBaseURLFallbackOrder = originalOrder }() - antigravityBaseURLFallbackOrder = func(auth *cliproxyauth.Auth) []string { - return []string{firstServer.URL, secondServer.URL} - } - - resp, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{ - Model: "gemini-2.5-flash", - Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`), - }, cliproxyexecutor.Options{ - SourceFormat: sdktranslator.FormatAntigravity, - }) + token, updatedAuth, err := exec.ensureAccessToken(ctx, auth) if err != nil { - t.Fatalf("Execute() error = %v", err) + t.Fatalf("ensureAccessToken() error = %v", err) } - if len(resp.Payload) == 0 { - t.Fatal("Execute() returned empty payload") + if token != "token" { + t.Fatalf("ensureAccessToken() token = %q, want %q", token, "token") } - if firstCount != 2 { - t.Fatalf("first server request count = %d, want 2", firstCount) + if updatedAuth != nil { + t.Fatalf("ensureAccessToken() updatedAuth = %v, want nil", updatedAuth) } - if secondCount != 1 { - t.Fatalf("second server request count = %d, want 1", secondCount) + if !cliproxyauth.HasKnownAntigravityCreditsHint(auth.ID) { + t.Fatal("expected credits hint to be populated for warm token auth") } -} - -func TestAntigravityExecute_DoesNotDirectInjectCreditsWhenFlagDisabled(t *testing.T) { - resetAntigravityCreditsRetryState() - t.Cleanup(resetAntigravityCreditsRetryState) - - var requestBodies []string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - _ = r.Body.Close() - requestBodies = append(requestBodies, string(body)) - w.WriteHeader(http.StatusTooManyRequests) - _, _ = w.Write([]byte(`{"error":{"status":"RESOURCE_EXHAUSTED","message":"QUOTA_EXHAUSTED"}}`)) - })) - defer server.Close() - - exec := NewAntigravityExecutor(&config.Config{ - QuotaExceeded: config.QuotaExceeded{AntigravityCredits: false}, - }) - auth := &cliproxyauth.Auth{ - ID: "auth-flag-disabled", - Attributes: map[string]string{ - "base_url": server.URL, - }, - Metadata: map[string]any{ - "access_token": "token", - "project_id": "project-1", - "expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339), - }, + hint, ok := cliproxyauth.GetAntigravityCreditsHint(auth.ID) + if !ok { + t.Fatal("expected credits hint lookup to succeed") } - markAntigravityPreferCredits(auth, "gemini-2.5-flash", time.Now(), nil) - - _, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{ - Model: "gemini-2.5-flash", - Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`), - }, cliproxyexecutor.Options{ - SourceFormat: sdktranslator.FormatAntigravity, - }) - if err == nil { - t.Fatal("Execute() error = nil, want 429") + if !hint.Available { + t.Fatalf("hint.Available = %v, want true", hint.Available) } - if len(requestBodies) != 1 { - t.Fatalf("request count = %d, want 1", len(requestBodies)) + if hint.CreditAmount != 25000 || hint.MinCreditAmount != 50 { + t.Fatalf("hint amounts = (%v, %v), want (25000, 50)", hint.CreditAmount, hint.MinCreditAmount) } - if strings.Contains(requestBodies[0], `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) { - t.Fatalf("request unexpectedly used enabledCreditTypes with flag disabled: %s", requestBodies[0]) +} + +func TestParseMetaFloat(t *testing.T) { + tests := []struct { + name string + value any + wantVal float64 + wantOK bool + }{ + {"string", "25000", 25000, true}, + {"float64", float64(100), 100, true}, + {"int", int(50), 50, true}, + {"int64", int64(75), 75, true}, + {"empty string", "", 0, false}, + {"invalid string", "abc", 0, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + meta := map[string]any{"key": tt.value} + got, ok := parseMetaFloat(meta, "key") + if ok != tt.wantOK { + t.Fatalf("parseMetaFloat() ok = %v, want %v", ok, tt.wantOK) + } + if ok && got != tt.wantVal { + t.Fatalf("parseMetaFloat() = %f, want %f", got, tt.wantVal) + } + }) } } diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go index d2df610966..a18f824a62 100644 --- a/internal/runtime/executor/gemini_cli_executor.go +++ b/internal/runtime/executor/gemini_cli_executor.go @@ -898,7 +898,14 @@ func parseRetryDelay(errorBody []byte) (*time.Duration, error) { if matches := re.FindStringSubmatch(message); len(matches) > 1 { seconds, err := strconv.Atoi(matches[1]) if err == nil { - return new(time.Duration(seconds) * time.Second), nil + duration := time.Duration(seconds) * time.Second + return &duration, nil + } + } + reHuman := regexp.MustCompile(`after\s+((?:\d+h)?(?:\d+m)?(?:\d+s)?)\.?`) + if matches := reHuman.FindStringSubmatch(strings.ToLower(message)); len(matches) > 1 { + if duration, err := time.ParseDuration(matches[1]); err == nil && duration > 0 { + return &duration, nil } } } diff --git a/internal/runtime/executor/helps/logging_helpers.go b/internal/runtime/executor/helps/logging_helpers.go index 767c882016..b77ec1a999 100644 --- a/internal/runtime/executor/helps/logging_helpers.go +++ b/internal/runtime/executor/helps/logging_helpers.go @@ -24,6 +24,14 @@ const ( apiRequestKey = "API_REQUEST" apiResponseKey = "API_RESPONSE" apiWebsocketTimelineKey = "API_WEBSOCKET_TIMELINE" + + // maxErrorLogResponseBodySize limits cached response body when request-log is disabled. + // Prevents unbounded memory growth for large/streaming responses in error-only mode. + maxErrorLogResponseBodySize = 32 * 1024 // 32KB + + // maxErrorLogRequestBodySize limits materialized request body in error-only mode. + // Prevents OOM from large payloads (e.g. base64 images) when full request logging is off. + maxErrorLogRequestBodySize = 32 * 1024 // 32KB ) // UpstreamRequestLog captures the outbound upstream request details for logging. @@ -42,6 +50,7 @@ type UpstreamRequestLog struct { type upstreamAttempt struct { index int request string + deferredBody []byte // lazy body reference; only materialized on error response *strings.Builder responseIntroWritten bool statusWritten bool @@ -50,13 +59,12 @@ type upstreamAttempt struct { bodyHasContent bool prevWasSSEEvent bool errorWritten bool + bodyBytesWritten int + bodyTruncated bool } // RecordAPIRequest stores the upstream request metadata in Gin context for request logging. func RecordAPIRequest(ctx context.Context, cfg *config.Config, info UpstreamRequestLog) { - if cfg == nil || !cfg.RequestLog { - return - } ginCtx := ginContextFrom(ctx) if ginCtx == nil { return @@ -65,6 +73,8 @@ func RecordAPIRequest(ctx context.Context, cfg *config.Config, info UpstreamRequ attempts := getAttempts(ginCtx) index := len(attempts) + 1 + requestLogEnabled := cfg != nil && cfg.RequestLog + builder := &strings.Builder{} builder.WriteString(fmt.Sprintf("=== API REQUEST %d ===\n", index)) builder.WriteString(fmt.Sprintf("Timestamp: %s\n", time.Now().Format(time.RFC3339Nano))) @@ -82,10 +92,20 @@ func RecordAPIRequest(ctx context.Context, cfg *config.Config, info UpstreamRequ builder.WriteString("\nHeaders:\n") writeHeaders(builder, info.Headers) builder.WriteString("\nBody:\n") - if len(info.Body) > 0 { - builder.WriteString(string(info.Body)) + if requestLogEnabled { + // Full request logging: format body inline + if len(info.Body) > 0 { + builder.WriteString(string(info.Body)) + } else { + builder.WriteString("") + } } else { - builder.WriteString("") + // Error-only mode: defer body to avoid allocating copies for the 99% success path + if len(info.Body) > 0 { + builder.WriteString(fmt.Sprintf("", len(info.Body))) + } else { + builder.WriteString("") + } } builder.WriteString("\n\n") @@ -94,6 +114,9 @@ func RecordAPIRequest(ctx context.Context, cfg *config.Config, info UpstreamRequ request: builder.String(), response: &strings.Builder{}, } + if !requestLogEnabled && len(info.Body) > 0 { + attempt.deferredBody = info.Body + } attempts = append(attempts, attempt) ginCtx.Set(apiAttemptsKey, attempts) updateAggregatedRequest(ginCtx, attempts) @@ -101,14 +124,18 @@ func RecordAPIRequest(ctx context.Context, cfg *config.Config, info UpstreamRequ // RecordAPIResponseMetadata captures upstream response status/header information for the latest attempt. func RecordAPIResponseMetadata(ctx context.Context, cfg *config.Config, status int, headers http.Header) { - if cfg == nil || !cfg.RequestLog { - return - } ginCtx := ginContextFrom(ctx) if ginCtx == nil { return } attempts, attempt := ensureAttempt(ginCtx) + + // Materialize deferred request body when upstream returns an error. + // Success responses (2xx) skip this — their deferred body is dropped with gin context. + if status >= http.StatusBadRequest { + materializeDeferredBodies(ginCtx, attempts) + } + ensureResponseIntro(attempt) if status > 0 && !attempt.statusWritten { @@ -127,7 +154,7 @@ func RecordAPIResponseMetadata(ctx context.Context, cfg *config.Config, status i // RecordAPIResponseError adds an error entry for the latest attempt when no HTTP response is available. func RecordAPIResponseError(ctx context.Context, cfg *config.Config, err error) { - if cfg == nil || !cfg.RequestLog || err == nil { + if err == nil { return } ginCtx := ginContextFrom(ctx) @@ -135,6 +162,11 @@ func RecordAPIResponseError(ctx context.Context, cfg *config.Config, err error) return } attempts, attempt := ensureAttempt(ginCtx) + + // Materialize deferred request body on error — this is the only path that + // actually needs the body. Success path (99%) never pays for body copies. + materializeDeferredBodies(ginCtx, attempts) + ensureResponseIntro(attempt) if attempt.bodyStarted && !attempt.bodyHasContent { @@ -152,9 +184,6 @@ func RecordAPIResponseError(ctx context.Context, cfg *config.Config, err error) // AppendAPIResponseChunk appends an upstream response chunk to Gin context for request logging. func AppendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byte) { - if cfg == nil || !cfg.RequestLog { - return - } data := bytes.TrimSpace(chunk) if len(data) == 0 { return @@ -166,6 +195,11 @@ func AppendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byt attempts, attempt := ensureAttempt(ginCtx) ensureResponseIntro(attempt) + requestLogEnabled := cfg != nil && cfg.RequestLog + if !requestLogEnabled && attempt.bodyTruncated { + return + } + if !attempt.headersWritten { attempt.response.WriteString("Headers:\n") writeHeaders(attempt.response, nil) @@ -176,6 +210,22 @@ func AppendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byt attempt.response.WriteString("Body:\n") attempt.bodyStarted = true } + + // Cap response body size when full request-log is disabled to prevent memory growth + if !requestLogEnabled { + remaining := maxErrorLogResponseBodySize - attempt.bodyBytesWritten + if remaining <= 0 { + attempt.bodyTruncated = true + attempt.response.WriteString("\n") + updateAggregatedResponse(ginCtx, attempts) + return + } + if len(data) > remaining { + data = data[:remaining] + attempt.bodyTruncated = true + } + } + currentChunkIsSSEEvent := bytes.HasPrefix(data, []byte("event:")) currentChunkIsSSEData := bytes.HasPrefix(data, []byte("data:")) if attempt.bodyHasContent { @@ -186,9 +236,14 @@ func AppendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byt attempt.response.WriteString(separator) } attempt.response.WriteString(string(data)) + attempt.bodyBytesWritten += len(data) attempt.bodyHasContent = true attempt.prevWasSSEEvent = currentChunkIsSSEEvent + if attempt.bodyTruncated { + attempt.response.WriteString("\n") + } + updateAggregatedResponse(ginCtx, attempts) } @@ -332,6 +387,27 @@ func ginContextFrom(ctx context.Context) *gin.Context { return ginCtx } +const creditsUsedKey = "__antigravity_credits_used__" + +// MarkCreditsUsed flags the request as having used AI credits for billing. +func MarkCreditsUsed(ctx context.Context) { + if ginCtx := ginContextFrom(ctx); ginCtx != nil { + ginCtx.Set(creditsUsedKey, true) + } +} + +// CreditsUsed returns true if the request used AI credits. +func CreditsUsed(ctx context.Context) bool { + if ginCtx := ginContextFrom(ctx); ginCtx != nil { + if val, exists := ginCtx.Get(creditsUsedKey); exists { + if b, ok := val.(bool); ok { + return b + } + } + } + return false +} + func getAttempts(ginCtx *gin.Context) []*upstreamAttempt { if ginCtx == nil { return nil @@ -344,6 +420,34 @@ func getAttempts(ginCtx *gin.Context) []*upstreamAttempt { return nil } +// materializeDeferredBodies replaces deferred body placeholders with actual +// (truncated) body content. Called only on the error path so the 99% success +// path pays zero allocation cost for request body logging. +func materializeDeferredBodies(ginCtx *gin.Context, attempts []*upstreamAttempt) { + changed := false + for _, attempt := range attempts { + if attempt.deferredBody == nil { + continue + } + body := attempt.deferredBody + attempt.deferredBody = nil // release reference to allow GC of full payload + + placeholder := fmt.Sprintf("", len(body)) + var replacement string + if len(body) > maxErrorLogRequestBodySize { + replacement = string(body[:maxErrorLogRequestBodySize]) + + fmt.Sprintf("\n", len(body), maxErrorLogRequestBodySize) + } else { + replacement = string(body) + } + attempt.request = strings.Replace(attempt.request, placeholder, replacement, 1) + changed = true + } + if changed { + updateAggregatedRequest(ginCtx, attempts) + } +} + func ensureAttempt(ginCtx *gin.Context) ([]*upstreamAttempt, *upstreamAttempt) { attempts := getAttempts(ginCtx) if len(attempts) == 0 { diff --git a/sdk/cliproxy/auth/antigravity_credits.go b/sdk/cliproxy/auth/antigravity_credits.go new file mode 100644 index 0000000000..77b03bfd3e --- /dev/null +++ b/sdk/cliproxy/auth/antigravity_credits.go @@ -0,0 +1,90 @@ +package auth + +import ( + "context" + "strings" + "sync" + "time" +) + +type antigravityUseCreditsContextKey struct{} + +// WithAntigravityCredits returns a child context that signals the executor to +// inject enabledCreditTypes into the request payload. +func WithAntigravityCredits(ctx context.Context) context.Context { + return context.WithValue(ctx, antigravityUseCreditsContextKey{}, true) +} + +// AntigravityCreditsRequested reports whether the context carries the credits flag. +func AntigravityCreditsRequested(ctx context.Context) bool { + if ctx == nil { + return false + } + v, _ := ctx.Value(antigravityUseCreditsContextKey{}).(bool) + return v +} + +// AntigravityCreditsHint stores the latest known AI credits state for one auth. +type AntigravityCreditsHint struct { + Known bool + Available bool + CreditAmount float64 + MinCreditAmount float64 + PaidTierID string + UpdatedAt time.Time +} + +var antigravityCreditsHintByAuth sync.Map + +// SetAntigravityCreditsHint updates the latest known AI credits state for an auth. +func SetAntigravityCreditsHint(authID string, hint AntigravityCreditsHint) { + authID = strings.TrimSpace(authID) + if authID == "" { + return + } + if hint.UpdatedAt.IsZero() { + hint.UpdatedAt = time.Now() + } + antigravityCreditsHintByAuth.Store(authID, hint) +} + +// GetAntigravityCreditsHint returns the latest known AI credits state for an auth. +func GetAntigravityCreditsHint(authID string) (AntigravityCreditsHint, bool) { + authID = strings.TrimSpace(authID) + if authID == "" { + return AntigravityCreditsHint{}, false + } + value, ok := antigravityCreditsHintByAuth.Load(authID) + if !ok { + return AntigravityCreditsHint{}, false + } + hint, ok := value.(AntigravityCreditsHint) + if !ok { + antigravityCreditsHintByAuth.Delete(authID) + return AntigravityCreditsHint{}, false + } + return hint, true +} + +// HasKnownAntigravityCreditsHint reports whether credits state has been discovered for an auth. +func HasKnownAntigravityCreditsHint(authID string) bool { + hint, ok := GetAntigravityCreditsHint(authID) + return ok && hint.Known +} + +func antigravityCreditsAvailableForModel(auth *Auth, model string) bool { + if auth == nil { + return false + } + if !strings.EqualFold(strings.TrimSpace(auth.Provider), "antigravity") { + return false + } + if !strings.Contains(strings.ToLower(strings.TrimSpace(model)), "claude") { + return false + } + hint, ok := GetAntigravityCreditsHint(auth.ID) + if !ok || !hint.Known { + return false + } + return hint.Available +} diff --git a/sdk/cliproxy/auth/antigravity_credits_test.go b/sdk/cliproxy/auth/antigravity_credits_test.go new file mode 100644 index 0000000000..8f59b4c78f --- /dev/null +++ b/sdk/cliproxy/auth/antigravity_credits_test.go @@ -0,0 +1,62 @@ +package auth + +import ( + "testing" + "time" +) + +func TestIsAuthBlockedForModel_ClaudeWithCreditsStillBlockedDuringCooldown(t *testing.T) { + auth := &Auth{ + ID: "ag-1", + Provider: "antigravity", + ModelStates: map[string]*ModelState{ + "claude-sonnet-4-6": { + Unavailable: true, + NextRetryAfter: time.Now().Add(10 * time.Minute), + Quota: QuotaState{ + Exceeded: true, + NextRecoverAt: time.Now().Add(10 * time.Minute), + }, + }, + }, + } + + SetAntigravityCreditsHint(auth.ID, AntigravityCreditsHint{ + Known: true, + Available: true, + UpdatedAt: time.Now(), + }) + + blocked, reason, _ := isAuthBlockedForModel(auth, "claude-sonnet-4-6", time.Now()) + if !blocked || reason != blockReasonCooldown { + t.Fatalf("expected auth to be blocked during cooldown even with credits, got blocked=%v reason=%v", blocked, reason) + } +} + +func TestIsAuthBlockedForModel_KeepsGeminiBlockedWithoutCreditsBypass(t *testing.T) { + auth := &Auth{ + ID: "ag-2", + Provider: "antigravity", + ModelStates: map[string]*ModelState{ + "gemini-3-flash": { + Unavailable: true, + NextRetryAfter: time.Now().Add(10 * time.Minute), + Quota: QuotaState{ + Exceeded: true, + NextRecoverAt: time.Now().Add(10 * time.Minute), + }, + }, + }, + } + + SetAntigravityCreditsHint(auth.ID, AntigravityCreditsHint{ + Known: true, + Available: true, + UpdatedAt: time.Now(), + }) + + blocked, reason, _ := isAuthBlockedForModel(auth, "gemini-3-flash", time.Now()) + if !blocked || reason != blockReasonCooldown { + t.Fatalf("expected gemini auth to remain blocked, got blocked=%v reason=%v", blocked, reason) + } +} diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 0a9c157b0a..dff479df40 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -1202,12 +1202,16 @@ func (m *Manager) Execute(ctx context.Context, providers []string, req cliproxye } } if lastErr != nil { + if shouldAttemptAntigravityCreditsFallback(m, lastErr, normalized) { + if resp, ok := m.tryAntigravityCreditsExecute(ctx, req, opts); ok { + return resp, nil + } + } return cliproxyexecutor.Response{}, lastErr } return cliproxyexecutor.Response{}, &Error{Code: "auth_not_found", Message: "no auth available"} } -// ExecuteCount performs a non-streaming execution using the configured selector and executor. // It supports multiple providers for the same model and round-robins the starting provider per model. func (m *Manager) ExecuteCount(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { normalized := m.normalizeProviders(providers) @@ -1233,6 +1237,11 @@ func (m *Manager) ExecuteCount(ctx context.Context, providers []string, req clip } } if lastErr != nil { + if shouldAttemptAntigravityCreditsFallback(m, lastErr, normalized) { + if resp, ok := m.tryAntigravityCreditsExecuteCount(ctx, req, opts); ok { + return resp, nil + } + } return cliproxyexecutor.Response{}, lastErr } return cliproxyexecutor.Response{}, &Error{Code: "auth_not_found", Message: "no auth available"} @@ -1264,6 +1273,11 @@ func (m *Manager) ExecuteStream(ctx context.Context, providers []string, req cli } } if lastErr != nil { + if shouldAttemptAntigravityCreditsFallback(m, lastErr, normalized) { + if result, ok := m.tryAntigravityCreditsExecuteStream(ctx, req, opts); ok { + return result, nil + } + } return nil, lastErr } return nil, &Error{Code: "auth_not_found", Message: "no auth available"} @@ -2319,7 +2333,8 @@ func retryAfterFromError(err error) *time.Duration { if retryAfter == nil { return nil } - return new(*retryAfter) + value := *retryAfter + return &value } func statusCodeFromResult(err *Error) int { @@ -2409,11 +2424,18 @@ func isRequestInvalidError(err error) bool { status := statusCodeFromError(err) switch status { case http.StatusBadRequest: - return strings.Contains(err.Error(), "invalid_request_error") + msg := err.Error() + return strings.Contains(msg, "invalid_request_error") || + strings.Contains(msg, "INVALID_ARGUMENT") || + strings.Contains(msg, "FAILED_PRECONDITION") case http.StatusNotFound: return isRequestScopedNotFoundMessage(err.Error()) case http.StatusUnprocessableEntity: return true + case http.StatusInternalServerError: + msg := err.Error() + return strings.Contains(msg, "\"status\":\"UNKNOWN\"") || + strings.Contains(msg, "\"status\": \"UNKNOWN\"") default: return false } @@ -2886,6 +2908,193 @@ func (m *Manager) pickNextMixed(ctx context.Context, providers []string, model s return authCopy, executor, providerKey, nil } +func (m *Manager) findAllAntigravityCreditsCandidateAuths(routeModel string) []creditsCandidateEntry { + if m == nil { + return nil + } + m.mu.RLock() + defer m.mu.RUnlock() + var candidates []creditsCandidateEntry + for _, auth := range m.auths { + if auth == nil || auth.Disabled || auth.Status == StatusDisabled { + continue + } + if !antigravityCreditsAvailableForModel(auth, routeModel) { + continue + } + providerKey := strings.TrimSpace(strings.ToLower(auth.Provider)) + executor, ok := m.executors[providerKey] + if !ok { + continue + } + candidates = append(candidates, creditsCandidateEntry{ + auth: auth.Clone(), + executor: executor, + provider: providerKey, + }) + } + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].auth.ID < candidates[j].auth.ID + }) + return candidates +} + +type creditsCandidateEntry struct { + auth *Auth + executor ProviderExecutor + provider string +} + +func shouldAttemptAntigravityCreditsFallback(m *Manager, lastErr error, providers []string) bool { + if m == nil || lastErr == nil { + return false + } + if len(providers) > 0 { + hasAntigravity := false + for _, p := range providers { + if strings.EqualFold(strings.TrimSpace(p), "antigravity") { + hasAntigravity = true + break + } + } + if !hasAntigravity { + return false + } + } + cfg, _ := m.runtimeConfig.Load().(*internalconfig.Config) + if cfg == nil || !cfg.QuotaExceeded.AntigravityCredits { + return false + } + status := statusCodeFromError(lastErr) + switch status { + case http.StatusTooManyRequests, http.StatusServiceUnavailable: + return true + case 0: + var authErr *Error + if errors.As(lastErr, &authErr) && authErr != nil { + return authErr.Code == "auth_not_found" || authErr.Code == "auth_unavailable" || authErr.Code == "model_cooldown" + } + var cooldownErr *modelCooldownError + if errors.As(lastErr, &cooldownErr) { + return true + } + return false + default: + return false + } +} + +func (m *Manager) tryAntigravityCreditsExecute(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, bool) { + routeModel := req.Model + candidates := m.findAllAntigravityCreditsCandidateAuths(routeModel) + for _, c := range candidates { + if ctx.Err() != nil { + return cliproxyexecutor.Response{}, false + } + creditsCtx := WithAntigravityCredits(ctx) + if rt := m.roundTripperFor(c.auth); rt != nil { + creditsCtx = context.WithValue(creditsCtx, roundTripperContextKey{}, rt) + creditsCtx = context.WithValue(creditsCtx, "cliproxy.roundtripper", rt) + } + creditsOpts := ensureRequestedModelMetadata(opts, routeModel) + publishSelectedAuthMetadata(creditsOpts.Metadata, c.auth.ID) + models := m.executionModelCandidates(c.auth, routeModel) + if len(models) == 0 { + continue + } + for _, upstreamModel := range models { + resultModel := m.stateModelForExecution(c.auth, routeModel, upstreamModel, len(models) > 1) + execReq := req + execReq.Model = upstreamModel + resp, errExec := c.executor.Execute(creditsCtx, c.auth, execReq, creditsOpts) + result := Result{AuthID: c.auth.ID, Provider: c.provider, Model: resultModel, Success: errExec == nil} + if errExec != nil { + result.Error = &Error{Message: errExec.Error()} + if se, ok := errors.AsType[cliproxyexecutor.StatusError](errExec); ok && se != nil { + result.Error.HTTPStatus = se.StatusCode() + } + if ra := retryAfterFromError(errExec); ra != nil { + result.RetryAfter = ra + } + m.MarkResult(creditsCtx, result) + continue + } + m.MarkResult(creditsCtx, result) + return resp, true + } + } + return cliproxyexecutor.Response{}, false +} + +func (m *Manager) tryAntigravityCreditsExecuteCount(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, bool) { + routeModel := req.Model + candidates := m.findAllAntigravityCreditsCandidateAuths(routeModel) + for _, c := range candidates { + if ctx.Err() != nil { + return cliproxyexecutor.Response{}, false + } + creditsCtx := WithAntigravityCredits(ctx) + if rt := m.roundTripperFor(c.auth); rt != nil { + creditsCtx = context.WithValue(creditsCtx, roundTripperContextKey{}, rt) + creditsCtx = context.WithValue(creditsCtx, "cliproxy.roundtripper", rt) + } + creditsOpts := ensureRequestedModelMetadata(opts, routeModel) + publishSelectedAuthMetadata(creditsOpts.Metadata, c.auth.ID) + models := m.executionModelCandidates(c.auth, routeModel) + if len(models) == 0 { + continue + } + for _, upstreamModel := range models { + resultModel := m.stateModelForExecution(c.auth, routeModel, upstreamModel, len(models) > 1) + execReq := req + execReq.Model = upstreamModel + resp, errExec := c.executor.CountTokens(creditsCtx, c.auth, execReq, creditsOpts) + result := Result{AuthID: c.auth.ID, Provider: c.provider, Model: resultModel, Success: errExec == nil} + if errExec != nil { + result.Error = &Error{Message: errExec.Error()} + if se, ok := errors.AsType[cliproxyexecutor.StatusError](errExec); ok && se != nil { + result.Error.HTTPStatus = se.StatusCode() + } + if ra := retryAfterFromError(errExec); ra != nil { + result.RetryAfter = ra + } + m.MarkResult(creditsCtx, result) + continue + } + m.MarkResult(creditsCtx, result) + return resp, true + } + } + return cliproxyexecutor.Response{}, false +} + +func (m *Manager) tryAntigravityCreditsExecuteStream(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, bool) { + routeModel := req.Model + candidates := m.findAllAntigravityCreditsCandidateAuths(routeModel) + for _, c := range candidates { + if ctx.Err() != nil { + return nil, false + } + creditsCtx := WithAntigravityCredits(ctx) + if rt := m.roundTripperFor(c.auth); rt != nil { + creditsCtx = context.WithValue(creditsCtx, roundTripperContextKey{}, rt) + creditsCtx = context.WithValue(creditsCtx, "cliproxy.roundtripper", rt) + } + creditsOpts := ensureRequestedModelMetadata(opts, routeModel) + publishSelectedAuthMetadata(creditsOpts.Metadata, c.auth.ID) + models := m.executionModelCandidates(c.auth, routeModel) + if len(models) == 0 { + continue + } + result, errStream := m.executeStreamWithModelPool(creditsCtx, c.executor, c.auth, c.provider, req, creditsOpts, routeModel, models, len(models) > 1) + if errStream != nil { + continue + } + return result, true + } + return nil, false +} + func (m *Manager) persist(ctx context.Context, auth *Auth) error { if m.store == nil || auth == nil { return nil @@ -3200,14 +3409,15 @@ func (m *Manager) refreshAuth(ctx context.Context, id string) { m.mu.RLock() auth := m.auths[id] var exec ProviderExecutor + var cloned *Auth if auth != nil { exec = m.executors[auth.Provider] + cloned = auth.Clone() } m.mu.RUnlock() if auth == nil || exec == nil { return } - cloned := auth.Clone() updated, err := exec.Refresh(ctx, cloned) if err != nil && errors.Is(err, context.Canceled) { log.Debugf("refresh canceled for %s, %s", auth.Provider, auth.ID) From 4de5c29f86f3e57186140a8fec8e505476d5772a Mon Sep 17 00:00:00 2001 From: sususu98 Date: Thu, 23 Apr 2026 15:17:00 +0800 Subject: [PATCH 21/30] fix(antigravity): remove credits fallback from CountTokens, fix gofmt CountTokens upstream API does not support enabledCreditTypes, so remove the dead credits fallback path from ExecuteCount and delete the unused tryAntigravityCreditsExecuteCount method. Fix gofmt on credits test file. --- .../antigravity_executor_credits_test.go | 2 - sdk/cliproxy/auth/conductor.go | 47 ------------------- 2 files changed, 49 deletions(-) diff --git a/internal/runtime/executor/antigravity_executor_credits_test.go b/internal/runtime/executor/antigravity_executor_credits_test.go index b9c7a91fd8..9e4662cff0 100644 --- a/internal/runtime/executor/antigravity_executor_credits_test.go +++ b/internal/runtime/executor/antigravity_executor_credits_test.go @@ -127,7 +127,6 @@ func TestAntigravityShouldRetryNoCapacity_Standard503(t *testing.T) { } } - func TestInjectEnabledCreditTypes(t *testing.T) { body := []byte(`{"model":"claude-sonnet-4-6","request":{}}`) got := injectEnabledCreditTypes(body) @@ -158,7 +157,6 @@ func TestParseRetryDelay_HumanReadableDuration(t *testing.T) { } } - func TestAntigravityExecute_RetriesTransient429ResourceExhausted(t *testing.T) { resetAntigravityCreditsRetryState() t.Cleanup(resetAntigravityCreditsRetryState) diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index dff479df40..2a4ee6cbca 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -1237,11 +1237,6 @@ func (m *Manager) ExecuteCount(ctx context.Context, providers []string, req clip } } if lastErr != nil { - if shouldAttemptAntigravityCreditsFallback(m, lastErr, normalized) { - if resp, ok := m.tryAntigravityCreditsExecuteCount(ctx, req, opts); ok { - return resp, nil - } - } return cliproxyexecutor.Response{}, lastErr } return cliproxyexecutor.Response{}, &Error{Code: "auth_not_found", Message: "no auth available"} @@ -3026,48 +3021,6 @@ func (m *Manager) tryAntigravityCreditsExecute(ctx context.Context, req cliproxy return cliproxyexecutor.Response{}, false } -func (m *Manager) tryAntigravityCreditsExecuteCount(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, bool) { - routeModel := req.Model - candidates := m.findAllAntigravityCreditsCandidateAuths(routeModel) - for _, c := range candidates { - if ctx.Err() != nil { - return cliproxyexecutor.Response{}, false - } - creditsCtx := WithAntigravityCredits(ctx) - if rt := m.roundTripperFor(c.auth); rt != nil { - creditsCtx = context.WithValue(creditsCtx, roundTripperContextKey{}, rt) - creditsCtx = context.WithValue(creditsCtx, "cliproxy.roundtripper", rt) - } - creditsOpts := ensureRequestedModelMetadata(opts, routeModel) - publishSelectedAuthMetadata(creditsOpts.Metadata, c.auth.ID) - models := m.executionModelCandidates(c.auth, routeModel) - if len(models) == 0 { - continue - } - for _, upstreamModel := range models { - resultModel := m.stateModelForExecution(c.auth, routeModel, upstreamModel, len(models) > 1) - execReq := req - execReq.Model = upstreamModel - resp, errExec := c.executor.CountTokens(creditsCtx, c.auth, execReq, creditsOpts) - result := Result{AuthID: c.auth.ID, Provider: c.provider, Model: resultModel, Success: errExec == nil} - if errExec != nil { - result.Error = &Error{Message: errExec.Error()} - if se, ok := errors.AsType[cliproxyexecutor.StatusError](errExec); ok && se != nil { - result.Error.HTTPStatus = se.StatusCode() - } - if ra := retryAfterFromError(errExec); ra != nil { - result.RetryAfter = ra - } - m.MarkResult(creditsCtx, result) - continue - } - m.MarkResult(creditsCtx, result) - return resp, true - } - } - return cliproxyexecutor.Response{}, false -} - func (m *Manager) tryAntigravityCreditsExecuteStream(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, bool) { routeModel := req.Model candidates := m.findAllAntigravityCreditsCandidateAuths(routeModel) From e75daa299b49dcbe43079eb97bcbe568af13431e Mon Sep 17 00:00:00 2001 From: sususu98 Date: Thu, 23 Apr 2026 17:38:02 +0800 Subject: [PATCH 22/30] fix(antigravity): respect pinned auth in credits fallback, release deferred body on success - findAllAntigravityCreditsCandidateAuths now filters by PinnedAuthMetadataKey to prevent credential isolation violations during credits fallback - Release deferredBody reference on success path to avoid holding large payloads in memory for the lifetime of the gin context --- internal/runtime/executor/helps/logging_helpers.go | 4 ++++ sdk/cliproxy/auth/conductor.go | 10 +++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/internal/runtime/executor/helps/logging_helpers.go b/internal/runtime/executor/helps/logging_helpers.go index b77ec1a999..1292deb451 100644 --- a/internal/runtime/executor/helps/logging_helpers.go +++ b/internal/runtime/executor/helps/logging_helpers.go @@ -134,6 +134,10 @@ func RecordAPIResponseMetadata(ctx context.Context, cfg *config.Config, status i // Success responses (2xx) skip this — their deferred body is dropped with gin context. if status >= http.StatusBadRequest { materializeDeferredBodies(ginCtx, attempts) + } else { + for _, a := range attempts { + a.deferredBody = nil + } } ensureResponseIntro(attempt) diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 2a4ee6cbca..d1490e3c11 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -2903,10 +2903,11 @@ func (m *Manager) pickNextMixed(ctx context.Context, providers []string, model s return authCopy, executor, providerKey, nil } -func (m *Manager) findAllAntigravityCreditsCandidateAuths(routeModel string) []creditsCandidateEntry { +func (m *Manager) findAllAntigravityCreditsCandidateAuths(routeModel string, opts cliproxyexecutor.Options) []creditsCandidateEntry { if m == nil { return nil } + pinnedAuthID := pinnedAuthIDFromMetadata(opts.Metadata) m.mu.RLock() defer m.mu.RUnlock() var candidates []creditsCandidateEntry @@ -2914,6 +2915,9 @@ func (m *Manager) findAllAntigravityCreditsCandidateAuths(routeModel string) []c if auth == nil || auth.Disabled || auth.Status == StatusDisabled { continue } + if pinnedAuthID != "" && auth.ID != pinnedAuthID { + continue + } if !antigravityCreditsAvailableForModel(auth, routeModel) { continue } @@ -2981,7 +2985,7 @@ func shouldAttemptAntigravityCreditsFallback(m *Manager, lastErr error, provider func (m *Manager) tryAntigravityCreditsExecute(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, bool) { routeModel := req.Model - candidates := m.findAllAntigravityCreditsCandidateAuths(routeModel) + candidates := m.findAllAntigravityCreditsCandidateAuths(routeModel, opts) for _, c := range candidates { if ctx.Err() != nil { return cliproxyexecutor.Response{}, false @@ -3023,7 +3027,7 @@ func (m *Manager) tryAntigravityCreditsExecute(ctx context.Context, req cliproxy func (m *Manager) tryAntigravityCreditsExecuteStream(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, bool) { routeModel := req.Model - candidates := m.findAllAntigravityCreditsCandidateAuths(routeModel) + candidates := m.findAllAntigravityCreditsCandidateAuths(routeModel, opts) for _, c := range candidates { if ctx.Err() != nil { return nil, false From 920b6efffa7ce82e7d964a017b03033ca55c281b Mon Sep 17 00:00:00 2001 From: sususu98 Date: Thu, 23 Apr 2026 17:41:54 +0800 Subject: [PATCH 23/30] refactor(logging): strip unrelated deferred body changes, keep credits-only logging Remove deferred body optimization and maxErrorLog constants that were unrelated to credits fallback. Keep only MarkCreditsUsed/CreditsUsed helpers for flagging requests that consumed AI credits. --- .../runtime/executor/helps/logging_helpers.go | 156 ++++-------------- 1 file changed, 35 insertions(+), 121 deletions(-) diff --git a/internal/runtime/executor/helps/logging_helpers.go b/internal/runtime/executor/helps/logging_helpers.go index 1292deb451..a0b30f7099 100644 --- a/internal/runtime/executor/helps/logging_helpers.go +++ b/internal/runtime/executor/helps/logging_helpers.go @@ -24,14 +24,7 @@ const ( apiRequestKey = "API_REQUEST" apiResponseKey = "API_RESPONSE" apiWebsocketTimelineKey = "API_WEBSOCKET_TIMELINE" - - // maxErrorLogResponseBodySize limits cached response body when request-log is disabled. - // Prevents unbounded memory growth for large/streaming responses in error-only mode. - maxErrorLogResponseBodySize = 32 * 1024 // 32KB - - // maxErrorLogRequestBodySize limits materialized request body in error-only mode. - // Prevents OOM from large payloads (e.g. base64 images) when full request logging is off. - maxErrorLogRequestBodySize = 32 * 1024 // 32KB + creditsUsedKey = "__antigravity_credits_used__" ) // UpstreamRequestLog captures the outbound upstream request details for logging. @@ -50,7 +43,6 @@ type UpstreamRequestLog struct { type upstreamAttempt struct { index int request string - deferredBody []byte // lazy body reference; only materialized on error response *strings.Builder responseIntroWritten bool statusWritten bool @@ -59,12 +51,13 @@ type upstreamAttempt struct { bodyHasContent bool prevWasSSEEvent bool errorWritten bool - bodyBytesWritten int - bodyTruncated bool } // RecordAPIRequest stores the upstream request metadata in Gin context for request logging. func RecordAPIRequest(ctx context.Context, cfg *config.Config, info UpstreamRequestLog) { + if cfg == nil || !cfg.RequestLog { + return + } ginCtx := ginContextFrom(ctx) if ginCtx == nil { return @@ -73,8 +66,6 @@ func RecordAPIRequest(ctx context.Context, cfg *config.Config, info UpstreamRequ attempts := getAttempts(ginCtx) index := len(attempts) + 1 - requestLogEnabled := cfg != nil && cfg.RequestLog - builder := &strings.Builder{} builder.WriteString(fmt.Sprintf("=== API REQUEST %d ===\n", index)) builder.WriteString(fmt.Sprintf("Timestamp: %s\n", time.Now().Format(time.RFC3339Nano))) @@ -92,20 +83,10 @@ func RecordAPIRequest(ctx context.Context, cfg *config.Config, info UpstreamRequ builder.WriteString("\nHeaders:\n") writeHeaders(builder, info.Headers) builder.WriteString("\nBody:\n") - if requestLogEnabled { - // Full request logging: format body inline - if len(info.Body) > 0 { - builder.WriteString(string(info.Body)) - } else { - builder.WriteString("") - } + if len(info.Body) > 0 { + builder.WriteString(string(info.Body)) } else { - // Error-only mode: defer body to avoid allocating copies for the 99% success path - if len(info.Body) > 0 { - builder.WriteString(fmt.Sprintf("", len(info.Body))) - } else { - builder.WriteString("") - } + builder.WriteString("") } builder.WriteString("\n\n") @@ -114,9 +95,6 @@ func RecordAPIRequest(ctx context.Context, cfg *config.Config, info UpstreamRequ request: builder.String(), response: &strings.Builder{}, } - if !requestLogEnabled && len(info.Body) > 0 { - attempt.deferredBody = info.Body - } attempts = append(attempts, attempt) ginCtx.Set(apiAttemptsKey, attempts) updateAggregatedRequest(ginCtx, attempts) @@ -124,22 +102,14 @@ func RecordAPIRequest(ctx context.Context, cfg *config.Config, info UpstreamRequ // RecordAPIResponseMetadata captures upstream response status/header information for the latest attempt. func RecordAPIResponseMetadata(ctx context.Context, cfg *config.Config, status int, headers http.Header) { + if cfg == nil || !cfg.RequestLog { + return + } ginCtx := ginContextFrom(ctx) if ginCtx == nil { return } attempts, attempt := ensureAttempt(ginCtx) - - // Materialize deferred request body when upstream returns an error. - // Success responses (2xx) skip this — their deferred body is dropped with gin context. - if status >= http.StatusBadRequest { - materializeDeferredBodies(ginCtx, attempts) - } else { - for _, a := range attempts { - a.deferredBody = nil - } - } - ensureResponseIntro(attempt) if status > 0 && !attempt.statusWritten { @@ -158,7 +128,7 @@ func RecordAPIResponseMetadata(ctx context.Context, cfg *config.Config, status i // RecordAPIResponseError adds an error entry for the latest attempt when no HTTP response is available. func RecordAPIResponseError(ctx context.Context, cfg *config.Config, err error) { - if err == nil { + if cfg == nil || !cfg.RequestLog || err == nil { return } ginCtx := ginContextFrom(ctx) @@ -166,11 +136,6 @@ func RecordAPIResponseError(ctx context.Context, cfg *config.Config, err error) return } attempts, attempt := ensureAttempt(ginCtx) - - // Materialize deferred request body on error — this is the only path that - // actually needs the body. Success path (99%) never pays for body copies. - materializeDeferredBodies(ginCtx, attempts) - ensureResponseIntro(attempt) if attempt.bodyStarted && !attempt.bodyHasContent { @@ -188,6 +153,9 @@ func RecordAPIResponseError(ctx context.Context, cfg *config.Config, err error) // AppendAPIResponseChunk appends an upstream response chunk to Gin context for request logging. func AppendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byte) { + if cfg == nil || !cfg.RequestLog { + return + } data := bytes.TrimSpace(chunk) if len(data) == 0 { return @@ -199,11 +167,6 @@ func AppendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byt attempts, attempt := ensureAttempt(ginCtx) ensureResponseIntro(attempt) - requestLogEnabled := cfg != nil && cfg.RequestLog - if !requestLogEnabled && attempt.bodyTruncated { - return - } - if !attempt.headersWritten { attempt.response.WriteString("Headers:\n") writeHeaders(attempt.response, nil) @@ -214,22 +177,6 @@ func AppendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byt attempt.response.WriteString("Body:\n") attempt.bodyStarted = true } - - // Cap response body size when full request-log is disabled to prevent memory growth - if !requestLogEnabled { - remaining := maxErrorLogResponseBodySize - attempt.bodyBytesWritten - if remaining <= 0 { - attempt.bodyTruncated = true - attempt.response.WriteString("\n") - updateAggregatedResponse(ginCtx, attempts) - return - } - if len(data) > remaining { - data = data[:remaining] - attempt.bodyTruncated = true - } - } - currentChunkIsSSEEvent := bytes.HasPrefix(data, []byte("event:")) currentChunkIsSSEData := bytes.HasPrefix(data, []byte("data:")) if attempt.bodyHasContent { @@ -240,14 +187,9 @@ func AppendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byt attempt.response.WriteString(separator) } attempt.response.WriteString(string(data)) - attempt.bodyBytesWritten += len(data) attempt.bodyHasContent = true attempt.prevWasSSEEvent = currentChunkIsSSEEvent - if attempt.bodyTruncated { - attempt.response.WriteString("\n") - } - updateAggregatedResponse(ginCtx, attempts) } @@ -391,27 +333,6 @@ func ginContextFrom(ctx context.Context) *gin.Context { return ginCtx } -const creditsUsedKey = "__antigravity_credits_used__" - -// MarkCreditsUsed flags the request as having used AI credits for billing. -func MarkCreditsUsed(ctx context.Context) { - if ginCtx := ginContextFrom(ctx); ginCtx != nil { - ginCtx.Set(creditsUsedKey, true) - } -} - -// CreditsUsed returns true if the request used AI credits. -func CreditsUsed(ctx context.Context) bool { - if ginCtx := ginContextFrom(ctx); ginCtx != nil { - if val, exists := ginCtx.Get(creditsUsedKey); exists { - if b, ok := val.(bool); ok { - return b - } - } - } - return false -} - func getAttempts(ginCtx *gin.Context) []*upstreamAttempt { if ginCtx == nil { return nil @@ -424,34 +345,6 @@ func getAttempts(ginCtx *gin.Context) []*upstreamAttempt { return nil } -// materializeDeferredBodies replaces deferred body placeholders with actual -// (truncated) body content. Called only on the error path so the 99% success -// path pays zero allocation cost for request body logging. -func materializeDeferredBodies(ginCtx *gin.Context, attempts []*upstreamAttempt) { - changed := false - for _, attempt := range attempts { - if attempt.deferredBody == nil { - continue - } - body := attempt.deferredBody - attempt.deferredBody = nil // release reference to allow GC of full payload - - placeholder := fmt.Sprintf("", len(body)) - var replacement string - if len(body) > maxErrorLogRequestBodySize { - replacement = string(body[:maxErrorLogRequestBodySize]) + - fmt.Sprintf("\n", len(body), maxErrorLogRequestBodySize) - } else { - replacement = string(body) - } - attempt.request = strings.Replace(attempt.request, placeholder, replacement, 1) - changed = true - } - if changed { - updateAggregatedRequest(ginCtx, attempts) - } -} - func ensureAttempt(ginCtx *gin.Context) ([]*upstreamAttempt, *upstreamAttempt) { attempts := getAttempts(ginCtx) if len(attempts) == 0 { @@ -676,3 +569,24 @@ func LogWithRequestID(ctx context.Context) *log.Entry { } return log.WithField("request_id", requestID) } + +// MarkCreditsUsed flags the request as having used AI credits for billing. +func MarkCreditsUsed(ctx context.Context) { + ginCtx := ginContextFrom(ctx) + if ginCtx != nil { + ginCtx.Set(creditsUsedKey, true) + } +} + +// CreditsUsed returns true if the request used AI credits. +func CreditsUsed(ctx context.Context) bool { + ginCtx := ginContextFrom(ctx) + if ginCtx != nil { + if val, exists := ginCtx.Get(creditsUsedKey); exists { + if b, ok := val.(bool); ok { + return b + } + } + } + return false +} From f130846ec17f28f4c9d85214c941c1bc8d2adacc Mon Sep 17 00:00:00 2001 From: sususu98 Date: Thu, 23 Apr 2026 22:47:51 +0800 Subject: [PATCH 24/30] fix(auth): break credits cold-start deadlock by keeping unknown-hint auths as fallback candidates Replace antigravityCreditsAvailableForModel with inline known/unknown split. Auths whose credit hints are not yet populated are kept as lower-priority candidates instead of being rejected, breaking the chicken-and-egg deadlock at cold start. --- sdk/cliproxy/auth/conductor.go | 32 ++++++++-- .../auth/conductor_credits_candidates_test.go | 61 +++++++++++++++++++ 2 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 sdk/cliproxy/auth/conductor_credits_candidates_test.go diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index d1490e3c11..4d37581a61 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -2910,7 +2910,8 @@ func (m *Manager) findAllAntigravityCreditsCandidateAuths(routeModel string, opt pinnedAuthID := pinnedAuthIDFromMetadata(opts.Metadata) m.mu.RLock() defer m.mu.RUnlock() - var candidates []creditsCandidateEntry + var known []creditsCandidateEntry + var unknown []creditsCandidateEntry for _, auth := range m.auths { if auth == nil || auth.Disabled || auth.Status == StatusDisabled { continue @@ -2918,7 +2919,10 @@ func (m *Manager) findAllAntigravityCreditsCandidateAuths(routeModel string, opt if pinnedAuthID != "" && auth.ID != pinnedAuthID { continue } - if !antigravityCreditsAvailableForModel(auth, routeModel) { + if !strings.EqualFold(strings.TrimSpace(auth.Provider), "antigravity") { + continue + } + if !strings.Contains(strings.ToLower(strings.TrimSpace(routeModel)), "claude") { continue } providerKey := strings.TrimSpace(strings.ToLower(auth.Provider)) @@ -2926,16 +2930,32 @@ func (m *Manager) findAllAntigravityCreditsCandidateAuths(routeModel string, opt if !ok { continue } - candidates = append(candidates, creditsCandidateEntry{ + + hint, okHint := GetAntigravityCreditsHint(auth.ID) + if okHint && hint.Known { + if !hint.Available { + continue + } + known = append(known, creditsCandidateEntry{ + auth: auth.Clone(), + executor: executor, + provider: providerKey, + }) + continue + } + unknown = append(unknown, creditsCandidateEntry{ auth: auth.Clone(), executor: executor, provider: providerKey, }) } - sort.Slice(candidates, func(i, j int) bool { - return candidates[i].auth.ID < candidates[j].auth.ID + sort.Slice(known, func(i, j int) bool { + return known[i].auth.ID < known[j].auth.ID + }) + sort.Slice(unknown, func(i, j int) bool { + return unknown[i].auth.ID < unknown[j].auth.ID }) - return candidates + return append(known, unknown...) } type creditsCandidateEntry struct { diff --git a/sdk/cliproxy/auth/conductor_credits_candidates_test.go b/sdk/cliproxy/auth/conductor_credits_candidates_test.go new file mode 100644 index 0000000000..e66798acf6 --- /dev/null +++ b/sdk/cliproxy/auth/conductor_credits_candidates_test.go @@ -0,0 +1,61 @@ +package auth + +import ( + "testing" + "time" + + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" +) + +func TestFindAllAntigravityCreditsCandidateAuths_PrefersKnownCreditsThenUnknown(t *testing.T) { + m := &Manager{ + auths: map[string]*Auth{ + "zz-credits": {ID: "zz-credits", Provider: "antigravity"}, + "aa-unknown": {ID: "aa-unknown", Provider: "antigravity"}, + "mm-no": {ID: "mm-no", Provider: "antigravity"}, + }, + executors: map[string]ProviderExecutor{ + "antigravity": schedulerTestExecutor{}, + }, + } + + SetAntigravityCreditsHint("zz-credits", AntigravityCreditsHint{ + Known: true, + Available: true, + UpdatedAt: time.Now(), + }) + SetAntigravityCreditsHint("mm-no", AntigravityCreditsHint{ + Known: true, + Available: false, + UpdatedAt: time.Now(), + }) + + opts := cliproxyexecutor.Options{} + + candidates := m.findAllAntigravityCreditsCandidateAuths("claude-sonnet-4-6", opts) + if len(candidates) != 2 { + t.Fatalf("candidates len = %d, want 2", len(candidates)) + } + if candidates[0].auth.ID != "zz-credits" { + t.Fatalf("candidates[0].auth.ID = %q, want %q", candidates[0].auth.ID, "zz-credits") + } + if candidates[1].auth.ID != "aa-unknown" { + t.Fatalf("candidates[1].auth.ID = %q, want %q", candidates[1].auth.ID, "aa-unknown") + } + + nonClaude := m.findAllAntigravityCreditsCandidateAuths("gemini-3-flash", opts) + if len(nonClaude) != 0 { + t.Fatalf("nonClaude len = %d, want 0", len(nonClaude)) + } + + pinnedOpts := cliproxyexecutor.Options{ + Metadata: map[string]any{cliproxyexecutor.PinnedAuthMetadataKey: "aa-unknown"}, + } + pinned := m.findAllAntigravityCreditsCandidateAuths("claude-sonnet-4-6", pinnedOpts) + if len(pinned) != 1 { + t.Fatalf("pinned len = %d, want 1", len(pinned)) + } + if pinned[0].auth.ID != "aa-unknown" { + t.Fatalf("pinned[0].auth.ID = %q, want %q", pinned[0].auth.ID, "aa-unknown") + } +} From 7ad19000411982cd066e74e6f4e4c33776335614 Mon Sep 17 00:00:00 2001 From: sususu98 Date: Thu, 23 Apr 2026 23:58:10 +0800 Subject: [PATCH 25/30] perf(antigravity): async credits hint refresh for warm tokens --- .../runtime/executor/antigravity_executor.go | 69 ++++++++++++++++++- .../antigravity_executor_credits_test.go | 9 ++- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 633373d29c..6983bface5 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -52,6 +52,8 @@ const ( defaultAntigravityAgent = "antigravity/1.21.9 darwin/arm64" // fallback only; overridden at runtime by misc.AntigravityUserAgent() antigravityAuthType = "antigravity" refreshSkew = 3000 * time.Second + antigravityCreditsHintRefreshInterval = 10 * time.Minute + antigravityCreditsHintRefreshTimeout = 5 * time.Second antigravityShortQuotaCooldownThreshold = 5 * time.Minute antigravityInstantRetryThreshold = 3 * time.Second // systemInstruction = "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**" @@ -89,6 +91,7 @@ var ( antigravityCreditsFailureByAuth sync.Map antigravityShortCooldownByAuth sync.Map antigravityCreditsBalanceByAuth sync.Map // auth.ID → antigravityCreditsBalance + antigravityCreditsHintRefreshByID sync.Map // auth.ID → *antigravityCreditsHintRefreshState antigravityQuotaExhaustedKeywords = []string{ "quota_exhausted", "quota exhausted", @@ -102,6 +105,11 @@ type antigravityCreditsBalance struct { Known bool } +type antigravityCreditsHintRefreshState struct { + mu sync.Mutex + lastAttempt time.Time +} + func antigravityAuthHasCredits(auth *cliproxyauth.Auth) bool { if auth == nil || strings.TrimSpace(auth.ID) == "" { return false @@ -1558,9 +1566,7 @@ func (e *AntigravityExecutor) ensureAccessToken(ctx context.Context, auth *clipr accessToken := metaStringValue(auth.Metadata, "access_token") expiry := tokenExpiry(auth.Metadata) if accessToken != "" && expiry.After(time.Now().Add(refreshSkew)) { - if !cliproxyauth.HasKnownAntigravityCreditsHint(auth.ID) { - e.updateAntigravityCreditsBalance(ctx, auth, accessToken) - } + e.maybeRefreshAntigravityCreditsHint(ctx, auth, accessToken) return accessToken, nil, nil } refreshCtx := context.Background() @@ -1576,6 +1582,63 @@ func (e *AntigravityExecutor) ensureAccessToken(ctx context.Context, auth *clipr return metaStringValue(updated.Metadata, "access_token"), updated, nil } +func (e *AntigravityExecutor) maybeRefreshAntigravityCreditsHint(ctx context.Context, auth *cliproxyauth.Auth, accessToken string) { + if e == nil || auth == nil || !antigravityCreditsRetryEnabled(e.cfg) { + return + } + if ctx != nil && ctx.Err() != nil { + return + } + authID := strings.TrimSpace(auth.ID) + if authID == "" { + return + } + if hint, ok := cliproxyauth.GetAntigravityCreditsHint(authID); ok && hint.Known { + return + } + if strings.TrimSpace(accessToken) == "" { + accessToken = metaStringValue(auth.Metadata, "access_token") + } + if strings.TrimSpace(accessToken) == "" { + return + } + + state := &antigravityCreditsHintRefreshState{} + if existing, loaded := antigravityCreditsHintRefreshByID.LoadOrStore(authID, state); loaded { + if cast, ok := existing.(*antigravityCreditsHintRefreshState); ok && cast != nil { + state = cast + } else { + antigravityCreditsHintRefreshByID.Delete(authID) + antigravityCreditsHintRefreshByID.Store(authID, state) + } + } + + now := time.Now() + if !state.mu.TryLock() { + return + } + if !state.lastAttempt.IsZero() && now.Sub(state.lastAttempt) < antigravityCreditsHintRefreshInterval { + state.mu.Unlock() + return + } + state.lastAttempt = now + + refreshCtx := context.Background() + if ctx != nil { + if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil { + refreshCtx = context.WithValue(refreshCtx, "cliproxy.roundtripper", rt) + } + } + refreshCtx, cancel := context.WithTimeout(refreshCtx, antigravityCreditsHintRefreshTimeout) + authCopy := auth.Clone() + + go func(state *antigravityCreditsHintRefreshState, auth *cliproxyauth.Auth, token string) { + defer cancel() + defer state.mu.Unlock() + e.updateAntigravityCreditsBalance(refreshCtx, auth, token) + }(state, authCopy, accessToken) +} + func (e *AntigravityExecutor) refreshToken(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { if auth == nil { return nil, statusErr{code: http.StatusUnauthorized, msg: "missing auth"} diff --git a/internal/runtime/executor/antigravity_executor_credits_test.go b/internal/runtime/executor/antigravity_executor_credits_test.go index 9e4662cff0..6e38223e50 100644 --- a/internal/runtime/executor/antigravity_executor_credits_test.go +++ b/internal/runtime/executor/antigravity_executor_credits_test.go @@ -20,6 +20,7 @@ func resetAntigravityCreditsRetryState() { antigravityCreditsFailureByAuth = sync.Map{} antigravityShortCooldownByAuth = sync.Map{} antigravityCreditsBalanceByAuth = sync.Map{} + antigravityCreditsHintRefreshByID = sync.Map{} } func TestClassifyAntigravity429(t *testing.T) { @@ -378,7 +379,9 @@ func TestEnsureAccessToken_WarmTokenLoadsCreditsHint(t *testing.T) { resetAntigravityCreditsRetryState() t.Cleanup(resetAntigravityCreditsRetryState) - exec := NewAntigravityExecutor(&config.Config{}) + exec := NewAntigravityExecutor(&config.Config{ + QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true}, + }) auth := &cliproxyauth.Auth{ ID: "auth-warm-token-credits", Metadata: map[string]any{ @@ -407,6 +410,10 @@ func TestEnsureAccessToken_WarmTokenLoadsCreditsHint(t *testing.T) { if updatedAuth != nil { t.Fatalf("ensureAccessToken() updatedAuth = %v, want nil", updatedAuth) } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && !cliproxyauth.HasKnownAntigravityCreditsHint(auth.ID) { + time.Sleep(10 * time.Millisecond) + } if !cliproxyauth.HasKnownAntigravityCreditsHint(auth.ID) { t.Fatal("expected credits hint to be populated for warm token auth") } From 25137b1984e6f8ebb7f8182b49beb2a254be26e7 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 24 Apr 2026 00:11:42 +0800 Subject: [PATCH 26/30] feat(logging): add AI API path support for image routes - Included `/v1/images` in AI API path prefixes. - Introduced tests to validate `/v1/images/generations` and `/v1/images/edits` as AI API paths. --- internal/logging/gin_logger.go | 1 + internal/logging/gin_logger_test.go | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/internal/logging/gin_logger.go b/internal/logging/gin_logger.go index b94d7afe6d..d92ae985e5 100644 --- a/internal/logging/gin_logger.go +++ b/internal/logging/gin_logger.go @@ -20,6 +20,7 @@ import ( var aiAPIPrefixes = []string{ "/v1/chat/completions", "/v1/completions", + "/v1/images", "/v1/messages", "/v1/responses", "/v1beta/models/", diff --git a/internal/logging/gin_logger_test.go b/internal/logging/gin_logger_test.go index 7de1833865..9bd3ddfba6 100644 --- a/internal/logging/gin_logger_test.go +++ b/internal/logging/gin_logger_test.go @@ -58,3 +58,12 @@ func TestGinLogrusRecoveryHandlesRegularPanic(t *testing.T) { t.Fatalf("expected 500, got %d", recorder.Code) } } + +func TestIsAIAPIPathIncludesImages(t *testing.T) { + if !isAIAPIPath("/v1/images/generations") { + t.Fatalf("expected /v1/images/generations to be treated as AI API path") + } + if !isAIAPIPath("/v1/images/edits") { + t.Fatalf("expected /v1/images/edits to be treated as AI API path") + } +} From 7d5f6d93828fc2f436dce984e30f1489d40bdcd8 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 24 Apr 2026 02:43:12 +0800 Subject: [PATCH 27/30] feat(models): add GPT-5.5 model entry to registry JSON --- internal/registry/models/models.json | 92 ++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json index 24b96ca95f..f98579373f 100644 --- a/internal/registry/models/models.json +++ b/internal/registry/models/models.json @@ -1292,6 +1292,29 @@ "xhigh" ] } + }, + { + "id": "gpt-5.5", + "object": "model", + "created": 1776902400, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.5", + "version": "gpt-5.5", + "description": "Stable version of GPT 5.5", + "context_length": 1050000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } } ], "codex-team": [ @@ -1387,6 +1410,29 @@ "xhigh" ] } + }, + { + "id": "gpt-5.5", + "object": "model", + "created": 1776902400, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.5", + "version": "gpt-5.5", + "description": "Stable version of GPT 5.5", + "context_length": 1050000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } } ], "codex-plus": [ @@ -1505,6 +1551,29 @@ "xhigh" ] } + }, + { + "id": "gpt-5.5", + "object": "model", + "created": 1776902400, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.5", + "version": "gpt-5.5", + "description": "Stable version of GPT 5.5", + "context_length": 1050000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } } ], "codex-pro": [ @@ -1623,6 +1692,29 @@ "xhigh" ] } + }, + { + "id": "gpt-5.5", + "object": "model", + "created": 1776902400, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.5", + "version": "gpt-5.5", + "description": "Stable version of GPT 5.5", + "context_length": 1050000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } } ], "kimi": [ From 736018a0b071c48e6e5a13034e92694f301c0b0d Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Thu, 23 Apr 2026 13:28:03 -0600 Subject: [PATCH 28/30] Add GPT-5.5 Codex model support --- internal/registry/model_definitions_test.go | 88 +++++++++++++++++++++ internal/registry/models/models.json | 16 ++-- 2 files changed, 96 insertions(+), 8 deletions(-) create mode 100644 internal/registry/model_definitions_test.go diff --git a/internal/registry/model_definitions_test.go b/internal/registry/model_definitions_test.go new file mode 100644 index 0000000000..7a0630c28d --- /dev/null +++ b/internal/registry/model_definitions_test.go @@ -0,0 +1,88 @@ +package registry + +import "testing" + +func TestCodexStaticModelsIncludeGPT55(t *testing.T) { + tierModels := map[string][]*ModelInfo{ + "free": GetCodexFreeModels(), + "team": GetCodexTeamModels(), + "plus": GetCodexPlusModels(), + "pro": GetCodexProModels(), + } + + for tier, models := range tierModels { + t.Run(tier, func(t *testing.T) { + model := findModelInfo(models, "gpt-5.5") + if model == nil { + t.Fatalf("expected codex %s tier to include gpt-5.5", tier) + } + assertGPT55ModelInfo(t, tier, model) + }) + } + + model := LookupStaticModelInfo("gpt-5.5") + if model == nil { + t.Fatal("expected LookupStaticModelInfo to find gpt-5.5") + } + assertGPT55ModelInfo(t, "lookup", model) +} + +func findModelInfo(models []*ModelInfo, id string) *ModelInfo { + for _, model := range models { + if model != nil && model.ID == id { + return model + } + } + return nil +} + +func assertGPT55ModelInfo(t *testing.T, source string, model *ModelInfo) { + t.Helper() + + if model.ID != "gpt-5.5" { + t.Fatalf("%s id mismatch: got %q", source, model.ID) + } + if model.Object != "model" { + t.Fatalf("%s object mismatch: got %q", source, model.Object) + } + if model.Created != 1776902400 { + t.Fatalf("%s created timestamp mismatch: got %d", source, model.Created) + } + if model.OwnedBy != "openai" { + t.Fatalf("%s owned_by mismatch: got %q", source, model.OwnedBy) + } + if model.Type != "openai" { + t.Fatalf("%s type mismatch: got %q", source, model.Type) + } + if model.DisplayName != "GPT 5.5" { + t.Fatalf("%s display name mismatch: got %q", source, model.DisplayName) + } + if model.Version != "gpt-5.5" { + t.Fatalf("%s version mismatch: got %q", source, model.Version) + } + if model.Description != "Frontier model for complex coding, research, and real-world work." { + t.Fatalf("%s description mismatch: got %q", source, model.Description) + } + if model.ContextLength != 272000 { + t.Fatalf("%s context length mismatch: got %d", source, model.ContextLength) + } + if model.MaxCompletionTokens != 128000 { + t.Fatalf("%s max completion tokens mismatch: got %d", source, model.MaxCompletionTokens) + } + if len(model.SupportedParameters) != 1 || model.SupportedParameters[0] != "tools" { + t.Fatalf("%s supported parameters mismatch: got %v", source, model.SupportedParameters) + } + if model.Thinking == nil { + t.Fatalf("%s missing thinking support", source) + } + + want := []string{"low", "medium", "high", "xhigh"} + if len(model.Thinking.Levels) != len(want) { + t.Fatalf("%s thinking level count mismatch: got %d, want %d", source, len(model.Thinking.Levels), len(want)) + } + for i, level := range want { + if model.Thinking.Levels[i] != level { + t.Fatalf("%s thinking level %d mismatch: got %q, want %q", source, i, model.Thinking.Levels[i], level) + } + } +} diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json index f98579373f..bf1d1bb1f3 100644 --- a/internal/registry/models/models.json +++ b/internal/registry/models/models.json @@ -1301,8 +1301,8 @@ "type": "openai", "display_name": "GPT 5.5", "version": "gpt-5.5", - "description": "Stable version of GPT 5.5", - "context_length": 1050000, + "description": "Frontier model for complex coding, research, and real-world work.", + "context_length": 272000, "max_completion_tokens": 128000, "supported_parameters": [ "tools" @@ -1419,8 +1419,8 @@ "type": "openai", "display_name": "GPT 5.5", "version": "gpt-5.5", - "description": "Stable version of GPT 5.5", - "context_length": 1050000, + "description": "Frontier model for complex coding, research, and real-world work.", + "context_length": 272000, "max_completion_tokens": 128000, "supported_parameters": [ "tools" @@ -1560,8 +1560,8 @@ "type": "openai", "display_name": "GPT 5.5", "version": "gpt-5.5", - "description": "Stable version of GPT 5.5", - "context_length": 1050000, + "description": "Frontier model for complex coding, research, and real-world work.", + "context_length": 272000, "max_completion_tokens": 128000, "supported_parameters": [ "tools" @@ -1701,8 +1701,8 @@ "type": "openai", "display_name": "GPT 5.5", "version": "gpt-5.5", - "description": "Stable version of GPT 5.5", - "context_length": 1050000, + "description": "Frontier model for complex coding, research, and real-world work.", + "context_length": 272000, "max_completion_tokens": 128000, "supported_parameters": [ "tools" From 7b89583cf86d04bdec931cf944343c51cb4b0e39 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 24 Apr 2026 05:07:03 +0800 Subject: [PATCH 29/30] chore(models): remove GPT-5.5 model entry from registry JSON --- internal/registry/models/models.json | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json index bf1d1bb1f3..a1abb5a381 100644 --- a/internal/registry/models/models.json +++ b/internal/registry/models/models.json @@ -1292,29 +1292,6 @@ "xhigh" ] } - }, - { - "id": "gpt-5.5", - "object": "model", - "created": 1776902400, - "owned_by": "openai", - "type": "openai", - "display_name": "GPT 5.5", - "version": "gpt-5.5", - "description": "Frontier model for complex coding, research, and real-world work.", - "context_length": 272000, - "max_completion_tokens": 128000, - "supported_parameters": [ - "tools" - ], - "thinking": { - "levels": [ - "low", - "medium", - "high", - "xhigh" - ] - } } ], "codex-team": [ From f1ba6151a99240902bcda12102c921b0ead01d2d Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 24 Apr 2026 07:21:03 +0800 Subject: [PATCH 30/30] feat(codex): pass base model to enable conditional image_generation tool injection - Modified `ensureImageGenerationTool` to accept `baseModel` for conditional logic. - Ensured `gpt-5.3-codex-spark` models bypass image_generation tool injection. - Updated relevant tests and executor logic to reflect changes. --- internal/runtime/executor/codex_executor.go | 12 ++++++---- .../executor/codex_executor_imagegen_test.go | 22 ++++++++++++++----- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 543e2c2779..38667231aa 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -180,7 +180,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re body, _ = sjson.DeleteBytes(body, "safety_identifier") body, _ = sjson.DeleteBytes(body, "stream_options") body = normalizeCodexInstructions(body) - body = ensureImageGenerationTool(body) + body = ensureImageGenerationTool(body, baseModel) url := strings.TrimSuffix(baseURL, "/") + "/responses" httpReq, err := e.cacheHelper(ctx, from, url, req, body) @@ -327,7 +327,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A body, _ = sjson.SetBytes(body, "model", baseModel) body, _ = sjson.DeleteBytes(body, "stream") body = normalizeCodexInstructions(body) - body = ensureImageGenerationTool(body) + body = ensureImageGenerationTool(body, baseModel) url := strings.TrimSuffix(baseURL, "/") + "/responses/compact" httpReq, err := e.cacheHelper(ctx, from, url, req, body) @@ -422,7 +422,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au body, _ = sjson.DeleteBytes(body, "stream_options") body, _ = sjson.SetBytes(body, "model", baseModel) body = normalizeCodexInstructions(body) - body = ensureImageGenerationTool(body) + body = ensureImageGenerationTool(body, baseModel) url := strings.TrimSuffix(baseURL, "/") + "/responses" httpReq, err := e.cacheHelper(ctx, from, url, req, body) @@ -827,7 +827,11 @@ func normalizeCodexInstructions(body []byte) []byte { var imageGenToolJSON = []byte(`{"type":"image_generation","output_format":"png"}`) var imageGenToolArrayJSON = []byte(`[{"type":"image_generation","output_format":"png"}]`) -func ensureImageGenerationTool(body []byte) []byte { +func ensureImageGenerationTool(body []byte, baseModel string) []byte { + if strings.HasSuffix(baseModel, "spark") { + return body + } + tools := gjson.GetBytes(body, "tools") if !tools.Exists() || !tools.IsArray() { body, _ = sjson.SetRawBytes(body, "tools", imageGenToolArrayJSON) diff --git a/internal/runtime/executor/codex_executor_imagegen_test.go b/internal/runtime/executor/codex_executor_imagegen_test.go index 43f42adee8..5e67c598a4 100644 --- a/internal/runtime/executor/codex_executor_imagegen_test.go +++ b/internal/runtime/executor/codex_executor_imagegen_test.go @@ -8,7 +8,7 @@ import ( func TestEnsureImageGenerationTool_NoTools(t *testing.T) { body := []byte(`{"model":"gpt-5.4","input":"draw a cat"}`) - result := ensureImageGenerationTool(body) + result := ensureImageGenerationTool(body, "gpt-5.4") tools := gjson.GetBytes(result, "tools") if !tools.IsArray() { @@ -28,7 +28,7 @@ func TestEnsureImageGenerationTool_NoTools(t *testing.T) { func TestEnsureImageGenerationTool_ExistingToolsWithoutImageGen(t *testing.T) { body := []byte(`{"model":"gpt-5.4","tools":[{"type":"function","name":"get_weather","parameters":{}}]}`) - result := ensureImageGenerationTool(body) + result := ensureImageGenerationTool(body, "gpt-5.4") tools := gjson.GetBytes(result, "tools") arr := tools.Array() @@ -45,7 +45,7 @@ func TestEnsureImageGenerationTool_ExistingToolsWithoutImageGen(t *testing.T) { func TestEnsureImageGenerationTool_AlreadyPresent(t *testing.T) { body := []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation","output_format":"webp"},{"type":"function","name":"f1"}]}`) - result := ensureImageGenerationTool(body) + result := ensureImageGenerationTool(body, "gpt-5.4") tools := gjson.GetBytes(result, "tools") arr := tools.Array() @@ -59,7 +59,7 @@ func TestEnsureImageGenerationTool_AlreadyPresent(t *testing.T) { func TestEnsureImageGenerationTool_EmptyToolsArray(t *testing.T) { body := []byte(`{"model":"gpt-5.4","tools":[]}`) - result := ensureImageGenerationTool(body) + result := ensureImageGenerationTool(body, "gpt-5.4") tools := gjson.GetBytes(result, "tools") arr := tools.Array() @@ -73,7 +73,7 @@ func TestEnsureImageGenerationTool_EmptyToolsArray(t *testing.T) { func TestEnsureImageGenerationTool_WebSearchAndImageGen(t *testing.T) { body := []byte(`{"model":"gpt-5.4","tools":[{"type":"web_search"}]}`) - result := ensureImageGenerationTool(body) + result := ensureImageGenerationTool(body, "gpt-5.4") tools := gjson.GetBytes(result, "tools") arr := tools.Array() @@ -87,3 +87,15 @@ func TestEnsureImageGenerationTool_WebSearchAndImageGen(t *testing.T) { t.Fatalf("expected second tool type=image_generation, got %s", arr[1].Get("type").String()) } } + +func TestEnsureImageGenerationTool_GPT53CodexSparkDoesNotInjectTool(t *testing.T) { + body := []byte(`{"model":"gpt-5.3-codex-spark","input":"draw a cat"}`) + result := ensureImageGenerationTool(body, "gpt-5.3-codex-spark") + + if string(result) != string(body) { + t.Fatalf("expected body to be unchanged, got %s", string(result)) + } + if gjson.GetBytes(result, "tools").Exists() { + t.Fatalf("expected no tools for gpt-5.3-codex-spark, got %s", gjson.GetBytes(result, "tools").Raw) + } +}