# Conflicts: # internal/runtime/executor/antigravity_executor.go
Unified · Split
| @@ -58,6 +58,7 @@ func main() { · 57 unmodified lines | ||
| 58 | 58 | // Command-line flags to control the application's behavior. |
| 59 | 59 | var login bool |
| 60 | 60 | var codexLogin bool |
| 61 | + var codexDeviceLogin bool | |
| 61 | 62 | var claudeLogin bool |
| 62 | 63 | var qwenLogin bool |
| 63 | 64 | var iflowLogin bool |
| @@ -76,6 +77,7 @@ func main() { · 12 unmodified lines | ||
| 76 | 77 | // Define command-line flags for different operation modes. |
| 77 | 78 | flag.BoolVar(&login, "login", false, "Login Google Account") |
| 78 | 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 | 81 | flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth") |
| 80 | 82 | flag.BoolVar(&qwenLogin, "qwen-login", false, "Login to Qwen using OAuth") |
| 81 | 83 | flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth") |
| @@ -467,6 +469,9 @@ func main() { · 385 unmodified lines | ||
| 467 | 469 | } else if codexLogin { |
| 468 | 470 | // Handle Codex login |
| 469 | 471 | cmd.DoCodexLogin(cfg, options) |
| 472 | + } else if codexDeviceLogin { | |
| 473 | + // Handle Codex device-code login | |
| 474 | + cmd.DoCodexDeviceLogin(cfg, options) | |
| 470 | 475 | } else if claudeLogin { |
| 471 | 476 | // Handle Claude login |
| 472 | 477 | cmd.DoClaudeLogin(cfg, options) |
| @@ -408,6 +408,9 @@ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H { · 407 unmodified lines | ||
| 408 | 408 | if !auth.LastRefreshedAt.IsZero() { |
| 409 | 409 | entry["last_refresh"] = auth.LastRefreshedAt |
| 410 | 410 | } |
| 411 | + if !auth.NextRetryAfter.IsZero() { | |
| 412 | + entry["next_retry_after"] = auth.NextRetryAfter | |
| 413 | + } | |
| 411 | 414 | if path != "" { |
| 412 | 415 | entry["path"] = path |
| 413 | 416 | entry["source"] = "file" |
| @@ -947,11 +950,17 @@ func (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (s · 533 unmodified lines | ||
| 947 | 950 | if store == nil { |
| 948 | 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 | 958 | return store.Save(ctx, record) |
| 951 | 959 | } |
| 952 | 960 | |
| 953 | 961 | func (h *Handler) RequestAnthropicToken(c *gin.Context) { |
| 954 | 962 | ctx := context.Background() |
| 963 | + ctx = PopulateAuthContext(ctx, c) | |
| 955 | 964 | |
| 956 | 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 | 1106 | func (h *Handler) RequestGeminiCLIToken(c *gin.Context) { |
| 1098 | 1107 | ctx := context.Background() |
| 1108 | + ctx = PopulateAuthContext(ctx, c) | |
| 1099 | 1109 | proxyHTTPClient := util.SetProxy(&h.cfg.SDKConfig, &http.Client{}) |
| 1100 | 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 | 1365 | func (h *Handler) RequestCodexToken(c *gin.Context) { |
| 1356 | 1366 | ctx := context.Background() |
| 1367 | + ctx = PopulateAuthContext(ctx, c) | |
| 1357 | 1368 | |
| 1358 | 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 | 1511 | func (h *Handler) RequestAntigravityToken(c *gin.Context) { |
| 1501 | 1512 | ctx := context.Background() |
| 1513 | + ctx = PopulateAuthContext(ctx, c) | |
| 1502 | 1514 | |
| 1503 | 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 | 1676 | func (h *Handler) RequestQwenToken(c *gin.Context) { |
| 1665 | 1677 | ctx := context.Background() |
| 1678 | + ctx = PopulateAuthContext(ctx, c) | |
| 1666 | 1679 | |
| 1667 | 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 | 1732 | func (h *Handler) RequestKimiToken(c *gin.Context) { |
| 1720 | 1733 | ctx := context.Background() |
| 1734 | + ctx = PopulateAuthContext(ctx, c) | |
| 1721 | 1735 | |
| 1722 | 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 | 1809 | func (h *Handler) RequestIFlowToken(c *gin.Context) { |
| 1796 | 1810 | ctx := context.Background() |
| 1811 | + ctx = PopulateAuthContext(ctx, c) | |
| 1797 | 1812 | |
| 1798 | 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 | 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 | 47 | allowRemoteOverride bool |
| 48 | 48 | envSecret string |
| 49 | 49 | logDir string |
| 50 | + postAuthHook coreauth.PostAuthHook | |
| 50 | 51 | } |
| 51 | 52 | |
| 52 | 53 | // NewHandler creates a new management handler instance. |
| @@ -128,6 +129,11 @@ func (h *Handler) SetLogDirectory(dir string) { · 75 unmodified lines | ||
| 128 | 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 | 137 | // Middleware enforces access control for management endpoints. |
| 132 | 138 | // All requests (local and remote) require a valid management key. |
| 133 | 139 | // Additionally, remote access requires allow-remote-management=true. |
| @@ -51,6 +51,7 @@ type serverOptionConfig struct { · 50 unmodified lines | ||
| 51 | 51 | keepAliveEnabled bool |
| 52 | 52 | keepAliveTimeout time.Duration |
| 53 | 53 | keepAliveOnTimeout func() |
| 54 | + postAuthHook auth.PostAuthHook | |
| 54 | 55 | } |
| 55 | 56 | |
| 56 | 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 | 122 | // Server represents the main API server. |
| 115 | 123 | // It encapsulates the Gin engine, HTTP server, handlers, and configuration. |
| 116 | 124 | type Server struct { |
| @@ -262,6 +270,9 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk · 145 unmodified lines | ||
| 262 | 270 | } |
| 263 | 271 | logDir := logging.ResolveLogDirectory(cfg) |
| 264 | 272 | s.mgmt.SetLogDirectory(logDir) |
| 273 | + if optionState.postAuthHook != nil { | |
| 274 | + s.mgmt.SetPostAuthHook(optionState.postAuthHook) | |
| 275 | + } | |
| 265 | 276 | s.localPassword = optionState.localPassword |
| 266 | 277 | |
| 267 | 278 | // Setup routes |
| @@ -36,11 +36,21 @@ type ClaudeTokenStorage struct { · 35 unmodified lines | ||
| 36 | 36 | |
| 37 | 37 | // Expire is the timestamp when the current access token expires. |
| 38 | 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 | 50 | // SaveTokenToFile serializes the Claude token storage to a JSON file. |
| 42 | 51 | // This method creates the necessary directory structure and writes the token |
| 43 | 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 | 55 | // Parameters: |
| 46 | 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 | 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 | 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 | 86 | return fmt.Errorf("failed to write token to file: %w", err) |
| 71 | 87 | } |
| 72 | 88 | return nil |
| @@ -71,16 +71,26 @@ func (o *CodexAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string, · 70 unmodified lines | ||
| 71 | 71 | // It performs an HTTP POST request to the OpenAI token endpoint with the provided |
| 72 | 72 | // authorization code and PKCE verifier. |
| 73 | 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 | 81 | if pkceCodes == nil { |
| 75 | 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 | 88 | // Prepare token exchange request |
| 79 | 89 | data := url.Values{ |
| 80 | 90 | "grant_type": {"authorization_code"}, |
| 81 | 91 | "client_id": {ClientID}, |
| 82 | 92 | "code": {code}, |
| 83 | − "redirect_uri": {RedirectURI}, | |
| 93 | + "redirect_uri": {strings.TrimSpace(redirectURI)}, | |
| 84 | 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 | 276 | if err == nil { |
| 267 | 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 | 284 | lastErr = err |
| 271 | 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 | 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 | 299 | // UpdateTokenStorage updates an existing CodexTokenStorage with new token data. |
| 278 | 300 | // This is typically called after a successful token refresh to persist the new credentials. |
| 279 | 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 | 32 | Type string `json:"type"` |
| 33 | 33 | // Expire is the timestamp when the current access token expires. |
| 34 | 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 | 46 | // SaveTokenToFile serializes the Codex token storage to a JSON file. |
| 38 | 47 | // This method creates the necessary directory structure and writes the token |
| 39 | 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 | 51 | // Parameters: |
| 42 | 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 | 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 | 78 | return fmt.Errorf("failed to write token to file: %w", err) |
| 63 | 79 | } |
| 64 | 80 | return nil |
| @@ -35,11 +35,21 @@ type GeminiTokenStorage struct { · 34 unmodified lines | ||
| 35 | 35 | |
| 36 | 36 | // Type indicates the authentication provider type, always "gemini" for this storage. |
| 37 | 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 | 49 | // SaveTokenToFile serializes the Gemini token storage to a JSON file. |
| 41 | 50 | // This method creates the necessary directory structure and writes the token |
| 42 | 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 | 54 | // Parameters: |
| 45 | 55 | // - authFilePath: The full path where the token file should be saved |
| @@ -49,6 +59,11 @@ type GeminiTokenStorage struct { · 3 unmodified lines | ||
| 49 | 59 | func (ts *GeminiTokenStorage) SaveTokenToFile(authFilePath string) error { |
| 50 | 60 | misc.LogSavingCredentials(authFilePath) |
| 51 | 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 | 67 | if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil { |
| 53 | 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 | 84 | return fmt.Errorf("failed to write token to file: %w", err) |
| 68 | 85 | } |
| 69 | 86 | return nil |
| @@ -21,6 +21,15 @@ type IFlowTokenStorage struct { · 20 unmodified lines | ||
| 21 | 21 | Scope string `json:"scope"` |
| 22 | 22 | Cookie string `json:"cookie"` |
| 23 | 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 | 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 | 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 | 56 | return fmt.Errorf("iflow token: encode token failed: %w", err) |
| 42 | 57 | } |
| 43 | 58 | return nil |
| @@ -29,6 +29,15 @@ type KimiTokenStorage struct { · 28 unmodified lines | ||
| 29 | 29 | Expired string `json:"expired,omitempty"` |
| 30 | 30 | // Type indicates the authentication provider type, always "kimi" for this storage. |
| 31 | 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 | 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 | 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 | 104 | encoder := json.NewEncoder(f) |
| 90 | 105 | encoder.SetIndent("", " ") |
| 91 | − if err = encoder.Encode(ts); err != nil { | |
| 106 | + if err = encoder.Encode(data); err != nil { | |
| 92 | 107 | return fmt.Errorf("failed to write token to file: %w", err) |
| 93 | 108 | } |
| 94 | 109 | return nil |
| @@ -30,11 +30,21 @@ type QwenTokenStorage struct { · 29 unmodified lines | ||
| 30 | 30 | Type string `json:"type"` |
| 31 | 31 | // Expire is the timestamp when the current access token expires. |
| 32 | 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 | 44 | // SaveTokenToFile serializes the Qwen token storage to a JSON file. |
| 36 | 45 | // This method creates the necessary directory structure and writes the token |
| 37 | 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 | 49 | // Parameters: |
| 40 | 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 | 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 | 76 | return fmt.Errorf("failed to write token to file: %w", err) |
| 61 | 77 | } |
| 62 | 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 | 1 | package misc |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "encoding/json" | |
| 4 | 5 | "fmt" |
| 5 | 6 | "path/filepath" |
| 6 | 7 | "strings" |
| @@ -24,3 +25,37 @@ func LogSavingCredentials(path string) { · 17 unmodified lines | ||
| 24 | 25 | func LogCredentialSeparator() { |
| 25 | 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 | 904 | Created int64 |
| 905 | 905 | Thinking *ThinkingSupport |
| 906 | 906 | }{ |
| 907 | − {ID: "tstars2.0", DisplayName: "TStars-2.0", Description: "iFlow TStars-2.0 multimodal assistant", Created: 1746489600}, | |
| 908 | 907 | {ID: "qwen3-coder-plus", DisplayName: "Qwen3-Coder-Plus", Description: "Qwen3 Coder Plus code generation", Created: 1753228800}, |
| 909 | 908 | {ID: "qwen3-max", DisplayName: "Qwen3-Max", Description: "Qwen3 flagship model", Created: 1758672000}, |
| 910 | 909 | {ID: "qwen3-vl-plus", DisplayName: "Qwen3-VL-Plus", Description: "Qwen3 multimodal vision-language", Created: 1758672000}, |
| 911 | 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 | 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 | 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 | 913 | {ID: "deepseek-v3.2", DisplayName: "DeepSeek-V3.2-Exp", Description: "DeepSeek V3.2 experimental", Created: 1759104000, Thinking: iFlowThinkingSupport}, |
| 921 | 914 | {ID: "deepseek-v3.1", DisplayName: "DeepSeek-V3.1-Terminus", Description: "DeepSeek V3.1 Terminus", Created: 1756339200, Thinking: iFlowThinkingSupport}, |
| 922 | 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 | 918 | {ID: "qwen3-235b-a22b-thinking-2507", DisplayName: "Qwen3-235B-A22B-Thinking", Description: "Qwen3 235B A22B Thinking (2507)", Created: 1753401600}, |
| 926 | 919 | {ID: "qwen3-235b-a22b-instruct", DisplayName: "Qwen3-235B-A22B-Instruct", Description: "Qwen3 235B A22B Instruct", Created: 1753401600}, |
| 927 | 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 | 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 | 923 | models := make([]*ModelInfo, 0, len(entries)) |
| 935 | 924 | for _, entry := range entries { |
| @@ -963,6 +952,7 @@ func GetAntigravityModelConfig() map[string]*AntigravityModelConfig { · 27 unmodified lines | ||
| 963 | 952 | "gemini-2.5-flash-lite": {Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}}, |
| 964 | 953 | "gemini-3-pro-high": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}}, |
| 965 | 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 | 956 | "gemini-3-flash": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}}}, |
| 967 | 957 | "claude-sonnet-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000}, |
| 968 | 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 | 55 | var ( |
| 56 | 56 | randSource = rand.New(rand.NewSource(time.Now().UnixNano())) |
| 57 | 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 | 130 | // AntigravityExecutor proxies requests to the antigravity upstream. |
| 61 | 131 | type AntigravityExecutor struct { |
| 62 | 132 | cfg *config.Config |
| @@ -1072,7 +1142,7 @@ func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *c · 1009 unmodified lines | ||
| 1072 | 1142 | exec := &AntigravityExecutor{cfg: cfg} |
| 1073 | 1143 | token, updatedAuth, errToken := exec.ensureAccessToken(ctx, auth) |
| 1074 | 1144 | if errToken != nil || token == "" { |
| 1075 | − return nil | |
| 1145 | + return fallbackAntigravityPrimaryModels() | |
| 1076 | 1146 | } |
| 1077 | 1147 | if updatedAuth != nil { |
| 1078 | 1148 | auth = updatedAuth |
| @@ -1096,7 +1166,7 @@ func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *c · 17 unmodified lines | ||
| 1096 | 1166 | |
| 1097 | 1167 | httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, modelsURL, bytes.NewReader(payload)) |
| 1098 | 1168 | if errReq != nil { |
| 1099 | − return nil | |
| 1169 | + return fallbackAntigravityPrimaryModels() | |
| 1100 | 1170 | } |
| 1101 | 1171 | httpReq.Close = true |
| 1102 | 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 | 1179 | httpResp, errDo := httpClient.Do(httpReq) |
| 1110 | 1180 | if errDo != nil { |
| 1111 | 1181 | if errors.Is(errDo, context.Canceled) || errors.Is(errDo, context.DeadlineExceeded) { |
| 1112 | − return nil | |
| 1182 | + return fallbackAntigravityPrimaryModels() | |
| 1113 | 1183 | } |
| 1114 | 1184 | if idx+1 < len(baseURLs) { |
| 1115 | 1185 | log.Debugf("antigravity executor: models request error on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1]) |
| 1116 | 1186 | continue |
| 1117 | 1187 | } |
| 1118 | − log.Errorf("antigravity executor: models request failed: %v", errDo) | |
| 1119 | − return nil | |
| 1188 | + return fallbackAntigravityPrimaryModels() | |
| 1120 | 1189 | } |
| 1121 | 1190 | |
| 1122 | 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 | 1197 | log.Debugf("antigravity executor: models read error on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1]) |
| 1129 | 1198 | continue |
| 1130 | 1199 | } |
| 1131 | − log.Errorf("antigravity executor: models read body failed: %v", errRead) | |
| 1132 | − return nil | |
| 1200 | + return fallbackAntigravityPrimaryModels() | |
| 1133 | 1201 | } |
| 1134 | 1202 | if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices { |
| 1135 | 1203 | if httpResp.StatusCode == http.StatusTooManyRequests && idx+1 < len(baseURLs) { |
| 1136 | 1204 | log.Debugf("antigravity executor: models request rate limited on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1]) |
| 1137 | 1205 | continue |
| 1138 | 1206 | } |
| 1139 | − log.Errorf("antigravity executor: models request error status %d: %s", httpResp.StatusCode, string(bodyBytes)) | |
| 1140 | − return nil | |
| 1207 | + if idx+1 < len(baseURLs) { | |
| 1208 | + log.Debugf("antigravity executor: models request failed with status %d on base url %s, retrying with fallback base url: %s", httpResp.StatusCode, baseURL, baseURLs[idx+1]) | |
| 1209 | + continue | |
| 1210 | + } | |
| 1211 | + return fallbackAntigravityPrimaryModels() | |
| 1141 | 1212 | } |
| 1142 | 1213 | |
| 1143 | 1214 | result := gjson.GetBytes(bodyBytes, "models") |
| 1144 | 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 | 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 | 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 | 1297 | return models |
| 1214 | 1298 | } |
| 1215 | − return nil | |
| 1299 | + return fallbackAntigravityPrimaryModels() | |
| 1216 | 1300 | } |
| 1217 | 1301 | |
| 1218 | 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 | 156 | b, _ := io.ReadAll(httpResp.Body) |
| 157 | 157 | appendAPIResponseChunk(ctx, e.cfg, b) |
| 158 | 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 | 160 | return resp, err |
| 161 | 161 | } |
| 162 | 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 | 260 | b, _ := io.ReadAll(httpResp.Body) |
| 261 | 261 | appendAPIResponseChunk(ctx, e.cfg, b) |
| 262 | 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 | 264 | return resp, err |
| 265 | 265 | } |
| 266 | 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 | 359 | appendAPIResponseChunk(ctx, e.cfg, data) |
| 360 | 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 | 362 | return nil, err |
| 363 | 363 | } |
| 364 | 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 | 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 | 705 | func codexCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { |
| 677 | 706 | if a == nil { |
| 678 | 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 | 8 | "io" |
| 9 | 9 | "net/http" |
| 10 | 10 | "strings" |
| 11 | + "sync" | |
| 11 | 12 | "time" |
| 12 | 13 | |
| 13 | 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 | 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 | 171 | // QwenExecutor is a stateless executor for Qwen Code using OpenAI-compatible chat completions. |
| 29 | 172 | // If access token is unavailable, it falls back to legacy via ClientAdapter. |
| 30 | 173 | type QwenExecutor struct { |
| @@ -67,6 +210,17 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req · 36 unmodified lines | ||
| 67 | 210 | if opts.Alt == "responses/compact" { |
| 68 | 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 | 224 | baseModel := thinking.ParseSuffix(req.Model).ModelName |
| 71 | 225 | |
| 72 | 226 | token, baseURL := qwenCreds(auth) |
| @@ -102,9 +256,8 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req · 29 unmodified lines | ||
| 102 | 256 | return resp, err |
| 103 | 257 | } |
| 104 | 258 | applyQwenHeaders(httpReq, token, false) |
| 105 | − var authID, authLabel, authType, authValue string | |
| 259 | + var authLabel, authType, authValue string | |
| 106 | 260 | if auth != nil { |
| 107 | − authID = auth.ID | |
| 108 | 261 | authLabel = auth.Label |
| 109 | 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 | 288 | if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { |
| 136 | 289 | b, _ := io.ReadAll(httpResp.Body) |
| 137 | 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)) | |
| 139 | − err = statusErr{code: httpResp.StatusCode, msg: string(b)} | |
| 291 | + | |
| 292 | + errCode, retryAfter := wrapQwenError(ctx, httpResp.StatusCode, b) | |
| 293 | + logWithRequestID(ctx).Debugf("request error, error status: %d (mapped: %d), error message: %s", httpResp.StatusCode, errCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b)) | |
| 294 | + err = statusErr{code: errCode, msg: string(b), retryAfter: retryAfter} | |
| 140 | 295 | return resp, err |
| 141 | 296 | } |
| 142 | 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 | 313 | if opts.Alt == "responses/compact" { |
| 159 | 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 | 327 | baseModel := thinking.ParseSuffix(req.Model).ModelName |
| 162 | 328 | |
| 163 | 329 | token, baseURL := qwenCreds(auth) |
| @@ -200,9 +366,8 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut · 36 unmodified lines | ||
| 200 | 366 | return nil, err |
| 201 | 367 | } |
| 202 | 368 | applyQwenHeaders(httpReq, token, true) |
| 203 | − var authID, authLabel, authType, authValue string | |
| 369 | + var authLabel, authType, authValue string | |
| 204 | 370 | if auth != nil { |
| 205 | − authID = auth.ID | |
| 206 | 371 | authLabel = auth.Label |
| 207 | 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 | 393 | if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { |
| 229 | 394 | b, _ := io.ReadAll(httpResp.Body) |
| 230 | 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 | 399 | if errClose := httpResp.Body.Close(); errClose != nil { |
| 233 | 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 | 403 | return nil, err |
| 237 | 404 | } |
| 238 | 405 | out := make(chan cliproxyexecutor.StreamChunk) |
| @@ -10,53 +10,10 @@ import ( · 9 unmodified lines | ||
| 10 | 10 | |
| 11 | 11 | "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" |
| 12 | 12 | "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" |
| 13 | − log "github.com/sirupsen/logrus" | |
| 14 | 13 | "github.com/tidwall/gjson" |
| 15 | 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 | 17 | // Applier implements thinking.ProviderApplier for OpenAI models. |
| 61 | 18 | // |
| 62 | 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 | 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 | 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 | 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 | 83 | return result, nil |
| 127 | 84 | } |
| 128 | 85 | |
| @@ -157,7 +114,7 @@ func applyCompatibleOpenAI(body []byte, config thinking.ThinkingConfig) ([]byte, · 28 unmodified lines | ||
| 157 | 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 | 118 | return result, nil |
| 162 | 119 | } |
| 163 | 120 | |
| @@ -223,14 +223,65 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ · 222 unmodified lines | ||
| 223 | 223 | functionResponseJSON, _ = sjson.Set(functionResponseJSON, "response.result", responseData) |
| 224 | 224 | } else if functionResponseResult.IsArray() { |
| 225 | 225 | frResults := functionResponseResult.Array() |
| 226 | − if len(frResults) == 1 { | |
| 227 | − functionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, "response.result", frResults[0].Raw) | |
| 226 | + nonImageCount := 0 | |
| 227 | + lastNonImageRaw := "" | |
| 228 | + filteredJSON := "[]" | |
| 229 | + imagePartsJSON := "[]" | |
| 230 | + for _, fr := range frResults { | |
| 231 | + if fr.Get("type").String() == "image" && fr.Get("source.type").String() == "base64" { | |
| 232 | + inlineDataJSON := `{}` | |
| 233 | + if mimeType := fr.Get("source.media_type").String(); mimeType != "" { | |
| 234 | + inlineDataJSON, _ = sjson.Set(inlineDataJSON, "mimeType", mimeType) | |
| 235 | + } | |
| 236 | + if data := fr.Get("source.data").String(); data != "" { | |
| 237 | + inlineDataJSON, _ = sjson.Set(inlineDataJSON, "data", data) | |
| 238 | + } | |
| 239 | + | |
| 240 | + imagePartJSON := `{}` | |
| 241 | + imagePartJSON, _ = sjson.SetRaw(imagePartJSON, "inlineData", inlineDataJSON) | |
| 242 | + imagePartsJSON, _ = sjson.SetRaw(imagePartsJSON, "-1", imagePartJSON) | |
| 243 | + continue | |
| 244 | + } | |
| 245 | + | |
| 246 | + nonImageCount++ | |
| 247 | + lastNonImageRaw = fr.Raw | |
| 248 | + filteredJSON, _ = sjson.SetRaw(filteredJSON, "-1", fr.Raw) | |
| 249 | + } | |
| 250 | + | |
| 251 | + if nonImageCount == 1 { | |
| 252 | + functionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, "response.result", lastNonImageRaw) | |
| 253 | + } else if nonImageCount > 1 { | |
| 254 | + functionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, "response.result", filteredJSON) | |
| 228 | 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 | 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 | 285 | } else if functionResponseResult.Raw != "" { |
| 235 | 286 | functionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, "response.result", functionResponseResult.Raw) |
| 236 | 287 | } else { |
| @@ -248,7 +299,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ · 11 unmodified lines | ||
| 248 | 299 | if sourceResult.Get("type").String() == "base64" { |
| 249 | 300 | inlineDataJSON := `{}` |
| 250 | 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 | 304 | if data := sourceResult.Get("data").String(); data != "" { |
| 254 | 305 | inlineDataJSON, _ = sjson.Set(inlineDataJSON, "data", data) |
| @@ -413,8 +413,8 @@ func TestConvertClaudeRequestToAntigravity_ImageContent(t *testing.T) { · 412 unmodified lines | ||
| 413 | 413 | if !inlineData.Exists() { |
| 414 | 414 | t.Error("inlineData should exist") |
| 415 | 415 | } |
| 416 | − if inlineData.Get("mime_type").String() != "image/png" { | |
| 417 | − t.Error("mime_type mismatch") | |
| 416 | + if inlineData.Get("mimeType").String() != "image/png" { | |
| 417 | + t.Error("mimeType mismatch") | |
| 418 | 418 | } |
| 419 | 419 | if !strings.Contains(inlineData.Get("data").String(), "iVBORw0KGgo") { |
| 420 | 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 | 1166 | func TestConvertClaudeRequestToAntigravity_ToolAndThinking_NoExistingSystem(t *testing.T) { |
| 744 | 1167 | // When tools + thinking but no system instruction, should create one with hint |
| 745 | 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 | 187 | if len(pieces) == 2 && len(pieces[1]) > 7 { |
| 188 | 188 | mime := pieces[0] |
| 189 | 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 | 191 | node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.data", data) |
| 192 | 192 | node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature) |
| 193 | 193 | p++ |
| @@ -201,7 +201,7 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ · 7 unmodified lines | ||
| 201 | 201 | ext = sp[len(sp)-1] |
| 202 | 202 | } |
| 203 | 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 | 205 | node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.data", fileData) |
| 206 | 206 | p++ |
| 207 | 207 | } else { |
| @@ -235,7 +235,7 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ · 27 unmodified lines | ||
| 235 | 235 | if len(pieces) == 2 && len(pieces[1]) > 7 { |
| 236 | 236 | mime := pieces[0] |
| 237 | 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 | 239 | node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.data", data) |
| 240 | 240 | node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature) |
| 241 | 241 | p++ |
| @@ -95,9 +95,9 @@ func ConvertAntigravityResponseToOpenAI(_ context.Context, _ string, originalReq · 94 unmodified lines | ||
| 95 | 95 | if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() { |
| 96 | 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 | 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 | 101 | if thoughtsTokenCount > 0 { |
| 102 | 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 | 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 | 218 | return true |
| 204 | 219 | }) |
| @@ -155,6 +155,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte · 154 unmodified lines | ||
| 155 | 155 | var textAggregate strings.Builder |
| 156 | 156 | var partsJSON []string |
| 157 | 157 | hasImage := false |
| 158 | + hasFile := false | |
| 158 | 159 | if parts := item.Get("content"); parts.Exists() && parts.IsArray() { |
| 159 | 160 | parts.ForEach(func(_, part gjson.Result) bool { |
| 160 | 161 | ptype := part.Get("type").String() |
| @@ -207,6 +208,30 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte · 46 unmodified lines | ||
| 207 | 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 | 236 | return true |
| 212 | 237 | }) |
| @@ -228,7 +253,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte · 15 unmodified lines | ||
| 228 | 253 | if len(partsJSON) > 0 { |
| 229 | 254 | msg := `{"role":"","content":[]}` |
| 230 | 255 | msg, _ = sjson.Set(msg, "role", role) |
| 231 | − if len(partsJSON) == 1 && !hasImage { | |
| 256 | + if len(partsJSON) == 1 && !hasImage && !hasFile { | |
| 232 | 257 | // Preserve legacy behavior for single text content |
| 233 | 258 | msg, _ = sjson.Delete(msg, "content") |
| 234 | 259 | textPart := gjson.Parse(partsJSON[0]) |
| @@ -180,7 +180,19 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b · 179 unmodified lines | ||
| 180 | 180 | msg, _ = sjson.SetRaw(msg, "content.-1", part) |
| 181 | 181 | } |
| 182 | 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 | 26 | rawJSON, _ = sjson.DeleteBytes(rawJSON, "temperature") |
| 27 | 27 | rawJSON, _ = sjson.DeleteBytes(rawJSON, "top_p") |
| 28 | 28 | rawJSON, _ = sjson.DeleteBytes(rawJSON, "service_tier") |
| 29 | + rawJSON, _ = sjson.DeleteBytes(rawJSON, "truncation") | |
| 30 | + rawJSON = applyResponsesCompactionCompatibility(rawJSON) | |
| 29 | 31 | |
| 30 | 32 | // Delete the user field as it is not supported by the Codex upstream. |
| 31 | 33 | rawJSON, _ = sjson.DeleteBytes(rawJSON, "user") |
| @@ -36,6 +38,23 @@ func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, · 4 unmodified lines | ||
| 36 | 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 | 58 | // convertSystemRoleToDeveloper traverses the input array and converts any message items |
| 40 | 59 | // with role "system" to role "developer". This is necessary because Codex API does not |
| 41 | 60 | // accept "system" role in the input array. |
| @@ -280,3 +280,41 @@ func TestUserFieldDeletion(t *testing.T) { · 279 unmodified lines | ||
| 280 | 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 | 101 | promptTokenCount := usageResult.Get("promptTokenCount").Int() |
| 102 | 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 | 104 | if thoughtsTokenCount > 0 { |
| 105 | 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 | 100 | if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() { |
| 101 | 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 | 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 | 106 | if thoughtsTokenCount > 0 { |
| 107 | 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 | 297 | promptTokenCount := usageResult.Get("promptTokenCount").Int() |
| 298 | 298 | thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int() |
| 299 | 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 | 301 | if thoughtsTokenCount > 0 { |
| 302 | 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 | 532 | // usage mapping |
| 533 | 533 | if um := root.Get("usageMetadata"); um.Exists() { |
| 534 | − // input tokens = prompt + thoughts | |
| 535 | − input := um.Get("promptTokenCount").Int() + um.Get("thoughtsTokenCount").Int() | |
| 534 | + // input tokens = prompt only (thoughts go to output) | |
| 535 | + input := um.Get("promptTokenCount").Int() | |
| 536 | 536 | completed, _ = sjson.Set(completed, "response.usage.input_tokens", input) |
| 537 | 537 | // cached token details: align with OpenAI "cached_tokens" semantics. |
| 538 | 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 | 738 | // usage mapping |
| 739 | 739 | if um := root.Get("usageMetadata"); um.Exists() { |
| 740 | − // input tokens = prompt + thoughts | |
| 741 | − input := um.Get("promptTokenCount").Int() + um.Get("thoughtsTokenCount").Int() | |
| 740 | + // input tokens = prompt only (thoughts go to output) | |
| 741 | + input := um.Get("promptTokenCount").Int() | |
| 742 | 742 | resp, _ = sjson.Set(resp, "usage.input_tokens", input) |
| 743 | 743 | // cached token details: align with OpenAI "cached_tokens" semantics. |
| 744 | 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 | 716 | return |
| 717 | 717 | } |
| 718 | 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 | 725 | sentPayload = true |
| 720 | 726 | if okSendData := sendData(cloneBytes(chunk.Payload)); !okSendData { |
| 721 | 727 | return |
| @@ -727,6 +733,35 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl · 5 unmodified lines | ||
| 727 | 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 | 765 | func statusFromError(err error) int { |
| 731 | 766 | if err == nil { |
| 732 | 767 | return 0 |
| @@ -134,6 +134,37 @@ type authAwareStreamExecutor struct { · 133 unmodified lines | ||
| 134 | 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 | 168 | func (e *authAwareStreamExecutor) Identifier() string { return "codex" } |
| 138 | 169 | |
| 139 | 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 | 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 | 265 | if errMsg.Error != nil && errMsg.Error.Error() != "" { |
| 266 | 266 | errText = errMsg.Error.Error() |
| 267 | 267 | } |
| 268 | − body := handlers.BuildErrorResponseBody(status, errText) | |
| 269 | − _, _ = fmt.Fprintf(c.Writer, "\nevent: error\ndata: %s\n\n", string(body)) | |
| 268 | + chunk := handlers.BuildOpenAIResponsesStreamErrorChunk(status, errText, 0) | |
| 269 | + _, _ = fmt.Fprintf(c.Writer, "\nevent: error\ndata: %s\n\n", string(chunk)) | |
| 270 | 270 | }, |
| 271 | 271 | WriteDone: func() { |
| 272 | 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 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | − "crypto/sha256" | |
| 6 | − "encoding/hex" | |
| 7 | 5 | "fmt" |
| 8 | 6 | "net/http" |
| 9 | 7 | "strings" |
| @@ -48,6 +46,10 @@ func (a *CodexAuthenticator) Login(ctx context.Context, cfg *config.Config, opts · 38 unmodified lines | ||
| 48 | 46 | opts = &LoginOptions{} |
| 49 | 47 | } |
| 50 | 48 | |
| 49 | + if shouldUseCodexDeviceFlow(opts) { | |
| 50 | + return a.loginWithDeviceFlow(ctx, cfg, opts) | |
| 51 | + } | |
| 52 | + | |
| 51 | 53 | callbackPort := a.CallbackPort |
| 52 | 54 | if opts.CallbackPort > 0 { |
| 53 | 55 | callbackPort = opts.CallbackPort |
| @@ -186,39 +188,5 @@ waitForCallback: · 132 unmodified lines | ||
| 186 | 188 | return nil, codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, err) |
| 187 | 189 | } |
| 188 | 190 | |
| 189 | − tokenStorage := authSvc.CreateTokenStorage(authBundle) | |
| 190 | − | |
| 191 | − if tokenStorage == nil || tokenStorage.Email == "" { | |
| 192 | − return nil, fmt.Errorf("codex token storage missing account information") | |
| 193 | − } | |
| 194 | − | |
| 195 | − planType := "" | |
| 196 | − hashAccountID := "" | |
| 197 | − if tokenStorage.IDToken != "" { | |
| 198 | − if claims, errParse := codex.ParseJWTToken(tokenStorage.IDToken); errParse == nil && claims != nil { | |
| 199 | − planType = strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType) | |
| 200 | − accountID := strings.TrimSpace(claims.CodexAuthInfo.ChatgptAccountID) | |
| 201 | − if accountID != "" { | |
| 202 | − digest := sha256.Sum256([]byte(accountID)) | |
| 203 | − hashAccountID = hex.EncodeToString(digest[:])[:8] | |
| 204 | − } | |
| 205 | − } | |
| 206 | − } | |
| 207 | − fileName := codex.CredentialFileName(tokenStorage.Email, planType, hashAccountID, true) | |
| 208 | − metadata := map[string]any{ | |
| 209 | − "email": tokenStorage.Email, | |
| 210 | − } | |
| 211 | − | |
| 212 | − fmt.Println("Codex authentication successful") | |
| 213 | − if authBundle.APIKey != "" { | |
| 214 | − fmt.Println("Codex API key obtained and stored") | |
| 215 | − } | |
| 216 | − | |
| 217 | − return &coreauth.Auth{ | |
| 218 | − ID: fileName, | |
| 219 | − Provider: a.Provider(), | |
| 220 | − FileName: fileName, | |
| 221 | − Storage: tokenStorage, | |
| 222 | − Metadata: metadata, | |
| 223 | − }, nil | |
| 191 | + return a.buildAuthRecord(authSvc, authBundle) | |
| 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 | 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 | 72 | switch { |
| 68 | 73 | case auth.Storage != nil: |
| 74 | + if setter, ok := auth.Storage.(metadataSetter); ok { | |
| 75 | + setter.SetMetadata(auth.Metadata) | |
| 76 | + } | |
| 69 | 77 | if err = auth.Storage.SaveTokenToFile(path); err != nil { |
| 70 | 78 | return "", err |
| 71 | 79 | } |
| @@ -60,6 +60,7 @@ type RefreshEvaluator interface { · 59 unmodified lines | ||
| 60 | 60 | |
| 61 | 61 | const ( |
| 62 | 62 | refreshCheckInterval = 5 * time.Second |
| 63 | + refreshMaxConcurrency = 16 | |
| 63 | 64 | refreshPendingBackoff = time.Minute |
| 64 | 65 | refreshFailureBackoff = 5 * time.Minute |
| 65 | 66 | quotaBackoffBase = time.Second |
| @@ -155,7 +156,8 @@ type Manager struct { · 89 unmodified lines | ||
| 155 | 156 | rtProvider RoundTripperProvider |
| 156 | 157 | |
| 157 | 158 | // Auto refresh state |
| 158 | − refreshCancel context.CancelFunc | |
| 159 | + refreshCancel context.CancelFunc | |
| 160 | + refreshSemaphore chan struct{} | |
| 159 | 161 | } |
| 160 | 162 | |
| 161 | 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 | 175 | hook: hook, |
| 174 | 176 | auths: make(map[string]*Auth), |
| 175 | 177 | providerOffsets: make(map[string]int), |
| 178 | + refreshSemaphore: make(chan struct{}, refreshMaxConcurrency), | |
| 176 | 179 | } |
| 177 | 180 | // atomic.Value requires non-nil initial value. |
| 178 | 181 | manager.runtimeConfig.Store(&internalconfig.Config{}) |
| @@ -1828,9 +1831,7 @@ func (m *Manager) persist(ctx context.Context, auth *Auth) error { · 1649 unmodified lines | ||
| 1828 | 1831 | // every few seconds and triggers refresh operations when required. |
| 1829 | 1832 | // Only one loop is kept alive; starting a new one cancels the previous run. |
| 1830 | 1833 | func (m *Manager) StartAutoRefresh(parent context.Context, interval time.Duration) { |
| 1831 | − if interval <= 0 || interval > refreshCheckInterval { | |
| 1832 | − interval = refreshCheckInterval | |
| 1833 | − } else { | |
| 1834 | + if interval <= 0 { | |
| 1834 | 1835 | interval = refreshCheckInterval |
| 1835 | 1836 | } |
| 1836 | 1837 | if m.refreshCancel != nil { |
| @@ -1880,11 +1881,25 @@ func (m *Manager) checkRefreshes(ctx context.Context) { · 43 unmodified lines | ||
| 1880 | 1881 | if !m.markRefreshPending(a.ID, now) { |
| 1881 | 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 | 1903 | func (m *Manager) snapshotAuths() []*Auth { |
| 1889 | 1904 | m.mu.RLock() |
| 1890 | 1905 | defer m.mu.RUnlock() |
| @@ -5,6 +5,7 @@ import ( · 4 unmodified lines | ||
| 5 | 5 | "encoding/json" |
| 6 | 6 | "fmt" |
| 7 | 7 | "math" |
| 8 | + "math/rand/v2" | |
| 8 | 9 | "net/http" |
| 9 | 10 | "sort" |
| 10 | 11 | "strconv" |
| @@ -248,6 +249,9 @@ func getAvailableAuths(auths []*Auth, provider, model string, now time.Time) ([] · 237 unmodified lines | ||
| 248 | 249 | } |
| 249 | 250 | |
| 250 | 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 | 255 | func (s *RoundRobinSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) { |
| 252 | 256 | _ = opts |
| 253 | 257 | now := time.Now() |
| @@ -265,21 +269,87 @@ func (s *RoundRobinSelector) Pick(ctx context.Context, provider, model string, o · 11 unmodified lines | ||
| 265 | 269 | if limit <= 0 { |
| 266 | 270 | limit = 4096 |
| 267 | 271 | } |
| 268 | − if _, ok := s.cursors[key]; !ok && len(s.cursors) >= limit { | |
| 269 | − s.cursors = make(map[string]int) | |
| 272 | + | |
| 273 | + // Check if any available auth has gemini_virtual_parent attribute, | |
| 274 | + // indicating gemini-cli virtual auths that should use credential-level polling. | |
| 275 | + groups, parentOrder := groupByVirtualParent(available) | |
| 276 | + if len(parentOrder) > 1 { | |
| 277 | + // Two-level round-robin: first select a credential group, then pick within it. | |
| 278 | + groupKey := key + "::group" | |
| 279 | + s.ensureCursorKey(groupKey, limit) | |
| 280 | + if _, exists := s.cursors[groupKey]; !exists { | |
| 281 | + // Seed with a random initial offset so the starting credential is randomized. | |
| 282 | + s.cursors[groupKey] = rand.IntN(len(parentOrder)) | |
| 283 | + } | |
| 284 | + groupIndex := s.cursors[groupKey] | |
| 285 | + if groupIndex >= 2_147_483_640 { | |
| 286 | + groupIndex = 0 | |
| 287 | + } | |
| 288 | + s.cursors[groupKey] = groupIndex + 1 | |
| 289 | + | |
| 290 | + selectedParent := parentOrder[groupIndex%len(parentOrder)] | |
| 291 | + group := groups[selectedParent] | |
| 292 | + | |
| 293 | + // Second level: round-robin within the selected credential group. | |
| 294 | + innerKey := key + "::cred:" + selectedParent | |
| 295 | + s.ensureCursorKey(innerKey, limit) | |
| 296 | + innerIndex := s.cursors[innerKey] | |
| 297 | + if innerIndex >= 2_147_483_640 { | |
| 298 | + innerIndex = 0 | |
| 299 | + } | |
| 300 | + s.cursors[innerKey] = innerIndex + 1 | |
| 301 | + s.mu.Unlock() | |
| 302 | + return group[innerIndex%len(group)], nil | |
| 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 | 308 | if index >= 2_147_483_640 { |
| 274 | 309 | index = 0 |
| 275 | 310 | } |
| 276 | − | |
| 277 | 311 | s.cursors[key] = index + 1 |
| 278 | 312 | s.mu.Unlock() |
| 279 | − // log.Debugf("available: %d, index: %d, key: %d", len(available), index, index%len(available)) | |
| 280 | 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 | 353 | // Pick selects the first available auth for the provider in a deterministic manner. |
| 284 | 354 | func (s *FillFirstSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) { |
| 285 | 355 | _ = opts |
| @@ -402,3 +402,128 @@ func TestRoundRobinSelectorPick_CursorKeyCap(t *testing.T) { · 401 unmodified lines | ||
| 402 | 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 | 1 | package auth |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "context" | |
| 4 | 5 | "crypto/sha256" |
| 5 | 6 | "encoding/hex" |
| 6 | 7 | "encoding/json" |
| 8 | + "net/http" | |
| 9 | + "net/url" | |
| 7 | 10 | "strconv" |
| 8 | 11 | "strings" |
| 9 | 12 | "sync" |
| @@ -12,6 +15,33 @@ import ( · 2 unmodified lines | ||
| 12 | 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 | 45 | // Auth encapsulates the runtime state and metadata associated with a single credential. |
| 16 | 46 | type Auth struct { |
| 17 | 47 | // ID uniquely identifies the auth record across restarts. |
| @@ -153,6 +153,16 @@ func (b *Builder) WithLocalManagementPassword(password string) *Builder { · 152 unmodified lines | ||
| 153 | 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 | 166 | // Build validates inputs, applies defaults, and returns a ready-to-run service. |
| 157 | 167 | func (b *Builder) Build() (*Service, error) { |
| 158 | 168 | if b.cfg == nil { |
| @@ -925,6 +925,9 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { · 924 unmodified lines | ||
| 925 | 925 | key = strings.ToLower(strings.TrimSpace(a.Provider)) |
| 926 | 926 | } |
| 927 | 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 | 931 | return |
| 929 | 932 | } |
| 930 | 933 | |
| @@ -1069,6 +1072,56 @@ func (s *Service) oauthExcludedModels(provider, authKind string) []string { · 138 unmodified lines | ||
| 1069 | 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 | 1125 | func applyExcludedModels(models []*ModelInfo, excluded []string) []*ModelInfo { |
| 1073 | 1126 | if len(models) == 0 || len(excluded) == 0 { |
| 1074 | 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 | +} | |