# Conflicts: # internal/runtime/executor/antigravity_executor.go
Unified · Split
| @@ -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 bool | 59 | var login bool |
| 60 | var codexLogin bool | 60 | var codexLogin bool |
| 61 | var codexDeviceLogin bool | ||
| 61 | var claudeLogin bool | 62 | var claudeLogin bool |
| 62 | var qwenLogin bool | 63 | var qwenLogin bool |
| 63 | var iflowLogin bool | 64 | 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 login | 470 | // 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 login | 476 | // Handle Claude login |
| 472 | cmd.DoClaudeLogin(cfg, options) | 477 | cmd.DoClaudeLogin(cfg, options) |
| @@ -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.LastRefreshedAt | 409 | 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"] = path | 415 | 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 | ||
| 953 | func (h *Handler) RequestAnthropicToken(c *gin.Context) { | 961 | func (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 | ||
| 1097 | func (h *Handler) RequestGeminiCLIToken(c *gin.Context) { | 1106 | func (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 | ||
| 1355 | func (h *Handler) RequestCodexToken(c *gin.Context) { | 1365 | func (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 | ||
| 1500 | func (h *Handler) RequestAntigravityToken(c *gin.Context) { | 1511 | func (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 | ||
| 1664 | func (h *Handler) RequestQwenToken(c *gin.Context) { | 1676 | func (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 | ||
| 1719 | func (h *Handler) RequestKimiToken(c *gin.Context) { | 1732 | func (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 | ||
| 1795 | func (h *Handler) RequestIFlowToken(c *gin.Context) { | 1809 | func (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 | ||
| 2432 | func PopulateAuthContext(ctx context.Context, c *gin.Context) context.Context { | ||
| 2433 | info := &coreauth.RequestInfo{ | ||
| 2434 | Query: c.Request.URL.Query(), | ||
| 2435 | Headers: c.Request.Header, | ||
| 2436 | } | ||
| 2437 | return coreauth.WithRequestInfo(ctx, info) | ||
| 2438 | } | ||
| @@ -47,6 +47,7 @@ type Handler struct { · 46 unmodified lines | |||
| 47 | allowRemoteOverride bool | 47 | allowRemoteOverride bool |
| 48 | envSecret string | 48 | envSecret string |
| 49 | logDir string | 49 | 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 = dir | 129 | h.logDir = dir |
| 129 | } | 130 | } |
| 130 | 131 | ||
| 132 | // SetPostAuthHook registers a hook to be called after auth record creation but before persistence. | ||
| 133 | func (h *Handler) SetPostAuthHook(hook coreauth.PostAuthHook) { | ||
| 134 | h.postAuthHook = hook | ||
| 135 | } | ||
| 136 | |||
| 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. |
| @@ -51,6 +51,7 @@ type serverOptionConfig struct { · 50 unmodified lines | |||
| 51 | keepAliveEnabled bool | 51 | keepAliveEnabled bool |
| 52 | keepAliveTimeout time.Duration | 52 | 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. | ||
| 116 | func 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. |
| 116 | type Server struct { | 124 | type 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.localPassword | 276 | s.localPassword = optionState.localPassword |
| 266 | 277 | ||
| 267 | // Setup routes | 278 | // Setup routes |
| @@ -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. | ||
| 46 | func (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 token | 51 | // 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 saved | 56 | // - 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 JSON | 84 | // 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 nil | 88 | return nil |
| @@ -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 provided | 71 | // 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. |
| 73 | func (o *CodexAuth) ExchangeCodeForTokens(ctx context.Context, code string, pkceCodes *PKCECodes) (*CodexAuthBundle, error) { | 73 | func (o *CodexAuth) ExchangeCodeForTokens(ctx context.Context, code string, pkceCodes *PKCECodes) (*CodexAuthBundle, error) { |
| 74 | return o.ExchangeCodeForTokensWithRedirect(ctx, code, RedirectURI, pkceCodes) | ||
| 75 | } | ||
| 76 | |||
| 77 | // ExchangeCodeForTokensWithRedirect exchanges an authorization code for tokens using | ||
| 78 | // a caller-provided redirect URI. This supports alternate auth flows such as device | ||
| 79 | // login while preserving the existing token parsing and storage behavior. | ||
| 80 | func (o *CodexAuth) ExchangeCodeForTokensWithRedirect(ctx context.Context, code, redirectURI string, pkceCodes *PKCECodes) (*CodexAuthBundle, error) { | ||
| 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 request | 88 | // 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, nil | 277 | 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 = err | 284 | 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 | ||
| 291 | func isNonRetryableRefreshErr(err error) bool { | ||
| 292 | if err == nil { | ||
| 293 | return false | ||
| 294 | } | ||
| 295 | raw := strings.ToLower(err.Error()) | ||
| 296 | return strings.Contains(raw, "refresh_token_reused") | ||
| 297 | } | ||
| 298 | |||
| 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. |
| 279 | func (o *CodexAuth) UpdateTokenStorage(storage *CodexTokenStorage, tokenData *CodexTokenData) { | 301 | func (o *CodexAuth) UpdateTokenStorage(storage *CodexTokenStorage, tokenData *CodexTokenData) { |
| @@ -0,0 +1,44 @@ | |||
| 1 | package codex | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "io" | ||
| 6 | "net/http" | ||
| 7 | "strings" | ||
| 8 | "sync/atomic" | ||
| 9 | "testing" | ||
| 10 | ) | ||
| 11 | |||
| 12 | type roundTripFunc func(*http.Request) (*http.Response, error) | ||
| 13 | |||
| 14 | func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { | ||
| 15 | return f(req) | ||
| 16 | } | ||
| 17 | |||
| 18 | func TestRefreshTokensWithRetry_NonRetryableOnlyAttemptsOnce(t *testing.T) { | ||
| 19 | var calls int32 | ||
| 20 | auth := &CodexAuth{ | ||
| 21 | httpClient: &http.Client{ | ||
| 22 | Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { | ||
| 23 | atomic.AddInt32(&calls, 1) | ||
| 24 | return &http.Response{ | ||
| 25 | StatusCode: http.StatusBadRequest, | ||
| 26 | Body: io.NopCloser(strings.NewReader(`{"error":"invalid_grant","code":"refresh_token_reused"}`)), | ||
| 27 | Header: make(http.Header), | ||
| 28 | Request: req, | ||
| 29 | }, nil | ||
| 30 | }), | ||
| 31 | }, | ||
| 32 | } | ||
| 33 | |||
| 34 | _, err := auth.RefreshTokensWithRetry(context.Background(), "dummy_refresh_token", 3) | ||
| 35 | if err == nil { | ||
| 36 | t.Fatalf("expected error for non-retryable refresh failure") | ||
| 37 | } | ||
| 38 | if !strings.Contains(strings.ToLower(err.Error()), "refresh_token_reused") { | ||
| 39 | t.Fatalf("expected refresh_token_reused in error, got: %v", err) | ||
| 40 | } | ||
| 41 | if got := atomic.LoadInt32(&calls); got != 1 { | ||
| 42 | t.Fatalf("expected 1 refresh attempt, got %d", got) | ||
| 43 | } | ||
| 44 | } | ||
| @@ -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. | ||
| 42 | func (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 token | 47 | // 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 saved | 52 | // - 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 nil | 80 | return nil |
| @@ -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. | ||
| 45 | func (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 token | 50 | // 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 saved | 55 | // - authFilePath: The full path where the token file should be saved |
| @@ -49,6 +59,11 @@ type GeminiTokenStorage struct { · 3 unmodified lines | |||
| 49 | func (ts *GeminiTokenStorage) SaveTokenToFile(authFilePath string) error { | 59 | func (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 nil | 86 | return nil |
| @@ -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. | ||
| 31 | func (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 nil | 58 | return nil |
| @@ -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. | ||
| 39 | func (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 nil | 109 | return nil |
| @@ -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. | ||
| 40 | func (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 token | 45 | // 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 saved | 50 | // - 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 nil | 78 | return nil |
| @@ -0,0 +1,60 @@ | |||
| 1 | package cmd | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "errors" | ||
| 6 | "fmt" | ||
| 7 | "os" | ||
| 8 | |||
| 9 | "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex" | ||
| 10 | "github.com/router-for-me/CLIProxyAPI/v6/internal/config" | ||
| 11 | sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" | ||
| 12 | log "github.com/sirupsen/logrus" | ||
| 13 | ) | ||
| 14 | |||
| 15 | const ( | ||
| 16 | codexLoginModeMetadataKey = "codex_login_mode" | ||
| 17 | codexLoginModeDevice = "device" | ||
| 18 | ) | ||
| 19 | |||
| 20 | // DoCodexDeviceLogin triggers the Codex device-code flow while keeping the | ||
| 21 | // existing codex-login OAuth callback flow intact. | ||
| 22 | func DoCodexDeviceLogin(cfg *config.Config, options *LoginOptions) { | ||
| 23 | if options == nil { | ||
| 24 | options = &LoginOptions{} | ||
| 25 | } | ||
| 26 | |||
| 27 | promptFn := options.Prompt | ||
| 28 | if promptFn == nil { | ||
| 29 | promptFn = defaultProjectPrompt() | ||
| 30 | } | ||
| 31 | |||
| 32 | manager := newAuthManager() | ||
| 33 | |||
| 34 | authOpts := &sdkAuth.LoginOptions{ | ||
| 35 | NoBrowser: options.NoBrowser, | ||
| 36 | CallbackPort: options.CallbackPort, | ||
| 37 | Metadata: map[string]string{ | ||
| 38 | codexLoginModeMetadataKey: codexLoginModeDevice, | ||
| 39 | }, | ||
| 40 | Prompt: promptFn, | ||
| 41 | } | ||
| 42 | |||
| 43 | _, savedPath, err := manager.Login(context.Background(), "codex", cfg, authOpts) | ||
| 44 | if err != nil { | ||
| 45 | if authErr, ok := errors.AsType[*codex.AuthenticationError](err); ok { | ||
| 46 | log.Error(codex.GetUserFriendlyMessage(authErr)) | ||
| 47 | if authErr.Type == codex.ErrPortInUse.Type { | ||
| 48 | os.Exit(codex.ErrPortInUse.Code) | ||
| 49 | } | ||
| 50 | return | ||
| 51 | } | ||
| 52 | fmt.Printf("Codex device authentication failed: %v\n", err) | ||
| 53 | return | ||
| 54 | } | ||
| 55 | |||
| 56 | if savedPath != "" { | ||
| 57 | fmt.Printf("Authentication saved to %s\n", savedPath) | ||
| 58 | } | ||
| 59 | fmt.Println("Codex device authentication successful!") | ||
| 60 | } | ||
| @@ -1,6 +1,7 @@ | |||
| 1 | package misc | 1 | package misc |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 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 | |||
| 24 | func LogCredentialSeparator() { | 25 | func 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. | ||
| 30 | func MergeMetadata(source any, metadata map[string]any) (map[string]any, error) { | ||
| 31 | var data map[string]any | ||
| 32 | |||
| 33 | // Fast path: if source is already a map, just copy it to avoid mutation of original | ||
| 34 | if srcMap, ok := source.(map[string]any); ok { | ||
| 35 | data = make(map[string]any, len(srcMap)+len(metadata)) | ||
| 36 | for k, v := range srcMap { | ||
| 37 | data[k] = v | ||
| 38 | } | ||
| 39 | } else { | ||
| 40 | // Slow path: marshal to JSON and back to map to respect JSON tags | ||
| 41 | temp, err := json.Marshal(source) | ||
| 42 | if err != nil { | ||
| 43 | return nil, fmt.Errorf("failed to marshal source: %w", err) | ||
| 44 | } | ||
| 45 | if err := json.Unmarshal(temp, &data); err != nil { | ||
| 46 | return nil, fmt.Errorf("failed to unmarshal to map: %w", err) | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | // Merge extra metadata | ||
| 51 | if metadata != nil { | ||
| 52 | if data == nil { | ||
| 53 | data = make(map[string]any) | ||
| 54 | } | ||
| 55 | for k, v := range metadata { | ||
| 56 | data[k] = v | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | return data, nil | ||
| 61 | } | ||
| @@ -904,19 +904,12 @@ func GetIFlowModels() []*ModelInfo { · 903 unmodified lines | |||
| 904 | Created int64 | 904 | Created int64 |
| 905 | Thinking *ThinkingSupport | 905 | 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}, |
| @@ -55,8 +55,78 @@ const ( · 54 unmodified lines | |||
| 55 | var ( | 55 | var ( |
| 56 | randSource = rand.New(rand.NewSource(time.Now().UnixNano())) | 56 | randSource = rand.New(rand.NewSource(time.Now().UnixNano())) |
| 57 | randSourceMutex sync.Mutex | 57 | 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 | ||
| 66 | func cloneAntigravityModels(models []*registry.ModelInfo) []*registry.ModelInfo { | ||
| 67 | if len(models) == 0 { | ||
| 68 | return nil | ||
| 69 | } | ||
| 70 | out := make([]*registry.ModelInfo, 0, len(models)) | ||
| 71 | for _, model := range models { | ||
| 72 | if model == nil || strings.TrimSpace(model.ID) == "" { | ||
| 73 | continue | ||
| 74 | } | ||
| 75 | out = append(out, cloneAntigravityModelInfo(model)) | ||
| 76 | } | ||
| 77 | if len(out) == 0 { | ||
| 78 | return nil | ||
| 79 | } | ||
| 80 | return out | ||
| 81 | } | ||
| 82 | |||
| 83 | func cloneAntigravityModelInfo(model *registry.ModelInfo) *registry.ModelInfo { | ||
| 84 | if model == nil { | ||
| 85 | return nil | ||
| 86 | } | ||
| 87 | clone := *model | ||
| 88 | if len(model.SupportedGenerationMethods) > 0 { | ||
| 89 | clone.SupportedGenerationMethods = append([]string(nil), model.SupportedGenerationMethods...) | ||
| 90 | } | ||
| 91 | if len(model.SupportedParameters) > 0 { | ||
| 92 | clone.SupportedParameters = append([]string(nil), model.SupportedParameters...) | ||
| 93 | } | ||
| 94 | if model.Thinking != nil { | ||
| 95 | thinkingClone := *model.Thinking | ||
| 96 | if len(model.Thinking.Levels) > 0 { | ||
| 97 | thinkingClone.Levels = append([]string(nil), model.Thinking.Levels...) | ||
| 98 | } | ||
| 99 | clone.Thinking = &thinkingClone | ||
| 100 | } | ||
| 101 | return &clone | ||
| 102 | } | ||
| 103 | |||
| 104 | func storeAntigravityPrimaryModels(models []*registry.ModelInfo) bool { | ||
| 105 | cloned := cloneAntigravityModels(models) | ||
| 106 | if len(cloned) == 0 { | ||
| 107 | return false | ||
| 108 | } | ||
| 109 | antigravityPrimaryModelsCache.mu.Lock() | ||
| 110 | antigravityPrimaryModelsCache.models = cloned | ||
| 111 | antigravityPrimaryModelsCache.mu.Unlock() | ||
| 112 | return true | ||
| 113 | } | ||
| 114 | |||
| 115 | func loadAntigravityPrimaryModels() []*registry.ModelInfo { | ||
| 116 | antigravityPrimaryModelsCache.mu.RLock() | ||
| 117 | cloned := cloneAntigravityModels(antigravityPrimaryModelsCache.models) | ||
| 118 | antigravityPrimaryModelsCache.mu.RUnlock() | ||
| 119 | return cloned | ||
| 120 | } | ||
| 121 | |||
| 122 | func fallbackAntigravityPrimaryModels() []*registry.ModelInfo { | ||
| 123 | models := loadAntigravityPrimaryModels() | ||
| 124 | if len(models) > 0 { | ||
| 125 | log.Debugf("antigravity executor: using cached primary model list (%d models)", len(models)) | ||
| 126 | } | ||
| 127 | return models | ||
| 128 | } | ||
| 129 | |||
| 60 | // AntigravityExecutor proxies requests to the antigravity upstream. | 130 | // AntigravityExecutor proxies requests to the antigravity upstream. |
| 61 | type AntigravityExecutor struct { | 131 | type AntigravityExecutor struct { |
| 62 | cfg *config.Config | 132 | 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 nil | 1145 | return fallbackAntigravityPrimaryModels() |
| 1076 | } | 1146 | } |
| 1077 | if updatedAuth != nil { | 1147 | if updatedAuth != nil { |
| 1078 | auth = updatedAuth | 1148 | 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 nil | 1169 | return fallbackAntigravityPrimaryModels() |
| 1100 | } | 1170 | } |
| 1101 | httpReq.Close = true | 1171 | 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 nil | 1182 | 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 | continue | 1186 | 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 | continue | 1198 | 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 | continue | 1205 | 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 nil | 1208 | log.Debugf("antigravity executor: models request failed with status %d on base url %s, retrying with fallback base url: %s", httpResp.StatusCode, baseURL, baseURLs[idx+1]) |
| 1209 | continue | ||
| 1210 | } | ||
| 1211 | return fallbackAntigravityPrimaryModels() | ||
| 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 nil | 1216 | if idx+1 < len(baseURLs) { |
| 1217 | log.Debugf("antigravity executor: models field missing on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1]) | ||
| 1218 | continue | ||
| 1219 | } | ||
| 1220 | return fallbackAntigravityPrimaryModels() | ||
| 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 models | 1297 | return models |
| 1214 | } | 1298 | } |
| 1215 | return nil | 1299 | return fallbackAntigravityPrimaryModels() |
| 1216 | } | 1300 | } |
| 1217 | 1301 | ||
| 1218 | func (e *AntigravityExecutor) ensureAccessToken(ctx context.Context, auth *cliproxyauth.Auth) (string, *cliproxyauth.Auth, error) { | 1302 | func (e *AntigravityExecutor) ensureAccessToken(ctx context.Context, auth *cliproxyauth.Auth) (string, *cliproxyauth.Auth, error) { |
| @@ -0,0 +1,90 @@ | |||
| 1 | package executor | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "testing" | ||
| 5 | |||
| 6 | "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" | ||
| 7 | ) | ||
| 8 | |||
| 9 | func resetAntigravityPrimaryModelsCacheForTest() { | ||
| 10 | antigravityPrimaryModelsCache.mu.Lock() | ||
| 11 | antigravityPrimaryModelsCache.models = nil | ||
| 12 | antigravityPrimaryModelsCache.mu.Unlock() | ||
| 13 | } | ||
| 14 | |||
| 15 | func TestStoreAntigravityPrimaryModels_EmptyDoesNotOverwrite(t *testing.T) { | ||
| 16 | resetAntigravityPrimaryModelsCacheForTest() | ||
| 17 | t.Cleanup(resetAntigravityPrimaryModelsCacheForTest) | ||
| 18 | |||
| 19 | seed := []*registry.ModelInfo{ | ||
| 20 | {ID: "claude-sonnet-4-5"}, | ||
| 21 | {ID: "gemini-2.5-pro"}, | ||
| 22 | } | ||
| 23 | if updated := storeAntigravityPrimaryModels(seed); !updated { | ||
| 24 | t.Fatal("expected non-empty model list to update primary cache") | ||
| 25 | } | ||
| 26 | |||
| 27 | if updated := storeAntigravityPrimaryModels(nil); updated { | ||
| 28 | t.Fatal("expected nil model list not to overwrite primary cache") | ||
| 29 | } | ||
| 30 | if updated := storeAntigravityPrimaryModels([]*registry.ModelInfo{}); updated { | ||
| 31 | t.Fatal("expected empty model list not to overwrite primary cache") | ||
| 32 | } | ||
| 33 | |||
| 34 | got := loadAntigravityPrimaryModels() | ||
| 35 | if len(got) != 2 { | ||
| 36 | t.Fatalf("expected cached model count 2, got %d", len(got)) | ||
| 37 | } | ||
| 38 | if got[0].ID != "claude-sonnet-4-5" || got[1].ID != "gemini-2.5-pro" { | ||
| 39 | t.Fatalf("unexpected cached model ids: %q, %q", got[0].ID, got[1].ID) | ||
| 40 | } | ||
| 41 | } | ||
| 42 | |||
| 43 | func TestLoadAntigravityPrimaryModels_ReturnsClone(t *testing.T) { | ||
| 44 | resetAntigravityPrimaryModelsCacheForTest() | ||
| 45 | t.Cleanup(resetAntigravityPrimaryModelsCacheForTest) | ||
| 46 | |||
| 47 | if updated := storeAntigravityPrimaryModels([]*registry.ModelInfo{{ | ||
| 48 | ID: "gpt-5", | ||
| 49 | DisplayName: "GPT-5", | ||
| 50 | SupportedGenerationMethods: []string{"generateContent"}, | ||
| 51 | SupportedParameters: []string{"temperature"}, | ||
| 52 | Thinking: ®istry.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 | } | ||
| @@ -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, err | 160 | 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, err | 264 | 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, err | 362 | 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 | ||
| 676 | func newCodexStatusErr(statusCode int, body []byte) statusErr { | ||
| 677 | err := statusErr{code: statusCode, msg: string(body)} | ||
| 678 | if retryAfter := parseCodexRetryAfter(statusCode, body, time.Now()); retryAfter != nil { | ||
| 679 | err.retryAfter = retryAfter | ||
| 680 | } | ||
| 681 | return err | ||
| 682 | } | ||
| 683 | |||
| 684 | func parseCodexRetryAfter(statusCode int, errorBody []byte, now time.Time) *time.Duration { | ||
| 685 | if statusCode != http.StatusTooManyRequests || len(errorBody) == 0 { | ||
| 686 | return nil | ||
| 687 | } | ||
| 688 | if strings.TrimSpace(gjson.GetBytes(errorBody, "error.type").String()) != "usage_limit_reached" { | ||
| 689 | return nil | ||
| 690 | } | ||
| 691 | if resetsAt := gjson.GetBytes(errorBody, "error.resets_at").Int(); resetsAt > 0 { | ||
| 692 | resetAtTime := time.Unix(resetsAt, 0) | ||
| 693 | if resetAtTime.After(now) { | ||
| 694 | retryAfter := resetAtTime.Sub(now) | ||
| 695 | return &retryAfter | ||
| 696 | } | ||
| 697 | } | ||
| 698 | if resetsInSeconds := gjson.GetBytes(errorBody, "error.resets_in_seconds").Int(); resetsInSeconds > 0 { | ||
| 699 | retryAfter := time.Duration(resetsInSeconds) * time.Second | ||
| 700 | return &retryAfter | ||
| 701 | } | ||
| 702 | return nil | ||
| 703 | } | ||
| 704 | |||
| 676 | func codexCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { | 705 | func codexCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { |
| 677 | if a == nil { | 706 | if a == nil { |
| 678 | return "", "" | 707 | return "", "" |
| @@ -0,0 +1,65 @@ | |||
| 1 | package executor | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "net/http" | ||
| 5 | "strconv" | ||
| 6 | "testing" | ||
| 7 | "time" | ||
| 8 | ) | ||
| 9 | |||
| 10 | func TestParseCodexRetryAfter(t *testing.T) { | ||
| 11 | now := time.Unix(1_700_000_000, 0) | ||
| 12 | |||
| 13 | t.Run("resets_in_seconds", func(t *testing.T) { | ||
| 14 | body := []byte(`{"error":{"type":"usage_limit_reached","resets_in_seconds":123}}`) | ||
| 15 | retryAfter := parseCodexRetryAfter(http.StatusTooManyRequests, body, now) | ||
| 16 | if retryAfter == nil { | ||
| 17 | t.Fatalf("expected retryAfter, got nil") | ||
| 18 | } | ||
| 19 | if *retryAfter != 123*time.Second { | ||
| 20 | t.Fatalf("retryAfter = %v, want %v", *retryAfter, 123*time.Second) | ||
| 21 | } | ||
| 22 | }) | ||
| 23 | |||
| 24 | t.Run("prefers resets_at", func(t *testing.T) { | ||
| 25 | resetAt := now.Add(5 * time.Minute).Unix() | ||
| 26 | body := []byte(`{"error":{"type":"usage_limit_reached","resets_at":` + itoa(resetAt) + `,"resets_in_seconds":1}}`) | ||
| 27 | retryAfter := parseCodexRetryAfter(http.StatusTooManyRequests, body, now) | ||
| 28 | if retryAfter == nil { | ||
| 29 | t.Fatalf("expected retryAfter, got nil") | ||
| 30 | } | ||
| 31 | if *retryAfter != 5*time.Minute { | ||
| 32 | t.Fatalf("retryAfter = %v, want %v", *retryAfter, 5*time.Minute) | ||
| 33 | } | ||
| 34 | }) | ||
| 35 | |||
| 36 | t.Run("fallback when resets_at is past", func(t *testing.T) { | ||
| 37 | resetAt := now.Add(-1 * time.Minute).Unix() | ||
| 38 | body := []byte(`{"error":{"type":"usage_limit_reached","resets_at":` + itoa(resetAt) + `,"resets_in_seconds":77}}`) | ||
| 39 | retryAfter := parseCodexRetryAfter(http.StatusTooManyRequests, body, now) | ||
| 40 | if retryAfter == nil { | ||
| 41 | t.Fatalf("expected retryAfter, got nil") | ||
| 42 | } | ||
| 43 | if *retryAfter != 77*time.Second { | ||
| 44 | t.Fatalf("retryAfter = %v, want %v", *retryAfter, 77*time.Second) | ||
| 45 | } | ||
| 46 | }) | ||
| 47 | |||
| 48 | t.Run("non-429 status code", func(t *testing.T) { | ||
| 49 | body := []byte(`{"error":{"type":"usage_limit_reached","resets_in_seconds":30}}`) | ||
| 50 | if got := parseCodexRetryAfter(http.StatusBadRequest, body, now); got != nil { | ||
| 51 | t.Fatalf("expected nil for non-429, got %v", *got) | ||
| 52 | } | ||
| 53 | }) | ||
| 54 | |||
| 55 | t.Run("non usage_limit_reached error type", func(t *testing.T) { | ||
| 56 | body := []byte(`{"error":{"type":"server_error","resets_in_seconds":30}}`) | ||
| 57 | if got := parseCodexRetryAfter(http.StatusTooManyRequests, body, now); got != nil { | ||
| 58 | t.Fatalf("expected nil for non-usage_limit_reached, got %v", *got) | ||
| 59 | } | ||
| 60 | }) | ||
| 61 | } | ||
| 62 | |||
| 63 | func itoa(v int64) string { | ||
| 64 | return strconv.FormatInt(v, 10) | ||
| 65 | } | ||
| @@ -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 | ||
| 24 | const ( | 25 | const ( |
| 25 | qwenUserAgent = "QwenCode/0.10.3 (darwin; arm64)" | 26 | qwenUserAgent = "QwenCode/0.10.3 (darwin; arm64)" |
| 27 | qwenRateLimitPerMin = 60 // 60 requests per minute per credential | ||
| 28 | qwenRateLimitWindow = time.Minute // sliding window duration | ||
| 26 | ) | 29 | ) |
| 27 | 30 | ||
| 31 | // qwenBeijingLoc caches the Beijing timezone to avoid repeated LoadLocation syscalls. | ||
| 32 | var qwenBeijingLoc = func() *time.Location { | ||
| 33 | loc, err := time.LoadLocation("Asia/Shanghai") | ||
| 34 | if err != nil || loc == nil { | ||
| 35 | log.Warnf("qwen: failed to load Asia/Shanghai timezone: %v, using fixed UTC+8", err) | ||
| 36 | return time.FixedZone("CST", 8*3600) | ||
| 37 | } | ||
| 38 | return loc | ||
| 39 | }() | ||
| 40 | |||
| 41 | // qwenQuotaCodes is a package-level set of error codes that indicate quota exhaustion. | ||
| 42 | var qwenQuotaCodes = map[string]struct{}{ | ||
| 43 | "insufficient_quota": {}, | ||
| 44 | "quota_exceeded": {}, | ||
| 45 | } | ||
| 46 | |||
| 47 | // qwenRateLimiter tracks request timestamps per credential for rate limiting. | ||
| 48 | // Qwen has a limit of 60 requests per minute per account. | ||
| 49 | var qwenRateLimiter = struct { | ||
| 50 | sync.Mutex | ||
| 51 | requests map[string][]time.Time // authID -> request timestamps | ||
| 52 | }{ | ||
| 53 | requests: make(map[string][]time.Time), | ||
| 54 | } | ||
| 55 | |||
| 56 | // redactAuthID returns a redacted version of the auth ID for safe logging. | ||
| 57 | // Keeps a small prefix/suffix to allow correlation across events. | ||
| 58 | func redactAuthID(id string) string { | ||
| 59 | if id == "" { | ||
| 60 | return "" | ||
| 61 | } | ||
| 62 | if len(id) <= 8 { | ||
| 63 | return id | ||
| 64 | } | ||
| 65 | return id[:4] + "..." + id[len(id)-4:] | ||
| 66 | } | ||
| 67 | |||
| 68 | // checkQwenRateLimit checks if the credential has exceeded the rate limit. | ||
| 69 | // Returns nil if allowed, or a statusErr with retryAfter if rate limited. | ||
| 70 | func checkQwenRateLimit(authID string) error { | ||
| 71 | if authID == "" { | ||
| 72 | // Empty authID should not bypass rate limiting in production | ||
| 73 | // Use debug level to avoid log spam for certain auth flows | ||
| 74 | log.Debug("qwen rate limit check: empty authID, skipping rate limit") | ||
| 75 | return nil | ||
| 76 | } | ||
| 77 | |||
| 78 | now := time.Now() | ||
| 79 | windowStart := now.Add(-qwenRateLimitWindow) | ||
| 80 | |||
| 81 | qwenRateLimiter.Lock() | ||
| 82 | defer qwenRateLimiter.Unlock() | ||
| 83 | |||
| 84 | // Get and filter timestamps within the window | ||
| 85 | timestamps := qwenRateLimiter.requests[authID] | ||
| 86 | var validTimestamps []time.Time | ||
| 87 | for _, ts := range timestamps { | ||
| 88 | if ts.After(windowStart) { | ||
| 89 | validTimestamps = append(validTimestamps, ts) | ||
| 90 | } | ||
| 91 | } | ||
| 92 | |||
| 93 | // Always prune expired entries to prevent memory leak | ||
| 94 | // Delete empty entries, otherwise update with pruned slice | ||
| 95 | if len(validTimestamps) == 0 { | ||
| 96 | delete(qwenRateLimiter.requests, authID) | ||
| 97 | } | ||
| 98 | |||
| 99 | // Check if rate limit exceeded | ||
| 100 | if len(validTimestamps) >= qwenRateLimitPerMin { | ||
| 101 | // Calculate when the oldest request will expire | ||
| 102 | oldestInWindow := validTimestamps[0] | ||
| 103 | retryAfter := oldestInWindow.Add(qwenRateLimitWindow).Sub(now) | ||
| 104 | if retryAfter < time.Second { | ||
| 105 | retryAfter = time.Second | ||
| 106 | } | ||
| 107 | retryAfterSec := int(retryAfter.Seconds()) | ||
| 108 | return statusErr{ | ||
| 109 | code: http.StatusTooManyRequests, | ||
| 110 | msg: fmt.Sprintf(`{"error":{"code":"rate_limit_exceeded","message":"Qwen rate limit: %d requests/minute exceeded, retry after %ds","type":"rate_limit_exceeded"}}`, qwenRateLimitPerMin, retryAfterSec), | ||
| 111 | retryAfter: &retryAfter, | ||
| 112 | } | ||
| 113 | } | ||
| 114 | |||
| 115 | // Record this request and update the map with pruned timestamps | ||
| 116 | validTimestamps = append(validTimestamps, now) | ||
| 117 | qwenRateLimiter.requests[authID] = validTimestamps | ||
| 118 | |||
| 119 | return nil | ||
| 120 | } | ||
| 121 | |||
| 122 | // isQwenQuotaError checks if the error response indicates a quota exceeded error. | ||
| 123 | // Qwen returns HTTP 403 with error.code="insufficient_quota" when daily quota is exhausted. | ||
| 124 | func isQwenQuotaError(body []byte) bool { | ||
| 125 | code := strings.ToLower(gjson.GetBytes(body, "error.code").String()) | ||
| 126 | errType := strings.ToLower(gjson.GetBytes(body, "error.type").String()) | ||
| 127 | |||
| 128 | // Primary check: exact match on error.code or error.type (most reliable) | ||
| 129 | if _, ok := qwenQuotaCodes[code]; ok { | ||
| 130 | return true | ||
| 131 | } | ||
| 132 | if _, ok := qwenQuotaCodes[errType]; ok { | ||
| 133 | return true | ||
| 134 | } | ||
| 135 | |||
| 136 | // Fallback: check message only if code/type don't match (less reliable) | ||
| 137 | msg := strings.ToLower(gjson.GetBytes(body, "error.message").String()) | ||
| 138 | if strings.Contains(msg, "insufficient_quota") || strings.Contains(msg, "quota exceeded") || | ||
| 139 | strings.Contains(msg, "free allocated quota exceeded") { | ||
| 140 | return true | ||
| 141 | } | ||
| 142 | |||
| 143 | return false | ||
| 144 | } | ||
| 145 | |||
| 146 | // wrapQwenError wraps an HTTP error response, detecting quota errors and mapping them to 429. | ||
| 147 | // Returns the appropriate status code and retryAfter duration for statusErr. | ||
| 148 | // Only checks for quota errors when httpCode is 403 or 429 to avoid false positives. | ||
| 149 | func wrapQwenError(ctx context.Context, httpCode int, body []byte) (errCode int, retryAfter *time.Duration) { | ||
| 150 | errCode = httpCode | ||
| 151 | // Only check quota errors for expected status codes to avoid false positives | ||
| 152 | // Qwen returns 403 for quota errors, 429 for rate limits | ||
| 153 | if (httpCode == http.StatusForbidden || httpCode == http.StatusTooManyRequests) && isQwenQuotaError(body) { | ||
| 154 | errCode = http.StatusTooManyRequests // Map to 429 to trigger quota logic | ||
| 155 | cooldown := timeUntilNextDay() | ||
| 156 | retryAfter = &cooldown | ||
| 157 | logWithRequestID(ctx).Warnf("qwen quota exceeded (http %d -> %d), cooling down until tomorrow (%v)", httpCode, errCode, cooldown) | ||
| 158 | } | ||
| 159 | return errCode, retryAfter | ||
| 160 | } | ||
| 161 | |||
| 162 | // timeUntilNextDay returns duration until midnight Beijing time (UTC+8). | ||
| 163 | // Qwen's daily quota resets at 00:00 Beijing time. | ||
| 164 | func timeUntilNextDay() time.Duration { | ||
| 165 | now := time.Now() | ||
| 166 | nowLocal := now.In(qwenBeijingLoc) | ||
| 167 | tomorrow := time.Date(nowLocal.Year(), nowLocal.Month(), nowLocal.Day()+1, 0, 0, 0, 0, qwenBeijingLoc) | ||
| 168 | return tomorrow.Sub(now) | ||
| 169 | } | ||
| 170 | |||
| 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. |
| 30 | type QwenExecutor struct { | 173 | type 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).ModelName | 224 | 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, err | 256 | return resp, err |
| 103 | } | 257 | } |
| 104 | applyQwenHeaders(httpReq, token, false) | 258 | applyQwenHeaders(httpReq, token, false) |
| 105 | var authID, authLabel, authType, authValue string | 259 | var authLabel, authType, authValue string |
| 106 | if auth != nil { | 260 | if auth != nil { |
| 107 | authID = auth.ID | ||
| 108 | authLabel = auth.Label | 261 | 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, err | 295 | 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).ModelName | 327 | 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, err | 366 | return nil, err |
| 201 | } | 367 | } |
| 202 | applyQwenHeaders(httpReq, token, true) | 368 | applyQwenHeaders(httpReq, token, true) |
| 203 | var authID, authLabel, authType, authValue string | 369 | var authLabel, authType, authValue string |
| 204 | if auth != nil { | 370 | if auth != nil { |
| 205 | authID = auth.ID | ||
| 206 | authLabel = auth.Label | 371 | 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, err | 403 | return nil, err |
| 237 | } | 404 | } |
| 238 | out := make(chan cliproxyexecutor.StreamChunk) | 405 | out := make(chan cliproxyexecutor.StreamChunk) |
| @@ -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. | ||
| 21 | var validReasoningEffortLevels = map[string]struct{}{ | ||
| 22 | "none": {}, | ||
| 23 | "low": {}, | ||
| 24 | "medium": {}, | ||
| 25 | "high": {}, | ||
| 26 | } | ||
| 27 | |||
| 28 | // clampReasoningEffort maps any thinking level string to a value that is safe | ||
| 29 | // to send as OpenAI reasoning_effort. Non-standard CPA-internal values are | ||
| 30 | // mapped to the nearest standard equivalent. | ||
| 31 | // | ||
| 32 | // Mapping rules: | ||
| 33 | // - none / low / medium / high → returned as-is (already valid) | ||
| 34 | // - xhigh → "high" (nearest lower standard level) | ||
| 35 | // - minimal → "low" (nearest higher standard level) | ||
| 36 | // - auto → "medium" (reasonable default) | ||
| 37 | // - anything else → "medium" (safe default) | ||
| 38 | func clampReasoningEffort(level string) string { | ||
| 39 | if _, ok := validReasoningEffortLevels[level]; ok { | ||
| 40 | return level | ||
| 41 | } | ||
| 42 | var clamped string | ||
| 43 | switch level { | ||
| 44 | case string(thinking.LevelXHigh): | ||
| 45 | clamped = string(thinking.LevelHigh) | ||
| 46 | case string(thinking.LevelMinimal): | ||
| 47 | clamped = string(thinking.LevelLow) | ||
| 48 | case string(thinking.LevelAuto): | ||
| 49 | clamped = string(thinking.LevelMedium) | ||
| 50 | default: | ||
| 51 | clamped = string(thinking.LevelMedium) | ||
| 52 | } | ||
| 53 | log.WithFields(log.Fields{ | ||
| 54 | "original": level, | ||
| 55 | "clamped": clamped, | ||
| 56 | }).Debug("openai: reasoning_effort clamped to nearest valid standard value") | ||
| 57 | return clamped | ||
| 58 | } | ||
| 59 | |||
| 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, nil | 62 | 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, nil | 79 | 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, nil | 83 | 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, nil | 114 | 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, nil | 118 | return result, nil |
| 162 | } | 119 | } |
| 163 | 120 | ||
| @@ -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) |
| @@ -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 | ||
| 743 | func TestConvertClaudeRequestToAntigravity_ToolResultWithImage(t *testing.T) { | ||
| 744 | // tool_result with array content containing text + image should place | ||
| 745 | // image data inside functionResponse.parts as inlineData, not as a | ||
| 746 | // sibling part in the outer content (to avoid base64 context bloat). | ||
| 747 | inputJSON := []byte(`{ | ||
| 748 | "model": "claude-3-5-sonnet-20240620", | ||
| 749 | "messages": [ | ||
| 750 | { | ||
| 751 | "role": "user", | ||
| 752 | "content": [ | ||
| 753 | { | ||
| 754 | "type": "tool_result", | ||
| 755 | "tool_use_id": "Read-123-456", | ||
| 756 | "content": [ | ||
| 757 | { | ||
| 758 | "type": "text", | ||
| 759 | "text": "File content here" | ||
| 760 | }, | ||
| 761 | { | ||
| 762 | "type": "image", | ||
| 763 | "source": { | ||
| 764 | "type": "base64", | ||
| 765 | "media_type": "image/png", | ||
| 766 | "data": "iVBORw0KGgoAAAANSUhEUg==" | ||
| 767 | } | ||
| 768 | } | ||
| 769 | ] | ||
| 770 | } | ||
| 771 | ] | ||
| 772 | } | ||
| 773 | ] | ||
| 774 | }`) | ||
| 775 | |||
| 776 | output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false) | ||
| 777 | outputStr := string(output) | ||
| 778 | |||
| 779 | if !gjson.Valid(outputStr) { | ||
| 780 | t.Fatalf("Result is not valid JSON:\n%s", outputStr) | ||
| 781 | } | ||
| 782 | |||
| 783 | // Image should be inside functionResponse.parts, not as outer sibling part | ||
| 784 | funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse") | ||
| 785 | if !funcResp.Exists() { | ||
| 786 | t.Fatal("functionResponse should exist") | ||
| 787 | } | ||
| 788 | |||
| 789 | // Text content should be in response.result | ||
| 790 | resultText := funcResp.Get("response.result.text").String() | ||
| 791 | if resultText != "File content here" { | ||
| 792 | t.Errorf("Expected response.result.text = 'File content here', got '%s'", resultText) | ||
| 793 | } | ||
| 794 | |||
| 795 | // Image should be in functionResponse.parts[0].inlineData | ||
| 796 | inlineData := funcResp.Get("parts.0.inlineData") | ||
| 797 | if !inlineData.Exists() { | ||
| 798 | t.Fatal("functionResponse.parts[0].inlineData should exist") | ||
| 799 | } | ||
| 800 | if inlineData.Get("mimeType").String() != "image/png" { | ||
| 801 | t.Errorf("Expected mimeType 'image/png', got '%s'", inlineData.Get("mimeType").String()) | ||
| 802 | } | ||
| 803 | if !strings.Contains(inlineData.Get("data").String(), "iVBORw0KGgo") { | ||
| 804 | t.Error("data mismatch") | ||
| 805 | } | ||
| 806 | |||
| 807 | // Image should NOT be in outer parts (only functionResponse part should exist) | ||
| 808 | outerParts := gjson.Get(outputStr, "request.contents.0.parts") | ||
| 809 | if outerParts.IsArray() && len(outerParts.Array()) > 1 { | ||
| 810 | t.Errorf("Expected only 1 outer part (functionResponse), got %d", len(outerParts.Array())) | ||
| 811 | } | ||
| 812 | } | ||
| 813 | |||
| 814 | func TestConvertClaudeRequestToAntigravity_ToolResultWithSingleImage(t *testing.T) { | ||
| 815 | // tool_result with single image object as content should place | ||
| 816 | // image data inside functionResponse.parts, not as outer sibling part. | ||
| 817 | inputJSON := []byte(`{ | ||
| 818 | "model": "claude-3-5-sonnet-20240620", | ||
| 819 | "messages": [ | ||
| 820 | { | ||
| 821 | "role": "user", | ||
| 822 | "content": [ | ||
| 823 | { | ||
| 824 | "type": "tool_result", | ||
| 825 | "tool_use_id": "Read-789-012", | ||
| 826 | "content": { | ||
| 827 | "type": "image", | ||
| 828 | "source": { | ||
| 829 | "type": "base64", | ||
| 830 | "media_type": "image/jpeg", | ||
| 831 | "data": "/9j/4AAQSkZJRgABAQ==" | ||
| 832 | } | ||
| 833 | } | ||
| 834 | } | ||
| 835 | ] | ||
| 836 | } | ||
| 837 | ] | ||
| 838 | }`) | ||
| 839 | |||
| 840 | output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false) | ||
| 841 | outputStr := string(output) | ||
| 842 | |||
| 843 | if !gjson.Valid(outputStr) { | ||
| 844 | t.Fatalf("Result is not valid JSON:\n%s", outputStr) | ||
| 845 | } | ||
| 846 | |||
| 847 | funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse") | ||
| 848 | if !funcResp.Exists() { | ||
| 849 | t.Fatal("functionResponse should exist") | ||
| 850 | } | ||
| 851 | |||
| 852 | // response.result should be empty (image only) | ||
| 853 | if funcResp.Get("response.result").String() != "" { | ||
| 854 | t.Errorf("Expected empty response.result for image-only content, got '%s'", funcResp.Get("response.result").String()) | ||
| 855 | } | ||
| 856 | |||
| 857 | // Image should be in functionResponse.parts[0].inlineData | ||
| 858 | inlineData := funcResp.Get("parts.0.inlineData") | ||
| 859 | if !inlineData.Exists() { | ||
| 860 | t.Fatal("functionResponse.parts[0].inlineData should exist") | ||
| 861 | } | ||
| 862 | if inlineData.Get("mimeType").String() != "image/jpeg" { | ||
| 863 | t.Errorf("Expected mimeType 'image/jpeg', got '%s'", inlineData.Get("mimeType").String()) | ||
| 864 | } | ||
| 865 | |||
| 866 | // Image should NOT be in outer parts | ||
| 867 | outerParts := gjson.Get(outputStr, "request.contents.0.parts") | ||
| 868 | if outerParts.IsArray() && len(outerParts.Array()) > 1 { | ||
| 869 | t.Errorf("Expected only 1 outer part, got %d", len(outerParts.Array())) | ||
| 870 | } | ||
| 871 | } | ||
| 872 | |||
| 873 | func TestConvertClaudeRequestToAntigravity_ToolResultWithMultipleImagesAndTexts(t *testing.T) { | ||
| 874 | // tool_result with array content: 2 text items + 2 images | ||
| 875 | // All images go into functionResponse.parts, texts into response.result array | ||
| 876 | inputJSON := []byte(`{ | ||
| 877 | "model": "claude-3-5-sonnet-20240620", | ||
| 878 | "messages": [ | ||
| 879 | { | ||
| 880 | "role": "user", | ||
| 881 | "content": [ | ||
| 882 | { | ||
| 883 | "type": "tool_result", | ||
| 884 | "tool_use_id": "Multi-001", | ||
| 885 | "content": [ | ||
| 886 | {"type": "text", "text": "First text"}, | ||
| 887 | { | ||
| 888 | "type": "image", | ||
| 889 | "source": {"type": "base64", "media_type": "image/png", "data": "AAAA"} | ||
| 890 | }, | ||
| 891 | {"type": "text", "text": "Second text"}, | ||
| 892 | { | ||
| 893 | "type": "image", | ||
| 894 | "source": {"type": "base64", "media_type": "image/jpeg", "data": "BBBB"} | ||
| 895 | } | ||
| 896 | ] | ||
| 897 | } | ||
| 898 | ] | ||
| 899 | } | ||
| 900 | ] | ||
| 901 | }`) | ||
| 902 | |||
| 903 | output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false) | ||
| 904 | outputStr := string(output) | ||
| 905 | |||
| 906 | if !gjson.Valid(outputStr) { | ||
| 907 | t.Fatalf("Result is not valid JSON:\n%s", outputStr) | ||
| 908 | } | ||
| 909 | |||
| 910 | funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse") | ||
| 911 | if !funcResp.Exists() { | ||
| 912 | t.Fatal("functionResponse should exist") | ||
| 913 | } | ||
| 914 | |||
| 915 | // Multiple text items => response.result is an array | ||
| 916 | resultArr := funcResp.Get("response.result") | ||
| 917 | if !resultArr.IsArray() { | ||
| 918 | t.Fatalf("Expected response.result to be an array, got: %s", resultArr.Raw) | ||
| 919 | } | ||
| 920 | results := resultArr.Array() | ||
| 921 | if len(results) != 2 { | ||
| 922 | t.Fatalf("Expected 2 result items, got %d", len(results)) | ||
| 923 | } | ||
| 924 | |||
| 925 | // Both images should be in functionResponse.parts | ||
| 926 | imgParts := funcResp.Get("parts").Array() | ||
| 927 | if len(imgParts) != 2 { | ||
| 928 | t.Fatalf("Expected 2 image parts in functionResponse.parts, got %d", len(imgParts)) | ||
| 929 | } | ||
| 930 | if imgParts[0].Get("inlineData.mimeType").String() != "image/png" { | ||
| 931 | t.Errorf("Expected first image mimeType 'image/png', got '%s'", imgParts[0].Get("inlineData.mimeType").String()) | ||
| 932 | } | ||
| 933 | if imgParts[0].Get("inlineData.data").String() != "AAAA" { | ||
| 934 | t.Errorf("Expected first image data 'AAAA', got '%s'", imgParts[0].Get("inlineData.data").String()) | ||
| 935 | } | ||
| 936 | if imgParts[1].Get("inlineData.mimeType").String() != "image/jpeg" { | ||
| 937 | t.Errorf("Expected second image mimeType 'image/jpeg', got '%s'", imgParts[1].Get("inlineData.mimeType").String()) | ||
| 938 | } | ||
| 939 | if imgParts[1].Get("inlineData.data").String() != "BBBB" { | ||
| 940 | t.Errorf("Expected second image data 'BBBB', got '%s'", imgParts[1].Get("inlineData.data").String()) | ||
| 941 | } | ||
| 942 | |||
| 943 | // Only 1 outer part (the functionResponse itself) | ||
| 944 | outerParts := gjson.Get(outputStr, "request.contents.0.parts").Array() | ||
| 945 | if len(outerParts) != 1 { | ||
| 946 | t.Errorf("Expected 1 outer part, got %d", len(outerParts)) | ||
| 947 | } | ||
| 948 | } | ||
| 949 | |||
| 950 | func TestConvertClaudeRequestToAntigravity_ToolResultWithOnlyMultipleImages(t *testing.T) { | ||
| 951 | // tool_result with only images (no text) — response.result should be empty string | ||
| 952 | inputJSON := []byte(`{ | ||
| 953 | "model": "claude-3-5-sonnet-20240620", | ||
| 954 | "messages": [ | ||
| 955 | { | ||
| 956 | "role": "user", | ||
| 957 | "content": [ | ||
| 958 | { | ||
| 959 | "type": "tool_result", | ||
| 960 | "tool_use_id": "ImgOnly-001", | ||
| 961 | "content": [ | ||
| 962 | { | ||
| 963 | "type": "image", | ||
| 964 | "source": {"type": "base64", "media_type": "image/png", "data": "PNG1"} | ||
| 965 | }, | ||
| 966 | { | ||
| 967 | "type": "image", | ||
| 968 | "source": {"type": "base64", "media_type": "image/gif", "data": "GIF1"} | ||
| 969 | } | ||
| 970 | ] | ||
| 971 | } | ||
| 972 | ] | ||
| 973 | } | ||
| 974 | ] | ||
| 975 | }`) | ||
| 976 | |||
| 977 | output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false) | ||
| 978 | outputStr := string(output) | ||
| 979 | |||
| 980 | if !gjson.Valid(outputStr) { | ||
| 981 | t.Fatalf("Result is not valid JSON:\n%s", outputStr) | ||
| 982 | } | ||
| 983 | |||
| 984 | funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse") | ||
| 985 | if !funcResp.Exists() { | ||
| 986 | t.Fatal("functionResponse should exist") | ||
| 987 | } | ||
| 988 | |||
| 989 | // No text => response.result should be empty string | ||
| 990 | if funcResp.Get("response.result").String() != "" { | ||
| 991 | t.Errorf("Expected empty response.result, got '%s'", funcResp.Get("response.result").String()) | ||
| 992 | } | ||
| 993 | |||
| 994 | // Both images in functionResponse.parts | ||
| 995 | imgParts := funcResp.Get("parts").Array() | ||
| 996 | if len(imgParts) != 2 { | ||
| 997 | t.Fatalf("Expected 2 image parts, got %d", len(imgParts)) | ||
| 998 | } | ||
| 999 | if imgParts[0].Get("inlineData.mimeType").String() != "image/png" { | ||
| 1000 | t.Error("first image mimeType mismatch") | ||
| 1001 | } | ||
| 1002 | if imgParts[1].Get("inlineData.mimeType").String() != "image/gif" { | ||
| 1003 | t.Error("second image mimeType mismatch") | ||
| 1004 | } | ||
| 1005 | |||
| 1006 | // Only 1 outer part | ||
| 1007 | outerParts := gjson.Get(outputStr, "request.contents.0.parts").Array() | ||
| 1008 | if len(outerParts) != 1 { | ||
| 1009 | t.Errorf("Expected 1 outer part, got %d", len(outerParts)) | ||
| 1010 | } | ||
| 1011 | } | ||
| 1012 | |||
| 1013 | func TestConvertClaudeRequestToAntigravity_ToolResultImageNotBase64(t *testing.T) { | ||
| 1014 | // image with source.type != "base64" should be treated as non-image (falls through) | ||
| 1015 | inputJSON := []byte(`{ | ||
| 1016 | "model": "claude-3-5-sonnet-20240620", | ||
| 1017 | "messages": [ | ||
| 1018 | { | ||
| 1019 | "role": "user", | ||
| 1020 | "content": [ | ||
| 1021 | { | ||
| 1022 | "type": "tool_result", | ||
| 1023 | "tool_use_id": "NotB64-001", | ||
| 1024 | "content": [ | ||
| 1025 | {"type": "text", "text": "some output"}, | ||
| 1026 | { | ||
| 1027 | "type": "image", | ||
| 1028 | "source": {"type": "url", "url": "https://example.com/img.png"} | ||
| 1029 | } | ||
| 1030 | ] | ||
| 1031 | } | ||
| 1032 | ] | ||
| 1033 | } | ||
| 1034 | ] | ||
| 1035 | }`) | ||
| 1036 | |||
| 1037 | output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false) | ||
| 1038 | outputStr := string(output) | ||
| 1039 | |||
| 1040 | if !gjson.Valid(outputStr) { | ||
| 1041 | t.Fatalf("Result is not valid JSON:\n%s", outputStr) | ||
| 1042 | } | ||
| 1043 | |||
| 1044 | funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse") | ||
| 1045 | if !funcResp.Exists() { | ||
| 1046 | t.Fatal("functionResponse should exist") | ||
| 1047 | } | ||
| 1048 | |||
| 1049 | // Non-base64 image is treated as non-image, so it goes into the filtered results | ||
| 1050 | // along with the text item. Since there are 2 non-image items, result is array. | ||
| 1051 | resultArr := funcResp.Get("response.result") | ||
| 1052 | if !resultArr.IsArray() { | ||
| 1053 | t.Fatalf("Expected response.result to be an array (2 non-image items), got: %s", resultArr.Raw) | ||
| 1054 | } | ||
| 1055 | results := resultArr.Array() | ||
| 1056 | if len(results) != 2 { | ||
| 1057 | t.Fatalf("Expected 2 result items, got %d", len(results)) | ||
| 1058 | } | ||
| 1059 | |||
| 1060 | // No functionResponse.parts (no base64 images collected) | ||
| 1061 | if funcResp.Get("parts").Exists() { | ||
| 1062 | t.Error("functionResponse.parts should NOT exist when no base64 images") | ||
| 1063 | } | ||
| 1064 | } | ||
| 1065 | |||
| 1066 | func TestConvertClaudeRequestToAntigravity_ToolResultImageMissingData(t *testing.T) { | ||
| 1067 | // image with source.type=base64 but missing data field | ||
| 1068 | inputJSON := []byte(`{ | ||
| 1069 | "model": "claude-3-5-sonnet-20240620", | ||
| 1070 | "messages": [ | ||
| 1071 | { | ||
| 1072 | "role": "user", | ||
| 1073 | "content": [ | ||
| 1074 | { | ||
| 1075 | "type": "tool_result", | ||
| 1076 | "tool_use_id": "NoData-001", | ||
| 1077 | "content": [ | ||
| 1078 | {"type": "text", "text": "output"}, | ||
| 1079 | { | ||
| 1080 | "type": "image", | ||
| 1081 | "source": {"type": "base64", "media_type": "image/png"} | ||
| 1082 | } | ||
| 1083 | ] | ||
| 1084 | } | ||
| 1085 | ] | ||
| 1086 | } | ||
| 1087 | ] | ||
| 1088 | }`) | ||
| 1089 | |||
| 1090 | output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false) | ||
| 1091 | outputStr := string(output) | ||
| 1092 | |||
| 1093 | if !gjson.Valid(outputStr) { | ||
| 1094 | t.Fatalf("Result is not valid JSON:\n%s", outputStr) | ||
| 1095 | } | ||
| 1096 | |||
| 1097 | funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse") | ||
| 1098 | if !funcResp.Exists() { | ||
| 1099 | t.Fatal("functionResponse should exist") | ||
| 1100 | } | ||
| 1101 | |||
| 1102 | // The image is still classified as base64 image (type check passes), | ||
| 1103 | // but data field is missing => inlineData has mimeType but no data | ||
| 1104 | imgParts := funcResp.Get("parts").Array() | ||
| 1105 | if len(imgParts) != 1 { | ||
| 1106 | t.Fatalf("Expected 1 image part, got %d", len(imgParts)) | ||
| 1107 | } | ||
| 1108 | if imgParts[0].Get("inlineData.mimeType").String() != "image/png" { | ||
| 1109 | t.Error("mimeType should still be set") | ||
| 1110 | } | ||
| 1111 | if imgParts[0].Get("inlineData.data").Exists() { | ||
| 1112 | t.Error("data should not exist when source.data is missing") | ||
| 1113 | } | ||
| 1114 | } | ||
| 1115 | |||
| 1116 | func TestConvertClaudeRequestToAntigravity_ToolResultImageMissingMediaType(t *testing.T) { | ||
| 1117 | // image with source.type=base64 but missing media_type field | ||
| 1118 | inputJSON := []byte(`{ | ||
| 1119 | "model": "claude-3-5-sonnet-20240620", | ||
| 1120 | "messages": [ | ||
| 1121 | { | ||
| 1122 | "role": "user", | ||
| 1123 | "content": [ | ||
| 1124 | { | ||
| 1125 | "type": "tool_result", | ||
| 1126 | "tool_use_id": "NoMime-001", | ||
| 1127 | "content": [ | ||
| 1128 | {"type": "text", "text": "output"}, | ||
| 1129 | { | ||
| 1130 | "type": "image", | ||
| 1131 | "source": {"type": "base64", "data": "AAAA"} | ||
| 1132 | } | ||
| 1133 | ] | ||
| 1134 | } | ||
| 1135 | ] | ||
| 1136 | } | ||
| 1137 | ] | ||
| 1138 | }`) | ||
| 1139 | |||
| 1140 | output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false) | ||
| 1141 | outputStr := string(output) | ||
| 1142 | |||
| 1143 | if !gjson.Valid(outputStr) { | ||
| 1144 | t.Fatalf("Result is not valid JSON:\n%s", outputStr) | ||
| 1145 | } | ||
| 1146 | |||
| 1147 | funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse") | ||
| 1148 | if !funcResp.Exists() { | ||
| 1149 | t.Fatal("functionResponse should exist") | ||
| 1150 | } | ||
| 1151 | |||
| 1152 | // The image is still classified as base64 image, | ||
| 1153 | // but media_type is missing => inlineData has data but no mimeType | ||
| 1154 | imgParts := funcResp.Get("parts").Array() | ||
| 1155 | if len(imgParts) != 1 { | ||
| 1156 | t.Fatalf("Expected 1 image part, got %d", len(imgParts)) | ||
| 1157 | } | ||
| 1158 | if imgParts[0].Get("inlineData.mimeType").Exists() { | ||
| 1159 | t.Error("mimeType should not exist when media_type is missing") | ||
| 1160 | } | ||
| 1161 | if imgParts[0].Get("inlineData.data").String() != "AAAA" { | ||
| 1162 | t.Error("data should still be set") | ||
| 1163 | } | ||
| 1164 | } | ||
| 1165 | |||
| 743 | func TestConvertClaudeRequestToAntigravity_ToolAndThinking_NoExistingSystem(t *testing.T) { | 1166 | func TestConvertClaudeRequestToAntigravity_ToolAndThinking_NoExistingSystem(t *testing.T) { |
| 744 | // When tools + thinking but no system instruction, should create one with hint | 1167 | // When tools + thinking but no system instruction, should create one with hint |
| 745 | inputJSON := []byte(`{ | 1168 | inputJSON := []byte(`{ |
| @@ -93,3 +93,81 @@ func TestConvertGeminiRequestToAntigravity_ParallelFunctionCalls(t *testing.T) { · 92 unmodified lines | |||
| 93 | } | 93 | } |
| 94 | } | 94 | } |
| 95 | } | 95 | } |
| 96 | |||
| 97 | func TestFixCLIToolResponse_PreservesFunctionResponseParts(t *testing.T) { | ||
| 98 | // When functionResponse contains a "parts" field with inlineData (from Claude | ||
| 99 | // translator's image embedding), fixCLIToolResponse should preserve it as-is. | ||
| 100 | // parseFunctionResponseRaw returns response.Raw for valid JSON objects, | ||
| 101 | // so extra fields like "parts" survive the pipeline. | ||
| 102 | input := `{ | ||
| 103 | "model": "claude-opus-4-6-thinking", | ||
| 104 | "request": { | ||
| 105 | "contents": [ | ||
| 106 | { | ||
| 107 | "role": "model", | ||
| 108 | "parts": [ | ||
| 109 | { | ||
| 110 | "functionCall": {"name": "screenshot", "args": {}} | ||
| 111 | } | ||
| 112 | ] | ||
| 113 | }, | ||
| 114 | { | ||
| 115 | "role": "function", | ||
| 116 | "parts": [ | ||
| 117 | { | ||
| 118 | "functionResponse": { | ||
| 119 | "id": "tool-001", | ||
| 120 | "name": "screenshot", | ||
| 121 | "response": {"result": "Screenshot taken"}, | ||
| 122 | "parts": [ | ||
| 123 | {"inlineData": {"mimeType": "image/png", "data": "iVBOR"}} | ||
| 124 | ] | ||
| 125 | } | ||
| 126 | } | ||
| 127 | ] | ||
| 128 | } | ||
| 129 | ] | ||
| 130 | } | ||
| 131 | }` | ||
| 132 | |||
| 133 | result, err := fixCLIToolResponse(input) | ||
| 134 | if err != nil { | ||
| 135 | t.Fatalf("fixCLIToolResponse failed: %v", err) | ||
| 136 | } | ||
| 137 | |||
| 138 | // Find the function response content (role=function) | ||
| 139 | contents := gjson.Get(result, "request.contents").Array() | ||
| 140 | var funcContent gjson.Result | ||
| 141 | for _, c := range contents { | ||
| 142 | if c.Get("role").String() == "function" { | ||
| 143 | funcContent = c | ||
| 144 | break | ||
| 145 | } | ||
| 146 | } | ||
| 147 | if !funcContent.Exists() { | ||
| 148 | t.Fatal("function role content should exist in output") | ||
| 149 | } | ||
| 150 | |||
| 151 | // The functionResponse should be preserved with its parts field | ||
| 152 | funcResp := funcContent.Get("parts.0.functionResponse") | ||
| 153 | if !funcResp.Exists() { | ||
| 154 | t.Fatal("functionResponse should exist in output") | ||
| 155 | } | ||
| 156 | |||
| 157 | // Verify the parts field with inlineData is preserved | ||
| 158 | inlineParts := funcResp.Get("parts").Array() | ||
| 159 | if len(inlineParts) != 1 { | ||
| 160 | t.Fatalf("Expected 1 inlineData part in functionResponse.parts, got %d", len(inlineParts)) | ||
| 161 | } | ||
| 162 | if inlineParts[0].Get("inlineData.mimeType").String() != "image/png" { | ||
| 163 | t.Errorf("Expected mimeType 'image/png', got '%s'", inlineParts[0].Get("inlineData.mimeType").String()) | ||
| 164 | } | ||
| 165 | if inlineParts[0].Get("inlineData.data").String() != "iVBOR" { | ||
| 166 | t.Errorf("Expected data 'iVBOR', got '%s'", inlineParts[0].Get("inlineData.data").String()) | ||
| 167 | } | ||
| 168 | |||
| 169 | // Verify response.result is also preserved | ||
| 170 | if funcResp.Get("response.result").String() != "Screenshot taken" { | ||
| 171 | t.Errorf("Expected response.result 'Screenshot taken', got '%s'", funcResp.Get("response.result").String()) | ||
| 172 | } | ||
| 173 | } | ||
| @@ -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++ |
| @@ -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() - cachedTokenCount | 98 | 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 | } |
| @@ -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 true | 218 | return true |
| 204 | }) | 219 | }) |
| @@ -155,6 +155,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte · 154 unmodified lines | |||
| 155 | var textAggregate strings.Builder | 155 | var textAggregate strings.Builder |
| 156 | var partsJSON []string | 156 | var partsJSON []string |
| 157 | hasImage := false | 157 | 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 = true | 208 | 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 true | 236 | 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 content | 257 | // 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]) |
| @@ -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 now | 183 | if role == "user" { |
| 184 | fileData := it.Get("file.file_data").String() | ||
| 185 | filename := it.Get("file.filename").String() | ||
| 186 | if fileData != "" { | ||
| 187 | part := `{}` | ||
| 188 | part, _ = sjson.Set(part, "type", "input_file") | ||
| 189 | part, _ = sjson.Set(part, "file_data", fileData) | ||
| 190 | if filename != "" { | ||
| 191 | part, _ = sjson.Set(part, "filename", filename) | ||
| 192 | } | ||
| 193 | msg, _ = sjson.SetRaw(msg, "content.-1", part) | ||
| 194 | } | ||
| 195 | } | ||
| 184 | } | 196 | } |
| 185 | } | 197 | } |
| 186 | } | 198 | } |
| @@ -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 rawJSON | 38 | 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. | ||
| 49 | func applyResponsesCompactionCompatibility(rawJSON []byte) []byte { | ||
| 50 | if !gjson.GetBytes(rawJSON, "context_management").Exists() { | ||
| 51 | return rawJSON | ||
| 52 | } | ||
| 53 | |||
| 54 | rawJSON, _ = sjson.DeleteBytes(rawJSON, "context_management") | ||
| 55 | return rawJSON | ||
| 56 | } | ||
| 57 | |||
| 39 | // convertSystemRoleToDeveloper traverses the input array and converts any message items | 58 | // convertSystemRoleToDeveloper traverses the input array and converts any message items |
| 40 | // with role "system" to role "developer". This is necessary because Codex API does not | 59 | // 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. |
| @@ -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 | |||
| 284 | func TestContextManagementCompactionCompatibility(t *testing.T) { | ||
| 285 | inputJSON := []byte(`{ | ||
| 286 | "model": "gpt-5.2", | ||
| 287 | "context_management": [ | ||
| 288 | { | ||
| 289 | "type": "compaction", | ||
| 290 | "compact_threshold": 12000 | ||
| 291 | } | ||
| 292 | ], | ||
| 293 | "input": [{"role":"user","content":"hello"}] | ||
| 294 | }`) | ||
| 295 | |||
| 296 | output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false) | ||
| 297 | outputStr := string(output) | ||
| 298 | |||
| 299 | if gjson.Get(outputStr, "context_management").Exists() { | ||
| 300 | t.Fatalf("context_management should be removed for Codex compatibility") | ||
| 301 | } | ||
| 302 | if gjson.Get(outputStr, "truncation").Exists() { | ||
| 303 | t.Fatalf("truncation should be removed for Codex compatibility") | ||
| 304 | } | ||
| 305 | } | ||
| 306 | |||
| 307 | func TestTruncationRemovedForCodexCompatibility(t *testing.T) { | ||
| 308 | inputJSON := []byte(`{ | ||
| 309 | "model": "gpt-5.2", | ||
| 310 | "truncation": "disabled", | ||
| 311 | "input": [{"role":"user","content":"hello"}] | ||
| 312 | }`) | ||
| 313 | |||
| 314 | output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false) | ||
| 315 | outputStr := string(output) | ||
| 316 | |||
| 317 | if gjson.Get(outputStr, "truncation").Exists() { | ||
| 318 | t.Fatalf("truncation should be removed for Codex compatibility") | ||
| 319 | } | ||
| 320 | } | ||
| @@ -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 | } |
| @@ -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() - cachedTokenCount | 103 | 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 | } |
| @@ -531,8 +531,8 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, · 530 unmodified lines | |||
| 531 | 531 | ||
| 532 | // usage mapping | 532 | // usage mapping |
| 533 | if um := root.Get("usageMetadata"); um.Exists() { | 533 | if um := root.Get("usageMetadata"); um.Exists() { |
| 534 | // input tokens = prompt + thoughts | 534 | // 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 mapping | 738 | // usage mapping |
| 739 | if um := root.Get("usageMetadata"); um.Exists() { | 739 | if um := root.Get("usageMetadata"); um.Exists() { |
| 740 | // input tokens = prompt + thoughts | 740 | // 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()) |
| @@ -716,6 +716,12 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl · 715 unmodified lines | |||
| 716 | return | 716 | 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 = true | 725 | sentPayload = true |
| 720 | if okSendData := sendData(cloneBytes(chunk.Payload)); !okSendData { | 726 | if okSendData := sendData(cloneBytes(chunk.Payload)); !okSendData { |
| 721 | return | 727 | return |
| @@ -727,6 +733,35 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl · 5 unmodified lines | |||
| 727 | return dataChan, upstreamHeaders, errChan | 733 | return dataChan, upstreamHeaders, errChan |
| 728 | } | 734 | } |
| 729 | 735 | ||
| 736 | func validateSSEDataJSON(chunk []byte) error { | ||
| 737 | for _, line := range bytes.Split(chunk, []byte("\n")) { | ||
| 738 | line = bytes.TrimSpace(line) | ||
| 739 | if len(line) == 0 { | ||
| 740 | continue | ||
| 741 | } | ||
| 742 | if !bytes.HasPrefix(line, []byte("data:")) { | ||
| 743 | continue | ||
| 744 | } | ||
| 745 | data := bytes.TrimSpace(line[5:]) | ||
| 746 | if len(data) == 0 { | ||
| 747 | continue | ||
| 748 | } | ||
| 749 | if bytes.Equal(data, []byte("[DONE]")) { | ||
| 750 | continue | ||
| 751 | } | ||
| 752 | if json.Valid(data) { | ||
| 753 | continue | ||
| 754 | } | ||
| 755 | const max = 512 | ||
| 756 | preview := data | ||
| 757 | if len(preview) > max { | ||
| 758 | preview = preview[:max] | ||
| 759 | } | ||
| 760 | return fmt.Errorf("invalid SSE data JSON (len=%d): %q", len(data), preview) | ||
| 761 | } | ||
| 762 | return nil | ||
| 763 | } | ||
| 764 | |||
| 730 | func statusFromError(err error) int { | 765 | func statusFromError(err error) int { |
| 731 | if err == nil { | 766 | if err == nil { |
| 732 | return 0 | 767 | return 0 |
| @@ -134,6 +134,37 @@ type authAwareStreamExecutor struct { · 133 unmodified lines | |||
| 134 | authIDs []string | 134 | authIDs []string |
| 135 | } | 135 | } |
| 136 | 136 | ||
| 137 | type invalidJSONStreamExecutor struct{} | ||
| 138 | |||
| 139 | func (e *invalidJSONStreamExecutor) Identifier() string { return "codex" } | ||
| 140 | |||
| 141 | func (e *invalidJSONStreamExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) { | ||
| 142 | return coreexecutor.Response{}, &coreauth.Error{Code: "not_implemented", Message: "Execute not implemented"} | ||
| 143 | } | ||
| 144 | |||
| 145 | func (e *invalidJSONStreamExecutor) ExecuteStream(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (*coreexecutor.StreamResult, error) { | ||
| 146 | ch := make(chan coreexecutor.StreamChunk, 1) | ||
| 147 | ch <- coreexecutor.StreamChunk{Payload: []byte("event: response.completed\ndata: {\"type\"")} | ||
| 148 | close(ch) | ||
| 149 | return &coreexecutor.StreamResult{Chunks: ch}, nil | ||
| 150 | } | ||
| 151 | |||
| 152 | func (e *invalidJSONStreamExecutor) Refresh(ctx context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) { | ||
| 153 | return auth, nil | ||
| 154 | } | ||
| 155 | |||
| 156 | func (e *invalidJSONStreamExecutor) CountTokens(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) { | ||
| 157 | return coreexecutor.Response{}, &coreauth.Error{Code: "not_implemented", Message: "CountTokens not implemented"} | ||
| 158 | } | ||
| 159 | |||
| 160 | func (e *invalidJSONStreamExecutor) HttpRequest(ctx context.Context, auth *coreauth.Auth, req *http.Request) (*http.Response, error) { | ||
| 161 | return nil, &coreauth.Error{ | ||
| 162 | Code: "not_implemented", | ||
| 163 | Message: "HttpRequest not implemented", | ||
| 164 | HTTPStatus: http.StatusNotImplemented, | ||
| 165 | } | ||
| 166 | } | ||
| 167 | |||
| 137 | func (e *authAwareStreamExecutor) Identifier() string { return "codex" } | 168 | func (e *authAwareStreamExecutor) Identifier() string { return "codex" } |
| 138 | 169 | ||
| 139 | func (e *authAwareStreamExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) { | 170 | func (e *authAwareStreamExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) { |
| @@ -524,3 +555,55 @@ func TestExecuteStreamWithAuthManager_SelectedAuthCallbackReceivesAuthID(t *test · 384 unmodified lines | |||
| 524 | t.Fatalf("selectedAuthID = %q, want %q", selectedAuthID, "auth2") | 555 | t.Fatalf("selectedAuthID = %q, want %q", selectedAuthID, "auth2") |
| 525 | } | 556 | } |
| 526 | } | 557 | } |
| 558 | |||
| 559 | func TestExecuteStreamWithAuthManager_ValidatesOpenAIResponsesStreamDataJSON(t *testing.T) { | ||
| 560 | executor := &invalidJSONStreamExecutor{} | ||
| 561 | manager := coreauth.NewManager(nil, nil, nil) | ||
| 562 | manager.RegisterExecutor(executor) | ||
| 563 | |||
| 564 | auth1 := &coreauth.Auth{ | ||
| 565 | ID: "auth1", | ||
| 566 | Provider: "codex", | ||
| 567 | Status: coreauth.StatusActive, | ||
| 568 | Metadata: map[string]any{"email": "test1@example.com"}, | ||
| 569 | } | ||
| 570 | if _, err := manager.Register(context.Background(), auth1); err != nil { | ||
| 571 | t.Fatalf("manager.Register(auth1): %v", err) | ||
| 572 | } | ||
| 573 | |||
| 574 | registry.GetGlobalRegistry().RegisterClient(auth1.ID, auth1.Provider, []*registry.ModelInfo{{ID: "test-model"}}) | ||
| 575 | t.Cleanup(func() { | ||
| 576 | registry.GetGlobalRegistry().UnregisterClient(auth1.ID) | ||
| 577 | }) | ||
| 578 | |||
| 579 | handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager) | ||
| 580 | dataChan, _, errChan := handler.ExecuteStreamWithAuthManager(context.Background(), "openai-response", "test-model", []byte(`{"model":"test-model"}`), "") | ||
| 581 | if dataChan == nil || errChan == nil { | ||
| 582 | t.Fatalf("expected non-nil channels") | ||
| 583 | } | ||
| 584 | |||
| 585 | var got []byte | ||
| 586 | for chunk := range dataChan { | ||
| 587 | got = append(got, chunk...) | ||
| 588 | } | ||
| 589 | if len(got) != 0 { | ||
| 590 | t.Fatalf("expected empty payload, got %q", string(got)) | ||
| 591 | } | ||
| 592 | |||
| 593 | gotErr := false | ||
| 594 | for msg := range errChan { | ||
| 595 | if msg == nil { | ||
| 596 | continue | ||
| 597 | } | ||
| 598 | if msg.StatusCode != http.StatusBadGateway { | ||
| 599 | t.Fatalf("expected status %d, got %d", http.StatusBadGateway, msg.StatusCode) | ||
| 600 | } | ||
| 601 | if msg.Error == nil { | ||
| 602 | t.Fatalf("expected error") | ||
| 603 | } | ||
| 604 | gotErr = true | ||
| 605 | } | ||
| 606 | if !gotErr { | ||
| 607 | t.Fatalf("expected terminal error") | ||
| 608 | } | ||
| 609 | } | ||
| @@ -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")) |
| @@ -0,0 +1,43 @@ | |||
| 1 | package openai | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "errors" | ||
| 5 | "net/http" | ||
| 6 | "net/http/httptest" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | |||
| 10 | "github.com/gin-gonic/gin" | ||
| 11 | "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" | ||
| 12 | "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" | ||
| 13 | sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" | ||
| 14 | ) | ||
| 15 | |||
| 16 | func TestForwardResponsesStreamTerminalErrorUsesResponsesErrorChunk(t *testing.T) { | ||
| 17 | gin.SetMode(gin.TestMode) | ||
| 18 | base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) | ||
| 19 | h := NewOpenAIResponsesAPIHandler(base) | ||
| 20 | |||
| 21 | recorder := httptest.NewRecorder() | ||
| 22 | c, _ := gin.CreateTestContext(recorder) | ||
| 23 | c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) | ||
| 24 | |||
| 25 | flusher, ok := c.Writer.(http.Flusher) | ||
| 26 | if !ok { | ||
| 27 | t.Fatalf("expected gin writer to implement http.Flusher") | ||
| 28 | } | ||
| 29 | |||
| 30 | data := make(chan []byte) | ||
| 31 | errs := make(chan *interfaces.ErrorMessage, 1) | ||
| 32 | errs <- &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: errors.New("unexpected EOF")} | ||
| 33 | close(errs) | ||
| 34 | |||
| 35 | h.forwardResponsesStream(c, flusher, func(error) {}, data, errs) | ||
| 36 | body := recorder.Body.String() | ||
| 37 | if !strings.Contains(body, `"type":"error"`) { | ||
| 38 | t.Fatalf("expected responses error chunk, got: %q", body) | ||
| 39 | } | ||
| 40 | if strings.Contains(body, `"error":{`) { | ||
| 41 | t.Fatalf("expected streaming error chunk (top-level type), got HTTP error body: %q", body) | ||
| 42 | } | ||
| 43 | } | ||
| @@ -0,0 +1,119 @@ | |||
| 1 | package handlers | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "fmt" | ||
| 6 | "net/http" | ||
| 7 | "strings" | ||
| 8 | ) | ||
| 9 | |||
| 10 | type openAIResponsesStreamErrorChunk struct { | ||
| 11 | Type string `json:"type"` | ||
| 12 | Code string `json:"code"` | ||
| 13 | Message string `json:"message"` | ||
| 14 | SequenceNumber int `json:"sequence_number"` | ||
| 15 | } | ||
| 16 | |||
| 17 | func openAIResponsesStreamErrorCode(status int) string { | ||
| 18 | switch status { | ||
| 19 | case http.StatusUnauthorized: | ||
| 20 | return "invalid_api_key" | ||
| 21 | case http.StatusForbidden: | ||
| 22 | return "insufficient_quota" | ||
| 23 | case http.StatusTooManyRequests: | ||
| 24 | return "rate_limit_exceeded" | ||
| 25 | case http.StatusNotFound: | ||
| 26 | return "model_not_found" | ||
| 27 | case http.StatusRequestTimeout: | ||
| 28 | return "request_timeout" | ||
| 29 | default: | ||
| 30 | if status >= http.StatusInternalServerError { | ||
| 31 | return "internal_server_error" | ||
| 32 | } | ||
| 33 | if status >= http.StatusBadRequest { | ||
| 34 | return "invalid_request_error" | ||
| 35 | } | ||
| 36 | return "unknown_error" | ||
| 37 | } | ||
| 38 | } | ||
| 39 | |||
| 40 | // BuildOpenAIResponsesStreamErrorChunk builds an OpenAI Responses streaming error chunk. | ||
| 41 | // | ||
| 42 | // Important: OpenAI's HTTP error bodies are shaped like {"error":{...}}; those are valid for | ||
| 43 | // non-streaming responses, but streaming clients validate SSE `data:` payloads against a union | ||
| 44 | // of chunks that requires a top-level `type` field. | ||
| 45 | func BuildOpenAIResponsesStreamErrorChunk(status int, errText string, sequenceNumber int) []byte { | ||
| 46 | if status <= 0 { | ||
| 47 | status = http.StatusInternalServerError | ||
| 48 | } | ||
| 49 | if sequenceNumber < 0 { | ||
| 50 | sequenceNumber = 0 | ||
| 51 | } | ||
| 52 | |||
| 53 | message := strings.TrimSpace(errText) | ||
| 54 | if message == "" { | ||
| 55 | message = http.StatusText(status) | ||
| 56 | } | ||
| 57 | |||
| 58 | code := openAIResponsesStreamErrorCode(status) | ||
| 59 | |||
| 60 | trimmed := strings.TrimSpace(errText) | ||
| 61 | if trimmed != "" && json.Valid([]byte(trimmed)) { | ||
| 62 | var payload map[string]any | ||
| 63 | if err := json.Unmarshal([]byte(trimmed), &payload); err == nil { | ||
| 64 | if t, ok := payload["type"].(string); ok && strings.TrimSpace(t) == "error" { | ||
| 65 | if m, ok := payload["message"].(string); ok && strings.TrimSpace(m) != "" { | ||
| 66 | message = strings.TrimSpace(m) | ||
| 67 | } | ||
| 68 | if v, ok := payload["code"]; ok && v != nil { | ||
| 69 | if c, ok := v.(string); ok && strings.TrimSpace(c) != "" { | ||
| 70 | code = strings.TrimSpace(c) | ||
| 71 | } else { | ||
| 72 | code = strings.TrimSpace(fmt.Sprint(v)) | ||
| 73 | } | ||
| 74 | } | ||
| 75 | if v, ok := payload["sequence_number"].(float64); ok && sequenceNumber == 0 { | ||
| 76 | sequenceNumber = int(v) | ||
| 77 | } | ||
| 78 | } | ||
| 79 | if e, ok := payload["error"].(map[string]any); ok { | ||
| 80 | if m, ok := e["message"].(string); ok && strings.TrimSpace(m) != "" { | ||
| 81 | message = strings.TrimSpace(m) | ||
| 82 | } | ||
| 83 | if v, ok := e["code"]; ok && v != nil { | ||
| 84 | if c, ok := v.(string); ok && strings.TrimSpace(c) != "" { | ||
| 85 | code = strings.TrimSpace(c) | ||
| 86 | } else { | ||
| 87 | code = strings.TrimSpace(fmt.Sprint(v)) | ||
| 88 | } | ||
| 89 | } | ||
| 90 | } | ||
| 91 | } | ||
| 92 | } | ||
| 93 | |||
| 94 | if strings.TrimSpace(code) == "" { | ||
| 95 | code = "unknown_error" | ||
| 96 | } | ||
| 97 | |||
| 98 | data, err := json.Marshal(openAIResponsesStreamErrorChunk{ | ||
| 99 | Type: "error", | ||
| 100 | Code: code, | ||
| 101 | Message: message, | ||
| 102 | SequenceNumber: sequenceNumber, | ||
| 103 | }) | ||
| 104 | if err == nil { | ||
| 105 | return data | ||
| 106 | } | ||
| 107 | |||
| 108 | // Extremely defensive fallback. | ||
| 109 | data, _ = json.Marshal(openAIResponsesStreamErrorChunk{ | ||
| 110 | Type: "error", | ||
| 111 | Code: "internal_server_error", | ||
| 112 | Message: message, | ||
| 113 | SequenceNumber: sequenceNumber, | ||
| 114 | }) | ||
| 115 | if len(data) > 0 { | ||
| 116 | return data | ||
| 117 | } | ||
| 118 | return []byte(`{"type":"error","code":"internal_server_error","message":"internal error","sequence_number":0}`) | ||
| 119 | } | ||
| @@ -0,0 +1,48 @@ | |||
| 1 | package handlers | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "net/http" | ||
| 6 | "testing" | ||
| 7 | ) | ||
| 8 | |||
| 9 | func TestBuildOpenAIResponsesStreamErrorChunk(t *testing.T) { | ||
| 10 | chunk := BuildOpenAIResponsesStreamErrorChunk(http.StatusInternalServerError, "unexpected EOF", 0) | ||
| 11 | var payload map[string]any | ||
| 12 | if err := json.Unmarshal(chunk, &payload); err != nil { | ||
| 13 | t.Fatalf("unmarshal: %v", err) | ||
| 14 | } | ||
| 15 | if payload["type"] != "error" { | ||
| 16 | t.Fatalf("type = %v, want %q", payload["type"], "error") | ||
| 17 | } | ||
| 18 | if payload["code"] != "internal_server_error" { | ||
| 19 | t.Fatalf("code = %v, want %q", payload["code"], "internal_server_error") | ||
| 20 | } | ||
| 21 | if payload["message"] != "unexpected EOF" { | ||
| 22 | t.Fatalf("message = %v, want %q", payload["message"], "unexpected EOF") | ||
| 23 | } | ||
| 24 | if payload["sequence_number"] != float64(0) { | ||
| 25 | t.Fatalf("sequence_number = %v, want %v", payload["sequence_number"], 0) | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 29 | func TestBuildOpenAIResponsesStreamErrorChunkExtractsHTTPErrorBody(t *testing.T) { | ||
| 30 | chunk := BuildOpenAIResponsesStreamErrorChunk( | ||
| 31 | http.StatusInternalServerError, | ||
| 32 | `{"error":{"message":"oops","type":"server_error","code":"internal_server_error"}}`, | ||
| 33 | 0, | ||
| 34 | ) | ||
| 35 | var payload map[string]any | ||
| 36 | if err := json.Unmarshal(chunk, &payload); err != nil { | ||
| 37 | t.Fatalf("unmarshal: %v", err) | ||
| 38 | } | ||
| 39 | if payload["type"] != "error" { | ||
| 40 | t.Fatalf("type = %v, want %q", payload["type"], "error") | ||
| 41 | } | ||
| 42 | if payload["code"] != "internal_server_error" { | ||
| 43 | t.Fatalf("code = %v, want %q", payload["code"], "internal_server_error") | ||
| 44 | } | ||
| 45 | if payload["message"] != "oops" { | ||
| 46 | t.Fatalf("message = %v, want %q", payload["message"], "oops") | ||
| 47 | } | ||
| 48 | } | ||
| @@ -2,8 +2,6 @@ package auth · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 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.CallbackPort | 53 | callbackPort := a.CallbackPort |
| 52 | if opts.CallbackPort > 0 { | 54 | if opts.CallbackPort > 0 { |
| 53 | callbackPort = opts.CallbackPort | 55 | 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 | } |
| @@ -0,0 +1,291 @@ | |||
| 1 | package auth | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "context" | ||
| 6 | "crypto/sha256" | ||
| 7 | "encoding/hex" | ||
| 8 | "encoding/json" | ||
| 9 | "fmt" | ||
| 10 | "io" | ||
| 11 | "net/http" | ||
| 12 | "strconv" | ||
| 13 | "strings" | ||
| 14 | "time" | ||
| 15 | |||
| 16 | "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex" | ||
| 17 | "github.com/router-for-me/CLIProxyAPI/v6/internal/browser" | ||
| 18 | "github.com/router-for-me/CLIProxyAPI/v6/internal/config" | ||
| 19 | "github.com/router-for-me/CLIProxyAPI/v6/internal/util" | ||
| 20 | coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" | ||
| 21 | log "github.com/sirupsen/logrus" | ||
| 22 | ) | ||
| 23 | |||
| 24 | const ( | ||
| 25 | codexLoginModeMetadataKey = "codex_login_mode" | ||
| 26 | codexLoginModeDevice = "device" | ||
| 27 | codexDeviceUserCodeURL = "https://auth.openai.com/api/accounts/deviceauth/usercode" | ||
| 28 | codexDeviceTokenURL = "https://auth.openai.com/api/accounts/deviceauth/token" | ||
| 29 | codexDeviceVerificationURL = "https://auth.openai.com/codex/device" | ||
| 30 | codexDeviceTokenExchangeRedirectURI = "https://auth.openai.com/deviceauth/callback" | ||
| 31 | codexDeviceTimeout = 15 * time.Minute | ||
| 32 | codexDeviceDefaultPollIntervalSeconds = 5 | ||
| 33 | ) | ||
| 34 | |||
| 35 | type codexDeviceUserCodeRequest struct { | ||
| 36 | ClientID string `json:"client_id"` | ||
| 37 | } | ||
| 38 | |||
| 39 | type codexDeviceUserCodeResponse struct { | ||
| 40 | DeviceAuthID string `json:"device_auth_id"` | ||
| 41 | UserCode string `json:"user_code"` | ||
| 42 | UserCodeAlt string `json:"usercode"` | ||
| 43 | Interval json.RawMessage `json:"interval"` | ||
| 44 | } | ||
| 45 | |||
| 46 | type codexDeviceTokenRequest struct { | ||
| 47 | DeviceAuthID string `json:"device_auth_id"` | ||
| 48 | UserCode string `json:"user_code"` | ||
| 49 | } | ||
| 50 | |||
| 51 | type codexDeviceTokenResponse struct { | ||
| 52 | AuthorizationCode string `json:"authorization_code"` | ||
| 53 | CodeVerifier string `json:"code_verifier"` | ||
| 54 | CodeChallenge string `json:"code_challenge"` | ||
| 55 | } | ||
| 56 | |||
| 57 | func shouldUseCodexDeviceFlow(opts *LoginOptions) bool { | ||
| 58 | if opts == nil || opts.Metadata == nil { | ||
| 59 | return false | ||
| 60 | } | ||
| 61 | return strings.EqualFold(strings.TrimSpace(opts.Metadata[codexLoginModeMetadataKey]), codexLoginModeDevice) | ||
| 62 | } | ||
| 63 | |||
| 64 | func (a *CodexAuthenticator) loginWithDeviceFlow(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) { | ||
| 65 | if ctx == nil { | ||
| 66 | ctx = context.Background() | ||
| 67 | } | ||
| 68 | |||
| 69 | httpClient := util.SetProxy(&cfg.SDKConfig, &http.Client{}) | ||
| 70 | |||
| 71 | userCodeResp, err := requestCodexDeviceUserCode(ctx, httpClient) | ||
| 72 | if err != nil { | ||
| 73 | return nil, err | ||
| 74 | } | ||
| 75 | |||
| 76 | deviceCode := strings.TrimSpace(userCodeResp.UserCode) | ||
| 77 | if deviceCode == "" { | ||
| 78 | deviceCode = strings.TrimSpace(userCodeResp.UserCodeAlt) | ||
| 79 | } | ||
| 80 | deviceAuthID := strings.TrimSpace(userCodeResp.DeviceAuthID) | ||
| 81 | if deviceCode == "" || deviceAuthID == "" { | ||
| 82 | return nil, fmt.Errorf("codex device flow did not return required fields") | ||
| 83 | } | ||
| 84 | |||
| 85 | pollInterval := parseCodexDevicePollInterval(userCodeResp.Interval) | ||
| 86 | |||
| 87 | fmt.Println("Starting Codex device authentication...") | ||
| 88 | fmt.Printf("Codex device URL: %s\n", codexDeviceVerificationURL) | ||
| 89 | fmt.Printf("Codex device code: %s\n", deviceCode) | ||
| 90 | |||
| 91 | if !opts.NoBrowser { | ||
| 92 | if !browser.IsAvailable() { | ||
| 93 | log.Warn("No browser available; please open the device URL manually") | ||
| 94 | } else if errOpen := browser.OpenURL(codexDeviceVerificationURL); errOpen != nil { | ||
| 95 | log.Warnf("Failed to open browser automatically: %v", errOpen) | ||
| 96 | } | ||
| 97 | } | ||
| 98 | |||
| 99 | tokenResp, err := pollCodexDeviceToken(ctx, httpClient, deviceAuthID, deviceCode, pollInterval) | ||
| 100 | if err != nil { | ||
| 101 | return nil, err | ||
| 102 | } | ||
| 103 | |||
| 104 | authCode := strings.TrimSpace(tokenResp.AuthorizationCode) | ||
| 105 | codeVerifier := strings.TrimSpace(tokenResp.CodeVerifier) | ||
| 106 | codeChallenge := strings.TrimSpace(tokenResp.CodeChallenge) | ||
| 107 | if authCode == "" || codeVerifier == "" || codeChallenge == "" { | ||
| 108 | return nil, fmt.Errorf("codex device flow token response missing required fields") | ||
| 109 | } | ||
| 110 | |||
| 111 | authSvc := codex.NewCodexAuth(cfg) | ||
| 112 | authBundle, err := authSvc.ExchangeCodeForTokensWithRedirect( | ||
| 113 | ctx, | ||
| 114 | authCode, | ||
| 115 | codexDeviceTokenExchangeRedirectURI, | ||
| 116 | &codex.PKCECodes{ | ||
| 117 | CodeVerifier: codeVerifier, | ||
| 118 | CodeChallenge: codeChallenge, | ||
| 119 | }, | ||
| 120 | ) | ||
| 121 | if err != nil { | ||
| 122 | return nil, codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, err) | ||
| 123 | } | ||
| 124 | |||
| 125 | return a.buildAuthRecord(authSvc, authBundle) | ||
| 126 | } | ||
| 127 | |||
| 128 | func requestCodexDeviceUserCode(ctx context.Context, client *http.Client) (*codexDeviceUserCodeResponse, error) { | ||
| 129 | body, err := json.Marshal(codexDeviceUserCodeRequest{ClientID: codex.ClientID}) | ||
| 130 | if err != nil { | ||
| 131 | return nil, fmt.Errorf("failed to encode codex device request: %w", err) | ||
| 132 | } | ||
| 133 | |||
| 134 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, codexDeviceUserCodeURL, bytes.NewReader(body)) | ||
| 135 | if err != nil { | ||
| 136 | return nil, fmt.Errorf("failed to create codex device request: %w", err) | ||
| 137 | } | ||
| 138 | req.Header.Set("Content-Type", "application/json") | ||
| 139 | req.Header.Set("Accept", "application/json") | ||
| 140 | |||
| 141 | resp, err := client.Do(req) | ||
| 142 | if err != nil { | ||
| 143 | return nil, fmt.Errorf("failed to request codex device code: %w", err) | ||
| 144 | } | ||
| 145 | defer func() { _ = resp.Body.Close() }() | ||
| 146 | |||
| 147 | respBody, err := io.ReadAll(resp.Body) | ||
| 148 | if err != nil { | ||
| 149 | return nil, fmt.Errorf("failed to read codex device code response: %w", err) | ||
| 150 | } | ||
| 151 | |||
| 152 | if !codexDeviceIsSuccessStatus(resp.StatusCode) { | ||
| 153 | trimmed := strings.TrimSpace(string(respBody)) | ||
| 154 | if resp.StatusCode == http.StatusNotFound { | ||
| 155 | return nil, fmt.Errorf("codex device endpoint is unavailable (status %d)", resp.StatusCode) | ||
| 156 | } | ||
| 157 | if trimmed == "" { | ||
| 158 | trimmed = "empty response body" | ||
| 159 | } | ||
| 160 | return nil, fmt.Errorf("codex device code request failed with status %d: %s", resp.StatusCode, trimmed) | ||
| 161 | } | ||
| 162 | |||
| 163 | var parsed codexDeviceUserCodeResponse | ||
| 164 | if err := json.Unmarshal(respBody, &parsed); err != nil { | ||
| 165 | return nil, fmt.Errorf("failed to decode codex device code response: %w", err) | ||
| 166 | } | ||
| 167 | |||
| 168 | return &parsed, nil | ||
| 169 | } | ||
| 170 | |||
| 171 | func pollCodexDeviceToken(ctx context.Context, client *http.Client, deviceAuthID, userCode string, interval time.Duration) (*codexDeviceTokenResponse, error) { | ||
| 172 | deadline := time.Now().Add(codexDeviceTimeout) | ||
| 173 | |||
| 174 | for { | ||
| 175 | if time.Now().After(deadline) { | ||
| 176 | return nil, fmt.Errorf("codex device authentication timed out after 15 minutes") | ||
| 177 | } | ||
| 178 | |||
| 179 | body, err := json.Marshal(codexDeviceTokenRequest{ | ||
| 180 | DeviceAuthID: deviceAuthID, | ||
| 181 | UserCode: userCode, | ||
| 182 | }) | ||
| 183 | if err != nil { | ||
| 184 | return nil, fmt.Errorf("failed to encode codex device poll request: %w", err) | ||
| 185 | } | ||
| 186 | |||
| 187 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, codexDeviceTokenURL, bytes.NewReader(body)) | ||
| 188 | if err != nil { | ||
| 189 | return nil, fmt.Errorf("failed to create codex device poll request: %w", err) | ||
| 190 | } | ||
| 191 | req.Header.Set("Content-Type", "application/json") | ||
| 192 | req.Header.Set("Accept", "application/json") | ||
| 193 | |||
| 194 | resp, err := client.Do(req) | ||
| 195 | if err != nil { | ||
| 196 | return nil, fmt.Errorf("failed to poll codex device token: %w", err) | ||
| 197 | } | ||
| 198 | |||
| 199 | respBody, readErr := io.ReadAll(resp.Body) | ||
| 200 | _ = resp.Body.Close() | ||
| 201 | if readErr != nil { | ||
| 202 | return nil, fmt.Errorf("failed to read codex device poll response: %w", readErr) | ||
| 203 | } | ||
| 204 | |||
| 205 | switch { | ||
| 206 | case codexDeviceIsSuccessStatus(resp.StatusCode): | ||
| 207 | var parsed codexDeviceTokenResponse | ||
| 208 | if err := json.Unmarshal(respBody, &parsed); err != nil { | ||
| 209 | return nil, fmt.Errorf("failed to decode codex device token response: %w", err) | ||
| 210 | } | ||
| 211 | return &parsed, nil | ||
| 212 | case resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound: | ||
| 213 | select { | ||
| 214 | case <-ctx.Done(): | ||
| 215 | return nil, ctx.Err() | ||
| 216 | case <-time.After(interval): | ||
| 217 | continue | ||
| 218 | } | ||
| 219 | default: | ||
| 220 | trimmed := strings.TrimSpace(string(respBody)) | ||
| 221 | if trimmed == "" { | ||
| 222 | trimmed = "empty response body" | ||
| 223 | } | ||
| 224 | return nil, fmt.Errorf("codex device token polling failed with status %d: %s", resp.StatusCode, trimmed) | ||
| 225 | } | ||
| 226 | } | ||
| 227 | } | ||
| 228 | |||
| 229 | func parseCodexDevicePollInterval(raw json.RawMessage) time.Duration { | ||
| 230 | defaultInterval := time.Duration(codexDeviceDefaultPollIntervalSeconds) * time.Second | ||
| 231 | if len(raw) == 0 { | ||
| 232 | return defaultInterval | ||
| 233 | } | ||
| 234 | |||
| 235 | var asString string | ||
| 236 | if err := json.Unmarshal(raw, &asString); err == nil { | ||
| 237 | if seconds, convErr := strconv.Atoi(strings.TrimSpace(asString)); convErr == nil && seconds > 0 { | ||
| 238 | return time.Duration(seconds) * time.Second | ||
| 239 | } | ||
| 240 | } | ||
| 241 | |||
| 242 | var asInt int | ||
| 243 | if err := json.Unmarshal(raw, &asInt); err == nil && asInt > 0 { | ||
| 244 | return time.Duration(asInt) * time.Second | ||
| 245 | } | ||
| 246 | |||
| 247 | return defaultInterval | ||
| 248 | } | ||
| 249 | |||
| 250 | func codexDeviceIsSuccessStatus(code int) bool { | ||
| 251 | return code >= 200 && code < 300 | ||
| 252 | } | ||
| 253 | |||
| 254 | func (a *CodexAuthenticator) buildAuthRecord(authSvc *codex.CodexAuth, authBundle *codex.CodexAuthBundle) (*coreauth.Auth, error) { | ||
| 255 | tokenStorage := authSvc.CreateTokenStorage(authBundle) | ||
| 256 | |||
| 257 | if tokenStorage == nil || tokenStorage.Email == "" { | ||
| 258 | return nil, fmt.Errorf("codex token storage missing account information") | ||
| 259 | } | ||
| 260 | |||
| 261 | planType := "" | ||
| 262 | hashAccountID := "" | ||
| 263 | if tokenStorage.IDToken != "" { | ||
| 264 | if claims, errParse := codex.ParseJWTToken(tokenStorage.IDToken); errParse == nil && claims != nil { | ||
| 265 | planType = strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType) | ||
| 266 | accountID := strings.TrimSpace(claims.CodexAuthInfo.ChatgptAccountID) | ||
| 267 | if accountID != "" { | ||
| 268 | digest := sha256.Sum256([]byte(accountID)) | ||
| 269 | hashAccountID = hex.EncodeToString(digest[:])[:8] | ||
| 270 | } | ||
| 271 | } | ||
| 272 | } | ||
| 273 | |||
| 274 | fileName := codex.CredentialFileName(tokenStorage.Email, planType, hashAccountID, true) | ||
| 275 | metadata := map[string]any{ | ||
| 276 | "email": tokenStorage.Email, | ||
| 277 | } | ||
| 278 | |||
| 279 | fmt.Println("Codex authentication successful") | ||
| 280 | if authBundle.APIKey != "" { | ||
| 281 | fmt.Println("Codex API key obtained and stored") | ||
| 282 | } | ||
| 283 | |||
| 284 | return &coreauth.Auth{ | ||
| 285 | ID: fileName, | ||
| 286 | Provider: a.Provider(), | ||
| 287 | FileName: fileName, | ||
| 288 | Storage: tokenStorage, | ||
| 289 | Metadata: metadata, | ||
| 290 | }, nil | ||
| 291 | } | ||
| @@ -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 "", err | 78 | return "", err |
| 71 | } | 79 | } |
| @@ -60,6 +60,7 @@ type RefreshEvaluator interface { · 59 unmodified lines | |||
| 60 | 60 | ||
| 61 | const ( | 61 | const ( |
| 62 | refreshCheckInterval = 5 * time.Second | 62 | refreshCheckInterval = 5 * time.Second |
| 63 | refreshMaxConcurrency = 16 | ||
| 63 | refreshPendingBackoff = time.Minute | 64 | refreshPendingBackoff = time.Minute |
| 64 | refreshFailureBackoff = 5 * time.Minute | 65 | refreshFailureBackoff = 5 * time.Minute |
| 65 | quotaBackoffBase = time.Second | 66 | quotaBackoffBase = time.Second |
| @@ -155,7 +156,8 @@ type Manager struct { · 89 unmodified lines | |||
| 155 | rtProvider RoundTripperProvider | 156 | rtProvider RoundTripperProvider |
| 156 | 157 | ||
| 157 | // Auto refresh state | 158 | // Auto refresh state |
| 158 | refreshCancel context.CancelFunc | 159 | 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. |
| 1830 | func (m *Manager) StartAutoRefresh(parent context.Context, interval time.Duration) { | 1833 | func (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 = refreshCheckInterval | 1835 | 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 | continue | 1882 | 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 | ||
| 1889 | func (m *Manager) refreshAuthWithLimit(ctx context.Context, id string) { | ||
| 1890 | if m.refreshSemaphore == nil { | ||
| 1891 | m.refreshAuth(ctx, id) | ||
| 1892 | return | ||
| 1893 | } | ||
| 1894 | select { | ||
| 1895 | case m.refreshSemaphore <- struct{}{}: | ||
| 1896 | defer func() { <-m.refreshSemaphore }() | ||
| 1897 | case <-ctx.Done(): | ||
| 1898 | return | ||
| 1899 | } | ||
| 1900 | m.refreshAuth(ctx, id) | ||
| 1901 | } | ||
| 1902 | |||
| 1888 | func (m *Manager) snapshotAuths() []*Auth { | 1903 | func (m *Manager) snapshotAuths() []*Auth { |
| 1889 | m.mu.RLock() | 1904 | m.mu.RLock() |
| 1890 | defer m.mu.RUnlock() | 1905 | defer m.mu.RUnlock() |
| @@ -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. | ||
| 251 | func (s *RoundRobinSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) { | 255 | func (s *RoundRobinSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) { |
| 252 | _ = opts | 256 | _ = 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 = 4096 | 270 | 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 = 0 | 309 | index = 0 |
| 275 | } | 310 | } |
| 276 | |||
| 277 | s.cursors[key] = index + 1 | 311 | 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)], nil | 313 | 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. | ||
| 318 | func (s *RoundRobinSelector) ensureCursorKey(key string, limit int) { | ||
| 319 | if _, ok := s.cursors[key]; !ok && len(s.cursors) >= limit { | ||
| 320 | s.cursors = make(map[string]int) | ||
| 321 | } | ||
| 322 | } | ||
| 323 | |||
| 324 | // groupByVirtualParent groups auths by their gemini_virtual_parent attribute. | ||
| 325 | // Returns a map of parentID -> auths and a sorted slice of parent IDs for stable iteration. | ||
| 326 | // Only auths with a non-empty gemini_virtual_parent are grouped; if any auth lacks | ||
| 327 | // this attribute, nil/nil is returned so the caller falls back to flat round-robin. | ||
| 328 | func groupByVirtualParent(auths []*Auth) (map[string][]*Auth, []string) { | ||
| 329 | if len(auths) == 0 { | ||
| 330 | return nil, nil | ||
| 331 | } | ||
| 332 | groups := make(map[string][]*Auth) | ||
| 333 | for _, a := range auths { | ||
| 334 | parent := "" | ||
| 335 | if a.Attributes != nil { | ||
| 336 | parent = strings.TrimSpace(a.Attributes["gemini_virtual_parent"]) | ||
| 337 | } | ||
| 338 | if parent == "" { | ||
| 339 | // Non-virtual auth present; fall back to flat round-robin. | ||
| 340 | return nil, nil | ||
| 341 | } | ||
| 342 | groups[parent] = append(groups[parent], a) | ||
| 343 | } | ||
| 344 | // Collect parent IDs in sorted order for stable cursor indexing. | ||
| 345 | parentOrder := make([]string, 0, len(groups)) | ||
| 346 | for p := range groups { | ||
| 347 | parentOrder = append(parentOrder, p) | ||
| 348 | } | ||
| 349 | sort.Strings(parentOrder) | ||
| 350 | return groups, parentOrder | ||
| 351 | } | ||
| 352 | |||
| 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. |
| 284 | func (s *FillFirstSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) { | 354 | func (s *FillFirstSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) { |
| 285 | _ = opts | 355 | _ = opts |
| @@ -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 | |||
| 406 | func TestRoundRobinSelectorPick_GeminiCLICredentialGrouping(t *testing.T) { | ||
| 407 | t.Parallel() | ||
| 408 | |||
| 409 | selector := &RoundRobinSelector{} | ||
| 410 | |||
| 411 | // Simulate two gemini-cli credentials, each with multiple projects: | ||
| 412 | // Credential A (parent = "cred-a.json") has 3 projects | ||
| 413 | // Credential B (parent = "cred-b.json") has 2 projects | ||
| 414 | auths := []*Auth{ | ||
| 415 | {ID: "cred-a.json::proj-a1", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}}, | ||
| 416 | {ID: "cred-a.json::proj-a2", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}}, | ||
| 417 | {ID: "cred-a.json::proj-a3", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}}, | ||
| 418 | {ID: "cred-b.json::proj-b1", Attributes: map[string]string{"gemini_virtual_parent": "cred-b.json"}}, | ||
| 419 | {ID: "cred-b.json::proj-b2", Attributes: map[string]string{"gemini_virtual_parent": "cred-b.json"}}, | ||
| 420 | } | ||
| 421 | |||
| 422 | // Two-level round-robin: consecutive picks must alternate between credentials. | ||
| 423 | // Credential group order is randomized, but within each call the group cursor | ||
| 424 | // advances by 1, so consecutive picks should cycle through different parents. | ||
| 425 | picks := make([]string, 6) | ||
| 426 | parents := make([]string, 6) | ||
| 427 | for i := 0; i < 6; i++ { | ||
| 428 | got, err := selector.Pick(context.Background(), "gemini-cli", "gemini-2.5-pro", cliproxyexecutor.Options{}, auths) | ||
| 429 | if err != nil { | ||
| 430 | t.Fatalf("Pick() #%d error = %v", i, err) | ||
| 431 | } | ||
| 432 | if got == nil { | ||
| 433 | t.Fatalf("Pick() #%d auth = nil", i) | ||
| 434 | } | ||
| 435 | picks[i] = got.ID | ||
| 436 | parents[i] = got.Attributes["gemini_virtual_parent"] | ||
| 437 | } | ||
| 438 | |||
| 439 | // Verify property: consecutive picks must alternate between credential groups. | ||
| 440 | for i := 1; i < len(parents); i++ { | ||
| 441 | if parents[i] == parents[i-1] { | ||
| 442 | t.Fatalf("Pick() #%d and #%d both from same parent %q (IDs: %q, %q); expected alternating credentials", | ||
| 443 | i-1, i, parents[i], picks[i-1], picks[i]) | ||
| 444 | } | ||
| 445 | } | ||
| 446 | |||
| 447 | // Verify property: each credential's projects are picked in sequence (round-robin within group). | ||
| 448 | credPicks := map[string][]string{} | ||
| 449 | for i, id := range picks { | ||
| 450 | credPicks[parents[i]] = append(credPicks[parents[i]], id) | ||
| 451 | } | ||
| 452 | for parent, ids := range credPicks { | ||
| 453 | for i := 1; i < len(ids); i++ { | ||
| 454 | if ids[i] == ids[i-1] { | ||
| 455 | t.Fatalf("Credential %q picked same project %q twice in a row", parent, ids[i]) | ||
| 456 | } | ||
| 457 | } | ||
| 458 | } | ||
| 459 | } | ||
| 460 | |||
| 461 | func TestRoundRobinSelectorPick_SingleParentFallsBackToFlat(t *testing.T) { | ||
| 462 | t.Parallel() | ||
| 463 | |||
| 464 | selector := &RoundRobinSelector{} | ||
| 465 | |||
| 466 | // All auths from the same parent - should fall back to flat round-robin | ||
| 467 | // because there's only one credential group (no benefit from two-level). | ||
| 468 | auths := []*Auth{ | ||
| 469 | {ID: "cred-a.json::proj-a1", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}}, | ||
| 470 | {ID: "cred-a.json::proj-a2", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}}, | ||
| 471 | {ID: "cred-a.json::proj-a3", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}}, | ||
| 472 | } | ||
| 473 | |||
| 474 | // With single parent group, parentOrder has length 1, so it uses flat round-robin. | ||
| 475 | // Sorted by ID: proj-a1, proj-a2, proj-a3 | ||
| 476 | want := []string{ | ||
| 477 | "cred-a.json::proj-a1", | ||
| 478 | "cred-a.json::proj-a2", | ||
| 479 | "cred-a.json::proj-a3", | ||
| 480 | "cred-a.json::proj-a1", | ||
| 481 | } | ||
| 482 | |||
| 483 | for i, expectedID := range want { | ||
| 484 | got, err := selector.Pick(context.Background(), "gemini-cli", "gemini-2.5-pro", cliproxyexecutor.Options{}, auths) | ||
| 485 | if err != nil { | ||
| 486 | t.Fatalf("Pick() #%d error = %v", i, err) | ||
| 487 | } | ||
| 488 | if got == nil { | ||
| 489 | t.Fatalf("Pick() #%d auth = nil", i) | ||
| 490 | } | ||
| 491 | if got.ID != expectedID { | ||
| 492 | t.Fatalf("Pick() #%d auth.ID = %q, want %q", i, got.ID, expectedID) | ||
| 493 | } | ||
| 494 | } | ||
| 495 | } | ||
| 496 | |||
| 497 | func TestRoundRobinSelectorPick_MixedVirtualAndNonVirtualFallsBackToFlat(t *testing.T) { | ||
| 498 | t.Parallel() | ||
| 499 | |||
| 500 | selector := &RoundRobinSelector{} | ||
| 501 | |||
| 502 | // Mix of virtual and non-virtual auths (e.g., a regular gemini-cli auth without projects | ||
| 503 | // alongside virtual ones). Should fall back to flat round-robin. | ||
| 504 | auths := []*Auth{ | ||
| 505 | {ID: "cred-a.json::proj-a1", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}}, | ||
| 506 | {ID: "cred-regular.json"}, // no gemini_virtual_parent | ||
| 507 | } | ||
| 508 | |||
| 509 | // groupByVirtualParent returns nil when any auth lacks the attribute, | ||
| 510 | // so flat round-robin is used. Sorted by ID: cred-a.json::proj-a1, cred-regular.json | ||
| 511 | want := []string{ | ||
| 512 | "cred-a.json::proj-a1", | ||
| 513 | "cred-regular.json", | ||
| 514 | "cred-a.json::proj-a1", | ||
| 515 | } | ||
| 516 | |||
| 517 | for i, expectedID := range want { | ||
| 518 | got, err := selector.Pick(context.Background(), "gemini-cli", "", cliproxyexecutor.Options{}, auths) | ||
| 519 | if err != nil { | ||
| 520 | t.Fatalf("Pick() #%d error = %v", i, err) | ||
| 521 | } | ||
| 522 | if got == nil { | ||
| 523 | t.Fatalf("Pick() #%d auth = nil", i) | ||
| 524 | } | ||
| 525 | if got.ID != expectedID { | ||
| 526 | t.Fatalf("Pick() #%d auth.ID = %q, want %q", i, got.ID, expectedID) | ||
| 527 | } | ||
| 528 | } | ||
| 529 | } | ||
| @@ -1,9 +1,12 @@ | |||
| 1 | package auth | 1 | package auth |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 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. | ||
| 21 | type PostAuthHook func(context.Context, *Auth) error | ||
| 22 | |||
| 23 | // RequestInfo holds information extracted from the HTTP request. | ||
| 24 | // It is injected into the context passed to PostAuthHook. | ||
| 25 | type RequestInfo struct { | ||
| 26 | Query url.Values | ||
| 27 | Headers http.Header | ||
| 28 | } | ||
| 29 | |||
| 30 | type requestInfoKey struct{} | ||
| 31 | |||
| 32 | // WithRequestInfo returns a new context with the given RequestInfo attached. | ||
| 33 | func WithRequestInfo(ctx context.Context, info *RequestInfo) context.Context { | ||
| 34 | return context.WithValue(ctx, requestInfoKey{}, info) | ||
| 35 | } | ||
| 36 | |||
| 37 | // GetRequestInfo retrieves the RequestInfo from the context, if present. | ||
| 38 | func GetRequestInfo(ctx context.Context) *RequestInfo { | ||
| 39 | if val, ok := ctx.Value(requestInfoKey{}).(*RequestInfo); ok { | ||
| 40 | return val | ||
| 41 | } | ||
| 42 | return nil | ||
| 43 | } | ||
| 44 | |||
| 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. |
| 16 | type Auth struct { | 46 | type Auth struct { |
| 17 | // ID uniquely identifies the auth record across restarts. | 47 | // ID uniquely identifies the auth record across restarts. |
| @@ -153,6 +153,16 @@ func (b *Builder) WithLocalManagementPassword(password string) *Builder { · 152 unmodified lines | |||
| 153 | return b | 153 | 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. | ||
| 158 | func (b *Builder) WithPostAuthHook(hook coreauth.PostAuthHook) *Builder { | ||
| 159 | if hook == nil { | ||
| 160 | return b | ||
| 161 | } | ||
| 162 | b.serverOptions = append(b.serverOptions, api.WithPostAuthHook(hook)) | ||
| 163 | return b | ||
| 164 | } | ||
| 165 | |||
| 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. |
| 157 | func (b *Builder) Build() (*Service, error) { | 167 | func (b *Builder) Build() (*Service, error) { |
| 158 | if b.cfg == nil { | 168 | if b.cfg == nil { |
| @@ -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 | return | 931 | 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 | ||
| 1075 | func (s *Service) backfillAntigravityModels(source *coreauth.Auth, primaryModels []*ModelInfo) { | ||
| 1076 | if s == nil || s.coreManager == nil || len(primaryModels) == 0 { | ||
| 1077 | return | ||
| 1078 | } | ||
| 1079 | |||
| 1080 | sourceID := "" | ||
| 1081 | if source != nil { | ||
| 1082 | sourceID = strings.TrimSpace(source.ID) | ||
| 1083 | } | ||
| 1084 | |||
| 1085 | reg := registry.GetGlobalRegistry() | ||
| 1086 | for _, candidate := range s.coreManager.List() { | ||
| 1087 | if candidate == nil || candidate.Disabled { | ||
| 1088 | continue | ||
| 1089 | } | ||
| 1090 | candidateID := strings.TrimSpace(candidate.ID) | ||
| 1091 | if candidateID == "" || candidateID == sourceID { | ||
| 1092 | continue | ||
| 1093 | } | ||
| 1094 | if !strings.EqualFold(strings.TrimSpace(candidate.Provider), "antigravity") { | ||
| 1095 | continue | ||
| 1096 | } | ||
| 1097 | if len(reg.GetModelsForClient(candidateID)) > 0 { | ||
| 1098 | continue | ||
| 1099 | } | ||
| 1100 | |||
| 1101 | authKind := strings.ToLower(strings.TrimSpace(candidate.Attributes["auth_kind"])) | ||
| 1102 | if authKind == "" { | ||
| 1103 | if kind, _ := candidate.AccountInfo(); strings.EqualFold(kind, "api_key") { | ||
| 1104 | authKind = "apikey" | ||
| 1105 | } | ||
| 1106 | } | ||
| 1107 | excluded := s.oauthExcludedModels("antigravity", authKind) | ||
| 1108 | if candidate.Attributes != nil { | ||
| 1109 | if val, ok := candidate.Attributes["excluded_models"]; ok && strings.TrimSpace(val) != "" { | ||
| 1110 | excluded = strings.Split(val, ",") | ||
| 1111 | } | ||
| 1112 | } | ||
| 1113 | |||
| 1114 | models := applyExcludedModels(primaryModels, excluded) | ||
| 1115 | models = applyOAuthModelAlias(s.cfg, "antigravity", authKind, models) | ||
| 1116 | if len(models) == 0 { | ||
| 1117 | continue | ||
| 1118 | } | ||
| 1119 | |||
| 1120 | reg.RegisterClient(candidateID, "antigravity", applyModelPrefixes(models, candidate.Prefix, s.cfg != nil && s.cfg.ForceModelPrefix)) | ||
| 1121 | log.Debugf("antigravity models backfilled for auth %s using primary model list", candidateID) | ||
| 1122 | } | ||
| 1123 | } | ||
| 1124 | |||
| 1072 | func applyExcludedModels(models []*ModelInfo, excluded []string) []*ModelInfo { | 1125 | func applyExcludedModels(models []*ModelInfo, excluded []string) []*ModelInfo { |
| 1073 | if len(models) == 0 || len(excluded) == 0 { | 1126 | if len(models) == 0 || len(excluded) == 0 { |
| 1074 | return models | 1127 | return models |
| @@ -0,0 +1,135 @@ | |||
| 1 | package cliproxy | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "strings" | ||
| 6 | "testing" | ||
| 7 | |||
| 8 | "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" | ||
| 9 | coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" | ||
| 10 | "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func TestBackfillAntigravityModels_RegistersMissingAuth(t *testing.T) { | ||
| 14 | source := &coreauth.Auth{ | ||
| 15 | ID: "ag-backfill-source", | ||
| 16 | Provider: "antigravity", | ||
| 17 | Status: coreauth.StatusActive, | ||
| 18 | Attributes: map[string]string{ | ||
| 19 | "auth_kind": "oauth", | ||
| 20 | }, | ||
| 21 | } | ||
| 22 | target := &coreauth.Auth{ | ||
| 23 | ID: "ag-backfill-target", | ||
| 24 | Provider: "antigravity", | ||
| 25 | Status: coreauth.StatusActive, | ||
| 26 | Attributes: map[string]string{ | ||
| 27 | "auth_kind": "oauth", | ||
| 28 | }, | ||
| 29 | } | ||
| 30 | |||
| 31 | manager := coreauth.NewManager(nil, nil, nil) | ||
| 32 | if _, err := manager.Register(context.Background(), source); err != nil { | ||
| 33 | t.Fatalf("register source auth: %v", err) | ||
| 34 | } | ||
| 35 | if _, err := manager.Register(context.Background(), target); err != nil { | ||
| 36 | t.Fatalf("register target auth: %v", err) | ||
| 37 | } | ||
| 38 | |||
| 39 | service := &Service{ | ||
| 40 | cfg: &config.Config{}, | ||
| 41 | coreManager: manager, | ||
| 42 | } | ||
| 43 | |||
| 44 | reg := registry.GetGlobalRegistry() | ||
| 45 | reg.UnregisterClient(source.ID) | ||
| 46 | reg.UnregisterClient(target.ID) | ||
| 47 | t.Cleanup(func() { | ||
| 48 | reg.UnregisterClient(source.ID) | ||
| 49 | reg.UnregisterClient(target.ID) | ||
| 50 | }) | ||
| 51 | |||
| 52 | primary := []*ModelInfo{ | ||
| 53 | {ID: "claude-sonnet-4-5"}, | ||
| 54 | {ID: "gemini-2.5-pro"}, | ||
| 55 | } | ||
| 56 | reg.RegisterClient(source.ID, "antigravity", primary) | ||
| 57 | |||
| 58 | service.backfillAntigravityModels(source, primary) | ||
| 59 | |||
| 60 | got := reg.GetModelsForClient(target.ID) | ||
| 61 | if len(got) != 2 { | ||
| 62 | t.Fatalf("expected target auth to be backfilled with 2 models, got %d", len(got)) | ||
| 63 | } | ||
| 64 | |||
| 65 | ids := make(map[string]struct{}, len(got)) | ||
| 66 | for _, model := range got { | ||
| 67 | if model == nil { | ||
| 68 | continue | ||
| 69 | } | ||
| 70 | ids[strings.ToLower(strings.TrimSpace(model.ID))] = struct{}{} | ||
| 71 | } | ||
| 72 | if _, ok := ids["claude-sonnet-4-5"]; !ok { | ||
| 73 | t.Fatal("expected backfilled model claude-sonnet-4-5") | ||
| 74 | } | ||
| 75 | if _, ok := ids["gemini-2.5-pro"]; !ok { | ||
| 76 | t.Fatal("expected backfilled model gemini-2.5-pro") | ||
| 77 | } | ||
| 78 | } | ||
| 79 | |||
| 80 | func TestBackfillAntigravityModels_RespectsExcludedModels(t *testing.T) { | ||
| 81 | source := &coreauth.Auth{ | ||
| 82 | ID: "ag-backfill-source-excluded", | ||
| 83 | Provider: "antigravity", | ||
| 84 | Status: coreauth.StatusActive, | ||
| 85 | Attributes: map[string]string{ | ||
| 86 | "auth_kind": "oauth", | ||
| 87 | }, | ||
| 88 | } | ||
| 89 | target := &coreauth.Auth{ | ||
| 90 | ID: "ag-backfill-target-excluded", | ||
| 91 | Provider: "antigravity", | ||
| 92 | Status: coreauth.StatusActive, | ||
| 93 | Attributes: map[string]string{ | ||
| 94 | "auth_kind": "oauth", | ||
| 95 | "excluded_models": "gemini-2.5-pro", | ||
| 96 | }, | ||
| 97 | } | ||
| 98 | |||
| 99 | manager := coreauth.NewManager(nil, nil, nil) | ||
| 100 | if _, err := manager.Register(context.Background(), source); err != nil { | ||
| 101 | t.Fatalf("register source auth: %v", err) | ||
| 102 | } | ||
| 103 | if _, err := manager.Register(context.Background(), target); err != nil { | ||
| 104 | t.Fatalf("register target auth: %v", err) | ||
| 105 | } | ||
| 106 | |||
| 107 | service := &Service{ | ||
| 108 | cfg: &config.Config{}, | ||
| 109 | coreManager: manager, | ||
| 110 | } | ||
| 111 | |||
| 112 | reg := registry.GetGlobalRegistry() | ||
| 113 | reg.UnregisterClient(source.ID) | ||
| 114 | reg.UnregisterClient(target.ID) | ||
| 115 | t.Cleanup(func() { | ||
| 116 | reg.UnregisterClient(source.ID) | ||
| 117 | reg.UnregisterClient(target.ID) | ||
| 118 | }) | ||
| 119 | |||
| 120 | primary := []*ModelInfo{ | ||
| 121 | {ID: "claude-sonnet-4-5"}, | ||
| 122 | {ID: "gemini-2.5-pro"}, | ||
| 123 | } | ||
| 124 | reg.RegisterClient(source.ID, "antigravity", primary) | ||
| 125 | |||
| 126 | service.backfillAntigravityModels(source, primary) | ||
| 127 | |||
| 128 | got := reg.GetModelsForClient(target.ID) | ||
| 129 | if len(got) != 1 { | ||
| 130 | t.Fatalf("expected 1 model after exclusion, got %d", len(got)) | ||
| 131 | } | ||
| 132 | if got[0] == nil || !strings.EqualFold(strings.TrimSpace(got[0].ID), "claude-sonnet-4-5") { | ||
| 133 | t.Fatalf("expected remaining model %q, got %+v", "claude-sonnet-4-5", got[0]) | ||
| 134 | } | ||
| 135 | } | ||