History

Merge remote-tracking branch 'upstream/dev' into dev

# Conflicts: # internal/runtime/executor/antigravity_executor.go

maplelove committed 8 months ago

Commit 4040b1e2 parents 8f97a5f 0da34d350 files · +2536 −158Browse files

Unified · Split

50 files
  1. cmd/server/main.go +5 −0
  2. internal/api/handlers/management/auth_files.go +24 −0
  3. internal/api/handlers/management/handler.go +6 −0
  4. internal/api/server.go +11 −0
  5. internal/auth/claude/token.go +17 −1
  6. internal/auth/codex/openai_auth.go +23 −1
  7. internal/auth/codex/openai_auth_test.go +44 −0
  8. internal/auth/codex/token.go +17 −1
  9. internal/auth/gemini/gemini_token.go +18 −1
  10. internal/auth/iflow/iflow_token.go +16 −1
  11. internal/auth/kimi/token.go +16 −1
  12. internal/auth/qwen/qwen_token.go +17 −1
  13. internal/cmd/openai_device_login.go +60 −0
  14. internal/misc/credentials.go +35 −0
  15. internal/registry/model_definitions_static_data.go +1 −11
  16. internal/runtime/executor/antigravity_executor.go +95 −11
  17. internal/runtime/executor/antigravity_executor_models_cache_test.go +90 −0
  18. internal/runtime/executor/codex_executor.go +32 −3
  19. internal/runtime/executor/codex_executor_retry_test.go +65 −0
  20. internal/runtime/executor/qwen_executor.go +176 −9
  21. internal/thinking/provider/openai/apply.go +3 −46
  22. internal/translator/antigravity/claude/antigravity_claude_request.go +56 −5
  23. internal/translator/antigravity/claude/antigravity_claude_request_test.go +425 −2
  24. internal/translator/antigravity/gemini/antigravity_gemini_request_test.go +78 −0
  25. internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go +3 −3
  26. internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go +2 −2
  27. internal/translator/claude/openai/chat-completions/claude_openai_request.go +15 −0
  28. internal/translator/claude/openai/responses/claude_openai-responses_request.go +26 −1
  29. internal/translator/codex/openai/chat-completions/codex_openai_request.go +13 −1
  30. internal/translator/codex/openai/responses/codex_openai-responses_request.go +19 −0
  31. internal/translator/codex/openai/responses/codex_openai-responses_request_test.go +38 −0
  32. internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go +1 −1
  33. internal/translator/gemini/openai/chat-completions/gemini_openai_response.go +3 −3
  34. internal/translator/gemini/openai/responses/gemini_openai-responses_response.go +4 −4
  35. sdk/api/handlers/handlers.go +35 −0
  36. sdk/api/handlers/handlers_stream_bootstrap_test.go +83 −0
  37. sdk/api/handlers/openai/openai_responses_handlers.go +2 −2
  38. sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go +43 −0
  39. sdk/api/handlers/openai_responses_stream_error.go +119 −0
  40. sdk/api/handlers/openai_responses_stream_error_test.go +48 −0
  41. sdk/auth/codex.go +5 −37
  42. sdk/auth/codex_device.go +291 −0
  43. sdk/auth/filestore.go +8 −0
  44. sdk/cliproxy/auth/conductor.go +20 −5
  45. sdk/cliproxy/auth/selector.go +75 −5
  46. sdk/cliproxy/auth/selector_test.go +125 −0
  47. sdk/cliproxy/auth/types.go +30 −0
  48. sdk/cliproxy/builder.go +10 −0
  49. sdk/cliproxy/service.go +53 −0
  50. sdk/cliproxy/service_antigravity_backfill_test.go +135 −0
