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
58 // Command-line flags to control the application's behavior.58 // Command-line flags to control the application's behavior.
59 var login bool59 var login bool
60 var codexLogin bool60 var codexLogin bool
61 var codexDeviceLogin bool
61 var claudeLogin bool62 var claudeLogin bool
62 var qwenLogin bool63 var qwenLogin bool
63 var iflowLogin bool64 var iflowLogin bool
@@ -76,6 +77,7 @@ func main() { · 12 unmodified lines
76 // Define command-line flags for different operation modes.77 // Define command-line flags for different operation modes.
77 flag.BoolVar(&login, "login", false, "Login Google Account")78 flag.BoolVar(&login, "login", false, "Login Google Account")
78 flag.BoolVar(&codexLogin, "codex-login", false, "Login to Codex using OAuth")79 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")
79 flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth")81 flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth")
80 flag.BoolVar(&qwenLogin, "qwen-login", false, "Login to Qwen using OAuth")82 flag.BoolVar(&qwenLogin, "qwen-login", false, "Login to Qwen using OAuth")
81 flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth")83 flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth")
@@ -467,6 +469,9 @@ func main() { · 385 unmodified lines
467 } else if codexLogin {469 } else if codexLogin {
468 // Handle Codex login470 // Handle Codex login
469 cmd.DoCodexLogin(cfg, options)471 cmd.DoCodexLogin(cfg, options)
472 } else if codexDeviceLogin {
473 // Handle Codex device-code login
474 cmd.DoCodexDeviceLogin(cfg, options)
470 } else if claudeLogin {475 } else if claudeLogin {
471 // Handle Claude login476 // Handle Claude login
472 cmd.DoClaudeLogin(cfg, options)477 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
408 if !auth.LastRefreshedAt.IsZero() {408 if !auth.LastRefreshedAt.IsZero() {
409 entry["last_refresh"] = auth.LastRefreshedAt409 entry["last_refresh"] = auth.LastRefreshedAt
410 }410 }
411 if !auth.NextRetryAfter.IsZero() {
412 entry["next_retry_after"] = auth.NextRetryAfter
413 }
411 if path != "" {414 if path != "" {
412 entry["path"] = path415 entry["path"] = path
413 entry["source"] = "file"416 entry["source"] = "file"
@@ -947,11 +950,17 @@ func (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (s · 533 unmodified lines
947 if store == nil {950 if store == nil {
948 return "", fmt.Errorf("token store unavailable")951 return "", fmt.Errorf("token store unavailable")
949 }952 }
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 }
950 return store.Save(ctx, record)958 return store.Save(ctx, record)
951}959}
952 960
953func (h *Handler) RequestAnthropicToken(c *gin.Context) {961func (h *Handler) RequestAnthropicToken(c *gin.Context) {
954 ctx := context.Background()962 ctx := context.Background()
963 ctx = PopulateAuthContext(ctx, c)
955 964
956 fmt.Println("Initializing Claude authentication...")965 fmt.Println("Initializing Claude authentication...")
957 966
@@ -1096,6 +1105,7 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) { · 138 unmodified lines
1096 1105
1097func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {1106func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
1098 ctx := context.Background()1107 ctx := context.Background()
1108 ctx = PopulateAuthContext(ctx, c)
1099 proxyHTTPClient := util.SetProxy(&h.cfg.SDKConfig, &http.Client{})1109 proxyHTTPClient := util.SetProxy(&h.cfg.SDKConfig, &http.Client{})
1100 ctx = context.WithValue(ctx, oauth2.HTTPClient, proxyHTTPClient)1110 ctx = context.WithValue(ctx, oauth2.HTTPClient, proxyHTTPClient)
1101 1111
@@ -1354,6 +1364,7 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) { · 252 unmodified lines
1354 1364
1355func (h *Handler) RequestCodexToken(c *gin.Context) {1365func (h *Handler) RequestCodexToken(c *gin.Context) {
1356 ctx := context.Background()1366 ctx := context.Background()
1367 ctx = PopulateAuthContext(ctx, c)
1357 1368
1358 fmt.Println("Initializing Codex authentication...")1369 fmt.Println("Initializing Codex authentication...")
1359 1370
@@ -1499,6 +1510,7 @@ func (h *Handler) RequestCodexToken(c *gin.Context) { · 139 unmodified lines
1499 1510
1500func (h *Handler) RequestAntigravityToken(c *gin.Context) {1511func (h *Handler) RequestAntigravityToken(c *gin.Context) {
1501 ctx := context.Background()1512 ctx := context.Background()
1513 ctx = PopulateAuthContext(ctx, c)
1502 1514
1503 fmt.Println("Initializing Antigravity authentication...")1515 fmt.Println("Initializing Antigravity authentication...")
1504 1516
@@ -1663,6 +1675,7 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) { · 158 unmodified lines
1663 1675
1664func (h *Handler) RequestQwenToken(c *gin.Context) {1676func (h *Handler) RequestQwenToken(c *gin.Context) {
1665 ctx := context.Background()1677 ctx := context.Background()
1678 ctx = PopulateAuthContext(ctx, c)
1666 1679
1667 fmt.Println("Initializing Qwen authentication...")1680 fmt.Println("Initializing Qwen authentication...")
1668 1681
@@ -1718,6 +1731,7 @@ func (h *Handler) RequestQwenToken(c *gin.Context) { · 49 unmodified lines
1718 1731
1719func (h *Handler) RequestKimiToken(c *gin.Context) {1732func (h *Handler) RequestKimiToken(c *gin.Context) {
1720 ctx := context.Background()1733 ctx := context.Background()
1734 ctx = PopulateAuthContext(ctx, c)
1721 1735
1722 fmt.Println("Initializing Kimi authentication...")1736 fmt.Println("Initializing Kimi authentication...")
1723 1737
@@ -1794,6 +1808,7 @@ func (h *Handler) RequestKimiToken(c *gin.Context) { · 70 unmodified lines
1794 1808
1795func (h *Handler) RequestIFlowToken(c *gin.Context) {1809func (h *Handler) RequestIFlowToken(c *gin.Context) {
1796 ctx := context.Background()1810 ctx := context.Background()
1811 ctx = PopulateAuthContext(ctx, c)
1797 1812
1798 fmt.Println("Initializing iFlow authentication...")1813 fmt.Println("Initializing iFlow authentication...")
1799 1814
@@ -2412,3 +2427,12 @@ func (h *Handler) GetAuthStatus(c *gin.Context) { · 612 unmodified lines
2412 }2427 }
2413 c.JSON(http.StatusOK, gin.H{"status": "wait"})2428 c.JSON(http.StatusOK, gin.H{"status": "wait"})
2414}2429}
2430
2431// PopulateAuthContext extracts request info and adds it to the context
2432func 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
47 allowRemoteOverride bool47 allowRemoteOverride bool
48 envSecret string48 envSecret string
49 logDir string49 logDir string
50 postAuthHook coreauth.PostAuthHook
50}51}
51 52
52// NewHandler creates a new management handler instance.53// NewHandler creates a new management handler instance.
@@ -128,6 +129,11 @@ func (h *Handler) SetLogDirectory(dir string) { · 75 unmodified lines
128 h.logDir = dir129 h.logDir = dir
129}130}
130 131
132// SetPostAuthHook registers a hook to be called after auth record creation but before persistence.
133func (h *Handler) SetPostAuthHook(hook coreauth.PostAuthHook) {
134 h.postAuthHook = hook
135}
136
131// Middleware enforces access control for management endpoints.137// Middleware enforces access control for management endpoints.
132// All requests (local and remote) require a valid management key.138// All requests (local and remote) require a valid management key.
133// Additionally, remote access requires allow-remote-management=true.139// Additionally, remote access requires allow-remote-management=true.
internal/api/server.go+11−0
@@ -51,6 +51,7 @@ type serverOptionConfig struct { · 50 unmodified lines
51 keepAliveEnabled bool51 keepAliveEnabled bool
52 keepAliveTimeout time.Duration52 keepAliveTimeout time.Duration
53 keepAliveOnTimeout func()53 keepAliveOnTimeout func()
54 postAuthHook auth.PostAuthHook
54}55}
55 56
56// ServerOption customises HTTP server construction.57// ServerOption customises HTTP server construction.
@@ -111,6 +112,13 @@ func WithRequestLoggerFactory(factory func(*config.Config, string) logging.Reque · 54 unmodified lines
111 }112 }
112}113}
113 114
115// WithPostAuthHook registers a hook to be called after auth record creation.
116func WithPostAuthHook(hook auth.PostAuthHook) ServerOption {
117 return func(cfg *serverOptionConfig) {
118 cfg.postAuthHook = hook
119 }
120}
121
114// Server represents the main API server.122// Server represents the main API server.
115// It encapsulates the Gin engine, HTTP server, handlers, and configuration.123// It encapsulates the Gin engine, HTTP server, handlers, and configuration.
116type Server struct {124type Server struct {
@@ -262,6 +270,9 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk · 145 unmodified lines
262 }270 }
263 logDir := logging.ResolveLogDirectory(cfg)271 logDir := logging.ResolveLogDirectory(cfg)
264 s.mgmt.SetLogDirectory(logDir)272 s.mgmt.SetLogDirectory(logDir)
273 if optionState.postAuthHook != nil {
274 s.mgmt.SetPostAuthHook(optionState.postAuthHook)
275 }
265 s.localPassword = optionState.localPassword276 s.localPassword = optionState.localPassword
266 277
267 // Setup routes278 // Setup routes
internal/auth/claude/token.go+17−1
@@ -36,11 +36,21 @@ type ClaudeTokenStorage struct { · 35 unmodified lines
36 36
37 // Expire is the timestamp when the current access token expires.37 // Expire is the timestamp when the current access token expires.
38 Expire string `json:"expired"`38 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.
46func (ts *ClaudeTokenStorage) SetMetadata(meta map[string]any) {
47 ts.Metadata = meta
39}48}
40 49
41// SaveTokenToFile serializes the Claude token storage to a JSON file.50// SaveTokenToFile serializes the Claude token storage to a JSON file.
42// This method creates the necessary directory structure and writes the token51// This method creates the necessary directory structure and writes the token
43// data in JSON format to the specified file path for persistent storage.52// data in JSON format to the specified file path for persistent storage.
53// It merges any injected metadata into the top-level JSON object.
44//54//
45// Parameters:55// Parameters:
46// - authFilePath: The full path where the token file should be saved56// - 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
65 _ = f.Close()75 _ = f.Close()
66 }()76 }()
67 77
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
68 // Encode and write the token data as JSON84 // 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 {
70 return fmt.Errorf("failed to write token to file: %w", err)86 return fmt.Errorf("failed to write token to file: %w", err)
71 }87 }
72 return nil88 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
71// It performs an HTTP POST request to the OpenAI token endpoint with the provided71// It performs an HTTP POST request to the OpenAI token endpoint with the provided
72// authorization code and PKCE verifier.72// authorization code and PKCE verifier.
73func (o *CodexAuth) ExchangeCodeForTokens(ctx context.Context, code string, pkceCodes *PKCECodes) (*CodexAuthBundle, error) {73func (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.
80func (o *CodexAuth) ExchangeCodeForTokensWithRedirect(ctx context.Context, code, redirectURI string, pkceCodes *PKCECodes) (*CodexAuthBundle, error) {
74 if pkceCodes == nil {81 if pkceCodes == nil {
75 return nil, fmt.Errorf("PKCE codes are required for token exchange")82 return nil, fmt.Errorf("PKCE codes are required for token exchange")
76 }83 }
84 if strings.TrimSpace(redirectURI) == "" {
85 return nil, fmt.Errorf("redirect URI is required for token exchange")
86 }
77 87
78 // Prepare token exchange request88 // Prepare token exchange request
79 data := url.Values{89 data := url.Values{
80 "grant_type": {"authorization_code"},90 "grant_type": {"authorization_code"},
81 "client_id": {ClientID},91 "client_id": {ClientID},
82 "code": {code},92 "code": {code},
83 "redirect_uri": {RedirectURI},93 "redirect_uri": {strings.TrimSpace(redirectURI)},
84 "code_verifier": {pkceCodes.CodeVerifier},94 "code_verifier": {pkceCodes.CodeVerifier},
85 }95 }
86 96
@@ -266,6 +276,10 @@ func (o *CodexAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken str · 179 unmodified lines
266 if err == nil {276 if err == nil {
267 return tokenData, nil277 return tokenData, nil
268 }278 }
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 }
269 283
270 lastErr = err284 lastErr = err
271 log.Warnf("Token refresh attempt %d failed: %v", attempt+1, err)285 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
274 return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr)288 return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr)
275}289}
276 290
291func 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
277// UpdateTokenStorage updates an existing CodexTokenStorage with new token data.299// UpdateTokenStorage updates an existing CodexTokenStorage with new token data.
278// This is typically called after a successful token refresh to persist the new credentials.300// This is typically called after a successful token refresh to persist the new credentials.
279func (o *CodexAuth) UpdateTokenStorage(storage *CodexTokenStorage, tokenData *CodexTokenData) {301func (o *CodexAuth) UpdateTokenStorage(storage *CodexTokenStorage, tokenData *CodexTokenData) {
internal/auth/codex/openai_auth_test.go+44−0
@@ -0,0 +1,44 @@
1package codex
2
3import (
4 "context"
5 "io"
6 "net/http"
7 "strings"
8 "sync/atomic"
9 "testing"
10)
11
12type roundTripFunc func(*http.Request) (*http.Response, error)
13
14func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
15 return f(req)
16}
17
18func 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
32 Type string `json:"type"`32 Type string `json:"type"`
33 // Expire is the timestamp when the current access token expires.33 // Expire is the timestamp when the current access token expires.
34 Expire string `json:"expired"`34 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.
42func (ts *CodexTokenStorage) SetMetadata(meta map[string]any) {
43 ts.Metadata = meta
35}44}
36 45
37// SaveTokenToFile serializes the Codex token storage to a JSON file.46// SaveTokenToFile serializes the Codex token storage to a JSON file.
38// This method creates the necessary directory structure and writes the token47// This method creates the necessary directory structure and writes the token
39// data in JSON format to the specified file path for persistent storage.48// data in JSON format to the specified file path for persistent storage.
49// It merges any injected metadata into the top-level JSON object.
40//50//
41// Parameters:51// Parameters:
42// - authFilePath: The full path where the token file should be saved52// - 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
58 _ = f.Close()68 _ = f.Close()
59 }()69 }()
60 70
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 {
62 return fmt.Errorf("failed to write token to file: %w", err)78 return fmt.Errorf("failed to write token to file: %w", err)
63 }79 }
64 return nil80 return nil
internal/auth/gemini/gemini_token.go+18−1
@@ -35,11 +35,21 @@ type GeminiTokenStorage struct { · 34 unmodified lines
35 35
36 // Type indicates the authentication provider type, always "gemini" for this storage.36 // Type indicates the authentication provider type, always "gemini" for this storage.
37 Type string `json:"type"`37 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.
45func (ts *GeminiTokenStorage) SetMetadata(meta map[string]any) {
46 ts.Metadata = meta
38}47}
39 48
40// SaveTokenToFile serializes the Gemini token storage to a JSON file.49// SaveTokenToFile serializes the Gemini token storage to a JSON file.
41// This method creates the necessary directory structure and writes the token50// This method creates the necessary directory structure and writes the token
42// data in JSON format to the specified file path for persistent storage.51// data in JSON format to the specified file path for persistent storage.
52// It merges any injected metadata into the top-level JSON object.
43//53//
44// Parameters:54// Parameters:
45// - authFilePath: The full path where the token file should be saved55// - authFilePath: The full path where the token file should be saved
@@ -49,6 +59,11 @@ type GeminiTokenStorage struct { · 3 unmodified lines
49func (ts *GeminiTokenStorage) SaveTokenToFile(authFilePath string) error {59func (ts *GeminiTokenStorage) SaveTokenToFile(authFilePath string) error {
50 misc.LogSavingCredentials(authFilePath)60 misc.LogSavingCredentials(authFilePath)
51 ts.Type = "gemini"61 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 }
52 if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {67 if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
53 return fmt.Errorf("failed to create directory: %v", err)68 return fmt.Errorf("failed to create directory: %v", err)
54 }69 }
@@ -63,7 +78,9 @@ func (ts *GeminiTokenStorage) SaveTokenToFile(authFilePath string) error { · 8 unmodified lines
63 }78 }
64 }()79 }()
65 80
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 {
67 return fmt.Errorf("failed to write token to file: %w", err)84 return fmt.Errorf("failed to write token to file: %w", err)
68 }85 }
69 return nil86 return nil
internal/auth/iflow/iflow_token.go+16−1
@@ -21,6 +21,15 @@ type IFlowTokenStorage struct { · 20 unmodified lines
21 Scope string `json:"scope"`21 Scope string `json:"scope"`
22 Cookie string `json:"cookie"`22 Cookie string `json:"cookie"`
23 Type string `json:"type"`23 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.
31func (ts *IFlowTokenStorage) SetMetadata(meta map[string]any) {
32 ts.Metadata = meta
24}33}
25 34
26// SaveTokenToFile serialises the token storage to disk.35// SaveTokenToFile serialises the token storage to disk.
@@ -37,7 +46,13 @@ func (ts *IFlowTokenStorage) SaveTokenToFile(authFilePath string) error { · 10 unmodified lines
37 }46 }
38 defer func() { _ = f.Close() }()47 defer func() { _ = f.Close() }()
39 48
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 {
41 return fmt.Errorf("iflow token: encode token failed: %w", err)56 return fmt.Errorf("iflow token: encode token failed: %w", err)
42 }57 }
43 return nil58 return nil
internal/auth/kimi/token.go+16−1
@@ -29,6 +29,15 @@ type KimiTokenStorage struct { · 28 unmodified lines
29 Expired string `json:"expired,omitempty"`29 Expired string `json:"expired,omitempty"`
30 // Type indicates the authentication provider type, always "kimi" for this storage.30 // Type indicates the authentication provider type, always "kimi" for this storage.
31 Type string `json:"type"`31 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.
39func (ts *KimiTokenStorage) SetMetadata(meta map[string]any) {
40 ts.Metadata = meta
32}41}
33 42
34// KimiTokenData holds the raw OAuth token response from Kimi.43// KimiTokenData holds the raw OAuth token response from Kimi.
@@ -86,9 +95,15 @@ func (ts *KimiTokenStorage) SaveTokenToFile(authFilePath string) error { · 51 unmodified lines
86 _ = f.Close()95 _ = f.Close()
87 }()96 }()
88 97
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
89 encoder := json.NewEncoder(f)104 encoder := json.NewEncoder(f)
90 encoder.SetIndent("", " ")105 encoder.SetIndent("", " ")
91 if err = encoder.Encode(ts); err != nil {106 if err = encoder.Encode(data); err != nil {
92 return fmt.Errorf("failed to write token to file: %w", err)107 return fmt.Errorf("failed to write token to file: %w", err)
93 }108 }
94 return nil109 return nil
internal/auth/qwen/qwen_token.go+17−1
@@ -30,11 +30,21 @@ type QwenTokenStorage struct { · 29 unmodified lines
30 Type string `json:"type"`30 Type string `json:"type"`
31 // Expire is the timestamp when the current access token expires.31 // Expire is the timestamp when the current access token expires.
32 Expire string `json:"expired"`32 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.
40func (ts *QwenTokenStorage) SetMetadata(meta map[string]any) {
41 ts.Metadata = meta
33}42}
34 43
35// SaveTokenToFile serializes the Qwen token storage to a JSON file.44// SaveTokenToFile serializes the Qwen token storage to a JSON file.
36// This method creates the necessary directory structure and writes the token45// This method creates the necessary directory structure and writes the token
37// data in JSON format to the specified file path for persistent storage.46// data in JSON format to the specified file path for persistent storage.
47// It merges any injected metadata into the top-level JSON object.
38//48//
39// Parameters:49// Parameters:
40// - authFilePath: The full path where the token file should be saved50// - 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
56 _ = f.Close()66 _ = f.Close()
57 }()67 }()
58 68
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 {
60 return fmt.Errorf("failed to write token to file: %w", err)76 return fmt.Errorf("failed to write token to file: %w", err)
61 }77 }
62 return nil78 return nil
internal/cmd/openai_device_login.go+60−0
@@ -0,0 +1,60 @@
1package cmd
2
3import (
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
15const (
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.
22func 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 @@
1package misc1package misc
2 2
3import (3import (
4 "encoding/json"
4 "fmt"5 "fmt"
5 "path/filepath"6 "path/filepath"
6 "strings"7 "strings"
@@ -24,3 +25,37 @@ func LogSavingCredentials(path string) { · 17 unmodified lines
24func LogCredentialSeparator() {25func LogCredentialSeparator() {
25 log.Debug(credentialSeparator)26 log.Debug(credentialSeparator)
26}27}
28
29// MergeMetadata serializes the source struct into a map and merges the provided metadata into it.
30func 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
904 Created int64904 Created int64
905 Thinking *ThinkingSupport905 Thinking *ThinkingSupport
906 }{906 }{
907 {ID: "tstars2.0", DisplayName: "TStars-2.0", Description: "iFlow TStars-2.0 multimodal assistant", Created: 1746489600},
908 {ID: "qwen3-coder-plus", DisplayName: "Qwen3-Coder-Plus", Description: "Qwen3 Coder Plus code generation", Created: 1753228800},907 {ID: "qwen3-coder-plus", DisplayName: "Qwen3-Coder-Plus", Description: "Qwen3 Coder Plus code generation", Created: 1753228800},
909 {ID: "qwen3-max", DisplayName: "Qwen3-Max", Description: "Qwen3 flagship model", Created: 1758672000},908 {ID: "qwen3-max", DisplayName: "Qwen3-Max", Description: "Qwen3 flagship model", Created: 1758672000},
910 {ID: "qwen3-vl-plus", DisplayName: "Qwen3-VL-Plus", Description: "Qwen3 multimodal vision-language", Created: 1758672000},909 {ID: "qwen3-vl-plus", DisplayName: "Qwen3-VL-Plus", Description: "Qwen3 multimodal vision-language", Created: 1758672000},
911 {ID: "qwen3-max-preview", DisplayName: "Qwen3-Max-Preview", Description: "Qwen3 Max preview build", Created: 1757030400, Thinking: iFlowThinkingSupport},910 {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},
913 {ID: "glm-4.6", DisplayName: "GLM-4.6", Description: "Zhipu GLM 4.6 general model", Created: 1759190400, Thinking: iFlowThinkingSupport},911 {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},
916 {ID: "kimi-k2", DisplayName: "Kimi-K2", Description: "Moonshot Kimi K2 general model", Created: 1752192000},912 {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},
920 {ID: "deepseek-v3.2", DisplayName: "DeepSeek-V3.2-Exp", Description: "DeepSeek V3.2 experimental", Created: 1759104000, Thinking: iFlowThinkingSupport},913 {ID: "deepseek-v3.2", DisplayName: "DeepSeek-V3.2-Exp", Description: "DeepSeek V3.2 experimental", Created: 1759104000, Thinking: iFlowThinkingSupport},
921 {ID: "deepseek-v3.1", DisplayName: "DeepSeek-V3.1-Terminus", Description: "DeepSeek V3.1 Terminus", Created: 1756339200, Thinking: iFlowThinkingSupport},914 {ID: "deepseek-v3.1", DisplayName: "DeepSeek-V3.1-Terminus", Description: "DeepSeek V3.1 Terminus", Created: 1756339200, Thinking: iFlowThinkingSupport},
922 {ID: "deepseek-r1", DisplayName: "DeepSeek-R1", Description: "DeepSeek reasoning model R1", Created: 1737331200},915 {ID: "deepseek-r1", DisplayName: "DeepSeek-R1", Description: "DeepSeek reasoning model R1", Created: 1737331200},
@@ -925,11 +918,7 @@ func GetIFlowModels() []*ModelInfo { · 2 unmodified lines
925 {ID: "qwen3-235b-a22b-thinking-2507", DisplayName: "Qwen3-235B-A22B-Thinking", Description: "Qwen3 235B A22B Thinking (2507)", Created: 1753401600},918 {ID: "qwen3-235b-a22b-thinking-2507", DisplayName: "Qwen3-235B-A22B-Thinking", Description: "Qwen3 235B A22B Thinking (2507)", Created: 1753401600},
926 {ID: "qwen3-235b-a22b-instruct", DisplayName: "Qwen3-235B-A22B-Instruct", Description: "Qwen3 235B A22B Instruct", Created: 1753401600},919 {ID: "qwen3-235b-a22b-instruct", DisplayName: "Qwen3-235B-A22B-Instruct", Description: "Qwen3 235B A22B Instruct", Created: 1753401600},
927 {ID: "qwen3-235b", DisplayName: "Qwen3-235B-A22B", Description: "Qwen3 235B A22B", Created: 1753401600},920 {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},
931 {ID: "iflow-rome-30ba3b", DisplayName: "iFlow-ROME", Description: "iFlow Rome 30BA3B model", Created: 1736899200},921 {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},
933 }922 }
934 models := make([]*ModelInfo, 0, len(entries))923 models := make([]*ModelInfo, 0, len(entries))
935 for _, entry := range entries {924 for _, entry := range entries {
@@ -963,6 +952,7 @@ func GetAntigravityModelConfig() map[string]*AntigravityModelConfig { · 27 unmodified lines
963 "gemini-2.5-flash-lite": {Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}},952 "gemini-2.5-flash-lite": {Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}},
964 "gemini-3-pro-high": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}},953 "gemini-3-pro-high": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}},
965 "gemini-3-pro-image": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}},954 "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"}}},
966 "gemini-3-flash": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}}},956 "gemini-3-flash": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}}},
967 "claude-sonnet-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000},957 "claude-sonnet-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000},
968 "claude-opus-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000},958 "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
55var (55var (
56 randSource = rand.New(rand.NewSource(time.Now().UnixNano()))56 randSource = rand.New(rand.NewSource(time.Now().UnixNano()))
57 randSourceMutex sync.Mutex57 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 }
58)64)
59 65
66func 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
83func 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
104func 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
115func loadAntigravityPrimaryModels() []*registry.ModelInfo {
116 antigravityPrimaryModelsCache.mu.RLock()
117 cloned := cloneAntigravityModels(antigravityPrimaryModelsCache.models)
118 antigravityPrimaryModelsCache.mu.RUnlock()
119 return cloned
120}
121
122func 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
60// AntigravityExecutor proxies requests to the antigravity upstream.130// AntigravityExecutor proxies requests to the antigravity upstream.
61type AntigravityExecutor struct {131type AntigravityExecutor struct {
62 cfg *config.Config132 cfg *config.Config
@@ -1072,7 +1142,7 @@ func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *c · 1009 unmodified lines
1072 exec := &AntigravityExecutor{cfg: cfg}1142 exec := &AntigravityExecutor{cfg: cfg}
1073 token, updatedAuth, errToken := exec.ensureAccessToken(ctx, auth)1143 token, updatedAuth, errToken := exec.ensureAccessToken(ctx, auth)
1074 if errToken != nil || token == "" {1144 if errToken != nil || token == "" {
1075 return nil1145 return fallbackAntigravityPrimaryModels()
1076 }1146 }
1077 if updatedAuth != nil {1147 if updatedAuth != nil {
1078 auth = updatedAuth1148 auth = updatedAuth
@@ -1096,7 +1166,7 @@ func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *c · 17 unmodified lines
1096 1166
1097 httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, modelsURL, bytes.NewReader(payload))1167 httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, modelsURL, bytes.NewReader(payload))
1098 if errReq != nil {1168 if errReq != nil {
1099 return nil1169 return fallbackAntigravityPrimaryModels()
1100 }1170 }
1101 httpReq.Close = true1171 httpReq.Close = true
1102 httpReq.Header.Set("Content-Type", "application/json")1172 httpReq.Header.Set("Content-Type", "application/json")
@@ -1109,14 +1179,13 @@ func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *c · 6 unmodified lines
1109 httpResp, errDo := httpClient.Do(httpReq)1179 httpResp, errDo := httpClient.Do(httpReq)
1110 if errDo != nil {1180 if errDo != nil {
1111 if errors.Is(errDo, context.Canceled) || errors.Is(errDo, context.DeadlineExceeded) {1181 if errors.Is(errDo, context.Canceled) || errors.Is(errDo, context.DeadlineExceeded) {
1112 return nil1182 return fallbackAntigravityPrimaryModels()
1113 }1183 }
1114 if idx+1 < len(baseURLs) {1184 if idx+1 < len(baseURLs) {
1115 log.Debugf("antigravity executor: models request error on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])1185 log.Debugf("antigravity executor: models request error on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
1116 continue1186 continue
1117 }1187 }
1118 log.Errorf("antigravity executor: models request failed: %v", errDo)1188 return fallbackAntigravityPrimaryModels()
1119 return nil
1120 }1189 }
1121 1190
1122 bodyBytes, errRead := io.ReadAll(httpResp.Body)1191 bodyBytes, errRead := io.ReadAll(httpResp.Body)
@@ -1128,21 +1197,27 @@ func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *c · 5 unmodified lines
1128 log.Debugf("antigravity executor: models read error on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])1197 log.Debugf("antigravity executor: models read error on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
1129 continue1198 continue
1130 }1199 }
1131 log.Errorf("antigravity executor: models read body failed: %v", errRead)1200 return fallbackAntigravityPrimaryModels()
1132 return nil
1133 }1201 }
1134 if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {1202 if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
1135 if httpResp.StatusCode == http.StatusTooManyRequests && idx+1 < len(baseURLs) {1203 if httpResp.StatusCode == http.StatusTooManyRequests && idx+1 < len(baseURLs) {
1136 log.Debugf("antigravity executor: models request rate limited on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])1204 log.Debugf("antigravity executor: models request rate limited on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
1137 continue1205 continue
1138 }1206 }
1139 log.Errorf("antigravity executor: models request error status %d: %s", httpResp.StatusCode, string(bodyBytes))1207 if idx+1 < len(baseURLs) {
1140 return nil1208 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()
1141 }1212 }
1142 1213
1143 result := gjson.GetBytes(bodyBytes, "models")1214 result := gjson.GetBytes(bodyBytes, "models")
1144 if !result.Exists() {1215 if !result.Exists() {
1145 return nil1216 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()
1146 }1221 }
1147 1222
1148 now := time.Now().Unix()1223 now := time.Now().Unix()
@@ -1210,9 +1285,18 @@ func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *c · 61 unmodified lines
1210 }1285 }
1211 models = append(models, modelInfo)1286 models = append(models, modelInfo)
1212 }1287 }
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)
1213 return models1297 return models
1214 }1298 }
1215 return nil1299 return fallbackAntigravityPrimaryModels()
1216}1300}
1217 1301
1218func (e *AntigravityExecutor) ensureAccessToken(ctx context.Context, auth *cliproxyauth.Auth) (string, *cliproxyauth.Auth, error) {1302func (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 @@
1package executor
2
3import (
4 "testing"
5
6 "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
7)
8
9func resetAntigravityPrimaryModelsCacheForTest() {
10 antigravityPrimaryModelsCache.mu.Lock()
11 antigravityPrimaryModelsCache.models = nil
12 antigravityPrimaryModelsCache.mu.Unlock()
13}
14
15func 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
43func 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
156 b, _ := io.ReadAll(httpResp.Body)156 b, _ := io.ReadAll(httpResp.Body)
157 appendAPIResponseChunk(ctx, e.cfg, b)157 appendAPIResponseChunk(ctx, e.cfg, b)
158 logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))158 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)
160 return resp, err160 return resp, err
161 }161 }
162 data, err := io.ReadAll(httpResp.Body)162 data, err := io.ReadAll(httpResp.Body)
@@ -260,7 +260,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A · 97 unmodified lines
260 b, _ := io.ReadAll(httpResp.Body)260 b, _ := io.ReadAll(httpResp.Body)
261 appendAPIResponseChunk(ctx, e.cfg, b)261 appendAPIResponseChunk(ctx, e.cfg, b)
262 logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))262 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)
264 return resp, err264 return resp, err
265 }265 }
266 data, err := io.ReadAll(httpResp.Body)266 data, err := io.ReadAll(httpResp.Body)
@@ -358,7 +358,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au · 91 unmodified lines
358 }358 }
359 appendAPIResponseChunk(ctx, e.cfg, data)359 appendAPIResponseChunk(ctx, e.cfg, data)
360 logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), data))360 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)
362 return nil, err362 return nil, err
363 }363 }
364 out := make(chan cliproxyexecutor.StreamChunk)364 out := make(chan cliproxyexecutor.StreamChunk)
@@ -673,6 +673,35 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s · 308 unmodified lines
673 util.ApplyCustomHeadersFromAttrs(r, attrs)673 util.ApplyCustomHeadersFromAttrs(r, attrs)
674}674}
675 675
676func 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
684func 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
676func codexCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {705func codexCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
677 if a == nil {706 if a == nil {
678 return "", ""707 return "", ""
internal/runtime/executor/codex_executor_retry_test.go+65−0
@@ -0,0 +1,65 @@
1package executor
2
3import (
4 "net/http"
5 "strconv"
6 "testing"
7 "time"
8)
9
10func 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
63func 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
8 "io"8 "io"
9 "net/http"9 "net/http"
10 "strings"10 "strings"
11 "sync"
11 "time"12 "time"
12 13
13 qwenauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"14 qwenauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
@@ -22,9 +23,151 @@ import ( · 8 unmodified lines
22)23)
23 24
24const (25const (
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
26)29)
27 30
31// qwenBeijingLoc caches the Beijing timezone to avoid repeated LoadLocation syscalls.
32var 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.
42var 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.
49var 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.
58func 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.
70func 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.
124func 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.
149func 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.
164func 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
28// QwenExecutor is a stateless executor for Qwen Code using OpenAI-compatible chat completions.171// QwenExecutor is a stateless executor for Qwen Code using OpenAI-compatible chat completions.
29// If access token is unavailable, it falls back to legacy via ClientAdapter.172// If access token is unavailable, it falls back to legacy via ClientAdapter.
30type QwenExecutor struct {173type QwenExecutor struct {
@@ -67,6 +210,17 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req · 36 unmodified lines
67 if opts.Alt == "responses/compact" {210 if opts.Alt == "responses/compact" {
68 return resp, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"}211 return resp, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"}
69 }212 }
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
70 baseModel := thinking.ParseSuffix(req.Model).ModelName224 baseModel := thinking.ParseSuffix(req.Model).ModelName
71 225
72 token, baseURL := qwenCreds(auth)226 token, baseURL := qwenCreds(auth)
@@ -102,9 +256,8 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req · 29 unmodified lines
102 return resp, err256 return resp, err
103 }257 }
104 applyQwenHeaders(httpReq, token, false)258 applyQwenHeaders(httpReq, token, false)
105 var authID, authLabel, authType, authValue string259 var authLabel, authType, authValue string
106 if auth != nil {260 if auth != nil {
107 authID = auth.ID
108 authLabel = auth.Label261 authLabel = auth.Label
109 authType, authValue = auth.AccountInfo()262 authType, authValue = auth.AccountInfo()
110 }263 }
@@ -135,8 +288,10 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req · 24 unmodified lines
135 if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {288 if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
136 b, _ := io.ReadAll(httpResp.Body)289 b, _ := io.ReadAll(httpResp.Body)
137 appendAPIResponseChunk(ctx, e.cfg, b)290 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))291
139 err = statusErr{code: httpResp.StatusCode, msg: string(b)}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}
140 return resp, err295 return resp, err
141 }296 }
142 data, err := io.ReadAll(httpResp.Body)297 data, err := io.ReadAll(httpResp.Body)
@@ -158,6 +313,17 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut · 15 unmodified lines
158 if opts.Alt == "responses/compact" {313 if opts.Alt == "responses/compact" {
159 return nil, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"}314 return nil, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"}
160 }315 }
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
161 baseModel := thinking.ParseSuffix(req.Model).ModelName327 baseModel := thinking.ParseSuffix(req.Model).ModelName
162 328
163 token, baseURL := qwenCreds(auth)329 token, baseURL := qwenCreds(auth)
@@ -200,9 +366,8 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut · 36 unmodified lines
200 return nil, err366 return nil, err
201 }367 }
202 applyQwenHeaders(httpReq, token, true)368 applyQwenHeaders(httpReq, token, true)
203 var authID, authLabel, authType, authValue string369 var authLabel, authType, authValue string
204 if auth != nil {370 if auth != nil {
205 authID = auth.ID
206 authLabel = auth.Label371 authLabel = auth.Label
207 authType, authValue = auth.AccountInfo()372 authType, authValue = auth.AccountInfo()
208 }373 }
@@ -228,11 +393,13 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut · 19 unmodified lines
228 if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {393 if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
229 b, _ := io.ReadAll(httpResp.Body)394 b, _ := io.ReadAll(httpResp.Body)
230 appendAPIResponseChunk(ctx, e.cfg, b)395 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))
232 if errClose := httpResp.Body.Close(); errClose != nil {399 if errClose := httpResp.Body.Close(); errClose != nil {
233 log.Errorf("qwen executor: close response body error: %v", errClose)400 log.Errorf("qwen executor: close response body error: %v", errClose)
234 }401 }
235 err = statusErr{code: httpResp.StatusCode, msg: string(b)}402 err = statusErr{code: errCode, msg: string(b), retryAfter: retryAfter}
236 return nil, err403 return nil, err
237 }404 }
238 out := make(chan cliproxyexecutor.StreamChunk)405 out := make(chan cliproxyexecutor.StreamChunk)
internal/thinking/provider/openai/apply.go+3−46
@@ -10,53 +10,10 @@ import ( · 9 unmodified lines
10 10
11 "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"11 "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
12 "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"12 "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
13 log "github.com/sirupsen/logrus"
14 "github.com/tidwall/gjson"13 "github.com/tidwall/gjson"
15 "github.com/tidwall/sjson"14 "github.com/tidwall/sjson"
16)15)
17 16
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.
21var 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)
38func 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
60// Applier implements thinking.ProviderApplier for OpenAI models.17// Applier implements thinking.ProviderApplier for OpenAI models.
61//18//
62// OpenAI-specific behavior:19// OpenAI-specific behavior:
@@ -101,7 +58,7 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo * · 38 unmodified lines
101 }58 }
102 59
103 if config.Mode == thinking.ModeLevel {60 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))
105 return result, nil62 return result, nil
106 }63 }
107 64
@@ -122,7 +79,7 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo * · 14 unmodified lines
122 return body, nil79 return body, nil
123 }80 }
124 81
125 result, _ := sjson.SetBytes(body, "reasoning_effort", clampReasoningEffort(effort))82 result, _ := sjson.SetBytes(body, "reasoning_effort", effort)
126 return result, nil83 return result, nil
127}84}
128 85
@@ -157,7 +114,7 @@ func applyCompatibleOpenAI(body []byte, config thinking.ThinkingConfig) ([]byte, · 28 unmodified lines
157 return body, nil114 return body, nil
158 }115 }
159 116
160 result, _ := sjson.SetBytes(body, "reasoning_effort", clampReasoningEffort(effort))117 result, _ := sjson.SetBytes(body, "reasoning_effort", effort)
161 return result, nil118 return result, nil
162}119}
163 120
internal/translator/antigravity/claude/antigravity_claude_request.go+56−5
@@ -223,14 +223,65 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ · 222 unmodified lines
223 functionResponseJSON, _ = sjson.Set(functionResponseJSON, "response.result", responseData)223 functionResponseJSON, _ = sjson.Set(functionResponseJSON, "response.result", responseData)
224 } else if functionResponseResult.IsArray() {224 } else if functionResponseResult.IsArray() {
225 frResults := functionResponseResult.Array()225 frResults := functionResponseResult.Array()
226 if len(frResults) == 1 {226 nonImageCount := 0
227 functionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, "response.result", frResults[0].Raw)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)
228 } else {255 } 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)
230 }264 }
231 265
232 } else if functionResponseResult.IsObject() {266 } 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 }
234 } else if functionResponseResult.Raw != "" {285 } else if functionResponseResult.Raw != "" {
235 functionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, "response.result", functionResponseResult.Raw)286 functionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, "response.result", functionResponseResult.Raw)
236 } else {287 } else {
@@ -248,7 +299,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ · 11 unmodified lines
248 if sourceResult.Get("type").String() == "base64" {299 if sourceResult.Get("type").String() == "base64" {
249 inlineDataJSON := `{}`300 inlineDataJSON := `{}`
250 if mimeType := sourceResult.Get("media_type").String(); mimeType != "" {301 if mimeType := sourceResult.Get("media_type").String(); mimeType != "" {
251 inlineDataJSON, _ = sjson.Set(inlineDataJSON, "mime_type", mimeType)302 inlineDataJSON, _ = sjson.Set(inlineDataJSON, "mimeType", mimeType)
252 }303 }
253 if data := sourceResult.Get("data").String(); data != "" {304 if data := sourceResult.Get("data").String(); data != "" {
254 inlineDataJSON, _ = sjson.Set(inlineDataJSON, "data", data)305 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
413 if !inlineData.Exists() {413 if !inlineData.Exists() {
414 t.Error("inlineData should exist")414 t.Error("inlineData should exist")
415 }415 }
416 if inlineData.Get("mime_type").String() != "image/png" {416 if inlineData.Get("mimeType").String() != "image/png" {
417 t.Error("mime_type mismatch")417 t.Error("mimeType mismatch")
418 }418 }
419 if !strings.Contains(inlineData.Get("data").String(), "iVBORw0KGgo") {419 if !strings.Contains(inlineData.Get("data").String(), "iVBORw0KGgo") {
420 t.Error("data mismatch")420 t.Error("data mismatch")
@@ -740,6 +740,429 @@ func TestConvertClaudeRequestToAntigravity_ToolResultNullContent(t *testing.T) { · 319 unmodified lines
740 }740 }
741}741}
742 742
743func 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
814func 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
873func 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
950func 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
1013func 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
1066func 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
1116func 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
743func TestConvertClaudeRequestToAntigravity_ToolAndThinking_NoExistingSystem(t *testing.T) {1166func TestConvertClaudeRequestToAntigravity_ToolAndThinking_NoExistingSystem(t *testing.T) {
744 // When tools + thinking but no system instruction, should create one with hint1167 // When tools + thinking but no system instruction, should create one with hint
745 inputJSON := []byte(`{1168 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
93 }93 }
94 }94 }
95}95}
96
97func 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
187 if len(pieces) == 2 && len(pieces[1]) > 7 {187 if len(pieces) == 2 && len(pieces[1]) > 7 {
188 mime := pieces[0]188 mime := pieces[0]
189 data := pieces[1][7:]189 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)
191 node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.data", data)191 node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.data", data)
192 node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature)192 node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature)
193 p++193 p++
@@ -201,7 +201,7 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ · 7 unmodified lines
201 ext = sp[len(sp)-1]201 ext = sp[len(sp)-1]
202 }202 }
203 if mimeType, ok := misc.MimeTypes[ext]; ok {203 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)
205 node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.data", fileData)205 node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.data", fileData)
206 p++206 p++
207 } else {207 } else {
@@ -235,7 +235,7 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ · 27 unmodified lines
235 if len(pieces) == 2 && len(pieces[1]) > 7 {235 if len(pieces) == 2 && len(pieces[1]) > 7 {
236 mime := pieces[0]236 mime := pieces[0]
237 data := pieces[1][7:]237 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)
239 node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.data", data)239 node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.data", data)
240 node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature)240 node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature)
241 p++241 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
95 if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() {95 if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() {
96 template, _ = sjson.Set(template, "usage.total_tokens", totalTokenCountResult.Int())96 template, _ = sjson.Set(template, "usage.total_tokens", totalTokenCountResult.Int())
97 }97 }
98 promptTokenCount := usageResult.Get("promptTokenCount").Int() - cachedTokenCount98 promptTokenCount := usageResult.Get("promptTokenCount").Int()
99 thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()99 thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
100 template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCount+thoughtsTokenCount)100 template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCount)
101 if thoughtsTokenCount > 0 {101 if thoughtsTokenCount > 0 {
102 template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)102 template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
103 }103 }
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
199 msg, _ = sjson.SetRaw(msg, "content.-1", imagePart)199 msg, _ = sjson.SetRaw(msg, "content.-1", imagePart)
200 }200 }
201 }201 }
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 }
202 }217 }
203 return true218 return true
204 })219 })
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
155 var textAggregate strings.Builder155 var textAggregate strings.Builder
156 var partsJSON []string156 var partsJSON []string
157 hasImage := false157 hasImage := false
158 hasFile := false
158 if parts := item.Get("content"); parts.Exists() && parts.IsArray() {159 if parts := item.Get("content"); parts.Exists() && parts.IsArray() {
159 parts.ForEach(func(_, part gjson.Result) bool {160 parts.ForEach(func(_, part gjson.Result) bool {
160 ptype := part.Get("type").String()161 ptype := part.Get("type").String()
@@ -207,6 +208,30 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte · 46 unmodified lines
207 hasImage = true208 hasImage = true
208 }209 }
209 }210 }
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 }
210 }235 }
211 return true236 return true
212 })237 })
@@ -228,7 +253,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte · 15 unmodified lines
228 if len(partsJSON) > 0 {253 if len(partsJSON) > 0 {
229 msg := `{"role":"","content":[]}`254 msg := `{"role":"","content":[]}`
230 msg, _ = sjson.Set(msg, "role", role)255 msg, _ = sjson.Set(msg, "role", role)
231 if len(partsJSON) == 1 && !hasImage {256 if len(partsJSON) == 1 && !hasImage && !hasFile {
232 // Preserve legacy behavior for single text content257 // Preserve legacy behavior for single text content
233 msg, _ = sjson.Delete(msg, "content")258 msg, _ = sjson.Delete(msg, "content")
234 textPart := gjson.Parse(partsJSON[0])259 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
180 msg, _ = sjson.SetRaw(msg, "content.-1", part)180 msg, _ = sjson.SetRaw(msg, "content.-1", part)
181 }181 }
182 case "file":182 case "file":
183 // Files are not specified in examples; skip for now183 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 }
184 }196 }
185 }197 }
186 }198 }
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
26 rawJSON, _ = sjson.DeleteBytes(rawJSON, "temperature")26 rawJSON, _ = sjson.DeleteBytes(rawJSON, "temperature")
27 rawJSON, _ = sjson.DeleteBytes(rawJSON, "top_p")27 rawJSON, _ = sjson.DeleteBytes(rawJSON, "top_p")
28 rawJSON, _ = sjson.DeleteBytes(rawJSON, "service_tier")28 rawJSON, _ = sjson.DeleteBytes(rawJSON, "service_tier")
29 rawJSON, _ = sjson.DeleteBytes(rawJSON, "truncation")
30 rawJSON = applyResponsesCompactionCompatibility(rawJSON)
29 31
30 // Delete the user field as it is not supported by the Codex upstream.32 // Delete the user field as it is not supported by the Codex upstream.
31 rawJSON, _ = sjson.DeleteBytes(rawJSON, "user")33 rawJSON, _ = sjson.DeleteBytes(rawJSON, "user")
@@ -36,6 +38,23 @@ func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, · 4 unmodified lines
36 return rawJSON38 return rawJSON
37}39}
38 40
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.
49func 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
39// convertSystemRoleToDeveloper traverses the input array and converts any message items58// convertSystemRoleToDeveloper traverses the input array and converts any message items
40// with role "system" to role "developer". This is necessary because Codex API does not59// with role "system" to role "developer". This is necessary because Codex API does not
41// accept "system" role in the input array.60// 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
280 t.Errorf("user field should be deleted, but it was found with value: %s", userField.Raw)280 t.Errorf("user field should be deleted, but it was found with value: %s", userField.Raw)
281 }281 }
282}282}
283
284func 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
307func 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
100 }100 }
101 promptTokenCount := usageResult.Get("promptTokenCount").Int()101 promptTokenCount := usageResult.Get("promptTokenCount").Int()
102 thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()102 thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
103 template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCount+thoughtsTokenCount)103 template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCount)
104 if thoughtsTokenCount > 0 {104 if thoughtsTokenCount > 0 {
105 template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)105 template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
106 }106 }
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
100 if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() {100 if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() {
101 baseTemplate, _ = sjson.Set(baseTemplate, "usage.total_tokens", totalTokenCountResult.Int())101 baseTemplate, _ = sjson.Set(baseTemplate, "usage.total_tokens", totalTokenCountResult.Int())
102 }102 }
103 promptTokenCount := usageResult.Get("promptTokenCount").Int() - cachedTokenCount103 promptTokenCount := usageResult.Get("promptTokenCount").Int()
104 thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()104 thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
105 baseTemplate, _ = sjson.Set(baseTemplate, "usage.prompt_tokens", promptTokenCount+thoughtsTokenCount)105 baseTemplate, _ = sjson.Set(baseTemplate, "usage.prompt_tokens", promptTokenCount)
106 if thoughtsTokenCount > 0 {106 if thoughtsTokenCount > 0 {
107 baseTemplate, _ = sjson.Set(baseTemplate, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)107 baseTemplate, _ = sjson.Set(baseTemplate, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
108 }108 }
@@ -297,7 +297,7 @@ func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, origina · 188 unmodified lines
297 promptTokenCount := usageResult.Get("promptTokenCount").Int()297 promptTokenCount := usageResult.Get("promptTokenCount").Int()
298 thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()298 thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
299 cachedTokenCount := usageResult.Get("cachedContentTokenCount").Int()299 cachedTokenCount := usageResult.Get("cachedContentTokenCount").Int()
300 template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCount+thoughtsTokenCount)300 template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCount)
301 if thoughtsTokenCount > 0 {301 if thoughtsTokenCount > 0 {
302 template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)302 template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
303 }303 }
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
531 531
532 // usage mapping532 // usage mapping
533 if um := root.Get("usageMetadata"); um.Exists() {533 if um := root.Get("usageMetadata"); um.Exists() {
534 // input tokens = prompt + thoughts534 // input tokens = prompt only (thoughts go to output)
535 input := um.Get("promptTokenCount").Int() + um.Get("thoughtsTokenCount").Int()535 input := um.Get("promptTokenCount").Int()
536 completed, _ = sjson.Set(completed, "response.usage.input_tokens", input)536 completed, _ = sjson.Set(completed, "response.usage.input_tokens", input)
537 // cached token details: align with OpenAI "cached_tokens" semantics.537 // cached token details: align with OpenAI "cached_tokens" semantics.
538 completed, _ = sjson.Set(completed, "response.usage.input_tokens_details.cached_tokens", um.Get("cachedContentTokenCount").Int())538 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
737 737
738 // usage mapping738 // usage mapping
739 if um := root.Get("usageMetadata"); um.Exists() {739 if um := root.Get("usageMetadata"); um.Exists() {
740 // input tokens = prompt + thoughts740 // input tokens = prompt only (thoughts go to output)
741 input := um.Get("promptTokenCount").Int() + um.Get("thoughtsTokenCount").Int()741 input := um.Get("promptTokenCount").Int()
742 resp, _ = sjson.Set(resp, "usage.input_tokens", input)742 resp, _ = sjson.Set(resp, "usage.input_tokens", input)
743 // cached token details: align with OpenAI "cached_tokens" semantics.743 // cached token details: align with OpenAI "cached_tokens" semantics.
744 resp, _ = sjson.Set(resp, "usage.input_tokens_details.cached_tokens", um.Get("cachedContentTokenCount").Int())744 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
716 return716 return
717 }717 }
718 if len(chunk.Payload) > 0 {718 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 }
719 sentPayload = true725 sentPayload = true
720 if okSendData := sendData(cloneBytes(chunk.Payload)); !okSendData {726 if okSendData := sendData(cloneBytes(chunk.Payload)); !okSendData {
721 return727 return
@@ -727,6 +733,35 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl · 5 unmodified lines
727 return dataChan, upstreamHeaders, errChan733 return dataChan, upstreamHeaders, errChan
728}734}
729 735
736func 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
730func statusFromError(err error) int {765func statusFromError(err error) int {
731 if err == nil {766 if err == nil {
732 return 0767 return 0
sdk/api/handlers/handlers_stream_bootstrap_test.go+83−0
@@ -134,6 +134,37 @@ type authAwareStreamExecutor struct { · 133 unmodified lines
134 authIDs []string134 authIDs []string
135}135}
136 136
137type invalidJSONStreamExecutor struct{}
138
139func (e *invalidJSONStreamExecutor) Identifier() string { return "codex" }
140
141func (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
145func (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
152func (e *invalidJSONStreamExecutor) Refresh(ctx context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) {
153 return auth, nil
154}
155
156func (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
160func (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
137func (e *authAwareStreamExecutor) Identifier() string { return "codex" }168func (e *authAwareStreamExecutor) Identifier() string { return "codex" }
138 169
139func (e *authAwareStreamExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {170func (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
524 t.Fatalf("selectedAuthID = %q, want %q", selectedAuthID, "auth2")555 t.Fatalf("selectedAuthID = %q, want %q", selectedAuthID, "auth2")
525 }556 }
526}557}
558
559func 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
265 if errMsg.Error != nil && errMsg.Error.Error() != "" {265 if errMsg.Error != nil && errMsg.Error.Error() != "" {
266 errText = errMsg.Error.Error()266 errText = errMsg.Error.Error()
267 }267 }
268 body := handlers.BuildErrorResponseBody(status, errText)268 chunk := handlers.BuildOpenAIResponsesStreamErrorChunk(status, errText, 0)
269 _, _ = fmt.Fprintf(c.Writer, "\nevent: error\ndata: %s\n\n", string(body))269 _, _ = fmt.Fprintf(c.Writer, "\nevent: error\ndata: %s\n\n", string(chunk))
270 },270 },
271 WriteDone: func() {271 WriteDone: func() {
272 _, _ = c.Writer.Write([]byte("\n"))272 _, _ = c.Writer.Write([]byte("\n"))
sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go+43−0
@@ -0,0 +1,43 @@
1package openai
2
3import (
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
16func 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 @@
1package handlers
2
3import (
4 "encoding/json"
5 "fmt"
6 "net/http"
7 "strings"
8)
9
10type 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
17func 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.
45func 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 @@
1package handlers
2
3import (
4 "encoding/json"
5 "net/http"
6 "testing"
7)
8
9func 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
29func 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
2 2
3import (3import (
4 "context"4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "fmt"5 "fmt"
8 "net/http"6 "net/http"
9 "strings"7 "strings"
@@ -48,6 +46,10 @@ func (a *CodexAuthenticator) Login(ctx context.Context, cfg *config.Config, opts · 38 unmodified lines
48 opts = &LoginOptions{}46 opts = &LoginOptions{}
49 }47 }
50 48
49 if shouldUseCodexDeviceFlow(opts) {
50 return a.loginWithDeviceFlow(ctx, cfg, opts)
51 }
52
51 callbackPort := a.CallbackPort53 callbackPort := a.CallbackPort
52 if opts.CallbackPort > 0 {54 if opts.CallbackPort > 0 {
53 callbackPort = opts.CallbackPort55 callbackPort = opts.CallbackPort
@@ -186,39 +188,5 @@ waitForCallback: · 132 unmodified lines
186 return nil, codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, err)188 return nil, codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, err)
187 }189 }
188 190
189 tokenStorage := authSvc.CreateTokenStorage(authBundle)191 return a.buildAuthRecord(authSvc, 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
224}192}
sdk/auth/codex_device.go+291−0
@@ -0,0 +1,291 @@
1package auth
2
3import (
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
24const (
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
35type codexDeviceUserCodeRequest struct {
36 ClientID string `json:"client_id"`
37}
38
39type 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
46type codexDeviceTokenRequest struct {
47 DeviceAuthID string `json:"device_auth_id"`
48 UserCode string `json:"user_code"`
49}
50
51type codexDeviceTokenResponse struct {
52 AuthorizationCode string `json:"authorization_code"`
53 CodeVerifier string `json:"code_verifier"`
54 CodeChallenge string `json:"code_challenge"`
55}
56
57func 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
64func (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
128func 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
171func 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
229func 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
250func codexDeviceIsSuccessStatus(code int) bool {
251 return code >= 200 && code < 300
252}
253
254func (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
64 return "", fmt.Errorf("auth filestore: create dir failed: %w", err)64 return "", fmt.Errorf("auth filestore: create dir failed: %w", err)
65 }65 }
66 66
67 // metadataSetter is a private interface for TokenStorage implementations that support metadata injection.
68 type metadataSetter interface {
69 SetMetadata(map[string]any)
70 }
71
67 switch {72 switch {
68 case auth.Storage != nil:73 case auth.Storage != nil:
74 if setter, ok := auth.Storage.(metadataSetter); ok {
75 setter.SetMetadata(auth.Metadata)
76 }
69 if err = auth.Storage.SaveTokenToFile(path); err != nil {77 if err = auth.Storage.SaveTokenToFile(path); err != nil {
70 return "", err78 return "", err
71 }79 }
sdk/cliproxy/auth/conductor.go+20−5
@@ -60,6 +60,7 @@ type RefreshEvaluator interface { · 59 unmodified lines
60 60
61const (61const (
62 refreshCheckInterval = 5 * time.Second62 refreshCheckInterval = 5 * time.Second
63 refreshMaxConcurrency = 16
63 refreshPendingBackoff = time.Minute64 refreshPendingBackoff = time.Minute
64 refreshFailureBackoff = 5 * time.Minute65 refreshFailureBackoff = 5 * time.Minute
65 quotaBackoffBase = time.Second66 quotaBackoffBase = time.Second
@@ -155,7 +156,8 @@ type Manager struct { · 89 unmodified lines
155 rtProvider RoundTripperProvider156 rtProvider RoundTripperProvider
156 157
157 // Auto refresh state158 // Auto refresh state
158 refreshCancel context.CancelFunc159 refreshCancel context.CancelFunc
160 refreshSemaphore chan struct{}
159}161}
160 162
161// NewManager constructs a manager with optional custom selector and hook.163// 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
173 hook: hook,175 hook: hook,
174 auths: make(map[string]*Auth),176 auths: make(map[string]*Auth),
175 providerOffsets: make(map[string]int),177 providerOffsets: make(map[string]int),
178 refreshSemaphore: make(chan struct{}, refreshMaxConcurrency),
176 }179 }
177 // atomic.Value requires non-nil initial value.180 // atomic.Value requires non-nil initial value.
178 manager.runtimeConfig.Store(&internalconfig.Config{})181 manager.runtimeConfig.Store(&internalconfig.Config{})
@@ -1828,9 +1831,7 @@ func (m *Manager) persist(ctx context.Context, auth *Auth) error { · 1649 unmodified lines
1828// every few seconds and triggers refresh operations when required.1831// every few seconds and triggers refresh operations when required.
1829// Only one loop is kept alive; starting a new one cancels the previous run.1832// Only one loop is kept alive; starting a new one cancels the previous run.
1830func (m *Manager) StartAutoRefresh(parent context.Context, interval time.Duration) {1833func (m *Manager) StartAutoRefresh(parent context.Context, interval time.Duration) {
1831 if interval <= 0 || interval > refreshCheckInterval {1834 if interval <= 0 {
1832 interval = refreshCheckInterval
1833 } else {
1834 interval = refreshCheckInterval1835 interval = refreshCheckInterval
1835 }1836 }
1836 if m.refreshCancel != nil {1837 if m.refreshCancel != nil {
@@ -1880,11 +1881,25 @@ func (m *Manager) checkRefreshes(ctx context.Context) { · 43 unmodified lines
1880 if !m.markRefreshPending(a.ID, now) {1881 if !m.markRefreshPending(a.ID, now) {
1881 continue1882 continue
1882 }1883 }
1883 go m.refreshAuth(ctx, a.ID)1884 go m.refreshAuthWithLimit(ctx, a.ID)
1884 }1885 }
1885 }1886 }
1886}1887}
1887 1888
1889func (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
1888func (m *Manager) snapshotAuths() []*Auth {1903func (m *Manager) snapshotAuths() []*Auth {
1889 m.mu.RLock()1904 m.mu.RLock()
1890 defer m.mu.RUnlock()1905 defer m.mu.RUnlock()
sdk/cliproxy/auth/selector.go+75−5
@@ -5,6 +5,7 @@ import ( · 4 unmodified lines
5 "encoding/json"5 "encoding/json"
6 "fmt"6 "fmt"
7 "math"7 "math"
8 "math/rand/v2"
8 "net/http"9 "net/http"
9 "sort"10 "sort"
10 "strconv"11 "strconv"
@@ -248,6 +249,9 @@ func getAvailableAuths(auths []*Auth, provider, model string, now time.Time) ([] · 237 unmodified lines
248}249}
249 250
250// Pick selects the next available auth for the provider in a round-robin manner.251// 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.
251func (s *RoundRobinSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) {255func (s *RoundRobinSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) {
252 _ = opts256 _ = opts
253 now := time.Now()257 now := time.Now()
@@ -265,21 +269,87 @@ func (s *RoundRobinSelector) Pick(ctx context.Context, provider, model string, o · 11 unmodified lines
265 if limit <= 0 {269 if limit <= 0 {
266 limit = 4096270 limit = 4096
267 }271 }
268 if _, ok := s.cursors[key]; !ok && len(s.cursors) >= limit {272
269 s.cursors = make(map[string]int)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
270 }303 }
271 index := s.cursors[key]
272 304
305 // Flat round-robin for non-grouped auths (original behavior).
306 s.ensureCursorKey(key, limit)
307 index := s.cursors[key]
273 if index >= 2_147_483_640 {308 if index >= 2_147_483_640 {
274 index = 0309 index = 0
275 }310 }
276
277 s.cursors[key] = index + 1311 s.cursors[key] = index + 1
278 s.mu.Unlock()312 s.mu.Unlock()
279 // log.Debugf("available: %d, index: %d, key: %d", len(available), index, index%len(available))
280 return available[index%len(available)], nil313 return available[index%len(available)], nil
281}314}
282 315
316// ensureCursorKey ensures the cursor map has capacity for the given key.
317// Must be called with s.mu held.
318func (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.
328func 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
283// Pick selects the first available auth for the provider in a deterministic manner.353// Pick selects the first available auth for the provider in a deterministic manner.
284func (s *FillFirstSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) {354func (s *FillFirstSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) {
285 _ = opts355 _ = opts
sdk/cliproxy/auth/selector_test.go+125−0
@@ -402,3 +402,128 @@ func TestRoundRobinSelectorPick_CursorKeyCap(t *testing.T) { · 401 unmodified lines
402 t.Fatalf("selector.cursors missing key %q", "gemini:m3")402 t.Fatalf("selector.cursors missing key %q", "gemini:m3")
403 }403 }
404}404}
405
406func 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
461func 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
497func 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 @@
1package auth1package auth
2 2
3import (3import (
4 "context"
4 "crypto/sha256"5 "crypto/sha256"
5 "encoding/hex"6 "encoding/hex"
6 "encoding/json"7 "encoding/json"
8 "net/http"
9 "net/url"
7 "strconv"10 "strconv"
8 "strings"11 "strings"
9 "sync"12 "sync"
@@ -12,6 +15,33 @@ import ( · 2 unmodified lines
12 baseauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth"15 baseauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth"
13)16)
14 17
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.
21type 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.
25type RequestInfo struct {
26 Query url.Values
27 Headers http.Header
28}
29
30type requestInfoKey struct{}
31
32// WithRequestInfo returns a new context with the given RequestInfo attached.
33func 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.
38func GetRequestInfo(ctx context.Context) *RequestInfo {
39 if val, ok := ctx.Value(requestInfoKey{}).(*RequestInfo); ok {
40 return val
41 }
42 return nil
43}
44
15// Auth encapsulates the runtime state and metadata associated with a single credential.45// Auth encapsulates the runtime state and metadata associated with a single credential.
16type Auth struct {46type Auth struct {
17 // ID uniquely identifies the auth record across restarts.47 // 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
153 return b153 return b
154}154}
155 155
156// WithPostAuthHook registers a hook to be called after an Auth record is created
157// but before it is persisted to storage.
158func (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
156// Build validates inputs, applies defaults, and returns a ready-to-run service.166// Build validates inputs, applies defaults, and returns a ready-to-run service.
157func (b *Builder) Build() (*Service, error) {167func (b *Builder) Build() (*Service, error) {
158 if b.cfg == nil {168 if b.cfg == nil {
sdk/cliproxy/service.go+53−0
@@ -925,6 +925,9 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { · 924 unmodified lines
925 key = strings.ToLower(strings.TrimSpace(a.Provider))925 key = strings.ToLower(strings.TrimSpace(a.Provider))
926 }926 }
927 GlobalModelRegistry().RegisterClient(a.ID, key, applyModelPrefixes(models, a.Prefix, s.cfg != nil && s.cfg.ForceModelPrefix))927 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 }
928 return931 return
929 }932 }
930 933
@@ -1069,6 +1072,56 @@ func (s *Service) oauthExcludedModels(provider, authKind string) []string { · 138 unmodified lines
1069 return cfg.OAuthExcludedModels[providerKey]1072 return cfg.OAuthExcludedModels[providerKey]
1070}1073}
1071 1074
1075func (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
1072func applyExcludedModels(models []*ModelInfo, excluded []string) []*ModelInfo {1125func applyExcludedModels(models []*ModelInfo, excluded []string) []*ModelInfo {
1073 if len(models) == 0 || len(excluded) == 0 {1126 if len(models) == 0 || len(excluded) == 0 {
1074 return models1127 return models
sdk/cliproxy/service_antigravity_backfill_test.go+135−0
@@ -0,0 +1,135 @@
1package cliproxy
2
3import (
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
13func 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
80func 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}