cmd/server/main.go+5−0
@@ -58,6 +58,7 @@ func main() { · 57 unmodified lines
5858 // Command-line flags to control the application's behavior.
5959 var login bool
6060 var codexLogin bool
61+ var codexDeviceLogin bool
6162 var claudeLogin bool
6263 var qwenLogin bool
6364 var iflowLogin bool
@@ -76,6 +77,7 @@ func main() { · 12 unmodified lines
7677 // Define command-line flags for different operation modes.
7778 flag.BoolVar(&login, "login", false, "Login Google Account")
7879 flag.BoolVar(&codexLogin, "codex-login", false, "Login to Codex using OAuth")
80+ flag.BoolVar(&codexDeviceLogin, "codex-device-login", false, "Login to Codex using device code flow")
7981 flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth")
8082 flag.BoolVar(&qwenLogin, "qwen-login", false, "Login to Qwen using OAuth")
8183 flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth")
@@ -467,6 +469,9 @@ func main() { · 385 unmodified lines
467469 } else if codexLogin {
468470 // Handle Codex login
469471 cmd.DoCodexLogin(cfg, options)
472+ } else if codexDeviceLogin {
473+ // Handle Codex device-code login
474+ cmd.DoCodexDeviceLogin(cfg, options)
470475 } else if claudeLogin {
471476 // Handle Claude login
472477 cmd.DoClaudeLogin(cfg, options)
internal/api/handlers/management/auth_files.go+24−0
@@ -408,6 +408,9 @@ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H { · 407 unmodified lines
408408 if !auth.LastRefreshedAt.IsZero() {
409409 entry["last_refresh"] = auth.LastRefreshedAt
410410 }
411+ if !auth.NextRetryAfter.IsZero() {
412+ entry["next_retry_after"] = auth.NextRetryAfter
413+ }
411414 if path != "" {
412415 entry["path"] = path
413416 entry["source"] = "file"
@@ -947,11 +950,17 @@ func (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (s · 533 unmodified lines
947950 if store == nil {
948951 return "", fmt.Errorf("token store unavailable")
949952 }
953+ if h.postAuthHook != nil {
954+ if err := h.postAuthHook(ctx, record); err != nil {
955+ return "", fmt.Errorf("post-auth hook failed: %w", err)
956+ }
957+ }
950958 return store.Save(ctx, record)
951959 }
952960
953961 func (h *Handler) RequestAnthropicToken(c *gin.Context) {
954962 ctx := context.Background()
963+ ctx = PopulateAuthContext(ctx, c)
955964
956965 fmt.Println("Initializing Claude authentication...")
957966
@@ -1096,6 +1105,7 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) { · 138 unmodified lines
10961105
10971106 func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
10981107 ctx := context.Background()
1108+ ctx = PopulateAuthContext(ctx, c)
10991109 proxyHTTPClient := util.SetProxy(&h.cfg.SDKConfig, &http.Client{})
11001110 ctx = context.WithValue(ctx, oauth2.HTTPClient, proxyHTTPClient)
11011111
@@ -1354,6 +1364,7 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) { · 252 unmodified lines
13541364
13551365 func (h *Handler) RequestCodexToken(c *gin.Context) {
13561366 ctx := context.Background()
1367+ ctx = PopulateAuthContext(ctx, c)
13571368
13581369 fmt.Println("Initializing Codex authentication...")
13591370
@@ -1499,6 +1510,7 @@ func (h *Handler) RequestCodexToken(c *gin.Context) { · 139 unmodified lines
14991510
15001511 func (h *Handler) RequestAntigravityToken(c *gin.Context) {
15011512 ctx := context.Background()
1513+ ctx = PopulateAuthContext(ctx, c)
15021514
15031515 fmt.Println("Initializing Antigravity authentication...")
15041516
@@ -1663,6 +1675,7 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) { · 158 unmodified lines
16631675
16641676 func (h *Handler) RequestQwenToken(c *gin.Context) {
16651677 ctx := context.Background()
1678+ ctx = PopulateAuthContext(ctx, c)
16661679
16671680 fmt.Println("Initializing Qwen authentication...")
16681681
@@ -1718,6 +1731,7 @@ func (h *Handler) RequestQwenToken(c *gin.Context) { · 49 unmodified lines
17181731
17191732 func (h *Handler) RequestKimiToken(c *gin.Context) {
17201733 ctx := context.Background()
1734+ ctx = PopulateAuthContext(ctx, c)
17211735
17221736 fmt.Println("Initializing Kimi authentication...")
17231737
@@ -1794,6 +1808,7 @@ func (h *Handler) RequestKimiToken(c *gin.Context) { · 70 unmodified lines
17941808
17951809 func (h *Handler) RequestIFlowToken(c *gin.Context) {
17961810 ctx := context.Background()
1811+ ctx = PopulateAuthContext(ctx, c)
17971812
17981813 fmt.Println("Initializing iFlow authentication...")
17991814
@@ -2412,3 +2427,12 @@ func (h *Handler) GetAuthStatus(c *gin.Context) { · 612 unmodified lines
24122427 }
24132428 c.JSON(http.StatusOK, gin.H{"status": "wait"})
24142429 }
2430+
2431+// PopulateAuthContext extracts request info and adds it to the context
2432+func PopulateAuthContext(ctx context.Context, c *gin.Context) context.Context {
2433+ info := &coreauth.RequestInfo{
2434+ Query: c.Request.URL.Query(),
2435+ Headers: c.Request.Header,
2436+ }
2437+ return coreauth.WithRequestInfo(ctx, info)
2438+}
internal/api/handlers/management/handler.go+6−0
@@ -47,6 +47,7 @@ type Handler struct { · 46 unmodified lines
4747 allowRemoteOverride bool
4848 envSecret string
4949 logDir string
50+ postAuthHook coreauth.PostAuthHook
5051 }
5152
5253 // NewHandler creates a new management handler instance.
@@ -128,6 +129,11 @@ func (h *Handler) SetLogDirectory(dir string) { · 75 unmodified lines
128129 h.logDir = dir
129130 }
130131
132+// SetPostAuthHook registers a hook to be called after auth record creation but before persistence.
133+func (h *Handler) SetPostAuthHook(hook coreauth.PostAuthHook) {
134+ h.postAuthHook = hook
135+}
136+
131137 // Middleware enforces access control for management endpoints.
132138 // All requests (local and remote) require a valid management key.
133139 // Additionally, remote access requires allow-remote-management=true.
internal/api/server.go+11−0
@@ -51,6 +51,7 @@ type serverOptionConfig struct { · 50 unmodified lines
5151 keepAliveEnabled bool
5252 keepAliveTimeout time.Duration
5353 keepAliveOnTimeout func()
54+ postAuthHook auth.PostAuthHook
5455 }
5556
5657 // ServerOption customises HTTP server construction.
@@ -111,6 +112,13 @@ func WithRequestLoggerFactory(factory func(*config.Config, string) logging.Reque · 54 unmodified lines
111112 }
112113 }
113114
115+// WithPostAuthHook registers a hook to be called after auth record creation.
116+func WithPostAuthHook(hook auth.PostAuthHook) ServerOption {
117+ return func(cfg *serverOptionConfig) {
118+ cfg.postAuthHook = hook
119+ }
120+}
121+
114122 // Server represents the main API server.
115123 // It encapsulates the Gin engine, HTTP server, handlers, and configuration.
116124 type Server struct {
@@ -262,6 +270,9 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk · 145 unmodified lines
262270 }
263271 logDir := logging.ResolveLogDirectory(cfg)
264272 s.mgmt.SetLogDirectory(logDir)
273+ if optionState.postAuthHook != nil {
274+ s.mgmt.SetPostAuthHook(optionState.postAuthHook)
275+ }
265276 s.localPassword = optionState.localPassword
266277
267278 // Setup routes
internal/auth/claude/token.go+17−1
@@ -36,11 +36,21 @@ type ClaudeTokenStorage struct { · 35 unmodified lines
3636
3737 // Expire is the timestamp when the current access token expires.
3838 Expire string `json:"expired"`
39+
40+ // Metadata holds arbitrary key-value pairs injected via hooks.
41+ // It is not exported to JSON directly to allow flattening during serialization.
42+ Metadata map[string]any `json:"-"`
43+}
44+
45+// SetMetadata allows external callers to inject metadata into the storage before saving.
46+func (ts *ClaudeTokenStorage) SetMetadata(meta map[string]any) {
47+ ts.Metadata = meta
3948 }
4049
4150 // SaveTokenToFile serializes the Claude token storage to a JSON file.
4251 // This method creates the necessary directory structure and writes the token
4352 // data in JSON format to the specified file path for persistent storage.
53+// It merges any injected metadata into the top-level JSON object.
4454 //
4555 // Parameters:
4656 // - authFilePath: The full path where the token file should be saved
@@ -65,8 +75,14 @@ func (ts *ClaudeTokenStorage) SaveTokenToFile(authFilePath string) error { · 18 unmodified lines
6575 _ = f.Close()
6676 }()
6777
78+ // Merge metadata using helper
79+ data, errMerge := misc.MergeMetadata(ts, ts.Metadata)
80+ if errMerge != nil {
81+ return fmt.Errorf("failed to merge metadata: %w", errMerge)
82+ }
83+
6884 // Encode and write the token data as JSON
69− if err = json.NewEncoder(f).Encode(ts); err != nil {
85+ if err = json.NewEncoder(f).Encode(data); err != nil {
7086 return fmt.Errorf("failed to write token to file: %w", err)
7187 }
7288 return nil
internal/auth/codex/openai_auth.go+23−1
@@ -71,16 +71,26 @@ func (o *CodexAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string, · 70 unmodified lines
7171 // It performs an HTTP POST request to the OpenAI token endpoint with the provided
7272 // authorization code and PKCE verifier.
7373 func (o *CodexAuth) ExchangeCodeForTokens(ctx context.Context, code string, pkceCodes *PKCECodes) (*CodexAuthBundle, error) {
74+ return o.ExchangeCodeForTokensWithRedirect(ctx, code, RedirectURI, pkceCodes)
75+}
76+
77+// ExchangeCodeForTokensWithRedirect exchanges an authorization code for tokens using
78+// a caller-provided redirect URI. This supports alternate auth flows such as device
79+// login while preserving the existing token parsing and storage behavior.
80+func (o *CodexAuth) ExchangeCodeForTokensWithRedirect(ctx context.Context, code, redirectURI string, pkceCodes *PKCECodes) (*CodexAuthBundle, error) {
7481 if pkceCodes == nil {
7582 return nil, fmt.Errorf("PKCE codes are required for token exchange")
7683 }
84+ if strings.TrimSpace(redirectURI) == "" {
85+ return nil, fmt.Errorf("redirect URI is required for token exchange")
86+ }
7787
7888 // Prepare token exchange request
7989 data := url.Values{
8090 "grant_type": {"authorization_code"},
8191 "client_id": {ClientID},
8292 "code": {code},
83− "redirect_uri": {RedirectURI},
93+ "redirect_uri": {strings.TrimSpace(redirectURI)},
8494 "code_verifier": {pkceCodes.CodeVerifier},
8595 }
8696
@@ -266,6 +276,10 @@ func (o *CodexAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken str · 179 unmodified lines
266276 if err == nil {
267277 return tokenData, nil
268278 }
279+ if isNonRetryableRefreshErr(err) {
280+ log.Warnf("Token refresh attempt %d failed with non-retryable error: %v", attempt+1, err)
281+ return nil, err
282+ }
269283
270284 lastErr = err
271285 log.Warnf("Token refresh attempt %d failed: %v", attempt+1, err)
@@ -274,6 +288,14 @@ func (o *CodexAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken str · 2 unmodified lines
274288 return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr)
275289 }
276290
291+func isNonRetryableRefreshErr(err error) bool {
292+ if err == nil {
293+ return false
294+ }
295+ raw := strings.ToLower(err.Error())
296+ return strings.Contains(raw, "refresh_token_reused")
297+}
298+
277299 // UpdateTokenStorage updates an existing CodexTokenStorage with new token data.
278300 // This is typically called after a successful token refresh to persist the new credentials.
279301 func (o *CodexAuth) UpdateTokenStorage(storage *CodexTokenStorage, tokenData *CodexTokenData) {
internal/auth/codex/openai_auth_test.go+44−0
@@ -0,0 +1,44 @@
1+package codex
2+
3+import (
4+ "context"
5+ "io"
6+ "net/http"
7+ "strings"
8+ "sync/atomic"
9+ "testing"
10+)
11+
12+type roundTripFunc func(*http.Request) (*http.Response, error)
13+
14+func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
15+ return f(req)
16+}
17+
18+func TestRefreshTokensWithRetry_NonRetryableOnlyAttemptsOnce(t *testing.T) {
19+ var calls int32
20+ auth := &CodexAuth{
21+ httpClient: &http.Client{
22+ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
23+ atomic.AddInt32(&calls, 1)
24+ return &http.Response{
25+ StatusCode: http.StatusBadRequest,
26+ Body: io.NopCloser(strings.NewReader(`{"error":"invalid_grant","code":"refresh_token_reused"}`)),
27+ Header: make(http.Header),
28+ Request: req,
29+ }, nil
30+ }),
31+ },
32+ }
33+
34+ _, err := auth.RefreshTokensWithRetry(context.Background(), "dummy_refresh_token", 3)
35+ if err == nil {
36+ t.Fatalf("expected error for non-retryable refresh failure")
37+ }
38+ if !strings.Contains(strings.ToLower(err.Error()), "refresh_token_reused") {
39+ t.Fatalf("expected refresh_token_reused in error, got: %v", err)
40+ }
41+ if got := atomic.LoadInt32(&calls); got != 1 {
42+ t.Fatalf("expected 1 refresh attempt, got %d", got)
43+ }
44+}
internal/auth/codex/token.go+17−1
@@ -32,11 +32,21 @@ type CodexTokenStorage struct { · 31 unmodified lines
3232 Type string `json:"type"`
3333 // Expire is the timestamp when the current access token expires.
3434 Expire string `json:"expired"`
35+
36+ // Metadata holds arbitrary key-value pairs injected via hooks.
37+ // It is not exported to JSON directly to allow flattening during serialization.
38+ Metadata map[string]any `json:"-"`
39+}
40+
41+// SetMetadata allows external callers to inject metadata into the storage before saving.
42+func (ts *CodexTokenStorage) SetMetadata(meta map[string]any) {
43+ ts.Metadata = meta
3544 }
3645
3746 // SaveTokenToFile serializes the Codex token storage to a JSON file.
3847 // This method creates the necessary directory structure and writes the token
3948 // data in JSON format to the specified file path for persistent storage.
49+// It merges any injected metadata into the top-level JSON object.
4050 //
4151 // Parameters:
4252 // - authFilePath: The full path where the token file should be saved
@@ -58,7 +68,13 @@ func (ts *CodexTokenStorage) SaveTokenToFile(authFilePath string) error { · 15 unmodified lines
5868 _ = f.Close()
5969 }()
6070
61− if err = json.NewEncoder(f).Encode(ts); err != nil {
71+ // Merge metadata using helper
72+ data, errMerge := misc.MergeMetadata(ts, ts.Metadata)
73+ if errMerge != nil {
74+ return fmt.Errorf("failed to merge metadata: %w", errMerge)
75+ }
76+
77+ if err = json.NewEncoder(f).Encode(data); err != nil {
6278 return fmt.Errorf("failed to write token to file: %w", err)
6379 }
6480 return nil
internal/auth/gemini/gemini_token.go+18−1
@@ -35,11 +35,21 @@ type GeminiTokenStorage struct { · 34 unmodified lines
3535
3636 // Type indicates the authentication provider type, always "gemini" for this storage.
3737 Type string `json:"type"`
38+
39+ // Metadata holds arbitrary key-value pairs injected via hooks.
40+ // It is not exported to JSON directly to allow flattening during serialization.
41+ Metadata map[string]any `json:"-"`
42+}
43+
44+// SetMetadata allows external callers to inject metadata into the storage before saving.
45+func (ts *GeminiTokenStorage) SetMetadata(meta map[string]any) {
46+ ts.Metadata = meta
3847 }
3948
4049 // SaveTokenToFile serializes the Gemini token storage to a JSON file.
4150 // This method creates the necessary directory structure and writes the token
4251 // data in JSON format to the specified file path for persistent storage.
52+// It merges any injected metadata into the top-level JSON object.
4353 //
4454 // Parameters:
4555 // - authFilePath: The full path where the token file should be saved
@@ -49,6 +59,11 @@ type GeminiTokenStorage struct { · 3 unmodified lines
4959 func (ts *GeminiTokenStorage) SaveTokenToFile(authFilePath string) error {
5060 misc.LogSavingCredentials(authFilePath)
5161 ts.Type = "gemini"
62+ // Merge metadata using helper
63+ data, errMerge := misc.MergeMetadata(ts, ts.Metadata)
64+ if errMerge != nil {
65+ return fmt.Errorf("failed to merge metadata: %w", errMerge)
66+ }
5267 if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
5368 return fmt.Errorf("failed to create directory: %v", err)
5469 }
@@ -63,7 +78,9 @@ func (ts *GeminiTokenStorage) SaveTokenToFile(authFilePath string) error { · 8 unmodified lines
6378 }
6479 }()
6580
66− if err = json.NewEncoder(f).Encode(ts); err != nil {
81+ enc := json.NewEncoder(f)
82+ enc.SetIndent("", " ")
83+ if err := enc.Encode(data); err != nil {
6784 return fmt.Errorf("failed to write token to file: %w", err)
6885 }
6986 return nil
internal/auth/iflow/iflow_token.go+16−1
@@ -21,6 +21,15 @@ type IFlowTokenStorage struct { · 20 unmodified lines
2121 Scope string `json:"scope"`
2222 Cookie string `json:"cookie"`
2323 Type string `json:"type"`
24+
25+ // Metadata holds arbitrary key-value pairs injected via hooks.
26+ // It is not exported to JSON directly to allow flattening during serialization.
27+ Metadata map[string]any `json:"-"`
28+}
29+
30+// SetMetadata allows external callers to inject metadata into the storage before saving.
31+func (ts *IFlowTokenStorage) SetMetadata(meta map[string]any) {
32+ ts.Metadata = meta
2433 }
2534
2635 // SaveTokenToFile serialises the token storage to disk.
@@ -37,7 +46,13 @@ func (ts *IFlowTokenStorage) SaveTokenToFile(authFilePath string) error { · 10 unmodified lines
3746 }
3847 defer func() { _ = f.Close() }()
3948
40− if err = json.NewEncoder(f).Encode(ts); err != nil {
49+ // Merge metadata using helper
50+ data, errMerge := misc.MergeMetadata(ts, ts.Metadata)
51+ if errMerge != nil {
52+ return fmt.Errorf("failed to merge metadata: %w", errMerge)
53+ }
54+
55+ if err = json.NewEncoder(f).Encode(data); err != nil {
4156 return fmt.Errorf("iflow token: encode token failed: %w", err)
4257 }
4358 return nil
internal/auth/kimi/token.go+16−1
@@ -29,6 +29,15 @@ type KimiTokenStorage struct { · 28 unmodified lines
2929 Expired string `json:"expired,omitempty"`
3030 // Type indicates the authentication provider type, always "kimi" for this storage.
3131 Type string `json:"type"`
32+
33+ // Metadata holds arbitrary key-value pairs injected via hooks.
34+ // It is not exported to JSON directly to allow flattening during serialization.
35+ Metadata map[string]any `json:"-"`
36+}
37+
38+// SetMetadata allows external callers to inject metadata into the storage before saving.
39+func (ts *KimiTokenStorage) SetMetadata(meta map[string]any) {
40+ ts.Metadata = meta
3241 }
3342
3443 // KimiTokenData holds the raw OAuth token response from Kimi.
@@ -86,9 +95,15 @@ func (ts *KimiTokenStorage) SaveTokenToFile(authFilePath string) error { · 51 unmodified lines
8695 _ = f.Close()
8796 }()
8897
98+ // Merge metadata using helper
99+ data, errMerge := misc.MergeMetadata(ts, ts.Metadata)
100+ if errMerge != nil {
101+ return fmt.Errorf("failed to merge metadata: %w", errMerge)
102+ }
103+
89104 encoder := json.NewEncoder(f)
90105 encoder.SetIndent("", " ")
91− if err = encoder.Encode(ts); err != nil {
106+ if err = encoder.Encode(data); err != nil {
92107 return fmt.Errorf("failed to write token to file: %w", err)
93108 }
94109 return nil
internal/auth/qwen/qwen_token.go+17−1
@@ -30,11 +30,21 @@ type QwenTokenStorage struct { · 29 unmodified lines
3030 Type string `json:"type"`
3131 // Expire is the timestamp when the current access token expires.
3232 Expire string `json:"expired"`
33+
34+ // Metadata holds arbitrary key-value pairs injected via hooks.
35+ // It is not exported to JSON directly to allow flattening during serialization.
36+ Metadata map[string]any `json:"-"`
37+}
38+
39+// SetMetadata allows external callers to inject metadata into the storage before saving.
40+func (ts *QwenTokenStorage) SetMetadata(meta map[string]any) {
41+ ts.Metadata = meta
3342 }
3443
3544 // SaveTokenToFile serializes the Qwen token storage to a JSON file.
3645 // This method creates the necessary directory structure and writes the token
3746 // data in JSON format to the specified file path for persistent storage.
47+// It merges any injected metadata into the top-level JSON object.
3848 //
3949 // Parameters:
4050 // - authFilePath: The full path where the token file should be saved
@@ -56,7 +66,13 @@ func (ts *QwenTokenStorage) SaveTokenToFile(authFilePath string) error { · 15 unmodified lines
5666 _ = f.Close()
5767 }()
5868
59− if err = json.NewEncoder(f).Encode(ts); err != nil {
69+ // Merge metadata using helper
70+ data, errMerge := misc.MergeMetadata(ts, ts.Metadata)
71+ if errMerge != nil {
72+ return fmt.Errorf("failed to merge metadata: %w", errMerge)
73+ }
74+
75+ if err = json.NewEncoder(f).Encode(data); err != nil {
6076 return fmt.Errorf("failed to write token to file: %w", err)
6177 }
6278 return nil
internal/cmd/openai_device_login.go+60−0
@@ -0,0 +1,60 @@
1+package cmd
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+
9+ "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
10+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
11+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
12+ log "github.com/sirupsen/logrus"
13+)
14+
15+const (
16+ codexLoginModeMetadataKey = "codex_login_mode"
17+ codexLoginModeDevice = "device"
18+)
19+
20+// DoCodexDeviceLogin triggers the Codex device-code flow while keeping the
21+// existing codex-login OAuth callback flow intact.
22+func DoCodexDeviceLogin(cfg *config.Config, options *LoginOptions) {
23+ if options == nil {
24+ options = &LoginOptions{}
25+ }
26+
27+ promptFn := options.Prompt
28+ if promptFn == nil {
29+ promptFn = defaultProjectPrompt()
30+ }
31+
32+ manager := newAuthManager()
33+
34+ authOpts := &sdkAuth.LoginOptions{
35+ NoBrowser: options.NoBrowser,
36+ CallbackPort: options.CallbackPort,
37+ Metadata: map[string]string{
38+ codexLoginModeMetadataKey: codexLoginModeDevice,
39+ },
40+ Prompt: promptFn,
41+ }
42+
43+ _, savedPath, err := manager.Login(context.Background(), "codex", cfg, authOpts)
44+ if err != nil {
45+ if authErr, ok := errors.AsType[*codex.AuthenticationError](err); ok {
46+ log.Error(codex.GetUserFriendlyMessage(authErr))
47+ if authErr.Type == codex.ErrPortInUse.Type {
48+ os.Exit(codex.ErrPortInUse.Code)
49+ }
50+ return
51+ }
52+ fmt.Printf("Codex device authentication failed: %v\n", err)
53+ return
54+ }
55+
56+ if savedPath != "" {
57+ fmt.Printf("Authentication saved to %s\n", savedPath)
58+ }
59+ fmt.Println("Codex device authentication successful!")
60+}
internal/misc/credentials.go+35−0
@@ -1,6 +1,7 @@
11 package misc
22
33 import (
4+ "encoding/json"
45 "fmt"
56 "path/filepath"
67 "strings"
@@ -24,3 +25,37 @@ func LogSavingCredentials(path string) { · 17 unmodified lines
2425 func LogCredentialSeparator() {
2526 log.Debug(credentialSeparator)
2627 }
28+
29+// MergeMetadata serializes the source struct into a map and merges the provided metadata into it.
30+func MergeMetadata(source any, metadata map[string]any) (map[string]any, error) {
31+ var data map[string]any
32+
33+ // Fast path: if source is already a map, just copy it to avoid mutation of original
34+ if srcMap, ok := source.(map[string]any); ok {
35+ data = make(map[string]any, len(srcMap)+len(metadata))
36+ for k, v := range srcMap {
37+ data[k] = v
38+ }
39+ } else {
40+ // Slow path: marshal to JSON and back to map to respect JSON tags
41+ temp, err := json.Marshal(source)
42+ if err != nil {
43+ return nil, fmt.Errorf("failed to marshal source: %w", err)
44+ }
45+ if err := json.Unmarshal(temp, &data); err != nil {
46+ return nil, fmt.Errorf("failed to unmarshal to map: %w", err)
47+ }
48+ }
49+
50+ // Merge extra metadata
51+ if metadata != nil {
52+ if data == nil {
53+ data = make(map[string]any)
54+ }
55+ for k, v := range metadata {
56+ data[k] = v
57+ }
58+ }
59+
60+ return data, nil
61+}
internal/registry/model_definitions_static_data.go+1−11
@@ -904,19 +904,12 @@ func GetIFlowModels() []*ModelInfo { · 903 unmodified lines
904904 Created int64
905905 Thinking *ThinkingSupport
906906 }{
907− {ID: "tstars2.0", DisplayName: "TStars-2.0", Description: "iFlow TStars-2.0 multimodal assistant", Created: 1746489600},
908907 {ID: "qwen3-coder-plus", DisplayName: "Qwen3-Coder-Plus", Description: "Qwen3 Coder Plus code generation", Created: 1753228800},
909908 {ID: "qwen3-max", DisplayName: "Qwen3-Max", Description: "Qwen3 flagship model", Created: 1758672000},
910909 {ID: "qwen3-vl-plus", DisplayName: "Qwen3-VL-Plus", Description: "Qwen3 multimodal vision-language", Created: 1758672000},
911910 {ID: "qwen3-max-preview", DisplayName: "Qwen3-Max-Preview", Description: "Qwen3 Max preview build", Created: 1757030400, Thinking: iFlowThinkingSupport},
912− {ID: "kimi-k2-0905", DisplayName: "Kimi-K2-Instruct-0905", Description: "Moonshot Kimi K2 instruct 0905", Created: 1757030400},
913911 {ID: "glm-4.6", DisplayName: "GLM-4.6", Description: "Zhipu GLM 4.6 general model", Created: 1759190400, Thinking: iFlowThinkingSupport},
914− {ID: "glm-4.7", DisplayName: "GLM-4.7", Description: "Zhipu GLM 4.7 general model", Created: 1766448000, Thinking: iFlowThinkingSupport},
915− {ID: "glm-5", DisplayName: "GLM-5", Description: "Zhipu GLM 5 general model", Created: 1770768000, Thinking: iFlowThinkingSupport},
916912 {ID: "kimi-k2", DisplayName: "Kimi-K2", Description: "Moonshot Kimi K2 general model", Created: 1752192000},
917− {ID: "kimi-k2-thinking", DisplayName: "Kimi-K2-Thinking", Description: "Moonshot Kimi K2 thinking model", Created: 1762387200},
918− {ID: "deepseek-v3.2-chat", DisplayName: "DeepSeek-V3.2", Description: "DeepSeek V3.2 Chat", Created: 1764576000},
919− {ID: "deepseek-v3.2-reasoner", DisplayName: "DeepSeek-V3.2", Description: "DeepSeek V3.2 Reasoner", Created: 1764576000},
920913 {ID: "deepseek-v3.2", DisplayName: "DeepSeek-V3.2-Exp", Description: "DeepSeek V3.2 experimental", Created: 1759104000, Thinking: iFlowThinkingSupport},
921914 {ID: "deepseek-v3.1", DisplayName: "DeepSeek-V3.1-Terminus", Description: "DeepSeek V3.1 Terminus", Created: 1756339200, Thinking: iFlowThinkingSupport},
922915 {ID: "deepseek-r1", DisplayName: "DeepSeek-R1", Description: "DeepSeek reasoning model R1", Created: 1737331200},
@@ -925,11 +918,7 @@ func GetIFlowModels() []*ModelInfo { · 2 unmodified lines
925918 {ID: "qwen3-235b-a22b-thinking-2507", DisplayName: "Qwen3-235B-A22B-Thinking", Description: "Qwen3 235B A22B Thinking (2507)", Created: 1753401600},
926919 {ID: "qwen3-235b-a22b-instruct", DisplayName: "Qwen3-235B-A22B-Instruct", Description: "Qwen3 235B A22B Instruct", Created: 1753401600},
927920 {ID: "qwen3-235b", DisplayName: "Qwen3-235B-A22B", Description: "Qwen3 235B A22B", Created: 1753401600},
928− {ID: "minimax-m2", DisplayName: "MiniMax-M2", Description: "MiniMax M2", Created: 1758672000, Thinking: iFlowThinkingSupport},
929− {ID: "minimax-m2.1", DisplayName: "MiniMax-M2.1", Description: "MiniMax M2.1", Created: 1766448000, Thinking: iFlowThinkingSupport},
930− {ID: "minimax-m2.5", DisplayName: "MiniMax-M2.5", Description: "MiniMax M2.5", Created: 1770825600, Thinking: iFlowThinkingSupport},
931921 {ID: "iflow-rome-30ba3b", DisplayName: "iFlow-ROME", Description: "iFlow Rome 30BA3B model", Created: 1736899200},
932− {ID: "kimi-k2.5", DisplayName: "Kimi-K2.5", Description: "Moonshot Kimi K2.5", Created: 1769443200, Thinking: iFlowThinkingSupport},
933922 }
934923 models := make([]*ModelInfo, 0, len(entries))
935924 for _, entry := range entries {
@@ -963,6 +952,7 @@ func GetAntigravityModelConfig() map[string]*AntigravityModelConfig { · 27 unmodified lines
963952 "gemini-2.5-flash-lite": {Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}},
964953 "gemini-3-pro-high": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}},
965954 "gemini-3-pro-image": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}},
955+ "gemini-3.1-pro-high": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}},
966956 "gemini-3-flash": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}}},
967957 "claude-sonnet-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000},
968958 "claude-opus-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000},
internal/runtime/executor/antigravity_executor.go+95−11
@@ -55,8 +55,78 @@ const ( · 54 unmodified lines
5555 var (
5656 randSource = rand.New(rand.NewSource(time.Now().UnixNano()))
5757 randSourceMutex sync.Mutex
58+ // antigravityPrimaryModelsCache keeps the latest non-empty model list fetched
59+ // from any antigravity auth. Empty fetches never overwrite this cache.
60+ antigravityPrimaryModelsCache struct {
61+ mu sync.RWMutex
62+ models []*registry.ModelInfo
63+ }
5864 )
5965
66+func cloneAntigravityModels(models []*registry.ModelInfo) []*registry.ModelInfo {
67+ if len(models) == 0 {
68+ return nil
69+ }
70+ out := make([]*registry.ModelInfo, 0, len(models))
71+ for _, model := range models {
72+ if model == nil || strings.TrimSpace(model.ID) == "" {
73+ continue
74+ }
75+ out = append(out, cloneAntigravityModelInfo(model))
76+ }
77+ if len(out) == 0 {
78+ return nil
79+ }
80+ return out
81+}
82+
83+func cloneAntigravityModelInfo(model *registry.ModelInfo) *registry.ModelInfo {
84+ if model == nil {
85+ return nil
86+ }
87+ clone := *model
88+ if len(model.SupportedGenerationMethods) > 0 {
89+ clone.SupportedGenerationMethods = append([]string(nil), model.SupportedGenerationMethods...)
90+ }
91+ if len(model.SupportedParameters) > 0 {
92+ clone.SupportedParameters = append([]string(nil), model.SupportedParameters...)
93+ }
94+ if model.Thinking != nil {
95+ thinkingClone := *model.Thinking
96+ if len(model.Thinking.Levels) > 0 {
97+ thinkingClone.Levels = append([]string(nil), model.Thinking.Levels...)
98+ }
99+ clone.Thinking = &thinkingClone
100+ }
101+ return &clone
102+}
103+
104+func storeAntigravityPrimaryModels(models []*registry.ModelInfo) bool {
105+ cloned := cloneAntigravityModels(models)
106+ if len(cloned) == 0 {
107+ return false
108+ }
109+ antigravityPrimaryModelsCache.mu.Lock()
110+ antigravityPrimaryModelsCache.models = cloned
111+ antigravityPrimaryModelsCache.mu.Unlock()
112+ return true
113+}
114+
115+func loadAntigravityPrimaryModels() []*registry.ModelInfo {
116+ antigravityPrimaryModelsCache.mu.RLock()
117+ cloned := cloneAntigravityModels(antigravityPrimaryModelsCache.models)
118+ antigravityPrimaryModelsCache.mu.RUnlock()
119+ return cloned
120+}
121+
122+func fallbackAntigravityPrimaryModels() []*registry.ModelInfo {
123+ models := loadAntigravityPrimaryModels()
124+ if len(models) > 0 {
125+ log.Debugf("antigravity executor: using cached primary model list (%d models)", len(models))
126+ }
127+ return models
128+}
129+
60130 // AntigravityExecutor proxies requests to the antigravity upstream.
61131 type AntigravityExecutor struct {
62132 cfg *config.Config
@@ -1072,7 +1142,7 @@ func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *c · 1009 unmodified lines
10721142 exec := &AntigravityExecutor{cfg: cfg}
10731143 token, updatedAuth, errToken := exec.ensureAccessToken(ctx, auth)
10741144 if errToken != nil || token == "" {
1075− return nil
1145+ return fallbackAntigravityPrimaryModels()
10761146 }
10771147 if updatedAuth != nil {
10781148 auth = updatedAuth
@@ -1096,7 +1166,7 @@ func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *c · 17 unmodified lines
10961166
10971167 httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, modelsURL, bytes.NewReader(payload))
10981168 if errReq != nil {
1099− return nil
1169+ return fallbackAntigravityPrimaryModels()
11001170 }
11011171 httpReq.Close = true
11021172 httpReq.Header.Set("Content-Type", "application/json")
@@ -1109,14 +1179,13 @@ func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *c · 6 unmodified lines
11091179 httpResp, errDo := httpClient.Do(httpReq)
11101180 if errDo != nil {
11111181 if errors.Is(errDo, context.Canceled) || errors.Is(errDo, context.DeadlineExceeded) {
1112− return nil
1182+ return fallbackAntigravityPrimaryModels()
11131183 }
11141184 if idx+1 < len(baseURLs) {
11151185 log.Debugf("antigravity executor: models request error on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
11161186 continue
11171187 }
1118− log.Errorf("antigravity executor: models request failed: %v", errDo)
1119− return nil
1188+ return fallbackAntigravityPrimaryModels()
11201189 }
11211190
11221191 bodyBytes, errRead := io.ReadAll(httpResp.Body)
@@ -1128,21 +1197,27 @@ func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *c · 5 unmodified lines
11281197 log.Debugf("antigravity executor: models read error on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
11291198 continue
11301199 }
1131− log.Errorf("antigravity executor: models read body failed: %v", errRead)
1132− return nil
1200+ return fallbackAntigravityPrimaryModels()
11331201 }
11341202 if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
11351203 if httpResp.StatusCode == http.StatusTooManyRequests && idx+1 < len(baseURLs) {
11361204 log.Debugf("antigravity executor: models request rate limited on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
11371205 continue
11381206 }
1139− log.Errorf("antigravity executor: models request error status %d: %s", httpResp.StatusCode, string(bodyBytes))
1140− return nil
1207+ if idx+1 < len(baseURLs) {
1208+ log.Debugf("antigravity executor: models request failed with status %d on base url %s, retrying with fallback base url: %s", httpResp.StatusCode, baseURL, baseURLs[idx+1])
1209+ continue
1210+ }
1211+ return fallbackAntigravityPrimaryModels()
11411212 }
11421213
11431214 result := gjson.GetBytes(bodyBytes, "models")
11441215 if !result.Exists() {
1145− return nil
1216+ if idx+1 < len(baseURLs) {
1217+ log.Debugf("antigravity executor: models field missing on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
1218+ continue
1219+ }
1220+ return fallbackAntigravityPrimaryModels()
11461221 }
11471222
11481223 now := time.Now().Unix()
@@ -1210,9 +1285,18 @@ func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *c · 61 unmodified lines
12101285 }
12111286 models = append(models, modelInfo)
12121287 }
1288+ if len(models) == 0 {
1289+ if idx+1 < len(baseURLs) {
1290+ log.Debugf("antigravity executor: empty models list on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
1291+ continue
1292+ }
1293+ log.Debug("antigravity executor: fetched empty model list; retaining cached primary model list")
1294+ return fallbackAntigravityPrimaryModels()
1295+ }
1296+ storeAntigravityPrimaryModels(models)
12131297 return models
12141298 }
1215− return nil
1299+ return fallbackAntigravityPrimaryModels()
12161300 }
12171301
12181302 func (e *AntigravityExecutor) ensureAccessToken(ctx context.Context, auth *cliproxyauth.Auth) (string, *cliproxyauth.Auth, error) {
internal/runtime/executor/antigravity_executor_models_cache_test.go+90−0
@@ -0,0 +1,90 @@
1+package executor
2+
3+import (
4+ "testing"
5+
6+ "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
7+)
8+
9+func resetAntigravityPrimaryModelsCacheForTest() {
10+ antigravityPrimaryModelsCache.mu.Lock()
11+ antigravityPrimaryModelsCache.models = nil
12+ antigravityPrimaryModelsCache.mu.Unlock()
13+}
14+
15+func TestStoreAntigravityPrimaryModels_EmptyDoesNotOverwrite(t *testing.T) {
16+ resetAntigravityPrimaryModelsCacheForTest()
17+ t.Cleanup(resetAntigravityPrimaryModelsCacheForTest)
18+
19+ seed := []*registry.ModelInfo{
20+ {ID: "claude-sonnet-4-5"},
21+ {ID: "gemini-2.5-pro"},
22+ }
23+ if updated := storeAntigravityPrimaryModels(seed); !updated {
24+ t.Fatal("expected non-empty model list to update primary cache")
25+ }
26+
27+ if updated := storeAntigravityPrimaryModels(nil); updated {
28+ t.Fatal("expected nil model list not to overwrite primary cache")
29+ }
30+ if updated := storeAntigravityPrimaryModels([]*registry.ModelInfo{}); updated {
31+ t.Fatal("expected empty model list not to overwrite primary cache")
32+ }
33+
34+ got := loadAntigravityPrimaryModels()
35+ if len(got) != 2 {
36+ t.Fatalf("expected cached model count 2, got %d", len(got))
37+ }
38+ if got[0].ID != "claude-sonnet-4-5" || got[1].ID != "gemini-2.5-pro" {
39+ t.Fatalf("unexpected cached model ids: %q, %q", got[0].ID, got[1].ID)
40+ }
41+}
42+
43+func TestLoadAntigravityPrimaryModels_ReturnsClone(t *testing.T) {
44+ resetAntigravityPrimaryModelsCacheForTest()
45+ t.Cleanup(resetAntigravityPrimaryModelsCacheForTest)
46+
47+ if updated := storeAntigravityPrimaryModels([]*registry.ModelInfo{{
48+ ID: "gpt-5",
49+ DisplayName: "GPT-5",
50+ SupportedGenerationMethods: []string{"generateContent"},
51+ SupportedParameters: []string{"temperature"},
52+ Thinking: &registry.ThinkingSupport{
53+ Levels: []string{"high"},
54+ },
55+ }}); !updated {
56+ t.Fatal("expected model cache update")
57+ }
58+
59+ got := loadAntigravityPrimaryModels()
60+ if len(got) != 1 {
61+ t.Fatalf("expected one cached model, got %d", len(got))
62+ }
63+ got[0].ID = "mutated-id"
64+ if len(got[0].SupportedGenerationMethods) > 0 {
65+ got[0].SupportedGenerationMethods[0] = "mutated-method"
66+ }
67+ if len(got[0].SupportedParameters) > 0 {
68+ got[0].SupportedParameters[0] = "mutated-parameter"
69+ }
70+ if got[0].Thinking != nil && len(got[0].Thinking.Levels) > 0 {
71+ got[0].Thinking.Levels[0] = "mutated-level"
72+ }
73+
74+ again := loadAntigravityPrimaryModels()
75+ if len(again) != 1 {
76+ t.Fatalf("expected one cached model after mutation, got %d", len(again))
77+ }
78+ if again[0].ID != "gpt-5" {
79+ t.Fatalf("expected cached model id to remain %q, got %q", "gpt-5", again[0].ID)
80+ }
81+ if len(again[0].SupportedGenerationMethods) == 0 || again[0].SupportedGenerationMethods[0] != "generateContent" {
82+ t.Fatalf("expected cached generation methods to be unmutated, got %v", again[0].SupportedGenerationMethods)
83+ }
84+ if len(again[0].SupportedParameters) == 0 || again[0].SupportedParameters[0] != "temperature" {
85+ t.Fatalf("expected cached supported parameters to be unmutated, got %v", again[0].SupportedParameters)
86+ }
87+ if again[0].Thinking == nil || len(again[0].Thinking.Levels) == 0 || again[0].Thinking.Levels[0] != "high" {
88+ t.Fatalf("expected cached model thinking levels to be unmutated, got %v", again[0].Thinking)
89+ }
90+}
internal/runtime/executor/codex_executor.go+32−3
@@ -156,7 +156,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re · 155 unmodified lines
156156 b, _ := io.ReadAll(httpResp.Body)
157157 appendAPIResponseChunk(ctx, e.cfg, b)
158158 logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
159− err = statusErr{code: httpResp.StatusCode, msg: string(b)}
159+ err = newCodexStatusErr(httpResp.StatusCode, b)
160160 return resp, err
161161 }
162162 data, err := io.ReadAll(httpResp.Body)
@@ -260,7 +260,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A · 97 unmodified lines
260260 b, _ := io.ReadAll(httpResp.Body)
261261 appendAPIResponseChunk(ctx, e.cfg, b)
262262 logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
263− err = statusErr{code: httpResp.StatusCode, msg: string(b)}
263+ err = newCodexStatusErr(httpResp.StatusCode, b)
264264 return resp, err
265265 }
266266 data, err := io.ReadAll(httpResp.Body)
@@ -358,7 +358,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au · 91 unmodified lines
358358 }
359359 appendAPIResponseChunk(ctx, e.cfg, data)
360360 logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
361− err = statusErr{code: httpResp.StatusCode, msg: string(data)}
361+ err = newCodexStatusErr(httpResp.StatusCode, data)
362362 return nil, err
363363 }
364364 out := make(chan cliproxyexecutor.StreamChunk)
@@ -673,6 +673,35 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s · 308 unmodified lines
673673 util.ApplyCustomHeadersFromAttrs(r, attrs)
674674 }
675675
676+func newCodexStatusErr(statusCode int, body []byte) statusErr {
677+ err := statusErr{code: statusCode, msg: string(body)}
678+ if retryAfter := parseCodexRetryAfter(statusCode, body, time.Now()); retryAfter != nil {
679+ err.retryAfter = retryAfter
680+ }
681+ return err
682+}
683+
684+func parseCodexRetryAfter(statusCode int, errorBody []byte, now time.Time) *time.Duration {
685+ if statusCode != http.StatusTooManyRequests || len(errorBody) == 0 {
686+ return nil
687+ }
688+ if strings.TrimSpace(gjson.GetBytes(errorBody, "error.type").String()) != "usage_limit_reached" {
689+ return nil
690+ }
691+ if resetsAt := gjson.GetBytes(errorBody, "error.resets_at").Int(); resetsAt > 0 {
692+ resetAtTime := time.Unix(resetsAt, 0)
693+ if resetAtTime.After(now) {
694+ retryAfter := resetAtTime.Sub(now)
695+ return &retryAfter
696+ }
697+ }
698+ if resetsInSeconds := gjson.GetBytes(errorBody, "error.resets_in_seconds").Int(); resetsInSeconds > 0 {
699+ retryAfter := time.Duration(resetsInSeconds) * time.Second
700+ return &retryAfter
701+ }
702+ return nil
703+}
704+
676705 func codexCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
677706 if a == nil {
678707 return "", ""
internal/runtime/executor/codex_executor_retry_test.go+65−0
@@ -0,0 +1,65 @@
1+package executor
2+
3+import (
4+ "net/http"
5+ "strconv"
6+ "testing"
7+ "time"
8+)
9+
10+func TestParseCodexRetryAfter(t *testing.T) {
11+ now := time.Unix(1_700_000_000, 0)
12+
13+ t.Run("resets_in_seconds", func(t *testing.T) {
14+ body := []byte(`{"error":{"type":"usage_limit_reached","resets_in_seconds":123}}`)
15+ retryAfter := parseCodexRetryAfter(http.StatusTooManyRequests, body, now)
16+ if retryAfter == nil {
17+ t.Fatalf("expected retryAfter, got nil")
18+ }
19+ if *retryAfter != 123*time.Second {
20+ t.Fatalf("retryAfter = %v, want %v", *retryAfter, 123*time.Second)
21+ }
22+ })
23+
24+ t.Run("prefers resets_at", func(t *testing.T) {
25+ resetAt := now.Add(5 * time.Minute).Unix()
26+ body := []byte(`{"error":{"type":"usage_limit_reached","resets_at":` + itoa(resetAt) + `,"resets_in_seconds":1}}`)
27+ retryAfter := parseCodexRetryAfter(http.StatusTooManyRequests, body, now)
28+ if retryAfter == nil {
29+ t.Fatalf("expected retryAfter, got nil")
30+ }
31+ if *retryAfter != 5*time.Minute {
32+ t.Fatalf("retryAfter = %v, want %v", *retryAfter, 5*time.Minute)
33+ }
34+ })
35+
36+ t.Run("fallback when resets_at is past", func(t *testing.T) {
37+ resetAt := now.Add(-1 * time.Minute).Unix()
38+ body := []byte(`{"error":{"type":"usage_limit_reached","resets_at":` + itoa(resetAt) + `,"resets_in_seconds":77}}`)
39+ retryAfter := parseCodexRetryAfter(http.StatusTooManyRequests, body, now)
40+ if retryAfter == nil {
41+ t.Fatalf("expected retryAfter, got nil")
42+ }
43+ if *retryAfter != 77*time.Second {
44+ t.Fatalf("retryAfter = %v, want %v", *retryAfter, 77*time.Second)
45+ }
46+ })
47+
48+ t.Run("non-429 status code", func(t *testing.T) {
49+ body := []byte(`{"error":{"type":"usage_limit_reached","resets_in_seconds":30}}`)
50+ if got := parseCodexRetryAfter(http.StatusBadRequest, body, now); got != nil {
51+ t.Fatalf("expected nil for non-429, got %v", *got)
52+ }
53+ })
54+
55+ t.Run("non usage_limit_reached error type", func(t *testing.T) {
56+ body := []byte(`{"error":{"type":"server_error","resets_in_seconds":30}}`)
57+ if got := parseCodexRetryAfter(http.StatusTooManyRequests, body, now); got != nil {
58+ t.Fatalf("expected nil for non-usage_limit_reached, got %v", *got)
59+ }
60+ })
61+}
62+
63+func itoa(v int64) string {
64+ return strconv.FormatInt(v, 10)
65+}
internal/runtime/executor/qwen_executor.go+176−9
@@ -8,6 +8,7 @@ import ( · 7 unmodified lines
88 "io"
99 "net/http"
1010 "strings"
11+ "sync"
1112 "time"
1213
1314 qwenauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
@@ -22,9 +23,151 @@ import ( · 8 unmodified lines
2223 )
2324
2425 const (
25− qwenUserAgent = "QwenCode/0.10.3 (darwin; arm64)"
26+ qwenUserAgent = "QwenCode/0.10.3 (darwin; arm64)"
27+ qwenRateLimitPerMin = 60 // 60 requests per minute per credential
28+ qwenRateLimitWindow = time.Minute // sliding window duration
2629 )
2730
31+// qwenBeijingLoc caches the Beijing timezone to avoid repeated LoadLocation syscalls.
32+var qwenBeijingLoc = func() *time.Location {
33+ loc, err := time.LoadLocation("Asia/Shanghai")
34+ if err != nil || loc == nil {
35+ log.Warnf("qwen: failed to load Asia/Shanghai timezone: %v, using fixed UTC+8", err)
36+ return time.FixedZone("CST", 8*3600)
37+ }
38+ return loc
39+}()
40+
41+// qwenQuotaCodes is a package-level set of error codes that indicate quota exhaustion.
42+var qwenQuotaCodes = map[string]struct{}{
43+ "insufficient_quota": {},
44+ "quota_exceeded": {},
45+}
46+
47+// qwenRateLimiter tracks request timestamps per credential for rate limiting.
48+// Qwen has a limit of 60 requests per minute per account.
49+var qwenRateLimiter = struct {
50+ sync.Mutex
51+ requests map[string][]time.Time // authID -> request timestamps
52+}{
53+ requests: make(map[string][]time.Time),
54+}
55+
56+// redactAuthID returns a redacted version of the auth ID for safe logging.
57+// Keeps a small prefix/suffix to allow correlation across events.
58+func redactAuthID(id string) string {
59+ if id == "" {
60+ return ""
61+ }
62+ if len(id) <= 8 {
63+ return id
64+ }
65+ return id[:4] + "..." + id[len(id)-4:]
66+}
67+
68+// checkQwenRateLimit checks if the credential has exceeded the rate limit.
69+// Returns nil if allowed, or a statusErr with retryAfter if rate limited.
70+func checkQwenRateLimit(authID string) error {
71+ if authID == "" {
72+ // Empty authID should not bypass rate limiting in production
73+ // Use debug level to avoid log spam for certain auth flows
74+ log.Debug("qwen rate limit check: empty authID, skipping rate limit")
75+ return nil
76+ }
77+
78+ now := time.Now()
79+ windowStart := now.Add(-qwenRateLimitWindow)
80+
81+ qwenRateLimiter.Lock()
82+ defer qwenRateLimiter.Unlock()
83+
84+ // Get and filter timestamps within the window
85+ timestamps := qwenRateLimiter.requests[authID]
86+ var validTimestamps []time.Time
87+ for _, ts := range timestamps {
88+ if ts.After(windowStart) {
89+ validTimestamps = append(validTimestamps, ts)
90+ }
91+ }
92+
93+ // Always prune expired entries to prevent memory leak
94+ // Delete empty entries, otherwise update with pruned slice
95+ if len(validTimestamps) == 0 {
96+ delete(qwenRateLimiter.requests, authID)
97+ }
98+
99+ // Check if rate limit exceeded
100+ if len(validTimestamps) >= qwenRateLimitPerMin {
101+ // Calculate when the oldest request will expire
102+ oldestInWindow := validTimestamps[0]
103+ retryAfter := oldestInWindow.Add(qwenRateLimitWindow).Sub(now)
104+ if retryAfter < time.Second {
105+ retryAfter = time.Second
106+ }
107+ retryAfterSec := int(retryAfter.Seconds())
108+ return statusErr{
109+ code: http.StatusTooManyRequests,
110+ msg: fmt.Sprintf(`{"error":{"code":"rate_limit_exceeded","message":"Qwen rate limit: %d requests/minute exceeded, retry after %ds","type":"rate_limit_exceeded"}}`, qwenRateLimitPerMin, retryAfterSec),
111+ retryAfter: &retryAfter,
112+ }
113+ }
114+
115+ // Record this request and update the map with pruned timestamps
116+ validTimestamps = append(validTimestamps, now)
117+ qwenRateLimiter.requests[authID] = validTimestamps
118+
119+ return nil
120+}
121+
122+// isQwenQuotaError checks if the error response indicates a quota exceeded error.
123+// Qwen returns HTTP 403 with error.code="insufficient_quota" when daily quota is exhausted.
124+func isQwenQuotaError(body []byte) bool {
125+ code := strings.ToLower(gjson.GetBytes(body, "error.code").String())
126+ errType := strings.ToLower(gjson.GetBytes(body, "error.type").String())
127+
128+ // Primary check: exact match on error.code or error.type (most reliable)
129+ if _, ok := qwenQuotaCodes[code]; ok {
130+ return true
131+ }
132+ if _, ok := qwenQuotaCodes[errType]; ok {
133+ return true
134+ }
135+
136+ // Fallback: check message only if code/type don't match (less reliable)
137+ msg := strings.ToLower(gjson.GetBytes(body, "error.message").String())
138+ if strings.Contains(msg, "insufficient_quota") || strings.Contains(msg, "quota exceeded") ||
139+ strings.Contains(msg, "free allocated quota exceeded") {
140+ return true
141+ }
142+
143+ return false
144+}
145+
146+// wrapQwenError wraps an HTTP error response, detecting quota errors and mapping them to 429.
147+// Returns the appropriate status code and retryAfter duration for statusErr.
148+// Only checks for quota errors when httpCode is 403 or 429 to avoid false positives.
149+func wrapQwenError(ctx context.Context, httpCode int, body []byte) (errCode int, retryAfter *time.Duration) {
150+ errCode = httpCode
151+ // Only check quota errors for expected status codes to avoid false positives
152+ // Qwen returns 403 for quota errors, 429 for rate limits
153+ if (httpCode == http.StatusForbidden || httpCode == http.StatusTooManyRequests) && isQwenQuotaError(body) {
154+ errCode = http.StatusTooManyRequests // Map to 429 to trigger quota logic
155+ cooldown := timeUntilNextDay()
156+ retryAfter = &cooldown
157+ logWithRequestID(ctx).Warnf("qwen quota exceeded (http %d -> %d), cooling down until tomorrow (%v)", httpCode, errCode, cooldown)
158+ }
159+ return errCode, retryAfter
160+}
161+
162+// timeUntilNextDay returns duration until midnight Beijing time (UTC+8).
163+// Qwen's daily quota resets at 00:00 Beijing time.
164+func timeUntilNextDay() time.Duration {
165+ now := time.Now()
166+ nowLocal := now.In(qwenBeijingLoc)
167+ tomorrow := time.Date(nowLocal.Year(), nowLocal.Month(), nowLocal.Day()+1, 0, 0, 0, 0, qwenBeijingLoc)
168+ return tomorrow.Sub(now)
169+}
170+
28171 // QwenExecutor is a stateless executor for Qwen Code using OpenAI-compatible chat completions.
29172 // If access token is unavailable, it falls back to legacy via ClientAdapter.
30173 type QwenExecutor struct {
@@ -67,6 +210,17 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req · 36 unmodified lines
67210 if opts.Alt == "responses/compact" {
68211 return resp, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"}
69212 }
213+
214+ // Check rate limit before proceeding
215+ var authID string
216+ if auth != nil {
217+ authID = auth.ID
218+ }
219+ if err := checkQwenRateLimit(authID); err != nil {
220+ logWithRequestID(ctx).Warnf("qwen rate limit exceeded for credential %s", redactAuthID(authID))
221+ return resp, err
222+ }
223+
70224 baseModel := thinking.ParseSuffix(req.Model).ModelName
71225
72226 token, baseURL := qwenCreds(auth)
@@ -102,9 +256,8 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req · 29 unmodified lines
102256 return resp, err
103257 }
104258 applyQwenHeaders(httpReq, token, false)
105− var authID, authLabel, authType, authValue string
259+ var authLabel, authType, authValue string
106260 if auth != nil {
107− authID = auth.ID
108261 authLabel = auth.Label
109262 authType, authValue = auth.AccountInfo()
110263 }
@@ -135,8 +288,10 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req · 24 unmodified lines
135288 if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
136289 b, _ := io.ReadAll(httpResp.Body)
137290 appendAPIResponseChunk(ctx, e.cfg, b)
138− logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
139− err = statusErr{code: httpResp.StatusCode, msg: string(b)}
291+
292+ errCode, retryAfter := wrapQwenError(ctx, httpResp.StatusCode, b)
293+ logWithRequestID(ctx).Debugf("request error, error status: %d (mapped: %d), error message: %s", httpResp.StatusCode, errCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
294+ err = statusErr{code: errCode, msg: string(b), retryAfter: retryAfter}
140295 return resp, err
141296 }
142297 data, err := io.ReadAll(httpResp.Body)
@@ -158,6 +313,17 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut · 15 unmodified lines
158313 if opts.Alt == "responses/compact" {
159314 return nil, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"}
160315 }
316+
317+ // Check rate limit before proceeding
318+ var authID string
319+ if auth != nil {
320+ authID = auth.ID
321+ }
322+ if err := checkQwenRateLimit(authID); err != nil {
323+ logWithRequestID(ctx).Warnf("qwen rate limit exceeded for credential %s", redactAuthID(authID))
324+ return nil, err
325+ }
326+
161327 baseModel := thinking.ParseSuffix(req.Model).ModelName
162328
163329 token, baseURL := qwenCreds(auth)
@@ -200,9 +366,8 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut · 36 unmodified lines
200366 return nil, err
201367 }
202368 applyQwenHeaders(httpReq, token, true)
203− var authID, authLabel, authType, authValue string
369+ var authLabel, authType, authValue string
204370 if auth != nil {
205− authID = auth.ID
206371 authLabel = auth.Label
207372 authType, authValue = auth.AccountInfo()
208373 }
@@ -228,11 +393,13 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut · 19 unmodified lines
228393 if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
229394 b, _ := io.ReadAll(httpResp.Body)
230395 appendAPIResponseChunk(ctx, e.cfg, b)
231− logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
396+
397+ errCode, retryAfter := wrapQwenError(ctx, httpResp.StatusCode, b)
398+ logWithRequestID(ctx).Debugf("request error, error status: %d (mapped: %d), error message: %s", httpResp.StatusCode, errCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
232399 if errClose := httpResp.Body.Close(); errClose != nil {
233400 log.Errorf("qwen executor: close response body error: %v", errClose)
234401 }
235− err = statusErr{code: httpResp.StatusCode, msg: string(b)}
402+ err = statusErr{code: errCode, msg: string(b), retryAfter: retryAfter}
236403 return nil, err
237404 }
238405 out := make(chan cliproxyexecutor.StreamChunk)
internal/thinking/provider/openai/apply.go+3−46
@@ -10,53 +10,10 @@ import ( · 9 unmodified lines
1010
1111 "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
1212 "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
13− log "github.com/sirupsen/logrus"
1413 "github.com/tidwall/gjson"
1514 "github.com/tidwall/sjson"
1615 )
1716
18−// validReasoningEffortLevels contains the standard values accepted by the
19−// OpenAI reasoning_effort field. Provider-specific extensions (xhigh, minimal,
20−// auto) are NOT in this set and must be clamped before use.
21−var validReasoningEffortLevels = map[string]struct{}{
22− "none": {},
23− "low": {},
24− "medium": {},
25− "high": {},
26−}
27−
28−// clampReasoningEffort maps any thinking level string to a value that is safe
29−// to send as OpenAI reasoning_effort. Non-standard CPA-internal values are
30−// mapped to the nearest standard equivalent.
31−//
32−// Mapping rules:
33−// - none / low / medium / high → returned as-is (already valid)
34−// - xhigh → "high" (nearest lower standard level)
35−// - minimal → "low" (nearest higher standard level)
36−// - auto → "medium" (reasonable default)
37−// - anything else → "medium" (safe default)
38−func clampReasoningEffort(level string) string {
39− if _, ok := validReasoningEffortLevels[level]; ok {
40− return level
41− }
42− var clamped string
43− switch level {
44− case string(thinking.LevelXHigh):
45− clamped = string(thinking.LevelHigh)
46− case string(thinking.LevelMinimal):
47− clamped = string(thinking.LevelLow)
48− case string(thinking.LevelAuto):
49− clamped = string(thinking.LevelMedium)
50− default:
51− clamped = string(thinking.LevelMedium)
52− }
53− log.WithFields(log.Fields{
54− "original": level,
55− "clamped": clamped,
56− }).Debug("openai: reasoning_effort clamped to nearest valid standard value")
57− return clamped
58−}
59−
6017 // Applier implements thinking.ProviderApplier for OpenAI models.
6118 //
6219 // OpenAI-specific behavior:
@@ -101,7 +58,7 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo * · 38 unmodified lines
10158 }
10259
10360 if config.Mode == thinking.ModeLevel {
104− result, _ := sjson.SetBytes(body, "reasoning_effort", clampReasoningEffort(string(config.Level)))
61+ result, _ := sjson.SetBytes(body, "reasoning_effort", string(config.Level))
10562 return result, nil
10663 }
10764
@@ -122,7 +79,7 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo * · 14 unmodified lines
12279 return body, nil
12380 }
12481
125− result, _ := sjson.SetBytes(body, "reasoning_effort", clampReasoningEffort(effort))
82+ result, _ := sjson.SetBytes(body, "reasoning_effort", effort)
12683 return result, nil
12784 }
12885
@@ -157,7 +114,7 @@ func applyCompatibleOpenAI(body []byte, config thinking.ThinkingConfig) ([]byte, · 28 unmodified lines
157114 return body, nil
158115 }
159116
160− result, _ := sjson.SetBytes(body, "reasoning_effort", clampReasoningEffort(effort))
117+ result, _ := sjson.SetBytes(body, "reasoning_effort", effort)
161118 return result, nil
162119 }
163120
internal/translator/antigravity/claude/antigravity_claude_request.go+56−5
@@ -223,14 +223,65 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ · 222 unmodified lines
223223 functionResponseJSON, _ = sjson.Set(functionResponseJSON, "response.result", responseData)
224224 } else if functionResponseResult.IsArray() {
225225 frResults := functionResponseResult.Array()
226− if len(frResults) == 1 {
227− functionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, "response.result", frResults[0].Raw)
226+ nonImageCount := 0
227+ lastNonImageRaw := ""
228+ filteredJSON := "[]"
229+ imagePartsJSON := "[]"
230+ for _, fr := range frResults {
231+ if fr.Get("type").String() == "image" && fr.Get("source.type").String() == "base64" {
232+ inlineDataJSON := `{}`
233+ if mimeType := fr.Get("source.media_type").String(); mimeType != "" {
234+ inlineDataJSON, _ = sjson.Set(inlineDataJSON, "mimeType", mimeType)
235+ }
236+ if data := fr.Get("source.data").String(); data != "" {
237+ inlineDataJSON, _ = sjson.Set(inlineDataJSON, "data", data)
238+ }
239+
240+ imagePartJSON := `{}`
241+ imagePartJSON, _ = sjson.SetRaw(imagePartJSON, "inlineData", inlineDataJSON)
242+ imagePartsJSON, _ = sjson.SetRaw(imagePartsJSON, "-1", imagePartJSON)
243+ continue
244+ }
245+
246+ nonImageCount++
247+ lastNonImageRaw = fr.Raw
248+ filteredJSON, _ = sjson.SetRaw(filteredJSON, "-1", fr.Raw)
249+ }
250+
251+ if nonImageCount == 1 {
252+ functionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, "response.result", lastNonImageRaw)
253+ } else if nonImageCount > 1 {
254+ functionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, "response.result", filteredJSON)
228255 } else {
229− functionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, "response.result", functionResponseResult.Raw)
256+ functionResponseJSON, _ = sjson.Set(functionResponseJSON, "response.result", "")
257+ }
258+
259+ // Place image data inside functionResponse.parts as inlineData
260+ // instead of as sibling parts in the outer content, to avoid
261+ // base64 data bloating the text context.
262+ if gjson.Get(imagePartsJSON, "#").Int() > 0 {
263+ functionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, "parts", imagePartsJSON)
230264 }
231265
232266 } else if functionResponseResult.IsObject() {
233− functionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, "response.result", functionResponseResult.Raw)
267+ if functionResponseResult.Get("type").String() == "image" && functionResponseResult.Get("source.type").String() == "base64" {
268+ inlineDataJSON := `{}`
269+ if mimeType := functionResponseResult.Get("source.media_type").String(); mimeType != "" {
270+ inlineDataJSON, _ = sjson.Set(inlineDataJSON, "mimeType", mimeType)
271+ }
272+ if data := functionResponseResult.Get("source.data").String(); data != "" {
273+ inlineDataJSON, _ = sjson.Set(inlineDataJSON, "data", data)
274+ }
275+
276+ imagePartJSON := `{}`
277+ imagePartJSON, _ = sjson.SetRaw(imagePartJSON, "inlineData", inlineDataJSON)
278+ imagePartsJSON := "[]"
279+ imagePartsJSON, _ = sjson.SetRaw(imagePartsJSON, "-1", imagePartJSON)
280+ functionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, "parts", imagePartsJSON)
281+ functionResponseJSON, _ = sjson.Set(functionResponseJSON, "response.result", "")
282+ } else {
283+ functionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, "response.result", functionResponseResult.Raw)
284+ }
234285 } else if functionResponseResult.Raw != "" {
235286 functionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, "response.result", functionResponseResult.Raw)
236287 } else {
@@ -248,7 +299,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ · 11 unmodified lines
248299 if sourceResult.Get("type").String() == "base64" {
249300 inlineDataJSON := `{}`
250301 if mimeType := sourceResult.Get("media_type").String(); mimeType != "" {
251− inlineDataJSON, _ = sjson.Set(inlineDataJSON, "mime_type", mimeType)
302+ inlineDataJSON, _ = sjson.Set(inlineDataJSON, "mimeType", mimeType)
252303 }
253304 if data := sourceResult.Get("data").String(); data != "" {
254305 inlineDataJSON, _ = sjson.Set(inlineDataJSON, "data", data)
internal/translator/antigravity/claude/antigravity_claude_request_test.go+425−2
@@ -413,8 +413,8 @@ func TestConvertClaudeRequestToAntigravity_ImageContent(t *testing.T) { · 412 unmodified lines
413413 if !inlineData.Exists() {
414414 t.Error("inlineData should exist")
415415 }
416− if inlineData.Get("mime_type").String() != "image/png" {
417− t.Error("mime_type mismatch")
416+ if inlineData.Get("mimeType").String() != "image/png" {
417+ t.Error("mimeType mismatch")
418418 }
419419 if !strings.Contains(inlineData.Get("data").String(), "iVBORw0KGgo") {
420420 t.Error("data mismatch")
@@ -740,6 +740,429 @@ func TestConvertClaudeRequestToAntigravity_ToolResultNullContent(t *testing.T) { · 319 unmodified lines
740740 }
741741 }
742742
743+func TestConvertClaudeRequestToAntigravity_ToolResultWithImage(t *testing.T) {
744+ // tool_result with array content containing text + image should place
745+ // image data inside functionResponse.parts as inlineData, not as a
746+ // sibling part in the outer content (to avoid base64 context bloat).
747+ inputJSON := []byte(`{
748+ "model": "claude-3-5-sonnet-20240620",
749+ "messages": [
750+ {
751+ "role": "user",
752+ "content": [
753+ {
754+ "type": "tool_result",
755+ "tool_use_id": "Read-123-456",
756+ "content": [
757+ {
758+ "type": "text",
759+ "text": "File content here"
760+ },
761+ {
762+ "type": "image",
763+ "source": {
764+ "type": "base64",
765+ "media_type": "image/png",
766+ "data": "iVBORw0KGgoAAAANSUhEUg=="
767+ }
768+ }
769+ ]
770+ }
771+ ]
772+ }
773+ ]
774+ }`)
775+
776+ output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
777+ outputStr := string(output)
778+
779+ if !gjson.Valid(outputStr) {
780+ t.Fatalf("Result is not valid JSON:\n%s", outputStr)
781+ }
782+
783+ // Image should be inside functionResponse.parts, not as outer sibling part
784+ funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
785+ if !funcResp.Exists() {
786+ t.Fatal("functionResponse should exist")
787+ }
788+
789+ // Text content should be in response.result
790+ resultText := funcResp.Get("response.result.text").String()
791+ if resultText != "File content here" {
792+ t.Errorf("Expected response.result.text = 'File content here', got '%s'", resultText)
793+ }
794+
795+ // Image should be in functionResponse.parts[0].inlineData
796+ inlineData := funcResp.Get("parts.0.inlineData")
797+ if !inlineData.Exists() {
798+ t.Fatal("functionResponse.parts[0].inlineData should exist")
799+ }
800+ if inlineData.Get("mimeType").String() != "image/png" {
801+ t.Errorf("Expected mimeType 'image/png', got '%s'", inlineData.Get("mimeType").String())
802+ }
803+ if !strings.Contains(inlineData.Get("data").String(), "iVBORw0KGgo") {
804+ t.Error("data mismatch")
805+ }
806+
807+ // Image should NOT be in outer parts (only functionResponse part should exist)
808+ outerParts := gjson.Get(outputStr, "request.contents.0.parts")
809+ if outerParts.IsArray() && len(outerParts.Array()) > 1 {
810+ t.Errorf("Expected only 1 outer part (functionResponse), got %d", len(outerParts.Array()))
811+ }
812+}
813+
814+func TestConvertClaudeRequestToAntigravity_ToolResultWithSingleImage(t *testing.T) {
815+ // tool_result with single image object as content should place
816+ // image data inside functionResponse.parts, not as outer sibling part.
817+ inputJSON := []byte(`{
818+ "model": "claude-3-5-sonnet-20240620",
819+ "messages": [
820+ {
821+ "role": "user",
822+ "content": [
823+ {
824+ "type": "tool_result",
825+ "tool_use_id": "Read-789-012",
826+ "content": {
827+ "type": "image",
828+ "source": {
829+ "type": "base64",
830+ "media_type": "image/jpeg",
831+ "data": "/9j/4AAQSkZJRgABAQ=="
832+ }
833+ }
834+ }
835+ ]
836+ }
837+ ]
838+ }`)
839+
840+ output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
841+ outputStr := string(output)
842+
843+ if !gjson.Valid(outputStr) {
844+ t.Fatalf("Result is not valid JSON:\n%s", outputStr)
845+ }
846+
847+ funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
848+ if !funcResp.Exists() {
849+ t.Fatal("functionResponse should exist")
850+ }
851+
852+ // response.result should be empty (image only)
853+ if funcResp.Get("response.result").String() != "" {
854+ t.Errorf("Expected empty response.result for image-only content, got '%s'", funcResp.Get("response.result").String())
855+ }
856+
857+ // Image should be in functionResponse.parts[0].inlineData
858+ inlineData := funcResp.Get("parts.0.inlineData")
859+ if !inlineData.Exists() {
860+ t.Fatal("functionResponse.parts[0].inlineData should exist")
861+ }
862+ if inlineData.Get("mimeType").String() != "image/jpeg" {
863+ t.Errorf("Expected mimeType 'image/jpeg', got '%s'", inlineData.Get("mimeType").String())
864+ }
865+
866+ // Image should NOT be in outer parts
867+ outerParts := gjson.Get(outputStr, "request.contents.0.parts")
868+ if outerParts.IsArray() && len(outerParts.Array()) > 1 {
869+ t.Errorf("Expected only 1 outer part, got %d", len(outerParts.Array()))
870+ }
871+}
872+
873+func TestConvertClaudeRequestToAntigravity_ToolResultWithMultipleImagesAndTexts(t *testing.T) {
874+ // tool_result with array content: 2 text items + 2 images
875+ // All images go into functionResponse.parts, texts into response.result array
876+ inputJSON := []byte(`{
877+ "model": "claude-3-5-sonnet-20240620",
878+ "messages": [
879+ {
880+ "role": "user",
881+ "content": [
882+ {
883+ "type": "tool_result",
884+ "tool_use_id": "Multi-001",
885+ "content": [
886+ {"type": "text", "text": "First text"},
887+ {
888+ "type": "image",
889+ "source": {"type": "base64", "media_type": "image/png", "data": "AAAA"}
890+ },
891+ {"type": "text", "text": "Second text"},
892+ {
893+ "type": "image",
894+ "source": {"type": "base64", "media_type": "image/jpeg", "data": "BBBB"}
895+ }
896+ ]
897+ }
898+ ]
899+ }
900+ ]
901+ }`)
902+
903+ output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
904+ outputStr := string(output)
905+
906+ if !gjson.Valid(outputStr) {
907+ t.Fatalf("Result is not valid JSON:\n%s", outputStr)
908+ }
909+
910+ funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
911+ if !funcResp.Exists() {
912+ t.Fatal("functionResponse should exist")
913+ }
914+
915+ // Multiple text items => response.result is an array
916+ resultArr := funcResp.Get("response.result")
917+ if !resultArr.IsArray() {
918+ t.Fatalf("Expected response.result to be an array, got: %s", resultArr.Raw)
919+ }
920+ results := resultArr.Array()
921+ if len(results) != 2 {
922+ t.Fatalf("Expected 2 result items, got %d", len(results))
923+ }
924+
925+ // Both images should be in functionResponse.parts
926+ imgParts := funcResp.Get("parts").Array()
927+ if len(imgParts) != 2 {
928+ t.Fatalf("Expected 2 image parts in functionResponse.parts, got %d", len(imgParts))
929+ }
930+ if imgParts[0].Get("inlineData.mimeType").String() != "image/png" {
931+ t.Errorf("Expected first image mimeType 'image/png', got '%s'", imgParts[0].Get("inlineData.mimeType").String())
932+ }
933+ if imgParts[0].Get("inlineData.data").String() != "AAAA" {
934+ t.Errorf("Expected first image data 'AAAA', got '%s'", imgParts[0].Get("inlineData.data").String())
935+ }
936+ if imgParts[1].Get("inlineData.mimeType").String() != "image/jpeg" {
937+ t.Errorf("Expected second image mimeType 'image/jpeg', got '%s'", imgParts[1].Get("inlineData.mimeType").String())
938+ }
939+ if imgParts[1].Get("inlineData.data").String() != "BBBB" {
940+ t.Errorf("Expected second image data 'BBBB', got '%s'", imgParts[1].Get("inlineData.data").String())
941+ }
942+
943+ // Only 1 outer part (the functionResponse itself)
944+ outerParts := gjson.Get(outputStr, "request.contents.0.parts").Array()
945+ if len(outerParts) != 1 {
946+ t.Errorf("Expected 1 outer part, got %d", len(outerParts))
947+ }
948+}
949+
950+func TestConvertClaudeRequestToAntigravity_ToolResultWithOnlyMultipleImages(t *testing.T) {
951+ // tool_result with only images (no text) — response.result should be empty string
952+ inputJSON := []byte(`{
953+ "model": "claude-3-5-sonnet-20240620",
954+ "messages": [
955+ {
956+ "role": "user",
957+ "content": [
958+ {
959+ "type": "tool_result",
960+ "tool_use_id": "ImgOnly-001",
961+ "content": [
962+ {
963+ "type": "image",
964+ "source": {"type": "base64", "media_type": "image/png", "data": "PNG1"}
965+ },
966+ {
967+ "type": "image",
968+ "source": {"type": "base64", "media_type": "image/gif", "data": "GIF1"}
969+ }
970+ ]
971+ }
972+ ]
973+ }
974+ ]
975+ }`)
976+
977+ output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
978+ outputStr := string(output)
979+
980+ if !gjson.Valid(outputStr) {
981+ t.Fatalf("Result is not valid JSON:\n%s", outputStr)
982+ }
983+
984+ funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
985+ if !funcResp.Exists() {
986+ t.Fatal("functionResponse should exist")
987+ }
988+
989+ // No text => response.result should be empty string
990+ if funcResp.Get("response.result").String() != "" {
991+ t.Errorf("Expected empty response.result, got '%s'", funcResp.Get("response.result").String())
992+ }
993+
994+ // Both images in functionResponse.parts
995+ imgParts := funcResp.Get("parts").Array()
996+ if len(imgParts) != 2 {
997+ t.Fatalf("Expected 2 image parts, got %d", len(imgParts))
998+ }
999+ if imgParts[0].Get("inlineData.mimeType").String() != "image/png" {
1000+ t.Error("first image mimeType mismatch")
1001+ }
1002+ if imgParts[1].Get("inlineData.mimeType").String() != "image/gif" {
1003+ t.Error("second image mimeType mismatch")
1004+ }
1005+
1006+ // Only 1 outer part
1007+ outerParts := gjson.Get(outputStr, "request.contents.0.parts").Array()
1008+ if len(outerParts) != 1 {
1009+ t.Errorf("Expected 1 outer part, got %d", len(outerParts))
1010+ }
1011+}
1012+
1013+func TestConvertClaudeRequestToAntigravity_ToolResultImageNotBase64(t *testing.T) {
1014+ // image with source.type != "base64" should be treated as non-image (falls through)
1015+ inputJSON := []byte(`{
1016+ "model": "claude-3-5-sonnet-20240620",
1017+ "messages": [
1018+ {
1019+ "role": "user",
1020+ "content": [
1021+ {
1022+ "type": "tool_result",
1023+ "tool_use_id": "NotB64-001",
1024+ "content": [
1025+ {"type": "text", "text": "some output"},
1026+ {
1027+ "type": "image",
1028+ "source": {"type": "url", "url": "https://example.com/img.png"}
1029+ }
1030+ ]
1031+ }
1032+ ]
1033+ }
1034+ ]
1035+ }`)
1036+
1037+ output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
1038+ outputStr := string(output)
1039+
1040+ if !gjson.Valid(outputStr) {
1041+ t.Fatalf("Result is not valid JSON:\n%s", outputStr)
1042+ }
1043+
1044+ funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
1045+ if !funcResp.Exists() {
1046+ t.Fatal("functionResponse should exist")
1047+ }
1048+
1049+ // Non-base64 image is treated as non-image, so it goes into the filtered results
1050+ // along with the text item. Since there are 2 non-image items, result is array.
1051+ resultArr := funcResp.Get("response.result")
1052+ if !resultArr.IsArray() {
1053+ t.Fatalf("Expected response.result to be an array (2 non-image items), got: %s", resultArr.Raw)
1054+ }
1055+ results := resultArr.Array()
1056+ if len(results) != 2 {
1057+ t.Fatalf("Expected 2 result items, got %d", len(results))
1058+ }
1059+
1060+ // No functionResponse.parts (no base64 images collected)
1061+ if funcResp.Get("parts").Exists() {
1062+ t.Error("functionResponse.parts should NOT exist when no base64 images")
1063+ }
1064+}
1065+
1066+func TestConvertClaudeRequestToAntigravity_ToolResultImageMissingData(t *testing.T) {
1067+ // image with source.type=base64 but missing data field
1068+ inputJSON := []byte(`{
1069+ "model": "claude-3-5-sonnet-20240620",
1070+ "messages": [
1071+ {
1072+ "role": "user",
1073+ "content": [
1074+ {
1075+ "type": "tool_result",
1076+ "tool_use_id": "NoData-001",
1077+ "content": [
1078+ {"type": "text", "text": "output"},
1079+ {
1080+ "type": "image",
1081+ "source": {"type": "base64", "media_type": "image/png"}
1082+ }
1083+ ]
1084+ }
1085+ ]
1086+ }
1087+ ]
1088+ }`)
1089+
1090+ output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
1091+ outputStr := string(output)
1092+
1093+ if !gjson.Valid(outputStr) {
1094+ t.Fatalf("Result is not valid JSON:\n%s", outputStr)
1095+ }
1096+
1097+ funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
1098+ if !funcResp.Exists() {
1099+ t.Fatal("functionResponse should exist")
1100+ }
1101+
1102+ // The image is still classified as base64 image (type check passes),
1103+ // but data field is missing => inlineData has mimeType but no data
1104+ imgParts := funcResp.Get("parts").Array()
1105+ if len(imgParts) != 1 {
1106+ t.Fatalf("Expected 1 image part, got %d", len(imgParts))
1107+ }
1108+ if imgParts[0].Get("inlineData.mimeType").String() != "image/png" {
1109+ t.Error("mimeType should still be set")
1110+ }
1111+ if imgParts[0].Get("inlineData.data").Exists() {
1112+ t.Error("data should not exist when source.data is missing")
1113+ }
1114+}
1115+
1116+func TestConvertClaudeRequestToAntigravity_ToolResultImageMissingMediaType(t *testing.T) {
1117+ // image with source.type=base64 but missing media_type field
1118+ inputJSON := []byte(`{
1119+ "model": "claude-3-5-sonnet-20240620",
1120+ "messages": [
1121+ {
1122+ "role": "user",
1123+ "content": [
1124+ {
1125+ "type": "tool_result",
1126+ "tool_use_id": "NoMime-001",
1127+ "content": [
1128+ {"type": "text", "text": "output"},
1129+ {
1130+ "type": "image",
1131+ "source": {"type": "base64", "data": "AAAA"}
1132+ }
1133+ ]
1134+ }
1135+ ]
1136+ }
1137+ ]
1138+ }`)
1139+
1140+ output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
1141+ outputStr := string(output)
1142+
1143+ if !gjson.Valid(outputStr) {
1144+ t.Fatalf("Result is not valid JSON:\n%s", outputStr)
1145+ }
1146+
1147+ funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
1148+ if !funcResp.Exists() {
1149+ t.Fatal("functionResponse should exist")
1150+ }
1151+
1152+ // The image is still classified as base64 image,
1153+ // but media_type is missing => inlineData has data but no mimeType
1154+ imgParts := funcResp.Get("parts").Array()
1155+ if len(imgParts) != 1 {
1156+ t.Fatalf("Expected 1 image part, got %d", len(imgParts))
1157+ }
1158+ if imgParts[0].Get("inlineData.mimeType").Exists() {
1159+ t.Error("mimeType should not exist when media_type is missing")
1160+ }
1161+ if imgParts[0].Get("inlineData.data").String() != "AAAA" {
1162+ t.Error("data should still be set")
1163+ }
1164+}
1165+
7431166 func TestConvertClaudeRequestToAntigravity_ToolAndThinking_NoExistingSystem(t *testing.T) {
7441167 // When tools + thinking but no system instruction, should create one with hint
7451168 inputJSON := []byte(`{
internal/translator/antigravity/gemini/antigravity_gemini_request_test.go+78−0
@@ -93,3 +93,81 @@ func TestConvertGeminiRequestToAntigravity_ParallelFunctionCalls(t *testing.T) { · 92 unmodified lines
9393 }
9494 }
9595 }
96+
97+func TestFixCLIToolResponse_PreservesFunctionResponseParts(t *testing.T) {
98+ // When functionResponse contains a "parts" field with inlineData (from Claude
99+ // translator's image embedding), fixCLIToolResponse should preserve it as-is.
100+ // parseFunctionResponseRaw returns response.Raw for valid JSON objects,
101+ // so extra fields like "parts" survive the pipeline.
102+ input := `{
103+ "model": "claude-opus-4-6-thinking",
104+ "request": {
105+ "contents": [
106+ {
107+ "role": "model",
108+ "parts": [
109+ {
110+ "functionCall": {"name": "screenshot", "args": {}}
111+ }
112+ ]
113+ },
114+ {
115+ "role": "function",
116+ "parts": [
117+ {
118+ "functionResponse": {
119+ "id": "tool-001",
120+ "name": "screenshot",
121+ "response": {"result": "Screenshot taken"},
122+ "parts": [
123+ {"inlineData": {"mimeType": "image/png", "data": "iVBOR"}}
124+ ]
125+ }
126+ }
127+ ]
128+ }
129+ ]
130+ }
131+ }`
132+
133+ result, err := fixCLIToolResponse(input)
134+ if err != nil {
135+ t.Fatalf("fixCLIToolResponse failed: %v", err)
136+ }
137+
138+ // Find the function response content (role=function)
139+ contents := gjson.Get(result, "request.contents").Array()
140+ var funcContent gjson.Result
141+ for _, c := range contents {
142+ if c.Get("role").String() == "function" {
143+ funcContent = c
144+ break
145+ }
146+ }
147+ if !funcContent.Exists() {
148+ t.Fatal("function role content should exist in output")
149+ }
150+
151+ // The functionResponse should be preserved with its parts field
152+ funcResp := funcContent.Get("parts.0.functionResponse")
153+ if !funcResp.Exists() {
154+ t.Fatal("functionResponse should exist in output")
155+ }
156+
157+ // Verify the parts field with inlineData is preserved
158+ inlineParts := funcResp.Get("parts").Array()
159+ if len(inlineParts) != 1 {
160+ t.Fatalf("Expected 1 inlineData part in functionResponse.parts, got %d", len(inlineParts))
161+ }
162+ if inlineParts[0].Get("inlineData.mimeType").String() != "image/png" {
163+ t.Errorf("Expected mimeType 'image/png', got '%s'", inlineParts[0].Get("inlineData.mimeType").String())
164+ }
165+ if inlineParts[0].Get("inlineData.data").String() != "iVBOR" {
166+ t.Errorf("Expected data 'iVBOR', got '%s'", inlineParts[0].Get("inlineData.data").String())
167+ }
168+
169+ // Verify response.result is also preserved
170+ if funcResp.Get("response.result").String() != "Screenshot taken" {
171+ t.Errorf("Expected response.result 'Screenshot taken', got '%s'", funcResp.Get("response.result").String())
172+ }
173+}
internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go+3−3
@@ -187,7 +187,7 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ · 186 unmodified lines
187187 if len(pieces) == 2 && len(pieces[1]) > 7 {
188188 mime := pieces[0]
189189 data := pieces[1][7:]
190− node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.mime_type", mime)
190+ node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.mimeType", mime)
191191 node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.data", data)
192192 node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature)
193193 p++
@@ -201,7 +201,7 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ · 7 unmodified lines
201201 ext = sp[len(sp)-1]
202202 }
203203 if mimeType, ok := misc.MimeTypes[ext]; ok {
204− node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.mime_type", mimeType)
204+ node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.mimeType", mimeType)
205205 node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.data", fileData)
206206 p++
207207 } else {
@@ -235,7 +235,7 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ · 27 unmodified lines
235235 if len(pieces) == 2 && len(pieces[1]) > 7 {
236236 mime := pieces[0]
237237 data := pieces[1][7:]
238− node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.mime_type", mime)
238+ node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.mimeType", mime)
239239 node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.data", data)
240240 node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature)
241241 p++
internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go+2−2
@@ -95,9 +95,9 @@ func ConvertAntigravityResponseToOpenAI(_ context.Context, _ string, originalReq · 94 unmodified lines
9595 if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() {
9696 template, _ = sjson.Set(template, "usage.total_tokens", totalTokenCountResult.Int())
9797 }
98− promptTokenCount := usageResult.Get("promptTokenCount").Int() - cachedTokenCount
98+ promptTokenCount := usageResult.Get("promptTokenCount").Int()
9999 thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
100− template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCount+thoughtsTokenCount)
100+ template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCount)
101101 if thoughtsTokenCount > 0 {
102102 template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
103103 }
internal/translator/claude/openai/chat-completions/claude_openai_request.go+15−0
@@ -199,6 +199,21 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream · 198 unmodified lines
199199 msg, _ = sjson.SetRaw(msg, "content.-1", imagePart)
200200 }
201201 }
202+
203+ case "file":
204+ fileData := part.Get("file.file_data").String()
205+ if strings.HasPrefix(fileData, "data:") {
206+ semicolonIdx := strings.Index(fileData, ";")
207+ commaIdx := strings.Index(fileData, ",")
208+ if semicolonIdx != -1 && commaIdx != -1 && commaIdx > semicolonIdx {
209+ mediaType := strings.TrimPrefix(fileData[:semicolonIdx], "data:")
210+ data := fileData[commaIdx+1:]
211+ docPart := `{"type":"document","source":{"type":"base64","media_type":"","data":""}}`
212+ docPart, _ = sjson.Set(docPart, "source.media_type", mediaType)
213+ docPart, _ = sjson.Set(docPart, "source.data", data)
214+ msg, _ = sjson.SetRaw(msg, "content.-1", docPart)
215+ }
216+ }
202217 }
203218 return true
204219 })
internal/translator/claude/openai/responses/claude_openai-responses_request.go+26−1
@@ -155,6 +155,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte · 154 unmodified lines
155155 var textAggregate strings.Builder
156156 var partsJSON []string
157157 hasImage := false
158+ hasFile := false
158159 if parts := item.Get("content"); parts.Exists() && parts.IsArray() {
159160 parts.ForEach(func(_, part gjson.Result) bool {
160161 ptype := part.Get("type").String()
@@ -207,6 +208,30 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte · 46 unmodified lines
207208 hasImage = true
208209 }
209210 }
211+ case "input_file":
212+ fileData := part.Get("file_data").String()
213+ if fileData != "" {
214+ mediaType := "application/octet-stream"
215+ data := fileData
216+ if strings.HasPrefix(fileData, "data:") {
217+ trimmed := strings.TrimPrefix(fileData, "data:")
218+ mediaAndData := strings.SplitN(trimmed, ";base64,", 2)
219+ if len(mediaAndData) == 2 {
220+ if mediaAndData[0] != "" {
221+ mediaType = mediaAndData[0]
222+ }
223+ data = mediaAndData[1]
224+ }
225+ }
226+ contentPart := `{"type":"document","source":{"type":"base64","media_type":"","data":""}}`
227+ contentPart, _ = sjson.Set(contentPart, "source.media_type", mediaType)
228+ contentPart, _ = sjson.Set(contentPart, "source.data", data)
229+ partsJSON = append(partsJSON, contentPart)
230+ if role == "" {
231+ role = "user"
232+ }
233+ hasFile = true
234+ }
210235 }
211236 return true
212237 })
@@ -228,7 +253,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte · 15 unmodified lines
228253 if len(partsJSON) > 0 {
229254 msg := `{"role":"","content":[]}`
230255 msg, _ = sjson.Set(msg, "role", role)
231− if len(partsJSON) == 1 && !hasImage {
256+ if len(partsJSON) == 1 && !hasImage && !hasFile {
232257 // Preserve legacy behavior for single text content
233258 msg, _ = sjson.Delete(msg, "content")
234259 textPart := gjson.Parse(partsJSON[0])
internal/translator/codex/openai/chat-completions/codex_openai_request.go+13−1
@@ -180,7 +180,19 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b · 179 unmodified lines
180180 msg, _ = sjson.SetRaw(msg, "content.-1", part)
181181 }
182182 case "file":
183− // Files are not specified in examples; skip for now
183+ if role == "user" {
184+ fileData := it.Get("file.file_data").String()
185+ filename := it.Get("file.filename").String()
186+ if fileData != "" {
187+ part := `{}`
188+ part, _ = sjson.Set(part, "type", "input_file")
189+ part, _ = sjson.Set(part, "file_data", fileData)
190+ if filename != "" {
191+ part, _ = sjson.Set(part, "filename", filename)
192+ }
193+ msg, _ = sjson.SetRaw(msg, "content.-1", part)
194+ }
195+ }
184196 }
185197 }
186198 }
internal/translator/codex/openai/responses/codex_openai-responses_request.go+19−0
@@ -26,6 +26,8 @@ func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, · 25 unmodified lines
2626 rawJSON, _ = sjson.DeleteBytes(rawJSON, "temperature")
2727 rawJSON, _ = sjson.DeleteBytes(rawJSON, "top_p")
2828 rawJSON, _ = sjson.DeleteBytes(rawJSON, "service_tier")
29+ rawJSON, _ = sjson.DeleteBytes(rawJSON, "truncation")
30+ rawJSON = applyResponsesCompactionCompatibility(rawJSON)
2931
3032 // Delete the user field as it is not supported by the Codex upstream.
3133 rawJSON, _ = sjson.DeleteBytes(rawJSON, "user")
@@ -36,6 +38,23 @@ func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, · 4 unmodified lines
3638 return rawJSON
3739 }
3840
41+// applyResponsesCompactionCompatibility handles OpenAI Responses context_management.compaction
42+// for Codex upstream compatibility.
43+//
44+// Codex /responses currently rejects context_management with:
45+// {"detail":"Unsupported parameter: context_management"}.
46+//
47+// Compatibility strategy:
48+// 1) Remove context_management before forwarding to Codex upstream.
49+func applyResponsesCompactionCompatibility(rawJSON []byte) []byte {
50+ if !gjson.GetBytes(rawJSON, "context_management").Exists() {
51+ return rawJSON
52+ }
53+
54+ rawJSON, _ = sjson.DeleteBytes(rawJSON, "context_management")
55+ return rawJSON
56+}
57+
3958 // convertSystemRoleToDeveloper traverses the input array and converts any message items
4059 // with role "system" to role "developer". This is necessary because Codex API does not
4160 // accept "system" role in the input array.
internal/translator/codex/openai/responses/codex_openai-responses_request_test.go+38−0
@@ -280,3 +280,41 @@ func TestUserFieldDeletion(t *testing.T) { · 279 unmodified lines
280280 t.Errorf("user field should be deleted, but it was found with value: %s", userField.Raw)
281281 }
282282 }
283+
284+func TestContextManagementCompactionCompatibility(t *testing.T) {
285+ inputJSON := []byte(`{
286+ "model": "gpt-5.2",
287+ "context_management": [
288+ {
289+ "type": "compaction",
290+ "compact_threshold": 12000
291+ }
292+ ],
293+ "input": [{"role":"user","content":"hello"}]
294+ }`)
295+
296+ output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
297+ outputStr := string(output)
298+
299+ if gjson.Get(outputStr, "context_management").Exists() {
300+ t.Fatalf("context_management should be removed for Codex compatibility")
301+ }
302+ if gjson.Get(outputStr, "truncation").Exists() {
303+ t.Fatalf("truncation should be removed for Codex compatibility")
304+ }
305+}
306+
307+func TestTruncationRemovedForCodexCompatibility(t *testing.T) {
308+ inputJSON := []byte(`{
309+ "model": "gpt-5.2",
310+ "truncation": "disabled",
311+ "input": [{"role":"user","content":"hello"}]
312+ }`)
313+
314+ output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
315+ outputStr := string(output)
316+
317+ if gjson.Get(outputStr, "truncation").Exists() {
318+ t.Fatalf("truncation should be removed for Codex compatibility")
319+ }
320+}
internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go+1−1
@@ -100,7 +100,7 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ · 99 unmodified lines
100100 }
101101 promptTokenCount := usageResult.Get("promptTokenCount").Int()
102102 thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
103− template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCount+thoughtsTokenCount)
103+ template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCount)
104104 if thoughtsTokenCount > 0 {
105105 template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
106106 }
internal/translator/gemini/openai/chat-completions/gemini_openai_response.go+3−3
@@ -100,9 +100,9 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR · 99 unmodified lines
100100 if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() {
101101 baseTemplate, _ = sjson.Set(baseTemplate, "usage.total_tokens", totalTokenCountResult.Int())
102102 }
103− promptTokenCount := usageResult.Get("promptTokenCount").Int() - cachedTokenCount
103+ promptTokenCount := usageResult.Get("promptTokenCount").Int()
104104 thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
105− baseTemplate, _ = sjson.Set(baseTemplate, "usage.prompt_tokens", promptTokenCount+thoughtsTokenCount)
105+ baseTemplate, _ = sjson.Set(baseTemplate, "usage.prompt_tokens", promptTokenCount)
106106 if thoughtsTokenCount > 0 {
107107 baseTemplate, _ = sjson.Set(baseTemplate, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
108108 }
@@ -297,7 +297,7 @@ func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, origina · 188 unmodified lines
297297 promptTokenCount := usageResult.Get("promptTokenCount").Int()
298298 thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
299299 cachedTokenCount := usageResult.Get("cachedContentTokenCount").Int()
300− template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCount+thoughtsTokenCount)
300+ template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCount)
301301 if thoughtsTokenCount > 0 {
302302 template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
303303 }
internal/translator/gemini/openai/responses/gemini_openai-responses_response.go+4−4
@@ -531,8 +531,8 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, · 530 unmodified lines
531531
532532 // usage mapping
533533 if um := root.Get("usageMetadata"); um.Exists() {
534− // input tokens = prompt + thoughts
535− input := um.Get("promptTokenCount").Int() + um.Get("thoughtsTokenCount").Int()
534+ // input tokens = prompt only (thoughts go to output)
535+ input := um.Get("promptTokenCount").Int()
536536 completed, _ = sjson.Set(completed, "response.usage.input_tokens", input)
537537 // cached token details: align with OpenAI "cached_tokens" semantics.
538538 completed, _ = sjson.Set(completed, "response.usage.input_tokens_details.cached_tokens", um.Get("cachedContentTokenCount").Int())
@@ -737,8 +737,8 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string · 198 unmodified lines
737737
738738 // usage mapping
739739 if um := root.Get("usageMetadata"); um.Exists() {
740− // input tokens = prompt + thoughts
741− input := um.Get("promptTokenCount").Int() + um.Get("thoughtsTokenCount").Int()
740+ // input tokens = prompt only (thoughts go to output)
741+ input := um.Get("promptTokenCount").Int()
742742 resp, _ = sjson.Set(resp, "usage.input_tokens", input)
743743 // cached token details: align with OpenAI "cached_tokens" semantics.
744744 resp, _ = sjson.Set(resp, "usage.input_tokens_details.cached_tokens", um.Get("cachedContentTokenCount").Int())
sdk/api/handlers/handlers.go+35−0
@@ -716,6 +716,12 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl · 715 unmodified lines
716716 return
717717 }
718718 if len(chunk.Payload) > 0 {
719+ if handlerType == "openai-response" {
720+ if err := validateSSEDataJSON(chunk.Payload); err != nil {
721+ _ = sendErr(&interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: err})
722+ return
723+ }
724+ }
719725 sentPayload = true
720726 if okSendData := sendData(cloneBytes(chunk.Payload)); !okSendData {
721727 return
@@ -727,6 +733,35 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl · 5 unmodified lines
727733 return dataChan, upstreamHeaders, errChan
728734 }
729735
736+func validateSSEDataJSON(chunk []byte) error {
737+ for _, line := range bytes.Split(chunk, []byte("\n")) {
738+ line = bytes.TrimSpace(line)
739+ if len(line) == 0 {
740+ continue
741+ }
742+ if !bytes.HasPrefix(line, []byte("data:")) {
743+ continue
744+ }
745+ data := bytes.TrimSpace(line[5:])
746+ if len(data) == 0 {
747+ continue
748+ }
749+ if bytes.Equal(data, []byte("[DONE]")) {
750+ continue
751+ }
752+ if json.Valid(data) {
753+ continue
754+ }
755+ const max = 512
756+ preview := data
757+ if len(preview) > max {
758+ preview = preview[:max]
759+ }
760+ return fmt.Errorf("invalid SSE data JSON (len=%d): %q", len(data), preview)
761+ }
762+ return nil
763+}
764+
730765 func statusFromError(err error) int {
731766 if err == nil {
732767 return 0
sdk/api/handlers/handlers_stream_bootstrap_test.go+83−0
@@ -134,6 +134,37 @@ type authAwareStreamExecutor struct { · 133 unmodified lines
134134 authIDs []string
135135 }
136136
137+type invalidJSONStreamExecutor struct{}
138+
139+func (e *invalidJSONStreamExecutor) Identifier() string { return "codex" }
140+
141+func (e *invalidJSONStreamExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {
142+ return coreexecutor.Response{}, &coreauth.Error{Code: "not_implemented", Message: "Execute not implemented"}
143+}
144+
145+func (e *invalidJSONStreamExecutor) ExecuteStream(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (*coreexecutor.StreamResult, error) {
146+ ch := make(chan coreexecutor.StreamChunk, 1)
147+ ch <- coreexecutor.StreamChunk{Payload: []byte("event: response.completed\ndata: {\"type\"")}
148+ close(ch)
149+ return &coreexecutor.StreamResult{Chunks: ch}, nil
150+}
151+
152+func (e *invalidJSONStreamExecutor) Refresh(ctx context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) {
153+ return auth, nil
154+}
155+
156+func (e *invalidJSONStreamExecutor) CountTokens(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {
157+ return coreexecutor.Response{}, &coreauth.Error{Code: "not_implemented", Message: "CountTokens not implemented"}
158+}
159+
160+func (e *invalidJSONStreamExecutor) HttpRequest(ctx context.Context, auth *coreauth.Auth, req *http.Request) (*http.Response, error) {
161+ return nil, &coreauth.Error{
162+ Code: "not_implemented",
163+ Message: "HttpRequest not implemented",
164+ HTTPStatus: http.StatusNotImplemented,
165+ }
166+}
167+
137168 func (e *authAwareStreamExecutor) Identifier() string { return "codex" }
138169
139170 func (e *authAwareStreamExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {
@@ -524,3 +555,55 @@ func TestExecuteStreamWithAuthManager_SelectedAuthCallbackReceivesAuthID(t *test · 384 unmodified lines
524555 t.Fatalf("selectedAuthID = %q, want %q", selectedAuthID, "auth2")
525556 }
526557 }
558+
559+func TestExecuteStreamWithAuthManager_ValidatesOpenAIResponsesStreamDataJSON(t *testing.T) {
560+ executor := &invalidJSONStreamExecutor{}
561+ manager := coreauth.NewManager(nil, nil, nil)
562+ manager.RegisterExecutor(executor)
563+
564+ auth1 := &coreauth.Auth{
565+ ID: "auth1",
566+ Provider: "codex",
567+ Status: coreauth.StatusActive,
568+ Metadata: map[string]any{"email": "test1@example.com"},
569+ }
570+ if _, err := manager.Register(context.Background(), auth1); err != nil {
571+ t.Fatalf("manager.Register(auth1): %v", err)
572+ }
573+
574+ registry.GetGlobalRegistry().RegisterClient(auth1.ID, auth1.Provider, []*registry.ModelInfo{{ID: "test-model"}})
575+ t.Cleanup(func() {
576+ registry.GetGlobalRegistry().UnregisterClient(auth1.ID)
577+ })
578+
579+ handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
580+ dataChan, _, errChan := handler.ExecuteStreamWithAuthManager(context.Background(), "openai-response", "test-model", []byte(`{"model":"test-model"}`), "")
581+ if dataChan == nil || errChan == nil {
582+ t.Fatalf("expected non-nil channels")
583+ }
584+
585+ var got []byte
586+ for chunk := range dataChan {
587+ got = append(got, chunk...)
588+ }
589+ if len(got) != 0 {
590+ t.Fatalf("expected empty payload, got %q", string(got))
591+ }
592+
593+ gotErr := false
594+ for msg := range errChan {
595+ if msg == nil {
596+ continue
597+ }
598+ if msg.StatusCode != http.StatusBadGateway {
599+ t.Fatalf("expected status %d, got %d", http.StatusBadGateway, msg.StatusCode)
600+ }
601+ if msg.Error == nil {
602+ t.Fatalf("expected error")
603+ }
604+ gotErr = true
605+ }
606+ if !gotErr {
607+ t.Fatalf("expected terminal error")
608+ }
609+}
sdk/api/handlers/openai/openai_responses_handlers.go+2−2
@@ -265,8 +265,8 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesStream(c *gin.Context, flush · 264 unmodified lines
265265 if errMsg.Error != nil && errMsg.Error.Error() != "" {
266266 errText = errMsg.Error.Error()
267267 }
268− body := handlers.BuildErrorResponseBody(status, errText)
269− _, _ = fmt.Fprintf(c.Writer, "\nevent: error\ndata: %s\n\n", string(body))
268+ chunk := handlers.BuildOpenAIResponsesStreamErrorChunk(status, errText, 0)
269+ _, _ = fmt.Fprintf(c.Writer, "\nevent: error\ndata: %s\n\n", string(chunk))
270270 },
271271 WriteDone: func() {
272272 _, _ = c.Writer.Write([]byte("\n"))
sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go+43−0
@@ -0,0 +1,43 @@
1+package openai
2+
3+import (
4+ "errors"
5+ "net/http"
6+ "net/http/httptest"
7+ "strings"
8+ "testing"
9+
10+ "github.com/gin-gonic/gin"
11+ "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
12+ "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
13+ sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
14+)
15+
16+func TestForwardResponsesStreamTerminalErrorUsesResponsesErrorChunk(t *testing.T) {
17+ gin.SetMode(gin.TestMode)
18+ base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil)
19+ h := NewOpenAIResponsesAPIHandler(base)
20+
21+ recorder := httptest.NewRecorder()
22+ c, _ := gin.CreateTestContext(recorder)
23+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
24+
25+ flusher, ok := c.Writer.(http.Flusher)
26+ if !ok {
27+ t.Fatalf("expected gin writer to implement http.Flusher")
28+ }
29+
30+ data := make(chan []byte)
31+ errs := make(chan *interfaces.ErrorMessage, 1)
32+ errs <- &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: errors.New("unexpected EOF")}
33+ close(errs)
34+
35+ h.forwardResponsesStream(c, flusher, func(error) {}, data, errs)
36+ body := recorder.Body.String()
37+ if !strings.Contains(body, `"type":"error"`) {
38+ t.Fatalf("expected responses error chunk, got: %q", body)
39+ }
40+ if strings.Contains(body, `"error":{`) {
41+ t.Fatalf("expected streaming error chunk (top-level type), got HTTP error body: %q", body)
42+ }
43+}
sdk/api/handlers/openai_responses_stream_error.go+119−0
@@ -0,0 +1,119 @@
1+package handlers
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "net/http"
7+ "strings"
8+)
9+
10+type openAIResponsesStreamErrorChunk struct {
11+ Type string `json:"type"`
12+ Code string `json:"code"`
13+ Message string `json:"message"`
14+ SequenceNumber int `json:"sequence_number"`
15+}
16+
17+func openAIResponsesStreamErrorCode(status int) string {
18+ switch status {
19+ case http.StatusUnauthorized:
20+ return "invalid_api_key"
21+ case http.StatusForbidden:
22+ return "insufficient_quota"
23+ case http.StatusTooManyRequests:
24+ return "rate_limit_exceeded"
25+ case http.StatusNotFound:
26+ return "model_not_found"
27+ case http.StatusRequestTimeout:
28+ return "request_timeout"
29+ default:
30+ if status >= http.StatusInternalServerError {
31+ return "internal_server_error"
32+ }
33+ if status >= http.StatusBadRequest {
34+ return "invalid_request_error"
35+ }
36+ return "unknown_error"
37+ }
38+}
39+
40+// BuildOpenAIResponsesStreamErrorChunk builds an OpenAI Responses streaming error chunk.
41+//
42+// Important: OpenAI's HTTP error bodies are shaped like {"error":{...}}; those are valid for
43+// non-streaming responses, but streaming clients validate SSE `data:` payloads against a union
44+// of chunks that requires a top-level `type` field.
45+func BuildOpenAIResponsesStreamErrorChunk(status int, errText string, sequenceNumber int) []byte {
46+ if status <= 0 {
47+ status = http.StatusInternalServerError
48+ }
49+ if sequenceNumber < 0 {
50+ sequenceNumber = 0
51+ }
52+
53+ message := strings.TrimSpace(errText)
54+ if message == "" {
55+ message = http.StatusText(status)
56+ }
57+
58+ code := openAIResponsesStreamErrorCode(status)
59+
60+ trimmed := strings.TrimSpace(errText)
61+ if trimmed != "" && json.Valid([]byte(trimmed)) {
62+ var payload map[string]any
63+ if err := json.Unmarshal([]byte(trimmed), &payload); err == nil {
64+ if t, ok := payload["type"].(string); ok && strings.TrimSpace(t) == "error" {
65+ if m, ok := payload["message"].(string); ok && strings.TrimSpace(m) != "" {
66+ message = strings.TrimSpace(m)
67+ }
68+ if v, ok := payload["code"]; ok && v != nil {
69+ if c, ok := v.(string); ok && strings.TrimSpace(c) != "" {
70+ code = strings.TrimSpace(c)
71+ } else {
72+ code = strings.TrimSpace(fmt.Sprint(v))
73+ }
74+ }
75+ if v, ok := payload["sequence_number"].(float64); ok && sequenceNumber == 0 {
76+ sequenceNumber = int(v)
77+ }
78+ }
79+ if e, ok := payload["error"].(map[string]any); ok {
80+ if m, ok := e["message"].(string); ok && strings.TrimSpace(m) != "" {
81+ message = strings.TrimSpace(m)
82+ }
83+ if v, ok := e["code"]; ok && v != nil {
84+ if c, ok := v.(string); ok && strings.TrimSpace(c) != "" {
85+ code = strings.TrimSpace(c)
86+ } else {
87+ code = strings.TrimSpace(fmt.Sprint(v))
88+ }
89+ }
90+ }
91+ }
92+ }
93+
94+ if strings.TrimSpace(code) == "" {
95+ code = "unknown_error"
96+ }
97+
98+ data, err := json.Marshal(openAIResponsesStreamErrorChunk{
99+ Type: "error",
100+ Code: code,
101+ Message: message,
102+ SequenceNumber: sequenceNumber,
103+ })
104+ if err == nil {
105+ return data
106+ }
107+
108+ // Extremely defensive fallback.
109+ data, _ = json.Marshal(openAIResponsesStreamErrorChunk{
110+ Type: "error",
111+ Code: "internal_server_error",
112+ Message: message,
113+ SequenceNumber: sequenceNumber,
114+ })
115+ if len(data) > 0 {
116+ return data
117+ }
118+ return []byte(`{"type":"error","code":"internal_server_error","message":"internal error","sequence_number":0}`)
119+}
sdk/api/handlers/openai_responses_stream_error_test.go+48−0
@@ -0,0 +1,48 @@
1+package handlers
2+
3+import (
4+ "encoding/json"
5+ "net/http"
6+ "testing"
7+)
8+
9+func TestBuildOpenAIResponsesStreamErrorChunk(t *testing.T) {
10+ chunk := BuildOpenAIResponsesStreamErrorChunk(http.StatusInternalServerError, "unexpected EOF", 0)
11+ var payload map[string]any
12+ if err := json.Unmarshal(chunk, &payload); err != nil {
13+ t.Fatalf("unmarshal: %v", err)
14+ }
15+ if payload["type"] != "error" {
16+ t.Fatalf("type = %v, want %q", payload["type"], "error")
17+ }
18+ if payload["code"] != "internal_server_error" {
19+ t.Fatalf("code = %v, want %q", payload["code"], "internal_server_error")
20+ }
21+ if payload["message"] != "unexpected EOF" {
22+ t.Fatalf("message = %v, want %q", payload["message"], "unexpected EOF")
23+ }
24+ if payload["sequence_number"] != float64(0) {
25+ t.Fatalf("sequence_number = %v, want %v", payload["sequence_number"], 0)
26+ }
27+}
28+
29+func TestBuildOpenAIResponsesStreamErrorChunkExtractsHTTPErrorBody(t *testing.T) {
30+ chunk := BuildOpenAIResponsesStreamErrorChunk(
31+ http.StatusInternalServerError,
32+ `{"error":{"message":"oops","type":"server_error","code":"internal_server_error"}}`,
33+ 0,
34+ )
35+ var payload map[string]any
36+ if err := json.Unmarshal(chunk, &payload); err != nil {
37+ t.Fatalf("unmarshal: %v", err)
38+ }
39+ if payload["type"] != "error" {
40+ t.Fatalf("type = %v, want %q", payload["type"], "error")
41+ }
42+ if payload["code"] != "internal_server_error" {
43+ t.Fatalf("code = %v, want %q", payload["code"], "internal_server_error")
44+ }
45+ if payload["message"] != "oops" {
46+ t.Fatalf("message = %v, want %q", payload["message"], "oops")
47+ }
48+}
sdk/auth/codex.go+5−37
@@ -2,8 +2,6 @@ package auth · 1 unmodified line
22
33 import (
44 "context"
5− "crypto/sha256"
6− "encoding/hex"
75 "fmt"
86 "net/http"
97 "strings"
@@ -48,6 +46,10 @@ func (a *CodexAuthenticator) Login(ctx context.Context, cfg *config.Config, opts · 38 unmodified lines
4846 opts = &LoginOptions{}
4947 }
5048
49+ if shouldUseCodexDeviceFlow(opts) {
50+ return a.loginWithDeviceFlow(ctx, cfg, opts)
51+ }
52+
5153 callbackPort := a.CallbackPort
5254 if opts.CallbackPort > 0 {
5355 callbackPort = opts.CallbackPort
@@ -186,39 +188,5 @@ waitForCallback: · 132 unmodified lines
186188 return nil, codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, err)
187189 }
188190
189− tokenStorage := authSvc.CreateTokenStorage(authBundle)
190−
191− if tokenStorage == nil || tokenStorage.Email == "" {
192− return nil, fmt.Errorf("codex token storage missing account information")
193− }
194−
195− planType := ""
196− hashAccountID := ""
197− if tokenStorage.IDToken != "" {
198− if claims, errParse := codex.ParseJWTToken(tokenStorage.IDToken); errParse == nil && claims != nil {
199− planType = strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType)
200− accountID := strings.TrimSpace(claims.CodexAuthInfo.ChatgptAccountID)
201− if accountID != "" {
202− digest := sha256.Sum256([]byte(accountID))
203− hashAccountID = hex.EncodeToString(digest[:])[:8]
204− }
205− }
206− }
207− fileName := codex.CredentialFileName(tokenStorage.Email, planType, hashAccountID, true)
208− metadata := map[string]any{
209− "email": tokenStorage.Email,
210− }
211−
212− fmt.Println("Codex authentication successful")
213− if authBundle.APIKey != "" {
214− fmt.Println("Codex API key obtained and stored")
215− }
216−
217− return &coreauth.Auth{
218− ID: fileName,
219− Provider: a.Provider(),
220− FileName: fileName,
221− Storage: tokenStorage,
222− Metadata: metadata,
223− }, nil
191+ return a.buildAuthRecord(authSvc, authBundle)
224192 }
sdk/auth/codex_device.go+291−0
@@ -0,0 +1,291 @@
1+package auth
2+
3+import (
4+ "bytes"
5+ "context"
6+ "crypto/sha256"
7+ "encoding/hex"
8+ "encoding/json"
9+ "fmt"
10+ "io"
11+ "net/http"
12+ "strconv"
13+ "strings"
14+ "time"
15+
16+ "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
17+ "github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
18+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
19+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
20+ coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
21+ log "github.com/sirupsen/logrus"
22+)
23+
24+const (
25+ codexLoginModeMetadataKey = "codex_login_mode"
26+ codexLoginModeDevice = "device"
27+ codexDeviceUserCodeURL = "https://auth.openai.com/api/accounts/deviceauth/usercode"
28+ codexDeviceTokenURL = "https://auth.openai.com/api/accounts/deviceauth/token"
29+ codexDeviceVerificationURL = "https://auth.openai.com/codex/device"
30+ codexDeviceTokenExchangeRedirectURI = "https://auth.openai.com/deviceauth/callback"
31+ codexDeviceTimeout = 15 * time.Minute
32+ codexDeviceDefaultPollIntervalSeconds = 5
33+)
34+
35+type codexDeviceUserCodeRequest struct {
36+ ClientID string `json:"client_id"`
37+}
38+
39+type codexDeviceUserCodeResponse struct {
40+ DeviceAuthID string `json:"device_auth_id"`
41+ UserCode string `json:"user_code"`
42+ UserCodeAlt string `json:"usercode"`
43+ Interval json.RawMessage `json:"interval"`
44+}
45+
46+type codexDeviceTokenRequest struct {
47+ DeviceAuthID string `json:"device_auth_id"`
48+ UserCode string `json:"user_code"`
49+}
50+
51+type codexDeviceTokenResponse struct {
52+ AuthorizationCode string `json:"authorization_code"`
53+ CodeVerifier string `json:"code_verifier"`
54+ CodeChallenge string `json:"code_challenge"`
55+}
56+
57+func shouldUseCodexDeviceFlow(opts *LoginOptions) bool {
58+ if opts == nil || opts.Metadata == nil {
59+ return false
60+ }
61+ return strings.EqualFold(strings.TrimSpace(opts.Metadata[codexLoginModeMetadataKey]), codexLoginModeDevice)
62+}
63+
64+func (a *CodexAuthenticator) loginWithDeviceFlow(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
65+ if ctx == nil {
66+ ctx = context.Background()
67+ }
68+
69+ httpClient := util.SetProxy(&cfg.SDKConfig, &http.Client{})
70+
71+ userCodeResp, err := requestCodexDeviceUserCode(ctx, httpClient)
72+ if err != nil {
73+ return nil, err
74+ }
75+
76+ deviceCode := strings.TrimSpace(userCodeResp.UserCode)
77+ if deviceCode == "" {
78+ deviceCode = strings.TrimSpace(userCodeResp.UserCodeAlt)
79+ }
80+ deviceAuthID := strings.TrimSpace(userCodeResp.DeviceAuthID)
81+ if deviceCode == "" || deviceAuthID == "" {
82+ return nil, fmt.Errorf("codex device flow did not return required fields")
83+ }
84+
85+ pollInterval := parseCodexDevicePollInterval(userCodeResp.Interval)
86+
87+ fmt.Println("Starting Codex device authentication...")
88+ fmt.Printf("Codex device URL: %s\n", codexDeviceVerificationURL)
89+ fmt.Printf("Codex device code: %s\n", deviceCode)
90+
91+ if !opts.NoBrowser {
92+ if !browser.IsAvailable() {
93+ log.Warn("No browser available; please open the device URL manually")
94+ } else if errOpen := browser.OpenURL(codexDeviceVerificationURL); errOpen != nil {
95+ log.Warnf("Failed to open browser automatically: %v", errOpen)
96+ }
97+ }
98+
99+ tokenResp, err := pollCodexDeviceToken(ctx, httpClient, deviceAuthID, deviceCode, pollInterval)
100+ if err != nil {
101+ return nil, err
102+ }
103+
104+ authCode := strings.TrimSpace(tokenResp.AuthorizationCode)
105+ codeVerifier := strings.TrimSpace(tokenResp.CodeVerifier)
106+ codeChallenge := strings.TrimSpace(tokenResp.CodeChallenge)
107+ if authCode == "" || codeVerifier == "" || codeChallenge == "" {
108+ return nil, fmt.Errorf("codex device flow token response missing required fields")
109+ }
110+
111+ authSvc := codex.NewCodexAuth(cfg)
112+ authBundle, err := authSvc.ExchangeCodeForTokensWithRedirect(
113+ ctx,
114+ authCode,
115+ codexDeviceTokenExchangeRedirectURI,
116+ &codex.PKCECodes{
117+ CodeVerifier: codeVerifier,
118+ CodeChallenge: codeChallenge,
119+ },
120+ )
121+ if err != nil {
122+ return nil, codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, err)
123+ }
124+
125+ return a.buildAuthRecord(authSvc, authBundle)
126+}
127+
128+func requestCodexDeviceUserCode(ctx context.Context, client *http.Client) (*codexDeviceUserCodeResponse, error) {
129+ body, err := json.Marshal(codexDeviceUserCodeRequest{ClientID: codex.ClientID})
130+ if err != nil {
131+ return nil, fmt.Errorf("failed to encode codex device request: %w", err)
132+ }
133+
134+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, codexDeviceUserCodeURL, bytes.NewReader(body))
135+ if err != nil {
136+ return nil, fmt.Errorf("failed to create codex device request: %w", err)
137+ }
138+ req.Header.Set("Content-Type", "application/json")
139+ req.Header.Set("Accept", "application/json")
140+
141+ resp, err := client.Do(req)
142+ if err != nil {
143+ return nil, fmt.Errorf("failed to request codex device code: %w", err)
144+ }
145+ defer func() { _ = resp.Body.Close() }()
146+
147+ respBody, err := io.ReadAll(resp.Body)
148+ if err != nil {
149+ return nil, fmt.Errorf("failed to read codex device code response: %w", err)
150+ }
151+
152+ if !codexDeviceIsSuccessStatus(resp.StatusCode) {
153+ trimmed := strings.TrimSpace(string(respBody))
154+ if resp.StatusCode == http.StatusNotFound {
155+ return nil, fmt.Errorf("codex device endpoint is unavailable (status %d)", resp.StatusCode)
156+ }
157+ if trimmed == "" {
158+ trimmed = "empty response body"
159+ }
160+ return nil, fmt.Errorf("codex device code request failed with status %d: %s", resp.StatusCode, trimmed)
161+ }
162+
163+ var parsed codexDeviceUserCodeResponse
164+ if err := json.Unmarshal(respBody, &parsed); err != nil {
165+ return nil, fmt.Errorf("failed to decode codex device code response: %w", err)
166+ }
167+
168+ return &parsed, nil
169+}
170+
171+func pollCodexDeviceToken(ctx context.Context, client *http.Client, deviceAuthID, userCode string, interval time.Duration) (*codexDeviceTokenResponse, error) {
172+ deadline := time.Now().Add(codexDeviceTimeout)
173+
174+ for {
175+ if time.Now().After(deadline) {
176+ return nil, fmt.Errorf("codex device authentication timed out after 15 minutes")
177+ }
178+
179+ body, err := json.Marshal(codexDeviceTokenRequest{
180+ DeviceAuthID: deviceAuthID,
181+ UserCode: userCode,
182+ })
183+ if err != nil {
184+ return nil, fmt.Errorf("failed to encode codex device poll request: %w", err)
185+ }
186+
187+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, codexDeviceTokenURL, bytes.NewReader(body))
188+ if err != nil {
189+ return nil, fmt.Errorf("failed to create codex device poll request: %w", err)
190+ }
191+ req.Header.Set("Content-Type", "application/json")
192+ req.Header.Set("Accept", "application/json")
193+
194+ resp, err := client.Do(req)
195+ if err != nil {
196+ return nil, fmt.Errorf("failed to poll codex device token: %w", err)
197+ }
198+
199+ respBody, readErr := io.ReadAll(resp.Body)
200+ _ = resp.Body.Close()
201+ if readErr != nil {
202+ return nil, fmt.Errorf("failed to read codex device poll response: %w", readErr)
203+ }
204+
205+ switch {
206+ case codexDeviceIsSuccessStatus(resp.StatusCode):
207+ var parsed codexDeviceTokenResponse
208+ if err := json.Unmarshal(respBody, &parsed); err != nil {
209+ return nil, fmt.Errorf("failed to decode codex device token response: %w", err)
210+ }
211+ return &parsed, nil
212+ case resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound:
213+ select {
214+ case <-ctx.Done():
215+ return nil, ctx.Err()
216+ case <-time.After(interval):
217+ continue
218+ }
219+ default:
220+ trimmed := strings.TrimSpace(string(respBody))
221+ if trimmed == "" {
222+ trimmed = "empty response body"
223+ }
224+ return nil, fmt.Errorf("codex device token polling failed with status %d: %s", resp.StatusCode, trimmed)
225+ }
226+ }
227+}
228+
229+func parseCodexDevicePollInterval(raw json.RawMessage) time.Duration {
230+ defaultInterval := time.Duration(codexDeviceDefaultPollIntervalSeconds) * time.Second
231+ if len(raw) == 0 {
232+ return defaultInterval
233+ }
234+
235+ var asString string
236+ if err := json.Unmarshal(raw, &asString); err == nil {
237+ if seconds, convErr := strconv.Atoi(strings.TrimSpace(asString)); convErr == nil && seconds > 0 {
238+ return time.Duration(seconds) * time.Second
239+ }
240+ }
241+
242+ var asInt int
243+ if err := json.Unmarshal(raw, &asInt); err == nil && asInt > 0 {
244+ return time.Duration(asInt) * time.Second
245+ }
246+
247+ return defaultInterval
248+}
249+
250+func codexDeviceIsSuccessStatus(code int) bool {
251+ return code >= 200 && code < 300
252+}
253+
254+func (a *CodexAuthenticator) buildAuthRecord(authSvc *codex.CodexAuth, authBundle *codex.CodexAuthBundle) (*coreauth.Auth, error) {
255+ tokenStorage := authSvc.CreateTokenStorage(authBundle)
256+
257+ if tokenStorage == nil || tokenStorage.Email == "" {
258+ return nil, fmt.Errorf("codex token storage missing account information")
259+ }
260+
261+ planType := ""
262+ hashAccountID := ""
263+ if tokenStorage.IDToken != "" {
264+ if claims, errParse := codex.ParseJWTToken(tokenStorage.IDToken); errParse == nil && claims != nil {
265+ planType = strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType)
266+ accountID := strings.TrimSpace(claims.CodexAuthInfo.ChatgptAccountID)
267+ if accountID != "" {
268+ digest := sha256.Sum256([]byte(accountID))
269+ hashAccountID = hex.EncodeToString(digest[:])[:8]
270+ }
271+ }
272+ }
273+
274+ fileName := codex.CredentialFileName(tokenStorage.Email, planType, hashAccountID, true)
275+ metadata := map[string]any{
276+ "email": tokenStorage.Email,
277+ }
278+
279+ fmt.Println("Codex authentication successful")
280+ if authBundle.APIKey != "" {
281+ fmt.Println("Codex API key obtained and stored")
282+ }
283+
284+ return &coreauth.Auth{
285+ ID: fileName,
286+ Provider: a.Provider(),
287+ FileName: fileName,
288+ Storage: tokenStorage,
289+ Metadata: metadata,
290+ }, nil
291+}
sdk/auth/filestore.go+8−0
@@ -64,8 +64,16 @@ func (s *FileTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (str · 63 unmodified lines
6464 return "", fmt.Errorf("auth filestore: create dir failed: %w", err)
6565 }
6666
67+ // metadataSetter is a private interface for TokenStorage implementations that support metadata injection.
68+ type metadataSetter interface {
69+ SetMetadata(map[string]any)
70+ }
71+
6772 switch {
6873 case auth.Storage != nil:
74+ if setter, ok := auth.Storage.(metadataSetter); ok {
75+ setter.SetMetadata(auth.Metadata)
76+ }
6977 if err = auth.Storage.SaveTokenToFile(path); err != nil {
7078 return "", err
7179 }
sdk/cliproxy/auth/conductor.go+20−5
@@ -60,6 +60,7 @@ type RefreshEvaluator interface { · 59 unmodified lines
6060
6161 const (
6262 refreshCheckInterval = 5 * time.Second
63+ refreshMaxConcurrency = 16
6364 refreshPendingBackoff = time.Minute
6465 refreshFailureBackoff = 5 * time.Minute
6566 quotaBackoffBase = time.Second
@@ -155,7 +156,8 @@ type Manager struct { · 89 unmodified lines
155156 rtProvider RoundTripperProvider
156157
157158 // Auto refresh state
158− refreshCancel context.CancelFunc
159+ refreshCancel context.CancelFunc
160+ refreshSemaphore chan struct{}
159161 }
160162
161163 // NewManager constructs a manager with optional custom selector and hook.
@@ -173,6 +175,7 @@ func NewManager(store Store, selector Selector, hook Hook) *Manager { · 11 unmodified lines
173175 hook: hook,
174176 auths: make(map[string]*Auth),
175177 providerOffsets: make(map[string]int),
178+ refreshSemaphore: make(chan struct{}, refreshMaxConcurrency),
176179 }
177180 // atomic.Value requires non-nil initial value.
178181 manager.runtimeConfig.Store(&internalconfig.Config{})
@@ -1828,9 +1831,7 @@ func (m *Manager) persist(ctx context.Context, auth *Auth) error { · 1649 unmodified lines
18281831 // every few seconds and triggers refresh operations when required.
18291832 // Only one loop is kept alive; starting a new one cancels the previous run.
18301833 func (m *Manager) StartAutoRefresh(parent context.Context, interval time.Duration) {
1831− if interval <= 0 || interval > refreshCheckInterval {
1832− interval = refreshCheckInterval
1833− } else {
1834+ if interval <= 0 {
18341835 interval = refreshCheckInterval
18351836 }
18361837 if m.refreshCancel != nil {
@@ -1880,11 +1881,25 @@ func (m *Manager) checkRefreshes(ctx context.Context) { · 43 unmodified lines
18801881 if !m.markRefreshPending(a.ID, now) {
18811882 continue
18821883 }
1883− go m.refreshAuth(ctx, a.ID)
1884+ go m.refreshAuthWithLimit(ctx, a.ID)
18841885 }
18851886 }
18861887 }
18871888
1889+func (m *Manager) refreshAuthWithLimit(ctx context.Context, id string) {
1890+ if m.refreshSemaphore == nil {
1891+ m.refreshAuth(ctx, id)
1892+ return
1893+ }
1894+ select {
1895+ case m.refreshSemaphore <- struct{}{}:
1896+ defer func() { <-m.refreshSemaphore }()
1897+ case <-ctx.Done():
1898+ return
1899+ }
1900+ m.refreshAuth(ctx, id)
1901+}
1902+
18881903 func (m *Manager) snapshotAuths() []*Auth {
18891904 m.mu.RLock()
18901905 defer m.mu.RUnlock()
sdk/cliproxy/auth/selector.go+75−5
@@ -5,6 +5,7 @@ import ( · 4 unmodified lines
55 "encoding/json"
66 "fmt"
77 "math"
8+ "math/rand/v2"
89 "net/http"
910 "sort"
1011 "strconv"
@@ -248,6 +249,9 @@ func getAvailableAuths(auths []*Auth, provider, model string, now time.Time) ([] · 237 unmodified lines
248249 }
249250
250251 // Pick selects the next available auth for the provider in a round-robin manner.
252+// For gemini-cli virtual auths (identified by the gemini_virtual_parent attribute),
253+// a two-level round-robin is used: first cycling across credential groups (parent
254+// accounts), then cycling within each group's project auths.
251255 func (s *RoundRobinSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) {
252256 _ = opts
253257 now := time.Now()
@@ -265,21 +269,87 @@ func (s *RoundRobinSelector) Pick(ctx context.Context, provider, model string, o · 11 unmodified lines
265269 if limit <= 0 {
266270 limit = 4096
267271 }
268− if _, ok := s.cursors[key]; !ok && len(s.cursors) >= limit {
269− s.cursors = make(map[string]int)
272+
273+ // Check if any available auth has gemini_virtual_parent attribute,
274+ // indicating gemini-cli virtual auths that should use credential-level polling.
275+ groups, parentOrder := groupByVirtualParent(available)
276+ if len(parentOrder) > 1 {
277+ // Two-level round-robin: first select a credential group, then pick within it.
278+ groupKey := key + "::group"
279+ s.ensureCursorKey(groupKey, limit)
280+ if _, exists := s.cursors[groupKey]; !exists {
281+ // Seed with a random initial offset so the starting credential is randomized.
282+ s.cursors[groupKey] = rand.IntN(len(parentOrder))
283+ }
284+ groupIndex := s.cursors[groupKey]
285+ if groupIndex >= 2_147_483_640 {
286+ groupIndex = 0
287+ }
288+ s.cursors[groupKey] = groupIndex + 1
289+
290+ selectedParent := parentOrder[groupIndex%len(parentOrder)]
291+ group := groups[selectedParent]
292+
293+ // Second level: round-robin within the selected credential group.
294+ innerKey := key + "::cred:" + selectedParent
295+ s.ensureCursorKey(innerKey, limit)
296+ innerIndex := s.cursors[innerKey]
297+ if innerIndex >= 2_147_483_640 {
298+ innerIndex = 0
299+ }
300+ s.cursors[innerKey] = innerIndex + 1
301+ s.mu.Unlock()
302+ return group[innerIndex%len(group)], nil
270303 }
271− index := s.cursors[key]
272304
305+ // Flat round-robin for non-grouped auths (original behavior).
306+ s.ensureCursorKey(key, limit)
307+ index := s.cursors[key]
273308 if index >= 2_147_483_640 {
274309 index = 0
275310 }
276−
277311 s.cursors[key] = index + 1
278312 s.mu.Unlock()
279− // log.Debugf("available: %d, index: %d, key: %d", len(available), index, index%len(available))
280313 return available[index%len(available)], nil
281314 }
282315
316+// ensureCursorKey ensures the cursor map has capacity for the given key.
317+// Must be called with s.mu held.
318+func (s *RoundRobinSelector) ensureCursorKey(key string, limit int) {
319+ if _, ok := s.cursors[key]; !ok && len(s.cursors) >= limit {
320+ s.cursors = make(map[string]int)
321+ }
322+}
323+
324+// groupByVirtualParent groups auths by their gemini_virtual_parent attribute.
325+// Returns a map of parentID -> auths and a sorted slice of parent IDs for stable iteration.
326+// Only auths with a non-empty gemini_virtual_parent are grouped; if any auth lacks
327+// this attribute, nil/nil is returned so the caller falls back to flat round-robin.
328+func groupByVirtualParent(auths []*Auth) (map[string][]*Auth, []string) {
329+ if len(auths) == 0 {
330+ return nil, nil
331+ }
332+ groups := make(map[string][]*Auth)
333+ for _, a := range auths {
334+ parent := ""
335+ if a.Attributes != nil {
336+ parent = strings.TrimSpace(a.Attributes["gemini_virtual_parent"])
337+ }
338+ if parent == "" {
339+ // Non-virtual auth present; fall back to flat round-robin.
340+ return nil, nil
341+ }
342+ groups[parent] = append(groups[parent], a)
343+ }
344+ // Collect parent IDs in sorted order for stable cursor indexing.
345+ parentOrder := make([]string, 0, len(groups))
346+ for p := range groups {
347+ parentOrder = append(parentOrder, p)
348+ }
349+ sort.Strings(parentOrder)
350+ return groups, parentOrder
351+}
352+
283353 // Pick selects the first available auth for the provider in a deterministic manner.
284354 func (s *FillFirstSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) {
285355 _ = opts
sdk/cliproxy/auth/selector_test.go+125−0
@@ -402,3 +402,128 @@ func TestRoundRobinSelectorPick_CursorKeyCap(t *testing.T) { · 401 unmodified lines
402402 t.Fatalf("selector.cursors missing key %q", "gemini:m3")
403403 }
404404 }
405+
406+func TestRoundRobinSelectorPick_GeminiCLICredentialGrouping(t *testing.T) {
407+ t.Parallel()
408+
409+ selector := &RoundRobinSelector{}
410+
411+ // Simulate two gemini-cli credentials, each with multiple projects:
412+ // Credential A (parent = "cred-a.json") has 3 projects
413+ // Credential B (parent = "cred-b.json") has 2 projects
414+ auths := []*Auth{
415+ {ID: "cred-a.json::proj-a1", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}},
416+ {ID: "cred-a.json::proj-a2", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}},
417+ {ID: "cred-a.json::proj-a3", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}},
418+ {ID: "cred-b.json::proj-b1", Attributes: map[string]string{"gemini_virtual_parent": "cred-b.json"}},
419+ {ID: "cred-b.json::proj-b2", Attributes: map[string]string{"gemini_virtual_parent": "cred-b.json"}},
420+ }
421+
422+ // Two-level round-robin: consecutive picks must alternate between credentials.
423+ // Credential group order is randomized, but within each call the group cursor
424+ // advances by 1, so consecutive picks should cycle through different parents.
425+ picks := make([]string, 6)
426+ parents := make([]string, 6)
427+ for i := 0; i < 6; i++ {
428+ got, err := selector.Pick(context.Background(), "gemini-cli", "gemini-2.5-pro", cliproxyexecutor.Options{}, auths)
429+ if err != nil {
430+ t.Fatalf("Pick() #%d error = %v", i, err)
431+ }
432+ if got == nil {
433+ t.Fatalf("Pick() #%d auth = nil", i)
434+ }
435+ picks[i] = got.ID
436+ parents[i] = got.Attributes["gemini_virtual_parent"]
437+ }
438+
439+ // Verify property: consecutive picks must alternate between credential groups.
440+ for i := 1; i < len(parents); i++ {
441+ if parents[i] == parents[i-1] {
442+ t.Fatalf("Pick() #%d and #%d both from same parent %q (IDs: %q, %q); expected alternating credentials",
443+ i-1, i, parents[i], picks[i-1], picks[i])
444+ }
445+ }
446+
447+ // Verify property: each credential's projects are picked in sequence (round-robin within group).
448+ credPicks := map[string][]string{}
449+ for i, id := range picks {
450+ credPicks[parents[i]] = append(credPicks[parents[i]], id)
451+ }
452+ for parent, ids := range credPicks {
453+ for i := 1; i < len(ids); i++ {
454+ if ids[i] == ids[i-1] {
455+ t.Fatalf("Credential %q picked same project %q twice in a row", parent, ids[i])
456+ }
457+ }
458+ }
459+}
460+
461+func TestRoundRobinSelectorPick_SingleParentFallsBackToFlat(t *testing.T) {
462+ t.Parallel()
463+
464+ selector := &RoundRobinSelector{}
465+
466+ // All auths from the same parent - should fall back to flat round-robin
467+ // because there's only one credential group (no benefit from two-level).
468+ auths := []*Auth{
469+ {ID: "cred-a.json::proj-a1", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}},
470+ {ID: "cred-a.json::proj-a2", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}},
471+ {ID: "cred-a.json::proj-a3", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}},
472+ }
473+
474+ // With single parent group, parentOrder has length 1, so it uses flat round-robin.
475+ // Sorted by ID: proj-a1, proj-a2, proj-a3
476+ want := []string{
477+ "cred-a.json::proj-a1",
478+ "cred-a.json::proj-a2",
479+ "cred-a.json::proj-a3",
480+ "cred-a.json::proj-a1",
481+ }
482+
483+ for i, expectedID := range want {
484+ got, err := selector.Pick(context.Background(), "gemini-cli", "gemini-2.5-pro", cliproxyexecutor.Options{}, auths)
485+ if err != nil {
486+ t.Fatalf("Pick() #%d error = %v", i, err)
487+ }
488+ if got == nil {
489+ t.Fatalf("Pick() #%d auth = nil", i)
490+ }
491+ if got.ID != expectedID {
492+ t.Fatalf("Pick() #%d auth.ID = %q, want %q", i, got.ID, expectedID)
493+ }
494+ }
495+}
496+
497+func TestRoundRobinSelectorPick_MixedVirtualAndNonVirtualFallsBackToFlat(t *testing.T) {
498+ t.Parallel()
499+
500+ selector := &RoundRobinSelector{}
501+
502+ // Mix of virtual and non-virtual auths (e.g., a regular gemini-cli auth without projects
503+ // alongside virtual ones). Should fall back to flat round-robin.
504+ auths := []*Auth{
505+ {ID: "cred-a.json::proj-a1", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}},
506+ {ID: "cred-regular.json"}, // no gemini_virtual_parent
507+ }
508+
509+ // groupByVirtualParent returns nil when any auth lacks the attribute,
510+ // so flat round-robin is used. Sorted by ID: cred-a.json::proj-a1, cred-regular.json
511+ want := []string{
512+ "cred-a.json::proj-a1",
513+ "cred-regular.json",
514+ "cred-a.json::proj-a1",
515+ }
516+
517+ for i, expectedID := range want {
518+ got, err := selector.Pick(context.Background(), "gemini-cli", "", cliproxyexecutor.Options{}, auths)
519+ if err != nil {
520+ t.Fatalf("Pick() #%d error = %v", i, err)
521+ }
522+ if got == nil {
523+ t.Fatalf("Pick() #%d auth = nil", i)
524+ }
525+ if got.ID != expectedID {
526+ t.Fatalf("Pick() #%d auth.ID = %q, want %q", i, got.ID, expectedID)
527+ }
528+ }
529+}
sdk/cliproxy/auth/types.go+30−0
@@ -1,9 +1,12 @@
11 package auth
22
33 import (
4+ "context"
45 "crypto/sha256"
56 "encoding/hex"
67 "encoding/json"
8+ "net/http"
9+ "net/url"
710 "strconv"
811 "strings"
912 "sync"
@@ -12,6 +15,33 @@ import ( · 2 unmodified lines
1215 baseauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth"
1316 )
1417
18+// PostAuthHook defines a function that is called after an Auth record is created
19+// but before it is persisted to storage. This allows for modification of the
20+// Auth record (e.g., injecting metadata) based on external context.
21+type PostAuthHook func(context.Context, *Auth) error
22+
23+// RequestInfo holds information extracted from the HTTP request.
24+// It is injected into the context passed to PostAuthHook.
25+type RequestInfo struct {
26+ Query url.Values
27+ Headers http.Header
28+}
29+
30+type requestInfoKey struct{}
31+
32+// WithRequestInfo returns a new context with the given RequestInfo attached.
33+func WithRequestInfo(ctx context.Context, info *RequestInfo) context.Context {
34+ return context.WithValue(ctx, requestInfoKey{}, info)
35+}
36+
37+// GetRequestInfo retrieves the RequestInfo from the context, if present.
38+func GetRequestInfo(ctx context.Context) *RequestInfo {
39+ if val, ok := ctx.Value(requestInfoKey{}).(*RequestInfo); ok {
40+ return val
41+ }
42+ return nil
43+}
44+
1545 // Auth encapsulates the runtime state and metadata associated with a single credential.
1646 type Auth struct {
1747 // ID uniquely identifies the auth record across restarts.
sdk/cliproxy/builder.go+10−0
@@ -153,6 +153,16 @@ func (b *Builder) WithLocalManagementPassword(password string) *Builder { · 152 unmodified lines
153153 return b
154154 }
155155
156+// WithPostAuthHook registers a hook to be called after an Auth record is created
157+// but before it is persisted to storage.
158+func (b *Builder) WithPostAuthHook(hook coreauth.PostAuthHook) *Builder {
159+ if hook == nil {
160+ return b
161+ }
162+ b.serverOptions = append(b.serverOptions, api.WithPostAuthHook(hook))
163+ return b
164+}
165+
156166 // Build validates inputs, applies defaults, and returns a ready-to-run service.
157167 func (b *Builder) Build() (*Service, error) {
158168 if b.cfg == nil {
sdk/cliproxy/service.go+53−0
@@ -925,6 +925,9 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { · 924 unmodified lines
925925 key = strings.ToLower(strings.TrimSpace(a.Provider))
926926 }
927927 GlobalModelRegistry().RegisterClient(a.ID, key, applyModelPrefixes(models, a.Prefix, s.cfg != nil && s.cfg.ForceModelPrefix))
928+ if provider == "antigravity" {
929+ s.backfillAntigravityModels(a, models)
930+ }
928931 return
929932 }
930933
@@ -1069,6 +1072,56 @@ func (s *Service) oauthExcludedModels(provider, authKind string) []string { · 138 unmodified lines
10691072 return cfg.OAuthExcludedModels[providerKey]
10701073 }
10711074
1075+func (s *Service) backfillAntigravityModels(source *coreauth.Auth, primaryModels []*ModelInfo) {
1076+ if s == nil || s.coreManager == nil || len(primaryModels) == 0 {
1077+ return
1078+ }
1079+
1080+ sourceID := ""
1081+ if source != nil {
1082+ sourceID = strings.TrimSpace(source.ID)
1083+ }
1084+
1085+ reg := registry.GetGlobalRegistry()
1086+ for _, candidate := range s.coreManager.List() {
1087+ if candidate == nil || candidate.Disabled {
1088+ continue
1089+ }
1090+ candidateID := strings.TrimSpace(candidate.ID)
1091+ if candidateID == "" || candidateID == sourceID {
1092+ continue
1093+ }
1094+ if !strings.EqualFold(strings.TrimSpace(candidate.Provider), "antigravity") {
1095+ continue
1096+ }
1097+ if len(reg.GetModelsForClient(candidateID)) > 0 {
1098+ continue
1099+ }
1100+
1101+ authKind := strings.ToLower(strings.TrimSpace(candidate.Attributes["auth_kind"]))
1102+ if authKind == "" {
1103+ if kind, _ := candidate.AccountInfo(); strings.EqualFold(kind, "api_key") {
1104+ authKind = "apikey"
1105+ }
1106+ }
1107+ excluded := s.oauthExcludedModels("antigravity", authKind)
1108+ if candidate.Attributes != nil {
1109+ if val, ok := candidate.Attributes["excluded_models"]; ok && strings.TrimSpace(val) != "" {
1110+ excluded = strings.Split(val, ",")
1111+ }
1112+ }
1113+
1114+ models := applyExcludedModels(primaryModels, excluded)
1115+ models = applyOAuthModelAlias(s.cfg, "antigravity", authKind, models)
1116+ if len(models) == 0 {
1117+ continue
1118+ }
1119+
1120+ reg.RegisterClient(candidateID, "antigravity", applyModelPrefixes(models, candidate.Prefix, s.cfg != nil && s.cfg.ForceModelPrefix))
1121+ log.Debugf("antigravity models backfilled for auth %s using primary model list", candidateID)
1122+ }
1123+}
1124+
10721125 func applyExcludedModels(models []*ModelInfo, excluded []string) []*ModelInfo {
10731126 if len(models) == 0 || len(excluded) == 0 {
10741127 return models
sdk/cliproxy/service_antigravity_backfill_test.go+135−0
@@ -0,0 +1,135 @@
1+package cliproxy
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+
8+ "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
9+ coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
10+ "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
11+)
12+
13+func TestBackfillAntigravityModels_RegistersMissingAuth(t *testing.T) {
14+ source := &coreauth.Auth{
15+ ID: "ag-backfill-source",
16+ Provider: "antigravity",
17+ Status: coreauth.StatusActive,
18+ Attributes: map[string]string{
19+ "auth_kind": "oauth",
20+ },
21+ }
22+ target := &coreauth.Auth{
23+ ID: "ag-backfill-target",
24+ Provider: "antigravity",
25+ Status: coreauth.StatusActive,
26+ Attributes: map[string]string{
27+ "auth_kind": "oauth",
28+ },
29+ }
30+
31+ manager := coreauth.NewManager(nil, nil, nil)
32+ if _, err := manager.Register(context.Background(), source); err != nil {
33+ t.Fatalf("register source auth: %v", err)
34+ }
35+ if _, err := manager.Register(context.Background(), target); err != nil {
36+ t.Fatalf("register target auth: %v", err)
37+ }
38+
39+ service := &Service{
40+ cfg: &config.Config{},
41+ coreManager: manager,
42+ }
43+
44+ reg := registry.GetGlobalRegistry()
45+ reg.UnregisterClient(source.ID)
46+ reg.UnregisterClient(target.ID)
47+ t.Cleanup(func() {
48+ reg.UnregisterClient(source.ID)
49+ reg.UnregisterClient(target.ID)
50+ })
51+
52+ primary := []*ModelInfo{
53+ {ID: "claude-sonnet-4-5"},
54+ {ID: "gemini-2.5-pro"},
55+ }
56+ reg.RegisterClient(source.ID, "antigravity", primary)
57+
58+ service.backfillAntigravityModels(source, primary)
59+
60+ got := reg.GetModelsForClient(target.ID)
61+ if len(got) != 2 {
62+ t.Fatalf("expected target auth to be backfilled with 2 models, got %d", len(got))
63+ }
64+
65+ ids := make(map[string]struct{}, len(got))
66+ for _, model := range got {
67+ if model == nil {
68+ continue
69+ }
70+ ids[strings.ToLower(strings.TrimSpace(model.ID))] = struct{}{}
71+ }
72+ if _, ok := ids["claude-sonnet-4-5"]; !ok {
73+ t.Fatal("expected backfilled model claude-sonnet-4-5")
74+ }
75+ if _, ok := ids["gemini-2.5-pro"]; !ok {
76+ t.Fatal("expected backfilled model gemini-2.5-pro")
77+ }
78+}
79+
80+func TestBackfillAntigravityModels_RespectsExcludedModels(t *testing.T) {
81+ source := &coreauth.Auth{
82+ ID: "ag-backfill-source-excluded",
83+ Provider: "antigravity",
84+ Status: coreauth.StatusActive,
85+ Attributes: map[string]string{
86+ "auth_kind": "oauth",
87+ },
88+ }
89+ target := &coreauth.Auth{
90+ ID: "ag-backfill-target-excluded",
91+ Provider: "antigravity",
92+ Status: coreauth.StatusActive,
93+ Attributes: map[string]string{
94+ "auth_kind": "oauth",
95+ "excluded_models": "gemini-2.5-pro",
96+ },
97+ }
98+
99+ manager := coreauth.NewManager(nil, nil, nil)
100+ if _, err := manager.Register(context.Background(), source); err != nil {
101+ t.Fatalf("register source auth: %v", err)
102+ }
103+ if _, err := manager.Register(context.Background(), target); err != nil {
104+ t.Fatalf("register target auth: %v", err)
105+ }
106+
107+ service := &Service{
108+ cfg: &config.Config{},
109+ coreManager: manager,
110+ }
111+
112+ reg := registry.GetGlobalRegistry()
113+ reg.UnregisterClient(source.ID)
114+ reg.UnregisterClient(target.ID)
115+ t.Cleanup(func() {
116+ reg.UnregisterClient(source.ID)
117+ reg.UnregisterClient(target.ID)
118+ })
119+
120+ primary := []*ModelInfo{
121+ {ID: "claude-sonnet-4-5"},
122+ {ID: "gemini-2.5-pro"},
123+ }
124+ reg.RegisterClient(source.ID, "antigravity", primary)
125+
126+ service.backfillAntigravityModels(source, primary)
127+
128+ got := reg.GetModelsForClient(target.ID)
129+ if len(got) != 1 {
130+ t.Fatalf("expected 1 model after exclusion, got %d", len(got))
131+ }
132+ if got[0] == nil || !strings.EqualFold(strings.TrimSpace(got[0].ID), "claude-sonnet-4-5") {
133+ t.Fatalf("expected remaining model %q, got %+v", "claude-sonnet-4-5", got[0])
134+ }
135+}