From fccfb162b402aa22638f339f68b4f08527040d30 Mon Sep 17 00:00:00 2001 From: daniel <15257433+kslamph@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:23:09 +0800 Subject: [PATCH 001/139] fix(gemini-cli): use backend project ID from onboarding response - Simplify project ID selection to always use the backend project ID returned by Gemini onboarding - Update Gemini CLI version from 0.31.0 to 0.34.0 - Add 'terminal' to User-Agent string for better client identification Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + .../api/handlers/management/auth_files.go | 17 ++------- internal/cmd/login.go | 36 ++----------------- internal/misc/header_utils.go | 4 +-- 4 files changed, 9 insertions(+), 49 deletions(-) diff --git a/.gitignore b/.gitignore index 90ff3a941d..80f4b2eb62 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ _bmad-output/* # macOS .DS_Store ._* +.gocache/ diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 2e1f02bff7..a7916e79a5 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -2566,20 +2566,9 @@ func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage finalProjectID := projectID if responseProjectID != "" { if explicitProject && !strings.EqualFold(responseProjectID, projectID) { - // Check if this is a free user (gen-lang-client projects or free/legacy tier) - isFreeUser := strings.HasPrefix(projectID, "gen-lang-client-") || - strings.EqualFold(tierID, "FREE") || - strings.EqualFold(tierID, "LEGACY") - - if isFreeUser { - // For free users, use backend project ID for preview model access - log.Infof("Gemini onboarding: frontend project %s maps to backend project %s", projectID, responseProjectID) - log.Infof("Using backend project ID: %s (recommended for preview model access)", responseProjectID) - finalProjectID = responseProjectID - } else { - // Pro users: keep requested project ID (original behavior) - log.Warnf("Gemini onboarding returned project %s instead of requested %s; keeping requested project ID.", responseProjectID, projectID) - } + log.Infof("Gemini onboarding: requested project %s maps to backend project %s", projectID, responseProjectID) + log.Infof("Using backend project ID: %s", responseProjectID) + finalProjectID = responseProjectID } else { finalProjectID = responseProjectID } diff --git a/internal/cmd/login.go b/internal/cmd/login.go index 16af718ebb..298e9546b4 100644 --- a/internal/cmd/login.go +++ b/internal/cmd/login.go @@ -333,39 +333,9 @@ func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage finalProjectID := projectID if responseProjectID != "" { if explicitProject && !strings.EqualFold(responseProjectID, projectID) { - // Check if this is a free user (gen-lang-client projects or free/legacy tier) - isFreeUser := strings.HasPrefix(projectID, "gen-lang-client-") || - strings.EqualFold(tierID, "FREE") || - strings.EqualFold(tierID, "LEGACY") - - if isFreeUser { - // Interactive prompt for free users - fmt.Printf("\nGoogle returned a different project ID:\n") - fmt.Printf(" Requested (frontend): %s\n", projectID) - fmt.Printf(" Returned (backend): %s\n\n", responseProjectID) - fmt.Printf(" Backend project IDs have access to preview models (gemini-3-*).\n") - fmt.Printf(" This is normal for free tier users.\n\n") - fmt.Printf("Which project ID would you like to use?\n") - fmt.Printf(" [1] Backend (recommended): %s\n", responseProjectID) - fmt.Printf(" [2] Frontend: %s\n\n", projectID) - fmt.Printf("Enter choice [1]: ") - - reader := bufio.NewReader(os.Stdin) - choice, _ := reader.ReadString('\n') - choice = strings.TrimSpace(choice) - - if choice == "2" { - log.Infof("Using frontend project ID: %s", projectID) - fmt.Println(". Warning: Frontend project IDs may not have access to preview models.") - finalProjectID = projectID - } else { - log.Infof("Using backend project ID: %s (recommended)", responseProjectID) - finalProjectID = responseProjectID - } - } else { - // Pro users: keep requested project ID (original behavior) - log.Warnf("Gemini onboarding returned project %s instead of requested %s; keeping requested project ID.", responseProjectID, projectID) - } + log.Infof("Gemini onboarding: requested project %s maps to backend project %s", projectID, responseProjectID) + log.Infof("Using backend project ID: %s", responseProjectID) + finalProjectID = responseProjectID } else { finalProjectID = responseProjectID } diff --git a/internal/misc/header_utils.go b/internal/misc/header_utils.go index 5752a26956..ac022a9627 100644 --- a/internal/misc/header_utils.go +++ b/internal/misc/header_utils.go @@ -12,7 +12,7 @@ import ( const ( // GeminiCLIVersion is the version string reported in the User-Agent for upstream requests. - GeminiCLIVersion = "0.31.0" + GeminiCLIVersion = "0.34.0" // GeminiCLIApiClientHeader is the value for the X-Goog-Api-Client header sent to the Gemini CLI upstream. GeminiCLIApiClientHeader = "google-genai-sdk/1.41.0 gl-node/v22.19.0" @@ -46,7 +46,7 @@ func GeminiCLIUserAgent(model string) string { if model == "" { model = "unknown" } - return fmt.Sprintf("GeminiCLI/%s/%s (%s; %s)", GeminiCLIVersion, model, geminiCLIOS(), geminiCLIArch()) + return fmt.Sprintf("GeminiCLI/%s/%s (%s; %s; terminal)", GeminiCLIVersion, model, geminiCLIOS(), geminiCLIArch()) } // ScrubProxyAndFingerprintHeaders removes all headers that could reveal From 91387ca2472aac07440b542b80fb1f070f63a624 Mon Sep 17 00:00:00 2001 From: daniel <15257433+kslamph@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:07:02 +0800 Subject: [PATCH 002/139] refactor(gemini-cli): simplify redundant if/else in project ID assignment Both branches assign finalProjectID = responseProjectID, so move the assignment outside the conditional and keep only the logging inside. --- internal/api/handlers/management/auth_files.go | 4 +--- internal/cmd/login.go | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index a7916e79a5..63b1d62de9 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -2568,10 +2568,8 @@ func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage if explicitProject && !strings.EqualFold(responseProjectID, projectID) { log.Infof("Gemini onboarding: requested project %s maps to backend project %s", projectID, responseProjectID) log.Infof("Using backend project ID: %s", responseProjectID) - finalProjectID = responseProjectID - } else { - finalProjectID = responseProjectID } + finalProjectID = responseProjectID } storage.ProjectID = strings.TrimSpace(finalProjectID) diff --git a/internal/cmd/login.go b/internal/cmd/login.go index 298e9546b4..22404dac9c 100644 --- a/internal/cmd/login.go +++ b/internal/cmd/login.go @@ -335,10 +335,8 @@ func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage if explicitProject && !strings.EqualFold(responseProjectID, projectID) { log.Infof("Gemini onboarding: requested project %s maps to backend project %s", projectID, responseProjectID) log.Infof("Using backend project ID: %s", responseProjectID) - finalProjectID = responseProjectID - } else { - finalProjectID = responseProjectID } + finalProjectID = responseProjectID } storage.ProjectID = strings.TrimSpace(finalProjectID) From 6431cec7d3c12d14a5eac272a8555d82753b4dad Mon Sep 17 00:00:00 2001 From: Code_G <12405078+codeg-dev@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:16:15 +0900 Subject: [PATCH 003/139] fix(claude-auth): dedupe OAuth refresh and honor 429 backoff Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- internal/auth/claude/anthropic_auth.go | 135 +++++++++++++++++++- internal/auth/claude/anthropic_auth_test.go | 123 ++++++++++++++++++ 2 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 internal/auth/claude/anthropic_auth_test.go diff --git a/internal/auth/claude/anthropic_auth.go b/internal/auth/claude/anthropic_auth.go index 12bb53ac37..b7f997efed 100644 --- a/internal/auth/claude/anthropic_auth.go +++ b/internal/auth/claude/anthropic_auth.go @@ -6,15 +6,18 @@ package claude import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" "net/url" "strings" + "sync" "time" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" log "github.com/sirupsen/logrus" + "golang.org/x/sync/singleflight" ) // OAuth configuration constants for Claude/Anthropic @@ -23,8 +26,94 @@ const ( TokenURL = "https://api.anthropic.com/v1/oauth/token" ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" RedirectURI = "http://localhost:54545/callback" + + claudeRefreshMinBackoff = 5 * time.Second + claudeRefreshMaxBackoff = 5 * time.Minute +) + +var ( + claudeRefreshGroup singleflight.Group + claudeRefreshMu sync.Mutex + claudeRefreshBlock = make(map[string]time.Time) ) +type refreshHTTPError struct { + status int + message string + retryable bool +} + +func (e *refreshHTTPError) Error() string { + return fmt.Sprintf("token refresh failed with status %d: %s", e.status, e.message) +} + +func (e *refreshHTTPError) Retryable() bool { + return e != nil && e.retryable +} + +func resetClaudeRefreshState() { + claudeRefreshMu.Lock() + defer claudeRefreshMu.Unlock() + claudeRefreshBlock = make(map[string]time.Time) + claudeRefreshGroup = singleflight.Group{} +} + +func claudeRefreshBlockedUntil(refreshToken string) time.Time { + claudeRefreshMu.Lock() + defer claudeRefreshMu.Unlock() + return claudeRefreshBlock[refreshToken] +} + +func setClaudeRefreshBlockedUntil(refreshToken string, until time.Time) { + claudeRefreshMu.Lock() + defer claudeRefreshMu.Unlock() + claudeRefreshBlock[refreshToken] = until +} + +func clearClaudeRefreshBlockedUntil(refreshToken string) { + claudeRefreshMu.Lock() + defer claudeRefreshMu.Unlock() + delete(claudeRefreshBlock, refreshToken) +} + +func clampClaudeRefreshBackoff(d time.Duration) time.Duration { + if d < claudeRefreshMinBackoff { + return claudeRefreshMinBackoff + } + if d > claudeRefreshMaxBackoff { + return claudeRefreshMaxBackoff + } + return d +} + +func parseClaudeRetryAfter(resp *http.Response) time.Duration { + if resp == nil { + return claudeRefreshMinBackoff + } + if raw := strings.TrimSpace(resp.Header.Get("Retry-After")); raw != "" { + if seconds, err := time.ParseDuration(raw + "s"); err == nil { + return clampClaudeRefreshBackoff(seconds) + } + if when, err := http.ParseTime(raw); err == nil { + return clampClaudeRefreshBackoff(time.Until(when)) + } + } + if raw := strings.TrimSpace(resp.Header.Get("Retry-After-Ms")); raw != "" { + if ms, err := time.ParseDuration(raw + "ms"); err == nil { + return clampClaudeRefreshBackoff(ms) + } + } + return claudeRefreshMinBackoff +} + +func isClaudeRefreshRetryable(err error) bool { + var httpErr *refreshHTTPError + if errors.As(err, &httpErr) { + return httpErr.Retryable() + } + return true +} + // tokenResponse represents the response structure from Anthropic's OAuth token endpoint. // It contains access token, refresh token, and associated user/organization information. type tokenResponse struct { @@ -222,6 +311,35 @@ func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*C if refreshToken == "" { return nil, fmt.Errorf("refresh token is required") } + if blockedUntil := claudeRefreshBlockedUntil(refreshToken); blockedUntil.After(time.Now()) { + return nil, &refreshHTTPError{ + status: http.StatusTooManyRequests, + message: fmt.Sprintf("refresh temporarily blocked until %s", blockedUntil.Format(time.RFC3339)), + retryable: false, + } + } + + result, err, _ := claudeRefreshGroup.Do(refreshToken, func() (interface{}, error) { + return o.refreshTokensSingleFlight(context.WithoutCancel(ctx), refreshToken) + }) + if err != nil { + return nil, err + } + tokenData, ok := result.(*ClaudeTokenData) + if !ok || tokenData == nil { + return nil, fmt.Errorf("token refresh failed: invalid single-flight result") + } + return tokenData, nil +} + +func (o *ClaudeAuth) refreshTokensSingleFlight(ctx context.Context, refreshToken string) (*ClaudeTokenData, error) { + if blockedUntil := claudeRefreshBlockedUntil(refreshToken); blockedUntil.After(time.Now()) { + return nil, &refreshHTTPError{ + status: http.StatusTooManyRequests, + message: fmt.Sprintf("refresh temporarily blocked until %s", blockedUntil.Format(time.RFC3339)), + retryable: false, + } + } reqBody := map[string]interface{}{ "client_id": ClientID, @@ -256,7 +374,17 @@ func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*C } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("token refresh failed with status %d: %s", resp.StatusCode, string(body)) + message := string(body) + if resp.StatusCode == http.StatusTooManyRequests { + retryAfter := parseClaudeRetryAfter(resp) + setClaudeRefreshBlockedUntil(refreshToken, time.Now().Add(retryAfter)) + return nil, &refreshHTTPError{status: resp.StatusCode, message: message, retryable: false} + } + return nil, &refreshHTTPError{ + status: resp.StatusCode, + message: message, + retryable: resp.StatusCode >= http.StatusInternalServerError, + } } // log.Debugf("Token response: %s", string(body)) @@ -267,6 +395,8 @@ func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*C } // Create token data + clearClaudeRefreshBlockedUntil(refreshToken) + return &ClaudeTokenData{ AccessToken: tokenResp.AccessToken, RefreshToken: tokenResp.RefreshToken, @@ -328,6 +458,9 @@ func (o *ClaudeAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken st lastErr = err log.Warnf("Token refresh attempt %d failed: %v", attempt+1, err) + if !isClaudeRefreshRetryable(err) { + break + } } return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr) diff --git a/internal/auth/claude/anthropic_auth_test.go b/internal/auth/claude/anthropic_auth_test.go new file mode 100644 index 0000000000..0b14d0834c --- /dev/null +++ b/internal/auth/claude/anthropic_auth_test.go @@ -0,0 +1,123 @@ +package claude + +import ( + "context" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestRefreshTokensWithRetry_429BlocksImmediateReplay(t *testing.T) { + resetClaudeRefreshState() + defer resetClaudeRefreshState() + + var calls int32 + auth := &ClaudeAuth{ + httpClient: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&calls, 1) + return &http.Response{ + StatusCode: http.StatusTooManyRequests, + Body: io.NopCloser(strings.NewReader(`{"error":"rate_limited"}`)), + Header: http.Header{"Retry-After": []string{"60"}}, + Request: req, + }, nil + }), + }, + } + + _, err := auth.RefreshTokensWithRetry(context.Background(), "dummy_refresh_token", 3) + if err == nil { + t.Fatalf("expected 429 refresh error") + } + if !strings.Contains(err.Error(), "status 429") { + t.Fatalf("expected status 429 in error, got %v", err) + } + if got := atomic.LoadInt32(&calls); got != 1 { + t.Fatalf("expected 1 refresh attempt after 429, got %d", got) + } + + _, err = auth.RefreshTokensWithRetry(context.Background(), "dummy_refresh_token", 3) + if err == nil { + t.Fatalf("expected immediate blocked refresh error") + } + if got := atomic.LoadInt32(&calls); got != 1 { + t.Fatalf("expected blocked retry to avoid a second refresh call, got %d attempts", got) + } + if blockedUntil := claudeRefreshBlockedUntil("dummy_refresh_token"); !blockedUntil.After(time.Now()) { + t.Fatalf("expected blocked-until timestamp to be set, got %v", blockedUntil) + } +} + +func TestRefreshTokens_DeduplicatesConcurrentRefresh(t *testing.T) { + resetClaudeRefreshState() + defer resetClaudeRefreshState() + + var calls int32 + started := make(chan struct{}) + release := make(chan struct{}) + var once sync.Once + + auth := &ClaudeAuth{ + httpClient: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&calls, 1) + once.Do(func() { close(started) }) + <-release + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "access_token":"new-access", + "refresh_token":"new-refresh", + "token_type":"Bearer", + "expires_in":3600, + "account":{"email_address":"shared@example.com"} + }`)), + Header: make(http.Header), + Request: req, + }, nil + }), + }, + } + + results := make(chan *ClaudeTokenData, 2) + errs := make(chan error, 2) + runRefresh := func() { + td, err := auth.RefreshTokens(context.Background(), "shared-refresh-token") + results <- td + errs <- err + } + + go runRefresh() + go runRefresh() + + <-started + time.Sleep(20 * time.Millisecond) + if got := atomic.LoadInt32(&calls); got != 1 { + t.Fatalf("expected concurrent refresh to share a single upstream call, got %d", got) + } + close(release) + + for i := 0; i < 2; i++ { + if err := <-errs; err != nil { + t.Fatalf("expected refresh to succeed, got %v", err) + } + td := <-results + if td == nil || td.AccessToken != "new-access" { + t.Fatalf("expected refreshed access token, got %#v", td) + } + } + if got := atomic.LoadInt32(&calls); got != 1 { + t.Fatalf("expected exactly 1 upstream refresh call, got %d", got) + } +} From 29e32aaab940f0681b9f9a9b2da94b81d2e5d098 Mon Sep 17 00:00:00 2001 From: Code_G <12405078+codeg-dev@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:16:42 +0900 Subject: [PATCH 004/139] fix(executor): route Claude refresh through retry-aware auth Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- internal/runtime/executor/claude_executor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 7b2e5d8d5b..93487311fd 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -594,7 +594,7 @@ func (e *ClaudeExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) ( return auth, nil } svc := claudeauth.NewClaudeAuth(e.cfg) - td, err := svc.RefreshTokens(ctx, refreshToken) + td, err := svc.RefreshTokensWithRetry(ctx, refreshToken, 3) if err != nil { return nil, err } From a0fe273081eaa3a4b89ec21fd718a4585b84fda2 Mon Sep 17 00:00:00 2001 From: DragonFSKY Date: Sun, 22 Mar 2026 09:49:34 +0800 Subject: [PATCH 005/139] fix(websocket): skip stale state merge after client-side compact After a Codex CLI compact, the client sends a full conversation transcript (with compaction items or assistant messages) as input. Previously, normalizeResponseSubsequentRequest() unconditionally merged this with stale lastRequest/lastResponseOutput, breaking function_call/function_call_output pairings and causing 400 errors ("No tool output found for function call"). Add inputContainsFullTranscript() heuristic that detects compaction items (type=compaction/compaction_summary) or assistant messages in the input array, and bypasses the merge when a full transcript is present. Fixes #2207 --- .../openai/openai_responses_websocket.go | 66 +++++++++--- .../openai/openai_responses_websocket_test.go | 101 ++++++++++++++++++ 2 files changed, 155 insertions(+), 12 deletions(-) diff --git a/sdk/api/handlers/openai/openai_responses_websocket.go b/sdk/api/handlers/openai/openai_responses_websocket.go index 2f6b14a779..6457cd3ee9 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket.go +++ b/sdk/api/handlers/openai/openai_responses_websocket.go @@ -315,20 +315,32 @@ func normalizeResponseSubsequentRequest(rawJSON []byte, lastRequest []byte, last } } - existingInput := gjson.GetBytes(lastRequest, "input") - mergedInput, errMerge := mergeJSONArrayRaw(existingInput.Raw, normalizeJSONArrayRaw(lastResponseOutput)) - if errMerge != nil { - return nil, lastRequest, &interfaces.ErrorMessage{ - StatusCode: http.StatusBadRequest, - Error: fmt.Errorf("invalid previous response output: %w", errMerge), + // When the client sends a full conversation transcript (e.g. after compact), + // the input already contains the complete history including assistant messages. + // In that case, skip merging with stale lastRequest/lastResponseOutput to avoid + // breaking function_call / function_call_output pairings. + // See: https://github.com/router-for-me/CLIProxyAPI/issues/2207 + var mergedInput string + if inputContainsFullTranscript(nextInput) { + log.Infof("responses websocket: full transcript detected, skipping stale merge (input items=%d)", len(nextInput.Array())) + mergedInput = nextInput.Raw + } else { + existingInput := gjson.GetBytes(lastRequest, "input") + var errMerge error + mergedInput, errMerge = mergeJSONArrayRaw(existingInput.Raw, normalizeJSONArrayRaw(lastResponseOutput)) + if errMerge != nil { + return nil, lastRequest, &interfaces.ErrorMessage{ + StatusCode: http.StatusBadRequest, + Error: fmt.Errorf("invalid previous response output: %w", errMerge), + } } - } - mergedInput, errMerge = mergeJSONArrayRaw(mergedInput, nextInput.Raw) - if errMerge != nil { - return nil, lastRequest, &interfaces.ErrorMessage{ - StatusCode: http.StatusBadRequest, - Error: fmt.Errorf("invalid request input: %w", errMerge), + mergedInput, errMerge = mergeJSONArrayRaw(mergedInput, nextInput.Raw) + if errMerge != nil { + return nil, lastRequest, &interfaces.ErrorMessage{ + StatusCode: http.StatusBadRequest, + Error: fmt.Errorf("invalid request input: %w", errMerge), + } } } dedupedInput, errDedupeFunctionCalls := dedupeFunctionCallsByCallID(mergedInput) @@ -691,6 +703,36 @@ func mergeJSONArrayRaw(existingRaw, appendRaw string) (string, error) { return string(out), nil } +// inputContainsFullTranscript returns true when the input array looks like a +// complete conversation history rather than an incremental append. After a +// client-side compact the input already carries the full (compacted) transcript +// which may include assistant messages or compaction items. Merging that with +// the stale lastRequest / lastResponseOutput would duplicate or break +// function_call / function_call_output pairings, so the caller should use the +// input as-is. +// +// Heuristic: the array is a full transcript when it contains either +// - a message with role="assistant", or +// - a compaction item (type="compaction" or "compaction_summary"). +// +// Normal incremental turns only contain user messages or function_call_output +// items and never carry either of these signals. +func inputContainsFullTranscript(input gjson.Result) bool { + if !input.IsArray() { + return false + } + for _, item := range input.Array() { + t := item.Get("type").String() + if t == "message" && item.Get("role").String() == "assistant" { + return true + } + if t == "compaction" || t == "compaction_summary" { + return true + } + } + return false +} + func normalizeJSONArrayRaw(raw []byte) string { trimmed := strings.TrimSpace(string(raw)) if trimmed == "" { diff --git a/sdk/api/handlers/openai/openai_responses_websocket_test.go b/sdk/api/handlers/openai/openai_responses_websocket_test.go index ecfc90b31b..2c5ef579b8 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket_test.go +++ b/sdk/api/handlers/openai/openai_responses_websocket_test.go @@ -1400,3 +1400,104 @@ func TestResponsesWebsocketCompactionResetsTurnStateOnTranscriptReplacement(t *t t.Fatalf("post-compact function call id = %s, want call-1", items[0].Get("call_id").String()) } } + +func TestInputContainsFullTranscriptDetectsAssistantMessage(t *testing.T) { + input := gjson.Parse(`[ + {"type":"message","role":"user","content":"hello"}, + {"type":"message","role":"assistant","content":"hi there"} + ]`) + if !inputContainsFullTranscript(input) { + t.Fatal("expected full transcript when assistant message is present") + } +} + +func TestInputContainsFullTranscriptDetectsCompactionItem(t *testing.T) { + for _, typ := range []string{"compaction", "compaction_summary"} { + input := gjson.Parse(`[{"type":"message","role":"user","content":"hello"},{"type":"` + typ + `","encrypted_content":"summary"}]`) + if !inputContainsFullTranscript(input) { + t.Fatalf("expected full transcript for type=%s", typ) + } + } +} + +func TestInputContainsFullTranscriptFalseForIncremental(t *testing.T) { + // Normal incremental turns: user messages or function_call_output only. + for _, raw := range []string{ + `[{"type":"function_call_output","call_id":"call-1","output":"result"}]`, + `[{"type":"message","role":"user","content":"next question"}]`, + `[]`, + } { + if inputContainsFullTranscript(gjson.Parse(raw)) { + t.Fatalf("incremental input must not be detected as full transcript: %s", raw) + } + } +} + +func TestNormalizeSubsequentRequestCompactSkipsMerge(t *testing.T) { + lastRequest := []byte(`{"model":"gpt-5.4","stream":true,"input":[ + {"type":"message","role":"user","id":"msg-1","content":"original long prompt"}, + {"type":"message","role":"assistant","id":"msg-2","content":"original long response"}, + {"type":"function_call","id":"fc-1","call_id":"call-old","name":"bash","arguments":"{}"}, + {"type":"function_call_output","id":"fco-1","call_id":"call-old","output":"old result"} + ]}`) + lastResponseOutput := []byte(`[ + {"type":"message","role":"assistant","id":"msg-3","content":"another assistant reply"}, + {"type":"function_call","id":"fc-2","call_id":"call-stale","name":"read","arguments":"{}"} + ]`) + + // Remote compact response: user messages + compaction item, NO assistant message. + // This is the primary compact scenario from Codex CLI. + raw := []byte(`{"type":"response.create","input":[ + {"type":"message","role":"user","id":"msg-1c","content":"compacted user msg"}, + {"type":"compaction","encrypted_content":"conversation summary"} + ]}`) + + normalized, _, errMsg := normalizeResponsesWebsocketRequest(raw, lastRequest, lastResponseOutput) + if errMsg != nil { + t.Fatalf("unexpected error: %v", errMsg.Error) + } + + input := gjson.GetBytes(normalized, "input").Array() + if len(input) != 2 { + t.Fatalf("input len = %d, want 2 (compacted only); stale state was not skipped", len(input)) + } + if input[0].Get("id").String() != "msg-1c" { + t.Fatalf("input[0].id = %q, want %q", input[0].Get("id").String(), "msg-1c") + } + if input[1].Get("type").String() != "compaction" { + t.Fatalf("input[1].type = %q, want %q", input[1].Get("type").String(), "compaction") + } +} + +func TestNormalizeSubsequentRequestIncrementalInputStillMerges(t *testing.T) { + // Normal incremental flow: user sends function_call_output (no assistant message). + lastRequest := []byte(`{"model":"gpt-5.4","stream":true,"input":[ + {"type":"message","role":"user","id":"msg-1","content":"hello"} + ]}`) + lastResponseOutput := []byte(`[ + {"type":"message","role":"assistant","id":"msg-2","content":"let me check"}, + {"type":"function_call","id":"fc-1","call_id":"call-1","name":"bash","arguments":"{}"} + ]`) + raw := []byte(`{"type":"response.create","input":[ + {"type":"function_call_output","call_id":"call-1","id":"fco-1","output":"done"} + ]}`) + + normalized, _, errMsg := normalizeResponsesWebsocketRequest(raw, lastRequest, lastResponseOutput) + if errMsg != nil { + t.Fatalf("unexpected error: %v", errMsg.Error) + } + + input := gjson.GetBytes(normalized, "input").Array() + + // Should be merged: msg-1 + msg-2 + fc-1 + fco-1 = 4 items + if len(input) != 4 { + t.Fatalf("input len = %d, want 4 (merged)", len(input)) + } + wantIDs := []string{"msg-1", "msg-2", "fc-1", "fco-1"} + for i, want := range wantIDs { + got := input[i].Get("id").String() + if got != want { + t.Fatalf("input[%d].id = %q, want %q", i, got, want) + } + } +} From d2d0e6f6a1c2e4126151cd1ad78e7809598620ad Mon Sep 17 00:00:00 2001 From: DragonFSKY Date: Mon, 23 Mar 2026 23:27:20 +0800 Subject: [PATCH 006/139] fix(websocket): narrow compact replay detection --- .../openai/openai_responses_websocket.go | 23 ++++-------- .../openai/openai_responses_websocket_test.go | 36 +++++++++++++++++-- 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/sdk/api/handlers/openai/openai_responses_websocket.go b/sdk/api/handlers/openai/openai_responses_websocket.go index 6457cd3ee9..273547d83f 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket.go +++ b/sdk/api/handlers/openai/openai_responses_websocket.go @@ -703,29 +703,20 @@ func mergeJSONArrayRaw(existingRaw, appendRaw string) (string, error) { return string(out), nil } -// inputContainsFullTranscript returns true when the input array looks like a -// complete conversation history rather than an incremental append. After a -// client-side compact the input already carries the full (compacted) transcript -// which may include assistant messages or compaction items. Merging that with -// the stale lastRequest / lastResponseOutput would duplicate or break -// function_call / function_call_output pairings, so the caller should use the -// input as-is. +// inputContainsFullTranscript returns true when the input array carries compact +// replay markers that indicate the client already sent the full conversation +// transcript. Merging that input with stale lastRequest/lastResponseOutput +// would duplicate or break function_call/function_call_output pairings, so the +// caller should use the input as-is. // -// Heuristic: the array is a full transcript when it contains either -// - a message with role="assistant", or -// - a compaction item (type="compaction" or "compaction_summary"). -// -// Normal incremental turns only contain user messages or function_call_output -// items and never carry either of these signals. +// Assistant messages alone are not enough to classify the payload as a replay: +// incremental websocket requests may legitimately append assistant items. func inputContainsFullTranscript(input gjson.Result) bool { if !input.IsArray() { return false } for _, item := range input.Array() { t := item.Get("type").String() - if t == "message" && item.Get("role").String() == "assistant" { - return true - } if t == "compaction" || t == "compaction_summary" { return true } diff --git a/sdk/api/handlers/openai/openai_responses_websocket_test.go b/sdk/api/handlers/openai/openai_responses_websocket_test.go index 2c5ef579b8..82b96f141c 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket_test.go +++ b/sdk/api/handlers/openai/openai_responses_websocket_test.go @@ -1401,13 +1401,13 @@ func TestResponsesWebsocketCompactionResetsTurnStateOnTranscriptReplacement(t *t } } -func TestInputContainsFullTranscriptDetectsAssistantMessage(t *testing.T) { +func TestInputContainsFullTranscriptFalseForAssistantMessageOnly(t *testing.T) { input := gjson.Parse(`[ {"type":"message","role":"user","content":"hello"}, {"type":"message","role":"assistant","content":"hi there"} ]`) - if !inputContainsFullTranscript(input) { - t.Fatal("expected full transcript when assistant message is present") + if inputContainsFullTranscript(input) { + t.Fatal("assistant message alone must not be treated as full transcript") } } @@ -1501,3 +1501,33 @@ func TestNormalizeSubsequentRequestIncrementalInputStillMerges(t *testing.T) { } } } + +func TestNormalizeSubsequentRequestAssistantIncrementalInputStillMerges(t *testing.T) { + lastRequest := []byte(`{"model":"gpt-5.4","stream":true,"input":[ + {"type":"message","role":"user","id":"msg-1","content":"hello"} + ]}`) + lastResponseOutput := []byte(`[ + {"type":"message","role":"assistant","id":"msg-2","content":"prior assistant"}, + {"type":"function_call","id":"fc-1","call_id":"call-1","name":"bash","arguments":"{}"} + ]`) + raw := []byte(`{"type":"response.append","input":[ + {"type":"message","role":"assistant","id":"msg-3","content":"patched assistant turn"} + ]}`) + + normalized, _, errMsg := normalizeResponsesWebsocketRequest(raw, lastRequest, lastResponseOutput) + if errMsg != nil { + t.Fatalf("unexpected error: %v", errMsg.Error) + } + + input := gjson.GetBytes(normalized, "input").Array() + if len(input) != 4 { + t.Fatalf("input len = %d, want 4 (merged)", len(input)) + } + wantIDs := []string{"msg-1", "msg-2", "fc-1", "msg-3"} + for i, want := range wantIDs { + got := input[i].Get("id").String() + if got != want { + t.Fatalf("input[%d].id = %q, want %q", i, got, want) + } + } +} From 4ca00f79832a2111d23d1d80b490e5a9c3026aab Mon Sep 17 00:00:00 2001 From: DragonFSKY Date: Tue, 24 Mar 2026 19:48:32 +0800 Subject: [PATCH 007/139] fix(websocket): gate compact replay by downstream support --- .../openai/openai_responses_websocket.go | 166 ++++++++++++------ .../openai/openai_responses_websocket_test.go | 106 +++++++++-- 2 files changed, 211 insertions(+), 61 deletions(-) diff --git a/sdk/api/handlers/openai/openai_responses_websocket.go b/sdk/api/handlers/openai/openai_responses_websocket.go index 273547d83f..caf26f131d 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket.go +++ b/sdk/api/handlers/openai/openai_responses_websocket.go @@ -116,6 +116,19 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) { allowIncrementalInputWithPreviousResponseID = h.websocketUpstreamSupportsIncrementalInputForModel(requestModelName) } + allowCompactionReplayBypass := false + if pinnedAuthID != "" && h != nil && h.AuthManager != nil { + if pinnedAuth, ok := h.AuthManager.GetByID(pinnedAuthID); ok && pinnedAuth != nil { + allowCompactionReplayBypass = responsesWebsocketAuthSupportsCompactionReplay(pinnedAuth) + } + } else { + requestModelName := strings.TrimSpace(gjson.GetBytes(payload, "model").String()) + if requestModelName == "" { + requestModelName = strings.TrimSpace(gjson.GetBytes(lastRequest, "model").String()) + } + allowCompactionReplayBypass = h.websocketUpstreamSupportsCompactionReplayForModel(requestModelName) + } + var requestJSON []byte var updatedLastRequest []byte var errMsg *interfaces.ErrorMessage @@ -124,6 +137,7 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) { lastRequest, lastResponseOutput, allowIncrementalInputWithPreviousResponseID, + allowCompactionReplayBypass, ) if errMsg != nil { h.LoggingAPIResponseError(context.WithValue(context.Background(), "gin", c), errMsg) @@ -222,10 +236,10 @@ func websocketUpgradeHeaders(req *http.Request) http.Header { } func normalizeResponsesWebsocketRequest(rawJSON []byte, lastRequest []byte, lastResponseOutput []byte) ([]byte, []byte, *interfaces.ErrorMessage) { - return normalizeResponsesWebsocketRequestWithMode(rawJSON, lastRequest, lastResponseOutput, true) + return normalizeResponsesWebsocketRequestWithMode(rawJSON, lastRequest, lastResponseOutput, true, true) } -func normalizeResponsesWebsocketRequestWithMode(rawJSON []byte, lastRequest []byte, lastResponseOutput []byte, allowIncrementalInputWithPreviousResponseID bool) ([]byte, []byte, *interfaces.ErrorMessage) { +func normalizeResponsesWebsocketRequestWithMode(rawJSON []byte, lastRequest []byte, lastResponseOutput []byte, allowIncrementalInputWithPreviousResponseID bool, allowCompactionReplayBypass bool) ([]byte, []byte, *interfaces.ErrorMessage) { requestType := strings.TrimSpace(gjson.GetBytes(rawJSON, "type").String()) switch requestType { case wsRequestTypeCreate: @@ -233,10 +247,10 @@ func normalizeResponsesWebsocketRequestWithMode(rawJSON []byte, lastRequest []by if len(lastRequest) == 0 { return normalizeResponseCreateRequest(rawJSON) } - return normalizeResponseSubsequentRequest(rawJSON, lastRequest, lastResponseOutput, allowIncrementalInputWithPreviousResponseID) + return normalizeResponseSubsequentRequest(rawJSON, lastRequest, lastResponseOutput, allowIncrementalInputWithPreviousResponseID, allowCompactionReplayBypass) case wsRequestTypeAppend: // log.Infof("responses websocket: response.append request") - return normalizeResponseSubsequentRequest(rawJSON, lastRequest, lastResponseOutput, allowIncrementalInputWithPreviousResponseID) + return normalizeResponseSubsequentRequest(rawJSON, lastRequest, lastResponseOutput, allowIncrementalInputWithPreviousResponseID, allowCompactionReplayBypass) default: return nil, lastRequest, &interfaces.ErrorMessage{ StatusCode: http.StatusBadRequest, @@ -265,7 +279,7 @@ func normalizeResponseCreateRequest(rawJSON []byte) ([]byte, []byte, *interfaces return normalized, bytes.Clone(normalized), nil } -func normalizeResponseSubsequentRequest(rawJSON []byte, lastRequest []byte, lastResponseOutput []byte, allowIncrementalInputWithPreviousResponseID bool) ([]byte, []byte, *interfaces.ErrorMessage) { +func normalizeResponseSubsequentRequest(rawJSON []byte, lastRequest []byte, lastResponseOutput []byte, allowIncrementalInputWithPreviousResponseID bool, allowCompactionReplayBypass bool) ([]byte, []byte, *interfaces.ErrorMessage) { if len(lastRequest) == 0 { return nil, lastRequest, &interfaces.ErrorMessage{ StatusCode: http.StatusBadRequest, @@ -315,16 +329,21 @@ func normalizeResponseSubsequentRequest(rawJSON []byte, lastRequest []byte, last } } - // When the client sends a full conversation transcript (e.g. after compact), - // the input already contains the complete history including assistant messages. - // In that case, skip merging with stale lastRequest/lastResponseOutput to avoid - // breaking function_call / function_call_output pairings. + // When the client sends a compact replay for a downstream that can consume it + // directly, the input already carries the canonical history. In that case, + // skip merging with stale lastRequest/lastResponseOutput to avoid breaking + // function_call / function_call_output pairings. // See: https://github.com/router-for-me/CLIProxyAPI/issues/2207 var mergedInput string - if inputContainsFullTranscript(nextInput) { + if allowCompactionReplayBypass && inputContainsFullTranscript(nextInput) { log.Infof("responses websocket: full transcript detected, skipping stale merge (input items=%d)", len(nextInput.Array())) mergedInput = nextInput.Raw } else { + appendInputRaw := nextInput.Raw + if inputContainsFullTranscript(nextInput) { + appendInputRaw = inputWithoutCompactionItems(nextInput) + } + existingInput := gjson.GetBytes(lastRequest, "input") var errMerge error mergedInput, errMerge = mergeJSONArrayRaw(existingInput.Raw, normalizeJSONArrayRaw(lastResponseOutput)) @@ -335,7 +354,7 @@ func normalizeResponseSubsequentRequest(rawJSON []byte, lastRequest []byte, last } } - mergedInput, errMerge = mergeJSONArrayRaw(mergedInput, nextInput.Raw) + mergedInput, errMerge = mergeJSONArrayRaw(mergedInput, appendInputRaw) if errMerge != nil { return nil, lastRequest, &interfaces.ErrorMessage{ StatusCode: http.StatusBadRequest, @@ -492,72 +511,104 @@ func websocketUpstreamSupportsIncrementalInput(attributes map[string]string, met } func (h *OpenAIResponsesAPIHandler) websocketUpstreamSupportsIncrementalInputForModel(modelName string) bool { - if h == nil || h.AuthManager == nil { + auths, _ := h.responsesWebsocketAvailableAuthsForModel(modelName) + for _, auth := range auths { + if websocketUpstreamSupportsIncrementalInput(auth.Attributes, auth.Metadata) { + return true + } + } + return false +} + +func (h *OpenAIResponsesAPIHandler) websocketUpstreamSupportsCompactionReplayForModel(modelName string) bool { + auths, _ := h.responsesWebsocketAvailableAuthsForModel(modelName) + if len(auths) == 0 { return false } + for _, auth := range auths { + if !responsesWebsocketAuthSupportsCompactionReplay(auth) { + return false + } + } + return true +} - resolvedModelName := modelName +func (h *OpenAIResponsesAPIHandler) responsesWebsocketAvailableAuthsForModel(modelName string) ([]*coreauth.Auth, string) { + if h == nil || h.AuthManager == nil { + return nil, "" + } + resolvedModelName := responsesWebsocketResolvedModelName(modelName) + providerSet, modelKey := responsesWebsocketProviderSetForModel(resolvedModelName) + if len(providerSet) == 0 { + return nil, modelKey + } + + registryRef := registry.GetGlobalRegistry() + now := time.Now() + auths := h.AuthManager.List() + available := make([]*coreauth.Auth, 0, len(auths)) + for _, auth := range auths { + if !responsesWebsocketAuthMatchesModel(auth, providerSet, modelKey, registryRef, now) { + continue + } + available = append(available, auth) + } + return available, modelKey +} + +func responsesWebsocketResolvedModelName(modelName string) string { initialSuffix := thinking.ParseSuffix(modelName) if initialSuffix.ModelName == "auto" { resolvedBase := util.ResolveAutoModel(initialSuffix.ModelName) if initialSuffix.HasSuffix { - resolvedModelName = fmt.Sprintf("%s(%s)", resolvedBase, initialSuffix.RawSuffix) - } else { - resolvedModelName = resolvedBase + return fmt.Sprintf("%s(%s)", resolvedBase, initialSuffix.RawSuffix) } - } else { - resolvedModelName = util.ResolveAutoModel(modelName) + return resolvedBase } + return util.ResolveAutoModel(modelName) +} +func responsesWebsocketProviderSetForModel(resolvedModelName string) (map[string]struct{}, string) { parsed := thinking.ParseSuffix(resolvedModelName) baseModel := strings.TrimSpace(parsed.ModelName) providers := util.GetProviderName(baseModel) if len(providers) == 0 && baseModel != resolvedModelName { providers = util.GetProviderName(resolvedModelName) } - if len(providers) == 0 { - return false - } - providerSet := make(map[string]struct{}, len(providers)) - for i := 0; i < len(providers); i++ { - providerKey := strings.TrimSpace(strings.ToLower(providers[i])) + for _, provider := range providers { + providerKey := strings.TrimSpace(strings.ToLower(provider)) if providerKey == "" { continue } providerSet[providerKey] = struct{}{} } - if len(providerSet) == 0 { - return false - } - modelKey := baseModel if modelKey == "" { modelKey = strings.TrimSpace(resolvedModelName) } - registryRef := registry.GetGlobalRegistry() - now := time.Now() - auths := h.AuthManager.List() - for i := 0; i < len(auths); i++ { - auth := auths[i] - if auth == nil { - continue - } - providerKey := strings.TrimSpace(strings.ToLower(auth.Provider)) - if _, ok := providerSet[providerKey]; !ok { - continue - } - if modelKey != "" && registryRef != nil && !registryRef.ClientSupportsModel(auth.ID, modelKey) { - continue - } - if !responsesWebsocketAuthAvailableForModel(auth, modelKey, now) { - continue - } - if websocketUpstreamSupportsIncrementalInput(auth.Attributes, auth.Metadata) { - return true - } + return providerSet, modelKey +} + +func responsesWebsocketAuthMatchesModel(auth *coreauth.Auth, providerSet map[string]struct{}, modelKey string, registryRef *registry.ModelRegistry, now time.Time) bool { + if auth == nil { + return false } - return false + providerKey := strings.TrimSpace(strings.ToLower(auth.Provider)) + if _, ok := providerSet[providerKey]; !ok { + return false + } + if modelKey != "" && registryRef != nil && !registryRef.ClientSupportsModel(auth.ID, modelKey) { + return false + } + return responsesWebsocketAuthAvailableForModel(auth, modelKey, now) +} + +func responsesWebsocketAuthSupportsCompactionReplay(auth *coreauth.Auth) bool { + if auth == nil { + return false + } + return strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") } func responsesWebsocketAuthAvailableForModel(auth *coreauth.Auth, modelName string, now time.Time) bool { @@ -724,6 +775,21 @@ func inputContainsFullTranscript(input gjson.Result) bool { return false } +func inputWithoutCompactionItems(input gjson.Result) string { + if !input.IsArray() { + return normalizeJSONArrayRaw([]byte(input.Raw)) + } + filtered := make([]string, 0, len(input.Array())) + for _, item := range input.Array() { + t := item.Get("type").String() + if t == "compaction" || t == "compaction_summary" { + continue + } + filtered = append(filtered, item.Raw) + } + return "[" + strings.Join(filtered, ",") + "]" +} + func normalizeJSONArrayRaw(raw []byte) string { trimmed := strings.TrimSpace(string(raw)) if trimmed == "" { diff --git a/sdk/api/handlers/openai/openai_responses_websocket_test.go b/sdk/api/handlers/openai/openai_responses_websocket_test.go index 82b96f141c..f2c4319eb0 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket_test.go +++ b/sdk/api/handlers/openai/openai_responses_websocket_test.go @@ -242,7 +242,7 @@ func TestNormalizeResponsesWebsocketRequestWithPreviousResponseIDIncremental(t * ]`) raw := []byte(`{"type":"response.create","previous_response_id":"resp-1","input":[{"type":"function_call_output","call_id":"call-1","id":"tool-out-1"}]}`) - normalized, next, errMsg := normalizeResponsesWebsocketRequestWithMode(raw, lastRequest, lastResponseOutput, true) + normalized, next, errMsg := normalizeResponsesWebsocketRequestWithMode(raw, lastRequest, lastResponseOutput, true, false) if errMsg != nil { t.Fatalf("unexpected error: %v", errMsg.Error) } @@ -278,7 +278,7 @@ func TestNormalizeResponsesWebsocketRequestWithPreviousResponseIDMergedWhenIncre ]`) raw := []byte(`{"type":"response.create","previous_response_id":"resp-1","input":[{"type":"function_call_output","call_id":"call-1","id":"tool-out-1"}]}`) - normalized, next, errMsg := normalizeResponsesWebsocketRequestWithMode(raw, lastRequest, lastResponseOutput, false) + normalized, next, errMsg := normalizeResponsesWebsocketRequestWithMode(raw, lastRequest, lastResponseOutput, false, false) if errMsg != nil { t.Fatalf("unexpected error: %v", errMsg.Error) } @@ -867,6 +867,53 @@ func TestWebsocketUpstreamSupportsIncrementalInputForModel(t *testing.T) { } } +func TestWebsocketUpstreamSupportsCompactionReplayForModel(t *testing.T) { + manager := coreauth.NewManager(nil, nil, nil) + auth := &coreauth.Auth{ + ID: "auth-codex", + Provider: "codex", + Status: coreauth.StatusActive, + } + if _, err := manager.Register(context.Background(), auth); err != nil { + t.Fatalf("Register auth: %v", err) + } + registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: "test-model"}}) + t.Cleanup(func() { + registry.GetGlobalRegistry().UnregisterClient(auth.ID) + }) + + base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager) + h := NewOpenAIResponsesAPIHandler(base) + if !h.websocketUpstreamSupportsCompactionReplayForModel("test-model") { + t.Fatalf("expected codex upstream to support compaction replay") + } +} + +func TestWebsocketUpstreamSupportsCompactionReplayForModelFalseWhenMixedBackends(t *testing.T) { + manager := coreauth.NewManager(nil, nil, nil) + auths := []*coreauth.Auth{ + {ID: "auth-codex", Provider: "codex", Status: coreauth.StatusActive}, + {ID: "auth-claude", Provider: "claude", Status: coreauth.StatusActive}, + } + for _, auth := range auths { + if _, err := manager.Register(context.Background(), auth); err != nil { + t.Fatalf("Register auth %s: %v", auth.ID, err) + } + registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: "test-model"}}) + } + t.Cleanup(func() { + for _, auth := range auths { + registry.GetGlobalRegistry().UnregisterClient(auth.ID) + } + }) + + base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager) + h := NewOpenAIResponsesAPIHandler(base) + if h.websocketUpstreamSupportsCompactionReplayForModel("test-model") { + t.Fatalf("expected mixed backend model to disable compaction replay bypass") + } +} + func TestResponsesWebsocketPrewarmHandledLocallyForSSEUpstream(t *testing.T) { gin.SetMode(gin.TestMode) @@ -1469,6 +1516,45 @@ func TestNormalizeSubsequentRequestCompactSkipsMerge(t *testing.T) { } } +func TestNormalizeSubsequentRequestCompactMergesWhenCompactionReplayUnsupported(t *testing.T) { + lastRequest := []byte(`{"model":"gpt-5.4","stream":true,"input":[ + {"type":"message","role":"user","id":"msg-1","content":"original long prompt"}, + {"type":"message","role":"assistant","id":"msg-2","content":"original long response"}, + {"type":"function_call","id":"fc-1","call_id":"call-old","name":"bash","arguments":"{}"}, + {"type":"function_call_output","id":"fco-1","call_id":"call-old","output":"old result"} + ]}`) + lastResponseOutput := []byte(`[ + {"type":"message","role":"assistant","id":"msg-3","content":"another assistant reply"}, + {"type":"function_call","id":"fc-2","call_id":"call-stale","name":"read","arguments":"{}"} + ]`) + raw := []byte(`{"type":"response.create","input":[ + {"type":"message","role":"user","id":"msg-1c","content":"compacted user msg"}, + {"type":"compaction","encrypted_content":"conversation summary"} + ]}`) + + normalized, _, errMsg := normalizeResponsesWebsocketRequestWithMode(raw, lastRequest, lastResponseOutput, false, false) + if errMsg != nil { + t.Fatalf("unexpected error: %v", errMsg.Error) + } + + input := gjson.GetBytes(normalized, "input").Array() + if len(input) != 7 { + t.Fatalf("input len = %d, want 7 (merged fallback without compaction items)", len(input)) + } + wantIDs := []string{"msg-1", "msg-2", "fc-1", "fco-1", "msg-3", "fc-2", "msg-1c"} + for i, want := range wantIDs { + got := input[i].Get("id").String() + if got != want { + t.Fatalf("input[%d].id = %q, want %q", i, got, want) + } + } + for _, item := range input { + if item.Get("type").String() == "compaction" || item.Get("type").String() == "compaction_summary" { + t.Fatalf("compaction items must be stripped for unsupported downstream fallback: %s", item.Raw) + } + } +} + func TestNormalizeSubsequentRequestIncrementalInputStillMerges(t *testing.T) { // Normal incremental flow: user sends function_call_output (no assistant message). lastRequest := []byte(`{"model":"gpt-5.4","stream":true,"input":[ @@ -1502,7 +1588,9 @@ func TestNormalizeSubsequentRequestIncrementalInputStillMerges(t *testing.T) { } } -func TestNormalizeSubsequentRequestAssistantIncrementalInputStillMerges(t *testing.T) { +func TestNormalizeSubsequentRequestAssistantInputTriggersTranscriptReplacement(t *testing.T) { + // After dev's shouldReplaceWebsocketTranscript, assistant messages in input + // trigger transcript replacement (no merge with prior state). lastRequest := []byte(`{"model":"gpt-5.4","stream":true,"input":[ {"type":"message","role":"user","id":"msg-1","content":"hello"} ]}`) @@ -1520,14 +1608,10 @@ func TestNormalizeSubsequentRequestAssistantIncrementalInputStillMerges(t *testi } input := gjson.GetBytes(normalized, "input").Array() - if len(input) != 4 { - t.Fatalf("input len = %d, want 4 (merged)", len(input)) + if len(input) != 1 { + t.Fatalf("input len = %d, want 1 (transcript replacement, not merge)", len(input)) } - wantIDs := []string{"msg-1", "msg-2", "fc-1", "msg-3"} - for i, want := range wantIDs { - got := input[i].Get("id").String() - if got != want { - t.Fatalf("input[%d].id = %q, want %q", i, got, want) - } + if input[0].Get("id").String() != "msg-3" { + t.Fatalf("input[0].id = %q, want %q", input[0].Get("id").String(), "msg-3") } } From 8f9e6622b029164991c6f97b67562f940da327bd Mon Sep 17 00:00:00 2001 From: muzhi1991 <2101044+muzhi1991@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:45:37 +0800 Subject: [PATCH 008/139] fix(util): forward custom Host header to upstream Custom headers configured under openai-compatibility (and any other provider passing through applyCustomHeaders) were silently dropped for the Host key, because Go's net/http reads the wire Host from req.Host, not req.Header["Host"]. As a result, virtual-host routed upstreams (e.g. LiteLLM behind an ingress) saw the base-url's host instead of the user-configured override and returned 404. Detect the Host key with http.CanonicalHeaderKey and assign it to req.Host so it is actually written on the wire. Other headers continue to use Header.Set as before. Fixes #2833 --- internal/util/header_helpers.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/util/header_helpers.go b/internal/util/header_helpers.go index c53c291f10..967903fce5 100644 --- a/internal/util/header_helpers.go +++ b/internal/util/header_helpers.go @@ -47,6 +47,14 @@ func applyCustomHeaders(r *http.Request, headers map[string]string) { if k == "" || v == "" { continue } + // Host is read from req.Host (not req.Header) by net/http when + // writing the request; setting it via Header.Set is silently + // dropped on the wire. Handle it explicitly so user-configured + // virtual-host overrides actually take effect upstream. + if http.CanonicalHeaderKey(k) == "Host" { + r.Host = v + continue + } r.Header.Set(k, v) } } From d9a3b3e5f33ca103f7d349b63a5b5bfea86234a0 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Fri, 17 Apr 2026 08:32:07 +0800 Subject: [PATCH 009/139] fix(tests): update model lookup references and enhance Claude executor tests --- .../registry/model_registry_safety_test.go | 4 +- .../runtime/executor/claude_executor_test.go | 76 ++++++++++++++----- test/thinking_conversion_test.go | 1 - 3 files changed, 58 insertions(+), 23 deletions(-) diff --git a/internal/registry/model_registry_safety_test.go b/internal/registry/model_registry_safety_test.go index 5f4f65d298..be5bf7908c 100644 --- a/internal/registry/model_registry_safety_test.go +++ b/internal/registry/model_registry_safety_test.go @@ -136,13 +136,13 @@ func TestGetAvailableModelsReturnsClonedSupportedParameters(t *testing.T) { } func TestLookupModelInfoReturnsCloneForStaticDefinitions(t *testing.T) { - first := LookupModelInfo("glm-4.6") + first := LookupModelInfo("claude-sonnet-4-6") if first == nil || first.Thinking == nil || len(first.Thinking.Levels) == 0 { t.Fatalf("expected static model with thinking levels, got %+v", first) } first.Thinking.Levels[0] = "mutated" - second := LookupModelInfo("glm-4.6") + second := LookupModelInfo("claude-sonnet-4-6") if second == nil || second.Thinking == nil || len(second.Thinking.Levels) == 0 || second.Thinking.Levels[0] == "mutated" { t.Fatalf("expected static lookup clone, got %+v", second) } diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index f456064dc6..c1ce8fc088 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -1714,7 +1714,27 @@ func TestClaudeExecutor_ExecuteStream_AcceptEncodingOverrideCannotBypassIdentity } } -// Test case 1: String system prompt is preserved and converted to a content block +func expectedClaudeCodeStaticPrompt() string { + return strings.Join([]string{ + helps.ClaudeCodeIntro, + helps.ClaudeCodeSystem, + helps.ClaudeCodeDoingTasks, + helps.ClaudeCodeToneAndStyle, + helps.ClaudeCodeOutputEfficiency, + }, "\n\n") +} + +func expectedForwardedSystemReminder(text string) string { + return fmt.Sprintf(` +As you answer the user's questions, you can use the following context from the system: +%s + +IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task. + +`, text) +} + +// Test case 1: String system prompt is preserved by forwarding it to the first user message func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) { payload := []byte(`{"system":"You are a helpful assistant.","messages":[{"role":"user","content":"hi"}]}`) @@ -1733,42 +1753,52 @@ func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) { if !strings.HasPrefix(blocks[0].Get("text").String(), "x-anthropic-billing-header:") { t.Fatalf("blocks[0] should be billing header, got %q", blocks[0].Get("text").String()) } - if blocks[1].Get("text").String() != "You are a Claude agent, built on Anthropic's Claude Agent SDK." { + if blocks[1].Get("text").String() != "You are Claude Code, Anthropic's official CLI for Claude." { t.Fatalf("blocks[1] should be agent block, got %q", blocks[1].Get("text").String()) } - if blocks[2].Get("text").String() != "You are a helpful assistant." { - t.Fatalf("blocks[2] should be user system prompt, got %q", blocks[2].Get("text").String()) + if blocks[2].Get("text").String() != expectedClaudeCodeStaticPrompt() { + t.Fatalf("blocks[2] should be static Claude Code prompt, got %q", blocks[2].Get("text").String()) + } + if blocks[2].Get("cache_control").Exists() { + t.Fatalf("blocks[2] should not have cache_control, got %s", blocks[2].Get("cache_control").Raw) } - if blocks[2].Get("cache_control.type").String() != "ephemeral" { - t.Fatalf("blocks[2] should have cache_control.type=ephemeral") + + if got := gjson.GetBytes(out, "messages.0.content").String(); got != expectedForwardedSystemReminder("You are a helpful assistant.")+"hi" { + t.Fatalf("messages[0].content should include forwarded system prompt, got %q", got) } } -// Test case 2: Strict mode drops the string system prompt +// Test case 2: Strict mode keeps only the injected Claude Code system blocks func TestCheckSystemInstructionsWithMode_StringSystemStrict(t *testing.T) { payload := []byte(`{"system":"You are a helpful assistant.","messages":[{"role":"user","content":"hi"}]}`) out := checkSystemInstructionsWithMode(payload, true) blocks := gjson.GetBytes(out, "system").Array() - if len(blocks) != 2 { - t.Fatalf("strict mode should produce 2 blocks, got %d", len(blocks)) + if len(blocks) != 3 { + t.Fatalf("strict mode should produce 3 injected blocks, got %d", len(blocks)) + } + if got := gjson.GetBytes(out, "messages.0.content").String(); got != "hi" { + t.Fatalf("strict mode should not forward system prompt into messages, got %q", got) } } -// Test case 3: Empty string system prompt does not produce a spurious block +// Test case 3: Empty string system prompt does not alter the first user message func TestCheckSystemInstructionsWithMode_EmptyStringSystemIgnored(t *testing.T) { payload := []byte(`{"system":"","messages":[{"role":"user","content":"hi"}]}`) out := checkSystemInstructionsWithMode(payload, false) blocks := gjson.GetBytes(out, "system").Array() - if len(blocks) != 2 { - t.Fatalf("empty string system should produce 2 blocks, got %d", len(blocks)) + if len(blocks) != 3 { + t.Fatalf("empty string system should still produce 3 injected blocks, got %d", len(blocks)) + } + if got := gjson.GetBytes(out, "messages.0.content").String(); got != "hi" { + t.Fatalf("empty string system should not alter messages, got %q", got) } } -// Test case 4: Array system prompt is unaffected by the string handling +// Test case 4: Array system prompt is forwarded to the first user message func TestCheckSystemInstructionsWithMode_ArraySystemStillWorks(t *testing.T) { payload := []byte(`{"system":[{"type":"text","text":"Be concise."}],"messages":[{"role":"user","content":"hi"}]}`) @@ -1778,12 +1808,15 @@ func TestCheckSystemInstructionsWithMode_ArraySystemStillWorks(t *testing.T) { if len(blocks) != 3 { t.Fatalf("expected 3 system blocks, got %d", len(blocks)) } - if blocks[2].Get("text").String() != "Be concise." { - t.Fatalf("blocks[2] should be user system prompt, got %q", blocks[2].Get("text").String()) + if blocks[2].Get("text").String() != expectedClaudeCodeStaticPrompt() { + t.Fatalf("blocks[2] should be static Claude Code prompt, got %q", blocks[2].Get("text").String()) + } + if got := gjson.GetBytes(out, "messages.0.content").String(); got != expectedForwardedSystemReminder("Be concise.")+"hi" { + t.Fatalf("messages[0].content should include forwarded array system prompt, got %q", got) } } -// Test case 5: Special characters in string system prompt survive conversion +// Test case 5: Special characters in string system prompt survive forwarding func TestCheckSystemInstructionsWithMode_StringWithSpecialChars(t *testing.T) { payload := []byte(`{"system":"Use tags & \"quotes\" in output.","messages":[{"role":"user","content":"hi"}]}`) @@ -1793,8 +1826,8 @@ func TestCheckSystemInstructionsWithMode_StringWithSpecialChars(t *testing.T) { if len(blocks) != 3 { t.Fatalf("expected 3 system blocks, got %d", len(blocks)) } - if blocks[2].Get("text").String() != `Use tags & "quotes" in output.` { - t.Fatalf("blocks[2] text mangled, got %q", blocks[2].Get("text").String()) + if got := gjson.GetBytes(out, "messages.0.content").String(); got != expectedForwardedSystemReminder(`Use tags & "quotes" in output.`)+"hi" { + t.Fatalf("forwarded system prompt text mangled, got %q", got) } } @@ -1902,8 +1935,11 @@ func TestApplyCloaking_PreservesConfiguredStrictModeAndSensitiveWordsWhenModeOmi out := applyCloaking(context.Background(), cfg, auth, payload, "claude-3-5-sonnet-20241022", "key-123") blocks := gjson.GetBytes(out, "system").Array() - if len(blocks) != 2 { - t.Fatalf("expected strict mode to keep only injected system blocks, got %d", len(blocks)) + if len(blocks) != 3 { + t.Fatalf("expected strict mode to keep the 3 injected Claude Code system blocks, got %d", len(blocks)) + } + if got := gjson.GetBytes(out, "messages.0.content.#").Int(); got != 1 { + t.Fatalf("strict mode should not prepend a forwarded system reminder block, got %d content blocks", got) } if got := gjson.GetBytes(out, "messages.0.content.0.text").String(); !strings.Contains(got, "\u200B") { t.Fatalf("expected configured sensitive word obfuscation to apply, got %q", got) diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index c6ade7b2a6..c76b1da101 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -2,7 +2,6 @@ package test import ( "fmt" - "strings" "testing" "time" From da43f63735f747266f8245e8aa2cc328c99c0fad Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Fri, 17 Apr 2026 08:43:19 +0800 Subject: [PATCH 010/139] fix(tests): update Gemini family test case numbers for consistency --- test/thinking_conversion_test.go | 52 ++++++++++++++++---------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index c76b1da101..51671a9c5f 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -1065,12 +1065,12 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { expectErr: false, }, - // Gemini Family Cross-Channel Consistency (Cases 106-114) + // Gemini Family Cross-Channel Consistency (Cases 90-95) // Tests that gemini/gemini-cli/antigravity as same API family should have consistent validation behavior - // Case 106: Gemini to Antigravity, budget 64000 (suffix) → clamped to Max + // Case 90: Gemini to Antigravity, budget 64000 (suffix) → clamped to Max { - name: "106", + name: "90", from: "gemini", to: "antigravity", model: "gemini-budget-model(64000)", @@ -1080,9 +1080,9 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { includeThoughts: "true", expectErr: false, }, - // Case 107: Gemini to Gemini-CLI, budget 64000 (suffix) → clamped to Max + // Case 91: Gemini to Gemini-CLI, budget 64000 (suffix) → clamped to Max { - name: "107", + name: "91", from: "gemini", to: "gemini-cli", model: "gemini-budget-model(64000)", @@ -1092,9 +1092,9 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { includeThoughts: "true", expectErr: false, }, - // Case 108: Gemini-CLI to Antigravity, budget 64000 (suffix) → clamped to Max + // Case 92: Gemini-CLI to Antigravity, budget 64000 (suffix) → clamped to Max { - name: "108", + name: "92", from: "gemini-cli", to: "antigravity", model: "gemini-budget-model(64000)", @@ -1104,9 +1104,9 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { includeThoughts: "true", expectErr: false, }, - // Case 109: Gemini-CLI to Gemini, budget 64000 (suffix) → clamped to Max + // Case 93: Gemini-CLI to Gemini, budget 64000 (suffix) → clamped to Max { - name: "109", + name: "93", from: "gemini-cli", to: "gemini", model: "gemini-budget-model(64000)", @@ -1116,9 +1116,9 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { includeThoughts: "true", expectErr: false, }, - // Case 110: Gemini to Antigravity, budget 8192 → passthrough (normal value) + // Case 94: Gemini to Antigravity, budget 8192 → passthrough (normal value) { - name: "110", + name: "94", from: "gemini", to: "antigravity", model: "gemini-budget-model(8192)", @@ -1128,9 +1128,9 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { includeThoughts: "true", expectErr: false, }, - // Case 111: Gemini-CLI to Antigravity, budget 8192 → passthrough (normal value) + // Case 95: Gemini-CLI to Antigravity, budget 8192 → passthrough (normal value) { - name: "111", + name: "95", from: "gemini-cli", to: "antigravity", model: "gemini-budget-model(8192)", @@ -2166,12 +2166,12 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { expectErr: true, }, - // Gemini Family Cross-Channel Consistency (Cases 106-114) + // Gemini Family Cross-Channel Consistency (Cases 90-95) // Tests that gemini/gemini-cli/antigravity as same API family should have consistent validation behavior - // Case 106: Gemini to Antigravity, thinkingBudget=64000 → exceeds Max error (same family strict validation) + // Case 90: Gemini to Antigravity, thinkingBudget=64000 → exceeds Max error (same family strict validation) { - name: "106", + name: "90", from: "gemini", to: "antigravity", model: "gemini-budget-model", @@ -2179,9 +2179,9 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { expectField: "", expectErr: true, }, - // Case 107: Gemini to Gemini-CLI, thinkingBudget=64000 → exceeds Max error (same family strict validation) + // Case 91: Gemini to Gemini-CLI, thinkingBudget=64000 → exceeds Max error (same family strict validation) { - name: "107", + name: "91", from: "gemini", to: "gemini-cli", model: "gemini-budget-model", @@ -2189,9 +2189,9 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { expectField: "", expectErr: true, }, - // Case 108: Gemini-CLI to Antigravity, thinkingBudget=64000 → exceeds Max error (same family strict validation) + // Case 92: Gemini-CLI to Antigravity, thinkingBudget=64000 → exceeds Max error (same family strict validation) { - name: "108", + name: "92", from: "gemini-cli", to: "antigravity", model: "gemini-budget-model", @@ -2199,9 +2199,9 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { expectField: "", expectErr: true, }, - // Case 109: Gemini-CLI to Gemini, thinkingBudget=64000 → exceeds Max error (same family strict validation) + // Case 93: Gemini-CLI to Gemini, thinkingBudget=64000 → exceeds Max error (same family strict validation) { - name: "109", + name: "93", from: "gemini-cli", to: "gemini", model: "gemini-budget-model", @@ -2209,9 +2209,9 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { expectField: "", expectErr: true, }, - // Case 110: Gemini to Antigravity, thinkingBudget=8192 → passthrough (normal value) + // Case 94: Gemini to Antigravity, thinkingBudget=8192 → passthrough (normal value) { - name: "110", + name: "94", from: "gemini", to: "antigravity", model: "gemini-budget-model", @@ -2221,9 +2221,9 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { includeThoughts: "true", expectErr: false, }, - // Case 111: Gemini-CLI to Antigravity, thinkingBudget=8192 → passthrough (normal value) + // Case 95: Gemini-CLI to Antigravity, thinkingBudget=8192 → passthrough (normal value) { - name: "111", + name: "95", from: "gemini-cli", to: "antigravity", model: "gemini-budget-model", From eba561bf6f08916a966cef698a5c53c7e273b375 Mon Sep 17 00:00:00 2001 From: muzhi1991 <2101044+muzhi1991@users.noreply.github.com> Date: Fri, 17 Apr 2026 09:28:59 +0800 Subject: [PATCH 011/139] fix(util): also keep Host in header map for synthetic requests Addressing the P1 note from the Codex reviewer: applyCustomHeaders is also called with a synthetic &http.Request{Header: ...} from the websockets executors (aistudio_executor.go, codex_websockets_executor.go), which forward only the header map. The previous continue meant a custom Host was dropped from that map, regressing virtual-host overrides on those flows. Mirror the value to both r.Host (for real net/http) and r.Header (for header-map-only consumers). --- internal/util/header_helpers.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/util/header_helpers.go b/internal/util/header_helpers.go index 967903fce5..0b8d72bcb4 100644 --- a/internal/util/header_helpers.go +++ b/internal/util/header_helpers.go @@ -47,13 +47,13 @@ func applyCustomHeaders(r *http.Request, headers map[string]string) { if k == "" || v == "" { continue } - // Host is read from req.Host (not req.Header) by net/http when - // writing the request; setting it via Header.Set is silently - // dropped on the wire. Handle it explicitly so user-configured - // virtual-host overrides actually take effect upstream. + // net/http reads Host from req.Host (not req.Header) when writing + // a real request, so we must mirror it there. Some callers pass + // synthetic requests (e.g. &http.Request{Header: ...}) and only + // consume r.Header afterwards, so keep the value in the header + // map too. if http.CanonicalHeaderKey(k) == "Host" { r.Host = v - continue } r.Header.Set(k, v) } From 894baad829f5f5a53411edc0d1af4f1af9f9d60d Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sat, 18 Apr 2026 16:44:33 +0800 Subject: [PATCH 012/139] feat(api): integrate auth index into key retrieval endpoints for Gemini, Claude, Codex, OpenAI, and Vertex --- .../handlers/management/config_auth_index.go | 245 ++++++++++++++++++ .../api/handlers/management/config_lists.go | 10 +- 2 files changed, 250 insertions(+), 5 deletions(-) create mode 100644 internal/api/handlers/management/config_auth_index.go diff --git a/internal/api/handlers/management/config_auth_index.go b/internal/api/handlers/management/config_auth_index.go new file mode 100644 index 0000000000..51f71aacf9 --- /dev/null +++ b/internal/api/handlers/management/config_auth_index.go @@ -0,0 +1,245 @@ +package management + +import ( + "strings" + "time" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/synthesizer" +) + +type configAuthIndexViews struct { + gemini []string + claude []string + codex []string + vertex []string + openAIEntries [][]string + openAIFallback []string +} + +type geminiKeyWithAuthIndex struct { + config.GeminiKey + AuthIndex string `json:"auth-index,omitempty"` +} + +type claudeKeyWithAuthIndex struct { + config.ClaudeKey + AuthIndex string `json:"auth-index,omitempty"` +} + +type codexKeyWithAuthIndex struct { + config.CodexKey + AuthIndex string `json:"auth-index,omitempty"` +} + +type vertexCompatKeyWithAuthIndex struct { + config.VertexCompatKey + AuthIndex string `json:"auth-index,omitempty"` +} + +type openAICompatibilityAPIKeyWithAuthIndex struct { + config.OpenAICompatibilityAPIKey + AuthIndex string `json:"auth-index,omitempty"` +} + +type openAICompatibilityWithAuthIndex struct { + Name string `json:"name"` + Priority int `json:"priority,omitempty"` + Prefix string `json:"prefix,omitempty"` + BaseURL string `json:"base-url"` + APIKeyEntries []openAICompatibilityAPIKeyWithAuthIndex `json:"api-key-entries,omitempty"` + Models []config.OpenAICompatibilityModel `json:"models,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + AuthIndex string `json:"auth-index,omitempty"` +} + +func (h *Handler) buildConfigAuthIndexViews() configAuthIndexViews { + cfg := h.cfg + if cfg == nil { + return configAuthIndexViews{} + } + + liveIndexByID := map[string]string{} + if h != nil && h.authManager != nil { + for _, auth := range h.authManager.List() { + if auth == nil || strings.TrimSpace(auth.ID) == "" { + continue + } + auth.EnsureIndex() + if auth.Index == "" { + continue + } + liveIndexByID[auth.ID] = auth.Index + } + } + + views := configAuthIndexViews{ + gemini: make([]string, len(cfg.GeminiKey)), + claude: make([]string, len(cfg.ClaudeKey)), + codex: make([]string, len(cfg.CodexKey)), + vertex: make([]string, len(cfg.VertexCompatAPIKey)), + openAIEntries: make([][]string, len(cfg.OpenAICompatibility)), + openAIFallback: make([]string, len(cfg.OpenAICompatibility)), + } + + auths, errSynthesize := synthesizer.NewConfigSynthesizer().Synthesize(&synthesizer.SynthesisContext{ + Config: cfg, + Now: time.Now(), + IDGenerator: synthesizer.NewStableIDGenerator(), + }) + if errSynthesize != nil { + return views + } + + cursor := 0 + nextAuthIndex := func() string { + if cursor >= len(auths) { + return "" + } + auth := auths[cursor] + cursor++ + if auth == nil || strings.TrimSpace(auth.ID) == "" { + return "" + } + // Do not expose an auth-index until it is present in the live auth manager. + // API tools resolve auth_index against h.authManager.List(), so returning + // config-only indexes can temporarily break tool calls around config edits. + return liveIndexByID[auth.ID] + } + + for i := range cfg.GeminiKey { + if strings.TrimSpace(cfg.GeminiKey[i].APIKey) == "" { + continue + } + views.gemini[i] = nextAuthIndex() + } + for i := range cfg.ClaudeKey { + if strings.TrimSpace(cfg.ClaudeKey[i].APIKey) == "" { + continue + } + views.claude[i] = nextAuthIndex() + } + for i := range cfg.CodexKey { + if strings.TrimSpace(cfg.CodexKey[i].APIKey) == "" { + continue + } + views.codex[i] = nextAuthIndex() + } + for i := range cfg.OpenAICompatibility { + entries := cfg.OpenAICompatibility[i].APIKeyEntries + if len(entries) == 0 { + views.openAIFallback[i] = nextAuthIndex() + continue + } + + views.openAIEntries[i] = make([]string, len(entries)) + for j := range entries { + views.openAIEntries[i][j] = nextAuthIndex() + } + } + for i := range cfg.VertexCompatAPIKey { + if strings.TrimSpace(cfg.VertexCompatAPIKey[i].APIKey) == "" { + continue + } + views.vertex[i] = nextAuthIndex() + } + + return views +} + +func (h *Handler) geminiKeysWithAuthIndex() []geminiKeyWithAuthIndex { + if h == nil || h.cfg == nil { + return nil + } + views := h.buildConfigAuthIndexViews() + out := make([]geminiKeyWithAuthIndex, len(h.cfg.GeminiKey)) + for i := range h.cfg.GeminiKey { + out[i] = geminiKeyWithAuthIndex{ + GeminiKey: h.cfg.GeminiKey[i], + AuthIndex: views.gemini[i], + } + } + return out +} + +func (h *Handler) claudeKeysWithAuthIndex() []claudeKeyWithAuthIndex { + if h == nil || h.cfg == nil { + return nil + } + views := h.buildConfigAuthIndexViews() + out := make([]claudeKeyWithAuthIndex, len(h.cfg.ClaudeKey)) + for i := range h.cfg.ClaudeKey { + out[i] = claudeKeyWithAuthIndex{ + ClaudeKey: h.cfg.ClaudeKey[i], + AuthIndex: views.claude[i], + } + } + return out +} + +func (h *Handler) codexKeysWithAuthIndex() []codexKeyWithAuthIndex { + if h == nil || h.cfg == nil { + return nil + } + views := h.buildConfigAuthIndexViews() + out := make([]codexKeyWithAuthIndex, len(h.cfg.CodexKey)) + for i := range h.cfg.CodexKey { + out[i] = codexKeyWithAuthIndex{ + CodexKey: h.cfg.CodexKey[i], + AuthIndex: views.codex[i], + } + } + return out +} + +func (h *Handler) vertexCompatKeysWithAuthIndex() []vertexCompatKeyWithAuthIndex { + if h == nil || h.cfg == nil { + return nil + } + views := h.buildConfigAuthIndexViews() + out := make([]vertexCompatKeyWithAuthIndex, len(h.cfg.VertexCompatAPIKey)) + for i := range h.cfg.VertexCompatAPIKey { + out[i] = vertexCompatKeyWithAuthIndex{ + VertexCompatKey: h.cfg.VertexCompatAPIKey[i], + AuthIndex: views.vertex[i], + } + } + return out +} + +func (h *Handler) openAICompatibilityWithAuthIndex() []openAICompatibilityWithAuthIndex { + if h == nil || h.cfg == nil { + return nil + } + + views := h.buildConfigAuthIndexViews() + normalized := normalizedOpenAICompatibilityEntries(h.cfg.OpenAICompatibility) + out := make([]openAICompatibilityWithAuthIndex, len(normalized)) + for i := range normalized { + entry := normalized[i] + response := openAICompatibilityWithAuthIndex{ + Name: entry.Name, + Priority: entry.Priority, + Prefix: entry.Prefix, + BaseURL: entry.BaseURL, + Models: entry.Models, + Headers: entry.Headers, + AuthIndex: views.openAIFallback[i], + } + if len(entry.APIKeyEntries) > 0 { + response.APIKeyEntries = make([]openAICompatibilityAPIKeyWithAuthIndex, len(entry.APIKeyEntries)) + for j := range entry.APIKeyEntries { + authIndex := "" + if i < len(views.openAIEntries) && j < len(views.openAIEntries[i]) { + authIndex = views.openAIEntries[i][j] + } + response.APIKeyEntries[j] = openAICompatibilityAPIKeyWithAuthIndex{ + OpenAICompatibilityAPIKey: entry.APIKeyEntries[j], + AuthIndex: authIndex, + } + } + } + out[i] = response + } + return out +} diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index fbaad956e0..8d3841335a 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -120,7 +120,7 @@ func (h *Handler) DeleteAPIKeys(c *gin.Context) { // gemini-api-key: []GeminiKey func (h *Handler) GetGeminiKeys(c *gin.Context) { - c.JSON(200, gin.H{"gemini-api-key": h.cfg.GeminiKey}) + c.JSON(200, gin.H{"gemini-api-key": h.geminiKeysWithAuthIndex()}) } func (h *Handler) PutGeminiKeys(c *gin.Context) { data, err := c.GetRawData() @@ -270,7 +270,7 @@ func (h *Handler) DeleteGeminiKey(c *gin.Context) { // claude-api-key: []ClaudeKey func (h *Handler) GetClaudeKeys(c *gin.Context) { - c.JSON(200, gin.H{"claude-api-key": h.cfg.ClaudeKey}) + c.JSON(200, gin.H{"claude-api-key": h.claudeKeysWithAuthIndex()}) } func (h *Handler) PutClaudeKeys(c *gin.Context) { data, err := c.GetRawData() @@ -414,7 +414,7 @@ func (h *Handler) DeleteClaudeKey(c *gin.Context) { // openai-compatibility: []OpenAICompatibility func (h *Handler) GetOpenAICompat(c *gin.Context) { - c.JSON(200, gin.H{"openai-compatibility": normalizedOpenAICompatibilityEntries(h.cfg.OpenAICompatibility)}) + c.JSON(200, gin.H{"openai-compatibility": h.openAICompatibilityWithAuthIndex()}) } func (h *Handler) PutOpenAICompat(c *gin.Context) { data, err := c.GetRawData() @@ -540,7 +540,7 @@ func (h *Handler) DeleteOpenAICompat(c *gin.Context) { // vertex-api-key: []VertexCompatKey func (h *Handler) GetVertexCompatKeys(c *gin.Context) { - c.JSON(200, gin.H{"vertex-api-key": h.cfg.VertexCompatAPIKey}) + c.JSON(200, gin.H{"vertex-api-key": h.vertexCompatKeysWithAuthIndex()}) } func (h *Handler) PutVertexCompatKeys(c *gin.Context) { data, err := c.GetRawData() @@ -886,7 +886,7 @@ func (h *Handler) DeleteOAuthModelAlias(c *gin.Context) { // codex-api-key: []CodexKey func (h *Handler) GetCodexKeys(c *gin.Context) { - c.JSON(200, gin.H{"codex-api-key": h.cfg.CodexKey}) + c.JSON(200, gin.H{"codex-api-key": h.codexKeysWithAuthIndex()}) } func (h *Handler) PutCodexKeys(c *gin.Context) { data, err := c.GetRawData() From c26936e2e61778ed6be40282c8577428c96d8aa4 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sat, 18 Apr 2026 17:12:14 +0800 Subject: [PATCH 013/139] fix(management): stabilize auth-index mapping --- .../handlers/management/config_auth_index.go | 232 ++++++++-------- .../management/config_auth_index_test.go | 250 ++++++++++++++++++ .../api/handlers/management/config_lists.go | 93 +++++-- internal/api/handlers/management/handler.go | 24 +- 4 files changed, 450 insertions(+), 149 deletions(-) create mode 100644 internal/api/handlers/management/config_auth_index_test.go diff --git a/internal/api/handlers/management/config_auth_index.go b/internal/api/handlers/management/config_auth_index.go index 51f71aacf9..ed0b3ec42d 100644 --- a/internal/api/handlers/management/config_auth_index.go +++ b/internal/api/handlers/management/config_auth_index.go @@ -1,22 +1,13 @@ package management import ( + "fmt" "strings" - "time" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/synthesizer" ) -type configAuthIndexViews struct { - gemini []string - claude []string - codex []string - vertex []string - openAIEntries [][]string - openAIFallback []string -} - type geminiKeyWithAuthIndex struct { config.GeminiKey AuthIndex string `json:"auth-index,omitempty"` @@ -53,170 +44,174 @@ type openAICompatibilityWithAuthIndex struct { AuthIndex string `json:"auth-index,omitempty"` } -func (h *Handler) buildConfigAuthIndexViews() configAuthIndexViews { - cfg := h.cfg - if cfg == nil { - return configAuthIndexViews{} - } - - liveIndexByID := map[string]string{} - if h != nil && h.authManager != nil { - for _, auth := range h.authManager.List() { - if auth == nil || strings.TrimSpace(auth.ID) == "" { - continue - } - auth.EnsureIndex() - if auth.Index == "" { - continue - } - liveIndexByID[auth.ID] = auth.Index - } - } - - views := configAuthIndexViews{ - gemini: make([]string, len(cfg.GeminiKey)), - claude: make([]string, len(cfg.ClaudeKey)), - codex: make([]string, len(cfg.CodexKey)), - vertex: make([]string, len(cfg.VertexCompatAPIKey)), - openAIEntries: make([][]string, len(cfg.OpenAICompatibility)), - openAIFallback: make([]string, len(cfg.OpenAICompatibility)), - } - - auths, errSynthesize := synthesizer.NewConfigSynthesizer().Synthesize(&synthesizer.SynthesisContext{ - Config: cfg, - Now: time.Now(), - IDGenerator: synthesizer.NewStableIDGenerator(), - }) - if errSynthesize != nil { - return views - } - - cursor := 0 - nextAuthIndex := func() string { - if cursor >= len(auths) { - return "" - } - auth := auths[cursor] - cursor++ - if auth == nil || strings.TrimSpace(auth.ID) == "" { - return "" - } - // Do not expose an auth-index until it is present in the live auth manager. - // API tools resolve auth_index against h.authManager.List(), so returning - // config-only indexes can temporarily break tool calls around config edits. - return liveIndexByID[auth.ID] +func (h *Handler) liveAuthIndexByID() map[string]string { + out := map[string]string{} + if h == nil { + return out } - - for i := range cfg.GeminiKey { - if strings.TrimSpace(cfg.GeminiKey[i].APIKey) == "" { - continue - } - views.gemini[i] = nextAuthIndex() + h.mu.Lock() + manager := h.authManager + h.mu.Unlock() + if manager == nil { + return out } - for i := range cfg.ClaudeKey { - if strings.TrimSpace(cfg.ClaudeKey[i].APIKey) == "" { + // authManager.List() returns clones, so EnsureIndex only affects these copies. + for _, auth := range manager.List() { + if auth == nil { continue } - views.claude[i] = nextAuthIndex() - } - for i := range cfg.CodexKey { - if strings.TrimSpace(cfg.CodexKey[i].APIKey) == "" { + id := strings.TrimSpace(auth.ID) + if id == "" { continue } - views.codex[i] = nextAuthIndex() - } - for i := range cfg.OpenAICompatibility { - entries := cfg.OpenAICompatibility[i].APIKeyEntries - if len(entries) == 0 { - views.openAIFallback[i] = nextAuthIndex() - continue - } - - views.openAIEntries[i] = make([]string, len(entries)) - for j := range entries { - views.openAIEntries[i][j] = nextAuthIndex() + idx := strings.TrimSpace(auth.Index) + if idx == "" { + idx = auth.EnsureIndex() } - } - for i := range cfg.VertexCompatAPIKey { - if strings.TrimSpace(cfg.VertexCompatAPIKey[i].APIKey) == "" { + if idx == "" { continue } - views.vertex[i] = nextAuthIndex() + out[id] = idx } - - return views + return out } func (h *Handler) geminiKeysWithAuthIndex() []geminiKeyWithAuthIndex { - if h == nil || h.cfg == nil { + if h == nil { return nil } - views := h.buildConfigAuthIndexViews() + liveIndexByID := h.liveAuthIndexByID() + + h.mu.Lock() + defer h.mu.Unlock() + if h.cfg == nil { + return nil + } + + idGen := synthesizer.NewStableIDGenerator() out := make([]geminiKeyWithAuthIndex, len(h.cfg.GeminiKey)) for i := range h.cfg.GeminiKey { + entry := h.cfg.GeminiKey[i] + authIndex := "" + if key := strings.TrimSpace(entry.APIKey); key != "" { + id, _ := idGen.Next("gemini:apikey", key, entry.BaseURL) + authIndex = liveIndexByID[id] + } out[i] = geminiKeyWithAuthIndex{ - GeminiKey: h.cfg.GeminiKey[i], - AuthIndex: views.gemini[i], + GeminiKey: entry, + AuthIndex: authIndex, } } return out } func (h *Handler) claudeKeysWithAuthIndex() []claudeKeyWithAuthIndex { - if h == nil || h.cfg == nil { + if h == nil { return nil } - views := h.buildConfigAuthIndexViews() + liveIndexByID := h.liveAuthIndexByID() + + h.mu.Lock() + defer h.mu.Unlock() + if h.cfg == nil { + return nil + } + + idGen := synthesizer.NewStableIDGenerator() out := make([]claudeKeyWithAuthIndex, len(h.cfg.ClaudeKey)) for i := range h.cfg.ClaudeKey { + entry := h.cfg.ClaudeKey[i] + authIndex := "" + if key := strings.TrimSpace(entry.APIKey); key != "" { + id, _ := idGen.Next("claude:apikey", key, entry.BaseURL) + authIndex = liveIndexByID[id] + } out[i] = claudeKeyWithAuthIndex{ - ClaudeKey: h.cfg.ClaudeKey[i], - AuthIndex: views.claude[i], + ClaudeKey: entry, + AuthIndex: authIndex, } } return out } func (h *Handler) codexKeysWithAuthIndex() []codexKeyWithAuthIndex { - if h == nil || h.cfg == nil { + if h == nil { + return nil + } + liveIndexByID := h.liveAuthIndexByID() + + h.mu.Lock() + defer h.mu.Unlock() + if h.cfg == nil { return nil } - views := h.buildConfigAuthIndexViews() + + idGen := synthesizer.NewStableIDGenerator() out := make([]codexKeyWithAuthIndex, len(h.cfg.CodexKey)) for i := range h.cfg.CodexKey { + entry := h.cfg.CodexKey[i] + authIndex := "" + if key := strings.TrimSpace(entry.APIKey); key != "" { + id, _ := idGen.Next("codex:apikey", key, entry.BaseURL) + authIndex = liveIndexByID[id] + } out[i] = codexKeyWithAuthIndex{ - CodexKey: h.cfg.CodexKey[i], - AuthIndex: views.codex[i], + CodexKey: entry, + AuthIndex: authIndex, } } return out } func (h *Handler) vertexCompatKeysWithAuthIndex() []vertexCompatKeyWithAuthIndex { - if h == nil || h.cfg == nil { + if h == nil { + return nil + } + liveIndexByID := h.liveAuthIndexByID() + + h.mu.Lock() + defer h.mu.Unlock() + if h.cfg == nil { return nil } - views := h.buildConfigAuthIndexViews() + + idGen := synthesizer.NewStableIDGenerator() out := make([]vertexCompatKeyWithAuthIndex, len(h.cfg.VertexCompatAPIKey)) for i := range h.cfg.VertexCompatAPIKey { + entry := h.cfg.VertexCompatAPIKey[i] + id, _ := idGen.Next("vertex:apikey", entry.APIKey, entry.BaseURL, entry.ProxyURL) + authIndex := liveIndexByID[id] out[i] = vertexCompatKeyWithAuthIndex{ - VertexCompatKey: h.cfg.VertexCompatAPIKey[i], - AuthIndex: views.vertex[i], + VertexCompatKey: entry, + AuthIndex: authIndex, } } return out } func (h *Handler) openAICompatibilityWithAuthIndex() []openAICompatibilityWithAuthIndex { - if h == nil || h.cfg == nil { + if h == nil { + return nil + } + liveIndexByID := h.liveAuthIndexByID() + + h.mu.Lock() + defer h.mu.Unlock() + if h.cfg == nil { return nil } - views := h.buildConfigAuthIndexViews() normalized := normalizedOpenAICompatibilityEntries(h.cfg.OpenAICompatibility) out := make([]openAICompatibilityWithAuthIndex, len(normalized)) + idGen := synthesizer.NewStableIDGenerator() for i := range normalized { entry := normalized[i] + providerName := strings.ToLower(strings.TrimSpace(entry.Name)) + if providerName == "" { + providerName = "openai-compatibility" + } + idKind := fmt.Sprintf("openai-compatibility:%s", providerName) + response := openAICompatibilityWithAuthIndex{ Name: entry.Name, Priority: entry.Priority, @@ -224,18 +219,19 @@ func (h *Handler) openAICompatibilityWithAuthIndex() []openAICompatibilityWithAu BaseURL: entry.BaseURL, Models: entry.Models, Headers: entry.Headers, - AuthIndex: views.openAIFallback[i], + AuthIndex: "", } - if len(entry.APIKeyEntries) > 0 { + if len(entry.APIKeyEntries) == 0 { + id, _ := idGen.Next(idKind, entry.BaseURL) + response.AuthIndex = liveIndexByID[id] + } else { response.APIKeyEntries = make([]openAICompatibilityAPIKeyWithAuthIndex, len(entry.APIKeyEntries)) for j := range entry.APIKeyEntries { - authIndex := "" - if i < len(views.openAIEntries) && j < len(views.openAIEntries[i]) { - authIndex = views.openAIEntries[i][j] - } + apiKeyEntry := entry.APIKeyEntries[j] + id, _ := idGen.Next(idKind, apiKeyEntry.APIKey, entry.BaseURL, apiKeyEntry.ProxyURL) response.APIKeyEntries[j] = openAICompatibilityAPIKeyWithAuthIndex{ - OpenAICompatibilityAPIKey: entry.APIKeyEntries[j], - AuthIndex: authIndex, + OpenAICompatibilityAPIKey: apiKeyEntry, + AuthIndex: liveIndexByID[id], } } } diff --git a/internal/api/handlers/management/config_auth_index_test.go b/internal/api/handlers/management/config_auth_index_test.go new file mode 100644 index 0000000000..b7c9809011 --- /dev/null +++ b/internal/api/handlers/management/config_auth_index_test.go @@ -0,0 +1,250 @@ +package management + +import ( + "context" + "testing" + "time" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/synthesizer" + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" +) + +func synthesizeConfigAuths(t *testing.T, cfg *config.Config) []*coreauth.Auth { + t.Helper() + + auths, errSynthesize := synthesizer.NewConfigSynthesizer().Synthesize(&synthesizer.SynthesisContext{ + Config: cfg, + Now: time.Unix(0, 0), + IDGenerator: synthesizer.NewStableIDGenerator(), + }) + if errSynthesize != nil { + t.Fatalf("synthesize config auths: %v", errSynthesize) + } + return auths +} + +func findAuth(t *testing.T, auths []*coreauth.Auth, predicate func(*coreauth.Auth) bool) *coreauth.Auth { + t.Helper() + for _, auth := range auths { + if predicate(auth) { + return auth + } + } + return nil +} + +func TestConfigAuthIndexResolvesLiveIndexes(t *testing.T) { + t.Parallel() + + cfg := &config.Config{ + GeminiKey: []config.GeminiKey{ + {APIKey: "shared-key", BaseURL: "https://a.example.com"}, + {APIKey: "shared-key", BaseURL: "https://b.example.com"}, + }, + ClaudeKey: []config.ClaudeKey{ + {APIKey: "claude-key", BaseURL: "https://claude.example.com"}, + }, + CodexKey: []config.CodexKey{ + {APIKey: "codex-key", BaseURL: "https://codex.example.com/v1"}, + }, + VertexCompatAPIKey: []config.VertexCompatKey{ + {APIKey: "vertex-key", BaseURL: "https://vertex.example.com", ProxyURL: "http://proxy.example.com:8080"}, + }, + OpenAICompatibility: []config.OpenAICompatibility{ + { + Name: "bohe", + BaseURL: "https://bohe.example.com/v1", + APIKeyEntries: []config.OpenAICompatibilityAPIKey{ + {APIKey: "compat-key"}, + }, + }, + }, + } + + auths := synthesizeConfigAuths(t, cfg) + manager := coreauth.NewManager(nil, nil, nil) + for _, auth := range auths { + if auth == nil { + continue + } + if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil { + t.Fatalf("register auth %q: %v", auth.ID, errRegister) + } + } + + h := &Handler{cfg: cfg, authManager: manager} + + geminiAuthA := findAuth(t, auths, func(auth *coreauth.Auth) bool { + if auth == nil { + return false + } + return auth.Provider == "gemini" && auth.Attributes["api_key"] == "shared-key" && auth.Attributes["base_url"] == "https://a.example.com" + }) + if geminiAuthA == nil { + t.Fatal("expected synthesized gemini auth (base a)") + } + geminiAuthB := findAuth(t, auths, func(auth *coreauth.Auth) bool { + if auth == nil { + return false + } + return auth.Provider == "gemini" && auth.Attributes["api_key"] == "shared-key" && auth.Attributes["base_url"] == "https://b.example.com" + }) + if geminiAuthB == nil { + t.Fatal("expected synthesized gemini auth (base b)") + } + + gemini := h.geminiKeysWithAuthIndex() + if len(gemini) != 2 { + t.Fatalf("gemini keys = %d, want 2", len(gemini)) + } + if got, want := gemini[0].AuthIndex, geminiAuthA.EnsureIndex(); got != want { + t.Fatalf("gemini[0] auth-index = %q, want %q", got, want) + } + if got, want := gemini[1].AuthIndex, geminiAuthB.EnsureIndex(); got != want { + t.Fatalf("gemini[1] auth-index = %q, want %q", got, want) + } + if gemini[0].AuthIndex == gemini[1].AuthIndex { + t.Fatalf("duplicate gemini entries returned the same auth-index %q", gemini[0].AuthIndex) + } + + claudeAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool { + if auth == nil { + return false + } + return auth.Provider == "claude" && auth.Attributes["api_key"] == "claude-key" + }) + if claudeAuth == nil { + t.Fatal("expected synthesized claude auth") + } + + claude := h.claudeKeysWithAuthIndex() + if len(claude) != 1 { + t.Fatalf("claude keys = %d, want 1", len(claude)) + } + if got, want := claude[0].AuthIndex, claudeAuth.EnsureIndex(); got != want { + t.Fatalf("claude auth-index = %q, want %q", got, want) + } + + codexAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool { + if auth == nil { + return false + } + return auth.Provider == "codex" && auth.Attributes["api_key"] == "codex-key" + }) + if codexAuth == nil { + t.Fatal("expected synthesized codex auth") + } + + codex := h.codexKeysWithAuthIndex() + if len(codex) != 1 { + t.Fatalf("codex keys = %d, want 1", len(codex)) + } + if got, want := codex[0].AuthIndex, codexAuth.EnsureIndex(); got != want { + t.Fatalf("codex auth-index = %q, want %q", got, want) + } + + vertexAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool { + if auth == nil { + return false + } + return auth.Provider == "vertex" && auth.Attributes["api_key"] == "vertex-key" + }) + if vertexAuth == nil { + t.Fatal("expected synthesized vertex auth") + } + + vertex := h.vertexCompatKeysWithAuthIndex() + if len(vertex) != 1 { + t.Fatalf("vertex keys = %d, want 1", len(vertex)) + } + if got, want := vertex[0].AuthIndex, vertexAuth.EnsureIndex(); got != want { + t.Fatalf("vertex auth-index = %q, want %q", got, want) + } + + compatAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool { + if auth == nil { + return false + } + if auth.Provider != "bohe" { + return false + } + if auth.Attributes["provider_key"] != "bohe" || auth.Attributes["compat_name"] != "bohe" { + return false + } + return auth.Attributes["api_key"] == "compat-key" + }) + if compatAuth == nil { + t.Fatal("expected synthesized openai-compat auth") + } + + compat := h.openAICompatibilityWithAuthIndex() + if len(compat) != 1 { + t.Fatalf("openai-compat providers = %d, want 1", len(compat)) + } + if len(compat[0].APIKeyEntries) != 1 { + t.Fatalf("openai-compat api-key-entries = %d, want 1", len(compat[0].APIKeyEntries)) + } + if compat[0].AuthIndex != "" { + t.Fatalf("provider-level auth-index should be empty when api-key-entries exist, got %q", compat[0].AuthIndex) + } + if got, want := compat[0].APIKeyEntries[0].AuthIndex, compatAuth.EnsureIndex(); got != want { + t.Fatalf("openai-compat auth-index = %q, want %q", got, want) + } +} + +func TestConfigAuthIndexOmitsIndexesNotInManager(t *testing.T) { + t.Parallel() + + cfg := &config.Config{ + GeminiKey: []config.GeminiKey{ + {APIKey: "gemini-key", BaseURL: "https://a.example.com"}, + }, + OpenAICompatibility: []config.OpenAICompatibility{ + { + Name: "bohe", + BaseURL: "https://bohe.example.com/v1", + APIKeyEntries: []config.OpenAICompatibilityAPIKey{ + {APIKey: "compat-key"}, + }, + }, + }, + } + + auths := synthesizeConfigAuths(t, cfg) + geminiAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool { + if auth == nil { + return false + } + return auth.Provider == "gemini" && auth.Attributes["api_key"] == "gemini-key" + }) + if geminiAuth == nil { + t.Fatal("expected synthesized gemini auth") + } + + manager := coreauth.NewManager(nil, nil, nil) + if _, errRegister := manager.Register(context.Background(), geminiAuth); errRegister != nil { + t.Fatalf("register gemini auth: %v", errRegister) + } + + h := &Handler{cfg: cfg, authManager: manager} + + gemini := h.geminiKeysWithAuthIndex() + if len(gemini) != 1 { + t.Fatalf("gemini keys = %d, want 1", len(gemini)) + } + if gemini[0].AuthIndex == "" { + t.Fatal("expected gemini auth-index to be set") + } + + compat := h.openAICompatibilityWithAuthIndex() + if len(compat) != 1 { + t.Fatalf("openai-compat providers = %d, want 1", len(compat)) + } + if len(compat[0].APIKeyEntries) != 1 { + t.Fatalf("openai-compat api-key-entries = %d, want 1", len(compat[0].APIKeyEntries)) + } + if compat[0].APIKeyEntries[0].AuthIndex != "" { + t.Fatalf("openai-compat auth-index = %q, want empty", compat[0].APIKeyEntries[0].AuthIndex) + } +} diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index 8d3841335a..ee3a4714b8 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -139,9 +139,11 @@ func (h *Handler) PutGeminiKeys(c *gin.Context) { } arr = obj.Items } + h.mu.Lock() + defer h.mu.Unlock() h.cfg.GeminiKey = append([]config.GeminiKey(nil), arr...) h.cfg.SanitizeGeminiKeys() - h.persist(c) + h.persistLocked(c) } func (h *Handler) PatchGeminiKey(c *gin.Context) { type geminiKeyPatch struct { @@ -161,6 +163,9 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) { c.JSON(400, gin.H{"error": "invalid body"}) return } + + h.mu.Lock() + defer h.mu.Unlock() targetIndex := -1 if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.GeminiKey) { targetIndex = *body.Index @@ -187,7 +192,7 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) { if trimmed == "" { h.cfg.GeminiKey = append(h.cfg.GeminiKey[:targetIndex], h.cfg.GeminiKey[targetIndex+1:]...) h.cfg.SanitizeGeminiKeys() - h.persist(c) + h.persistLocked(c) return } entry.APIKey = trimmed @@ -209,10 +214,12 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) { } h.cfg.GeminiKey[targetIndex] = entry h.cfg.SanitizeGeminiKeys() - h.persist(c) + h.persistLocked(c) } func (h *Handler) DeleteGeminiKey(c *gin.Context) { + h.mu.Lock() + defer h.mu.Unlock() if val := strings.TrimSpace(c.Query("api-key")); val != "" { if baseRaw, okBase := c.GetQuery("base-url"); okBase { base := strings.TrimSpace(baseRaw) @@ -226,7 +233,7 @@ func (h *Handler) DeleteGeminiKey(c *gin.Context) { if len(out) != len(h.cfg.GeminiKey) { h.cfg.GeminiKey = out h.cfg.SanitizeGeminiKeys() - h.persist(c) + h.persistLocked(c) } else { c.JSON(404, gin.H{"error": "item not found"}) } @@ -253,7 +260,7 @@ func (h *Handler) DeleteGeminiKey(c *gin.Context) { } h.cfg.GeminiKey = append(h.cfg.GeminiKey[:matchIndex], h.cfg.GeminiKey[matchIndex+1:]...) h.cfg.SanitizeGeminiKeys() - h.persist(c) + h.persistLocked(c) return } if idxStr := c.Query("index"); idxStr != "" { @@ -261,7 +268,7 @@ func (h *Handler) DeleteGeminiKey(c *gin.Context) { if _, err := fmt.Sscanf(idxStr, "%d", &idx); err == nil && idx >= 0 && idx < len(h.cfg.GeminiKey) { h.cfg.GeminiKey = append(h.cfg.GeminiKey[:idx], h.cfg.GeminiKey[idx+1:]...) h.cfg.SanitizeGeminiKeys() - h.persist(c) + h.persistLocked(c) return } } @@ -292,9 +299,11 @@ func (h *Handler) PutClaudeKeys(c *gin.Context) { for i := range arr { normalizeClaudeKey(&arr[i]) } + h.mu.Lock() + defer h.mu.Unlock() h.cfg.ClaudeKey = arr h.cfg.SanitizeClaudeKeys() - h.persist(c) + h.persistLocked(c) } func (h *Handler) PatchClaudeKey(c *gin.Context) { type claudeKeyPatch struct { @@ -315,6 +324,9 @@ func (h *Handler) PatchClaudeKey(c *gin.Context) { c.JSON(400, gin.H{"error": "invalid body"}) return } + + h.mu.Lock() + defer h.mu.Unlock() targetIndex := -1 if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.ClaudeKey) { targetIndex = *body.Index @@ -358,10 +370,12 @@ func (h *Handler) PatchClaudeKey(c *gin.Context) { normalizeClaudeKey(&entry) h.cfg.ClaudeKey[targetIndex] = entry h.cfg.SanitizeClaudeKeys() - h.persist(c) + h.persistLocked(c) } func (h *Handler) DeleteClaudeKey(c *gin.Context) { + h.mu.Lock() + defer h.mu.Unlock() if val := strings.TrimSpace(c.Query("api-key")); val != "" { if baseRaw, okBase := c.GetQuery("base-url"); okBase { base := strings.TrimSpace(baseRaw) @@ -374,7 +388,7 @@ func (h *Handler) DeleteClaudeKey(c *gin.Context) { } h.cfg.ClaudeKey = out h.cfg.SanitizeClaudeKeys() - h.persist(c) + h.persistLocked(c) return } @@ -396,7 +410,7 @@ func (h *Handler) DeleteClaudeKey(c *gin.Context) { h.cfg.ClaudeKey = append(h.cfg.ClaudeKey[:matchIndex], h.cfg.ClaudeKey[matchIndex+1:]...) } h.cfg.SanitizeClaudeKeys() - h.persist(c) + h.persistLocked(c) return } if idxStr := c.Query("index"); idxStr != "" { @@ -405,7 +419,7 @@ func (h *Handler) DeleteClaudeKey(c *gin.Context) { if err == nil && idx >= 0 && idx < len(h.cfg.ClaudeKey) { h.cfg.ClaudeKey = append(h.cfg.ClaudeKey[:idx], h.cfg.ClaudeKey[idx+1:]...) h.cfg.SanitizeClaudeKeys() - h.persist(c) + h.persistLocked(c) return } } @@ -440,9 +454,11 @@ func (h *Handler) PutOpenAICompat(c *gin.Context) { filtered = append(filtered, arr[i]) } } + h.mu.Lock() + defer h.mu.Unlock() h.cfg.OpenAICompatibility = filtered h.cfg.SanitizeOpenAICompatibility() - h.persist(c) + h.persistLocked(c) } func (h *Handler) PatchOpenAICompat(c *gin.Context) { type openAICompatPatch struct { @@ -462,6 +478,9 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) { c.JSON(400, gin.H{"error": "invalid body"}) return } + + h.mu.Lock() + defer h.mu.Unlock() targetIndex := -1 if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) { targetIndex = *body.Index @@ -492,7 +511,7 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) { if trimmed == "" { h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:targetIndex], h.cfg.OpenAICompatibility[targetIndex+1:]...) h.cfg.SanitizeOpenAICompatibility() - h.persist(c) + h.persistLocked(c) return } entry.BaseURL = trimmed @@ -509,10 +528,12 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) { normalizeOpenAICompatibilityEntry(&entry) h.cfg.OpenAICompatibility[targetIndex] = entry h.cfg.SanitizeOpenAICompatibility() - h.persist(c) + h.persistLocked(c) } func (h *Handler) DeleteOpenAICompat(c *gin.Context) { + h.mu.Lock() + defer h.mu.Unlock() if name := c.Query("name"); name != "" { out := make([]config.OpenAICompatibility, 0, len(h.cfg.OpenAICompatibility)) for _, v := range h.cfg.OpenAICompatibility { @@ -522,7 +543,7 @@ func (h *Handler) DeleteOpenAICompat(c *gin.Context) { } h.cfg.OpenAICompatibility = out h.cfg.SanitizeOpenAICompatibility() - h.persist(c) + h.persistLocked(c) return } if idxStr := c.Query("index"); idxStr != "" { @@ -531,7 +552,7 @@ func (h *Handler) DeleteOpenAICompat(c *gin.Context) { if err == nil && idx >= 0 && idx < len(h.cfg.OpenAICompatibility) { h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:idx], h.cfg.OpenAICompatibility[idx+1:]...) h.cfg.SanitizeOpenAICompatibility() - h.persist(c) + h.persistLocked(c) return } } @@ -566,9 +587,11 @@ func (h *Handler) PutVertexCompatKeys(c *gin.Context) { return } } + h.mu.Lock() + defer h.mu.Unlock() h.cfg.VertexCompatAPIKey = append([]config.VertexCompatKey(nil), arr...) h.cfg.SanitizeVertexCompatKeys() - h.persist(c) + h.persistLocked(c) } func (h *Handler) PatchVertexCompatKey(c *gin.Context) { type vertexCompatPatch struct { @@ -589,6 +612,9 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) { c.JSON(400, gin.H{"error": "invalid body"}) return } + + h.mu.Lock() + defer h.mu.Unlock() targetIndex := -1 if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.VertexCompatAPIKey) { targetIndex = *body.Index @@ -615,7 +641,7 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) { if trimmed == "" { h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:targetIndex], h.cfg.VertexCompatAPIKey[targetIndex+1:]...) h.cfg.SanitizeVertexCompatKeys() - h.persist(c) + h.persistLocked(c) return } entry.APIKey = trimmed @@ -628,7 +654,7 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) { if trimmed == "" { h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:targetIndex], h.cfg.VertexCompatAPIKey[targetIndex+1:]...) h.cfg.SanitizeVertexCompatKeys() - h.persist(c) + h.persistLocked(c) return } entry.BaseURL = trimmed @@ -648,10 +674,12 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) { normalizeVertexCompatKey(&entry) h.cfg.VertexCompatAPIKey[targetIndex] = entry h.cfg.SanitizeVertexCompatKeys() - h.persist(c) + h.persistLocked(c) } func (h *Handler) DeleteVertexCompatKey(c *gin.Context) { + h.mu.Lock() + defer h.mu.Unlock() if val := strings.TrimSpace(c.Query("api-key")); val != "" { if baseRaw, okBase := c.GetQuery("base-url"); okBase { base := strings.TrimSpace(baseRaw) @@ -664,7 +692,7 @@ func (h *Handler) DeleteVertexCompatKey(c *gin.Context) { } h.cfg.VertexCompatAPIKey = out h.cfg.SanitizeVertexCompatKeys() - h.persist(c) + h.persistLocked(c) return } @@ -686,7 +714,7 @@ func (h *Handler) DeleteVertexCompatKey(c *gin.Context) { h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:matchIndex], h.cfg.VertexCompatAPIKey[matchIndex+1:]...) } h.cfg.SanitizeVertexCompatKeys() - h.persist(c) + h.persistLocked(c) return } if idxStr := c.Query("index"); idxStr != "" { @@ -695,7 +723,7 @@ func (h *Handler) DeleteVertexCompatKey(c *gin.Context) { if errScan == nil && idx >= 0 && idx < len(h.cfg.VertexCompatAPIKey) { h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:idx], h.cfg.VertexCompatAPIKey[idx+1:]...) h.cfg.SanitizeVertexCompatKeys() - h.persist(c) + h.persistLocked(c) return } } @@ -915,9 +943,11 @@ func (h *Handler) PutCodexKeys(c *gin.Context) { } filtered = append(filtered, entry) } + h.mu.Lock() + defer h.mu.Unlock() h.cfg.CodexKey = filtered h.cfg.SanitizeCodexKeys() - h.persist(c) + h.persistLocked(c) } func (h *Handler) PatchCodexKey(c *gin.Context) { type codexKeyPatch struct { @@ -938,6 +968,9 @@ func (h *Handler) PatchCodexKey(c *gin.Context) { c.JSON(400, gin.H{"error": "invalid body"}) return } + + h.mu.Lock() + defer h.mu.Unlock() targetIndex := -1 if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) { targetIndex = *body.Index @@ -968,7 +1001,7 @@ func (h *Handler) PatchCodexKey(c *gin.Context) { if trimmed == "" { h.cfg.CodexKey = append(h.cfg.CodexKey[:targetIndex], h.cfg.CodexKey[targetIndex+1:]...) h.cfg.SanitizeCodexKeys() - h.persist(c) + h.persistLocked(c) return } entry.BaseURL = trimmed @@ -988,10 +1021,12 @@ func (h *Handler) PatchCodexKey(c *gin.Context) { normalizeCodexKey(&entry) h.cfg.CodexKey[targetIndex] = entry h.cfg.SanitizeCodexKeys() - h.persist(c) + h.persistLocked(c) } func (h *Handler) DeleteCodexKey(c *gin.Context) { + h.mu.Lock() + defer h.mu.Unlock() if val := strings.TrimSpace(c.Query("api-key")); val != "" { if baseRaw, okBase := c.GetQuery("base-url"); okBase { base := strings.TrimSpace(baseRaw) @@ -1004,7 +1039,7 @@ func (h *Handler) DeleteCodexKey(c *gin.Context) { } h.cfg.CodexKey = out h.cfg.SanitizeCodexKeys() - h.persist(c) + h.persistLocked(c) return } @@ -1026,7 +1061,7 @@ func (h *Handler) DeleteCodexKey(c *gin.Context) { h.cfg.CodexKey = append(h.cfg.CodexKey[:matchIndex], h.cfg.CodexKey[matchIndex+1:]...) } h.cfg.SanitizeCodexKeys() - h.persist(c) + h.persistLocked(c) return } if idxStr := c.Query("index"); idxStr != "" { @@ -1035,7 +1070,7 @@ func (h *Handler) DeleteCodexKey(c *gin.Context) { if err == nil && idx >= 0 && idx < len(h.cfg.CodexKey) { h.cfg.CodexKey = append(h.cfg.CodexKey[:idx], h.cfg.CodexKey[idx+1:]...) h.cfg.SanitizeCodexKeys() - h.persist(c) + h.persistLocked(c) return } } diff --git a/internal/api/handlers/management/handler.go b/internal/api/handlers/management/handler.go index 45786b9d3e..30cc973817 100644 --- a/internal/api/handlers/management/handler.go +++ b/internal/api/handlers/management/handler.go @@ -105,10 +105,24 @@ func NewHandlerWithoutConfigFilePath(cfg *config.Config, manager *coreauth.Manag } // SetConfig updates the in-memory config reference when the server hot-reloads. -func (h *Handler) SetConfig(cfg *config.Config) { h.cfg = cfg } +func (h *Handler) SetConfig(cfg *config.Config) { + if h == nil { + return + } + h.mu.Lock() + h.cfg = cfg + h.mu.Unlock() +} // SetAuthManager updates the auth manager reference used by management endpoints. -func (h *Handler) SetAuthManager(manager *coreauth.Manager) { h.authManager = manager } +func (h *Handler) SetAuthManager(manager *coreauth.Manager) { + if h == nil { + return + } + h.mu.Lock() + h.authManager = manager + h.mu.Unlock() +} // SetUsageStatistics allows replacing the usage statistics reference. func (h *Handler) SetUsageStatistics(stats *usage.RequestStatistics) { h.usageStats = stats } @@ -276,6 +290,12 @@ func (h *Handler) Middleware() gin.HandlerFunc { func (h *Handler) persist(c *gin.Context) bool { h.mu.Lock() defer h.mu.Unlock() + return h.persistLocked(c) +} + +// persistLocked saves the current in-memory config to disk. +// It expects the caller to hold h.mu. +func (h *Handler) persistLocked(c *gin.Context) bool { // Preserve comments when writing if err := config.SaveConfigPreserveComments(h.configFilePath, h.cfg); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save config: %v", err)}) From a64141a9a6a7628db3b994eed9762aaf6f770727 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sat, 18 Apr 2026 17:22:16 +0800 Subject: [PATCH 014/139] fix(tests): remove obsolete config_auth_index_test file --- .../management/config_auth_index_test.go | 250 ------------------ 1 file changed, 250 deletions(-) delete mode 100644 internal/api/handlers/management/config_auth_index_test.go diff --git a/internal/api/handlers/management/config_auth_index_test.go b/internal/api/handlers/management/config_auth_index_test.go deleted file mode 100644 index b7c9809011..0000000000 --- a/internal/api/handlers/management/config_auth_index_test.go +++ /dev/null @@ -1,250 +0,0 @@ -package management - -import ( - "context" - "testing" - "time" - - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/synthesizer" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" -) - -func synthesizeConfigAuths(t *testing.T, cfg *config.Config) []*coreauth.Auth { - t.Helper() - - auths, errSynthesize := synthesizer.NewConfigSynthesizer().Synthesize(&synthesizer.SynthesisContext{ - Config: cfg, - Now: time.Unix(0, 0), - IDGenerator: synthesizer.NewStableIDGenerator(), - }) - if errSynthesize != nil { - t.Fatalf("synthesize config auths: %v", errSynthesize) - } - return auths -} - -func findAuth(t *testing.T, auths []*coreauth.Auth, predicate func(*coreauth.Auth) bool) *coreauth.Auth { - t.Helper() - for _, auth := range auths { - if predicate(auth) { - return auth - } - } - return nil -} - -func TestConfigAuthIndexResolvesLiveIndexes(t *testing.T) { - t.Parallel() - - cfg := &config.Config{ - GeminiKey: []config.GeminiKey{ - {APIKey: "shared-key", BaseURL: "https://a.example.com"}, - {APIKey: "shared-key", BaseURL: "https://b.example.com"}, - }, - ClaudeKey: []config.ClaudeKey{ - {APIKey: "claude-key", BaseURL: "https://claude.example.com"}, - }, - CodexKey: []config.CodexKey{ - {APIKey: "codex-key", BaseURL: "https://codex.example.com/v1"}, - }, - VertexCompatAPIKey: []config.VertexCompatKey{ - {APIKey: "vertex-key", BaseURL: "https://vertex.example.com", ProxyURL: "http://proxy.example.com:8080"}, - }, - OpenAICompatibility: []config.OpenAICompatibility{ - { - Name: "bohe", - BaseURL: "https://bohe.example.com/v1", - APIKeyEntries: []config.OpenAICompatibilityAPIKey{ - {APIKey: "compat-key"}, - }, - }, - }, - } - - auths := synthesizeConfigAuths(t, cfg) - manager := coreauth.NewManager(nil, nil, nil) - for _, auth := range auths { - if auth == nil { - continue - } - if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil { - t.Fatalf("register auth %q: %v", auth.ID, errRegister) - } - } - - h := &Handler{cfg: cfg, authManager: manager} - - geminiAuthA := findAuth(t, auths, func(auth *coreauth.Auth) bool { - if auth == nil { - return false - } - return auth.Provider == "gemini" && auth.Attributes["api_key"] == "shared-key" && auth.Attributes["base_url"] == "https://a.example.com" - }) - if geminiAuthA == nil { - t.Fatal("expected synthesized gemini auth (base a)") - } - geminiAuthB := findAuth(t, auths, func(auth *coreauth.Auth) bool { - if auth == nil { - return false - } - return auth.Provider == "gemini" && auth.Attributes["api_key"] == "shared-key" && auth.Attributes["base_url"] == "https://b.example.com" - }) - if geminiAuthB == nil { - t.Fatal("expected synthesized gemini auth (base b)") - } - - gemini := h.geminiKeysWithAuthIndex() - if len(gemini) != 2 { - t.Fatalf("gemini keys = %d, want 2", len(gemini)) - } - if got, want := gemini[0].AuthIndex, geminiAuthA.EnsureIndex(); got != want { - t.Fatalf("gemini[0] auth-index = %q, want %q", got, want) - } - if got, want := gemini[1].AuthIndex, geminiAuthB.EnsureIndex(); got != want { - t.Fatalf("gemini[1] auth-index = %q, want %q", got, want) - } - if gemini[0].AuthIndex == gemini[1].AuthIndex { - t.Fatalf("duplicate gemini entries returned the same auth-index %q", gemini[0].AuthIndex) - } - - claudeAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool { - if auth == nil { - return false - } - return auth.Provider == "claude" && auth.Attributes["api_key"] == "claude-key" - }) - if claudeAuth == nil { - t.Fatal("expected synthesized claude auth") - } - - claude := h.claudeKeysWithAuthIndex() - if len(claude) != 1 { - t.Fatalf("claude keys = %d, want 1", len(claude)) - } - if got, want := claude[0].AuthIndex, claudeAuth.EnsureIndex(); got != want { - t.Fatalf("claude auth-index = %q, want %q", got, want) - } - - codexAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool { - if auth == nil { - return false - } - return auth.Provider == "codex" && auth.Attributes["api_key"] == "codex-key" - }) - if codexAuth == nil { - t.Fatal("expected synthesized codex auth") - } - - codex := h.codexKeysWithAuthIndex() - if len(codex) != 1 { - t.Fatalf("codex keys = %d, want 1", len(codex)) - } - if got, want := codex[0].AuthIndex, codexAuth.EnsureIndex(); got != want { - t.Fatalf("codex auth-index = %q, want %q", got, want) - } - - vertexAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool { - if auth == nil { - return false - } - return auth.Provider == "vertex" && auth.Attributes["api_key"] == "vertex-key" - }) - if vertexAuth == nil { - t.Fatal("expected synthesized vertex auth") - } - - vertex := h.vertexCompatKeysWithAuthIndex() - if len(vertex) != 1 { - t.Fatalf("vertex keys = %d, want 1", len(vertex)) - } - if got, want := vertex[0].AuthIndex, vertexAuth.EnsureIndex(); got != want { - t.Fatalf("vertex auth-index = %q, want %q", got, want) - } - - compatAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool { - if auth == nil { - return false - } - if auth.Provider != "bohe" { - return false - } - if auth.Attributes["provider_key"] != "bohe" || auth.Attributes["compat_name"] != "bohe" { - return false - } - return auth.Attributes["api_key"] == "compat-key" - }) - if compatAuth == nil { - t.Fatal("expected synthesized openai-compat auth") - } - - compat := h.openAICompatibilityWithAuthIndex() - if len(compat) != 1 { - t.Fatalf("openai-compat providers = %d, want 1", len(compat)) - } - if len(compat[0].APIKeyEntries) != 1 { - t.Fatalf("openai-compat api-key-entries = %d, want 1", len(compat[0].APIKeyEntries)) - } - if compat[0].AuthIndex != "" { - t.Fatalf("provider-level auth-index should be empty when api-key-entries exist, got %q", compat[0].AuthIndex) - } - if got, want := compat[0].APIKeyEntries[0].AuthIndex, compatAuth.EnsureIndex(); got != want { - t.Fatalf("openai-compat auth-index = %q, want %q", got, want) - } -} - -func TestConfigAuthIndexOmitsIndexesNotInManager(t *testing.T) { - t.Parallel() - - cfg := &config.Config{ - GeminiKey: []config.GeminiKey{ - {APIKey: "gemini-key", BaseURL: "https://a.example.com"}, - }, - OpenAICompatibility: []config.OpenAICompatibility{ - { - Name: "bohe", - BaseURL: "https://bohe.example.com/v1", - APIKeyEntries: []config.OpenAICompatibilityAPIKey{ - {APIKey: "compat-key"}, - }, - }, - }, - } - - auths := synthesizeConfigAuths(t, cfg) - geminiAuth := findAuth(t, auths, func(auth *coreauth.Auth) bool { - if auth == nil { - return false - } - return auth.Provider == "gemini" && auth.Attributes["api_key"] == "gemini-key" - }) - if geminiAuth == nil { - t.Fatal("expected synthesized gemini auth") - } - - manager := coreauth.NewManager(nil, nil, nil) - if _, errRegister := manager.Register(context.Background(), geminiAuth); errRegister != nil { - t.Fatalf("register gemini auth: %v", errRegister) - } - - h := &Handler{cfg: cfg, authManager: manager} - - gemini := h.geminiKeysWithAuthIndex() - if len(gemini) != 1 { - t.Fatalf("gemini keys = %d, want 1", len(gemini)) - } - if gemini[0].AuthIndex == "" { - t.Fatal("expected gemini auth-index to be set") - } - - compat := h.openAICompatibilityWithAuthIndex() - if len(compat) != 1 { - t.Fatalf("openai-compat providers = %d, want 1", len(compat)) - } - if len(compat[0].APIKeyEntries) != 1 { - t.Fatalf("openai-compat api-key-entries = %d, want 1", len(compat[0].APIKeyEntries)) - } - if compat[0].APIKeyEntries[0].AuthIndex != "" { - t.Fatalf("openai-compat auth-index = %q, want empty", compat[0].APIKeyEntries[0].AuthIndex) - } -} From 86c856f56f43bba2d3016a21c3d619f38fba196a Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 19 Apr 2026 03:21:59 +0800 Subject: [PATCH 015/139] feat(translator): add partial and full image generation support in Codex-GPT and Codex-Gemini flows - Introduced `LastImageHashByItemID` in Codex-GPT and `LastImageHashByID` in Codex-Gemini for deduplication of generated images. - Added support for handling `partial_image` and `image_generation_call` types, with inline data embedding for Gemini and URL payload conversion for GPT. - Extended unit tests to verify image handling in both streaming and non-streaming modes. --- .../codex/gemini/codex_gemini_response.go | 92 +++++++++++++ .../gemini/codex_gemini_response_test.go | 76 +++++++++++ .../chat-completions/codex_openai_response.go | 125 +++++++++++++++++- .../codex_openai_response_test.go | 59 +++++++++ 4 files changed, 351 insertions(+), 1 deletion(-) diff --git a/internal/translator/codex/gemini/codex_gemini_response.go b/internal/translator/codex/gemini/codex_gemini_response.go index f6ef87710a..a2e4e20ea2 100644 --- a/internal/translator/codex/gemini/codex_gemini_response.go +++ b/internal/translator/codex/gemini/codex_gemini_response.go @@ -7,6 +7,8 @@ package gemini import ( "bytes" "context" + "crypto/sha256" + "strings" "time" translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common" @@ -25,6 +27,7 @@ type ConvertCodexResponseToGeminiParams struct { ResponseID string LastStorageOutput []byte HasOutputTextDelta bool + LastImageHashByID map[string][32]byte } // ConvertCodexResponseToGemini converts Codex streaming response format to Gemini format. @@ -48,6 +51,7 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR ResponseID: "", LastStorageOutput: nil, HasOutputTextDelta: false, + LastImageHashByID: make(map[string][32]byte), } } @@ -74,10 +78,63 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR template, _ = sjson.SetBytes(template, "responseId", params.ResponseID) } + if typeStr == "response.image_generation_call.partial_image" { + itemID := rootResult.Get("item_id").String() + b64 := rootResult.Get("partial_image_b64").String() + if b64 == "" { + return [][]byte{} + } + if itemID != "" { + if params.LastImageHashByID == nil { + params.LastImageHashByID = make(map[string][32]byte) + } + hash := sha256.Sum256([]byte(b64)) + if last, ok := params.LastImageHashByID[itemID]; ok && last == hash { + return [][]byte{} + } + params.LastImageHashByID[itemID] = hash + } + + outputFormat := rootResult.Get("output_format").String() + mimeType := mimeTypeFromCodexOutputFormat(outputFormat) + + part := []byte(`{"inlineData":{"data":"","mimeType":""}}`) + part, _ = sjson.SetBytes(part, "inlineData.data", b64) + part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType) + template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part) + return [][]byte{template} + } + // Handle function call completion if typeStr == "response.output_item.done" { itemResult := rootResult.Get("item") itemType := itemResult.Get("type").String() + if itemType == "image_generation_call" { + itemID := itemResult.Get("id").String() + b64 := itemResult.Get("result").String() + if b64 == "" { + return [][]byte{} + } + if itemID != "" { + if params.LastImageHashByID == nil { + params.LastImageHashByID = make(map[string][32]byte) + } + hash := sha256.Sum256([]byte(b64)) + if last, ok := params.LastImageHashByID[itemID]; ok && last == hash { + return [][]byte{} + } + params.LastImageHashByID[itemID] = hash + } + + outputFormat := itemResult.Get("output_format").String() + mimeType := mimeTypeFromCodexOutputFormat(outputFormat) + + part := []byte(`{"inlineData":{"data":"","mimeType":""}}`) + part, _ = sjson.SetBytes(part, "inlineData.data", b64) + part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType) + template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part) + return [][]byte{template} + } if itemType == "function_call" { // Create function call part functionCall := []byte(`{"functionCall":{"name":"","args":{}}}`) @@ -270,6 +327,20 @@ func ConvertCodexResponseToGeminiNonStream(_ context.Context, modelName string, }) } + case "image_generation_call": + flushPendingFunctionCalls() + b64 := value.Get("result").String() + if b64 == "" { + break + } + outputFormat := value.Get("output_format").String() + mimeType := mimeTypeFromCodexOutputFormat(outputFormat) + + part := []byte(`{"inlineData":{"data":"","mimeType":""}}`) + part, _ = sjson.SetBytes(part, "inlineData.data", b64) + part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType) + template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part) + case "function_call": // Collect function call for potential merging with consecutive ones hasToolCall = true @@ -342,3 +413,24 @@ func buildReverseMapFromGeminiOriginal(original []byte) map[string]string { func GeminiTokenCount(ctx context.Context, count int64) []byte { return translatorcommon.GeminiTokenCountJSON(count) } + +func mimeTypeFromCodexOutputFormat(outputFormat string) string { + if outputFormat == "" { + return "image/png" + } + if strings.Contains(outputFormat, "/") { + return outputFormat + } + switch strings.ToLower(outputFormat) { + case "png": + return "image/png" + case "jpg", "jpeg": + return "image/jpeg" + case "webp": + return "image/webp" + case "gif": + return "image/gif" + default: + return "image/png" + } +} diff --git a/internal/translator/codex/gemini/codex_gemini_response_test.go b/internal/translator/codex/gemini/codex_gemini_response_test.go index b8f227beb5..547ee84715 100644 --- a/internal/translator/codex/gemini/codex_gemini_response_test.go +++ b/internal/translator/codex/gemini/codex_gemini_response_test.go @@ -33,3 +33,79 @@ func TestConvertCodexResponseToGemini_StreamEmptyOutputUsesOutputItemDoneMessage t.Fatalf("expected fallback content from response.output_item.done message; outputs=%q", outputs) } } + +func TestConvertCodexResponseToGemini_StreamPartialImageEmitsInlineData(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"tools":[]}`) + var param any + + chunk := []byte(`data: {"type":"response.image_generation_call.partial_image","item_id":"ig_123","output_format":"png","partial_image_b64":"aGVsbG8=","partial_image_index":0}`) + out := ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, chunk, ¶m) + if len(out) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(out)) + } + + got := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.data").String() + if got != "aGVsbG8=" { + t.Fatalf("expected inlineData.data %q, got %q; chunk=%s", "aGVsbG8=", got, string(out[0])) + } + + gotMime := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.mimeType").String() + if gotMime != "image/png" { + t.Fatalf("expected inlineData.mimeType %q, got %q; chunk=%s", "image/png", gotMime, string(out[0])) + } + + out = ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, chunk, ¶m) + if len(out) != 0 { + t.Fatalf("expected duplicate image chunk to be suppressed, got %d", len(out)) + } +} + +func TestConvertCodexResponseToGemini_StreamImageGenerationCallDoneEmitsInlineData(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"tools":[]}`) + var param any + + out := ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, []byte(`data: {"type":"response.image_generation_call.partial_image","item_id":"ig_123","output_format":"png","partial_image_b64":"aGVsbG8=","partial_image_index":0}`), ¶m) + if len(out) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(out)) + } + + out = ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, []byte(`data: {"type":"response.output_item.done","item":{"id":"ig_123","type":"image_generation_call","output_format":"png","result":"aGVsbG8="}}`), ¶m) + if len(out) != 0 { + t.Fatalf("expected output_item.done to be suppressed when identical to last partial image, got %d", len(out)) + } + + out = ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, []byte(`data: {"type":"response.output_item.done","item":{"id":"ig_123","type":"image_generation_call","output_format":"jpeg","result":"Ymll"}}`), ¶m) + if len(out) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(out)) + } + + got := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.data").String() + if got != "Ymll" { + t.Fatalf("expected inlineData.data %q, got %q; chunk=%s", "Ymll", got, string(out[0])) + } + + gotMime := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.mimeType").String() + if gotMime != "image/jpeg" { + t.Fatalf("expected inlineData.mimeType %q, got %q; chunk=%s", "image/jpeg", gotMime, string(out[0])) + } +} + +func TestConvertCodexResponseToGemini_NonStreamImageGenerationCallAddsInlineDataPart(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"tools":[]}`) + + raw := []byte(`{"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"usage":{"input_tokens":1,"output_tokens":1},"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]},{"type":"image_generation_call","output_format":"png","result":"aGVsbG8="}]}}`) + out := ConvertCodexResponseToGeminiNonStream(ctx, "gemini-2.5-pro", originalRequest, nil, raw, nil) + + got := gjson.GetBytes(out, "candidates.0.content.parts.1.inlineData.data").String() + if got != "aGVsbG8=" { + t.Fatalf("expected inlineData.data %q, got %q; chunk=%s", "aGVsbG8=", got, string(out)) + } + + gotMime := gjson.GetBytes(out, "candidates.0.content.parts.1.inlineData.mimeType").String() + if gotMime != "image/png" { + t.Fatalf("expected inlineData.mimeType %q, got %q; chunk=%s", "image/png", gotMime, string(out)) + } +} diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_response.go b/internal/translator/codex/openai/chat-completions/codex_openai_response.go index afae35d48d..75b5b848b3 100644 --- a/internal/translator/codex/openai/chat-completions/codex_openai_response.go +++ b/internal/translator/codex/openai/chat-completions/codex_openai_response.go @@ -8,6 +8,8 @@ package chat_completions import ( "bytes" "context" + "crypto/sha256" + "strings" "time" "github.com/tidwall/gjson" @@ -26,6 +28,7 @@ type ConvertCliToOpenAIParams struct { FunctionCallIndex int HasReceivedArgumentsDelta bool HasToolCallAnnounced bool + LastImageHashByItemID map[string][32]byte } // ConvertCodexResponseToOpenAI translates a single chunk of a streaming response from the @@ -51,6 +54,7 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR FunctionCallIndex: -1, HasReceivedArgumentsDelta: false, HasToolCallAnnounced: false, + LastImageHashByItemID: make(map[string][32]byte), } } @@ -70,6 +74,9 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR (*param).(*ConvertCliToOpenAIParams).ResponseID = rootResult.Get("response.id").String() (*param).(*ConvertCliToOpenAIParams).CreatedAt = rootResult.Get("response.created_at").Int() (*param).(*ConvertCliToOpenAIParams).Model = rootResult.Get("response.model").String() + if (*param).(*ConvertCliToOpenAIParams).LastImageHashByItemID == nil { + (*param).(*ConvertCliToOpenAIParams).LastImageHashByItemID = make(map[string][32]byte) + } return [][]byte{} } @@ -120,6 +127,39 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant") template, _ = sjson.SetBytes(template, "choices.0.delta.content", deltaResult.String()) } + } else if dataType == "response.image_generation_call.partial_image" { + itemID := rootResult.Get("item_id").String() + b64 := rootResult.Get("partial_image_b64").String() + if b64 == "" { + return [][]byte{} + } + if itemID != "" { + p := (*param).(*ConvertCliToOpenAIParams) + if p.LastImageHashByItemID == nil { + p.LastImageHashByItemID = make(map[string][32]byte) + } + hash := sha256.Sum256([]byte(b64)) + if last, ok := p.LastImageHashByItemID[itemID]; ok && last == hash { + return [][]byte{} + } + p.LastImageHashByItemID[itemID] = hash + } + + outputFormat := rootResult.Get("output_format").String() + mimeType := mimeTypeFromCodexOutputFormat(outputFormat) + imageURL := "data:" + mimeType + ";base64," + b64 + + imagesResult := gjson.GetBytes(template, "choices.0.delta.images") + if !imagesResult.Exists() || !imagesResult.IsArray() { + template, _ = sjson.SetRawBytes(template, "choices.0.delta.images", []byte(`[]`)) + } + imageIndex := len(gjson.GetBytes(template, "choices.0.delta.images").Array()) + imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`) + imagePayload, _ = sjson.SetBytes(imagePayload, "index", imageIndex) + imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL) + + template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant") + template, _ = sjson.SetRawBytes(template, "choices.0.delta.images.-1", imagePayload) } else if dataType == "response.completed" { finishReason := "stop" if (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex != -1 { @@ -183,7 +223,46 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR } else if dataType == "response.output_item.done" { itemResult := rootResult.Get("item") - if !itemResult.Exists() || itemResult.Get("type").String() != "function_call" { + if !itemResult.Exists() { + return [][]byte{} + } + itemType := itemResult.Get("type").String() + if itemType == "image_generation_call" { + itemID := itemResult.Get("id").String() + b64 := itemResult.Get("result").String() + if b64 == "" { + return [][]byte{} + } + if itemID != "" { + p := (*param).(*ConvertCliToOpenAIParams) + if p.LastImageHashByItemID == nil { + p.LastImageHashByItemID = make(map[string][32]byte) + } + hash := sha256.Sum256([]byte(b64)) + if last, ok := p.LastImageHashByItemID[itemID]; ok && last == hash { + return [][]byte{} + } + p.LastImageHashByItemID[itemID] = hash + } + + outputFormat := itemResult.Get("output_format").String() + mimeType := mimeTypeFromCodexOutputFormat(outputFormat) + imageURL := "data:" + mimeType + ";base64," + b64 + + imagesResult := gjson.GetBytes(template, "choices.0.delta.images") + if !imagesResult.Exists() || !imagesResult.IsArray() { + template, _ = sjson.SetRawBytes(template, "choices.0.delta.images", []byte(`[]`)) + } + imageIndex := len(gjson.GetBytes(template, "choices.0.delta.images").Array()) + imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`) + imagePayload, _ = sjson.SetBytes(imagePayload, "index", imageIndex) + imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL) + + template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant") + template, _ = sjson.SetRawBytes(template, "choices.0.delta.images.-1", imagePayload) + return [][]byte{template} + } + if itemType != "function_call" { return [][]byte{} } @@ -285,6 +364,7 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original // Process the output array for content and function calls var toolCalls [][]byte + var images [][]byte outputResult := responseResult.Get("output") if outputResult.IsArray() { outputArray := outputResult.Array() @@ -339,6 +419,19 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original } toolCalls = append(toolCalls, functionCallTemplate) + case "image_generation_call": + b64 := outputItem.Get("result").String() + if b64 == "" { + break + } + outputFormat := outputItem.Get("output_format").String() + mimeType := mimeTypeFromCodexOutputFormat(outputFormat) + imageURL := "data:" + mimeType + ";base64," + b64 + + imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`) + imagePayload, _ = sjson.SetBytes(imagePayload, "index", len(images)) + imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL) + images = append(images, imagePayload) } } @@ -361,6 +454,15 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original } template, _ = sjson.SetBytes(template, "choices.0.message.role", "assistant") } + + // Add images if any + if len(images) > 0 { + template, _ = sjson.SetRawBytes(template, "choices.0.message.images", []byte(`[]`)) + for _, image := range images { + template, _ = sjson.SetRawBytes(template, "choices.0.message.images.-1", image) + } + template, _ = sjson.SetBytes(template, "choices.0.message.role", "assistant") + } } // Extract and set the finish reason based on status @@ -409,3 +511,24 @@ func buildReverseMapFromOriginalOpenAI(original []byte) map[string]string { } return rev } + +func mimeTypeFromCodexOutputFormat(outputFormat string) string { + if outputFormat == "" { + return "image/png" + } + if strings.Contains(outputFormat, "/") { + return outputFormat + } + switch strings.ToLower(outputFormat) { + case "png": + return "image/png" + case "jpg", "jpeg": + return "image/jpeg" + case "webp": + return "image/webp" + case "gif": + return "image/gif" + default: + return "image/png" + } +} diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go b/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go index 534884c229..a6bb486fdf 100644 --- a/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go +++ b/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go @@ -90,3 +90,62 @@ func TestConvertCodexResponseToOpenAI_ToolCallArgumentsDeltaOmitsNullContentFiel t.Fatalf("expected tool call arguments delta to exist, got %s", string(out[0])) } } + +func TestConvertCodexResponseToOpenAI_StreamPartialImageEmitsDeltaImages(t *testing.T) { + ctx := context.Background() + var param any + + chunk := []byte(`data: {"type":"response.image_generation_call.partial_image","item_id":"ig_123","output_format":"png","partial_image_b64":"aGVsbG8=","partial_image_index":0}`) + + out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, chunk, ¶m) + if len(out) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(out)) + } + + gotURL := gjson.GetBytes(out[0], "choices.0.delta.images.0.image_url.url").String() + if gotURL != "data:image/png;base64,aGVsbG8=" { + t.Fatalf("expected image url %q, got %q; chunk=%s", "data:image/png;base64,aGVsbG8=", gotURL, string(out[0])) + } + + out = ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, chunk, ¶m) + if len(out) != 0 { + t.Fatalf("expected duplicate image chunk to be suppressed, got %d", len(out)) + } +} + +func TestConvertCodexResponseToOpenAI_StreamImageGenerationCallDoneEmitsDeltaImages(t *testing.T) { + ctx := context.Background() + var param any + + out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, []byte(`data: {"type":"response.image_generation_call.partial_image","item_id":"ig_123","output_format":"png","partial_image_b64":"aGVsbG8=","partial_image_index":0}`), ¶m) + if len(out) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(out)) + } + + out = ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, []byte(`data: {"type":"response.output_item.done","item":{"id":"ig_123","type":"image_generation_call","output_format":"png","result":"aGVsbG8="}}`), ¶m) + if len(out) != 0 { + t.Fatalf("expected output_item.done to be suppressed when identical to last partial image, got %d", len(out)) + } + + out = ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, []byte(`data: {"type":"response.output_item.done","item":{"id":"ig_123","type":"image_generation_call","output_format":"jpeg","result":"Ymll"}}`), ¶m) + if len(out) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(out)) + } + + gotURL := gjson.GetBytes(out[0], "choices.0.delta.images.0.image_url.url").String() + if gotURL != "data:image/jpeg;base64,Ymll" { + t.Fatalf("expected image url %q, got %q; chunk=%s", "data:image/jpeg;base64,Ymll", gotURL, string(out[0])) + } +} + +func TestConvertCodexResponseToOpenAI_NonStreamImageGenerationCallAddsMessageImages(t *testing.T) { + ctx := context.Background() + + raw := []byte(`{"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"model":"gpt-5.4","status":"completed","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]},{"type":"image_generation_call","output_format":"png","result":"aGVsbG8="}]}}`) + out := ConvertCodexResponseToOpenAINonStream(ctx, "gpt-5.4", nil, nil, raw, nil) + + gotURL := gjson.GetBytes(out, "choices.0.message.images.0.image_url.url").String() + if gotURL != "data:image/png;base64,aGVsbG8=" { + t.Fatalf("expected image url %q, got %q; chunk=%s", "data:image/png;base64,aGVsbG8=", gotURL, string(out)) + } +} From f4eb16102b7f5e5a6b83fb7b74d220f4714c82d5 Mon Sep 17 00:00:00 2001 From: octo-patch Date: Sun, 19 Apr 2026 10:38:16 +0800 Subject: [PATCH 016/139] fix(executor): drop obsolete context-1m-2025-08-07 beta header (fixes #2866) Anthropic has moved the 1M-context-window feature to General Availability, so the context-1m-2025-08-07 beta flag is no longer accepted and now causes 400 Bad Request errors when forwarded upstream. Remove the X-CPA-CLAUDE-1M detection and the corresponding injection of the now-invalid beta header. Also drop the unused net/textproto import that was only needed for the header-key lookup. --- internal/runtime/executor/claude_executor.go | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 0311827bae..235db1f3b2 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -11,7 +11,6 @@ import ( "fmt" "io" "net/http" - "net/textproto" "strings" "time" @@ -911,15 +910,8 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, baseBetas += ",interleaved-thinking-2025-05-14" } - hasClaude1MHeader := false - if ginHeaders != nil { - if _, ok := ginHeaders[textproto.CanonicalMIMEHeaderKey("X-CPA-CLAUDE-1M")]; ok { - hasClaude1MHeader = true - } - } - // Merge extra betas from request body and request flags. - if len(extraBetas) > 0 || hasClaude1MHeader { + if len(extraBetas) > 0 { existingSet := make(map[string]bool) for _, b := range strings.Split(baseBetas, ",") { betaName := strings.TrimSpace(b) @@ -934,9 +926,6 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, existingSet[beta] = true } } - if hasClaude1MHeader && !existingSet["context-1m-2025-08-07"] { - baseBetas += ",context-1m-2025-08-07" - } } r.Header.Set("Anthropic-Beta", baseBetas) From 8f4a4eabfc8688e73eb21effe1e077e83494a8b5 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 19 Apr 2026 23:00:09 +0800 Subject: [PATCH 017/139] feat(docs): add VisionCoder sponsorship details and optimize external links - Added VisionCoder sponsorship information to `README.md`, `README_CN.md`, and `README_JA.md`. - Updated external links to include `target="_blank"` for improved user experience. - Added new logo asset `visioncoder.png` for README use. --- README.md | 6 ++++++ README_CN.md | 16 +++++++++++----- README_JA.md | 4 ++++ assets/visioncoder.png | Bin 0 -> 155590 bytes 4 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 assets/visioncoder.png diff --git a/README.md b/README.md index 53acdd5178..77b8667b2f 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,12 @@ Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB PoixeAI Thanks to Poixe AI for sponsoring this project! Poixe AI provides reliable LLM API services. You can leverage the platform's API endpoints to seamlessly build AI-powered products. Additionally, you can become a vendor by providing AI API resources to the platform and earn revenue. Register through the exclusive CLIProxyAPI referral link and receive a bonus of $5 USD on your first top-up. + +VisionCoder +Thanks to VisionCoder for supporting this project. VisionCoder Developer Platform is a reliable and efficient API relay service provider, offering access to mainstream AI models such as Claude Code, Codex, and Gemini. It helps developers and teams integrate AI capabilities more easily and improve productivity. +

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

+VisionCoder 还为我们的用户提供 Token Plan 限时活动:购买 1 个月,赠送 1 个月。 diff --git a/README_JA.md b/README_JA.md index 8c34325b49..cf8a0f77d8 100644 --- a/README_JA.md +++ b/README_JA.md @@ -42,6 +42,10 @@ GLM CODING PLANを10%割引で取得:https://z.ai/subscribe?ic=8JVLJQFSKB PoixeAI Poixe AIのスポンサーシップに感謝します!Poixe AIは信頼できるAIモデルAPIサービスを提供しており、プラットフォームが提供するLLM APIを使って簡単にAI製品を構築できます。また、サプライヤーとしてプラットフォームに大規模モデルのリソースを提供し、収益を得ることも可能です。CLIProxyAPIの専用リンクから登録すると、チャージ時に追加で$5が付与されます。 + +VisionCoder +VisionCoderのご支援に感謝します!VisionCoder 開発プラットフォーム は、信頼性が高く効率的なAPIリレーサービスプロバイダーで、Claude Code、Codex、Geminiなどの主要AIモデルを提供し、開発者やチームがより簡単にAI機能を統合して生産性を向上できるよう支援します。さらに、VisionCoderはユーザー向けに Token Plan の期間限定キャンペーン(1か月購入で1か月分プレゼント)も提供しています。 + diff --git a/assets/visioncoder.png b/assets/visioncoder.png new file mode 100644 index 0000000000000000000000000000000000000000..24b1760ce5afe51d7cedbac1985211c3c4ca78bf GIT binary patch literal 155590 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

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

NS-own{b-@DQN*}Xzo^`N_RT-{imAg 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�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 B6q5h| literal 0 HcmV?d00001 From e6866ff19c26599b0a5df48c0a0c3a3cfd3145dd Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 20 Apr 2026 15:40:43 +0800 Subject: [PATCH 018/139] feat(auth): add refresh backoff for ineffective token updates - Introduced `refreshIneffectiveBackoff` to prevent tight-looping in auto-refresh when token refresh fails to update expiry. - Adjusted refresh logic to apply backoff when `shouldRefresh` evaluates true. Closes: #2830 --- sdk/cliproxy/auth/conductor.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index f58722039c..0a9c157b0a 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -64,8 +64,13 @@ const ( refreshMaxConcurrency = 16 refreshPendingBackoff = time.Minute refreshFailureBackoff = 5 * time.Minute - quotaBackoffBase = time.Second - quotaBackoffMax = 30 * time.Minute + // refreshIneffectiveBackoff throttles refresh attempts when an executor returns + // success but the auth still evaluates as needing refresh (e.g. token expiry + // wasn't updated). Without this guard, the auto-refresh loop can tight-loop and + // burn CPU at idle. + refreshIneffectiveBackoff = 30 * time.Second + quotaBackoffBase = time.Second + quotaBackoffMax = 30 * time.Minute ) var quotaCooldownDisabled atomic.Bool @@ -3240,6 +3245,9 @@ func (m *Manager) refreshAuth(ctx context.Context, id string) { updated.NextRefreshAfter = time.Time{} updated.LastError = nil updated.UpdatedAt = now + if m.shouldRefresh(updated, now) { + updated.NextRefreshAfter = now.Add(refreshIneffectiveBackoff) + } _, _ = m.Update(ctx, updated) } From bb8408cef591cd292ecb41ff4ff805d11a489315 Mon Sep 17 00:00:00 2001 From: stringer07 <1742292793@qq.com> Date: Tue, 21 Apr 2026 16:03:56 +0800 Subject: [PATCH 019/139] fix(codex): backfill streaming response output --- internal/runtime/executor/codex_executor.go | 54 ++++++++++++++++++- .../codex_executor_stream_output_test.go | 51 ++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 41b1c32527..bceeeb6c9d 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -36,6 +36,48 @@ const ( var dataTag = []byte("data:") +// Streamed Codex responses may emit response.output_item.done events while leaving +// response.completed.response.output empty. Keep the stream path aligned with the +// already-patched non-stream path by reconstructing response.output from those items. +func collectCodexOutputItemDone(eventData []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback *[][]byte) { + itemResult := gjson.GetBytes(eventData, "item") + if !itemResult.Exists() || itemResult.Type != gjson.JSON { + return + } + outputIndexResult := gjson.GetBytes(eventData, "output_index") + if outputIndexResult.Exists() { + outputItemsByIndex[outputIndexResult.Int()] = []byte(itemResult.Raw) + return + } + *outputItemsFallback = append(*outputItemsFallback, []byte(itemResult.Raw)) +} + +func patchCodexCompletedOutput(eventData []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback [][]byte) []byte { + outputResult := gjson.GetBytes(eventData, "response.output") + shouldPatchOutput := (!outputResult.Exists() || !outputResult.IsArray() || len(outputResult.Array()) == 0) && (len(outputItemsByIndex) > 0 || len(outputItemsFallback) > 0) + if !shouldPatchOutput { + return eventData + } + + completedDataPatched := eventData + completedDataPatched, _ = sjson.SetRawBytes(completedDataPatched, "response.output", []byte(`[]`)) + + indexes := make([]int64, 0, len(outputItemsByIndex)) + for idx := range outputItemsByIndex { + indexes = append(indexes, idx) + } + sort.Slice(indexes, func(i, j int) bool { + return indexes[i] < indexes[j] + }) + for _, idx := range indexes { + completedDataPatched, _ = sjson.SetRawBytes(completedDataPatched, "response.output.-1", outputItemsByIndex[idx]) + } + for _, item := range outputItemsFallback { + completedDataPatched, _ = sjson.SetRawBytes(completedDataPatched, "response.output.-1", item) + } + return completedDataPatched +} + // CodexExecutor is a stateless executor for Codex (OpenAI Responses API entrypoint). // If api_key is unavailable on auth, it falls back to legacy via ClientAdapter. type CodexExecutor struct { @@ -414,20 +456,28 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au scanner := bufio.NewScanner(httpResp.Body) scanner.Buffer(nil, 52_428_800) // 50MB var param any + outputItemsByIndex := make(map[int64][]byte) + var outputItemsFallback [][]byte for scanner.Scan() { line := scanner.Bytes() helps.AppendAPIResponseChunk(ctx, e.cfg, line) + translatedLine := bytes.Clone(line) if bytes.HasPrefix(line, dataTag) { data := bytes.TrimSpace(line[5:]) - if gjson.GetBytes(data, "type").String() == "response.completed" { + switch gjson.GetBytes(data, "type").String() { + case "response.output_item.done": + collectCodexOutputItemDone(data, outputItemsByIndex, &outputItemsFallback) + case "response.completed": if detail, ok := helps.ParseCodexUsage(data); ok { reporter.Publish(ctx, detail) } + data = patchCodexCompletedOutput(data, outputItemsByIndex, outputItemsFallback) + translatedLine = append([]byte("data: "), data...) } } - chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, body, bytes.Clone(line), ¶m) + chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, body, translatedLine, ¶m) for i := range chunks { out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]} } diff --git a/internal/runtime/executor/codex_executor_stream_output_test.go b/internal/runtime/executor/codex_executor_stream_output_test.go index 91d9b0761c..a2da45e199 100644 --- a/internal/runtime/executor/codex_executor_stream_output_test.go +++ b/internal/runtime/executor/codex_executor_stream_output_test.go @@ -1,6 +1,7 @@ package executor import ( + "bytes" "context" "net/http" "net/http/httptest" @@ -44,3 +45,53 @@ func TestCodexExecutorExecute_EmptyStreamCompletionOutputUsesOutputItemDone(t *t t.Fatalf("choices.0.message.content = %q, want %q; payload=%s", gotContent, "ok", string(resp.Payload)) } } + +func TestCodexExecutorExecuteStream_EmptyStreamCompletionOutputUsesOutputItemDone(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]},\"output_index\":0}\n")) + _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"created_at\":1775555723,\"status\":\"completed\",\"model\":\"gpt-5.4-mini-2026-03-17\",\"output\":[],\"usage\":{\"input_tokens\":8,\"output_tokens\":28,\"total_tokens\":36}}}\n\n")) + })) + defer server.Close() + + executor := NewCodexExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "base_url": server.URL, + "api_key": "test", + }} + + result, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ + Model: "gpt-5.4-mini", + Payload: []byte(`{"model":"gpt-5.4-mini","input":"Say ok"}`), + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai-response"), + Stream: true, + }) + if err != nil { + t.Fatalf("ExecuteStream error: %v", err) + } + + var completed []byte + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatalf("stream chunk error: %v", chunk.Err) + } + payload := bytes.TrimSpace(chunk.Payload) + if !bytes.HasPrefix(payload, []byte("data:")) { + continue + } + data := bytes.TrimSpace(payload[5:]) + if gjson.GetBytes(data, "type").String() == "response.completed" { + completed = append([]byte(nil), data...) + } + } + + if len(completed) == 0 { + t.Fatal("missing response.completed chunk") + } + + gotContent := gjson.GetBytes(completed, "response.output.0.content.0.text").String() + if gotContent != "ok" { + t.Fatalf("response.output[0].content[0].text = %q, want %q; completed=%s", gotContent, "ok", string(completed)) + } +} From b6781d69bedae8693fe156d369b9bf69b98eb7b3 Mon Sep 17 00:00:00 2001 From: stringer07 <1742292793@qq.com> Date: Tue, 21 Apr 2026 16:29:54 +0800 Subject: [PATCH 020/139] perf(codex): avoid repeated output patch writes --- internal/runtime/executor/codex_executor.go | 33 +++++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index bceeeb6c9d..7d4d3edf89 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -59,9 +59,6 @@ func patchCodexCompletedOutput(eventData []byte, outputItemsByIndex map[int64][] return eventData } - completedDataPatched := eventData - completedDataPatched, _ = sjson.SetRawBytes(completedDataPatched, "response.output", []byte(`[]`)) - indexes := make([]int64, 0, len(outputItemsByIndex)) for idx := range outputItemsByIndex { indexes = append(indexes, idx) @@ -69,12 +66,36 @@ func patchCodexCompletedOutput(eventData []byte, outputItemsByIndex map[int64][] sort.Slice(indexes, func(i, j int) bool { return indexes[i] < indexes[j] }) + + items := make([][]byte, 0, len(outputItemsByIndex)+len(outputItemsFallback)) for _, idx := range indexes { - completedDataPatched, _ = sjson.SetRawBytes(completedDataPatched, "response.output.-1", outputItemsByIndex[idx]) + items = append(items, outputItemsByIndex[idx]) } - for _, item := range outputItemsFallback { - completedDataPatched, _ = sjson.SetRawBytes(completedDataPatched, "response.output.-1", item) + items = append(items, outputItemsFallback...) + + outputArray := []byte("[]") + if len(items) > 0 { + var buf bytes.Buffer + totalLen := 2 + for _, item := range items { + totalLen += len(item) + } + if len(items) > 1 { + totalLen += len(items) - 1 + } + buf.Grow(totalLen) + buf.WriteByte('[') + for i, item := range items { + if i > 0 { + buf.WriteByte(',') + } + buf.Write(item) + } + buf.WriteByte(']') + outputArray = buf.Bytes() } + + completedDataPatched, _ := sjson.SetRawBytes(eventData, "response.output", outputArray) return completedDataPatched } From 1716a845eb78a9d2ee39dddc0941d3981bf543ab Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Tue, 21 Apr 2026 20:16:18 +0800 Subject: [PATCH 021/139] feat(api): add support for `HEAD` requests to `/healthz` endpoint - Refactored `/healthz` handler to support `HEAD` requests alongside `GET`. - Updated tests to include validation for `HEAD` requests with expected status and empty body. Closes: #2929 --- internal/api/server.go | 11 +++++++-- internal/api/server_test.go | 45 ++++++++++++++++++++++++------------- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index 075455ba83..9b7452555b 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -319,9 +319,16 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk // setupRoutes configures the API routes for the server. // It defines the endpoints and associates them with their respective handlers. func (s *Server) setupRoutes() { - s.engine.GET("/healthz", func(c *gin.Context) { + healthzHandler := func(c *gin.Context) { + if c.Request.Method == http.MethodHead { + c.Status(http.StatusOK) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) - }) + } + s.engine.GET("/healthz", healthzHandler) + s.engine.HEAD("/healthz", healthzHandler) s.engine.GET("/management.html", s.serveManagementControlPanel) openaiHandlers := openai.NewOpenAIAPIHandler(s.handlers) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index dbc2cd5a83..db1ef27d17 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -50,23 +50,38 @@ func newTestServer(t *testing.T) *Server { func TestHealthz(t *testing.T) { server := newTestServer(t) - req := httptest.NewRequest(http.MethodGet, "/healthz", nil) - rr := httptest.NewRecorder() - server.engine.ServeHTTP(rr, req) + t.Run("GET", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rr := httptest.NewRecorder() + server.engine.ServeHTTP(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("unexpected status code: got %d want %d; body=%s", rr.Code, http.StatusOK, rr.Body.String()) - } + if rr.Code != http.StatusOK { + t.Fatalf("unexpected status code: got %d want %d; body=%s", rr.Code, http.StatusOK, rr.Body.String()) + } - var resp struct { - Status string `json:"status"` - } - if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { - t.Fatalf("failed to parse response JSON: %v; body=%s", err, rr.Body.String()) - } - if resp.Status != "ok" { - t.Fatalf("unexpected response status: got %q want %q", resp.Status, "ok") - } + var resp struct { + Status string `json:"status"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response JSON: %v; body=%s", err, rr.Body.String()) + } + if resp.Status != "ok" { + t.Fatalf("unexpected response status: got %q want %q", resp.Status, "ok") + } + }) + + t.Run("HEAD", func(t *testing.T) { + req := httptest.NewRequest(http.MethodHead, "/healthz", nil) + rr := httptest.NewRecorder() + server.engine.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("unexpected status code: got %d want %d; body=%s", rr.Code, http.StatusOK, rr.Body.String()) + } + if rr.Body.Len() != 0 { + t.Fatalf("expected empty body for HEAD request, got %q", rr.Body.String()) + } + }) } func TestAmpProviderModelRoutes(t *testing.T) { From 4fc2c619fb350a2b7bea371a5bd15f6e41dcc8e7 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Tue, 21 Apr 2026 20:53:03 +0800 Subject: [PATCH 022/139] feat(models): add Kimi K2.6 model entry to registry JSON --- internal/registry/models/models.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json index 65d8325169..24b96ca95f 100644 --- a/internal/registry/models/models.json +++ b/internal/registry/models/models.json @@ -1670,6 +1670,23 @@ "zero_allowed": true, "dynamic_allowed": true } + }, + { + "id": "kimi-k2.6", + "object": "model", + "created": 1776729600, + "owned_by": "moonshot", + "type": "kimi", + "display_name": "Kimi K2.6", + "description": "Kimi K2.6 - Latest Moonshot AI coding model with improved capabilities", + "context_length": 262144, + "max_completion_tokens": 65536, + "thinking": { + "min": 1024, + "max": 32000, + "zero_allowed": true, + "dynamic_allowed": true + } } ], "antigravity": [ From e935196df43cb9af478fea377571873d07c9a39b Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 22 Apr 2026 20:51:13 +0800 Subject: [PATCH 023/139] feat(models): add hardcoded GPT-Image-2 model support in Codex - Added `GPT-Image-2` as a built-in model to avoid dependency on remote updates for Codex. - Updated model tier functions (`CodexFree`, `CodexTeam`, etc.) to include built-in models via `WithCodexBuiltins`. - Introduced new handlers for image generation and edit operations under `OpenAIAPIHandler`. - Extended tests to validate 503 response for unsupported image model requests. --- internal/api/server.go | 2 + internal/registry/model_definitions.go | 75 +- sdk/api/handlers/handlers.go | 7 + .../handlers/handlers_request_details_test.go | 21 + .../handlers/openai/openai_images_handlers.go | 904 ++++++++++++++++++ sdk/cliproxy/service.go | 2 +- 6 files changed, 1006 insertions(+), 5 deletions(-) create mode 100644 sdk/api/handlers/openai/openai_images_handlers.go diff --git a/internal/api/server.go b/internal/api/server.go index 9b7452555b..7c571e23cf 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -344,6 +344,8 @@ func (s *Server) setupRoutes() { v1.GET("/models", s.unifiedModelsHandler(openaiHandlers, claudeCodeHandlers)) v1.POST("/chat/completions", openaiHandlers.ChatCompletions) v1.POST("/completions", openaiHandlers.Completions) + v1.POST("/images/generations", openaiHandlers.ImagesGenerations) + v1.POST("/images/edits", openaiHandlers.ImagesEdits) v1.POST("/messages", claudeCodeHandlers.ClaudeMessages) v1.POST("/messages/count_tokens", claudeCodeHandlers.ClaudeCountTokens) v1.GET("/responses", openaiResponsesHandlers.ResponsesWebsocket) diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index ab7258f845..7ac6b469ac 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -6,6 +6,8 @@ import ( "strings" ) +const codexBuiltinImageModelID = "gpt-image-2" + // staticModelsJSON mirrors the top-level structure of models.json. type staticModelsJSON struct { Claude []*ModelInfo `json:"claude"` @@ -48,22 +50,22 @@ func GetAIStudioModels() []*ModelInfo { // GetCodexFreeModels returns model definitions for the Codex free plan tier. func GetCodexFreeModels() []*ModelInfo { - return cloneModelInfos(getModels().CodexFree) + return WithCodexBuiltins(cloneModelInfos(getModels().CodexFree)) } // GetCodexTeamModels returns model definitions for the Codex team plan tier. func GetCodexTeamModels() []*ModelInfo { - return cloneModelInfos(getModels().CodexTeam) + return WithCodexBuiltins(cloneModelInfos(getModels().CodexTeam)) } // GetCodexPlusModels returns model definitions for the Codex plus plan tier. func GetCodexPlusModels() []*ModelInfo { - return cloneModelInfos(getModels().CodexPlus) + return WithCodexBuiltins(cloneModelInfos(getModels().CodexPlus)) } // GetCodexProModels returns model definitions for the Codex pro plan tier. func GetCodexProModels() []*ModelInfo { - return cloneModelInfos(getModels().CodexPro) + return WithCodexBuiltins(cloneModelInfos(getModels().CodexPro)) } // GetKimiModels returns the standard Kimi (Moonshot AI) model definitions. @@ -76,6 +78,71 @@ func GetAntigravityModels() []*ModelInfo { return cloneModelInfos(getModels().Antigravity) } +// WithCodexBuiltins injects hard-coded Codex-only model definitions that should +// not depend on remote models.json updates. Built-ins replace any matching IDs +// already present in the provided slice. +func WithCodexBuiltins(models []*ModelInfo) []*ModelInfo { + return upsertModelInfos(models, codexBuiltinImageModelInfo()) +} + +func codexBuiltinImageModelInfo() *ModelInfo { + return &ModelInfo{ + ID: codexBuiltinImageModelID, + Object: "model", + Created: 1704067200, // 2024-01-01 + OwnedBy: "openai", + Type: "openai", + DisplayName: "GPT Image 2", + Version: codexBuiltinImageModelID, + } +} + +func upsertModelInfos(models []*ModelInfo, extras ...*ModelInfo) []*ModelInfo { + if len(extras) == 0 { + return models + } + + extraIDs := make(map[string]struct{}, len(extras)) + extraList := make([]*ModelInfo, 0, len(extras)) + for _, extra := range extras { + if extra == nil { + continue + } + id := strings.TrimSpace(extra.ID) + if id == "" { + continue + } + key := strings.ToLower(id) + if _, exists := extraIDs[key]; exists { + continue + } + extraIDs[key] = struct{}{} + extraList = append(extraList, cloneModelInfo(extra)) + } + + if len(extraList) == 0 { + return models + } + + filtered := make([]*ModelInfo, 0, len(models)+len(extraList)) + for _, model := range models { + if model == nil { + continue + } + id := strings.TrimSpace(model.ID) + if id == "" { + continue + } + if _, exists := extraIDs[strings.ToLower(id)]; exists { + continue + } + filtered = append(filtered, model) + } + + filtered = append(filtered, extraList...) + return filtered +} + // cloneModelInfos returns a shallow copy of the slice with each element deep-cloned. func cloneModelInfos(models []*ModelInfo) []*ModelInfo { if len(models) == 0 { diff --git a/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go index 49e73d4637..1fda8f49f0 100644 --- a/sdk/api/handlers/handlers.go +++ b/sdk/api/handlers/handlers.go @@ -795,6 +795,13 @@ func (h *BaseAPIHandler) getRequestDetails(modelName string) (providers []string parsed := thinking.ParseSuffix(resolvedModelName) baseModel := strings.TrimSpace(parsed.ModelName) + if strings.EqualFold(baseModel, "gpt-image-2") { + return nil, "", &interfaces.ErrorMessage{ + StatusCode: http.StatusServiceUnavailable, + Error: fmt.Errorf("model %s is only supported on /v1/images/generations and /v1/images/edits", baseModel), + } + } + providers = util.GetProviderName(baseModel) // Fallback: if baseModel has no provider but differs from resolvedModelName, // try using the full model name. This handles edge cases where custom models diff --git a/sdk/api/handlers/handlers_request_details_test.go b/sdk/api/handlers/handlers_request_details_test.go index b0f6b13262..c98580f224 100644 --- a/sdk/api/handlers/handlers_request_details_test.go +++ b/sdk/api/handlers/handlers_request_details_test.go @@ -1,7 +1,9 @@ package handlers import ( + "net/http" "reflect" + "strings" "testing" "time" @@ -116,3 +118,22 @@ func TestGetRequestDetails_PreservesSuffix(t *testing.T) { }) } } + +func TestGetRequestDetails_ImageModelReturns503(t *testing.T) { + handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, coreauth.NewManager(nil, nil, nil)) + + _, _, errMsg := handler.getRequestDetails("gpt-image-2") + if errMsg == nil { + t.Fatalf("expected error for gpt-image-2, got nil") + } + if errMsg.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("unexpected status code: got %d want %d", errMsg.StatusCode, http.StatusServiceUnavailable) + } + if errMsg.Error == nil { + t.Fatalf("expected error message, got nil") + } + msg := errMsg.Error.Error() + if !strings.Contains(msg, "/v1/images/generations") || !strings.Contains(msg, "/v1/images/edits") { + t.Fatalf("unexpected error message: %q", msg) + } +} diff --git a/sdk/api/handlers/openai/openai_images_handlers.go b/sdk/api/handlers/openai/openai_images_handlers.go new file mode 100644 index 0000000000..586354dedb --- /dev/null +++ b/sdk/api/handlers/openai/openai_images_handlers.go @@ -0,0 +1,904 @@ +package openai + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" + log "github.com/sirupsen/logrus" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const ( + defaultImagesMainModel = "gpt-5.4-mini" + defaultImagesToolModel = "gpt-image-2" +) + +type imageCallResult struct { + Result string + RevisedPrompt string + OutputFormat string + Size string + Background string + Quality string +} + +type sseFrameAccumulator struct { + pending []byte +} + +func (a *sseFrameAccumulator) AddChunk(chunk []byte) [][]byte { + if len(chunk) == 0 { + return nil + } + + if responsesSSENeedsLineBreak(a.pending, chunk) { + a.pending = append(a.pending, '\n') + } + a.pending = append(a.pending, chunk...) + + var frames [][]byte + for { + frameLen := responsesSSEFrameLen(a.pending) + if frameLen == 0 { + break + } + frames = append(frames, a.pending[:frameLen]) + copy(a.pending, a.pending[frameLen:]) + a.pending = a.pending[:len(a.pending)-frameLen] + } + + if len(bytes.TrimSpace(a.pending)) == 0 { + a.pending = a.pending[:0] + return frames + } + if len(a.pending) == 0 || !responsesSSECanEmitWithoutDelimiter(a.pending) { + return frames + } + frames = append(frames, a.pending) + a.pending = a.pending[:0] + return frames +} + +func (a *sseFrameAccumulator) Flush() [][]byte { + if len(a.pending) == 0 { + return nil + } + + var frames [][]byte + for { + frameLen := responsesSSEFrameLen(a.pending) + if frameLen == 0 { + break + } + frames = append(frames, a.pending[:frameLen]) + copy(a.pending, a.pending[frameLen:]) + a.pending = a.pending[:len(a.pending)-frameLen] + } + + if len(bytes.TrimSpace(a.pending)) == 0 { + a.pending = nil + return frames + } + if responsesSSECanEmitWithoutDelimiter(a.pending) { + frames = append(frames, a.pending) + } + a.pending = nil + return frames +} + +func mimeTypeFromOutputFormat(outputFormat string) string { + if outputFormat == "" { + return "image/png" + } + if strings.Contains(outputFormat, "/") { + return outputFormat + } + switch strings.ToLower(strings.TrimSpace(outputFormat)) { + case "png": + return "image/png" + case "jpg", "jpeg": + return "image/jpeg" + case "webp": + return "image/webp" + default: + return "image/png" + } +} + +func multipartFileToDataURL(fileHeader *multipart.FileHeader) (string, error) { + if fileHeader == nil { + return "", fmt.Errorf("upload file is nil") + } + f, err := fileHeader.Open() + if err != nil { + return "", fmt.Errorf("open upload file failed: %w", err) + } + defer func() { + if errClose := f.Close(); errClose != nil { + log.Errorf("openai images: close upload file error: %v", errClose) + } + }() + + data, err := io.ReadAll(f) + if err != nil { + return "", fmt.Errorf("read upload file failed: %w", err) + } + + mediaType := strings.TrimSpace(fileHeader.Header.Get("Content-Type")) + if mediaType == "" { + mediaType = http.DetectContentType(data) + } + + b64 := base64.StdEncoding.EncodeToString(data) + return "data:" + mediaType + ";base64," + b64, nil +} + +func parseIntField(raw string, fallback int64) int64 { + raw = strings.TrimSpace(raw) + if raw == "" { + return fallback + } + v, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return fallback + } + return v +} + +func parseBoolField(raw string, fallback bool) bool { + raw = strings.TrimSpace(strings.ToLower(raw)) + if raw == "" { + return fallback + } + switch raw { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + return fallback + } +} + +func (h *OpenAIAPIHandler) ImagesGenerations(c *gin.Context) { + rawJSON, err := c.GetRawData() + if err != nil { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: fmt.Sprintf("Invalid request: %v", err), + Type: "invalid_request_error", + }, + }) + return + } + if !json.Valid(rawJSON) { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: "Invalid request: body must be valid JSON", + Type: "invalid_request_error", + }, + }) + return + } + + prompt := strings.TrimSpace(gjson.GetBytes(rawJSON, "prompt").String()) + if prompt == "" { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: "Invalid request: prompt is required", + Type: "invalid_request_error", + }, + }) + return + } + + imageModel := strings.TrimSpace(gjson.GetBytes(rawJSON, "model").String()) + if imageModel == "" { + imageModel = defaultImagesToolModel + } + responseFormat := strings.TrimSpace(gjson.GetBytes(rawJSON, "response_format").String()) + if responseFormat == "" { + responseFormat = "b64_json" + } + stream := gjson.GetBytes(rawJSON, "stream").Bool() + + tool := []byte(`{"type":"image_generation","action":"generate"}`) + tool, _ = sjson.SetBytes(tool, "model", imageModel) + + if v := strings.TrimSpace(gjson.GetBytes(rawJSON, "size").String()); v != "" { + tool, _ = sjson.SetBytes(tool, "size", v) + } + if v := strings.TrimSpace(gjson.GetBytes(rawJSON, "quality").String()); v != "" { + tool, _ = sjson.SetBytes(tool, "quality", v) + } + if v := strings.TrimSpace(gjson.GetBytes(rawJSON, "background").String()); v != "" { + tool, _ = sjson.SetBytes(tool, "background", v) + } + if v := strings.TrimSpace(gjson.GetBytes(rawJSON, "output_format").String()); v != "" { + tool, _ = sjson.SetBytes(tool, "output_format", v) + } + if v := gjson.GetBytes(rawJSON, "output_compression"); v.Exists() { + if v.Type == gjson.Number { + tool, _ = sjson.SetBytes(tool, "output_compression", v.Int()) + } + } + if v := gjson.GetBytes(rawJSON, "partial_images"); v.Exists() { + if v.Type == gjson.Number { + tool, _ = sjson.SetBytes(tool, "partial_images", v.Int()) + } + } + if v := gjson.GetBytes(rawJSON, "n"); v.Exists() { + if v.Type == gjson.Number { + tool, _ = sjson.SetBytes(tool, "n", v.Int()) + } + } + if v := strings.TrimSpace(gjson.GetBytes(rawJSON, "moderation").String()); v != "" { + tool, _ = sjson.SetBytes(tool, "moderation", v) + } + + responsesReq := buildImagesResponsesRequest(prompt, nil, tool) + if stream { + h.streamImagesFromResponses(c, responsesReq, responseFormat, "image_generation") + return + } + h.collectImagesFromResponses(c, responsesReq, responseFormat) +} + +func (h *OpenAIAPIHandler) ImagesEdits(c *gin.Context) { + contentType := strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))) + if strings.HasPrefix(contentType, "application/json") { + h.imagesEditsFromJSON(c) + return + } + if strings.HasPrefix(contentType, "multipart/form-data") || contentType == "" { + h.imagesEditsFromMultipart(c) + return + } + + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: fmt.Sprintf("Invalid request: unsupported Content-Type %q", contentType), + Type: "invalid_request_error", + }, + }) +} + +func (h *OpenAIAPIHandler) imagesEditsFromMultipart(c *gin.Context) { + form, err := c.MultipartForm() + if err != nil { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: fmt.Sprintf("Invalid request: %v", err), + Type: "invalid_request_error", + }, + }) + return + } + + prompt := strings.TrimSpace(c.PostForm("prompt")) + if prompt == "" { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: "Invalid request: prompt is required", + Type: "invalid_request_error", + }, + }) + return + } + + var imageFiles []*multipart.FileHeader + if files := form.File["image[]"]; len(files) > 0 { + imageFiles = files + } else if files := form.File["image"]; len(files) > 0 { + imageFiles = files + } + if len(imageFiles) == 0 { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: "Invalid request: image is required", + Type: "invalid_request_error", + }, + }) + return + } + + images := make([]string, 0, len(imageFiles)) + for _, fh := range imageFiles { + dataURL, err := multipartFileToDataURL(fh) + if err != nil { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: fmt.Sprintf("Invalid request: %v", err), + Type: "invalid_request_error", + }, + }) + return + } + images = append(images, dataURL) + } + + var maskDataURL *string + if maskFiles := form.File["mask"]; len(maskFiles) > 0 && maskFiles[0] != nil { + dataURL, err := multipartFileToDataURL(maskFiles[0]) + if err != nil { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: fmt.Sprintf("Invalid request: %v", err), + Type: "invalid_request_error", + }, + }) + return + } + maskDataURL = &dataURL + } + + imageModel := strings.TrimSpace(c.PostForm("model")) + if imageModel == "" { + imageModel = defaultImagesToolModel + } + responseFormat := strings.TrimSpace(c.PostForm("response_format")) + if responseFormat == "" { + responseFormat = "b64_json" + } + stream := parseBoolField(c.PostForm("stream"), false) + + tool := []byte(`{"type":"image_generation","action":"edit"}`) + tool, _ = sjson.SetBytes(tool, "model", imageModel) + + if v := strings.TrimSpace(c.PostForm("size")); v != "" { + tool, _ = sjson.SetBytes(tool, "size", v) + } + if v := strings.TrimSpace(c.PostForm("quality")); v != "" { + tool, _ = sjson.SetBytes(tool, "quality", v) + } + if v := strings.TrimSpace(c.PostForm("background")); v != "" { + tool, _ = sjson.SetBytes(tool, "background", v) + } + if v := strings.TrimSpace(c.PostForm("output_format")); v != "" { + tool, _ = sjson.SetBytes(tool, "output_format", v) + } + if v := strings.TrimSpace(c.PostForm("input_fidelity")); v != "" { + tool, _ = sjson.SetBytes(tool, "input_fidelity", v) + } + if v := strings.TrimSpace(c.PostForm("moderation")); v != "" { + tool, _ = sjson.SetBytes(tool, "moderation", v) + } + + if v := strings.TrimSpace(c.PostForm("output_compression")); v != "" { + tool, _ = sjson.SetBytes(tool, "output_compression", parseIntField(v, 0)) + } + if v := strings.TrimSpace(c.PostForm("partial_images")); v != "" { + tool, _ = sjson.SetBytes(tool, "partial_images", parseIntField(v, 0)) + } + if v := strings.TrimSpace(c.PostForm("n")); v != "" { + tool, _ = sjson.SetBytes(tool, "n", parseIntField(v, 0)) + } + + if maskDataURL != nil && strings.TrimSpace(*maskDataURL) != "" { + tool, _ = sjson.SetBytes(tool, "input_image_mask.image_url", strings.TrimSpace(*maskDataURL)) + } + + responsesReq := buildImagesResponsesRequest(prompt, images, tool) + if stream { + h.streamImagesFromResponses(c, responsesReq, responseFormat, "image_edit") + return + } + h.collectImagesFromResponses(c, responsesReq, responseFormat) +} + +func (h *OpenAIAPIHandler) imagesEditsFromJSON(c *gin.Context) { + rawJSON, err := c.GetRawData() + if err != nil { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: fmt.Sprintf("Invalid request: %v", err), + Type: "invalid_request_error", + }, + }) + return + } + if !json.Valid(rawJSON) { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: "Invalid request: body must be valid JSON", + Type: "invalid_request_error", + }, + }) + return + } + + prompt := strings.TrimSpace(gjson.GetBytes(rawJSON, "prompt").String()) + if prompt == "" { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: "Invalid request: prompt is required", + Type: "invalid_request_error", + }, + }) + return + } + + var images []string + imagesResult := gjson.GetBytes(rawJSON, "images") + if imagesResult.IsArray() { + for _, img := range imagesResult.Array() { + url := strings.TrimSpace(img.Get("image_url").String()) + if url == "" { + continue + } + images = append(images, url) + } + } + if len(images) == 0 { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: "Invalid request: images[].image_url is required (file_id is not supported)", + Type: "invalid_request_error", + }, + }) + return + } + + var maskDataURL *string + if mask := gjson.GetBytes(rawJSON, "mask.image_url"); mask.Exists() { + url := strings.TrimSpace(mask.String()) + if url != "" { + maskDataURL = &url + } + } else if mask := gjson.GetBytes(rawJSON, "mask.file_id"); mask.Exists() { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: "Invalid request: mask.file_id is not supported (use mask.image_url instead)", + Type: "invalid_request_error", + }, + }) + return + } + + imageModel := strings.TrimSpace(gjson.GetBytes(rawJSON, "model").String()) + if imageModel == "" { + imageModel = defaultImagesToolModel + } + responseFormat := strings.TrimSpace(gjson.GetBytes(rawJSON, "response_format").String()) + if responseFormat == "" { + responseFormat = "b64_json" + } + stream := gjson.GetBytes(rawJSON, "stream").Bool() + + tool := []byte(`{"type":"image_generation","action":"edit"}`) + tool, _ = sjson.SetBytes(tool, "model", imageModel) + + for _, field := range []string{"size", "quality", "background", "output_format", "input_fidelity", "moderation"} { + if v := strings.TrimSpace(gjson.GetBytes(rawJSON, field).String()); v != "" { + tool, _ = sjson.SetBytes(tool, field, v) + } + } + + for _, field := range []string{"output_compression", "partial_images", "n"} { + if v := gjson.GetBytes(rawJSON, field); v.Exists() && v.Type == gjson.Number { + tool, _ = sjson.SetBytes(tool, field, v.Int()) + } + } + + if maskDataURL != nil && strings.TrimSpace(*maskDataURL) != "" { + tool, _ = sjson.SetBytes(tool, "input_image_mask.image_url", strings.TrimSpace(*maskDataURL)) + } + + responsesReq := buildImagesResponsesRequest(prompt, images, tool) + if stream { + h.streamImagesFromResponses(c, responsesReq, responseFormat, "image_edit") + return + } + h.collectImagesFromResponses(c, responsesReq, responseFormat) +} + +func buildImagesResponsesRequest(prompt string, images []string, toolJSON []byte) []byte { + req := []byte(`{"instructions":"","stream":true,"reasoning":{"effort":"medium","summary":"auto"},"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"model":"","store":false,"tool_choice":{"type":"image_generation"}}`) + req, _ = sjson.SetBytes(req, "model", defaultImagesMainModel) + + input := []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`) + input, _ = sjson.SetBytes(input, "0.content.0.text", prompt) + contentIndex := 1 + for _, img := range images { + if strings.TrimSpace(img) == "" { + continue + } + part := []byte(`{"type":"input_image","image_url":""}`) + part, _ = sjson.SetBytes(part, "image_url", img) + path := fmt.Sprintf("0.content.%d", contentIndex) + input, _ = sjson.SetRawBytes(input, path, part) + contentIndex++ + } + req, _ = sjson.SetRawBytes(req, "input", input) + + req, _ = sjson.SetRawBytes(req, "tools", []byte(`[]`)) + if len(toolJSON) > 0 && json.Valid(toolJSON) { + req, _ = sjson.SetRawBytes(req, "tools.-1", toolJSON) + } + return req +} + +func (h *OpenAIAPIHandler) collectImagesFromResponses(c *gin.Context, responsesReq []byte, responseFormat string) { + c.Header("Content-Type", "application/json") + + cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background()) + stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx) + + dataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, "openai-response", defaultImagesMainModel, responsesReq, "") + + out, errMsg := collectImagesFromResponsesStream(cliCtx, dataChan, errChan, responseFormat) + stopKeepAlive() + if errMsg != nil { + h.WriteErrorResponse(c, errMsg) + if errMsg.Error != nil { + cliCancel(errMsg.Error) + } else { + cliCancel(nil) + } + return + } + handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders) + _, _ = c.Writer.Write(out) + cliCancel() +} + +func collectImagesFromResponsesStream(ctx context.Context, data <-chan []byte, errs <-chan *interfaces.ErrorMessage, responseFormat string) ([]byte, *interfaces.ErrorMessage) { + acc := &sseFrameAccumulator{} + + processFrame := func(frame []byte) ([]byte, bool, *interfaces.ErrorMessage) { + for _, line := range bytes.Split(frame, []byte("\n")) { + trimmed := bytes.TrimSpace(bytes.TrimRight(line, "\r")) + if len(trimmed) == 0 { + continue + } + if !bytes.HasPrefix(trimmed, []byte("data:")) { + continue + } + payload := bytes.TrimSpace(trimmed[len("data:"):]) + if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) { + continue + } + if !json.Valid(payload) { + return nil, false, &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: fmt.Errorf("invalid SSE data JSON")} + } + + if gjson.GetBytes(payload, "type").String() != "response.completed" { + continue + } + + results, createdAt, usageRaw, firstMeta, err := extractImagesFromResponsesCompleted(payload) + if err != nil { + return nil, false, &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: err} + } + if len(results) == 0 { + return nil, false, &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: fmt.Errorf("upstream did not return image output")} + } + out, err := buildImagesAPIResponse(results, createdAt, usageRaw, firstMeta, responseFormat) + if err != nil { + return nil, false, &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: err} + } + return out, true, nil + } + return nil, false, nil + } + + for { + select { + case <-ctx.Done(): + return nil, &interfaces.ErrorMessage{StatusCode: http.StatusRequestTimeout, Error: ctx.Err()} + case errMsg, ok := <-errs: + if ok && errMsg != nil { + return nil, errMsg + } + errs = nil + case chunk, ok := <-data: + if !ok { + for _, frame := range acc.Flush() { + if out, done, errMsg := processFrame(frame); errMsg != nil { + return nil, errMsg + } else if done { + return out, nil + } + } + return nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: fmt.Errorf("stream disconnected before completion")} + } + for _, frame := range acc.AddChunk(chunk) { + if out, done, errMsg := processFrame(frame); errMsg != nil { + return nil, errMsg + } else if done { + return out, nil + } + } + } + } +} + +func extractImagesFromResponsesCompleted(payload []byte) (results []imageCallResult, createdAt int64, usageRaw []byte, firstMeta imageCallResult, err error) { + if gjson.GetBytes(payload, "type").String() != "response.completed" { + return nil, 0, nil, imageCallResult{}, fmt.Errorf("unexpected event type") + } + + createdAt = gjson.GetBytes(payload, "response.created_at").Int() + if createdAt <= 0 { + createdAt = time.Now().Unix() + } + + output := gjson.GetBytes(payload, "response.output") + if output.IsArray() { + for _, item := range output.Array() { + if item.Get("type").String() != "image_generation_call" { + continue + } + res := strings.TrimSpace(item.Get("result").String()) + if res == "" { + continue + } + entry := imageCallResult{ + Result: res, + RevisedPrompt: strings.TrimSpace(item.Get("revised_prompt").String()), + OutputFormat: strings.TrimSpace(item.Get("output_format").String()), + Size: strings.TrimSpace(item.Get("size").String()), + Background: strings.TrimSpace(item.Get("background").String()), + Quality: strings.TrimSpace(item.Get("quality").String()), + } + if len(results) == 0 { + firstMeta = entry + } + results = append(results, entry) + } + } + + if usage := gjson.GetBytes(payload, "response.tool_usage.image_gen"); usage.Exists() && usage.IsObject() { + usageRaw = []byte(usage.Raw) + } + + return results, createdAt, usageRaw, firstMeta, nil +} + +func buildImagesAPIResponse(results []imageCallResult, createdAt int64, usageRaw []byte, firstMeta imageCallResult, responseFormat string) ([]byte, error) { + out := []byte(`{"created":0,"data":[]}`) + out, _ = sjson.SetBytes(out, "created", createdAt) + + responseFormat = strings.ToLower(strings.TrimSpace(responseFormat)) + if responseFormat == "" { + responseFormat = "b64_json" + } + + for _, img := range results { + item := []byte(`{}`) + if responseFormat == "url" { + mt := mimeTypeFromOutputFormat(img.OutputFormat) + item, _ = sjson.SetBytes(item, "url", "data:"+mt+";base64,"+img.Result) + } else { + item, _ = sjson.SetBytes(item, "b64_json", img.Result) + } + if img.RevisedPrompt != "" { + item, _ = sjson.SetBytes(item, "revised_prompt", img.RevisedPrompt) + } + out, _ = sjson.SetRawBytes(out, "data.-1", item) + } + + if firstMeta.Background != "" { + out, _ = sjson.SetBytes(out, "background", firstMeta.Background) + } + if firstMeta.OutputFormat != "" { + out, _ = sjson.SetBytes(out, "output_format", firstMeta.OutputFormat) + } + if firstMeta.Quality != "" { + out, _ = sjson.SetBytes(out, "quality", firstMeta.Quality) + } + if firstMeta.Size != "" { + out, _ = sjson.SetBytes(out, "size", firstMeta.Size) + } + + if len(usageRaw) > 0 && json.Valid(usageRaw) { + out, _ = sjson.SetRawBytes(out, "usage", usageRaw) + } + + return out, nil +} + +func (h *OpenAIAPIHandler) streamImagesFromResponses(c *gin.Context, responsesReq []byte, responseFormat string, streamPrefix string) { + flusher, ok := c.Writer.(http.Flusher) + if !ok { + c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: "Streaming not supported", + Type: "server_error", + }, + }) + return + } + + cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background()) + dataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, "openai-response", defaultImagesMainModel, responsesReq, "") + + setSSEHeaders := func() { + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("Access-Control-Allow-Origin", "*") + } + + writeEvent := func(eventName string, dataJSON []byte) { + if strings.TrimSpace(eventName) != "" { + _, _ = fmt.Fprintf(c.Writer, "event: %s\n", eventName) + } + _, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", string(dataJSON)) + flusher.Flush() + } + + // Peek for first chunk/error so we can still return a JSON error body. + for { + select { + case <-c.Request.Context().Done(): + cliCancel(c.Request.Context().Err()) + return + case errMsg, ok := <-errChan: + if !ok { + errChan = nil + continue + } + h.WriteErrorResponse(c, errMsg) + if errMsg != nil { + cliCancel(errMsg.Error) + } else { + cliCancel(nil) + } + return + case chunk, ok := <-dataChan: + if !ok { + setSSEHeaders() + handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders) + _, _ = c.Writer.Write([]byte("\n")) + flusher.Flush() + cliCancel(nil) + return + } + + setSSEHeaders() + handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders) + + h.forwardImagesStream(cliCtx, c, flusher, func(err error) { cliCancel(err) }, dataChan, errChan, chunk, responseFormat, streamPrefix, writeEvent) + return + } + } +} + +func (h *OpenAIAPIHandler) forwardImagesStream(ctx context.Context, c *gin.Context, flusher http.Flusher, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage, firstChunk []byte, responseFormat string, streamPrefix string, writeEvent func(string, []byte)) { + acc := &sseFrameAccumulator{} + + responseFormat = strings.ToLower(strings.TrimSpace(responseFormat)) + if responseFormat == "" { + responseFormat = "b64_json" + } + + emitError := func(errMsg *interfaces.ErrorMessage) { + if errMsg == nil { + return + } + status := http.StatusInternalServerError + if errMsg.StatusCode > 0 { + status = errMsg.StatusCode + } + errText := http.StatusText(status) + if errMsg.Error != nil && strings.TrimSpace(errMsg.Error.Error()) != "" { + errText = errMsg.Error.Error() + } + body := handlers.BuildErrorResponseBody(status, errText) + writeEvent("error", body) + } + + processFrame := func(frame []byte) (done bool) { + for _, line := range bytes.Split(frame, []byte("\n")) { + trimmed := bytes.TrimSpace(bytes.TrimRight(line, "\r")) + if len(trimmed) == 0 || !bytes.HasPrefix(trimmed, []byte("data:")) { + continue + } + payload := bytes.TrimSpace(trimmed[len("data:"):]) + if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) || !json.Valid(payload) { + continue + } + + switch gjson.GetBytes(payload, "type").String() { + case "response.image_generation_call.partial_image": + b64 := strings.TrimSpace(gjson.GetBytes(payload, "partial_image_b64").String()) + if b64 == "" { + continue + } + outputFormat := strings.TrimSpace(gjson.GetBytes(payload, "output_format").String()) + index := gjson.GetBytes(payload, "partial_image_index").Int() + eventName := streamPrefix + ".partial_image" + data := []byte(`{"type":"","partial_image_index":0}`) + data, _ = sjson.SetBytes(data, "type", eventName) + data, _ = sjson.SetBytes(data, "partial_image_index", index) + if responseFormat == "url" { + mt := mimeTypeFromOutputFormat(outputFormat) + data, _ = sjson.SetBytes(data, "url", "data:"+mt+";base64,"+b64) + } else { + data, _ = sjson.SetBytes(data, "b64_json", b64) + } + writeEvent(eventName, data) + case "response.completed": + results, _, usageRaw, _, err := extractImagesFromResponsesCompleted(payload) + if err != nil { + emitError(&interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: err}) + return true + } + if len(results) == 0 { + emitError(&interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: fmt.Errorf("upstream did not return image output")}) + return true + } + eventName := streamPrefix + ".completed" + for _, img := range results { + data := []byte(`{"type":""}`) + data, _ = sjson.SetBytes(data, "type", eventName) + if responseFormat == "url" { + mt := mimeTypeFromOutputFormat(img.OutputFormat) + data, _ = sjson.SetBytes(data, "url", "data:"+mt+";base64,"+img.Result) + } else { + data, _ = sjson.SetBytes(data, "b64_json", img.Result) + } + if len(usageRaw) > 0 && json.Valid(usageRaw) { + data, _ = sjson.SetRawBytes(data, "usage", usageRaw) + } + writeEvent(eventName, data) + } + return true + } + } + return false + } + + for _, frame := range acc.AddChunk(firstChunk) { + if processFrame(frame) { + cancel(nil) + return + } + } + + for { + select { + case <-c.Request.Context().Done(): + cancel(c.Request.Context().Err()) + return + case errMsg, ok := <-errs: + if ok && errMsg != nil { + emitError(errMsg) + cancel(errMsg.Error) + return + } + errs = nil + case chunk, ok := <-data: + if !ok { + for _, frame := range acc.Flush() { + if processFrame(frame) { + cancel(nil) + return + } + } + cancel(nil) + return + } + for _, frame := range acc.AddChunk(chunk) { + if processFrame(frame) { + cancel(nil) + return + } + } + } + } +} diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 5e873d370b..fa0d8a0aa7 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -1410,7 +1410,7 @@ func buildCodexConfigModels(entry *config.CodexKey) []*ModelInfo { if entry == nil { return nil } - return buildConfigModels(entry.Models, "openai", "openai") + return registry.WithCodexBuiltins(buildConfigModels(entry.Models, "openai", "openai")) } func rewriteModelInfoName(name, oldID, newID string) string { From fd71960c3eecf8a56a075dfd83eb275bcd93d9b1 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 22 Apr 2026 21:12:50 +0800 Subject: [PATCH 024/139] fix(handlers): remove handling of unsupported `n` parameter in OpenAI image handlers --- sdk/api/handlers/openai/openai_images_handlers.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/sdk/api/handlers/openai/openai_images_handlers.go b/sdk/api/handlers/openai/openai_images_handlers.go index 586354dedb..7c96ae88cb 100644 --- a/sdk/api/handlers/openai/openai_images_handlers.go +++ b/sdk/api/handlers/openai/openai_images_handlers.go @@ -383,9 +383,11 @@ func (h *OpenAIAPIHandler) imagesEditsFromMultipart(c *gin.Context) { if v := strings.TrimSpace(c.PostForm("partial_images")); v != "" { tool, _ = sjson.SetBytes(tool, "partial_images", parseIntField(v, 0)) } - if v := strings.TrimSpace(c.PostForm("n")); v != "" { - tool, _ = sjson.SetBytes(tool, "n", parseIntField(v, 0)) - } + + // Unsupported parameter + // if v := strings.TrimSpace(c.PostForm("n")); v != "" { + // tool, _ = sjson.SetBytes(tool, "n", parseIntField(v, 0)) + // } if maskDataURL != nil && strings.TrimSpace(*maskDataURL) != "" { tool, _ = sjson.SetBytes(tool, "input_image_mask.image_url", strings.TrimSpace(*maskDataURL)) From a188159632429b3400d5dadd2b0322afba60de3c Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 22 Apr 2026 21:28:17 +0800 Subject: [PATCH 025/139] fix(handlers): remove references to unsupported `n` parameter in OpenAI image handlers --- sdk/api/handlers/openai/openai_images_handlers.go | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/sdk/api/handlers/openai/openai_images_handlers.go b/sdk/api/handlers/openai/openai_images_handlers.go index 7c96ae88cb..93d45460d0 100644 --- a/sdk/api/handlers/openai/openai_images_handlers.go +++ b/sdk/api/handlers/openai/openai_images_handlers.go @@ -240,11 +240,6 @@ func (h *OpenAIAPIHandler) ImagesGenerations(c *gin.Context) { tool, _ = sjson.SetBytes(tool, "partial_images", v.Int()) } } - if v := gjson.GetBytes(rawJSON, "n"); v.Exists() { - if v.Type == gjson.Number { - tool, _ = sjson.SetBytes(tool, "n", v.Int()) - } - } if v := strings.TrimSpace(gjson.GetBytes(rawJSON, "moderation").String()); v != "" { tool, _ = sjson.SetBytes(tool, "moderation", v) } @@ -384,11 +379,6 @@ func (h *OpenAIAPIHandler) imagesEditsFromMultipart(c *gin.Context) { tool, _ = sjson.SetBytes(tool, "partial_images", parseIntField(v, 0)) } - // Unsupported parameter - // if v := strings.TrimSpace(c.PostForm("n")); v != "" { - // tool, _ = sjson.SetBytes(tool, "n", parseIntField(v, 0)) - // } - if maskDataURL != nil && strings.TrimSpace(*maskDataURL) != "" { tool, _ = sjson.SetBytes(tool, "input_image_mask.image_url", strings.TrimSpace(*maskDataURL)) } @@ -489,7 +479,7 @@ func (h *OpenAIAPIHandler) imagesEditsFromJSON(c *gin.Context) { } } - for _, field := range []string{"output_compression", "partial_images", "n"} { + for _, field := range []string{"output_compression", "partial_images"} { if v := gjson.GetBytes(rawJSON, field); v.Exists() && v.Type == gjson.Number { tool, _ = sjson.SetBytes(tool, field, v.Int()) } From 31934ae04c04dd6cda7e32c3308a8e7291da3721 Mon Sep 17 00:00:00 2001 From: MoYeRanQianZhi Date: Thu, 23 Apr 2026 01:15:47 +0800 Subject: [PATCH 026/139] feat(codex): enable image generation for all Codex upstream requests Codex CLI gates the built-in image_generation tool behind AuthMode::Chatgpt (OAuth only). When clients connect via API key auth through CPA, the tool is absent from requests, making image generation unavailable through the reverse proxy. Changes: 1. Inject image_generation tool (codex_executor.go): Add ensureImageGenerationTool() that appends {"type":"image_generation","output_format":"png"} to the tools array if not already present. Applied to all three execution paths: Execute, executeCompact, and ExecuteStream. 2. Route aliases for Codex CLI direct access (server.go): Add /backend-api/codex/responses routes that map to the same OpenAI Responses API handlers as /v1/responses. This allows Codex CLI to connect via chatgpt_base_url config while keeping AuthMode::Chatgpt, which enables the built-in image_generation tool on the client side. 3. Unit tests (codex_executor_imagegen_test.go): Cover no-tools, existing tools, already-present, empty array, and mixed built-in tool scenarios. --- internal/api/server.go | 9 ++ internal/runtime/executor/codex_executor.go | 21 +++++ .../executor/codex_executor_imagegen_test.go | 89 +++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 internal/runtime/executor/codex_executor_imagegen_test.go diff --git a/internal/api/server.go b/internal/api/server.go index 7c571e23cf..32ae3164fd 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -353,6 +353,15 @@ func (s *Server) setupRoutes() { v1.POST("/responses/compact", openaiResponsesHandlers.Compact) } + // Codex CLI direct route aliases (chatgpt_base_url compatible) + codexDirect := s.engine.Group("/backend-api/codex") + codexDirect.Use(AuthMiddleware(s.accessManager)) + { + codexDirect.GET("/responses", openaiResponsesHandlers.ResponsesWebsocket) + codexDirect.POST("/responses", openaiResponsesHandlers.Responses) + codexDirect.POST("/responses/compact", openaiResponsesHandlers.Compact) + } + // Gemini compatible API routes v1beta := s.engine.Group("/v1beta") v1beta.Use(AuthMiddleware(s.accessManager)) diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 7d4d3edf89..543e2c2779 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -180,6 +180,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re body, _ = sjson.DeleteBytes(body, "safety_identifier") body, _ = sjson.DeleteBytes(body, "stream_options") body = normalizeCodexInstructions(body) + body = ensureImageGenerationTool(body) url := strings.TrimSuffix(baseURL, "/") + "/responses" httpReq, err := e.cacheHelper(ctx, from, url, req, body) @@ -326,6 +327,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A body, _ = sjson.SetBytes(body, "model", baseModel) body, _ = sjson.DeleteBytes(body, "stream") body = normalizeCodexInstructions(body) + body = ensureImageGenerationTool(body) url := strings.TrimSuffix(baseURL, "/") + "/responses/compact" httpReq, err := e.cacheHelper(ctx, from, url, req, body) @@ -420,6 +422,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au body, _ = sjson.DeleteBytes(body, "stream_options") body, _ = sjson.SetBytes(body, "model", baseModel) body = normalizeCodexInstructions(body) + body = ensureImageGenerationTool(body) url := strings.TrimSuffix(baseURL, "/") + "/responses" httpReq, err := e.cacheHelper(ctx, from, url, req, body) @@ -821,6 +824,24 @@ func normalizeCodexInstructions(body []byte) []byte { return body } +var imageGenToolJSON = []byte(`{"type":"image_generation","output_format":"png"}`) +var imageGenToolArrayJSON = []byte(`[{"type":"image_generation","output_format":"png"}]`) + +func ensureImageGenerationTool(body []byte) []byte { + tools := gjson.GetBytes(body, "tools") + if !tools.Exists() || !tools.IsArray() { + body, _ = sjson.SetRawBytes(body, "tools", imageGenToolArrayJSON) + return body + } + for _, t := range tools.Array() { + if t.Get("type").String() == "image_generation" { + return body + } + } + body, _ = sjson.SetRawBytes(body, "tools.-1", imageGenToolJSON) + return body +} + func isCodexModelCapacityError(errorBody []byte) bool { if len(errorBody) == 0 { return false diff --git a/internal/runtime/executor/codex_executor_imagegen_test.go b/internal/runtime/executor/codex_executor_imagegen_test.go new file mode 100644 index 0000000000..43f42adee8 --- /dev/null +++ b/internal/runtime/executor/codex_executor_imagegen_test.go @@ -0,0 +1,89 @@ +package executor + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestEnsureImageGenerationTool_NoTools(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","input":"draw a cat"}`) + result := ensureImageGenerationTool(body) + + tools := gjson.GetBytes(result, "tools") + if !tools.IsArray() { + t.Fatalf("expected tools array, got %v", tools.Type) + } + arr := tools.Array() + if len(arr) != 1 { + t.Fatalf("expected 1 tool, got %d", len(arr)) + } + if arr[0].Get("type").String() != "image_generation" { + t.Fatalf("expected type=image_generation, got %s", arr[0].Get("type").String()) + } + if arr[0].Get("output_format").String() != "png" { + t.Fatalf("expected output_format=png, got %s", arr[0].Get("output_format").String()) + } +} + +func TestEnsureImageGenerationTool_ExistingToolsWithoutImageGen(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","tools":[{"type":"function","name":"get_weather","parameters":{}}]}`) + result := ensureImageGenerationTool(body) + + tools := gjson.GetBytes(result, "tools") + arr := tools.Array() + if len(arr) != 2 { + t.Fatalf("expected 2 tools, got %d", len(arr)) + } + if arr[0].Get("type").String() != "function" { + t.Fatalf("expected first tool type=function, got %s", arr[0].Get("type").String()) + } + if arr[1].Get("type").String() != "image_generation" { + t.Fatalf("expected second tool type=image_generation, got %s", arr[1].Get("type").String()) + } +} + +func TestEnsureImageGenerationTool_AlreadyPresent(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation","output_format":"webp"},{"type":"function","name":"f1"}]}`) + result := ensureImageGenerationTool(body) + + tools := gjson.GetBytes(result, "tools") + arr := tools.Array() + if len(arr) != 2 { + t.Fatalf("expected 2 tools (no duplicate), got %d", len(arr)) + } + if arr[0].Get("output_format").String() != "webp" { + t.Fatalf("expected original output_format=webp preserved, got %s", arr[0].Get("output_format").String()) + } +} + +func TestEnsureImageGenerationTool_EmptyToolsArray(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","tools":[]}`) + result := ensureImageGenerationTool(body) + + tools := gjson.GetBytes(result, "tools") + arr := tools.Array() + if len(arr) != 1 { + t.Fatalf("expected 1 tool, got %d", len(arr)) + } + if arr[0].Get("type").String() != "image_generation" { + t.Fatalf("expected type=image_generation, got %s", arr[0].Get("type").String()) + } +} + +func TestEnsureImageGenerationTool_WebSearchAndImageGen(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","tools":[{"type":"web_search"}]}`) + result := ensureImageGenerationTool(body) + + tools := gjson.GetBytes(result, "tools") + arr := tools.Array() + if len(arr) != 2 { + t.Fatalf("expected 2 tools, got %d", len(arr)) + } + if arr[0].Get("type").String() != "web_search" { + t.Fatalf("expected first tool type=web_search, got %s", arr[0].Get("type").String()) + } + if arr[1].Get("type").String() != "image_generation" { + t.Fatalf("expected second tool type=image_generation, got %s", arr[1].Get("type").String()) + } +} From 14d46a0a5dedb338d5e64bd8660d4ac7f2909bcd Mon Sep 17 00:00:00 2001 From: sususu98 Date: Thu, 23 Apr 2026 13:44:20 +0800 Subject: [PATCH 027/139] feat(antigravity): conductor-level credits fallback for Claude models Move credits handling from executor-level retry to conductor-level orchestration. When all free-tier auths are exhausted (429/503), the conductor discovers auths with available Google One AI credits and retries with enabledCreditTypes injected via context flag. Key changes: - Add AntigravityCreditsHint system for tracking per-auth credits state - Conductor tries credits fallback after all auths fail (Execute/Stream/Count) - Executor injects enabledCreditTypes only when conductor sets context flag - Credits fallback respects provider scope (requires antigravity in providers) - Add context cancellation check in credits fallback to avoid wasted requests - Remove executor-level attemptCreditsFallback and preferCredits machinery - Restructure 429 decision logic (parse details first, keyword fallback) - Expand shouldAbort to cover INVALID_ARGUMENT/FAILED_PRECONDITION/500+UNKNOWN - Support human-readable retry delay parsing (e.g. "1h43m56s") --- config.example.yaml | 2 +- internal/config/config.go | 5 +- .../runtime/executor/antigravity_executor.go | 644 +++++++----------- .../antigravity_executor_credits_test.go | 459 ++++++------- .../runtime/executor/gemini_cli_executor.go | 9 +- .../runtime/executor/helps/logging_helpers.go | 130 +++- sdk/cliproxy/auth/antigravity_credits.go | 90 +++ sdk/cliproxy/auth/antigravity_credits_test.go | 62 ++ sdk/cliproxy/auth/conductor.go | 218 +++++- 9 files changed, 957 insertions(+), 662 deletions(-) create mode 100644 sdk/cliproxy/auth/antigravity_credits.go create mode 100644 sdk/cliproxy/auth/antigravity_credits_test.go diff --git a/config.example.yaml b/config.example.yaml index 734dd7d522..13042b78d3 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -98,7 +98,7 @@ disable-cooling: false quota-exceeded: switch-project: true # Whether to automatically switch to another project when a quota is exceeded switch-preview-model: true # Whether to automatically switch to a preview model when a quota is exceeded - antigravity-credits: true # Whether to retry Antigravity quota_exhausted 429s once with enabledCreditTypes=["GOOGLE_ONE_AI"] + antigravity-credits: true # Whether to use credits as last-resort fallback when all free-tier auths are exhausted for Claude models # Routing strategy for selecting credentials when multiple match. routing: diff --git a/internal/config/config.go b/internal/config/config.go index 760d43ec4a..1ebbb460c0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -206,8 +206,9 @@ type QuotaExceeded struct { // SwitchPreviewModel indicates whether to automatically switch to a preview model when a quota is exceeded. SwitchPreviewModel bool `yaml:"switch-preview-model" json:"switch-preview-model"` - // AntigravityCredits indicates whether to retry Antigravity quota_exhausted 429s once - // on the same credential with enabledCreditTypes=["GOOGLE_ONE_AI"]. + // AntigravityCredits enables credits-based last-resort fallback for Claude models. + // When all free-tier auths are exhausted (429/503), the conductor retries with + // an auth that has available Google One AI credits. AntigravityCredits bool `yaml:"antigravity-credits" json:"antigravity-credits"` } diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 163b2d9279..633373d29c 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -52,8 +52,6 @@ const ( defaultAntigravityAgent = "antigravity/1.21.9 darwin/arm64" // fallback only; overridden at runtime by misc.AntigravityUserAgent() antigravityAuthType = "antigravity" refreshSkew = 3000 * time.Second - antigravityCreditsRetryTTL = 5 * time.Hour - antigravityCreditsAutoDisableDuration = 5 * time.Hour antigravityShortQuotaCooldownThreshold = 5 * time.Minute antigravityInstantRetryThreshold = 3 * time.Second // systemInstruction = "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**" @@ -62,8 +60,6 @@ const ( type antigravity429Category string type antigravityCreditsFailureState struct { - Count int - DisabledUntil time.Time PermanentlyDisabled bool ExplicitBalanceExhausted bool } @@ -91,28 +87,79 @@ var ( randSource = rand.New(rand.NewSource(time.Now().UnixNano())) randSourceMutex sync.Mutex antigravityCreditsFailureByAuth sync.Map - antigravityPreferCreditsByModel sync.Map antigravityShortCooldownByAuth sync.Map + antigravityCreditsBalanceByAuth sync.Map // auth.ID → antigravityCreditsBalance antigravityQuotaExhaustedKeywords = []string{ "quota_exhausted", "quota exhausted", } - antigravityCreditsExhaustedKeywords = []string{ - "google_one_ai", - "insufficient credit", - "insufficient credits", - "not enough credit", - "not enough credits", - "credit exhausted", - "credits exhausted", - "credit balance", - "minimumcreditamountforusage", - "minimum credit amount for usage", - "minimum credit", - "resource has been exhausted", - } ) +type antigravityCreditsBalance struct { + CreditAmount float64 + MinCreditAmount float64 + PaidTierID string + Known bool +} + +func antigravityAuthHasCredits(auth *cliproxyauth.Auth) bool { + if auth == nil || strings.TrimSpace(auth.ID) == "" { + return false + } + if hint, ok := cliproxyauth.GetAntigravityCreditsHint(auth.ID); ok && hint.Known { + return hint.Available + } + val, ok := antigravityCreditsBalanceByAuth.Load(strings.TrimSpace(auth.ID)) + if !ok { + return true // optimistic: assume credits available when balance unknown + } + bal, valid := val.(antigravityCreditsBalance) + if !valid { + antigravityCreditsBalanceByAuth.Delete(strings.TrimSpace(auth.ID)) + return false + } + if !bal.Known { + return false + } + available := bal.CreditAmount >= bal.MinCreditAmount + cliproxyauth.SetAntigravityCreditsHint(strings.TrimSpace(auth.ID), cliproxyauth.AntigravityCreditsHint{ + Known: true, + Available: available, + CreditAmount: bal.CreditAmount, + MinCreditAmount: bal.MinCreditAmount, + PaidTierID: bal.PaidTierID, + UpdatedAt: time.Now(), + }) + return available +} + +// parseMetaFloat extracts a float64 from auth.Metadata (handles string and numeric types). +func parseMetaFloat(metadata map[string]any, key string) (float64, bool) { + v, ok := metadata[key] + if !ok { + return 0, false + } + switch typed := v.(type) { + case float64: + return typed, true + case int: + return float64(typed), true + case int64: + return float64(typed), true + case uint64: + return float64(typed), true + case json.Number: + if f, err := typed.Float64(); err == nil { + return f, true + } + case string: + if f, err := strconv.ParseFloat(strings.TrimSpace(typed), 64); err == nil { + return f, true + } + } + return 0, false +} + // AntigravityExecutor proxies requests to the antigravity upstream. type AntigravityExecutor struct { cfg *config.Config @@ -189,7 +236,7 @@ func validateAntigravityRequestSignatures(from sdktranslator.Format, rawJSON []b if from.String() != "claude" { return rawJSON, nil } - // Always strip thinking blocks with empty signatures (proxy-generated). + // Always strip thinking blocks with invalid signatures (empty or non-Claude-format). rawJSON = antigravityclaude.StripEmptySignatureThinkingBlocks(rawJSON) if cache.SignatureCacheEnabled() { return rawJSON, nil @@ -298,49 +345,46 @@ func decideAntigravity429(body []byte) antigravity429Decision { decision.retryAfter = retryAfter } - lowerBody := strings.ToLower(string(body)) - for _, keyword := range antigravityQuotaExhaustedKeywords { - if strings.Contains(lowerBody, keyword) { - decision.kind = antigravity429DecisionFullQuotaExhausted - decision.reason = "quota_exhausted" - return decision - } - } - status := strings.TrimSpace(gjson.GetBytes(body, "error.status").String()) if !strings.EqualFold(status, "RESOURCE_EXHAUSTED") { return decision } details := gjson.GetBytes(body, "error.details") - if !details.Exists() || !details.IsArray() { - decision.kind = antigravity429DecisionSoftRetry - return decision - } - - for _, detail := range details.Array() { - if detail.Get("@type").String() != "type.googleapis.com/google.rpc.ErrorInfo" { - continue - } - reason := strings.TrimSpace(detail.Get("reason").String()) - decision.reason = reason - switch { - case strings.EqualFold(reason, "QUOTA_EXHAUSTED"): - decision.kind = antigravity429DecisionFullQuotaExhausted - return decision - case strings.EqualFold(reason, "RATE_LIMIT_EXCEEDED"): - if decision.retryAfter == nil { - decision.kind = antigravity429DecisionSoftRetry - return decision + if details.Exists() && details.IsArray() { + for _, detail := range details.Array() { + if detail.Get("@type").String() != "type.googleapis.com/google.rpc.ErrorInfo" { + continue } + reason := strings.TrimSpace(detail.Get("reason").String()) + decision.reason = reason switch { - case *decision.retryAfter < antigravityInstantRetryThreshold: - decision.kind = antigravity429DecisionInstantRetrySameAuth - case *decision.retryAfter < antigravityShortQuotaCooldownThreshold: - decision.kind = antigravity429DecisionShortCooldownSwitchAuth - default: + case strings.EqualFold(reason, "QUOTA_EXHAUSTED"): decision.kind = antigravity429DecisionFullQuotaExhausted + return decision + case strings.EqualFold(reason, "RATE_LIMIT_EXCEEDED"): + if decision.retryAfter == nil { + decision.kind = antigravity429DecisionSoftRetry + return decision + } + switch { + case *decision.retryAfter < antigravityInstantRetryThreshold: + decision.kind = antigravity429DecisionInstantRetrySameAuth + case *decision.retryAfter < antigravityShortQuotaCooldownThreshold: + decision.kind = antigravity429DecisionShortCooldownSwitchAuth + default: + decision.kind = antigravity429DecisionFullQuotaExhausted + } + return decision } + } + } + + lowerBody := strings.ToLower(string(body)) + for _, keyword := range antigravityQuotaExhaustedKeywords { + if strings.Contains(lowerBody, keyword) { + decision.kind = antigravity429DecisionFullQuotaExhausted + decision.reason = "quota_exhausted" return decision } } @@ -349,81 +393,10 @@ func decideAntigravity429(body []byte) antigravity429Decision { return decision } -func antigravityHasQuotaResetDelayOrModelInfo(body []byte) bool { - if len(body) == 0 { - return false - } - details := gjson.GetBytes(body, "error.details") - if !details.Exists() || !details.IsArray() { - return false - } - for _, detail := range details.Array() { - if detail.Get("@type").String() != "type.googleapis.com/google.rpc.ErrorInfo" { - continue - } - if strings.TrimSpace(detail.Get("metadata.quotaResetDelay").String()) != "" { - return true - } - if strings.TrimSpace(detail.Get("metadata.model").String()) != "" { - return true - } - } - return false -} - func antigravityCreditsRetryEnabled(cfg *config.Config) bool { return cfg != nil && cfg.QuotaExceeded.AntigravityCredits } -func antigravityCreditsFailureStateForAuth(auth *cliproxyauth.Auth) (string, antigravityCreditsFailureState, bool) { - if auth == nil || strings.TrimSpace(auth.ID) == "" { - return "", antigravityCreditsFailureState{}, false - } - authID := strings.TrimSpace(auth.ID) - value, ok := antigravityCreditsFailureByAuth.Load(authID) - if !ok { - return authID, antigravityCreditsFailureState{}, true - } - state, ok := value.(antigravityCreditsFailureState) - if !ok { - antigravityCreditsFailureByAuth.Delete(authID) - return authID, antigravityCreditsFailureState{}, true - } - return authID, state, true -} - -func antigravityCreditsDisabled(auth *cliproxyauth.Auth, now time.Time) bool { - authID, state, ok := antigravityCreditsFailureStateForAuth(auth) - if !ok { - return false - } - if state.PermanentlyDisabled { - return true - } - if state.DisabledUntil.IsZero() { - return false - } - if state.DisabledUntil.After(now) { - return true - } - antigravityCreditsFailureByAuth.Delete(authID) - return false -} - -func recordAntigravityCreditsFailure(auth *cliproxyauth.Auth, now time.Time) { - authID, state, ok := antigravityCreditsFailureStateForAuth(auth) - if !ok { - return - } - if state.PermanentlyDisabled { - antigravityCreditsFailureByAuth.Store(authID, state) - return - } - state.Count++ - state.DisabledUntil = now.Add(antigravityCreditsAutoDisableDuration) - antigravityCreditsFailureByAuth.Store(authID, state) -} - func clearAntigravityCreditsFailureState(auth *cliproxyauth.Auth) { if auth == nil || strings.TrimSpace(auth.ID) == "" { return @@ -440,6 +413,25 @@ func markAntigravityCreditsPermanentlyDisabled(auth *cliproxyauth.Auth) { ExplicitBalanceExhausted: true, } antigravityCreditsFailureByAuth.Store(authID, state) + antigravityCreditsBalanceByAuth.Store(authID, antigravityCreditsBalance{ + CreditAmount: 0, + MinCreditAmount: 1, + Known: true, + }) + cliproxyauth.SetAntigravityCreditsHint(authID, cliproxyauth.AntigravityCreditsHint{ + Known: true, + Available: false, + CreditAmount: 0, + MinCreditAmount: 1, + UpdatedAt: time.Now(), + }) +} + +func clearAntigravityCreditsPermanentlyDisabled(auth *cliproxyauth.Auth) { + if auth == nil || strings.TrimSpace(auth.ID) == "" { + return + } + antigravityCreditsFailureByAuth.Delete(strings.TrimSpace(auth.ID)) } func antigravityHasExplicitCreditsBalanceExhaustedReason(body []byte) bool { @@ -462,81 +454,6 @@ func antigravityHasExplicitCreditsBalanceExhaustedReason(body []byte) bool { return false } -func antigravityPreferCreditsKey(auth *cliproxyauth.Auth, modelName string) string { - if auth == nil { - return "" - } - authID := strings.TrimSpace(auth.ID) - modelName = strings.TrimSpace(modelName) - if authID == "" || modelName == "" { - return "" - } - return authID + "|" + modelName -} - -func antigravityShouldPreferCredits(auth *cliproxyauth.Auth, modelName string, now time.Time) bool { - key := antigravityPreferCreditsKey(auth, modelName) - if key == "" { - return false - } - value, ok := antigravityPreferCreditsByModel.Load(key) - if !ok { - return false - } - until, ok := value.(time.Time) - if !ok || until.IsZero() { - antigravityPreferCreditsByModel.Delete(key) - return false - } - if !until.After(now) { - antigravityPreferCreditsByModel.Delete(key) - return false - } - return true -} - -func markAntigravityPreferCredits(auth *cliproxyauth.Auth, modelName string, now time.Time, retryAfter *time.Duration) { - key := antigravityPreferCreditsKey(auth, modelName) - if key == "" { - return - } - until := now.Add(antigravityCreditsRetryTTL) - if retryAfter != nil && *retryAfter > 0 { - until = now.Add(*retryAfter) - } - antigravityPreferCreditsByModel.Store(key, until) -} - -func clearAntigravityPreferCredits(auth *cliproxyauth.Auth, modelName string) { - key := antigravityPreferCreditsKey(auth, modelName) - if key == "" { - return - } - antigravityPreferCreditsByModel.Delete(key) -} - -func shouldMarkAntigravityCreditsExhausted(statusCode int, body []byte, reqErr error) bool { - if reqErr != nil || statusCode == 0 { - return false - } - if statusCode >= http.StatusInternalServerError || statusCode == http.StatusRequestTimeout { - return false - } - lowerBody := strings.ToLower(string(body)) - for _, keyword := range antigravityCreditsExhaustedKeywords { - if strings.Contains(lowerBody, keyword) { - if keyword == "resource has been exhausted" && - statusCode == http.StatusTooManyRequests && - decideAntigravity429(body).kind == antigravity429DecisionSoftRetry && - !antigravityHasQuotaResetDelayOrModelInfo(body) { - return false - } - return true - } - } - return false -} - func newAntigravityStatusErr(statusCode int, body []byte) statusErr { err := statusErr{code: statusCode, msg: string(body)} if statusCode == http.StatusTooManyRequests { @@ -547,129 +464,6 @@ func newAntigravityStatusErr(statusCode int, body []byte) statusErr { return err } -func (e *AntigravityExecutor) attemptCreditsFallback( - ctx context.Context, - auth *cliproxyauth.Auth, - httpClient *http.Client, - token string, - modelName string, - payload []byte, - stream bool, - alt string, - baseURL string, - originalBody []byte, -) (*http.Response, bool) { - if !antigravityCreditsRetryEnabled(e.cfg) { - return nil, false - } - if decideAntigravity429(originalBody).kind != antigravity429DecisionFullQuotaExhausted { - return nil, false - } - now := time.Now() - if shouldForcePermanentDisableCredits(originalBody) { - clearAntigravityPreferCredits(auth, modelName) - markAntigravityCreditsPermanentlyDisabled(auth) - return nil, false - } - - if antigravityHasExplicitCreditsBalanceExhaustedReason(originalBody) { - clearAntigravityPreferCredits(auth, modelName) - markAntigravityCreditsPermanentlyDisabled(auth) - return nil, false - } - - if antigravityCreditsDisabled(auth, now) { - return nil, false - } - creditsPayload := injectEnabledCreditTypes(payload) - if len(creditsPayload) == 0 { - return nil, false - } - - httpReq, errReq := e.buildRequest(ctx, auth, token, modelName, creditsPayload, stream, alt, baseURL) - if errReq != nil { - helps.RecordAPIResponseError(ctx, e.cfg, errReq) - clearAntigravityPreferCredits(auth, modelName) - recordAntigravityCreditsFailure(auth, now) - return nil, true - } - httpResp, errDo := httpClient.Do(httpReq) - if errDo != nil { - helps.RecordAPIResponseError(ctx, e.cfg, errDo) - clearAntigravityPreferCredits(auth, modelName) - recordAntigravityCreditsFailure(auth, now) - return nil, true - } - if httpResp.StatusCode >= http.StatusOK && httpResp.StatusCode < http.StatusMultipleChoices { - retryAfter, _ := parseRetryDelay(originalBody) - markAntigravityPreferCredits(auth, modelName, now, retryAfter) - clearAntigravityCreditsFailureState(auth) - return httpResp, true - } - - helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) - bodyBytes, errRead := io.ReadAll(httpResp.Body) - if errClose := httpResp.Body.Close(); errClose != nil { - log.Errorf("antigravity executor: close credits fallback response body error: %v", errClose) - } - if errRead != nil { - helps.RecordAPIResponseError(ctx, e.cfg, errRead) - clearAntigravityPreferCredits(auth, modelName) - recordAntigravityCreditsFailure(auth, now) - return nil, true - } - helps.AppendAPIResponseChunk(ctx, e.cfg, bodyBytes) - if shouldForcePermanentDisableCredits(bodyBytes) { - clearAntigravityPreferCredits(auth, modelName) - markAntigravityCreditsPermanentlyDisabled(auth) - return nil, true - } - - if antigravityHasExplicitCreditsBalanceExhaustedReason(bodyBytes) { - clearAntigravityPreferCredits(auth, modelName) - markAntigravityCreditsPermanentlyDisabled(auth) - return nil, true - } - - clearAntigravityPreferCredits(auth, modelName) - recordAntigravityCreditsFailure(auth, now) - return nil, true -} - -func (e *AntigravityExecutor) handleDirectCreditsFailure(ctx context.Context, auth *cliproxyauth.Auth, modelName string, reqErr error) { - if reqErr != nil { - if shouldForcePermanentDisableCredits(reqErrBody(reqErr)) { - clearAntigravityPreferCredits(auth, modelName) - markAntigravityCreditsPermanentlyDisabled(auth) - return - } - - if antigravityHasExplicitCreditsBalanceExhaustedReason(reqErrBody(reqErr)) { - clearAntigravityPreferCredits(auth, modelName) - markAntigravityCreditsPermanentlyDisabled(auth) - return - } - - helps.RecordAPIResponseError(ctx, e.cfg, reqErr) - } - clearAntigravityPreferCredits(auth, modelName) - recordAntigravityCreditsFailure(auth, time.Now()) -} -func reqErrBody(reqErr error) []byte { - if reqErr == nil { - return nil - } - msg := reqErr.Error() - if strings.TrimSpace(msg) == "" { - return nil - } - return []byte(msg) -} - -func shouldForcePermanentDisableCredits(body []byte) bool { - return antigravityHasExplicitCreditsBalanceExhaustedReason(body) -} - // Execute performs a non-streaming request to the Antigravity API. func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { if opts.Alt == "responses/compact" { @@ -721,6 +515,8 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au requestedModel := helps.PayloadRequestedModel(opts, req.Model) translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel) + useCredits := cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(e.cfg) + baseURLs := antigravityBaseURLFallbackOrder(auth) httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0) attempts := antigravityRetryAttempts(auth, e.cfg) @@ -733,11 +529,10 @@ attemptLoop: for idx, baseURL := range baseURLs { requestPayload := translated - usedCreditsDirect := false - if antigravityCreditsRetryEnabled(e.cfg) && antigravityShouldPreferCredits(auth, baseModel, time.Now()) { - if creditsPayload := injectEnabledCreditTypes(translated); len(creditsPayload) > 0 { - requestPayload = creditsPayload - usedCreditsDirect = true + if useCredits { + if cp := injectEnabledCreditTypes(translated); len(cp) > 0 { + requestPayload = cp + helps.MarkCreditsUsed(ctx) } } @@ -785,7 +580,6 @@ attemptLoop: wait := antigravityInstantRetryDelay(*decision.retryAfter) log.Debugf("antigravity executor: instant retry for model %s, waiting %s", baseModel, wait) if errWait := antigravityWait(ctx, wait); errWait != nil { - return resp, errWait } } @@ -794,34 +588,13 @@ attemptLoop: case antigravity429DecisionShortCooldownSwitchAuth: if decision.retryAfter != nil && *decision.retryAfter > 0 { markAntigravityShortCooldown(auth, baseModel, time.Now(), *decision.retryAfter) - log.Debugf("antigravity executor: short quota cooldown (%s) for model %s, recorded cooldown and skipping credits fallback", *decision.retryAfter, baseModel) + log.Debugf("antigravity executor: short quota cooldown (%s) for model %s, recorded cooldown", *decision.retryAfter, baseModel) } case antigravity429DecisionFullQuotaExhausted: - if usedCreditsDirect { - clearAntigravityPreferCredits(auth, baseModel) - recordAntigravityCreditsFailure(auth, time.Now()) - } else { - creditsResp, _ := e.attemptCreditsFallback(ctx, auth, httpClient, token, baseModel, translated, false, opts.Alt, baseURL, bodyBytes) - if creditsResp != nil { - helps.RecordAPIResponseMetadata(ctx, e.cfg, creditsResp.StatusCode, creditsResp.Header.Clone()) - creditsBody, errCreditsRead := io.ReadAll(creditsResp.Body) - if errClose := creditsResp.Body.Close(); errClose != nil { - log.Errorf("antigravity executor: close credits success response body error: %v", errClose) - } - if errCreditsRead != nil { - helps.RecordAPIResponseError(ctx, e.cfg, errCreditsRead) - err = errCreditsRead - return resp, err - } - helps.AppendAPIResponseChunk(ctx, e.cfg, creditsBody) - reporter.Publish(ctx, helps.ParseAntigravityUsage(creditsBody)) - var param any - converted := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, creditsBody, ¶m) - resp = cliproxyexecutor.Response{Payload: converted, Headers: creditsResp.Header.Clone()} - reporter.EnsurePublished(ctx) - return resp, nil - } + if useCredits && antigravityHasExplicitCreditsBalanceExhaustedReason(bodyBytes) { + markAntigravityCreditsPermanentlyDisabled(auth) } + // No credits logic - just fall through to error return below } } @@ -870,6 +643,10 @@ attemptLoop: return resp, err } + // Success + if useCredits { + clearAntigravityCreditsFailureState(auth) + } reporter.Publish(ctx, helps.ParseAntigravityUsage(bodyBytes)) var param any converted := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bodyBytes, ¶m) @@ -935,6 +712,8 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * requestedModel := helps.PayloadRequestedModel(opts, req.Model) translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel) + useCredits := cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(e.cfg) + baseURLs := antigravityBaseURLFallbackOrder(auth) httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0) @@ -948,11 +727,10 @@ attemptLoop: for idx, baseURL := range baseURLs { requestPayload := translated - usedCreditsDirect := false - if antigravityCreditsRetryEnabled(e.cfg) && antigravityShouldPreferCredits(auth, baseModel, time.Now()) { - if creditsPayload := injectEnabledCreditTypes(translated); len(creditsPayload) > 0 { - requestPayload = creditsPayload - usedCreditsDirect = true + if useCredits { + if cp := injectEnabledCreditTypes(translated); len(cp) > 0 { + requestPayload = cp + helps.MarkCreditsUsed(ctx) } } httpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, requestPayload, true, opts.Alt, baseURL) @@ -1014,7 +792,6 @@ attemptLoop: wait := antigravityInstantRetryDelay(*decision.retryAfter) log.Debugf("antigravity executor: instant retry for model %s, waiting %s", baseModel, wait) if errWait := antigravityWait(ctx, wait); errWait != nil { - return resp, errWait } } @@ -1023,25 +800,16 @@ attemptLoop: case antigravity429DecisionShortCooldownSwitchAuth: if decision.retryAfter != nil && *decision.retryAfter > 0 { markAntigravityShortCooldown(auth, baseModel, time.Now(), *decision.retryAfter) - log.Debugf("antigravity executor: short quota cooldown (%s) for model %s, recorded cooldown and skipping credits fallback", *decision.retryAfter, baseModel) + log.Debugf("antigravity executor: short quota cooldown (%s) for model %s, recorded cooldown", *decision.retryAfter, baseModel) } case antigravity429DecisionFullQuotaExhausted: - if usedCreditsDirect { - clearAntigravityPreferCredits(auth, baseModel) - recordAntigravityCreditsFailure(auth, time.Now()) - } else { - creditsResp, _ := e.attemptCreditsFallback(ctx, auth, httpClient, token, baseModel, translated, true, opts.Alt, baseURL, bodyBytes) - if creditsResp != nil { - httpResp = creditsResp - helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) - } + if useCredits && antigravityHasExplicitCreditsBalanceExhaustedReason(bodyBytes) { + markAntigravityCreditsPermanentlyDisabled(auth) } + // No credits logic - just fall through to error return below } } - if httpResp.StatusCode >= http.StatusOK && httpResp.StatusCode < http.StatusMultipleChoices { - goto streamSuccessClaudeNonStream - } lastStatus = httpResp.StatusCode lastBody = append([]byte(nil), bodyBytes...) lastErr = nil @@ -1085,7 +853,10 @@ attemptLoop: return resp, err } - streamSuccessClaudeNonStream: + // Stream success + if useCredits { + clearAntigravityCreditsFailureState(auth) + } out := make(chan cliproxyexecutor.StreamChunk) go func(resp *http.Response) { defer close(out) @@ -1389,6 +1160,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya if updatedAuth != nil { auth = updatedAuth } + originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true) translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true) @@ -1400,6 +1172,8 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya requestedModel := helps.PayloadRequestedModel(opts, req.Model) translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel) + useCredits := cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(e.cfg) + baseURLs := antigravityBaseURLFallbackOrder(auth) httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0) @@ -1413,11 +1187,10 @@ attemptLoop: for idx, baseURL := range baseURLs { requestPayload := translated - usedCreditsDirect := false - if antigravityCreditsRetryEnabled(e.cfg) && antigravityShouldPreferCredits(auth, baseModel, time.Now()) { - if creditsPayload := injectEnabledCreditTypes(translated); len(creditsPayload) > 0 { - requestPayload = creditsPayload - usedCreditsDirect = true + if useCredits { + if cp := injectEnabledCreditTypes(translated); len(cp) > 0 { + requestPayload = cp + helps.MarkCreditsUsed(ctx) } } httpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, requestPayload, true, opts.Alt, baseURL) @@ -1478,7 +1251,6 @@ attemptLoop: wait := antigravityInstantRetryDelay(*decision.retryAfter) log.Debugf("antigravity executor: instant retry for model %s, waiting %s", baseModel, wait) if errWait := antigravityWait(ctx, wait); errWait != nil { - return nil, errWait } } @@ -1487,25 +1259,16 @@ attemptLoop: case antigravity429DecisionShortCooldownSwitchAuth: if decision.retryAfter != nil && *decision.retryAfter > 0 { markAntigravityShortCooldown(auth, baseModel, time.Now(), *decision.retryAfter) - log.Debugf("antigravity executor: short quota cooldown (%s) for model %s, recorded cooldown and skipping credits fallback", *decision.retryAfter, baseModel) + log.Debugf("antigravity executor: short quota cooldown (%s) for model %s recorded", *decision.retryAfter, baseModel) } case antigravity429DecisionFullQuotaExhausted: - if usedCreditsDirect { - clearAntigravityPreferCredits(auth, baseModel) - recordAntigravityCreditsFailure(auth, time.Now()) - } else { - creditsResp, _ := e.attemptCreditsFallback(ctx, auth, httpClient, token, baseModel, translated, true, opts.Alt, baseURL, bodyBytes) - if creditsResp != nil { - httpResp = creditsResp - helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) - } + if useCredits && antigravityHasExplicitCreditsBalanceExhaustedReason(bodyBytes) { + markAntigravityCreditsPermanentlyDisabled(auth) } + // No credits logic - just fall through to error return below } } - if httpResp.StatusCode >= http.StatusOK && httpResp.StatusCode < http.StatusMultipleChoices { - goto streamSuccessExecuteStream - } lastStatus = httpResp.StatusCode lastBody = append([]byte(nil), bodyBytes...) lastErr = nil @@ -1549,7 +1312,10 @@ attemptLoop: return nil, err } - streamSuccessExecuteStream: + // Stream success + if useCredits { + clearAntigravityCreditsFailureState(auth) + } out := make(chan cliproxyexecutor.StreamChunk) go func(resp *http.Response) { defer close(out) @@ -1792,6 +1558,9 @@ func (e *AntigravityExecutor) ensureAccessToken(ctx context.Context, auth *clipr accessToken := metaStringValue(auth.Metadata, "access_token") expiry := tokenExpiry(auth.Metadata) if accessToken != "" && expiry.After(time.Now().Add(refreshSkew)) { + if !cliproxyauth.HasKnownAntigravityCreditsHint(auth.ID) { + e.updateAntigravityCreditsBalance(ctx, auth, accessToken) + } return accessToken, nil, nil } refreshCtx := context.Background() @@ -1882,6 +1651,7 @@ func (e *AntigravityExecutor) refreshToken(ctx context.Context, auth *cliproxyau if errProject := e.ensureAntigravityProjectID(ctx, auth, tokenResp.AccessToken); errProject != nil { log.Warnf("antigravity executor: ensure project id failed: %v", errProject) } + e.updateAntigravityCreditsBalance(ctx, auth, tokenResp.AccessToken) return auth, nil } @@ -1918,6 +1688,94 @@ func (e *AntigravityExecutor) ensureAntigravityProjectID(ctx context.Context, au return nil } +func (e *AntigravityExecutor) updateAntigravityCreditsBalance(ctx context.Context, auth *cliproxyauth.Auth, accessToken string) { + if auth == nil || strings.TrimSpace(auth.ID) == "" { + return + } + token := strings.TrimSpace(accessToken) + if token == "" { + token = metaStringValue(auth.Metadata, "access_token") + } + if token == "" { + return + } + + loadReqBody := `{"metadata":{"ideType":"ANTIGRAVITY","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}}` + endpointURL := "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" + httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, endpointURL, strings.NewReader(loadReqBody)) + if errReq != nil { + log.Debugf("antigravity executor: create loadCodeAssist request error: %v", errReq) + return + } + httpReq.Header.Set("Authorization", "Bearer "+token) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("User-Agent", "google-api-nodejs-client/9.15.1") + + httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0) + httpResp, errDo := httpClient.Do(httpReq) + if errDo != nil { + log.Debugf("antigravity executor: loadCodeAssist request error: %v", errDo) + return + } + defer func() { + if errClose := httpResp.Body.Close(); errClose != nil { + log.Errorf("antigravity executor: close loadCodeAssist response body error: %v", errClose) + } + }() + + bodyBytes, errRead := io.ReadAll(httpResp.Body) + if errRead != nil || httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices { + log.Debugf("antigravity executor: loadCodeAssist returned status %d, err=%v", httpResp.StatusCode, errRead) + return + } + + authID := strings.TrimSpace(auth.ID) + paidTierID := strings.TrimSpace(gjson.GetBytes(bodyBytes, "paidTier.id").String()) + + credits := gjson.GetBytes(bodyBytes, "paidTier.availableCredits") + if !credits.IsArray() { + cliproxyauth.SetAntigravityCreditsHint(authID, cliproxyauth.AntigravityCreditsHint{ + Known: true, + Available: false, + PaidTierID: paidTierID, + UpdatedAt: time.Now(), + }) + return + } + for _, credit := range credits.Array() { + if !strings.EqualFold(credit.Get("creditType").String(), "GOOGLE_ONE_AI") { + continue + } + creditAmount, errCA := strconv.ParseFloat(strings.TrimSpace(credit.Get("creditAmount").String()), 64) + if errCA != nil { + continue + } + minAmount, errMA := strconv.ParseFloat(strings.TrimSpace(credit.Get("minimumCreditAmountForUsage").String()), 64) + if errMA != nil { + continue + } + bal := antigravityCreditsBalance{ + CreditAmount: creditAmount, + MinCreditAmount: minAmount, + PaidTierID: paidTierID, + Known: true, + } + antigravityCreditsBalanceByAuth.Store(authID, bal) + cliproxyauth.SetAntigravityCreditsHint(authID, cliproxyauth.AntigravityCreditsHint{ + Known: true, + Available: creditAmount >= minAmount, + CreditAmount: creditAmount, + MinCreditAmount: minAmount, + PaidTierID: paidTierID, + UpdatedAt: time.Now(), + }) + if creditAmount >= minAmount { + clearAntigravityCreditsPermanentlyDisabled(auth) + } + return + } +} + func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyauth.Auth, token, modelName string, payload []byte, stream bool, alt, baseURL string) (*http.Request, error) { if token == "" { return nil, statusErr{code: http.StatusUnauthorized, msg: "missing access token"} diff --git a/internal/runtime/executor/antigravity_executor_credits_test.go b/internal/runtime/executor/antigravity_executor_credits_test.go index cf968ac794..b9c7a91fd8 100644 --- a/internal/runtime/executor/antigravity_executor_credits_test.go +++ b/internal/runtime/executor/antigravity_executor_credits_test.go @@ -18,8 +18,8 @@ import ( func resetAntigravityCreditsRetryState() { antigravityCreditsFailureByAuth = sync.Map{} - antigravityPreferCreditsByModel = sync.Map{} antigravityShortCooldownByAuth = sync.Map{} + antigravityCreditsBalanceByAuth = sync.Map{} } func TestClassifyAntigravity429(t *testing.T) { @@ -30,6 +30,43 @@ func TestClassifyAntigravity429(t *testing.T) { } }) + t.Run("standard antigravity rate limit with ui message stays rate limited", func(t *testing.T) { + body := []byte(`{ + "error": { + "code": 429, + "message": "You have exhausted your capacity on this model. Your quota will reset after 0s.", + "status": "RESOURCE_EXHAUSTED", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "reason": "RATE_LIMIT_EXCEEDED", + "domain": "cloudcode-pa.googleapis.com", + "metadata": { + "model": "claude-opus-4-6-thinking", + "quotaResetDelay": "479.417207ms", + "quotaResetTimeStamp": "2026-04-20T09:19:49Z", + "uiMessage": "true" + } + }, + { + "@type": "type.googleapis.com/google.rpc.RetryInfo", + "retryDelay": "0.479417207s" + } + ] + } + }`) + if got := classifyAntigravity429(body); got != antigravity429RateLimited { + t.Fatalf("classifyAntigravity429() = %q, want %q", got, antigravity429RateLimited) + } + decision := decideAntigravity429(body) + if decision.kind != antigravity429DecisionInstantRetrySameAuth { + t.Fatalf("decideAntigravity429().kind = %q, want %q", decision.kind, antigravity429DecisionInstantRetrySameAuth) + } + if decision.retryAfter == nil { + t.Fatal("decideAntigravity429().retryAfter = nil") + } + }) + t.Run("structured rate limit", func(t *testing.T) { body := []byte(`{ "error": { @@ -67,8 +104,32 @@ func TestClassifyAntigravity429(t *testing.T) { }) } +func TestAntigravityShouldRetryNoCapacity_Standard503(t *testing.T) { + body := []byte(`{ + "error": { + "code": 503, + "message": "No capacity available for model gemini-3.1-flash-image on the server", + "status": "UNAVAILABLE", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "reason": "MODEL_CAPACITY_EXHAUSTED", + "domain": "cloudcode-pa.googleapis.com", + "metadata": { + "model": "gemini-3.1-flash-image" + } + } + ] + } + }`) + if !antigravityShouldRetryNoCapacity(http.StatusServiceUnavailable, body) { + t.Fatal("antigravityShouldRetryNoCapacity() = false, want true") + } +} + + func TestInjectEnabledCreditTypes(t *testing.T) { - body := []byte(`{"model":"gemini-2.5-flash","request":{}}`) + body := []byte(`{"model":"claude-sonnet-4-6","request":{}}`) got := injectEnabledCreditTypes(body) if got == nil { t.Fatal("injectEnabledCreditTypes() returned nil") @@ -82,37 +143,22 @@ func TestInjectEnabledCreditTypes(t *testing.T) { } } -func TestShouldMarkAntigravityCreditsExhausted(t *testing.T) { - t.Run("credit errors are marked", func(t *testing.T) { - for _, body := range [][]byte{ - []byte(`{"error":{"message":"Insufficient GOOGLE_ONE_AI credits"}}`), - []byte(`{"error":{"message":"minimumCreditAmountForUsage requirement not met"}}`), - } { - if !shouldMarkAntigravityCreditsExhausted(http.StatusForbidden, body, nil) { - t.Fatalf("shouldMarkAntigravityCreditsExhausted(%s) = false, want true", string(body)) - } - } - }) - - t.Run("transient 429 resource exhausted is not marked", func(t *testing.T) { - body := []byte(`{"error":{"code":429,"message":"Resource has been exhausted (e.g. check quota).","status":"RESOURCE_EXHAUSTED"}}`) - if shouldMarkAntigravityCreditsExhausted(http.StatusTooManyRequests, body, nil) { - t.Fatalf("shouldMarkAntigravityCreditsExhausted(%s) = true, want false", string(body)) - } - }) - - t.Run("resource exhausted with quota metadata is still marked", func(t *testing.T) { - body := []byte(`{"error":{"code":429,"message":"Resource has been exhausted","status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","metadata":{"quotaResetDelay":"1h","model":"claude-sonnet-4-6"}}]}}`) - if !shouldMarkAntigravityCreditsExhausted(http.StatusTooManyRequests, body, nil) { - t.Fatalf("shouldMarkAntigravityCreditsExhausted(%s) = false, want true", string(body)) - } - }) - - if shouldMarkAntigravityCreditsExhausted(http.StatusServiceUnavailable, []byte(`{"error":{"message":"credits exhausted"}}`), nil) { - t.Fatal("shouldMarkAntigravityCreditsExhausted() = true for 5xx, want false") +func TestParseRetryDelay_HumanReadableDuration(t *testing.T) { + body := []byte(`{"error":{"message":"You have exhausted your capacity on this model. Your quota will reset after 1h43m56s."}}`) + retryAfter, err := parseRetryDelay(body) + if err != nil { + t.Fatalf("parseRetryDelay() error = %v", err) + } + if retryAfter == nil { + t.Fatal("parseRetryDelay() returned nil") + } + want := time.Hour + 43*time.Minute + 56*time.Second + if *retryAfter != want { + t.Fatalf("parseRetryDelay() = %v, want %v", *retryAfter, want) } } + func TestAntigravityExecute_RetriesTransient429ResourceExhausted(t *testing.T) { resetAntigravityCreditsRetryState() t.Cleanup(resetAntigravityCreditsRetryState) @@ -147,7 +193,7 @@ func TestAntigravityExecute_RetriesTransient429ResourceExhausted(t *testing.T) { } resp, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{ - Model: "gemini-2.5-flash", + Model: "claude-sonnet-4-6", Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`), }, cliproxyexecutor.Options{ SourceFormat: sdktranslator.FormatAntigravity, @@ -163,32 +209,18 @@ func TestAntigravityExecute_RetriesTransient429ResourceExhausted(t *testing.T) { } } -func TestAntigravityExecute_RetriesQuotaExhaustedWithCredits(t *testing.T) { +func TestAntigravityExecute_CreditsInjectedWhenConductorRequests(t *testing.T) { resetAntigravityCreditsRetryState() t.Cleanup(resetAntigravityCreditsRetryState) - var ( - mu sync.Mutex - requestBodies []string - ) - + var requestBodies []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) _ = r.Body.Close() - - mu.Lock() requestBodies = append(requestBodies, string(body)) - reqNum := len(requestBodies) - mu.Unlock() - - if reqNum == 1 { - w.WriteHeader(http.StatusTooManyRequests) - _, _ = w.Write([]byte(`{"error":{"status":"RESOURCE_EXHAUSTED","message":"QUOTA_EXHAUSTED"}}`)) - return - } if !strings.Contains(string(body), `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) { - t.Fatalf("second request body missing enabledCreditTypes: %s", string(body)) + t.Fatalf("request body missing enabledCreditTypes: %s", string(body)) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]}}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}}`)) @@ -199,7 +231,7 @@ func TestAntigravityExecute_RetriesQuotaExhaustedWithCredits(t *testing.T) { QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true}, }) auth := &cliproxyauth.Auth{ - ID: "auth-credits-ok", + ID: "auth-credits-conductor", Attributes: map[string]string{ "base_url": server.URL, }, @@ -210,8 +242,11 @@ func TestAntigravityExecute_RetriesQuotaExhaustedWithCredits(t *testing.T) { }, } - resp, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{ - Model: "gemini-2.5-flash", + // Simulate conductor setting credits requested flag in context + ctx := cliproxyauth.WithAntigravityCredits(context.Background()) + + resp, err := exec.Execute(ctx, auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-4-6", Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`), }, cliproxyexecutor.Options{ SourceFormat: sdktranslator.FormatAntigravity, @@ -222,21 +257,20 @@ func TestAntigravityExecute_RetriesQuotaExhaustedWithCredits(t *testing.T) { if len(resp.Payload) == 0 { t.Fatal("Execute() returned empty payload") } - - mu.Lock() - defer mu.Unlock() - if len(requestBodies) != 2 { - t.Fatalf("request count = %d, want 2", len(requestBodies)) + if len(requestBodies) != 1 { + t.Fatalf("request count = %d, want 1", len(requestBodies)) } } -func TestAntigravityExecute_SkipsCreditsRetryWhenAlreadyExhausted(t *testing.T) { +func TestAntigravityExecute_NoCreditsWithoutConductorFlag(t *testing.T) { resetAntigravityCreditsRetryState() t.Cleanup(resetAntigravityCreditsRetryState) - var requestCount int + var requestBodies []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requestCount++ + body, _ := io.ReadAll(r.Body) + _ = r.Body.Close() + requestBodies = append(requestBodies, string(body)) w.WriteHeader(http.StatusTooManyRequests) _, _ = w.Write([]byte(`{"error":{"status":"RESOURCE_EXHAUSTED","message":"QUOTA_EXHAUSTED"}}`)) })) @@ -246,7 +280,7 @@ func TestAntigravityExecute_SkipsCreditsRetryWhenAlreadyExhausted(t *testing.T) QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true}, }) auth := &cliproxyauth.Auth{ - ID: "auth-credits-exhausted", + ID: "auth-no-conductor-flag", Attributes: map[string]string{ "base_url": server.URL, }, @@ -256,10 +290,10 @@ func TestAntigravityExecute_SkipsCreditsRetryWhenAlreadyExhausted(t *testing.T) "expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339), }, } - recordAntigravityCreditsFailure(auth, time.Now()) + // No conductor credits flag set in context _, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{ - Model: "gemini-2.5-flash", + Model: "claude-sonnet-4-6", Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`), }, cliproxyexecutor.Options{ SourceFormat: sdktranslator.FormatAntigravity, @@ -267,224 +301,153 @@ func TestAntigravityExecute_SkipsCreditsRetryWhenAlreadyExhausted(t *testing.T) if err == nil { t.Fatal("Execute() error = nil, want 429") } - sErr, ok := err.(statusErr) - if !ok { - t.Fatalf("Execute() error type = %T, want statusErr", err) - } - if got := sErr.StatusCode(); got != http.StatusTooManyRequests { - t.Fatalf("Execute() status code = %d, want %d", got, http.StatusTooManyRequests) + if len(requestBodies) != 1 { + t.Fatalf("request count = %d, want 1", len(requestBodies)) } - if requestCount != 1 { - t.Fatalf("request count = %d, want 1", requestCount) + // Should NOT contain credits since conductor didn't request them + if strings.Contains(requestBodies[0], `"enabledCreditTypes"`) { + t.Fatalf("request should not contain enabledCreditTypes without conductor flag: %s", requestBodies[0]) } } -func TestAntigravityExecute_PrefersCreditsAfterSuccessfulFallback(t *testing.T) { - resetAntigravityCreditsRetryState() - t.Cleanup(resetAntigravityCreditsRetryState) - - var ( - mu sync.Mutex - requestBodies []string - ) +func TestAntigravityAuthHasCredits(t *testing.T) { + t.Run("sufficient balance", func(t *testing.T) { + resetAntigravityCreditsRetryState() + auth := &cliproxyauth.Auth{ID: "test-sufficient"} + antigravityCreditsBalanceByAuth.Store("test-sufficient", antigravityCreditsBalance{ + CreditAmount: 25000, + MinCreditAmount: 50, + Known: true, + }) + if !antigravityAuthHasCredits(auth) { + t.Fatal("antigravityAuthHasCredits() = false, want true") + } + }) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - _ = r.Body.Close() + t.Run("insufficient balance", func(t *testing.T) { + resetAntigravityCreditsRetryState() + auth := &cliproxyauth.Auth{ID: "test-insufficient"} + antigravityCreditsBalanceByAuth.Store("test-insufficient", antigravityCreditsBalance{ + CreditAmount: 30, + MinCreditAmount: 50, + Known: true, + }) + if antigravityAuthHasCredits(auth) { + t.Fatal("antigravityAuthHasCredits() = true, want false") + } + }) - mu.Lock() - requestBodies = append(requestBodies, string(body)) - reqNum := len(requestBodies) - mu.Unlock() + t.Run("no balance stored returns true (optimistic)", func(t *testing.T) { + resetAntigravityCreditsRetryState() + auth := &cliproxyauth.Auth{ID: "test-no-balance"} + if !antigravityAuthHasCredits(auth) { + t.Fatal("antigravityAuthHasCredits() = false with no balance stored, want true (optimistic default)") + } + }) - switch reqNum { - case 1: - w.WriteHeader(http.StatusTooManyRequests) - _, _ = w.Write([]byte(`{"error":{"status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"QUOTA_EXHAUSTED"},{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"10s"}]}}`)) - case 2, 3: - if !strings.Contains(string(body), `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) { - t.Fatalf("request %d body missing enabledCreditTypes: %s", reqNum, string(body)) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"OK"}]}}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}}`)) - default: - t.Fatalf("unexpected request count %d", reqNum) + t.Run("nil auth returns false", func(t *testing.T) { + if antigravityAuthHasCredits(nil) { + t.Fatal("antigravityAuthHasCredits(nil) = true, want false") } - })) - defer server.Close() + }) - exec := NewAntigravityExecutor(&config.Config{ - QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true}, + t.Run("empty ID returns false", func(t *testing.T) { + auth := &cliproxyauth.Auth{} + if antigravityAuthHasCredits(auth) { + t.Fatal("antigravityAuthHasCredits(empty ID) = true, want false") + } }) - auth := &cliproxyauth.Auth{ - ID: "auth-prefer-credits", - Attributes: map[string]string{ - "base_url": server.URL, - }, - Metadata: map[string]any{ - "access_token": "token", - "project_id": "project-1", - "expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339), - }, - } - request := cliproxyexecutor.Request{ - Model: "gemini-2.5-flash", - Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`), - } - opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatAntigravity} + t.Run("unknown balance returns false", func(t *testing.T) { + resetAntigravityCreditsRetryState() + auth := &cliproxyauth.Auth{ID: "test-unknown"} + antigravityCreditsBalanceByAuth.Store("test-unknown", antigravityCreditsBalance{ + Known: false, + }) + if antigravityAuthHasCredits(auth) { + t.Fatal("antigravityAuthHasCredits() = true for unknown balance, want false") + } + }) +} - if _, err := exec.Execute(context.Background(), auth, request, opts); err != nil { - t.Fatalf("first Execute() error = %v", err) - } - if _, err := exec.Execute(context.Background(), auth, request, opts); err != nil { - t.Fatalf("second Execute() error = %v", err) - } +type roundTripperFunc func(*http.Request) (*http.Response, error) - mu.Lock() - defer mu.Unlock() - if len(requestBodies) != 3 { - t.Fatalf("request count = %d, want 3", len(requestBodies)) - } - if strings.Contains(requestBodies[0], `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) { - t.Fatalf("first request unexpectedly used credits: %s", requestBodies[0]) - } - if !strings.Contains(requestBodies[1], `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) { - t.Fatalf("fallback request missing credits: %s", requestBodies[1]) - } - if !strings.Contains(requestBodies[2], `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) { - t.Fatalf("preferred request missing credits: %s", requestBodies[2]) - } +func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) } -func TestAntigravityExecute_PreservesBaseURLFallbackAfterCreditsRetryFailure(t *testing.T) { +func TestEnsureAccessToken_WarmTokenLoadsCreditsHint(t *testing.T) { resetAntigravityCreditsRetryState() t.Cleanup(resetAntigravityCreditsRetryState) - var ( - mu sync.Mutex - firstCount int - secondCount int - ) - - firstServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - _ = r.Body.Close() - - mu.Lock() - firstCount++ - reqNum := firstCount - mu.Unlock() - - switch reqNum { - case 1: - w.WriteHeader(http.StatusTooManyRequests) - _, _ = w.Write([]byte(`{"error":{"status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"QUOTA_EXHAUSTED"}]}}`)) - case 2: - if !strings.Contains(string(body), `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) { - t.Fatalf("credits retry missing enabledCreditTypes: %s", string(body)) - } - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"error":{"message":"permission denied"}}`)) - default: - t.Fatalf("unexpected first server request count %d", reqNum) - } - })) - defer firstServer.Close() - - secondServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mu.Lock() - secondCount++ - mu.Unlock() - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]}}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}}`)) - })) - defer secondServer.Close() - - exec := NewAntigravityExecutor(&config.Config{ - QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true}, - }) + exec := NewAntigravityExecutor(&config.Config{}) auth := &cliproxyauth.Auth{ - ID: "auth-baseurl-fallback", - Attributes: map[string]string{ - "base_url": firstServer.URL, - }, + ID: "auth-warm-token-credits", Metadata: map[string]any{ "access_token": "token", - "project_id": "project-1", "expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339), }, } + ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", roundTripperFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.String() != "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" { + t.Fatalf("unexpected request url %s", req.URL.String()) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"paidTier":{"id":"tier-1","availableCredits":[{"creditType":"GOOGLE_ONE_AI","creditAmount":"25000","minimumCreditAmountForUsage":"50"}]}}`)), + }, nil + })) - originalOrder := antigravityBaseURLFallbackOrder - defer func() { antigravityBaseURLFallbackOrder = originalOrder }() - antigravityBaseURLFallbackOrder = func(auth *cliproxyauth.Auth) []string { - return []string{firstServer.URL, secondServer.URL} - } - - resp, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{ - Model: "gemini-2.5-flash", - Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`), - }, cliproxyexecutor.Options{ - SourceFormat: sdktranslator.FormatAntigravity, - }) + token, updatedAuth, err := exec.ensureAccessToken(ctx, auth) if err != nil { - t.Fatalf("Execute() error = %v", err) + t.Fatalf("ensureAccessToken() error = %v", err) } - if len(resp.Payload) == 0 { - t.Fatal("Execute() returned empty payload") + if token != "token" { + t.Fatalf("ensureAccessToken() token = %q, want %q", token, "token") } - if firstCount != 2 { - t.Fatalf("first server request count = %d, want 2", firstCount) + if updatedAuth != nil { + t.Fatalf("ensureAccessToken() updatedAuth = %v, want nil", updatedAuth) } - if secondCount != 1 { - t.Fatalf("second server request count = %d, want 1", secondCount) + if !cliproxyauth.HasKnownAntigravityCreditsHint(auth.ID) { + t.Fatal("expected credits hint to be populated for warm token auth") } -} - -func TestAntigravityExecute_DoesNotDirectInjectCreditsWhenFlagDisabled(t *testing.T) { - resetAntigravityCreditsRetryState() - t.Cleanup(resetAntigravityCreditsRetryState) - - var requestBodies []string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - _ = r.Body.Close() - requestBodies = append(requestBodies, string(body)) - w.WriteHeader(http.StatusTooManyRequests) - _, _ = w.Write([]byte(`{"error":{"status":"RESOURCE_EXHAUSTED","message":"QUOTA_EXHAUSTED"}}`)) - })) - defer server.Close() - - exec := NewAntigravityExecutor(&config.Config{ - QuotaExceeded: config.QuotaExceeded{AntigravityCredits: false}, - }) - auth := &cliproxyauth.Auth{ - ID: "auth-flag-disabled", - Attributes: map[string]string{ - "base_url": server.URL, - }, - Metadata: map[string]any{ - "access_token": "token", - "project_id": "project-1", - "expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339), - }, + hint, ok := cliproxyauth.GetAntigravityCreditsHint(auth.ID) + if !ok { + t.Fatal("expected credits hint lookup to succeed") } - markAntigravityPreferCredits(auth, "gemini-2.5-flash", time.Now(), nil) - - _, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{ - Model: "gemini-2.5-flash", - Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`), - }, cliproxyexecutor.Options{ - SourceFormat: sdktranslator.FormatAntigravity, - }) - if err == nil { - t.Fatal("Execute() error = nil, want 429") + if !hint.Available { + t.Fatalf("hint.Available = %v, want true", hint.Available) } - if len(requestBodies) != 1 { - t.Fatalf("request count = %d, want 1", len(requestBodies)) + if hint.CreditAmount != 25000 || hint.MinCreditAmount != 50 { + t.Fatalf("hint amounts = (%v, %v), want (25000, 50)", hint.CreditAmount, hint.MinCreditAmount) } - if strings.Contains(requestBodies[0], `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) { - t.Fatalf("request unexpectedly used enabledCreditTypes with flag disabled: %s", requestBodies[0]) +} + +func TestParseMetaFloat(t *testing.T) { + tests := []struct { + name string + value any + wantVal float64 + wantOK bool + }{ + {"string", "25000", 25000, true}, + {"float64", float64(100), 100, true}, + {"int", int(50), 50, true}, + {"int64", int64(75), 75, true}, + {"empty string", "", 0, false}, + {"invalid string", "abc", 0, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + meta := map[string]any{"key": tt.value} + got, ok := parseMetaFloat(meta, "key") + if ok != tt.wantOK { + t.Fatalf("parseMetaFloat() ok = %v, want %v", ok, tt.wantOK) + } + if ok && got != tt.wantVal { + t.Fatalf("parseMetaFloat() = %f, want %f", got, tt.wantVal) + } + }) } } diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go index d2df610966..a18f824a62 100644 --- a/internal/runtime/executor/gemini_cli_executor.go +++ b/internal/runtime/executor/gemini_cli_executor.go @@ -898,7 +898,14 @@ func parseRetryDelay(errorBody []byte) (*time.Duration, error) { if matches := re.FindStringSubmatch(message); len(matches) > 1 { seconds, err := strconv.Atoi(matches[1]) if err == nil { - return new(time.Duration(seconds) * time.Second), nil + duration := time.Duration(seconds) * time.Second + return &duration, nil + } + } + reHuman := regexp.MustCompile(`after\s+((?:\d+h)?(?:\d+m)?(?:\d+s)?)\.?`) + if matches := reHuman.FindStringSubmatch(strings.ToLower(message)); len(matches) > 1 { + if duration, err := time.ParseDuration(matches[1]); err == nil && duration > 0 { + return &duration, nil } } } diff --git a/internal/runtime/executor/helps/logging_helpers.go b/internal/runtime/executor/helps/logging_helpers.go index 767c882016..b77ec1a999 100644 --- a/internal/runtime/executor/helps/logging_helpers.go +++ b/internal/runtime/executor/helps/logging_helpers.go @@ -24,6 +24,14 @@ const ( apiRequestKey = "API_REQUEST" apiResponseKey = "API_RESPONSE" apiWebsocketTimelineKey = "API_WEBSOCKET_TIMELINE" + + // maxErrorLogResponseBodySize limits cached response body when request-log is disabled. + // Prevents unbounded memory growth for large/streaming responses in error-only mode. + maxErrorLogResponseBodySize = 32 * 1024 // 32KB + + // maxErrorLogRequestBodySize limits materialized request body in error-only mode. + // Prevents OOM from large payloads (e.g. base64 images) when full request logging is off. + maxErrorLogRequestBodySize = 32 * 1024 // 32KB ) // UpstreamRequestLog captures the outbound upstream request details for logging. @@ -42,6 +50,7 @@ type UpstreamRequestLog struct { type upstreamAttempt struct { index int request string + deferredBody []byte // lazy body reference; only materialized on error response *strings.Builder responseIntroWritten bool statusWritten bool @@ -50,13 +59,12 @@ type upstreamAttempt struct { bodyHasContent bool prevWasSSEEvent bool errorWritten bool + bodyBytesWritten int + bodyTruncated bool } // RecordAPIRequest stores the upstream request metadata in Gin context for request logging. func RecordAPIRequest(ctx context.Context, cfg *config.Config, info UpstreamRequestLog) { - if cfg == nil || !cfg.RequestLog { - return - } ginCtx := ginContextFrom(ctx) if ginCtx == nil { return @@ -65,6 +73,8 @@ func RecordAPIRequest(ctx context.Context, cfg *config.Config, info UpstreamRequ attempts := getAttempts(ginCtx) index := len(attempts) + 1 + requestLogEnabled := cfg != nil && cfg.RequestLog + builder := &strings.Builder{} builder.WriteString(fmt.Sprintf("=== API REQUEST %d ===\n", index)) builder.WriteString(fmt.Sprintf("Timestamp: %s\n", time.Now().Format(time.RFC3339Nano))) @@ -82,10 +92,20 @@ func RecordAPIRequest(ctx context.Context, cfg *config.Config, info UpstreamRequ builder.WriteString("\nHeaders:\n") writeHeaders(builder, info.Headers) builder.WriteString("\nBody:\n") - if len(info.Body) > 0 { - builder.WriteString(string(info.Body)) + if requestLogEnabled { + // Full request logging: format body inline + if len(info.Body) > 0 { + builder.WriteString(string(info.Body)) + } else { + builder.WriteString("") + } } else { - builder.WriteString("") + // Error-only mode: defer body to avoid allocating copies for the 99% success path + if len(info.Body) > 0 { + builder.WriteString(fmt.Sprintf("", len(info.Body))) + } else { + builder.WriteString("") + } } builder.WriteString("\n\n") @@ -94,6 +114,9 @@ func RecordAPIRequest(ctx context.Context, cfg *config.Config, info UpstreamRequ request: builder.String(), response: &strings.Builder{}, } + if !requestLogEnabled && len(info.Body) > 0 { + attempt.deferredBody = info.Body + } attempts = append(attempts, attempt) ginCtx.Set(apiAttemptsKey, attempts) updateAggregatedRequest(ginCtx, attempts) @@ -101,14 +124,18 @@ func RecordAPIRequest(ctx context.Context, cfg *config.Config, info UpstreamRequ // RecordAPIResponseMetadata captures upstream response status/header information for the latest attempt. func RecordAPIResponseMetadata(ctx context.Context, cfg *config.Config, status int, headers http.Header) { - if cfg == nil || !cfg.RequestLog { - return - } ginCtx := ginContextFrom(ctx) if ginCtx == nil { return } attempts, attempt := ensureAttempt(ginCtx) + + // Materialize deferred request body when upstream returns an error. + // Success responses (2xx) skip this — their deferred body is dropped with gin context. + if status >= http.StatusBadRequest { + materializeDeferredBodies(ginCtx, attempts) + } + ensureResponseIntro(attempt) if status > 0 && !attempt.statusWritten { @@ -127,7 +154,7 @@ func RecordAPIResponseMetadata(ctx context.Context, cfg *config.Config, status i // RecordAPIResponseError adds an error entry for the latest attempt when no HTTP response is available. func RecordAPIResponseError(ctx context.Context, cfg *config.Config, err error) { - if cfg == nil || !cfg.RequestLog || err == nil { + if err == nil { return } ginCtx := ginContextFrom(ctx) @@ -135,6 +162,11 @@ func RecordAPIResponseError(ctx context.Context, cfg *config.Config, err error) return } attempts, attempt := ensureAttempt(ginCtx) + + // Materialize deferred request body on error — this is the only path that + // actually needs the body. Success path (99%) never pays for body copies. + materializeDeferredBodies(ginCtx, attempts) + ensureResponseIntro(attempt) if attempt.bodyStarted && !attempt.bodyHasContent { @@ -152,9 +184,6 @@ func RecordAPIResponseError(ctx context.Context, cfg *config.Config, err error) // AppendAPIResponseChunk appends an upstream response chunk to Gin context for request logging. func AppendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byte) { - if cfg == nil || !cfg.RequestLog { - return - } data := bytes.TrimSpace(chunk) if len(data) == 0 { return @@ -166,6 +195,11 @@ func AppendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byt attempts, attempt := ensureAttempt(ginCtx) ensureResponseIntro(attempt) + requestLogEnabled := cfg != nil && cfg.RequestLog + if !requestLogEnabled && attempt.bodyTruncated { + return + } + if !attempt.headersWritten { attempt.response.WriteString("Headers:\n") writeHeaders(attempt.response, nil) @@ -176,6 +210,22 @@ func AppendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byt attempt.response.WriteString("Body:\n") attempt.bodyStarted = true } + + // Cap response body size when full request-log is disabled to prevent memory growth + if !requestLogEnabled { + remaining := maxErrorLogResponseBodySize - attempt.bodyBytesWritten + if remaining <= 0 { + attempt.bodyTruncated = true + attempt.response.WriteString("\n") + updateAggregatedResponse(ginCtx, attempts) + return + } + if len(data) > remaining { + data = data[:remaining] + attempt.bodyTruncated = true + } + } + currentChunkIsSSEEvent := bytes.HasPrefix(data, []byte("event:")) currentChunkIsSSEData := bytes.HasPrefix(data, []byte("data:")) if attempt.bodyHasContent { @@ -186,9 +236,14 @@ func AppendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byt attempt.response.WriteString(separator) } attempt.response.WriteString(string(data)) + attempt.bodyBytesWritten += len(data) attempt.bodyHasContent = true attempt.prevWasSSEEvent = currentChunkIsSSEEvent + if attempt.bodyTruncated { + attempt.response.WriteString("\n") + } + updateAggregatedResponse(ginCtx, attempts) } @@ -332,6 +387,27 @@ func ginContextFrom(ctx context.Context) *gin.Context { return ginCtx } +const creditsUsedKey = "__antigravity_credits_used__" + +// MarkCreditsUsed flags the request as having used AI credits for billing. +func MarkCreditsUsed(ctx context.Context) { + if ginCtx := ginContextFrom(ctx); ginCtx != nil { + ginCtx.Set(creditsUsedKey, true) + } +} + +// CreditsUsed returns true if the request used AI credits. +func CreditsUsed(ctx context.Context) bool { + if ginCtx := ginContextFrom(ctx); ginCtx != nil { + if val, exists := ginCtx.Get(creditsUsedKey); exists { + if b, ok := val.(bool); ok { + return b + } + } + } + return false +} + func getAttempts(ginCtx *gin.Context) []*upstreamAttempt { if ginCtx == nil { return nil @@ -344,6 +420,34 @@ func getAttempts(ginCtx *gin.Context) []*upstreamAttempt { return nil } +// materializeDeferredBodies replaces deferred body placeholders with actual +// (truncated) body content. Called only on the error path so the 99% success +// path pays zero allocation cost for request body logging. +func materializeDeferredBodies(ginCtx *gin.Context, attempts []*upstreamAttempt) { + changed := false + for _, attempt := range attempts { + if attempt.deferredBody == nil { + continue + } + body := attempt.deferredBody + attempt.deferredBody = nil // release reference to allow GC of full payload + + placeholder := fmt.Sprintf("", len(body)) + var replacement string + if len(body) > maxErrorLogRequestBodySize { + replacement = string(body[:maxErrorLogRequestBodySize]) + + fmt.Sprintf("\n", len(body), maxErrorLogRequestBodySize) + } else { + replacement = string(body) + } + attempt.request = strings.Replace(attempt.request, placeholder, replacement, 1) + changed = true + } + if changed { + updateAggregatedRequest(ginCtx, attempts) + } +} + func ensureAttempt(ginCtx *gin.Context) ([]*upstreamAttempt, *upstreamAttempt) { attempts := getAttempts(ginCtx) if len(attempts) == 0 { diff --git a/sdk/cliproxy/auth/antigravity_credits.go b/sdk/cliproxy/auth/antigravity_credits.go new file mode 100644 index 0000000000..77b03bfd3e --- /dev/null +++ b/sdk/cliproxy/auth/antigravity_credits.go @@ -0,0 +1,90 @@ +package auth + +import ( + "context" + "strings" + "sync" + "time" +) + +type antigravityUseCreditsContextKey struct{} + +// WithAntigravityCredits returns a child context that signals the executor to +// inject enabledCreditTypes into the request payload. +func WithAntigravityCredits(ctx context.Context) context.Context { + return context.WithValue(ctx, antigravityUseCreditsContextKey{}, true) +} + +// AntigravityCreditsRequested reports whether the context carries the credits flag. +func AntigravityCreditsRequested(ctx context.Context) bool { + if ctx == nil { + return false + } + v, _ := ctx.Value(antigravityUseCreditsContextKey{}).(bool) + return v +} + +// AntigravityCreditsHint stores the latest known AI credits state for one auth. +type AntigravityCreditsHint struct { + Known bool + Available bool + CreditAmount float64 + MinCreditAmount float64 + PaidTierID string + UpdatedAt time.Time +} + +var antigravityCreditsHintByAuth sync.Map + +// SetAntigravityCreditsHint updates the latest known AI credits state for an auth. +func SetAntigravityCreditsHint(authID string, hint AntigravityCreditsHint) { + authID = strings.TrimSpace(authID) + if authID == "" { + return + } + if hint.UpdatedAt.IsZero() { + hint.UpdatedAt = time.Now() + } + antigravityCreditsHintByAuth.Store(authID, hint) +} + +// GetAntigravityCreditsHint returns the latest known AI credits state for an auth. +func GetAntigravityCreditsHint(authID string) (AntigravityCreditsHint, bool) { + authID = strings.TrimSpace(authID) + if authID == "" { + return AntigravityCreditsHint{}, false + } + value, ok := antigravityCreditsHintByAuth.Load(authID) + if !ok { + return AntigravityCreditsHint{}, false + } + hint, ok := value.(AntigravityCreditsHint) + if !ok { + antigravityCreditsHintByAuth.Delete(authID) + return AntigravityCreditsHint{}, false + } + return hint, true +} + +// HasKnownAntigravityCreditsHint reports whether credits state has been discovered for an auth. +func HasKnownAntigravityCreditsHint(authID string) bool { + hint, ok := GetAntigravityCreditsHint(authID) + return ok && hint.Known +} + +func antigravityCreditsAvailableForModel(auth *Auth, model string) bool { + if auth == nil { + return false + } + if !strings.EqualFold(strings.TrimSpace(auth.Provider), "antigravity") { + return false + } + if !strings.Contains(strings.ToLower(strings.TrimSpace(model)), "claude") { + return false + } + hint, ok := GetAntigravityCreditsHint(auth.ID) + if !ok || !hint.Known { + return false + } + return hint.Available +} diff --git a/sdk/cliproxy/auth/antigravity_credits_test.go b/sdk/cliproxy/auth/antigravity_credits_test.go new file mode 100644 index 0000000000..8f59b4c78f --- /dev/null +++ b/sdk/cliproxy/auth/antigravity_credits_test.go @@ -0,0 +1,62 @@ +package auth + +import ( + "testing" + "time" +) + +func TestIsAuthBlockedForModel_ClaudeWithCreditsStillBlockedDuringCooldown(t *testing.T) { + auth := &Auth{ + ID: "ag-1", + Provider: "antigravity", + ModelStates: map[string]*ModelState{ + "claude-sonnet-4-6": { + Unavailable: true, + NextRetryAfter: time.Now().Add(10 * time.Minute), + Quota: QuotaState{ + Exceeded: true, + NextRecoverAt: time.Now().Add(10 * time.Minute), + }, + }, + }, + } + + SetAntigravityCreditsHint(auth.ID, AntigravityCreditsHint{ + Known: true, + Available: true, + UpdatedAt: time.Now(), + }) + + blocked, reason, _ := isAuthBlockedForModel(auth, "claude-sonnet-4-6", time.Now()) + if !blocked || reason != blockReasonCooldown { + t.Fatalf("expected auth to be blocked during cooldown even with credits, got blocked=%v reason=%v", blocked, reason) + } +} + +func TestIsAuthBlockedForModel_KeepsGeminiBlockedWithoutCreditsBypass(t *testing.T) { + auth := &Auth{ + ID: "ag-2", + Provider: "antigravity", + ModelStates: map[string]*ModelState{ + "gemini-3-flash": { + Unavailable: true, + NextRetryAfter: time.Now().Add(10 * time.Minute), + Quota: QuotaState{ + Exceeded: true, + NextRecoverAt: time.Now().Add(10 * time.Minute), + }, + }, + }, + } + + SetAntigravityCreditsHint(auth.ID, AntigravityCreditsHint{ + Known: true, + Available: true, + UpdatedAt: time.Now(), + }) + + blocked, reason, _ := isAuthBlockedForModel(auth, "gemini-3-flash", time.Now()) + if !blocked || reason != blockReasonCooldown { + t.Fatalf("expected gemini auth to remain blocked, got blocked=%v reason=%v", blocked, reason) + } +} diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 0a9c157b0a..dff479df40 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -1202,12 +1202,16 @@ func (m *Manager) Execute(ctx context.Context, providers []string, req cliproxye } } if lastErr != nil { + if shouldAttemptAntigravityCreditsFallback(m, lastErr, normalized) { + if resp, ok := m.tryAntigravityCreditsExecute(ctx, req, opts); ok { + return resp, nil + } + } return cliproxyexecutor.Response{}, lastErr } return cliproxyexecutor.Response{}, &Error{Code: "auth_not_found", Message: "no auth available"} } -// ExecuteCount performs a non-streaming execution using the configured selector and executor. // It supports multiple providers for the same model and round-robins the starting provider per model. func (m *Manager) ExecuteCount(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { normalized := m.normalizeProviders(providers) @@ -1233,6 +1237,11 @@ func (m *Manager) ExecuteCount(ctx context.Context, providers []string, req clip } } if lastErr != nil { + if shouldAttemptAntigravityCreditsFallback(m, lastErr, normalized) { + if resp, ok := m.tryAntigravityCreditsExecuteCount(ctx, req, opts); ok { + return resp, nil + } + } return cliproxyexecutor.Response{}, lastErr } return cliproxyexecutor.Response{}, &Error{Code: "auth_not_found", Message: "no auth available"} @@ -1264,6 +1273,11 @@ func (m *Manager) ExecuteStream(ctx context.Context, providers []string, req cli } } if lastErr != nil { + if shouldAttemptAntigravityCreditsFallback(m, lastErr, normalized) { + if result, ok := m.tryAntigravityCreditsExecuteStream(ctx, req, opts); ok { + return result, nil + } + } return nil, lastErr } return nil, &Error{Code: "auth_not_found", Message: "no auth available"} @@ -2319,7 +2333,8 @@ func retryAfterFromError(err error) *time.Duration { if retryAfter == nil { return nil } - return new(*retryAfter) + value := *retryAfter + return &value } func statusCodeFromResult(err *Error) int { @@ -2409,11 +2424,18 @@ func isRequestInvalidError(err error) bool { status := statusCodeFromError(err) switch status { case http.StatusBadRequest: - return strings.Contains(err.Error(), "invalid_request_error") + msg := err.Error() + return strings.Contains(msg, "invalid_request_error") || + strings.Contains(msg, "INVALID_ARGUMENT") || + strings.Contains(msg, "FAILED_PRECONDITION") case http.StatusNotFound: return isRequestScopedNotFoundMessage(err.Error()) case http.StatusUnprocessableEntity: return true + case http.StatusInternalServerError: + msg := err.Error() + return strings.Contains(msg, "\"status\":\"UNKNOWN\"") || + strings.Contains(msg, "\"status\": \"UNKNOWN\"") default: return false } @@ -2886,6 +2908,193 @@ func (m *Manager) pickNextMixed(ctx context.Context, providers []string, model s return authCopy, executor, providerKey, nil } +func (m *Manager) findAllAntigravityCreditsCandidateAuths(routeModel string) []creditsCandidateEntry { + if m == nil { + return nil + } + m.mu.RLock() + defer m.mu.RUnlock() + var candidates []creditsCandidateEntry + for _, auth := range m.auths { + if auth == nil || auth.Disabled || auth.Status == StatusDisabled { + continue + } + if !antigravityCreditsAvailableForModel(auth, routeModel) { + continue + } + providerKey := strings.TrimSpace(strings.ToLower(auth.Provider)) + executor, ok := m.executors[providerKey] + if !ok { + continue + } + candidates = append(candidates, creditsCandidateEntry{ + auth: auth.Clone(), + executor: executor, + provider: providerKey, + }) + } + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].auth.ID < candidates[j].auth.ID + }) + return candidates +} + +type creditsCandidateEntry struct { + auth *Auth + executor ProviderExecutor + provider string +} + +func shouldAttemptAntigravityCreditsFallback(m *Manager, lastErr error, providers []string) bool { + if m == nil || lastErr == nil { + return false + } + if len(providers) > 0 { + hasAntigravity := false + for _, p := range providers { + if strings.EqualFold(strings.TrimSpace(p), "antigravity") { + hasAntigravity = true + break + } + } + if !hasAntigravity { + return false + } + } + cfg, _ := m.runtimeConfig.Load().(*internalconfig.Config) + if cfg == nil || !cfg.QuotaExceeded.AntigravityCredits { + return false + } + status := statusCodeFromError(lastErr) + switch status { + case http.StatusTooManyRequests, http.StatusServiceUnavailable: + return true + case 0: + var authErr *Error + if errors.As(lastErr, &authErr) && authErr != nil { + return authErr.Code == "auth_not_found" || authErr.Code == "auth_unavailable" || authErr.Code == "model_cooldown" + } + var cooldownErr *modelCooldownError + if errors.As(lastErr, &cooldownErr) { + return true + } + return false + default: + return false + } +} + +func (m *Manager) tryAntigravityCreditsExecute(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, bool) { + routeModel := req.Model + candidates := m.findAllAntigravityCreditsCandidateAuths(routeModel) + for _, c := range candidates { + if ctx.Err() != nil { + return cliproxyexecutor.Response{}, false + } + creditsCtx := WithAntigravityCredits(ctx) + if rt := m.roundTripperFor(c.auth); rt != nil { + creditsCtx = context.WithValue(creditsCtx, roundTripperContextKey{}, rt) + creditsCtx = context.WithValue(creditsCtx, "cliproxy.roundtripper", rt) + } + creditsOpts := ensureRequestedModelMetadata(opts, routeModel) + publishSelectedAuthMetadata(creditsOpts.Metadata, c.auth.ID) + models := m.executionModelCandidates(c.auth, routeModel) + if len(models) == 0 { + continue + } + for _, upstreamModel := range models { + resultModel := m.stateModelForExecution(c.auth, routeModel, upstreamModel, len(models) > 1) + execReq := req + execReq.Model = upstreamModel + resp, errExec := c.executor.Execute(creditsCtx, c.auth, execReq, creditsOpts) + result := Result{AuthID: c.auth.ID, Provider: c.provider, Model: resultModel, Success: errExec == nil} + if errExec != nil { + result.Error = &Error{Message: errExec.Error()} + if se, ok := errors.AsType[cliproxyexecutor.StatusError](errExec); ok && se != nil { + result.Error.HTTPStatus = se.StatusCode() + } + if ra := retryAfterFromError(errExec); ra != nil { + result.RetryAfter = ra + } + m.MarkResult(creditsCtx, result) + continue + } + m.MarkResult(creditsCtx, result) + return resp, true + } + } + return cliproxyexecutor.Response{}, false +} + +func (m *Manager) tryAntigravityCreditsExecuteCount(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, bool) { + routeModel := req.Model + candidates := m.findAllAntigravityCreditsCandidateAuths(routeModel) + for _, c := range candidates { + if ctx.Err() != nil { + return cliproxyexecutor.Response{}, false + } + creditsCtx := WithAntigravityCredits(ctx) + if rt := m.roundTripperFor(c.auth); rt != nil { + creditsCtx = context.WithValue(creditsCtx, roundTripperContextKey{}, rt) + creditsCtx = context.WithValue(creditsCtx, "cliproxy.roundtripper", rt) + } + creditsOpts := ensureRequestedModelMetadata(opts, routeModel) + publishSelectedAuthMetadata(creditsOpts.Metadata, c.auth.ID) + models := m.executionModelCandidates(c.auth, routeModel) + if len(models) == 0 { + continue + } + for _, upstreamModel := range models { + resultModel := m.stateModelForExecution(c.auth, routeModel, upstreamModel, len(models) > 1) + execReq := req + execReq.Model = upstreamModel + resp, errExec := c.executor.CountTokens(creditsCtx, c.auth, execReq, creditsOpts) + result := Result{AuthID: c.auth.ID, Provider: c.provider, Model: resultModel, Success: errExec == nil} + if errExec != nil { + result.Error = &Error{Message: errExec.Error()} + if se, ok := errors.AsType[cliproxyexecutor.StatusError](errExec); ok && se != nil { + result.Error.HTTPStatus = se.StatusCode() + } + if ra := retryAfterFromError(errExec); ra != nil { + result.RetryAfter = ra + } + m.MarkResult(creditsCtx, result) + continue + } + m.MarkResult(creditsCtx, result) + return resp, true + } + } + return cliproxyexecutor.Response{}, false +} + +func (m *Manager) tryAntigravityCreditsExecuteStream(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, bool) { + routeModel := req.Model + candidates := m.findAllAntigravityCreditsCandidateAuths(routeModel) + for _, c := range candidates { + if ctx.Err() != nil { + return nil, false + } + creditsCtx := WithAntigravityCredits(ctx) + if rt := m.roundTripperFor(c.auth); rt != nil { + creditsCtx = context.WithValue(creditsCtx, roundTripperContextKey{}, rt) + creditsCtx = context.WithValue(creditsCtx, "cliproxy.roundtripper", rt) + } + creditsOpts := ensureRequestedModelMetadata(opts, routeModel) + publishSelectedAuthMetadata(creditsOpts.Metadata, c.auth.ID) + models := m.executionModelCandidates(c.auth, routeModel) + if len(models) == 0 { + continue + } + result, errStream := m.executeStreamWithModelPool(creditsCtx, c.executor, c.auth, c.provider, req, creditsOpts, routeModel, models, len(models) > 1) + if errStream != nil { + continue + } + return result, true + } + return nil, false +} + func (m *Manager) persist(ctx context.Context, auth *Auth) error { if m.store == nil || auth == nil { return nil @@ -3200,14 +3409,15 @@ func (m *Manager) refreshAuth(ctx context.Context, id string) { m.mu.RLock() auth := m.auths[id] var exec ProviderExecutor + var cloned *Auth if auth != nil { exec = m.executors[auth.Provider] + cloned = auth.Clone() } m.mu.RUnlock() if auth == nil || exec == nil { return } - cloned := auth.Clone() updated, err := exec.Refresh(ctx, cloned) if err != nil && errors.Is(err, context.Canceled) { log.Debugf("refresh canceled for %s, %s", auth.Provider, auth.ID) From 4d6457e6ec6ac6f8fcc1c31190a64121a3e3ca86 Mon Sep 17 00:00:00 2001 From: XYenon Date: Thu, 23 Apr 2026 13:47:22 +0800 Subject: [PATCH 028/139] feat: support extracting X-Amp-Thread-Id header as session id for session affinity --- sdk/cliproxy/auth/selector.go | 20 ++++++++++----- sdk/cliproxy/auth/selector_test.go | 40 ++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/sdk/cliproxy/auth/selector.go b/sdk/cliproxy/auth/selector.go index 51275a3115..f49979ce49 100644 --- a/sdk/cliproxy/auth/selector.go +++ b/sdk/cliproxy/auth/selector.go @@ -570,9 +570,10 @@ func (s *SessionAffinitySelector) InvalidateAuth(authID string) { // Priority order: // 1. metadata.user_id (Claude Code format with _session_{uuid}) - highest priority for Claude Code clients // 2. X-Session-ID header -// 3. metadata.user_id (non-Claude Code format) -// 4. conversation_id field in request body -// 5. Stable hash from first few messages content (fallback) +// 3. X-Amp-Thread-Id header (Amp CLI thread ID) +// 4. metadata.user_id (non-Claude Code format) +// 5. conversation_id field in request body +// 6. Stable hash from first few messages content (fallback) func ExtractSessionID(headers http.Header, payload []byte, metadata map[string]any) string { primary, _ := extractSessionIDs(headers, payload, metadata) return primary @@ -608,22 +609,29 @@ func extractSessionIDs(headers http.Header, payload []byte, metadata map[string] } } + // 3. X-Amp-Thread-Id header (Amp CLI thread ID) + if headers != nil { + if tid := headers.Get("X-Amp-Thread-Id"); tid != "" { + return "amp:" + tid, "" + } + } + if len(payload) == 0 { return "", "" } - // 3. metadata.user_id (non-Claude Code format) + // 4. metadata.user_id (non-Claude Code format) userID := gjson.GetBytes(payload, "metadata.user_id").String() if userID != "" { return "user:" + userID, "" } - // 4. conversation_id field + // 5. conversation_id field if convID := gjson.GetBytes(payload, "conversation_id").String(); convID != "" { return "conv:" + convID, "" } - // 5. Hash-based fallback from message content + // 6. Hash-based fallback from message content return extractMessageHashIDs(payload) } diff --git a/sdk/cliproxy/auth/selector_test.go b/sdk/cliproxy/auth/selector_test.go index 560d3b9e97..c3041b5bac 100644 --- a/sdk/cliproxy/auth/selector_test.go +++ b/sdk/cliproxy/auth/selector_test.go @@ -776,6 +776,46 @@ func TestExtractSessionID_Headers(t *testing.T) { } } +func TestExtractSessionID_AmpThreadId(t *testing.T) { + t.Parallel() + + headers := make(http.Header) + headers.Set("X-Amp-Thread-Id", "T-7873e6bd-6354-4a9a-be2c-c7702c6e1b64") + + got := ExtractSessionID(headers, nil, nil) + want := "amp:T-7873e6bd-6354-4a9a-be2c-c7702c6e1b64" + if got != want { + t.Errorf("ExtractSessionID() with X-Amp-Thread-Id = %q, want %q", got, want) + } +} + +// TestExtractSessionID_AmpThreadIdLowerPriority verifies X-Amp-Thread-Id is lower +// priority than Claude Code metadata.user_id but higher than conversation_id. +func TestExtractSessionID_AmpThreadIdPriority(t *testing.T) { + t.Parallel() + + // X-Amp-Thread-Id should be used when no Claude Code user_id is present + headers := make(http.Header) + headers.Set("X-Amp-Thread-Id", "T-priority-test") + + payload := []byte(`{"conversation_id":"conv-12345"}`) + got := ExtractSessionID(headers, payload, nil) + want := "amp:T-priority-test" + if got != want { + t.Errorf("ExtractSessionID() = %q, want %q (Amp thread ID should take priority over conversation_id)", got, want) + } + + // Claude Code user_id should take priority over X-Amp-Thread-Id + headers2 := make(http.Header) + headers2.Set("X-Amp-Thread-Id", "T-priority-test") + payload2 := []byte(`{"metadata":{"user_id":"user_xxx_account__session_ac980658-63bd-4fb3-97ba-8da64cb1e344"}}`) + got2 := ExtractSessionID(headers2, payload2, nil) + want2 := "claude:ac980658-63bd-4fb3-97ba-8da64cb1e344" + if got2 != want2 { + t.Errorf("ExtractSessionID() = %q, want %q (Claude Code should take priority over Amp thread ID)", got2, want2) + } +} + // TestExtractSessionID_IdempotencyKey verifies that idempotency_key is intentionally // ignored for session affinity (it's auto-generated per-request, causing cache misses). func TestExtractSessionID_IdempotencyKey(t *testing.T) { From 4de5c29f86f3e57186140a8fec8e505476d5772a Mon Sep 17 00:00:00 2001 From: sususu98 Date: Thu, 23 Apr 2026 15:17:00 +0800 Subject: [PATCH 029/139] fix(antigravity): remove credits fallback from CountTokens, fix gofmt CountTokens upstream API does not support enabledCreditTypes, so remove the dead credits fallback path from ExecuteCount and delete the unused tryAntigravityCreditsExecuteCount method. Fix gofmt on credits test file. --- .../antigravity_executor_credits_test.go | 2 - sdk/cliproxy/auth/conductor.go | 47 ------------------- 2 files changed, 49 deletions(-) diff --git a/internal/runtime/executor/antigravity_executor_credits_test.go b/internal/runtime/executor/antigravity_executor_credits_test.go index b9c7a91fd8..9e4662cff0 100644 --- a/internal/runtime/executor/antigravity_executor_credits_test.go +++ b/internal/runtime/executor/antigravity_executor_credits_test.go @@ -127,7 +127,6 @@ func TestAntigravityShouldRetryNoCapacity_Standard503(t *testing.T) { } } - func TestInjectEnabledCreditTypes(t *testing.T) { body := []byte(`{"model":"claude-sonnet-4-6","request":{}}`) got := injectEnabledCreditTypes(body) @@ -158,7 +157,6 @@ func TestParseRetryDelay_HumanReadableDuration(t *testing.T) { } } - func TestAntigravityExecute_RetriesTransient429ResourceExhausted(t *testing.T) { resetAntigravityCreditsRetryState() t.Cleanup(resetAntigravityCreditsRetryState) diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index dff479df40..2a4ee6cbca 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -1237,11 +1237,6 @@ func (m *Manager) ExecuteCount(ctx context.Context, providers []string, req clip } } if lastErr != nil { - if shouldAttemptAntigravityCreditsFallback(m, lastErr, normalized) { - if resp, ok := m.tryAntigravityCreditsExecuteCount(ctx, req, opts); ok { - return resp, nil - } - } return cliproxyexecutor.Response{}, lastErr } return cliproxyexecutor.Response{}, &Error{Code: "auth_not_found", Message: "no auth available"} @@ -3026,48 +3021,6 @@ func (m *Manager) tryAntigravityCreditsExecute(ctx context.Context, req cliproxy return cliproxyexecutor.Response{}, false } -func (m *Manager) tryAntigravityCreditsExecuteCount(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, bool) { - routeModel := req.Model - candidates := m.findAllAntigravityCreditsCandidateAuths(routeModel) - for _, c := range candidates { - if ctx.Err() != nil { - return cliproxyexecutor.Response{}, false - } - creditsCtx := WithAntigravityCredits(ctx) - if rt := m.roundTripperFor(c.auth); rt != nil { - creditsCtx = context.WithValue(creditsCtx, roundTripperContextKey{}, rt) - creditsCtx = context.WithValue(creditsCtx, "cliproxy.roundtripper", rt) - } - creditsOpts := ensureRequestedModelMetadata(opts, routeModel) - publishSelectedAuthMetadata(creditsOpts.Metadata, c.auth.ID) - models := m.executionModelCandidates(c.auth, routeModel) - if len(models) == 0 { - continue - } - for _, upstreamModel := range models { - resultModel := m.stateModelForExecution(c.auth, routeModel, upstreamModel, len(models) > 1) - execReq := req - execReq.Model = upstreamModel - resp, errExec := c.executor.CountTokens(creditsCtx, c.auth, execReq, creditsOpts) - result := Result{AuthID: c.auth.ID, Provider: c.provider, Model: resultModel, Success: errExec == nil} - if errExec != nil { - result.Error = &Error{Message: errExec.Error()} - if se, ok := errors.AsType[cliproxyexecutor.StatusError](errExec); ok && se != nil { - result.Error.HTTPStatus = se.StatusCode() - } - if ra := retryAfterFromError(errExec); ra != nil { - result.RetryAfter = ra - } - m.MarkResult(creditsCtx, result) - continue - } - m.MarkResult(creditsCtx, result) - return resp, true - } - } - return cliproxyexecutor.Response{}, false -} - func (m *Manager) tryAntigravityCreditsExecuteStream(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, bool) { routeModel := req.Model candidates := m.findAllAntigravityCreditsCandidateAuths(routeModel) From 8e49c795f5ebddc0e9ec594c654d2aa23aeb4145 Mon Sep 17 00:00:00 2001 From: XYenon Date: Thu, 23 Apr 2026 15:26:14 +0800 Subject: [PATCH 030/139] fix: forward HTTP headers to executor Options so session affinity can read X-Amp-Thread-Id --- sdk/api/handlers/handlers.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go index 1fda8f49f0..369ab5a8dc 100644 --- a/sdk/api/handlers/handlers.go +++ b/sdk/api/handlers/handlers.go @@ -211,6 +211,19 @@ func requestExecutionMetadata(ctx context.Context) map[string]any { return meta } +// headersFromContext extracts the original HTTP request headers from the gin context +// embedded in the provided context. This allows session affinity selectors to read +// client headers like X-Amp-Thread-Id. +func headersFromContext(ctx context.Context) http.Header { + if ctx == nil { + return nil + } + if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil { + return ginCtx.Request.Header.Clone() + } + return nil +} + func pinnedAuthIDFromContext(ctx context.Context) string { if ctx == nil { return "" @@ -488,6 +501,7 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType Alt: alt, OriginalRequest: rawJSON, SourceFormat: sdktranslator.FromString(handlerType), + Headers: headersFromContext(ctx), } opts.Metadata = reqMeta resp, err := h.AuthManager.Execute(ctx, providers, req, opts) @@ -535,6 +549,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle Alt: alt, OriginalRequest: rawJSON, SourceFormat: sdktranslator.FromString(handlerType), + Headers: headersFromContext(ctx), } opts.Metadata = reqMeta resp, err := h.AuthManager.ExecuteCount(ctx, providers, req, opts) @@ -586,6 +601,7 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl Alt: alt, OriginalRequest: rawJSON, SourceFormat: sdktranslator.FromString(handlerType), + Headers: headersFromContext(ctx), } opts.Metadata = reqMeta streamResult, err := h.AuthManager.ExecuteStream(ctx, providers, req, opts) From e75daa299b49dcbe43079eb97bcbe568af13431e Mon Sep 17 00:00:00 2001 From: sususu98 Date: Thu, 23 Apr 2026 17:38:02 +0800 Subject: [PATCH 031/139] fix(antigravity): respect pinned auth in credits fallback, release deferred body on success - findAllAntigravityCreditsCandidateAuths now filters by PinnedAuthMetadataKey to prevent credential isolation violations during credits fallback - Release deferredBody reference on success path to avoid holding large payloads in memory for the lifetime of the gin context --- internal/runtime/executor/helps/logging_helpers.go | 4 ++++ sdk/cliproxy/auth/conductor.go | 10 +++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/internal/runtime/executor/helps/logging_helpers.go b/internal/runtime/executor/helps/logging_helpers.go index b77ec1a999..1292deb451 100644 --- a/internal/runtime/executor/helps/logging_helpers.go +++ b/internal/runtime/executor/helps/logging_helpers.go @@ -134,6 +134,10 @@ func RecordAPIResponseMetadata(ctx context.Context, cfg *config.Config, status i // Success responses (2xx) skip this — their deferred body is dropped with gin context. if status >= http.StatusBadRequest { materializeDeferredBodies(ginCtx, attempts) + } else { + for _, a := range attempts { + a.deferredBody = nil + } } ensureResponseIntro(attempt) diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 2a4ee6cbca..d1490e3c11 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -2903,10 +2903,11 @@ func (m *Manager) pickNextMixed(ctx context.Context, providers []string, model s return authCopy, executor, providerKey, nil } -func (m *Manager) findAllAntigravityCreditsCandidateAuths(routeModel string) []creditsCandidateEntry { +func (m *Manager) findAllAntigravityCreditsCandidateAuths(routeModel string, opts cliproxyexecutor.Options) []creditsCandidateEntry { if m == nil { return nil } + pinnedAuthID := pinnedAuthIDFromMetadata(opts.Metadata) m.mu.RLock() defer m.mu.RUnlock() var candidates []creditsCandidateEntry @@ -2914,6 +2915,9 @@ func (m *Manager) findAllAntigravityCreditsCandidateAuths(routeModel string) []c if auth == nil || auth.Disabled || auth.Status == StatusDisabled { continue } + if pinnedAuthID != "" && auth.ID != pinnedAuthID { + continue + } if !antigravityCreditsAvailableForModel(auth, routeModel) { continue } @@ -2981,7 +2985,7 @@ func shouldAttemptAntigravityCreditsFallback(m *Manager, lastErr error, provider func (m *Manager) tryAntigravityCreditsExecute(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, bool) { routeModel := req.Model - candidates := m.findAllAntigravityCreditsCandidateAuths(routeModel) + candidates := m.findAllAntigravityCreditsCandidateAuths(routeModel, opts) for _, c := range candidates { if ctx.Err() != nil { return cliproxyexecutor.Response{}, false @@ -3023,7 +3027,7 @@ func (m *Manager) tryAntigravityCreditsExecute(ctx context.Context, req cliproxy func (m *Manager) tryAntigravityCreditsExecuteStream(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, bool) { routeModel := req.Model - candidates := m.findAllAntigravityCreditsCandidateAuths(routeModel) + candidates := m.findAllAntigravityCreditsCandidateAuths(routeModel, opts) for _, c := range candidates { if ctx.Err() != nil { return nil, false From 920b6efffa7ce82e7d964a017b03033ca55c281b Mon Sep 17 00:00:00 2001 From: sususu98 Date: Thu, 23 Apr 2026 17:41:54 +0800 Subject: [PATCH 032/139] refactor(logging): strip unrelated deferred body changes, keep credits-only logging Remove deferred body optimization and maxErrorLog constants that were unrelated to credits fallback. Keep only MarkCreditsUsed/CreditsUsed helpers for flagging requests that consumed AI credits. --- .../runtime/executor/helps/logging_helpers.go | 156 ++++-------------- 1 file changed, 35 insertions(+), 121 deletions(-) diff --git a/internal/runtime/executor/helps/logging_helpers.go b/internal/runtime/executor/helps/logging_helpers.go index 1292deb451..a0b30f7099 100644 --- a/internal/runtime/executor/helps/logging_helpers.go +++ b/internal/runtime/executor/helps/logging_helpers.go @@ -24,14 +24,7 @@ const ( apiRequestKey = "API_REQUEST" apiResponseKey = "API_RESPONSE" apiWebsocketTimelineKey = "API_WEBSOCKET_TIMELINE" - - // maxErrorLogResponseBodySize limits cached response body when request-log is disabled. - // Prevents unbounded memory growth for large/streaming responses in error-only mode. - maxErrorLogResponseBodySize = 32 * 1024 // 32KB - - // maxErrorLogRequestBodySize limits materialized request body in error-only mode. - // Prevents OOM from large payloads (e.g. base64 images) when full request logging is off. - maxErrorLogRequestBodySize = 32 * 1024 // 32KB + creditsUsedKey = "__antigravity_credits_used__" ) // UpstreamRequestLog captures the outbound upstream request details for logging. @@ -50,7 +43,6 @@ type UpstreamRequestLog struct { type upstreamAttempt struct { index int request string - deferredBody []byte // lazy body reference; only materialized on error response *strings.Builder responseIntroWritten bool statusWritten bool @@ -59,12 +51,13 @@ type upstreamAttempt struct { bodyHasContent bool prevWasSSEEvent bool errorWritten bool - bodyBytesWritten int - bodyTruncated bool } // RecordAPIRequest stores the upstream request metadata in Gin context for request logging. func RecordAPIRequest(ctx context.Context, cfg *config.Config, info UpstreamRequestLog) { + if cfg == nil || !cfg.RequestLog { + return + } ginCtx := ginContextFrom(ctx) if ginCtx == nil { return @@ -73,8 +66,6 @@ func RecordAPIRequest(ctx context.Context, cfg *config.Config, info UpstreamRequ attempts := getAttempts(ginCtx) index := len(attempts) + 1 - requestLogEnabled := cfg != nil && cfg.RequestLog - builder := &strings.Builder{} builder.WriteString(fmt.Sprintf("=== API REQUEST %d ===\n", index)) builder.WriteString(fmt.Sprintf("Timestamp: %s\n", time.Now().Format(time.RFC3339Nano))) @@ -92,20 +83,10 @@ func RecordAPIRequest(ctx context.Context, cfg *config.Config, info UpstreamRequ builder.WriteString("\nHeaders:\n") writeHeaders(builder, info.Headers) builder.WriteString("\nBody:\n") - if requestLogEnabled { - // Full request logging: format body inline - if len(info.Body) > 0 { - builder.WriteString(string(info.Body)) - } else { - builder.WriteString("") - } + if len(info.Body) > 0 { + builder.WriteString(string(info.Body)) } else { - // Error-only mode: defer body to avoid allocating copies for the 99% success path - if len(info.Body) > 0 { - builder.WriteString(fmt.Sprintf("", len(info.Body))) - } else { - builder.WriteString("") - } + builder.WriteString("") } builder.WriteString("\n\n") @@ -114,9 +95,6 @@ func RecordAPIRequest(ctx context.Context, cfg *config.Config, info UpstreamRequ request: builder.String(), response: &strings.Builder{}, } - if !requestLogEnabled && len(info.Body) > 0 { - attempt.deferredBody = info.Body - } attempts = append(attempts, attempt) ginCtx.Set(apiAttemptsKey, attempts) updateAggregatedRequest(ginCtx, attempts) @@ -124,22 +102,14 @@ func RecordAPIRequest(ctx context.Context, cfg *config.Config, info UpstreamRequ // RecordAPIResponseMetadata captures upstream response status/header information for the latest attempt. func RecordAPIResponseMetadata(ctx context.Context, cfg *config.Config, status int, headers http.Header) { + if cfg == nil || !cfg.RequestLog { + return + } ginCtx := ginContextFrom(ctx) if ginCtx == nil { return } attempts, attempt := ensureAttempt(ginCtx) - - // Materialize deferred request body when upstream returns an error. - // Success responses (2xx) skip this — their deferred body is dropped with gin context. - if status >= http.StatusBadRequest { - materializeDeferredBodies(ginCtx, attempts) - } else { - for _, a := range attempts { - a.deferredBody = nil - } - } - ensureResponseIntro(attempt) if status > 0 && !attempt.statusWritten { @@ -158,7 +128,7 @@ func RecordAPIResponseMetadata(ctx context.Context, cfg *config.Config, status i // RecordAPIResponseError adds an error entry for the latest attempt when no HTTP response is available. func RecordAPIResponseError(ctx context.Context, cfg *config.Config, err error) { - if err == nil { + if cfg == nil || !cfg.RequestLog || err == nil { return } ginCtx := ginContextFrom(ctx) @@ -166,11 +136,6 @@ func RecordAPIResponseError(ctx context.Context, cfg *config.Config, err error) return } attempts, attempt := ensureAttempt(ginCtx) - - // Materialize deferred request body on error — this is the only path that - // actually needs the body. Success path (99%) never pays for body copies. - materializeDeferredBodies(ginCtx, attempts) - ensureResponseIntro(attempt) if attempt.bodyStarted && !attempt.bodyHasContent { @@ -188,6 +153,9 @@ func RecordAPIResponseError(ctx context.Context, cfg *config.Config, err error) // AppendAPIResponseChunk appends an upstream response chunk to Gin context for request logging. func AppendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byte) { + if cfg == nil || !cfg.RequestLog { + return + } data := bytes.TrimSpace(chunk) if len(data) == 0 { return @@ -199,11 +167,6 @@ func AppendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byt attempts, attempt := ensureAttempt(ginCtx) ensureResponseIntro(attempt) - requestLogEnabled := cfg != nil && cfg.RequestLog - if !requestLogEnabled && attempt.bodyTruncated { - return - } - if !attempt.headersWritten { attempt.response.WriteString("Headers:\n") writeHeaders(attempt.response, nil) @@ -214,22 +177,6 @@ func AppendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byt attempt.response.WriteString("Body:\n") attempt.bodyStarted = true } - - // Cap response body size when full request-log is disabled to prevent memory growth - if !requestLogEnabled { - remaining := maxErrorLogResponseBodySize - attempt.bodyBytesWritten - if remaining <= 0 { - attempt.bodyTruncated = true - attempt.response.WriteString("\n") - updateAggregatedResponse(ginCtx, attempts) - return - } - if len(data) > remaining { - data = data[:remaining] - attempt.bodyTruncated = true - } - } - currentChunkIsSSEEvent := bytes.HasPrefix(data, []byte("event:")) currentChunkIsSSEData := bytes.HasPrefix(data, []byte("data:")) if attempt.bodyHasContent { @@ -240,14 +187,9 @@ func AppendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byt attempt.response.WriteString(separator) } attempt.response.WriteString(string(data)) - attempt.bodyBytesWritten += len(data) attempt.bodyHasContent = true attempt.prevWasSSEEvent = currentChunkIsSSEEvent - if attempt.bodyTruncated { - attempt.response.WriteString("\n") - } - updateAggregatedResponse(ginCtx, attempts) } @@ -391,27 +333,6 @@ func ginContextFrom(ctx context.Context) *gin.Context { return ginCtx } -const creditsUsedKey = "__antigravity_credits_used__" - -// MarkCreditsUsed flags the request as having used AI credits for billing. -func MarkCreditsUsed(ctx context.Context) { - if ginCtx := ginContextFrom(ctx); ginCtx != nil { - ginCtx.Set(creditsUsedKey, true) - } -} - -// CreditsUsed returns true if the request used AI credits. -func CreditsUsed(ctx context.Context) bool { - if ginCtx := ginContextFrom(ctx); ginCtx != nil { - if val, exists := ginCtx.Get(creditsUsedKey); exists { - if b, ok := val.(bool); ok { - return b - } - } - } - return false -} - func getAttempts(ginCtx *gin.Context) []*upstreamAttempt { if ginCtx == nil { return nil @@ -424,34 +345,6 @@ func getAttempts(ginCtx *gin.Context) []*upstreamAttempt { return nil } -// materializeDeferredBodies replaces deferred body placeholders with actual -// (truncated) body content. Called only on the error path so the 99% success -// path pays zero allocation cost for request body logging. -func materializeDeferredBodies(ginCtx *gin.Context, attempts []*upstreamAttempt) { - changed := false - for _, attempt := range attempts { - if attempt.deferredBody == nil { - continue - } - body := attempt.deferredBody - attempt.deferredBody = nil // release reference to allow GC of full payload - - placeholder := fmt.Sprintf("", len(body)) - var replacement string - if len(body) > maxErrorLogRequestBodySize { - replacement = string(body[:maxErrorLogRequestBodySize]) + - fmt.Sprintf("\n", len(body), maxErrorLogRequestBodySize) - } else { - replacement = string(body) - } - attempt.request = strings.Replace(attempt.request, placeholder, replacement, 1) - changed = true - } - if changed { - updateAggregatedRequest(ginCtx, attempts) - } -} - func ensureAttempt(ginCtx *gin.Context) ([]*upstreamAttempt, *upstreamAttempt) { attempts := getAttempts(ginCtx) if len(attempts) == 0 { @@ -676,3 +569,24 @@ func LogWithRequestID(ctx context.Context) *log.Entry { } return log.WithField("request_id", requestID) } + +// MarkCreditsUsed flags the request as having used AI credits for billing. +func MarkCreditsUsed(ctx context.Context) { + ginCtx := ginContextFrom(ctx) + if ginCtx != nil { + ginCtx.Set(creditsUsedKey, true) + } +} + +// CreditsUsed returns true if the request used AI credits. +func CreditsUsed(ctx context.Context) bool { + ginCtx := ginContextFrom(ctx) + if ginCtx != nil { + if val, exists := ginCtx.Get(creditsUsedKey); exists { + if b, ok := val.(bool); ok { + return b + } + } + } + return false +} From f130846ec17f28f4c9d85214c941c1bc8d2adacc Mon Sep 17 00:00:00 2001 From: sususu98 Date: Thu, 23 Apr 2026 22:47:51 +0800 Subject: [PATCH 033/139] fix(auth): break credits cold-start deadlock by keeping unknown-hint auths as fallback candidates Replace antigravityCreditsAvailableForModel with inline known/unknown split. Auths whose credit hints are not yet populated are kept as lower-priority candidates instead of being rejected, breaking the chicken-and-egg deadlock at cold start. --- sdk/cliproxy/auth/conductor.go | 32 ++++++++-- .../auth/conductor_credits_candidates_test.go | 61 +++++++++++++++++++ 2 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 sdk/cliproxy/auth/conductor_credits_candidates_test.go diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index d1490e3c11..4d37581a61 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -2910,7 +2910,8 @@ func (m *Manager) findAllAntigravityCreditsCandidateAuths(routeModel string, opt pinnedAuthID := pinnedAuthIDFromMetadata(opts.Metadata) m.mu.RLock() defer m.mu.RUnlock() - var candidates []creditsCandidateEntry + var known []creditsCandidateEntry + var unknown []creditsCandidateEntry for _, auth := range m.auths { if auth == nil || auth.Disabled || auth.Status == StatusDisabled { continue @@ -2918,7 +2919,10 @@ func (m *Manager) findAllAntigravityCreditsCandidateAuths(routeModel string, opt if pinnedAuthID != "" && auth.ID != pinnedAuthID { continue } - if !antigravityCreditsAvailableForModel(auth, routeModel) { + if !strings.EqualFold(strings.TrimSpace(auth.Provider), "antigravity") { + continue + } + if !strings.Contains(strings.ToLower(strings.TrimSpace(routeModel)), "claude") { continue } providerKey := strings.TrimSpace(strings.ToLower(auth.Provider)) @@ -2926,16 +2930,32 @@ func (m *Manager) findAllAntigravityCreditsCandidateAuths(routeModel string, opt if !ok { continue } - candidates = append(candidates, creditsCandidateEntry{ + + hint, okHint := GetAntigravityCreditsHint(auth.ID) + if okHint && hint.Known { + if !hint.Available { + continue + } + known = append(known, creditsCandidateEntry{ + auth: auth.Clone(), + executor: executor, + provider: providerKey, + }) + continue + } + unknown = append(unknown, creditsCandidateEntry{ auth: auth.Clone(), executor: executor, provider: providerKey, }) } - sort.Slice(candidates, func(i, j int) bool { - return candidates[i].auth.ID < candidates[j].auth.ID + sort.Slice(known, func(i, j int) bool { + return known[i].auth.ID < known[j].auth.ID + }) + sort.Slice(unknown, func(i, j int) bool { + return unknown[i].auth.ID < unknown[j].auth.ID }) - return candidates + return append(known, unknown...) } type creditsCandidateEntry struct { diff --git a/sdk/cliproxy/auth/conductor_credits_candidates_test.go b/sdk/cliproxy/auth/conductor_credits_candidates_test.go new file mode 100644 index 0000000000..e66798acf6 --- /dev/null +++ b/sdk/cliproxy/auth/conductor_credits_candidates_test.go @@ -0,0 +1,61 @@ +package auth + +import ( + "testing" + "time" + + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" +) + +func TestFindAllAntigravityCreditsCandidateAuths_PrefersKnownCreditsThenUnknown(t *testing.T) { + m := &Manager{ + auths: map[string]*Auth{ + "zz-credits": {ID: "zz-credits", Provider: "antigravity"}, + "aa-unknown": {ID: "aa-unknown", Provider: "antigravity"}, + "mm-no": {ID: "mm-no", Provider: "antigravity"}, + }, + executors: map[string]ProviderExecutor{ + "antigravity": schedulerTestExecutor{}, + }, + } + + SetAntigravityCreditsHint("zz-credits", AntigravityCreditsHint{ + Known: true, + Available: true, + UpdatedAt: time.Now(), + }) + SetAntigravityCreditsHint("mm-no", AntigravityCreditsHint{ + Known: true, + Available: false, + UpdatedAt: time.Now(), + }) + + opts := cliproxyexecutor.Options{} + + candidates := m.findAllAntigravityCreditsCandidateAuths("claude-sonnet-4-6", opts) + if len(candidates) != 2 { + t.Fatalf("candidates len = %d, want 2", len(candidates)) + } + if candidates[0].auth.ID != "zz-credits" { + t.Fatalf("candidates[0].auth.ID = %q, want %q", candidates[0].auth.ID, "zz-credits") + } + if candidates[1].auth.ID != "aa-unknown" { + t.Fatalf("candidates[1].auth.ID = %q, want %q", candidates[1].auth.ID, "aa-unknown") + } + + nonClaude := m.findAllAntigravityCreditsCandidateAuths("gemini-3-flash", opts) + if len(nonClaude) != 0 { + t.Fatalf("nonClaude len = %d, want 0", len(nonClaude)) + } + + pinnedOpts := cliproxyexecutor.Options{ + Metadata: map[string]any{cliproxyexecutor.PinnedAuthMetadataKey: "aa-unknown"}, + } + pinned := m.findAllAntigravityCreditsCandidateAuths("claude-sonnet-4-6", pinnedOpts) + if len(pinned) != 1 { + t.Fatalf("pinned len = %d, want 1", len(pinned)) + } + if pinned[0].auth.ID != "aa-unknown" { + t.Fatalf("pinned[0].auth.ID = %q, want %q", pinned[0].auth.ID, "aa-unknown") + } +} From 7ad19000411982cd066e74e6f4e4c33776335614 Mon Sep 17 00:00:00 2001 From: sususu98 Date: Thu, 23 Apr 2026 23:58:10 +0800 Subject: [PATCH 034/139] perf(antigravity): async credits hint refresh for warm tokens --- .../runtime/executor/antigravity_executor.go | 69 ++++++++++++++++++- .../antigravity_executor_credits_test.go | 9 ++- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 633373d29c..6983bface5 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -52,6 +52,8 @@ const ( defaultAntigravityAgent = "antigravity/1.21.9 darwin/arm64" // fallback only; overridden at runtime by misc.AntigravityUserAgent() antigravityAuthType = "antigravity" refreshSkew = 3000 * time.Second + antigravityCreditsHintRefreshInterval = 10 * time.Minute + antigravityCreditsHintRefreshTimeout = 5 * time.Second antigravityShortQuotaCooldownThreshold = 5 * time.Minute antigravityInstantRetryThreshold = 3 * time.Second // systemInstruction = "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**" @@ -89,6 +91,7 @@ var ( antigravityCreditsFailureByAuth sync.Map antigravityShortCooldownByAuth sync.Map antigravityCreditsBalanceByAuth sync.Map // auth.ID → antigravityCreditsBalance + antigravityCreditsHintRefreshByID sync.Map // auth.ID → *antigravityCreditsHintRefreshState antigravityQuotaExhaustedKeywords = []string{ "quota_exhausted", "quota exhausted", @@ -102,6 +105,11 @@ type antigravityCreditsBalance struct { Known bool } +type antigravityCreditsHintRefreshState struct { + mu sync.Mutex + lastAttempt time.Time +} + func antigravityAuthHasCredits(auth *cliproxyauth.Auth) bool { if auth == nil || strings.TrimSpace(auth.ID) == "" { return false @@ -1558,9 +1566,7 @@ func (e *AntigravityExecutor) ensureAccessToken(ctx context.Context, auth *clipr accessToken := metaStringValue(auth.Metadata, "access_token") expiry := tokenExpiry(auth.Metadata) if accessToken != "" && expiry.After(time.Now().Add(refreshSkew)) { - if !cliproxyauth.HasKnownAntigravityCreditsHint(auth.ID) { - e.updateAntigravityCreditsBalance(ctx, auth, accessToken) - } + e.maybeRefreshAntigravityCreditsHint(ctx, auth, accessToken) return accessToken, nil, nil } refreshCtx := context.Background() @@ -1576,6 +1582,63 @@ func (e *AntigravityExecutor) ensureAccessToken(ctx context.Context, auth *clipr return metaStringValue(updated.Metadata, "access_token"), updated, nil } +func (e *AntigravityExecutor) maybeRefreshAntigravityCreditsHint(ctx context.Context, auth *cliproxyauth.Auth, accessToken string) { + if e == nil || auth == nil || !antigravityCreditsRetryEnabled(e.cfg) { + return + } + if ctx != nil && ctx.Err() != nil { + return + } + authID := strings.TrimSpace(auth.ID) + if authID == "" { + return + } + if hint, ok := cliproxyauth.GetAntigravityCreditsHint(authID); ok && hint.Known { + return + } + if strings.TrimSpace(accessToken) == "" { + accessToken = metaStringValue(auth.Metadata, "access_token") + } + if strings.TrimSpace(accessToken) == "" { + return + } + + state := &antigravityCreditsHintRefreshState{} + if existing, loaded := antigravityCreditsHintRefreshByID.LoadOrStore(authID, state); loaded { + if cast, ok := existing.(*antigravityCreditsHintRefreshState); ok && cast != nil { + state = cast + } else { + antigravityCreditsHintRefreshByID.Delete(authID) + antigravityCreditsHintRefreshByID.Store(authID, state) + } + } + + now := time.Now() + if !state.mu.TryLock() { + return + } + if !state.lastAttempt.IsZero() && now.Sub(state.lastAttempt) < antigravityCreditsHintRefreshInterval { + state.mu.Unlock() + return + } + state.lastAttempt = now + + refreshCtx := context.Background() + if ctx != nil { + if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil { + refreshCtx = context.WithValue(refreshCtx, "cliproxy.roundtripper", rt) + } + } + refreshCtx, cancel := context.WithTimeout(refreshCtx, antigravityCreditsHintRefreshTimeout) + authCopy := auth.Clone() + + go func(state *antigravityCreditsHintRefreshState, auth *cliproxyauth.Auth, token string) { + defer cancel() + defer state.mu.Unlock() + e.updateAntigravityCreditsBalance(refreshCtx, auth, token) + }(state, authCopy, accessToken) +} + func (e *AntigravityExecutor) refreshToken(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { if auth == nil { return nil, statusErr{code: http.StatusUnauthorized, msg: "missing auth"} diff --git a/internal/runtime/executor/antigravity_executor_credits_test.go b/internal/runtime/executor/antigravity_executor_credits_test.go index 9e4662cff0..6e38223e50 100644 --- a/internal/runtime/executor/antigravity_executor_credits_test.go +++ b/internal/runtime/executor/antigravity_executor_credits_test.go @@ -20,6 +20,7 @@ func resetAntigravityCreditsRetryState() { antigravityCreditsFailureByAuth = sync.Map{} antigravityShortCooldownByAuth = sync.Map{} antigravityCreditsBalanceByAuth = sync.Map{} + antigravityCreditsHintRefreshByID = sync.Map{} } func TestClassifyAntigravity429(t *testing.T) { @@ -378,7 +379,9 @@ func TestEnsureAccessToken_WarmTokenLoadsCreditsHint(t *testing.T) { resetAntigravityCreditsRetryState() t.Cleanup(resetAntigravityCreditsRetryState) - exec := NewAntigravityExecutor(&config.Config{}) + exec := NewAntigravityExecutor(&config.Config{ + QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true}, + }) auth := &cliproxyauth.Auth{ ID: "auth-warm-token-credits", Metadata: map[string]any{ @@ -407,6 +410,10 @@ func TestEnsureAccessToken_WarmTokenLoadsCreditsHint(t *testing.T) { if updatedAuth != nil { t.Fatalf("ensureAccessToken() updatedAuth = %v, want nil", updatedAuth) } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && !cliproxyauth.HasKnownAntigravityCreditsHint(auth.ID) { + time.Sleep(10 * time.Millisecond) + } if !cliproxyauth.HasKnownAntigravityCreditsHint(auth.ID) { t.Fatal("expected credits hint to be populated for warm token auth") } From 25137b1984e6f8ebb7f8182b49beb2a254be26e7 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 24 Apr 2026 00:11:42 +0800 Subject: [PATCH 035/139] feat(logging): add AI API path support for image routes - Included `/v1/images` in AI API path prefixes. - Introduced tests to validate `/v1/images/generations` and `/v1/images/edits` as AI API paths. --- internal/logging/gin_logger.go | 1 + internal/logging/gin_logger_test.go | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/internal/logging/gin_logger.go b/internal/logging/gin_logger.go index b94d7afe6d..d92ae985e5 100644 --- a/internal/logging/gin_logger.go +++ b/internal/logging/gin_logger.go @@ -20,6 +20,7 @@ import ( var aiAPIPrefixes = []string{ "/v1/chat/completions", "/v1/completions", + "/v1/images", "/v1/messages", "/v1/responses", "/v1beta/models/", diff --git a/internal/logging/gin_logger_test.go b/internal/logging/gin_logger_test.go index 7de1833865..9bd3ddfba6 100644 --- a/internal/logging/gin_logger_test.go +++ b/internal/logging/gin_logger_test.go @@ -58,3 +58,12 @@ func TestGinLogrusRecoveryHandlesRegularPanic(t *testing.T) { t.Fatalf("expected 500, got %d", recorder.Code) } } + +func TestIsAIAPIPathIncludesImages(t *testing.T) { + if !isAIAPIPath("/v1/images/generations") { + t.Fatalf("expected /v1/images/generations to be treated as AI API path") + } + if !isAIAPIPath("/v1/images/edits") { + t.Fatalf("expected /v1/images/edits to be treated as AI API path") + } +} From 7d5f6d93828fc2f436dce984e30f1489d40bdcd8 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 24 Apr 2026 02:43:12 +0800 Subject: [PATCH 036/139] feat(models): add GPT-5.5 model entry to registry JSON --- internal/registry/models/models.json | 92 ++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json index 24b96ca95f..f98579373f 100644 --- a/internal/registry/models/models.json +++ b/internal/registry/models/models.json @@ -1292,6 +1292,29 @@ "xhigh" ] } + }, + { + "id": "gpt-5.5", + "object": "model", + "created": 1776902400, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.5", + "version": "gpt-5.5", + "description": "Stable version of GPT 5.5", + "context_length": 1050000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } } ], "codex-team": [ @@ -1387,6 +1410,29 @@ "xhigh" ] } + }, + { + "id": "gpt-5.5", + "object": "model", + "created": 1776902400, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.5", + "version": "gpt-5.5", + "description": "Stable version of GPT 5.5", + "context_length": 1050000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } } ], "codex-plus": [ @@ -1505,6 +1551,29 @@ "xhigh" ] } + }, + { + "id": "gpt-5.5", + "object": "model", + "created": 1776902400, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.5", + "version": "gpt-5.5", + "description": "Stable version of GPT 5.5", + "context_length": 1050000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } } ], "codex-pro": [ @@ -1623,6 +1692,29 @@ "xhigh" ] } + }, + { + "id": "gpt-5.5", + "object": "model", + "created": 1776902400, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.5", + "version": "gpt-5.5", + "description": "Stable version of GPT 5.5", + "context_length": 1050000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } } ], "kimi": [ From 736018a0b071c48e6e5a13034e92694f301c0b0d Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Thu, 23 Apr 2026 13:28:03 -0600 Subject: [PATCH 037/139] Add GPT-5.5 Codex model support --- internal/registry/model_definitions_test.go | 88 +++++++++++++++++++++ internal/registry/models/models.json | 16 ++-- 2 files changed, 96 insertions(+), 8 deletions(-) create mode 100644 internal/registry/model_definitions_test.go diff --git a/internal/registry/model_definitions_test.go b/internal/registry/model_definitions_test.go new file mode 100644 index 0000000000..7a0630c28d --- /dev/null +++ b/internal/registry/model_definitions_test.go @@ -0,0 +1,88 @@ +package registry + +import "testing" + +func TestCodexStaticModelsIncludeGPT55(t *testing.T) { + tierModels := map[string][]*ModelInfo{ + "free": GetCodexFreeModels(), + "team": GetCodexTeamModels(), + "plus": GetCodexPlusModels(), + "pro": GetCodexProModels(), + } + + for tier, models := range tierModels { + t.Run(tier, func(t *testing.T) { + model := findModelInfo(models, "gpt-5.5") + if model == nil { + t.Fatalf("expected codex %s tier to include gpt-5.5", tier) + } + assertGPT55ModelInfo(t, tier, model) + }) + } + + model := LookupStaticModelInfo("gpt-5.5") + if model == nil { + t.Fatal("expected LookupStaticModelInfo to find gpt-5.5") + } + assertGPT55ModelInfo(t, "lookup", model) +} + +func findModelInfo(models []*ModelInfo, id string) *ModelInfo { + for _, model := range models { + if model != nil && model.ID == id { + return model + } + } + return nil +} + +func assertGPT55ModelInfo(t *testing.T, source string, model *ModelInfo) { + t.Helper() + + if model.ID != "gpt-5.5" { + t.Fatalf("%s id mismatch: got %q", source, model.ID) + } + if model.Object != "model" { + t.Fatalf("%s object mismatch: got %q", source, model.Object) + } + if model.Created != 1776902400 { + t.Fatalf("%s created timestamp mismatch: got %d", source, model.Created) + } + if model.OwnedBy != "openai" { + t.Fatalf("%s owned_by mismatch: got %q", source, model.OwnedBy) + } + if model.Type != "openai" { + t.Fatalf("%s type mismatch: got %q", source, model.Type) + } + if model.DisplayName != "GPT 5.5" { + t.Fatalf("%s display name mismatch: got %q", source, model.DisplayName) + } + if model.Version != "gpt-5.5" { + t.Fatalf("%s version mismatch: got %q", source, model.Version) + } + if model.Description != "Frontier model for complex coding, research, and real-world work." { + t.Fatalf("%s description mismatch: got %q", source, model.Description) + } + if model.ContextLength != 272000 { + t.Fatalf("%s context length mismatch: got %d", source, model.ContextLength) + } + if model.MaxCompletionTokens != 128000 { + t.Fatalf("%s max completion tokens mismatch: got %d", source, model.MaxCompletionTokens) + } + if len(model.SupportedParameters) != 1 || model.SupportedParameters[0] != "tools" { + t.Fatalf("%s supported parameters mismatch: got %v", source, model.SupportedParameters) + } + if model.Thinking == nil { + t.Fatalf("%s missing thinking support", source) + } + + want := []string{"low", "medium", "high", "xhigh"} + if len(model.Thinking.Levels) != len(want) { + t.Fatalf("%s thinking level count mismatch: got %d, want %d", source, len(model.Thinking.Levels), len(want)) + } + for i, level := range want { + if model.Thinking.Levels[i] != level { + t.Fatalf("%s thinking level %d mismatch: got %q, want %q", source, i, model.Thinking.Levels[i], level) + } + } +} diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json index f98579373f..bf1d1bb1f3 100644 --- a/internal/registry/models/models.json +++ b/internal/registry/models/models.json @@ -1301,8 +1301,8 @@ "type": "openai", "display_name": "GPT 5.5", "version": "gpt-5.5", - "description": "Stable version of GPT 5.5", - "context_length": 1050000, + "description": "Frontier model for complex coding, research, and real-world work.", + "context_length": 272000, "max_completion_tokens": 128000, "supported_parameters": [ "tools" @@ -1419,8 +1419,8 @@ "type": "openai", "display_name": "GPT 5.5", "version": "gpt-5.5", - "description": "Stable version of GPT 5.5", - "context_length": 1050000, + "description": "Frontier model for complex coding, research, and real-world work.", + "context_length": 272000, "max_completion_tokens": 128000, "supported_parameters": [ "tools" @@ -1560,8 +1560,8 @@ "type": "openai", "display_name": "GPT 5.5", "version": "gpt-5.5", - "description": "Stable version of GPT 5.5", - "context_length": 1050000, + "description": "Frontier model for complex coding, research, and real-world work.", + "context_length": 272000, "max_completion_tokens": 128000, "supported_parameters": [ "tools" @@ -1701,8 +1701,8 @@ "type": "openai", "display_name": "GPT 5.5", "version": "gpt-5.5", - "description": "Stable version of GPT 5.5", - "context_length": 1050000, + "description": "Frontier model for complex coding, research, and real-world work.", + "context_length": 272000, "max_completion_tokens": 128000, "supported_parameters": [ "tools" From 7b89583cf86d04bdec931cf944343c51cb4b0e39 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 24 Apr 2026 05:07:03 +0800 Subject: [PATCH 038/139] chore(models): remove GPT-5.5 model entry from registry JSON --- internal/registry/models/models.json | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json index bf1d1bb1f3..a1abb5a381 100644 --- a/internal/registry/models/models.json +++ b/internal/registry/models/models.json @@ -1292,29 +1292,6 @@ "xhigh" ] } - }, - { - "id": "gpt-5.5", - "object": "model", - "created": 1776902400, - "owned_by": "openai", - "type": "openai", - "display_name": "GPT 5.5", - "version": "gpt-5.5", - "description": "Frontier model for complex coding, research, and real-world work.", - "context_length": 272000, - "max_completion_tokens": 128000, - "supported_parameters": [ - "tools" - ], - "thinking": { - "levels": [ - "low", - "medium", - "high", - "xhigh" - ] - } } ], "codex-team": [ From f1ba6151a99240902bcda12102c921b0ead01d2d Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 24 Apr 2026 07:21:03 +0800 Subject: [PATCH 039/139] feat(codex): pass base model to enable conditional image_generation tool injection - Modified `ensureImageGenerationTool` to accept `baseModel` for conditional logic. - Ensured `gpt-5.3-codex-spark` models bypass image_generation tool injection. - Updated relevant tests and executor logic to reflect changes. --- internal/runtime/executor/codex_executor.go | 12 ++++++---- .../executor/codex_executor_imagegen_test.go | 22 ++++++++++++++----- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 543e2c2779..38667231aa 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -180,7 +180,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re body, _ = sjson.DeleteBytes(body, "safety_identifier") body, _ = sjson.DeleteBytes(body, "stream_options") body = normalizeCodexInstructions(body) - body = ensureImageGenerationTool(body) + body = ensureImageGenerationTool(body, baseModel) url := strings.TrimSuffix(baseURL, "/") + "/responses" httpReq, err := e.cacheHelper(ctx, from, url, req, body) @@ -327,7 +327,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A body, _ = sjson.SetBytes(body, "model", baseModel) body, _ = sjson.DeleteBytes(body, "stream") body = normalizeCodexInstructions(body) - body = ensureImageGenerationTool(body) + body = ensureImageGenerationTool(body, baseModel) url := strings.TrimSuffix(baseURL, "/") + "/responses/compact" httpReq, err := e.cacheHelper(ctx, from, url, req, body) @@ -422,7 +422,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au body, _ = sjson.DeleteBytes(body, "stream_options") body, _ = sjson.SetBytes(body, "model", baseModel) body = normalizeCodexInstructions(body) - body = ensureImageGenerationTool(body) + body = ensureImageGenerationTool(body, baseModel) url := strings.TrimSuffix(baseURL, "/") + "/responses" httpReq, err := e.cacheHelper(ctx, from, url, req, body) @@ -827,7 +827,11 @@ func normalizeCodexInstructions(body []byte) []byte { var imageGenToolJSON = []byte(`{"type":"image_generation","output_format":"png"}`) var imageGenToolArrayJSON = []byte(`[{"type":"image_generation","output_format":"png"}]`) -func ensureImageGenerationTool(body []byte) []byte { +func ensureImageGenerationTool(body []byte, baseModel string) []byte { + if strings.HasSuffix(baseModel, "spark") { + return body + } + tools := gjson.GetBytes(body, "tools") if !tools.Exists() || !tools.IsArray() { body, _ = sjson.SetRawBytes(body, "tools", imageGenToolArrayJSON) diff --git a/internal/runtime/executor/codex_executor_imagegen_test.go b/internal/runtime/executor/codex_executor_imagegen_test.go index 43f42adee8..5e67c598a4 100644 --- a/internal/runtime/executor/codex_executor_imagegen_test.go +++ b/internal/runtime/executor/codex_executor_imagegen_test.go @@ -8,7 +8,7 @@ import ( func TestEnsureImageGenerationTool_NoTools(t *testing.T) { body := []byte(`{"model":"gpt-5.4","input":"draw a cat"}`) - result := ensureImageGenerationTool(body) + result := ensureImageGenerationTool(body, "gpt-5.4") tools := gjson.GetBytes(result, "tools") if !tools.IsArray() { @@ -28,7 +28,7 @@ func TestEnsureImageGenerationTool_NoTools(t *testing.T) { func TestEnsureImageGenerationTool_ExistingToolsWithoutImageGen(t *testing.T) { body := []byte(`{"model":"gpt-5.4","tools":[{"type":"function","name":"get_weather","parameters":{}}]}`) - result := ensureImageGenerationTool(body) + result := ensureImageGenerationTool(body, "gpt-5.4") tools := gjson.GetBytes(result, "tools") arr := tools.Array() @@ -45,7 +45,7 @@ func TestEnsureImageGenerationTool_ExistingToolsWithoutImageGen(t *testing.T) { func TestEnsureImageGenerationTool_AlreadyPresent(t *testing.T) { body := []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation","output_format":"webp"},{"type":"function","name":"f1"}]}`) - result := ensureImageGenerationTool(body) + result := ensureImageGenerationTool(body, "gpt-5.4") tools := gjson.GetBytes(result, "tools") arr := tools.Array() @@ -59,7 +59,7 @@ func TestEnsureImageGenerationTool_AlreadyPresent(t *testing.T) { func TestEnsureImageGenerationTool_EmptyToolsArray(t *testing.T) { body := []byte(`{"model":"gpt-5.4","tools":[]}`) - result := ensureImageGenerationTool(body) + result := ensureImageGenerationTool(body, "gpt-5.4") tools := gjson.GetBytes(result, "tools") arr := tools.Array() @@ -73,7 +73,7 @@ func TestEnsureImageGenerationTool_EmptyToolsArray(t *testing.T) { func TestEnsureImageGenerationTool_WebSearchAndImageGen(t *testing.T) { body := []byte(`{"model":"gpt-5.4","tools":[{"type":"web_search"}]}`) - result := ensureImageGenerationTool(body) + result := ensureImageGenerationTool(body, "gpt-5.4") tools := gjson.GetBytes(result, "tools") arr := tools.Array() @@ -87,3 +87,15 @@ func TestEnsureImageGenerationTool_WebSearchAndImageGen(t *testing.T) { t.Fatalf("expected second tool type=image_generation, got %s", arr[1].Get("type").String()) } } + +func TestEnsureImageGenerationTool_GPT53CodexSparkDoesNotInjectTool(t *testing.T) { + body := []byte(`{"model":"gpt-5.3-codex-spark","input":"draw a cat"}`) + result := ensureImageGenerationTool(body, "gpt-5.3-codex-spark") + + if string(result) != string(body) { + t.Fatalf("expected body to be unchanged, got %s", string(result)) + } + if gjson.GetBytes(result, "tools").Exists() { + t.Fatalf("expected no tools for gpt-5.3-codex-spark, got %s", gjson.GetBytes(result, "tools").Raw) + } +} From 5f5d5936fa61ed451ee8bb491bdc888d8ccaa2e2 Mon Sep 17 00:00:00 2001 From: sususu98 Date: Fri, 24 Apr 2026 15:47:18 +0800 Subject: [PATCH 040/139] fix antigravity credits stream fallback --- sdk/cliproxy/auth/antigravity_credits_test.go | 92 +++++++++++++++++++ sdk/cliproxy/auth/conductor.go | 26 ++++-- 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/sdk/cliproxy/auth/antigravity_credits_test.go b/sdk/cliproxy/auth/antigravity_credits_test.go index 8f59b4c78f..38c08dcfbc 100644 --- a/sdk/cliproxy/auth/antigravity_credits_test.go +++ b/sdk/cliproxy/auth/antigravity_credits_test.go @@ -1,10 +1,102 @@ package auth import ( + "context" + "fmt" + "net/http" "testing" "time" + + internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" ) +type antigravityCreditsFallbackExecutor struct { + streamCreditsRequested []bool +} + +func (e *antigravityCreditsFallbackExecutor) Identifier() string { return "antigravity" } + +func (e *antigravityCreditsFallbackExecutor) Execute(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + return cliproxyexecutor.Response{}, &Error{HTTPStatus: http.StatusNotImplemented, Message: "Execute not implemented"} +} + +func (e *antigravityCreditsFallbackExecutor) ExecuteStream(ctx context.Context, _ *Auth, req cliproxyexecutor.Request, _ cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) { + creditsRequested := AntigravityCreditsRequested(ctx) + e.streamCreditsRequested = append(e.streamCreditsRequested, creditsRequested) + ch := make(chan cliproxyexecutor.StreamChunk, 1) + if !creditsRequested { + ch <- cliproxyexecutor.StreamChunk{Err: &Error{HTTPStatus: http.StatusTooManyRequests, Message: "quota exhausted"}} + close(ch) + return &cliproxyexecutor.StreamResult{Headers: http.Header{"X-Initial": {req.Model}}, Chunks: ch}, nil + } + ch <- cliproxyexecutor.StreamChunk{Payload: []byte("credits fallback")} + close(ch) + return &cliproxyexecutor.StreamResult{Headers: http.Header{"X-Credits": {req.Model}}, Chunks: ch}, nil +} + +func (e *antigravityCreditsFallbackExecutor) Refresh(_ context.Context, auth *Auth) (*Auth, error) { + return auth, nil +} + +func (e *antigravityCreditsFallbackExecutor) CountTokens(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + return cliproxyexecutor.Response{}, &Error{HTTPStatus: http.StatusNotImplemented, Message: "CountTokens not implemented"} +} + +func (e *antigravityCreditsFallbackExecutor) HttpRequest(context.Context, *Auth, *http.Request) (*http.Response, error) { + return nil, &Error{HTTPStatus: http.StatusNotImplemented, Message: "HttpRequest not implemented"} +} + +func TestManagerExecuteStream_AntigravityCreditsFallbackAfterBootstrap429(t *testing.T) { + const model = "claude-opus-4-6-thinking" + executor := &antigravityCreditsFallbackExecutor{} + manager := NewManager(nil, nil, nil) + manager.SetConfig(&internalconfig.Config{ + QuotaExceeded: internalconfig.QuotaExceeded{AntigravityCredits: true}, + }) + manager.RegisterExecutor(executor) + registry.GetGlobalRegistry().RegisterClient("ag-credits", "antigravity", []*registry.ModelInfo{{ID: model}}) + t.Cleanup(func() { registry.GetGlobalRegistry().UnregisterClient("ag-credits") }) + if _, errRegister := manager.Register(context.Background(), &Auth{ID: "ag-credits", Provider: "antigravity"}); errRegister != nil { + t.Fatalf("register auth: %v", errRegister) + } + + streamResult, errExecute := manager.ExecuteStream(context.Background(), []string{"antigravity"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{}) + if errExecute != nil { + t.Fatalf("execute stream: %v", errExecute) + } + + var payload []byte + for chunk := range streamResult.Chunks { + if chunk.Err != nil { + t.Fatalf("unexpected stream error: %v", chunk.Err) + } + payload = append(payload, chunk.Payload...) + } + if string(payload) != "credits fallback" { + t.Fatalf("payload = %q, want %q", string(payload), "credits fallback") + } + if got := streamResult.Headers.Get("X-Credits"); got != model { + t.Fatalf("X-Credits header = %q, want routed model", got) + } + if len(executor.streamCreditsRequested) != 2 { + t.Fatalf("stream calls = %d, want 2", len(executor.streamCreditsRequested)) + } + if executor.streamCreditsRequested[0] || !executor.streamCreditsRequested[1] { + t.Fatalf("credits flags = %v, want [false true]", executor.streamCreditsRequested) + } +} + +func TestStatusCodeFromError_UnwrapsStreamBootstrap429(t *testing.T) { + bootstrapErr := newStreamBootstrapError(&Error{HTTPStatus: http.StatusTooManyRequests, Message: "quota exhausted"}, nil) + wrappedErr := fmt.Errorf("conductor stream failed: %w", bootstrapErr) + + if status := statusCodeFromError(wrappedErr); status != http.StatusTooManyRequests { + t.Fatalf("statusCodeFromError() = %d, want %d", status, http.StatusTooManyRequests) + } +} + func TestIsAuthBlockedForModel_ClaudeWithCreditsStillBlockedDuringCooldown(t *testing.T) { auth := &Auth{ ID: "ag-1", diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 4d37581a61..05a32ceb2c 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -1273,6 +1273,10 @@ func (m *Manager) ExecuteStream(ctx context.Context, providers []string, req cli return result, nil } } + var bootstrapErr *streamBootstrapError + if errors.As(lastErr, &bootstrapErr) && bootstrapErr != nil { + return streamErrorResult(bootstrapErr.Headers(), bootstrapErr.cause), nil + } return nil, lastErr } return nil, &Error{Code: "auth_not_found", Message: "no auth available"} @@ -1446,10 +1450,6 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string for { if maxRetryCredentials > 0 && len(attempted) >= maxRetryCredentials { if lastErr != nil { - var bootstrapErr *streamBootstrapError - if errors.As(lastErr, &bootstrapErr) && bootstrapErr != nil { - return streamErrorResult(bootstrapErr.Headers(), bootstrapErr.cause), nil - } return nil, lastErr } return nil, &Error{Code: "auth_not_found", Message: "no auth available"} @@ -1457,10 +1457,6 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, opts, tried) if errPick != nil { if lastErr != nil { - var bootstrapErr *streamBootstrapError - if errors.As(lastErr, &bootstrapErr) && bootstrapErr != nil { - return streamErrorResult(bootstrapErr.Headers(), bootstrapErr.cause), nil - } return nil, lastErr } return nil, errPick @@ -2299,6 +2295,13 @@ func cloneError(err *Error) *Error { } } +func errorString(err error) string { + if err == nil { + return "" + } + return err.Error() +} + func statusCodeFromError(err error) int { if err == nil { return 0 @@ -2965,6 +2968,12 @@ type creditsCandidateEntry struct { } func shouldAttemptAntigravityCreditsFallback(m *Manager, lastErr error, providers []string) bool { + status := statusCodeFromError(lastErr) + log.WithFields(log.Fields{ + "lastErr": errorString(lastErr), + "status": status, + "providers": providers, + }).Debug("shouldAttemptAntigravityCreditsFallback") if m == nil || lastErr == nil { return false } @@ -2984,7 +2993,6 @@ func shouldAttemptAntigravityCreditsFallback(m *Manager, lastErr error, provider if cfg == nil || !cfg.QuotaExceeded.AntigravityCredits { return false } - status := statusCodeFromError(lastErr) switch status { case http.StatusTooManyRequests, http.StatusServiceUnavailable: return true From 4056c2590be9785fa93c64b78b199112eef99cc0 Mon Sep 17 00:00:00 2001 From: Matthias319 Date: Fri, 24 Apr 2026 17:13:23 +0200 Subject: [PATCH 041/139] fix(codex): classify known upstream failures Normalize Codex context, thinking-signature, previous-response, and auth failures to explicit error codes: context_too_large, thinking_signature_invalid, previous_response_not_found, auth_unavailable. Refs #2596. --- internal/runtime/executor/codex_executor.go | 47 ++++++++++ .../executor/codex_executor_retry_test.go | 89 +++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 38667231aa..48b3755eda 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -809,6 +809,7 @@ func newCodexStatusErr(statusCode int, body []byte) statusErr { if isCodexModelCapacityError(body) { errCode = http.StatusTooManyRequests } + body = classifyCodexStatusError(errCode, body) err := statusErr{code: errCode, msg: string(body)} if retryAfter := parseCodexRetryAfter(errCode, body, time.Now()); retryAfter != nil { err.retryAfter = retryAfter @@ -816,6 +817,52 @@ func newCodexStatusErr(statusCode int, body []byte) statusErr { return err } +func classifyCodexStatusError(statusCode int, body []byte) []byte { + code, errType, ok := codexStatusErrorClassification(statusCode, body) + if !ok { + return body + } + message := gjson.GetBytes(body, "error.message").String() + if message == "" { + message = gjson.GetBytes(body, "message").String() + } + if message == "" { + message = strings.TrimSpace(string(body)) + } + if message == "" { + message = http.StatusText(statusCode) + } + out := []byte(`{"error":{}}`) + out, _ = sjson.SetBytes(out, "error.message", message) + out, _ = sjson.SetBytes(out, "error.type", errType) + out, _ = sjson.SetBytes(out, "error.code", code) + return out +} + +func codexStatusErrorClassification(statusCode int, body []byte) (code string, errType string, ok bool) { + errorMessage := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.message").String())) + if errorMessage == "" { + errorMessage = strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "message").String())) + } + lower := strings.ToLower(strings.TrimSpace(string(body))) + upstreamCode := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.code").String())) + upstreamType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.type").String())) + isInvalidRequest := upstreamType == "" || upstreamType == "invalid_request_error" + + switch { + case statusCode == http.StatusRequestEntityTooLarge || upstreamCode == "context_length_exceeded" || upstreamCode == "context_too_large" || isInvalidRequest && (strings.Contains(errorMessage, "context length") || strings.Contains(errorMessage, "context_length") || strings.Contains(errorMessage, "maximum context") || strings.Contains(errorMessage, "too many tokens")): + return "context_too_large", "invalid_request_error", true + case strings.Contains(lower, "invalid signature in thinking block") || strings.Contains(lower, "invalid_encrypted_content"): + return "thinking_signature_invalid", "invalid_request_error", true + case upstreamCode == "previous_response_not_found" || strings.Contains(lower, "previous_response_not_found") || strings.Contains(lower, "previous_response_id") && strings.Contains(lower, "not found"): + return "previous_response_not_found", "invalid_request_error", true + case statusCode == http.StatusUnauthorized || upstreamType == "authentication_error" || upstreamCode == "invalid_api_key" || strings.Contains(lower, "invalid or expired token") || strings.Contains(lower, "refresh_token_reused"): + return "auth_unavailable", "authentication_error", true + default: + return "", "", false + } +} + func normalizeCodexInstructions(body []byte) []byte { instructions := gjson.GetBytes(body, "instructions") if !instructions.Exists() || instructions.Type == gjson.Null { diff --git a/internal/runtime/executor/codex_executor_retry_test.go b/internal/runtime/executor/codex_executor_retry_test.go index 249d40d656..7207d5734c 100644 --- a/internal/runtime/executor/codex_executor_retry_test.go +++ b/internal/runtime/executor/codex_executor_retry_test.go @@ -1,6 +1,7 @@ package executor import ( + "encoding/json" "net/http" "strconv" "testing" @@ -73,6 +74,94 @@ func TestNewCodexStatusErrTreatsCapacityAsRetryableRateLimit(t *testing.T) { } } +func TestNewCodexStatusErrClassifiesKnownCodexFailures(t *testing.T) { + tests := []struct { + name string + statusCode int + body []byte + wantStatus int + wantType string + wantCode string + }{ + { + name: "context length status", + statusCode: http.StatusRequestEntityTooLarge, + body: []byte(`{"error":{"message":"context length exceeded","type":"invalid_request_error","code":"context_length_exceeded"}}`), + wantStatus: http.StatusRequestEntityTooLarge, + wantType: "invalid_request_error", + wantCode: "context_too_large", + }, + { + name: "thinking signature", + statusCode: http.StatusBadRequest, + body: []byte(`{"error":{"message":"Invalid signature in thinking block","type":"invalid_request_error","code":"invalid_request_error"}}`), + wantStatus: http.StatusBadRequest, + wantType: "invalid_request_error", + wantCode: "thinking_signature_invalid", + }, + { + name: "previous response missing", + statusCode: http.StatusBadRequest, + body: []byte(`{"error":{"message":"No response found for previous_response_id resp_123","type":"invalid_request_error","code":"previous_response_not_found"}}`), + wantStatus: http.StatusBadRequest, + wantType: "invalid_request_error", + wantCode: "previous_response_not_found", + }, + { + name: "auth unavailable", + statusCode: http.StatusUnauthorized, + body: []byte(`{"error":{"message":"invalid or expired token","type":"authentication_error","code":"invalid_api_key"}}`), + wantStatus: http.StatusUnauthorized, + wantType: "authentication_error", + wantCode: "auth_unavailable", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := newCodexStatusErr(tc.statusCode, tc.body) + + if got := err.StatusCode(); got != tc.wantStatus { + t.Fatalf("status code = %d, want %d", got, tc.wantStatus) + } + assertCodexErrorCode(t, err.Error(), tc.wantType, tc.wantCode) + }) + } +} + +func TestNewCodexStatusErrPreservesUnclassifiedErrors(t *testing.T) { + body := []byte(`{"error":{"message":"documentation mentions too many tokens, but this is a billing configuration failure","type":"server_error","code":"billing_config_error"}}`) + + err := newCodexStatusErr(http.StatusBadGateway, body) + + if got := err.StatusCode(); got != http.StatusBadGateway { + t.Fatalf("status code = %d, want %d", got, http.StatusBadGateway) + } + if got := err.Error(); got != string(body) { + t.Fatalf("error body = %s, want original %s", got, string(body)) + } +} + +func assertCodexErrorCode(t *testing.T, raw string, wantType string, wantCode string) { + t.Helper() + + var payload struct { + Error struct { + Type string `json:"type"` + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + t.Fatalf("error body is not valid JSON: %v; body=%s", err, raw) + } + if payload.Error.Type != wantType { + t.Fatalf("error.type = %q, want %q; body=%s", payload.Error.Type, wantType, raw) + } + if payload.Error.Code != wantCode { + t.Fatalf("error.code = %q, want %q; body=%s", payload.Error.Code, wantCode, raw) + } +} + func itoa(v int64) string { return strconv.FormatInt(v, 10) } From a7e92e2639d87240648d3f35704e39d0a5cf63f2 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 24 Apr 2026 23:18:56 +0800 Subject: [PATCH 042/139] feat(auth): disallow free-tier Codex auth during selection process - Introduced `disallowFreeAuthFromMetadata` and `isFreeCodexAuth` to enforce skipping free-tier credentials. - Modified scheduler logic to honor `DisallowFreeAuthMetadataKey` during auth selection. - Updated `ensureImageGenerationTool` to skip tool injection for free-tier Codex auth. - Added context utility `WithDisallowFreeAuth` and integrated with image handlers. - Augmented relevant tests to cover free-tier exclusion scenarios. --- internal/runtime/executor/codex_executor.go | 21 ++- .../executor/codex_executor_imagegen_test.go | 29 +++- sdk/api/handlers/handlers.go | 20 +++ .../handlers/openai/openai_images_handlers.go | 2 + sdk/cliproxy/auth/conductor.go | 144 +++++++++++++----- sdk/cliproxy/auth/scheduler_test.go | 33 ++++ sdk/cliproxy/executor/types.go | 3 + 7 files changed, 200 insertions(+), 52 deletions(-) diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 38667231aa..dc3254a769 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -180,7 +180,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re body, _ = sjson.DeleteBytes(body, "safety_identifier") body, _ = sjson.DeleteBytes(body, "stream_options") body = normalizeCodexInstructions(body) - body = ensureImageGenerationTool(body, baseModel) + body = ensureImageGenerationTool(body, baseModel, auth) url := strings.TrimSuffix(baseURL, "/") + "/responses" httpReq, err := e.cacheHelper(ctx, from, url, req, body) @@ -327,7 +327,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A body, _ = sjson.SetBytes(body, "model", baseModel) body, _ = sjson.DeleteBytes(body, "stream") body = normalizeCodexInstructions(body) - body = ensureImageGenerationTool(body, baseModel) + body = ensureImageGenerationTool(body, baseModel, auth) url := strings.TrimSuffix(baseURL, "/") + "/responses/compact" httpReq, err := e.cacheHelper(ctx, from, url, req, body) @@ -422,7 +422,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au body, _ = sjson.DeleteBytes(body, "stream_options") body, _ = sjson.SetBytes(body, "model", baseModel) body = normalizeCodexInstructions(body) - body = ensureImageGenerationTool(body, baseModel) + body = ensureImageGenerationTool(body, baseModel, auth) url := strings.TrimSuffix(baseURL, "/") + "/responses" httpReq, err := e.cacheHelper(ctx, from, url, req, body) @@ -827,10 +827,23 @@ func normalizeCodexInstructions(body []byte) []byte { var imageGenToolJSON = []byte(`{"type":"image_generation","output_format":"png"}`) var imageGenToolArrayJSON = []byte(`[{"type":"image_generation","output_format":"png"}]`) -func ensureImageGenerationTool(body []byte, baseModel string) []byte { +func isCodexFreePlanAuth(auth *cliproxyauth.Auth) bool { + if auth == nil || auth.Attributes == nil { + return false + } + if !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") { + return false + } + return strings.EqualFold(strings.TrimSpace(auth.Attributes["plan_type"]), "free") +} + +func ensureImageGenerationTool(body []byte, baseModel string, auth *cliproxyauth.Auth) []byte { if strings.HasSuffix(baseModel, "spark") { return body } + if isCodexFreePlanAuth(auth) { + return body + } tools := gjson.GetBytes(body, "tools") if !tools.Exists() || !tools.IsArray() { diff --git a/internal/runtime/executor/codex_executor_imagegen_test.go b/internal/runtime/executor/codex_executor_imagegen_test.go index 5e67c598a4..1657209a91 100644 --- a/internal/runtime/executor/codex_executor_imagegen_test.go +++ b/internal/runtime/executor/codex_executor_imagegen_test.go @@ -3,12 +3,13 @@ package executor import ( "testing" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" "github.com/tidwall/gjson" ) func TestEnsureImageGenerationTool_NoTools(t *testing.T) { body := []byte(`{"model":"gpt-5.4","input":"draw a cat"}`) - result := ensureImageGenerationTool(body, "gpt-5.4") + result := ensureImageGenerationTool(body, "gpt-5.4", nil) tools := gjson.GetBytes(result, "tools") if !tools.IsArray() { @@ -28,7 +29,7 @@ func TestEnsureImageGenerationTool_NoTools(t *testing.T) { func TestEnsureImageGenerationTool_ExistingToolsWithoutImageGen(t *testing.T) { body := []byte(`{"model":"gpt-5.4","tools":[{"type":"function","name":"get_weather","parameters":{}}]}`) - result := ensureImageGenerationTool(body, "gpt-5.4") + result := ensureImageGenerationTool(body, "gpt-5.4", nil) tools := gjson.GetBytes(result, "tools") arr := tools.Array() @@ -45,7 +46,7 @@ func TestEnsureImageGenerationTool_ExistingToolsWithoutImageGen(t *testing.T) { func TestEnsureImageGenerationTool_AlreadyPresent(t *testing.T) { body := []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation","output_format":"webp"},{"type":"function","name":"f1"}]}`) - result := ensureImageGenerationTool(body, "gpt-5.4") + result := ensureImageGenerationTool(body, "gpt-5.4", nil) tools := gjson.GetBytes(result, "tools") arr := tools.Array() @@ -59,7 +60,7 @@ func TestEnsureImageGenerationTool_AlreadyPresent(t *testing.T) { func TestEnsureImageGenerationTool_EmptyToolsArray(t *testing.T) { body := []byte(`{"model":"gpt-5.4","tools":[]}`) - result := ensureImageGenerationTool(body, "gpt-5.4") + result := ensureImageGenerationTool(body, "gpt-5.4", nil) tools := gjson.GetBytes(result, "tools") arr := tools.Array() @@ -73,7 +74,7 @@ func TestEnsureImageGenerationTool_EmptyToolsArray(t *testing.T) { func TestEnsureImageGenerationTool_WebSearchAndImageGen(t *testing.T) { body := []byte(`{"model":"gpt-5.4","tools":[{"type":"web_search"}]}`) - result := ensureImageGenerationTool(body, "gpt-5.4") + result := ensureImageGenerationTool(body, "gpt-5.4", nil) tools := gjson.GetBytes(result, "tools") arr := tools.Array() @@ -90,7 +91,7 @@ func TestEnsureImageGenerationTool_WebSearchAndImageGen(t *testing.T) { func TestEnsureImageGenerationTool_GPT53CodexSparkDoesNotInjectTool(t *testing.T) { body := []byte(`{"model":"gpt-5.3-codex-spark","input":"draw a cat"}`) - result := ensureImageGenerationTool(body, "gpt-5.3-codex-spark") + result := ensureImageGenerationTool(body, "gpt-5.3-codex-spark", nil) if string(result) != string(body) { t.Fatalf("expected body to be unchanged, got %s", string(result)) @@ -99,3 +100,19 @@ func TestEnsureImageGenerationTool_GPT53CodexSparkDoesNotInjectTool(t *testing.T t.Fatalf("expected no tools for gpt-5.3-codex-spark, got %s", gjson.GetBytes(result, "tools").Raw) } } + +func TestEnsureImageGenerationTool_FreeCodexAuthDoesNotInjectTool(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","input":"draw a cat"}`) + freeAuth := &cliproxyauth.Auth{ + Provider: "codex", + Attributes: map[string]string{"plan_type": "free"}, + } + result := ensureImageGenerationTool(body, "gpt-5.4", freeAuth) + + if string(result) != string(body) { + t.Fatalf("expected body to be unchanged, got %s", string(result)) + } + if gjson.GetBytes(result, "tools").Exists() { + t.Fatalf("expected no tools for free codex auth, got %s", gjson.GetBytes(result, "tools").Raw) + } +} diff --git a/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go index 1fda8f49f0..5f0ea7b817 100644 --- a/sdk/api/handlers/handlers.go +++ b/sdk/api/handlers/handlers.go @@ -55,6 +55,7 @@ const ( type pinnedAuthContextKey struct{} type selectedAuthCallbackContextKey struct{} type executionSessionContextKey struct{} +type disallowFreeAuthContextKey struct{} // WithPinnedAuthID returns a child context that requests execution on a specific auth ID. func WithPinnedAuthID(ctx context.Context, authID string) context.Context { @@ -91,6 +92,14 @@ func WithExecutionSessionID(ctx context.Context, sessionID string) context.Conte return context.WithValue(ctx, executionSessionContextKey{}, sessionID) } +// WithDisallowFreeAuth returns a child context that requests skipping known free-tier credentials. +func WithDisallowFreeAuth(ctx context.Context) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, disallowFreeAuthContextKey{}, true) +} + // BuildErrorResponseBody builds an OpenAI-compatible JSON error response body. // If errText is already valid JSON, it is returned as-is to preserve upstream error payloads. func BuildErrorResponseBody(status int, errText string) []byte { @@ -208,6 +217,9 @@ func requestExecutionMetadata(ctx context.Context) map[string]any { if executionSessionID := executionSessionIDFromContext(ctx); executionSessionID != "" { meta[coreexecutor.ExecutionSessionMetadataKey] = executionSessionID } + if disallowFreeAuthFromContext(ctx) { + meta[coreexecutor.DisallowFreeAuthMetadataKey] = true + } return meta } @@ -252,6 +264,14 @@ func executionSessionIDFromContext(ctx context.Context) string { } } +func disallowFreeAuthFromContext(ctx context.Context) bool { + if ctx == nil { + return false + } + raw, ok := ctx.Value(disallowFreeAuthContextKey{}).(bool) + return ok && raw +} + // BaseAPIHandler contains the handlers for API endpoints. // It holds a pool of clients to interact with the backend service and manages // load balancing, client selection, and configuration. diff --git a/sdk/api/handlers/openai/openai_images_handlers.go b/sdk/api/handlers/openai/openai_images_handlers.go index 93d45460d0..17243314f9 100644 --- a/sdk/api/handlers/openai/openai_images_handlers.go +++ b/sdk/api/handlers/openai/openai_images_handlers.go @@ -527,6 +527,7 @@ func (h *OpenAIAPIHandler) collectImagesFromResponses(c *gin.Context, responsesR c.Header("Content-Type", "application/json") cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background()) + cliCtx = handlers.WithDisallowFreeAuth(cliCtx) stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx) dataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, "openai-response", defaultImagesMainModel, responsesReq, "") @@ -716,6 +717,7 @@ func (h *OpenAIAPIHandler) streamImagesFromResponses(c *gin.Context, responsesRe } cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background()) + cliCtx = handlers.WithDisallowFreeAuth(cliCtx) dataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, "openai-response", defaultImagesMainModel, responsesReq, "") setSSEHeaders := func() { diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 05a32ceb2c..2091f669ae 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -1549,6 +1549,38 @@ func pinnedAuthIDFromMetadata(meta map[string]any) string { } } +func disallowFreeAuthFromMetadata(meta map[string]any) bool { + if len(meta) == 0 { + return false + } + raw, ok := meta[cliproxyexecutor.DisallowFreeAuthMetadataKey] + if !ok || raw == nil { + return false + } + switch val := raw.(type) { + case bool: + return val + case string: + parsed, err := strconv.ParseBool(strings.TrimSpace(val)) + return err == nil && parsed + case []byte: + parsed, err := strconv.ParseBool(strings.TrimSpace(string(val))) + return err == nil && parsed + default: + return false + } +} + +func isFreeCodexAuth(auth *Auth) bool { + if auth == nil || auth.Attributes == nil { + return false + } + if !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") { + return false + } + return strings.EqualFold(strings.TrimSpace(auth.Attributes["plan_type"]), "free") +} + func publishSelectedAuthMetadata(meta map[string]any, authID string) { if len(meta) == 0 { return @@ -2633,6 +2665,7 @@ func (m *Manager) routeAwareSelectionRequired(auth *Auth, routeModel string) boo func (m *Manager) pickNextLegacy(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, error) { pinnedAuthID := pinnedAuthIDFromMetadata(opts.Metadata) + disallowFreeAuth := disallowFreeAuthFromMetadata(opts.Metadata) m.mu.RLock() executor, okExecutor := m.executors[provider] @@ -2657,6 +2690,9 @@ func (m *Manager) pickNextLegacy(ctx context.Context, provider, model string, op if pinnedAuthID != "" && candidate.ID != pinnedAuthID { continue } + if disallowFreeAuth && isFreeCodexAuth(candidate) { + continue + } if _, used := tried[candidate.ID]; used { continue } @@ -2720,31 +2756,42 @@ func (m *Manager) pickNext(ctx context.Context, provider, model string, opts cli if !okExecutor { return nil, nil, &Error{Code: "executor_not_found", Message: "executor not registered"} } - selected, errPick := m.scheduler.pickSingle(ctx, provider, model, opts, tried) - if errPick != nil && model != "" && shouldRetrySchedulerPick(errPick) { - m.syncScheduler() - selected, errPick = m.scheduler.pickSingle(ctx, provider, model, opts, tried) - } - if errPick != nil { - return nil, nil, errPick - } - if selected == nil { - return nil, nil, &Error{Code: "auth_not_found", Message: "selector returned no auth"} - } - authCopy := selected.Clone() - if !selected.indexAssigned { - m.mu.Lock() - if current := m.auths[authCopy.ID]; current != nil && !current.indexAssigned { - current.EnsureIndex() - authCopy = current.Clone() + disallowFreeAuth := disallowFreeAuthFromMetadata(opts.Metadata) + for { + selected, errPick := m.scheduler.pickSingle(ctx, provider, model, opts, tried) + if errPick != nil && model != "" && shouldRetrySchedulerPick(errPick) { + m.syncScheduler() + selected, errPick = m.scheduler.pickSingle(ctx, provider, model, opts, tried) } - m.mu.Unlock() + if errPick != nil { + return nil, nil, errPick + } + if selected == nil { + return nil, nil, &Error{Code: "auth_not_found", Message: "selector returned no auth"} + } + if disallowFreeAuth && isFreeCodexAuth(selected) { + if tried == nil { + tried = make(map[string]struct{}) + } + tried[selected.ID] = struct{}{} + continue + } + authCopy := selected.Clone() + if !selected.indexAssigned { + m.mu.Lock() + if current := m.auths[authCopy.ID]; current != nil && !current.indexAssigned { + current.EnsureIndex() + authCopy = current.Clone() + } + m.mu.Unlock() + } + return authCopy, executor, nil } - return authCopy, executor, nil } func (m *Manager) pickNextMixedLegacy(ctx context.Context, providers []string, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, string, error) { pinnedAuthID := pinnedAuthIDFromMetadata(opts.Metadata) + disallowFreeAuth := disallowFreeAuthFromMetadata(opts.Metadata) providerSet := make(map[string]struct{}, len(providers)) for _, provider := range providers { @@ -2776,6 +2823,9 @@ func (m *Manager) pickNextMixedLegacy(ctx context.Context, providers []string, m if pinnedAuthID != "" && candidate.ID != pinnedAuthID { continue } + if disallowFreeAuth && isFreeCodexAuth(candidate) { + continue + } providerKey := strings.TrimSpace(strings.ToLower(candidate.Provider)) if providerKey == "" { continue @@ -2879,31 +2929,41 @@ func (m *Manager) pickNextMixed(ctx context.Context, providers []string, model s m.mu.RUnlock() } - selected, providerKey, errPick := m.scheduler.pickMixed(ctx, eligibleProviders, model, opts, tried) - if errPick != nil && model != "" && shouldRetrySchedulerPick(errPick) { - m.syncScheduler() - selected, providerKey, errPick = m.scheduler.pickMixed(ctx, eligibleProviders, model, opts, tried) - } - if errPick != nil { - return nil, nil, "", errPick - } - if selected == nil { - return nil, nil, "", &Error{Code: "auth_not_found", Message: "selector returned no auth"} - } - executor, okExecutor := m.Executor(providerKey) - if !okExecutor { - return nil, nil, "", &Error{Code: "executor_not_found", Message: "executor not registered"} - } - authCopy := selected.Clone() - if !selected.indexAssigned { - m.mu.Lock() - if current := m.auths[authCopy.ID]; current != nil && !current.indexAssigned { - current.EnsureIndex() - authCopy = current.Clone() + disallowFreeAuth := disallowFreeAuthFromMetadata(opts.Metadata) + for { + selected, providerKey, errPick := m.scheduler.pickMixed(ctx, eligibleProviders, model, opts, tried) + if errPick != nil && model != "" && shouldRetrySchedulerPick(errPick) { + m.syncScheduler() + selected, providerKey, errPick = m.scheduler.pickMixed(ctx, eligibleProviders, model, opts, tried) } - m.mu.Unlock() + if errPick != nil { + return nil, nil, "", errPick + } + if selected == nil { + return nil, nil, "", &Error{Code: "auth_not_found", Message: "selector returned no auth"} + } + if disallowFreeAuth && isFreeCodexAuth(selected) { + if tried == nil { + tried = make(map[string]struct{}) + } + tried[selected.ID] = struct{}{} + continue + } + executor, okExecutor := m.Executor(providerKey) + if !okExecutor { + return nil, nil, "", &Error{Code: "executor_not_found", Message: "executor not registered"} + } + authCopy := selected.Clone() + if !selected.indexAssigned { + m.mu.Lock() + if current := m.auths[authCopy.ID]; current != nil && !current.indexAssigned { + current.EnsureIndex() + authCopy = current.Clone() + } + m.mu.Unlock() + } + return authCopy, executor, providerKey, nil } - return authCopy, executor, providerKey, nil } func (m *Manager) findAllAntigravityCreditsCandidateAuths(routeModel string, opts cliproxyexecutor.Options) []creditsCandidateEntry { diff --git a/sdk/cliproxy/auth/scheduler_test.go b/sdk/cliproxy/auth/scheduler_test.go index d744ec32d0..8caaa4735b 100644 --- a/sdk/cliproxy/auth/scheduler_test.go +++ b/sdk/cliproxy/auth/scheduler_test.go @@ -333,6 +333,39 @@ func TestManager_PickNextMixed_UsesWeightedProviderRotationBeforeCredentialRotat } } +func TestManager_PickNextMixed_DisallowFreeAuthSkipsCodexFreePlan(t *testing.T) { + t.Parallel() + + model := "gpt-5.4-mini" + registerSchedulerModels(t, "codex", model, "codex-a-free", "codex-b-plus") + + manager := NewManager(nil, &RoundRobinSelector{}, nil) + manager.executors["codex"] = schedulerTestExecutor{} + if _, errRegister := manager.Register(context.Background(), &Auth{ID: "codex-a-free", Provider: "codex", Attributes: map[string]string{"plan_type": "free"}}); errRegister != nil { + t.Fatalf("Register(codex-a-free) error = %v", errRegister) + } + if _, errRegister := manager.Register(context.Background(), &Auth{ID: "codex-b-plus", Provider: "codex", Attributes: map[string]string{"plan_type": "plus"}}); errRegister != nil { + t.Fatalf("Register(codex-b-plus) error = %v", errRegister) + } + + opts := cliproxyexecutor.Options{ + Metadata: map[string]any{cliproxyexecutor.DisallowFreeAuthMetadataKey: true}, + } + got, _, provider, errPick := manager.pickNextMixed(context.Background(), []string{"codex"}, model, opts, map[string]struct{}{}) + if errPick != nil { + t.Fatalf("pickNextMixed() error = %v", errPick) + } + if got == nil { + t.Fatalf("pickNextMixed() auth = nil") + } + if provider != "codex" { + t.Fatalf("pickNextMixed() provider = %q, want %q", provider, "codex") + } + if got.ID != "codex-b-plus" { + t.Fatalf("pickNextMixed() auth.ID = %q, want %q", got.ID, "codex-b-plus") + } +} + func TestManagerCustomSelector_FallsBackToLegacyPath(t *testing.T) { t.Parallel() diff --git a/sdk/cliproxy/executor/types.go b/sdk/cliproxy/executor/types.go index 4ea8103947..ac58286fd7 100644 --- a/sdk/cliproxy/executor/types.go +++ b/sdk/cliproxy/executor/types.go @@ -10,6 +10,9 @@ import ( // RequestedModelMetadataKey stores the client-requested model name in Options.Metadata. const RequestedModelMetadataKey = "requested_model" +// DisallowFreeAuthMetadataKey instructs auth selection to skip known free-tier credentials. +const DisallowFreeAuthMetadataKey = "disallow_free_auth" + const ( // PinnedAuthMetadataKey locks execution to a specific auth ID. PinnedAuthMetadataKey = "pinned_auth_id" From faad8e30ddfdc07912ab06b72e653408cda5a92b Mon Sep 17 00:00:00 2001 From: Will Date: Fri, 24 Apr 2026 23:28:44 +0800 Subject: [PATCH 043/139] Add CPA Usage Keeper to README ecosystem list --- README.md | 4 ++++ README_CN.md | 4 ++++ README_JA.md | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/README.md b/README.md index 77b8667b2f..e12f46f26c 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,10 @@ Cross-platform desktop app (macOS, Windows, Linux) wrapping CLIProxyAPI with a n Ready-to-use cross-platform quota inspector for CLIProxyAPI, supporting per-account codex 5h/7d quota windows, plan-based sorting, status coloring, and multi-account summary analytics. +### [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper) + +Standalone usage persistence and visualization service for CLIProxyAPI. Periodically pulls CPA data, stores normalized events in SQLite, exposes aggregate APIs, and includes a built-in web dashboard for usage, pricing, request health, and model/API statistics. + > [!NOTE] > If you developed a project based on CLIProxyAPI, please open a PR to add it to this list. diff --git a/README_CN.md b/README_CN.md index 75d50e7ac1..c0da39fc4f 100644 --- a/README_CN.md +++ b/README_CN.md @@ -183,6 +183,10 @@ Shadow AI 是一款专为受限环境设计的 AI 辅助工具。提供无窗口 上手即用的面向 CLIProxyAPI 跨平台配额查询工具,支持按账号展示 codex 5h/7d 配额窗口、按计划排序、状态着色及多账号汇总分析。 +### [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper) + +独立的 CLIProxyAPI 使用量持久化与可视化服务。定期拉取 CPA 数据,将标准化事件持久化到 SQLite,提供聚合 API,并内置用于使用量、价格、请求健康状态以及模型/API 统计的 Web 仪表盘。 + > [!NOTE] > 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。 diff --git a/README_JA.md b/README_JA.md index cf8a0f77d8..a89afed778 100644 --- a/README_JA.md +++ b/README_JA.md @@ -182,6 +182,10 @@ CLIProxyAPIをネイティブGUIでラップしたクロスプラットフォー CLIProxyAPI向けのすぐに使えるクロスプラットフォームのクォータ確認ツール。アカウントごとの codex 5h/7d クォータ表示、プラン別ソート、ステータス色分け、複数アカウントの集計分析に対応。 +### [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper) + +CLIProxyAPI向けの独立した使用量永続化・可視化サービス。CPAデータを定期的に取得し、正規化されたイベントをSQLiteに保存、集計APIを提供し、使用量、料金、リクエスト健全性、モデル/API統計を確認できる組み込みWebダッシュボードを備えています。 + > [!NOTE] > CLIProxyAPIをベースにプロジェクトを開発した場合は、PRを送ってこのリストに追加してください。 From cf043f6c07a3f775925d4cd4d6e7e93b9f09584f Mon Sep 17 00:00:00 2001 From: Will Date: Fri, 24 Apr 2026 23:54:09 +0800 Subject: [PATCH 044/139] docs:Add CPA Usage Keeper to README ecosystem list --- README.md | 2 +- README_CN.md | 2 +- README_JA.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e12f46f26c..049f9c4b5c 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ Ready-to-use cross-platform quota inspector for CLIProxyAPI, supporting per-acco ### [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper) -Standalone usage persistence and visualization service for CLIProxyAPI. Periodically pulls CPA data, stores normalized events in SQLite, exposes aggregate APIs, and includes a built-in web dashboard for usage, pricing, request health, and model/API statistics. +Standalone persistence and visualization service for CLIProxyAPI, with periodic data sync, SQLite storage, aggregate APIs, and a built-in dashboard for usage and statistics. > [!NOTE] > If you developed a project based on CLIProxyAPI, please open a PR to add it to this list. diff --git a/README_CN.md b/README_CN.md index c0da39fc4f..7770786288 100644 --- a/README_CN.md +++ b/README_CN.md @@ -185,7 +185,7 @@ Shadow AI 是一款专为受限环境设计的 AI 辅助工具。提供无窗口 ### [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper) -独立的 CLIProxyAPI 使用量持久化与可视化服务。定期拉取 CPA 数据,将标准化事件持久化到 SQLite,提供聚合 API,并内置用于使用量、价格、请求健康状态以及模型/API 统计的 Web 仪表盘。 +独立的 CLIProxyAPI 使用量持久化与可视化服务,定期同步 CPA 数据,存储到 SQLite,提供聚合 API,并内置使用量分析与统计仪表盘。 > [!NOTE] > 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。 diff --git a/README_JA.md b/README_JA.md index a89afed778..b7a2c153d3 100644 --- a/README_JA.md +++ b/README_JA.md @@ -184,7 +184,7 @@ CLIProxyAPI向けのすぐに使えるクロスプラットフォームのクォ ### [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper) -CLIProxyAPI向けの独立した使用量永続化・可視化サービス。CPAデータを定期的に取得し、正規化されたイベントをSQLiteに保存、集計APIを提供し、使用量、料金、リクエスト健全性、モデル/API統計を確認できる組み込みWebダッシュボードを備えています。 +CLIProxyAPI向けの独立した使用量永続化・可視化サービス。CPAデータを定期同期してSQLiteに保存し、集計APIと、使用量や各種統計を確認できる組み込みダッシュボードを提供します。 > [!NOTE] > CLIProxyAPIをベースにプロジェクトを開発した場合は、PRを送ってこのリストに追加してください。 From 28d78273e4366c8f5430c8ce2c7188e66fd6fc42 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 25 Apr 2026 16:12:35 +0800 Subject: [PATCH 045/139] feat(api): implement protocol multiplexer and Redis queue for usage integration - Added `protocol_multiplexer.go`, enabling support for both HTTP and Redis protocols on a single listener. - Introduced `redis_queue_protocol.go` to handle Redis-compatible RESP commands for queue management. - Integrated `redisqueue` package, supporting in-memory queuing with expiration pruning. - Updated server initialization to manage a shared listener and multiplex connections. - Adjusted `Handler` to adopt `AuthenticateManagementKey` for modular key validation, supporting both HTTP and Redis flows. --- internal/api/buffered_conn.go | 32 ++ internal/api/handlers/management/handler.go | 184 +++++----- internal/api/mux_listener.go | 68 ++++ internal/api/protocol_multiplexer.go | 109 ++++++ internal/api/redis_queue_protocol.go | 317 ++++++++++++++++++ .../redis_queue_protocol_integration_test.go | 304 +++++++++++++++++ internal/api/server.go | 117 ++++++- internal/redisqueue/plugin.go | 145 ++++++++ internal/redisqueue/plugin_test.go | 160 +++++++++ internal/redisqueue/queue.go | 133 ++++++++ .../runtime/executor/helps/usage_helpers.go | 15 + sdk/cliproxy/service.go | 1 + sdk/cliproxy/usage/manager.go | 1 + 13 files changed, 1487 insertions(+), 99 deletions(-) create mode 100644 internal/api/buffered_conn.go create mode 100644 internal/api/mux_listener.go create mode 100644 internal/api/protocol_multiplexer.go create mode 100644 internal/api/redis_queue_protocol.go create mode 100644 internal/api/redis_queue_protocol_integration_test.go create mode 100644 internal/redisqueue/plugin.go create mode 100644 internal/redisqueue/plugin_test.go create mode 100644 internal/redisqueue/queue.go diff --git a/internal/api/buffered_conn.go b/internal/api/buffered_conn.go new file mode 100644 index 0000000000..5eb55f9658 --- /dev/null +++ b/internal/api/buffered_conn.go @@ -0,0 +1,32 @@ +package api + +import ( + "bufio" + "crypto/tls" + "net" +) + +type bufferedConn struct { + net.Conn + reader *bufio.Reader +} + +func (c *bufferedConn) Read(p []byte) (int, error) { + if c == nil { + return 0, net.ErrClosed + } + if c.reader == nil { + return c.Conn.Read(p) + } + return c.reader.Read(p) +} + +func (c *bufferedConn) ConnectionState() tls.ConnectionState { + if c == nil || c.Conn == nil { + return tls.ConnectionState{} + } + if stater, ok := c.Conn.(interface{ ConnectionState() tls.ConnectionState }); ok { + return stater.ConnectionState() + } + return tls.ConnectionState{} +} diff --git a/internal/api/handlers/management/handler.go b/internal/api/handlers/management/handler.go index 30cc973817..ee96ed79b8 100644 --- a/internal/api/handlers/management/handler.go +++ b/internal/api/handlers/management/handler.go @@ -152,9 +152,6 @@ func (h *Handler) SetPostAuthHook(hook coreauth.PostAuthHook) { // All requests (local and remote) require a valid management key. // Additionally, remote access requires allow-remote-management=true. func (h *Handler) Middleware() gin.HandlerFunc { - const maxFailures = 5 - const banDuration = 30 * time.Minute - return func(c *gin.Context) { c.Header("X-CPA-VERSION", buildinfo.Version) c.Header("X-CPA-COMMIT", buildinfo.Commit) @@ -162,64 +159,6 @@ func (h *Handler) Middleware() gin.HandlerFunc { clientIP := c.ClientIP() localClient := clientIP == "127.0.0.1" || clientIP == "::1" - cfg := h.cfg - var ( - allowRemote bool - secretHash string - ) - if cfg != nil { - allowRemote = cfg.RemoteManagement.AllowRemote - secretHash = cfg.RemoteManagement.SecretKey - } - if h.allowRemoteOverride { - allowRemote = true - } - envSecret := h.envSecret - - fail := func() {} - if !localClient { - h.attemptsMu.Lock() - ai := h.failedAttempts[clientIP] - if ai != nil { - if !ai.blockedUntil.IsZero() { - if time.Now().Before(ai.blockedUntil) { - remaining := time.Until(ai.blockedUntil).Round(time.Second) - h.attemptsMu.Unlock() - c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("IP banned due to too many failed attempts. Try again in %s", remaining)}) - return - } - // Ban expired, reset state - ai.blockedUntil = time.Time{} - ai.count = 0 - } - } - h.attemptsMu.Unlock() - - if !allowRemote { - c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management disabled"}) - return - } - - fail = func() { - h.attemptsMu.Lock() - aip := h.failedAttempts[clientIP] - if aip == nil { - aip = &attemptInfo{} - h.failedAttempts[clientIP] = aip - } - aip.count++ - aip.lastActivity = time.Now() - if aip.count >= maxFailures { - aip.blockedUntil = time.Now().Add(banDuration) - aip.count = 0 - } - h.attemptsMu.Unlock() - } - } - if secretHash == "" && envSecret == "" { - c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management key not set"}) - return - } // Accept either Authorization: Bearer or X-Management-Key var provided string @@ -235,44 +174,98 @@ func (h *Handler) Middleware() gin.HandlerFunc { provided = c.GetHeader("X-Management-Key") } - if provided == "" { - if !localClient { - fail() - } - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing management key"}) + allowed, statusCode, errMsg := h.AuthenticateManagementKey(clientIP, localClient, provided) + if !allowed { + c.AbortWithStatusJSON(statusCode, gin.H{"error": errMsg}) return } + c.Next() + } +} - if localClient { - if lp := h.localPassword; lp != "" { - if subtle.ConstantTimeCompare([]byte(provided), []byte(lp)) == 1 { - c.Next() - return +// AuthenticateManagementKey verifies the provided management key for the given client. +// It mirrors the behaviour of Middleware() so non-HTTP callers can reuse the same logic. +func (h *Handler) AuthenticateManagementKey(clientIP string, localClient bool, provided string) (bool, int, string) { + const maxFailures = 5 + const banDuration = 30 * time.Minute + + if h == nil { + return false, http.StatusForbidden, "remote management disabled" + } + + cfg := h.cfg + var ( + allowRemote bool + secretHash string + ) + if cfg != nil { + allowRemote = cfg.RemoteManagement.AllowRemote + secretHash = cfg.RemoteManagement.SecretKey + } + if h.allowRemoteOverride { + allowRemote = true + } + envSecret := h.envSecret + + fail := func() {} + if !localClient { + h.attemptsMu.Lock() + ai := h.failedAttempts[clientIP] + if ai != nil { + if !ai.blockedUntil.IsZero() { + if time.Now().Before(ai.blockedUntil) { + remaining := time.Until(ai.blockedUntil).Round(time.Second) + h.attemptsMu.Unlock() + return false, http.StatusForbidden, fmt.Sprintf("IP banned due to too many failed attempts. Try again in %s", remaining) } + // Ban expired, reset state + ai.blockedUntil = time.Time{} + ai.count = 0 } } + h.attemptsMu.Unlock() - if envSecret != "" && subtle.ConstantTimeCompare([]byte(provided), []byte(envSecret)) == 1 { - if !localClient { - h.attemptsMu.Lock() - if ai := h.failedAttempts[clientIP]; ai != nil { - ai.count = 0 - ai.blockedUntil = time.Time{} - } - h.attemptsMu.Unlock() + if !allowRemote { + return false, http.StatusForbidden, "remote management disabled" + } + + fail = func() { + h.attemptsMu.Lock() + aip := h.failedAttempts[clientIP] + if aip == nil { + aip = &attemptInfo{} + h.failedAttempts[clientIP] = aip } - c.Next() - return + aip.count++ + aip.lastActivity = time.Now() + if aip.count >= maxFailures { + aip.blockedUntil = time.Now().Add(banDuration) + aip.count = 0 + } + h.attemptsMu.Unlock() } + } + + if secretHash == "" && envSecret == "" { + return false, http.StatusForbidden, "remote management key not set" + } - if secretHash == "" || bcrypt.CompareHashAndPassword([]byte(secretHash), []byte(provided)) != nil { - if !localClient { - fail() + if provided == "" { + if !localClient { + fail() + } + return false, http.StatusUnauthorized, "missing management key" + } + + if localClient { + if lp := h.localPassword; lp != "" { + if subtle.ConstantTimeCompare([]byte(provided), []byte(lp)) == 1 { + return true, 0, "" } - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid management key"}) - return } + } + if envSecret != "" && subtle.ConstantTimeCompare([]byte(provided), []byte(envSecret)) == 1 { if !localClient { h.attemptsMu.Lock() if ai := h.failedAttempts[clientIP]; ai != nil { @@ -281,9 +274,26 @@ func (h *Handler) Middleware() gin.HandlerFunc { } h.attemptsMu.Unlock() } + return true, 0, "" + } + + if secretHash == "" || bcrypt.CompareHashAndPassword([]byte(secretHash), []byte(provided)) != nil { + if !localClient { + fail() + } + return false, http.StatusUnauthorized, "invalid management key" + } - c.Next() + if !localClient { + h.attemptsMu.Lock() + if ai := h.failedAttempts[clientIP]; ai != nil { + ai.count = 0 + ai.blockedUntil = time.Time{} + } + h.attemptsMu.Unlock() } + + return true, 0, "" } // persist saves the current in-memory config to disk. diff --git a/internal/api/mux_listener.go b/internal/api/mux_listener.go new file mode 100644 index 0000000000..d9a0c9f401 --- /dev/null +++ b/internal/api/mux_listener.go @@ -0,0 +1,68 @@ +package api + +import ( + "net" + "sync" +) + +type muxListener struct { + addr net.Addr + connCh chan net.Conn + closeCh chan struct{} + once sync.Once +} + +func newMuxListener(addr net.Addr, buffer int) *muxListener { + if buffer <= 0 { + buffer = 1 + } + return &muxListener{ + addr: addr, + connCh: make(chan net.Conn, buffer), + closeCh: make(chan struct{}), + } +} + +func (l *muxListener) Put(conn net.Conn) error { + if conn == nil { + return nil + } + select { + case <-l.closeCh: + return net.ErrClosed + case l.connCh <- conn: + return nil + } +} + +func (l *muxListener) Accept() (net.Conn, error) { + select { + case <-l.closeCh: + return nil, net.ErrClosed + case conn := <-l.connCh: + if conn == nil { + return nil, net.ErrClosed + } + return conn, nil + } +} + +func (l *muxListener) Close() error { + if l == nil { + return nil + } + l.once.Do(func() { + close(l.closeCh) + }) + return nil +} + +func (l *muxListener) Addr() net.Addr { + if l == nil { + return &net.TCPAddr{} + } + if l.addr == nil { + return &net.TCPAddr{} + } + return l.addr +} diff --git a/internal/api/protocol_multiplexer.go b/internal/api/protocol_multiplexer.go new file mode 100644 index 0000000000..14068dc556 --- /dev/null +++ b/internal/api/protocol_multiplexer.go @@ -0,0 +1,109 @@ +package api + +import ( + "bufio" + "crypto/tls" + "errors" + "net" + "net/http" + "strings" + + log "github.com/sirupsen/logrus" +) + +func normalizeHTTPServeError(err error) error { + if err == nil { + return nil + } + if errors.Is(err, net.ErrClosed) { + return nil + } + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} + +func normalizeListenerError(err error) error { + if err == nil { + return nil + } + if errors.Is(err, net.ErrClosed) { + return nil + } + return err +} + +func (s *Server) acceptMuxConnections(listener net.Listener, httpListener *muxListener) error { + if s == nil || listener == nil { + return net.ErrClosed + } + + for { + conn, errAccept := listener.Accept() + if errAccept != nil { + return errAccept + } + if conn == nil { + continue + } + + tlsConn, ok := conn.(*tls.Conn) + if ok { + if errHandshake := tlsConn.Handshake(); errHandshake != nil { + if errClose := conn.Close(); errClose != nil { + log.Errorf("failed to close connection after TLS handshake error: %v", errClose) + } + continue + } + proto := strings.TrimSpace(tlsConn.ConnectionState().NegotiatedProtocol) + if proto == "h2" || proto == "http/1.1" { + if httpListener == nil { + if errClose := conn.Close(); errClose != nil { + log.Errorf("failed to close connection: %v", errClose) + } + continue + } + if errPut := httpListener.Put(tlsConn); errPut != nil { + if errClose := conn.Close(); errClose != nil { + log.Errorf("failed to close connection after HTTP routing failure: %v", errClose) + } + } + continue + } + } + + reader := bufio.NewReader(conn) + prefix, errPeek := reader.Peek(1) + if errPeek != nil { + if errClose := conn.Close(); errClose != nil { + log.Errorf("failed to close connection after protocol peek failure: %v", errClose) + } + continue + } + + if isRedisRESPPrefix(prefix[0]) { + if !s.managementRoutesEnabled.Load() { + if errClose := conn.Close(); errClose != nil { + log.Errorf("failed to close redis connection while management is disabled: %v", errClose) + } + continue + } + go s.handleRedisConnection(conn, reader) + continue + } + + if httpListener == nil { + if errClose := conn.Close(); errClose != nil { + log.Errorf("failed to close connection without HTTP listener: %v", errClose) + } + continue + } + + if errPut := httpListener.Put(&bufferedConn{Conn: conn, reader: reader}); errPut != nil { + if errClose := conn.Close(); errClose != nil { + log.Errorf("failed to close connection after HTTP routing failure: %v", errClose) + } + } + } +} diff --git a/internal/api/redis_queue_protocol.go b/internal/api/redis_queue_protocol.go new file mode 100644 index 0000000000..053a99c755 --- /dev/null +++ b/internal/api/redis_queue_protocol.go @@ -0,0 +1,317 @@ +package api + +import ( + "bufio" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue" + log "github.com/sirupsen/logrus" +) + +func isRedisRESPPrefix(prefix byte) bool { + switch prefix { + case '*', '$', '+', '-', ':': + return true + default: + return false + } +} + +func (s *Server) handleRedisConnection(conn net.Conn, reader *bufio.Reader) { + if s == nil || conn == nil || reader == nil { + return + } + + clientIP, localClient := resolveRemoteIP(conn.RemoteAddr()) + authed := false + writer := bufio.NewWriter(conn) + defer func() { + if errClose := conn.Close(); errClose != nil { + log.Errorf("redis connection close error: %v", errClose) + } + }() + + flush := func() bool { + if errFlush := writer.Flush(); errFlush != nil { + log.Errorf("redis protocol flush error: %v", errFlush) + return false + } + return true + } + + for { + if !s.managementRoutesEnabled.Load() { + return + } + + args, err := readRESPArray(reader) + if err != nil { + if !errors.Is(err, io.EOF) { + _ = writeRedisError(writer, "ERR "+err.Error()) + _ = writer.Flush() + } + return + } + if len(args) == 0 { + _ = writeRedisError(writer, "ERR empty command") + if !flush() { + return + } + continue + } + + cmd := strings.ToUpper(strings.TrimSpace(args[0])) + switch cmd { + case "AUTH": + password, ok := parseAuthPassword(args) + if !ok { + _ = writeRedisError(writer, "ERR wrong number of arguments for 'auth' command") + if !flush() { + return + } + continue + } + if s.mgmt == nil { + _ = writeRedisError(writer, "ERR remote management disabled") + if !flush() { + return + } + continue + } + allowed, _, errMsg := s.mgmt.AuthenticateManagementKey(clientIP, localClient, password) + if !allowed { + _ = writeRedisError(writer, "ERR "+errMsg) + if !flush() { + return + } + continue + } + authed = true + _ = writeRedisSimpleString(writer, "OK") + if !flush() { + return + } + case "LPOP", "RPOP": + if !authed { + _ = writeRedisError(writer, "NOAUTH Authentication required.") + if !flush() { + return + } + continue + } + count, hasCount, ok := parsePopCount(args) + if !ok { + _ = writeRedisError(writer, "ERR wrong number of arguments for '"+strings.ToLower(cmd)+"' command") + if !flush() { + return + } + continue + } + if count <= 0 { + _ = writeRedisError(writer, "ERR value is not an integer or out of range") + if !flush() { + return + } + continue + } + items := redisqueue.PopOldest(count) + if hasCount { + _ = writeRedisArrayOfBulkStrings(writer, items) + if !flush() { + return + } + continue + } + if len(items) == 0 { + _ = writeRedisNilBulkString(writer) + if !flush() { + return + } + continue + } + _ = writeRedisBulkString(writer, items[0]) + if !flush() { + return + } + default: + _ = writeRedisError(writer, fmt.Sprintf("ERR unknown command '%s'", strings.ToLower(cmd))) + if !flush() { + return + } + } + } +} + +func resolveRemoteIP(addr net.Addr) (ip string, localClient bool) { + if addr == nil { + return "", false + } + host := addr.String() + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + host = strings.TrimSpace(host) + localClient = host == "127.0.0.1" || host == "::1" + return host, localClient +} + +func parseAuthPassword(args []string) (string, bool) { + switch len(args) { + case 2: + return args[1], true + case 3: + // Support AUTH by ignoring username for compatibility. + return args[2], true + default: + return "", false + } +} + +func parsePopCount(args []string) (count int, hasCount bool, ok bool) { + if len(args) != 2 && len(args) != 3 { + return 0, false, false + } + if len(args) == 2 { + return 1, false, true + } + parsed, err := strconv.Atoi(strings.TrimSpace(args[2])) + if err != nil { + return 0, true, true + } + return parsed, true, true +} + +func readRESPArray(reader *bufio.Reader) ([]string, error) { + prefix, err := reader.ReadByte() + if err != nil { + return nil, err + } + if prefix != '*' { + return nil, fmt.Errorf("protocol error") + } + line, err := readRESPLine(reader) + if err != nil { + return nil, err + } + count, err := strconv.Atoi(line) + if err != nil || count < 0 { + return nil, fmt.Errorf("protocol error") + } + args := make([]string, 0, count) + for i := 0; i < count; i++ { + value, err := readRESPString(reader) + if err != nil { + return nil, err + } + args = append(args, value) + } + return args, nil +} + +func readRESPString(reader *bufio.Reader) (string, error) { + prefix, err := reader.ReadByte() + if err != nil { + return "", err + } + switch prefix { + case '$': + return readRESPBulkString(reader) + case '+', ':': + return readRESPLine(reader) + default: + return "", fmt.Errorf("protocol error") + } +} + +func readRESPBulkString(reader *bufio.Reader) (string, error) { + line, err := readRESPLine(reader) + if err != nil { + return "", err + } + length, err := strconv.Atoi(line) + if err != nil { + return "", fmt.Errorf("protocol error") + } + if length < 0 { + return "", nil + } + buf := make([]byte, length+2) + if _, err := io.ReadFull(reader, buf); err != nil { + return "", err + } + if length+2 < 2 || buf[length] != '\r' || buf[length+1] != '\n' { + return "", fmt.Errorf("protocol error") + } + return string(buf[:length]), nil +} + +func readRESPLine(reader *bufio.Reader) (string, error) { + line, err := reader.ReadString('\n') + if err != nil { + return "", err + } + line = strings.TrimSuffix(line, "\n") + line = strings.TrimSuffix(line, "\r") + return line, nil +} + +func writeRedisSimpleString(writer *bufio.Writer, value string) error { + if writer == nil { + return net.ErrClosed + } + _, err := writer.WriteString("+" + value + "\r\n") + return err +} + +func writeRedisError(writer *bufio.Writer, message string) error { + if writer == nil { + return net.ErrClosed + } + _, err := writer.WriteString("-" + message + "\r\n") + return err +} + +func writeRedisNilBulkString(writer *bufio.Writer) error { + if writer == nil { + return net.ErrClosed + } + _, err := writer.WriteString("$-1\r\n") + return err +} + +func writeRedisBulkString(writer *bufio.Writer, payload []byte) error { + if writer == nil { + return net.ErrClosed + } + if payload == nil { + return writeRedisNilBulkString(writer) + } + if _, err := writer.WriteString("$" + strconv.Itoa(len(payload)) + "\r\n"); err != nil { + return err + } + if _, err := writer.Write(payload); err != nil { + return err + } + _, err := writer.WriteString("\r\n") + return err +} + +func writeRedisArrayOfBulkStrings(writer *bufio.Writer, items [][]byte) error { + if writer == nil { + return net.ErrClosed + } + if _, err := writer.WriteString("*" + strconv.Itoa(len(items)) + "\r\n"); err != nil { + return err + } + for i := range items { + if err := writeRedisBulkString(writer, items[i]); err != nil { + return err + } + } + return nil +} diff --git a/internal/api/redis_queue_protocol_integration_test.go b/internal/api/redis_queue_protocol_integration_test.go new file mode 100644 index 0000000000..18ab0279a6 --- /dev/null +++ b/internal/api/redis_queue_protocol_integration_test.go @@ -0,0 +1,304 @@ +package api + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "testing" + "time" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue" +) + +func startRedisMuxListener(t *testing.T, server *Server) (addr string, stop func()) { + t.Helper() + + listener, errListen := net.Listen("tcp", "127.0.0.1:0") + if errListen != nil { + t.Fatalf("failed to listen: %v", errListen) + } + + errCh := make(chan error, 1) + go func() { + errCh <- server.acceptMuxConnections(listener, nil) + }() + + stop = func() { + _ = listener.Close() + select { + case err := <-errCh: + if err != nil && !errors.Is(err, net.ErrClosed) { + t.Errorf("accept loop returned unexpected error: %v", err) + } + case <-time.After(2 * time.Second): + t.Errorf("timeout waiting for accept loop to exit") + } + } + + return listener.Addr().String(), stop +} + +func writeTestRESPCommand(conn net.Conn, args ...string) error { + if conn == nil { + return net.ErrClosed + } + if len(args) == 0 { + return nil + } + + var buf bytes.Buffer + fmt.Fprintf(&buf, "*%d\r\n", len(args)) + for _, arg := range args { + fmt.Fprintf(&buf, "$%d\r\n%s\r\n", len(arg), arg) + } + _, err := conn.Write(buf.Bytes()) + return err +} + +func readTestRESPLine(r *bufio.Reader) (string, error) { + line, err := r.ReadString('\n') + if err != nil { + return "", err + } + if !strings.HasSuffix(line, "\r\n") { + return "", fmt.Errorf("invalid RESP line terminator: %q", line) + } + return strings.TrimSuffix(line, "\r\n"), nil +} + +func readTestRESPSimpleString(r *bufio.Reader) (string, error) { + prefix, err := r.ReadByte() + if err != nil { + return "", err + } + if prefix != '+' { + return "", fmt.Errorf("expected simple string prefix '+', got %q", prefix) + } + return readTestRESPLine(r) +} + +func readTestRESPError(r *bufio.Reader) (string, error) { + prefix, err := r.ReadByte() + if err != nil { + return "", err + } + if prefix != '-' { + return "", fmt.Errorf("expected error prefix '-', got %q", prefix) + } + return readTestRESPLine(r) +} + +func readTestRESPBulkString(r *bufio.Reader) ([]byte, error) { + prefix, err := r.ReadByte() + if err != nil { + return nil, err + } + if prefix != '$' { + return nil, fmt.Errorf("expected bulk string prefix '$', got %q", prefix) + } + + line, err := readTestRESPLine(r) + if err != nil { + return nil, err + } + length, err := strconv.Atoi(line) + if err != nil { + return nil, fmt.Errorf("invalid bulk string length %q: %v", line, err) + } + if length == -1 { + return nil, nil + } + if length < -1 { + return nil, fmt.Errorf("invalid bulk string length %d", length) + } + + payload := make([]byte, length+2) + if _, err := io.ReadFull(r, payload); err != nil { + return nil, err + } + if payload[length] != '\r' || payload[length+1] != '\n' { + return nil, fmt.Errorf("invalid bulk string terminator") + } + return payload[:length], nil +} + +func readRESPArrayOfBulkStrings(r *bufio.Reader) ([][]byte, error) { + prefix, err := r.ReadByte() + if err != nil { + return nil, err + } + if prefix != '*' { + return nil, fmt.Errorf("expected array prefix '*', got %q", prefix) + } + + line, err := readTestRESPLine(r) + if err != nil { + return nil, err + } + count, err := strconv.Atoi(line) + if err != nil { + return nil, fmt.Errorf("invalid array length %q: %v", line, err) + } + if count < 0 { + return nil, fmt.Errorf("invalid array length %d", count) + } + + out := make([][]byte, 0, count) + for i := 0; i < count; i++ { + item, err := readTestRESPBulkString(r) + if err != nil { + return nil, err + } + out = append(out, item) + } + return out, nil +} + +func TestRedisProtocol_ManagementDisabled_RejectsConnection(t *testing.T) { + t.Setenv("MANAGEMENT_PASSWORD", "") + redisqueue.SetEnabled(false) + + server := newTestServer(t) + if server.managementRoutesEnabled.Load() { + t.Fatalf("expected managementRoutesEnabled to be false") + } + + addr, stop := startRedisMuxListener(t, server) + t.Cleanup(stop) + + conn, errDial := net.DialTimeout("tcp", addr, time.Second) + if errDial != nil { + t.Fatalf("failed to dial redis listener: %v", errDial) + } + t.Cleanup(func() { _ = conn.Close() }) + + _ = conn.SetDeadline(time.Now().Add(2 * time.Second)) + if errWrite := writeTestRESPCommand(conn, "PING"); errWrite != nil { + t.Fatalf("failed to write RESP command: %v", errWrite) + } + + buf := make([]byte, 1) + _, errRead := conn.Read(buf) + if errRead == nil { + t.Fatalf("expected connection to be closed when management is disabled") + } + if ne, ok := errRead.(net.Error); ok && ne.Timeout() { + t.Fatalf("expected connection to be closed when management is disabled, got timeout: %v", errRead) + } +} + +func TestRedisProtocol_AUTH_And_PopContracts(t *testing.T) { + const managementPassword = "test-management-password" + + t.Setenv("MANAGEMENT_PASSWORD", managementPassword) + redisqueue.SetEnabled(false) + t.Cleanup(func() { redisqueue.SetEnabled(false) }) + + server := newTestServer(t) + if !server.managementRoutesEnabled.Load() { + t.Fatalf("expected managementRoutesEnabled to be true") + } + + addr, stop := startRedisMuxListener(t, server) + t.Cleanup(stop) + + conn, errDial := net.DialTimeout("tcp", addr, time.Second) + if errDial != nil { + t.Fatalf("failed to dial redis listener: %v", errDial) + } + t.Cleanup(func() { _ = conn.Close() }) + + reader := bufio.NewReader(conn) + + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + + if errWrite := writeTestRESPCommand(conn, "AUTH", "test-key"); errWrite != nil { + t.Fatalf("failed to write AUTH command: %v", errWrite) + } + if msg, err := readTestRESPError(reader); err != nil { + t.Fatalf("failed to read AUTH error: %v", err) + } else if msg != "ERR invalid management key" { + t.Fatalf("unexpected AUTH error: %q", msg) + } + + if errWrite := writeTestRESPCommand(conn, "LPOP", "queue"); errWrite != nil { + t.Fatalf("failed to write LPOP command: %v", errWrite) + } + if msg, err := readTestRESPError(reader); err != nil { + t.Fatalf("failed to read LPOP NOAUTH error: %v", err) + } else if msg != "NOAUTH Authentication required." { + t.Fatalf("unexpected LPOP NOAUTH error: %q", msg) + } + + if errWrite := writeTestRESPCommand(conn, "AUTH", managementPassword); errWrite != nil { + t.Fatalf("failed to write AUTH command: %v", errWrite) + } + if msg, err := readTestRESPSimpleString(reader); err != nil { + t.Fatalf("failed to read AUTH response: %v", err) + } else if msg != "OK" { + t.Fatalf("unexpected AUTH response: %q", msg) + } + + if !redisqueue.Enabled() { + t.Fatalf("expected redisqueue to be enabled") + } + redisqueue.Enqueue([]byte("a")) + redisqueue.Enqueue([]byte("b")) + redisqueue.Enqueue([]byte("c")) + + if errWrite := writeTestRESPCommand(conn, "RPOP", "queue"); errWrite != nil { + t.Fatalf("failed to write RPOP command: %v", errWrite) + } + if item, err := readTestRESPBulkString(reader); err != nil { + t.Fatalf("failed to read RPOP response: %v", err) + } else if string(item) != "a" { + t.Fatalf("unexpected RPOP item: %q", string(item)) + } + + if errWrite := writeTestRESPCommand(conn, "LPOP", "queue"); errWrite != nil { + t.Fatalf("failed to write LPOP command: %v", errWrite) + } + if item, err := readTestRESPBulkString(reader); err != nil { + t.Fatalf("failed to read LPOP response: %v", err) + } else if string(item) != "b" { + t.Fatalf("unexpected LPOP item: %q", string(item)) + } + + if errWrite := writeTestRESPCommand(conn, "RPOP", "queue", "10"); errWrite != nil { + t.Fatalf("failed to write RPOP count command: %v", errWrite) + } + items, errItems := readRESPArrayOfBulkStrings(reader) + if errItems != nil { + t.Fatalf("failed to read RPOP count response: %v", errItems) + } + if len(items) != 1 || string(items[0]) != "c" { + t.Fatalf("unexpected RPOP count items: %#v", items) + } + + if errWrite := writeTestRESPCommand(conn, "LPOP", "queue"); errWrite != nil { + t.Fatalf("failed to write LPOP empty command: %v", errWrite) + } + item, errItem := readTestRESPBulkString(reader) + if errItem != nil { + t.Fatalf("failed to read LPOP empty response: %v", errItem) + } + if item != nil { + t.Fatalf("expected nil bulk string for empty queue, got %q", string(item)) + } + + if errWrite := writeTestRESPCommand(conn, "RPOP", "queue", "2"); errWrite != nil { + t.Fatalf("failed to write RPOP empty count command: %v", errWrite) + } + emptyItems, errEmpty := readRESPArrayOfBulkStrings(reader) + if errEmpty != nil { + t.Fatalf("failed to read RPOP empty count response: %v", errEmpty) + } + if len(emptyItems) != 0 { + t.Fatalf("expected empty array for empty queue with count, got %#v", emptyItems) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 32ae3164fd..e70883b02d 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -7,8 +7,10 @@ package api import ( "context" "crypto/subtle" + "crypto/tls" "errors" "fmt" + "net" "net/http" "os" "path/filepath" @@ -28,6 +30,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" "github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset" + "github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue" "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" @@ -38,6 +41,7 @@ import ( sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" + "golang.org/x/net/http2" "gopkg.in/yaml.v3" ) @@ -127,6 +131,12 @@ type Server struct { // server is the underlying HTTP server. server *http.Server + // muxBaseListener is the shared TCP listener used to serve both HTTP and Redis protocol traffic. + muxBaseListener net.Listener + + // muxHTTPListener receives HTTP connections selected by the multiplexer. + muxHTTPListener *muxListener + // handlers contains the API handlers for processing requests. handlers *handlers.BaseAPIHandler @@ -299,6 +309,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk // or when a local management password is provided (e.g. TUI mode). hasManagementSecret := cfg.RemoteManagement.SecretKey != "" || envManagementSecret || s.localPassword != "" s.managementRoutesEnabled.Store(hasManagementSecret) + redisqueue.SetEnabled(hasManagementSecret) if hasManagementSecret { s.registerManagementRoutes() } @@ -797,26 +808,98 @@ func (s *Server) Start() error { return fmt.Errorf("failed to start HTTP server: server not initialized") } + addr := s.server.Addr + listener, errListen := net.Listen("tcp", addr) + if errListen != nil { + return fmt.Errorf("failed to start HTTP server: %v", errListen) + } + useTLS := s.cfg != nil && s.cfg.TLS.Enable if useTLS { - cert := strings.TrimSpace(s.cfg.TLS.Cert) - key := strings.TrimSpace(s.cfg.TLS.Key) - if cert == "" || key == "" { + certPath := strings.TrimSpace(s.cfg.TLS.Cert) + keyPath := strings.TrimSpace(s.cfg.TLS.Key) + if certPath == "" || keyPath == "" { + if errClose := listener.Close(); errClose != nil { + log.Errorf("failed to close listener after TLS validation failure: %v", errClose) + } return fmt.Errorf("failed to start HTTPS server: tls.cert or tls.key is empty") } - log.Debugf("Starting API server on %s with TLS", s.server.Addr) - if errServeTLS := s.server.ListenAndServeTLS(cert, key); errServeTLS != nil && !errors.Is(errServeTLS, http.ErrServerClosed) { - return fmt.Errorf("failed to start HTTPS server: %v", errServeTLS) + certPair, errLoad := tls.LoadX509KeyPair(certPath, keyPath) + if errLoad != nil { + if errClose := listener.Close(); errClose != nil { + log.Errorf("failed to close listener after TLS key pair load failure: %v", errClose) + } + return fmt.Errorf("failed to start HTTPS server: %v", errLoad) } - return nil - } - log.Debugf("Starting API server on %s", s.server.Addr) - if errServe := s.server.ListenAndServe(); errServe != nil && !errors.Is(errServe, http.ErrServerClosed) { - return fmt.Errorf("failed to start HTTP server: %v", errServe) + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{certPair}, + NextProtos: []string{"h2", "http/1.1"}, + } + s.server.TLSConfig = tlsConfig + if errHTTP2 := http2.ConfigureServer(s.server, &http2.Server{}); errHTTP2 != nil { + log.Warnf("failed to configure HTTP/2: %v", errHTTP2) + } + listener = tls.NewListener(listener, tlsConfig) + log.Debugf("Starting API server on %s with TLS", addr) + } else { + log.Debugf("Starting API server on %s", addr) } - return nil + httpListener := newMuxListener(listener.Addr(), 1024) + s.muxBaseListener = listener + s.muxHTTPListener = httpListener + + httpErrCh := make(chan error, 1) + acceptErrCh := make(chan error, 1) + + go func() { + httpErrCh <- s.server.Serve(httpListener) + }() + go func() { + acceptErrCh <- s.acceptMuxConnections(listener, httpListener) + }() + + select { + case errServe := <-httpErrCh: + if s.muxBaseListener != nil { + if errClose := s.muxBaseListener.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) { + log.Debugf("failed to close shared listener after HTTP serve exit: %v", errClose) + } + } + if s.muxHTTPListener != nil { + _ = s.muxHTTPListener.Close() + } + errAccept := <-acceptErrCh + errServe = normalizeHTTPServeError(errServe) + errAccept = normalizeListenerError(errAccept) + if errServe != nil { + return fmt.Errorf("failed to start HTTP server: %v", errServe) + } + if errAccept != nil { + return fmt.Errorf("failed to start HTTP server: %v", errAccept) + } + return nil + case errAccept := <-acceptErrCh: + if s.muxHTTPListener != nil { + _ = s.muxHTTPListener.Close() + } + if s.muxBaseListener != nil { + if errClose := s.muxBaseListener.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) { + log.Debugf("failed to close shared listener after accept loop exit: %v", errClose) + } + } + errServe := <-httpErrCh + errServe = normalizeHTTPServeError(errServe) + errAccept = normalizeListenerError(errAccept) + if errAccept != nil { + return fmt.Errorf("failed to start HTTP server: %v", errAccept) + } + if errServe != nil { + return fmt.Errorf("failed to start HTTP server: %v", errServe) + } + return nil + } } // Stop gracefully shuts down the API server without interrupting any @@ -837,6 +920,15 @@ func (s *Server) Stop(ctx context.Context) error { } } + if s.muxHTTPListener != nil { + _ = s.muxHTTPListener.Close() + } + if s.muxBaseListener != nil { + if errClose := s.muxBaseListener.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) { + log.Debugf("failed to close shared listener: %v", errClose) + } + } + // Shutdown the HTTP server. if err := s.server.Shutdown(ctx); err != nil { return fmt.Errorf("failed to shutdown HTTP server: %v", err) @@ -963,6 +1055,7 @@ func (s *Server) UpdateClients(cfg *config.Config) { s.managementRoutesEnabled.Store(!newSecretEmpty) } } + redisqueue.SetEnabled(s.managementRoutesEnabled.Load()) s.applyAccessConfig(oldCfg, cfg) s.cfg = cfg diff --git a/internal/redisqueue/plugin.go b/internal/redisqueue/plugin.go new file mode 100644 index 0000000000..a805e5dad5 --- /dev/null +++ b/internal/redisqueue/plugin.go @@ -0,0 +1,145 @@ +package redisqueue + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + internallogging "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" + internalusage "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" + coreusage "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" +) + +func init() { + coreusage.RegisterPlugin(&usageQueuePlugin{}) +} + +type usageQueuePlugin struct{} + +func (p *usageQueuePlugin) HandleUsage(ctx context.Context, record coreusage.Record) { + if p == nil { + return + } + if !Enabled() || !internalusage.StatisticsEnabled() { + return + } + + timestamp := record.RequestedAt + if timestamp.IsZero() { + timestamp = time.Now() + } + + modelName := strings.TrimSpace(record.Model) + if modelName == "" { + modelName = "unknown" + } + provider := strings.TrimSpace(record.Provider) + if provider == "" { + provider = "unknown" + } + authType := strings.TrimSpace(record.AuthType) + if authType == "" { + authType = "unknown" + } + apiKey := strings.TrimSpace(record.APIKey) + requestID := strings.TrimSpace(internallogging.GetRequestID(ctx)) + if requestID == "" { + if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil { + requestID = strings.TrimSpace(internallogging.GetGinRequestID(ginCtx)) + } + } + + tokens := internalusage.TokenStats{ + InputTokens: record.Detail.InputTokens, + OutputTokens: record.Detail.OutputTokens, + ReasoningTokens: record.Detail.ReasoningTokens, + CachedTokens: record.Detail.CachedTokens, + TotalTokens: record.Detail.TotalTokens, + } + if tokens.TotalTokens == 0 { + tokens.TotalTokens = tokens.InputTokens + tokens.OutputTokens + tokens.ReasoningTokens + } + if tokens.TotalTokens == 0 { + tokens.TotalTokens = tokens.InputTokens + tokens.OutputTokens + tokens.ReasoningTokens + tokens.CachedTokens + } + + failed := record.Failed + if !failed { + failed = !resolveSuccess(ctx) + } + + detail := internalusage.RequestDetail{ + Timestamp: timestamp, + LatencyMs: record.Latency.Milliseconds(), + Source: record.Source, + AuthIndex: record.AuthIndex, + Tokens: tokens, + Failed: failed, + } + + payload, err := json.Marshal(queuedUsageDetail{ + RequestDetail: detail, + Provider: provider, + Model: modelName, + Endpoint: resolveEndpoint(ctx), + AuthType: authType, + APIKey: apiKey, + RequestID: requestID, + }) + if err != nil { + return + } + Enqueue(payload) +} + +type queuedUsageDetail struct { + internalusage.RequestDetail + Provider string `json:"provider"` + Model string `json:"model"` + Endpoint string `json:"endpoint"` + AuthType string `json:"auth_type"` + APIKey string `json:"api_key"` + RequestID string `json:"request_id"` +} + +func resolveSuccess(ctx context.Context) bool { + if ctx == nil { + return true + } + ginCtx, ok := ctx.Value("gin").(*gin.Context) + if !ok || ginCtx == nil { + return true + } + status := ginCtx.Writer.Status() + if status == 0 { + return true + } + return status < http.StatusBadRequest +} + +func resolveEndpoint(ctx context.Context) string { + if ctx == nil { + return "" + } + ginCtx, ok := ctx.Value("gin").(*gin.Context) + if !ok || ginCtx == nil || ginCtx.Request == nil { + return "" + } + + path := strings.TrimSpace(ginCtx.FullPath()) + if path == "" && ginCtx.Request.URL != nil { + path = strings.TrimSpace(ginCtx.Request.URL.Path) + } + if path == "" { + return "" + } + + method := strings.TrimSpace(ginCtx.Request.Method) + if method == "" { + return path + } + return method + " " + path +} diff --git a/internal/redisqueue/plugin_test.go b/internal/redisqueue/plugin_test.go new file mode 100644 index 0000000000..907b8aeeb5 --- /dev/null +++ b/internal/redisqueue/plugin_test.go @@ -0,0 +1,160 @@ +package redisqueue + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + internallogging "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" + internalusage "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" + coreusage "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" +) + +func TestUsageQueuePluginPayloadIncludesStableFieldsAndSuccess(t *testing.T) { + withEnabledQueue(t, func() { + ginCtx := newTestGinContext(t, http.MethodPost, "/v1/chat/completions", http.StatusOK) + internallogging.SetGinRequestID(ginCtx, "gin-request-id-ignored") + ctx := context.WithValue(internallogging.WithRequestID(context.Background(), "ctx-request-id"), "gin", ginCtx) + + plugin := &usageQueuePlugin{} + plugin.HandleUsage(ctx, coreusage.Record{ + Provider: "openai", + Model: "gpt-5.4", + APIKey: "test-key", + AuthIndex: "0", + AuthType: "apikey", + Source: "user@example.com", + RequestedAt: time.Date(2026, 4, 25, 0, 0, 0, 0, time.UTC), + Latency: 1500 * time.Millisecond, + Detail: coreusage.Detail{ + InputTokens: 10, + OutputTokens: 20, + TotalTokens: 30, + }, + }) + + payload := popSinglePayload(t) + requireStringField(t, payload, "provider", "openai") + requireStringField(t, payload, "model", "gpt-5.4") + requireStringField(t, payload, "endpoint", "POST /v1/chat/completions") + requireStringField(t, payload, "auth_type", "apikey") + requireStringField(t, payload, "request_id", "ctx-request-id") + requireBoolField(t, payload, "failed", false) + }) +} + +func TestUsageQueuePluginPayloadIncludesStableFieldsAndFailureAndGinRequestID(t *testing.T) { + withEnabledQueue(t, func() { + ginCtx := newTestGinContext(t, http.MethodGet, "/v1/responses", http.StatusInternalServerError) + internallogging.SetGinRequestID(ginCtx, "gin-request-id") + ctx := context.WithValue(context.Background(), "gin", ginCtx) + + plugin := &usageQueuePlugin{} + plugin.HandleUsage(ctx, coreusage.Record{ + Provider: "openai", + Model: "gpt-5.4-mini", + APIKey: "test-key", + AuthIndex: "0", + AuthType: "apikey", + Source: "user@example.com", + RequestedAt: time.Date(2026, 4, 25, 0, 0, 0, 0, time.UTC), + Latency: 2500 * time.Millisecond, + Detail: coreusage.Detail{ + InputTokens: 10, + OutputTokens: 20, + TotalTokens: 30, + }, + }) + + payload := popSinglePayload(t) + requireStringField(t, payload, "provider", "openai") + requireStringField(t, payload, "model", "gpt-5.4-mini") + requireStringField(t, payload, "endpoint", "GET /v1/responses") + requireStringField(t, payload, "auth_type", "apikey") + requireStringField(t, payload, "request_id", "gin-request-id") + requireBoolField(t, payload, "failed", true) + }) +} + +func withEnabledQueue(t *testing.T, fn func()) { + t.Helper() + + prevQueueEnabled := Enabled() + prevStatsEnabled := internalusage.StatisticsEnabled() + + SetEnabled(false) + SetEnabled(true) + internalusage.SetStatisticsEnabled(true) + + defer func() { + SetEnabled(false) + SetEnabled(prevQueueEnabled) + internalusage.SetStatisticsEnabled(prevStatsEnabled) + }() + + fn() +} + +func newTestGinContext(t *testing.T, method, path string, status int) *gin.Context { + t.Helper() + + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = httptest.NewRequest(method, "http://example.com"+path, nil) + if status != 0 { + ginCtx.Status(status) + } + return ginCtx +} + +func popSinglePayload(t *testing.T) map[string]json.RawMessage { + t.Helper() + + items := PopOldest(10) + if len(items) != 1 { + t.Fatalf("PopOldest() items = %d, want 1", len(items)) + } + + var payload map[string]json.RawMessage + if err := json.Unmarshal(items[0], &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + return payload +} + +func requireStringField(t *testing.T, payload map[string]json.RawMessage, key, want string) { + t.Helper() + + raw, ok := payload[key] + if !ok { + t.Fatalf("payload missing %q", key) + } + var got string + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal %q: %v", key, err) + } + if got != want { + t.Fatalf("%s = %q, want %q", key, got, want) + } +} + +func requireBoolField(t *testing.T, payload map[string]json.RawMessage, key string, want bool) { + t.Helper() + + raw, ok := payload[key] + if !ok { + t.Fatalf("payload missing %q", key) + } + var got bool + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal %q: %v", key, err) + } + if got != want { + t.Fatalf("%s = %t, want %t", key, got, want) + } +} diff --git a/internal/redisqueue/queue.go b/internal/redisqueue/queue.go new file mode 100644 index 0000000000..8a4b6742f5 --- /dev/null +++ b/internal/redisqueue/queue.go @@ -0,0 +1,133 @@ +package redisqueue + +import ( + "sync" + "sync/atomic" + "time" +) + +const retentionWindow = time.Minute + +type queueItem struct { + enqueuedAt time.Time + payload []byte +} + +type queue struct { + mu sync.Mutex + items []queueItem + head int +} + +var ( + enabled atomic.Bool + global queue +) + +func SetEnabled(value bool) { + enabled.Store(value) + if !value { + global.clear() + } +} + +func Enabled() bool { + return enabled.Load() +} + +func Enqueue(payload []byte) { + if !Enabled() { + return + } + if len(payload) == 0 { + return + } + global.enqueue(payload) +} + +func PopOldest(count int) [][]byte { + if !Enabled() { + return nil + } + if count <= 0 { + return nil + } + return global.popOldest(count) +} + +func (q *queue) clear() { + q.mu.Lock() + defer q.mu.Unlock() + q.items = nil + q.head = 0 +} + +func (q *queue) enqueue(payload []byte) { + now := time.Now() + + q.mu.Lock() + defer q.mu.Unlock() + + q.pruneLocked(now) + q.items = append(q.items, queueItem{ + enqueuedAt: now, + payload: append([]byte(nil), payload...), + }) + q.maybeCompactLocked() +} + +func (q *queue) popOldest(count int) [][]byte { + now := time.Now() + + q.mu.Lock() + defer q.mu.Unlock() + + q.pruneLocked(now) + available := len(q.items) - q.head + if available <= 0 { + q.items = nil + q.head = 0 + return nil + } + if count > available { + count = available + } + + out := make([][]byte, 0, count) + for i := 0; i < count; i++ { + item := q.items[q.head+i] + out = append(out, item.payload) + } + q.head += count + q.maybeCompactLocked() + return out +} + +func (q *queue) pruneLocked(now time.Time) { + if q.head >= len(q.items) { + q.items = nil + q.head = 0 + return + } + + cutoff := now.Add(-retentionWindow) + for q.head < len(q.items) && q.items[q.head].enqueuedAt.Before(cutoff) { + q.head++ + } +} + +func (q *queue) maybeCompactLocked() { + if q.head == 0 { + return + } + if q.head >= len(q.items) { + q.items = nil + q.head = 0 + return + } + if q.head < 1024 && q.head*2 < len(q.items) { + return + } + q.items = append([]queueItem(nil), q.items[q.head:]...) + q.head = 0 +} diff --git a/internal/runtime/executor/helps/usage_helpers.go b/internal/runtime/executor/helps/usage_helpers.go index 8da8fd1e7a..97c1c61130 100644 --- a/internal/runtime/executor/helps/usage_helpers.go +++ b/internal/runtime/executor/helps/usage_helpers.go @@ -20,6 +20,7 @@ type UsageReporter struct { model string authID string authIndex string + authType string apiKey string source string requestedAt time.Time @@ -34,6 +35,7 @@ func NewUsageReporter(ctx context.Context, provider, model string, auth *cliprox requestedAt: time.Now(), apiKey: apiKey, source: resolveUsageSource(auth, apiKey), + authType: resolveUsageAuthType(auth), } if auth != nil { reporter.authID = auth.ID @@ -98,6 +100,7 @@ func (r *UsageReporter) buildRecord(detail usage.Detail, failed bool) usage.Reco APIKey: r.apiKey, AuthID: r.authID, AuthIndex: r.authIndex, + AuthType: r.authType, RequestedAt: r.requestedAt, Latency: r.latency(), Failed: failed, @@ -181,6 +184,18 @@ func resolveUsageSource(auth *cliproxyauth.Auth, ctxAPIKey string) string { return "" } +func resolveUsageAuthType(auth *cliproxyauth.Auth) string { + if auth == nil { + return "" + } + kind, _ := auth.AccountInfo() + kind = strings.TrimSpace(kind) + if kind == "api_key" { + return "apikey" + } + return kind +} + func ParseCodexUsage(data []byte) (usage.Detail, bool) { usageNode := gjson.ParseBytes(data).Get("response.usage") if !usageNode.Exists() { diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index fa0d8a0aa7..c5458b488c 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -13,6 +13,7 @@ import ( "time" "github.com/router-for-me/CLIProxyAPI/v6/internal/api" + _ "github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue" "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" diff --git a/sdk/cliproxy/usage/manager.go b/sdk/cliproxy/usage/manager.go index 8d24f51f4e..c3d95f663c 100644 --- a/sdk/cliproxy/usage/manager.go +++ b/sdk/cliproxy/usage/manager.go @@ -15,6 +15,7 @@ type Record struct { APIKey string AuthID string AuthIndex string + AuthType string Source string RequestedAt time.Time Latency time.Duration From 2c626efc599b7dfdad7f15d49d85fba16f458e28 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 25 Apr 2026 21:39:58 +0800 Subject: [PATCH 046/139] feat(security): implement IP ban for repeated management key and Redis AUTH failures - Added IP ban logic to `AuthenticateManagementKey` and Redis protocol handlers, blocking requests after multiple failed attempts. - Introduced unit tests to validate IP ban behavior across localhost and remote clients. - Synchronized Redis protocol's authentication policy with management key validation. --- internal/api/handlers/management/handler.go | 96 +++++----- .../api/handlers/management/handler_test.go | 38 ++++ internal/api/redis_queue_protocol.go | 60 +++++- .../redis_queue_protocol_integration_test.go | 172 ++++++++++++++++++ 4 files changed, 309 insertions(+), 57 deletions(-) create mode 100644 internal/api/handlers/management/handler_test.go diff --git a/internal/api/handlers/management/handler.go b/internal/api/handlers/management/handler.go index ee96ed79b8..af11366c33 100644 --- a/internal/api/handlers/management/handler.go +++ b/internal/api/handlers/management/handler.go @@ -207,43 +207,48 @@ func (h *Handler) AuthenticateManagementKey(clientIP string, localClient bool, p } envSecret := h.envSecret - fail := func() {} - if !localClient { - h.attemptsMu.Lock() - ai := h.failedAttempts[clientIP] - if ai != nil { - if !ai.blockedUntil.IsZero() { - if time.Now().Before(ai.blockedUntil) { - remaining := time.Until(ai.blockedUntil).Round(time.Second) - h.attemptsMu.Unlock() - return false, http.StatusForbidden, fmt.Sprintf("IP banned due to too many failed attempts. Try again in %s", remaining) - } - // Ban expired, reset state - ai.blockedUntil = time.Time{} - ai.count = 0 - } + now := time.Now() + h.attemptsMu.Lock() + ai := h.failedAttempts[clientIP] + if ai != nil && !ai.blockedUntil.IsZero() { + if now.Before(ai.blockedUntil) { + remaining := ai.blockedUntil.Sub(now).Round(time.Second) + h.attemptsMu.Unlock() + return false, http.StatusForbidden, fmt.Sprintf("IP banned due to too many failed attempts. Try again in %s", remaining) } - h.attemptsMu.Unlock() + // Ban expired, reset state + ai.blockedUntil = time.Time{} + ai.count = 0 + } + h.attemptsMu.Unlock() - if !allowRemote { - return false, http.StatusForbidden, "remote management disabled" + if !localClient && !allowRemote { + return false, http.StatusForbidden, "remote management disabled" + } + + fail := func() { + h.attemptsMu.Lock() + aip := h.failedAttempts[clientIP] + if aip == nil { + aip = &attemptInfo{} + h.failedAttempts[clientIP] = aip } + aip.count++ + aip.lastActivity = time.Now() + if aip.count >= maxFailures { + aip.blockedUntil = time.Now().Add(banDuration) + aip.count = 0 + } + h.attemptsMu.Unlock() + } - fail = func() { - h.attemptsMu.Lock() - aip := h.failedAttempts[clientIP] - if aip == nil { - aip = &attemptInfo{} - h.failedAttempts[clientIP] = aip - } - aip.count++ - aip.lastActivity = time.Now() - if aip.count >= maxFailures { - aip.blockedUntil = time.Now().Add(banDuration) - aip.count = 0 - } - h.attemptsMu.Unlock() + reset := func() { + h.attemptsMu.Lock() + if ai := h.failedAttempts[clientIP]; ai != nil { + ai.count = 0 + ai.blockedUntil = time.Time{} } + h.attemptsMu.Unlock() } if secretHash == "" && envSecret == "" { @@ -251,47 +256,30 @@ func (h *Handler) AuthenticateManagementKey(clientIP string, localClient bool, p } if provided == "" { - if !localClient { - fail() - } + fail() return false, http.StatusUnauthorized, "missing management key" } if localClient { if lp := h.localPassword; lp != "" { if subtle.ConstantTimeCompare([]byte(provided), []byte(lp)) == 1 { + reset() return true, 0, "" } } } if envSecret != "" && subtle.ConstantTimeCompare([]byte(provided), []byte(envSecret)) == 1 { - if !localClient { - h.attemptsMu.Lock() - if ai := h.failedAttempts[clientIP]; ai != nil { - ai.count = 0 - ai.blockedUntil = time.Time{} - } - h.attemptsMu.Unlock() - } + reset() return true, 0, "" } if secretHash == "" || bcrypt.CompareHashAndPassword([]byte(secretHash), []byte(provided)) != nil { - if !localClient { - fail() - } + fail() return false, http.StatusUnauthorized, "invalid management key" } - if !localClient { - h.attemptsMu.Lock() - if ai := h.failedAttempts[clientIP]; ai != nil { - ai.count = 0 - ai.blockedUntil = time.Time{} - } - h.attemptsMu.Unlock() - } + reset() return true, 0, "" } diff --git a/internal/api/handlers/management/handler_test.go b/internal/api/handlers/management/handler_test.go new file mode 100644 index 0000000000..f3a6086e95 --- /dev/null +++ b/internal/api/handlers/management/handler_test.go @@ -0,0 +1,38 @@ +package management + +import ( + "net/http" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" +) + +func TestAuthenticateManagementKey_LocalhostIPBan_BlocksCorrectKeyDuringBan(t *testing.T) { + h := &Handler{ + cfg: &config.Config{}, + failedAttempts: make(map[string]*attemptInfo), + envSecret: "test-secret", + } + + for i := 0; i < 5; i++ { + allowed, statusCode, errMsg := h.AuthenticateManagementKey("127.0.0.1", true, "wrong-secret") + if allowed { + t.Fatalf("expected auth to be denied at attempt %d", i+1) + } + if statusCode != http.StatusUnauthorized || errMsg != "invalid management key" { + t.Fatalf("unexpected auth failure at attempt %d: status=%d msg=%q", i+1, statusCode, errMsg) + } + } + + allowed, statusCode, errMsg := h.AuthenticateManagementKey("127.0.0.1", true, "test-secret") + if allowed { + t.Fatalf("expected correct key to be denied while banned") + } + if statusCode != http.StatusForbidden { + t.Fatalf("expected forbidden status while banned, got %d", statusCode) + } + if !strings.HasPrefix(errMsg, "IP banned due to too many failed attempts. Try again in") { + t.Fatalf("unexpected banned message: %q", errMsg) + } +} diff --git a/internal/api/redis_queue_protocol.go b/internal/api/redis_queue_protocol.go index 053a99c755..caaba2316d 100644 --- a/internal/api/redis_queue_protocol.go +++ b/internal/api/redis_queue_protocol.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net" + "net/http" "strconv" "strings" @@ -66,10 +67,38 @@ func (s *Server) handleRedisConnection(conn net.Conn, reader *bufio.Reader) { } cmd := strings.ToUpper(strings.TrimSpace(args[0])) + + if cmd != "AUTH" && !authed { + if s.mgmt != nil { + _, statusCode, errMsg := s.mgmt.AuthenticateManagementKey(clientIP, localClient, "") + if statusCode == http.StatusForbidden && strings.HasPrefix(errMsg, "IP banned due to too many failed attempts") { + _ = writeRedisError(writer, "ERR "+errMsg) + } else { + _ = writeRedisError(writer, "NOAUTH Authentication required.") + } + } else { + _ = writeRedisError(writer, "NOAUTH Authentication required.") + } + if !flush() { + return + } + continue + } + switch cmd { case "AUTH": password, ok := parseAuthPassword(args) if !ok { + if s.mgmt != nil { + _, statusCode, errMsg := s.mgmt.AuthenticateManagementKey(clientIP, localClient, "") + if statusCode == http.StatusForbidden && strings.HasPrefix(errMsg, "IP banned due to too many failed attempts") { + _ = writeRedisError(writer, "ERR "+errMsg) + if !flush() { + return + } + continue + } + } _ = writeRedisError(writer, "ERR wrong number of arguments for 'auth' command") if !flush() { return @@ -151,10 +180,35 @@ func resolveRemoteIP(addr net.Addr) (ip string, localClient bool) { if addr == nil { return "", false } - host := addr.String() - if h, _, err := net.SplitHostPort(host); err == nil { - host = h + + var host string + switch a := addr.(type) { + case *net.TCPAddr: + if a != nil && a.IP != nil { + if ip4 := a.IP.To4(); ip4 != nil { + host = ip4.String() + } else { + host = a.IP.String() + } + } + default: + host = addr.String() + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + host = strings.TrimSpace(host) + if raw, _, ok := strings.Cut(host, "%"); ok { + host = raw + } + if parsed := net.ParseIP(host); parsed != nil { + if ip4 := parsed.To4(); ip4 != nil { + host = ip4.String() + } else { + host = parsed.String() + } + } } + host = strings.TrimSpace(host) localClient = host == "127.0.0.1" || host == "::1" return host, localClient diff --git a/internal/api/redis_queue_protocol_integration_test.go b/internal/api/redis_queue_protocol_integration_test.go index 18ab0279a6..93bfeb8663 100644 --- a/internal/api/redis_queue_protocol_integration_test.go +++ b/internal/api/redis_queue_protocol_integration_test.go @@ -15,6 +15,18 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue" ) +type remoteAddrConn struct { + net.Conn + remoteAddr net.Addr +} + +func (c *remoteAddrConn) RemoteAddr() net.Addr { + if c == nil { + return nil + } + return c.remoteAddr +} + func startRedisMuxListener(t *testing.T, server *Server) (addr string, stop func()) { t.Helper() @@ -302,3 +314,163 @@ func TestRedisProtocol_AUTH_And_PopContracts(t *testing.T) { t.Fatalf("expected empty array for empty queue with count, got %#v", emptyItems) } } + +func TestRedisProtocol_IPBan_MirrorsManagementPolicy(t *testing.T) { + const managementPassword = "test-management-password" + + t.Setenv("MANAGEMENT_PASSWORD", managementPassword) + redisqueue.SetEnabled(false) + t.Cleanup(func() { redisqueue.SetEnabled(false) }) + + server := newTestServer(t) + if !server.managementRoutesEnabled.Load() { + t.Fatalf("expected managementRoutesEnabled to be true") + } + + clientConn, serverConn := net.Pipe() + t.Cleanup(func() { _ = clientConn.Close() }) + t.Cleanup(func() { _ = serverConn.Close() }) + + fakeRemote := &net.TCPAddr{ + IP: net.ParseIP("1.2.3.4"), + Port: 1234, + } + wrappedConn := &remoteAddrConn{Conn: serverConn, remoteAddr: fakeRemote} + + go server.handleRedisConnection(wrappedConn, bufio.NewReader(wrappedConn)) + + reader := bufio.NewReader(clientConn) + _ = clientConn.SetDeadline(time.Now().Add(5 * time.Second)) + + for i := 0; i < 5; i++ { + if errWrite := writeTestRESPCommand(clientConn, "LPOP", "queue"); errWrite != nil { + t.Fatalf("failed to write LPOP command: %v", errWrite) + } + if msg, err := readTestRESPError(reader); err != nil { + t.Fatalf("failed to read LPOP NOAUTH error: %v", err) + } else if msg != "NOAUTH Authentication required." { + t.Fatalf("unexpected LPOP NOAUTH error at attempt %d: %q", i+1, msg) + } + } + + if errWrite := writeTestRESPCommand(clientConn, "LPOP", "queue"); errWrite != nil { + t.Fatalf("failed to write LPOP command after failures: %v", errWrite) + } + msg, err := readTestRESPError(reader) + if err != nil { + t.Fatalf("failed to read LPOP banned error: %v", err) + } + if !strings.HasPrefix(msg, "ERR IP banned due to too many failed attempts. Try again in") { + t.Fatalf("unexpected LPOP banned error: %q", msg) + } +} + +func TestRedisProtocol_AUTH_IPBan_BlocksCorrectPasswordDuringBan(t *testing.T) { + const managementPassword = "test-management-password" + + t.Setenv("MANAGEMENT_PASSWORD", managementPassword) + redisqueue.SetEnabled(false) + t.Cleanup(func() { redisqueue.SetEnabled(false) }) + + server := newTestServer(t) + if !server.managementRoutesEnabled.Load() { + t.Fatalf("expected managementRoutesEnabled to be true") + } + + clientConn, serverConn := net.Pipe() + t.Cleanup(func() { _ = clientConn.Close() }) + t.Cleanup(func() { _ = serverConn.Close() }) + + fakeRemote := &net.TCPAddr{ + IP: net.ParseIP("1.2.3.4"), + Port: 1234, + } + wrappedConn := &remoteAddrConn{Conn: serverConn, remoteAddr: fakeRemote} + + go server.handleRedisConnection(wrappedConn, bufio.NewReader(wrappedConn)) + + reader := bufio.NewReader(clientConn) + _ = clientConn.SetDeadline(time.Now().Add(5 * time.Second)) + + for i := 0; i < 5; i++ { + if errWrite := writeTestRESPCommand(clientConn, "AUTH", "wrong-password"); errWrite != nil { + t.Fatalf("failed to write AUTH command: %v", errWrite) + } + if msg, err := readTestRESPError(reader); err != nil { + t.Fatalf("failed to read AUTH error: %v", err) + } else if msg != "ERR invalid management key" { + t.Fatalf("unexpected AUTH error at attempt %d: %q", i+1, msg) + } + } + + for i := 0; i < 2; i++ { + if errWrite := writeTestRESPCommand(clientConn, "AUTH", "wrong-password"); errWrite != nil { + t.Fatalf("failed to write AUTH command after failures: %v", errWrite) + } + msg, err := readTestRESPError(reader) + if err != nil { + t.Fatalf("failed to read AUTH banned error: %v", err) + } + if !strings.HasPrefix(msg, "ERR IP banned due to too many failed attempts. Try again in") { + t.Fatalf("unexpected AUTH banned error at attempt %d: %q", i+6, msg) + } + } + + if errWrite := writeTestRESPCommand(clientConn, "AUTH", managementPassword); errWrite != nil { + t.Fatalf("failed to write AUTH command with correct password: %v", errWrite) + } + msg, err := readTestRESPError(reader) + if err != nil { + t.Fatalf("failed to read AUTH banned error for correct password: %v", err) + } + if !strings.HasPrefix(msg, "ERR IP banned due to too many failed attempts. Try again in") { + t.Fatalf("unexpected AUTH banned error for correct password: %q", msg) + } +} + +func TestRedisProtocol_LOCALHOST_AUTH_IPBan_BlocksCorrectPasswordDuringBan(t *testing.T) { + const managementPassword = "test-management-password" + + t.Setenv("MANAGEMENT_PASSWORD", managementPassword) + redisqueue.SetEnabled(false) + t.Cleanup(func() { redisqueue.SetEnabled(false) }) + + server := newTestServer(t) + if !server.managementRoutesEnabled.Load() { + t.Fatalf("expected managementRoutesEnabled to be true") + } + + addr, stop := startRedisMuxListener(t, server) + t.Cleanup(stop) + + conn, errDial := net.DialTimeout("tcp", addr, time.Second) + if errDial != nil { + t.Fatalf("failed to dial redis listener: %v", errDial) + } + t.Cleanup(func() { _ = conn.Close() }) + + reader := bufio.NewReader(conn) + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + + for i := 0; i < 5; i++ { + if errWrite := writeTestRESPCommand(conn, "AUTH", "wrong-password"); errWrite != nil { + t.Fatalf("failed to write AUTH command: %v", errWrite) + } + if msg, err := readTestRESPError(reader); err != nil { + t.Fatalf("failed to read AUTH error: %v", err) + } else if msg != "ERR invalid management key" { + t.Fatalf("unexpected AUTH error at attempt %d: %q", i+1, msg) + } + } + + if errWrite := writeTestRESPCommand(conn, "AUTH", managementPassword); errWrite != nil { + t.Fatalf("failed to write AUTH command with correct password: %v", errWrite) + } + msg, err := readTestRESPError(reader) + if err != nil { + t.Fatalf("failed to read AUTH banned error for correct password: %v", err) + } + if !strings.HasPrefix(msg, "ERR IP banned due to too many failed attempts. Try again in") { + t.Fatalf("unexpected AUTH banned error for correct password: %q", msg) + } +} From ea670ef8c04ad509f1604e88cff0eedb98fb275f Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 26 Apr 2026 03:09:06 +0800 Subject: [PATCH 047/139] feat(models): add Codex Auto Review model entry to registry JSON Closes: #2995 --- internal/registry/models/models.json | 92 ++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json index a1abb5a381..d276cdc21e 100644 --- a/internal/registry/models/models.json +++ b/internal/registry/models/models.json @@ -1292,6 +1292,29 @@ "xhigh" ] } + }, + { + "id": "codex-auto-review", + "object": "model", + "created": 1776902400, + "owned_by": "openai", + "type": "openai", + "display_name": "Codex Auto Review", + "version": "Codex Auto Review", + "description": "Automatic approval review model for Codex.", + "context_length": 272000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } } ], "codex-team": [ @@ -1410,6 +1433,29 @@ "xhigh" ] } + }, + { + "id": "codex-auto-review", + "object": "model", + "created": 1776902400, + "owned_by": "openai", + "type": "openai", + "display_name": "Codex Auto Review", + "version": "Codex Auto Review", + "description": "Automatic approval review model for Codex.", + "context_length": 272000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } } ], "codex-plus": [ @@ -1551,6 +1597,29 @@ "xhigh" ] } + }, + { + "id": "codex-auto-review", + "object": "model", + "created": 1776902400, + "owned_by": "openai", + "type": "openai", + "display_name": "Codex Auto Review", + "version": "Codex Auto Review", + "description": "Automatic approval review model for Codex.", + "context_length": 272000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } } ], "codex-pro": [ @@ -1692,6 +1761,29 @@ "xhigh" ] } + }, + { + "id": "codex-auto-review", + "object": "model", + "created": 1776902400, + "owned_by": "openai", + "type": "openai", + "display_name": "Codex Auto Review", + "version": "Codex Auto Review", + "description": "Automatic approval review model for Codex.", + "context_length": 272000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } } ], "kimi": [ From 0a7c6b0a4a191c470e3424f17e68c0227203388f Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 26 Apr 2026 03:24:43 +0800 Subject: [PATCH 048/139] feat(api): enhance model assignment logic in image handlers - Updated `buildImagesResponsesRequest` to derive `model` dynamically based on `toolJSON`. - Adjusted streaming execution to handle dynamic model resolution across multiple contexts. Closes: #2965 --- .../handlers/openai/openai_images_handlers.go | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/sdk/api/handlers/openai/openai_images_handlers.go b/sdk/api/handlers/openai/openai_images_handlers.go index 17243314f9..64b41232f4 100644 --- a/sdk/api/handlers/openai/openai_images_handlers.go +++ b/sdk/api/handlers/openai/openai_images_handlers.go @@ -499,7 +499,17 @@ func (h *OpenAIAPIHandler) imagesEditsFromJSON(c *gin.Context) { func buildImagesResponsesRequest(prompt string, images []string, toolJSON []byte) []byte { req := []byte(`{"instructions":"","stream":true,"reasoning":{"effort":"medium","summary":"auto"},"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"model":"","store":false,"tool_choice":{"type":"image_generation"}}`) - req, _ = sjson.SetBytes(req, "model", defaultImagesMainModel) + mainModel := defaultImagesMainModel + if len(toolJSON) > 0 && json.Valid(toolJSON) { + toolModel := strings.TrimSpace(gjson.GetBytes(toolJSON, "model").String()) + if idx := strings.LastIndex(toolModel, "/"); idx > 0 && idx < len(toolModel)-1 { + prefix := strings.TrimSpace(toolModel[:idx]) + if prefix != "" { + mainModel = prefix + "/" + defaultImagesMainModel + } + } + } + req, _ = sjson.SetBytes(req, "model", mainModel) input := []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`) input, _ = sjson.SetBytes(input, "0.content.0.text", prompt) @@ -530,7 +540,11 @@ func (h *OpenAIAPIHandler) collectImagesFromResponses(c *gin.Context, responsesR cliCtx = handlers.WithDisallowFreeAuth(cliCtx) stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx) - dataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, "openai-response", defaultImagesMainModel, responsesReq, "") + mainModel := strings.TrimSpace(gjson.GetBytes(responsesReq, "model").String()) + if mainModel == "" { + mainModel = defaultImagesMainModel + } + dataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, "openai-response", mainModel, responsesReq, "") out, errMsg := collectImagesFromResponsesStream(cliCtx, dataChan, errChan, responseFormat) stopKeepAlive() @@ -718,7 +732,11 @@ func (h *OpenAIAPIHandler) streamImagesFromResponses(c *gin.Context, responsesRe cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background()) cliCtx = handlers.WithDisallowFreeAuth(cliCtx) - dataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, "openai-response", defaultImagesMainModel, responsesReq, "") + mainModel := strings.TrimSpace(gjson.GetBytes(responsesReq, "model").String()) + if mainModel == "" { + mainModel = defaultImagesMainModel + } + dataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, "openai-response", mainModel, responsesReq, "") setSSEHeaders := func() { c.Header("Content-Type", "text/event-stream") From e707cf7d462f0895f3eb3901aec8f337e68a980e Mon Sep 17 00:00:00 2001 From: Enzo Lucchesi Date: Sat, 18 Apr 2026 10:34:02 -0400 Subject: [PATCH 049/139] fix(claude): only reverse-remap OAuth tool names that were forward-renamed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remapOAuthToolNames renames lowercase client-sent tools (e.g. `glob` → `Glob`) to Claude Code equivalents on OAuth requests to avoid tool-name fingerprinting. The reverse pass previously ran against a *global* reverse map and rewrote every tool_use block whose name matched any value in oauthToolRenameMap — regardless of what the client actually sent. For clients that send mixed casing (notably Amp CLI — `Bash`, `Read`, `Grep`, `Task` alongside `glob`, `skill`, etc.) this corrupted the response. Any forward rename in the request set the "renamed" flag, which then unconditionally lowercased every `Bash` in the response to `bash`. Amp's tool registry has `Bash`, not `bash`, so it rejected the tool_use with `tool "bash" is not allowed for smart mode` and tool execution failed. Fix: `remapOAuthToolNames` now returns a per-request map keyed on the upstream (TitleCase) name valued with the original client-sent name. The reverse functions take this map and only touch entries in it. Names the client sent in TitleCase pass through untouched in both directions. - Change remapOAuthToolNames signature from `([]byte, bool)` to `([]byte, map[string]string)`; populate at every rename site (tools[], tool_choice.name, message tool_use, tool_reference, nested tool_reference inside tool_result). - Change reverseRemapOAuthToolNames and reverseRemapOAuthToolNamesFromStreamLine to accept and consume the per-request map; remove the global oauthToolRenameReverseMap. - Update all three executor call sites (Execute, ExecuteStream direct passthrough, ExecuteStream translated) + count_tokens. - Add regression tests for the mixed-case scenario in both the non-streaming and SSE code paths. --- internal/runtime/executor/claude_executor.go | 95 ++++++++++++------- .../runtime/executor/claude_executor_test.go | 91 +++++++++++++++--- 2 files changed, 136 insertions(+), 50 deletions(-) diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 235db1f3b2..7f00ac08ba 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -65,14 +65,13 @@ var oauthToolRenameMap = map[string]string{ "notebookedit": "NotebookEdit", } -// oauthToolRenameReverseMap is the inverse of oauthToolRenameMap for response decoding. -var oauthToolRenameReverseMap = func() map[string]string { - m := make(map[string]string, len(oauthToolRenameMap)) - for k, v := range oauthToolRenameMap { - m[v] = k - } - return m -}() +// The reverse map is now computed per-request in remapOAuthToolNames so that +// only names the client actually caused us to rewrite are restored on the +// response. A global reverse map — as used previously — corrupted responses +// for clients that sent mixed casing (e.g. Amp CLI sends `Bash` TitleCase +// alongside `glob` lowercase; the request flagged renames via `glob→Glob`, +// then the global reverse map incorrectly rewrote every `Bash` in the +// response to `bash`, causing Amp to reject the tool_use as unknown). // oauthToolsToRemove lists tool names that must be stripped from OAuth requests // even after remapping. Currently empty — all tools are mapped instead of removed. @@ -191,7 +190,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r bodyForTranslation := body bodyForUpstream := body oauthToken := isClaudeOAuthToken(apiKey) - oauthToolNamesRemapped := false + var oauthToolNamesReverseMap map[string]string if oauthToken && !auth.ToolPrefixDisabled() { bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix) } @@ -199,7 +198,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r // tools without official counterparts. This prevents Anthropic from // fingerprinting the request as third-party via tool naming patterns. if oauthToken { - bodyForUpstream, oauthToolNamesRemapped = remapOAuthToolNames(bodyForUpstream) + bodyForUpstream, oauthToolNamesReverseMap = remapOAuthToolNames(bodyForUpstream) } // Enable cch signing by default for OAuth tokens (not just experimental flag). // Claude Code always computes cch; missing or invalid cch is a detectable fingerprint. @@ -297,8 +296,8 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r data = stripClaudeToolPrefixFromResponse(data, claudeToolPrefix) } // Reverse the OAuth tool name remap so the downstream client sees original names. - if isClaudeOAuthToken(apiKey) && oauthToolNamesRemapped { - data = reverseRemapOAuthToolNames(data) + if isClaudeOAuthToken(apiKey) && len(oauthToolNamesReverseMap) > 0 { + data = reverseRemapOAuthToolNames(data, oauthToolNamesReverseMap) } var param any out := sdktranslator.TranslateNonStream( @@ -373,7 +372,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A bodyForTranslation := body bodyForUpstream := body oauthToken := isClaudeOAuthToken(apiKey) - oauthToolNamesRemapped := false + var oauthToolNamesReverseMap map[string]string if oauthToken && !auth.ToolPrefixDisabled() { bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix) } @@ -381,7 +380,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A // tools without official counterparts. This prevents Anthropic from // fingerprinting the request as third-party via tool naming patterns. if oauthToken { - bodyForUpstream, oauthToolNamesRemapped = remapOAuthToolNames(bodyForUpstream) + bodyForUpstream, oauthToolNamesReverseMap = remapOAuthToolNames(bodyForUpstream) } // Enable cch signing by default for OAuth tokens (not just experimental flag). if oauthToken || experimentalCCHSigningEnabled(e.cfg, auth) { @@ -475,8 +474,8 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() { line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix) } - if isClaudeOAuthToken(apiKey) && oauthToolNamesRemapped { - line = reverseRemapOAuthToolNamesFromStreamLine(line) + if isClaudeOAuthToken(apiKey) && len(oauthToolNamesReverseMap) > 0 { + line = reverseRemapOAuthToolNamesFromStreamLine(line, oauthToolNamesReverseMap) } // Forward the line as-is to preserve SSE format cloned := make([]byte, len(line)+1) @@ -505,8 +504,8 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() { line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix) } - if isClaudeOAuthToken(apiKey) && oauthToolNamesRemapped { - line = reverseRemapOAuthToolNamesFromStreamLine(line) + if isClaudeOAuthToken(apiKey) && len(oauthToolNamesReverseMap) > 0 { + line = reverseRemapOAuthToolNamesFromStreamLine(line, oauthToolNamesReverseMap) } chunks := sdktranslator.TranslateStream( ctx, @@ -1009,8 +1008,25 @@ func isClaudeOAuthToken(apiKey string) bool { // It operates on: tools[].name, tool_choice.name, and all tool_use/tool_reference // references in messages. Removed tools' corresponding tool_result blocks are preserved // (they just become orphaned, which is safe for Claude). -func remapOAuthToolNames(body []byte) ([]byte, bool) { - renamed := false +// +// The returned map is keyed on the upstream (TitleCase) name and maps to the +// client-supplied original name. Callers MUST pass this map to the reverse +// functions so only names the client actually caused us to rewrite are restored +// on the response. A global reverse map (the previous implementation) incorrectly +// rewrote names the client originally sent in TitleCase (e.g. Amp CLI's `Bash`) +// when any OTHER tool in the same request triggered a forward rename (e.g. +// Amp's `glob`→`Glob`), because the global reverse map contained `Bash`→`bash` +// regardless of what the client originally sent. +func remapOAuthToolNames(body []byte) ([]byte, map[string]string) { + reverseMap := make(map[string]string) + recordRename := func(original, renamed string) { + // Preserve the first-seen original name if the same upstream name is + // produced from multiple call sites; they all map back identically. + if _, exists := reverseMap[renamed]; !exists { + reverseMap[renamed] = original + } + } + // 1. Rewrite tools array in a single pass (if present). // IMPORTANT: do not mutate names first and then rebuild from an older gjson // snapshot. gjson results are snapshots of the original bytes; rebuilding from a @@ -1043,7 +1059,7 @@ func remapOAuthToolNames(body []byte) ([]byte, bool) { updatedTool, err := sjson.Set(toolJSON, "name", newName) if err == nil { toolJSON = updatedTool - renamed = true + recordRename(name, newName) } } @@ -1068,7 +1084,7 @@ func remapOAuthToolNames(body []byte) ([]byte, bool) { body, _ = sjson.DeleteBytes(body, "tool_choice") } else if newName, ok := oauthToolRenameMap[tcName]; ok && newName != tcName { body, _ = sjson.SetBytes(body, "tool_choice.name", newName) - renamed = true + recordRename(tcName, newName) } } @@ -1088,14 +1104,14 @@ func remapOAuthToolNames(body []byte) ([]byte, bool) { if newName, ok := oauthToolRenameMap[name]; ok && newName != name { path := fmt.Sprintf("messages.%d.content.%d.name", msgIndex.Int(), contentIndex.Int()) body, _ = sjson.SetBytes(body, path, newName) - renamed = true + recordRename(name, newName) } case "tool_reference": toolName := part.Get("tool_name").String() if newName, ok := oauthToolRenameMap[toolName]; ok && newName != toolName { path := fmt.Sprintf("messages.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int()) body, _ = sjson.SetBytes(body, path, newName) - renamed = true + recordRename(toolName, newName) } case "tool_result": // Handle nested tool_reference blocks inside tool_result.content[] @@ -1109,7 +1125,7 @@ func remapOAuthToolNames(body []byte) ([]byte, bool) { if newName, ok := oauthToolRenameMap[nestedToolName]; ok && newName != nestedToolName { nestedPath := fmt.Sprintf("messages.%d.content.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int(), nestedIndex.Int()) body, _ = sjson.SetBytes(body, nestedPath, newName) - renamed = true + recordRename(nestedToolName, newName) } } return true @@ -1122,13 +1138,16 @@ func remapOAuthToolNames(body []byte) ([]byte, bool) { }) } - return body, renamed + return body, reverseMap } -// reverseRemapOAuthToolNames reverses the tool name mapping for non-stream responses. -// It maps Claude Code TitleCase names back to the original lowercase names so the -// downstream client receives tool names it recognizes. -func reverseRemapOAuthToolNames(body []byte) []byte { +// reverseRemapOAuthToolNames reverses the tool name mapping for non-stream responses +// using the per-request map produced by remapOAuthToolNames. Names the client sent +// that were NOT forward-renamed are passed through unchanged. +func reverseRemapOAuthToolNames(body []byte, reverseMap map[string]string) []byte { + if len(reverseMap) == 0 { + return body + } content := gjson.GetBytes(body, "content") if !content.Exists() || !content.IsArray() { return body @@ -1138,13 +1157,13 @@ func reverseRemapOAuthToolNames(body []byte) []byte { switch partType { case "tool_use": name := part.Get("name").String() - if origName, ok := oauthToolRenameReverseMap[name]; ok { + if origName, ok := reverseMap[name]; ok { path := fmt.Sprintf("content.%d.name", index.Int()) body, _ = sjson.SetBytes(body, path, origName) } case "tool_reference": toolName := part.Get("tool_name").String() - if origName, ok := oauthToolRenameReverseMap[toolName]; ok { + if origName, ok := reverseMap[toolName]; ok { path := fmt.Sprintf("content.%d.tool_name", index.Int()) body, _ = sjson.SetBytes(body, path, origName) } @@ -1154,8 +1173,12 @@ func reverseRemapOAuthToolNames(body []byte) []byte { return body } -// reverseRemapOAuthToolNamesFromStreamLine reverses the tool name mapping for SSE stream lines. -func reverseRemapOAuthToolNamesFromStreamLine(line []byte) []byte { +// reverseRemapOAuthToolNamesFromStreamLine reverses the tool name mapping for SSE +// stream lines, using the per-request reverseMap produced by remapOAuthToolNames. +func reverseRemapOAuthToolNamesFromStreamLine(line []byte, reverseMap map[string]string) []byte { + if len(reverseMap) == 0 { + return line + } payload := helps.JSONPayload(line) if len(payload) == 0 || !gjson.ValidBytes(payload) { return line @@ -1173,7 +1196,7 @@ func reverseRemapOAuthToolNamesFromStreamLine(line []byte) []byte { switch blockType { case "tool_use": name := contentBlock.Get("name").String() - if origName, ok := oauthToolRenameReverseMap[name]; ok { + if origName, ok := reverseMap[name]; ok { updated, err = sjson.SetBytes(payload, "content_block.name", origName) if err != nil { return line @@ -1183,7 +1206,7 @@ func reverseRemapOAuthToolNamesFromStreamLine(line []byte) []byte { } case "tool_reference": toolName := contentBlock.Get("tool_name").String() - if origName, ok := oauthToolRenameReverseMap[toolName]; ok { + if origName, ok := reverseMap[toolName]; ok { updated, err = sjson.SetBytes(payload, "content_block.tool_name", origName) if err != nil { return line diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index c1ce8fc088..0176340b5c 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -1989,19 +1989,16 @@ func TestNormalizeClaudeTemperatureForThinking_AfterForcedToolChoiceKeepsOrigina func TestRemapOAuthToolNames_TitleCase_NoReverseNeeded(t *testing.T) { body := []byte(`{"tools":[{"name":"Bash","description":"Run shell commands","input_schema":{"type":"object","properties":{"cmd":{"type":"string"}}}}],"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`) - out, renamed := remapOAuthToolNames(body) - if renamed { - t.Fatalf("renamed = true, want false") + out, reverseMap := remapOAuthToolNames(body) + if len(reverseMap) != 0 { + t.Fatalf("reverseMap = %v, want empty", reverseMap) } if got := gjson.GetBytes(out, "tools.0.name").String(); got != "Bash" { t.Fatalf("tools.0.name = %q, want %q", got, "Bash") } resp := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"cmd":"ls"}}]}`) - reversed := resp - if renamed { - reversed = reverseRemapOAuthToolNames(resp) - } + reversed := reverseRemapOAuthToolNames(resp, reverseMap) if got := gjson.GetBytes(reversed, "content.0.name").String(); got != "Bash" { t.Fatalf("content.0.name = %q, want %q", got, "Bash") } @@ -2010,20 +2007,86 @@ func TestRemapOAuthToolNames_TitleCase_NoReverseNeeded(t *testing.T) { func TestRemapOAuthToolNames_Lowercase_ReverseApplied(t *testing.T) { body := []byte(`{"tools":[{"name":"bash","description":"Run shell commands","input_schema":{"type":"object","properties":{"cmd":{"type":"string"}}}}],"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`) - out, renamed := remapOAuthToolNames(body) - if !renamed { - t.Fatalf("renamed = false, want true") + out, reverseMap := remapOAuthToolNames(body) + if reverseMap["Bash"] != "bash" { + t.Fatalf("reverseMap = %v, want entry Bash->bash", reverseMap) } if got := gjson.GetBytes(out, "tools.0.name").String(); got != "Bash" { t.Fatalf("tools.0.name = %q, want %q", got, "Bash") } resp := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"cmd":"ls"}}]}`) - reversed := resp - if renamed { - reversed = reverseRemapOAuthToolNames(resp) - } + reversed := reverseRemapOAuthToolNames(resp, reverseMap) if got := gjson.GetBytes(reversed, "content.0.name").String(); got != "bash" { t.Fatalf("content.0.name = %q, want %q", got, "bash") } } + +// TestRemapOAuthToolNames_MixedCase_OnlyRenamedToolsReversed is the regression +// test for a case where a single request contains both a TitleCase tool (which +// must pass through unchanged) and a lowercase tool that we forward-rename. +// Before the fix, triggering ANY forward rename caused the reverse pass to +// lowercase every TitleCase tool in the response using a global reverse map, +// corrupting tool names the client originally sent in TitleCase (notably Amp +// CLI's `Bash`, which its registry lookup cannot find as `bash`). +func TestRemapOAuthToolNames_MixedCase_OnlyRenamedToolsReversed(t *testing.T) { + body := []byte(`{"tools":[` + + `{"name":"Bash","input_schema":{"type":"object","properties":{"cmd":{"type":"string"}}}},` + + `{"name":"glob","input_schema":{"type":"object","properties":{"filePattern":{"type":"string"}}}}` + + `]}`) + + out, reverseMap := remapOAuthToolNames(body) + + // Forward: TitleCase `Bash` is not a forward-map key, must pass through. + if got := gjson.GetBytes(out, "tools.0.name").String(); got != "Bash" { + t.Fatalf("tools.0.name = %q, want %q (TitleCase tool must not be renamed)", got, "Bash") + } + // Forward: `glob` is a forward-map key, upstream sees `Glob`. + if got := gjson.GetBytes(out, "tools.1.name").String(); got != "Glob" { + t.Fatalf("tools.1.name = %q, want %q", got, "Glob") + } + + // Reverse map records ONLY the rename that happened. + if len(reverseMap) != 1 || reverseMap["Glob"] != "glob" { + t.Fatalf("reverseMap = %v, want {Glob:glob}", reverseMap) + } + + // Upstream responds with a `Bash` tool_use. Since we never renamed `Bash`, + // reverseRemap MUST leave it alone. + bashResp := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"cmd":"ls"}}]}`) + reversed := reverseRemapOAuthToolNames(bashResp, reverseMap) + if got := gjson.GetBytes(reversed, "content.0.name").String(); got != "Bash" { + t.Fatalf("content.0.name = %q, want %q (Bash must be preserved; was never forward-renamed)", got, "Bash") + } + + // Upstream responds with a `Glob` tool_use. Since we renamed `glob`→`Glob`, + // reverseRemap MUST restore the original `glob`. + globResp := []byte(`{"content":[{"type":"tool_use","id":"toolu_02","name":"Glob","input":{"filePattern":"**/*.go"}}]}`) + reversed = reverseRemapOAuthToolNames(globResp, reverseMap) + if got := gjson.GetBytes(reversed, "content.0.name").String(); got != "glob" { + t.Fatalf("content.0.name = %q, want %q (Glob must be restored to client's original `glob`)", got, "glob") + } +} + +// TestReverseRemapOAuthToolNamesFromStreamLine_HonorsPerRequestMap guards the +// SSE streaming code path against the same mixed-case bug. +func TestReverseRemapOAuthToolNamesFromStreamLine_HonorsPerRequestMap(t *testing.T) { + reverseMap := map[string]string{"Glob": "glob"} + + // Bash block was never renamed, must pass through as-is. + bashLine := []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01","name":"Bash","input":{}}}`) + out := reverseRemapOAuthToolNamesFromStreamLine(bashLine, reverseMap) + if !bytes.Contains(out, []byte(`"name":"Bash"`)) { + t.Fatalf("Bash should be preserved, got: %s", string(out)) + } + if bytes.Contains(out, []byte(`"name":"bash"`)) { + t.Fatalf("Bash must not be lowercased, got: %s", string(out)) + } + + // Glob block IS in the reverseMap, must be restored to `glob`. + globLine := []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_02","name":"Glob","input":{}}}`) + out = reverseRemapOAuthToolNamesFromStreamLine(globLine, reverseMap) + if !bytes.Contains(out, []byte(`"name":"glob"`)) { + t.Fatalf("Glob should be restored to glob, got: %s", string(out)) + } +} From 03ea4e569fb1df9237d7032469a744737cd30d72 Mon Sep 17 00:00:00 2001 From: edlsh Date: Sat, 18 Apr 2026 12:49:02 -0400 Subject: [PATCH 050/139] perf(claude): pre-allocate reverseMap capacity Address Gemini code review suggestion: the reverseMap can contain at most len(oauthToolRenameMap) entries, so pre-allocating avoids reallocations as entries are added. --- internal/runtime/executor/claude_executor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 7f00ac08ba..78fa3cd6ff 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -1018,7 +1018,7 @@ func isClaudeOAuthToken(apiKey string) bool { // Amp's `glob`→`Glob`), because the global reverse map contained `Bash`→`bash` // regardless of what the client originally sent. func remapOAuthToolNames(body []byte) ([]byte, map[string]string) { - reverseMap := make(map[string]string) + reverseMap := make(map[string]string, len(oauthToolRenameMap)) recordRename := func(original, renamed string) { // Preserve the first-seen original name if the same upstream name is // produced from multiple call sites; they all map back identically. From fc1ddf365f489ca465e5fd85334d01303e635f11 Mon Sep 17 00:00:00 2001 From: Enzo Lucchesi Date: Sun, 19 Apr 2026 14:36:25 +0000 Subject: [PATCH 051/139] fix(claude): centralize oauth tool-name transform flow --- internal/runtime/executor/claude_executor.go | 74 +++++++++---------- .../runtime/executor/claude_executor_test.go | 64 ++++++++++++++++ 2 files changed, 100 insertions(+), 38 deletions(-) diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 78fa3cd6ff..2dbff1d3e7 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -191,14 +191,8 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r bodyForUpstream := body oauthToken := isClaudeOAuthToken(apiKey) var oauthToolNamesReverseMap map[string]string - if oauthToken && !auth.ToolPrefixDisabled() { - bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix) - } - // Remap third-party tool names to Claude Code equivalents and remove - // tools without official counterparts. This prevents Anthropic from - // fingerprinting the request as third-party via tool naming patterns. if oauthToken { - bodyForUpstream, oauthToolNamesReverseMap = remapOAuthToolNames(bodyForUpstream) + bodyForUpstream, oauthToolNamesReverseMap = prepareClaudeOAuthToolNamesForUpstream(bodyForUpstream, claudeToolPrefix, auth.ToolPrefixDisabled()) } // Enable cch signing by default for OAuth tokens (not just experimental flag). // Claude Code always computes cch; missing or invalid cch is a detectable fingerprint. @@ -292,13 +286,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } else { reporter.Publish(ctx, helps.ParseClaudeUsage(data)) } - if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() { - data = stripClaudeToolPrefixFromResponse(data, claudeToolPrefix) - } - // Reverse the OAuth tool name remap so the downstream client sees original names. - if isClaudeOAuthToken(apiKey) && len(oauthToolNamesReverseMap) > 0 { - data = reverseRemapOAuthToolNames(data, oauthToolNamesReverseMap) - } + data = restoreClaudeOAuthToolNamesFromResponse(data, claudeToolPrefix, auth.ToolPrefixDisabled(), oauthToolNamesReverseMap) var param any out := sdktranslator.TranslateNonStream( ctx, @@ -373,14 +361,8 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A bodyForUpstream := body oauthToken := isClaudeOAuthToken(apiKey) var oauthToolNamesReverseMap map[string]string - if oauthToken && !auth.ToolPrefixDisabled() { - bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix) - } - // Remap third-party tool names to Claude Code equivalents and remove - // tools without official counterparts. This prevents Anthropic from - // fingerprinting the request as third-party via tool naming patterns. if oauthToken { - bodyForUpstream, oauthToolNamesReverseMap = remapOAuthToolNames(bodyForUpstream) + bodyForUpstream, oauthToolNamesReverseMap = prepareClaudeOAuthToolNamesForUpstream(bodyForUpstream, claudeToolPrefix, auth.ToolPrefixDisabled()) } // Enable cch signing by default for OAuth tokens (not just experimental flag). if oauthToken || experimentalCCHSigningEnabled(e.cfg, auth) { @@ -471,12 +453,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if detail, ok := helps.ParseClaudeStreamUsage(line); ok { reporter.Publish(ctx, detail) } - if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() { - line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix) - } - if isClaudeOAuthToken(apiKey) && len(oauthToolNamesReverseMap) > 0 { - line = reverseRemapOAuthToolNamesFromStreamLine(line, oauthToolNamesReverseMap) - } + line = restoreClaudeOAuthToolNamesFromStreamLine(line, claudeToolPrefix, auth.ToolPrefixDisabled(), oauthToolNamesReverseMap) // Forward the line as-is to preserve SSE format cloned := make([]byte, len(line)+1) copy(cloned, line) @@ -501,12 +478,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if detail, ok := helps.ParseClaudeStreamUsage(line); ok { reporter.Publish(ctx, detail) } - if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() { - line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix) - } - if isClaudeOAuthToken(apiKey) && len(oauthToolNamesReverseMap) > 0 { - line = reverseRemapOAuthToolNamesFromStreamLine(line, oauthToolNamesReverseMap) - } + line = restoreClaudeOAuthToolNamesFromStreamLine(line, claudeToolPrefix, auth.ToolPrefixDisabled(), oauthToolNamesReverseMap) chunks := sdktranslator.TranslateStream( ctx, to, @@ -556,12 +528,8 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut // Extract betas from body and convert to header (for count_tokens too) var extraBetas []string extraBetas, body = extractAndRemoveBetas(body) - if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() { - body = applyClaudeToolPrefix(body, claudeToolPrefix) - } - // Remap tool names for OAuth token requests to avoid third-party fingerprinting. if isClaudeOAuthToken(apiKey) { - body, _ = remapOAuthToolNames(body) + body, _ = prepareClaudeOAuthToolNamesForUpstream(body, claudeToolPrefix, auth.ToolPrefixDisabled()) } url := fmt.Sprintf("%s/v1/messages/count_tokens?beta=true", baseURL) @@ -1001,6 +969,36 @@ func isClaudeOAuthToken(apiKey string) bool { return strings.Contains(apiKey, "sk-ant-oat") } +// prepareClaudeOAuthToolNamesForUpstream applies the Claude OAuth tool-name +// transforms in the same order across request paths. Remap runs before prefixing +// so any future non-empty prefix still composes correctly with the per-request +// reverse map. +func prepareClaudeOAuthToolNamesForUpstream(body []byte, prefix string, prefixDisabled bool) ([]byte, map[string]string) { + body, reverseMap := remapOAuthToolNames(body) + if !prefixDisabled { + body = applyClaudeToolPrefix(body, prefix) + } + return body, reverseMap +} + +// restoreClaudeOAuthToolNamesFromResponse undoes the Claude OAuth tool-name +// transforms for non-stream responses in reverse order. +func restoreClaudeOAuthToolNamesFromResponse(body []byte, prefix string, prefixDisabled bool, reverseMap map[string]string) []byte { + if !prefixDisabled { + body = stripClaudeToolPrefixFromResponse(body, prefix) + } + return reverseRemapOAuthToolNames(body, reverseMap) +} + +// restoreClaudeOAuthToolNamesFromStreamLine undoes the Claude OAuth tool-name +// transforms for SSE lines in reverse order. +func restoreClaudeOAuthToolNamesFromStreamLine(line []byte, prefix string, prefixDisabled bool, reverseMap map[string]string) []byte { + if !prefixDisabled { + line = stripClaudeToolPrefixFromStreamLine(line, prefix) + } + return reverseRemapOAuthToolNamesFromStreamLine(line, reverseMap) +} + // remapOAuthToolNames renames third-party tool names to Claude Code equivalents // and removes tools without an official counterpart. This prevents Anthropic from // fingerprinting the request as a third-party client via tool naming patterns. diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 0176340b5c..9011be04b2 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -2090,3 +2090,67 @@ func TestReverseRemapOAuthToolNamesFromStreamLine_HonorsPerRequestMap(t *testing t.Fatalf("Glob should be restored to glob, got: %s", string(out)) } } + +func TestPrepareClaudeOAuthToolNamesForUpstream_MixedCaseWithPrefix(t *testing.T) { + body := []byte(`{"tools":[` + + `{"name":"Bash","input_schema":{"type":"object","properties":{"cmd":{"type":"string"}}}},` + + `{"name":"glob","input_schema":{"type":"object","properties":{"filePattern":{"type":"string"}}}}` + + `],"messages":[{"role":"assistant","content":[` + + `{"type":"tool_use","id":"toolu_01","name":"Bash","input":{}},` + + `{"type":"tool_use","id":"toolu_02","name":"glob","input":{}}` + + `]}]}`) + + out, reverseMap := prepareClaudeOAuthToolNamesForUpstream(body, "proxy_", false) + + if got := gjson.GetBytes(out, "tools.0.name").String(); got != "proxy_Bash" { + t.Fatalf("tools.0.name = %q, want %q", got, "proxy_Bash") + } + if got := gjson.GetBytes(out, "tools.1.name").String(); got != "proxy_Glob" { + t.Fatalf("tools.1.name = %q, want %q", got, "proxy_Glob") + } + if got := gjson.GetBytes(out, "messages.0.content.0.name").String(); got != "proxy_Bash" { + t.Fatalf("messages.0.content.0.name = %q, want %q", got, "proxy_Bash") + } + if got := gjson.GetBytes(out, "messages.0.content.1.name").String(); got != "proxy_Glob" { + t.Fatalf("messages.0.content.1.name = %q, want %q", got, "proxy_Glob") + } + if len(reverseMap) != 1 || reverseMap["Glob"] != "glob" { + t.Fatalf("reverseMap = %v, want {Glob:glob}", reverseMap) + } +} + +func TestRestoreClaudeOAuthToolNamesFromResponse_MixedCaseWithPrefix(t *testing.T) { + reverseMap := map[string]string{"Glob": "glob"} + resp := []byte(`{"content":[` + + `{"type":"tool_use","id":"toolu_01","name":"proxy_Bash","input":{}},` + + `{"type":"tool_use","id":"toolu_02","name":"proxy_Glob","input":{}}` + + `]}`) + + out := restoreClaudeOAuthToolNamesFromResponse(resp, "proxy_", false, reverseMap) + + if got := gjson.GetBytes(out, "content.0.name").String(); got != "Bash" { + t.Fatalf("content.0.name = %q, want %q", got, "Bash") + } + if got := gjson.GetBytes(out, "content.1.name").String(); got != "glob" { + t.Fatalf("content.1.name = %q, want %q", got, "glob") + } +} + +func TestRestoreClaudeOAuthToolNamesFromStreamLine_MixedCaseWithPrefix(t *testing.T) { + reverseMap := map[string]string{"Glob": "glob"} + + bashLine := []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01","name":"proxy_Bash","input":{}}}`) + out := restoreClaudeOAuthToolNamesFromStreamLine(bashLine, "proxy_", false, reverseMap) + if !bytes.Contains(out, []byte(`"name":"Bash"`)) { + t.Fatalf("Bash should be preserved, got: %s", string(out)) + } + if bytes.Contains(out, []byte(`"name":"bash"`)) { + t.Fatalf("Bash must not be lowercased, got: %s", string(out)) + } + + globLine := []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_02","name":"proxy_Glob","input":{}}}`) + out = restoreClaudeOAuthToolNamesFromStreamLine(globLine, "proxy_", false, reverseMap) + if !bytes.Contains(out, []byte(`"name":"glob"`)) { + t.Fatalf("Glob should be restored to glob, got: %s", string(out)) + } +} From 95318ad46dce78e19a74e06872b4901b83985bc9 Mon Sep 17 00:00:00 2001 From: edlsh Date: Mon, 13 Apr 2026 09:39:01 -0400 Subject: [PATCH 052/139] fix(amp): preserve lowercase glob tool name --- internal/api/modules/amp/response_rewriter.go | 50 ++++++++++++++++++ .../api/modules/amp/response_rewriter_test.go | 51 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/internal/api/modules/amp/response_rewriter.go b/internal/api/modules/amp/response_rewriter.go index 707fe576b4..895c494e74 100644 --- a/internal/api/modules/amp/response_rewriter.go +++ b/internal/api/modules/amp/response_rewriter.go @@ -123,6 +123,52 @@ func (rw *ResponseRewriter) Flush() { var modelFieldPaths = []string{"message.model", "model", "modelVersion", "response.model", "response.modelVersion"} +// ampCanonicalToolNames maps tool names to the exact casing expected by the +// Amp mode tool whitelist (case-sensitive match). +var ampCanonicalToolNames = map[string]string{ + "bash": "Bash", + "read": "Read", + "grep": "Grep", + "glob": "glob", + "task": "Task", + "check": "Check", +} + +// normalizeAmpToolNames fixes tool_use block names to match Amp's canonical casing. +// Some upstream models return lowercase tool names (e.g. "bash" instead of "Bash") +// which causes Amp's case-sensitive mode whitelist to reject them. +func normalizeAmpToolNames(data []byte) []byte { + // Non-streaming: content[].name in tool_use blocks + for index, block := range gjson.GetBytes(data, "content").Array() { + if block.Get("type").String() != "tool_use" { + continue + } + name := block.Get("name").String() + if canonical, ok := ampCanonicalToolNames[strings.ToLower(name)]; ok && name != canonical { + path := fmt.Sprintf("content.%d.name", index) + var err error + data, err = sjson.SetBytes(data, path, canonical) + if err != nil { + log.Warnf("Amp ResponseRewriter: failed to normalize tool name %q to %q: %v", name, canonical, err) + } + } + } + + // Streaming: content_block.name in content_block_start events + if gjson.GetBytes(data, "content_block.type").String() == "tool_use" { + name := gjson.GetBytes(data, "content_block.name").String() + if canonical, ok := ampCanonicalToolNames[strings.ToLower(name)]; ok && name != canonical { + var err error + data, err = sjson.SetBytes(data, "content_block.name", canonical) + if err != nil { + log.Warnf("Amp ResponseRewriter: failed to normalize streaming tool name %q to %q: %v", name, canonical, err) + } + } + } + + return data +} + // ensureAmpSignature injects empty signature fields into tool_use/thinking blocks // in API responses so that the Amp TUI does not crash on P.signature.length. func ensureAmpSignature(data []byte) []byte { @@ -179,6 +225,7 @@ func (rw *ResponseRewriter) suppressAmpThinking(data []byte) []byte { func (rw *ResponseRewriter) rewriteModelInResponse(data []byte) []byte { data = ensureAmpSignature(data) + data = normalizeAmpToolNames(data) data = rw.suppressAmpThinking(data) if len(data) == 0 { return data @@ -278,6 +325,9 @@ func (rw *ResponseRewriter) rewriteStreamEvent(data []byte) []byte { // Inject empty signature where needed data = ensureAmpSignature(data) + // Normalize tool names to canonical casing + data = normalizeAmpToolNames(data) + // Rewrite model name if rw.originalModel != "" { for _, path := range modelFieldPaths { diff --git a/internal/api/modules/amp/response_rewriter_test.go b/internal/api/modules/amp/response_rewriter_test.go index ac95dfc64f..a3a350cb23 100644 --- a/internal/api/modules/amp/response_rewriter_test.go +++ b/internal/api/modules/amp/response_rewriter_test.go @@ -175,6 +175,57 @@ func TestSanitizeAmpRequestBody_MixedInvalidThinkingAndToolUseSignature(t *testi } } +func TestNormalizeAmpToolNames_NonStreaming(t *testing.T) { + input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"bash","input":{"cmd":"ls"}},{"type":"tool_use","id":"toolu_02","name":"read","input":{"path":"/tmp"}},{"type":"text","text":"hello"}]}`) + result := normalizeAmpToolNames(input) + + if !contains(result, []byte(`"name":"Bash"`)) { + t.Errorf("expected bash->Bash, got %s", string(result)) + } + if !contains(result, []byte(`"name":"Read"`)) { + t.Errorf("expected read->Read, got %s", string(result)) + } + if contains(result, []byte(`"name":"bash"`)) { + t.Errorf("expected lowercase bash to be replaced, got %s", string(result)) + } +} + +func TestNormalizeAmpToolNames_Streaming(t *testing.T) { + input := []byte(`{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","name":"grep","id":"toolu_01","input":{}}}`) + result := normalizeAmpToolNames(input) + + if !contains(result, []byte(`"name":"Grep"`)) { + t.Errorf("expected grep->Grep in streaming, got %s", string(result)) + } +} + +func TestNormalizeAmpToolNames_AlreadyCorrect(t *testing.T) { + input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"cmd":"ls"}}]}`) + result := normalizeAmpToolNames(input) + + if string(result) != string(input) { + t.Errorf("expected no modification for correctly-cased tool, got %s", string(result)) + } +} + +func TestNormalizeAmpToolNames_GlobPreserved(t *testing.T) { + input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"glob","input":{"pattern":"*.go"}}]}`) + result := normalizeAmpToolNames(input) + + if string(result) != string(input) { + t.Errorf("expected glob to remain lowercase, got %s", string(result)) + } +} + +func TestNormalizeAmpToolNames_UnknownToolUntouched(t *testing.T) { + input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"edit_file","input":{"path":"/tmp/x"}}]}`) + result := normalizeAmpToolNames(input) + + if string(result) != string(input) { + t.Errorf("expected no modification for unknown tool, got %s", string(result)) + } +} + func contains(data, substr []byte) bool { for i := 0; i <= len(data)-len(substr); i++ { if string(data[i:i+len(substr)]) == string(substr) { From fd45dece7f027ef198f00cad8c6455a333a36ca4 Mon Sep 17 00:00:00 2001 From: edlsh Date: Fri, 24 Apr 2026 15:15:01 -0400 Subject: [PATCH 053/139] fix(openai): repair empty responses stream output --- .../openai/openai_responses_handlers.go | 122 +++++++++++++++++- .../openai_responses_handlers_stream_test.go | 34 ++++- 2 files changed, 151 insertions(+), 5 deletions(-) diff --git a/sdk/api/handlers/openai/openai_responses_handlers.go b/sdk/api/handlers/openai/openai_responses_handlers.go index 8969ce2f6d..67c648dcf3 100644 --- a/sdk/api/handlers/openai/openai_responses_handlers.go +++ b/sdk/api/handlers/openai/openai_responses_handlers.go @@ -13,6 +13,7 @@ import ( "fmt" "io" "net/http" + "sort" "github.com/gin-gonic/gin" . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" @@ -45,7 +46,9 @@ func writeResponsesSSEChunk(w io.Writer, chunk []byte) { } type responsesSSEFramer struct { - pending []byte + pending []byte + outputItems map[int][]byte + outputOrder []int } func (f *responsesSSEFramer) WriteChunk(w io.Writer, chunk []byte) { @@ -61,7 +64,7 @@ func (f *responsesSSEFramer) WriteChunk(w io.Writer, chunk []byte) { if frameLen == 0 { break } - writeResponsesSSEChunk(w, f.pending[:frameLen]) + f.writeFrame(w, f.pending[:frameLen]) copy(f.pending, f.pending[frameLen:]) f.pending = f.pending[:len(f.pending)-frameLen] } @@ -72,7 +75,7 @@ func (f *responsesSSEFramer) WriteChunk(w io.Writer, chunk []byte) { if len(f.pending) == 0 || !responsesSSECanEmitWithoutDelimiter(f.pending) { return } - writeResponsesSSEChunk(w, f.pending) + f.writeFrame(w, f.pending) f.pending = f.pending[:0] } @@ -88,10 +91,121 @@ func (f *responsesSSEFramer) Flush(w io.Writer) { f.pending = f.pending[:0] return } - writeResponsesSSEChunk(w, f.pending) + f.writeFrame(w, f.pending) f.pending = f.pending[:0] } +func (f *responsesSSEFramer) writeFrame(w io.Writer, frame []byte) { + writeResponsesSSEChunk(w, f.repairFrame(frame)) +} + +func (f *responsesSSEFramer) repairFrame(frame []byte) []byte { + payload, ok := responsesSSEDataPayload(frame) + if !ok || len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) || !json.Valid(payload) { + return frame + } + + switch gjson.GetBytes(payload, "type").String() { + case "response.output_item.done": + f.recordOutputItem(payload) + case "response.completed": + repaired := f.repairCompletedPayload(payload) + if !bytes.Equal(repaired, payload) { + return responsesSSEFrameWithData(frame, repaired) + } + } + return frame +} + +func responsesSSEDataPayload(frame []byte) ([]byte, bool) { + var payload []byte + found := false + for _, line := range bytes.Split(frame, []byte("\n")) { + line = bytes.TrimRight(line, "\r") + trimmed := bytes.TrimSpace(line) + if !bytes.HasPrefix(trimmed, []byte("data:")) { + continue + } + data := bytes.TrimSpace(trimmed[len("data:"):]) + if found { + payload = append(payload, '\n') + } + payload = append(payload, data...) + found = true + } + return payload, found +} + +func responsesSSEFrameWithData(frame, payload []byte) []byte { + var out bytes.Buffer + for _, line := range bytes.Split(frame, []byte("\n")) { + line = bytes.TrimRight(line, "\r") + trimmed := bytes.TrimSpace(line) + if len(trimmed) == 0 || bytes.HasPrefix(trimmed, []byte("data:")) { + continue + } + out.Write(line) + out.WriteByte('\n') + } + out.WriteString("data: ") + out.Write(payload) + out.WriteString("\n\n") + return out.Bytes() +} + +func (f *responsesSSEFramer) recordOutputItem(payload []byte) { + item := gjson.GetBytes(payload, "item") + if !item.Exists() || !item.IsObject() || item.Get("type").String() == "" { + return + } + + index := len(f.outputOrder) + if outputIndex := gjson.GetBytes(payload, "output_index"); outputIndex.Exists() { + index = int(outputIndex.Int()) + } + if f.outputItems == nil { + f.outputItems = make(map[int][]byte) + } + if _, exists := f.outputItems[index]; !exists { + f.outputOrder = append(f.outputOrder, index) + } + f.outputItems[index] = append([]byte(nil), item.Raw...) +} + +func (f *responsesSSEFramer) repairCompletedPayload(payload []byte) []byte { + if len(f.outputOrder) == 0 { + return payload + } + output := gjson.GetBytes(payload, "response.output") + if output.Exists() && (!output.IsArray() || len(output.Array()) > 0) { + return payload + } + + var outputJSON bytes.Buffer + outputJSON.WriteByte('[') + indexes := append([]int(nil), f.outputOrder...) + sort.Ints(indexes) + written := 0 + for _, index := range indexes { + item, ok := f.outputItems[index] + if !ok { + continue + } + if written > 0 { + outputJSON.WriteByte(',') + } + outputJSON.Write(item) + written++ + } + outputJSON.WriteByte(']') + + repaired, err := sjson.SetRawBytes(payload, "response.output", outputJSON.Bytes()) + if err != nil { + return payload + } + return repaired +} + func responsesSSEFrameLen(chunk []byte) int { if len(chunk) == 0 { return 0 diff --git a/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go b/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go index ef16fe80ac..8b3f79e33d 100644 --- a/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go +++ b/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go @@ -10,6 +10,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + "github.com/tidwall/gjson" ) func newResponsesStreamTestHandler(t *testing.T) (*OpenAIResponsesAPIHandler, *httptest.ResponseRecorder, *gin.Context, http.Flusher) { @@ -53,12 +54,43 @@ func TestForwardResponsesStreamSeparatesDataOnlySSEChunks(t *testing.T) { t.Errorf("unexpected first event.\nGot: %q\nWant: %q", parts[0], expectedPart1) } - expectedPart2 := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"output\":[]}}" + expectedPart2 := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"output\":[{\"type\":\"function_call\",\"arguments\":\"{}\"}]}}" if parts[1] != expectedPart2 { t.Errorf("unexpected second event.\nGot: %q\nWant: %q", parts[1], expectedPart2) } } +func TestForwardResponsesStreamRepairsEmptyCompletedOutputFromDoneItems(t *testing.T) { + h, recorder, c, flusher := newResponsesStreamTestHandler(t) + + data := make(chan []byte, 3) + errs := make(chan *interfaces.ErrorMessage) + data <- []byte(`data: {"type":"response.output_item.done","output_index":0,"item":{"type":"reasoning","id":"rs-1","summary":[]}}`) + data <- []byte(`data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","id":"fc-1","call_id":"call-1","name":"shell","arguments":"{\"cmd\":\"pwd\"}","status":"completed"}}`) + data <- []byte(`data: {"type":"response.completed","response":{"id":"resp-1","output":[]}}`) + close(data) + close(errs) + + h.forwardResponsesStream(c, flusher, func(error) {}, data, errs, nil) + + parts := strings.Split(strings.TrimSpace(recorder.Body.String()), "\n\n") + if len(parts) != 3 { + t.Fatalf("expected 3 SSE events, got %d. Body: %q", len(parts), recorder.Body.String()) + } + + payload := strings.TrimPrefix(parts[2], "data: ") + output := gjson.Get(payload, "response.output") + if !output.IsArray() || len(output.Array()) != 2 { + t.Fatalf("expected repaired completed output with 2 items, got %s", output.Raw) + } + if got := gjson.Get(payload, "response.output.1.name").String(); got != "shell" { + t.Fatalf("expected function_call name to be preserved, got %q in %s", got, payload) + } + if got := gjson.Get(payload, "response.output.1.arguments").String(); got != `{"cmd":"pwd"}` { + t.Fatalf("expected function_call arguments to be preserved, got %q in %s", got, payload) + } +} + func TestForwardResponsesStreamReassemblesSplitSSEEventChunks(t *testing.T) { h, recorder, c, flusher := newResponsesStreamTestHandler(t) From d36e70e9dcfd5e4a79f2165a582e76e385423895 Mon Sep 17 00:00:00 2001 From: edlsh Date: Sat, 25 Apr 2026 18:06:00 -0400 Subject: [PATCH 054/139] fix(openai): preserve unindexed response output items --- .../openai/openai_responses_handlers.go | 36 ++++++++++++------- .../openai_responses_handlers_stream_test.go | 31 ++++++++++++++++ 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/sdk/api/handlers/openai/openai_responses_handlers.go b/sdk/api/handlers/openai/openai_responses_handlers.go index 67c648dcf3..578977d62b 100644 --- a/sdk/api/handlers/openai/openai_responses_handlers.go +++ b/sdk/api/handlers/openai/openai_responses_handlers.go @@ -46,9 +46,10 @@ func writeResponsesSSEChunk(w io.Writer, chunk []byte) { } type responsesSSEFramer struct { - pending []byte - outputItems map[int][]byte - outputOrder []int + pending []byte + outputItems map[int][]byte + outputOrder []int + unindexedOutputItems [][]byte } func (f *responsesSSEFramer) WriteChunk(w io.Writer, chunk []byte) { @@ -159,21 +160,23 @@ func (f *responsesSSEFramer) recordOutputItem(payload []byte) { return } - index := len(f.outputOrder) if outputIndex := gjson.GetBytes(payload, "output_index"); outputIndex.Exists() { - index = int(outputIndex.Int()) - } - if f.outputItems == nil { - f.outputItems = make(map[int][]byte) - } - if _, exists := f.outputItems[index]; !exists { - f.outputOrder = append(f.outputOrder, index) + index := int(outputIndex.Int()) + if f.outputItems == nil { + f.outputItems = make(map[int][]byte) + } + if _, exists := f.outputItems[index]; !exists { + f.outputOrder = append(f.outputOrder, index) + } + f.outputItems[index] = append([]byte(nil), item.Raw...) + return } - f.outputItems[index] = append([]byte(nil), item.Raw...) + + f.unindexedOutputItems = append(f.unindexedOutputItems, append([]byte(nil), item.Raw...)) } func (f *responsesSSEFramer) repairCompletedPayload(payload []byte) []byte { - if len(f.outputOrder) == 0 { + if len(f.outputOrder) == 0 && len(f.unindexedOutputItems) == 0 { return payload } output := gjson.GetBytes(payload, "response.output") @@ -197,6 +200,13 @@ func (f *responsesSSEFramer) repairCompletedPayload(payload []byte) []byte { outputJSON.Write(item) written++ } + for _, item := range f.unindexedOutputItems { + if written > 0 { + outputJSON.WriteByte(',') + } + outputJSON.Write(item) + written++ + } outputJSON.WriteByte(']') repaired, err := sjson.SetRawBytes(payload, "response.output", outputJSON.Bytes()) diff --git a/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go b/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go index 8b3f79e33d..3851278fbf 100644 --- a/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go +++ b/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go @@ -91,6 +91,37 @@ func TestForwardResponsesStreamRepairsEmptyCompletedOutputFromDoneItems(t *testi } } +func TestForwardResponsesStreamRepairsMixedIndexedAndUnindexedDoneItems(t *testing.T) { + h, recorder, c, flusher := newResponsesStreamTestHandler(t) + + data := make(chan []byte, 3) + errs := make(chan *interfaces.ErrorMessage) + data <- []byte(`data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","id":"fc-1","call_id":"call-1","name":"shell","arguments":"{}","status":"completed"}}`) + data <- []byte(`data: {"type":"response.output_item.done","item":{"type":"message","id":"msg-1","role":"assistant","content":[{"type":"output_text","text":"done"}]}}`) + data <- []byte(`data: {"type":"response.completed","response":{"id":"resp-1","output":[]}}`) + close(data) + close(errs) + + h.forwardResponsesStream(c, flusher, func(error) {}, data, errs, nil) + + parts := strings.Split(strings.TrimSpace(recorder.Body.String()), "\n\n") + if len(parts) != 3 { + t.Fatalf("expected 3 SSE events, got %d. Body: %q", len(parts), recorder.Body.String()) + } + + payload := strings.TrimPrefix(parts[2], "data: ") + output := gjson.Get(payload, "response.output") + if !output.IsArray() || len(output.Array()) != 2 { + t.Fatalf("expected repaired completed output with 2 items, got %s", output.Raw) + } + if got := gjson.Get(payload, "response.output.0.name").String(); got != "shell" { + t.Fatalf("expected indexed function_call to be preserved first, got %q in %s", got, payload) + } + if got := gjson.Get(payload, "response.output.1.id").String(); got != "msg-1" { + t.Fatalf("expected unindexed message to be appended, got %q in %s", got, payload) + } +} + func TestForwardResponsesStreamReassemblesSplitSSEEventChunks(t *testing.T) { h, recorder, c, flusher := newResponsesStreamTestHandler(t) From 80eb03709a569a7620b6bba4f1e4f30f8170d3bd Mon Sep 17 00:00:00 2001 From: edlsh Date: Sat, 25 Apr 2026 18:12:27 -0400 Subject: [PATCH 055/139] fix(openai): preserve multiline repaired SSE data --- .../openai/openai_responses_handlers.go | 9 +++-- .../openai_responses_handlers_stream_test.go | 34 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/sdk/api/handlers/openai/openai_responses_handlers.go b/sdk/api/handlers/openai/openai_responses_handlers.go index 578977d62b..8dd1a0a7b1 100644 --- a/sdk/api/handlers/openai/openai_responses_handlers.go +++ b/sdk/api/handlers/openai/openai_responses_handlers.go @@ -148,9 +148,12 @@ func responsesSSEFrameWithData(frame, payload []byte) []byte { out.Write(line) out.WriteByte('\n') } - out.WriteString("data: ") - out.Write(payload) - out.WriteString("\n\n") + for _, line := range bytes.Split(payload, []byte("\n")) { + out.WriteString("data: ") + out.Write(line) + out.WriteByte('\n') + } + out.WriteByte('\n') return out.Bytes() } diff --git a/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go b/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go index 3851278fbf..151da9a79f 100644 --- a/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go +++ b/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go @@ -122,6 +122,40 @@ func TestForwardResponsesStreamRepairsMixedIndexedAndUnindexedDoneItems(t *testi } } +func TestForwardResponsesStreamRepairsMultilineCompletedOutputAsSSEDataLines(t *testing.T) { + h, recorder, c, flusher := newResponsesStreamTestHandler(t) + + data := make(chan []byte, 2) + errs := make(chan *interfaces.ErrorMessage) + data <- []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","arguments":"{}"}}`) + data <- []byte("data: {\"type\":\"response.completed\",\ndata: \"response\":{\"id\":\"resp-1\",\"output\":[]}}\n\n") + close(data) + close(errs) + + h.forwardResponsesStream(c, flusher, func(error) {}, data, errs, nil) + + parts := strings.Split(strings.TrimSpace(recorder.Body.String()), "\n\n") + if len(parts) != 2 { + t.Fatalf("expected 2 SSE events, got %d. Body: %q", len(parts), recorder.Body.String()) + } + + completedFrame := []byte(parts[1]) + for _, line := range strings.Split(parts[1], "\n") { + if line != "" && !strings.HasPrefix(line, "data: ") { + t.Fatalf("expected every completed payload line to be an SSE data line, got %q in %q", line, parts[1]) + } + } + + payload, ok := responsesSSEDataPayload(completedFrame) + if !ok { + t.Fatalf("expected completed frame to contain data payload: %q", parts[1]) + } + output := gjson.GetBytes(payload, "response.output") + if !output.IsArray() || len(output.Array()) != 1 { + t.Fatalf("expected repaired completed output with 1 item, got %s from %q", output.Raw, payload) + } +} + func TestForwardResponsesStreamReassemblesSplitSSEEventChunks(t *testing.T) { h, recorder, c, flusher := newResponsesStreamTestHandler(t) From 32ef1588e82b75ab9060d9c185ceb19eba04e531 Mon Sep 17 00:00:00 2001 From: philipbankier Date: Sat, 25 Apr 2026 22:11:08 -0400 Subject: [PATCH 056/139] fix(test): remove free tier from GPT-5.5 inclusion test GPT-5.5 was correctly removed from codex-free tier in 7b89583c (since free accounts cannot access it), but the test was not updated to reflect this. This caused TestCodexStaticModelsIncludeGPT55 to fail on the free subtest. Changes: - Remove free tier from GPT-5.5 inclusion test - Add new TestCodexFreeModelsExcludeGPT55 to explicitly verify that free tier does NOT include GPT-5.5 --- internal/registry/model_definitions_test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/registry/model_definitions_test.go b/internal/registry/model_definitions_test.go index 7a0630c28d..bb2fc46046 100644 --- a/internal/registry/model_definitions_test.go +++ b/internal/registry/model_definitions_test.go @@ -2,9 +2,15 @@ package registry import "testing" +func TestCodexFreeModelsExcludeGPT55(t *testing.T) { + model := findModelInfo(GetCodexFreeModels(), "gpt-5.5") + if model != nil { + t.Fatal("expected codex free tier to NOT include gpt-5.5") + } +} + func TestCodexStaticModelsIncludeGPT55(t *testing.T) { tierModels := map[string][]*ModelInfo{ - "free": GetCodexFreeModels(), "team": GetCodexTeamModels(), "plus": GetCodexPlusModels(), "pro": GetCodexProModels(), From 38573050aa23bc7a3c704b100aa601daecf1dc61 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 26 Apr 2026 21:49:36 +0800 Subject: [PATCH 057/139] feat(config): add support for disabling OpenAI compatibility providers - Introduced a `Disabled` flag to OpenAI compatibility configurations. - Updated routing, auth selection, and API handling logic to respect the `Disabled` state. - Extended relevant APIs, YAML configurations, and data structures to include the `Disabled` field. - Adjusted all relevant loops and filters to skip disabled providers. Closes: #3060 #3059 #2977 --- config.example.yaml | 1 + internal/api/handlers/management/api_tools.go | 3 +++ internal/api/handlers/management/config_auth_index.go | 2 ++ internal/api/handlers/management/config_lists.go | 4 ++++ internal/api/server.go | 3 +++ internal/config/config.go | 3 +++ internal/runtime/executor/openai_compat_executor.go | 3 +++ internal/util/provider.go | 6 ++++++ internal/watcher/clients.go | 3 +++ internal/watcher/diff/openai_compat.go | 3 +++ internal/watcher/synthesizer/config.go | 3 +++ sdk/cliproxy/auth/conductor.go | 3 +++ sdk/cliproxy/service.go | 3 +++ 13 files changed, 40 insertions(+) diff --git a/config.example.yaml b/config.example.yaml index 13042b78d3..22696069f1 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -229,6 +229,7 @@ nonstream-keepalive-interval: 0 # OpenAI compatibility providers # openai-compatibility: # - name: "openrouter" # The name of the provider; it will be used in the user agent and other places. +# disabled: false # optional: set to true to disable this provider without removing it # prefix: "test" # optional: require calls like "test/kimi-k2" to target this provider's credentials # base-url: "https://openrouter.ai/api/v1" # The base URL of the provider. # headers: diff --git a/internal/api/handlers/management/api_tools.go b/internal/api/handlers/management/api_tools.go index cb4805e9ef..51b08cea4f 100644 --- a/internal/api/handlers/management/api_tools.go +++ b/internal/api/handlers/management/api_tools.go @@ -766,6 +766,9 @@ func resolveOpenAICompatAPIKeyProxyURL(cfg *config.Config, auth *coreauth.Auth, for i := range cfg.OpenAICompatibility { compat := &cfg.OpenAICompatibility[i] + if compat.Disabled { + continue + } for _, candidate := range candidates { if candidate != "" && strings.EqualFold(strings.TrimSpace(candidate), compat.Name) { for j := range compat.APIKeyEntries { diff --git a/internal/api/handlers/management/config_auth_index.go b/internal/api/handlers/management/config_auth_index.go index ed0b3ec42d..7b01512559 100644 --- a/internal/api/handlers/management/config_auth_index.go +++ b/internal/api/handlers/management/config_auth_index.go @@ -36,6 +36,7 @@ type openAICompatibilityAPIKeyWithAuthIndex struct { type openAICompatibilityWithAuthIndex struct { Name string `json:"name"` Priority int `json:"priority,omitempty"` + Disabled bool `json:"disabled"` Prefix string `json:"prefix,omitempty"` BaseURL string `json:"base-url"` APIKeyEntries []openAICompatibilityAPIKeyWithAuthIndex `json:"api-key-entries,omitempty"` @@ -215,6 +216,7 @@ func (h *Handler) openAICompatibilityWithAuthIndex() []openAICompatibilityWithAu response := openAICompatibilityWithAuthIndex{ Name: entry.Name, Priority: entry.Priority, + Disabled: entry.Disabled, Prefix: entry.Prefix, BaseURL: entry.BaseURL, Models: entry.Models, diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index ee3a4714b8..e487627a00 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -464,6 +464,7 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) { type openAICompatPatch struct { Name *string `json:"name"` Prefix *string `json:"prefix"` + Disabled *bool `json:"disabled"` BaseURL *string `json:"base-url"` APIKeyEntries *[]config.OpenAICompatibilityAPIKey `json:"api-key-entries"` Models *[]config.OpenAICompatibilityModel `json:"models"` @@ -506,6 +507,9 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) { if body.Value.Prefix != nil { entry.Prefix = strings.TrimSpace(*body.Value.Prefix) } + if body.Value.Disabled != nil { + entry.Disabled = *body.Value.Disabled + } if body.Value.BaseURL != nil { trimmed := strings.TrimSpace(*body.Value.BaseURL) if trimmed == "" { diff --git a/internal/api/server.go b/internal/api/server.go index e70883b02d..f817ac309b 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1100,6 +1100,9 @@ func (s *Server) UpdateClients(cfg *config.Config) { openAICompatCount := 0 for i := range cfg.OpenAICompatibility { entry := cfg.OpenAICompatibility[i] + if entry.Disabled { + continue + } openAICompatCount += len(entry.APIKeyEntries) } diff --git a/internal/config/config.go b/internal/config/config.go index 1ebbb460c0..9817a8a715 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -519,6 +519,9 @@ type OpenAICompatibility struct { // Higher values are preferred; defaults to 0. Priority int `yaml:"priority,omitempty" json:"priority,omitempty"` + // Disabled prevents this provider from being used for routing. + Disabled bool `yaml:"disabled,omitempty" json:"disabled,omitempty"` + // Prefix optionally namespaces model aliases for this provider (e.g., "teamA/kimi-k2"). Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"` diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index 7f202055a4..d5739a6377 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -378,6 +378,9 @@ func (e *OpenAICompatExecutor) resolveCompatConfig(auth *cliproxyauth.Auth) *con } for i := range e.cfg.OpenAICompatibility { compat := &e.cfg.OpenAICompatibility[i] + if compat.Disabled { + continue + } for _, candidate := range candidates { if candidate != "" && strings.EqualFold(strings.TrimSpace(candidate), compat.Name) { return compat diff --git a/internal/util/provider.go b/internal/util/provider.go index ce0ed1a397..beee9add9d 100644 --- a/internal/util/provider.go +++ b/internal/util/provider.go @@ -98,6 +98,9 @@ func IsOpenAICompatibilityAlias(modelName string, cfg *config.Config) bool { } for _, compat := range cfg.OpenAICompatibility { + if compat.Disabled { + continue + } for _, model := range compat.Models { if model.Alias == modelName { return true @@ -123,6 +126,9 @@ func GetOpenAICompatibilityConfig(alias string, cfg *config.Config) (*config.Ope } for _, compat := range cfg.OpenAICompatibility { + if compat.Disabled { + continue + } for _, model := range compat.Models { if model.Alias == alias { return &compat, &model diff --git a/internal/watcher/clients.go b/internal/watcher/clients.go index 7746f4ad3b..fb0d7865bc 100644 --- a/internal/watcher/clients.go +++ b/internal/watcher/clients.go @@ -357,6 +357,9 @@ func BuildAPIKeyClients(cfg *config.Config) (int, int, int, int, int) { } if len(cfg.OpenAICompatibility) > 0 { for _, compatConfig := range cfg.OpenAICompatibility { + if compatConfig.Disabled { + continue + } openAICompatCount += len(compatConfig.APIKeyEntries) } } diff --git a/internal/watcher/diff/openai_compat.go b/internal/watcher/diff/openai_compat.go index 6b01aed296..541b35b3d1 100644 --- a/internal/watcher/diff/openai_compat.go +++ b/internal/watcher/diff/openai_compat.go @@ -66,6 +66,9 @@ func describeOpenAICompatibilityUpdate(oldEntry, newEntry config.OpenAICompatibi oldModelCount := countOpenAIModels(oldEntry.Models) newModelCount := countOpenAIModels(newEntry.Models) details := make([]string, 0, 3) + if oldEntry.Disabled != newEntry.Disabled { + details = append(details, fmt.Sprintf("disabled %t -> %t", oldEntry.Disabled, newEntry.Disabled)) + } if oldKeyCount != newKeyCount { details = append(details, fmt.Sprintf("api-keys %d -> %d", oldKeyCount, newKeyCount)) } diff --git a/internal/watcher/synthesizer/config.go b/internal/watcher/synthesizer/config.go index 52ae9a4808..8026b02fa9 100644 --- a/internal/watcher/synthesizer/config.go +++ b/internal/watcher/synthesizer/config.go @@ -194,6 +194,9 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor out := make([]*coreauth.Auth, 0) for i := range cfg.OpenAICompatibility { compat := &cfg.OpenAICompatibility[i] + if compat.Disabled { + continue + } prefix := strings.TrimSpace(compat.Prefix) providerName := strings.ToLower(strings.TrimSpace(compat.Name)) if providerName == "" { diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 2091f669ae..6571518d31 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -1799,6 +1799,9 @@ func resolveOpenAICompatConfig(cfg *internalconfig.Config, providerKey, compatNa } for i := range cfg.OpenAICompatibility { compat := &cfg.OpenAICompatibility[i] + if compat.Disabled { + continue + } for _, candidate := range candidates { if candidate != "" && strings.EqualFold(strings.TrimSpace(candidate), compat.Name) { return compat diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index c5458b488c..d9613150e0 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -969,6 +969,9 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { } for i := range s.cfg.OpenAICompatibility { compat := &s.cfg.OpenAICompatibility[i] + if compat.Disabled { + continue + } if strings.EqualFold(compat.Name, compatName) { isCompatAuth = true // Convert compatibility models to registry models From c7b28ba0589b7ed079ba7b9975aedce0625089eb Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 26 Apr 2026 22:19:03 +0800 Subject: [PATCH 058/139] feat(executor): add support for Codex image generation tool usage tracking - Introduced `publishCodexImageToolUsage` to report image generation tool metrics. - Updated executor logic to handle image generation tool events and defaults. - Added parsing logic for `image_gen` tool usage details in `helps/usage_helpers.go`. - Updated `UsageReporter` for additional model-specific usage publishing. - Refactored usage detail normalizations. Closes: #3063 --- internal/runtime/executor/codex_executor.go | 32 ++++++++++- .../runtime/executor/helps/usage_helpers.go | 55 ++++++++++++++----- 2 files changed, 70 insertions(+), 17 deletions(-) diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index dc3254a769..2832f41c3c 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -30,8 +30,9 @@ import ( ) const ( - codexUserAgent = "codex-tui/0.118.0 (Mac OS 26.3.1; arm64) iTerm.app/3.6.9 (codex-tui; 0.118.0)" - codexOriginator = "codex-tui" + codexUserAgent = "codex-tui/0.118.0 (Mac OS 26.3.1; arm64) iTerm.app/3.6.9 (codex-tui; 0.118.0)" + codexOriginator = "codex-tui" + codexDefaultImageToolModel = "gpt-image-2" ) var dataTag = []byte("data:") @@ -263,6 +264,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re if detail, ok := helps.ParseCodexUsage(eventData); ok { reporter.Publish(ctx, detail) } + publishCodexImageToolUsage(ctx, reporter, body, eventData) completedData := eventData outputResult := gjson.GetBytes(completedData, "response.output") @@ -496,6 +498,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au if detail, ok := helps.ParseCodexUsage(data); ok { reporter.Publish(ctx, detail) } + publishCodexImageToolUsage(ctx, reporter, body, data) data = patchCodexCompletedOutput(data, outputItemsByIndex, outputItemsFallback) translatedLine = append([]byte("data: "), data...) } @@ -859,6 +862,31 @@ func ensureImageGenerationTool(body []byte, baseModel string, auth *cliproxyauth return body } +func publishCodexImageToolUsage(ctx context.Context, reporter *helps.UsageReporter, body []byte, completedData []byte) { + detail, ok := helps.ParseCodexImageToolUsage(completedData) + if !ok { + return + } + reporter.EnsurePublished(ctx) + reporter.PublishAdditionalModel(ctx, codexImageGenerationToolModel(body), detail) +} + +func codexImageGenerationToolModel(body []byte) string { + tools := gjson.GetBytes(body, "tools") + if tools.IsArray() { + for _, tool := range tools.Array() { + if tool.Get("type").String() != "image_generation" { + continue + } + if model := strings.TrimSpace(tool.Get("model").String()); model != "" { + return model + } + break + } + } + return codexDefaultImageToolModel +} + func isCodexModelCapacityError(errorBody []byte) bool { if len(errorBody) == 0 { return false diff --git a/internal/runtime/executor/helps/usage_helpers.go b/internal/runtime/executor/helps/usage_helpers.go index 97c1c61130..615b6bedfb 100644 --- a/internal/runtime/executor/helps/usage_helpers.go +++ b/internal/runtime/executor/helps/usage_helpers.go @@ -48,6 +48,18 @@ func (r *UsageReporter) Publish(ctx context.Context, detail usage.Detail) { r.publishWithOutcome(ctx, detail, false) } +func (r *UsageReporter) PublishAdditionalModel(ctx context.Context, model string, detail usage.Detail) { + if r == nil { + return + } + model = strings.TrimSpace(model) + if model == "" { + return + } + detail = normalizeUsageDetailTotal(detail) + usage.PublishRecord(ctx, r.buildRecordForModel(model, detail, false)) +} + func (r *UsageReporter) PublishFailure(ctx context.Context) { r.publishWithOutcome(ctx, usage.Detail{}, true) } @@ -65,15 +77,20 @@ func (r *UsageReporter) publishWithOutcome(ctx context.Context, detail usage.Det if r == nil { return } + detail = normalizeUsageDetailTotal(detail) + r.once.Do(func() { + usage.PublishRecord(ctx, r.buildRecord(detail, failed)) + }) +} + +func normalizeUsageDetailTotal(detail usage.Detail) usage.Detail { if detail.TotalTokens == 0 { total := detail.InputTokens + detail.OutputTokens + detail.ReasoningTokens if total > 0 { detail.TotalTokens = total } } - r.once.Do(func() { - usage.PublishRecord(ctx, r.buildRecord(detail, failed)) - }) + return detail } // ensurePublished guarantees that a usage record is emitted exactly once. @@ -93,9 +110,16 @@ func (r *UsageReporter) buildRecord(detail usage.Detail, failed bool) usage.Reco if r == nil { return usage.Record{Detail: detail, Failed: failed} } + return r.buildRecordForModel(r.model, detail, failed) +} + +func (r *UsageReporter) buildRecordForModel(model string, detail usage.Detail, failed bool) usage.Record { + if r == nil { + return usage.Record{Model: model, Detail: detail, Failed: failed} + } return usage.Record{ Provider: r.provider, - Model: r.model, + Model: model, Source: r.source, APIKey: r.apiKey, AuthID: r.authID, @@ -201,18 +225,15 @@ func ParseCodexUsage(data []byte) (usage.Detail, bool) { if !usageNode.Exists() { return usage.Detail{}, false } - detail := usage.Detail{ - InputTokens: usageNode.Get("input_tokens").Int(), - OutputTokens: usageNode.Get("output_tokens").Int(), - TotalTokens: usageNode.Get("total_tokens").Int(), - } - if cached := usageNode.Get("input_tokens_details.cached_tokens"); cached.Exists() { - detail.CachedTokens = cached.Int() - } - if reasoning := usageNode.Get("output_tokens_details.reasoning_tokens"); reasoning.Exists() { - detail.ReasoningTokens = reasoning.Int() + return parseOpenAIStyleUsageNode(usageNode), true +} + +func ParseCodexImageToolUsage(data []byte) (usage.Detail, bool) { + usageNode := gjson.ParseBytes(data).Get("response.tool_usage.image_gen") + if !usageNode.Exists() || !usageNode.IsObject() { + return usage.Detail{}, false } - return detail, true + return parseOpenAIStyleUsageNode(usageNode), true } func ParseOpenAIUsage(data []byte) usage.Detail { @@ -220,6 +241,10 @@ func ParseOpenAIUsage(data []byte) usage.Detail { if !usageNode.Exists() { return usage.Detail{} } + return parseOpenAIStyleUsageNode(usageNode) +} + +func parseOpenAIStyleUsageNode(usageNode gjson.Result) usage.Detail { inputNode := usageNode.Get("prompt_tokens") if !inputNode.Exists() { inputNode = usageNode.Get("input_tokens") From 6fc23568dfe266377478a0dfc4f949fdf697f90a Mon Sep 17 00:00:00 2001 From: sususu98 Date: Sun, 26 Apr 2026 23:04:06 +0800 Subject: [PATCH 059/139] logging: mark antigravity credits requests --- internal/logging/gin_logger.go | 20 ++++++++++++++++++- .../runtime/executor/antigravity_executor.go | 4 ++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/internal/logging/gin_logger.go b/internal/logging/gin_logger.go index d92ae985e5..4d6d088c03 100644 --- a/internal/logging/gin_logger.go +++ b/internal/logging/gin_logger.go @@ -27,7 +27,10 @@ var aiAPIPrefixes = []string{ "/api/provider/", } -const skipGinLogKey = "__gin_skip_request_logging__" +const ( + skipGinLogKey = "__gin_skip_request_logging__" + creditsUsedKey = "__antigravity_credits_used__" +) // GinLogrusLogger returns a Gin middleware handler that logs HTTP requests and responses // using logrus. It captures request details including method, path, status code, latency, @@ -79,6 +82,9 @@ func GinLogrusLogger() gin.HandlerFunc { requestID = "--------" } logLine := fmt.Sprintf("%3d | %13v | %15s | %-7s \"%s\"", statusCode, latency, clientIP, method, path) + if creditsUsed(c) { + logLine += " [credits]" + } if errorMessage != "" { logLine = logLine + " | " + errorMessage } @@ -149,3 +155,15 @@ func shouldSkipGinRequestLogging(c *gin.Context) bool { flag, ok := val.(bool) return ok && flag } + +func creditsUsed(c *gin.Context) bool { + if c == nil { + return false + } + val, exists := c.Get(creditsUsedKey) + if !exists { + return false + } + flag, ok := val.(bool) + return ok && flag +} diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 6983bface5..6657493430 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -2242,9 +2242,9 @@ var antigravityBaseURLFallbackOrder = func(auth *cliproxyauth.Auth) []string { return []string{base} } return []string{ - antigravityBaseURLProd, antigravityBaseURLDaily, - antigravitySandboxBaseURLDaily, + antigravityBaseURLProd, + // antigravitySandboxBaseURLDaily, } } From 04a336f7dfc4e1623dabb3eca7be8612cb5e5cc2 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 27 Apr 2026 10:56:22 +0800 Subject: [PATCH 060/139] fix(usage_helpers): skip zero-token usage in additional model records - Added `buildAdditionalModelRecord` to filter out zero-token usage details. - Introduced `hasNonZeroTokenUsage` helper function for token usage validation. - Updated tests to cover scenarios for zero and non-zero token usage. --- .../runtime/executor/helps/usage_helpers.go | 25 ++++++++++++++++--- .../executor/helps/usage_helpers_test.go | 18 +++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/internal/runtime/executor/helps/usage_helpers.go b/internal/runtime/executor/helps/usage_helpers.go index 615b6bedfb..d3093de18c 100644 --- a/internal/runtime/executor/helps/usage_helpers.go +++ b/internal/runtime/executor/helps/usage_helpers.go @@ -49,15 +49,26 @@ func (r *UsageReporter) Publish(ctx context.Context, detail usage.Detail) { } func (r *UsageReporter) PublishAdditionalModel(ctx context.Context, model string, detail usage.Detail) { - if r == nil { + record, ok := r.buildAdditionalModelRecord(model, detail) + if !ok { return } + usage.PublishRecord(ctx, record) +} + +func (r *UsageReporter) buildAdditionalModelRecord(model string, detail usage.Detail) (usage.Record, bool) { + if r == nil { + return usage.Record{}, false + } model = strings.TrimSpace(model) if model == "" { - return + return usage.Record{}, false } detail = normalizeUsageDetailTotal(detail) - usage.PublishRecord(ctx, r.buildRecordForModel(model, detail, false)) + if !hasNonZeroTokenUsage(detail) { + return usage.Record{}, false + } + return r.buildRecordForModel(model, detail, false), true } func (r *UsageReporter) PublishFailure(ctx context.Context) { @@ -93,6 +104,14 @@ func normalizeUsageDetailTotal(detail usage.Detail) usage.Detail { return detail } +func hasNonZeroTokenUsage(detail usage.Detail) bool { + return detail.InputTokens != 0 || + detail.OutputTokens != 0 || + detail.ReasoningTokens != 0 || + detail.CachedTokens != 0 || + detail.TotalTokens != 0 +} + // ensurePublished guarantees that a usage record is emitted exactly once. // It is safe to call multiple times; only the first call wins due to once.Do. // This is used to ensure request counting even when upstream responses do not diff --git a/internal/runtime/executor/helps/usage_helpers_test.go b/internal/runtime/executor/helps/usage_helpers_test.go index 1a5648e89b..3708b73175 100644 --- a/internal/runtime/executor/helps/usage_helpers_test.go +++ b/internal/runtime/executor/helps/usage_helpers_test.go @@ -62,3 +62,21 @@ func TestUsageReporterBuildRecordIncludesLatency(t *testing.T) { t.Fatalf("latency = %v, want <= 3s", record.Latency) } } + +func TestUsageReporterBuildAdditionalModelRecordSkipsZeroTokens(t *testing.T) { + reporter := &UsageReporter{ + provider: "codex", + model: "gpt-5.4", + requestedAt: time.Now(), + } + + if _, ok := reporter.buildAdditionalModelRecord("gpt-image-2", usage.Detail{}); ok { + t.Fatalf("expected all-zero token usage to be skipped") + } + if _, ok := reporter.buildAdditionalModelRecord("gpt-image-2", usage.Detail{InputTokens: 2}); !ok { + t.Fatalf("expected non-zero input token usage to be recorded") + } + if _, ok := reporter.buildAdditionalModelRecord("gpt-image-2", usage.Detail{CachedTokens: 2}); !ok { + t.Fatalf("expected non-zero cached token usage to be recorded") + } +} From 01e16a8509c1e65ff55daf68230bf87b2c7169be Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 27 Apr 2026 16:31:26 +0800 Subject: [PATCH 061/139] feat(codex): handle thinking-signature conversion for reasoning content - Implemented `appendReasoningContent` to support processing of `thinking` signature and text as reasoning input. - Added test cases to validate reasoning content conversion with and without text. --- .../codex/claude/codex_claude_request.go | 27 +++++++ .../codex/claude/codex_claude_request_test.go | 72 +++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index adff9a038d..0a034d6eb5 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -120,6 +120,30 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) hasContent = true } + appendReasoningContent := func(part gjson.Result) { + if messageRole != "assistant" { + return + } + + thinkingText := thinking.GetThinkingText(part) + signature := part.Get("signature").String() + if strings.TrimSpace(thinkingText) == "" && signature == "" { + return + } + + reasoningItem := []byte(`{"type":"reasoning","summary":[]}`) + if signature != "" { + reasoningItem, _ = sjson.SetBytes(reasoningItem, "encrypted_content", signature) + } + if strings.TrimSpace(thinkingText) != "" { + summary := []byte(`{"type":"summary_text","text":""}`) + summary, _ = sjson.SetBytes(summary, "text", thinkingText) + reasoningItem, _ = sjson.SetRawBytes(reasoningItem, "summary.-1", summary) + } + + template, _ = sjson.SetRawBytes(template, "input.-1", reasoningItem) + } + messageContentsResult := messageResult.Get("content") if messageContentsResult.IsArray() { messageContentResults := messageContentsResult.Array() @@ -130,6 +154,9 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) switch contentType { case "text": appendTextContent(messageContentResult.Get("text").String()) + case "thinking": + flushMessage() + appendReasoningContent(messageContentResult) case "image": sourceResult := messageContentResult.Get("source") if sourceResult.Exists() { diff --git a/internal/translator/codex/claude/codex_claude_request_test.go b/internal/translator/codex/claude/codex_claude_request_test.go index 3cf0236962..21df206e10 100644 --- a/internal/translator/codex/claude/codex_claude_request_test.go +++ b/internal/translator/codex/claude/codex_claude_request_test.go @@ -133,3 +133,75 @@ func TestConvertClaudeRequestToCodex_ParallelToolCalls(t *testing.T) { }) } } + +func TestConvertClaudeRequestToCodex_ThinkingSignatureToEncryptedContent(t *testing.T) { + result := ConvertClaudeRequestToCodex("test-model", []byte(`{ + "model": "claude-3-opus", + "messages": [{ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Internal reasoning.", "signature": "sig_123"}, + {"type": "text", "text": "Visible answer."} + ] + }] + }`), false) + resultJSON := gjson.ParseBytes(result) + inputs := resultJSON.Get("input").Array() + + if len(inputs) != 2 { + t.Fatalf("got %d input items, want 2. Output: %s", len(inputs), string(result)) + } + + reasoning := inputs[0] + if got := reasoning.Get("type").String(); got != "reasoning" { + t.Fatalf("input[0].type = %q, want %q. Output: %s", got, "reasoning", string(result)) + } + if got := reasoning.Get("encrypted_content").String(); got != "sig_123" { + t.Fatalf("encrypted_content = %q, want %q. Output: %s", got, "sig_123", string(result)) + } + if got := reasoning.Get("summary.0.type").String(); got != "summary_text" { + t.Fatalf("summary.0.type = %q, want %q. Output: %s", got, "summary_text", string(result)) + } + if got := reasoning.Get("summary.0.text").String(); got != "Internal reasoning." { + t.Fatalf("summary.0.text = %q, want %q. Output: %s", got, "Internal reasoning.", string(result)) + } + + message := inputs[1] + if got := message.Get("type").String(); got != "message" { + t.Fatalf("input[1].type = %q, want %q. Output: %s", got, "message", string(result)) + } + if got := message.Get("role").String(); got != "assistant" { + t.Fatalf("input[1].role = %q, want %q. Output: %s", got, "assistant", string(result)) + } + if got := message.Get("content.0.type").String(); got != "output_text" { + t.Fatalf("content.0.type = %q, want %q. Output: %s", got, "output_text", string(result)) + } + if got := message.Get("content.0.text").String(); got != "Visible answer." { + t.Fatalf("content.0.text = %q, want %q. Output: %s", got, "Visible answer.", string(result)) + } +} + +func TestConvertClaudeRequestToCodex_ThinkingSignatureWithoutText(t *testing.T) { + result := ConvertClaudeRequestToCodex("test-model", []byte(`{ + "model": "claude-3-opus", + "messages": [{ + "role": "assistant", + "content": [{"type": "thinking", "thinking": "", "signature": "sig_empty_text"}] + }] + }`), false) + resultJSON := gjson.ParseBytes(result) + inputs := resultJSON.Get("input").Array() + + if len(inputs) != 1 { + t.Fatalf("got %d input items, want 1. Output: %s", len(inputs), string(result)) + } + if got := inputs[0].Get("type").String(); got != "reasoning" { + t.Fatalf("input[0].type = %q, want %q. Output: %s", got, "reasoning", string(result)) + } + if got := inputs[0].Get("encrypted_content").String(); got != "sig_empty_text" { + t.Fatalf("encrypted_content = %q, want %q. Output: %s", got, "sig_empty_text", string(result)) + } + if got := len(inputs[0].Get("summary").Array()); got != 0 { + t.Fatalf("summary length = %d, want 0. Output: %s", got, string(result)) + } +} From d85e13b04451e8a502659f95ffdcf12415fe4bc2 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 27 Apr 2026 16:41:23 +0800 Subject: [PATCH 062/139] fix(codex): include `content` field in reasoning item initialization --- internal/translator/codex/claude/codex_claude_request.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index 0a034d6eb5..afc2900e75 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -131,7 +131,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) return } - reasoningItem := []byte(`{"type":"reasoning","summary":[]}`) + reasoningItem := []byte(`{"type":"reasoning","summary":[],"content":null}`) if signature != "" { reasoningItem, _ = sjson.SetBytes(reasoningItem, "encrypted_content", signature) } @@ -140,7 +140,6 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) summary, _ = sjson.SetBytes(summary, "text", thinkingText) reasoningItem, _ = sjson.SetRawBytes(reasoningItem, "summary.-1", summary) } - template, _ = sjson.SetRawBytes(template, "input.-1", reasoningItem) } From c5231014392767f43b7144b972b6c687d00209ed Mon Sep 17 00:00:00 2001 From: sususu Date: Mon, 27 Apr 2026 16:46:00 +0800 Subject: [PATCH 063/139] Preserve Codex reasoning signatures for Claude --- .../codex/claude/codex_claude_request.go | 48 +++-- .../codex/claude/codex_claude_request_test.go | 171 +++++++++++++----- .../codex/claude/codex_claude_response.go | 46 +++-- .../claude/codex_claude_response_test.go | 141 +++++++++++++++ 4 files changed, 332 insertions(+), 74 deletions(-) diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index afc2900e75..239c3e4d16 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -6,6 +6,7 @@ package claude import ( + "encoding/base64" "fmt" "strconv" "strings" @@ -125,21 +126,14 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) return } - thinkingText := thinking.GetThinkingText(part) signature := part.Get("signature").String() - if strings.TrimSpace(thinkingText) == "" && signature == "" { + if !isFernetLikeReasoningSignature(signature) { return } + flushMessage() reasoningItem := []byte(`{"type":"reasoning","summary":[],"content":null}`) - if signature != "" { - reasoningItem, _ = sjson.SetBytes(reasoningItem, "encrypted_content", signature) - } - if strings.TrimSpace(thinkingText) != "" { - summary := []byte(`{"type":"summary_text","text":""}`) - summary, _ = sjson.SetBytes(summary, "text", thinkingText) - reasoningItem, _ = sjson.SetRawBytes(reasoningItem, "summary.-1", summary) - } + reasoningItem, _ = sjson.SetBytes(reasoningItem, "encrypted_content", signature) template, _ = sjson.SetRawBytes(template, "input.-1", reasoningItem) } @@ -154,7 +148,6 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) case "text": appendTextContent(messageContentResult.Get("text").String()) case "thinking": - flushMessage() appendReasoningContent(messageContentResult) case "image": sourceResult := messageContentResult.Get("source") @@ -344,6 +337,39 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) return template } +// isFernetLikeReasoningSignature checks only the encrypted_content envelope shape +// observed in OpenAI reasoning signatures. It does not authenticate source or payload type. +func isFernetLikeReasoningSignature(signature string) bool { + const ( + fernetVersionLen = 1 + fernetTimestamp = 8 + fernetIV = 16 + fernetHMAC = 32 + aesBlockSize = 16 + ) + + signature = strings.TrimSpace(signature) + if !strings.HasPrefix(signature, "gAAAA") { + return false + } + + decoded, err := base64.URLEncoding.DecodeString(signature) + if err != nil { + decoded, err = base64.RawURLEncoding.DecodeString(signature) + if err != nil { + return false + } + } + + minLen := fernetVersionLen + fernetTimestamp + fernetIV + aesBlockSize + fernetHMAC + if len(decoded) < minLen || decoded[0] != 0x80 { + return false + } + + ciphertextLen := len(decoded) - fernetVersionLen - fernetTimestamp - fernetIV - fernetHMAC + return ciphertextLen > 0 && ciphertextLen%aesBlockSize == 0 +} + // shortenNameIfNeeded applies a simple shortening rule for a single name. func shortenNameIfNeeded(name string) string { const limit = 64 diff --git a/internal/translator/codex/claude/codex_claude_request_test.go b/internal/translator/codex/claude/codex_claude_request_test.go index 21df206e10..85d10267f4 100644 --- a/internal/translator/codex/claude/codex_claude_request_test.go +++ b/internal/translator/codex/claude/codex_claude_request_test.go @@ -1,6 +1,8 @@ package claude import ( + "encoding/base64" + "strings" "testing" "github.com/tidwall/gjson" @@ -134,74 +136,143 @@ func TestConvertClaudeRequestToCodex_ParallelToolCalls(t *testing.T) { } } -func TestConvertClaudeRequestToCodex_ThinkingSignatureToEncryptedContent(t *testing.T) { - result := ConvertClaudeRequestToCodex("test-model", []byte(`{ +func TestConvertClaudeRequestToCodex_AssistantThinkingSignatureToReasoningItem(t *testing.T) { + signature := validCodexReasoningSignature() + inputJSON := `{ "model": "claude-3-opus", - "messages": [{ - "role": "assistant", - "content": [ - {"type": "thinking", "thinking": "Internal reasoning.", "signature": "sig_123"}, - {"type": "text", "text": "Visible answer."} - ] - }] - }`), false) + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "visible summary must not be replayed", + "signature": "` + signature + `" + }, + { + "type": "text", + "text": "visible answer" + } + ] + }, + { + "role": "user", + "content": "continue" + } + ] + }` + + result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false) resultJSON := gjson.ParseBytes(result) inputs := resultJSON.Get("input").Array() - - if len(inputs) != 2 { - t.Fatalf("got %d input items, want 2. Output: %s", len(inputs), string(result)) + if len(inputs) != 3 { + t.Fatalf("got %d input items, want 3. Output: %s", len(inputs), string(result)) } reasoning := inputs[0] if got := reasoning.Get("type").String(); got != "reasoning" { - t.Fatalf("input[0].type = %q, want %q. Output: %s", got, "reasoning", string(result)) + t.Fatalf("first input type = %q, want reasoning. Output: %s", got, string(result)) } - if got := reasoning.Get("encrypted_content").String(); got != "sig_123" { - t.Fatalf("encrypted_content = %q, want %q. Output: %s", got, "sig_123", string(result)) + if got := reasoning.Get("encrypted_content").String(); got != signature { + t.Fatalf("encrypted_content = %q, want %q", got, signature) } - if got := reasoning.Get("summary.0.type").String(); got != "summary_text" { - t.Fatalf("summary.0.type = %q, want %q. Output: %s", got, "summary_text", string(result)) + if got := reasoning.Get("summary").Raw; got != "[]" { + t.Fatalf("summary = %s, want []", got) } - if got := reasoning.Get("summary.0.text").String(); got != "Internal reasoning." { - t.Fatalf("summary.0.text = %q, want %q. Output: %s", got, "Internal reasoning.", string(result)) + if got := reasoning.Get("content").Raw; got != "null" { + t.Fatalf("content = %s, want null", got) } - message := inputs[1] - if got := message.Get("type").String(); got != "message" { - t.Fatalf("input[1].type = %q, want %q. Output: %s", got, "message", string(result)) + assistantMessage := inputs[1] + if got := assistantMessage.Get("role").String(); got != "assistant" { + t.Fatalf("second input role = %q, want assistant. Output: %s", got, string(result)) } - if got := message.Get("role").String(); got != "assistant" { - t.Fatalf("input[1].role = %q, want %q. Output: %s", got, "assistant", string(result)) + if got := assistantMessage.Get("content.0.type").String(); got != "output_text" { + t.Fatalf("assistant content type = %q, want output_text", got) } - if got := message.Get("content.0.type").String(); got != "output_text" { - t.Fatalf("content.0.type = %q, want %q. Output: %s", got, "output_text", string(result)) + if got := assistantMessage.Get("content.0.text").String(); got != "visible answer" { + t.Fatalf("assistant text = %q, want visible answer", got) } - if got := message.Get("content.0.text").String(); got != "Visible answer." { - t.Fatalf("content.0.text = %q, want %q. Output: %s", got, "Visible answer.", string(result)) + if strings.Contains(string(result), "visible summary must not be replayed") { + t.Fatalf("thinking text should not be replayed into Codex input. Output: %s", string(result)) } } -func TestConvertClaudeRequestToCodex_ThinkingSignatureWithoutText(t *testing.T) { - result := ConvertClaudeRequestToCodex("test-model", []byte(`{ - "model": "claude-3-opus", - "messages": [{ - "role": "assistant", - "content": [{"type": "thinking", "thinking": "", "signature": "sig_empty_text"}] - }] - }`), false) - resultJSON := gjson.ParseBytes(result) - inputs := resultJSON.Get("input").Array() - - if len(inputs) != 1 { - t.Fatalf("got %d input items, want 1. Output: %s", len(inputs), string(result)) - } - if got := inputs[0].Get("type").String(); got != "reasoning" { - t.Fatalf("input[0].type = %q, want %q. Output: %s", got, "reasoning", string(result)) - } - if got := inputs[0].Get("encrypted_content").String(); got != "sig_empty_text" { - t.Fatalf("encrypted_content = %q, want %q. Output: %s", got, "sig_empty_text", string(result)) +func TestConvertClaudeRequestToCodex_IgnoresNonCodexThinkingSignatures(t *testing.T) { + tests := []struct { + name string + inputJSON string + }{ + { + name: "Ignore user thinking even with Codex-shaped signature", + inputJSON: `{ + "model": "claude-3-opus", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "thinking", + "thinking": "user supplied thinking", + "signature": "` + validCodexReasoningSignature() + `" + }, + { + "type": "text", + "text": "hello" + } + ] + } + ] + }`, + }, + { + name: "Ignore Anthropic native signature", + inputJSON: `{ + "model": "claude-3-opus", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "anthropic thinking", + "signature": "Eo8Canthropic-state" + }, + { + "type": "text", + "text": "visible answer" + } + ] + } + ] + }`, + }, } - if got := len(inputs[0].Get("summary").Array()); got != 0 { - t.Fatalf("summary length = %d, want 0. Output: %s", got, string(result)) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ConvertClaudeRequestToCodex("test-model", []byte(tt.inputJSON), false) + if got := countRequestInputItemsByType(result, "reasoning"); got != 0 { + t.Fatalf("got %d reasoning items, want 0. Output: %s", got, string(result)) + } + }) } } + +func countRequestInputItemsByType(result []byte, itemType string) int { + count := 0 + gjson.GetBytes(result, "input").ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() == itemType { + count++ + } + return true + }) + return count +} + +func validCodexReasoningSignature() string { + raw := make([]byte, 1+8+16+16+32) + raw[0] = 0x80 + raw[8] = 1 + return base64.URLEncoding.EncodeToString(raw) +} diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index 388b907ae9..e48a56f8b7 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -31,6 +31,7 @@ type ConvertCodexResponseToClaudeParams struct { ThinkingBlockOpen bool ThinkingStopPending bool ThinkingSignature string + ThinkingSummarySeen bool } // ConvertCodexResponseToClaude performs sophisticated streaming response format conversion. @@ -86,12 +87,8 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa if params.ThinkingBlockOpen && params.ThinkingStopPending { output = append(output, finalizeCodexThinkingBlock(params)...) } - template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`) - template, _ = sjson.SetBytes(template, "index", params.BlockIndex) - params.ThinkingBlockOpen = true - params.ThinkingStopPending = false - - output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2) + params.ThinkingSummarySeen = true + output = append(output, startCodexThinkingBlock(params)...) } else if typeStr == "response.reasoning_summary_text.delta" { template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}`) template, _ = sjson.SetBytes(template, "index", params.BlockIndex) @@ -100,9 +97,6 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2) } else if typeStr == "response.reasoning_summary_part.done" { params.ThinkingStopPending = true - if params.ThinkingSignature != "" { - output = append(output, finalizeCodexThinkingBlock(params)...) - } } else if typeStr == "response.content_part.added" { template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`) template, _ = sjson.SetBytes(template, "index", params.BlockIndex) @@ -169,10 +163,8 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2) } else if itemType == "reasoning" { + params.ThinkingSummarySeen = false params.ThinkingSignature = itemResult.Get("encrypted_content").String() - if params.ThinkingStopPending { - output = append(output, finalizeCodexThinkingBlock(params)...) - } } } else if typeStr == "response.output_item.done" { itemResult := rootResult.Get("item") @@ -229,8 +221,13 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa if signature := itemResult.Get("encrypted_content").String(); signature != "" { params.ThinkingSignature = signature } - output = append(output, finalizeCodexThinkingBlock(params)...) + if params.ThinkingSummarySeen { + output = append(output, finalizeCodexThinkingBlock(params)...) + } else { + output = append(output, finalizeCodexSignatureOnlyThinkingBlock(params)...) + } params.ThinkingSignature = "" + params.ThinkingSummarySeen = false } } else if typeStr == "response.function_call_arguments.delta" { params.HasReceivedArgumentsDelta = true @@ -437,6 +434,29 @@ func ClaudeTokenCount(_ context.Context, count int64) []byte { return translatorcommon.ClaudeInputTokensJSON(count) } +func startCodexThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte { + if params.ThinkingBlockOpen { + return nil + } + + template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`) + template, _ = sjson.SetBytes(template, "index", params.BlockIndex) + params.ThinkingBlockOpen = true + params.ThinkingStopPending = false + + return translatorcommon.AppendSSEEventBytes(nil, "content_block_start", template, 2) +} + +func finalizeCodexSignatureOnlyThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte { + if params.ThinkingSignature == "" { + return nil + } + + output := startCodexThinkingBlock(params) + output = append(output, finalizeCodexThinkingBlock(params)...) + return output +} + func finalizeCodexThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte { if !params.ThinkingBlockOpen { return nil diff --git a/internal/translator/codex/claude/codex_claude_response_test.go b/internal/translator/codex/claude/codex_claude_response_test.go index c36c9edb68..bbd71da085 100644 --- a/internal/translator/codex/claude/codex_claude_response_test.go +++ b/internal/translator/codex/claude/codex_claude_response_test.go @@ -243,6 +243,147 @@ func TestConvertCodexResponseToClaude_StreamThinkingUsesEarlyCapturedSignatureWh } } +func TestConvertCodexResponseToClaude_StreamThinkingUsesFinalDoneSignature(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"messages":[]}`) + var param any + + chunks := [][]byte{ + []byte("data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"reasoning\",\"encrypted_content\":\"enc_sig_initial\"}}"), + []byte("data: {\"type\":\"response.reasoning_summary_part.added\"}"), + []byte("data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"Let me think\"}"), + []byte("data: {\"type\":\"response.reasoning_summary_part.done\"}"), + []byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"reasoning\",\"encrypted_content\":\"enc_sig_final\"}}"), + } + + var outputs [][]byte + for _, chunk := range chunks { + outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, ¶m)...) + } + + signatureDeltaCount := 0 + events := []string{} + for _, out := range outputs { + for _, line := range strings.Split(string(out), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + data := gjson.Parse(strings.TrimPrefix(line, "data: ")) + if data.Get("type").String() == "content_block_start" && data.Get("content_block.type").String() == "thinking" { + events = append(events, "thinking_start") + } + if data.Get("type").String() == "content_block_delta" && data.Get("delta.type").String() == "thinking_delta" { + events = append(events, "thinking_delta") + } + if data.Get("type").String() == "content_block_stop" && data.Get("index").Int() == 0 { + events = append(events, "thinking_stop") + } + if data.Get("type").String() != "content_block_delta" || data.Get("delta.type").String() != "signature_delta" { + continue + } + events = append(events, "signature_delta") + signatureDeltaCount++ + if got := data.Get("delta.signature").String(); got != "enc_sig_final" { + t.Fatalf("signature delta = %q, want final done signature", got) + } + } + } + + if signatureDeltaCount != 1 { + t.Fatalf("expected one signature_delta, got %d", signatureDeltaCount) + } + if got, want := strings.Join(events, ","), "thinking_start,thinking_delta,signature_delta,thinking_stop"; got != want { + t.Fatalf("thinking event order = %s, want %s", got, want) + } +} + +func TestConvertCodexResponseToClaude_StreamSignatureOnlyReasoningEmitsThinkingSignature(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"messages":[]}`) + var param any + + chunks := [][]byte{ + []byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_123\",\"model\":\"gpt-5\"}}"), + []byte("data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"reasoning\",\"encrypted_content\":\"enc_sig_initial\"}}"), + []byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"reasoning\",\"encrypted_content\":\"enc_sig_only\"}}"), + []byte("data: {\"type\":\"response.content_part.added\"}"), + []byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}"), + } + + var outputs [][]byte + for _, chunk := range chunks { + outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, ¶m)...) + } + + thinkingStartFound := false + thinkingDeltaFound := false + signatureDeltaFound := false + thinkingStopFound := false + textStartIndex := int64(-1) + events := []string{} + + for _, out := range outputs { + for _, line := range strings.Split(string(out), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + data := gjson.Parse(strings.TrimPrefix(line, "data: ")) + switch data.Get("type").String() { + case "content_block_start": + if data.Get("content_block.type").String() == "thinking" { + events = append(events, "thinking_start") + thinkingStartFound = true + if got := data.Get("index").Int(); got != 0 { + t.Fatalf("thinking block index = %d, want 0", got) + } + } + if data.Get("content_block.type").String() == "text" { + events = append(events, "text_start") + textStartIndex = data.Get("index").Int() + } + case "content_block_delta": + switch data.Get("delta.type").String() { + case "thinking_delta": + thinkingDeltaFound = true + case "signature_delta": + events = append(events, "signature_delta") + signatureDeltaFound = true + if got := data.Get("index").Int(); got != 0 { + t.Fatalf("signature delta index = %d, want 0", got) + } + if got := data.Get("delta.signature").String(); got != "enc_sig_only" { + t.Fatalf("unexpected signature delta: %q", got) + } + } + case "content_block_stop": + if data.Get("index").Int() == 0 { + events = append(events, "thinking_stop") + thinkingStopFound = true + } + } + } + } + + if !thinkingStartFound { + t.Fatal("expected signature-only reasoning to start a thinking block") + } + if thinkingDeltaFound { + t.Fatal("did not expect thinking_delta when upstream omitted summary text") + } + if !signatureDeltaFound { + t.Fatal("expected signature_delta from encrypted_content-only reasoning") + } + if !thinkingStopFound { + t.Fatal("expected signature-only thinking block to stop") + } + if textStartIndex != 1 { + t.Fatalf("text block index = %d, want 1 after signature-only thinking block", textStartIndex) + } + if got, want := strings.Join(events, ","), "thinking_start,signature_delta,thinking_stop,text_start"; got != want { + t.Fatalf("signature-only event order = %s, want %s", got, want) + } +} + func TestConvertCodexResponseToClaudeNonStream_ThinkingIncludesSignature(t *testing.T) { ctx := context.Background() originalRequest := []byte(`{"messages":[]}`) From 3ac39dcc7d4e5594414cf0d9073fe4134d09873f Mon Sep 17 00:00:00 2001 From: XYenon Date: Mon, 27 Apr 2026 17:08:49 +0800 Subject: [PATCH 064/139] feat: support Codex/PI session headers for session affinity Amp-Thread-ID: https://ampcode.com/threads/T-019dce25-c070-773a-ac52-11c541220b30 Co-authored-by: Amp --- config.example.yaml | 5 +-- internal/config/config.go | 4 ++- sdk/cliproxy/auth/selector.go | 43 +++++++++++++++++------- sdk/cliproxy/auth/selector_test.go | 54 ++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 15 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 22696069f1..24e3d99c83 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -104,8 +104,9 @@ quota-exceeded: routing: strategy: "round-robin" # round-robin (default), fill-first # Enable universal session-sticky routing for all clients. - # Session IDs are extracted from: X-Session-ID header, Idempotency-Key, - # metadata.user_id, conversation_id, or first few messages hash. + # Session IDs are extracted from: metadata.user_id (Claude Code session format), + # X-Session-ID, Session_id (Codex), X-Amp-Thread-Id (Amp CLI), + # X-Client-Request-Id (PI), conversation_id, or first few messages hash. # Automatic failover is always enabled when bound auth becomes unavailable. session-affinity: false # default: false # How long session-to-auth bindings are retained. Default: 1h diff --git a/internal/config/config.go b/internal/config/config.go index 9817a8a715..1ee7aed536 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -226,7 +226,9 @@ type RoutingConfig struct { // SessionAffinity enables universal session-sticky routing for all clients. // Session IDs are extracted from multiple sources: - // X-Session-ID header, Idempotency-Key, metadata.user_id, conversation_id, or message hash. + // metadata.user_id (Claude Code session format), X-Session-ID, Session_id (Codex), + // X-Amp-Thread-Id (Amp CLI thread), X-Client-Request-Id (PI), metadata.user_id, + // conversation_id, or message hash. // Automatic failover is always enabled when bound auth becomes unavailable. SessionAffinity bool `yaml:"session-affinity,omitempty" json:"session-affinity,omitempty"` diff --git a/sdk/cliproxy/auth/selector.go b/sdk/cliproxy/auth/selector.go index f49979ce49..f0fe237c83 100644 --- a/sdk/cliproxy/auth/selector.go +++ b/sdk/cliproxy/auth/selector.go @@ -469,11 +469,14 @@ func NewSessionAffinitySelectorWithConfig(cfg SessionAffinityConfig) *SessionAff // Pick selects an auth with session affinity when possible. // Priority for session ID extraction: -// 1. metadata.user_id (Claude Code format) - highest priority +// 1. metadata.user_id (Claude Code format with _session_{uuid}) - highest priority // 2. X-Session-ID header -// 3. metadata.user_id (non-Claude Code format) -// 4. conversation_id field -// 5. Hash-based fallback from messages +// 3. Session_id header (Codex) +// 4. X-Amp-Thread-Id header (Amp CLI thread ID) +// 5. X-Client-Request-Id header (PI) +// 6. metadata.user_id (non-Claude Code format) +// 7. conversation_id field in request body +// 8. Stable hash from first few messages content (fallback) // // Note: The cache key includes provider, session ID, and model to handle cases where // a session uses multiple models (e.g., gemini-2.5-pro and gemini-3-flash-preview) @@ -570,10 +573,12 @@ func (s *SessionAffinitySelector) InvalidateAuth(authID string) { // Priority order: // 1. metadata.user_id (Claude Code format with _session_{uuid}) - highest priority for Claude Code clients // 2. X-Session-ID header -// 3. X-Amp-Thread-Id header (Amp CLI thread ID) -// 4. metadata.user_id (non-Claude Code format) -// 5. conversation_id field in request body -// 6. Stable hash from first few messages content (fallback) +// 3. Session_id header (Codex) +// 4. X-Amp-Thread-Id header (Amp CLI thread ID) +// 5. X-Client-Request-Id header (PI) +// 6. metadata.user_id (non-Claude Code format) +// 7. conversation_id field in request body +// 8. Stable hash from first few messages content (fallback) func ExtractSessionID(headers http.Header, payload []byte, metadata map[string]any) string { primary, _ := extractSessionIDs(headers, payload, metadata) return primary @@ -609,29 +614,43 @@ func extractSessionIDs(headers http.Header, payload []byte, metadata map[string] } } - // 3. X-Amp-Thread-Id header (Amp CLI thread ID) + // 3. Session_id header (Codex) + if headers != nil { + if sid := headers.Get("Session_id"); sid != "" { + return "codex:" + sid, "" + } + } + + // 4. X-Amp-Thread-Id header (Amp CLI thread ID) if headers != nil { if tid := headers.Get("X-Amp-Thread-Id"); tid != "" { return "amp:" + tid, "" } } + // 5. X-Client-Request-Id header (PI) + if headers != nil { + if rid := headers.Get("X-Client-Request-Id"); rid != "" { + return "clientreq:" + rid, "" + } + } + if len(payload) == 0 { return "", "" } - // 4. metadata.user_id (non-Claude Code format) + // 6. metadata.user_id (non-Claude Code format) userID := gjson.GetBytes(payload, "metadata.user_id").String() if userID != "" { return "user:" + userID, "" } - // 5. conversation_id field + // 7. conversation_id field if convID := gjson.GetBytes(payload, "conversation_id").String(); convID != "" { return "conv:" + convID, "" } - // 6. Hash-based fallback from message content + // 8. Hash-based fallback from message content return extractMessageHashIDs(payload) } diff --git a/sdk/cliproxy/auth/selector_test.go b/sdk/cliproxy/auth/selector_test.go index c3041b5bac..f6682c6fce 100644 --- a/sdk/cliproxy/auth/selector_test.go +++ b/sdk/cliproxy/auth/selector_test.go @@ -776,6 +776,46 @@ func TestExtractSessionID_Headers(t *testing.T) { } } +func TestExtractSessionID_CodexSessionIDHeader(t *testing.T) { + t.Parallel() + + headers := make(http.Header) + headers.Set("Session_id", "codex-session-123") + + got := ExtractSessionID(headers, nil, nil) + want := "codex:codex-session-123" + if got != want { + t.Errorf("ExtractSessionID() with Session_id = %q, want %q", got, want) + } +} + +func TestExtractSessionID_ClientRequestIDHeader(t *testing.T) { + t.Parallel() + + headers := make(http.Header) + headers.Set("X-Client-Request-Id", "pi-session-123") + + got := ExtractSessionID(headers, nil, nil) + want := "clientreq:pi-session-123" + if got != want { + t.Errorf("ExtractSessionID() with X-Client-Request-Id = %q, want %q", got, want) + } +} + +func TestExtractSessionID_CodexSessionIDPriorityOverClientRequestID(t *testing.T) { + t.Parallel() + + headers := make(http.Header) + headers.Set("X-Client-Request-Id", "pi-session-123") + headers.Set("Session_id", "codex-session-456") + + got := ExtractSessionID(headers, nil, nil) + want := "codex:codex-session-456" + if got != want { + t.Errorf("ExtractSessionID() = %q, want %q (Session_id should take priority over X-Client-Request-Id)", got, want) + } +} + func TestExtractSessionID_AmpThreadId(t *testing.T) { t.Parallel() @@ -789,6 +829,20 @@ func TestExtractSessionID_AmpThreadId(t *testing.T) { } } +func TestExtractSessionID_AmpThreadIdPriorityOverClientRequestID(t *testing.T) { + t.Parallel() + + headers := make(http.Header) + headers.Set("X-Amp-Thread-Id", "T-priority-test") + headers.Set("X-Client-Request-Id", "pi-session-123") + + got := ExtractSessionID(headers, nil, nil) + want := "amp:T-priority-test" + if got != want { + t.Errorf("ExtractSessionID() = %q, want %q (X-Amp-Thread-Id should take priority over X-Client-Request-Id)", got, want) + } +} + // TestExtractSessionID_AmpThreadIdLowerPriority verifies X-Amp-Thread-Id is lower // priority than Claude Code metadata.user_id but higher than conversation_id. func TestExtractSessionID_AmpThreadIdPriority(t *testing.T) { From a992dee4e860348e9af8fb31619b107ab999cca9 Mon Sep 17 00:00:00 2001 From: xbang Date: Tue, 28 Apr 2026 16:21:15 +0800 Subject: [PATCH 065/139] fix(antigravity): use real antigravity UA when polling credits balance The loadCodeAssist polling call hardcoded the User-Agent to google-api-nodejs-client/9.15.1. Google Cloud Code returns the paidTier object WITHOUT the availableCredits array for that UA, so updateAntigravityCreditsBalance always saw "no credits", set the hint to Available=false for every Google One AI Ultra account, and the conductor-level credits fallback could never find a candidate. Switching to resolveUserAgent(auth) (the same UA used for streamGenerateContent / generateContent) makes the response include availableCredits, so the credits hint is populated correctly and the fallback can actually inject enabledCreditTypes:["GOOGLE_ONE_AI"] when free tier is exhausted. --- internal/runtime/executor/antigravity_executor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 6983bface5..5cc93448d9 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -1772,7 +1772,7 @@ func (e *AntigravityExecutor) updateAntigravityCreditsBalance(ctx context.Contex } httpReq.Header.Set("Authorization", "Bearer "+token) httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("User-Agent", "google-api-nodejs-client/9.15.1") + httpReq.Header.Set("User-Agent", resolveUserAgent(auth)) httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0) httpResp, errDo := httpClient.Do(httpReq) From 9fb6a49260e89914b054ea9618117ddced570570 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Tue, 28 Apr 2026 17:19:12 +0800 Subject: [PATCH 066/139] test(api): add validation for unsupported models in OpenAI image handlers - Introduced tests to ensure unsupported models are rejected in `/images/generations` and `/images/edits`. - Added `isSupportedImagesModel` and `rejectUnsupportedImagesModel` functions for consistent model validation. - Enhanced image handler logic to apply validation checks for model compatibility. --- .../handlers/openai/openai_images_handlers.go | 60 +++++++++--- .../openai/openai_images_handlers_test.go | 95 +++++++++++++++++++ 2 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 sdk/api/handlers/openai/openai_images_handlers_test.go diff --git a/sdk/api/handlers/openai/openai_images_handlers.go b/sdk/api/handlers/openai/openai_images_handlers.go index 64b41232f4..081547c0f6 100644 --- a/sdk/api/handlers/openai/openai_images_handlers.go +++ b/sdk/api/handlers/openai/openai_images_handlers.go @@ -24,6 +24,8 @@ import ( const ( defaultImagesMainModel = "gpt-5.4-mini" defaultImagesToolModel = "gpt-image-2" + imagesGenerationsPath = "/v1/images/generations" + imagesEditsPath = "/v1/images/edits" ) type imageCallResult struct { @@ -99,6 +101,28 @@ func (a *sseFrameAccumulator) Flush() [][]byte { return frames } +func isSupportedImagesModel(model string) bool { + baseModel := strings.TrimSpace(model) + if idx := strings.LastIndex(baseModel, "/"); idx >= 0 && idx < len(baseModel)-1 { + baseModel = strings.TrimSpace(baseModel[idx+1:]) + } + return baseModel == defaultImagesToolModel +} + +func rejectUnsupportedImagesModel(c *gin.Context, model string) bool { + if isSupportedImagesModel(model) { + return false + } + + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: fmt.Sprintf("Model %s is not supported on %s or %s. Use %s.", model, imagesGenerationsPath, imagesEditsPath, defaultImagesToolModel), + Type: "invalid_request_error", + }, + }) + return true +} + func mimeTypeFromOutputFormat(outputFormat string) string { if outputFormat == "" { return "image/png" @@ -194,6 +218,14 @@ func (h *OpenAIAPIHandler) ImagesGenerations(c *gin.Context) { return } + imageModel := strings.TrimSpace(gjson.GetBytes(rawJSON, "model").String()) + if imageModel == "" { + imageModel = defaultImagesToolModel + } + if rejectUnsupportedImagesModel(c, imageModel) { + return + } + prompt := strings.TrimSpace(gjson.GetBytes(rawJSON, "prompt").String()) if prompt == "" { c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ @@ -205,10 +237,6 @@ func (h *OpenAIAPIHandler) ImagesGenerations(c *gin.Context) { return } - imageModel := strings.TrimSpace(gjson.GetBytes(rawJSON, "model").String()) - if imageModel == "" { - imageModel = defaultImagesToolModel - } responseFormat := strings.TrimSpace(gjson.GetBytes(rawJSON, "response_format").String()) if responseFormat == "" { responseFormat = "b64_json" @@ -283,6 +311,14 @@ func (h *OpenAIAPIHandler) imagesEditsFromMultipart(c *gin.Context) { return } + imageModel := strings.TrimSpace(c.PostForm("model")) + if imageModel == "" { + imageModel = defaultImagesToolModel + } + if rejectUnsupportedImagesModel(c, imageModel) { + return + } + prompt := strings.TrimSpace(c.PostForm("prompt")) if prompt == "" { c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ @@ -340,10 +376,6 @@ func (h *OpenAIAPIHandler) imagesEditsFromMultipart(c *gin.Context) { maskDataURL = &dataURL } - imageModel := strings.TrimSpace(c.PostForm("model")) - if imageModel == "" { - imageModel = defaultImagesToolModel - } responseFormat := strings.TrimSpace(c.PostForm("response_format")) if responseFormat == "" { responseFormat = "b64_json" @@ -412,6 +444,14 @@ func (h *OpenAIAPIHandler) imagesEditsFromJSON(c *gin.Context) { return } + imageModel := strings.TrimSpace(gjson.GetBytes(rawJSON, "model").String()) + if imageModel == "" { + imageModel = defaultImagesToolModel + } + if rejectUnsupportedImagesModel(c, imageModel) { + return + } + prompt := strings.TrimSpace(gjson.GetBytes(rawJSON, "prompt").String()) if prompt == "" { c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ @@ -460,10 +500,6 @@ func (h *OpenAIAPIHandler) imagesEditsFromJSON(c *gin.Context) { return } - imageModel := strings.TrimSpace(gjson.GetBytes(rawJSON, "model").String()) - if imageModel == "" { - imageModel = defaultImagesToolModel - } responseFormat := strings.TrimSpace(gjson.GetBytes(rawJSON, "response_format").String()) if responseFormat == "" { responseFormat = "b64_json" diff --git a/sdk/api/handlers/openai/openai_images_handlers_test.go b/sdk/api/handlers/openai/openai_images_handlers_test.go new file mode 100644 index 0000000000..679bec6a2f --- /dev/null +++ b/sdk/api/handlers/openai/openai_images_handlers_test.go @@ -0,0 +1,95 @@ +package openai + +import ( + "bytes" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" +) + +func performImagesEndpointRequest(t *testing.T, endpointPath string, contentType string, body io.Reader, handler gin.HandlerFunc) *httptest.ResponseRecorder { + t.Helper() + + gin.SetMode(gin.TestMode) + router := gin.New() + router.POST(endpointPath, handler) + + req := httptest.NewRequest(http.MethodPost, endpointPath, body) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + return resp +} + +func assertUnsupportedImagesModelResponse(t *testing.T, resp *httptest.ResponseRecorder, model string) { + t.Helper() + + if resp.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusBadRequest, resp.Body.String()) + } + + message := gjson.GetBytes(resp.Body.Bytes(), "error.message").String() + expectedMessage := "Model " + model + " is not supported on " + imagesGenerationsPath + " or " + imagesEditsPath + ". Use " + defaultImagesToolModel + "." + if message != expectedMessage { + t.Fatalf("error message = %q, want %q", message, expectedMessage) + } + if errorType := gjson.GetBytes(resp.Body.Bytes(), "error.type").String(); errorType != "invalid_request_error" { + t.Fatalf("error type = %q, want invalid_request_error", errorType) + } +} + +func TestImagesModelValidationAllowsGPTImage2WithOptionalPrefix(t *testing.T) { + for _, model := range []string{"gpt-image-2", "codex/gpt-image-2"} { + if !isSupportedImagesModel(model) { + t.Fatalf("expected %s to be supported", model) + } + } + if isSupportedImagesModel("gpt-5.4-mini") { + t.Fatal("expected gpt-5.4-mini to be rejected") + } +} + +func TestImagesGenerationsRejectsUnsupportedModel(t *testing.T) { + handler := &OpenAIAPIHandler{} + body := strings.NewReader(`{"model":"gpt-5.4-mini","prompt":"draw a square"}`) + + resp := performImagesEndpointRequest(t, imagesGenerationsPath, "application/json", body, handler.ImagesGenerations) + + assertUnsupportedImagesModelResponse(t, resp, "gpt-5.4-mini") +} + +func TestImagesEditsJSONRejectsUnsupportedModel(t *testing.T) { + handler := &OpenAIAPIHandler{} + body := strings.NewReader(`{"model":"gpt-5.4-mini","prompt":"edit this","images":[{"image_url":"data:image/png;base64,AA=="}]}`) + + resp := performImagesEndpointRequest(t, imagesEditsPath, "application/json", body, handler.ImagesEdits) + + assertUnsupportedImagesModelResponse(t, resp, "gpt-5.4-mini") +} + +func TestImagesEditsMultipartRejectsUnsupportedModel(t *testing.T) { + handler := &OpenAIAPIHandler{} + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("model", "gpt-5.4-mini"); err != nil { + t.Fatalf("write model field: %v", err) + } + if err := writer.WriteField("prompt", "edit this"); err != nil { + t.Fatalf("write prompt field: %v", err) + } + if errClose := writer.Close(); errClose != nil { + t.Fatalf("close multipart writer: %v", errClose) + } + + resp := performImagesEndpointRequest(t, imagesEditsPath, writer.FormDataContentType(), &body, handler.ImagesEdits) + + assertUnsupportedImagesModelResponse(t, resp, "gpt-5.4-mini") +} From e78d45acc90bf623ad1bd8f0bc8cabcc7929322f Mon Sep 17 00:00:00 2001 From: sususu98 Date: Fri, 24 Apr 2026 19:46:52 +0800 Subject: [PATCH 067/139] fix antigravity user agent handling --- internal/auth/antigravity/auth.go | 25 +++++--- internal/misc/antigravity_version.go | 61 +++++++++++++++++++ .../runtime/executor/antigravity_executor.go | 48 +++++++++++---- .../antigravity_executor_credits_test.go | 45 ++++++++++++++ 4 files changed, 158 insertions(+), 21 deletions(-) diff --git a/internal/auth/antigravity/auth.go b/internal/auth/antigravity/auth.go index 449f413fc1..12d112c4e0 100644 --- a/internal/auth/antigravity/auth.go +++ b/internal/auth/antigravity/auth.go @@ -12,6 +12,7 @@ import ( "time" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" log "github.com/sirupsen/logrus" ) @@ -36,17 +37,21 @@ type AntigravityAuth struct { // NewAntigravityAuth creates a new Antigravity auth service. func NewAntigravityAuth(cfg *config.Config, httpClient *http.Client) *AntigravityAuth { - if httpClient != nil { - return &AntigravityAuth{httpClient: httpClient} - } if cfg == nil { cfg = &config.Config{} } + if httpClient != nil { + return &AntigravityAuth{httpClient: httpClient} + } return &AntigravityAuth{ httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}), } } +func (o *AntigravityAuth) loadCodeAssistUserAgent() string { + return misc.AntigravityLoadCodeAssistUserAgent("") +} + // BuildAuthURL generates the OAuth authorization URL. func (o *AntigravityAuth) BuildAuthURL(state, redirectURI string) string { if strings.TrimSpace(redirectURI) == "" { @@ -153,11 +158,12 @@ func (o *AntigravityAuth) FetchUserInfo(ctx context.Context, accessToken string) // FetchProjectID retrieves the project ID for the authenticated user via loadCodeAssist func (o *AntigravityAuth) FetchProjectID(ctx context.Context, accessToken string) (string, error) { + userAgent := o.loadCodeAssistUserAgent() loadReqBody := map[string]any{ "metadata": map[string]string{ - "ideType": "ANTIGRAVITY", - "platform": "PLATFORM_UNSPECIFIED", - "pluginType": "GEMINI", + "ide_type": "ANTIGRAVITY", + "ide_version": misc.AntigravityVersionFromUserAgent(userAgent), + "ide_name": "antigravity", }, } @@ -173,9 +179,8 @@ func (o *AntigravityAuth) FetchProjectID(ctx context.Context, accessToken string } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", APIUserAgent) - req.Header.Set("X-Goog-Api-Client", APIClient) - req.Header.Set("Client-Metadata", ClientMetadata) + req.Header.Set("User-Agent", userAgent) + req.Header.Set("X-Goog-Api-Client", "gl-node/22.21.1") resp, errDo := o.httpClient.Do(req) if errDo != nil { @@ -277,7 +282,7 @@ func (o *AntigravityAuth) OnboardUser(ctx context.Context, accessToken, tierID s } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", APIUserAgent) + req.Header.Set("User-Agent", o.loadCodeAssistUserAgent()) req.Header.Set("X-Goog-Api-Client", APIClient) req.Header.Set("Client-Metadata", ClientMetadata) diff --git a/internal/misc/antigravity_version.go b/internal/misc/antigravity_version.go index 595cfefd96..1f05073eed 100644 --- a/internal/misc/antigravity_version.go +++ b/internal/misc/antigravity_version.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net/http" + "strings" "sync" "time" @@ -18,6 +19,7 @@ const ( antigravityFallbackVersion = "1.21.9" antigravityVersionCacheTTL = 6 * time.Hour antigravityFetchTimeout = 10 * time.Second + AntigravityNodeAPIClientUA = "google-api-nodejs-client/10.3.0" ) type antigravityRelease struct { @@ -107,6 +109,65 @@ func AntigravityUserAgent() string { return fmt.Sprintf("antigravity/%s darwin/arm64", AntigravityLatestVersion()) } +func antigravityBaseUserAgent(userAgent string) string { + userAgent = strings.TrimSpace(userAgent) + if userAgent == "" { + return AntigravityUserAgent() + } + lower := strings.ToLower(userAgent) + if strings.HasPrefix(lower, "antigravity/") { + if idx := strings.Index(lower, " google-api-nodejs-client/"); idx >= 0 { + trimmed := strings.TrimSpace(userAgent[:idx]) + if trimmed != "" { + return trimmed + } + } + } + return userAgent +} + +// AntigravityRequestUserAgent returns the short Antigravity runtime UA used by +// generate/stream/model-list requests. +func AntigravityRequestUserAgent(userAgent string) string { + return antigravityBaseUserAgent(userAgent) +} + +// AntigravityLoadCodeAssistUserAgent returns the long Antigravity control-plane +// UA used by loadCodeAssist requests. +func AntigravityLoadCodeAssistUserAgent(userAgent string) string { + userAgent = strings.TrimSpace(userAgent) + if userAgent == "" { + return AntigravityUserAgent() + " " + AntigravityNodeAPIClientUA + } + lower := strings.ToLower(userAgent) + if !strings.HasPrefix(lower, "antigravity/") { + return userAgent + } + if strings.Contains(lower, "google-api-nodejs-client/") { + return userAgent + } + return antigravityBaseUserAgent(userAgent) + " " + AntigravityNodeAPIClientUA +} + +// AntigravityVersionFromUserAgent extracts the Antigravity version prefix from +// either the short or long Antigravity UA forms. +func AntigravityVersionFromUserAgent(userAgent string) string { + base := antigravityBaseUserAgent(userAgent) + lower := strings.ToLower(base) + if !strings.HasPrefix(lower, "antigravity/") { + return AntigravityLatestVersion() + } + rest := base[len("antigravity/"):] + if idx := strings.IndexAny(rest, " \t"); idx >= 0 { + rest = rest[:idx] + } + rest = strings.TrimSpace(rest) + if rest == "" { + return AntigravityLatestVersion() + } + return rest +} + func fetchAntigravityLatestVersion(ctx context.Context) (string, error) { if ctx == nil { ctx = context.Background() diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 5cc93448d9..3b3943b8a8 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -478,7 +478,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au return resp, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"} } baseModel := thinking.ParseSuffix(req.Model).ModelName - if inCooldown, remaining := antigravityIsInShortCooldown(auth, baseModel, time.Now()); inCooldown { + if inCooldown, remaining := antigravityIsInShortCooldown(auth, baseModel, time.Now()); inCooldown && !antigravityShouldBypassShortCooldown(ctx, e.cfg) { log.Debugf("antigravity executor: auth %s in short cooldown for model %s (%s remaining), returning 429 to switch auth", auth.ID, baseModel, remaining) d := remaining return resp, statusErr{code: http.StatusTooManyRequests, msg: fmt.Sprintf("auth in short cooldown, %s remaining", remaining), retryAfter: &d} @@ -680,7 +680,7 @@ attemptLoop: // executeClaudeNonStream performs a claude non-streaming request to the Antigravity API. func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { baseModel := thinking.ParseSuffix(req.Model).ModelName - if inCooldown, remaining := antigravityIsInShortCooldown(auth, baseModel, time.Now()); inCooldown { + if inCooldown, remaining := antigravityIsInShortCooldown(auth, baseModel, time.Now()); inCooldown && !antigravityShouldBypassShortCooldown(ctx, e.cfg) { log.Debugf("antigravity executor: auth %s in short cooldown for model %s (%s remaining), returning 429 to switch auth", auth.ID, baseModel, remaining) d := remaining return resp, statusErr{code: http.StatusTooManyRequests, msg: fmt.Sprintf("auth in short cooldown, %s remaining", remaining), retryAfter: &d} @@ -1139,7 +1139,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya baseModel := thinking.ParseSuffix(req.Model).ModelName ctx = context.WithValue(ctx, "alt", "") - if inCooldown, remaining := antigravityIsInShortCooldown(auth, baseModel, time.Now()); inCooldown { + if inCooldown, remaining := antigravityIsInShortCooldown(auth, baseModel, time.Now()); inCooldown && !antigravityShouldBypassShortCooldown(ctx, e.cfg) { log.Debugf("antigravity executor: auth %s in short cooldown for model %s (%s remaining), returning 429 to switch auth", auth.ID, baseModel, remaining) d := remaining return nil, statusErr{code: http.StatusTooManyRequests, msg: fmt.Sprintf("auth in short cooldown, %s remaining", remaining), retryAfter: &d} @@ -1763,16 +1763,29 @@ func (e *AntigravityExecutor) updateAntigravityCreditsBalance(ctx context.Contex return } - loadReqBody := `{"metadata":{"ideType":"ANTIGRAVITY","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}}` - endpointURL := "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" - httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, endpointURL, strings.NewReader(loadReqBody)) + userAgent := resolveLoadCodeAssistUserAgent(auth) + loadReqBody, errMarshal := json.Marshal(map[string]any{ + "metadata": map[string]string{ + "ide_type": "ANTIGRAVITY", + "ide_version": misc.AntigravityVersionFromUserAgent(userAgent), + "ide_name": "antigravity", + }, + }) + if errMarshal != nil { + log.Debugf("antigravity executor: marshal loadCodeAssist request error: %v", errMarshal) + return + } + baseURL := buildBaseURL(auth) + endpointURL := strings.TrimSuffix(baseURL, "/") + "/v1internal:loadCodeAssist" + httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, endpointURL, bytes.NewReader(loadReqBody)) if errReq != nil { log.Debugf("antigravity executor: create loadCodeAssist request error: %v", errReq) return } httpReq.Header.Set("Authorization", "Bearer "+token) httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("User-Agent", resolveUserAgent(auth)) + httpReq.Header.Set("User-Agent", userAgent) + httpReq.Header.Set("X-Goog-Api-Client", "gl-node/22.21.1") httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0) httpResp, errDo := httpClient.Do(httpReq) @@ -2070,19 +2083,28 @@ func resolveHost(base string) string { } func resolveUserAgent(auth *cliproxyauth.Auth) string { + return misc.AntigravityRequestUserAgent(antigravityConfiguredUserAgent(auth)) +} + +func resolveLoadCodeAssistUserAgent(auth *cliproxyauth.Auth) string { + return misc.AntigravityLoadCodeAssistUserAgent(antigravityConfiguredUserAgent(auth)) +} + +func antigravityConfiguredUserAgent(auth *cliproxyauth.Auth) string { + raw := "" if auth != nil { if auth.Attributes != nil { if ua := strings.TrimSpace(auth.Attributes["user_agent"]); ua != "" { - return ua + raw = ua } } - if auth.Metadata != nil { + if raw == "" && auth.Metadata != nil { if ua, ok := auth.Metadata["user_agent"].(string); ok && strings.TrimSpace(ua) != "" { - return strings.TrimSpace(ua) + raw = strings.TrimSpace(ua) } } } - return misc.AntigravityUserAgent() + return raw } func antigravityRetryAttempts(auth *cliproxyauth.Auth, cfg *config.Config) int { @@ -2141,6 +2163,10 @@ func antigravityShouldRetrySoftRateLimit(statusCode int, body []byte) bool { return decideAntigravity429(body).kind == antigravity429DecisionSoftRetry } +func antigravityShouldBypassShortCooldown(ctx context.Context, cfg *config.Config) bool { + return cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(cfg) +} + func antigravitySoftRateLimitDelay(attempt int) time.Duration { if attempt < 0 { attempt = 0 diff --git a/internal/runtime/executor/antigravity_executor_credits_test.go b/internal/runtime/executor/antigravity_executor_credits_test.go index 6e38223e50..4569f5dfd7 100644 --- a/internal/runtime/executor/antigravity_executor_credits_test.go +++ b/internal/runtime/executor/antigravity_executor_credits_test.go @@ -216,6 +216,11 @@ func TestAntigravityExecute_CreditsInjectedWhenConductorRequests(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) _ = r.Body.Close() + if r.URL.Path == "/v1internal:loadCodeAssist" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"paidTier":{"id":"tier-1","availableCredits":[{"creditType":"GOOGLE_ONE_AI","creditAmount":"25000","minimumCreditAmountForUsage":"50"}]}}`)) + return + } requestBodies = append(requestBodies, string(body)) if !strings.Contains(string(body), `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) { @@ -269,6 +274,11 @@ func TestAntigravityExecute_NoCreditsWithoutConductorFlag(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) _ = r.Body.Close() + if r.URL.Path == "/v1internal:loadCodeAssist" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"paidTier":{"id":"tier-1","availableCredits":[{"creditType":"GOOGLE_ONE_AI","creditAmount":"25000","minimumCreditAmountForUsage":"50"}]}}`)) + return + } requestBodies = append(requestBodies, string(body)) w.WriteHeader(http.StatusTooManyRequests) _, _ = w.Write([]byte(`{"error":{"status":"RESOURCE_EXHAUSTED","message":"QUOTA_EXHAUSTED"}}`)) @@ -429,6 +439,41 @@ func TestEnsureAccessToken_WarmTokenLoadsCreditsHint(t *testing.T) { } } +func TestUpdateAntigravityCreditsBalance_LoadCodeAssistUserAgent(t *testing.T) { + resetAntigravityCreditsRetryState() + t.Cleanup(resetAntigravityCreditsRetryState) + + exec := NewAntigravityExecutor(&config.Config{}) + const userAgent = "antigravity/1.23.2 windows/amd64 google-api-nodejs-client/10.3.0" + auth := &cliproxyauth.Auth{ + ID: "auth-load-code-assist-ua", + Attributes: map[string]string{"user_agent": userAgent}, + } + ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", roundTripperFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.String() != "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" { + t.Fatalf("unexpected request url %s", req.URL.String()) + } + if got := req.Header.Get("User-Agent"); got != userAgent { + t.Fatalf("User-Agent = %q, want %q", got, userAgent) + } + if got := req.Header.Get("X-Goog-Api-Client"); got != "gl-node/22.21.1" { + t.Fatalf("X-Goog-Api-Client = %q, want %q", got, "gl-node/22.21.1") + } + body, _ := io.ReadAll(req.Body) + _ = req.Body.Close() + if string(body) != `{"metadata":{"ide_name":"antigravity","ide_type":"ANTIGRAVITY","ide_version":"1.23.2"}}` { + t.Fatalf("loadCodeAssist body = %s", string(body)) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"paidTier":{"id":"tier-1","availableCredits":[{"creditType":"GOOGLE_ONE_AI","creditAmount":"25000","minimumCreditAmountForUsage":"50"}]}}`)), + }, nil + })) + + exec.updateAntigravityCreditsBalance(ctx, auth, "token") +} + func TestParseMetaFloat(t *testing.T) { tests := []struct { name string From 0e1235122e1c1b26e254225c3768a5818747b8b2 Mon Sep 17 00:00:00 2001 From: sususu98 Date: Fri, 24 Apr 2026 23:14:30 +0800 Subject: [PATCH 068/139] fix antigravity client agent headers --- internal/auth/antigravity/auth.go | 15 ++++++++------- internal/auth/antigravity/constants.go | 9 +++------ internal/misc/antigravity_version.go | 1 + internal/runtime/executor/antigravity_executor.go | 2 +- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/internal/auth/antigravity/auth.go b/internal/auth/antigravity/auth.go index 12d112c4e0..8d3b216fbc 100644 --- a/internal/auth/antigravity/auth.go +++ b/internal/auth/antigravity/auth.go @@ -123,6 +123,7 @@ func (o *AntigravityAuth) FetchUserInfo(ctx context.Context, accessToken string) return "", fmt.Errorf("antigravity userinfo: create request: %w", err) } req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("User-Agent", o.loadCodeAssistUserAgent()) resp, errDo := o.httpClient.Do(req) if errDo != nil { @@ -180,7 +181,7 @@ func (o *AntigravityAuth) FetchProjectID(ctx context.Context, accessToken string req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", userAgent) - req.Header.Set("X-Goog-Api-Client", "gl-node/22.21.1") + req.Header.Set("X-Goog-Api-Client", misc.AntigravityGoogAPIClientUA) resp, errDo := o.httpClient.Do(req) if errDo != nil { @@ -249,12 +250,13 @@ func (o *AntigravityAuth) FetchProjectID(ctx context.Context, accessToken string // OnboardUser attempts to fetch the project ID via onboardUser by polling for completion func (o *AntigravityAuth) OnboardUser(ctx context.Context, accessToken, tierID string) (string, error) { log.Infof("Antigravity: onboarding user with tier: %s", tierID) + userAgent := o.loadCodeAssistUserAgent() requestBody := map[string]any{ "tierId": tierID, "metadata": map[string]string{ - "ideType": "ANTIGRAVITY", - "platform": "PLATFORM_UNSPECIFIED", - "pluginType": "GEMINI", + "ide_type": "ANTIGRAVITY", + "ide_version": misc.AntigravityVersionFromUserAgent(userAgent), + "ide_name": "antigravity", }, } @@ -282,9 +284,8 @@ func (o *AntigravityAuth) OnboardUser(ctx context.Context, accessToken, tierID s } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", o.loadCodeAssistUserAgent()) - req.Header.Set("X-Goog-Api-Client", APIClient) - req.Header.Set("Client-Metadata", ClientMetadata) + req.Header.Set("User-Agent", userAgent) + req.Header.Set("X-Goog-Api-Client", misc.AntigravityGoogAPIClientUA) resp, errDo := o.httpClient.Do(req) if errDo != nil { diff --git a/internal/auth/antigravity/constants.go b/internal/auth/antigravity/constants.go index 680c8e3c70..61e736971a 100644 --- a/internal/auth/antigravity/constants.go +++ b/internal/auth/antigravity/constants.go @@ -21,14 +21,11 @@ var Scopes = []string{ const ( TokenEndpoint = "https://oauth2.googleapis.com/token" AuthEndpoint = "https://accounts.google.com/o/oauth2/v2/auth" - UserInfoEndpoint = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json" + UserInfoEndpoint = "https://www.googleapis.com/oauth2/v2/userinfo?alt=json" ) // Antigravity API configuration const ( - APIEndpoint = "https://cloudcode-pa.googleapis.com" - APIVersion = "v1internal" - APIUserAgent = "google-api-nodejs-client/9.15.1" - APIClient = "google-cloud-sdk vscode_cloudshelleditor/0.1" - ClientMetadata = `{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}` + APIEndpoint = "https://cloudcode-pa.googleapis.com" + APIVersion = "v1internal" ) diff --git a/internal/misc/antigravity_version.go b/internal/misc/antigravity_version.go index 1f05073eed..0d187c254f 100644 --- a/internal/misc/antigravity_version.go +++ b/internal/misc/antigravity_version.go @@ -20,6 +20,7 @@ const ( antigravityVersionCacheTTL = 6 * time.Hour antigravityFetchTimeout = 10 * time.Second AntigravityNodeAPIClientUA = "google-api-nodejs-client/10.3.0" + AntigravityGoogAPIClientUA = "gl-node/22.21.1" ) type antigravityRelease struct { diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 3b3943b8a8..15d05a4642 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -1785,7 +1785,7 @@ func (e *AntigravityExecutor) updateAntigravityCreditsBalance(ctx context.Contex httpReq.Header.Set("Authorization", "Bearer "+token) httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("User-Agent", userAgent) - httpReq.Header.Set("X-Goog-Api-Client", "gl-node/22.21.1") + httpReq.Header.Set("X-Goog-Api-Client", misc.AntigravityGoogAPIClientUA) httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0) httpResp, errDo := httpClient.Do(httpReq) From 2ea8f77efbd06a03d699c3d1459f993f3705f6ea Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 29 Apr 2026 09:49:26 +0800 Subject: [PATCH 069/139] feat(models): add GPT-5.5 to the registry with support for advanced tasks --- internal/registry/models/models.json | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json index d276cdc21e..fa56bb42a2 100644 --- a/internal/registry/models/models.json +++ b/internal/registry/models/models.json @@ -1293,6 +1293,29 @@ ] } }, + { + "id": "gpt-5.5", + "object": "model", + "created": 1776902400, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.5", + "version": "gpt-5.5", + "description": "Frontier model for complex coding, research, and real-world work.", + "context_length": 272000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } + }, { "id": "codex-auto-review", "object": "model", From 4982512da2e3a0497e599ac9a43fce2063e8f4df Mon Sep 17 00:00:00 2001 From: sususu98 Date: Wed, 29 Apr 2026 12:46:53 +0800 Subject: [PATCH 070/139] fix: parse gemini cli usage metadata variants --- .../runtime/executor/gemini_cli_executor.go | 2 + .../runtime/executor/helps/usage_helpers.go | 40 ++++++++++++++--- .../executor/helps/usage_helpers_test.go | 44 +++++++++++++++++++ 3 files changed, 79 insertions(+), 7 deletions(-) diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go index a18f824a62..375989839f 100644 --- a/internal/runtime/executor/gemini_cli_executor.go +++ b/internal/runtime/executor/gemini_cli_executor.go @@ -422,7 +422,9 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut helps.RecordAPIResponseError(ctx, e.cfg, errScan) reporter.PublishFailure(ctx) out <- cliproxyexecutor.StreamChunk{Err: errScan} + return } + reporter.EnsurePublished(ctx) return } diff --git a/internal/runtime/executor/helps/usage_helpers.go b/internal/runtime/executor/helps/usage_helpers.go index d3093de18c..c5e258c86b 100644 --- a/internal/runtime/executor/helps/usage_helpers.go +++ b/internal/runtime/executor/helps/usage_helpers.go @@ -370,12 +370,22 @@ func parseGeminiFamilyUsageDetail(node gjson.Result) usage.Detail { return detail } +func hasGeminiFamilyUsageTokenFields(node gjson.Result) bool { + return node.Get("promptTokenCount").Exists() || + node.Get("candidatesTokenCount").Exists() || + node.Get("thoughtsTokenCount").Exists() || + node.Get("totalTokenCount").Exists() || + node.Get("cachedContentTokenCount").Exists() +} + func ParseGeminiCLIUsage(data []byte) usage.Detail { usageNode := gjson.ParseBytes(data) - node := usageNode.Get("response.usageMetadata") - if !node.Exists() { - node = usageNode.Get("response.usage_metadata") - } + node := firstExistingUsageNode(usageNode, + "response.usageMetadata", + "response.usage_metadata", + "usageMetadata", + "usage_metadata", + ) if !node.Exists() { return usage.Detail{} } @@ -414,16 +424,32 @@ func ParseGeminiCLIStreamUsage(line []byte) (usage.Detail, bool) { if len(payload) == 0 || !gjson.ValidBytes(payload) { return usage.Detail{}, false } - node := gjson.GetBytes(payload, "response.usageMetadata") + root := gjson.ParseBytes(payload) + node := firstExistingUsageNode(root, + "response.usageMetadata", + "response.usage_metadata", + "usageMetadata", + "usage_metadata", + ) if !node.Exists() { - node = gjson.GetBytes(payload, "usage_metadata") + return usage.Detail{}, false } - if !node.Exists() { + if !hasGeminiFamilyUsageTokenFields(node) { return usage.Detail{}, false } return parseGeminiFamilyUsageDetail(node), true } +func firstExistingUsageNode(root gjson.Result, paths ...string) gjson.Result { + for _, path := range paths { + node := root.Get(path) + if node.Exists() { + return node + } + } + return gjson.Result{} +} + func ParseAntigravityUsage(data []byte) usage.Detail { usageNode := gjson.ParseBytes(data) node := usageNode.Get("response.usageMetadata") diff --git a/internal/runtime/executor/helps/usage_helpers_test.go b/internal/runtime/executor/helps/usage_helpers_test.go index 3708b73175..c77335fd63 100644 --- a/internal/runtime/executor/helps/usage_helpers_test.go +++ b/internal/runtime/executor/helps/usage_helpers_test.go @@ -47,6 +47,50 @@ func TestParseOpenAIUsageResponses(t *testing.T) { } } +func TestParseGeminiCLIUsage_TopLevelUsageMetadata(t *testing.T) { + data := []byte(`{"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":7,"thoughtsTokenCount":3,"totalTokenCount":21,"cachedContentTokenCount":5}}`) + detail := ParseGeminiCLIUsage(data) + if detail.InputTokens != 11 { + t.Fatalf("input tokens = %d, want %d", detail.InputTokens, 11) + } + if detail.OutputTokens != 7 { + t.Fatalf("output tokens = %d, want %d", detail.OutputTokens, 7) + } + if detail.ReasoningTokens != 3 { + t.Fatalf("reasoning tokens = %d, want %d", detail.ReasoningTokens, 3) + } + if detail.TotalTokens != 21 { + t.Fatalf("total tokens = %d, want %d", detail.TotalTokens, 21) + } + if detail.CachedTokens != 5 { + t.Fatalf("cached tokens = %d, want %d", detail.CachedTokens, 5) + } +} + +func TestParseGeminiCLIStreamUsage_ResponseSnakeCaseUsageMetadata(t *testing.T) { + line := []byte(`data: {"response":{"usage_metadata":{"promptTokenCount":13,"candidatesTokenCount":2,"totalTokenCount":15}}}`) + detail, ok := ParseGeminiCLIStreamUsage(line) + if !ok { + t.Fatal("ParseGeminiCLIStreamUsage() ok = false, want true") + } + if detail.InputTokens != 13 { + t.Fatalf("input tokens = %d, want %d", detail.InputTokens, 13) + } + if detail.OutputTokens != 2 { + t.Fatalf("output tokens = %d, want %d", detail.OutputTokens, 2) + } + if detail.TotalTokens != 15 { + t.Fatalf("total tokens = %d, want %d", detail.TotalTokens, 15) + } +} + +func TestParseGeminiCLIStreamUsage_IgnoresTrafficTypeOnlyUsageMetadata(t *testing.T) { + line := []byte(`data: {"response":{"usageMetadata":{"trafficType":"ON_DEMAND"}}}`) + if detail, ok := ParseGeminiCLIStreamUsage(line); ok { + t.Fatalf("ParseGeminiCLIStreamUsage() = (%+v, true), want false for traffic-only usage metadata", detail) + } +} + func TestUsageReporterBuildRecordIncludesLatency(t *testing.T) { reporter := &UsageReporter{ provider: "openai", From 1c0c426b85cd9c16742f1f2297ac51ef36841fc4 Mon Sep 17 00:00:00 2001 From: sususu98 Date: Wed, 29 Apr 2026 18:47:03 +0800 Subject: [PATCH 071/139] fix: align claude codex translation --- .../codex/claude/codex_claude_request.go | 100 +++++++-- .../codex/claude/codex_claude_request_test.go | 112 ++++++++++ .../codex/claude/codex_claude_response.go | 74 +++++-- .../claude/codex_claude_response_test.go | 204 ++++++++++++++++++ 4 files changed, 454 insertions(+), 36 deletions(-) diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index 239c3e4d16..85d2b3e224 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -40,6 +40,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) template := []byte(`{"model":"","instructions":"","input":[]}`) rootResult := gjson.ParseBytes(rawJSON) + toolNameMap := buildReverseMapFromClaudeOriginalToShort(rawJSON) template, _ = sjson.SetBytes(template, "model", modelName) // Process system messages and convert them to input content format. @@ -174,8 +175,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) functionCallMessage, _ = sjson.SetBytes(functionCallMessage, "call_id", messageContentResult.Get("id").String()) { name := messageContentResult.Get("name").String() - toolMap := buildReverseMapFromClaudeOriginalToShort(rawJSON) - if short, ok := toolMap[name]; ok { + if short, ok := toolNameMap[name]; ok { name = short } else { name = shortenNameIfNeeded(name) @@ -249,23 +249,14 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) toolsResult := rootResult.Get("tools") if toolsResult.IsArray() { template, _ = sjson.SetRawBytes(template, "tools", []byte(`[]`)) - template, _ = sjson.SetBytes(template, "tool_choice", `auto`) + webSearchToolNames := buildClaudeWebSearchToolNameSet(toolsResult) + template, _ = sjson.SetRawBytes(template, "tool_choice", convertClaudeToolChoiceToCodex(rootResult.Get("tool_choice"), toolNameMap, webSearchToolNames)) toolResults := toolsResult.Array() - // Build short name map from declared tools - var names []string - for i := 0; i < len(toolResults); i++ { - n := toolResults[i].Get("name").String() - if n != "" { - names = append(names, n) - } - } - shortMap := buildShortNameMap(names) for i := 0; i < len(toolResults); i++ { toolResult := toolResults[i] // Special handling: map Claude web search tool to Codex web_search - if toolResult.Get("type").String() == "web_search_20250305" { - // Replace the tool content entirely with {"type":"web_search"} - template, _ = sjson.SetRawBytes(template, "tools.-1", []byte(`{"type":"web_search"}`)) + if isClaudeWebSearchToolType(toolResult.Get("type").String()) { + template, _ = sjson.SetRawBytes(template, "tools.-1", convertClaudeWebSearchToolToCodex(toolResult)) continue } tool := []byte(toolResult.Raw) @@ -273,7 +264,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) // Apply shortened name if needed if v := toolResult.Get("name"); v.Exists() { name := v.String() - if short, ok := shortMap[name]; ok { + if short, ok := toolNameMap[name]; ok { name = short } else { name = shortenNameIfNeeded(name) @@ -370,6 +361,83 @@ func isFernetLikeReasoningSignature(signature string) bool { return ciphertextLen > 0 && ciphertextLen%aesBlockSize == 0 } +func isClaudeWebSearchToolType(toolType string) bool { + return toolType == "web_search_20250305" || toolType == "web_search_20260209" +} + +func buildClaudeWebSearchToolNameSet(tools gjson.Result) map[string]struct{} { + names := map[string]struct{}{} + if !tools.IsArray() { + return names + } + + tools.ForEach(func(_, tool gjson.Result) bool { + toolType := tool.Get("type").String() + if !isClaudeWebSearchToolType(toolType) { + return true + } + + names["web_search"] = struct{}{} + names[toolType] = struct{}{} + if name := tool.Get("name").String(); name != "" { + names[name] = struct{}{} + } + return true + }) + + return names +} + +func convertClaudeToolChoiceToCodex(toolChoice gjson.Result, toolNameMap map[string]string, webSearchToolNames map[string]struct{}) []byte { + if !toolChoice.Exists() || toolChoice.Type == gjson.Null { + return []byte(`"auto"`) + } + + choiceType := toolChoice.Get("type").String() + if choiceType == "" && toolChoice.Type == gjson.String { + choiceType = toolChoice.String() + } + + switch choiceType { + case "auto", "": + return []byte(`"auto"`) + case "any": + return []byte(`"required"`) + case "none": + return []byte(`"none"`) + case "tool": + name := toolChoice.Get("name").String() + if _, ok := webSearchToolNames[name]; ok { + return []byte(`{"type":"web_search"}`) + } + if short, ok := toolNameMap[name]; ok { + name = short + } else { + name = shortenNameIfNeeded(name) + } + if name == "" { + return []byte(`"auto"`) + } + + choice := []byte(`{"type":"function","name":""}`) + choice, _ = sjson.SetBytes(choice, "name", name) + return choice + default: + return []byte(`"auto"`) + } +} + +func convertClaudeWebSearchToolToCodex(tool gjson.Result) []byte { + out := []byte(`{"type":"web_search"}`) + if allowedDomains := tool.Get("allowed_domains"); allowedDomains.Exists() && allowedDomains.IsArray() { + out, _ = sjson.SetRawBytes(out, "filters.allowed_domains", []byte(allowedDomains.Raw)) + } + if userLocation := tool.Get("user_location"); userLocation.Exists() && userLocation.IsObject() { + out, _ = sjson.SetRawBytes(out, "user_location", []byte(userLocation.Raw)) + } + return out +} + // shortenNameIfNeeded applies a simple shortening rule for a single name. func shortenNameIfNeeded(name string) string { const limit = 64 diff --git a/internal/translator/codex/claude/codex_claude_request_test.go b/internal/translator/codex/claude/codex_claude_request_test.go index 85d10267f4..4866b470e7 100644 --- a/internal/translator/codex/claude/codex_claude_request_test.go +++ b/internal/translator/codex/claude/codex_claude_request_test.go @@ -136,6 +136,118 @@ func TestConvertClaudeRequestToCodex_ParallelToolCalls(t *testing.T) { } } +func TestConvertClaudeRequestToCodex_ToolChoiceModeMapping(t *testing.T) { + tests := []struct { + name string + claudeToolChoice string + wantCodexToolChoice string + }{ + { + name: "Any requires at least one tool", + claudeToolChoice: `{"type":"any"}`, + wantCodexToolChoice: "required", + }, + { + name: "None disables tools", + claudeToolChoice: `{"type":"none"}`, + wantCodexToolChoice: "none", + }, + { + name: "Auto stays auto", + claudeToolChoice: `{"type":"auto"}`, + wantCodexToolChoice: "auto", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inputJSON := `{ + "model": "claude-3-opus", + "tools": [ + {"name": "lookup", "description": "Lookup", "input_schema": {"type":"object","properties":{}}} + ], + "tool_choice": ` + tt.claudeToolChoice + `, + "messages": [{"role": "user", "content": "hello"}] + }` + + result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + + if got := resultJSON.Get("tool_choice").String(); got != tt.wantCodexToolChoice { + t.Fatalf("tool_choice = %q, want %q. Output: %s", got, tt.wantCodexToolChoice, string(result)) + } + }) + } +} + +func TestConvertClaudeRequestToCodex_ToolChoiceSpecificFunctionUsesConvertedName(t *testing.T) { + longName := "mcp__server_with_a_very_long_name_that_exceeds_sixty_four_characters__search" + inputJSON := `{ + "model": "claude-3-opus", + "tools": [ + {"name": "` + longName + `", "description": "Search", "input_schema": {"type":"object","properties":{}}} + ], + "tool_choice": {"type":"tool","name":"` + longName + `"}, + "messages": [{"role": "user", "content": "hello"}] + }` + + result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + + if got := resultJSON.Get("tool_choice.type").String(); got != "function" { + t.Fatalf("tool_choice.type = %q, want function. Output: %s", got, string(result)) + } + toolName := resultJSON.Get("tools.0.name").String() + choiceName := resultJSON.Get("tool_choice.name").String() + if choiceName != toolName { + t.Fatalf("tool_choice.name = %q, want converted tool name %q. Output: %s", choiceName, toolName, string(result)) + } + if choiceName == longName { + t.Fatalf("tool_choice.name should use shortened Codex tool name. Output: %s", string(result)) + } +} + +func TestConvertClaudeRequestToCodex_WebSearchToolMapping(t *testing.T) { + inputJSON := `{ + "model": "claude-3-opus", + "tools": [ + { + "type": "web_search_20260209", + "name": "web_search", + "allowed_domains": ["example.com"], + "blocked_domains": ["blocked.example"], + "user_location": { + "type": "approximate", + "city": "Beijing", + "country": "CN", + "timezone": "Asia/Shanghai" + } + } + ], + "tool_choice": {"type":"tool","name":"web_search"}, + "messages": [{"role": "user", "content": "hello"}] + }` + + result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + + if got := resultJSON.Get("tools.0.type").String(); got != "web_search" { + t.Fatalf("tools.0.type = %q, want web_search. Output: %s", got, string(result)) + } + if got := resultJSON.Get("tools.0.filters.allowed_domains.0").String(); got != "example.com" { + t.Fatalf("tools.0.filters.allowed_domains.0 = %q, want example.com. Output: %s", got, string(result)) + } + if resultJSON.Get("tools.0.blocked_domains").Exists() { + t.Fatalf("tools.0.blocked_domains should not be forwarded to Codex. Output: %s", string(result)) + } + if got := resultJSON.Get("tools.0.user_location.city").String(); got != "Beijing" { + t.Fatalf("tools.0.user_location.city = %q, want Beijing. Output: %s", got, string(result)) + } + if got := resultJSON.Get("tool_choice.type").String(); got != "web_search" { + t.Fatalf("tool_choice.type = %q, want web_search. Output: %s", got, string(result)) + } +} + func TestConvertClaudeRequestToCodex_AssistantThinkingSignatureToReasoningItem(t *testing.T) { signature := validCodexReasoningSignature() inputJSON := `{ diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index e48a56f8b7..a401a1b7e5 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -68,7 +68,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa params := (*param).(*ConvertCodexResponseToClaudeParams) if params.ThinkingBlockOpen && params.ThinkingStopPending { switch rootResult.Get("type").String() { - case "response.content_part.added", "response.completed": + case "response.content_part.added", "response.completed", "response.incomplete": output = append(output, finalizeCodexThinkingBlock(params)...) } } @@ -117,18 +117,12 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa params.BlockIndex++ output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2) - } else if typeStr == "response.completed" { + } else if typeStr == "response.completed" || typeStr == "response.incomplete" { template = []byte(`{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`) - p := params.HasToolCall - stopReason := rootResult.Get("response.stop_reason").String() - if p { - template, _ = sjson.SetBytes(template, "delta.stop_reason", "tool_use") - } else if stopReason == "max_tokens" || stopReason == "stop" { - template, _ = sjson.SetBytes(template, "delta.stop_reason", stopReason) - } else { - template, _ = sjson.SetBytes(template, "delta.stop_reason", "end_turn") - } - inputTokens, outputTokens, cachedTokens := extractResponsesUsage(rootResult.Get("response.usage")) + responseData := rootResult.Get("response") + template, _ = sjson.SetBytes(template, "delta.stop_reason", mapCodexStopReasonToClaude(codexStopReason(responseData), params.HasToolCall)) + template = setClaudeStopSequence(template, "delta.stop_sequence", responseData) + inputTokens, outputTokens, cachedTokens := extractResponsesUsage(responseData.Get("usage")) template, _ = sjson.SetBytes(template, "usage.input_tokens", inputTokens) template, _ = sjson.SetBytes(template, "usage.output_tokens", outputTokens) if cachedTokens > 0 { @@ -259,7 +253,8 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original revNames := buildReverseMapFromClaudeOriginalShortToOriginal(originalRequestRawJSON) rootResult := gjson.ParseBytes(rawJSON) - if rootResult.Get("type").String() != "response.completed" { + typeStr := rootResult.Get("type").String() + if typeStr != "response.completed" && typeStr != "response.incomplete" { return []byte{} } @@ -371,18 +366,57 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original }) } + out, _ = sjson.SetBytes(out, "stop_reason", mapCodexStopReasonToClaude(codexStopReason(responseData), hasToolCall)) + out = setClaudeStopSequence(out, "stop_sequence", responseData) + + return out +} + +func codexStopReason(responseData gjson.Result) string { if stopReason := responseData.Get("stop_reason"); stopReason.Exists() && stopReason.String() != "" { - out, _ = sjson.SetBytes(out, "stop_reason", stopReason.String()) - } else if hasToolCall { - out, _ = sjson.SetBytes(out, "stop_reason", "tool_use") - } else { - out, _ = sjson.SetBytes(out, "stop_reason", "end_turn") + if stopReason.String() == "stop" && codexStopSequence(responseData).String() != "" { + return "stop_sequence" + } + return stopReason.String() + } + if reason := responseData.Get("incomplete_details.reason"); reason.Exists() && reason.String() != "" { + return reason.String() + } + if codexStopSequence(responseData).String() != "" { + return "stop_sequence" + } + return "" +} + +func mapCodexStopReasonToClaude(stopReason string, hasToolCall bool) string { + if hasToolCall { + return "tool_use" } - if stopSequence := responseData.Get("stop_sequence"); stopSequence.Exists() && stopSequence.String() != "" { - out, _ = sjson.SetRawBytes(out, "stop_sequence", []byte(stopSequence.Raw)) + switch stopReason { + case "", "stop", "completed": + return "end_turn" + case "max_tokens", "max_output_tokens": + return "max_tokens" + case "tool_use", "tool_calls", "function_call": + return "tool_use" + case "end_turn", "stop_sequence", "pause_turn", "refusal", "model_context_window_exceeded": + return stopReason + case "content_filter": + return "refusal" + default: + return "end_turn" } +} + +func codexStopSequence(responseData gjson.Result) gjson.Result { + return responseData.Get("stop_sequence") +} +func setClaudeStopSequence(out []byte, path string, responseData gjson.Result) []byte { + if stopSequence := codexStopSequence(responseData); stopSequence.Exists() && stopSequence.String() != "" { + out, _ = sjson.SetRawBytes(out, path, []byte(stopSequence.Raw)) + } return out } diff --git a/internal/translator/codex/claude/codex_claude_response_test.go b/internal/translator/codex/claude/codex_claude_response_test.go index bbd71da085..565e8156bb 100644 --- a/internal/translator/codex/claude/codex_claude_response_test.go +++ b/internal/translator/codex/claude/codex_claude_response_test.go @@ -458,3 +458,207 @@ func TestConvertCodexResponseToClaude_StreamEmptyOutputUsesOutputItemDoneMessage t.Fatalf("expected fallback content from response.output_item.done message; outputs=%q", outputs) } } + +func TestConvertCodexResponseToClaude_StreamStopReasonMapping(t *testing.T) { + tests := []struct { + name string + chunks [][]byte + wantReason string + }{ + { + name: "Stop maps to end_turn", + chunks: [][]byte{ + []byte("data: {\"type\":\"response.completed\",\"response\":{\"stop_reason\":\"stop\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"), + }, + wantReason: "end_turn", + }, + { + name: "Incomplete max output maps to max_tokens", + chunks: [][]byte{ + []byte("data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"max_output_tokens\"},\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"), + }, + wantReason: "max_tokens", + }, + { + name: "Tool call wins over stop", + chunks: [][]byte{ + []byte("data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"call_id\":\"call_1\",\"name\":\"lookup\"}}"), + []byte("data: {\"type\":\"response.completed\",\"response\":{\"stop_reason\":\"stop\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"), + }, + wantReason: "tool_use", + }, + { + name: "Content filter maps to Claude refusal", + chunks: [][]byte{ + []byte("data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"content_filter\"},\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"), + }, + wantReason: "refusal", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"tools":[{"name":"lookup","input_schema":{"type":"object","properties":{}}}]}`) + var param any + var outputs [][]byte + + for _, chunk := range tt.chunks { + outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, ¶m)...) + } + + got, ok := findClaudeStreamStopReason(outputs) + if !ok { + t.Fatalf("did not find message_delta stop_reason; outputs=%q", outputs) + } + if got != tt.wantReason { + t.Fatalf("stop_reason = %q, want %q. Outputs=%q", got, tt.wantReason, outputs) + } + }) + } +} + +func TestConvertCodexResponseToClaude_StreamStopSequenceMapping(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"messages":[]}`) + var param any + + outputs := ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, []byte("data: {\"type\":\"response.completed\",\"response\":{\"stop_reason\":\"stop\",\"stop_sequence\":\"\\nEND\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"), ¶m) + messageDelta, ok := findClaudeStreamMessageDelta(outputs) + if !ok { + t.Fatalf("did not find message_delta; outputs=%q", outputs) + } + if got := messageDelta.Get("delta.stop_reason").String(); got != "stop_sequence" { + t.Fatalf("stop_reason = %q, want stop_sequence. Outputs=%q", got, outputs) + } + if got := messageDelta.Get("delta.stop_sequence").String(); got != "\nEND" { + t.Fatalf("stop_sequence = %q, want newline END. Outputs=%q", got, outputs) + } +} + +func TestConvertCodexResponseToClaudeNonStream_StopReasonMapping(t *testing.T) { + tests := []struct { + name string + response []byte + wantReason string + }{ + { + name: "Stop maps to end_turn", + response: []byte(`{ + "type":"response.completed", + "response":{ + "id":"resp_1", + "model":"gpt-5", + "stop_reason":"stop", + "usage":{"input_tokens":1,"output_tokens":1}, + "output":[] + } + }`), + wantReason: "end_turn", + }, + { + name: "Incomplete max output maps to max_tokens", + response: []byte(`{ + "type":"response.incomplete", + "response":{ + "id":"resp_1", + "model":"gpt-5", + "incomplete_details":{"reason":"max_output_tokens"}, + "usage":{"input_tokens":1,"output_tokens":1}, + "output":[] + } + }`), + wantReason: "max_tokens", + }, + { + name: "Tool call wins over stop", + response: []byte(`{ + "type":"response.completed", + "response":{ + "id":"resp_1", + "model":"gpt-5", + "stop_reason":"stop", + "usage":{"input_tokens":1,"output_tokens":1}, + "output":[{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"}] + } + }`), + wantReason: "tool_use", + }, + { + name: "Content filter maps to Claude refusal", + response: []byte(`{ + "type":"response.incomplete", + "response":{ + "id":"resp_1", + "model":"gpt-5", + "incomplete_details":{"reason":"content_filter"}, + "usage":{"input_tokens":1,"output_tokens":1}, + "output":[] + } + }`), + wantReason: "refusal", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"tools":[{"name":"lookup","input_schema":{"type":"object","properties":{}}}]}`) + out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, tt.response, nil) + parsed := gjson.ParseBytes(out) + + if got := parsed.Get("stop_reason").String(); got != tt.wantReason { + t.Fatalf("stop_reason = %q, want %q. Output: %s", got, tt.wantReason, string(out)) + } + }) + } +} + +func TestConvertCodexResponseToClaudeNonStream_StopSequenceMapping(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"messages":[]}`) + response := []byte(`{ + "type":"response.completed", + "response":{ + "id":"resp_1", + "model":"gpt-5", + "stop_reason":"stop", + "stop_sequence":"\nEND", + "usage":{"input_tokens":1,"output_tokens":1}, + "output":[] + } + }`) + + out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, response, nil) + parsed := gjson.ParseBytes(out) + + if got := parsed.Get("stop_reason").String(); got != "stop_sequence" { + t.Fatalf("stop_reason = %q, want stop_sequence. Output: %s", got, string(out)) + } + if got := parsed.Get("stop_sequence").String(); got != "\nEND" { + t.Fatalf("stop_sequence = %q, want newline END. Output: %s", got, string(out)) + } +} + +func findClaudeStreamStopReason(outputs [][]byte) (string, bool) { + messageDelta, ok := findClaudeStreamMessageDelta(outputs) + if !ok { + return "", false + } + return messageDelta.Get("delta.stop_reason").String(), true +} + +func findClaudeStreamMessageDelta(outputs [][]byte) (gjson.Result, bool) { + for _, out := range outputs { + for _, line := range strings.Split(string(out), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + data := gjson.Parse(strings.TrimPrefix(line, "data: ")) + if data.Get("type").String() == "message_delta" { + return data, true + } + } + } + return gjson.Result{}, false +} From 0d107dd566e4e7992f3fe3b56e2b3dba6812810b Mon Sep 17 00:00:00 2001 From: sususu98 Date: Wed, 29 Apr 2026 19:24:53 +0800 Subject: [PATCH 072/139] fix: respect declared claude web search tool names --- .../codex/claude/codex_claude_request.go | 2 -- .../codex/claude/codex_claude_request_test.go | 22 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index 85d2b3e224..1e168f0993 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -377,8 +377,6 @@ func buildClaudeWebSearchToolNameSet(tools gjson.Result) map[string]struct{} { return true } - names["web_search"] = struct{}{} - names[toolType] = struct{}{} if name := tool.Get("name").String(); name != "" { names[name] = struct{}{} } diff --git a/internal/translator/codex/claude/codex_claude_request_test.go b/internal/translator/codex/claude/codex_claude_request_test.go index 4866b470e7..16bb46c9ef 100644 --- a/internal/translator/codex/claude/codex_claude_request_test.go +++ b/internal/translator/codex/claude/codex_claude_request_test.go @@ -248,6 +248,28 @@ func TestConvertClaudeRequestToCodex_WebSearchToolMapping(t *testing.T) { } } +func TestConvertClaudeRequestToCodex_WebSearchToolChoiceUsesDeclaredTypedToolName(t *testing.T) { + inputJSON := `{ + "model": "claude-opus-4-7", + "tools": [ + {"type": "web_search_20250305", "name": "browser_search"}, + {"name": "web_search", "description": "Local search", "input_schema": {"type":"object","properties":{}}} + ], + "tool_choice": {"type":"tool","name":"web_search"}, + "messages": [{"role": "user", "content": "hello"}] + }` + + result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + + if got := resultJSON.Get("tool_choice.type").String(); got != "function" { + t.Fatalf("tool_choice.type = %q, want function. Output: %s", got, string(result)) + } + if got := resultJSON.Get("tool_choice.name").String(); got != "web_search" { + t.Fatalf("tool_choice.name = %q, want web_search. Output: %s", got, string(result)) + } +} + func TestConvertClaudeRequestToCodex_AssistantThinkingSignatureToReasoningItem(t *testing.T) { signature := validCodexReasoningSignature() inputJSON := `{ From 359ec30d0c5674659d9d73080de378f9a7417c4a Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 29 Apr 2026 23:13:12 +0800 Subject: [PATCH 073/139] chore(docs): remove LingtrueAPI sponsorship section from README files --- README.md | 4 ---- README_CN.md | 4 ---- README_JA.md | 4 ---- 3 files changed, 12 deletions(-) diff --git a/README.md b/README.md index 049f9c4b5c..70f5a0441a 100644 --- a/README.md +++ b/README.md @@ -35,10 +35,6 @@ Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB Huge thanks to BmoPlus for sponsoring this project! BmoPlus is a highly reliable AI account provider built strictly for heavy AI users and developers. They offer rock-solid, ready-to-use accounts and official top-up services for ChatGPT Plus / ChatGPT Pro (Full Warranty) / Claude Pro / Super Grok / Gemini Pro. By registering and ordering through BmoPlus - Premium AI Accounts & Top-ups, users can unlock the mind-blowing rate of 10% of the official GPT subscription price (90% OFF)! -LingtrueAPI -Thanks to LingtrueAPI for its sponsorship of this project! LingtrueAPI is a global large - model API intermediary service platform that provides API calling services for various top - notch models such as Claude Code, Codex, and Gemini. It is committed to enabling users to connect to global AI capabilities at low cost and with high stability. LingtrueAPI offers special discounts to users of this software: register using this link, and enter the promo code "LingtrueAPI" when making the first recharge to enjoy a 10% discount. - - PoixeAI Thanks to Poixe AI for sponsoring this project! Poixe AI provides reliable LLM API services. You can leverage the platform's API endpoints to seamlessly build AI-powered products. Additionally, you can become a vendor by providing AI API resources to the platform and earn revenue. Register through the exclusive CLIProxyAPI referral link and receive a bonus of $5 USD on your first top-up. diff --git a/README_CN.md b/README_CN.md index 7770786288..e08e4ed1d9 100644 --- a/README_CN.md +++ b/README_CN.md @@ -35,10 +35,6 @@ GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元 感谢 BmoPlus 赞助了本项目!BmoPlus 是一家专为AI订阅重度用户打造的可靠 AI 账号代充服务商,提供稳定的 ChatGPT Plus / ChatGPT Pro(全程质保) / Claude Pro / Super Grok / Gemini Pro 的官方代充&成品账号。 通过BmoPlus AI成品号专卖/代充注册下单的用户,可享GPT 官网订阅一折 的震撼价格! -LingtrueAPI -感谢 LingtrueAPI 对本项目的赞助!LingtrueAPI 是一家全球大模型API中转服务平台,提供Claude Code、Codex、Gemini 等多种顶级模型API调用服务,致力于让用户以低成本、高稳定性链接全球AI能力。LingtrueAPI为本软件用户提供了特别优惠:使用此链接注册,并在首次充值时输入 "LingtrueAPI" 优惠码即可享受9折优惠。 - - PoixeAI 感谢 Poixe AI 对本项目的赞助!Poixe AI 提供可靠的 AI 模型接口服务,您可以使用平台提供的 LLM API 接口轻松构建 AI 产品,同时也可以成为供应商,为平台提供大模型资源以赚取收益。通过 CLIProxyAPI 专属链接注册,充值额外赠送 $5 美金 diff --git a/README_JA.md b/README_JA.md index b7a2c153d3..6360320c2f 100644 --- a/README_JA.md +++ b/README_JA.md @@ -35,10 +35,6 @@ GLM CODING PLANを10%割引で取得:https://z.ai/subscribe?ic=8JVLJQFSKB 本プロジェクトにご支援いただいた BmoPlus に感謝いたします!BmoPlusは、AIサブスクリプションのヘビーユーザー向けに特化した信頼性の高いAIアカウントサービスプロバイダーであり、安定した ChatGPT Plus / ChatGPT Pro (完全保証) / Claude Pro / Super Grok / Gemini Pro の公式代行チャージおよび即納アカウントを提供しています。こちらのBmoPlus AIアカウント専門店/代行チャージ経由でご登録・ご注文いただいたユーザー様は、GPTを 公式サイト価格の約1割(90% OFF) という驚異的な価格でご利用いただけます! -LingtrueAPI -LingtrueAPIのスポンサーシップに感謝します!LingtrueAPIはグローバルな大規模モデルAPIリレーサービスプラットフォームで、Claude Code、Codex、GeminiなどのトップモデルAPI呼び出しサービスを提供し、ユーザーが低コストかつ高い安定性で世界中のAI能力に接続できるよう支援しています。LingtrueAPIは本ソフトウェアのユーザーに特別割引を提供しています:こちらのリンクから登録し、初回チャージ時にプロモーションコード「LingtrueAPI」を入力すると10%割引になります。 - - PoixeAI Poixe AIのスポンサーシップに感謝します!Poixe AIは信頼できるAIモデルAPIサービスを提供しており、プラットフォームが提供するLLM APIを使って簡単にAI製品を構築できます。また、サプライヤーとしてプラットフォームに大規模モデルのリソースを提供し、収益を得ることも可能です。CLIProxyAPIの専用リンクから登録すると、チャージ時に追加で$5が付与されます。 From e3e60f914ba82a6caa7a17a717f65a3b2f02285f Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 30 Apr 2026 03:42:27 +0800 Subject: [PATCH 074/139] feat: support disabling image generation globally - Added `disable-image-generation` configuration flag to disable the `image_generation` tool globally. - Updated payload handling to remove `image_generation` tools from request payload arrays when the flag is enabled. - Modified OpenAI image handlers (`ImagesGenerations`, `ImagesEdits`) to return 404 when the feature is disabled. - Enhanced configuration diff logging to track changes for the `disable-image-generation` flag. - Added accompanying unit tests for the new feature in payload helpers and image handler logic. --- config.example.yaml | 4 + internal/api/server.go | 4 + internal/config/config.go | 1 + internal/config/sdk_config.go | 6 + internal/runtime/executor/codex_executor.go | 12 +- .../runtime/executor/helps/payload_helpers.go | 280 ++++++++++-------- ...d_helpers_disable_image_generation_test.go | 50 ++++ internal/watcher/diff/config_diff.go | 3 + internal/watcher/diff/config_diff_test.go | 10 +- .../handlers/openai/openai_images_handlers.go | 10 + .../openai/openai_images_handlers_test.go | 26 ++ 11 files changed, 282 insertions(+), 124 deletions(-) create mode 100644 internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go diff --git a/config.example.yaml b/config.example.yaml index 24e3d99c83..772a6416eb 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -90,6 +90,10 @@ max-retry-interval: 30 # When true, disable auth/model cooldown scheduling globally (prevents blackout windows after failure states). disable-cooling: false +# When true, disable the built-in image_generation tool globally. +# The server will stop injecting image_generation and will also remove it from request payload tools arrays. +disable-image-generation: false + # Core auth auto-refresh worker pool size (OAuth/file-based auth token refresh). # When > 0, overrides the default worker count (16). # auth-auto-refresh-workers: 16 diff --git a/internal/api/server.go b/internal/api/server.go index f817ac309b..c414e10a1a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1013,6 +1013,10 @@ func (s *Server) UpdateClients(cfg *config.Config) { auth.SetQuotaCooldownDisabled(cfg.DisableCooling) } + if oldCfg != nil && oldCfg.DisableImageGeneration != cfg.DisableImageGeneration { + log.Infof("disable-image-generation updated: %t -> %t", oldCfg.DisableImageGeneration, cfg.DisableImageGeneration) + } + applySignatureCacheConfig(oldCfg, cfg) if s.handlers != nil && s.handlers.AuthManager != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 1ee7aed536..c30593f673 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -610,6 +610,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { cfg.ErrorLogsMaxFiles = 10 cfg.UsageStatisticsEnabled = false cfg.DisableCooling = false + cfg.DisableImageGeneration = false cfg.Pprof.Enable = false cfg.Pprof.Addr = DefaultPprofAddr cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient diff --git a/internal/config/sdk_config.go b/internal/config/sdk_config.go index aa27526d1e..752f53aa9c 100644 --- a/internal/config/sdk_config.go +++ b/internal/config/sdk_config.go @@ -9,6 +9,12 @@ type SDKConfig struct { // ProxyURL is the URL of an optional proxy server to use for outbound requests. ProxyURL string `yaml:"proxy-url" json:"proxy-url"` + // DisableImageGeneration disables the built-in image_generation tool when true. + // When enabled, the server will avoid injecting image_generation into request payloads, + // will remove any existing image_generation tool entries from tools arrays, and will + // return 404 for /v1/images/generations and /v1/images/edits. + DisableImageGeneration bool `yaml:"disable-image-generation" json:"disable-image-generation"` + // EnableGeminiCLIEndpoint controls whether Gemini CLI internal endpoints (/v1internal:*) are enabled. // Default is false for safety; when false, /v1internal:* requests are rejected. EnableGeminiCLIEndpoint bool `yaml:"enable-gemini-cli-endpoint" json:"enable-gemini-cli-endpoint"` diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 2a01c7ac07..1948beac44 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -181,7 +181,9 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re body, _ = sjson.DeleteBytes(body, "safety_identifier") body, _ = sjson.DeleteBytes(body, "stream_options") body = normalizeCodexInstructions(body) - body = ensureImageGenerationTool(body, baseModel, auth) + if e.cfg == nil || !e.cfg.DisableImageGeneration { + body = ensureImageGenerationTool(body, baseModel, auth) + } url := strings.TrimSuffix(baseURL, "/") + "/responses" httpReq, err := e.cacheHelper(ctx, from, url, req, body) @@ -329,7 +331,9 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A body, _ = sjson.SetBytes(body, "model", baseModel) body, _ = sjson.DeleteBytes(body, "stream") body = normalizeCodexInstructions(body) - body = ensureImageGenerationTool(body, baseModel, auth) + if e.cfg == nil || !e.cfg.DisableImageGeneration { + body = ensureImageGenerationTool(body, baseModel, auth) + } url := strings.TrimSuffix(baseURL, "/") + "/responses/compact" httpReq, err := e.cacheHelper(ctx, from, url, req, body) @@ -424,7 +428,9 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au body, _ = sjson.DeleteBytes(body, "stream_options") body, _ = sjson.SetBytes(body, "model", baseModel) body = normalizeCodexInstructions(body) - body = ensureImageGenerationTool(body, baseModel, auth) + if e.cfg == nil || !e.cfg.DisableImageGeneration { + body = ensureImageGenerationTool(body, baseModel, auth) + } url := strings.TrimSuffix(baseURL, "/") + "/responses" httpReq, err := e.cacheHelper(ctx, from, url, req, body) diff --git a/internal/runtime/executor/helps/payload_helpers.go b/internal/runtime/executor/helps/payload_helpers.go index 73514c2dd1..b868d445a9 100644 --- a/internal/runtime/executor/helps/payload_helpers.go +++ b/internal/runtime/executor/helps/payload_helpers.go @@ -20,133 +20,137 @@ func ApplyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string if cfg == nil || len(payload) == 0 { return payload } - rules := cfg.Payload - if len(rules.Default) == 0 && len(rules.DefaultRaw) == 0 && len(rules.Override) == 0 && len(rules.OverrideRaw) == 0 && len(rules.Filter) == 0 { - return payload - } - model = strings.TrimSpace(model) - requestedModel = strings.TrimSpace(requestedModel) - if model == "" && requestedModel == "" { - return payload - } - candidates := payloadModelCandidates(model, requestedModel) out := payload - source := original - if len(source) == 0 { - source = payload - } - appliedDefaults := make(map[string]struct{}) - // Apply default rules: first write wins per field across all matching rules. - for i := range rules.Default { - rule := &rules.Default[i] - if !payloadModelRulesMatch(rule.Models, protocol, candidates) { - continue - } - for path, value := range rule.Params { - fullPath := buildPayloadPath(root, path) - if fullPath == "" { - continue - } - if gjson.GetBytes(source, fullPath).Exists() { - continue - } - if _, ok := appliedDefaults[fullPath]; ok { - continue - } - updated, errSet := sjson.SetBytes(out, fullPath, value) - if errSet != nil { - continue - } - out = updated - appliedDefaults[fullPath] = struct{}{} - } - } - // Apply default raw rules: first write wins per field across all matching rules. - for i := range rules.DefaultRaw { - rule := &rules.DefaultRaw[i] - if !payloadModelRulesMatch(rule.Models, protocol, candidates) { - continue - } - for path, value := range rule.Params { - fullPath := buildPayloadPath(root, path) - if fullPath == "" { - continue - } - if gjson.GetBytes(source, fullPath).Exists() { - continue - } - if _, ok := appliedDefaults[fullPath]; ok { - continue - } - rawValue, ok := payloadRawValue(value) - if !ok { - continue - } - updated, errSet := sjson.SetRawBytes(out, fullPath, rawValue) - if errSet != nil { - continue + + rules := cfg.Payload + hasPayloadRules := len(rules.Default) != 0 || len(rules.DefaultRaw) != 0 || len(rules.Override) != 0 || len(rules.OverrideRaw) != 0 || len(rules.Filter) != 0 + if hasPayloadRules { + model = strings.TrimSpace(model) + requestedModel = strings.TrimSpace(requestedModel) + if model != "" || requestedModel != "" { + candidates := payloadModelCandidates(model, requestedModel) + source := original + if len(source) == 0 { + source = payload } - out = updated - appliedDefaults[fullPath] = struct{}{} - } - } - // Apply override rules: last write wins per field across all matching rules. - for i := range rules.Override { - rule := &rules.Override[i] - if !payloadModelRulesMatch(rule.Models, protocol, candidates) { - continue - } - for path, value := range rule.Params { - fullPath := buildPayloadPath(root, path) - if fullPath == "" { - continue + appliedDefaults := make(map[string]struct{}) + // Apply default rules: first write wins per field across all matching rules. + for i := range rules.Default { + rule := &rules.Default[i] + if !payloadModelRulesMatch(rule.Models, protocol, candidates) { + continue + } + for path, value := range rule.Params { + fullPath := buildPayloadPath(root, path) + if fullPath == "" { + continue + } + if gjson.GetBytes(source, fullPath).Exists() { + continue + } + if _, ok := appliedDefaults[fullPath]; ok { + continue + } + updated, errSet := sjson.SetBytes(out, fullPath, value) + if errSet != nil { + continue + } + out = updated + appliedDefaults[fullPath] = struct{}{} + } } - updated, errSet := sjson.SetBytes(out, fullPath, value) - if errSet != nil { - continue + // Apply default raw rules: first write wins per field across all matching rules. + for i := range rules.DefaultRaw { + rule := &rules.DefaultRaw[i] + if !payloadModelRulesMatch(rule.Models, protocol, candidates) { + continue + } + for path, value := range rule.Params { + fullPath := buildPayloadPath(root, path) + if fullPath == "" { + continue + } + if gjson.GetBytes(source, fullPath).Exists() { + continue + } + if _, ok := appliedDefaults[fullPath]; ok { + continue + } + rawValue, ok := payloadRawValue(value) + if !ok { + continue + } + updated, errSet := sjson.SetRawBytes(out, fullPath, rawValue) + if errSet != nil { + continue + } + out = updated + appliedDefaults[fullPath] = struct{}{} + } } - out = updated - } - } - // Apply override raw rules: last write wins per field across all matching rules. - for i := range rules.OverrideRaw { - rule := &rules.OverrideRaw[i] - if !payloadModelRulesMatch(rule.Models, protocol, candidates) { - continue - } - for path, value := range rule.Params { - fullPath := buildPayloadPath(root, path) - if fullPath == "" { - continue + // Apply override rules: last write wins per field across all matching rules. + for i := range rules.Override { + rule := &rules.Override[i] + if !payloadModelRulesMatch(rule.Models, protocol, candidates) { + continue + } + for path, value := range rule.Params { + fullPath := buildPayloadPath(root, path) + if fullPath == "" { + continue + } + updated, errSet := sjson.SetBytes(out, fullPath, value) + if errSet != nil { + continue + } + out = updated + } } - rawValue, ok := payloadRawValue(value) - if !ok { - continue + // Apply override raw rules: last write wins per field across all matching rules. + for i := range rules.OverrideRaw { + rule := &rules.OverrideRaw[i] + if !payloadModelRulesMatch(rule.Models, protocol, candidates) { + continue + } + for path, value := range rule.Params { + fullPath := buildPayloadPath(root, path) + if fullPath == "" { + continue + } + rawValue, ok := payloadRawValue(value) + if !ok { + continue + } + updated, errSet := sjson.SetRawBytes(out, fullPath, rawValue) + if errSet != nil { + continue + } + out = updated + } } - updated, errSet := sjson.SetRawBytes(out, fullPath, rawValue) - if errSet != nil { - continue + // Apply filter rules: remove matching paths from payload. + for i := range rules.Filter { + rule := &rules.Filter[i] + if !payloadModelRulesMatch(rule.Models, protocol, candidates) { + continue + } + for _, path := range rule.Params { + fullPath := buildPayloadPath(root, path) + if fullPath == "" { + continue + } + updated, errDel := sjson.DeleteBytes(out, fullPath) + if errDel != nil { + continue + } + out = updated + } } - out = updated } } - // Apply filter rules: remove matching paths from payload. - for i := range rules.Filter { - rule := &rules.Filter[i] - if !payloadModelRulesMatch(rule.Models, protocol, candidates) { - continue - } - for _, path := range rule.Params { - fullPath := buildPayloadPath(root, path) - if fullPath == "" { - continue - } - updated, errDel := sjson.DeleteBytes(out, fullPath) - if errDel != nil { - continue - } - out = updated - } + + if cfg.DisableImageGeneration { + out = removeToolTypeFromPayloadWithRoot(out, root, "image_generation") } return out } @@ -226,6 +230,46 @@ func buildPayloadPath(root, path string) string { return r + "." + p } +func removeToolTypeFromPayloadWithRoot(payload []byte, root string, toolType string) []byte { + if len(payload) == 0 { + return payload + } + toolType = strings.TrimSpace(toolType) + if toolType == "" { + return payload + } + toolsPath := buildPayloadPath(root, "tools") + return removeToolTypeFromToolsArray(payload, toolsPath, toolType) +} + +func removeToolTypeFromToolsArray(payload []byte, toolsPath string, toolType string) []byte { + tools := gjson.GetBytes(payload, toolsPath) + if !tools.Exists() || !tools.IsArray() { + return payload + } + removed := false + filtered := []byte(`[]`) + for _, tool := range tools.Array() { + if tool.Get("type").String() == toolType { + removed = true + continue + } + updated, errSet := sjson.SetRawBytes(filtered, "-1", []byte(tool.Raw)) + if errSet != nil { + continue + } + filtered = updated + } + if !removed { + return payload + } + updated, errSet := sjson.SetRawBytes(payload, toolsPath, filtered) + if errSet != nil { + return payload + } + return updated +} + func payloadRawValue(value any) ([]byte, bool) { if value == nil { return nil, false diff --git a/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go b/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go new file mode 100644 index 0000000000..143393dceb --- /dev/null +++ b/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go @@ -0,0 +1,50 @@ +package helps + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/tidwall/gjson" +) + +func TestApplyPayloadConfigWithRoot_DisableImageGeneration_RemovesToolsEntry(t *testing.T) { + cfg := &config.Config{ + SDKConfig: config.SDKConfig{DisableImageGeneration: true}, + } + payload := []byte(`{"tools":[{"type":"image_generation","output_format":"png"},{"type":"function","name":"f1"}]}`) + + out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "openai-response", "", payload, nil, "") + + tools := gjson.GetBytes(out, "tools") + if !tools.Exists() || !tools.IsArray() { + t.Fatalf("expected tools array, got %v", tools.Type) + } + arr := tools.Array() + if len(arr) != 1 { + t.Fatalf("expected 1 tool after removal, got %d", len(arr)) + } + if got := arr[0].Get("type").String(); got != "function" { + t.Fatalf("expected remaining tool type=function, got %q", got) + } +} + +func TestApplyPayloadConfigWithRoot_DisableImageGeneration_RemovesToolsEntryWithRoot(t *testing.T) { + cfg := &config.Config{ + SDKConfig: config.SDKConfig{DisableImageGeneration: true}, + } + payload := []byte(`{"request":{"tools":[{"type":"image_generation"},{"type":"web_search"}]}}`) + + out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "gemini-cli", "request", payload, nil, "") + + tools := gjson.GetBytes(out, "request.tools") + if !tools.Exists() || !tools.IsArray() { + t.Fatalf("expected request.tools array, got %v", tools.Type) + } + arr := tools.Array() + if len(arr) != 1 { + t.Fatalf("expected 1 tool after removal, got %d", len(arr)) + } + if got := arr[0].Get("type").String(); got != "web_search" { + t.Fatalf("expected remaining tool type=web_search, got %q", got) + } +} diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go index 11f9093e80..15ab5d31ff 100644 --- a/internal/watcher/diff/config_diff.go +++ b/internal/watcher/diff/config_diff.go @@ -42,6 +42,9 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if oldCfg.DisableCooling != newCfg.DisableCooling { changes = append(changes, fmt.Sprintf("disable-cooling: %t -> %t", oldCfg.DisableCooling, newCfg.DisableCooling)) } + if oldCfg.DisableImageGeneration != newCfg.DisableImageGeneration { + changes = append(changes, fmt.Sprintf("disable-image-generation: %t -> %t", oldCfg.DisableImageGeneration, newCfg.DisableImageGeneration)) + } if oldCfg.RequestLog != newCfg.RequestLog { changes = append(changes, fmt.Sprintf("request-log: %t -> %t", oldCfg.RequestLog, newCfg.RequestLog)) } diff --git a/internal/watcher/diff/config_diff_test.go b/internal/watcher/diff/config_diff_test.go index 2d45aa5743..6cfda7b19f 100644 --- a/internal/watcher/diff/config_diff_test.go +++ b/internal/watcher/diff/config_diff_test.go @@ -279,6 +279,7 @@ func TestBuildConfigChangeDetails_FlagsAndKeys(t *testing.T) { APIKeys: []string{" key-1 ", "key-2"}, ForceModelPrefix: true, NonStreamKeepAliveInterval: 5, + DisableImageGeneration: true, }, } @@ -287,6 +288,7 @@ func TestBuildConfigChangeDetails_FlagsAndKeys(t *testing.T) { expectContains(t, details, "logging-to-file: false -> true") expectContains(t, details, "usage-statistics-enabled: false -> true") expectContains(t, details, "disable-cooling: false -> true") + expectContains(t, details, "disable-image-generation: false -> true") expectContains(t, details, "request-log: false -> true") expectContains(t, details, "request-retry: 1 -> 2") expectContains(t, details, "max-retry-credentials: 1 -> 3") @@ -403,9 +405,10 @@ func TestBuildConfigChangeDetails_AllBranches(t *testing.T) { SecretKey: "", }, SDKConfig: sdkconfig.SDKConfig{ - RequestLog: true, - ProxyURL: "http://new-proxy", - APIKeys: []string{"keyB"}, + RequestLog: true, + ProxyURL: "http://new-proxy", + APIKeys: []string{"keyB"}, + DisableImageGeneration: true, }, OAuthExcludedModels: map[string][]string{"p1": {"b", "c"}, "p2": {"d"}}, OpenAICompatibility: []config.OpenAICompatibility{ @@ -431,6 +434,7 @@ func TestBuildConfigChangeDetails_AllBranches(t *testing.T) { expectContains(t, changes, "logging-to-file: false -> true") expectContains(t, changes, "usage-statistics-enabled: false -> true") expectContains(t, changes, "disable-cooling: false -> true") + expectContains(t, changes, "disable-image-generation: false -> true") expectContains(t, changes, "request-retry: 1 -> 2") expectContains(t, changes, "max-retry-credentials: 1 -> 3") expectContains(t, changes, "max-retry-interval: 1 -> 3") diff --git a/sdk/api/handlers/openai/openai_images_handlers.go b/sdk/api/handlers/openai/openai_images_handlers.go index 081547c0f6..162bf41ebc 100644 --- a/sdk/api/handlers/openai/openai_images_handlers.go +++ b/sdk/api/handlers/openai/openai_images_handlers.go @@ -198,6 +198,11 @@ func parseBoolField(raw string, fallback bool) bool { } func (h *OpenAIAPIHandler) ImagesGenerations(c *gin.Context) { + if h != nil && h.BaseAPIHandler != nil && h.BaseAPIHandler.Cfg != nil && h.BaseAPIHandler.Cfg.DisableImageGeneration { + c.AbortWithStatus(http.StatusNotFound) + return + } + rawJSON, err := c.GetRawData() if err != nil { c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ @@ -281,6 +286,11 @@ func (h *OpenAIAPIHandler) ImagesGenerations(c *gin.Context) { } func (h *OpenAIAPIHandler) ImagesEdits(c *gin.Context) { + if h != nil && h.BaseAPIHandler != nil && h.BaseAPIHandler.Cfg != nil && h.BaseAPIHandler.Cfg.DisableImageGeneration { + c.AbortWithStatus(http.StatusNotFound) + return + } + contentType := strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))) if strings.HasPrefix(contentType, "application/json") { h.imagesEditsFromJSON(c) diff --git a/sdk/api/handlers/openai/openai_images_handlers_test.go b/sdk/api/handlers/openai/openai_images_handlers_test.go index 679bec6a2f..7604c5d45f 100644 --- a/sdk/api/handlers/openai/openai_images_handlers_test.go +++ b/sdk/api/handlers/openai/openai_images_handlers_test.go @@ -10,6 +10,8 @@ import ( "testing" "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" "github.com/tidwall/gjson" ) @@ -93,3 +95,27 @@ func TestImagesEditsMultipartRejectsUnsupportedModel(t *testing.T) { assertUnsupportedImagesModelResponse(t, resp, "gpt-5.4-mini") } + +func TestImagesGenerations_DisableImageGeneration_Returns404(t *testing.T) { + base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{DisableImageGeneration: true}, nil) + handler := NewOpenAIAPIHandler(base) + body := strings.NewReader(`{"prompt":"draw a square"}`) + + resp := performImagesEndpointRequest(t, imagesGenerationsPath, "application/json", body, handler.ImagesGenerations) + + if resp.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusNotFound, resp.Body.String()) + } +} + +func TestImagesEdits_DisableImageGeneration_Returns404(t *testing.T) { + base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{DisableImageGeneration: true}, nil) + handler := NewOpenAIAPIHandler(base) + body := strings.NewReader(`{"prompt":"edit this","images":[{"image_url":"data:image/png;base64,AA=="}]}`) + + resp := performImagesEndpointRequest(t, imagesEditsPath, "application/json", body, handler.ImagesEdits) + + if resp.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusNotFound, resp.Body.String()) + } +} From 46018417ad70ee50ecb5ded63f988bad802c434d Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 30 Apr 2026 08:24:14 +0800 Subject: [PATCH 075/139] feat: remove `tool_choice` for `image_generation` when disabled - Added logic to remove `tool_choice` entries of type `image_generation` from payloads when `disable-image-generation` is enabled. - Updated `ApplyPayloadConfigWithRoot` to handle new removal logic. - Added unit tests to verify `tool_choice` removal behavior. --- .../runtime/executor/helps/payload_helpers.go | 50 +++++++++++++++++++ ...d_helpers_disable_image_generation_test.go | 26 ++++++++++ 2 files changed, 76 insertions(+) diff --git a/internal/runtime/executor/helps/payload_helpers.go b/internal/runtime/executor/helps/payload_helpers.go index b868d445a9..5377a8c117 100644 --- a/internal/runtime/executor/helps/payload_helpers.go +++ b/internal/runtime/executor/helps/payload_helpers.go @@ -151,6 +151,7 @@ func ApplyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string if cfg.DisableImageGeneration { out = removeToolTypeFromPayloadWithRoot(out, root, "image_generation") + out = removeToolChoiceFromPayloadWithRoot(out, root, "image_generation") } return out } @@ -242,6 +243,55 @@ func removeToolTypeFromPayloadWithRoot(payload []byte, root string, toolType str return removeToolTypeFromToolsArray(payload, toolsPath, toolType) } +func removeToolChoiceFromPayloadWithRoot(payload []byte, root string, toolType string) []byte { + if len(payload) == 0 { + return payload + } + toolType = strings.TrimSpace(toolType) + if toolType == "" { + return payload + } + toolChoicePath := buildPayloadPath(root, "tool_choice") + return removeToolChoiceFromPayload(payload, toolChoicePath, toolType) +} + +func removeToolChoiceFromPayload(payload []byte, toolChoicePath string, toolType string) []byte { + choice := gjson.GetBytes(payload, toolChoicePath) + if !choice.Exists() { + return payload + } + if choice.Type == gjson.String { + if strings.EqualFold(strings.TrimSpace(choice.String()), toolType) { + updated, errDel := sjson.DeleteBytes(payload, toolChoicePath) + if errDel == nil { + return updated + } + } + return payload + } + if choice.Type != gjson.JSON { + return payload + } + choiceType := strings.TrimSpace(choice.Get("type").String()) + if strings.EqualFold(choiceType, toolType) { + updated, errDel := sjson.DeleteBytes(payload, toolChoicePath) + if errDel == nil { + return updated + } + return payload + } + if strings.EqualFold(choiceType, "tool") { + name := strings.TrimSpace(choice.Get("name").String()) + if strings.EqualFold(name, toolType) { + updated, errDel := sjson.DeleteBytes(payload, toolChoicePath) + if errDel == nil { + return updated + } + } + } + return payload +} + func removeToolTypeFromToolsArray(payload []byte, toolsPath string, toolType string) []byte { tools := gjson.GetBytes(payload, toolsPath) if !tools.Exists() || !tools.IsArray() { diff --git a/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go b/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go index 143393dceb..ae75f45087 100644 --- a/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go +++ b/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go @@ -48,3 +48,29 @@ func TestApplyPayloadConfigWithRoot_DisableImageGeneration_RemovesToolsEntryWith t.Fatalf("expected remaining tool type=web_search, got %q", got) } } + +func TestApplyPayloadConfigWithRoot_DisableImageGeneration_RemovesToolChoiceByType(t *testing.T) { + cfg := &config.Config{ + SDKConfig: config.SDKConfig{DisableImageGeneration: true}, + } + payload := []byte(`{"tools":[{"type":"image_generation"},{"type":"function","name":"f1"}],"tool_choice":{"type":"image_generation"}}`) + + out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "openai-response", "", payload, nil, "") + + if gjson.GetBytes(out, "tool_choice").Exists() { + t.Fatalf("expected tool_choice to be removed") + } +} + +func TestApplyPayloadConfigWithRoot_DisableImageGeneration_RemovesToolChoiceByNameWithRoot(t *testing.T) { + cfg := &config.Config{ + SDKConfig: config.SDKConfig{DisableImageGeneration: true}, + } + payload := []byte(`{"request":{"tools":[{"type":"image_generation"},{"type":"web_search"}],"tool_choice":{"type":"tool","name":"image_generation"}}}`) + + out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "gemini-cli", "request", payload, nil, "") + + if gjson.GetBytes(out, "request.tool_choice").Exists() { + t.Fatalf("expected request.tool_choice to be removed") + } +} From f56a19e5b82ef0903daf0822b4f712375a5bb296 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 30 Apr 2026 11:59:50 +0800 Subject: [PATCH 076/139] feat: add tri-state support for `disable-image-generation` configuration - Introduced `DisableImageGenerationMode` with support for `false`, `true`, and `chat` values. - Updated payload handling to preserve `image_generation` on images endpoints when `chat` mode is enabled. - Modified OpenAI image handlers (`ImagesGenerations`, `ImagesEdits`) to respect tri-state logic. - Added unit tests for `DisableImageGenerationMode` behavior and endpoint-specific handling. - Enhanced configuration diff logging to support `DisableImageGenerationMode`. --- config.example.yaml | 5 +- internal/api/server.go | 2 +- internal/config/config.go | 2 +- .../config/disable_image_generation_mode.go | 136 ++++++++++++++++++ .../disable_image_generation_mode_test.go | 76 ++++++++++ internal/config/sdk_config.go | 14 +- .../runtime/executor/aistudio_executor.go | 3 +- .../runtime/executor/antigravity_executor.go | 9 +- internal/runtime/executor/claude_executor.go | 6 +- internal/runtime/executor/codex_executor.go | 15 +- .../executor/codex_websockets_executor.go | 15 +- .../runtime/executor/gemini_cli_executor.go | 6 +- internal/runtime/executor/gemini_executor.go | 6 +- .../executor/gemini_vertex_executor.go | 12 +- .../runtime/executor/helps/payload_helpers.go | 44 +++++- ...d_helpers_disable_image_generation_test.go | 37 +++-- internal/runtime/executor/kimi_executor.go | 6 +- .../executor/openai_compat_executor.go | 6 +- internal/watcher/diff/config_diff.go | 2 +- internal/watcher/diff/config_diff_test.go | 4 +- sdk/api/handlers/handlers.go | 8 ++ .../handlers/openai/openai_images_handlers.go | 5 +- .../openai/openai_images_handlers_test.go | 29 +++- sdk/cliproxy/executor/types.go | 4 + 24 files changed, 398 insertions(+), 54 deletions(-) create mode 100644 internal/config/disable_image_generation_mode.go create mode 100644 internal/config/disable_image_generation_mode_test.go diff --git a/config.example.yaml b/config.example.yaml index 772a6416eb..172e961f62 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -90,8 +90,9 @@ max-retry-interval: 30 # When true, disable auth/model cooldown scheduling globally (prevents blackout windows after failure states). disable-cooling: false -# When true, disable the built-in image_generation tool globally. -# The server will stop injecting image_generation and will also remove it from request payload tools arrays. +# disable-image-generation supports: false (default), true, or "chat". +# - true: disable image_generation everywhere (also returns 404 for /v1/images/generations and /v1/images/edits). +# - "chat": disable image_generation injection on non-images endpoints, but keep /v1/images/generations and /v1/images/edits enabled. disable-image-generation: false # Core auth auto-refresh worker pool size (OAuth/file-based auth token refresh). diff --git a/internal/api/server.go b/internal/api/server.go index c414e10a1a..8421357ba3 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1014,7 +1014,7 @@ func (s *Server) UpdateClients(cfg *config.Config) { } if oldCfg != nil && oldCfg.DisableImageGeneration != cfg.DisableImageGeneration { - log.Infof("disable-image-generation updated: %t -> %t", oldCfg.DisableImageGeneration, cfg.DisableImageGeneration) + log.Infof("disable-image-generation updated: %v -> %v", oldCfg.DisableImageGeneration, cfg.DisableImageGeneration) } applySignatureCacheConfig(oldCfg, cfg) diff --git a/internal/config/config.go b/internal/config/config.go index c30593f673..39c91127ad 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -610,7 +610,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { cfg.ErrorLogsMaxFiles = 10 cfg.UsageStatisticsEnabled = false cfg.DisableCooling = false - cfg.DisableImageGeneration = false + cfg.DisableImageGeneration = DisableImageGenerationOff cfg.Pprof.Enable = false cfg.Pprof.Addr = DefaultPprofAddr cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient diff --git a/internal/config/disable_image_generation_mode.go b/internal/config/disable_image_generation_mode.go new file mode 100644 index 0000000000..1712638b86 --- /dev/null +++ b/internal/config/disable_image_generation_mode.go @@ -0,0 +1,136 @@ +package config + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +// DisableImageGenerationMode is a tri-state config value for disable-image-generation. +// +// It supports: +// - false: enabled +// - true: disabled everywhere (including /v1/images/* endpoints) +// - "chat": disabled for all non-images endpoints, but enabled for /v1/images/generations and /v1/images/edits +type DisableImageGenerationMode int + +const ( + DisableImageGenerationOff DisableImageGenerationMode = iota + DisableImageGenerationAll + DisableImageGenerationChat +) + +func (m DisableImageGenerationMode) String() string { + switch m { + case DisableImageGenerationOff: + return "false" + case DisableImageGenerationAll: + return "true" + case DisableImageGenerationChat: + return "chat" + default: + return "false" + } +} + +func (m DisableImageGenerationMode) MarshalYAML() (any, error) { + switch m { + case DisableImageGenerationAll: + return true, nil + case DisableImageGenerationChat: + return "chat", nil + default: + return false, nil + } +} + +func (m *DisableImageGenerationMode) UnmarshalYAML(value *yaml.Node) error { + mode, err := parseDisableImageGenerationNode(value) + if err != nil { + return err + } + *m = mode + return nil +} + +func (m DisableImageGenerationMode) MarshalJSON() ([]byte, error) { + switch m { + case DisableImageGenerationAll: + return []byte("true"), nil + case DisableImageGenerationChat: + return json.Marshal("chat") + default: + return []byte("false"), nil + } +} + +func (m *DisableImageGenerationMode) UnmarshalJSON(data []byte) error { + mode, err := parseDisableImageGenerationJSON(data) + if err != nil { + return err + } + *m = mode + return nil +} + +func parseDisableImageGenerationNode(value *yaml.Node) (DisableImageGenerationMode, error) { + if value == nil { + return DisableImageGenerationOff, nil + } + + // First try a typed bool decode (covers unquoted true/false and YAML 1.1 bools). + var b bool + if err := value.Decode(&b); err == nil && value.Kind == yaml.ScalarNode && value.ShortTag() == "!!bool" { + if b { + return DisableImageGenerationAll, nil + } + return DisableImageGenerationOff, nil + } + + // Fall back to string decoding (covers quoted "true"/"false" and "chat"). + var s string + if err := value.Decode(&s); err != nil { + return DisableImageGenerationOff, fmt.Errorf("invalid disable-image-generation value") + } + return parseDisableImageGenerationString(s) +} + +func parseDisableImageGenerationJSON(data []byte) (DisableImageGenerationMode, error) { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return DisableImageGenerationOff, nil + } + + // bool + var b bool + if err := json.Unmarshal(trimmed, &b); err == nil { + if b { + return DisableImageGenerationAll, nil + } + return DisableImageGenerationOff, nil + } + + // string + var s string + if err := json.Unmarshal(trimmed, &s); err != nil { + return DisableImageGenerationOff, fmt.Errorf("invalid disable-image-generation value") + } + return parseDisableImageGenerationString(s) +} + +func parseDisableImageGenerationString(s string) (DisableImageGenerationMode, error) { + s = strings.TrimSpace(strings.ToLower(s)) + switch s { + case "", "false", "0", "off", "no": + return DisableImageGenerationOff, nil + case "true", "1", "on", "yes": + return DisableImageGenerationAll, nil + case "chat": + return DisableImageGenerationChat, nil + default: + return DisableImageGenerationOff, fmt.Errorf("invalid disable-image-generation value %q (allowed: true, false, chat)", s) + } +} diff --git a/internal/config/disable_image_generation_mode_test.go b/internal/config/disable_image_generation_mode_test.go new file mode 100644 index 0000000000..433a5cbf96 --- /dev/null +++ b/internal/config/disable_image_generation_mode_test.go @@ -0,0 +1,76 @@ +package config + +import ( + "encoding/json" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestDisableImageGenerationMode_UnmarshalYAML(t *testing.T) { + type wrapper struct { + V DisableImageGenerationMode `yaml:"disable-image-generation"` + } + + { + var w wrapper + if err := yaml.Unmarshal([]byte("disable-image-generation: false\n"), &w); err != nil { + t.Fatalf("unmarshal false: %v", err) + } + if w.V != DisableImageGenerationOff { + t.Fatalf("false => %v, want %v", w.V, DisableImageGenerationOff) + } + } + + { + var w wrapper + if err := yaml.Unmarshal([]byte("disable-image-generation: true\n"), &w); err != nil { + t.Fatalf("unmarshal true: %v", err) + } + if w.V != DisableImageGenerationAll { + t.Fatalf("true => %v, want %v", w.V, DisableImageGenerationAll) + } + } + + { + var w wrapper + if err := yaml.Unmarshal([]byte("disable-image-generation: chat\n"), &w); err != nil { + t.Fatalf("unmarshal chat: %v", err) + } + if w.V != DisableImageGenerationChat { + t.Fatalf("chat => %v, want %v", w.V, DisableImageGenerationChat) + } + } +} + +func TestDisableImageGenerationMode_UnmarshalJSON(t *testing.T) { + { + var v DisableImageGenerationMode + if err := json.Unmarshal([]byte("false"), &v); err != nil { + t.Fatalf("unmarshal false: %v", err) + } + if v != DisableImageGenerationOff { + t.Fatalf("false => %v, want %v", v, DisableImageGenerationOff) + } + } + + { + var v DisableImageGenerationMode + if err := json.Unmarshal([]byte("true"), &v); err != nil { + t.Fatalf("unmarshal true: %v", err) + } + if v != DisableImageGenerationAll { + t.Fatalf("true => %v, want %v", v, DisableImageGenerationAll) + } + } + + { + var v DisableImageGenerationMode + if err := json.Unmarshal([]byte(`"chat"`), &v); err != nil { + t.Fatalf("unmarshal chat: %v", err) + } + if v != DisableImageGenerationChat { + t.Fatalf("chat => %v, want %v", v, DisableImageGenerationChat) + } + } +} diff --git a/internal/config/sdk_config.go b/internal/config/sdk_config.go index 752f53aa9c..48c0fe5f17 100644 --- a/internal/config/sdk_config.go +++ b/internal/config/sdk_config.go @@ -9,11 +9,15 @@ type SDKConfig struct { // ProxyURL is the URL of an optional proxy server to use for outbound requests. ProxyURL string `yaml:"proxy-url" json:"proxy-url"` - // DisableImageGeneration disables the built-in image_generation tool when true. - // When enabled, the server will avoid injecting image_generation into request payloads, - // will remove any existing image_generation tool entries from tools arrays, and will - // return 404 for /v1/images/generations and /v1/images/edits. - DisableImageGeneration bool `yaml:"disable-image-generation" json:"disable-image-generation"` + // DisableImageGeneration controls whether the built-in image_generation tool is injected/allowed. + // + // Supported values: + // - false (default): image_generation is enabled everywhere (normal behavior). + // - true: image_generation is disabled everywhere. The server stops injecting it, removes it from request payloads, + // and returns 404 for /v1/images/generations and /v1/images/edits. + // - "chat": disable image_generation injection for all non-images endpoints (e.g. /v1/responses, /v1/chat/completions), + // while keeping /v1/images/generations and /v1/images/edits enabled and preserving image_generation there. + DisableImageGeneration DisableImageGenerationMode `yaml:"disable-image-generation" json:"disable-image-generation"` // EnableGeminiCLIEndpoint controls whether Gemini CLI internal endpoints (/v1internal:*) are enabled. // Default is false for safety; when false, /v1internal:* requests are rejected. diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index f53e3e4d1d..73491d8248 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -428,7 +428,8 @@ func (e *AIStudioExecutor) translateRequest(req cliproxyexecutor.Request, opts c } payload = fixGeminiImageAspectRatio(baseModel, payload) requestedModel := helps.PayloadRequestedModel(opts, req.Model) - payload = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", payload, originalTranslated, requestedModel) + requestPath := helps.PayloadRequestPath(opts) + payload = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", payload, originalTranslated, requestedModel, requestPath) payload, _ = sjson.DeleteBytes(payload, "generationConfig.maxOutputTokens") payload, _ = sjson.DeleteBytes(payload, "generationConfig.responseMimeType") payload, _ = sjson.DeleteBytes(payload, "generationConfig.responseJsonSchema") diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index ad30c8194d..280c799af4 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -521,7 +521,8 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au } requestedModel := helps.PayloadRequestedModel(opts, req.Model) - translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel) + requestPath := helps.PayloadRequestPath(opts) + translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel, requestPath) useCredits := cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(e.cfg) @@ -718,7 +719,8 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * } requestedModel := helps.PayloadRequestedModel(opts, req.Model) - translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel) + requestPath := helps.PayloadRequestPath(opts) + translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel, requestPath) useCredits := cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(e.cfg) @@ -1178,7 +1180,8 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya } requestedModel := helps.PayloadRequestedModel(opts, req.Model) - translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel) + requestPath := helps.PayloadRequestPath(opts) + translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel, requestPath) useCredits := cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(e.cfg) diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 235db1f3b2..66432ac404 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -164,7 +164,8 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r body = applyCloaking(ctx, e.cfg, auth, body, baseModel, apiKey) requestedModel := helps.PayloadRequestedModel(opts, req.Model) - body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) + requestPath := helps.PayloadRequestPath(opts) + body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath) body = ensureModelMaxTokens(body, baseModel) // Disable thinking if tool_choice forces tool use (Anthropic API constraint) @@ -349,7 +350,8 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A body = applyCloaking(ctx, e.cfg, auth, body, baseModel, apiKey) requestedModel := helps.PayloadRequestedModel(opts, req.Model) - body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) + requestPath := helps.PayloadRequestPath(opts) + body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath) body = ensureModelMaxTokens(body, baseModel) // Disable thinking if tool_choice forces tool use (Anthropic API constraint) diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 1948beac44..aa8223f4fe 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -173,7 +173,8 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re } requestedModel := helps.PayloadRequestedModel(opts, req.Model) - body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) + requestPath := helps.PayloadRequestPath(opts) + body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath) body, _ = sjson.SetBytes(body, "model", baseModel) body, _ = sjson.SetBytes(body, "stream", true) body, _ = sjson.DeleteBytes(body, "previous_response_id") @@ -181,7 +182,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re body, _ = sjson.DeleteBytes(body, "safety_identifier") body, _ = sjson.DeleteBytes(body, "stream_options") body = normalizeCodexInstructions(body) - if e.cfg == nil || !e.cfg.DisableImageGeneration { + if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { body = ensureImageGenerationTool(body, baseModel, auth) } @@ -327,11 +328,12 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A } requestedModel := helps.PayloadRequestedModel(opts, req.Model) - body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) + requestPath := helps.PayloadRequestPath(opts) + body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath) body, _ = sjson.SetBytes(body, "model", baseModel) body, _ = sjson.DeleteBytes(body, "stream") body = normalizeCodexInstructions(body) - if e.cfg == nil || !e.cfg.DisableImageGeneration { + if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { body = ensureImageGenerationTool(body, baseModel, auth) } @@ -421,14 +423,15 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au } requestedModel := helps.PayloadRequestedModel(opts, req.Model) - body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) + requestPath := helps.PayloadRequestPath(opts) + body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath) body, _ = sjson.DeleteBytes(body, "previous_response_id") body, _ = sjson.DeleteBytes(body, "prompt_cache_retention") body, _ = sjson.DeleteBytes(body, "safety_identifier") body, _ = sjson.DeleteBytes(body, "stream_options") body, _ = sjson.SetBytes(body, "model", baseModel) body = normalizeCodexInstructions(body) - if e.cfg == nil || !e.cfg.DisableImageGeneration { + if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { body = ensureImageGenerationTool(body, baseModel, auth) } diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go index 94c9b262e8..40ba7e92ea 100644 --- a/internal/runtime/executor/codex_websockets_executor.go +++ b/internal/runtime/executor/codex_websockets_executor.go @@ -184,14 +184,16 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut } requestedModel := helps.PayloadRequestedModel(opts, req.Model) - body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) + requestPath := helps.PayloadRequestPath(opts) + body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath) body, _ = sjson.SetBytes(body, "model", baseModel) body, _ = sjson.SetBytes(body, "stream", true) body, _ = sjson.DeleteBytes(body, "previous_response_id") body, _ = sjson.DeleteBytes(body, "prompt_cache_retention") body, _ = sjson.DeleteBytes(body, "safety_identifier") - if !gjson.GetBytes(body, "instructions").Exists() { - body, _ = sjson.SetBytes(body, "instructions", "") + body = normalizeCodexInstructions(body) + if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { + body = ensureImageGenerationTool(body, baseModel, auth) } httpURL := strings.TrimSuffix(baseURL, "/") + "/responses" @@ -387,7 +389,12 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr } requestedModel := helps.PayloadRequestedModel(opts, req.Model) - body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, body, requestedModel) + requestPath := helps.PayloadRequestPath(opts) + body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, body, requestedModel, requestPath) + body = normalizeCodexInstructions(body) + if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { + body = ensureImageGenerationTool(body, baseModel, auth) + } httpURL := strings.TrimSuffix(baseURL, "/") + "/responses" wsURL, err := buildCodexResponsesWebsocketURL(httpURL) diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go index 375989839f..15e8457224 100644 --- a/internal/runtime/executor/gemini_cli_executor.go +++ b/internal/runtime/executor/gemini_cli_executor.go @@ -139,7 +139,8 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth basePayload = fixGeminiCLIImageAspectRatio(baseModel, basePayload) requestedModel := helps.PayloadRequestedModel(opts, req.Model) - basePayload = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "gemini", "request", basePayload, originalTranslated, requestedModel) + requestPath := helps.PayloadRequestPath(opts) + basePayload = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "gemini", "request", basePayload, originalTranslated, requestedModel, requestPath) action := "generateContent" if req.Metadata != nil { @@ -294,7 +295,8 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut basePayload = fixGeminiCLIImageAspectRatio(baseModel, basePayload) requestedModel := helps.PayloadRequestedModel(opts, req.Model) - basePayload = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "gemini", "request", basePayload, originalTranslated, requestedModel) + requestPath := helps.PayloadRequestPath(opts) + basePayload = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "gemini", "request", basePayload, originalTranslated, requestedModel, requestPath) projectID := resolveGeminiProjectID(auth) diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go index fb4fbfdaf2..0e3c3ec6b8 100644 --- a/internal/runtime/executor/gemini_executor.go +++ b/internal/runtime/executor/gemini_executor.go @@ -132,7 +132,8 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r body = fixGeminiImageAspectRatio(baseModel, body) requestedModel := helps.PayloadRequestedModel(opts, req.Model) - body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) + requestPath := helps.PayloadRequestPath(opts) + body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath) body, _ = sjson.SetBytes(body, "model", baseModel) action := "generateContent" @@ -239,7 +240,8 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A body = fixGeminiImageAspectRatio(baseModel, body) requestedModel := helps.PayloadRequestedModel(opts, req.Model) - body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) + requestPath := helps.PayloadRequestPath(opts) + body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath) body, _ = sjson.SetBytes(body, "model", baseModel) baseURL := resolveGeminiBaseURL(auth) diff --git a/internal/runtime/executor/gemini_vertex_executor.go b/internal/runtime/executor/gemini_vertex_executor.go index 50e66219ac..b147fde975 100644 --- a/internal/runtime/executor/gemini_vertex_executor.go +++ b/internal/runtime/executor/gemini_vertex_executor.go @@ -335,7 +335,8 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au body = fixGeminiImageAspectRatio(baseModel, body) requestedModel := helps.PayloadRequestedModel(opts, req.Model) - body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) + requestPath := helps.PayloadRequestPath(opts) + body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath) body, _ = sjson.SetBytes(body, "model", baseModel) } @@ -455,7 +456,8 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip body = fixGeminiImageAspectRatio(baseModel, body) requestedModel := helps.PayloadRequestedModel(opts, req.Model) - body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) + requestPath := helps.PayloadRequestPath(opts) + body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath) body, _ = sjson.SetBytes(body, "model", baseModel) action := getVertexAction(baseModel, false) @@ -565,7 +567,8 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte body = fixGeminiImageAspectRatio(baseModel, body) requestedModel := helps.PayloadRequestedModel(opts, req.Model) - body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) + requestPath := helps.PayloadRequestPath(opts) + body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath) body, _ = sjson.SetBytes(body, "model", baseModel) action := getVertexAction(baseModel, true) @@ -694,7 +697,8 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth body = fixGeminiImageAspectRatio(baseModel, body) requestedModel := helps.PayloadRequestedModel(opts, req.Model) - body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) + requestPath := helps.PayloadRequestPath(opts) + body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath) body, _ = sjson.SetBytes(body, "model", baseModel) action := getVertexAction(baseModel, true) diff --git a/internal/runtime/executor/helps/payload_helpers.go b/internal/runtime/executor/helps/payload_helpers.go index 5377a8c117..f8905ae740 100644 --- a/internal/runtime/executor/helps/payload_helpers.go +++ b/internal/runtime/executor/helps/payload_helpers.go @@ -16,7 +16,8 @@ import ( // and restricts matches to the given protocol when supplied. Defaults are checked // against the original payload when provided. requestedModel carries the client-visible // model name before alias resolution so payload rules can target aliases precisely. -func ApplyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string, payload, original []byte, requestedModel string) []byte { +// requestPath is the inbound HTTP request path (when available) used for endpoint-scoped gates. +func ApplyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string, payload, original []byte, requestedModel string, requestPath string) []byte { if cfg == nil || len(payload) == 0 { return payload } @@ -149,13 +150,34 @@ func ApplyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string } } - if cfg.DisableImageGeneration { + if cfg.DisableImageGeneration != config.DisableImageGenerationOff { + if cfg.DisableImageGeneration == config.DisableImageGenerationChat && isImagesEndpointRequestPath(requestPath) { + return out + } out = removeToolTypeFromPayloadWithRoot(out, root, "image_generation") out = removeToolChoiceFromPayloadWithRoot(out, root, "image_generation") } return out } +func isImagesEndpointRequestPath(path string) bool { + path = strings.TrimSpace(path) + if path == "" { + return false + } + if path == "/v1/images/generations" || path == "/v1/images/edits" { + return true + } + // Be tolerant of prefix routers that may report a longer matched route. + if strings.HasSuffix(path, "/v1/images/generations") || strings.HasSuffix(path, "/v1/images/edits") { + return true + } + if strings.HasSuffix(path, "/images/generations") || strings.HasSuffix(path, "/images/edits") { + return true + } + return false +} + func payloadModelRulesMatch(rules []config.PayloadModelRule, protocol string, models []string) bool { if len(rules) == 0 || len(models) == 0 { return false @@ -367,6 +389,24 @@ func PayloadRequestedModel(opts cliproxyexecutor.Options, fallback string) strin } } +func PayloadRequestPath(opts cliproxyexecutor.Options) string { + if len(opts.Metadata) == 0 { + return "" + } + raw, ok := opts.Metadata[cliproxyexecutor.RequestPathMetadataKey] + if !ok || raw == nil { + return "" + } + switch v := raw.(type) { + case string: + return strings.TrimSpace(v) + case []byte: + return strings.TrimSpace(string(v)) + default: + return "" + } +} + // matchModelPattern performs simple wildcard matching where '*' matches zero or more characters. // Examples: // diff --git a/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go b/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go index ae75f45087..1458d229d3 100644 --- a/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go +++ b/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go @@ -9,11 +9,11 @@ import ( func TestApplyPayloadConfigWithRoot_DisableImageGeneration_RemovesToolsEntry(t *testing.T) { cfg := &config.Config{ - SDKConfig: config.SDKConfig{DisableImageGeneration: true}, + SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}, } payload := []byte(`{"tools":[{"type":"image_generation","output_format":"png"},{"type":"function","name":"f1"}]}`) - out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "openai-response", "", payload, nil, "") + out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "openai-response", "", payload, nil, "", "") tools := gjson.GetBytes(out, "tools") if !tools.Exists() || !tools.IsArray() { @@ -30,11 +30,11 @@ func TestApplyPayloadConfigWithRoot_DisableImageGeneration_RemovesToolsEntry(t * func TestApplyPayloadConfigWithRoot_DisableImageGeneration_RemovesToolsEntryWithRoot(t *testing.T) { cfg := &config.Config{ - SDKConfig: config.SDKConfig{DisableImageGeneration: true}, + SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}, } payload := []byte(`{"request":{"tools":[{"type":"image_generation"},{"type":"web_search"}]}}`) - out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "gemini-cli", "request", payload, nil, "") + out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "gemini-cli", "request", payload, nil, "", "") tools := gjson.GetBytes(out, "request.tools") if !tools.Exists() || !tools.IsArray() { @@ -51,11 +51,11 @@ func TestApplyPayloadConfigWithRoot_DisableImageGeneration_RemovesToolsEntryWith func TestApplyPayloadConfigWithRoot_DisableImageGeneration_RemovesToolChoiceByType(t *testing.T) { cfg := &config.Config{ - SDKConfig: config.SDKConfig{DisableImageGeneration: true}, + SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}, } payload := []byte(`{"tools":[{"type":"image_generation"},{"type":"function","name":"f1"}],"tool_choice":{"type":"image_generation"}}`) - out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "openai-response", "", payload, nil, "") + out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "openai-response", "", payload, nil, "", "") if gjson.GetBytes(out, "tool_choice").Exists() { t.Fatalf("expected tool_choice to be removed") @@ -64,13 +64,34 @@ func TestApplyPayloadConfigWithRoot_DisableImageGeneration_RemovesToolChoiceByTy func TestApplyPayloadConfigWithRoot_DisableImageGeneration_RemovesToolChoiceByNameWithRoot(t *testing.T) { cfg := &config.Config{ - SDKConfig: config.SDKConfig{DisableImageGeneration: true}, + SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}, } payload := []byte(`{"request":{"tools":[{"type":"image_generation"},{"type":"web_search"}],"tool_choice":{"type":"tool","name":"image_generation"}}}`) - out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "gemini-cli", "request", payload, nil, "") + out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "gemini-cli", "request", payload, nil, "", "") if gjson.GetBytes(out, "request.tool_choice").Exists() { t.Fatalf("expected request.tool_choice to be removed") } } + +func TestApplyPayloadConfigWithRoot_DisableImageGenerationChat_KeepsImageGenerationOnImagesEndpoints(t *testing.T) { + cfg := &config.Config{ + SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationChat}, + } + payload := []byte(`{"tools":[{"type":"image_generation"},{"type":"function","name":"f1"}],"tool_choice":{"type":"image_generation"}}`) + + out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "openai-response", "", payload, nil, "", "/v1/images/generations") + + tools := gjson.GetBytes(out, "tools") + if !tools.Exists() || !tools.IsArray() { + t.Fatalf("expected tools array, got %v", tools.Type) + } + arr := tools.Array() + if len(arr) != 2 { + t.Fatalf("expected 2 tools (no removal), got %d", len(arr)) + } + if !gjson.GetBytes(out, "tool_choice").Exists() { + t.Fatalf("expected tool_choice to be kept on images endpoint") + } +} diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go index 931e3a569f..3588c9624b 100644 --- a/internal/runtime/executor/kimi_executor.go +++ b/internal/runtime/executor/kimi_executor.go @@ -108,7 +108,8 @@ func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req } requestedModel := helps.PayloadRequestedModel(opts, req.Model) - body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) + requestPath := helps.PayloadRequestPath(opts) + body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath) body, err = normalizeKimiToolMessageLinks(body) if err != nil { return resp, err @@ -217,7 +218,8 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut return nil, fmt.Errorf("kimi executor: failed to set stream_options in payload: %w", err) } requestedModel := helps.PayloadRequestedModel(opts, req.Model) - body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) + requestPath := helps.PayloadRequestPath(opts) + body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath) body, err = normalizeKimiToolMessageLinks(body) if err != nil { return nil, err diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index d5739a6377..4e44a7ae06 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -97,7 +97,8 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, opts.Stream) translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, opts.Stream) requestedModel := helps.PayloadRequestedModel(opts, req.Model) - translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel) + requestPath := helps.PayloadRequestPath(opts) + translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel, requestPath) if opts.Alt == "responses/compact" { if updated, errDelete := sjson.DeleteBytes(translated, "stream"); errDelete == nil { translated = updated @@ -199,7 +200,8 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true) translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true) requestedModel := helps.PayloadRequestedModel(opts, req.Model) - translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel) + requestPath := helps.PayloadRequestPath(opts) + translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel, requestPath) translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go index 15ab5d31ff..2be9aa9087 100644 --- a/internal/watcher/diff/config_diff.go +++ b/internal/watcher/diff/config_diff.go @@ -43,7 +43,7 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { changes = append(changes, fmt.Sprintf("disable-cooling: %t -> %t", oldCfg.DisableCooling, newCfg.DisableCooling)) } if oldCfg.DisableImageGeneration != newCfg.DisableImageGeneration { - changes = append(changes, fmt.Sprintf("disable-image-generation: %t -> %t", oldCfg.DisableImageGeneration, newCfg.DisableImageGeneration)) + changes = append(changes, fmt.Sprintf("disable-image-generation: %v -> %v", oldCfg.DisableImageGeneration, newCfg.DisableImageGeneration)) } if oldCfg.RequestLog != newCfg.RequestLog { changes = append(changes, fmt.Sprintf("request-log: %t -> %t", oldCfg.RequestLog, newCfg.RequestLog)) diff --git a/internal/watcher/diff/config_diff_test.go b/internal/watcher/diff/config_diff_test.go index 6cfda7b19f..b9a9153b18 100644 --- a/internal/watcher/diff/config_diff_test.go +++ b/internal/watcher/diff/config_diff_test.go @@ -279,7 +279,7 @@ func TestBuildConfigChangeDetails_FlagsAndKeys(t *testing.T) { APIKeys: []string{" key-1 ", "key-2"}, ForceModelPrefix: true, NonStreamKeepAliveInterval: 5, - DisableImageGeneration: true, + DisableImageGeneration: config.DisableImageGenerationAll, }, } @@ -408,7 +408,7 @@ func TestBuildConfigChangeDetails_AllBranches(t *testing.T) { RequestLog: true, ProxyURL: "http://new-proxy", APIKeys: []string{"keyB"}, - DisableImageGeneration: true, + DisableImageGeneration: config.DisableImageGenerationAll, }, OAuthExcludedModels: map[string][]string{"p1": {"b", "c"}, "p2": {"d"}}, OpenAICompatibility: []config.OpenAICompatibility{ diff --git a/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go index e5387c5fcd..22f7c41a17 100644 --- a/sdk/api/handlers/handlers.go +++ b/sdk/api/handlers/handlers.go @@ -198,9 +198,14 @@ func requestExecutionMetadata(ctx context.Context) map[string]any { // Idempotency-Key is an optional client-supplied header used to correlate retries. // Only include it if the client explicitly provides it. key := "" + requestPath := "" if ctx != nil { if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil { key = strings.TrimSpace(ginCtx.GetHeader("Idempotency-Key")) + requestPath = strings.TrimSpace(ginCtx.FullPath()) + if requestPath == "" && ginCtx.Request.URL != nil { + requestPath = strings.TrimSpace(ginCtx.Request.URL.Path) + } } } @@ -208,6 +213,9 @@ func requestExecutionMetadata(ctx context.Context) map[string]any { if key != "" { meta[idempotencyKeyMetadataKey] = key } + if requestPath != "" { + meta[coreexecutor.RequestPathMetadataKey] = requestPath + } if pinnedAuthID := pinnedAuthIDFromContext(ctx); pinnedAuthID != "" { meta[coreexecutor.PinnedAuthMetadataKey] = pinnedAuthID } diff --git a/sdk/api/handlers/openai/openai_images_handlers.go b/sdk/api/handlers/openai/openai_images_handlers.go index 162bf41ebc..8d22a4f4ed 100644 --- a/sdk/api/handlers/openai/openai_images_handlers.go +++ b/sdk/api/handlers/openai/openai_images_handlers.go @@ -14,6 +14,7 @@ import ( "time" "github.com/gin-gonic/gin" + internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" log "github.com/sirupsen/logrus" @@ -198,7 +199,7 @@ func parseBoolField(raw string, fallback bool) bool { } func (h *OpenAIAPIHandler) ImagesGenerations(c *gin.Context) { - if h != nil && h.BaseAPIHandler != nil && h.BaseAPIHandler.Cfg != nil && h.BaseAPIHandler.Cfg.DisableImageGeneration { + if h != nil && h.BaseAPIHandler != nil && h.BaseAPIHandler.Cfg != nil && h.BaseAPIHandler.Cfg.DisableImageGeneration == internalconfig.DisableImageGenerationAll { c.AbortWithStatus(http.StatusNotFound) return } @@ -286,7 +287,7 @@ func (h *OpenAIAPIHandler) ImagesGenerations(c *gin.Context) { } func (h *OpenAIAPIHandler) ImagesEdits(c *gin.Context) { - if h != nil && h.BaseAPIHandler != nil && h.BaseAPIHandler.Cfg != nil && h.BaseAPIHandler.Cfg.DisableImageGeneration { + if h != nil && h.BaseAPIHandler != nil && h.BaseAPIHandler.Cfg != nil && h.BaseAPIHandler.Cfg.DisableImageGeneration == internalconfig.DisableImageGenerationAll { c.AbortWithStatus(http.StatusNotFound) return } diff --git a/sdk/api/handlers/openai/openai_images_handlers_test.go b/sdk/api/handlers/openai/openai_images_handlers_test.go index 7604c5d45f..ea65ca3a5d 100644 --- a/sdk/api/handlers/openai/openai_images_handlers_test.go +++ b/sdk/api/handlers/openai/openai_images_handlers_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/gin-gonic/gin" + internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" "github.com/tidwall/gjson" @@ -97,7 +98,7 @@ func TestImagesEditsMultipartRejectsUnsupportedModel(t *testing.T) { } func TestImagesGenerations_DisableImageGeneration_Returns404(t *testing.T) { - base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{DisableImageGeneration: true}, nil) + base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{DisableImageGeneration: internalconfig.DisableImageGenerationAll}, nil) handler := NewOpenAIAPIHandler(base) body := strings.NewReader(`{"prompt":"draw a square"}`) @@ -109,7 +110,7 @@ func TestImagesGenerations_DisableImageGeneration_Returns404(t *testing.T) { } func TestImagesEdits_DisableImageGeneration_Returns404(t *testing.T) { - base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{DisableImageGeneration: true}, nil) + base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{DisableImageGeneration: internalconfig.DisableImageGenerationAll}, nil) handler := NewOpenAIAPIHandler(base) body := strings.NewReader(`{"prompt":"edit this","images":[{"image_url":"data:image/png;base64,AA=="}]}`) @@ -119,3 +120,27 @@ func TestImagesEdits_DisableImageGeneration_Returns404(t *testing.T) { t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusNotFound, resp.Body.String()) } } + +func TestImagesGenerations_DisableImageGenerationChat_DoesNotReturn404(t *testing.T) { + base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{DisableImageGeneration: internalconfig.DisableImageGenerationChat}, nil) + handler := NewOpenAIAPIHandler(base) + body := strings.NewReader(`{"model":"gpt-5.4-mini","prompt":"draw a square"}`) + + resp := performImagesEndpointRequest(t, imagesGenerationsPath, "application/json", body, handler.ImagesGenerations) + + if resp.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusBadRequest, resp.Body.String()) + } +} + +func TestImagesEdits_DisableImageGenerationChat_DoesNotReturn404(t *testing.T) { + base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{DisableImageGeneration: internalconfig.DisableImageGenerationChat}, nil) + handler := NewOpenAIAPIHandler(base) + body := strings.NewReader(`{"model":"gpt-5.4-mini","prompt":"edit this","images":[{"image_url":"data:image/png;base64,AA=="}]}`) + + resp := performImagesEndpointRequest(t, imagesEditsPath, "application/json", body, handler.ImagesEdits) + + if resp.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusBadRequest, resp.Body.String()) + } +} diff --git a/sdk/cliproxy/executor/types.go b/sdk/cliproxy/executor/types.go index ac58286fd7..c8bb917d03 100644 --- a/sdk/cliproxy/executor/types.go +++ b/sdk/cliproxy/executor/types.go @@ -10,6 +10,10 @@ import ( // RequestedModelMetadataKey stores the client-requested model name in Options.Metadata. const RequestedModelMetadataKey = "requested_model" +// RequestPathMetadataKey stores the inbound HTTP request path (e.g. "/v1/images/generations") in Options.Metadata. +// It is optional and may be absent for non-HTTP executions. +const RequestPathMetadataKey = "request_path" + // DisallowFreeAuthMetadataKey instructs auth selection to skip known free-tier credentials. const DisallowFreeAuthMetadataKey = "disallow_free_auth" From 6ba7c810a78c9afa88550a80b90c48b24e8b4852 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 30 Apr 2026 12:42:08 +0800 Subject: [PATCH 077/139] feat: apply image_generation filtering before payload rules - Updated `ApplyPayloadConfigWithRoot` to prioritize `disable-image-generation` filtering before applying payload rules. - Ensured payload overrides can explicitly re-enable `image_generation` when required. - Added unit tests to validate `image_generation` restoration through overrides. --- .../runtime/executor/helps/payload_helpers.go | 17 +++++---- ...d_helpers_disable_image_generation_test.go | 37 +++++++++++++++++++ 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/internal/runtime/executor/helps/payload_helpers.go b/internal/runtime/executor/helps/payload_helpers.go index f8905ae740..d6baba275b 100644 --- a/internal/runtime/executor/helps/payload_helpers.go +++ b/internal/runtime/executor/helps/payload_helpers.go @@ -23,6 +23,15 @@ func ApplyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string } out := payload + // Apply disable-image-generation filtering before payload rules so config payload + // overrides can explicitly re-enable image_generation when desired. + if cfg.DisableImageGeneration != config.DisableImageGenerationOff { + if cfg.DisableImageGeneration != config.DisableImageGenerationChat || !isImagesEndpointRequestPath(requestPath) { + out = removeToolTypeFromPayloadWithRoot(out, root, "image_generation") + out = removeToolChoiceFromPayloadWithRoot(out, root, "image_generation") + } + } + rules := cfg.Payload hasPayloadRules := len(rules.Default) != 0 || len(rules.DefaultRaw) != 0 || len(rules.Override) != 0 || len(rules.OverrideRaw) != 0 || len(rules.Filter) != 0 if hasPayloadRules { @@ -149,14 +158,6 @@ func ApplyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string } } } - - if cfg.DisableImageGeneration != config.DisableImageGenerationOff { - if cfg.DisableImageGeneration == config.DisableImageGenerationChat && isImagesEndpointRequestPath(requestPath) { - return out - } - out = removeToolTypeFromPayloadWithRoot(out, root, "image_generation") - out = removeToolChoiceFromPayloadWithRoot(out, root, "image_generation") - } return out } diff --git a/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go b/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go index 1458d229d3..6fd3a0e055 100644 --- a/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go +++ b/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go @@ -95,3 +95,40 @@ func TestApplyPayloadConfigWithRoot_DisableImageGenerationChat_KeepsImageGenerat t.Fatalf("expected tool_choice to be kept on images endpoint") } } + +func TestApplyPayloadConfigWithRoot_DisableImageGeneration_PayloadOverrideCanRestoreImageGeneration(t *testing.T) { + cfg := &config.Config{ + SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}, + Payload: config.PayloadConfig{ + OverrideRaw: []config.PayloadRule{ + { + Models: []config.PayloadModelRule{ + {Name: "gpt-5.4", Protocol: "openai-response"}, + }, + Params: map[string]any{ + "tools": `[{"type":"image_generation"},{"type":"function","name":"f1"}]`, + "tool_choice": `{"type":"image_generation"}`, + }, + }, + }, + }, + } + payload := []byte(`{"tools":[{"type":"image_generation"},{"type":"function","name":"f1"}],"tool_choice":{"type":"image_generation"}}`) + + out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "openai-response", "", payload, nil, "", "") + + tools := gjson.GetBytes(out, "tools") + if !tools.Exists() || !tools.IsArray() { + t.Fatalf("expected tools array, got %v", tools.Type) + } + arr := tools.Array() + if len(arr) != 2 { + t.Fatalf("expected 2 tools after payload override, got %d", len(arr)) + } + if got := arr[0].Get("type").String(); got != "image_generation" { + t.Fatalf("expected first tool type=image_generation, got %q", got) + } + if !gjson.GetBytes(out, "tool_choice").Exists() { + t.Fatalf("expected tool_choice to be restored by payload override") + } +} From 243c5821593e59e6dc81903e1203c968fd9eff4c Mon Sep 17 00:00:00 2001 From: songyu Date: Thu, 30 Apr 2026 13:33:40 +0800 Subject: [PATCH 078/139] feat: add unit tests for OpenAI responses request conversion - Introduced a new test file for validating the conversion of OpenAI responses to chat completions. - Implemented tests to ensure correct merging of consecutive function calls and proper handling of interrupted function calls. - Enhanced the main conversion function to buffer consecutive function calls and emit them as a single assistant message. --- .../openai_openai-responses_request.go | 23 +++-- .../openai_openai-responses_request_test.go | 87 +++++++++++++++++++ 2 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 internal/translator/openai/openai/responses/openai_openai-responses_request_test.go diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request.go b/internal/translator/openai/openai/responses/openai_openai-responses_request.go index 2366c9c37b..9164a4116a 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go @@ -57,11 +57,25 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu // Convert input array to messages if input := root.Get("input"); input.Exists() && input.IsArray() { + pendingToolCalls := make([]interface{}, 0) + flushPendingToolCalls := func() { + if len(pendingToolCalls) == 0 { + return + } + assistantMessage := []byte(`{"role":"assistant","tool_calls":[]}`) + assistantMessage, _ = sjson.SetBytes(assistantMessage, "tool_calls", pendingToolCalls) + out, _ = sjson.SetRawBytes(out, "messages.-1", assistantMessage) + pendingToolCalls = pendingToolCalls[:0] + } + input.ForEach(func(_, item gjson.Result) bool { itemType := item.Get("type").String() if itemType == "" && item.Get("role").String() != "" { itemType = "message" } + if itemType != "function_call" { + flushPendingToolCalls() + } switch itemType { case "message", "": @@ -112,9 +126,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu out, _ = sjson.SetRawBytes(out, "messages.-1", message) case "function_call": - // Handle function call conversion to assistant message with tool_calls - assistantMessage := []byte(`{"role":"assistant","tool_calls":[]}`) - + // Buffer consecutive function calls and emit them as one assistant message. toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`) if callId := item.Get("call_id"); callId.Exists() { @@ -128,9 +140,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu if arguments := item.Get("arguments"); arguments.Exists() { toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", arguments.String()) } - - assistantMessage, _ = sjson.SetRawBytes(assistantMessage, "tool_calls.0", toolCall) - out, _ = sjson.SetRawBytes(out, "messages.-1", assistantMessage) + pendingToolCalls = append(pendingToolCalls, gjson.ParseBytes(toolCall).Value()) case "function_call_output": // Handle function call output conversion to tool message @@ -149,6 +159,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu return true }) + flushPendingToolCalls() } else if input.Type == gjson.String { msg := []byte(`{}`) msg, _ = sjson.SetBytes(msg, "role", "user") diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go new file mode 100644 index 0000000000..e9339753a3 --- /dev/null +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go @@ -0,0 +1,87 @@ +package responses + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/tidwall/gjson" +) + +func prettyJSONForTest(raw []byte) string { + if !gjson.ValidBytes(raw) { + return string(raw) + } + var out bytes.Buffer + if err := json.Indent(&out, raw, "", " "); err != nil { + return string(raw) + } + return out.String() +} + +func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MergeConsecutiveFunctionCalls(t *testing.T) { + raw := []byte(`{ + "input": [ + {"type":"function_call","call_id":"exec_command:0","name":"exec_command","arguments":"{\"cmd\":\"ls\"}"}, + {"type":"function_call","call_id":"exec_command:1","name":"exec_command","arguments":"{\"cmd\":\"pwd\"}"}, + {"type":"function_call_output","call_id":"exec_command:0","output":"ok0"}, + {"type":"function_call_output","call_id":"exec_command:1","output":"ok1"} + ] + }`) + t.Logf("input json:\n%s", prettyJSONForTest(raw)) + + out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k2.6", raw, true) + t.Logf("output json:\n%s", prettyJSONForTest(out)) + + msgs := gjson.GetBytes(out, "messages") + if !msgs.Exists() || !msgs.IsArray() { + t.Fatalf("messages should be an array") + } + if got := len(msgs.Array()); got != 3 { + t.Fatalf("messages count = %d, want %d", got, 3) + } + + if got := gjson.GetBytes(out, "messages.0.role").String(); got != "assistant" { + t.Fatalf("messages.0.role = %q, want %q", got, "assistant") + } + if got := len(gjson.GetBytes(out, "messages.0.tool_calls").Array()); got != 2 { + t.Fatalf("messages.0.tool_calls length = %d, want %d", got, 2) + } + if got := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String(); got != "exec_command:0" { + t.Fatalf("messages.0.tool_calls.0.id = %q, want %q", got, "exec_command:0") + } + if got := gjson.GetBytes(out, "messages.0.tool_calls.1.id").String(); got != "exec_command:1" { + t.Fatalf("messages.0.tool_calls.1.id = %q, want %q", got, "exec_command:1") + } + + if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != "exec_command:0" { + t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "exec_command:0") + } + if got := gjson.GetBytes(out, "messages.2.tool_call_id").String(); got != "exec_command:1" { + t.Fatalf("messages.2.tool_call_id = %q, want %q", got, "exec_command:1") + } +} + +func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_SplitFunctionCallsWhenInterrupted(t *testing.T) { + raw := []byte(`{ + "input": [ + {"type":"function_call","call_id":"call_a","name":"tool_a","arguments":"{}"}, + {"type":"message","role":"user","content":"next"}, + {"type":"function_call","call_id":"call_b","name":"tool_b","arguments":"{}"} + ] + }`) + t.Logf("input json:\n%s", prettyJSONForTest(raw)) + + out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k2.6", raw, false) + t.Logf("output json:\n%s", prettyJSONForTest(out)) + + if got := len(gjson.GetBytes(out, "messages").Array()); got != 3 { + t.Fatalf("messages count = %d, want %d", got, 3) + } + if got := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String(); got != "call_a" { + t.Fatalf("messages.0.tool_calls.0.id = %q, want %q", got, "call_a") + } + if got := gjson.GetBytes(out, "messages.2.tool_calls.0.id").String(); got != "call_b" { + t.Fatalf("messages.2.tool_calls.0.id = %q, want %q", got, "call_b") + } +} From 05ecfb6241f380cb67bde52123adbb43f1917021 Mon Sep 17 00:00:00 2001 From: songyu Date: Thu, 30 Apr 2026 14:01:56 +0800 Subject: [PATCH 079/139] feat: add local Docker build script and update compose configuration - Introduced a new script `docker-build-local.sh` to build a local Docker image and start services using Docker Compose. - Updated `docker-compose.yml` to allow dynamic pull policy configuration via the `CLI_PROXY_PULL_POLICY` environment variable. - Modified `Dockerfile` to support build arguments for Go module proxy settings during the `go mod download` step. --- Dockerfile | 7 +++++- docker-build-local.sh | 50 +++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 2 +- 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100755 docker-build-local.sh diff --git a/Dockerfile b/Dockerfile index 3e10c4f9f8..1419fffdd8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,12 @@ WORKDIR /app COPY go.mod go.sum ./ -RUN go mod download +ARG GOPROXY=https://proxy.golang.org,direct +ARG GOSUMDB=sum.golang.org +ARG GOPRIVATE= + +RUN GOPROXY="${GOPROXY}" GOSUMDB="${GOSUMDB}" GOPRIVATE="${GOPRIVATE}" go mod download || \ + GOPROXY="https://goproxy.cn,direct" GOSUMDB="sum.golang.google.cn" GOPRIVATE="${GOPRIVATE}" go mod download COPY . . diff --git a/docker-build-local.sh b/docker-build-local.sh new file mode 100755 index 0000000000..ce187a356c --- /dev/null +++ b/docker-build-local.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +# Build local image with docker build (no buildx required), +# then start services via docker compose. + +set -euo pipefail + +if ! command -v docker >/dev/null 2>&1; then + echo "Error: docker command not found." + exit 1 +fi + +if ! docker compose version >/dev/null 2>&1; then + echo "Error: docker compose plugin not available." + exit 1 +fi + +IMAGE_TAG="${CLI_PROXY_IMAGE:-cli-proxy-api:local}" + +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + VERSION="$(git describe --tags --always --dirty)" + COMMIT="$(git rev-parse --short HEAD)" +else + VERSION="dev" + COMMIT="none" +fi +BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +echo "Building local image with:" +echo " Image Tag: ${IMAGE_TAG}" +echo " Version: ${VERSION}" +echo " Commit: ${COMMIT}" +echo " Build Date: ${BUILD_DATE}" +echo "----------------------------------------" + +docker build \ + -t "${IMAGE_TAG}" \ + --build-arg VERSION="${VERSION}" \ + --build-arg COMMIT="${COMMIT}" \ + --build-arg BUILD_DATE="${BUILD_DATE}" \ + --build-arg GOPROXY="${GOPROXY:-https://proxy.golang.org,direct}" \ + --build-arg GOSUMDB="${GOSUMDB:-sum.golang.org}" \ + --build-arg GOPRIVATE="${GOPRIVATE:-}" \ + . + +echo "Starting services from local image..." +CLI_PROXY_IMAGE="${IMAGE_TAG}" CLI_PROXY_PULL_POLICY="never" docker compose up -d --remove-orphans --no-build --pull never + +echo "Done." +echo "Use 'docker compose logs -f' to view logs." diff --git a/docker-compose.yml b/docker-compose.yml index ad2190c23a..e2f6728fb0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: cli-proxy-api: image: ${CLI_PROXY_IMAGE:-eceasy/cli-proxy-api:latest} - pull_policy: always + pull_policy: ${CLI_PROXY_PULL_POLICY:-always} build: context: . dockerfile: Dockerfile From aa70d13f606e96d570c65e4eeb1792913f0a51ce Mon Sep 17 00:00:00 2001 From: C4AL <104809382+C4AL@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:36:37 +0800 Subject: [PATCH 080/139] docs: add CodexCliPlus to README ecosystem list --- README.md | 4 ++++ README_CN.md | 4 ++++ README_JA.md | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/README.md b/README.md index 70f5a0441a..93ef6f71d3 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,10 @@ Ready-to-use cross-platform quota inspector for CLIProxyAPI, supporting per-acco Standalone persistence and visualization service for CLIProxyAPI, with periodic data sync, SQLite storage, aggregate APIs, and a built-in dashboard for usage and statistics. +### [CodexCliPlus](https://github.com/C4AL/CodexCliPlus) + +Windows-focused, local-first desktop management platform for Codex CLI built on CLIProxyAPI, focused on simplifying local setup, account and runtime management, and providing a more complete Codex CLI experience for local users. + > [!NOTE] > If you developed a project based on CLIProxyAPI, please open a PR to add it to this list. diff --git a/README_CN.md b/README_CN.md index e08e4ed1d9..6199095c11 100644 --- a/README_CN.md +++ b/README_CN.md @@ -183,6 +183,10 @@ Shadow AI 是一款专为受限环境设计的 AI 辅助工具。提供无窗口 独立的 CLIProxyAPI 使用量持久化与可视化服务,定期同步 CPA 数据,存储到 SQLite,提供聚合 API,并内置使用量分析与统计仪表盘。 +### [CodexCliPlus](https://github.com/C4AL/CodexCliPlus) + +基于 CLIProxyAPI 的 Windows Codex CLI 本地优先桌面管理平台,聚焦简化本机配置、账号与运行状态管理,并为本地用户提供更完整的 Codex CLI 使用体验。 + > [!NOTE] > 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。 diff --git a/README_JA.md b/README_JA.md index 6360320c2f..1bb30d48e6 100644 --- a/README_JA.md +++ b/README_JA.md @@ -182,6 +182,10 @@ CLIProxyAPI向けのすぐに使えるクロスプラットフォームのクォ CLIProxyAPI向けの独立した使用量永続化・可視化サービス。CPAデータを定期同期してSQLiteに保存し、集計APIと、使用量や各種統計を確認できる組み込みダッシュボードを提供します。 +### [CodexCliPlus](https://github.com/C4AL/CodexCliPlus) + +CLIProxyAPIを基盤にしたWindows向けのローカル優先Codex CLIデスクトップ管理プラットフォーム。ローカル設定、アカウント、実行状態の管理を簡素化し、ローカルユーザーにより包括的なCodex CLI体験を提供します。 + > [!NOTE] > CLIProxyAPIをベースにプロジェクトを開発した場合は、PRを送ってこのリストに追加してください。 From 4035abc0cd6b7dabdff49b695256d6d6ceb03245 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 30 Apr 2026 23:36:07 +0800 Subject: [PATCH 081/139] refactor(logging): replace gin-specific context handling with generic context-based request metadata utilities - Introduced reusable utilities in `requestmeta` to manage endpoint and response status in request contexts. - Refactored plugins and handlers to use context-based metadata, removing direct dependency on `gin`. - Updated tests to validate new context utilities and replaced `gin`-based context handling. Fixed: #3166 --- internal/logging/requestmeta.go | 62 ++++++++++++++++++++++ internal/redisqueue/plugin.go | 42 ++------------- internal/redisqueue/plugin_test.go | 84 +++++++++++++++++++++++++++--- internal/usage/logger_plugin.go | 28 ++-------- sdk/api/handlers/handlers.go | 26 ++++++++- 5 files changed, 174 insertions(+), 68 deletions(-) create mode 100644 internal/logging/requestmeta.go diff --git a/internal/logging/requestmeta.go b/internal/logging/requestmeta.go new file mode 100644 index 0000000000..a28d7c6287 --- /dev/null +++ b/internal/logging/requestmeta.go @@ -0,0 +1,62 @@ +package logging + +import ( + "context" + "sync/atomic" +) + +type endpointKey struct{} +type responseStatusKey struct{} + +type responseStatusHolder struct { + status atomic.Int32 +} + +func WithEndpoint(ctx context.Context, endpoint string) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, endpointKey{}, endpoint) +} + +func GetEndpoint(ctx context.Context) string { + if ctx == nil { + return "" + } + if endpoint, ok := ctx.Value(endpointKey{}).(string); ok { + return endpoint + } + return "" +} + +func WithResponseStatusHolder(ctx context.Context) context.Context { + if ctx == nil { + ctx = context.Background() + } + if holder, ok := ctx.Value(responseStatusKey{}).(*responseStatusHolder); ok && holder != nil { + return ctx + } + return context.WithValue(ctx, responseStatusKey{}, &responseStatusHolder{}) +} + +func SetResponseStatus(ctx context.Context, status int) { + if ctx == nil || status <= 0 { + return + } + holder, ok := ctx.Value(responseStatusKey{}).(*responseStatusHolder) + if !ok || holder == nil { + return + } + holder.status.Store(int32(status)) +} + +func GetResponseStatus(ctx context.Context) int { + if ctx == nil { + return 0 + } + holder, ok := ctx.Value(responseStatusKey{}).(*responseStatusHolder) + if !ok || holder == nil { + return 0 + } + return int(holder.status.Load()) +} diff --git a/internal/redisqueue/plugin.go b/internal/redisqueue/plugin.go index a805e5dad5..39739dbe46 100644 --- a/internal/redisqueue/plugin.go +++ b/internal/redisqueue/plugin.go @@ -3,11 +3,9 @@ package redisqueue import ( "context" "encoding/json" - "net/http" "strings" "time" - "github.com/gin-gonic/gin" internallogging "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" internalusage "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" coreusage "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" @@ -46,11 +44,6 @@ func (p *usageQueuePlugin) HandleUsage(ctx context.Context, record coreusage.Rec } apiKey := strings.TrimSpace(record.APIKey) requestID := strings.TrimSpace(internallogging.GetRequestID(ctx)) - if requestID == "" { - if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil { - requestID = strings.TrimSpace(internallogging.GetGinRequestID(ginCtx)) - } - } tokens := internalusage.TokenStats{ InputTokens: record.Detail.InputTokens, @@ -106,40 +99,15 @@ type queuedUsageDetail struct { } func resolveSuccess(ctx context.Context) bool { - if ctx == nil { - return true - } - ginCtx, ok := ctx.Value("gin").(*gin.Context) - if !ok || ginCtx == nil { - return true - } - status := ginCtx.Writer.Status() + status := internallogging.GetResponseStatus(ctx) if status == 0 { return true } - return status < http.StatusBadRequest + return status < httpStatusBadRequest } func resolveEndpoint(ctx context.Context) string { - if ctx == nil { - return "" - } - ginCtx, ok := ctx.Value("gin").(*gin.Context) - if !ok || ginCtx == nil || ginCtx.Request == nil { - return "" - } - - path := strings.TrimSpace(ginCtx.FullPath()) - if path == "" && ginCtx.Request.URL != nil { - path = strings.TrimSpace(ginCtx.Request.URL.Path) - } - if path == "" { - return "" - } - - method := strings.TrimSpace(ginCtx.Request.Method) - if method == "" { - return path - } - return method + " " + path + return strings.TrimSpace(internallogging.GetEndpoint(ctx)) } + +const httpStatusBadRequest = 400 diff --git a/internal/redisqueue/plugin_test.go b/internal/redisqueue/plugin_test.go index 907b8aeeb5..1e8bda482c 100644 --- a/internal/redisqueue/plugin_test.go +++ b/internal/redisqueue/plugin_test.go @@ -16,9 +16,10 @@ import ( func TestUsageQueuePluginPayloadIncludesStableFieldsAndSuccess(t *testing.T) { withEnabledQueue(t, func() { - ginCtx := newTestGinContext(t, http.MethodPost, "/v1/chat/completions", http.StatusOK) - internallogging.SetGinRequestID(ginCtx, "gin-request-id-ignored") - ctx := context.WithValue(internallogging.WithRequestID(context.Background(), "ctx-request-id"), "gin", ginCtx) + ctx := internallogging.WithRequestID(context.Background(), "ctx-request-id") + ctx = internallogging.WithEndpoint(ctx, "POST /v1/chat/completions") + ctx = internallogging.WithResponseStatusHolder(ctx) + internallogging.SetResponseStatus(ctx, http.StatusOK) plugin := &usageQueuePlugin{} plugin.HandleUsage(ctx, coreusage.Record{ @@ -49,9 +50,10 @@ func TestUsageQueuePluginPayloadIncludesStableFieldsAndSuccess(t *testing.T) { func TestUsageQueuePluginPayloadIncludesStableFieldsAndFailureAndGinRequestID(t *testing.T) { withEnabledQueue(t, func() { - ginCtx := newTestGinContext(t, http.MethodGet, "/v1/responses", http.StatusInternalServerError) - internallogging.SetGinRequestID(ginCtx, "gin-request-id") - ctx := context.WithValue(context.Background(), "gin", ginCtx) + ctx := internallogging.WithRequestID(context.Background(), "gin-request-id") + ctx = internallogging.WithEndpoint(ctx, "GET /v1/responses") + ctx = internallogging.WithResponseStatusHolder(ctx) + internallogging.SetResponseStatus(ctx, http.StatusInternalServerError) plugin := &usageQueuePlugin{} plugin.HandleUsage(ctx, coreusage.Record{ @@ -80,6 +82,47 @@ func TestUsageQueuePluginPayloadIncludesStableFieldsAndFailureAndGinRequestID(t }) } +func TestUsageQueuePluginAsyncIgnoresRecycledGinContext(t *testing.T) { + withEnabledQueue(t, func() { + ginCtx := newTestGinContext(t, http.MethodPost, "/v1/chat/completions", http.StatusOK) + ctx := context.WithValue(context.Background(), "gin", ginCtx) + ctx = internallogging.WithRequestID(ctx, "ctx-request-id") + ctx = internallogging.WithEndpoint(ctx, "POST /v1/chat/completions") + ctx = internallogging.WithResponseStatusHolder(ctx) + internallogging.SetResponseStatus(ctx, http.StatusInternalServerError) + + mgr := coreusage.NewManager(16) + defer mgr.Stop() + + mgr.Register(pluginFunc(func(_ context.Context, _ coreusage.Record) { + ginCtx.Request = httptest.NewRequest(http.MethodGet, "http://example.com/v1/responses", nil) + ginCtx.Status(http.StatusOK) + })) + mgr.Register(&usageQueuePlugin{}) + + mgr.Publish(ctx, coreusage.Record{ + Provider: "openai", + Model: "gpt-5.4", + APIKey: "test-key", + AuthIndex: "0", + AuthType: "apikey", + Source: "user@example.com", + RequestedAt: time.Date(2026, 4, 25, 0, 0, 0, 0, time.UTC), + Latency: 1500 * time.Millisecond, + Detail: coreusage.Detail{ + InputTokens: 10, + OutputTokens: 20, + TotalTokens: 30, + }, + }) + + payload := waitForSinglePayload(t, 2*time.Second) + requireStringField(t, payload, "endpoint", "POST /v1/chat/completions") + requireStringField(t, payload, "request_id", "ctx-request-id") + requireBoolField(t, payload, "failed", true) + }) +} + func withEnabledQueue(t *testing.T, fn func()) { t.Helper() @@ -127,6 +170,29 @@ func popSinglePayload(t *testing.T) map[string]json.RawMessage { return payload } +func waitForSinglePayload(t *testing.T, timeout time.Duration) map[string]json.RawMessage { + t.Helper() + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + items := PopOldest(10) + if len(items) == 0 { + time.Sleep(10 * time.Millisecond) + continue + } + if len(items) != 1 { + t.Fatalf("PopOldest() items = %d, want 1", len(items)) + } + var payload map[string]json.RawMessage + if err := json.Unmarshal(items[0], &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + return payload + } + t.Fatalf("timeout waiting for queued payload") + return nil +} + func requireStringField(t *testing.T, payload map[string]json.RawMessage, key, want string) { t.Helper() @@ -143,6 +209,12 @@ func requireStringField(t *testing.T, payload map[string]json.RawMessage, key, w } } +type pluginFunc func(context.Context, coreusage.Record) + +func (fn pluginFunc) HandleUsage(ctx context.Context, record coreusage.Record) { + fn(ctx, record) +} + func requireBoolField(t *testing.T, payload map[string]json.RawMessage, key string, want bool) { t.Helper() diff --git a/internal/usage/logger_plugin.go b/internal/usage/logger_plugin.go index 803d005ee2..9d59de4feb 100644 --- a/internal/usage/logger_plugin.go +++ b/internal/usage/logger_plugin.go @@ -11,7 +11,7 @@ import ( "sync/atomic" "time" - "github.com/gin-gonic/gin" + internallogging "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" coreusage "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" ) @@ -401,21 +401,8 @@ func dedupKey(apiName, modelName string, detail RequestDetail) string { func resolveAPIIdentifier(ctx context.Context, record coreusage.Record) string { if ctx != nil { - if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil { - path := ginCtx.FullPath() - if path == "" && ginCtx.Request != nil { - path = ginCtx.Request.URL.Path - } - method := "" - if ginCtx.Request != nil { - method = ginCtx.Request.Method - } - if path != "" { - if method != "" { - return method + " " + path - } - return path - } + if endpoint := strings.TrimSpace(internallogging.GetEndpoint(ctx)); endpoint != "" { + return endpoint } } if record.Provider != "" { @@ -425,14 +412,7 @@ func resolveAPIIdentifier(ctx context.Context, record coreusage.Record) string { } func resolveSuccess(ctx context.Context) bool { - if ctx == nil { - return true - } - ginCtx, ok := ctx.Value("gin").(*gin.Context) - if !ok || ginCtx == nil { - return true - } - status := ginCtx.Writer.Status() + status := internallogging.GetResponseStatus(ctx) if status == 0 { return true } diff --git a/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go index 22f7c41a17..52b2a4fdeb 100644 --- a/sdk/api/handlers/handlers.go +++ b/sdk/api/handlers/handlers.go @@ -375,11 +375,32 @@ func (h *BaseAPIHandler) GetContextWithCancel(handler interfaces.APIHandler, c * if requestCtx != nil && logging.GetRequestID(parentCtx) == "" { if requestID := logging.GetRequestID(requestCtx); requestID != "" { parentCtx = logging.WithRequestID(parentCtx, requestID) - } else if requestID := logging.GetGinRequestID(c); requestID != "" { + } else if requestID = logging.GetGinRequestID(c); requestID != "" { parentCtx = logging.WithRequestID(parentCtx, requestID) } } newCtx, cancel := context.WithCancel(parentCtx) + + endpoint := "" + if c != nil && c.Request != nil { + path := strings.TrimSpace(c.FullPath()) + if path == "" && c.Request.URL != nil { + path = strings.TrimSpace(c.Request.URL.Path) + } + if path != "" { + method := strings.TrimSpace(c.Request.Method) + if method != "" { + endpoint = method + " " + path + } else { + endpoint = path + } + } + } + if endpoint != "" { + newCtx = logging.WithEndpoint(newCtx, endpoint) + } + newCtx = logging.WithResponseStatusHolder(newCtx) + cancelCtx := newCtx if requestCtx != nil && requestCtx != parentCtx { go func() { @@ -393,6 +414,9 @@ func (h *BaseAPIHandler) GetContextWithCancel(handler interfaces.APIHandler, c * newCtx = context.WithValue(newCtx, "gin", c) newCtx = context.WithValue(newCtx, "handler", handler) return newCtx, func(params ...interface{}) { + if c != nil { + logging.SetResponseStatus(cancelCtx, c.Writer.Status()) + } if h.Cfg.RequestLog && len(params) == 1 { if existing, exists := c.Get("API_RESPONSE"); exists { if existingBytes, ok := existing.([]byte); ok && len(bytes.TrimSpace(existingBytes)) > 0 { From 61879190002c267d70ad0dd3992c817ad0014b23 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 1 May 2026 22:55:22 +0800 Subject: [PATCH 082/139] feat: add support for recent request tracking in auth records - Implemented `RecentRequestsSnapshot` in `Auth` to capture bucketed recent request data. - Added new fields and methods to `Auth` for tracking request success and failure counts over time. - Updated `/v0/management/auth-files` response to include recent request data for each auth record. - Introduced unit tests to validate request tracking and snapshot generation logic. --- .../api/handlers/management/auth_files.go | 1 + .../auth_files_recent_requests_test.go | 87 ++++++++++++++++++ sdk/cliproxy/auth/conductor.go | 1 + .../auth/conductor_recent_requests_test.go | 44 ++++++++++ sdk/cliproxy/auth/types.go | 88 ++++++++++++++++++- sdk/cliproxy/auth/types_test.go | 75 +++++++++++++++- 6 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 internal/api/handlers/management/auth_files_recent_requests_test.go create mode 100644 sdk/cliproxy/auth/conductor_recent_requests_test.go diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 8f7b8c5e19..2bcfaac4ee 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -388,6 +388,7 @@ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H { "source": "memory", "size": int64(0), } + entry["recent_requests"] = auth.RecentRequestsSnapshot(time.Now()) if email := authEmail(auth); email != "" { entry["email"] = email } diff --git a/internal/api/handlers/management/auth_files_recent_requests_test.go b/internal/api/handlers/management/auth_files_recent_requests_test.go new file mode 100644 index 0000000000..fd28ca1df2 --- /dev/null +++ b/internal/api/handlers/management/auth_files_recent_requests_test.go @@ -0,0 +1,87 @@ +package management + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" +) + +func TestListAuthFiles_IncludesRecentRequestsBuckets(t *testing.T) { + t.Setenv("MANAGEMENT_PASSWORD", "") + gin.SetMode(gin.TestMode) + + manager := coreauth.NewManager(nil, nil, nil) + record := &coreauth.Auth{ + ID: "runtime-only-auth-1", + Provider: "codex", + Attributes: map[string]string{ + "runtime_only": "true", + }, + Metadata: map[string]any{ + "type": "codex", + }, + } + if _, errRegister := manager.Register(context.Background(), record); errRegister != nil { + t.Fatalf("failed to register auth record: %v", errRegister) + } + + h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, manager) + h.tokenStore = &memoryAuthStore{} + + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + req := httptest.NewRequest(http.MethodGet, "/v0/management/auth-files", nil) + ginCtx.Request = req + + h.ListAuthFiles(ginCtx) + + if rec.Code != http.StatusOK { + t.Fatalf("expected list status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + var payload map[string]any + if errUnmarshal := json.Unmarshal(rec.Body.Bytes(), &payload); errUnmarshal != nil { + t.Fatalf("failed to decode list payload: %v", errUnmarshal) + } + filesRaw, ok := payload["files"].([]any) + if !ok { + t.Fatalf("expected files array, payload: %#v", payload) + } + if len(filesRaw) != 1 { + t.Fatalf("expected 1 auth entry, got %d", len(filesRaw)) + } + + fileEntry, ok := filesRaw[0].(map[string]any) + if !ok { + t.Fatalf("expected file entry object, got %#v", filesRaw[0]) + } + + recentRaw, ok := fileEntry["recent_requests"].([]any) + if !ok { + t.Fatalf("expected recent_requests array, got %#v", fileEntry["recent_requests"]) + } + if len(recentRaw) != 20 { + t.Fatalf("expected 20 recent_requests buckets, got %d", len(recentRaw)) + } + for idx, item := range recentRaw { + bucket, ok := item.(map[string]any) + if !ok { + t.Fatalf("expected bucket object at %d, got %#v", idx, item) + } + if _, ok := bucket["time"].(string); !ok { + t.Fatalf("expected bucket time string at %d, got %#v", idx, bucket["time"]) + } + if _, ok := bucket["success"].(float64); !ok { + t.Fatalf("expected bucket success number at %d, got %#v", idx, bucket["success"]) + } + if _, ok := bucket["failed"].(float64); !ok { + t.Fatalf("expected bucket failed number at %d, got %#v", idx, bucket["failed"]) + } + } +} diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 6571518d31..61a0e41358 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -2021,6 +2021,7 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) { m.mu.Lock() if auth, ok := m.auths[result.AuthID]; ok && auth != nil { now := time.Now() + auth.recordRecentRequest(now, result.Success) if result.Success { if result.Model != "" { diff --git a/sdk/cliproxy/auth/conductor_recent_requests_test.go b/sdk/cliproxy/auth/conductor_recent_requests_test.go new file mode 100644 index 0000000000..3f5a721261 --- /dev/null +++ b/sdk/cliproxy/auth/conductor_recent_requests_test.go @@ -0,0 +1,44 @@ +package auth + +import ( + "context" + "testing" + "time" +) + +func TestManagerMarkResultRecordsRecentRequests(t *testing.T) { + mgr := NewManager(nil, nil, nil) + auth := &Auth{ + ID: "auth-1", + Provider: "antigravity", + Attributes: map[string]string{ + "runtime_only": "true", + }, + Metadata: map[string]any{ + "type": "antigravity", + }, + } + + if _, err := mgr.Register(WithSkipPersist(context.Background()), auth); err != nil { + t.Fatalf("Register returned error: %v", err) + } + + mgr.MarkResult(context.Background(), Result{AuthID: "auth-1", Provider: "antigravity", Model: "gpt-5", Success: true}) + mgr.MarkResult(context.Background(), Result{AuthID: "auth-1", Provider: "antigravity", Model: "gpt-5", Success: false}) + + gotAuth, ok := mgr.GetByID("auth-1") + if !ok || gotAuth == nil { + t.Fatalf("GetByID returned ok=%v auth=%v", ok, gotAuth) + } + + snapshot := gotAuth.RecentRequestsSnapshot(time.Now()) + var successTotal int64 + var failedTotal int64 + for _, bucket := range snapshot { + successTotal += bucket.Success + failedTotal += bucket.Failed + } + if successTotal != 1 || failedTotal != 1 { + t.Fatalf("totals = success=%d failed=%d, want 1/1", successTotal, failedTotal) + } +} diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go index f30f4dc011..93dd3881ed 100644 --- a/sdk/cliproxy/auth/types.go +++ b/sdk/cliproxy/auth/types.go @@ -92,7 +92,29 @@ type Auth struct { // Runtime carries non-serialisable data used during execution (in-memory only). Runtime any `json:"-"` - indexAssigned bool `json:"-"` + recentRequests recentRequestRing `json:"-"` + indexAssigned bool `json:"-"` +} + +const ( + recentRequestBucketSeconds int64 = 10 * 60 + recentRequestBucketCount = 20 +) + +type recentRequestBucket struct { + bucketID int64 + success int64 + failed int64 +} + +type recentRequestRing struct { + buckets [recentRequestBucketCount]recentRequestBucket +} + +type RecentRequestBucket struct { + Time string `json:"time"` + Success int64 `json:"success"` + Failed int64 `json:"failed"` } // QuotaState contains limiter tracking data for a credential. @@ -125,6 +147,70 @@ type ModelState struct { UpdatedAt time.Time `json:"updated_at"` } +func recentRequestBucketID(now time.Time) int64 { + if now.IsZero() { + return 0 + } + return now.Unix() / recentRequestBucketSeconds +} + +func recentRequestBucketIndex(bucketID int64) int { + mod := bucketID % int64(recentRequestBucketCount) + if mod < 0 { + mod += int64(recentRequestBucketCount) + } + return int(mod) +} + +func formatRecentRequestBucketLabel(bucketID int64) string { + start := time.Unix(bucketID*recentRequestBucketSeconds, 0).In(time.Local) + end := start.Add(10 * time.Minute) + return start.Format("15:04") + "-" + end.Format("15:04") +} + +func (a *Auth) recordRecentRequest(now time.Time, success bool) { + if a == nil { + return + } + bucketID := recentRequestBucketID(now) + idx := recentRequestBucketIndex(bucketID) + bucket := &a.recentRequests.buckets[idx] + if bucket.bucketID != bucketID { + bucket.bucketID = bucketID + bucket.success = 0 + bucket.failed = 0 + } + if success { + bucket.success++ + return + } + bucket.failed++ +} + +func (a *Auth) RecentRequestsSnapshot(now time.Time) []RecentRequestBucket { + out := make([]RecentRequestBucket, 0, recentRequestBucketCount) + if a == nil { + return out + } + + currentBucketID := recentRequestBucketID(now) + for i := recentRequestBucketCount - 1; i >= 0; i-- { + bucketID := currentBucketID - int64(i) + idx := recentRequestBucketIndex(bucketID) + bucket := a.recentRequests.buckets[idx] + entry := RecentRequestBucket{ + Time: formatRecentRequestBucketLabel(bucketID), + } + if bucket.bucketID == bucketID { + entry.Success = bucket.success + entry.Failed = bucket.failed + } + out = append(out, entry) + } + + return out +} + // Clone shallow copies the Auth structure, duplicating maps to avoid accidental mutation. func (a *Auth) Clone() *Auth { if a == nil { diff --git a/sdk/cliproxy/auth/types_test.go b/sdk/cliproxy/auth/types_test.go index e7029385a3..06836da1f2 100644 --- a/sdk/cliproxy/auth/types_test.go +++ b/sdk/cliproxy/auth/types_test.go @@ -1,6 +1,10 @@ package auth -import "testing" +import ( + "strings" + "testing" + "time" +) func TestToolPrefixDisabled(t *testing.T) { var a *Auth @@ -96,3 +100,72 @@ func TestEnsureIndexUsesCredentialIdentity(t *testing.T) { t.Fatalf("duplicate config entries should be separated by source-derived seed, got %q", geminiIndex) } } + +func TestRecentRequestsSnapshotEmptyReturnsTwentyBuckets(t *testing.T) { + now := time.Unix(1_700_000_000, 0).In(time.Local) + a := &Auth{} + + got := a.RecentRequestsSnapshot(now) + if len(got) != recentRequestBucketCount { + t.Fatalf("len = %d, want %d", len(got), recentRequestBucketCount) + } + + currentBucketID := now.Unix() / recentRequestBucketSeconds + baseBucketID := currentBucketID - int64(recentRequestBucketCount-1) + for i, bucket := range got { + if bucket.Success != 0 || bucket.Failed != 0 { + t.Fatalf("bucket[%d] counts = %d/%d, want 0/0", i, bucket.Success, bucket.Failed) + } + if strings.TrimSpace(bucket.Time) == "" { + t.Fatalf("bucket[%d] time label is empty", i) + } + expectedBucketID := baseBucketID + int64(i) + start := time.Unix(expectedBucketID*recentRequestBucketSeconds, 0).In(time.Local) + end := start.Add(10 * time.Minute) + expected := start.Format("15:04") + "-" + end.Format("15:04") + if bucket.Time != expected { + t.Fatalf("bucket[%d] time = %q, want %q", i, bucket.Time, expected) + } + } +} + +func TestRecentRequestsSnapshotIncludesCounts(t *testing.T) { + now := time.Unix(1_700_000_000, 0).In(time.Local) + a := &Auth{} + + a.recordRecentRequest(now, true) + a.recordRecentRequest(now, false) + + got := a.RecentRequestsSnapshot(now) + if len(got) != recentRequestBucketCount { + t.Fatalf("len = %d, want %d", len(got), recentRequestBucketCount) + } + + newest := got[len(got)-1] + if newest.Success != 1 || newest.Failed != 1 { + t.Fatalf("newest bucket = success=%d failed=%d, want 1/1", newest.Success, newest.Failed) + } +} + +func TestRecentRequestsSnapshotBucketAdvanceMovesCounts(t *testing.T) { + now := time.Unix(1_700_000_000, 0).In(time.Local) + next := now.Add(10 * time.Minute) + a := &Auth{} + + a.recordRecentRequest(now, true) + a.recordRecentRequest(next, false) + + got := a.RecentRequestsSnapshot(next) + if len(got) != recentRequestBucketCount { + t.Fatalf("len = %d, want %d", len(got), recentRequestBucketCount) + } + + secondNewest := got[len(got)-2] + newest := got[len(got)-1] + if secondNewest.Success != 1 || secondNewest.Failed != 0 { + t.Fatalf("second newest bucket = success=%d failed=%d, want 1/0", secondNewest.Success, secondNewest.Failed) + } + if newest.Success != 0 || newest.Failed != 1 { + t.Fatalf("newest bucket = success=%d failed=%d, want 0/1", newest.Success, newest.Failed) + } +} From b0dc9df887ef9f8fd9fad5bd9e4ebd639d6de8f3 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 1 May 2026 23:34:18 +0800 Subject: [PATCH 083/139] feat: add API key usage endpoint with provider and key grouping - Implemented `GetAPIKeyUsage` to expose recent request data grouped by provider and API key. - Added supporting function `mergeRecentRequestBuckets` for bucket aggregation. - Registered new endpoint `/v0/management/api-key-usage` in the management API. - Included extensive unit tests for provider and key-based grouping validation. - Updated `formatRecentRequestBucketLabel` to support configurable bucket duration. --- .../api/handlers/management/api_key_usage.go | 86 ++++++++++++++++++ .../handlers/management/api_key_usage_test.go | 87 +++++++++++++++++++ internal/api/server.go | 1 + sdk/cliproxy/auth/types.go | 2 +- 4 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 internal/api/handlers/management/api_key_usage.go create mode 100644 internal/api/handlers/management/api_key_usage_test.go diff --git a/internal/api/handlers/management/api_key_usage.go b/internal/api/handlers/management/api_key_usage.go new file mode 100644 index 0000000000..599fbad98b --- /dev/null +++ b/internal/api/handlers/management/api_key_usage.go @@ -0,0 +1,86 @@ +package management + +import ( + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" +) + +func mergeRecentRequestBuckets(dst, src []coreauth.RecentRequestBucket) []coreauth.RecentRequestBucket { + if len(dst) == 0 { + return src + } + if len(src) == 0 { + return dst + } + if len(dst) != len(src) { + n := len(dst) + if len(src) < n { + n = len(src) + } + for i := 0; i < n; i++ { + dst[i].Success += src[i].Success + dst[i].Failed += src[i].Failed + } + return dst + } + for i := range dst { + dst[i].Success += src[i].Success + dst[i].Failed += src[i].Failed + } + return dst +} + +// GetAPIKeyUsage returns recent request buckets for all in-memory api_key auths, +// grouped by provider and keyed by the raw api-key value. +func (h *Handler) GetAPIKeyUsage(c *gin.Context) { + if h == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "handler not initialized"}) + return + } + + h.mu.Lock() + manager := h.authManager + h.mu.Unlock() + if manager == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"}) + return + } + + now := time.Now() + out := make(map[string]map[string][]coreauth.RecentRequestBucket) + for _, auth := range manager.List() { + if auth == nil { + continue + } + kind, apiKey := auth.AccountInfo() + if !strings.EqualFold(strings.TrimSpace(kind), "api_key") { + continue + } + apiKey = strings.TrimSpace(apiKey) + if apiKey == "" { + continue + } + provider := strings.ToLower(strings.TrimSpace(auth.Provider)) + if provider == "" { + provider = "unknown" + } + + recent := auth.RecentRequestsSnapshot(now) + providerBucket, ok := out[provider] + if !ok { + providerBucket = make(map[string][]coreauth.RecentRequestBucket) + out[provider] = providerBucket + } + if existing, exists := providerBucket[apiKey]; exists { + providerBucket[apiKey] = mergeRecentRequestBuckets(existing, recent) + continue + } + providerBucket[apiKey] = recent + } + + c.JSON(http.StatusOK, out) +} diff --git a/internal/api/handlers/management/api_key_usage_test.go b/internal/api/handlers/management/api_key_usage_test.go new file mode 100644 index 0000000000..230dca4a69 --- /dev/null +++ b/internal/api/handlers/management/api_key_usage_test.go @@ -0,0 +1,87 @@ +package management + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" +) + +func sumRecentRequestBuckets(buckets []coreauth.RecentRequestBucket) (int64, int64) { + var success int64 + var failed int64 + for _, bucket := range buckets { + success += bucket.Success + failed += bucket.Failed + } + return success, failed +} + +func TestGetAPIKeyUsage_GroupsByProviderAndAPIKey(t *testing.T) { + t.Setenv("MANAGEMENT_PASSWORD", "") + gin.SetMode(gin.TestMode) + + manager := coreauth.NewManager(nil, nil, nil) + if _, err := manager.Register(context.Background(), &coreauth.Auth{ + ID: "codex-auth", + Provider: "codex", + Attributes: map[string]string{ + "api_key": "codex-key", + }, + }); err != nil { + t.Fatalf("register codex auth: %v", err) + } + if _, err := manager.Register(context.Background(), &coreauth.Auth{ + ID: "claude-auth", + Provider: "claude", + Attributes: map[string]string{ + "api_key": "claude-key", + }, + }); err != nil { + t.Fatalf("register claude auth: %v", err) + } + + manager.MarkResult(context.Background(), coreauth.Result{AuthID: "codex-auth", Provider: "codex", Model: "gpt-5", Success: true}) + manager.MarkResult(context.Background(), coreauth.Result{AuthID: "codex-auth", Provider: "codex", Model: "gpt-5", Success: false}) + manager.MarkResult(context.Background(), coreauth.Result{AuthID: "claude-auth", Provider: "claude", Model: "claude-4", Success: true}) + + h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, manager) + + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + req := httptest.NewRequest(http.MethodGet, "/v0/management/api-key-usage", nil) + ginCtx.Request = req + h.GetAPIKeyUsage(ginCtx) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var payload map[string]map[string][]coreauth.RecentRequestBucket + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + + codexBuckets := payload["codex"]["codex-key"] + if len(codexBuckets) != 20 { + t.Fatalf("codex buckets len = %d, want 20", len(codexBuckets)) + } + codexSuccess, codexFailed := sumRecentRequestBuckets(codexBuckets) + if codexSuccess != 1 || codexFailed != 1 { + t.Fatalf("codex totals = %d/%d, want 1/1", codexSuccess, codexFailed) + } + + claudeBuckets := payload["claude"]["claude-key"] + if len(claudeBuckets) != 20 { + t.Fatalf("claude buckets len = %d, want 20", len(claudeBuckets)) + } + claudeSuccess, claudeFailed := sumRecentRequestBuckets(claudeBuckets) + if claudeSuccess != 1 || claudeFailed != 0 { + t.Fatalf("claude totals = %d/%d, want 1/0", claudeSuccess, claudeFailed) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 8421357ba3..4d51460dd4 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -554,6 +554,7 @@ func (s *Server) registerManagementRoutes() { mgmt.PUT("/api-keys", s.mgmt.PutAPIKeys) mgmt.PATCH("/api-keys", s.mgmt.PatchAPIKeys) mgmt.DELETE("/api-keys", s.mgmt.DeleteAPIKeys) + mgmt.GET("/api-key-usage", s.mgmt.GetAPIKeyUsage) mgmt.GET("/gemini-api-key", s.mgmt.GetGeminiKeys) mgmt.PUT("/gemini-api-key", s.mgmt.PutGeminiKeys) diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go index 93dd3881ed..4a394ad485 100644 --- a/sdk/cliproxy/auth/types.go +++ b/sdk/cliproxy/auth/types.go @@ -164,7 +164,7 @@ func recentRequestBucketIndex(bucketID int64) int { func formatRecentRequestBucketLabel(bucketID int64) string { start := time.Unix(bucketID*recentRequestBucketSeconds, 0).In(time.Local) - end := start.Add(10 * time.Minute) + end := start.Add(time.Duration(recentRequestBucketSeconds) * time.Second) return start.Format("15:04") + "-" + end.Format("15:04") } From e37f3be0bfc482934d9669b58df1562e59f1196e Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 2 May 2026 00:09:08 +0800 Subject: [PATCH 084/139] chore: update .goreleaser.yml to include custom archive naming with arch override logic --- .goreleaser.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.goreleaser.yml b/.goreleaser.yml index f8bebfc1d9..c479255eaf 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -19,6 +19,8 @@ builds: archives: - id: "cli-proxy-api" format: tar.gz + name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{- if eq .Arch "arm64" -}}aarch64{{- else -}}{{ .Arch }}{{- end -}} format_overrides: - goos: windows format: zip From 8c2f1a80d39e542d3d85d569d035d7df8d5c39f6 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 2 May 2026 02:20:49 +0800 Subject: [PATCH 085/139] feat: enhance API key usage grouping with base URL inclusion - Updated `GetAPIKeyUsage` to group API key usage by "base_url|api_key" composite keys. - Adjusted logic to handle `base_url` extraction from auth attributes. - Revised unit tests to validate "base_url|api_key" grouping behavior. --- .../api/handlers/management/api_key_usage.go | 16 ++++++++++++---- .../handlers/management/api_key_usage_test.go | 10 ++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/internal/api/handlers/management/api_key_usage.go b/internal/api/handlers/management/api_key_usage.go index 599fbad98b..76b32bbb67 100644 --- a/internal/api/handlers/management/api_key_usage.go +++ b/internal/api/handlers/management/api_key_usage.go @@ -35,7 +35,7 @@ func mergeRecentRequestBuckets(dst, src []coreauth.RecentRequestBucket) []coreau } // GetAPIKeyUsage returns recent request buckets for all in-memory api_key auths, -// grouped by provider and keyed by the raw api-key value. +// grouped by provider and keyed by "base_url|api_key". func (h *Handler) GetAPIKeyUsage(c *gin.Context) { if h == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "handler not initialized"}) @@ -64,6 +64,14 @@ func (h *Handler) GetAPIKeyUsage(c *gin.Context) { if apiKey == "" { continue } + baseURL := "" + if auth.Attributes != nil { + baseURL = strings.TrimSpace(auth.Attributes["base_url"]) + if baseURL == "" { + baseURL = strings.TrimSpace(auth.Attributes["base-url"]) + } + } + compositeKey := baseURL + "|" + apiKey provider := strings.ToLower(strings.TrimSpace(auth.Provider)) if provider == "" { provider = "unknown" @@ -75,11 +83,11 @@ func (h *Handler) GetAPIKeyUsage(c *gin.Context) { providerBucket = make(map[string][]coreauth.RecentRequestBucket) out[provider] = providerBucket } - if existing, exists := providerBucket[apiKey]; exists { - providerBucket[apiKey] = mergeRecentRequestBuckets(existing, recent) + if existing, exists := providerBucket[compositeKey]; exists { + providerBucket[compositeKey] = mergeRecentRequestBuckets(existing, recent) continue } - providerBucket[apiKey] = recent + providerBucket[compositeKey] = recent } c.JSON(http.StatusOK, out) diff --git a/internal/api/handlers/management/api_key_usage_test.go b/internal/api/handlers/management/api_key_usage_test.go index 230dca4a69..56617161c5 100644 --- a/internal/api/handlers/management/api_key_usage_test.go +++ b/internal/api/handlers/management/api_key_usage_test.go @@ -31,7 +31,8 @@ func TestGetAPIKeyUsage_GroupsByProviderAndAPIKey(t *testing.T) { ID: "codex-auth", Provider: "codex", Attributes: map[string]string{ - "api_key": "codex-key", + "api_key": "codex-key", + "base_url": "https://codex.example.com", }, }); err != nil { t.Fatalf("register codex auth: %v", err) @@ -40,7 +41,8 @@ func TestGetAPIKeyUsage_GroupsByProviderAndAPIKey(t *testing.T) { ID: "claude-auth", Provider: "claude", Attributes: map[string]string{ - "api_key": "claude-key", + "api_key": "claude-key", + "base_url": "https://claude.example.com", }, }); err != nil { t.Fatalf("register claude auth: %v", err) @@ -67,7 +69,7 @@ func TestGetAPIKeyUsage_GroupsByProviderAndAPIKey(t *testing.T) { t.Fatalf("decode payload: %v", err) } - codexBuckets := payload["codex"]["codex-key"] + codexBuckets := payload["codex"]["https://codex.example.com|codex-key"] if len(codexBuckets) != 20 { t.Fatalf("codex buckets len = %d, want 20", len(codexBuckets)) } @@ -76,7 +78,7 @@ func TestGetAPIKeyUsage_GroupsByProviderAndAPIKey(t *testing.T) { t.Fatalf("codex totals = %d/%d, want 1/1", codexSuccess, codexFailed) } - claudeBuckets := payload["claude"]["claude-key"] + claudeBuckets := payload["claude"]["https://claude.example.com|claude-key"] if len(claudeBuckets) != 20 { t.Fatalf("claude buckets len = %d, want 20", len(claudeBuckets)) } From b8bba053fcdafd80abc2152c88c78f4e7713c05a Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 2 May 2026 03:40:00 +0800 Subject: [PATCH 086/139] feat: add tracking for auth request success and failure counts - Introduced `Success` and `Failed` fields in auth records to track request outcomes. - Updated `/v0/management/auth-files` and `/v0/management/api-key-usage` responses to include success and failure counts. - Enhanced tests to validate tracking logic and API responses. --- .../api/handlers/management/api_key_usage.go | 21 ++++++-- .../handlers/management/api_key_usage_test.go | 24 +++++---- .../api/handlers/management/auth_files.go | 2 + .../auth_files_recent_requests_test.go | 7 +++ sdk/cliproxy/auth/conductor.go | 8 +++ .../auth/conductor_recent_requests_test.go | 51 +++++++++++++++++++ sdk/cliproxy/auth/types.go | 3 ++ 7 files changed, 103 insertions(+), 13 deletions(-) diff --git a/internal/api/handlers/management/api_key_usage.go b/internal/api/handlers/management/api_key_usage.go index 76b32bbb67..3361da5d28 100644 --- a/internal/api/handlers/management/api_key_usage.go +++ b/internal/api/handlers/management/api_key_usage.go @@ -9,6 +9,12 @@ import ( coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" ) +type apiKeyUsageEntry struct { + Success int64 `json:"success"` + Failed int64 `json:"failed"` + RecentRequests []coreauth.RecentRequestBucket `json:"recent_requests"` +} + func mergeRecentRequestBuckets(dst, src []coreauth.RecentRequestBucket) []coreauth.RecentRequestBucket { if len(dst) == 0 { return src @@ -51,7 +57,7 @@ func (h *Handler) GetAPIKeyUsage(c *gin.Context) { } now := time.Now() - out := make(map[string]map[string][]coreauth.RecentRequestBucket) + out := make(map[string]map[string]apiKeyUsageEntry) for _, auth := range manager.List() { if auth == nil { continue @@ -80,14 +86,21 @@ func (h *Handler) GetAPIKeyUsage(c *gin.Context) { recent := auth.RecentRequestsSnapshot(now) providerBucket, ok := out[provider] if !ok { - providerBucket = make(map[string][]coreauth.RecentRequestBucket) + providerBucket = make(map[string]apiKeyUsageEntry) out[provider] = providerBucket } if existing, exists := providerBucket[compositeKey]; exists { - providerBucket[compositeKey] = mergeRecentRequestBuckets(existing, recent) + existing.Success += auth.Success + existing.Failed += auth.Failed + existing.RecentRequests = mergeRecentRequestBuckets(existing.RecentRequests, recent) + providerBucket[compositeKey] = existing continue } - providerBucket[compositeKey] = recent + providerBucket[compositeKey] = apiKeyUsageEntry{ + Success: auth.Success, + Failed: auth.Failed, + RecentRequests: recent, + } } c.JSON(http.StatusOK, out) diff --git a/internal/api/handlers/management/api_key_usage_test.go b/internal/api/handlers/management/api_key_usage_test.go index 56617161c5..2880567f8c 100644 --- a/internal/api/handlers/management/api_key_usage_test.go +++ b/internal/api/handlers/management/api_key_usage_test.go @@ -64,25 +64,31 @@ func TestGetAPIKeyUsage_GroupsByProviderAndAPIKey(t *testing.T) { t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusOK, rec.Body.String()) } - var payload map[string]map[string][]coreauth.RecentRequestBucket + var payload map[string]map[string]apiKeyUsageEntry if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode payload: %v", err) } - codexBuckets := payload["codex"]["https://codex.example.com|codex-key"] - if len(codexBuckets) != 20 { - t.Fatalf("codex buckets len = %d, want 20", len(codexBuckets)) + codexEntry := payload["codex"]["https://codex.example.com|codex-key"] + if codexEntry.Success != 1 || codexEntry.Failed != 1 { + t.Fatalf("codex totals = %d/%d, want 1/1", codexEntry.Success, codexEntry.Failed) } - codexSuccess, codexFailed := sumRecentRequestBuckets(codexBuckets) + if len(codexEntry.RecentRequests) != 20 { + t.Fatalf("codex buckets len = %d, want 20", len(codexEntry.RecentRequests)) + } + codexSuccess, codexFailed := sumRecentRequestBuckets(codexEntry.RecentRequests) if codexSuccess != 1 || codexFailed != 1 { t.Fatalf("codex totals = %d/%d, want 1/1", codexSuccess, codexFailed) } - claudeBuckets := payload["claude"]["https://claude.example.com|claude-key"] - if len(claudeBuckets) != 20 { - t.Fatalf("claude buckets len = %d, want 20", len(claudeBuckets)) + claudeEntry := payload["claude"]["https://claude.example.com|claude-key"] + if claudeEntry.Success != 1 || claudeEntry.Failed != 0 { + t.Fatalf("claude totals = %d/%d, want 1/0", claudeEntry.Success, claudeEntry.Failed) + } + if len(claudeEntry.RecentRequests) != 20 { + t.Fatalf("claude buckets len = %d, want 20", len(claudeEntry.RecentRequests)) } - claudeSuccess, claudeFailed := sumRecentRequestBuckets(claudeBuckets) + claudeSuccess, claudeFailed := sumRecentRequestBuckets(claudeEntry.RecentRequests) if claudeSuccess != 1 || claudeFailed != 0 { t.Fatalf("claude totals = %d/%d, want 1/0", claudeSuccess, claudeFailed) } diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 2bcfaac4ee..bb94daa9ae 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -388,6 +388,8 @@ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H { "source": "memory", "size": int64(0), } + entry["success"] = auth.Success + entry["failed"] = auth.Failed entry["recent_requests"] = auth.RecentRequestsSnapshot(time.Now()) if email := authEmail(auth); email != "" { entry["email"] = email diff --git a/internal/api/handlers/management/auth_files_recent_requests_test.go b/internal/api/handlers/management/auth_files_recent_requests_test.go index fd28ca1df2..979040f58b 100644 --- a/internal/api/handlers/management/auth_files_recent_requests_test.go +++ b/internal/api/handlers/management/auth_files_recent_requests_test.go @@ -62,6 +62,13 @@ func TestListAuthFiles_IncludesRecentRequestsBuckets(t *testing.T) { t.Fatalf("expected file entry object, got %#v", filesRaw[0]) } + if _, ok := fileEntry["success"].(float64); !ok { + t.Fatalf("expected success number, got %#v", fileEntry["success"]) + } + if _, ok := fileEntry["failed"].(float64); !ok { + t.Fatalf("expected failed number, got %#v", fileEntry["failed"]) + } + recentRaw, ok := fileEntry["recent_requests"].([]any) if !ok { t.Fatalf("expected recent_requests array, got %#v", fileEntry["recent_requests"]) diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 61a0e41358..d2a3db1884 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -1126,6 +1126,9 @@ func (m *Manager) Update(ctx context.Context, auth *Auth) (*Auth, error) { auth.Index = existing.Index auth.indexAssigned = existing.indexAssigned } + auth.Success = existing.Success + auth.Failed = existing.Failed + auth.recentRequests = existing.recentRequests if !existing.Disabled && existing.Status != StatusDisabled && !auth.Disabled && auth.Status != StatusDisabled { if len(auth.ModelStates) == 0 && len(existing.ModelStates) > 0 { auth.ModelStates = existing.ModelStates @@ -2022,6 +2025,11 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) { if auth, ok := m.auths[result.AuthID]; ok && auth != nil { now := time.Now() auth.recordRecentRequest(now, result.Success) + if result.Success { + auth.Success++ + } else { + auth.Failed++ + } if result.Success { if result.Model != "" { diff --git a/sdk/cliproxy/auth/conductor_recent_requests_test.go b/sdk/cliproxy/auth/conductor_recent_requests_test.go index 3f5a721261..d2003b7ccb 100644 --- a/sdk/cliproxy/auth/conductor_recent_requests_test.go +++ b/sdk/cliproxy/auth/conductor_recent_requests_test.go @@ -31,6 +31,10 @@ func TestManagerMarkResultRecordsRecentRequests(t *testing.T) { t.Fatalf("GetByID returned ok=%v auth=%v", ok, gotAuth) } + if gotAuth.Success != 1 || gotAuth.Failed != 1 { + t.Fatalf("auth totals = success=%d failed=%d, want 1/1", gotAuth.Success, gotAuth.Failed) + } + snapshot := gotAuth.RecentRequestsSnapshot(time.Now()) var successTotal int64 var failedTotal int64 @@ -42,3 +46,50 @@ func TestManagerMarkResultRecordsRecentRequests(t *testing.T) { t.Fatalf("totals = success=%d failed=%d, want 1/1", successTotal, failedTotal) } } + +func TestManagerUpdatePreservesRecentRequestsAndTotals(t *testing.T) { + mgr := NewManager(nil, nil, nil) + auth := &Auth{ + ID: "auth-1", + Provider: "antigravity", + Metadata: map[string]any{ + "type": "antigravity", + }, + } + if _, err := mgr.Register(WithSkipPersist(context.Background()), auth); err != nil { + t.Fatalf("Register returned error: %v", err) + } + + mgr.MarkResult(context.Background(), Result{AuthID: "auth-1", Provider: "antigravity", Model: "gpt-5", Success: true}) + + updated := &Auth{ + ID: "auth-1", + Provider: "antigravity", + Metadata: map[string]any{ + "type": "antigravity", + "note": "updated", + }, + } + if _, err := mgr.Update(WithSkipPersist(context.Background()), updated); err != nil { + t.Fatalf("Update returned error: %v", err) + } + + gotAuth, ok := mgr.GetByID("auth-1") + if !ok || gotAuth == nil { + t.Fatalf("GetByID returned ok=%v auth=%v", ok, gotAuth) + } + if gotAuth.Success != 1 || gotAuth.Failed != 0 { + t.Fatalf("auth totals = success=%d failed=%d, want 1/0", gotAuth.Success, gotAuth.Failed) + } + + snapshot := gotAuth.RecentRequestsSnapshot(time.Now()) + var successTotal int64 + var failedTotal int64 + for _, bucket := range snapshot { + successTotal += bucket.Success + failedTotal += bucket.Failed + } + if successTotal != 1 || failedTotal != 0 { + t.Fatalf("bucket totals = success=%d failed=%d, want 1/0", successTotal, failedTotal) + } +} diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go index 4a394ad485..76f4c396c8 100644 --- a/sdk/cliproxy/auth/types.go +++ b/sdk/cliproxy/auth/types.go @@ -92,6 +92,9 @@ type Auth struct { // Runtime carries non-serialisable data used during execution (in-memory only). Runtime any `json:"-"` + Success int64 `json:"-"` + Failed int64 `json:"-"` + recentRequests recentRequestRing `json:"-"` indexAssigned bool `json:"-"` } From 18bb9c315fced2c428f57b4a0e66b06183c46c06 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 2 May 2026 04:50:58 +0800 Subject: [PATCH 087/139] chore: remove usage tracking and logging functionality - Deleted the `LoggerPlugin` along with associated usage tracking and in-memory statistics logic. - Removed all related tests (`logger_plugin_test.go`, `usage_tab_test.go`) and external-facing handler (`usage.go`) for usage statistics export/import. - Cleaned up TUI integration by deleting `usage_tab.go`. --- cmd/server/main.go | 4 +- docker-build.sh | 136 +----- internal/api/handlers/management/handler.go | 6 - internal/api/handlers/management/usage.go | 79 ---- internal/api/server.go | 6 +- internal/redisqueue/plugin.go | 28 +- internal/redisqueue/plugin_test.go | 7 +- internal/redisqueue/usage_toggle.go | 16 + internal/tui/app.go | 22 +- internal/tui/client.go | 5 - internal/tui/dashboard.go | 77 +--- internal/tui/i18n.go | 4 +- internal/tui/usage_tab.go | 418 ------------------ internal/tui/usage_tab_test.go | 134 ------ internal/usage/logger_plugin.go | 464 -------------------- internal/usage/logger_plugin_test.go | 96 ---- sdk/cliproxy/service.go | 1 - test/usage_logging_test.go | 83 ++-- 18 files changed, 116 insertions(+), 1470 deletions(-) delete mode 100644 internal/api/handlers/management/usage.go create mode 100644 internal/redisqueue/usage_toggle.go delete mode 100644 internal/tui/usage_tab.go delete mode 100644 internal/tui/usage_tab_test.go delete mode 100644 internal/usage/logger_plugin.go delete mode 100644 internal/usage/logger_plugin_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index b8707f0a43..e735b144c4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -24,11 +24,11 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" "github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset" "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue" "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/store" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator" "github.com/router-for-me/CLIProxyAPI/v6/internal/tui" - "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" @@ -417,7 +417,7 @@ func main() { configFileExists = true } } - usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled) + redisqueue.SetUsageStatisticsEnabled(cfg.UsageStatisticsEnabled) coreauth.SetQuotaCooldownDisabled(cfg.DisableCooling) if err = logging.ConfigureLogOutput(cfg); err != nil { diff --git a/docker-build.sh b/docker-build.sh index 4538b80716..ebe7d92384 100644 --- a/docker-build.sh +++ b/docker-build.sh @@ -5,123 +5,13 @@ # This script automates the process of building and running the Docker container # with version information dynamically injected at build time. -# Hidden feature: Preserve usage statistics across rebuilds -# Usage: ./docker-build.sh --with-usage -# First run prompts for management API key, saved to temp/stats/.api_secret - set -euo pipefail -STATS_DIR="temp/stats" -STATS_FILE="${STATS_DIR}/.usage_backup.json" -SECRET_FILE="${STATS_DIR}/.api_secret" -WITH_USAGE=false - -get_port() { - if [[ -f "config.yaml" ]]; then - grep -E "^port:" config.yaml | sed -E 's/^port: *["'"'"']?([0-9]+)["'"'"']?.*$/\1/' - else - echo "8317" - fi -} - -export_stats_api_secret() { - if [[ -f "${SECRET_FILE}" ]]; then - API_SECRET=$(cat "${SECRET_FILE}") - else - if [[ ! -d "${STATS_DIR}" ]]; then - mkdir -p "${STATS_DIR}" - fi - echo "First time using --with-usage. Management API key required." - read -r -p "Enter management key: " -s API_SECRET - echo - echo "${API_SECRET}" > "${SECRET_FILE}" - chmod 600 "${SECRET_FILE}" - fi -} - -check_container_running() { - local port - port=$(get_port) - - if ! curl -s -o /dev/null -w "%{http_code}" "http://localhost:${port}/" | grep -q "200"; then - echo "Error: cli-proxy-api service is not responding at localhost:${port}" - echo "Please start the container first or use without --with-usage flag." - exit 1 - fi -} - -export_stats() { - local port - port=$(get_port) - - if [[ ! -d "${STATS_DIR}" ]]; then - mkdir -p "${STATS_DIR}" - fi - check_container_running - echo "Exporting usage statistics..." - EXPORT_RESPONSE=$(curl -s -w "\n%{http_code}" -H "X-Management-Key: ${API_SECRET}" \ - "http://localhost:${port}/v0/management/usage/export") - HTTP_CODE=$(echo "${EXPORT_RESPONSE}" | tail -n1) - RESPONSE_BODY=$(echo "${EXPORT_RESPONSE}" | sed '$d') - - if [[ "${HTTP_CODE}" != "200" ]]; then - echo "Export failed (HTTP ${HTTP_CODE}): ${RESPONSE_BODY}" - exit 1 - fi - - echo "${RESPONSE_BODY}" > "${STATS_FILE}" - echo "Statistics exported to ${STATS_FILE}" -} - -import_stats() { - local port - port=$(get_port) - - echo "Importing usage statistics..." - IMPORT_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ - -H "X-Management-Key: ${API_SECRET}" \ - -H "Content-Type: application/json" \ - -d @"${STATS_FILE}" \ - "http://localhost:${port}/v0/management/usage/import") - IMPORT_CODE=$(echo "${IMPORT_RESPONSE}" | tail -n1) - IMPORT_BODY=$(echo "${IMPORT_RESPONSE}" | sed '$d') - - if [[ "${IMPORT_CODE}" == "200" ]]; then - echo "Statistics imported successfully" - else - echo "Import failed (HTTP ${IMPORT_CODE}): ${IMPORT_BODY}" - fi - - rm -f "${STATS_FILE}" -} - -wait_for_service() { - local port - port=$(get_port) - - echo "Waiting for service to be ready..." - for i in {1..30}; do - if curl -s -o /dev/null -w "%{http_code}" "http://localhost:${port}/" | grep -q "200"; then - break - fi - sleep 1 - done - sleep 2 -} - -case "${1:-}" in - "") - ;; - "--with-usage") - WITH_USAGE=true - export_stats_api_secret - ;; - *) - echo "Error: unknown option '${1}'. Did you mean '--with-usage'?" - echo "Usage: ./docker-build.sh [--with-usage]" - exit 1 - ;; -esac +if [[ "${1:-}" != "" ]]; then + echo "Error: unknown option '${1}'." + echo "Usage: ./docker-build.sh" + exit 1 +fi # --- Step 1: Choose Environment --- echo "Please select an option:" @@ -133,14 +23,7 @@ read -r -p "Enter choice [1-2]: " choice case "$choice" in 1) echo "--- Running with Pre-built Image ---" - if [[ "${WITH_USAGE}" == "true" ]]; then - export_stats - fi docker compose up -d --remove-orphans --no-build - if [[ "${WITH_USAGE}" == "true" ]]; then - wait_for_service - import_stats - fi echo "Services are starting from remote image." echo "Run 'docker compose logs -f' to see the logs." ;; @@ -167,18 +50,9 @@ case "$choice" in --build-arg COMMIT="${COMMIT}" \ --build-arg BUILD_DATE="${BUILD_DATE}" - if [[ "${WITH_USAGE}" == "true" ]]; then - export_stats - fi - echo "Starting the services..." docker compose up -d --remove-orphans --pull never - if [[ "${WITH_USAGE}" == "true" ]]; then - wait_for_service - import_stats - fi - echo "Build complete. Services are starting." echo "Run 'docker compose logs -f' to see the logs." ;; diff --git a/internal/api/handlers/management/handler.go b/internal/api/handlers/management/handler.go index af11366c33..9abc8a5c8a 100644 --- a/internal/api/handlers/management/handler.go +++ b/internal/api/handlers/management/handler.go @@ -15,7 +15,6 @@ import ( "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" "golang.org/x/crypto/bcrypt" @@ -41,7 +40,6 @@ type Handler struct { attemptsMu sync.Mutex failedAttempts map[string]*attemptInfo // keyed by client IP authManager *coreauth.Manager - usageStats *usage.RequestStatistics tokenStore coreauth.Store localPassword string allowRemoteOverride bool @@ -60,7 +58,6 @@ func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Man configFilePath: configFilePath, failedAttempts: make(map[string]*attemptInfo), authManager: manager, - usageStats: usage.GetRequestStatistics(), tokenStore: sdkAuth.GetTokenStore(), allowRemoteOverride: envSecret != "", envSecret: envSecret, @@ -124,9 +121,6 @@ func (h *Handler) SetAuthManager(manager *coreauth.Manager) { h.mu.Unlock() } -// SetUsageStatistics allows replacing the usage statistics reference. -func (h *Handler) SetUsageStatistics(stats *usage.RequestStatistics) { h.usageStats = stats } - // SetLocalPassword configures the runtime-local password accepted for localhost requests. func (h *Handler) SetLocalPassword(password string) { h.localPassword = password } diff --git a/internal/api/handlers/management/usage.go b/internal/api/handlers/management/usage.go deleted file mode 100644 index 5f79408963..0000000000 --- a/internal/api/handlers/management/usage.go +++ /dev/null @@ -1,79 +0,0 @@ -package management - -import ( - "encoding/json" - "net/http" - "time" - - "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" -) - -type usageExportPayload struct { - Version int `json:"version"` - ExportedAt time.Time `json:"exported_at"` - Usage usage.StatisticsSnapshot `json:"usage"` -} - -type usageImportPayload struct { - Version int `json:"version"` - Usage usage.StatisticsSnapshot `json:"usage"` -} - -// GetUsageStatistics returns the in-memory request statistics snapshot. -func (h *Handler) GetUsageStatistics(c *gin.Context) { - var snapshot usage.StatisticsSnapshot - if h != nil && h.usageStats != nil { - snapshot = h.usageStats.Snapshot() - } - c.JSON(http.StatusOK, gin.H{ - "usage": snapshot, - "failed_requests": snapshot.FailureCount, - }) -} - -// ExportUsageStatistics returns a complete usage snapshot for backup/migration. -func (h *Handler) ExportUsageStatistics(c *gin.Context) { - var snapshot usage.StatisticsSnapshot - if h != nil && h.usageStats != nil { - snapshot = h.usageStats.Snapshot() - } - c.JSON(http.StatusOK, usageExportPayload{ - Version: 1, - ExportedAt: time.Now().UTC(), - Usage: snapshot, - }) -} - -// ImportUsageStatistics merges a previously exported usage snapshot into memory. -func (h *Handler) ImportUsageStatistics(c *gin.Context) { - if h == nil || h.usageStats == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "usage statistics unavailable"}) - return - } - - data, err := c.GetRawData() - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"}) - return - } - - var payload usageImportPayload - if err := json.Unmarshal(data, &payload); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"}) - return - } - if payload.Version != 0 && payload.Version != 1 { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported version"}) - return - } - - result := h.usageStats.MergeSnapshot(payload.Usage) - snapshot := h.usageStats.Snapshot() - c.JSON(http.StatusOK, gin.H{ - "added": result.Added, - "skipped": result.Skipped, - "total_requests": snapshot.TotalRequests, - "failed_requests": snapshot.FailureCount, - }) -} diff --git a/internal/api/server.go b/internal/api/server.go index 4d51460dd4..176bc2a385 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -31,7 +31,6 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" "github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset" "github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue" - "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" @@ -507,9 +506,6 @@ func (s *Server) registerManagementRoutes() { mgmt := s.engine.Group("/v0/management") mgmt.Use(s.managementAvailabilityMiddleware(), s.mgmt.Middleware()) { - mgmt.GET("/usage", s.mgmt.GetUsageStatistics) - mgmt.GET("/usage/export", s.mgmt.ExportUsageStatistics) - mgmt.POST("/usage/import", s.mgmt.ImportUsageStatistics) mgmt.GET("/config", s.mgmt.GetConfig) mgmt.GET("/config.yaml", s.mgmt.GetConfigYAML) mgmt.PUT("/config.yaml", s.mgmt.PutConfigYAML) @@ -1001,7 +997,7 @@ func (s *Server) UpdateClients(cfg *config.Config) { } if oldCfg == nil || oldCfg.UsageStatisticsEnabled != cfg.UsageStatisticsEnabled { - usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled) + redisqueue.SetUsageStatisticsEnabled(cfg.UsageStatisticsEnabled) } if s.requestLogger != nil && (oldCfg == nil || oldCfg.ErrorLogsMaxFiles != cfg.ErrorLogsMaxFiles) { diff --git a/internal/redisqueue/plugin.go b/internal/redisqueue/plugin.go index 39739dbe46..9716841901 100644 --- a/internal/redisqueue/plugin.go +++ b/internal/redisqueue/plugin.go @@ -7,7 +7,6 @@ import ( "time" internallogging "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" - internalusage "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" coreusage "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" ) @@ -21,7 +20,7 @@ func (p *usageQueuePlugin) HandleUsage(ctx context.Context, record coreusage.Rec if p == nil { return } - if !Enabled() || !internalusage.StatisticsEnabled() { + if !Enabled() || !UsageStatisticsEnabled() { return } @@ -45,7 +44,7 @@ func (p *usageQueuePlugin) HandleUsage(ctx context.Context, record coreusage.Rec apiKey := strings.TrimSpace(record.APIKey) requestID := strings.TrimSpace(internallogging.GetRequestID(ctx)) - tokens := internalusage.TokenStats{ + tokens := tokenStats{ InputTokens: record.Detail.InputTokens, OutputTokens: record.Detail.OutputTokens, ReasoningTokens: record.Detail.ReasoningTokens, @@ -64,7 +63,7 @@ func (p *usageQueuePlugin) HandleUsage(ctx context.Context, record coreusage.Rec failed = !resolveSuccess(ctx) } - detail := internalusage.RequestDetail{ + detail := requestDetail{ Timestamp: timestamp, LatencyMs: record.Latency.Milliseconds(), Source: record.Source, @@ -74,7 +73,7 @@ func (p *usageQueuePlugin) HandleUsage(ctx context.Context, record coreusage.Rec } payload, err := json.Marshal(queuedUsageDetail{ - RequestDetail: detail, + requestDetail: detail, Provider: provider, Model: modelName, Endpoint: resolveEndpoint(ctx), @@ -89,7 +88,7 @@ func (p *usageQueuePlugin) HandleUsage(ctx context.Context, record coreusage.Rec } type queuedUsageDetail struct { - internalusage.RequestDetail + requestDetail Provider string `json:"provider"` Model string `json:"model"` Endpoint string `json:"endpoint"` @@ -98,6 +97,23 @@ type queuedUsageDetail struct { RequestID string `json:"request_id"` } +type requestDetail struct { + Timestamp time.Time `json:"timestamp"` + LatencyMs int64 `json:"latency_ms"` + Source string `json:"source"` + AuthIndex string `json:"auth_index"` + Tokens tokenStats `json:"tokens"` + Failed bool `json:"failed"` +} + +type tokenStats struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + ReasoningTokens int64 `json:"reasoning_tokens"` + CachedTokens int64 `json:"cached_tokens"` + TotalTokens int64 `json:"total_tokens"` +} + func resolveSuccess(ctx context.Context) bool { status := internallogging.GetResponseStatus(ctx) if status == 0 { diff --git a/internal/redisqueue/plugin_test.go b/internal/redisqueue/plugin_test.go index 1e8bda482c..0cc8b9b9cb 100644 --- a/internal/redisqueue/plugin_test.go +++ b/internal/redisqueue/plugin_test.go @@ -10,7 +10,6 @@ import ( "github.com/gin-gonic/gin" internallogging "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" - internalusage "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" coreusage "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" ) @@ -127,16 +126,16 @@ func withEnabledQueue(t *testing.T, fn func()) { t.Helper() prevQueueEnabled := Enabled() - prevStatsEnabled := internalusage.StatisticsEnabled() + prevUsageEnabled := UsageStatisticsEnabled() SetEnabled(false) SetEnabled(true) - internalusage.SetStatisticsEnabled(true) + SetUsageStatisticsEnabled(true) defer func() { SetEnabled(false) SetEnabled(prevQueueEnabled) - internalusage.SetStatisticsEnabled(prevStatsEnabled) + SetUsageStatisticsEnabled(prevUsageEnabled) }() fn() diff --git a/internal/redisqueue/usage_toggle.go b/internal/redisqueue/usage_toggle.go new file mode 100644 index 0000000000..dddbeca692 --- /dev/null +++ b/internal/redisqueue/usage_toggle.go @@ -0,0 +1,16 @@ +package redisqueue + +import "sync/atomic" + +var usageStatisticsEnabled atomic.Bool + +func init() { + usageStatisticsEnabled.Store(true) +} + +// SetUsageStatisticsEnabled toggles whether usage records are enqueued into the redisqueue payload buffer. +// This is controlled by the config field `usage-statistics-enabled` and the corresponding management API. +func SetUsageStatisticsEnabled(enabled bool) { usageStatisticsEnabled.Store(enabled) } + +// UsageStatisticsEnabled reports whether the usage queue plugin should publish records. +func UsageStatisticsEnabled() bool { return usageStatisticsEnabled.Load() } diff --git a/internal/tui/app.go b/internal/tui/app.go index b9ee9e1a3a..c0a7c3a8ab 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -18,7 +18,6 @@ const ( tabAuthFiles tabAPIKeys tabOAuth - tabUsage tabLogs ) @@ -40,7 +39,6 @@ type App struct { auth authTabModel keys keysTabModel oauth oauthTabModel - usage usageTabModel logs logsTabModel client *Client @@ -50,7 +48,7 @@ type App struct { ready bool // Track which tabs have been initialized (fetched data) - initialized [7]bool + initialized [6]bool } type authConnectMsg struct { @@ -81,10 +79,9 @@ func NewApp(port int, secretKey string, hook *LogHook) App { auth: newAuthTabModel(client), keys: newKeysTabModel(client), oauth: newOAuthTabModel(client), - usage: newUsageTabModel(client), logs: newLogsTabModel(client, hook), client: client, - initialized: [7]bool{ + initialized: [6]bool{ tabDashboard: true, tabLogs: true, }, @@ -92,7 +89,7 @@ func NewApp(port int, secretKey string, hook *LogHook) App { app.refreshTabs() if authRequired { - app.initialized = [7]bool{} + app.initialized = [6]bool{} } app.setAuthInputPrompt() return app @@ -128,7 +125,6 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.auth.SetSize(contentW, contentH) a.keys.SetSize(contentW, contentH) a.oauth.SetSize(contentW, contentH) - a.usage.SetSize(contentW, contentH) a.logs.SetSize(contentW, contentH) return a, nil @@ -142,7 +138,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.authenticated = true a.logsEnabled = a.standalone || isLogsEnabledFromConfig(msg.cfg) a.refreshTabs() - a.initialized = [7]bool{} + a.initialized = [6]bool{} a.initialized[tabDashboard] = true cmds := []tea.Cmd{a.dashboard.Init()} if a.logsEnabled { @@ -258,8 +254,6 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.keys, cmd = a.keys.Update(msg) case tabOAuth: a.oauth, cmd = a.oauth.Update(msg) - case tabUsage: - a.usage, cmd = a.usage.Update(msg) case tabLogs: a.logs, cmd = a.logs.Update(msg) } @@ -322,8 +316,6 @@ func (a *App) initTabIfNeeded(_ int) tea.Cmd { return a.keys.Init() case tabOAuth: return a.oauth.Init() - case tabUsage: - return a.usage.Init() case tabLogs: if !a.logsEnabled { return nil @@ -360,8 +352,6 @@ func (a App) View() string { sb.WriteString(a.keys.View()) case tabOAuth: sb.WriteString(a.oauth.View()) - case tabUsage: - sb.WriteString(a.usage.View()) case tabLogs: if a.logsEnabled { sb.WriteString(a.logs.View()) @@ -529,10 +519,6 @@ func (a App) broadcastToAllTabs(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd != nil { cmds = append(cmds, cmd) } - a.usage, cmd = a.usage.Update(msg) - if cmd != nil { - cmds = append(cmds, cmd) - } a.logs, cmd = a.logs.Update(msg) if cmd != nil { cmds = append(cmds, cmd) diff --git a/internal/tui/client.go b/internal/tui/client.go index 6f75d6befc..747f30b985 100644 --- a/internal/tui/client.go +++ b/internal/tui/client.go @@ -140,11 +140,6 @@ func (c *Client) PutConfigYAML(yamlContent string) error { return err } -// GetUsage fetches usage statistics. -func (c *Client) GetUsage() (map[string]any, error) { - return c.getJSON("/v0/management/usage") -} - // GetAuthFiles lists auth credential files. // API returns {"files": [...]}. func (c *Client) GetAuthFiles() ([]map[string]any, error) { diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index 8561fe9c5b..99b5409c2e 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -22,14 +22,12 @@ type dashboardModel struct { // Cached data for re-rendering on locale change lastConfig map[string]any - lastUsage map[string]any lastAuthFiles []map[string]any lastAPIKeys []string } type dashboardDataMsg struct { config map[string]any - usage map[string]any authFiles []map[string]any apiKeys []string err error @@ -47,25 +45,24 @@ func (m dashboardModel) Init() tea.Cmd { func (m dashboardModel) fetchData() tea.Msg { cfg, cfgErr := m.client.GetConfig() - usage, usageErr := m.client.GetUsage() authFiles, authErr := m.client.GetAuthFiles() apiKeys, keysErr := m.client.GetAPIKeys() var err error - for _, e := range []error{cfgErr, usageErr, authErr, keysErr} { + for _, e := range []error{cfgErr, authErr, keysErr} { if e != nil { err = e break } } - return dashboardDataMsg{config: cfg, usage: usage, authFiles: authFiles, apiKeys: apiKeys, err: err} + return dashboardDataMsg{config: cfg, authFiles: authFiles, apiKeys: apiKeys, err: err} } func (m dashboardModel) Update(msg tea.Msg) (dashboardModel, tea.Cmd) { switch msg := msg.(type) { case localeChangedMsg: // Re-render immediately with cached data using new locale - m.content = m.renderDashboard(m.lastConfig, m.lastUsage, m.lastAuthFiles, m.lastAPIKeys) + m.content = m.renderDashboard(m.lastConfig, m.lastAuthFiles, m.lastAPIKeys) m.viewport.SetContent(m.content) // Also fetch fresh data in background return m, m.fetchData @@ -78,11 +75,10 @@ func (m dashboardModel) Update(msg tea.Msg) (dashboardModel, tea.Cmd) { m.err = nil // Cache data for locale switching m.lastConfig = msg.config - m.lastUsage = msg.usage m.lastAuthFiles = msg.authFiles m.lastAPIKeys = msg.apiKeys - m.content = m.renderDashboard(msg.config, msg.usage, msg.authFiles, msg.apiKeys) + m.content = m.renderDashboard(msg.config, msg.authFiles, msg.apiKeys) } m.viewport.SetContent(m.content) return m, nil @@ -121,7 +117,7 @@ func (m dashboardModel) View() string { return m.viewport.View() } -func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []map[string]any, apiKeys []string) string { +func (m dashboardModel) renderDashboard(cfg map[string]any, authFiles []map[string]any, apiKeys []string) string { var sb strings.Builder sb.WriteString(titleStyle.Render(T("dashboard_title"))) @@ -138,7 +134,7 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m // ━━━ Stats Cards ━━━ cardWidth := 25 if m.width > 0 { - cardWidth = (m.width - 6) / 4 + cardWidth = (m.width - 2) / 2 if cardWidth < 18 { cardWidth = 18 } @@ -173,34 +169,7 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s (%d %s)", T("auth_files_label"), activeAuth, T("active_suffix"))), )) - // Card 3: Total Requests - totalReqs := int64(0) - successReqs := int64(0) - failedReqs := int64(0) - totalTokens := int64(0) - if usage != nil { - if usageMap, ok := usage["usage"].(map[string]any); ok { - totalReqs = int64(getFloat(usageMap, "total_requests")) - successReqs = int64(getFloat(usageMap, "success_count")) - failedReqs = int64(getFloat(usageMap, "failure_count")) - totalTokens = int64(getFloat(usageMap, "total_tokens")) - } - } - card3 := cardStyle.Render(fmt.Sprintf( - "%s\n%s", - lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render(fmt.Sprintf("📈 %d", totalReqs)), - lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s (✓%d ✗%d)", T("total_requests"), successReqs, failedReqs)), - )) - - // Card 4: Total Tokens - tokenStr := formatLargeNumber(totalTokens) - card4 := cardStyle.Render(fmt.Sprintf( - "%s\n%s", - lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Render(fmt.Sprintf("🔤 %s", tokenStr)), - lipgloss.NewStyle().Foreground(colorMuted).Render(T("total_tokens")), - )) - - sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, card1, " ", card2, " ", card3, " ", card4)) + sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, card1, " ", card2)) sb.WriteString("\n\n") // ━━━ Current Config ━━━ @@ -258,38 +227,6 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m sb.WriteString("\n") - // ━━━ Per-Model Usage ━━━ - if usage != nil { - if usageMap, ok := usage["usage"].(map[string]any); ok { - if apis, ok := usageMap["apis"].(map[string]any); ok && len(apis) > 0 { - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("model_stats"))) - sb.WriteString("\n") - sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) - sb.WriteString("\n") - - header := fmt.Sprintf(" %-40s %10s %12s", T("model"), T("requests"), T("tokens")) - sb.WriteString(tableHeaderStyle.Render(header)) - sb.WriteString("\n") - - for _, apiSnap := range apis { - if apiMap, ok := apiSnap.(map[string]any); ok { - if models, ok := apiMap["models"].(map[string]any); ok { - for model, v := range models { - if stats, ok := v.(map[string]any); ok { - reqs := int64(getFloat(stats, "total_requests")) - toks := int64(getFloat(stats, "total_tokens")) - row := fmt.Sprintf(" %-40s %10d %12s", truncate(model, 40), reqs, formatLargeNumber(toks)) - sb.WriteString(tableCellStyle.Render(row)) - sb.WriteString("\n") - } - } - } - } - } - } - } - } - return sb.String() } diff --git a/internal/tui/i18n.go b/internal/tui/i18n.go index f6a33ca481..a4c0ac1658 100644 --- a/internal/tui/i18n.go +++ b/internal/tui/i18n.go @@ -50,8 +50,8 @@ var locales = map[string]map[string]string{ // ────────────────────────────────────────── // Tab names // ────────────────────────────────────────── -var zhTabNames = []string{"仪表盘", "配置", "认证文件", "API 密钥", "OAuth", "使用统计", "日志"} -var enTabNames = []string{"Dashboard", "Config", "Auth Files", "API Keys", "OAuth", "Usage", "Logs"} +var zhTabNames = []string{"仪表盘", "配置", "认证文件", "API 密钥", "OAuth", "日志"} +var enTabNames = []string{"Dashboard", "Config", "Auth Files", "API Keys", "OAuth", "Logs"} // TabNames returns tab names in the current locale. func TabNames() []string { diff --git a/internal/tui/usage_tab.go b/internal/tui/usage_tab.go deleted file mode 100644 index 6b9fef5e11..0000000000 --- a/internal/tui/usage_tab.go +++ /dev/null @@ -1,418 +0,0 @@ -package tui - -import ( - "fmt" - "sort" - "strings" - - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -// usageTabModel displays usage statistics with charts and breakdowns. -type usageTabModel struct { - client *Client - viewport viewport.Model - usage map[string]any - err error - width int - height int - ready bool -} - -type usageDataMsg struct { - usage map[string]any - err error -} - -func newUsageTabModel(client *Client) usageTabModel { - return usageTabModel{ - client: client, - } -} - -func (m usageTabModel) Init() tea.Cmd { - return m.fetchData -} - -func (m usageTabModel) fetchData() tea.Msg { - usage, err := m.client.GetUsage() - return usageDataMsg{usage: usage, err: err} -} - -func (m usageTabModel) Update(msg tea.Msg) (usageTabModel, tea.Cmd) { - switch msg := msg.(type) { - case localeChangedMsg: - m.viewport.SetContent(m.renderContent()) - return m, nil - case usageDataMsg: - if msg.err != nil { - m.err = msg.err - } else { - m.err = nil - m.usage = msg.usage - } - m.viewport.SetContent(m.renderContent()) - return m, nil - - case tea.KeyMsg: - if msg.String() == "r" { - return m, m.fetchData - } - var cmd tea.Cmd - m.viewport, cmd = m.viewport.Update(msg) - return m, cmd - } - - var cmd tea.Cmd - m.viewport, cmd = m.viewport.Update(msg) - return m, cmd -} - -func (m *usageTabModel) SetSize(w, h int) { - m.width = w - m.height = h - if !m.ready { - m.viewport = viewport.New(w, h) - m.viewport.SetContent(m.renderContent()) - m.ready = true - } else { - m.viewport.Width = w - m.viewport.Height = h - } -} - -func (m usageTabModel) View() string { - if !m.ready { - return T("loading") - } - return m.viewport.View() -} - -func (m usageTabModel) renderContent() string { - var sb strings.Builder - - sb.WriteString(titleStyle.Render(T("usage_title"))) - sb.WriteString("\n") - sb.WriteString(helpStyle.Render(T("usage_help"))) - sb.WriteString("\n\n") - - if m.err != nil { - sb.WriteString(errorStyle.Render("⚠ Error: " + m.err.Error())) - sb.WriteString("\n") - return sb.String() - } - - if m.usage == nil { - sb.WriteString(subtitleStyle.Render(T("usage_no_data"))) - sb.WriteString("\n") - return sb.String() - } - - usageMap, _ := m.usage["usage"].(map[string]any) - if usageMap == nil { - sb.WriteString(subtitleStyle.Render(T("usage_no_data"))) - sb.WriteString("\n") - return sb.String() - } - - totalReqs := int64(getFloat(usageMap, "total_requests")) - successCnt := int64(getFloat(usageMap, "success_count")) - failureCnt := int64(getFloat(usageMap, "failure_count")) - totalTokens := int64(getFloat(usageMap, "total_tokens")) - - // ━━━ Overview Cards ━━━ - cardWidth := 20 - if m.width > 0 { - cardWidth = (m.width - 6) / 4 - if cardWidth < 16 { - cardWidth = 16 - } - } - cardStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("240")). - Padding(0, 1). - Width(cardWidth). - Height(3) - - // Total Requests - card1 := cardStyle.Copy().BorderForeground(lipgloss.Color("111")).Render(fmt.Sprintf( - "%s\n%s\n%s", - lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_total_reqs")), - lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("111")).Render(fmt.Sprintf("%d", totalReqs)), - lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("● %s: %d ● %s: %d", T("usage_success"), successCnt, T("usage_failure"), failureCnt)), - )) - - // Total Tokens - card2 := cardStyle.Copy().BorderForeground(lipgloss.Color("214")).Render(fmt.Sprintf( - "%s\n%s\n%s", - lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_total_tokens")), - lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render(formatLargeNumber(totalTokens)), - lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s: %s", T("usage_total_token_l"), formatLargeNumber(totalTokens))), - )) - - // RPM - rpm := float64(0) - if totalReqs > 0 { - if rByH, ok := usageMap["requests_by_hour"].(map[string]any); ok && len(rByH) > 0 { - rpm = float64(totalReqs) / float64(len(rByH)) / 60.0 - } - } - card3 := cardStyle.Copy().BorderForeground(lipgloss.Color("76")).Render(fmt.Sprintf( - "%s\n%s\n%s", - lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_rpm")), - lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("76")).Render(fmt.Sprintf("%.2f", rpm)), - lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s: %d", T("usage_total_reqs"), totalReqs)), - )) - - // TPM - tpm := float64(0) - if totalTokens > 0 { - if tByH, ok := usageMap["tokens_by_hour"].(map[string]any); ok && len(tByH) > 0 { - tpm = float64(totalTokens) / float64(len(tByH)) / 60.0 - } - } - card4 := cardStyle.Copy().BorderForeground(lipgloss.Color("170")).Render(fmt.Sprintf( - "%s\n%s\n%s", - lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_tpm")), - lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Render(fmt.Sprintf("%.2f", tpm)), - lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s: %s", T("usage_total_tokens"), formatLargeNumber(totalTokens))), - )) - - sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, card1, " ", card2, " ", card3, " ", card4)) - sb.WriteString("\n\n") - - // ━━━ Requests by Hour (ASCII bar chart) ━━━ - if rByH, ok := usageMap["requests_by_hour"].(map[string]any); ok && len(rByH) > 0 { - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("usage_req_by_hour"))) - sb.WriteString("\n") - sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) - sb.WriteString("\n") - sb.WriteString(renderBarChart(rByH, m.width-6, lipgloss.Color("111"))) - sb.WriteString("\n") - } - - // ━━━ Tokens by Hour ━━━ - if tByH, ok := usageMap["tokens_by_hour"].(map[string]any); ok && len(tByH) > 0 { - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("usage_tok_by_hour"))) - sb.WriteString("\n") - sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) - sb.WriteString("\n") - sb.WriteString(renderBarChart(tByH, m.width-6, lipgloss.Color("214"))) - sb.WriteString("\n") - } - - // ━━━ Requests by Day ━━━ - if rByD, ok := usageMap["requests_by_day"].(map[string]any); ok && len(rByD) > 0 { - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("usage_req_by_day"))) - sb.WriteString("\n") - sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) - sb.WriteString("\n") - sb.WriteString(renderBarChart(rByD, m.width-6, lipgloss.Color("76"))) - sb.WriteString("\n") - } - - // ━━━ API Detail Stats ━━━ - if apis, ok := usageMap["apis"].(map[string]any); ok && len(apis) > 0 { - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("usage_api_detail"))) - sb.WriteString("\n") - sb.WriteString(strings.Repeat("─", minInt(m.width, 80))) - sb.WriteString("\n") - - header := fmt.Sprintf(" %-30s %10s %12s", "API", T("requests"), T("tokens")) - sb.WriteString(tableHeaderStyle.Render(header)) - sb.WriteString("\n") - - for apiName, apiSnap := range apis { - if apiMap, ok := apiSnap.(map[string]any); ok { - apiReqs := int64(getFloat(apiMap, "total_requests")) - apiToks := int64(getFloat(apiMap, "total_tokens")) - - row := fmt.Sprintf(" %-30s %10d %12s", - truncate(maskKey(apiName), 30), apiReqs, formatLargeNumber(apiToks)) - sb.WriteString(lipgloss.NewStyle().Bold(true).Render(row)) - sb.WriteString("\n") - - // Per-model breakdown - if models, ok := apiMap["models"].(map[string]any); ok { - for model, v := range models { - if stats, ok := v.(map[string]any); ok { - mReqs := int64(getFloat(stats, "total_requests")) - mToks := int64(getFloat(stats, "total_tokens")) - mRow := fmt.Sprintf(" ├─ %-28s %10d %12s", - truncate(model, 28), mReqs, formatLargeNumber(mToks)) - sb.WriteString(tableCellStyle.Render(mRow)) - sb.WriteString("\n") - - // Token type breakdown from details - sb.WriteString(m.renderTokenBreakdown(stats)) - - // Latency breakdown from details - sb.WriteString(m.renderLatencyBreakdown(stats)) - } - } - } - } - } - } - - sb.WriteString("\n") - return sb.String() -} - -// renderTokenBreakdown aggregates input/output/cached/reasoning tokens from model details. -func (m usageTabModel) renderTokenBreakdown(modelStats map[string]any) string { - details, ok := modelStats["details"] - if !ok { - return "" - } - detailList, ok := details.([]any) - if !ok || len(detailList) == 0 { - return "" - } - - var inputTotal, outputTotal, cachedTotal, reasoningTotal int64 - for _, d := range detailList { - dm, ok := d.(map[string]any) - if !ok { - continue - } - tokens, ok := dm["tokens"].(map[string]any) - if !ok { - continue - } - inputTotal += int64(getFloat(tokens, "input_tokens")) - outputTotal += int64(getFloat(tokens, "output_tokens")) - cachedTotal += int64(getFloat(tokens, "cached_tokens")) - reasoningTotal += int64(getFloat(tokens, "reasoning_tokens")) - } - - if inputTotal == 0 && outputTotal == 0 && cachedTotal == 0 && reasoningTotal == 0 { - return "" - } - - parts := []string{} - if inputTotal > 0 { - parts = append(parts, fmt.Sprintf("%s:%s", T("usage_input"), formatLargeNumber(inputTotal))) - } - if outputTotal > 0 { - parts = append(parts, fmt.Sprintf("%s:%s", T("usage_output"), formatLargeNumber(outputTotal))) - } - if cachedTotal > 0 { - parts = append(parts, fmt.Sprintf("%s:%s", T("usage_cached"), formatLargeNumber(cachedTotal))) - } - if reasoningTotal > 0 { - parts = append(parts, fmt.Sprintf("%s:%s", T("usage_reasoning"), formatLargeNumber(reasoningTotal))) - } - - return fmt.Sprintf(" │ %s\n", - lipgloss.NewStyle().Foreground(colorMuted).Render(strings.Join(parts, " "))) -} - -// renderLatencyBreakdown aggregates latency_ms from model details and displays avg/min/max. -func (m usageTabModel) renderLatencyBreakdown(modelStats map[string]any) string { - details, ok := modelStats["details"] - if !ok { - return "" - } - detailList, ok := details.([]any) - if !ok || len(detailList) == 0 { - return "" - } - - var totalLatency int64 - var count int - var minLatency, maxLatency int64 - first := true - - for _, d := range detailList { - dm, ok := d.(map[string]any) - if !ok { - continue - } - latencyMs := int64(getFloat(dm, "latency_ms")) - if latencyMs <= 0 { - continue - } - totalLatency += latencyMs - count++ - if first { - minLatency = latencyMs - maxLatency = latencyMs - first = false - } else { - if latencyMs < minLatency { - minLatency = latencyMs - } - if latencyMs > maxLatency { - maxLatency = latencyMs - } - } - } - - if count == 0 { - return "" - } - - avgLatency := totalLatency / int64(count) - return fmt.Sprintf(" │ %s: avg %dms min %dms max %dms\n", - lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_time")), - avgLatency, minLatency, maxLatency) -} - -// renderBarChart renders a simple ASCII horizontal bar chart. -func renderBarChart(data map[string]any, maxBarWidth int, barColor lipgloss.Color) string { - if maxBarWidth < 10 { - maxBarWidth = 10 - } - - // Sort keys - keys := make([]string, 0, len(data)) - for k := range data { - keys = append(keys, k) - } - sort.Strings(keys) - - // Find max value - maxVal := float64(0) - for _, k := range keys { - v := getFloat(data, k) - if v > maxVal { - maxVal = v - } - } - if maxVal == 0 { - return "" - } - - barStyle := lipgloss.NewStyle().Foreground(barColor) - var sb strings.Builder - - labelWidth := 12 - barAvail := maxBarWidth - labelWidth - 12 - if barAvail < 5 { - barAvail = 5 - } - - for _, k := range keys { - v := getFloat(data, k) - barLen := int(v / maxVal * float64(barAvail)) - if barLen < 1 && v > 0 { - barLen = 1 - } - bar := strings.Repeat("█", barLen) - label := k - if len(label) > labelWidth { - label = label[:labelWidth] - } - sb.WriteString(fmt.Sprintf(" %-*s %s %s\n", - labelWidth, label, - barStyle.Render(bar), - lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%.0f", v)), - )) - } - - return sb.String() -} diff --git a/internal/tui/usage_tab_test.go b/internal/tui/usage_tab_test.go deleted file mode 100644 index 4fffcd989f..0000000000 --- a/internal/tui/usage_tab_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package tui - -import ( - "strings" - "testing" -) - -func TestRenderLatencyBreakdown(t *testing.T) { - tests := []struct { - name string - modelStats map[string]any - wantEmpty bool - wantContains string - }{ - { - name: "no details", - modelStats: map[string]any{}, - wantEmpty: true, - }, - { - name: "empty details", - modelStats: map[string]any{ - "details": []any{}, - }, - wantEmpty: true, - }, - { - name: "details with zero latency", - modelStats: map[string]any{ - "details": []any{ - map[string]any{ - "latency_ms": float64(0), - }, - }, - }, - wantEmpty: true, - }, - { - name: "single request with latency", - modelStats: map[string]any{ - "details": []any{ - map[string]any{ - "latency_ms": float64(1500), - }, - }, - }, - wantEmpty: false, - wantContains: "avg 1500ms min 1500ms max 1500ms", - }, - { - name: "multiple requests with varying latency", - modelStats: map[string]any{ - "details": []any{ - map[string]any{ - "latency_ms": float64(100), - }, - map[string]any{ - "latency_ms": float64(200), - }, - map[string]any{ - "latency_ms": float64(300), - }, - }, - }, - wantEmpty: false, - wantContains: "avg 200ms min 100ms max 300ms", - }, - { - name: "mixed valid and invalid latency values", - modelStats: map[string]any{ - "details": []any{ - map[string]any{ - "latency_ms": float64(500), - }, - map[string]any{ - "latency_ms": float64(0), - }, - map[string]any{ - "latency_ms": float64(1500), - }, - }, - }, - wantEmpty: false, - wantContains: "avg 1000ms min 500ms max 1500ms", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := usageTabModel{} - result := m.renderLatencyBreakdown(tt.modelStats) - - if tt.wantEmpty { - if result != "" { - t.Errorf("renderLatencyBreakdown() = %q, want empty string", result) - } - return - } - - if result == "" { - t.Errorf("renderLatencyBreakdown() = empty, want non-empty string") - return - } - - if tt.wantContains != "" && !strings.Contains(result, tt.wantContains) { - t.Errorf("renderLatencyBreakdown() = %q, want to contain %q", result, tt.wantContains) - } - }) - } -} - -func TestUsageTimeTranslations(t *testing.T) { - prevLocale := CurrentLocale() - t.Cleanup(func() { - SetLocale(prevLocale) - }) - - tests := []struct { - locale string - want string - }{ - {locale: "en", want: "Time"}, - {locale: "zh", want: "时间"}, - } - - for _, tt := range tests { - t.Run(tt.locale, func(t *testing.T) { - SetLocale(tt.locale) - if got := T("usage_time"); got != tt.want { - t.Fatalf("T(usage_time) = %q, want %q", got, tt.want) - } - }) - } -} diff --git a/internal/usage/logger_plugin.go b/internal/usage/logger_plugin.go deleted file mode 100644 index 9d59de4feb..0000000000 --- a/internal/usage/logger_plugin.go +++ /dev/null @@ -1,464 +0,0 @@ -// Package usage provides usage tracking and logging functionality for the CLI Proxy API server. -// It includes plugins for monitoring API usage, token consumption, and other metrics -// to help with observability and billing purposes. -package usage - -import ( - "context" - "fmt" - "strings" - "sync" - "sync/atomic" - "time" - - internallogging "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" - coreusage "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" -) - -var statisticsEnabled atomic.Bool - -func init() { - statisticsEnabled.Store(true) - coreusage.RegisterPlugin(NewLoggerPlugin()) -} - -// LoggerPlugin collects in-memory request statistics for usage analysis. -// It implements coreusage.Plugin to receive usage records emitted by the runtime. -type LoggerPlugin struct { - stats *RequestStatistics -} - -// NewLoggerPlugin constructs a new logger plugin instance. -// -// Returns: -// - *LoggerPlugin: A new logger plugin instance wired to the shared statistics store. -func NewLoggerPlugin() *LoggerPlugin { return &LoggerPlugin{stats: defaultRequestStatistics} } - -// HandleUsage implements coreusage.Plugin. -// It updates the in-memory statistics store whenever a usage record is received. -// -// Parameters: -// - ctx: The context for the usage record -// - record: The usage record to aggregate -func (p *LoggerPlugin) HandleUsage(ctx context.Context, record coreusage.Record) { - if !statisticsEnabled.Load() { - return - } - if p == nil || p.stats == nil { - return - } - p.stats.Record(ctx, record) -} - -// SetStatisticsEnabled toggles whether in-memory statistics are recorded. -func SetStatisticsEnabled(enabled bool) { statisticsEnabled.Store(enabled) } - -// StatisticsEnabled reports the current recording state. -func StatisticsEnabled() bool { return statisticsEnabled.Load() } - -// RequestStatistics maintains aggregated request metrics in memory. -type RequestStatistics struct { - mu sync.RWMutex - - totalRequests int64 - successCount int64 - failureCount int64 - totalTokens int64 - - apis map[string]*apiStats - - requestsByDay map[string]int64 - requestsByHour map[int]int64 - tokensByDay map[string]int64 - tokensByHour map[int]int64 -} - -// apiStats holds aggregated metrics for a single API key. -type apiStats struct { - TotalRequests int64 - TotalTokens int64 - Models map[string]*modelStats -} - -// modelStats holds aggregated metrics for a specific model within an API. -type modelStats struct { - TotalRequests int64 - TotalTokens int64 - Details []RequestDetail -} - -// RequestDetail stores the timestamp, latency, and token usage for a single request. -type RequestDetail struct { - Timestamp time.Time `json:"timestamp"` - LatencyMs int64 `json:"latency_ms"` - Source string `json:"source"` - AuthIndex string `json:"auth_index"` - Tokens TokenStats `json:"tokens"` - Failed bool `json:"failed"` -} - -// TokenStats captures the token usage breakdown for a request. -type TokenStats struct { - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` - ReasoningTokens int64 `json:"reasoning_tokens"` - CachedTokens int64 `json:"cached_tokens"` - TotalTokens int64 `json:"total_tokens"` -} - -// StatisticsSnapshot represents an immutable view of the aggregated metrics. -type StatisticsSnapshot struct { - TotalRequests int64 `json:"total_requests"` - SuccessCount int64 `json:"success_count"` - FailureCount int64 `json:"failure_count"` - TotalTokens int64 `json:"total_tokens"` - - APIs map[string]APISnapshot `json:"apis"` - - RequestsByDay map[string]int64 `json:"requests_by_day"` - RequestsByHour map[string]int64 `json:"requests_by_hour"` - TokensByDay map[string]int64 `json:"tokens_by_day"` - TokensByHour map[string]int64 `json:"tokens_by_hour"` -} - -// APISnapshot summarises metrics for a single API key. -type APISnapshot struct { - TotalRequests int64 `json:"total_requests"` - TotalTokens int64 `json:"total_tokens"` - Models map[string]ModelSnapshot `json:"models"` -} - -// ModelSnapshot summarises metrics for a specific model. -type ModelSnapshot struct { - TotalRequests int64 `json:"total_requests"` - TotalTokens int64 `json:"total_tokens"` - Details []RequestDetail `json:"details"` -} - -var defaultRequestStatistics = NewRequestStatistics() - -// GetRequestStatistics returns the shared statistics store. -func GetRequestStatistics() *RequestStatistics { return defaultRequestStatistics } - -// NewRequestStatistics constructs an empty statistics store. -func NewRequestStatistics() *RequestStatistics { - return &RequestStatistics{ - apis: make(map[string]*apiStats), - requestsByDay: make(map[string]int64), - requestsByHour: make(map[int]int64), - tokensByDay: make(map[string]int64), - tokensByHour: make(map[int]int64), - } -} - -// Record ingests a new usage record and updates the aggregates. -func (s *RequestStatistics) Record(ctx context.Context, record coreusage.Record) { - if s == nil { - return - } - if !statisticsEnabled.Load() { - return - } - timestamp := record.RequestedAt - if timestamp.IsZero() { - timestamp = time.Now() - } - detail := normaliseDetail(record.Detail) - totalTokens := detail.TotalTokens - statsKey := record.APIKey - if statsKey == "" { - statsKey = resolveAPIIdentifier(ctx, record) - } - failed := record.Failed - if !failed { - failed = !resolveSuccess(ctx) - } - success := !failed - modelName := record.Model - if modelName == "" { - modelName = "unknown" - } - dayKey := timestamp.Format("2006-01-02") - hourKey := timestamp.Hour() - - s.mu.Lock() - defer s.mu.Unlock() - - s.totalRequests++ - if success { - s.successCount++ - } else { - s.failureCount++ - } - s.totalTokens += totalTokens - - stats, ok := s.apis[statsKey] - if !ok { - stats = &apiStats{Models: make(map[string]*modelStats)} - s.apis[statsKey] = stats - } - s.updateAPIStats(stats, modelName, RequestDetail{ - Timestamp: timestamp, - LatencyMs: normaliseLatency(record.Latency), - Source: record.Source, - AuthIndex: record.AuthIndex, - Tokens: detail, - Failed: failed, - }) - - s.requestsByDay[dayKey]++ - s.requestsByHour[hourKey]++ - s.tokensByDay[dayKey] += totalTokens - s.tokensByHour[hourKey] += totalTokens -} - -func (s *RequestStatistics) updateAPIStats(stats *apiStats, model string, detail RequestDetail) { - stats.TotalRequests++ - stats.TotalTokens += detail.Tokens.TotalTokens - modelStatsValue, ok := stats.Models[model] - if !ok { - modelStatsValue = &modelStats{} - stats.Models[model] = modelStatsValue - } - modelStatsValue.TotalRequests++ - modelStatsValue.TotalTokens += detail.Tokens.TotalTokens - modelStatsValue.Details = append(modelStatsValue.Details, detail) -} - -// Snapshot returns a copy of the aggregated metrics for external consumption. -func (s *RequestStatistics) Snapshot() StatisticsSnapshot { - result := StatisticsSnapshot{} - if s == nil { - return result - } - - s.mu.RLock() - defer s.mu.RUnlock() - - result.TotalRequests = s.totalRequests - result.SuccessCount = s.successCount - result.FailureCount = s.failureCount - result.TotalTokens = s.totalTokens - - result.APIs = make(map[string]APISnapshot, len(s.apis)) - for apiName, stats := range s.apis { - apiSnapshot := APISnapshot{ - TotalRequests: stats.TotalRequests, - TotalTokens: stats.TotalTokens, - Models: make(map[string]ModelSnapshot, len(stats.Models)), - } - for modelName, modelStatsValue := range stats.Models { - requestDetails := make([]RequestDetail, len(modelStatsValue.Details)) - copy(requestDetails, modelStatsValue.Details) - apiSnapshot.Models[modelName] = ModelSnapshot{ - TotalRequests: modelStatsValue.TotalRequests, - TotalTokens: modelStatsValue.TotalTokens, - Details: requestDetails, - } - } - result.APIs[apiName] = apiSnapshot - } - - result.RequestsByDay = make(map[string]int64, len(s.requestsByDay)) - for k, v := range s.requestsByDay { - result.RequestsByDay[k] = v - } - - result.RequestsByHour = make(map[string]int64, len(s.requestsByHour)) - for hour, v := range s.requestsByHour { - key := formatHour(hour) - result.RequestsByHour[key] = v - } - - result.TokensByDay = make(map[string]int64, len(s.tokensByDay)) - for k, v := range s.tokensByDay { - result.TokensByDay[k] = v - } - - result.TokensByHour = make(map[string]int64, len(s.tokensByHour)) - for hour, v := range s.tokensByHour { - key := formatHour(hour) - result.TokensByHour[key] = v - } - - return result -} - -type MergeResult struct { - Added int64 `json:"added"` - Skipped int64 `json:"skipped"` -} - -// MergeSnapshot merges an exported statistics snapshot into the current store. -// Existing data is preserved and duplicate request details are skipped. -func (s *RequestStatistics) MergeSnapshot(snapshot StatisticsSnapshot) MergeResult { - result := MergeResult{} - if s == nil { - return result - } - - s.mu.Lock() - defer s.mu.Unlock() - - seen := make(map[string]struct{}) - for apiName, stats := range s.apis { - if stats == nil { - continue - } - for modelName, modelStatsValue := range stats.Models { - if modelStatsValue == nil { - continue - } - for _, detail := range modelStatsValue.Details { - seen[dedupKey(apiName, modelName, detail)] = struct{}{} - } - } - } - - for apiName, apiSnapshot := range snapshot.APIs { - apiName = strings.TrimSpace(apiName) - if apiName == "" { - continue - } - stats, ok := s.apis[apiName] - if !ok || stats == nil { - stats = &apiStats{Models: make(map[string]*modelStats)} - s.apis[apiName] = stats - } else if stats.Models == nil { - stats.Models = make(map[string]*modelStats) - } - for modelName, modelSnapshot := range apiSnapshot.Models { - modelName = strings.TrimSpace(modelName) - if modelName == "" { - modelName = "unknown" - } - for _, detail := range modelSnapshot.Details { - detail.Tokens = normaliseTokenStats(detail.Tokens) - if detail.LatencyMs < 0 { - detail.LatencyMs = 0 - } - if detail.Timestamp.IsZero() { - detail.Timestamp = time.Now() - } - key := dedupKey(apiName, modelName, detail) - if _, exists := seen[key]; exists { - result.Skipped++ - continue - } - seen[key] = struct{}{} - s.recordImported(apiName, modelName, stats, detail) - result.Added++ - } - } - } - - return result -} - -func (s *RequestStatistics) recordImported(apiName, modelName string, stats *apiStats, detail RequestDetail) { - totalTokens := detail.Tokens.TotalTokens - if totalTokens < 0 { - totalTokens = 0 - } - - s.totalRequests++ - if detail.Failed { - s.failureCount++ - } else { - s.successCount++ - } - s.totalTokens += totalTokens - - s.updateAPIStats(stats, modelName, detail) - - dayKey := detail.Timestamp.Format("2006-01-02") - hourKey := detail.Timestamp.Hour() - - s.requestsByDay[dayKey]++ - s.requestsByHour[hourKey]++ - s.tokensByDay[dayKey] += totalTokens - s.tokensByHour[hourKey] += totalTokens -} - -func dedupKey(apiName, modelName string, detail RequestDetail) string { - timestamp := detail.Timestamp.UTC().Format(time.RFC3339Nano) - tokens := normaliseTokenStats(detail.Tokens) - return fmt.Sprintf( - "%s|%s|%s|%s|%s|%t|%d|%d|%d|%d|%d", - apiName, - modelName, - timestamp, - detail.Source, - detail.AuthIndex, - detail.Failed, - tokens.InputTokens, - tokens.OutputTokens, - tokens.ReasoningTokens, - tokens.CachedTokens, - tokens.TotalTokens, - ) -} - -func resolveAPIIdentifier(ctx context.Context, record coreusage.Record) string { - if ctx != nil { - if endpoint := strings.TrimSpace(internallogging.GetEndpoint(ctx)); endpoint != "" { - return endpoint - } - } - if record.Provider != "" { - return record.Provider - } - return "unknown" -} - -func resolveSuccess(ctx context.Context) bool { - status := internallogging.GetResponseStatus(ctx) - if status == 0 { - return true - } - return status < httpStatusBadRequest -} - -const httpStatusBadRequest = 400 - -func normaliseDetail(detail coreusage.Detail) TokenStats { - tokens := TokenStats{ - InputTokens: detail.InputTokens, - OutputTokens: detail.OutputTokens, - ReasoningTokens: detail.ReasoningTokens, - CachedTokens: detail.CachedTokens, - TotalTokens: detail.TotalTokens, - } - if tokens.TotalTokens == 0 { - tokens.TotalTokens = detail.InputTokens + detail.OutputTokens + detail.ReasoningTokens - } - if tokens.TotalTokens == 0 { - tokens.TotalTokens = detail.InputTokens + detail.OutputTokens + detail.ReasoningTokens + detail.CachedTokens - } - return tokens -} - -func normaliseTokenStats(tokens TokenStats) TokenStats { - if tokens.TotalTokens == 0 { - tokens.TotalTokens = tokens.InputTokens + tokens.OutputTokens + tokens.ReasoningTokens - } - if tokens.TotalTokens == 0 { - tokens.TotalTokens = tokens.InputTokens + tokens.OutputTokens + tokens.ReasoningTokens + tokens.CachedTokens - } - return tokens -} - -func normaliseLatency(latency time.Duration) int64 { - if latency <= 0 { - return 0 - } - return latency.Milliseconds() -} - -func formatHour(hour int) string { - if hour < 0 { - hour = 0 - } - hour = hour % 24 - return fmt.Sprintf("%02d", hour) -} diff --git a/internal/usage/logger_plugin_test.go b/internal/usage/logger_plugin_test.go deleted file mode 100644 index 842b3f0cad..0000000000 --- a/internal/usage/logger_plugin_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package usage - -import ( - "context" - "testing" - "time" - - coreusage "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" -) - -func TestRequestStatisticsRecordIncludesLatency(t *testing.T) { - stats := NewRequestStatistics() - stats.Record(context.Background(), coreusage.Record{ - APIKey: "test-key", - Model: "gpt-5.4", - RequestedAt: time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC), - Latency: 1500 * time.Millisecond, - Detail: coreusage.Detail{ - InputTokens: 10, - OutputTokens: 20, - TotalTokens: 30, - }, - }) - - snapshot := stats.Snapshot() - details := snapshot.APIs["test-key"].Models["gpt-5.4"].Details - if len(details) != 1 { - t.Fatalf("details len = %d, want 1", len(details)) - } - if details[0].LatencyMs != 1500 { - t.Fatalf("latency_ms = %d, want 1500", details[0].LatencyMs) - } -} - -func TestRequestStatisticsMergeSnapshotDedupIgnoresLatency(t *testing.T) { - stats := NewRequestStatistics() - timestamp := time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC) - first := StatisticsSnapshot{ - APIs: map[string]APISnapshot{ - "test-key": { - Models: map[string]ModelSnapshot{ - "gpt-5.4": { - Details: []RequestDetail{{ - Timestamp: timestamp, - LatencyMs: 0, - Source: "user@example.com", - AuthIndex: "0", - Tokens: TokenStats{ - InputTokens: 10, - OutputTokens: 20, - TotalTokens: 30, - }, - }}, - }, - }, - }, - }, - } - second := StatisticsSnapshot{ - APIs: map[string]APISnapshot{ - "test-key": { - Models: map[string]ModelSnapshot{ - "gpt-5.4": { - Details: []RequestDetail{{ - Timestamp: timestamp, - LatencyMs: 2500, - Source: "user@example.com", - AuthIndex: "0", - Tokens: TokenStats{ - InputTokens: 10, - OutputTokens: 20, - TotalTokens: 30, - }, - }}, - }, - }, - }, - }, - } - - result := stats.MergeSnapshot(first) - if result.Added != 1 || result.Skipped != 0 { - t.Fatalf("first merge = %+v, want added=1 skipped=0", result) - } - - result = stats.MergeSnapshot(second) - if result.Added != 0 || result.Skipped != 1 { - t.Fatalf("second merge = %+v, want added=0 skipped=1", result) - } - - snapshot := stats.Snapshot() - details := snapshot.APIs["test-key"].Models["gpt-5.4"].Details - if len(details) != 1 { - t.Fatalf("details len = %d, want 1", len(details)) - } -} diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index d9613150e0..9f195f5679 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -16,7 +16,6 @@ import ( _ "github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue" "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher" "github.com/router-for-me/CLIProxyAPI/v6/internal/wsrelay" sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" diff --git a/test/usage_logging_test.go b/test/usage_logging_test.go index 41c2ee341a..ee03c4d79c 100644 --- a/test/usage_logging_test.go +++ b/test/usage_logging_test.go @@ -2,6 +2,7 @@ package test import ( "context" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -9,14 +10,14 @@ import ( "time" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue" runtimeexecutor "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor" - internalusage "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" ) -func TestGeminiExecutorRecordsSuccessfulZeroUsageInStatistics(t *testing.T) { +func TestGeminiExecutorRecordsSuccessfulZeroUsageInQueue(t *testing.T) { model := fmt.Sprintf("gemini-2.5-flash-zero-usage-%d", time.Now().UnixNano()) source := fmt.Sprintf("zero-usage-%d@example.com", time.Now().UnixNano()) @@ -42,10 +43,15 @@ func TestGeminiExecutorRecordsSuccessfulZeroUsageInStatistics(t *testing.T) { }, } - prevStatsEnabled := internalusage.StatisticsEnabled() - internalusage.SetStatisticsEnabled(true) + prevQueueEnabled := redisqueue.Enabled() + prevUsageEnabled := redisqueue.UsageStatisticsEnabled() + redisqueue.SetEnabled(false) + redisqueue.SetEnabled(true) + redisqueue.SetUsageStatisticsEnabled(true) t.Cleanup(func() { - internalusage.SetStatisticsEnabled(prevStatsEnabled) + redisqueue.SetEnabled(false) + redisqueue.SetEnabled(prevQueueEnabled) + redisqueue.SetUsageStatisticsEnabled(prevUsageEnabled) }) _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ @@ -59,39 +65,58 @@ func TestGeminiExecutorRecordsSuccessfulZeroUsageInStatistics(t *testing.T) { t.Fatalf("Execute error: %v", err) } - detail := waitForStatisticsDetail(t, "gemini", model, source) - if detail.Failed { - t.Fatalf("detail failed = true, want false") - } - if detail.Tokens.TotalTokens != 0 { - t.Fatalf("total tokens = %d, want 0", detail.Tokens.TotalTokens) - } + waitForQueuedUsageModelTotalTokens(t, "gemini", model, 0) } -func waitForStatisticsDetail(t *testing.T, apiName, model, source string) internalusage.RequestDetail { +func waitForQueuedUsageModelTotalTokens(t *testing.T, wantProvider, wantModel string, wantTokens int64) { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { - snapshot := internalusage.GetRequestStatistics().Snapshot() - apiSnapshot, ok := snapshot.APIs[apiName] - if !ok { - time.Sleep(10 * time.Millisecond) - continue - } - modelSnapshot, ok := apiSnapshot.Models[model] - if !ok { - time.Sleep(10 * time.Millisecond) - continue - } - for _, detail := range modelSnapshot.Details { - if detail.Source == source { - return detail + items := redisqueue.PopOldest(10) + for _, item := range items { + got, ok := parseQueuedUsagePayload(t, item) + if !ok { + continue } + if got.Provider != wantProvider || got.Model != wantModel { + continue + } + if got.Failed { + t.Fatalf("payload failed = true, want false") + } + if got.Tokens.TotalTokens != wantTokens { + t.Fatalf("payload total tokens = %d, want %d", got.Tokens.TotalTokens, wantTokens) + } + return } time.Sleep(10 * time.Millisecond) } - t.Fatalf("timed out waiting for statistics detail for api=%q model=%q source=%q", apiName, model, source) - return internalusage.RequestDetail{} + t.Fatalf("timed out waiting for queued usage payload for provider=%q model=%q", wantProvider, wantModel) +} + +type queuedUsagePayload struct { + Provider string `json:"provider"` + Model string `json:"model"` + Failed bool `json:"failed"` + Tokens struct { + TotalTokens int64 `json:"total_tokens"` + } `json:"tokens"` +} + +func parseQueuedUsagePayload(t *testing.T, payload []byte) (queuedUsagePayload, bool) { + t.Helper() + + var parsed queuedUsagePayload + if len(payload) == 0 { + return parsed, false + } + if err := json.Unmarshal(payload, &parsed); err != nil { + return parsed, false + } + if parsed.Provider == "" || parsed.Model == "" { + return parsed, false + } + return parsed, true } From 79579c34bf9ea72f51ccaea53908741d84d05829 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 2 May 2026 13:35:19 +0800 Subject: [PATCH 088/139] docs: update README to consolidate and clarify CPA Usage Keeper details - Moved CPA Usage Keeper from "CLI tools" to a dedicated "Usage Statistics" section. - Added details on its functionality, periodic data sync, SQLite storage, and built-in dashboard. - Applied updates across English, Chinese, and Japanese README files for consistency. --- README.md | 12 ++++++++---- README_CN.md | 12 ++++++++---- README_JA.md | 12 ++++++++---- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 93ef6f71d3..3958668e23 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,14 @@ CLIProxyAPI Guides: [https://help.router-for.me/](https://help.router-for.me/) see [MANAGEMENT_API.md](https://help.router-for.me/management/api) +## Usage Statistics + +Since v6.10.0, CLIProxyAPI and [CPAMC](https://github.com/router-for-me/Cli-Proxy-API-Management-Center) no longer ship built-in usage statistics. If you need usage statistics, use: + +### [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper) + +Standalone persistence and visualization service for CLIProxyAPI, with periodic data sync, SQLite storage, aggregate APIs, and a built-in dashboard for usage and statistics. + ## Amp CLI Support CLIProxyAPI includes integrated support for [Amp CLI](https://ampcode.com) and Amp IDE extensions, enabling you to use your Google/ChatGPT/Claude OAuth subscriptions with Amp's coding tools: @@ -183,10 +191,6 @@ Cross-platform desktop app (macOS, Windows, Linux) wrapping CLIProxyAPI with a n Ready-to-use cross-platform quota inspector for CLIProxyAPI, supporting per-account codex 5h/7d quota windows, plan-based sorting, status coloring, and multi-account summary analytics. -### [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper) - -Standalone persistence and visualization service for CLIProxyAPI, with periodic data sync, SQLite storage, aggregate APIs, and a built-in dashboard for usage and statistics. - ### [CodexCliPlus](https://github.com/C4AL/CodexCliPlus) Windows-focused, local-first desktop management platform for Codex CLI built on CLIProxyAPI, focused on simplifying local setup, account and runtime management, and providing a more complete Codex CLI experience for local users. diff --git a/README_CN.md b/README_CN.md index 6199095c11..5c341d2773 100644 --- a/README_CN.md +++ b/README_CN.md @@ -74,6 +74,14 @@ CLIProxyAPI 用户手册: [https://help.router-for.me/](https://help.router-fo 请参见 [MANAGEMENT_API_CN.md](https://help.router-for.me/cn/management/api) +## 使用量统计 + +自v6.10.0版本以后,CLIProxyAPI及 [CPAMC](https://github.com/router-for-me/Cli-Proxy-API-Management-Center) 项目不再预置数据统计功能,如果有数据统计需求的请使用以下项目: + +### [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper) + +独立的 CLIProxyAPI 使用量持久化与可视化服务,定期同步 CLIProxyAPI 数据,存储到 SQLite,提供聚合 API,并内置使用量分析与统计仪表盘。 + ## Amp CLI 支持 CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支持,可让你使用自己的 Google/ChatGPT/Claude OAuth 订阅来配合 Amp 编码工具: @@ -179,10 +187,6 @@ Shadow AI 是一款专为受限环境设计的 AI 辅助工具。提供无窗口 上手即用的面向 CLIProxyAPI 跨平台配额查询工具,支持按账号展示 codex 5h/7d 配额窗口、按计划排序、状态着色及多账号汇总分析。 -### [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper) - -独立的 CLIProxyAPI 使用量持久化与可视化服务,定期同步 CPA 数据,存储到 SQLite,提供聚合 API,并内置使用量分析与统计仪表盘。 - ### [CodexCliPlus](https://github.com/C4AL/CodexCliPlus) 基于 CLIProxyAPI 的 Windows Codex CLI 本地优先桌面管理平台,聚焦简化本机配置、账号与运行状态管理,并为本地用户提供更完整的 Codex CLI 使用体验。 diff --git a/README_JA.md b/README_JA.md index 1bb30d48e6..cbb37767b6 100644 --- a/README_JA.md +++ b/README_JA.md @@ -72,6 +72,14 @@ CLIProxyAPIガイド:[https://help.router-for.me/](https://help.router-for.me/ [MANAGEMENT_API.md](https://help.router-for.me/management/api)を参照 +## 使用量統計 + +v6.10.0以降、CLIProxyAPIおよび [CPAMC](https://github.com/router-for-me/Cli-Proxy-API-Management-Center) プロジェクトには使用量統計機能がプリセットされなくなりました。使用量統計が必要な場合は、次のプロジェクトをご利用ください: + +### [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper) + +CLIProxyAPI向けの独立した使用量永続化・可視化サービス。CLIProxyAPIデータを定期同期してSQLiteに保存し、集計APIと、使用量や各種統計を確認できる組み込みダッシュボードを提供します。 + ## Amp CLIサポート CLIProxyAPIは[Amp CLI](https://ampcode.com)およびAmp IDE拡張機能の統合サポートを含んでおり、Google/ChatGPT/ClaudeのOAuthサブスクリプションをAmpのコーディングツールで使用できます: @@ -178,10 +186,6 @@ CLIProxyAPIをネイティブGUIでラップしたクロスプラットフォー CLIProxyAPI向けのすぐに使えるクロスプラットフォームのクォータ確認ツール。アカウントごとの codex 5h/7d クォータ表示、プラン別ソート、ステータス色分け、複数アカウントの集計分析に対応。 -### [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper) - -CLIProxyAPI向けの独立した使用量永続化・可視化サービス。CPAデータを定期同期してSQLiteに保存し、集計APIと、使用量や各種統計を確認できる組み込みダッシュボードを提供します。 - ### [CodexCliPlus](https://github.com/C4AL/CodexCliPlus) CLIProxyAPIを基盤にしたWindows向けのローカル優先Codex CLIデスクトップ管理プラットフォーム。ローカル設定、アカウント、実行状態の管理を簡素化し、ローカルユーザーにより包括的なCodex CLI体験を提供します。 From 2efa56dbb8191f02a0c43aee0075fa04cb899775 Mon Sep 17 00:00:00 2001 From: daishuge Date: Sat, 2 May 2026 15:34:57 +0800 Subject: [PATCH 089/139] docs: add Playful Proxy API Panel --- README.md | 4 ++++ README_CN.md | 4 ++++ README_JA.md | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/README.md b/README.md index 3958668e23..47ea690965 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,10 @@ Never stop coding. Smart routing to FREE & low-cost AI models with automatic fal OmniRoute is an AI gateway for multi-provider LLMs: an OpenAI-compatible endpoint with smart routing, load balancing, retries, and fallbacks. Add policies, rate limits, caching, and observability for reliable, cost-aware inference. +### [Playful Proxy API Panel (PPAP)](https://github.com/daishuge/playful-proxy-api-panel) + +A public CLIProxyAPI-compatible fork and bundled management panel. It keeps upstream-style usage while restoring built-in usage statistics, adding cache hit rate, first-byte latency, TPS tracking, and Docker-oriented self-hosted installation docs. + > [!NOTE] > If you have developed a port of CLIProxyAPI or a project inspired by it, please open a PR to add it to this list. diff --git a/README_CN.md b/README_CN.md index 5c341d2773..e9b9c2a4c4 100644 --- a/README_CN.md +++ b/README_CN.md @@ -208,6 +208,10 @@ Shadow AI 是一款专为受限环境设计的 AI 辅助工具。提供无窗口 OmniRoute 是一个面向多供应商大语言模型的 AI 网关:它提供兼容 OpenAI 的端点,具备智能路由、负载均衡、重试及回退机制。通过添加策略、速率限制、缓存和可观测性,确保推理过程既可靠又具备成本意识。 +### [Playful Proxy API Panel (PPAP)](https://github.com/daishuge/playful-proxy-api-panel) + +一个公开的 CLIProxyAPI 兼容二开版本和配套管理面板,尽量保持与上游一致的使用方式,同时恢复内置使用量统计,并补充缓存命中率、首字响应时间、TPS 记录和面向 Docker 自托管的安装说明。 + > [!NOTE] > 如果你开发了 CLIProxyAPI 的移植或衍生项目,请提交 PR 将其添加到此列表中。 diff --git a/README_JA.md b/README_JA.md index cbb37767b6..58ad22cf0c 100644 --- a/README_JA.md +++ b/README_JA.md @@ -207,6 +207,10 @@ CLIProxyAPIに触発されたNext.js実装。インストールと使用が簡 OmniRouteはマルチプロバイダーLLM向けのAIゲートウェイです:スマートルーティング、負荷分散、リトライ、フォールバックを備えたOpenAI互換エンドポイント。ポリシー、レート制限、キャッシュ、可観測性を追加して、信頼性が高くコストを意識した推論を実現します。 +### [Playful Proxy API Panel (PPAP)](https://github.com/daishuge/playful-proxy-api-panel) + +上流に近い使い方を維持する公開CLIProxyAPI互換フォーク兼管理パネルです。内蔵の使用量統計を復元し、キャッシュヒット率、初回バイト待ち時間、TPSの記録、Docker向けのセルフホスト手順を追加しています。 + > [!NOTE] > CLIProxyAPIの移植版またはそれに触発されたプロジェクトを開発した場合は、PRを送ってこのリストに追加してください。 From 56df36895a0ed21720a3aa315f5b394f8b20b1b3 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 2 May 2026 20:43:16 +0800 Subject: [PATCH 090/139] feat: add configurable retention period for Redis usage queue - Introduced `redis-usage-queue-retention-seconds` config parameter with a default of 60 seconds and a max of 3600 seconds. - Updated logic in `redisqueue` to honor configurable retention periods for enqueued usage data. - Modified config validation and initialization to support and enforce retention limits. - Enhanced change tracking in `config_diff` to detect updates to this parameter. --- cmd/server/main.go | 1 + config.example.yaml | 4 ++++ internal/api/server.go | 4 ++++ internal/config/config.go | 13 ++++++++++++ internal/redisqueue/queue.go | 30 ++++++++++++++++++++++++---- internal/watcher/diff/config_diff.go | 3 +++ 6 files changed, 51 insertions(+), 4 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index e735b144c4..b10bc9c8dd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -418,6 +418,7 @@ func main() { } } redisqueue.SetUsageStatisticsEnabled(cfg.UsageStatisticsEnabled) + redisqueue.SetRetentionSeconds(cfg.RedisUsageQueueRetentionSeconds) coreauth.SetQuotaCooldownDisabled(cfg.DisableCooling) if err = logging.ConfigureLogOutput(cfg); err != nil { diff --git a/config.example.yaml b/config.example.yaml index 172e961f62..d7d5a9f56b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -66,6 +66,10 @@ error-logs-max-files: 10 # When false, disable in-memory usage statistics aggregation usage-statistics-enabled: false +# How long (in seconds) Redis usage queue items are retained in memory for the RESP interface (LPOP/RPOP). +# Default: 60. Max: 3600. +redis-usage-queue-retention-seconds: 60 + # Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/ # Per-entry proxy-url also supports "direct" or "none" to bypass both the global proxy-url and environment proxies explicitly. proxy-url: "" diff --git a/internal/api/server.go b/internal/api/server.go index 176bc2a385..2e89ac5a34 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1000,6 +1000,10 @@ func (s *Server) UpdateClients(cfg *config.Config) { redisqueue.SetUsageStatisticsEnabled(cfg.UsageStatisticsEnabled) } + if oldCfg == nil || oldCfg.RedisUsageQueueRetentionSeconds != cfg.RedisUsageQueueRetentionSeconds { + redisqueue.SetRetentionSeconds(cfg.RedisUsageQueueRetentionSeconds) + } + if s.requestLogger != nil && (oldCfg == nil || oldCfg.ErrorLogsMaxFiles != cfg.ErrorLogsMaxFiles) { if setter, ok := s.requestLogger.(interface{ SetErrorLogsMaxFiles(int) }); ok { setter.SetErrorLogsMaxFiles(cfg.ErrorLogsMaxFiles) diff --git a/internal/config/config.go b/internal/config/config.go index 39c91127ad..46ce4f5099 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -65,6 +65,11 @@ type Config struct { // UsageStatisticsEnabled toggles in-memory usage aggregation; when false, usage data is discarded. UsageStatisticsEnabled bool `yaml:"usage-statistics-enabled" json:"usage-statistics-enabled"` + // RedisUsageQueueRetentionSeconds controls how long (in seconds) usage queue items + // are retained in memory for the Redis RESP interface (LPOP/RPOP). + // Default: 60. Max: 3600. + RedisUsageQueueRetentionSeconds int `yaml:"redis-usage-queue-retention-seconds" json:"redis-usage-queue-retention-seconds"` + // DisableCooling disables quota cooldown scheduling when true. DisableCooling bool `yaml:"disable-cooling" json:"disable-cooling"` @@ -609,6 +614,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { cfg.LogsMaxTotalSizeMB = 0 cfg.ErrorLogsMaxFiles = 10 cfg.UsageStatisticsEnabled = false + cfg.RedisUsageQueueRetentionSeconds = 60 cfg.DisableCooling = false cfg.DisableImageGeneration = DisableImageGenerationOff cfg.Pprof.Enable = false @@ -671,6 +677,13 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { cfg.ErrorLogsMaxFiles = 10 } + if cfg.RedisUsageQueueRetentionSeconds <= 0 { + cfg.RedisUsageQueueRetentionSeconds = 60 + } else if cfg.RedisUsageQueueRetentionSeconds > 3600 { + log.WithField("value", cfg.RedisUsageQueueRetentionSeconds).Warn("redis-usage-queue-retention-seconds too large; clamping to 3600") + cfg.RedisUsageQueueRetentionSeconds = 3600 + } + if cfg.MaxRetryCredentials < 0 { cfg.MaxRetryCredentials = 0 } diff --git a/internal/redisqueue/queue.go b/internal/redisqueue/queue.go index 8a4b6742f5..2fea58391a 100644 --- a/internal/redisqueue/queue.go +++ b/internal/redisqueue/queue.go @@ -6,7 +6,10 @@ import ( "time" ) -const retentionWindow = time.Minute +const ( + defaultRetentionSeconds int64 = 60 + maxRetentionSeconds int64 = 3600 +) type queueItem struct { enqueuedAt time.Time @@ -20,10 +23,15 @@ type queue struct { } var ( - enabled atomic.Bool - global queue + enabled atomic.Bool + retentionSeconds atomic.Int64 + global queue ) +func init() { + retentionSeconds.Store(defaultRetentionSeconds) +} + func SetEnabled(value bool) { enabled.Store(value) if !value { @@ -35,6 +43,16 @@ func Enabled() bool { return enabled.Load() } +func SetRetentionSeconds(value int) { + normalized := int64(value) + if normalized <= 0 { + normalized = defaultRetentionSeconds + } else if normalized > maxRetentionSeconds { + normalized = maxRetentionSeconds + } + retentionSeconds.Store(normalized) +} + func Enqueue(payload []byte) { if !Enabled() { return @@ -110,7 +128,11 @@ func (q *queue) pruneLocked(now time.Time) { return } - cutoff := now.Add(-retentionWindow) + windowSeconds := retentionSeconds.Load() + if windowSeconds <= 0 { + windowSeconds = defaultRetentionSeconds + } + cutoff := now.Add(-time.Duration(windowSeconds) * time.Second) for q.head < len(q.items) && q.items[q.head].enqueuedAt.Before(cutoff) { q.head++ } diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go index 2be9aa9087..b414ed5adf 100644 --- a/internal/watcher/diff/config_diff.go +++ b/internal/watcher/diff/config_diff.go @@ -39,6 +39,9 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if oldCfg.UsageStatisticsEnabled != newCfg.UsageStatisticsEnabled { changes = append(changes, fmt.Sprintf("usage-statistics-enabled: %t -> %t", oldCfg.UsageStatisticsEnabled, newCfg.UsageStatisticsEnabled)) } + if oldCfg.RedisUsageQueueRetentionSeconds != newCfg.RedisUsageQueueRetentionSeconds { + changes = append(changes, fmt.Sprintf("redis-usage-queue-retention-seconds: %d -> %d", oldCfg.RedisUsageQueueRetentionSeconds, newCfg.RedisUsageQueueRetentionSeconds)) + } if oldCfg.DisableCooling != newCfg.DisableCooling { changes = append(changes, fmt.Sprintf("disable-cooling: %t -> %t", oldCfg.DisableCooling, newCfg.DisableCooling)) } From 101b59cfe872778f5915b0eef0ccac0eec79cae4 Mon Sep 17 00:00:00 2001 From: Vijay Chimmi Date: Sat, 2 May 2026 17:37:38 -0700 Subject: [PATCH 091/139] docs: update Subtitle Translator project description --- README.md | 2 +- README_CN.md | 2 +- README_JA.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 47ea690965..91f3823933 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ Native macOS menu bar app to use your Claude Code & ChatGPT subscriptions with A ### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator) -Browser-based tool to translate SRT subtitles using your Gemini subscription via CLIProxyAPI with automatic validation/error correction - no API keys needed +A cross-platform desktop and web app to translate and validate SRT subtitles using your existing LLM subscriptions (Gemini, ChatGPT, Claude, etc.) via CLIProxyAPI - no API keys needed. ### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs) diff --git a/README_CN.md b/README_CN.md index e9b9c2a4c4..a307dc95a0 100644 --- a/README_CN.md +++ b/README_CN.md @@ -129,7 +129,7 @@ CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支 ### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator) -一款基于浏览器的 SRT 字幕翻译工具,可通过 CLI 代理 API 使用您的 Gemini 订阅。内置自动验证与错误修正功能,无需 API 密钥。 +一款跨平台的桌面和 Web 应用程序,可通过 CLIProxyAPI 使用您现有的 LLM 订阅(Gemini、ChatGPT、Claude, etc.)来翻译和验证 SRT 字幕 - 无需 API 密钥。 ### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs) diff --git a/README_JA.md b/README_JA.md index 58ad22cf0c..266b612a8f 100644 --- a/README_JA.md +++ b/README_JA.md @@ -128,7 +128,7 @@ macOSネイティブのメニューバーアプリで、Claude CodeとChatGPTの ### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator) -CLIProxyAPI経由でGeminiサブスクリプションを使用してSRT字幕を翻訳するブラウザベースのツール。自動検証/エラー修正機能付き - APIキー不要 +CLIProxyAPI経由で既存のLLMサブスクリプション(Gemini、ChatGPT、Claude, etc.)を使用してSRT字幕を翻訳および検証する、クロスプラットフォームのデスクトップおよびWebアプリ - APIキー不要。 ### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs) From 5fc6f662e14a30be75d3994b3c64270d04358593 Mon Sep 17 00:00:00 2001 From: ziwu Date: Sun, 3 May 2026 18:25:11 +0800 Subject: [PATCH 092/139] docs: add CLIProxy Pool Watch project --- README.md | 4 ++++ README_CN.md | 4 ++++ README_JA.md | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/README.md b/README.md index 91f3823933..e404e89489 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,10 @@ Ready-to-use cross-platform quota inspector for CLIProxyAPI, supporting per-acco Windows-focused, local-first desktop management platform for Codex CLI built on CLIProxyAPI, focused on simplifying local setup, account and runtime management, and providing a more complete Codex CLI experience for local users. +### [CLIProxy Pool Watch](https://github.com/murasame612/CLIProxyPoolWidget) + +Native macOS SwiftUI app for monitoring ChatGPT/Codex account quotas in CLIProxyAPI pools. Displays account availability, Plus-base capacity, 5-hour and weekly quota bars, plan weights, and restore forecasts through the Management API. + > [!NOTE] > If you developed a project based on CLIProxyAPI, please open a PR to add it to this list. diff --git a/README_CN.md b/README_CN.md index a307dc95a0..e5d9db1e93 100644 --- a/README_CN.md +++ b/README_CN.md @@ -191,6 +191,10 @@ Shadow AI 是一款专为受限环境设计的 AI 辅助工具。提供无窗口 基于 CLIProxyAPI 的 Windows Codex CLI 本地优先桌面管理平台,聚焦简化本机配置、账号与运行状态管理,并为本地用户提供更完整的 Codex CLI 使用体验。 +### [CLIProxy Pool Watch](https://github.com/murasame612/CLIProxyPoolWidget) + +原生 macOS SwiftUI 应用,用于监控 CLIProxyAPI 池中的 ChatGPT/Codex 账号额度。通过 Management API 展示账号可用状态、Plus 基准容量、5 小时与周额度进度条、套餐权重和恢复预测。 + > [!NOTE] > 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。 diff --git a/README_JA.md b/README_JA.md index 266b612a8f..8481664110 100644 --- a/README_JA.md +++ b/README_JA.md @@ -190,6 +190,10 @@ CLIProxyAPI向けのすぐに使えるクロスプラットフォームのクォ CLIProxyAPIを基盤にしたWindows向けのローカル優先Codex CLIデスクトップ管理プラットフォーム。ローカル設定、アカウント、実行状態の管理を簡素化し、ローカルユーザーにより包括的なCodex CLI体験を提供します。 +### [CLIProxy Pool Watch](https://github.com/murasame612/CLIProxyPoolWidget) + +CLIProxyAPIプール内のChatGPT/Codexアカウントクォータを監視するmacOSネイティブSwiftUIアプリ。Management APIを通じて、アカウントの可用性、Plus基準の容量、5時間/週次クォータバー、プラン重み、復元予測を表示します。 + > [!NOTE] > CLIProxyAPIをベースにプロジェクトを開発した場合は、PRを送ってこのリストに追加してください。 From 81db7fdc1e06b88c6fe9d14e9f1dfb57d5c642d1 Mon Sep 17 00:00:00 2001 From: John Date: Sun, 3 May 2026 20:23:23 +0800 Subject: [PATCH 093/139] Add CLIProxyAPI Usage Dashboard to statistics docs --- README.md | 4 ++++ README_CN.md | 4 ++++ README_JA.md | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/README.md b/README.md index 91f3823933..f5bcc4eee3 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,10 @@ Since v6.10.0, CLIProxyAPI and [CPAMC](https://github.com/router-for-me/Cli-Prox Standalone persistence and visualization service for CLIProxyAPI, with periodic data sync, SQLite storage, aggregate APIs, and a built-in dashboard for usage and statistics. +### [CLIProxyAPI Usage Dashboard](https://github.com/zhanglunet/cliproxyapi-usage-dashboard) + +Local-first usage and quota dashboard for CLIProxyAPI. It collects per-request token usage from the Redis-compatible usage queue into SQLite, visualizes daily and recent-window usage by account and model, and shows Codex 5h/7d quota remaining in a local web UI. + ## Amp CLI Support CLIProxyAPI includes integrated support for [Amp CLI](https://ampcode.com) and Amp IDE extensions, enabling you to use your Google/ChatGPT/Claude OAuth subscriptions with Amp's coding tools: diff --git a/README_CN.md b/README_CN.md index a307dc95a0..10bba6e32c 100644 --- a/README_CN.md +++ b/README_CN.md @@ -82,6 +82,10 @@ CLIProxyAPI 用户手册: [https://help.router-for.me/](https://help.router-fo 独立的 CLIProxyAPI 使用量持久化与可视化服务,定期同步 CLIProxyAPI 数据,存储到 SQLite,提供聚合 API,并内置使用量分析与统计仪表盘。 +### [CLIProxyAPI Usage Dashboard](https://github.com/zhanglunet/cliproxyapi-usage-dashboard) + +面向 CLIProxyAPI 的本地优先使用量与配额看板。它从 Redis 兼容使用量队列采集每次请求的 token 消耗并写入 SQLite,按账号和模型展示当天及最近时间窗口的用量,并在本地网页中显示 Codex 5h/7d 配额余量。 + ## Amp CLI 支持 CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支持,可让你使用自己的 Google/ChatGPT/Claude OAuth 订阅来配合 Amp 编码工具: diff --git a/README_JA.md b/README_JA.md index 266b612a8f..d5638173fd 100644 --- a/README_JA.md +++ b/README_JA.md @@ -80,6 +80,10 @@ v6.10.0以降、CLIProxyAPIおよび [CPAMC](https://github.com/router-for-me/Cl CLIProxyAPI向けの独立した使用量永続化・可視化サービス。CLIProxyAPIデータを定期同期してSQLiteに保存し、集計APIと、使用量や各種統計を確認できる組み込みダッシュボードを提供します。 +### [CLIProxyAPI Usage Dashboard](https://github.com/zhanglunet/cliproxyapi-usage-dashboard) + +CLIProxyAPI向けのローカル優先の使用量・クォータダッシュボード。Redis互換の使用量キューからリクエストごとのtoken使用量を収集してSQLiteに保存し、アカウント別・モデル別の日次および直近時間枠の使用量と、Codex 5h/7dクォータ残量をローカルWeb UIで表示します。 + ## Amp CLIサポート CLIProxyAPIは[Amp CLI](https://ampcode.com)およびAmp IDE拡張機能の統合サポートを含んでおり、Google/ChatGPT/ClaudeのOAuthサブスクリプションをAmpのコーディングツールで使用できます: From 7972130513c2f234be29dd733a33ec7c48f6a54c Mon Sep 17 00:00:00 2001 From: zhanglu <1160377+zhanglunet@users.noreply.github.com> Date: Sun, 3 May 2026 20:38:25 +0800 Subject: [PATCH 094/139] Update README_CN.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- README_CN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README_CN.md b/README_CN.md index 10bba6e32c..6989cf3f52 100644 --- a/README_CN.md +++ b/README_CN.md @@ -84,7 +84,7 @@ CLIProxyAPI 用户手册: [https://help.router-for.me/](https://help.router-fo ### [CLIProxyAPI Usage Dashboard](https://github.com/zhanglunet/cliproxyapi-usage-dashboard) -面向 CLIProxyAPI 的本地优先使用量与配额看板。它从 Redis 兼容使用量队列采集每次请求的 token 消耗并写入 SQLite,按账号和模型展示当天及最近时间窗口的用量,并在本地网页中显示 Codex 5h/7d 配额余量。 +面向 CLIProxyAPI 的本地优先使用量与配额看板。它从 Redis 兼容使用量队列采集每次请求的 Token 消耗并写入 SQLite,按账号和模型可视化每日及最近时间窗口的用量,并在本地网页中显示 Codex 5h/7d 配额余量。 ## Amp CLI 支持 From d2386a31144530c0f6aadd5330f90d8067c0a796 Mon Sep 17 00:00:00 2001 From: zhanglu <1160377+zhanglunet@users.noreply.github.com> Date: Sun, 3 May 2026 20:38:51 +0800 Subject: [PATCH 095/139] Update README_JA.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- README_JA.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README_JA.md b/README_JA.md index d5638173fd..b07ca49268 100644 --- a/README_JA.md +++ b/README_JA.md @@ -82,7 +82,7 @@ CLIProxyAPI向けの独立した使用量永続化・可視化サービス。CLI ### [CLIProxyAPI Usage Dashboard](https://github.com/zhanglunet/cliproxyapi-usage-dashboard) -CLIProxyAPI向けのローカル優先の使用量・クォータダッシュボード。Redis互換の使用量キューからリクエストごとのtoken使用量を収集してSQLiteに保存し、アカウント別・モデル別の日次および直近時間枠の使用量と、Codex 5h/7dクォータ残量をローカルWeb UIで表示します。 +CLIProxyAPI向けのローカル優先の使用量・クォータダッシュボード。Redis互換の使用量キューからリクエストごとのToken使用量を収集してSQLiteに保存し、アカウント別・モデル別の日次および直近時間枠の使用量を可視化し、Codex 5h/7dクォータ残量をローカルWeb UIで表示します。 ## Amp CLIサポート From af65908cb0e172c91f467cfd6b18b0b596ed47c4 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 3 May 2026 22:26:23 +0800 Subject: [PATCH 096/139] feat: enhance tool mapping with namespace and web search support - Added functions to handle tool conversion, including namespace-based tools and web search tools. - Improved parameter normalization and tool input schema standardization. - Integrated logic to handle qualified tool names and map override functionality. - Refactored existing tool processing for better extensibility and maintainability. Fixed: #3199 --- .../claude_openai-responses_request.go | 206 ++++++++++++++++-- .../claude_openai-responses_response.go | 10 +- 2 files changed, 197 insertions(+), 19 deletions(-) diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go index 514129ca9b..c0479b87ea 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -339,25 +339,21 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte }) } + includedToolNames := map[string]struct{}{} + toolNameMap := map[string]string{} + // tools mapping: parameters -> input_schema if tools := root.Get("tools"); tools.Exists() && tools.IsArray() { toolsJSON := []byte("[]") tools.ForEach(func(_, tool gjson.Result) bool { - tJSON := []byte(`{"name":"","description":"","input_schema":{}}`) - if n := tool.Get("name"); n.Exists() { - tJSON, _ = sjson.SetBytes(tJSON, "name", n.String()) - } - if d := tool.Get("description"); d.Exists() { - tJSON, _ = sjson.SetBytes(tJSON, "description", d.String()) - } - - if params := tool.Get("parameters"); params.Exists() { - tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", []byte(params.Raw)) - } else if params = tool.Get("parametersJsonSchema"); params.Exists() { - tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", []byte(params.Raw)) + convertedTools := convertResponsesToolToClaudeTools(tool, toolNameMap) + for _, tJSON := range convertedTools { + toolName := gjson.GetBytes(tJSON, "name").String() + if toolName != "" { + includedToolNames[toolName] = struct{}{} + } + toolsJSON, _ = sjson.SetRawBytes(toolsJSON, "-1", tJSON) } - - toolsJSON, _ = sjson.SetRawBytes(toolsJSON, "-1", tJSON) return true }) if parsedTools := gjson.ParseBytes(toolsJSON); parsedTools.IsArray() && len(parsedTools.Array()) > 0 { @@ -375,14 +371,24 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte case "none": // Leave unset; implies no tools case "required": - out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`)) + if len(includedToolNames) > 0 { + out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`)) + } } case gjson.JSON: if toolChoice.Get("type").String() == "function" { fn := toolChoice.Get("function.name").String() - toolChoiceJSON := []byte(`{"name":"","type":"tool"}`) - toolChoiceJSON, _ = sjson.SetBytes(toolChoiceJSON, "name", fn) - out, _ = sjson.SetRawBytes(out, "tool_choice", toolChoiceJSON) + if fn == "" { + fn = toolChoice.Get("name").String() + } + if mappedName := toolNameMap[fn]; mappedName != "" { + fn = mappedName + } + if _, ok := includedToolNames[fn]; ok { + toolChoiceJSON := []byte(`{"name":"","type":"tool"}`) + toolChoiceJSON, _ = sjson.SetBytes(toolChoiceJSON, "name", fn) + out, _ = sjson.SetRawBytes(out, "tool_choice", toolChoiceJSON) + } } default: @@ -391,3 +397,167 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte return out } + +func convertResponsesToolToClaudeTools(tool gjson.Result, toolNameMap map[string]string) [][]byte { + toolType := strings.TrimSpace(tool.Get("type").String()) + switch toolType { + case "", "function": + if tJSON, ok := convertResponsesFunctionToolToClaude(tool, ""); ok { + return [][]byte{tJSON} + } + case "namespace": + return convertResponsesNamespaceToolToClaude(tool, toolNameMap) + case "web_search": + if tJSON, ok := convertResponsesWebSearchToolToClaude(tool); ok { + if name := gjson.GetBytes(tJSON, "name").String(); name != "" { + toolNameMap[name] = name + } + return [][]byte{tJSON} + } + default: + if isUnsupportedOpenAIBuiltinToolType(toolType) { + return nil + } + if tool.Get("name").String() != "" { + return [][]byte{[]byte(tool.Raw)} + } + } + return nil +} + +func convertResponsesNamespaceToolToClaude(tool gjson.Result, toolNameMap map[string]string) [][]byte { + namespaceName := strings.TrimSpace(tool.Get("name").String()) + children := tool.Get("tools") + if !children.Exists() || !children.IsArray() { + return nil + } + + var out [][]byte + children.ForEach(func(_, child gjson.Result) bool { + childName := responsesToolName(child) + qualifiedName := qualifyResponsesNamespaceToolName(namespaceName, childName) + if tJSON, ok := convertResponsesFunctionToolToClaude(child, qualifiedName); ok { + out = append(out, tJSON) + toolNameMap[qualifiedName] = qualifiedName + if childName != "" { + toolNameMap[childName] = qualifiedName + } + } + return true + }) + return out +} + +func convertResponsesFunctionToolToClaude(tool gjson.Result, overrideName string) ([]byte, bool) { + name := strings.TrimSpace(overrideName) + if name == "" { + name = responsesToolName(tool) + } + if name == "" { + return nil, false + } + + tJSON := []byte(`{"name":"","description":"","input_schema":{}}`) + tJSON, _ = sjson.SetBytes(tJSON, "name", name) + if d := responsesToolDescription(tool); d != "" { + tJSON, _ = sjson.SetBytes(tJSON, "description", d) + } + tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", normalizeClaudeToolInputSchema(responsesToolParameters(tool))) + return tJSON, true +} + +func convertResponsesWebSearchToolToClaude(tool gjson.Result) ([]byte, bool) { + if externalWebAccess := tool.Get("external_web_access"); externalWebAccess.Exists() && !externalWebAccess.Bool() { + return nil, false + } + + name := strings.TrimSpace(tool.Get("name").String()) + if name == "" { + name = "web_search" + } + tJSON := []byte(`{"type":"web_search_20250305","name":""}`) + tJSON, _ = sjson.SetBytes(tJSON, "name", name) + if maxUses := tool.Get("max_uses"); maxUses.Exists() { + tJSON, _ = sjson.SetBytes(tJSON, "max_uses", maxUses.Int()) + } + if allowedDomains := tool.Get("filters.allowed_domains"); allowedDomains.Exists() && allowedDomains.IsArray() { + tJSON, _ = sjson.SetRawBytes(tJSON, "allowed_domains", []byte(allowedDomains.Raw)) + } + if userLocation := tool.Get("user_location"); userLocation.Exists() && userLocation.IsObject() { + tJSON, _ = sjson.SetRawBytes(tJSON, "user_location", []byte(userLocation.Raw)) + } + return tJSON, true +} + +func responsesToolName(tool gjson.Result) string { + if name := strings.TrimSpace(tool.Get("name").String()); name != "" { + return name + } + return strings.TrimSpace(tool.Get("function.name").String()) +} + +func responsesToolDescription(tool gjson.Result) string { + if description := tool.Get("description").String(); description != "" { + return description + } + return tool.Get("function.description").String() +} + +func responsesToolParameters(tool gjson.Result) gjson.Result { + for _, path := range []string{ + "parameters", + "parametersJsonSchema", + "input_schema", + "function.parameters", + "function.parametersJsonSchema", + } { + if parameters := tool.Get(path); parameters.Exists() { + return parameters + } + } + return gjson.Result{} +} + +func normalizeClaudeToolInputSchema(parameters gjson.Result) []byte { + raw := strings.TrimSpace(parameters.Raw) + if raw == "" || raw == "null" || !gjson.Valid(raw) { + return []byte(`{"type":"object","properties":{}}`) + } + result := gjson.Parse(raw) + if !result.IsObject() { + return []byte(`{"type":"object","properties":{}}`) + } + schema := []byte(raw) + schemaType := result.Get("type").String() + if schemaType == "" { + schema, _ = sjson.SetBytes(schema, "type", "object") + schemaType = "object" + } + if schemaType == "object" && !result.Get("properties").Exists() { + schema, _ = sjson.SetRawBytes(schema, "properties", []byte(`{}`)) + } + return schema +} + +func qualifyResponsesNamespaceToolName(namespaceName, childName string) string { + childName = strings.TrimSpace(childName) + if childName == "" || namespaceName == "" || strings.HasPrefix(childName, "mcp__") { + return childName + } + if strings.HasPrefix(childName, namespaceName) { + return childName + } + if strings.HasSuffix(namespaceName, "__") { + return namespaceName + childName + } + return namespaceName + "__" + childName +} + +func isUnsupportedOpenAIBuiltinToolType(toolType string) bool { + switch toolType { + case "image_generation", "file_search", "code_interpreter", "computer_use_preview": + return true + default: + return false + } +} diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response.go b/internal/translator/claude/openai/responses/claude_openai-responses_response.go index ef2cc1f845..10d12c9963 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_response.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_response.go @@ -26,7 +26,8 @@ type claudeToResponsesState struct { FuncNames map[int]string // index -> function name FuncCallIDs map[int]string // index -> call id // message text aggregation - TextBuf strings.Builder + TextBuf strings.Builder + CurrentTextBuf strings.Builder // reasoning state ReasoningActive bool ReasoningItemID string @@ -80,6 +81,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin st.CreatedAt = time.Now().Unix() // Reset per-message aggregation state st.TextBuf.Reset() + st.CurrentTextBuf.Reset() st.ReasoningBuf.Reset() st.ReasoningActive = false st.InTextBlock = false @@ -128,6 +130,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin if typ == "text" { // open message item + content part st.InTextBlock = true + st.CurrentTextBuf.Reset() st.CurrentMsgID = fmt.Sprintf("msg_%s_0", st.ResponseID) item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"in_progress","content":[],"role":"assistant"}}`) item, _ = sjson.SetBytes(item, "sequence_number", nextSeq()) @@ -189,6 +192,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin out = append(out, emitEvent("response.output_text.delta", msg)) // aggregate text for response.output st.TextBuf.WriteString(t.String()) + st.CurrentTextBuf.WriteString(t.String()) } } else if dt == "input_json_delta" { idx := int(root.Get("index").Int()) @@ -220,17 +224,21 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin case "content_block_stop": idx := int(root.Get("index").Int()) if st.InTextBlock { + fullText := st.CurrentTextBuf.String() done := []byte(`{"type":"response.output_text.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"text":"","logprobs":[]}`) done, _ = sjson.SetBytes(done, "sequence_number", nextSeq()) done, _ = sjson.SetBytes(done, "item_id", st.CurrentMsgID) + done, _ = sjson.SetBytes(done, "text", fullText) out = append(out, emitEvent("response.output_text.done", done)) partDone := []byte(`{"type":"response.content_part.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`) partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq()) partDone, _ = sjson.SetBytes(partDone, "item_id", st.CurrentMsgID) + partDone, _ = sjson.SetBytes(partDone, "part.text", fullText) out = append(out, emitEvent("response.content_part.done", partDone)) final := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","content":[{"type":"output_text","text":""}],"role":"assistant"}}`) final, _ = sjson.SetBytes(final, "sequence_number", nextSeq()) final, _ = sjson.SetBytes(final, "item.id", st.CurrentMsgID) + final, _ = sjson.SetBytes(final, "item.content.0.text", fullText) out = append(out, emitEvent("response.output_item.done", final)) st.InTextBlock = false } else if st.InFuncBlock { From 672fdd14ed0a5d1c0db9e8cd9140023545fa30e8 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 3 May 2026 22:40:42 +0800 Subject: [PATCH 097/139] feat: filter and drop empty assistant messages in Kimi executor - Added `filterKimiEmptyAssistantMessages` to identify and remove empty assistant messages with no content, tool links, or reasoning. - Integrated logging to track the number of dropped messages. - Updated tests to validate the filtering logic for both empty and valid assistant messages. Fixed: #1730 --- internal/runtime/executor/kimi_executor.go | 103 +++++++++++++++++- .../runtime/executor/kimi_executor_test.go | 67 ++++++++++++ 2 files changed, 168 insertions(+), 2 deletions(-) diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go index 3588c9624b..12c8239f6c 100644 --- a/internal/runtime/executor/kimi_executor.go +++ b/internal/runtime/executor/kimi_executor.go @@ -322,7 +322,17 @@ func normalizeKimiToolMessageLinks(body []byte) ([]byte, error) { return body, nil } - out := body + msgs := messages.Array() + out, dropped, err := filterKimiEmptyAssistantMessages(body, msgs) + if err != nil { + return body, err + } + if dropped > 0 { + log.WithField("dropped_assistant_messages", dropped).Debug("kimi executor: dropped empty assistant messages") + } + + messages = gjson.GetBytes(out, "messages") + msgs = messages.Array() pending := make([]string, 0) patched := 0 patchedReasoning := 0 @@ -340,7 +350,6 @@ func normalizeKimiToolMessageLinks(body []byte) ([]byte, error) { } } - msgs := messages.Array() for msgIdx := range msgs { msg := msgs[msgIdx] role := strings.TrimSpace(msg.Get("role").String()) @@ -428,6 +437,96 @@ func normalizeKimiToolMessageLinks(body []byte) ([]byte, error) { return out, nil } +func filterKimiEmptyAssistantMessages(body []byte, msgs []gjson.Result) ([]byte, int, error) { + kept := make([]string, 0, len(msgs)) + dropped := 0 + for _, msg := range msgs { + if shouldDropKimiAssistantMessage(msg) { + dropped++ + continue + } + kept = append(kept, msg.Raw) + } + if dropped == 0 { + return body, 0, nil + } + + rawMessages := []byte("[" + strings.Join(kept, ",") + "]") + out, err := sjson.SetRawBytes(body, "messages", rawMessages) + if err != nil { + return body, 0, fmt.Errorf("kimi executor: failed to drop empty assistant messages: %w", err) + } + return out, dropped, nil +} + +func shouldDropKimiAssistantMessage(msg gjson.Result) bool { + if strings.TrimSpace(msg.Get("role").String()) != "assistant" { + return false + } + if hasKimiToolCalls(msg) || hasKimiLegacyFunctionCall(msg) || hasKimiAssistantReasoning(msg) { + return false + } + return isKimiAssistantContentEmpty(msg.Get("content")) +} + +func hasKimiToolCalls(msg gjson.Result) bool { + toolCalls := msg.Get("tool_calls") + return toolCalls.Exists() && toolCalls.IsArray() && len(toolCalls.Array()) > 0 +} + +func hasKimiLegacyFunctionCall(msg gjson.Result) bool { + functionCall := msg.Get("function_call") + if !functionCall.Exists() || functionCall.Type == gjson.Null { + return false + } + if functionCall.IsObject() && strings.TrimSpace(functionCall.Raw) == "{}" { + return false + } + return strings.TrimSpace(functionCall.Raw) != "" +} + +func hasKimiAssistantReasoning(msg gjson.Result) bool { + reasoning := msg.Get("reasoning_content") + return reasoning.Exists() && strings.TrimSpace(reasoning.String()) != "" +} + +func isKimiAssistantContentEmpty(content gjson.Result) bool { + if !content.Exists() || content.Type == gjson.Null { + return true + } + if content.Type == gjson.String { + return strings.TrimSpace(content.String()) == "" + } + if !content.IsArray() { + return false + } + for _, part := range content.Array() { + if !isKimiAssistantContentPartEmpty(part) { + return false + } + } + return true +} + +func isKimiAssistantContentPartEmpty(part gjson.Result) bool { + if !part.Exists() || part.Type == gjson.Null { + return true + } + if part.Type == gjson.String { + return strings.TrimSpace(part.String()) == "" + } + if !part.IsObject() { + return false + } + if text := part.Get("text"); text.Exists() { + return strings.TrimSpace(text.String()) == "" + } + if strings.TrimSpace(part.Get("type").String()) == "text" { + return true + } + return strings.TrimSpace(part.Raw) == "{}" +} + func fallbackAssistantReasoning(msg gjson.Result, hasLatest bool, latest string) string { if hasLatest && strings.TrimSpace(latest) != "" { return latest diff --git a/internal/runtime/executor/kimi_executor_test.go b/internal/runtime/executor/kimi_executor_test.go index 210ddb0ef9..f3de70f1bd 100644 --- a/internal/runtime/executor/kimi_executor_test.go +++ b/internal/runtime/executor/kimi_executor_test.go @@ -203,3 +203,70 @@ func TestNormalizeKimiToolMessageLinks_RepairsIDsAndReasoningTogether(t *testing t.Fatalf("messages.2.reasoning_content = %q, want %q", got, "r1") } } + +func TestNormalizeKimiToolMessageLinks_DropsEmptyAssistantWithoutToolLink(t *testing.T) { + body := []byte(`{ + "messages":[ + {"role":"user","content":"start"}, + {"role":"assistant","content":""}, + {"role":"assistant","content":" "}, + {"role":"assistant","content":"","tool_calls":null}, + {"role":"assistant","content":[{"type":"text","text":" "}]}, + {"role":"assistant"}, + {"role":"assistant","content":"keep"}, + {"role":"user","content":"next"} + ] + }`) + + out, err := normalizeKimiToolMessageLinks(body) + if err != nil { + t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err) + } + + messages := gjson.GetBytes(out, "messages").Array() + if len(messages) != 3 { + t.Fatalf("messages length = %d, want 3, raw = %s", len(messages), gjson.GetBytes(out, "messages").Raw) + } + if got := messages[0].Get("content").String(); got != "start" { + t.Fatalf("messages.0.content = %q, want %q", got, "start") + } + if got := messages[1].Get("content").String(); got != "keep" { + t.Fatalf("messages.1.content = %q, want %q", got, "keep") + } + if got := messages[2].Get("content").String(); got != "next" { + t.Fatalf("messages.2.content = %q, want %q", got, "next") + } +} + +func TestNormalizeKimiToolMessageLinks_PreservesAssistantWithToolLinkOrReasoning(t *testing.T) { + body := []byte(`{ + "messages":[ + {"role":"assistant","content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]}, + {"role":"assistant","content":"","function_call":{"name":"legacy_call","arguments":"{}"}}, + {"role":"assistant","content":"","reasoning_content":"thought"}, + {"role":"assistant","content":[{"type":"text","text":" visible "}]} + ] + }`) + + out, err := normalizeKimiToolMessageLinks(body) + if err != nil { + t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err) + } + + messages := gjson.GetBytes(out, "messages").Array() + if len(messages) != 4 { + t.Fatalf("messages length = %d, want 4, raw = %s", len(messages), gjson.GetBytes(out, "messages").Raw) + } + if !messages[0].Get("tool_calls").Exists() { + t.Fatalf("messages.0.tool_calls should exist") + } + if !messages[1].Get("function_call").Exists() { + t.Fatalf("messages.1.function_call should exist") + } + if got := messages[2].Get("reasoning_content").String(); got != "thought" { + t.Fatalf("messages.2.reasoning_content = %q, want %q", got, "thought") + } + if got := messages[3].Get("content.0.text").String(); got != " visible " { + t.Fatalf("messages.3.content.0.text = %q, want %q", got, " visible ") + } +} From bf0e5c23f731e6d80457679e721c535823e5e60e Mon Sep 17 00:00:00 2001 From: 1137043480 <1137043480@users.noreply.github.com> Date: Sun, 3 May 2026 11:25:04 -0400 Subject: [PATCH 098/139] fix: prevent goroutine leaks in streaming executors via context-aware channel sends All streaming executors use bare channel sends (out <- chunk) inside goroutines that process upstream SSE responses. When the downstream consumer disconnects (client timeout, network drop, etc.), these sends block indefinitely, causing the goroutine and all associated resources (HTTP response body, scanner buffers, translation state) to leak permanently. Over time, leaked goroutines accumulate monotonically, leading to RSS growth from ~30MB to 3.7GB+ and eventual OOM kills on resource-constrained VPS hosts. Fix: Replace all bare 'out <- ...' sends with: select { case out <- ...: case <-ctx.Done(): return } This ensures goroutines terminate promptly when the request context is canceled, allowing GC to reclaim all associated resources. Affected executors (9 files, 36+ send sites): - antigravity_executor.go (5 sites) - gemini_cli_executor.go (6 sites) - gemini_vertex_executor.go (6 sites) - aistudio_executor.go (4 sites) - gemini_executor.go (3 sites) - openai_compat_executor.go (3 sites) - claude_executor.go (4 sites) - codex_executor.go (2 sites) - kimi_executor.go (3 sites) --- .../runtime/executor/aistudio_executor.go | 22 +++++++++--- .../runtime/executor/antigravity_executor.go | 28 ++++++++++++--- internal/runtime/executor/claude_executor.go | 22 +++++++++--- internal/runtime/executor/codex_executor.go | 11 ++++-- .../runtime/executor/gemini_cli_executor.go | 34 +++++++++++++++---- internal/runtime/executor/gemini_executor.go | 17 ++++++++-- .../executor/gemini_vertex_executor.go | 34 +++++++++++++++---- internal/runtime/executor/kimi_executor.go | 17 ++++++++-- .../executor/openai_compat_executor.go | 17 ++++++++-- 9 files changed, 166 insertions(+), 36 deletions(-) diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index 73491d8248..37e85377b2 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -285,7 +285,10 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth if event.Err != nil { helps.RecordAPIResponseError(ctx, e.cfg, event.Err) reporter.PublishFailure(ctx) - out <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf("wsrelay: %v", event.Err)} + select { + case out <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf("wsrelay: %v", event.Err)}: + case <-ctx.Done(): + } return false } switch event.Type { @@ -303,7 +306,11 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth } lines := sdktranslator.TranslateStream(ctx, body.toFormat, opts.SourceFormat, req.Model, opts.OriginalRequest, translatedReq, filtered, ¶m) for i := range lines { - out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON(lines[i])} + select { + case out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON(lines[i])}: + case <-ctx.Done(): + return false + } } break } @@ -319,14 +326,21 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth } lines := sdktranslator.TranslateStream(ctx, body.toFormat, opts.SourceFormat, req.Model, opts.OriginalRequest, translatedReq, event.Payload, ¶m) for i := range lines { - out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON(lines[i])} + select { + case out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON(lines[i])}: + case <-ctx.Done(): + return false + } } reporter.Publish(ctx, helps.ParseGeminiUsage(event.Payload)) return false case wsrelay.MessageTypeError: helps.RecordAPIResponseError(ctx, e.cfg, event.Err) reporter.PublishFailure(ctx) - out <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf("wsrelay: %v", event.Err)} + select { + case out <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf("wsrelay: %v", event.Err)}: + case <-ctx.Done(): + } return false } return true diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 280c799af4..c07680e8ec 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -894,12 +894,19 @@ attemptLoop: reporter.Publish(ctx, detail) } - out <- cliproxyexecutor.StreamChunk{Payload: payload} + select { + case out <- cliproxyexecutor.StreamChunk{Payload: payload}: + case <-ctx.Done(): + return + } } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) reporter.PublishFailure(ctx) - out <- cliproxyexecutor.StreamChunk{Err: errScan} + select { + case out <- cliproxyexecutor.StreamChunk{Err: errScan}: + case <-ctx.Done(): + } } else { reporter.EnsurePublished(ctx) } @@ -1357,17 +1364,28 @@ attemptLoop: chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bytes.Clone(payload), ¶m) for i := range chunks { - out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]} + select { + case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}: + case <-ctx.Done(): + return + } } } tail := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, []byte("[DONE]"), ¶m) for i := range tail { - out <- cliproxyexecutor.StreamChunk{Payload: tail[i]} + select { + case out <- cliproxyexecutor.StreamChunk{Payload: tail[i]}: + case <-ctx.Done(): + return + } } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) reporter.PublishFailure(ctx) - out <- cliproxyexecutor.StreamChunk{Err: errScan} + select { + case out <- cliproxyexecutor.StreamChunk{Err: errScan}: + case <-ctx.Done(): + } } else { reporter.EnsurePublished(ctx) } diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 66432ac404..ea94526e1a 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -484,12 +484,19 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A cloned := make([]byte, len(line)+1) copy(cloned, line) cloned[len(line)] = '\n' - out <- cliproxyexecutor.StreamChunk{Payload: cloned} + select { + case out <- cliproxyexecutor.StreamChunk{Payload: cloned}: + case <-ctx.Done(): + return + } } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) reporter.PublishFailure(ctx) - out <- cliproxyexecutor.StreamChunk{Err: errScan} + select { + case out <- cliproxyexecutor.StreamChunk{Err: errScan}: + case <-ctx.Done(): + } } return } @@ -521,13 +528,20 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A ¶m, ) for i := range chunks { - out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]} + select { + case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}: + case <-ctx.Done(): + return + } } } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) reporter.PublishFailure(ctx) - out <- cliproxyexecutor.StreamChunk{Err: errScan} + select { + case out <- cliproxyexecutor.StreamChunk{Err: errScan}: + case <-ctx.Done(): + } } }() return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index aa8223f4fe..6efc25b019 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -515,13 +515,20 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, body, translatedLine, ¶m) for i := range chunks { - out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]} + select { + case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}: + case <-ctx.Done(): + return + } } } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) reporter.PublishFailure(ctx) - out <- cliproxyexecutor.StreamChunk{Err: errScan} + select { + case out <- cliproxyexecutor.StreamChunk{Err: errScan}: + case <-ctx.Done(): + } } }() return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go index 15e8457224..b6210e6a1d 100644 --- a/internal/runtime/executor/gemini_cli_executor.go +++ b/internal/runtime/executor/gemini_cli_executor.go @@ -411,19 +411,30 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut if bytes.HasPrefix(line, dataTag) { segments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, bytes.Clone(line), ¶m) for i := range segments { - out <- cliproxyexecutor.StreamChunk{Payload: segments[i]} + select { + case out <- cliproxyexecutor.StreamChunk{Payload: segments[i]}: + case <-ctx.Done(): + return + } } } } segments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, []byte("[DONE]"), ¶m) for i := range segments { - out <- cliproxyexecutor.StreamChunk{Payload: segments[i]} + select { + case out <- cliproxyexecutor.StreamChunk{Payload: segments[i]}: + case <-ctx.Done(): + return + } } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) reporter.PublishFailure(ctx) - out <- cliproxyexecutor.StreamChunk{Err: errScan} + select { + case out <- cliproxyexecutor.StreamChunk{Err: errScan}: + case <-ctx.Done(): + } return } reporter.EnsurePublished(ctx) @@ -434,7 +445,10 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut if errRead != nil { helps.RecordAPIResponseError(ctx, e.cfg, errRead) reporter.PublishFailure(ctx) - out <- cliproxyexecutor.StreamChunk{Err: errRead} + select { + case out <- cliproxyexecutor.StreamChunk{Err: errRead}: + case <-ctx.Done(): + } return } helps.AppendAPIResponseChunk(ctx, e.cfg, data) @@ -442,12 +456,20 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut var param any segments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, data, ¶m) for i := range segments { - out <- cliproxyexecutor.StreamChunk{Payload: segments[i]} + select { + case out <- cliproxyexecutor.StreamChunk{Payload: segments[i]}: + case <-ctx.Done(): + return + } } segments = sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, []byte("[DONE]"), ¶m) for i := range segments { - out <- cliproxyexecutor.StreamChunk{Payload: segments[i]} + select { + case out <- cliproxyexecutor.StreamChunk{Payload: segments[i]}: + case <-ctx.Done(): + return + } } }(httpResp, append([]byte(nil), payload...), attemptModel) diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go index 0e3c3ec6b8..2a6e9a6e79 100644 --- a/internal/runtime/executor/gemini_executor.go +++ b/internal/runtime/executor/gemini_executor.go @@ -324,17 +324,28 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(payload), ¶m) for i := range lines { - out <- cliproxyexecutor.StreamChunk{Payload: lines[i]} + select { + case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}: + case <-ctx.Done(): + return + } } } lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m) for i := range lines { - out <- cliproxyexecutor.StreamChunk{Payload: lines[i]} + select { + case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}: + case <-ctx.Done(): + return + } } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) reporter.PublishFailure(ctx) - out <- cliproxyexecutor.StreamChunk{Err: errScan} + select { + case out <- cliproxyexecutor.StreamChunk{Err: errScan}: + case <-ctx.Done(): + } } }() return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil diff --git a/internal/runtime/executor/gemini_vertex_executor.go b/internal/runtime/executor/gemini_vertex_executor.go index b147fde975..20f5aec12c 100644 --- a/internal/runtime/executor/gemini_vertex_executor.go +++ b/internal/runtime/executor/gemini_vertex_executor.go @@ -656,17 +656,28 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte } lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m) for i := range lines { - out <- cliproxyexecutor.StreamChunk{Payload: lines[i]} + select { + case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}: + case <-ctx.Done(): + return + } } } lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m) for i := range lines { - out <- cliproxyexecutor.StreamChunk{Payload: lines[i]} + select { + case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}: + case <-ctx.Done(): + return + } } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) reporter.PublishFailure(ctx) - out <- cliproxyexecutor.StreamChunk{Err: errScan} + select { + case out <- cliproxyexecutor.StreamChunk{Err: errScan}: + case <-ctx.Done(): + } } }() return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil @@ -786,17 +797,28 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth } lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m) for i := range lines { - out <- cliproxyexecutor.StreamChunk{Payload: lines[i]} + select { + case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}: + case <-ctx.Done(): + return + } } } lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m) for i := range lines { - out <- cliproxyexecutor.StreamChunk{Payload: lines[i]} + select { + case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}: + case <-ctx.Done(): + return + } } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) reporter.PublishFailure(ctx) - out <- cliproxyexecutor.StreamChunk{Err: errScan} + select { + case out <- cliproxyexecutor.StreamChunk{Err: errScan}: + case <-ctx.Done(): + } } }() return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go index 3588c9624b..2bb0c7fda0 100644 --- a/internal/runtime/executor/kimi_executor.go +++ b/internal/runtime/executor/kimi_executor.go @@ -290,17 +290,28 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut } chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m) for i := range chunks { - out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]} + select { + case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}: + case <-ctx.Done(): + return + } } } doneChunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m) for i := range doneChunks { - out <- cliproxyexecutor.StreamChunk{Payload: doneChunks[i]} + select { + case out <- cliproxyexecutor.StreamChunk{Payload: doneChunks[i]}: + case <-ctx.Done(): + return + } } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) reporter.PublishFailure(ctx) - out <- cliproxyexecutor.StreamChunk{Err: errScan} + select { + case out <- cliproxyexecutor.StreamChunk{Err: errScan}: + case <-ctx.Done(): + } } }() return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index 4e44a7ae06..ebddfddb16 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -293,20 +293,31 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy // Pass through translator; it yields one or more chunks for the target schema. chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bytes.Clone(line), ¶m) for i := range chunks { - out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]} + select { + case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}: + case <-ctx.Done(): + return + } } } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) reporter.PublishFailure(ctx) - out <- cliproxyexecutor.StreamChunk{Err: errScan} + select { + case out <- cliproxyexecutor.StreamChunk{Err: errScan}: + case <-ctx.Done(): + } } else { // In case the upstream close the stream without a terminal [DONE] marker. // Feed a synthetic done marker through the translator so pending // response.completed events are still emitted exactly once. chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, []byte("data: [DONE]"), ¶m) for i := range chunks { - out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]} + select { + case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}: + case <-ctx.Done(): + return + } } } // Ensure we record the request if no usage chunk was ever seen From 2753d9fb711bb967e5310f35cde43135b04d84e9 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 4 May 2026 03:37:31 +0800 Subject: [PATCH 099/139] feat: add validation for Claude streaming responses - Implemented `validateClaudeStreamingResponse` to ensure upstream streaming data integrity. - Added new tests to verify response validation, including empty streams, error events, incomplete streams, and valid streams. - Integrated validation logic into the Claude executor's streaming handler, returning detailed errors for malformed upstream data. Fixed: #2193 --- internal/runtime/executor/claude_executor.go | 62 ++++++++++ .../runtime/executor/claude_executor_test.go | 107 ++++++++++++++++++ 2 files changed, 169 insertions(+) diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 66432ac404..3734f26202 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -285,6 +285,10 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } helps.AppendAPIResponseChunk(ctx, e.cfg, data) if stream { + if errValidate := validateClaudeStreamingResponse(data); errValidate != nil { + helps.RecordAPIResponseError(ctx, e.cfg, errValidate) + return resp, errValidate + } lines := bytes.Split(data, []byte("\n")) for _, line := range lines { if detail, ok := helps.ParseClaudeStreamUsage(line); ok { @@ -533,6 +537,64 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil } +func validateClaudeStreamingResponse(data []byte) error { + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(nil, 52_428_800) + + hasData := false + hasMessageStart := false + hasMessageDelta := false + + for scanner.Scan() { + line := bytes.TrimSpace(scanner.Bytes()) + if len(line) == 0 || !bytes.HasPrefix(line, []byte("data:")) { + continue + } + payload := bytes.TrimSpace(line[len("data:"):]) + if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) { + continue + } + hasData = true + if !gjson.ValidBytes(payload) { + return statusErr{code: http.StatusBadGateway, msg: "claude executor: upstream returned malformed stream data"} + } + + root := gjson.ParseBytes(payload) + switch root.Get("type").String() { + case "error": + message := strings.TrimSpace(root.Get("error.message").String()) + if message == "" { + message = strings.TrimSpace(root.Get("error.type").String()) + } + if message == "" { + message = "unknown upstream error" + } + return statusErr{code: http.StatusBadGateway, msg: "claude executor: upstream returned error event: " + message} + case "message_start": + message := root.Get("message") + if strings.TrimSpace(message.Get("id").String()) == "" || strings.TrimSpace(message.Get("model").String()) == "" { + return statusErr{code: http.StatusBadGateway, msg: "claude executor: upstream stream message_start is missing id or model"} + } + hasMessageStart = true + case "message_delta": + hasMessageDelta = true + } + } + if errScan := scanner.Err(); errScan != nil { + return errScan + } + if !hasData { + return statusErr{code: http.StatusBadGateway, msg: "claude executor: upstream returned empty stream response"} + } + if !hasMessageStart { + return statusErr{code: http.StatusBadGateway, msg: "claude executor: upstream stream response is missing message_start"} + } + if !hasMessageDelta { + return statusErr{code: http.StatusBadGateway, msg: "claude executor: upstream stream response ended before message completion"} + } + return nil +} + func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { baseModel := thinking.ParseSuffix(req.Model).ModelName diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index c1ce8fc088..6793adda48 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -936,6 +936,113 @@ func TestClaudeExecutor_GeneratesNewUserIDByDefault(t *testing.T) { } } +func TestClaudeExecutor_ExecuteOpenAINonStreamRejectsEmptyClaudeStream(t *testing.T) { + _, err := executeOpenAIChatCompletionThroughClaude(t, "") + if err == nil { + t.Fatal("Execute error = nil, want empty stream error") + } + assertStatusErr(t, err, http.StatusBadGateway) + if !strings.Contains(err.Error(), "empty stream response") { + t.Fatalf("Execute error = %q, want empty stream response", err.Error()) + } +} + +func TestClaudeExecutor_ExecuteOpenAINonStreamRejectsClaudeErrorEvent(t *testing.T) { + body := `data: {"type":"error","error":{"type":"overloaded_error","message":"upstream overloaded"}}` + "\n" + _, err := executeOpenAIChatCompletionThroughClaude(t, body) + if err == nil { + t.Fatal("Execute error = nil, want upstream error event") + } + assertStatusErr(t, err, http.StatusBadGateway) + if !strings.Contains(err.Error(), "upstream overloaded") { + t.Fatalf("Execute error = %q, want upstream overloaded", err.Error()) + } +} + +func TestClaudeExecutor_ExecuteOpenAINonStreamRejectsIncompleteClaudeStream(t *testing.T) { + body := strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_123","model":"claude-3-5-sonnet-20241022"}}`, + `data: {"type":"message_stop"}`, + ``, + }, "\n") + + _, err := executeOpenAIChatCompletionThroughClaude(t, body) + if err == nil { + t.Fatal("Execute error = nil, want incomplete stream error") + } + assertStatusErr(t, err, http.StatusBadGateway) + if !strings.Contains(err.Error(), "ended before message completion") { + t.Fatalf("Execute error = %q, want incomplete stream error", err.Error()) + } +} + +func TestClaudeExecutor_ExecuteOpenAINonStreamConvertsValidClaudeStream(t *testing.T) { + body := strings.Join([]string{ + `event: message_start`, + `data: {"type":"message_start","message":{"id":"msg_123","model":"claude-3-5-sonnet-20241022"}}`, + `event: content_block_delta`, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}`, + `event: message_delta`, + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":2,"output_tokens":1}}`, + `event: message_stop`, + `data: {"type":"message_stop"}`, + ``, + }, "\n") + + resp, err := executeOpenAIChatCompletionThroughClaude(t, body) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if got := gjson.GetBytes(resp.Payload, "id").String(); got != "msg_123" { + t.Fatalf("response id = %q, want msg_123; payload=%s", got, string(resp.Payload)) + } + if got := gjson.GetBytes(resp.Payload, "model").String(); got != "claude-3-5-sonnet-20241022" { + t.Fatalf("response model = %q, want claude-3-5-sonnet-20241022", got) + } + if got := gjson.GetBytes(resp.Payload, "choices.0.message.content").String(); got != "ok" { + t.Fatalf("response content = %q, want ok", got) + } + if got := gjson.GetBytes(resp.Payload, "usage.total_tokens").Int(); got != 3 { + t.Fatalf("usage.total_tokens = %d, want 3", got) + } +} + +func executeOpenAIChatCompletionThroughClaude(t *testing.T, upstreamBody string) (cliproxyexecutor.Response, error) { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(upstreamBody)) + })) + defer server.Close() + + executor := NewClaudeExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "api_key": "key-123", + "base_url": server.URL, + }} + payload := []byte(`{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":"hi"}]}`) + + return executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-3-5-sonnet-20241022", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai"), + }) +} + +func assertStatusErr(t *testing.T, err error, want int) { + t.Helper() + + status, ok := err.(interface{ StatusCode() int }) + if !ok { + t.Fatalf("error %T does not expose StatusCode", err) + } + if got := status.StatusCode(); got != want { + t.Fatalf("StatusCode() = %d, want %d", got, want) + } +} + func TestStripClaudeToolPrefixFromResponse_NestedToolReference(t *testing.T) { input := []byte(`{"content":[{"type":"tool_result","tool_use_id":"toolu_123","content":[{"type":"tool_reference","tool_name":"proxy_mcp__nia__manage_resource"}]}]}`) out := stripClaudeToolPrefixFromResponse(input, "proxy_") From a1487b095855d37998e4c9edbf94aad1670e8e9f Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 4 May 2026 05:08:31 +0800 Subject: [PATCH 100/139] fix(translator): handle non-string types in tools result processing - Skip setting values for non-string `type` fields to prevent runtime errors. Closes: #2226 --- .../codex_gemini-cli_request_test.go | 78 +++++++++++++++++++ .../codex/gemini/codex_gemini_request.go | 6 +- 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 internal/translator/codex/gemini-cli/codex_gemini-cli_request_test.go diff --git a/internal/translator/codex/gemini-cli/codex_gemini-cli_request_test.go b/internal/translator/codex/gemini-cli/codex_gemini-cli_request_test.go new file mode 100644 index 0000000000..fc41452b10 --- /dev/null +++ b/internal/translator/codex/gemini-cli/codex_gemini-cli_request_test.go @@ -0,0 +1,78 @@ +package geminiCLI + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertGeminiCLIRequestToCodex_PreservesSchemaPropertyNamedType(t *testing.T) { + input := []byte(`{ + "request": { + "tools": [ + { + "functionDeclarations": [ + { + "name": "ask_user", + "description": "Ask the user one or more questions.", + "parametersJsonSchema": { + "type": "object", + "properties": { + "questions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "header": { + "type": "string" + }, + "type": { + "default": "choice", + "description": "Question type.", + "enum": [ + "choice", + "text", + "yesno" + ], + "type": "string" + } + }, + "required": [ + "question", + "header", + "type" + ] + } + } + }, + "required": [ + "questions" + ] + } + } + ] + } + ] + } + }`) + + out := ConvertGeminiCLIRequestToCodex("gpt-5.2", input, true) + tool := gjson.GetBytes(out, "tools.0") + if got := tool.Get("type").String(); got != "function" { + t.Fatalf("expected tool type %q, got %q; output=%s", "function", got, string(out)) + } + + typeProperty := tool.Get("parameters.properties.questions.items.properties.type") + if !typeProperty.IsObject() { + t.Fatalf("expected schema property named type to stay an object; output=%s", string(out)) + } + if got := typeProperty.Get("type").String(); got != "string" { + t.Fatalf("expected schema property type %q, got %q; output=%s", "string", got, string(out)) + } + if got := typeProperty.Get("default").String(); got != "choice" { + t.Fatalf("expected default %q, got %q; output=%s", "choice", got, string(out)) + } + if got := typeProperty.Get("enum.2").String(); got != "yesno" { + t.Fatalf("expected enum value %q, got %q; output=%s", "yesno", got, string(out)) + } +} diff --git a/internal/translator/codex/gemini/codex_gemini_request.go b/internal/translator/codex/gemini/codex_gemini_request.go index 23dae7d71e..373997007f 100644 --- a/internal/translator/codex/gemini/codex_gemini_request.go +++ b/internal/translator/codex/gemini/codex_gemini_request.go @@ -284,7 +284,11 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) util.Walk(toolsResult, "", "type", &pathsToLower) for _, p := range pathsToLower { fullPath := fmt.Sprintf("tools.%s", p) - out, _ = sjson.SetBytes(out, fullPath, strings.ToLower(gjson.GetBytes(out, fullPath).String())) + typeValue := gjson.GetBytes(out, fullPath) + if typeValue.Type != gjson.String { + continue + } + out, _ = sjson.SetBytes(out, fullPath, strings.ToLower(typeValue.String())) } return out From 8e6ef3fa645caf15466130084760c1ecf6d925bb Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 4 May 2026 05:23:23 +0800 Subject: [PATCH 101/139] fix(websocket): ensure state consistency on auth errors in streaming - Added logic to reset `pinnedAuthID` and replay transcript on unauthorized, forbidden, or throttling errors. - Enhanced error handling in `forwardResponsesWebsocket` with detailed status inspection. - Introduced `shouldReleaseResponsesWebsocketPinnedAuth` to determine auth reset conditions. - Updated state management to preserve prior request and response data during forced replay. Fixed: #2230 --- .../openai/openai_responses_websocket.go | 53 ++++- .../openai/openai_responses_websocket_test.go | 187 +++++++++++++++++- 2 files changed, 229 insertions(+), 11 deletions(-) diff --git a/sdk/api/handlers/openai/openai_responses_websocket.go b/sdk/api/handlers/openai/openai_responses_websocket.go index caf26f131d..7a9d2224f7 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket.go +++ b/sdk/api/handlers/openai/openai_responses_websocket.go @@ -79,6 +79,7 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) { var lastRequest []byte lastResponseOutput := []byte("[]") pinnedAuthID := "" + forceTranscriptReplayNextRequest := false for { msgType, payload, errReadMessage := conn.ReadMessage() @@ -115,6 +116,9 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) { } allowIncrementalInputWithPreviousResponseID = h.websocketUpstreamSupportsIncrementalInputForModel(requestModelName) } + if forceTranscriptReplayNextRequest { + allowIncrementalInputWithPreviousResponseID = false + } allowCompactionReplayBypass := false if pinnedAuthID != "" && h != nil && h.AuthManager != nil { @@ -179,7 +183,13 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) { requestJSON = repairResponsesWebsocketToolCalls(downstreamSessionKey, requestJSON) updatedLastRequest = bytes.Clone(requestJSON) + previousLastRequest := bytes.Clone(lastRequest) + previousLastResponseOutput := bytes.Clone(lastResponseOutput) + forcedTranscriptReplay := forceTranscriptReplayNextRequest lastRequest = updatedLastRequest + if forcedTranscriptReplay { + forceTranscriptReplayNextRequest = false + } modelName := gjson.GetBytes(requestJSON, "model").String() cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background()) @@ -204,12 +214,19 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) { } dataChan, _, errChan := h.ExecuteStreamWithAuthManager(cliCtx, h.HandlerType(), modelName, requestJSON, "") - completedOutput, errForward := h.forwardResponsesWebsocket(c, conn, cliCancel, dataChan, errChan, &wsTimelineLog, passthroughSessionID) + completedOutput, forwardErrMsg, errForward := h.forwardResponsesWebsocket(c, conn, cliCancel, dataChan, errChan, &wsTimelineLog, passthroughSessionID) if errForward != nil { wsTerminateErr = errForward log.Warnf("responses websocket: forward failed id=%s error=%v", passthroughSessionID, errForward) return } + if shouldReleaseResponsesWebsocketPinnedAuth(forwardErrMsg) { + pinnedAuthID = "" + forceTranscriptReplayNextRequest = true + lastRequest = previousLastRequest + lastResponseOutput = previousLastResponseOutput + continue + } lastResponseOutput = completedOutput } } @@ -810,7 +827,7 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocket( errs <-chan *interfaces.ErrorMessage, wsTimelineLog *strings.Builder, sessionID string, -) ([]byte, error) { +) ([]byte, *interfaces.ErrorMessage, error) { completed := false completedOutput := []byte("[]") downstreamSessionKey := "" @@ -822,7 +839,7 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocket( select { case <-c.Request.Context().Done(): cancel(c.Request.Context().Err()) - return completedOutput, c.Request.Context().Err() + return completedOutput, nil, c.Request.Context().Err() case errMsg, ok := <-errs: if !ok { errs = nil @@ -847,7 +864,7 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocket( // errWrite, // ) cancel(errMsg.Error) - return completedOutput, errWrite + return completedOutput, errMsg, errWrite } } if errMsg != nil { @@ -855,7 +872,7 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocket( } else { cancel(nil) } - return completedOutput, nil + return completedOutput, errMsg, nil case chunk, ok := <-data: if !ok { if !completed { @@ -881,13 +898,13 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocket( errWrite, ) cancel(errMsg.Error) - return completedOutput, errWrite + return completedOutput, errMsg, errWrite } cancel(errMsg.Error) - return completedOutput, nil + return completedOutput, errMsg, nil } cancel(nil) - return completedOutput, nil + return completedOutput, nil, nil } payloads := websocketJSONPayloadsFromChunk(chunk) @@ -914,13 +931,31 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocket( errWrite, ) cancel(errWrite) - return completedOutput, errWrite + return completedOutput, nil, errWrite } } } } } +func shouldReleaseResponsesWebsocketPinnedAuth(errMsg *interfaces.ErrorMessage) bool { + if errMsg == nil { + return false + } + status := errMsg.StatusCode + if status <= 0 && errMsg.Error != nil { + if se, ok := errMsg.Error.(interface{ StatusCode() int }); ok && se != nil { + status = se.StatusCode() + } + } + switch status { + case http.StatusUnauthorized, http.StatusPaymentRequired, http.StatusForbidden, http.StatusTooManyRequests: + return true + default: + return false + } +} + func responseCompletedOutputFromPayload(payload []byte) []byte { output := gjson.GetBytes(payload, "response.output") if output.Exists() && output.IsArray() { diff --git a/sdk/api/handlers/openai/openai_responses_websocket_test.go b/sdk/api/handlers/openai/openai_responses_websocket_test.go index f2c4319eb0..1d397ecd2a 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket_test.go +++ b/sdk/api/handlers/openai/openai_responses_websocket_test.go @@ -69,6 +69,22 @@ type websocketAuthCaptureExecutor struct { authIDs []string } +type websocketPinnedFailoverExecutor struct { + mu sync.Mutex + authIDs []string + calls map[string]int + payloads map[string][][]byte +} + +type websocketPinnedFailoverStatusError struct { + status int + msg string +} + +func (e websocketPinnedFailoverStatusError) Error() string { return e.msg } + +func (e websocketPinnedFailoverStatusError) StatusCode() int { return e.status } + func (e *websocketAuthCaptureExecutor) Identifier() string { return "test-provider" } func (e *websocketAuthCaptureExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) { @@ -106,6 +122,76 @@ func (e *websocketAuthCaptureExecutor) AuthIDs() []string { return append([]string(nil), e.authIDs...) } +func (e *websocketPinnedFailoverExecutor) Identifier() string { return "test-provider" } + +func (e *websocketPinnedFailoverExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) { + return coreexecutor.Response{}, errors.New("not implemented") +} + +func (e *websocketPinnedFailoverExecutor) ExecuteStream(_ context.Context, auth *coreauth.Auth, req coreexecutor.Request, _ coreexecutor.Options) (*coreexecutor.StreamResult, error) { + authID := "" + if auth != nil { + authID = auth.ID + } + + e.mu.Lock() + if e.calls == nil { + e.calls = make(map[string]int) + } + if e.payloads == nil { + e.payloads = make(map[string][][]byte) + } + e.authIDs = append(e.authIDs, authID) + e.calls[authID]++ + call := e.calls[authID] + e.payloads[authID] = append(e.payloads[authID], bytes.Clone(req.Payload)) + e.mu.Unlock() + + if authID == "auth-a" && call == 2 { + chunks := make(chan coreexecutor.StreamChunk, 1) + chunks <- coreexecutor.StreamChunk{Err: websocketPinnedFailoverStatusError{ + status: http.StatusTooManyRequests, + msg: `{"error":{"message":"quota exhausted","type":"rate_limit_error","code":"rate_limit_exceeded"}}`, + }} + close(chunks) + return &coreexecutor.StreamResult{Chunks: chunks}, nil + } + + chunks := make(chan coreexecutor.StreamChunk, 1) + chunks <- coreexecutor.StreamChunk{Payload: []byte(fmt.Sprintf(`{"type":"response.completed","response":{"id":"resp-%s-%d","output":[{"type":"message","id":"out-%s-%d"}]}}`, authID, call, authID, call))} + close(chunks) + return &coreexecutor.StreamResult{Chunks: chunks}, nil +} + +func (e *websocketPinnedFailoverExecutor) Refresh(_ context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) { + return auth, nil +} + +func (e *websocketPinnedFailoverExecutor) CountTokens(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) { + return coreexecutor.Response{}, errors.New("not implemented") +} + +func (e *websocketPinnedFailoverExecutor) HttpRequest(context.Context, *coreauth.Auth, *http.Request) (*http.Response, error) { + return nil, errors.New("not implemented") +} + +func (e *websocketPinnedFailoverExecutor) AuthIDs() []string { + e.mu.Lock() + defer e.mu.Unlock() + return append([]string(nil), e.authIDs...) +} + +func (e *websocketPinnedFailoverExecutor) Payloads(authID string) [][]byte { + e.mu.Lock() + defer e.mu.Unlock() + src := e.payloads[authID] + out := make([][]byte, len(src)) + for i := range src { + out[i] = bytes.Clone(src[i]) + } + return out +} + func (e *websocketCaptureExecutor) Identifier() string { return "test-provider" } func (e *websocketCaptureExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) { @@ -681,7 +767,7 @@ func TestForwardResponsesWebsocketPreservesCompletedEvent(t *testing.T) { close(errCh) var timelineLog strings.Builder - completedOutput, err := (*OpenAIResponsesAPIHandler)(nil).forwardResponsesWebsocket( + completedOutput, errMsg, err := (*OpenAIResponsesAPIHandler)(nil).forwardResponsesWebsocket( ctx, conn, func(...interface{}) {}, @@ -694,6 +780,10 @@ func TestForwardResponsesWebsocketPreservesCompletedEvent(t *testing.T) { serverErrCh <- err return } + if errMsg != nil { + serverErrCh <- fmt.Errorf("unexpected websocket error message: %v", errMsg.Error) + return + } if gjson.GetBytes(completedOutput, "0.id").String() != "out-1" { serverErrCh <- errors.New("completed output not captured") return @@ -760,7 +850,7 @@ func TestForwardResponsesWebsocketLogsAttemptedResponseOnWriteFailure(t *testing return } - _, err = (*OpenAIResponsesAPIHandler)(nil).forwardResponsesWebsocket( + _, _, err = (*OpenAIResponsesAPIHandler)(nil).forwardResponsesWebsocket( ctx, conn, func(...interface{}) {}, @@ -1113,6 +1203,99 @@ func TestResponsesWebsocketPinsOnlyWebsocketCapableAuth(t *testing.T) { } } +func TestResponsesWebsocketReleasesPinnedAuthAfterQuotaError(t *testing.T) { + gin.SetMode(gin.TestMode) + + selector := &orderedWebsocketSelector{order: []string{"auth-a", "auth-b"}} + executor := &websocketPinnedFailoverExecutor{} + manager := coreauth.NewManager(nil, selector, nil) + manager.RegisterExecutor(executor) + + authA := &coreauth.Auth{ + ID: "auth-a", + Provider: executor.Identifier(), + Status: coreauth.StatusActive, + Attributes: map[string]string{"websockets": "true"}, + } + if _, err := manager.Register(context.Background(), authA); err != nil { + t.Fatalf("Register auth A: %v", err) + } + authB := &coreauth.Auth{ + ID: "auth-b", + Provider: executor.Identifier(), + Status: coreauth.StatusActive, + Attributes: map[string]string{"websockets": "true"}, + } + if _, err := manager.Register(context.Background(), authB); err != nil { + t.Fatalf("Register auth B: %v", err) + } + + registry.GetGlobalRegistry().RegisterClient(authA.ID, authA.Provider, []*registry.ModelInfo{{ID: "quota-model"}}) + registry.GetGlobalRegistry().RegisterClient(authB.ID, authB.Provider, []*registry.ModelInfo{{ID: "quota-model"}}) + t.Cleanup(func() { + registry.GetGlobalRegistry().UnregisterClient(authA.ID) + registry.GetGlobalRegistry().UnregisterClient(authB.ID) + }) + + base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager) + h := NewOpenAIResponsesAPIHandler(base) + router := gin.New() + router.GET("/v1/responses/ws", h.ResponsesWebsocket) + + server := httptest.NewServer(router) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/v1/responses/ws" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial websocket: %v", err) + } + defer func() { + if errClose := conn.Close(); errClose != nil { + t.Fatalf("close websocket: %v", errClose) + } + }() + + requests := []string{ + `{"type":"response.create","model":"quota-model","input":[{"type":"message","id":"msg-1"}]}`, + `{"type":"response.create","previous_response_id":"resp-auth-a-1","input":[{"type":"message","id":"msg-2"}]}`, + `{"type":"response.create","previous_response_id":"resp-auth-a-1","input":[{"type":"message","id":"msg-3"}]}`, + } + wantTypes := []string{wsEventTypeCompleted, wsEventTypeError, wsEventTypeCompleted} + for i := range requests { + if errWrite := conn.WriteMessage(websocket.TextMessage, []byte(requests[i])); errWrite != nil { + t.Fatalf("write websocket message %d: %v", i+1, errWrite) + } + _, payload, errReadMessage := conn.ReadMessage() + if errReadMessage != nil { + t.Fatalf("read websocket message %d: %v", i+1, errReadMessage) + } + if got := gjson.GetBytes(payload, "type").String(); got != wantTypes[i] { + t.Fatalf("message %d payload type = %s, want %s: %s", i+1, got, wantTypes[i], payload) + } + if i == 1 && int(gjson.GetBytes(payload, "status").Int()) != http.StatusTooManyRequests { + t.Fatalf("quota payload status = %d, want %d: %s", gjson.GetBytes(payload, "status").Int(), http.StatusTooManyRequests, payload) + } + } + + if got := executor.AuthIDs(); len(got) != 3 || got[0] != "auth-a" || got[1] != "auth-a" || got[2] != "auth-b" { + t.Fatalf("selected auth IDs = %v, want [auth-a auth-a auth-b]", got) + } + + authBPayloads := executor.Payloads("auth-b") + if len(authBPayloads) != 1 { + t.Fatalf("auth-b payload count = %d, want 1", len(authBPayloads)) + } + authBPayload := authBPayloads[0] + if gjson.GetBytes(authBPayload, "previous_response_id").Exists() { + t.Fatalf("previous_response_id leaked after auth failover: %s", authBPayload) + } + authBInput := gjson.GetBytes(authBPayload, "input").Raw + if !strings.Contains(authBInput, `"id":"msg-1"`) || !strings.Contains(authBInput, `"id":"msg-3"`) { + t.Fatalf("auth-b replay input missing expected transcript items: %s", authBInput) + } +} + func TestNormalizeResponsesWebsocketRequestTreatsTranscriptReplacementAsReset(t *testing.T) { lastRequest := []byte(`{"model":"test-model","stream":true,"input":[{"type":"message","id":"msg-1"},{"type":"function_call","id":"fc-1","call_id":"call-1"},{"type":"function_call_output","id":"tool-out-1","call_id":"call-1"},{"type":"message","id":"assistant-1","role":"assistant"}]}`) lastResponseOutput := []byte(`[ From 38dad2afdf8280350e0f09b07a32ba0865596a26 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 4 May 2026 05:36:09 +0800 Subject: [PATCH 102/139] chore(docker): upgrade base image to alpine 3.23 Fixed: #2265 --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3e10c4f9f8..b4caaee325 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ ARG BUILD_DATE=unknown RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X 'main.Version=${VERSION}' -X 'main.Commit=${COMMIT}' -X 'main.BuildDate=${BUILD_DATE}'" -o ./CLIProxyAPI ./cmd/server/ -FROM alpine:3.22.0 +FROM alpine:3.23 RUN apk add --no-cache tzdata @@ -32,4 +32,4 @@ ENV TZ=Asia/Shanghai RUN cp /usr/share/zoneinfo/${TZ} /etc/localtime && echo "${TZ}" > /etc/timezone -CMD ["./CLIProxyAPI"] \ No newline at end of file +CMD ["./CLIProxyAPI"] From 17be6442a8bfebe69e20631d99b5b6961eb54e4c Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 4 May 2026 05:50:01 +0800 Subject: [PATCH 103/139] fix(translator): improve tool response handling for non-string content - Added `setToolCallOutputContent` to process various content types, including arrays and fallback cases. - Implemented robust handling for specific tool output types like text, image URLs, and files, ensuring proper serialization. - Improved fallback logic to handle unexpected or missing data. Fixed: #2313 Closes: #2349 --- .../chat-completions/codex_openai_request.go | 89 ++++++++- .../codex_openai_request_test.go | 176 ++++++++++++++++++ 2 files changed, 263 insertions(+), 2 deletions(-) diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_request.go b/internal/translator/codex/openai/chat-completions/codex_openai_request.go index 6cc701e707..569e06e316 100644 --- a/internal/translator/codex/openai/chat-completions/codex_openai_request.go +++ b/internal/translator/codex/openai/chat-completions/codex_openai_request.go @@ -121,13 +121,13 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b case "tool": // Handle tool response messages as top-level function_call_output objects toolCallID := m.Get("tool_call_id").String() - content := m.Get("content").String() + content := m.Get("content") // Create function_call_output object funcOutput := []byte(`{}`) funcOutput, _ = sjson.SetBytes(funcOutput, "type", "function_call_output") funcOutput, _ = sjson.SetBytes(funcOutput, "call_id", toolCallID) - funcOutput, _ = sjson.SetBytes(funcOutput, "output", content) + funcOutput = setToolCallOutputContent(funcOutput, content) out, _ = sjson.SetRawBytes(out, "input.-1", funcOutput) default: @@ -359,6 +359,91 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b return out } +func setToolCallOutputContent(funcOutput []byte, content gjson.Result) []byte { + switch { + case content.Type == gjson.String: + funcOutput, _ = sjson.SetBytes(funcOutput, "output", content.String()) + case content.IsArray(): + output := []byte(`[]`) + for _, item := range content.Array() { + output = appendToolOutputContentPart(output, item) + } + funcOutput, _ = sjson.SetRawBytes(funcOutput, "output", output) + default: + fallbackOutput := content.Raw + if fallbackOutput == "" { + fallbackOutput = content.String() + } + funcOutput, _ = sjson.SetBytes(funcOutput, "output", fallbackOutput) + } + return funcOutput +} + +func appendToolOutputContentPart(output []byte, item gjson.Result) []byte { + switch item.Get("type").String() { + case "text": + part := []byte(`{}`) + part, _ = sjson.SetBytes(part, "type", "input_text") + part, _ = sjson.SetBytes(part, "text", item.Get("text").String()) + output, _ = sjson.SetRawBytes(output, "-1", part) + case "image_url": + imageURL := item.Get("image_url.url").String() + fileID := item.Get("image_url.file_id").String() + if imageURL == "" && fileID == "" { + return appendToolOutputFallbackPart(output, item) + } + part := []byte(`{}`) + part, _ = sjson.SetBytes(part, "type", "input_image") + if imageURL != "" { + part, _ = sjson.SetBytes(part, "image_url", imageURL) + } + if fileID != "" { + part, _ = sjson.SetBytes(part, "file_id", fileID) + } + if detail := item.Get("image_url.detail").String(); detail != "" { + part, _ = sjson.SetBytes(part, "detail", detail) + } + output, _ = sjson.SetRawBytes(output, "-1", part) + case "file": + fileID := item.Get("file.file_id").String() + fileData := item.Get("file.file_data").String() + fileURL := item.Get("file.file_url").String() + if fileID == "" && fileData == "" && fileURL == "" { + return appendToolOutputFallbackPart(output, item) + } + part := []byte(`{}`) + part, _ = sjson.SetBytes(part, "type", "input_file") + if fileID != "" { + part, _ = sjson.SetBytes(part, "file_id", fileID) + } + if fileData != "" { + part, _ = sjson.SetBytes(part, "file_data", fileData) + } + if fileURL != "" { + part, _ = sjson.SetBytes(part, "file_url", fileURL) + } + if filename := item.Get("file.filename").String(); filename != "" { + part, _ = sjson.SetBytes(part, "filename", filename) + } + output, _ = sjson.SetRawBytes(output, "-1", part) + default: + output = appendToolOutputFallbackPart(output, item) + } + return output +} + +func appendToolOutputFallbackPart(output []byte, item gjson.Result) []byte { + text := item.Raw + if text == "" { + text = item.String() + } + part := []byte(`{}`) + part, _ = sjson.SetBytes(part, "type", "input_text") + part, _ = sjson.SetBytes(part, "text", text) + output, _ = sjson.SetRawBytes(output, "-1", part) + return output +} + // shortenNameIfNeeded applies the simple shortening rule for a single name. // If the name length exceeds 64, it will try to preserve the "mcp__" prefix and last segment. // Otherwise it truncates to 64 characters. diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_request_test.go b/internal/translator/codex/openai/chat-completions/codex_openai_request_test.go index 84c8dad2cc..e31db6d373 100644 --- a/internal/translator/codex/openai/chat-completions/codex_openai_request_test.go +++ b/internal/translator/codex/openai/chat-completions/codex_openai_request_test.go @@ -176,6 +176,182 @@ func TestToolCallWithContent(t *testing.T) { } } +func TestToolCallOutputWithMultimodalContent(t *testing.T) { + input := []byte(`{ + "model": "gpt-4o", + "messages": [ + {"role": "user", "content": "Show me the generated result."}, + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_output_1", + "type": "function", + "function": {"name": "render_output", "arguments": "{}"} + } + ] + }, + { + "role": "tool", + "tool_call_id": "call_output_1", + "content": [ + {"type":"text","text":"Rendered result attached."}, + {"type":"image_url","image_url":{"url":"https://example.com/generated.png","detail":"high"}}, + {"type":"image_url","image_url":{"file_id":"file-img-123"}}, + {"type":"file","file":{"file_id":"file-doc-123","filename":"doc.pdf"}}, + {"type":"file","file":{"file_data":"SGVsbG8=","filename":"inline.txt"}}, + {"type":"file","file":{"file_url":"https://example.com/report.pdf","filename":"report.pdf"}} + ] + } + ], + "tools": [ + { + "type": "function", + "function": {"name": "render_output", "description": "Render output", "parameters": {"type": "object", "properties": {}}} + } + ] + }`) + + out := ConvertOpenAIRequestToCodex("gpt-4o", input, true) + result := string(out) + + output := gjson.Get(result, "input.2.output") + if !output.IsArray() { + t.Fatalf("expected tool output to be an array, got: %s", output.Raw) + } + + parts := output.Array() + if len(parts) != 6 { + t.Fatalf("expected 6 output parts, got %d: %s", len(parts), output.Raw) + } + if parts[0].Get("type").String() != "input_text" || parts[0].Get("text").String() != "Rendered result attached." { + t.Fatalf("part 0: expected input_text with rendered text, got %s", parts[0].Raw) + } + if parts[1].Get("type").String() != "input_image" { + t.Fatalf("part 1: expected input_image, got %s", parts[1].Raw) + } + if parts[1].Get("image_url").String() != "https://example.com/generated.png" { + t.Errorf("part 1: unexpected image_url %s", parts[1].Get("image_url").String()) + } + if parts[1].Get("detail").String() != "high" { + t.Errorf("part 1: unexpected detail %s", parts[1].Get("detail").String()) + } + if parts[2].Get("type").String() != "input_image" || parts[2].Get("file_id").String() != "file-img-123" { + t.Fatalf("part 2: expected file_id-backed input_image, got %s", parts[2].Raw) + } + if parts[3].Get("type").String() != "input_file" || parts[3].Get("file_id").String() != "file-doc-123" { + t.Fatalf("part 3: expected file_id-backed input_file, got %s", parts[3].Raw) + } + if parts[3].Get("filename").String() != "doc.pdf" { + t.Errorf("part 3: unexpected filename %s", parts[3].Get("filename").String()) + } + if parts[4].Get("type").String() != "input_file" || parts[4].Get("file_data").String() != "SGVsbG8=" { + t.Fatalf("part 4: expected file_data-backed input_file, got %s", parts[4].Raw) + } + if parts[5].Get("type").String() != "input_file" || parts[5].Get("file_url").String() != "https://example.com/report.pdf" { + t.Fatalf("part 5: expected file_url-backed input_file, got %s", parts[5].Raw) + } +} + +func TestToolCallOutputFallsBackForInvalidStructuredParts(t *testing.T) { + input := []byte(`{ + "model": "gpt-4o", + "messages": [ + {"role": "user", "content": "Check tool output."}, + { + "role": "assistant", + "content": null, + "tool_calls": [ + {"id": "call_invalid_parts", "type": "function", "function": {"name": "inspect", "arguments": "{}"}} + ] + }, + { + "role": "tool", + "tool_call_id": "call_invalid_parts", + "content": [ + {"type":"image_url","image_url":{"detail":"low"}}, + {"type":"file","file":{"filename":"orphan.txt"}}, + {"type":"unknown_type","foo":"bar","nested":{"a":1}} + ] + } + ], + "tools": [ + {"type": "function", "function": {"name": "inspect", "description": "Inspect", "parameters": {"type": "object", "properties": {}}}} + ] + }`) + + out := ConvertOpenAIRequestToCodex("gpt-4o", input, true) + result := string(out) + + parts := gjson.Get(result, "input.2.output").Array() + if len(parts) != 3 { + t.Fatalf("expected 3 output parts, got %d: %s", len(parts), gjson.Get(result, "input.2.output").Raw) + } + + expectedFallbacks := []string{ + `{"type":"image_url","image_url":{"detail":"low"}}`, + `{"type":"file","file":{"filename":"orphan.txt"}}`, + `{"type":"unknown_type","foo":"bar","nested":{"a":1}}`, + } + for i, expectedFallback := range expectedFallbacks { + if parts[i].Get("type").String() != "input_text" { + t.Fatalf("part %d: expected input_text fallback, got %s", i, parts[i].Raw) + } + if parts[i].Get("text").String() != expectedFallback { + t.Fatalf("part %d: expected fallback %s, got %s", i, expectedFallback, parts[i].Get("text").String()) + } + } +} + +func TestToolCallOutputWithNonStringJSONContent(t *testing.T) { + tests := []struct { + name string + content string + expectedOutput string + }{ + {name: "null", content: `null`, expectedOutput: `null`}, + {name: "object", content: `{"status":"ok","count":2}`, expectedOutput: `{"status":"ok","count":2}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := []byte(`{ + "model": "gpt-4o", + "messages": [ + {"role": "user", "content": "Check tool output."}, + { + "role": "assistant", + "content": null, + "tool_calls": [ + {"id": "call_json", "type": "function", "function": {"name": "inspect", "arguments": "{}"}} + ] + }, + { + "role": "tool", + "tool_call_id": "call_json", + "content": ` + tt.content + ` + } + ], + "tools": [ + {"type": "function", "function": {"name": "inspect", "description": "Inspect", "parameters": {"type": "object", "properties": {}}}} + ] + }`) + + out := ConvertOpenAIRequestToCodex("gpt-4o", input, true) + result := string(out) + + output := gjson.Get(result, "input.2.output") + if !output.Exists() { + t.Fatalf("expected output field to exist: %s", gjson.Get(result, "input.2").Raw) + } + if output.String() != tt.expectedOutput { + t.Fatalf("expected output %s, got %s", tt.expectedOutput, output.String()) + } + }) + } +} + // Parallel tool calls: assistant invokes 3 tools at once, all call_ids // and outputs must be translated and paired correctly. func TestMultipleToolCalls(t *testing.T) { From c19ae1d5be32537218b61e26ebe7845720966801 Mon Sep 17 00:00:00 2001 From: Kenny Date: Sun, 3 May 2026 15:56:39 -0700 Subject: [PATCH 104/139] Align Codex websocket protocol semantics --- internal/runtime/executor/codex_executor.go | 2 +- .../executor/codex_websockets_executor.go | 153 +++++++++++--- .../codex_websockets_executor_test.go | 189 +++++++++++++++++- 3 files changed, 310 insertions(+), 34 deletions(-) diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index aa8223f4fe..5e892ecdb4 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -31,7 +31,7 @@ import ( const ( codexUserAgent = "codex-tui/0.118.0 (Mac OS 26.3.1; arm64) iTerm.app/3.6.9 (codex-tui; 0.118.0)" - codexOriginator = "codex-tui" + codexOriginator = "codex_cli_rs" codexDefaultImageToolModel = "gpt-image-2" ) diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go index 40ba7e92ea..87ae0efe49 100644 --- a/internal/runtime/executor/codex_websockets_executor.go +++ b/internal/runtime/executor/codex_websockets_executor.go @@ -188,7 +188,6 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath) body, _ = sjson.SetBytes(body, "model", baseModel) body, _ = sjson.SetBytes(body, "stream", true) - body, _ = sjson.DeleteBytes(body, "previous_response_id") body, _ = sjson.DeleteBytes(body, "prompt_cache_retention") body, _ = sjson.DeleteBytes(body, "safety_identifier") body = normalizeCodexInstructions(body) @@ -776,6 +775,11 @@ func buildCodexResponsesWebsocketURL(httpURL string) (string, error) { parsed.Scheme = "ws" case "https": parsed.Scheme = "wss" + default: + return "", fmt.Errorf("codex websockets executor: unsupported responses websocket URL scheme %q", parsed.Scheme) + } + if strings.TrimSpace(parsed.Host) == "" { + return "", fmt.Errorf("codex websockets executor: responses websocket URL host is empty") } return parsed.String(), nil } @@ -809,6 +813,7 @@ func applyCodexPromptCacheHeaders(from sdktranslator.Format, req cliproxyexecuto if cache.ID != "" { rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", cache.ID) + setHeaderCasePreserved(headers, "session_id", cache.ID) headers.Set("Conversation_id", cache.ID) } @@ -828,13 +833,19 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth * ginHeaders = ginCtx.Request.Header.Clone() } - _, cfgBetaFeatures := codexHeaderDefaults(cfg, auth) + isAPIKey := codexAuthUsesAPIKey(auth) + cfgUserAgent, cfgBetaFeatures := codexHeaderDefaults(cfg, auth) ensureHeaderWithPriority(headers, ginHeaders, "x-codex-beta-features", cfgBetaFeatures, "") misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-state", "") misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-metadata", "") misc.EnsureHeader(headers, ginHeaders, "x-client-request-id", "") misc.EnsureHeader(headers, ginHeaders, "x-responsesapi-include-timing-metrics", "") misc.EnsureHeader(headers, ginHeaders, "Version", "") + if isAPIKey { + ensureHeaderWithPriority(headers, ginHeaders, "User-Agent", "", "") + } else { + ensureHeaderWithConfigPrecedence(headers, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent) + } betaHeader := strings.TrimSpace(headers.Get("OpenAI-Beta")) if betaHeader == "" && ginHeaders != nil { @@ -845,16 +856,9 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth * } headers.Set("OpenAI-Beta", betaHeader) if strings.Contains(headers.Get("User-Agent"), "Mac OS") { - misc.EnsureHeader(headers, ginHeaders, "Session_id", uuid.NewString()) - } - headers.Del("User-Agent") - - isAPIKey := false - if auth != nil && auth.Attributes != nil { - if v := strings.TrimSpace(auth.Attributes["api_key"]); v != "" { - isAPIKey = true - } + ensureHeaderCasePreserved(headers, ginHeaders, "session_id", "", uuid.NewString()) } + ensureHeaderCasePreserved(headers, ginHeaders, "session_id", "", "") if originator := strings.TrimSpace(ginHeaders.Get("Originator")); originator != "" { headers.Set("Originator", originator) } else if !isAPIKey { @@ -864,7 +868,7 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth * if auth != nil && auth.Metadata != nil { if accountID, ok := auth.Metadata["account_id"].(string); ok { if trimmed := strings.TrimSpace(accountID); trimmed != "" { - headers.Set("Chatgpt-Account-Id", trimmed) + headers.Set("ChatGPT-Account-ID", trimmed) } } } @@ -879,6 +883,77 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth * return headers } +func codexAuthUsesAPIKey(auth *cliproxyauth.Auth) bool { + if auth == nil || auth.Attributes == nil { + return false + } + return strings.TrimSpace(auth.Attributes["api_key"]) != "" +} + +func ensureHeaderCasePreserved(target http.Header, source http.Header, key, configValue, fallbackValue string) { + if target == nil { + return + } + if strings.TrimSpace(headerValueCaseInsensitive(target, key)) != "" { + return + } + if source != nil { + if val := strings.TrimSpace(headerValueCaseInsensitive(source, key)); val != "" { + setHeaderCasePreserved(target, key, val) + return + } + } + if val := strings.TrimSpace(configValue); val != "" { + setHeaderCasePreserved(target, key, val) + return + } + if val := strings.TrimSpace(fallbackValue); val != "" { + setHeaderCasePreserved(target, key, val) + } +} + +func setHeaderCasePreserved(headers http.Header, key string, value string) { + if headers == nil { + return + } + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if key == "" || value == "" { + return + } + deleteHeaderCaseInsensitive(headers, key) + headers[key] = []string{value} +} + +func headerValueCaseInsensitive(headers http.Header, key string) string { + key = strings.TrimSpace(key) + if headers == nil || key == "" { + return "" + } + if val := strings.TrimSpace(headers.Get(key)); val != "" { + return val + } + for existingKey, values := range headers { + if !strings.EqualFold(existingKey, key) { + continue + } + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + } + return "" +} + +func deleteHeaderCaseInsensitive(headers http.Header, key string) { + for existingKey := range headers { + if strings.EqualFold(existingKey, key) { + delete(headers, existingKey) + } + } +} + func codexHeaderDefaults(cfg *config.Config, auth *cliproxyauth.Auth) (string, string) { if cfg == nil || auth == nil { return "", "" @@ -962,25 +1037,53 @@ func parseCodexWebsocketError(payload []byte) (error, bool) { return nil, false } - out := []byte(`{}`) - if errNode := gjson.GetBytes(payload, "error"); errNode.Exists() { - raw := errNode.Raw - if errNode.Type == gjson.String { - raw = errNode.Raw - } - out, _ = sjson.SetRawBytes(out, "error", []byte(raw)) - } else { - out, _ = sjson.SetBytes(out, "error.type", "server_error") - out, _ = sjson.SetBytes(out, "error.message", http.StatusText(status)) - } - + out := buildCodexWebsocketErrorPayload(payload, status) headers := parseCodexWebsocketErrorHeaders(payload) + statusError := statusErr{code: status, msg: string(out)} + if isCodexWebsocketConnectionLimitError(payload) { + retryAfter := time.Duration(0) + statusError.retryAfter = &retryAfter + } return statusErrWithHeaders{ - statusErr: statusErr{code: status, msg: string(out)}, + statusErr: statusError, headers: headers, }, true } +func buildCodexWebsocketErrorPayload(payload []byte, status int) []byte { + out := []byte(`{}`) + out, _ = sjson.SetBytes(out, "status", status) + + if bodyNode := gjson.GetBytes(payload, "body"); bodyNode.Exists() { + out, _ = sjson.SetRawBytes(out, "body", []byte(bodyNode.Raw)) + if bodyErrorNode := bodyNode.Get("error"); bodyErrorNode.Exists() { + out, _ = sjson.SetRawBytes(out, "error", []byte(bodyErrorNode.Raw)) + return out + } + } + + if errNode := gjson.GetBytes(payload, "error"); errNode.Exists() { + out, _ = sjson.SetRawBytes(out, "error", []byte(errNode.Raw)) + return out + } + + out, _ = sjson.SetBytes(out, "error.type", "server_error") + out, _ = sjson.SetBytes(out, "error.message", http.StatusText(status)) + return out +} + +func isCodexWebsocketConnectionLimitError(payload []byte) bool { + if len(payload) == 0 { + return false + } + for _, path := range []string{"error.code", "error.type", "body.error.code", "body.error.type", "code", "error"} { + if strings.TrimSpace(gjson.GetBytes(payload, path).String()) == "websocket_connection_limit_reached" { + return true + } + } + return false +} + func parseCodexWebsocketErrorHeaders(payload []byte) http.Header { headersNode := gjson.GetBytes(payload, "headers") if !headersNode.Exists() || !headersNode.IsObject() { diff --git a/internal/runtime/executor/codex_websockets_executor_test.go b/internal/runtime/executor/codex_websockets_executor_test.go index dec356de4c..0b7a546e98 100644 --- a/internal/runtime/executor/codex_websockets_executor_test.go +++ b/internal/runtime/executor/codex_websockets_executor_test.go @@ -1,15 +1,20 @@ package executor import ( + "bytes" "context" "net/http" "net/http/httptest" "testing" + "time" "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" "github.com/tidwall/gjson" ) @@ -32,14 +37,71 @@ func TestBuildCodexWebsocketRequestBodyPreservesPreviousResponseID(t *testing.T) } } +func TestCodexWebsocketsExecutePreservesPreviousResponseIDUpstream(t *testing.T) { + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + capturedPayload := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/responses" { + t.Fatalf("request path = %s, want /responses", r.URL.Path) + } + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Fatalf("upgrade websocket: %v", err) + } + defer func() { _ = conn.Close() }() + + msgType, payload, err := conn.ReadMessage() + if err != nil { + t.Fatalf("read upstream websocket message: %v", err) + } + if msgType != websocket.TextMessage { + t.Fatalf("message type = %d, want text", msgType) + } + capturedPayload <- bytes.Clone(payload) + + completed := []byte(`{"type":"response.completed","response":{"id":"resp-2","output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}`) + if errWrite := conn.WriteMessage(websocket.TextMessage, completed); errWrite != nil { + t.Fatalf("write completed websocket message: %v", errWrite) + } + })) + defer server.Close() + + exec := NewCodexWebsocketsExecutor(&config.Config{SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}}) + auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "sk-test", "base_url": server.URL}} + req := cliproxyexecutor.Request{ + Model: "gpt-5-codex", + Payload: []byte(`{"model":"gpt-5-codex","previous_response_id":"resp-1","input":[{"type":"message","id":"msg-1"}]}`), + } + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("codex")} + + if _, err := exec.Execute(context.Background(), auth, req, opts); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + select { + case payload := <-capturedPayload: + if got := gjson.GetBytes(payload, "type").String(); got != "response.create" { + t.Fatalf("upstream type = %s, want response.create; payload=%s", got, payload) + } + if got := gjson.GetBytes(payload, "previous_response_id").String(); got != "resp-1" { + t.Fatalf("upstream previous_response_id = %s, want resp-1; payload=%s", got, payload) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for upstream websocket payload") + } +} + func TestApplyCodexWebsocketHeadersDefaultsToCurrentResponsesBeta(t *testing.T) { headers := applyCodexWebsocketHeaders(context.Background(), http.Header{}, nil, "", nil) if got := headers.Get("OpenAI-Beta"); got != codexResponsesWebsocketBetaHeaderValue { t.Fatalf("OpenAI-Beta = %s, want %s", got, codexResponsesWebsocketBetaHeaderValue) } - if got := headers.Get("User-Agent"); got != "" { - t.Fatalf("User-Agent = %s, want empty", got) + if got := headers.Get("User-Agent"); got != codexUserAgent { + t.Fatalf("User-Agent = %s, want %s", got, codexUserAgent) + } + if got := headers.Get("Originator"); got != codexOriginator { + t.Fatalf("Originator = %s, want %s", got, codexOriginator) } if got := headers.Get("Version"); got != "" { t.Fatalf("Version = %q, want empty", got) @@ -62,9 +124,11 @@ func TestApplyCodexWebsocketHeadersPassesThroughClientIdentityHeaders(t *testing } ctx := contextWithGinHeaders(map[string]string{ "Originator": "Codex Desktop", + "User-Agent": "codex_cli_rs/0.1.0", "Version": "0.115.0-alpha.27", "X-Codex-Turn-Metadata": `{"turn_id":"turn-1"}`, "X-Client-Request-Id": "019d2233-e240-7162-992d-38df0a2a0e0d", + "session_id": "sess-client", }) headers := applyCodexWebsocketHeaders(ctx, http.Header{}, auth, "", nil) @@ -72,6 +136,9 @@ func TestApplyCodexWebsocketHeadersPassesThroughClientIdentityHeaders(t *testing if got := headers.Get("Originator"); got != "Codex Desktop" { t.Fatalf("Originator = %s, want %s", got, "Codex Desktop") } + if got := headers.Get("User-Agent"); got != "codex_cli_rs/0.1.0" { + t.Fatalf("User-Agent = %s, want %s", got, "codex_cli_rs/0.1.0") + } if got := headers.Get("Version"); got != "0.115.0-alpha.27" { t.Fatalf("Version = %s, want %s", got, "0.115.0-alpha.27") } @@ -81,6 +148,12 @@ func TestApplyCodexWebsocketHeadersPassesThroughClientIdentityHeaders(t *testing if got := headers.Get("X-Client-Request-Id"); got != "019d2233-e240-7162-992d-38df0a2a0e0d" { t.Fatalf("X-Client-Request-Id = %s, want %s", got, "019d2233-e240-7162-992d-38df0a2a0e0d") } + if got := headerValueCaseInsensitive(headers, "session_id"); got != "sess-client" { + t.Fatalf("session_id = %s, want sess-client", got) + } + if _, ok := headers["session_id"]; !ok { + t.Fatalf("expected lowercase session_id header key, got %#v", headers) + } } func TestApplyCodexWebsocketHeadersUsesConfigDefaultsForOAuth(t *testing.T) { @@ -97,8 +170,8 @@ func TestApplyCodexWebsocketHeadersUsesConfigDefaultsForOAuth(t *testing.T) { headers := applyCodexWebsocketHeaders(context.Background(), http.Header{}, auth, "", cfg) - if got := headers.Get("User-Agent"); got != "" { - t.Fatalf("User-Agent = %s, want empty", got) + if got := headers.Get("User-Agent"); got != "my-codex-client/1.0" { + t.Fatalf("User-Agent = %s, want %s", got, "my-codex-client/1.0") } if got := headers.Get("x-codex-beta-features"); got != "feature-a,feature-b" { t.Fatalf("x-codex-beta-features = %s, want %s", got, "feature-a,feature-b") @@ -129,8 +202,8 @@ func TestApplyCodexWebsocketHeadersPrefersExistingHeadersOverClientAndConfig(t * got := applyCodexWebsocketHeaders(ctx, headers, auth, "", cfg) - if gotVal := got.Get("User-Agent"); gotVal != "" { - t.Fatalf("User-Agent = %s, want empty", gotVal) + if gotVal := got.Get("User-Agent"); gotVal != "existing-ua" { + t.Fatalf("User-Agent = %s, want %s", gotVal, "existing-ua") } if gotVal := got.Get("x-codex-beta-features"); gotVal != "existing-beta" { t.Fatalf("x-codex-beta-features = %s, want %s", gotVal, "existing-beta") @@ -155,8 +228,8 @@ func TestApplyCodexWebsocketHeadersConfigUserAgentOverridesClientHeader(t *testi headers := applyCodexWebsocketHeaders(ctx, http.Header{}, auth, "", cfg) - if got := headers.Get("User-Agent"); got != "" { - t.Fatalf("User-Agent = %s, want empty", got) + if got := headers.Get("User-Agent"); got != "config-ua" { + t.Fatalf("User-Agent = %s, want %s", got, "config-ua") } if got := headers.Get("x-codex-beta-features"); got != "client-beta" { t.Fatalf("x-codex-beta-features = %s, want %s", got, "client-beta") @@ -183,6 +256,106 @@ func TestApplyCodexWebsocketHeadersIgnoresConfigForAPIKeyAuth(t *testing.T) { if got := headers.Get("x-codex-beta-features"); got != "" { t.Fatalf("x-codex-beta-features = %q, want empty", got) } + if got := headers.Get("Originator"); got != "" { + t.Fatalf("Originator = %s, want empty", got) + } +} + +func TestApplyCodexWebsocketHeadersPreservesExplicitAPIKeyUserAgent(t *testing.T) { + auth := &cliproxyauth.Auth{Provider: "codex", Attributes: map[string]string{"api_key": "sk-test"}} + ctx := contextWithGinHeaders(map[string]string{"User-Agent": "api-key-client/1.0", "Originator": "explicit-origin"}) + + headers := applyCodexWebsocketHeaders(ctx, http.Header{}, auth, "sk-test", nil) + + if got := headers.Get("User-Agent"); got != "api-key-client/1.0" { + t.Fatalf("User-Agent = %s, want api-key-client/1.0", got) + } + if got := headers.Get("Originator"); got != "explicit-origin" { + t.Fatalf("Originator = %s, want explicit-origin", got) + } +} + +func TestApplyCodexPromptCacheHeadersSetsLowercaseSessionAndLegacyConversation(t *testing.T) { + req := cliproxyexecutor.Request{Model: "gpt-5-codex", Payload: []byte(`{"prompt_cache_key":"cache-1"}`)} + + _, headers := applyCodexPromptCacheHeaders("openai-response", req, []byte(`{"model":"gpt-5-codex"}`)) + + if got := headerValueCaseInsensitive(headers, "session_id"); got != "cache-1" { + t.Fatalf("session_id = %s, want cache-1", got) + } + if _, ok := headers["session_id"]; !ok { + t.Fatalf("expected lowercase session_id key, got %#v", headers) + } + if got := headers.Get("Conversation_id"); got != "cache-1" { + t.Fatalf("Conversation_id = %s, want cache-1", got) + } +} + +func TestApplyCodexWebsocketHeadersUsesCanonicalAccountHeader(t *testing.T) { + auth := &cliproxyauth.Auth{Provider: "codex", Metadata: map[string]any{"account_id": "acct-1"}} + + headers := applyCodexWebsocketHeaders(context.Background(), http.Header{}, auth, "", nil) + + if got := headers.Get("ChatGPT-Account-ID"); got != "acct-1" { + t.Fatalf("ChatGPT-Account-ID = %s, want acct-1", got) + } +} + +func TestBuildCodexResponsesWebsocketURLRequiresHTTPURL(t *testing.T) { + if got, err := buildCodexResponsesWebsocketURL("https://example.com/backend/responses"); err != nil || got != "wss://example.com/backend/responses" { + t.Fatalf("https URL = %q, %v; want wss URL", got, err) + } + if _, err := buildCodexResponsesWebsocketURL("ftp://example.com/responses"); err == nil { + t.Fatalf("expected unsupported scheme error") + } + if _, err := buildCodexResponsesWebsocketURL("https:///responses"); err == nil { + t.Fatalf("expected empty host error") + } +} + +func TestParseCodexWebsocketErrorMarksConnectionLimitRetryable(t *testing.T) { + err, ok := parseCodexWebsocketError([]byte(`{"type":"error","status":429,"error":{"code":"websocket_connection_limit_reached","message":"too many websockets"},"headers":{"retry-after":"1"}}`)) + if !ok { + t.Fatalf("expected websocket error") + } + status, ok := err.(interface{ StatusCode() int }) + if !ok || status.StatusCode() != http.StatusTooManyRequests { + t.Fatalf("status = %#v, want 429", err) + } + retryable, ok := err.(interface{ RetryAfter() *time.Duration }) + if !ok || retryable.RetryAfter() == nil { + t.Fatalf("expected retryable websocket connection limit error") + } + withHeaders, ok := err.(interface{ Headers() http.Header }) + if !ok || withHeaders.Headers().Get("retry-after") != "1" { + t.Fatalf("headers = %#v, want retry-after", err) + } +} + +func TestParseCodexWebsocketErrorPreservesWrappedBodyAndHeaders(t *testing.T) { + err, ok := parseCodexWebsocketError([]byte(`{"type":"error","status":429,"body":{"error":{"code":"websocket_connection_limit_reached","type":"server_error","message":"too many websocket connections"}},"headers":{"x-request-id":"req-1"}}`)) + if !ok { + t.Fatalf("expected websocket error") + } + + parsed := gjson.Parse(err.Error()) + if got := parsed.Get("status").Int(); got != http.StatusTooManyRequests { + t.Fatalf("wrapped status = %d, want 429; payload=%s", got, err.Error()) + } + if got := parsed.Get("body.error.code").String(); got != "websocket_connection_limit_reached" { + t.Fatalf("wrapped body error code = %s, want websocket_connection_limit_reached; payload=%s", got, err.Error()) + } + if got := parsed.Get("error.code").String(); got != "websocket_connection_limit_reached" { + t.Fatalf("surface error code = %s, want websocket_connection_limit_reached; payload=%s", got, err.Error()) + } + retryable, ok := err.(interface{ RetryAfter() *time.Duration }) + if !ok || retryable.RetryAfter() == nil { + t.Fatalf("expected body.error.code websocket connection limit to be retryable") + } + withHeaders, ok := err.(interface{ Headers() http.Header }) + if !ok || withHeaders.Headers().Get("x-request-id") != "req-1" { + t.Fatalf("headers = %#v, want x-request-id", err) + } } func TestApplyCodexHeadersUsesConfigUserAgentForOAuth(t *testing.T) { From 08b0fe6816380dc76ebe7a3f5442d8c7a0bdb661 Mon Sep 17 00:00:00 2001 From: Kenny Date: Sun, 3 May 2026 19:01:44 -0700 Subject: [PATCH 105/139] Fix Codex websocket retry metadata --- .../executor/codex_websockets_executor.go | 6 +++-- .../codex_websockets_executor_test.go | 27 ++++++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go index 87ae0efe49..d6f1de86b2 100644 --- a/internal/runtime/executor/codex_websockets_executor.go +++ b/internal/runtime/executor/codex_websockets_executor.go @@ -868,7 +868,7 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth * if auth != nil && auth.Metadata != nil { if accountID, ok := auth.Metadata["account_id"].(string); ok { if trimmed := strings.TrimSpace(accountID); trimmed != "" { - headers.Set("ChatGPT-Account-ID", trimmed) + setHeaderCasePreserved(headers, "ChatGPT-Account-ID", trimmed) } } } @@ -1040,7 +1040,9 @@ func parseCodexWebsocketError(payload []byte) (error, bool) { out := buildCodexWebsocketErrorPayload(payload, status) headers := parseCodexWebsocketErrorHeaders(payload) statusError := statusErr{code: status, msg: string(out)} - if isCodexWebsocketConnectionLimitError(payload) { + if retryAfter := parseCodexRetryAfter(status, out, time.Now()); retryAfter != nil { + statusError.retryAfter = retryAfter + } else if isCodexWebsocketConnectionLimitError(payload) { retryAfter := time.Duration(0) statusError.retryAfter = &retryAfter } diff --git a/internal/runtime/executor/codex_websockets_executor_test.go b/internal/runtime/executor/codex_websockets_executor_test.go index 0b7a546e98..bf12ef7860 100644 --- a/internal/runtime/executor/codex_websockets_executor_test.go +++ b/internal/runtime/executor/codex_websockets_executor_test.go @@ -296,9 +296,16 @@ func TestApplyCodexWebsocketHeadersUsesCanonicalAccountHeader(t *testing.T) { headers := applyCodexWebsocketHeaders(context.Background(), http.Header{}, auth, "", nil) - if got := headers.Get("ChatGPT-Account-ID"); got != "acct-1" { + if got := headerValueCaseInsensitive(headers, "ChatGPT-Account-ID"); got != "acct-1" { t.Fatalf("ChatGPT-Account-ID = %s, want acct-1", got) } + values, ok := headers["ChatGPT-Account-ID"] + if !ok { + t.Fatalf("expected exact ChatGPT-Account-ID key, got %#v", headers) + } + if len(values) != 1 || values[0] != "acct-1" { + t.Fatalf("ChatGPT-Account-ID values = %#v, want [acct-1]", values) + } } func TestBuildCodexResponsesWebsocketURLRequiresHTTPURL(t *testing.T) { @@ -326,12 +333,30 @@ func TestParseCodexWebsocketErrorMarksConnectionLimitRetryable(t *testing.T) { if !ok || retryable.RetryAfter() == nil { t.Fatalf("expected retryable websocket connection limit error") } + if got := *retryable.RetryAfter(); got != 0 { + t.Fatalf("retryAfter = %v, want connection-limit fallback 0", got) + } withHeaders, ok := err.(interface{ Headers() http.Header }) if !ok || withHeaders.Headers().Get("retry-after") != "1" { t.Fatalf("headers = %#v, want retry-after", err) } } +func TestParseCodexWebsocketErrorUsesUsageLimitRetryMetadata(t *testing.T) { + err, ok := parseCodexWebsocketError([]byte(`{"type":"error","status":429,"body":{"error":{"type":"usage_limit_reached","message":"usage limit reached","resets_in_seconds":7}}}`)) + if !ok { + t.Fatalf("expected websocket error") + } + + retryable, ok := err.(interface{ RetryAfter() *time.Duration }) + if !ok || retryable.RetryAfter() == nil { + t.Fatalf("expected retryable usage limit websocket error") + } + if got := *retryable.RetryAfter(); got != 7*time.Second { + t.Fatalf("retryAfter = %v, want 7s", got) + } +} + func TestParseCodexWebsocketErrorPreservesWrappedBodyAndHeaders(t *testing.T) { err, ok := parseCodexWebsocketError([]byte(`{"type":"error","status":429,"body":{"error":{"code":"websocket_connection_limit_reached","type":"server_error","message":"too many websocket connections"}},"headers":{"x-request-id":"req-1"}}`)) if !ok { From 6b4bc0a9a852d38da2e330178b7902a9f7f7db7b Mon Sep 17 00:00:00 2001 From: Kenny Date: Sun, 3 May 2026 21:13:37 -0700 Subject: [PATCH 106/139] Align Codex default identity and docs --- README.md | 2 +- README_CN.md | 2 +- README_JA.md | 2 +- internal/runtime/executor/codex_executor.go | 2 +- .../runtime/executor/codex_websockets_executor_test.go | 10 ++++++++++ 5 files changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2fd6937afa..bcadeb8717 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ VisionCoder is also offering our users a limited-time Date: Mon, 4 May 2026 16:45:25 +0800 Subject: [PATCH 107/139] fix(executor): adjust ApplyThinking order and add payload override test - Moved `ApplyThinking` logic earlier in `openai_compat_executor` to align with configuration application sequence. - Added test to verify payload override precedence over Thinking suffix configuration. --- .../executor/openai_compat_executor.go | 18 ++++---- .../openai_compat_executor_compact_test.go | 44 +++++++++++++++++++ 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index 4e44a7ae06..63be2d3c63 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -96,6 +96,12 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A originalPayload := originalPayloadSource originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, opts.Stream) translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, opts.Stream) + + translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) + if err != nil { + return resp, err + } + requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel, requestPath) @@ -105,11 +111,6 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A } } - translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) - if err != nil { - return resp, err - } - url := strings.TrimSuffix(baseURL, "/") + endpoint httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated)) if err != nil { @@ -199,15 +200,16 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy originalPayload := originalPayloadSource originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true) translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true) - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel, requestPath) translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return nil, err } + requestedModel := helps.PayloadRequestedModel(opts, req.Model) + requestPath := helps.PayloadRequestPath(opts) + translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel, requestPath) + // Request usage data in the final streaming chunk so that token statistics // are captured even when the upstream is an OpenAI-compatible provider. translated, _ = sjson.SetBytes(translated, "stream_options.include_usage", true) diff --git a/internal/runtime/executor/openai_compat_executor_compact_test.go b/internal/runtime/executor/openai_compat_executor_compact_test.go index fe2812623b..ac9d9b325d 100644 --- a/internal/runtime/executor/openai_compat_executor_compact_test.go +++ b/internal/runtime/executor/openai_compat_executor_compact_test.go @@ -56,3 +56,47 @@ func TestOpenAICompatExecutorCompactPassthrough(t *testing.T) { t.Fatalf("payload = %s", string(resp.Payload)) } } + +func TestOpenAICompatExecutorPayloadOverrideWinsOverThinkingSuffix(t *testing.T) { + var gotBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + gotBody = body + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"chatcmpl_1","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)) + })) + defer server.Close() + + executor := NewOpenAICompatExecutor("openai-compatibility", &config.Config{ + Payload: config.PayloadConfig{ + Override: []config.PayloadRule{ + { + Models: []config.PayloadModelRule{ + {Name: "custom-openai", Protocol: "openai"}, + }, + Params: map[string]any{ + "reasoning_effort": "low", + }, + }, + }, + }, + }) + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "base_url": server.URL + "/v1", + "api_key": "test", + }} + payload := []byte(`{"model":"custom-openai(high)","messages":[{"role":"user","content":"hi"}]}`) + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "custom-openai(high)", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai"), + Stream: false, + }) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if got := gjson.GetBytes(gotBody, "reasoning_effort").String(); got != "low" { + t.Fatalf("reasoning_effort = %q, want %q; body=%s", got, "low", string(gotBody)) + } +} From 85c015065302ed17bac32b0ec05d94b59cf46eb6 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 4 May 2026 16:57:50 +0800 Subject: [PATCH 108/139] feat(translator): add token usage tracking and improve usage handling - Introduced `claudeUsageTokens` struct for detailed token usage tracking. - Replaced `calculateClaudeUsageTokens` with `Merge` and `OpenAIUsage` methods for better modularity. - Enhanced integration of usage tokens into response processing, enabling more accurate reporting of token details. Fixed: #2419 --- .../claude_openai_response.go | 58 ++++++++++++++----- .../claude_openai_response_test.go | 58 +++++++++++++++++++ 2 files changed, 103 insertions(+), 13 deletions(-) diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_response.go b/internal/translator/claude/openai/chat-completions/claude_openai_response.go index 1fd3f2ae16..99c7523874 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_response.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_response.go @@ -25,10 +25,19 @@ type ConvertAnthropicResponseToOpenAIParams struct { CreatedAt int64 ResponseID string FinishReason string + Usage claudeUsageTokens // Tool calls accumulator for streaming ToolCallsAccumulator map[int]*ToolCallAccumulator } +type claudeUsageTokens struct { + InputTokens int64 + OutputTokens int64 + CacheCreationInputTokens int64 + CacheReadInputTokens int64 + HasUsage bool +} + // ToolCallAccumulator holds the state for accumulating tool call data type ToolCallAccumulator struct { ID string @@ -36,15 +45,30 @@ type ToolCallAccumulator struct { Arguments strings.Builder } -func calculateClaudeUsageTokens(usage gjson.Result) (promptTokens, completionTokens, totalTokens, cachedTokens int64) { - inputTokens := usage.Get("input_tokens").Int() - completionTokens = usage.Get("output_tokens").Int() - cachedTokens = usage.Get("cache_read_input_tokens").Int() - cacheCreationInputTokens := usage.Get("cache_creation_input_tokens").Int() +func (u *claudeUsageTokens) Merge(usage gjson.Result) { + if !usage.Exists() { + return + } + u.HasUsage = true + if inputTokens := usage.Get("input_tokens"); inputTokens.Exists() { + u.InputTokens = inputTokens.Int() + } + if outputTokens := usage.Get("output_tokens"); outputTokens.Exists() { + u.OutputTokens = outputTokens.Int() + } + if cacheCreationInputTokens := usage.Get("cache_creation_input_tokens"); cacheCreationInputTokens.Exists() { + u.CacheCreationInputTokens = cacheCreationInputTokens.Int() + } + if cacheReadInputTokens := usage.Get("cache_read_input_tokens"); cacheReadInputTokens.Exists() { + u.CacheReadInputTokens = cacheReadInputTokens.Int() + } +} - promptTokens = inputTokens + cacheCreationInputTokens + cachedTokens +func (u claudeUsageTokens) OpenAIUsage() (promptTokens, completionTokens, totalTokens, cachedTokens int64) { + cachedTokens = u.CacheReadInputTokens + promptTokens = u.InputTokens + u.CacheCreationInputTokens + cachedTokens + completionTokens = u.OutputTokens totalTokens = promptTokens + completionTokens - return promptTokens, completionTokens, totalTokens, cachedTokens } @@ -112,6 +136,7 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original if (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator == nil { (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator = make(map[int]*ToolCallAccumulator) } + (*param).(*ConvertAnthropicResponseToOpenAIParams).Usage.Merge(message.Get("usage")) } return [][]byte{template} @@ -215,7 +240,8 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original // Handle usage information for token counts if usage := root.Get("usage"); usage.Exists() { - promptTokens, completionTokens, totalTokens, cachedTokens := calculateClaudeUsageTokens(usage) + (*param).(*ConvertAnthropicResponseToOpenAIParams).Usage.Merge(usage) + promptTokens, completionTokens, totalTokens, cachedTokens := (*param).(*ConvertAnthropicResponseToOpenAIParams).Usage.OpenAIUsage() template, _ = sjson.SetBytes(template, "usage.prompt_tokens", promptTokens) template, _ = sjson.SetBytes(template, "usage.completion_tokens", completionTokens) template, _ = sjson.SetBytes(template, "usage.total_tokens", totalTokens) @@ -296,6 +322,7 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina var stopReason string var contentParts []string var reasoningParts []string + usageTokens := claudeUsageTokens{} toolCallsAccumulator := make(map[int]*ToolCallAccumulator) for _, chunk := range chunks { @@ -309,6 +336,7 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina messageID = message.Get("id").String() model = message.Get("model").String() createdAt = time.Now().Unix() + usageTokens.Merge(message.Get("usage")) } case "content_block_start": @@ -371,15 +399,19 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina } } if usage := root.Get("usage"); usage.Exists() { - promptTokens, completionTokens, totalTokens, cachedTokens := calculateClaudeUsageTokens(usage) - out, _ = sjson.SetBytes(out, "usage.prompt_tokens", promptTokens) - out, _ = sjson.SetBytes(out, "usage.completion_tokens", completionTokens) - out, _ = sjson.SetBytes(out, "usage.total_tokens", totalTokens) - out, _ = sjson.SetBytes(out, "usage.prompt_tokens_details.cached_tokens", cachedTokens) + usageTokens.Merge(usage) } } } + if usageTokens.HasUsage { + promptTokens, completionTokens, totalTokens, cachedTokens := usageTokens.OpenAIUsage() + out, _ = sjson.SetBytes(out, "usage.prompt_tokens", promptTokens) + out, _ = sjson.SetBytes(out, "usage.completion_tokens", completionTokens) + out, _ = sjson.SetBytes(out, "usage.total_tokens", totalTokens) + out, _ = sjson.SetBytes(out, "usage.prompt_tokens_details.cached_tokens", cachedTokens) + } + // Set basic response fields including message ID, creation time, and model out, _ = sjson.SetBytes(out, "id", messageID) out, _ = sjson.SetBytes(out, "created", createdAt) diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_response_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_response_test.go index 7bd6eb1f15..5a9a6d3ad5 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_response_test.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_response_test.go @@ -37,6 +37,44 @@ func TestConvertClaudeResponseToOpenAI_StreamUsageIncludesCachedTokens(t *testin } } +func TestConvertClaudeResponseToOpenAI_StreamUsageMergesMessageStartUsage(t *testing.T) { + ctx := context.Background() + var param any + + ConvertClaudeResponseToOpenAI( + ctx, + "claude-opus-4-6", + nil, + nil, + []byte(`data: {"type":"message_start","message":{"id":"msg_123","model":"claude-opus-4-6","usage":{"input_tokens":13,"output_tokens":1,"cache_read_input_tokens":22000,"cache_creation_input_tokens":31}}}`), + ¶m, + ) + out := ConvertClaudeResponseToOpenAI( + ctx, + "claude-opus-4-6", + nil, + nil, + []byte(`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":4}}`), + ¶m, + ) + if len(out) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(out)) + } + + if gotPromptTokens := gjson.GetBytes(out[0], "usage.prompt_tokens").Int(); gotPromptTokens != 22044 { + t.Fatalf("expected prompt_tokens %d, got %d", 22044, gotPromptTokens) + } + if gotCompletionTokens := gjson.GetBytes(out[0], "usage.completion_tokens").Int(); gotCompletionTokens != 4 { + t.Fatalf("expected completion_tokens %d, got %d", 4, gotCompletionTokens) + } + if gotTotalTokens := gjson.GetBytes(out[0], "usage.total_tokens").Int(); gotTotalTokens != 22048 { + t.Fatalf("expected total_tokens %d, got %d", 22048, gotTotalTokens) + } + if gotCachedTokens := gjson.GetBytes(out[0], "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 22000 { + t.Fatalf("expected cached_tokens %d, got %d", 22000, gotCachedTokens) + } +} + func TestConvertClaudeResponseToOpenAINonStream_UsageIncludesCachedTokens(t *testing.T) { rawJSON := []byte("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"model\":\"claude-opus-4-6\"}}\n" + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":13,\"output_tokens\":4,\"cache_read_input_tokens\":22000,\"cache_creation_input_tokens\":31}}\n") @@ -56,3 +94,23 @@ func TestConvertClaudeResponseToOpenAINonStream_UsageIncludesCachedTokens(t *tes t.Fatalf("expected cached_tokens %d, got %d", 22000, gotCachedTokens) } } + +func TestConvertClaudeResponseToOpenAINonStream_UsageMergesMessageStartUsage(t *testing.T) { + rawJSON := []byte("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"model\":\"claude-opus-4-6\",\"usage\":{\"input_tokens\":13,\"output_tokens\":1,\"cache_read_input_tokens\":22000,\"cache_creation_input_tokens\":31}}}\n" + + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":4}}\n") + + out := ConvertClaudeResponseToOpenAINonStream(context.Background(), "", nil, nil, rawJSON, nil) + + if gotPromptTokens := gjson.GetBytes(out, "usage.prompt_tokens").Int(); gotPromptTokens != 22044 { + t.Fatalf("expected prompt_tokens %d, got %d", 22044, gotPromptTokens) + } + if gotCompletionTokens := gjson.GetBytes(out, "usage.completion_tokens").Int(); gotCompletionTokens != 4 { + t.Fatalf("expected completion_tokens %d, got %d", 4, gotCompletionTokens) + } + if gotTotalTokens := gjson.GetBytes(out, "usage.total_tokens").Int(); gotTotalTokens != 22048 { + t.Fatalf("expected total_tokens %d, got %d", 22048, gotTotalTokens) + } + if gotCachedTokens := gjson.GetBytes(out, "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 22000 { + t.Fatalf("expected cached_tokens %d, got %d", 22000, gotCachedTokens) + } +} From bf6fa402e203a1048f065ee8fc8f3e821f528b7a Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 4 May 2026 17:54:16 +0800 Subject: [PATCH 109/139] fix(executor): strip Vertex OpenAI response tool call IDs for consistency - Integrated `StripVertexOpenAIResponsesToolCallIDs` to remove tool call ID data from request bodies and translated requests. - Ensures uniformity and avoids unnecessary payload data propagation. Fixed: #2549 --- .../executor/gemini_vertex_executor.go | 6 +++ .../executor/helps/vertex_payload_helpers.go | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 internal/runtime/executor/helps/vertex_payload_helpers.go diff --git a/internal/runtime/executor/gemini_vertex_executor.go b/internal/runtime/executor/gemini_vertex_executor.go index b147fde975..84a84b3d7e 100644 --- a/internal/runtime/executor/gemini_vertex_executor.go +++ b/internal/runtime/executor/gemini_vertex_executor.go @@ -338,6 +338,7 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au requestPath := helps.PayloadRequestPath(opts) body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath) body, _ = sjson.SetBytes(body, "model", baseModel) + body = helps.StripVertexOpenAIResponsesToolCallIDs(body, from.String()) } action := getVertexAction(baseModel, false) @@ -459,6 +460,7 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip requestPath := helps.PayloadRequestPath(opts) body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath) body, _ = sjson.SetBytes(body, "model", baseModel) + body = helps.StripVertexOpenAIResponsesToolCallIDs(body, from.String()) action := getVertexAction(baseModel, false) if req.Metadata != nil { @@ -570,6 +572,7 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte requestPath := helps.PayloadRequestPath(opts) body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath) body, _ = sjson.SetBytes(body, "model", baseModel) + body = helps.StripVertexOpenAIResponsesToolCallIDs(body, from.String()) action := getVertexAction(baseModel, true) baseURL := vertexBaseURL(location) @@ -700,6 +703,7 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth requestPath := helps.PayloadRequestPath(opts) body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath) body, _ = sjson.SetBytes(body, "model", baseModel) + body = helps.StripVertexOpenAIResponsesToolCallIDs(body, from.String()) action := getVertexAction(baseModel, true) // For API key auth, use simpler URL format without project/location @@ -818,6 +822,7 @@ func (e *GeminiVertexExecutor) countTokensWithServiceAccount(ctx context.Context translatedReq = fixGeminiImageAspectRatio(baseModel, translatedReq) translatedReq, _ = sjson.SetBytes(translatedReq, "model", baseModel) + translatedReq = helps.StripVertexOpenAIResponsesToolCallIDs(translatedReq, from.String()) respCtx := context.WithValue(ctx, "alt", opts.Alt) translatedReq, _ = sjson.DeleteBytes(translatedReq, "tools") translatedReq, _ = sjson.DeleteBytes(translatedReq, "generationConfig") @@ -907,6 +912,7 @@ func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth * translatedReq = fixGeminiImageAspectRatio(baseModel, translatedReq) translatedReq, _ = sjson.SetBytes(translatedReq, "model", baseModel) + translatedReq = helps.StripVertexOpenAIResponsesToolCallIDs(translatedReq, from.String()) respCtx := context.WithValue(ctx, "alt", opts.Alt) translatedReq, _ = sjson.DeleteBytes(translatedReq, "tools") translatedReq, _ = sjson.DeleteBytes(translatedReq, "generationConfig") diff --git a/internal/runtime/executor/helps/vertex_payload_helpers.go b/internal/runtime/executor/helps/vertex_payload_helpers.go new file mode 100644 index 0000000000..4c84fae45e --- /dev/null +++ b/internal/runtime/executor/helps/vertex_payload_helpers.go @@ -0,0 +1,43 @@ +package helps + +import ( + "fmt" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// StripVertexOpenAIResponsesToolCallIDs removes OpenAI Responses call IDs that +// Vertex rejects in Gemini functionCall/functionResponse payloads. +func StripVertexOpenAIResponsesToolCallIDs(payload []byte, sourceFormat string) []byte { + if !strings.EqualFold(strings.TrimSpace(sourceFormat), "openai-response") { + return payload + } + + contents := gjson.GetBytes(payload, "contents") + if !contents.IsArray() { + return payload + } + + out := payload + for contentIndex, content := range contents.Array() { + parts := content.Get("parts") + if !parts.IsArray() { + continue + } + for partIndex, part := range parts.Array() { + if part.Get("functionCall.id").Exists() { + if updated, errDelete := sjson.DeleteBytes(out, fmt.Sprintf("contents.%d.parts.%d.functionCall.id", contentIndex, partIndex)); errDelete == nil { + out = updated + } + } + if part.Get("functionResponse.id").Exists() { + if updated, errDelete := sjson.DeleteBytes(out, fmt.Sprintf("contents.%d.parts.%d.functionResponse.id", contentIndex, partIndex)); errDelete == nil { + out = updated + } + } + } + } + return out +} From c1caa454b35e9990fe93e67ff3111d69d154d84c Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 4 May 2026 21:00:33 +0800 Subject: [PATCH 110/139] fix(translator): handle empty tool function names in OpenAI Claude responses - Added check to prevent processing of empty `function.name` values, ensuring valid data is handled. Fixed: #2557 --- .../openai/claude/openai_claude_response.go | 2 +- .../claude/openai_claude_response_test.go | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 internal/translator/openai/claude/openai_claude_response_test.go diff --git a/internal/translator/openai/claude/openai_claude_response.go b/internal/translator/openai/claude/openai_claude_response.go index 46c75898c4..af49d306d7 100644 --- a/internal/translator/openai/claude/openai_claude_response.go +++ b/internal/translator/openai/claude/openai_claude_response.go @@ -236,7 +236,7 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI // Handle function name if function := toolCall.Get("function"); function.Exists() { - if name := function.Get("name"); name.Exists() { + if name := function.Get("name"); name.Exists() && name.String() != "" { accumulator.Name = util.MapToolName(param.ToolNameMap, name.String()) stopThinkingContentBlock(param, &results) diff --git a/internal/translator/openai/claude/openai_claude_response_test.go b/internal/translator/openai/claude/openai_claude_response_test.go new file mode 100644 index 0000000000..8c36fc3d8c --- /dev/null +++ b/internal/translator/openai/claude/openai_claude_response_test.go @@ -0,0 +1,41 @@ +package claude + +import ( + "bytes" + "context" + "testing" +) + +func TestConvertOpenAIResponseToClaude_StreamIgnoresNullToolNameDelta(t *testing.T) { + originalRequest := []byte(`{"stream":true}`) + var param any + + firstChunks := ConvertOpenAIResponseToClaude( + context.Background(), + "test-model", + originalRequest, + nil, + []byte(`data: {"id":"chatcmpl_1","model":"test-model","created":1,"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"read_file","arguments":""}}]},"finish_reason":null}]}`), + ¶m, + ) + firstOutput := bytes.Join(firstChunks, nil) + if !bytes.Contains(firstOutput, []byte(`"name":"read_file"`)) { + t.Fatalf("expected first chunk to start read_file tool block, got %s", string(firstOutput)) + } + + secondChunks := ConvertOpenAIResponseToClaude( + context.Background(), + "test-model", + originalRequest, + nil, + []byte(`data: {"id":"chatcmpl_1","model":"test-model","created":1,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":null,"arguments":"{\"path\":\"/tmp/a\"}"}}]},"finish_reason":null}]}`), + ¶m, + ) + secondOutput := bytes.Join(secondChunks, nil) + if bytes.Contains(secondOutput, []byte(`content_block_start`)) { + t.Fatalf("did not expect null tool name delta to start a new content block, got %s", string(secondOutput)) + } + if bytes.Contains(secondOutput, []byte(`"name":""`)) { + t.Fatalf("did not expect null tool name delta to emit an empty tool name, got %s", string(secondOutput)) + } +} From ecf1c2590c1b4772e0e0d4d0f0e602d811f15024 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 4 May 2026 21:18:18 +0800 Subject: [PATCH 111/139] fix: preserve Antigravity cancellation errors --- internal/runtime/executor/antigravity_executor.go | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index c07680e8ec..418ed7b1c5 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -894,19 +894,12 @@ attemptLoop: reporter.Publish(ctx, detail) } - select { - case out <- cliproxyexecutor.StreamChunk{Payload: payload}: - case <-ctx.Done(): - return - } + out <- cliproxyexecutor.StreamChunk{Payload: payload} } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) reporter.PublishFailure(ctx) - select { - case out <- cliproxyexecutor.StreamChunk{Err: errScan}: - case <-ctx.Done(): - } + out <- cliproxyexecutor.StreamChunk{Err: errScan} } else { reporter.EnsurePublished(ctx) } From e4a93c02c584108b981d65691600fc87012b86ca Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 4 May 2026 23:42:26 +0800 Subject: [PATCH 112/139] fix(executor): enhance parsing of OpenAI stream data lines - Added trimming for stream input lines to prevent processing of unnecessary whitespace. - Improved handling of unsupported prefixes and malformed JSON responses, ensuring errors are recorded and propagated appropriately. Fixed: #2690 --- .../executor/openai_compat_executor.go | 24 ++++-- .../openai_compat_executor_compact_test.go | 79 +++++++++++++++++++ 2 files changed, 98 insertions(+), 5 deletions(-) diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index e0a7bd882e..7e81637ca6 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -283,17 +283,31 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy if detail, ok := helps.ParseOpenAIStreamUsage(line); ok { reporter.Publish(ctx, detail) } - if len(line) == 0 { + trimmedLine := bytes.TrimSpace(line) + if len(trimmedLine) == 0 { continue } - if !bytes.HasPrefix(line, []byte("data:")) { + if !bytes.HasPrefix(trimmedLine, []byte("data:")) { + if bytes.HasPrefix(trimmedLine, []byte(":")) || bytes.HasPrefix(trimmedLine, []byte("event:")) || + bytes.HasPrefix(trimmedLine, []byte("id:")) || bytes.HasPrefix(trimmedLine, []byte("retry:")) { + continue + } + if bytes.HasPrefix(trimmedLine, []byte("{")) || bytes.HasPrefix(trimmedLine, []byte("[")) { + streamErr := statusErr{code: http.StatusBadGateway, msg: string(trimmedLine)} + helps.RecordAPIResponseError(ctx, e.cfg, streamErr) + reporter.PublishFailure(ctx) + select { + case out <- cliproxyexecutor.StreamChunk{Err: streamErr}: + case <-ctx.Done(): + } + return + } continue } - // OpenAI-compatible streams are SSE: lines typically prefixed with "data: ". - // Pass through translator; it yields one or more chunks for the target schema. - chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bytes.Clone(line), ¶m) + // OpenAI-compatible streams must use SSE data lines. + chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bytes.Clone(trimmedLine), ¶m) for i := range chunks { select { case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}: diff --git a/internal/runtime/executor/openai_compat_executor_compact_test.go b/internal/runtime/executor/openai_compat_executor_compact_test.go index ac9d9b325d..49b2cccbbb 100644 --- a/internal/runtime/executor/openai_compat_executor_compact_test.go +++ b/internal/runtime/executor/openai_compat_executor_compact_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" @@ -100,3 +101,81 @@ func TestOpenAICompatExecutorPayloadOverrideWinsOverThinkingSuffix(t *testing.T) t.Fatalf("reasoning_effort = %q, want %q; body=%s", got, "low", string(gotBody)) } } + +func TestOpenAICompatExecutorStreamRejectsPlainJSONAfterBlankLines(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("\n\n: openrouter processing\n\nevent: error\n")) + _, _ = w.Write([]byte(`{"error":{"message":"upstream failed","type":"server_error"}}` + "\n")) + })) + defer server.Close() + + executor := NewOpenAICompatExecutor("openai-compatibility", &config.Config{}) + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "base_url": server.URL + "/v1", + "api_key": "test", + }} + result, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ + Model: "openrouter-model", + Payload: []byte(`{"model":"openrouter-model","messages":[{"role":"user","content":"hi"}],"stream":true}`), + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai"), + Stream: true, + }) + if err != nil { + t.Fatalf("ExecuteStream error: %v", err) + } + + var gotErr error + for chunk := range result.Chunks { + if chunk.Err != nil { + gotErr = chunk.Err + break + } + } + if gotErr == nil { + t.Fatalf("expected plain JSON stream error") + } + if status, ok := gotErr.(interface{ StatusCode() int }); !ok || status.StatusCode() != http.StatusBadGateway { + t.Fatalf("stream error status = %v, want %d", gotErr, http.StatusBadGateway) + } + if !strings.Contains(gotErr.Error(), "upstream failed") { + t.Fatalf("stream error = %v", gotErr) + } +} + +func TestOpenAICompatExecutorStreamSkipsKeepAliveUntilDataLine(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("\n\n: openrouter processing\n\nevent: ping\nid: 1\nretry: 1000\n")) + _, _ = w.Write([]byte(`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"hello"},"finish_reason":null}]}` + "\n")) + })) + defer server.Close() + + executor := NewOpenAICompatExecutor("openai-compatibility", &config.Config{}) + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "base_url": server.URL + "/v1", + "api_key": "test", + }} + result, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ + Model: "openrouter-model", + Payload: []byte(`{"model":"openrouter-model","messages":[{"role":"user","content":"hi"}],"stream":true}`), + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai"), + Stream: true, + }) + if err != nil { + t.Fatalf("ExecuteStream error: %v", err) + } + + var got strings.Builder + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatalf("unexpected stream error: %v", chunk.Err) + } + got.Write(chunk.Payload) + } + if gjson.Get(got.String(), "choices.0.delta.content").String() != "hello" { + t.Fatalf("stream payload = %s", got.String()) + } +} From ba5d8ca7336e78ca2b7c6134638a4054b589c330 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Tue, 5 May 2026 01:47:53 +0800 Subject: [PATCH 113/139] feat(usage): add support for requested model alias handling - Introduced methods for setting and retrieving model aliases in execution and usage contexts. - Enhanced `UsageReporter` and related structures to include client-requested aliases. - Updated tests to validate alias propagation and ensure correct usage reporting. - Adjusted metadata handling in CLIProxyAPI executors to address alias integration. --- internal/redisqueue/plugin.go | 6 ++++ internal/redisqueue/plugin_test.go | 6 ++++ .../runtime/executor/helps/usage_helpers.go | 7 ++++ .../executor/helps/usage_helpers_test.go | 14 ++++++++ sdk/api/handlers/handlers.go | 6 ++-- sdk/cliproxy/auth/conductor.go | 35 +++++++++++++++++++ .../conductor_oauth_alias_suspension_test.go | 25 +++++++++++-- sdk/cliproxy/usage/manager.go | 32 +++++++++++++++++ 8 files changed, 125 insertions(+), 6 deletions(-) diff --git a/internal/redisqueue/plugin.go b/internal/redisqueue/plugin.go index 9716841901..b33bc8fd95 100644 --- a/internal/redisqueue/plugin.go +++ b/internal/redisqueue/plugin.go @@ -33,6 +33,10 @@ func (p *usageQueuePlugin) HandleUsage(ctx context.Context, record coreusage.Rec if modelName == "" { modelName = "unknown" } + aliasName := strings.TrimSpace(record.Alias) + if aliasName == "" { + aliasName = modelName + } provider := strings.TrimSpace(record.Provider) if provider == "" { provider = "unknown" @@ -76,6 +80,7 @@ func (p *usageQueuePlugin) HandleUsage(ctx context.Context, record coreusage.Rec requestDetail: detail, Provider: provider, Model: modelName, + Alias: aliasName, Endpoint: resolveEndpoint(ctx), AuthType: authType, APIKey: apiKey, @@ -91,6 +96,7 @@ type queuedUsageDetail struct { requestDetail Provider string `json:"provider"` Model string `json:"model"` + Alias string `json:"alias"` Endpoint string `json:"endpoint"` AuthType string `json:"auth_type"` APIKey string `json:"api_key"` diff --git a/internal/redisqueue/plugin_test.go b/internal/redisqueue/plugin_test.go index 0cc8b9b9cb..8dcade90ee 100644 --- a/internal/redisqueue/plugin_test.go +++ b/internal/redisqueue/plugin_test.go @@ -24,6 +24,7 @@ func TestUsageQueuePluginPayloadIncludesStableFieldsAndSuccess(t *testing.T) { plugin.HandleUsage(ctx, coreusage.Record{ Provider: "openai", Model: "gpt-5.4", + Alias: "client-gpt", APIKey: "test-key", AuthIndex: "0", AuthType: "apikey", @@ -40,6 +41,7 @@ func TestUsageQueuePluginPayloadIncludesStableFieldsAndSuccess(t *testing.T) { payload := popSinglePayload(t) requireStringField(t, payload, "provider", "openai") requireStringField(t, payload, "model", "gpt-5.4") + requireStringField(t, payload, "alias", "client-gpt") requireStringField(t, payload, "endpoint", "POST /v1/chat/completions") requireStringField(t, payload, "auth_type", "apikey") requireStringField(t, payload, "request_id", "ctx-request-id") @@ -58,6 +60,7 @@ func TestUsageQueuePluginPayloadIncludesStableFieldsAndFailureAndGinRequestID(t plugin.HandleUsage(ctx, coreusage.Record{ Provider: "openai", Model: "gpt-5.4-mini", + Alias: "client-mini", APIKey: "test-key", AuthIndex: "0", AuthType: "apikey", @@ -74,6 +77,7 @@ func TestUsageQueuePluginPayloadIncludesStableFieldsAndFailureAndGinRequestID(t payload := popSinglePayload(t) requireStringField(t, payload, "provider", "openai") requireStringField(t, payload, "model", "gpt-5.4-mini") + requireStringField(t, payload, "alias", "client-mini") requireStringField(t, payload, "endpoint", "GET /v1/responses") requireStringField(t, payload, "auth_type", "apikey") requireStringField(t, payload, "request_id", "gin-request-id") @@ -102,6 +106,7 @@ func TestUsageQueuePluginAsyncIgnoresRecycledGinContext(t *testing.T) { mgr.Publish(ctx, coreusage.Record{ Provider: "openai", Model: "gpt-5.4", + Alias: "client-gpt", APIKey: "test-key", AuthIndex: "0", AuthType: "apikey", @@ -117,6 +122,7 @@ func TestUsageQueuePluginAsyncIgnoresRecycledGinContext(t *testing.T) { payload := waitForSinglePayload(t, 2*time.Second) requireStringField(t, payload, "endpoint", "POST /v1/chat/completions") + requireStringField(t, payload, "alias", "client-gpt") requireStringField(t, payload, "request_id", "ctx-request-id") requireBoolField(t, payload, "failed", true) }) diff --git a/internal/runtime/executor/helps/usage_helpers.go b/internal/runtime/executor/helps/usage_helpers.go index c5e258c86b..312a1d35c3 100644 --- a/internal/runtime/executor/helps/usage_helpers.go +++ b/internal/runtime/executor/helps/usage_helpers.go @@ -18,6 +18,7 @@ import ( type UsageReporter struct { provider string model string + alias string authID string authIndex string authType string @@ -29,9 +30,14 @@ type UsageReporter struct { func NewUsageReporter(ctx context.Context, provider, model string, auth *cliproxyauth.Auth) *UsageReporter { apiKey := APIKeyFromContext(ctx) + alias := usage.RequestedModelAliasFromContext(ctx) + if alias == "" { + alias = model + } reporter := &UsageReporter{ provider: provider, model: model, + alias: strings.TrimSpace(alias), requestedAt: time.Now(), apiKey: apiKey, source: resolveUsageSource(auth, apiKey), @@ -139,6 +145,7 @@ func (r *UsageReporter) buildRecordForModel(model string, detail usage.Detail, f return usage.Record{ Provider: r.provider, Model: model, + Alias: r.alias, Source: r.source, APIKey: r.apiKey, AuthID: r.authID, diff --git a/internal/runtime/executor/helps/usage_helpers_test.go b/internal/runtime/executor/helps/usage_helpers_test.go index c77335fd63..ef2c7de581 100644 --- a/internal/runtime/executor/helps/usage_helpers_test.go +++ b/internal/runtime/executor/helps/usage_helpers_test.go @@ -1,6 +1,7 @@ package helps import ( + "context" "testing" "time" @@ -107,6 +108,19 @@ func TestUsageReporterBuildRecordIncludesLatency(t *testing.T) { } } +func TestUsageReporterBuildRecordIncludesRequestedModelAlias(t *testing.T) { + ctx := usage.WithRequestedModelAlias(context.Background(), "client-gpt") + reporter := NewUsageReporter(ctx, "openai", "gpt-5.4", nil) + + record := reporter.buildRecord(usage.Detail{TotalTokens: 3}, false) + if record.Model != "gpt-5.4" { + t.Fatalf("model = %q, want %q", record.Model, "gpt-5.4") + } + if record.Alias != "client-gpt" { + t.Fatalf("alias = %q, want %q", record.Alias, "client-gpt") + } +} + func TestUsageReporterBuildAdditionalModelRecordSkipsZeroTokens(t *testing.T) { reporter := &UsageReporter{ provider: "codex", diff --git a/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go index 52b2a4fdeb..e89227aa70 100644 --- a/sdk/api/handlers/handlers.go +++ b/sdk/api/handlers/handlers.go @@ -539,7 +539,7 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType return nil, nil, errMsg } reqMeta := requestExecutionMetadata(ctx) - reqMeta[coreexecutor.RequestedModelMetadataKey] = normalizedModel + reqMeta[coreexecutor.RequestedModelMetadataKey] = modelName payload := rawJSON if len(payload) == 0 { payload = nil @@ -587,7 +587,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle return nil, nil, errMsg } reqMeta := requestExecutionMetadata(ctx) - reqMeta[coreexecutor.RequestedModelMetadataKey] = normalizedModel + reqMeta[coreexecutor.RequestedModelMetadataKey] = modelName payload := rawJSON if len(payload) == 0 { payload = nil @@ -639,7 +639,7 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl return nil, nil, errChan } reqMeta := requestExecutionMetadata(ctx) - reqMeta[coreexecutor.RequestedModelMetadataKey] = normalizedModel + reqMeta[coreexecutor.RequestedModelMetadataKey] = modelName payload := rawJSON if len(payload) == 0 { payload = nil diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index d2a3db1884..ab3eca4957 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -22,6 +22,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + coreusage "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" log "github.com/sirupsen/logrus" ) @@ -827,6 +828,7 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi if executor == nil { return nil, &Error{Code: "executor_not_found", Message: "executor not registered"} } + ctx = contextWithRequestedModelAlias(ctx, opts, routeModel) var lastErr error for idx, execModel := range execModels { resultModel := m.stateModelForExecution(auth, routeModel, execModel, pooled) @@ -1319,6 +1321,7 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req execCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt) execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt) } + execCtx = contextWithRequestedModelAlias(execCtx, opts, routeModel) models, pooled := m.preparedExecutionModels(auth, routeModel) if len(models) == 0 { @@ -1397,6 +1400,7 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string, execCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt) execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt) } + execCtx = contextWithRequestedModelAlias(execCtx, opts, routeModel) models, pooled := m.preparedExecutionModels(auth, routeModel) if len(models) == 0 { @@ -1534,6 +1538,36 @@ func hasRequestedModelMetadata(meta map[string]any) bool { } } +func contextWithRequestedModelAlias(ctx context.Context, opts cliproxyexecutor.Options, fallback string) context.Context { + alias := requestedModelAliasFromOptions(opts, fallback) + return coreusage.WithRequestedModelAlias(ctx, alias) +} + +func requestedModelAliasFromOptions(opts cliproxyexecutor.Options, fallback string) string { + fallback = strings.TrimSpace(fallback) + if len(opts.Metadata) == 0 { + return fallback + } + raw, ok := opts.Metadata[cliproxyexecutor.RequestedModelMetadataKey] + if !ok || raw == nil { + return fallback + } + switch value := raw.(type) { + case string: + if strings.TrimSpace(value) == "" { + return fallback + } + return strings.TrimSpace(value) + case []byte: + if len(value) == 0 { + return fallback + } + return strings.TrimSpace(string(value)) + default: + return fallback + } +} + func pinnedAuthIDFromMetadata(meta map[string]any) string { if len(meta) == 0 { return "" @@ -3096,6 +3130,7 @@ func (m *Manager) tryAntigravityCreditsExecute(ctx context.Context, req cliproxy creditsCtx = context.WithValue(creditsCtx, "cliproxy.roundtripper", rt) } creditsOpts := ensureRequestedModelMetadata(opts, routeModel) + creditsCtx = contextWithRequestedModelAlias(creditsCtx, creditsOpts, routeModel) publishSelectedAuthMetadata(creditsOpts.Metadata, c.auth.ID) models := m.executionModelCandidates(c.auth, routeModel) if len(models) == 0 { diff --git a/sdk/cliproxy/auth/conductor_oauth_alias_suspension_test.go b/sdk/cliproxy/auth/conductor_oauth_alias_suspension_test.go index 8bc779e53d..b4b72204c8 100644 --- a/sdk/cliproxy/auth/conductor_oauth_alias_suspension_test.go +++ b/sdk/cliproxy/auth/conductor_oauth_alias_suspension_test.go @@ -10,20 +10,23 @@ import ( internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + coreusage "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" ) type aliasRoutingExecutor struct { id string - mu sync.Mutex - executeModels []string + mu sync.Mutex + executeModels []string + executeAliases []string } func (e *aliasRoutingExecutor) Identifier() string { return e.id } -func (e *aliasRoutingExecutor) Execute(_ context.Context, _ *Auth, req cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { +func (e *aliasRoutingExecutor) Execute(ctx context.Context, _ *Auth, req cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { e.mu.Lock() e.executeModels = append(e.executeModels, req.Model) + e.executeAliases = append(e.executeAliases, coreusage.RequestedModelAliasFromContext(ctx)) e.mu.Unlock() return cliproxyexecutor.Response{Payload: []byte(req.Model)}, nil } @@ -52,6 +55,14 @@ func (e *aliasRoutingExecutor) ExecuteModels() []string { return out } +func (e *aliasRoutingExecutor) ExecuteAliases() []string { + e.mu.Lock() + defer e.mu.Unlock() + out := make([]string, len(e.executeAliases)) + copy(out, e.executeAliases) + return out +} + func TestManagerExecute_OAuthAliasBypassesBlockedRouteModel(t *testing.T) { const ( provider = "antigravity" @@ -108,4 +119,12 @@ func TestManagerExecute_OAuthAliasBypassesBlockedRouteModel(t *testing.T) { if gotModels[0] != targetModel { t.Fatalf("execute model = %q, want %q", gotModels[0], targetModel) } + + gotAliases := executor.ExecuteAliases() + if len(gotAliases) != 1 { + t.Fatalf("execute aliases len = %d, want 1", len(gotAliases)) + } + if gotAliases[0] != routeModel { + t.Fatalf("execute alias = %q, want %q", gotAliases[0], routeModel) + } } diff --git a/sdk/cliproxy/usage/manager.go b/sdk/cliproxy/usage/manager.go index c3d95f663c..72405d7587 100644 --- a/sdk/cliproxy/usage/manager.go +++ b/sdk/cliproxy/usage/manager.go @@ -2,6 +2,7 @@ package usage import ( "context" + "strings" "sync" "time" @@ -12,6 +13,7 @@ import ( type Record struct { Provider string Model string + Alias string APIKey string AuthID string AuthIndex string @@ -32,6 +34,36 @@ type Detail struct { TotalTokens int64 } +type requestedModelAliasContextKey struct{} + +// WithRequestedModelAlias stores the client-requested model name for usage sinks. +func WithRequestedModelAlias(ctx context.Context, alias string) context.Context { + if ctx == nil { + ctx = context.Background() + } + alias = strings.TrimSpace(alias) + if alias == "" { + return ctx + } + return context.WithValue(ctx, requestedModelAliasContextKey{}, alias) +} + +// RequestedModelAliasFromContext returns the client-requested model name stored in ctx. +func RequestedModelAliasFromContext(ctx context.Context) string { + if ctx == nil { + return "" + } + raw := ctx.Value(requestedModelAliasContextKey{}) + switch value := raw.(type) { + case string: + return strings.TrimSpace(value) + case []byte: + return strings.TrimSpace(string(value)) + default: + return "" + } +} + // Plugin consumes usage records emitted by the proxy runtime. type Plugin interface { HandleUsage(ctx context.Context, record Record) From 61b39d49bd8cad26c8d74eb0bd0f6b8fda16ab2c Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Tue, 5 May 2026 02:53:04 +0800 Subject: [PATCH 114/139] feat(management): add usage record retrieval endpoint - Implemented `/v0/management/usage` endpoint for fetching queued usage records from Redis. - Included validation for `count` parameter to ensure positive integers. - Added unit tests for queue retrieval and validation, with authentication validation in integration tests. - Updated management routing to include the new endpoint. --- internal/api/handlers/management/usage.go | 55 +++++++++++ .../api/handlers/management/usage_test.go | 98 +++++++++++++++++++ internal/api/server.go | 1 + internal/api/server_test.go | 55 +++++++++++ 4 files changed, 209 insertions(+) create mode 100644 internal/api/handlers/management/usage.go create mode 100644 internal/api/handlers/management/usage_test.go diff --git a/internal/api/handlers/management/usage.go b/internal/api/handlers/management/usage.go new file mode 100644 index 0000000000..8cb175eb67 --- /dev/null +++ b/internal/api/handlers/management/usage.go @@ -0,0 +1,55 @@ +package management + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue" +) + +type usageQueueRecord []byte + +func (r usageQueueRecord) MarshalJSON() ([]byte, error) { + if json.Valid(r) { + return append([]byte(nil), r...), nil + } + return json.Marshal(string(r)) +} + +// GetUsage pops queued usage records from the Redis-compatible usage queue. +func (h *Handler) GetUsage(c *gin.Context) { + if h == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"}) + return + } + + count, errCount := parseUsageQueueCount(c.Query("count")) + if errCount != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": errCount.Error()}) + return + } + + items := redisqueue.PopOldest(count) + records := make([]usageQueueRecord, 0, len(items)) + for _, item := range items { + records = append(records, usageQueueRecord(append([]byte(nil), item...))) + } + + c.JSON(http.StatusOK, records) +} + +func parseUsageQueueCount(value string) (int, error) { + value = strings.TrimSpace(value) + if value == "" { + return 1, nil + } + count, errCount := strconv.Atoi(value) + if errCount != nil || count <= 0 { + return 0, errors.New("count must be a positive integer") + } + return count, nil +} diff --git a/internal/api/handlers/management/usage_test.go b/internal/api/handlers/management/usage_test.go new file mode 100644 index 0000000000..5c5f5c69d1 --- /dev/null +++ b/internal/api/handlers/management/usage_test.go @@ -0,0 +1,98 @@ +package management + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue" +) + +func TestGetUsagePopsRequestedRecords(t *testing.T) { + gin.SetMode(gin.TestMode) + withManagementUsageQueue(t, func() { + redisqueue.Enqueue([]byte(`{"id":1}`)) + redisqueue.Enqueue([]byte(`{"id":2}`)) + redisqueue.Enqueue([]byte(`{"id":3}`)) + + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + ginCtx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/usage?count=2", nil) + + h := &Handler{} + h.GetUsage(ginCtx) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var payload []json.RawMessage + if errUnmarshal := json.Unmarshal(rec.Body.Bytes(), &payload); errUnmarshal != nil { + t.Fatalf("unmarshal response: %v", errUnmarshal) + } + if len(payload) != 2 { + t.Fatalf("response records = %d, want 2", len(payload)) + } + requireRecordID(t, payload[0], 1) + requireRecordID(t, payload[1], 2) + + remaining := redisqueue.PopOldest(10) + if len(remaining) != 1 || string(remaining[0]) != `{"id":3}` { + t.Fatalf("remaining queue = %q, want third item only", remaining) + } + }) +} + +func TestGetUsageInvalidCountDoesNotPop(t *testing.T) { + gin.SetMode(gin.TestMode) + withManagementUsageQueue(t, func() { + redisqueue.Enqueue([]byte(`{"id":1}`)) + + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + ginCtx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/usage?count=0", nil) + + h := &Handler{} + h.GetUsage(ginCtx) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + + remaining := redisqueue.PopOldest(10) + if len(remaining) != 1 || string(remaining[0]) != `{"id":1}` { + t.Fatalf("remaining queue = %q, want original item", remaining) + } + }) +} + +func withManagementUsageQueue(t *testing.T, fn func()) { + t.Helper() + + prevQueueEnabled := redisqueue.Enabled() + redisqueue.SetEnabled(false) + redisqueue.SetEnabled(true) + + defer func() { + redisqueue.SetEnabled(false) + redisqueue.SetEnabled(prevQueueEnabled) + }() + + fn() +} + +func requireRecordID(t *testing.T, raw json.RawMessage, want int) { + t.Helper() + + var payload struct { + ID int `json:"id"` + } + if errUnmarshal := json.Unmarshal(raw, &payload); errUnmarshal != nil { + t.Fatalf("unmarshal record: %v", errUnmarshal) + } + if payload.ID != want { + t.Fatalf("record id = %d, want %d", payload.ID, want) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 2e89ac5a34..5c43db48cc 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -551,6 +551,7 @@ func (s *Server) registerManagementRoutes() { mgmt.PATCH("/api-keys", s.mgmt.PatchAPIKeys) mgmt.DELETE("/api-keys", s.mgmt.DeleteAPIKeys) mgmt.GET("/api-key-usage", s.mgmt.GetAPIKeyUsage) + mgmt.GET("/usage", s.mgmt.GetUsage) mgmt.GET("/gemini-api-key", s.mgmt.GetGeminiKeys) mgmt.PUT("/gemini-api-key", s.mgmt.PutGeminiKeys) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index db1ef27d17..d5718091a5 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -13,6 +13,7 @@ import ( gin "github.com/gin-gonic/gin" proxyconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" internallogging "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" + "github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue" sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" @@ -84,6 +85,60 @@ func TestHealthz(t *testing.T) { }) } +func TestManagementUsageRequiresManagementAuthAndPopsArray(t *testing.T) { + t.Setenv("MANAGEMENT_PASSWORD", "test-management-key") + + prevQueueEnabled := redisqueue.Enabled() + redisqueue.SetEnabled(false) + t.Cleanup(func() { + redisqueue.SetEnabled(false) + redisqueue.SetEnabled(prevQueueEnabled) + }) + + server := newTestServer(t) + + redisqueue.Enqueue([]byte(`{"id":1}`)) + redisqueue.Enqueue([]byte(`{"id":2}`)) + + missingKeyReq := httptest.NewRequest(http.MethodGet, "/v0/management/usage?count=2", nil) + missingKeyRR := httptest.NewRecorder() + server.engine.ServeHTTP(missingKeyRR, missingKeyReq) + if missingKeyRR.Code != http.StatusUnauthorized { + t.Fatalf("missing key status = %d, want %d body=%s", missingKeyRR.Code, http.StatusUnauthorized, missingKeyRR.Body.String()) + } + + authReq := httptest.NewRequest(http.MethodGet, "/v0/management/usage?count=2", nil) + authReq.Header.Set("Authorization", "Bearer test-management-key") + authRR := httptest.NewRecorder() + server.engine.ServeHTTP(authRR, authReq) + if authRR.Code != http.StatusOK { + t.Fatalf("authenticated status = %d, want %d body=%s", authRR.Code, http.StatusOK, authRR.Body.String()) + } + + var payload []json.RawMessage + if errUnmarshal := json.Unmarshal(authRR.Body.Bytes(), &payload); errUnmarshal != nil { + t.Fatalf("unmarshal response: %v body=%s", errUnmarshal, authRR.Body.String()) + } + if len(payload) != 2 { + t.Fatalf("response records = %d, want 2", len(payload)) + } + for i, raw := range payload { + var record struct { + ID int `json:"id"` + } + if errUnmarshal := json.Unmarshal(raw, &record); errUnmarshal != nil { + t.Fatalf("unmarshal record %d: %v", i, errUnmarshal) + } + if record.ID != i+1 { + t.Fatalf("record %d id = %d, want %d", i, record.ID, i+1) + } + } + + if remaining := redisqueue.PopOldest(1); len(remaining) != 0 { + t.Fatalf("remaining queue = %q, want empty", remaining) + } +} + func TestAmpProviderModelRoutes(t *testing.T) { testCases := []struct { name string From da6c599efd8da34d23d3668371fbb5ac70399e9d Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Tue, 5 May 2026 03:02:25 +0800 Subject: [PATCH 115/139] refactor(management): rename `GetUsage` to `GetUsageQueue` and update routes/tests - Renamed handler and test methods for better clarity on functionality. - Updated route from `/v0/management/usage` to `/v0/management/usage-queue`. - Adjusted integration and unit tests to reflect new naming and routes. --- internal/api/handlers/management/usage.go | 4 ++-- internal/api/handlers/management/usage_test.go | 12 ++++++------ internal/api/server.go | 2 +- internal/api/server_test.go | 12 ++++++++++-- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/internal/api/handlers/management/usage.go b/internal/api/handlers/management/usage.go index 8cb175eb67..dfddf50346 100644 --- a/internal/api/handlers/management/usage.go +++ b/internal/api/handlers/management/usage.go @@ -20,8 +20,8 @@ func (r usageQueueRecord) MarshalJSON() ([]byte, error) { return json.Marshal(string(r)) } -// GetUsage pops queued usage records from the Redis-compatible usage queue. -func (h *Handler) GetUsage(c *gin.Context) { +// GetUsageQueue pops queued usage records from the usage queue. +func (h *Handler) GetUsageQueue(c *gin.Context) { if h == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"}) return diff --git a/internal/api/handlers/management/usage_test.go b/internal/api/handlers/management/usage_test.go index 5c5f5c69d1..ca46d976f5 100644 --- a/internal/api/handlers/management/usage_test.go +++ b/internal/api/handlers/management/usage_test.go @@ -10,7 +10,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue" ) -func TestGetUsagePopsRequestedRecords(t *testing.T) { +func TestGetUsageQueuePopsRequestedRecords(t *testing.T) { gin.SetMode(gin.TestMode) withManagementUsageQueue(t, func() { redisqueue.Enqueue([]byte(`{"id":1}`)) @@ -19,10 +19,10 @@ func TestGetUsagePopsRequestedRecords(t *testing.T) { rec := httptest.NewRecorder() ginCtx, _ := gin.CreateTestContext(rec) - ginCtx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/usage?count=2", nil) + ginCtx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/usage-queue?count=2", nil) h := &Handler{} - h.GetUsage(ginCtx) + h.GetUsageQueue(ginCtx) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusOK, rec.Body.String()) @@ -45,17 +45,17 @@ func TestGetUsagePopsRequestedRecords(t *testing.T) { }) } -func TestGetUsageInvalidCountDoesNotPop(t *testing.T) { +func TestGetUsageQueueInvalidCountDoesNotPop(t *testing.T) { gin.SetMode(gin.TestMode) withManagementUsageQueue(t, func() { redisqueue.Enqueue([]byte(`{"id":1}`)) rec := httptest.NewRecorder() ginCtx, _ := gin.CreateTestContext(rec) - ginCtx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/usage?count=0", nil) + ginCtx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/usage-queue?count=0", nil) h := &Handler{} - h.GetUsage(ginCtx) + h.GetUsageQueue(ginCtx) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) diff --git a/internal/api/server.go b/internal/api/server.go index 5c43db48cc..487ea571e6 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -551,7 +551,7 @@ func (s *Server) registerManagementRoutes() { mgmt.PATCH("/api-keys", s.mgmt.PatchAPIKeys) mgmt.DELETE("/api-keys", s.mgmt.DeleteAPIKeys) mgmt.GET("/api-key-usage", s.mgmt.GetAPIKeyUsage) - mgmt.GET("/usage", s.mgmt.GetUsage) + mgmt.GET("/usage-queue", s.mgmt.GetUsageQueue) mgmt.GET("/gemini-api-key", s.mgmt.GetGeminiKeys) mgmt.PUT("/gemini-api-key", s.mgmt.PutGeminiKeys) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index d5718091a5..fe37cb72ef 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -100,14 +100,22 @@ func TestManagementUsageRequiresManagementAuthAndPopsArray(t *testing.T) { redisqueue.Enqueue([]byte(`{"id":1}`)) redisqueue.Enqueue([]byte(`{"id":2}`)) - missingKeyReq := httptest.NewRequest(http.MethodGet, "/v0/management/usage?count=2", nil) + missingKeyReq := httptest.NewRequest(http.MethodGet, "/v0/management/usage-queue?count=2", nil) missingKeyRR := httptest.NewRecorder() server.engine.ServeHTTP(missingKeyRR, missingKeyReq) if missingKeyRR.Code != http.StatusUnauthorized { t.Fatalf("missing key status = %d, want %d body=%s", missingKeyRR.Code, http.StatusUnauthorized, missingKeyRR.Body.String()) } - authReq := httptest.NewRequest(http.MethodGet, "/v0/management/usage?count=2", nil) + legacyReq := httptest.NewRequest(http.MethodGet, "/v0/management/usage?count=2", nil) + legacyReq.Header.Set("Authorization", "Bearer test-management-key") + legacyRR := httptest.NewRecorder() + server.engine.ServeHTTP(legacyRR, legacyReq) + if legacyRR.Code != http.StatusNotFound { + t.Fatalf("legacy usage status = %d, want %d body=%s", legacyRR.Code, http.StatusNotFound, legacyRR.Body.String()) + } + + authReq := httptest.NewRequest(http.MethodGet, "/v0/management/usage-queue?count=2", nil) authReq.Header.Set("Authorization", "Bearer test-management-key") authRR := httptest.NewRecorder() server.engine.ServeHTTP(authRR, authReq) From 99dfbaef616a8990f4aece0b52188a9320dcae2a Mon Sep 17 00:00:00 2001 From: mochenya Date: Tue, 5 May 2026 12:30:03 +0800 Subject: [PATCH 116/139] fix(executor): ignore null OpenAI stream usage chunks - Added validation so OpenAI-style usage parsing only accepts object payloads with token fields. - Prevented streaming usage:null chunks from publishing zero-token records before the final usage chunk arrives. - Reused the shared OpenAI-style parser for stream usage to support both chat completions and responses token field names. - Added tests covering null usage chunks and input/output token usage fields in streaming responses. --- .../runtime/executor/helps/usage_helpers.go | 36 ++++++++++-------- .../executor/helps/usage_helpers_test.go | 38 +++++++++++++++++++ 2 files changed, 58 insertions(+), 16 deletions(-) diff --git a/internal/runtime/executor/helps/usage_helpers.go b/internal/runtime/executor/helps/usage_helpers.go index 312a1d35c3..e6be94aaa9 100644 --- a/internal/runtime/executor/helps/usage_helpers.go +++ b/internal/runtime/executor/helps/usage_helpers.go @@ -248,7 +248,7 @@ func resolveUsageAuthType(auth *cliproxyauth.Auth) string { func ParseCodexUsage(data []byte) (usage.Detail, bool) { usageNode := gjson.ParseBytes(data).Get("response.usage") - if !usageNode.Exists() { + if !hasOpenAIStyleUsageTokenFields(usageNode) { return usage.Detail{}, false } return parseOpenAIStyleUsageNode(usageNode), true @@ -256,7 +256,7 @@ func ParseCodexUsage(data []byte) (usage.Detail, bool) { func ParseCodexImageToolUsage(data []byte) (usage.Detail, bool) { usageNode := gjson.ParseBytes(data).Get("response.tool_usage.image_gen") - if !usageNode.Exists() || !usageNode.IsObject() { + if !hasOpenAIStyleUsageTokenFields(usageNode) { return usage.Detail{}, false } return parseOpenAIStyleUsageNode(usageNode), true @@ -264,12 +264,27 @@ func ParseCodexImageToolUsage(data []byte) (usage.Detail, bool) { func ParseOpenAIUsage(data []byte) usage.Detail { usageNode := gjson.ParseBytes(data).Get("usage") - if !usageNode.Exists() { + if !hasOpenAIStyleUsageTokenFields(usageNode) { return usage.Detail{} } return parseOpenAIStyleUsageNode(usageNode) } +func hasOpenAIStyleUsageTokenFields(usageNode gjson.Result) bool { + if !usageNode.Exists() || !usageNode.IsObject() { + return false + } + return usageNode.Get("prompt_tokens").Exists() || + usageNode.Get("input_tokens").Exists() || + usageNode.Get("completion_tokens").Exists() || + usageNode.Get("output_tokens").Exists() || + usageNode.Get("total_tokens").Exists() || + usageNode.Get("prompt_tokens_details.cached_tokens").Exists() || + usageNode.Get("input_tokens_details.cached_tokens").Exists() || + usageNode.Get("completion_tokens_details.reasoning_tokens").Exists() || + usageNode.Get("output_tokens_details.reasoning_tokens").Exists() +} + func parseOpenAIStyleUsageNode(usageNode gjson.Result) usage.Detail { inputNode := usageNode.Get("prompt_tokens") if !inputNode.Exists() { @@ -307,21 +322,10 @@ func ParseOpenAIStreamUsage(line []byte) (usage.Detail, bool) { return usage.Detail{}, false } usageNode := gjson.GetBytes(payload, "usage") - if !usageNode.Exists() { + if !hasOpenAIStyleUsageTokenFields(usageNode) { return usage.Detail{}, false } - detail := usage.Detail{ - InputTokens: usageNode.Get("prompt_tokens").Int(), - OutputTokens: usageNode.Get("completion_tokens").Int(), - TotalTokens: usageNode.Get("total_tokens").Int(), - } - if cached := usageNode.Get("prompt_tokens_details.cached_tokens"); cached.Exists() { - detail.CachedTokens = cached.Int() - } - if reasoning := usageNode.Get("completion_tokens_details.reasoning_tokens"); reasoning.Exists() { - detail.ReasoningTokens = reasoning.Int() - } - return detail, true + return parseOpenAIStyleUsageNode(usageNode), true } func ParseClaudeUsage(data []byte) usage.Detail { diff --git a/internal/runtime/executor/helps/usage_helpers_test.go b/internal/runtime/executor/helps/usage_helpers_test.go index ef2c7de581..644ff09614 100644 --- a/internal/runtime/executor/helps/usage_helpers_test.go +++ b/internal/runtime/executor/helps/usage_helpers_test.go @@ -48,6 +48,44 @@ func TestParseOpenAIUsageResponses(t *testing.T) { } } +func TestParseOpenAIUsageIgnoresNullUsage(t *testing.T) { + data := []byte(`{"usage":null}`) + detail := ParseOpenAIUsage(data) + if detail != (usage.Detail{}) { + t.Fatalf("detail = %+v, want zero detail", detail) + } +} + +func TestParseOpenAIStreamUsageIgnoresNullUsage(t *testing.T) { + line := []byte(`data: {"id":"chunk_1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":null}],"usage":null}`) + if detail, ok := ParseOpenAIStreamUsage(line); ok { + t.Fatalf("ParseOpenAIStreamUsage() = (%+v, true), want false for null usage", detail) + } +} + +func TestParseOpenAIStreamUsageResponsesFields(t *testing.T) { + line := []byte(`data: {"id":"chunk_1","object":"chat.completion.chunk","choices":[],"usage":{"input_tokens":8,"output_tokens":5,"total_tokens":13,"input_tokens_details":{"cached_tokens":3},"output_tokens_details":{"reasoning_tokens":2}}}`) + detail, ok := ParseOpenAIStreamUsage(line) + if !ok { + t.Fatal("ParseOpenAIStreamUsage() ok = false, want true") + } + if detail.InputTokens != 8 { + t.Fatalf("input tokens = %d, want %d", detail.InputTokens, 8) + } + if detail.OutputTokens != 5 { + t.Fatalf("output tokens = %d, want %d", detail.OutputTokens, 5) + } + if detail.TotalTokens != 13 { + t.Fatalf("total tokens = %d, want %d", detail.TotalTokens, 13) + } + if detail.CachedTokens != 3 { + t.Fatalf("cached tokens = %d, want %d", detail.CachedTokens, 3) + } + if detail.ReasoningTokens != 2 { + t.Fatalf("reasoning tokens = %d, want %d", detail.ReasoningTokens, 2) + } +} + func TestParseGeminiCLIUsage_TopLevelUsageMetadata(t *testing.T) { data := []byte(`{"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":7,"thoughtsTokenCount":3,"totalTokenCount":21,"cachedContentTokenCount":5}}`) detail := ParseGeminiCLIUsage(data) From ed1458aa6d3430ba59538aeb980b8934f0e80c1f Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 6 May 2026 00:41:50 +0800 Subject: [PATCH 117/139] chore(docs): update sponsor details in README - Replaced sponsor `z.ai` with `PackyCode` and updated related descriptions, images, and links in `README.md`, `README_CN.md`, and `README_JA.md`. - Removed outdated sponsor entries for `Poixe AI` in all README files. - Added new image assets for PackyCode (`packycode-cn.png` and `packycode-en.png`). --- README.md | 16 ++++------------ README_CN.md | 16 ++++------------ README_JA.md | 16 ++++------------ assets/packycode-cn.png | Bin 0 -> 173559 bytes assets/packycode-en.png | Bin 0 -> 410370 bytes 5 files changed, 12 insertions(+), 36 deletions(-) create mode 100644 assets/packycode-cn.png create mode 100644 assets/packycode-en.png diff --git a/README.md b/README.md index bcadeb8717..b1ddb9c08c 100644 --- a/README.md +++ b/README.md @@ -10,23 +10,19 @@ So you can use local or multi-account CLI access with OpenAI(include Responses)/ ## Sponsor -[![z.ai](https://assets.router-for.me/english-5-0.jpg)](https://z.ai/subscribe?ic=8JVLJQFSKB) +[![https://www.packyapi.com/register?aff=cliproxyapi](./assets/packycode-en.png)](https://www.packyapi.com/register?aff=cliproxyapi) -This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN. +Thanks to PackyCode for sponsoring this project! -GLM CODING PLAN is a subscription service designed for AI coding, starting at just $10/month. It provides access to their flagship GLM-4.7 & (GLM-5 Only Available for Pro Users)model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences. +PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. -Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB +PackyCode provides special discounts for our software users: register using this link and enter the "cliproxyapi" promo code during recharge to get 10% off. --- - - - - @@ -35,10 +31,6 @@ Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB - - - -
PackyCodeThanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using this link and enter the "cliproxyapi" promo code during recharge to get 10% off.
AICodeMirror Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CLIProxyAPI users: register via this link to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!
Huge thanks to BmoPlus for sponsoring this project! BmoPlus is a highly reliable AI account provider built strictly for heavy AI users and developers. They offer rock-solid, ready-to-use accounts and official top-up services for ChatGPT Plus / ChatGPT Pro (Full Warranty) / Claude Pro / Super Grok / Gemini Pro. By registering and ordering through BmoPlus - Premium AI Accounts & Top-ups, users can unlock the mind-blowing rate of 10% of the official GPT subscription price (90% OFF)!
PoixeAIThanks to Poixe AI for sponsoring this project! Poixe AI provides reliable LLM API services. You can leverage the platform's API endpoints to seamlessly build AI-powered products. Additionally, you can become a vendor by providing AI API resources to the platform and earn revenue. Register through the exclusive CLIProxyAPI referral link and receive a bonus of $5 USD on your first top-up.
VisionCoder Thanks to VisionCoder for supporting this project. VisionCoder Developer Platform is a reliable and efficient API relay service provider, offering access to mainstream AI models such as Claude Code, Codex, and Gemini. It helps developers and teams integrate AI capabilities more easily and improve productivity.

diff --git a/README_CN.md b/README_CN.md index 266025848c..e7fa787822 100644 --- a/README_CN.md +++ b/README_CN.md @@ -10,23 +10,19 @@ ## 赞助商 -[![bigmodel.cn](https://assets.router-for.me/chinese-5-0.jpg)](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII) +[![https://www.packyapi.com/register?aff=cliproxyapi](./assets/packycode-cn.png)](https://www.packyapi.com/register?aff=cliproxyapi) -本项目由 Z智谱 提供赞助, 他们通过 GLM CODING PLAN 对本项目提供技术支持。 +感谢 PackyCode 对本项目的赞助! -GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline、Roo Code 中畅享智谱旗舰模型GLM-4.7(受限于算力,目前仅限Pro用户开放),为开发者提供顶尖的编码体验。 +PackyCode 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。 -智谱AI为本产品提供了特别优惠,使用以下链接购买可以享受九折优惠:https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII +PackyCode 为本软件用户提供了特别优惠:使用此链接注册,并在充值时输入 "cliproxyapi" 优惠码即可享受九折优惠。 --- - - - - @@ -35,10 +31,6 @@ GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元 - - - -
PackyCode感谢 PackyCode 对本项目的赞助!PackyCode 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。PackyCode 为本软件用户提供了特别优惠:使用此链接注册,并在充值时输入 "cliproxyapi" 优惠码即可享受九折优惠。
AICodeMirror 感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。 Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 CLIProxyAPI 的用户提供了特别福利,通过此链接注册的用户,可享受首充8折,企业客户最高可享 7.5 折!
感谢 BmoPlus 赞助了本项目!BmoPlus 是一家专为AI订阅重度用户打造的可靠 AI 账号代充服务商,提供稳定的 ChatGPT Plus / ChatGPT Pro(全程质保) / Claude Pro / Super Grok / Gemini Pro 的官方代充&成品账号。 通过BmoPlus AI成品号专卖/代充注册下单的用户,可享GPT 官网订阅一折 的震撼价格!
PoixeAI感谢 Poixe AI 对本项目的赞助!Poixe AI 提供可靠的 AI 模型接口服务,您可以使用平台提供的 LLM API 接口轻松构建 AI 产品,同时也可以成为供应商,为平台提供大模型资源以赚取收益。通过 CLIProxyAPI 专属链接注册,充值额外赠送 $5 美金
VisionCoder 感谢 VisionCoder 对本项目的支持。VisionCoder 开发平台 是一个可靠高效的 API 中继服务提供商,提供 Claude Code、Codex、Gemini 等主流 AI 模型,帮助开发者和团队更轻松地集成 AI 功能,提升工作效率。

diff --git a/README_JA.md b/README_JA.md index a1eaf1bdf2..debe4ae5d1 100644 --- a/README_JA.md +++ b/README_JA.md @@ -10,23 +10,19 @@ OAuth経由でOpenAI Codex(GPTモデル)およびClaude Codeもサポート ## スポンサー -[![z.ai](https://assets.router-for.me/english-5-0.jpg)](https://z.ai/subscribe?ic=8JVLJQFSKB) +[![https://www.packyapi.com/register?aff=cliproxyapi](./assets/packycode-en.png)](https://www.packyapi.com/register?aff=cliproxyapi) -本プロジェクトはZ.aiにスポンサーされており、GLM CODING PLANの提供を受けています。 +PackyCodeのスポンサーシップに感謝します! -GLM CODING PLANはAIコーディング向けに設計されたサブスクリプションサービスで、月額わずか$10から利用可能です。フラッグシップのGLM-4.7および(GLM-5はProユーザーのみ利用可能)モデルを10以上の人気AIコーディングツール(Claude Code、Cline、Roo Codeなど)で利用でき、開発者にトップクラスの高速かつ安定したコーディング体験を提供します。 +PackyCodeは信頼性が高く効率的なAPIリレーサービスプロバイダーで、Claude Code、Codex、Geminiなどのリレーサービスを提供しています。 -GLM CODING PLANを10%割引で取得:https://z.ai/subscribe?ic=8JVLJQFSKB +PackyCodeは当ソフトウェアのユーザーに特別割引を提供しています:こちらのリンクから登録し、チャージ時にプロモーションコード「cliproxyapi」を入力すると10%割引になります。 --- - - - - @@ -35,10 +31,6 @@ GLM CODING PLANを10%割引で取得:https://z.ai/subscribe?ic=8JVLJQFSKB - - - - diff --git a/assets/packycode-cn.png b/assets/packycode-cn.png new file mode 100644 index 0000000000000000000000000000000000000000..3e34d6caed0c2e4b8c8ae19a7f71253c91fd63c3 GIT binary patch literal 173559 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 literal 0 HcmV?d00001 diff --git a/assets/packycode-en.png b/assets/packycode-en.png new file mode 100644 index 0000000000000000000000000000000000000000..90f716e2a443c48fd3f99aaffa9d0fec6103d593 GIT binary patch literal 410370 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%0DP8XYT;x%U5Qvv z7tLFcCW2q@ceEN+1FoFF6p}>j=y}N7-Z1JZB;N%n&`Dz*=Dv`P_5z{sNRz2Zm zT5w7{dHI2Zk<-}ntDykj%YG7YidMlxndRbDVmyG>PX`k6u9~cct}qEsIvM-?_F&u$ z@|*F~3hS-Hk%AsS0af4p1Wo=4w#5PRm%|8Alg)`ZBO$@M5Voudanrl+0FI;Qn2qYQ zw)FX7+ge5~_Ixpc|A;U%PUr;Aaarc=$MWqB(CB_1Ge8sKW!!hTNta@li6alS-Z0&n zmCeIeaFjLm(00cIx+JfULOnfV8NP5%fngq_R~PsYMGjz`|c`i&i@V@3Z~4;HKksO?t+3Yj)^EncX#&w8QoVxI=${?SKbq&CX0|VYk}=Tu>g~b&Ldzn8%n2U-BrlN=5zdM z>SeKYP;t+L1Cg-yZ!CMZv)M>YksQlxm94My$Vi*yN***)zm^QP@pA<+%`RrF34=LEY~Cp1u>c6LBWHH!g*FAB^-fnDma_P7v0Sr*z807`Iz~bae`0=v3=Ra-FZ1VxwZ`=uKfp~3k3yo zTKV#a@j;TRZ6Hs3j7H@}-aCq@^}v<_2>l26n#o$qPBPT~zRhq>#ptrhKhEBZ4xli& zeqKB=Np5a|dz{65>XeFc0)RYI#;Eh`P!!j0YcL*!{W?94y_z=7Lw>{4`iI-~xQ_ZQ zqSo6}}@WbS>D{Z4ptk^aXx7giO1o0$~qsZ z3@2pr1nOLFwgS$1XO~vzZLgNfr)(3}R=QBR^21A=aNgo;v2anJB-YTtVV z_>x0g1PCL~Q51{|cOKrDVQ94lDY*?zKEl7;1OIoOoQX~>*dB@%?KW;CpDc6W-65?2 z4n*^~mdmJ*fiXQ+R8@ryOM0nIyJ<1Nv4wA+kjvb~;IM9sw_LjP`SgvJgisFg`a+cp z;+LJZdfXWI#-0jeexFiyQZexVc=&1Gj3Ssrhkp;`3Bhlrc!N4IAnh%8LP}%=F5F1e zaoCCh)yXK20bq!RE}58F6tANSegzOJ+>71RqRZ}=uNayiQ%sX1OtOGl^Pu++0XV3j zQ^y_fw}&8*{(4_4{oLfFT^$xYvWL1fkZigGInWFx;AG3)Jlq*oN z!-R}55}zu4%HM%FW}(28(KAd$#?}ei-!v??!sGC%UgvXmSzQx8m%PkL1Mr|55uLU^ zSR;kZ$b}}bia|O7>9-m%umQKaxD<}LY^=hY@iY3}%2FqJ9@}H07NC7h$*Vz+566jt z+%DBOA|u#3ev9EFS^3(G)|VQrEy?;3b8Ii7akDVb@T&WbnJ+1|)Gi2S&;Vp-{g$z~ zjy-JNLH)NaX~u?md%}GA6W`O=N0;X`iu?P12(LyC^v>sP%3jIj%lglqGI&4BZ$uV7 zhh(P;Yw^g9kc}QW%%6$-t@TGSr-SVx$-SqgdM(~(UK70tF7X@BznbYcM9pCUS=dhK zy#ojxUIZxtS!+QnO82La*l9r^7TDj#+j3;b<1I!02MiN{|Bv=I6LGsB?GNV}L=UsC z$l(37;eNM~ip1wrHjg-5qrMYbm#lD8{*oj$6Qzb8A({7pE>O2&C^Q+H?dKQ-|6D5J0+rs@Pxu&;R02jp-r&a=qe{tOvFf3;2z$XirO&aU& zqV=)$U9QOYA4pyVTPGWTduNa~H%;_$EW!NaG#3|*PhA71LSFT&fC=w`!{z8(?Aw*v z@t9FKfjEO%BVE0JHB~A6d$`~`Nm|K5>`)ekaiN46HOrf#;(B+bX=D`;e+c^mNuy(d zOFK{q&p;7PUKyz|ddp?@&;72U(~DshuQ<-kkCmh+lkG_!){S)U>~`?i2ZldW{{Om> z|I}r$1*MeqW?<~&5VCSSEcz>zHesT!pd^eh8rz!I>Jj@i^Ow7<4GjZbACj9U(nmm7 z;=rsjC)NoPrL|?f%w=WC7VE$PT39EnQu^hOe^Pm%0LcG#{gfi35|A z6(HLRmCq)ITN>H}>$5A#%eu@9uI-nPnl&U5=R4fj;Q=lixu~5HMs&vi5S0J6RedIc z!iz=ragRKV5fKnc4zz^w&`A}%ty)*p0QXid2gyuV(w}-mnRq6?(3Z8TVeAaFeJh=z zkiC!Nz07aU&8dyw7s17vHjE}us?78al^l(7VE@f6D;azOxndh0Rm#|LEi5>N#sc|P zmNkxcb9+!Q2p778F(o03j#-zy{g0WBwtR}ncAZn`mHy4uRxB!8%ATPfZoGwX_bG1) z*P)iSd2?($e0maxk`DFRzYS@kh2CPRx6;!jY8dZ zSAr6|=w1GL!SAv5J|feD)D2)yo8HxnfsYn_!0Ufn$F-cu~pP?wygo#i%zWsAo*CXZ;4mP8F$v^ zmC1zAFQ7x#@bZRqWWh?Fi8%Vh3oRukz*;~=eSe~pQT_xTGf4>tihj^#Npxddcf1-e zZ&ZD^J>`)z+Hh1>MyfAcI!W1Bk|Qsg5R@cV;HrF7ulGH!PfPXD_N0sil&dJ7%THk_ zBKG;Y+%^*1p}D@41N2m`#qY}BZuPhAx9w#f$0>6*p^0?e$@`%eKc?SjT ziKQY(xLt|56(!_97L?H`*8_n!he@Q`bF1`W(f zy`KfXw}P1e=M?%S+-etHV+>~*pr{n3vSYP?N`eQ_oD7(G0K4&l=cKg~q(`8?JXA%U z>*ry2O1xw{)die8h&zaCQBUa{?&`V8@tHkV2w>y=q1wN4Nd1J{_y@M9nJCa(3SBjZ zU34$?7YIFy6Z}x7#9;Y`pDCjLZauhP4^tH7fmT`MxGv5s2-4$3QgB_ulJM-suwu9S zw=9*UF|=1`q*HXw5q&)mv*b9mow;o(%i>sO1hQ=HBSXYmb-FH@S!i_wRk&ku8l*p4 zwpcv+-WQELYY+pcTE>P7vt`>B6O*6INeRn{F(C`DpGp@yz(zMJPrt0(`iMe;^GUmG z-0^-^;*KMX4x(Vwe@l79$BAC?M7Cp@xPclr@%VH zty{0yXlysOZKGj>22Eo$wr$&JY}>Yz#%ctfbYxl!(1N5YO##SGj zHc8wMIqIHJa^<<%LHChy!BGWnO5Od4_`3jii3lStvRkx9B;Z%p6X(%#dwV5MhJ003 zUX;tq%^&`Ey%xPWFM;}JTwWpbA!eAwk(HuayDi)kyO<9yMjQd3T}G?`m6+OutoZRH zSjR71Koe7`5M7VrJx!7L{kk6PGMX~s`TIGG1T7|3MlIzlw*P3J>3@dihyWCVR&`DW zN?BGti(^_1rQ?$}--`!R`M0h>#4v#aB9YmkF1@j1xEgMsjnAvLv%??csArvj z`3;@K#&@dB2oi0$>MP9y%VW@z&uo`Xx7=X_#+GflpD$>1p_ml-Os)H9_7}SXUdMkz z^xRLr{%+K#eV7^xmwPFt4kYg;vsX|+qAKKOxsOgdh?^4n^h{nBds?#*+H$Gl zu$Hl-FXp>wnDcokZ?yIomv;SuRKZORTps9o13Aj59RYmoD&}n zO0O80!pZNesL*Le!=Z5)waIKo+TQwbGY)D$S=;*H6j&P41w?9SvlJ6EW)3HQf6QvB ziwu%m`l?jtzrHXjPfP4J)>^YBc-;emX4~AGDwYC0S{?1tl{lUpKeS#TY=C%KI3&@M z+93P=f&TQ)1OYvxy{9`@T8}(7Rd~^5)tpKs()iH1bZoI|f&S9^*OVOfx|_j&T!ebu zI&i$Iyc8L(DtB(O7owC7%l;`@%}eDz>6^-#TeI~ojmrx^H0OvWhSAqmDlXvD`TP%) z`@reD8G=k`H>$oKIoN%K`=qg3-y+-isBNsW!D!GOYV=U)p!c{9J|Xvjt;5kj# z+aJsiI|5__PC*T}G@HN)Z0=Ol=lzCZylbGt4!-VAc81Kh1rCFQ>Wovks*x0kvkJzw z$#oJVvJB%n2N|LV=IgSRLowGHDvaW_J=l-S$BzBXKqusLwJdHy{mmfzcY{oz*4!G4 zM~rVGM7(zRd2lapMV*ezs^0pS%vj}mG|_{^f9|9lNnjdLL{Z(T-M%&Mg5$8M$IEQh z^UyTRed#EqerU4`Janr8MD3CARW`)&d@sF|*`+)+2E6m&xFTT1;J+Oa$TF9QGw)BJ z1M)}pAa0?T$`uImyUu|&e!>xc@hNB+mC^||7?Kd|zHwvlR{B=yrariaZ740QdVu@1 z8ZB7_8XjuLY={o^pdJClg z?2O&UC20#`ol!8GDynzkpERvm8xo2f!xi1vERE*;Q-dxI7KQ{bys|O0venhjlFD}H zazfg<^Y}iE3vVeyeCFTy$m7pR5qk~9Ym$E4S7|p_wYO&gl3AgU zv9_sxp0Od}eAzQ_WYuin#mA_%FYYDrf)k$o=0f4KN}hp2%Z2wsEj(a*o$R^j;+qP% z%!bXkst)N-zQBN@Qr3(oxp9$y?Npa|>$!UbfyNj7oNN_n^h(mKZlNQN#+X z>r7KGqiu?TX}C{mVqmm-s^!jtR2sVc>jP*Y$mQg(Orv~;J^Ov+r|3Q@V^@1Y$gU?Q zb@_mXU!l#D8WvHxF`YwvUKz=p3g=lGn`vFft>Lc_#vrewEEi-GpXKc4RvC-DTVRvWms zJQI3eOn*rTomN9;-SP%CY;Sd6>O{4EDOq%~!>pN!Dvm>PmUM zK}*L{x~7dzGu}DvSJ47kCicHit_l}eWg4xh&5J9q?)m|P1FCh?SLKWmA(Mq^Bvq$S}(SRjk*OJlW)f(uwjYhPn{O%78}2R#_H1urM$9)G+Y zOn+wrt|yvAXU(sUp(cTT*o|10s|bzxE{Iz#;bx3AZV4hB zDDiwb{AFxrhf=q+32eBxvdE_ySFfJDa^>6>K}$tkDeu_nYO3#D) zjhO`3rVEdfDt=swoAC;Pj9o`ZaT-PT-ez_K_5FlAv{tLRp!$Mr)TN#A^cD01#bQ)2 ze%OkBF8LXXF0%_Kw8wO&p9vk#V$k0}r>6%lVI)xmc5Z5`0MT~(4T3PbqU2+(fY!qW zRqT2?*8^ilyZ6&Eo0J*#HKOKbZZy|!&l(L<6TAUs-MohYRJRCS#!iMKeS;P)rn^Of z`0v0LVCyqF3|FpRY0owQ&vAnr39E6eswi-Px2GAe;sjy?DUIt!iq7UL^4_$~hAI)K ztMKB8@#)58dtCB-khDop+tkEZ0xVZIr+q%|Kuwi=T}mM1_}~r)Om^D3r^(k3TUL-siWk)yR@b9E;w()H z3kz1mOHq=u>=>>&t7?B)P*rb{ucL}$=j625^S)VXYXcpJadE>l_yCC?-Rdf(hEMXY zb16hUexS^OoedZ9C!3gWM4(1QYg==M;7p>}xL{&IlA$5Q)4WT&%R%ih5uJcWFayI` z0Jy_7$fY#4j@(k^_TX1V#^9(>ZIEotrQ`3x(OVZSrz$*LQaJ?AKLP3+P7@Qh&3KIT zbhdFZ^Sf8U`vk{h%|mbt`JSiI8Ns+JTCN|koR4}y8`N}mqAn}^HC-50rxV(VP53{<%B4du#!iuaf;XYp&DN zG0>iK29zr9s!4dPA2o3}7QaSG=7b;>n3q5zDGpnWTS_Ng&)*)6YuZT8^EDGR5nq3F zbaC5bLFDZO%O;ST)&@cd{?^ll{bi;7)KSm0!IU<(mt$hu#$t7e!`sxxU5)UekawQj zRLER?Q?X%-79o=6WtKUxMv74_y>U0eGHc8Hoe6-sm+9?hg~Q zU)0ayk@M}@pWH)`M2LPj1+%(9l(jPn_BL0zyYEf#?ON=E`Z)%?eR5pR<||zOo7LA; z>*Ua0R;0w&p(4LRUqh4ZFoAESfBuME|EVLGtay`22>+`ouH>gO*Oiaki(#B{01I$mt=w$@u%Pf4n$KV@tRxY@@fk>ls#u4}`2X*nvZ=jhCRE5QQf!9g5rNW|F zY@A1R=Ob+b;l?z#!jIqav~n4%U3z#FhJjfrV0ZVPAo^ddO<0oU_@ZR2p1T*A#4@cM z5&jtQxkb;4lLmR}m z)k-l|3NPo02!?XjJFWf;x*}vsZBB58?jcb4XN2OuMY`P`{vwE(^@`Iz;b|JwmEB6M zqR+~({2wzZ9WER+qHxr1j!#lxx2?>BLlBaoC5zqz5@YuXpPG z@9IkHn-VybPjcND)#O%TrxHZY5MV=#0?%mLI(4G~AB-Y*=HAmU8DU4oFUbIR%ynAJYW9D3r9W;*a6a+CR-q8q zMa82_hjRwV)_$G$-Sochh2gci;HlBlFXPvTy8?XjoqdBish5XnPt3VC?_uB0j)$S& z`5tZkzqJmVd9ub=XAt{!PY3#O8*1R9Oc@gp@W2ecDQovrYZ&DTq!~~X{9Q~$Y{gQX zfrazP63r$OKcPE8J%&DhdO52OxskTnurH{!qfv&?i6RpC(XLC|$q6NUqcc|fj$pq% z<5+>A^iRcn$UXFDsDidjN(ckg~&Aq&gXn!Y}3-= zFv@Ju53DNkOkt4}vT{Be$)!mleZKHMtvk^0yxq?$_9xw#{X=S2I56_Mf$DIGkCO=~ zY@Heq7U~D(J1b_W`6tNgs5Er7^`$t|KAib?y8oB9_XXH9DFB5;bx=h)+;^E88zmFs zQFHpqm%UqZP~z_I+}v&vUChR+JtN|Lt>L?Q$6hqwt~H3Z$y2pc1d_rjWb^)6DVE>6 zA1ZCWJXU*MSb~rW9-{o6BY(ogNrPhd+T1H|4C^g;@H8Q ztzQdchd_@*q-jLP_Nq-Fn|&cp7WdT3FmYVCoD8qEo?9zudens;#@b1bOMc+mdr?UH zsxp#>t#dyrJmuLL_LkK;?m95bTJN8tyBU4o9wU+MS#iN{k?J?$;4455rK>8N*>~~A zITU7`tqBiWl?b8pg$^mLP_58dLWMeEh&u=2gd7~HpBk)B-#})9NSBF+c~rs>&da#= z0-4+{C&J4GK zV3CMXUF;VMwDO#_1CLW=eXsb<`x`?CEUoKc?)Fq}#txS>N3J>om}W$P|6L-}lJ# z#>VdK08(@hHXns?XC%xSbA8l?T>p;I3xiR`P_b)AqI}J)>ok^Z@kCn)oKt6Yb=Me- z+9Pz2Nh0t50946PHQ4j6KgLT^v+#ZrR0ed09db#UN>Q&c0#jC8A+r#Lo?;Wu%|_&0 z;wsuNk+P4*<1Oq|cJk07`l1|8n5Mw$AEO`y&;K|pTBgO>4LDifLc{7*KHNbWg3z_d ze2OHxeQ59ef|;jg-~tvoeH1K*nqZl(eJN?Xo#bR1L4RyTO7mRXZ_FgHwXQel(G+PU zjoYri<{lG&?NHyxpCl#P6PhV4Y*gPearjc|cnResBnCk@x&pGYj}fuFF7|L42RhpU zNO^4V5EP++wb!pkzBh(|`F|EhhUs{EP9 zu{RnHA`DtgR;_WgJXXodjStzKn~`ZkBn|U^K9*8|-GRReeneNnZr0 zc}YP0MqInLV1Ckbf8GfpW-RzA4sfD(XY4MQ_a0-ox~_GO9PmI%#5uA8_!M-A3BPYQ ze*M>TU2m(gwh`dU@MZ45^l&CAS&do+Jy9jD>O4$ER8+j^TZQelLUDipNVq;773?Qs8` zj+s7CjnCmpz{B17>$!X}S&T@zZI&9lv9qSu_2^^L97JdqAK%&HLG1BRK+Ub(b^M!X zN`dQeA!bj*cIz>h5xmVwS3sd2>s++zH?MrQ#p5rtxKiH0p{@5DI4gm>Wd?fyR|6E1 zjn@pooeKP|Mv)bF)_oq@7~tB&i=ov93?Ga&`p9Nb6Ib0m2T$^w7n^)K^PaU~`&IAY z^7P%;@39CfMcdM|PV_1A!@8AA6t>P)80L`u8J5TA%fG*HBS0ML;hSOl1bvWs5SvBd zRk(pO!>nUl|0@{(ubJ12T%ub=JOQIx5O{s^1=U}?dMw-sDjlJDW&;ELl$Elh9^AtW zOtMd3BFTx1jSos7{q7z%jC9$VPfrgiiH zWewI{RkO9DS73_W&G8h9h?)oY==^%p_k7#za8A7iCJ%32Cx2Fszix?h3H*L8-pNQ^ z^(zhJu_&JL1jGowQ0wbG(Qw?nQE4Ln9=1jz!fr`h@-72CmWc=c(r)@K`EUET>rkFm zk+(Q8u)l0ej?0YMIOLr5@aYFq>4+9Ndci&02)TVfmAm^ddBD=vwMjN_p>+WkoSm3O zdZ*||4q`HW9j`62t8O;BR#a76ml6eDhcGCNaLF+OZXV2KxdtT`1G0DY^BFR?rU;uz zJ0{!Mo6LVAZu{rC%v&s{K!(QM_wQ%~RU* zqGaeNgtxYI*B{wD0o$$sb-6rX_bbWjZUf~iqx#8k>RCI<80o}mv&7eGa8wJYwKM01 zE48^nW#`%#lGSvxtn#fVb$y@9lN{QC@b~^)6j_NDL48iV{{hKB`@YKKVO8E}WrOu6 z_fVZzM4z|Sxw;V&Kub6+zP!oOg~mkB5rl*uHG2DpRXk@GFK^eGubE!94ZJdhz@ ztwlecRd^kx_F)4EyTqtTx(x`C#k0QE&Bl`^pEhCZxsu{q${fk9e$U0rPu7G>|6W{y zpwtK`ZZAgVgQq~ypZPX)T~Gt#4q_NRwFT)G1~qDH*8cum2d=pa8q={Ce0Y-NKTvhF zCUTucl1)*JN4c?NQFLa|J5>d2wWM1&8Euz8fs0%PDeCSD|JhZUiN2r^J6up}O}=66 zuML0s+N}E#lAe)Ki;d7kk7-4yp{uJKnO?sO6Zk|c{fAzMNB!p__L|#tfOCh52l?l6 z=hJ2eHgX#oOW;4dZg9_B4gjy@HtLlJkV|8fwSjL-Ch64FT~sZaAjy5 zX2ju4^?DfU2B-J8zd zwc@FO$*)DB8%^Y?5q!=MFVM$V-7v-cA8Upz@CblpV87P$)gfr>87HB0M)QpGD#KEj zx3`=NIr8@34F#)SdH${~w^+P=8s3Rf^a|tn`;dHO0TYbw3q*@wJE=)p=1dD5*{v>| zTofteHB^eC&>0Hz+Q@S2cX#MuM}*3y`tXRLKC$Z#vZtmc50?-$sA323o9)j-y{{_Q zN2o2m*mAW8#@T)IKamzlDV-2O!ag}g7rFYi{z?Ds$bvkruTFToGDY0%83yV z1*AL&97mi7cnN~o>@EJp&v6N7-74d9;~Tc9C65K1wAymxk{9QyQPnVD!TVU}vrTb} zU4-G`F1|+QlR)(Z2Kh83-gd+%B=?o)bSTwEezV%5r@29hUvg+AxKH5c^*zJTOxg_J zNlowl5#PEr7Y0L|1QZLl?Jmb_0AZ3OO}bvP|I{SLM=4xs_BmuesZC?Hd!{LkNA)9M zomY?4B(h~&3^=CdH?-AE={0V*cMup}Ceo-jBrryvKS<0v)#Z=lliEYXKct_1haa%E zWqs~4qM?oC!H;I#JJ6{Uoz%UNANR`$vIwZ?x{0zu72_(~M@CRw=qHdfK zAxG}tx9n4vI5=9SmCLdhf5ek8CMqTtnNdcO;=nmfh)OB+v0J#O-HvUj3-#iRl^jU4 zVxS=RC3fX)?Fqk>V8VO`#mJnKkssQoYBtG^^JaKGiQL4xhBc1vMfA_-ogT3)1-z`HI zO`IeHH~y=58f+SsJ*3rE82J+91BoqwSvX~Vicp|9({9H#UA9Q{WgOK!GZs8jwROO- zdeZs?SylTE0-6%)UJ-=Q0We2VkrP<|r-h>j_RV&5dnv9CJp8@-`E(`+P}xP0n1hU9rvkeq(Q8V*N)jcJx_bjezn zrIAfZ0;TO0rphC`UF`W3P0Tl;XX>S!N&T5y6_MYL_ro#ijI0UVkN>o7C^}BOVb9Uv zq=r!#2LPY`#F!TnHcllVXNisR`IDB0 z-)T^p;&qA+lCZ}dF5ix+*=2v2{|Jt0a^`Y*p!fW<_bhLs$%&#Egry-%X>ltw-(1so zX%f%hh0_;E(+`?K6AsSr%z$u7NQaHsDLU5=5OtWd+Jca%3cw0w}28 zRxR9#Fd?eCPZ!ApvCzTb@$2_XjPm^ZhMB4>!~GWrru6rc4KAKE2xVZ**pQ%878Ysb zz&b&qHc{wL0n1xcIpCV zzNRYUS$?W<_cc`-bTV9S_oWDj@1uajf%qfiNYAgH7yH!z*ZH`P%V@>bQ2UEDYLIn^ zB-Y(c@j-9|dBu~tt~QvTx~}PeQ(FY3xedVh;Xex?Eb_h^Aq)g91C`PWeI!?q#j36Dw|{)~DH^k=hp8yAyky*UJ0iux(P9Pi(-Cg~s62DGCCOupeX)J&??jH06Xa zZCN3!;=KQEMm#!QR{m&|9r@a5Z`@uznm{!VgIi$)y9B;moa5d0%{+~bMWeHW%vZNX zp1(DTBJ%lMj#M6(rpl4)uBpmw*RC^QfsKLbd+ZZ_w>>@i4HbsM?5}V8?)h%BkiN_ecP%Wykq0}Y<9Ob9=OeSsG#jpnjlE2zO z2&G0YNgDeuK}H-htwgn>A|p{DqfAghzS|$0WJ3jpJvBh3cos z-gausDxGMmtTy#+SRZ7LP?*_0SR$CvB#^FLc57xLOcsSygx`+r$%!6Dh?wlLGUaaU@# zNG@e&w0J@4D2OQ77UPg5kTEBeJo@^i`j%4wdkqshz!u|L`jsq5bHpXL&AA8XQZ&8y zdlNNuLX{3K;FKA- zEDzwXTS$n$$R!J6EuQCQ({s9y*H80MQf`>uFy@0QYYkfzrtm z7dtu6Yh84$lg#6fwnYTC@;W{*b$>KAY_(&|eE8`QhJT#4W84aFlgo^BKMyh)q$Smk z8!Z>y&9SBgYtXqC3WHZ&WtrJKLG6GLE!a!e_1qc2r4LXf?0mD&LLfx!jtDVOGgvkI zHW{0NdF$LJ4dmm>P{CYpS1XU2=+Ykx7pH~WS~vVuuFrVt__!pev5jDPs{E{?ykKiU z`+4qhW+4-~j!O>(_#`!ZTUk?WUbC|VX{6tde+&K`4R_USt)KgfVK5t*>Q2Ynx-ay8 z2X+BA$=RF4gK)Y07fRn^((+T0g8(m9KBM(q>CrUnBq4HO%mhJQ|xvgs?JtSv7~|M_XI zs+>I6s1m5sTyB&vt;#}oxR7kDt8F_CYuo|SG^A_s?@oc9%FWy5&^UA^^SlFiRe~8P zc&O0!AjU>JOH5^^+hN){d8$&t%$X`(pjbF*7)6jxnu+_MP*^gfZW(6LR}d!%D$oex zGn)Ta+D|wmaZB@Y3$(OPqzut-iF`nsHhdDSq*c#AOV$U{n+?Xcb9k44tzMbeR4!NrGCfqn79#G9-9R%cJ%r6LPj88n{nSrdbKG{;l*^xdO(yNpjGV=KXbnZ@y`R2Ig=-eeMS*_Ln&| zN!ioj>VmBXx{ABfQDYWmV;s-6o6M|s z|G|9+%3n@$wblx@@FQ<@OY#P0K=>JZq9%l`uOvd=cIqIO=tvUS(8hy*TQfczh7a37 z=HYQ@B}DQKA0ZzPXzNkpF0dxB&DuGXo~>xjVt!l;~NbWxy~xEAU?*o zGvg$?krz=kpQ*SEa|*o=Fzofbu#}^JpoxY;^E2rxRwL|iFa)0=Ql!HLks0n4kJRO= z8ujqeaGduDeXyTti+IWS98A`r2tEtbk5J>d?#7R)W{u;$A3Hb2_j>EPkZAY_D26)C zGChj~nFW!zYhf&+T>Jd-+llMI9B}@B%Jp?}?29$W6`lNu1VS6p9(tV}NUrV~*Cf&y zIPc!(s<5d=>tJab;0@;NsHv)5PPu{;1g$@?`${|DG@W;;GT8i5ehXSpnms4RWmVlo zc?MpdYrtkQvE8l`mLwFuENMp`*ZQL}`JK!qwJusYsbC;KHosC3EAtj4d@QN`)%I68 zo7fKml!%jh) zFYm_%jyU16nIIyuYEzd_Ox(QaSSz)Gw;zaRg4Vpa)`14qBbYm@EI~%RcFyXaCpIg0 z$N4eIiO_{4o&1X4A9pzk&HA8~W}tZfE=FLqU8}bXSOQ{H>Buqn;PGsJ*cwQiaa~ZB zitEe_e9LJQvH~5^GqRN1xx1ktnydgk+ADwln~f%n^!Dxc!M2hHD=WEQg233FUeuLE zW}&;h$J{SN!27G>m`#?bC+y!VLO4K?bjX$=d=F_JUXR==OG$Qbi4yM;b-uY86>G zWFIJtsIs?AHs$lSo%m?Zq@0cr3PfnPS<2{jzOBlTOQMV#fo+TmP(}U#PN>Ak5l zW>PP}8jl+9rul3xWWYVq`U&N#+B_HP&&l^KMF=X~o}TY6;!e*(q`cui&q29ASE$z< zgK|V2D=JX-O*Og`f2kwVug4t-dxR&SBC`CL=0IU$r>*n*YQT1lGFOc5Ykv21*{2VD z9ef|jF|qS`WC0{|^#gitDdF)5MCP^IU#}{hq?o`*oI{-(RG%GZhWoYxz!6>bqX)eR z#cyl>i-v~wZO$Y?w>U(nbp~ZKsGF2K%3{A4Eb>D27Y^7w*g~RtJp9$*IB|R(_K(wF z8{`Gf$uaHRX?M7?Kn@C+=LN2B`DkE4a*rWEplJ%@s_cB-PN)r$T>4Wq3_ zFpaU`C5Ka42z_=i-zp3q1%+gVDg8;ft*nGf+2VOI23cglH@O5CGe*9oM3T14>x#Tm zCI0lqJqZhh0`K(34r8C4%i$UezxKbsMlXL@fAVR6$d+-O0NJ#jMeGhh4uO8qbwjfB z1aeL9kbid_y_AC#u5^^N{>meZj*$N?PIVb)^lS39#gFaf%>4!WR=#^w3Zy#aA4=%; zd{hV5pu7u&UI`X(SuKz$9z~0M9RbcY`NOixi71h*V5~yu4kH)nb;tKNP68XH@ zH39f?ydJgCjJt2g=yWhHz0%`<)lq;61Em^*r#_EUqvTBr5DcWPozhy7KFNtwJ3_Qg<4x7oK2KNkJ4IH# z1Y>{z>77Z<*lybsqqj!pS$TVDceXHOG0m^M; zaZMFJU9akg50<~C`96S``E?N_MS{LzBY6MGVfH(>!95r|X^$Fq+B5CHmv#;J4a?A0$5Ia=)j_q3;qU#duZ~~lUq5@Xy1qTp zu%5aU70wI)_GwexS{Kn{xb~w3^AdFqryn@Lu4DsaPmUETLHWc~Lwx!*Y~Y9Kd`g3O zza&xs`J1@?$nDv7sfQ3o8PC=!5aI4D8R^Ruve1~tbX)GVP@S<-=(YocE4>3x10+5E z>#Z*8CWI}#7@{~rjh@9*$R|^9@b$Kj|E^R}%%X&LD&AS`hvYyCW;Vo&#Yk=mQcNn! z|JUXe<8T68zl<(8Z9oK`tjvNU=j>fU2z2T0eP`fHYED4_%?AU9<-q34*E~sr`Brv zQLu4rXIa#x$B>4R+9GjA6h{vFbavPJ0P38Xv#&E}p^kgdM?AHdWwhiB{htPrIfXV; z&S0cSr^uIcDMmy^A}5g%_PW`{;wywLRqfsJXM-vMC?x!#L$`6e#)8NX+RzL8)o!N6 zD*oQyGW4ENwWaC|53BpGijSD)2cyFmj_tydN;$U;%#`XBbK;YzTw>$MTJ3;?p$=_w zxVeWSMjs0o*xJX3W<2e%q^{~*`cG9dQ1|S1Q^5NWUR z|8y_pb>2AC7i|$mR2Wvbd>`ck{Ufc3_R$%Ej%jbc?wh5R#D}0nv1$#MC4l=^56&-^ zXQh@1CQ_lDe~blqsw_4Cq~Ag?1^cE!`>n_O`WBH)Li=(_f}-Vt3kUi8istkBYJNsB zXP>jGI0Is+_j!#%ubWO|?5O)}%a*ish{3mAz{RyCy{D*CHz!rabQY7R&Y>tFTkF4X z_+3DTrUc`a1HDbzZkbY)r9vh!=3+XQcWCNo8rU@l>f9N@QMmF}eZQ_*NfrbMnD#;P ziB;k3CATwKL>70ol_+2qS;SBI> zEf!(eflQ|dm^|27EdiP{!343fKYvHgBfvf#uIJAb30}tjOd`xOdQx?fPP^F6_$<&v~=?&$?rCVF<_Z`)r7#^C^XGsh1O|zZ+duCFN zl+FzAbM!%D_*gNaaf^&a!u|!TMFH^wsYbrYzIFY&GNcpu>pP@Q#x*X4g0{Ow-uk!` z_7?Y7IC4s+Sj^<$Y9520lB|E7K_*fq-^#vAB zuA0Y>9#@^7yoN4>?E3b_in48QgRpgPZ^x@$Prf&1c|-Tu^SmGe*o>g2H{D>;Ay0U_ zrt{uLq;5WET?+duuyOS7@f9#87QwP!zL$wSDezizakNo<5Q z-!2VUO;6#qlshcz!QLuBAHp{(GBOeo@Ih>FnK!hgP(06EfVMK2h8utR^jGfGa{!|( zxiJMdY0&_phah5*H4U*|5Bgi?S}LQOXc(YuPbKs_pSpJFjinaWZ3bC9!)+y54)xqU z*)*L$fB$*JM1_9y`dTo0UqVmJ3~9t7aYR~HE)FFf3!d|mXgySOb^N?QtLF9UJb86mc!xi9NdxQAmfvZbdEk@Uxq-@_ zAib^U=A)~>{ucg$>!P3M>0Rb-yh!-6Px&*N3cc9bPpOX!f5?zEm~E;j=g)PK-`6}> zK(1c97oq|Lr>VqeBF+R4Nl}0U$>iU-=qW-!ef_mZSX3G%yW^|DaVCr;!f5GsD-6K% z&b*$lHU4@l4ZXPMK! z!RMQS-phWEwzhWU0p2IVU5KA~9Zv(J_*^!f?pE)bKfrz)0v1)lGFjPz^jQ(b&bf^WZXPVcTzHZixn8&eRBM_&oGm4t15LgxYVg1(eg0Lk|37@qTz zkX!HPjSH1Si7u*-dMdpRm+t$U0M#GOqim5+0$}?52Ud1?`hj~z_o&by1I+LHh)xH; zxH8_o=d6{q6{kk&hN8Np4X8$HY9%paWrOh2)dh|3>g?YUs3GIj#gr+Eepqr;(v7G_ z2%86(GrfA!93=*P{`?%4(M9o*Gp1jt(uF9 zB%z%^fU7ei=%C0y2w3zuLf&uJJND{r!ty&#JZGdJGaXYKSidV-^$^hw<;8F(h+182pm-;DNb9Y zXq%Faz_g#_c7zAcKIyuFR5d@!*%Z(mnfb4QZZ~8ga!~ zv1ml=m;PEvWUn{TYapxMWpG9nWF=(%U?l`QRE>Dq4^*8ucLo}`UMUY`5>6cItZw@S zJCAi`k8{%Zof_AMZ;=B2*2jwP*0W07P7osO(rGRb5&{&qIy06pGssw$5?yl4RSGbd zlEi(xcIPpxPnoC*L~i!6!L2uz(OgR*QFWiZGVDs|1KNXs1ieE(b`6eAU6_yZB4CiZ z4MwT*&jz`f&kYx!-TjtVrD@-U1$4T1KMWtyVID%f?}nmM^rF23-?nE&kdO^cQw5J& zg_{%KpCStYw%+e{X#i%@9Pp_R{D^@Jyk1`@D5rbd=Y!7K5Qm#X?hSI5V1I=4tOvB8 zxO#1lCxk+s*d{+Uy?fnjhoKR5SZq<_^f8oF1+Q1Xd~X(fFF#WMG}PrLa4udJS6~|Q zy1)d;NA?E&z55nu1$+NvS$@O9`Y#G0SF>l@YLj-LB3!?)fOr88wEmZvh-(>-PmQ6j z&pzZ-8On-|pKJFZ=qV8ZVjFynQ}#XbY}ZU}fvEn-T2A4urQX8)ysUZcWild4FV5A( z>nL9L#)m6Cie}b4jJ^5-92k!!vEM{^kShV+BTg`DAgGA(5IHU>Nx??8!+%dx%&Oe} zF%T7y!Op<@CRLkaqV>5-i}oeLaKuzT5eMwZP1qI67ZackIwPP%(h=SRjk&Kh%Dj-A zlr)+-mxY!(wrS9H=I5&XcnzkiO|+CF=*9aoK@v6p%V2uXf6JvW5U#MW@N&LWiZtJv zCyq9xIC;yd|2GzKEL5aa6rO~h{0ilAgo$9&r@f*X>ol`_rK9I`Xz?I90oOPLzbd_P za4G7ms=(?&aX<`(=*c4zTkRhBu=ArWh_W$CpPV&DJ?^IWXFjMbnRusCyIIfc;qMzk zYkr)DhDN<|M$VH%J4fs~%Wld9<;K&X*J~XKi{Wn@VFY702cY9-J6sk2&gR*qRYQ;w zGm3KWEAN2u%Gw3ks$J^(%n9QB`X@bc6Dt@U;H?tla!?>9F=#L`%gSYu=xr3{=laLy zDvJ$cwfjyK>60LT$<;xjZ+kB)XR0b87J?e?!Z8~Pb5i_LAEf6aQc8~q0`wUZd>-O@ zIZ$*%TUab>`YKQd*}`{n%KbzHhM89{vX+~8``foIrJ0aZGEF}}&j4xwABB;K13`>GMz7 z0bX&Uapp(@2>m8+$$aH`5T2NMBJS8hHKntYw9{4FU@m=x;VQe#v%J)*q5gg z^UfgY4xNkXDpTFEa@q7!FwXjtnVI(`SV5$~e1rvXI71Q&d1=Ao!^3=RGtq!Qug)E0 zs^>V{EWYS7%kQeWP|X2+kNbcio9;BSUW=D;o}+1FW^?NX7y;ySpy2^@fQkap58xVz z8h})!G?59~yx}z~fo&eWKBd96^?;SLtKhH^{KMuSCClUKGU`csWa2Z%eGiVCDO{*p&l8C8RNh5cU z*XOABJ`nVZ2=Fuf}Su%Bx^)p$|9faQuANYMxJQrMWZg$Ye2uW|DX! zyIN~z`e2-xe&(V2o!rki-rT?8ev4Tr%xd_f)>0`y--93yd5>@TV~O=?SXAvZsiw z*oYkdH4KROH7!!SxVx zdsH`AqS7_CA)*=kHXWQkcNgl90lq{EoPJ1JXOo_z=iv8m3aD2vrE<%L*gev1cHC2c z*^x?cU>p@qP|<@$;c{7#NvPOPS(tGmyzr>Gr0r=e?D!e|)iYmE$1IF#0XodP`t^{$ z2m@aviiB(l-NUl7PR~O*g7Y7SdHbB0AC>Pl#%rH3V!7w?GjCXil_e37KiO(Y0)Zc6 z-=97@+q2M+UO4jK^_q^RF%1)^ zrM|6b?tR#W4O5+7F*G0(qSmT!v?6T zeN=r8=Al=e62na}bhalbEFi1B@cXxNgcYB_5&ilUK|HBNw`?~JE*IsMw zy|;?K)Lm!99PrX>x8~P8XI&LNrII)Ey(!^&Tz#4dr=yOpw0pgnId7ZET>jH=HRJvI z-8+Ier!6h$e4H*eN62u74xkXvNQD6Jnt~7Jn8q;5Il&VV=BBbEuREC>SK?ca6#5W&wbd0;tt4c{Y zNHr6J%RJV(V5;5Z=r>zYb*+5+b@MITcxLLoyVl}m!q8*6GTY{A{0I=V;d?lot5}W z&)kIQPCs(zn8^O=MC#h|W2VzA=D*p|Wk&$_s z{DX8=LJ_<|8btP`$>mrIHWM9>gtEX=Yuo^SJmmOI&+U4aoLcj_*YInEv%XqYs!MIz7WYDcuaUM z36QRzl0PJj*Zk`}IQr_z<~_=6M-D```)C8o4qJFX*iPRc93AkdCOL9A((Q8EdRrbY zKKJDb5%bvSt6mFJq7DWg`R((XJq$Ndc(;AgQ_wGEU_=8Yf>@i7>7}_&-j{~a>rwa| z?z9Dw-Vot%?p|VFm%HT-Q&*StBo-D_LRRg=g|l@tyh~ZNSoGRtN)4O`)3!+kvDM2| zKW8IT3FqW5;)nbH1hHC!2NM;$&0j%fTMT@IQdy}wUt97!v(xm?vuXt%GkG31Ei|5U z7LEXGM?^}NvM%;QIGNa3iYo?HeF`e>EF8nQONoopE?eukbZrajX7#*c@`k@$hurtV zGWrVH`H1s=2_&lEPGvwqFYY1=VO*&1;1JPqVL9mFLhvMRqi|<3h}u~a&E@yq*Mb3&i)nq?NQ zG}r~#)TLbRqnjzrXZ-+-7)OMM-(g{w^pSs}(kKisBQsxa^`bcaDdO6v+qfp$h{sl# z(cqu*#z@N3Q&2?5`@3rx)pOL^%tML$)59%&hG*qXhRn`TAs~_F^L9^#?XEqg@1_Dvw6)r#=n>HEnH%uDO_2p{m>Ws-evjCkude_Q_PQ9xyq_ZG7mk!@A>E3 z1)thQxiVotkPSt_x^oasI77*f*3U}i^;C=RO6lu)bt^&_;^sfONmbc$SPu5(bF^j7 zEud%-b)GR7YpYNAoOeYdsG32){sd~ih2b2tNJIjnkPp#h7BoLZ$PnHKvgtSXnC?aMoCS!W!uNII;4=@e0 zbc2LFmoiT9QCBMUA;f9e-za^!{rj-d&jKmY!#Zukr>1no97~`D+o-KheT0P0uP`RXz?pUI_57O#*P$ozU z97>6dYKR9j0b?+%OY|B_nwY;v`1ncdMSDTT>gx|tr%4s3xr5A;n`ei9w3*M>xUh!} zM~8D>o4G%&5-;@!wP@Ta4xpDSW!^CnjTj_#mdkF7cVPwQM3u}&ka~1#9AB8Rn(Ro$ z@az{a(`3^nR$6{InyhY`qK=ntZ^M;bp7Jfo*S_|BM>buqsBO)zHhM`k`Mx!ZTws@+ zYeC3}4hLK`GHN)S>Il25O`K*8&2`hOZl0)M4Df+C@xc%^44nWFMTSk zDUEc2*pyGHn}$@4sSh{`9F&noHl^jN*#1t}MD9~NTNum%QNt2PVZV_+qwM^V$~T~E zEku9ElZvl&QU~kDD_JJ{!>?)nlEw*5Wh&=&lFiy@7;=8_hd-$eTNd2)kK7^`N@5KT zxeiODcB?ASy=ii+LY^`T>FJXcMMFNX!5(%ws z76B=O&wn!8J8W-}&U*0Jkbtj6&E{a2|7h+W&gBwVG?1|Ot%Zv}@bv_Kf-y-eY}UTM z@-nipzMcAo#IHXdp)DhJ?q`DR_zsn`!4#8X61$IA}`9cAfg1w2@Le4*o-bptX6PP(vQ&RW_;kytrS%x~ql=~$Mlc%4#y z6@oLkA85j%|Lc@W1%a8gn+OcWw6?EBVO0`Ai??~ zXrrkG@KUeY`{OWGEJv(mekQmb0O~x&WUjvDnzk^lwT3h%KV zPNYJnbNzs(TP~>~_<1JPEs&@M{-#%2|&z6+FC1JkgZE!=bpK=aB zTlp>^bm<(8=&@@l;wt?3A#F?1N6z;h(tCJNJ|P2C_T_IRe2e&XJtA)QnceQMIkvG= z?UXf5In`)NItYE%7{wa(4omsk#)d#Iv${k_mB$5#u*w+8v)4`3pu^_PZ|$R~-_rfF zPp2-@=P$`(a?Lc@{N~?5f55SKC*aA7CfI|SabS1b1I?8tz)n_R=1RW^L78^~F}KA; zu~rr0H_G0ORN$JZwV~Oj+yS1d&yaf6-vQz;cB>+#qN1Yqr_26sySo|aoQHeKGFRLt z^=kMY$--;@+WnxSC*AvQmv_n4=xCvVii%2H*M2=mqZQvvu1h|AwCKZ!55U^0wX;j| zMV-&InE?)4rc0G31AtE;Ffj031)n~*hxL}==#U*g^dV11bpIIbqh9JVo=^2p1FOaz zd>=PT`0{#cbziCf9iTvP-Gm77mhs=R27B1PkT*rzpE=ycG3`#5=D{Q71R&9O`P0%% z(Ggai(d5A)oIHC%_~_`qbPs9`8F+@7`uK$0;eWqb3cGAsHME7+>t|n*)c(rNEh9MJ5F=%(}gp&6peY34LRG>t+tL9CbLuvhWcUj-DGoSpPil}*1cj2j&Z z3bD36)Yt!r$tqH8RB@6K&oMW5GR>*cjORCYX>Tm5h2d%ZOnHGSs&-0&4azkKar~^5 zzI*NK?JzTLRn>C(R{9Ymd~QDH`+VAvize~G*~C)M-f;rS(PZ+$^#xxU`TOq>B9JO# z;ddeEfC84GFvEF1_8!z)EBxuj*}U&BdF(?{hPpSdbBF^aa3AI=w|zZXMo{Fqu6c-O zU5bVvy$Bn;?422ld2s#3!}F&D^a{kTYG)6f9A+JgECTEm%;BcvzD9nZidR7XKHP=Pu$;iq2BU80aWq?6?{ z@~5W5)xh&7_0sZ%<;v$PmvP((t#qGRA@^u!>fUub94TLOnCTBleJHA-{P@k_CQ_SG z04jbmukG+8n~VXx*weR@?$S+9yvGt>$W2=U^M>)wD0Qv-ceTO0Ctb0w&dw8=Dliy4 zU1recu-OlUtC8iv4DY+(kpxz~wI=%j&;6;Am6esCAmpWGUMeavO{TOgU&*mY4G|$P z0^vjvOtyyNI@&4j6I-hU%0kPNw#qdh3#BJk&qrXf?70>UpS!ME@NN+6;%eU3=WaKz zdFlw*-WNeG)Zezhv$NCL`Oe$jwS@}#8-U)i;s0LIdcuQgq@F#RsNnSMgyNL&P20Ee zP1|3O+Q(<5M@-XiY0uXttM_@8A0Ji3G5RW^`WC=1w+P@j=C6n2DOKk2 z8NjsuXsZvA1!pHC!uL;i-VparO9;m0!&mqU9on zb2?}%Aamd5bYfwvpkh+bCvd{53>zks4fi##MTabHd89~F`1ohw zAh1eZzI<~vumOEb@+TRVNUX-w$8)r@#yp=A_#40ZVQ4>0l^LwJc(etv4UzwI{_vSg^<7pmnO>aq1H z4A9ux=}HY@H1%CNiY=48SGG5Mgopq5@#AsZb1Qq3UtxsElk4eP7eLf+ zir=kuh49)etdPGQL^3$h|1H={vgXIunVj%-3u!H0d%|uiyQMLZ0L_aUNmdAeX8*lb zGiXNsXDFW7Yh>ui&_#?Wl)b2sqnf}WrmJl)9|oWO({G3Lj+p5~Z{1nv_n~FRx-CiG z)8M>Zx?eh>>S_cgdIc_1j}C82f*|z=Xofc$(aO(5S{zN>Hd=i(^(x3eRfz%XY^ooI67v=?3M1|Eb~A1r8^aJ$%7DnbLL zqQqmM#t9IJvKnW!?$*3nx)SY`r=sQy;~SU>d##9$ z;_YsBqe7jXPK3W-(^eHDZ8aop*YQ>~@y?8)vsOERbF28$BMGX>btHB%Y+PqriK5t|rSlpWLSevB*Jwv@9; z=B`5z%yJ~V-40Q7q8WLC6l`i2lyPmsW;X*?=sdi+Ey8P-dX%dK0eA-MZ<|=?dpDYQ zRS3GMRB|z3N^TYHrrF6BX|ct(0aytGKKZ0Bun-^tqr!&+%1%37-32HZ&edDvy@4KR z-zy-*W~0gJaPG1*829cYe3zn}NLK6Aq3fx@o}$Ls=jQ(#k8W7eJztYb!r^z)w-Mvj z=d8H8uY9|of2>9DC<(bDD4sFZ^{F>Jw_h)fnME4eA{EG3FG28Pi4jlOyx91oM^+7yMAY;9g^nGiMbw+(oXd!UiYMihN~ooQ~-ICh8&3r7$zb67ei zfFb&T;_&eDMUL{kse30@5ANNJJ8%-mGQxHd0_&J=iC~{r#@p}^?D}AY>>#y(OxOGT zu!7CN??Rl+-_TZMI=Lv5lsB%3zcVK|hU%YEAe&tLbL!lk)egDB93q_JT`k(gTlkaN#kVt)OO#FxyQ`a- zIsv_cs0-Y&#frwkm{Opd%-HBb^g|fc{R&k>7;z%W6o<%J`Kj&Z8O3W;w~eu8pNAp! zqo2P~M`jwYm9NQst+EkUzz7I-M0l{qC9{eA7g$ecH)Xv~XWWI9KytJQqJYLRs(ou> zr(l3SuN&X3lqQ78y?_XNKimMbr^tzj`Y!W=HqP*h)7@BpkRQsoVHF7fFbxO^(ey&{ z@;HrUvBej(oMP5HT^a}7q=z-9p94#6+KbdeV65~5f!KADo7Zrj<*m;`rrwHsFA>p! zH|?YwqjP8M7d}ocSQZ{HvOLSsOV-%dQ7JLWO&)NtC><=R=+|{D zX9`q*Y!9ewzdX*+eE>?#ej7 z!zN}J@sF_5{#V&8NWE9)Z#1zwp7)2}%zH$C+t#9MSe z?siU5BqeX~B$U|5?dfu%5I;;Yp6zCP9AT5({_F}SNVIUlKfkf7Z5ZU@0^kjfG|<*h z-cQ2iVBm;-wBBKOE_tgxV8O*hpV)o^%@odOw|kRC4+$D&`Yo38p;O)s7L9)j*}PqY-N&MgN2wn#0y1)NR}2roOvPzx_mh|R;l{GFP$PX-N6L95+)h@`i!^8{W>6pPe&|l@0{hf%J{Dzej-u5}Q9ge( ziibbb^5q4Bojv#=kG=gRtNz}&i|C`2ycc3!uu{YG7c9e8^0!tSLn%+dQzxSxGdpOb zD%*tIr^ak3VA`QMPZ%CI{$6s4Tc5%BQh^4x^1jAGH5|eov{EOw8YS>SFuLo&nx|yK zKxOGqqL|sx-<$TM62?PD06^`#7hVY_vrJIwQbi9dCA%)F)y+UjK^~5SfL#=WqdqxC z0HZ-~X~d_@VWntlJTGh+#1g`g2BLO$Ki*VKy(ScrP0KGRT`vll(;y`JlQk2PivK z353t=%r6|!*~u6fQExgDdv{UUI`%eiK?H3;$k;tUwc>Zo7S>YT@qA7a8lfT9N+lEY zbE)VlZOx;;4@Y(V|1#`+Ecbr zUo$-gvyrdJhd8z+maTxFJF)sB7$E(gt-w}Pzz6rYqu)XJ#`OC0N5LDWE$_#>uI_GB zbC%{xJ|dy9`zH7+p}ofX>&+F~K+3;V*1xV3sv)+LAv_n-&r`R9uh`5Ys7AaN87wsy z+H_bq!?#`9_w|>x!70U@wJF<&JZy7D`res_hf$CtXbb;Jh650!&=SUN7Y31$-(zwA zk_ycE)%j~X*K~e}K>kcA3}oWYWOx96pvQqpPWGPSnhM|5r`Xjwk#J3798nOuBbW=j zUp_tj4ln>%^0(R!pFTZ@A(Rlz5oC=0K?Uc3rK``}=eRdzN=0IoXqA-ZZG4Ba<2K)S z%m$Xn_+(fmW(aLG%iZElBwfbRN-|`gU`-ETyN}lLqh$T|QJ&!gfL%Wand;C$+cYyC zIz`Js-VT~KMh5V_DbW|3M+bPE)c#;x0ZUIYWYV{|Z;Y>Yr|&2VuKKVdE+1}_Q0xIB zeucRHYOPpX_I!`E&$5y0F#AwzoUJLMQ_M~{Q3x;S%PA~Mvt%1Zda(qv479Xa&=z>3 zr&4C3T!#t;RD?`(F8S|d{M8!y6K{iRSO4(#j!(gwo|Rj7h*&YgehFbLzz>vsYoS`f z1PD`TU_|)OKR0H(GT<3}5GsdRtg<}S%178|>NlrzC6=Y@NO5kCEPjZ|^EEu`MMti+ z!Q-~gI7F;K@Nl^S##6lKR1U1S~aZHt?!z2k>IG2R2G7?v-&y;r0D%Ki@X4sROrVA3-J|2yB@Nt3g^=9x7 zVSCCxeMuJl#Fr|FIymxU=os4d+pKjg6j9<=zN4?+TYrjdpz#)YmxM?EXfCf5R1E>SpmK2IT|#@l3V)Tt-|DKl@Dtg zbz2)?X@RM=i;YhE!k3ZHl-Fpu`}P|EQx--haMeejf%ip2@$pB>T^0i%>WDZWB-%AL zR%}&iz7)|t{pB!r`FQm_8N?nB1=xdIuYXm0f3+Qv7SIBo15z6(xKdDk4%u;Wzf*18C-XM< zHcgk_`b*dL^zqxk;WJXZ;aORtR&SWOZ{45fn#>O6=xpW6&nl?ne-7LlDkpOrbycGz zi4)&|=nzp?o>_}~$GUi*NEFpF_@%P#JK{^o8k;q$Z2X=Fp7u&S_jwDo%03<XNd9 z6_>NBqh~tjzf~fQ5TtZX(R1|&SRSNdPXmKyKh34?7xb=xMljG8iXoi`Lb){Zina7t z@WD|Lnqe3oo*kI^n&Q<^@&|OWiBD8izH{Gp`!`nYzKU#o;ZOnV@*$2RR`OF*(uIO| zoB)+0fl^hOie^~&v)5kCqhAI&-qps65;?@??Q4s@qvEgGj4GoBcy%gEF0V=sVbqFs z+LxnDOam0ejnx{bLld6su5SDl`Q+9@EM4h#+lb{rTB5FjQ)zYNjt(;TAWU#G%Wok7=Z6hmeWd_M<7p5+ll0x_XoH31MAtHh)hdxP&3yD@$}yysIhX zkp7cY3}<~z`_Ar$@n|^<-RP?htkU4|g${bYx?{M>c29uNPJw80fx1`N0AMBk>=g#7 zr{T9>dk1i9{*RL#HOqwGpZArNOtiS?KO_fBnvA7Xls^+Q3(GaJ zevcoY35ogH_s)zXxb+R(Qo5LqLc-e-72zAHz1EpUk0c4cl~a-feWd2ePO_6VAq0TP z2>u%yznpu+dVGeTc0X^iX>i!+D)PeT{v#F8Eh@-Ggb&Hf)f#_kL_5PTN~$M)=kP{r z3QtWnPzwC8OaPlex-tB=oa^mmnwpW(=DZ%SaO|yAq~GC=kz#kbKLF-k3uz2X4&qk4 zIP+O^NLb$3zvltjoG|-fQ$^f9SXSgA%3PV-s zbMebxKD4jlS~R{uzD&g&Ltr1`%?3RIKP*;JySlI3?_-nC&R+eHiOmX4k!uz*(E!v| z9Pp!LAFqH0MUeH?>{8&ew)#=caeqr~?m|%!xwpwB+BvSseER*RB0@q8KC3%^9E!DP z1W4Fb`thQE!7wEI$$6woKu{3%z(otNuAD&<7frKesd_?Be8slZ(%hDpc@PVnK$~IS zI?@)n$MtZ(FE8ZkU85;@EIa{7`h^p$wtW zTruNsjSg+*8(9ms&PjDosU!)9(L(_;W|`}wpUx}RBU5|cxD(4q!t=%H@m%-6_saQYsgU2maO>{x@&lFf$(j19(z+tcm#@ zjPn%IfGVQL#4)OH7)yM@hkzq-nU|3fRrbxllj^_i5467M6S<Z3wu5TgBkFI5;u_$NYG)p2IZU79Fr(no>-#vo ziUI^y0z6PXYcj)9BDOs4!5Mu5PmY)_u$OFk`fX8r6p^G~-iM;f%K;7>aU3-9B<=^o%Y&p_DBS27aHXOPp&}S@#v;-wo20#nZQ@iz#`yJ z-d^tB-vSFf0B$Py2Za=ouXtN_gFHb};1OM408BJ|sMf1BC$sC-KwaKuF~m@G*k8xp z9f6$<90s}Z(E)jIm4V1>d2WNf#S}4+SJR*w#5hw{Yu5UZw=4#?FTn@7MG+>=f-CQC zy!X-*b^0o2F$xi34D5!Ww~`2UysS{(*?n7k?zyePDm^lv z4>*8p2z5o702V9ua}aYwJW!`J+?R3W@{Am2?U$(+`G-Y(FsSL9kAL>KAFqy z{;*EK<>&zzx96VGMX&v#7 z0*fwzDcyG2p*e{!{;^yA+ei^Dil@(N2dmMXISb+0$M$R_-3$A!?X6@@I+29zL}zx{ zT|tMNIcx0d-T2UrUXl-YRHHlb%{hG5Fr|8y$1%m}2-B9uZ1r#LfP0HHvOy-4aCnI1 z$-TZebOD84uuF8OMRwlUmB9^1L~KK5($n_dY~v>A#o-seH~{NHknne+jn;En%37Yb z_HCx=tBBsKOTKkv?|7O7O)h{= z*4sCV&D+WDhS`}@$0{HoAesiOga?rLTPZhItv2-%Pg1z%&q75LMmoznK->(qO-_?84VWq+&XNSIskSR<0WD)uxR)3}*`Mavk z)2H-@Ti*^QB5xV-B9k)z=U6qyLeV0dy01GwZaW&Z;yk1@4hhcBps`fT8{~~&URTeF zI=;XlG@60?pHOwe!1C<%HR7lxBWlvCWZ2JT)RtiR)z)%QE2koQ88G%OHo6=BKeWR? zkX{!@={J4c$F40%Hb!NM;rs6`8Z}3jKX2g+=GaT7T=V(kr^rjV$$OIJp$U0~w%!G` zX*s}3G-RYf`B7+G_-c{$`V>E%ZS|S<=x^}3_UJyQnnfc6z6=?xS!mHNWwUqhcwy(Yf5ek427?gng2h14 z*PafTLeW|--tCcT4pyuTnVlr(NY>o758aPLZ+dww{a>3>pNr`H5Q09xqRsRRL0+Lp zBy`2!q4R#B3(DKxg%A=#yTsZ=Ox=vU0wPr}I}6oBls`&z0Xq}kqP*BVK7OH0b;X2&K``iN&| zRC}!8D8)^cx_*!<2A6n{gr6&YvEnB`YLv1Ui&Q}x{<(s07KCSpINQ@+^=7^E_=&~^ z${gIH5z%+LOpVbgvJ^jTEtqWV5$2_=82P;*(Z(lFAP*-1`dgd_O~n#y%z^jTy!MS3SX&@{}c8(fC4XochE#^`3**iBa63wGJ&U&&F3f+N7x z+<>}?ti;{BVe;7m)cWwa+x@UH#S{PeM8JfshKd_9ACEAb!2H(N-ehs8lA#MpRORPe zRZI!+14XW@`A}NZRr95@ER{_NR@BZWTGY|z4~+%9DqFOS4BVz*vQ9utTdMWSJ7GBS zXeWo!{wM@j4fDsG|G`n|nH;W35Tei49T?uM)`?xV5B4~8EBD|6xnI>bY}WxSaUDYW z^=KpLNqF~k39NX^)*dxq9(KJ}%k2^3b7Ni!}TMwnUzz7jnGC=NW@)Ye|5Th5t+S-*ZncwkN;D&R_j$W$UeF^tH=tKiTp z3RNylTD03}oH#5hrf-r|+l$d{uoNFkKP?Ln&8X*L5=?&q`s)JtR}`ndrXG@(lN*er zlJH+^p@(O#eTY{&hC3*GLhh24d%n6Gd0=hc`D$$ZtM}>i!^?lOL;u8o*4Kp4ekezG zg6C&EC?hD7pP99u6hl@q@@Gosrd2-Y_X$!(5_xDYZh;-F#MJyZ*hO#S&Z@uz=2E&^ zL}oNSM{ip(a`M*DKxD=5)-oK=O4wrK5sH;DAmkz>z$eJ|x&mfp-zdarbca;z!sJ!y zGZq6JfCru$n*VQKzreRn-ikZ(YZCCDA*F6^rWN%}!6|m&L0(i^X6LQRp9Zd{lPo%Q z&)=&)T??*y0ua8_P&g?M}SwO@EV*3#R5rUl-19<2Nbp=~M zEAdw~m)R1tZRQ`q3WcH6aD z{OI-T?eAZ!YBw|+%L=$DZcfGWV)+s^^~ln`yheNjX4Mkh@VhwCa?U{UvOjwJ`vM2zb6N*q15s z)`YCA5vx(Zx*$i-BlLROkcbKp??90q_%s}GPJOU4!C% zkUPsJz@fC`2bK0eCvha2~R(^`DE^*uXjZf2Z#e-=3(m4WEMY?zqA}Eycgc5-2A4o3@Y|rX8 zcNK+?O852k)=&rTk-ob|E@faf-wderduNDBg3)P*7Li-wwZEpDMEI;G1hV+m3M8a{ zCC38z-`DKNnEn=d9fKJxyq9`Mf_6!pN#-)Gtk9ZYy17u|lpUfsI!An(bM`Yu*!c3? z{tb6wF&?3@ijVN2(WFIR?-D&`w$rs7-z$}`u|`VRM&EMwbw=4)mBlBZexcUoLmKz! zSZK$_N6k_eLNNTRx{JYq5Pu9pvh@s@o6gN5Qt;;^I=s(Fz;%LbsX>rSl*9 zz?LmFxl)#A`&RKJR`#K7zjPI2@Z!Kq0e~IWL|Leu8DAU8WA3||HAd(B5U;2OS*I|v zUzmAh85kT%lp)IJO$7Z|Z$W3Yws=g8XZyHKsp#Ry#YX;$Y%Lj_8EKTaKcXd)9Idoj zRiD??rEwGmn+Iv^r=M))587f8R}DtHF8}=Rm3bYm)RvXck@5KW*z|Sx_j+v?LJ2nP zI{5ra80^{R5!}PviWNa;(B_?1_q6%R#5?-t$Nw!lO@M|IRa8cHkT{4Y2J+oFu@Omo z=xl-n38M#whFg=6%V0Z9Q(`KU))v2PuySi$Q8nUlorxd*iHT)>`joWXy4!iY`S3O+ zBFRSTy%ZBR>RLN+7fV5`3ZkN;iDTa4*FO@E+eCoYog-d1Z8J@HIKbzx1wtdsrkFI< zk>oRg^P+a;RUVlwPt})|&+yt`@<0hyf6uf6nVB;AVbqSv>WBY<&YY-}ji6o!>-0bn z_1!rg6iz&6q^GY%iZRazQ7YD|%JU-s&$HuOBUX5S=prH_++c=0eb_e&EU_;DVIBA}j1 zHJ(IQZ8}>XIt}0LTTSvXI8DtH)F{>#l;67viDuN%f`1+lA=400uHY zW1DXx=u-rpZ*2v*;>MbYY)^jnlk>$(iiAWip9S)Wjq<}cA}|Fy7^8TWWQvWex+v4h zX`7Z!a<^JU+%O8^T3*QH&pj$k+SuEME36MuqcSrZ{jc2dxF-^_9jT#8;Amxa;E#g) zcL>{JuftJALl+bOb18rr%Dc#_^u4s%uoqiAT!iii4%tK`?1%p&QU13{$C?3dlrSIm zVpbscW0G!&Y1MTB*%yfzluZQ6l^9Ek0f$MF}TwhzHMur~t_Iu}D zf)ufUPgOw@LF}ic^mv}_!#*!;i7z2?aJlIibMpTHiGVGLRHH%XYfZYnHGQtNi1jCU zLL=b3|3i3btbUHORQ;)nP#_#mA+pr!HnU6m#Itpq6}U9sqkt*! z+@KOLe?8D{flY+Zt0r@}18qAsu>UXMWbwa1km5ewZMqJzHF0E`K8A&Qjk@!25%wVQ zwjXq{?G|y?#Be77hfs**4oDQ0bkH<~ z>z@+R#N6ikMfws3I?JY(Q|324koGm!2QgQymC5C@q29qMtUyS;6{_K>_cnIsXZFlk z3;&N>^Ne%|`3`i0V@?Gn$EYL1z}4YqS>~`!I9x1fhUeOZBr*_V6K7z4CyY-+BCXRw zMc$&#wDxLKa)W5b)QIEqyUk7dWO{Yu4J}g)sv~1W5y-{mV!V>R7S$(EA}S8ho-uik zjub)Dj00l-1_M8vDx;@&deL;d4%(2*(i0n zXWf9J7lBf%;!ez?IC0nyi#OEnGr6&38lfMPWRx*=ycwT|W?c%y!b%0{a{F%xS)oh{ zAJF?F)i96|q!^S6-`MrjEWZxeW)gWnUsj=^vu;s2 zw&$|+BCU`zRucN*H$j%)8_wVba1|in^ng5?KqI4Oe359otc4tu zAXEhLMbOG8rF-UAT)D_wz{)IxHi>~Dg@3gdI z#QKcXO_zT-a2Ea#jB$en9{dLH?T!3m`>EqQx6MLg%1WTu&Yikvx&Bez z>1dmB^X@Uwj;;8FksGh&4mD8!OPwpgjQ1kjoMz>dS$> zYLw}jg{tBUfKIC3#jTM7y6d|tH*O?EwR^#fV100eOWWCBHZoxWS(7K>%ZSrXd-;I0v{bZXUGAda2#-H?DwdXb$v9P`Iy63$;4^}lj60t*S5bqM}g!XzhfiJN}>m z$_}K~3*>)FCjb%u?FBQ>q6Zhykq$1l-*h62x>H#_2ei~FSqP#KhyKK%E6>GU&H1pm z&ly*@J*xOp#!}H;Ih3+F`YG*&c;Ypn8w09AV-$;fXLECNn#VP_&6W62BgQ+7ZIL;Y z#N{IUmhbXBRtw272|5=+4A6mm5GzD!nQ}$em*em#0#2$~LWsoTsAgi!F{%3el`$a| zYY)F=VAVO^Hm|@3i&mFyNKxe#^t?!Ij}4p&cBLW4VZA37(!i5hXR2xIw#HCv$`ld;sndCJm*2Y*#0h9R;!52N`FIZUXI-M}k;gV|oSeUw zC_7X#Zbhc<=Wrh%GVTO#3cObpFR)4l`Rd2=N5jvvbVlUO1o><4>Jw3^ZACbF8vFfk z`f+hPRCJS3aiB5c;bg--9n+=<6xRK2_8)B0+-4^U2rXGTrnGdG&UI9Va=Hm6_jc7#r;4iSp5425@0nhEEq63T7*fnV2VW2!ce#>l{#@+G zlCX-MS*$l^y%Kdb*Vs&uEpM^MKEJ z4H`!)f@3Z z@^mjy(G%flHWItrn0|Zm-yA};EX6xdRqV*Q&Vsjl28RoM4Zr1F8oX1!jTrbP`eR7+ zyj;V8rUd3EsQ_JwcG=9NNR}TA6-vtGIM7l7d*wsDpppg71q@qVhgp2u7+jcPW!!Qg zeewaw-Pv6TDE5VqzHr~XieKHUuULe0T2x-2x|<%%hVW(=`4!!n=V%Ile@u(NG2bV0 zTRM($cM?L6M|4ak#5H~Pk1SbL@qGBmhKl+o2OAGV21}tti{aeThG{FI^WNH(JZ?EV z2vrdwG>8vR1B|Ht@BsVXqkt?;qJjaj&Tf$PZfw5P*R62c*uzBjYo3j2yKcC&u7ILG zc2XWwsRyX^C4eQUsK|9~B)cXhBzzv~I(`V~16rh1dOrI0#)$9Lo%?n5Ypt$56xtko zhKUL^G7~gE7}|E<+Gr42Olv3VoTsHPC@@E9rWh`(%mQJ4OgQVr6F-WbFuccF13Q)w6n`CoK@`mpR##f!Bkb9eOBktp%6fea$<|H;>S_ zHsxU=F4L8-C8u~r(Q2DkNmn(!bC9|`nYl};2YU^d&+5@UB+x6<)4ilbnTFL5x#;$M zojh6+L`W$h@Dcw{?H?><*})IIs#pEvTVtJI(8}6c^%`#&2aVOb0ry=S*4<|KJz=2s zqjvW=R?czJHsn?;^jpvW%9NjD(2RSuIbbDgaVW??(Fl>6NZ2m#qJ>5!pn2JQFKeE> zt@-5+8zH4nI&y=p73J;l&ZlkQF$8CZfa8$ZkdW^#oAg{hPx+~rq|?RE`Wa!Pr?_J^ z`@FuQc}DM%G7Sp$|(1H!)i)j)E_5b~DBP_>pBE5M8K0Ls7vJjOfS}>@UA}RIZ`Yb}&VM zDd&4ryOFXp5a3n5Z@AF4{mzU7)5TwX+jU8ph(&pA zg=W-tL_ccKR2L8$ef-uuX$A!9s$)i&0=5s0GTfM-=Nq2X*Jm~n^pqPc9XMbmOeL3H z)dj(3c2|*GjdjwalESdVI<0&^{o!&Hf~Hqol(9S41_kT2dau5)y+yZxHvl3Af&cl! z3ky#FDSG(}gL;+&l`_J^hlrQswF)6-Z2jDR$S$WU>vV(S?flxrjzgg&;DW8XXJ$C< zzQT@<4j8FO_3?uv43cCw9r!h7uV!!7Y3icg$0!TzIDn)DJ6Q}kuc^~@Txec4t+{Wd zl}EmHYvFiKMOoXx&br4*(2o5rc-@nA_^7$n##wUG0%Q6=AIf5j+d85l5>FXM{3XAk zN@2uY?M2GyAS|g4B=>ymGcwC1 z_$S-Lk2O4E@^-;f18epklMmzEqevn?!gJdP4+a7E4K^9uzngjJ^844id|OnDJ(YjV z(YC6NhR6$T6kpV8r*!mNfUzq{(?4c=>2u!{@=0U^wCM4T`%<0EQ)V zABU(KZ;)0NJNL_4b|-js$}Xxj)Kri-_mb(}>s3dA77!+*pIjG3y-MsR77Ldv4tRvx z__y=(Z%5ya7W2~F&vWczNYl?!WT;rV%V)&dc=VW0rKL_(FBAc_KBLo^i>Y z8+e4gfqftRf8HZtn6LkW>A!-XCI(Yye;nggbn#hBGdt+^DIMoK47IVpk?#8imzOGS zK@Hoo^|98FB*F)j%~qd+sl4`Y?GuRV=l{IZ3h@mE&oGJX>+NydYc{R8=>u+fLv@0) z2l-yYH{O@mb{dC9Q*1C4WFGMK!;FlgX#q4v&6VE5&rv$g=mnQVWMZmPI%`%XsN=O8 zU!q(|`f+ruWjXb>Q%8oj&%KG(G0s^?#V&UsqWWvY)sa-kB*>dqLIqf$@Brx(W=Gyd zGJ~bL<;`IyPiiR2E{9FF6m4(I4wKt!dZabUDRoU`iK#}>F^l-P>Nmm{31oV-D5;*O z>UU5RHkcHn%eYty_NI3BCO0xOvaV+~B83;D23h%8=?CULHOAEk@Ug|zis8;#iGkA( z49nXfgi0dmBTcw5nZsGvCfvO!srFUt)WoNjeBwrd-SgH%LE2)`4S{{5mOResATg~_D6Y9THr0}(yvO4B74Q^ zB?d`bl7mCkrMG=6<7)@jD)o;JWZAV?rc0=GSnB-nMgHzw)@2no4i$ZefpNNz;w#Oi zHLV-FcFuuGvLggziX_eKQV3B&Sc?Dhw7~5D|Bii#iMHZsu}#AuCpzf&!?6bi>pJ8I zyp$JU6>&g)492d(3Q}ZXDN{Jje6Wk9G2g5HR?&P%7xR2(ef$W|p4@7B^<}8RING;Q&uzEa+ILzZk zvS%aGcX^~@b7WhO3uV@XOTG%_0K6WGFHbw z)g-azLD_gSg_e`~RzOWp`SVTSL55spq7*))GYCvXLz+j)B`Gc)N%LiZAMFMD`%W=s zQmlG(@Egjhdf?;BXmRInXkp$UNeOto;FN58ILnj|c_g52k`x5X_JYDftH%3^Vy$DH zN7#X{fIe6I(NpLBSz)7Z)^|et2qqxvzTp8mTI7H*AQE>N79ZH zozntn;ujY3F%1fdv3TprDnqE@wBajZbBk;Y3yfmek^tIA>TrM%lf1s%a| zDqPxhqs*xD9bU-7qKcxI*0IRJ;-JBYW^P-=^p(Aau>87ym1fZ%2X{kxlPnz(o{BxT zb7Ux}pElQGG<~3Yk2$eR_@x`g=a9V|&n$keW7M-JAeCl1%9kC;YI^ytVib?y z2o-$pCBxatO4Wg{{KvpJ(7$=%UjxHz4Pu7llL2(8CBB3UzBC0X%0G{NIsTAWNA=30 zI_t&IuIXPh8v+8oD##|#RV;{v#B4N$$mvm-5}G`ra@Hwi63UBeOxSr;^oc61E}#?X zQvm3-O&uxGG zN}3>UJe}Si8Tqpj`A(~1M7ljA9N#J&@ltmqR*)J5N9X<*8az*sIZpdodG>d!Pcrs? zTE53c`^N_q{;q@he#Hw`Ez9p~|4a-TT{?-(&%TX~?m@SP+fd#x*%ny9C4Sz-24CBc zr9SwI+Oe>W)YSuYTu{7(8a5JVAq8s&CH$&JKlQ9ec|TtpJ>D{rtN@1G%wrWM-Jy8? zs`?eyBIOEeWg#Mxiao`$CTo%1+WT^2zmH+~Q6wdgZ?Po51T#8A38jFD|5)^&y!$_< zrXhst!}#f64{<%92*gYVX)1%_)=h)HKvKpC0zFvL2A9%XhN5Y1CLVgsuZ#$yr0wkN zN=izI*bId8cEoDi$~}6L@2{6d+_p+TeP#8t-7FZcDcpzg$b1MySx*haDu+K8aD4f&c0$H;UQiF=)}U-__T+ zEJSrowd8YfSFx@7fkT$$cNBR2i*-J}6#y2+>;OKL)-)r9mbr;hA#H4eqO1=t+2d7e z0;7%_i(3ykvo1wFl2`oSf0+6+tv@4Bo)X--@|jq&#~PaFBGGp*GDdjD4=vmFc_3r? z-;3L^5vZNvKi=mW`Yx9+*Q=N*IDgr#fb#JqaFZ}AH}}O;VVE9(4e~!nsN9FgQbpdS z>_}*Pzi$CkZLY`vLHFGm1hmh$!B(JH&HGsfa?p^QFl9_#O^w52^;UOMMIx&XPg}kH zQ3r1FvA}&?L%woiGFzaf#-D{lqXTPOM+yoB_+yMFV5>MwAIxX#N;#RJD^}bapiP1veg~Uh_Q+NCg39(4e|z3cs80>`LY7F-t`f_ zetjmpc$m%*J2Gd){4o1-5SbS$o|Rpg3kSQz%UzBPIlGb!Nwezvf{~0*=TM3F8IDfZ zFh;ZD^Lrn)Ouc0}#=;fh?HQku^VTY|)z$mi8Rg_UFJ7m^m6d7Zg?i)WHXmHc7%Eq< zcSSFWLO(hWnPe~SEjlr^IaZ>#*QMTSc?~`veT|~pdT!u+zP>(x_UCQ$wQ9W$mfUE- z6}C&(W_U8_*2tC-_Wa|yV}%DI1`z|m-wW`?hXnsw`9$d}i?PcQt_rXN?*va^LSi*k zR!h>r5zC!f=A{-rJX>2!2>pLfa(Wp{Tk@%_=dq#k?lS5(RYD>+yw5XVC!VZC{#Hxt(`!ea6u(BE5lbWnT zhJLKK1RE`GwWB7EV(MAuDN~E5%>KQXtHO9cm!ck5YUCCJGyJ)2pr0y)?7z{_#|3@D zLg{TbgcPqnPk_D;{_9vfSk+#q!=1L?PW#o2tt{Er9N%bz) zImHA@4m)U^=&p?_9XDIf1V5e?hQv(_Kc|?d zQYgUgkGc~ng$#YHIF~n$-OJP1ntP&S0EiwZE=N7natks_UbD&*{=@)kR0fF+?x}6I2l=vbGo{wS6BB+YDSKFbmdWF_$@tI zh=TpM9&FeU90&-&hFw7RH7OJq;sB?4X@mkb4?={Pj5UtgRkX~g%Ys@u-avINqHlQn z#fx1=xE=lC>b`06AN@9o9`#sjGx)*5qr{ad>lN{HmC5Kb*)_c!NJlN5F+glZUwmpt z04;m9^aC$$(KXSU)i?MQm*?e>p-SUom@2mc*Tq}OzuTk=J$~)vOV=QH5g&dfVmfWa zQL?D?i+clg1?u8sW1@HJGA{;&R^1=ByH*>QeRdr*zDzq>lDPJ^(PSHBw{!e27n%ix z-L@jX8y&O(?1EOKnTM~%_#pSZpu!IQLh@aA__aCjUqANM)Z+c!iJBM6>w@P4s|J=@ zA*Va71y!Jz8g`FY=6nzVyT|br#COjnfHU!rD8i<%#+~l{N$6yumg)rd^c3COy z{Z`?-BnBFA-w%YfmnMd@hOu2arlL7Ndipx45si2;P3i2sKj`p`98)+5{@&CPK>u%h zdx35)XN{cDpnWhuL6;Xffz=zSg6jh(VmLrd^Ld0M^m>Ti6?YKZ&6P~NfUmpA+M4>! zQ^J*#C2AKOB#_x|8U^A{GM0$Q9Pcsa238?uQbjS=mf*GnXE;pQA4-4Q^_6Fj<8;?a-Z@#m ze>zs!efTQ9&d~w%Syf#{lV^zimzQfGLt(UQnbSu7P)&QhhcB(ee>#ZhG=)hN&MOKzwPhT(bs8C}QyGL!1V}>;{5hpev_A10nZ!m`9Q|JJ za8o_QWD`F%o0`Qo-E~Tefjr~1Gy2l8MPuy1BZ(YHG9Qr|3lHl21VDh8c*ZXsg zkgVx`an{!TnkQ~~VVA%=)7-log{4ysa`_R5QLGgn9j2YvJ7PQ=TPbh5taF*^=L zPfg`FkvH2>4Egvy&xUtCaZ}fa=IAKF>-QqkzZI1~eG)5Tr)H+Ynum8TjQ;{4Ly@5X zFo*_q0sNwf@z?fWDz6vpFiV>$b#w8H`$#H+>Zv3U7IWK~4!K4kee zt+0pR5DC2TvV~9y#UR=hJ*9c0;XJ34h-08aB@xK`&n;z{s5aP=p;oKWg}g3n#;5mZ-y+MijZ!MXGbIB| z9E|BFq=FY+K0gCl7sK_r{72Sg^g&@>r*X@>0Y%jLRAaIiChU>70fL+#%iGNoc|YmE zIJ)Nmxf=l#ntw<66(7Pm?j5C#*7Z9O7MfG|Ii9`@4=X7h4GN0v*<9RiXVrnVm2D?% zVoQN+wwHGcAp2Ky4cRaP(tQhOwm=#HE9Z`n)+L0hrTe)Kb?WEdy0$<6qdw#QG=u_? zgEs!M&TE)Wdhs>W-gexDEqZoq^N;wYE`s@^gxiL^s`6R16%Gi${B8Sjn&ZIgm38IS z4cB}&^r=$g7)hSvX5@FoKl|QT1W>wpbyeHkHuZM{;gjQQ=}k5YX!vfm+X@W^7Lxh+3VEo~EX69>48{glZ zkWNX}7kNHVfnm^`PT|FlmZ485Y%3pQV+Bg{+{Cg3yhiTd<%(5*qJ>|ZmQPZ8)e&Pi zE{Ec9!Dud!qYDZ%`5y_4X4(#?(##8|{2P}l#1T0VWgpaIjCfP7zI^$*C?av{!QsVF; z9(nw3A$v~r_)>JCk!BuR8FYlr(ekyruaPG$@j7`C2)cWu{L77pBDV_kh2o1w8YIAJ9*@)0Z9sG6vq!@w> z?aU9ZHN~mhIQ$YS>V|~bgVuD!#m$`S_1%cWmELZ#df`^4n|7o{7#_{?GbmyQ$B*vk z7}Xo`HzXP~B&wkH_=4yJvfIS010j77+}2?`sueh0C(`HdDZ9ojdAdF%9L1i}x5f3zBCr zFv#1zq@`m&sX+bpmKR?>ZUH%>0Wzffv@FXV-FY=eN$-%8-dH-EC8gtJkUb}@^*K^5 z77s-xr{w+xKns8YbZTI6NlpHg#3!s8NrWN0LVfB6?(e{Ym7u8=N#{CEH4~ajtR-z3 z8HR%|1NPI$v)CB^>=tItRHX^Eo}TO6X-NE_IT3~vMTEz|Um?xi9As-N?K-=sQpSeJ z$OM^;Q2LqUy&ajwcxEK=*lfPWFvq9Gvf)ciE~Y+~=T>drW8S{EksHU=vWI z|C`qMBCPrA6~?)beGRgBo=*Kmn5<)q1OUWXMYGvgQDnU457SFRP}{a>rB_zMPdA%h zD?Z!vR#{2qo9X4A_eDWF2tdcHP9vDC)A{yj;8{O?kxC{~>>4LB6rzF(07Gd1ao%2h z$oX_IWBg57_Zk%}78)xaXjmpSeAc)QaC`ijzdAydl6PX1;iN({nvp@_K;7hXUa*I~ z|D%o<1URlNEiXGTX1p5XDn;D{5WilohajQ2VA5|D;Gv*-Qb^1PgFmbO%@}$#!8cat z-tWJ|=5Vdu5B<0RQO+!o)tb@?v?j}ybdSmWHye7kIzG}!5AOibjiM^)zHxHm!2gt` zLTiC*7l(nWRKG8=#?qOzFNH@{F6iBW8zUT_YA;_R^{JYp!BXU$uI_dnFVj`Hv!E%68dKn-&+2;eVSY@0MS+qVBNrs{I|bV0|pfb2Y8CCpN17kYdxy-%eqM9qjDOd zCB)>>Nq=92|1+k#eEQ->b=}PR;tnWfljt^Cu?KYg@FD2$Z2*{C!0x9BJP$)#;n4-; zXseRE_b=~e6#v+@!g55|J<$H?jmi_pxnz3JoxjDcOU`$exQ-3di$*3x8a*Gf=b6#O+7Nq>H4}_OP~ENY`U_G%WvPbpbg;N2w$qF5U==~+kKQU> zmR^Rnat;aB3)M6XJgb!Uqc2MtuhPrrkEsTS2(Vf?pRjMJ_<$%u^eIz{nTzuLg)e`gJV{_~*&0;*?{ zza}%7wlIr)(?dl``LwssMwGnuHQvnaY(j26KycYtu!V(?xAI-$iheRVj)zXlR_D}b z`*JnrqpW#Td#BI3dc6rvmJ{@BbTVueEJ!ss)5%5*7#Ft%d*5jC3_0uC8{QAwn!b7~jx4dHK3cfbIELz+$xoj)zNQ#H4%jX}1 z;tcvMxV-VEg}$k1T`2j)gI}f3N6uY$u4i_4`@eO5S}nq~&13A@SMzPsB*?!{qBQ@z z5&!_v`0rnI=rY57Yg;GhG>ocSO(d+OtO()$PVzjOngww~@^DX1I8mIryYWJj0mnn{ zb;G+LkEi*NdNZZ%`>$$Mw~ZH(i;GqXEhT5e4-ZRC$1P;2LNE$%7z(ntsGs$ ztL1d2-AI#&b?%ye!XXa7>XH|`knY`mQ=22pVcEQ?afXU(Tzwo1_WL?H;rNjqzZ!i_ zYO?(q8`*N2v<^*HTM;IGCkOK6VJkhh@^UuHw`aTMeo0P(n@L<1kpDGTFcI@zee+MM z1Lw^LUaKosxi?*|`-=>x`z-W|8DSTQ{XHtEk{2RG5dw~NEDJG~qpalqhY*IOsIy2) z`_-mQjsQ(h75N$`TaOkj=x6<}JZWEK72eFZTh=Jo+Du-yHNEIhn!?5T+aB9j9v`bU zdR)laL{QUCJ48CjNMv-wLW3|19mi8dW!XvbJmPoo3qP-TuOrN=Iik~y`#IsuYoW6A zIIwc$h!CGlezP;2c?zYYu$n?-W;5F@Q+d@~`&QtDVm>5GDABl z)G&~rwp!c7mr(}~WVuip#6RgVzWu;|^$6Ozu5l&E~c^ z3}%Oi5^}+-RIb+=bYu)_KN@~Pu^6^xDk%GLuWLEd5sCfJ(`$6sayt;wj@XJ#0L!DK?^=5t5A>~3U1lSF|vS6<^7J*YLtQ7A|ota@uWaqVbETUbV1O2Uz6 zDkZo+&>kwD82-2Y;6Gb6^=T|+|4b&bHbSNH3PGk_0e{UK3DA{r0WAL=1^6TyrWN=c z(7e9B-u9KKVm?o2IZ?tunzSWt>`38DmKyo^Gg}$iuM;+?msE>Mx#1%$jA4l^Qu0qx z+VjG-&mgg_vW-F=SegQWuo+Z1UYVovx4feaZn|f9rafJejZYZ?!>^m`B>!m|K^y5Q zReim2SsJ0A{g~8j&xu~M0Prv1=CBh%q&!H*e+|RY!v`FH1o080M@L2`{@%;l%PT!l zmj5uan5DXqjF<&dR0rFf=I0B4DtOggU(TBT$iaVexBjH%Yq*Cdpo?@^l3dl~+lwvjKiqm&-etw%#xU7PbiLoy-iti8-|2;8J%S_nFDkKmoiR^X| zOrGPfy)dByJe}-EZ0$lqLS|-WWN+F+@^rYY)(3Mr%2L^~X^Qv&ylK(fGoxc?l(M>$ z;g0Ss;oDNDoz7qh`lhgVo>YO)yIQMzz71`g-&WSzqj`?`jVlZUfRcgPt@x@pK^-qq zx4TmDIT1N^EtQ(K10@Y!Oa9|w;l*b2ez=t$q@fHN$bNw?FP$GFTU{t^_n&}LNrhYJ ze+{toldzrzaCCS`K}EG1d%OCbw=7ldBtN~N)N$x_pr$vPb*^q~Pvpuqube|*bxL0m7y(%U~zAPB@*J|Eo zEq+O(*-YuOs>mb5Wz8es#r)!zI3bGGtQ$^cKvlfd zTNcuv1={4*ZCM&GKET&HYG*gAG~@_J7b};qy5SL;IDW%$-+UCo%6Z!aX$j8|MKQQ! zdY60{OVM8t9ANBaK!ry)NCmGDUsd$(soyBS?ZJI_a^Cc+F1y9OpoNQj^;^oZ;;@1J za4WaZQPTSO&vjSK`nRsXI3C2!cGTUx?dyGhiNIH)xF}=Y9F^`hHfe?%{%cNvPu%;S z^l;hSl%i3O9fmz&5B%p~R}_!{o{{qW%*x4<{Fp7{&hT1s^AlNK6QbA^mQa{)^)=(F zX=r)kUYQ$W{0gEzf6(vJXRsa6IQ-oIt^wB^#?xwysn8%BP1w^w@xJT_O+?E6mXrYX zquK{r$o#Z$l5N0f)O^&Gi%3;u;&ew-+O7i1zQg#(tF=cwLa9I_4X{PN{b2J{i=%O3 zmHMS5la)YUMk3M-=g+__ThU+x3pqk*sNWke1r~?s2@FoX_`34ymtSpxfNN(Mo&K}I zPID$AkPy+AX6dEF021gl;$(#^c|H$pV>P8NEg`M)((c z^N8as_^~bKuiMZPK^Hn0d8uilz*n3sb*7ZaM{{J%=A#X+b|+^8YRRl3vrjv>yek4O z0Y_b-SeY$1^OID8PeVi^m`{}~FJAG?Da<7e@Qm(y$b}rZbK+Fd>yW9?hVA2M)p)62 z&JESdpW(Xcb#-_TpFPPNVKY8fg9QGNJ0wfJAJ&^Zug95|*RkW7M3W=07uo%waFcQ% zM6$i2bhV-Q^7r?Jo5nboArr%C`utLi@)^jF>dOVrleY^+>ZToD^ON*4;)E-ime7pt*A_xSXXqDa@t`j)c^<$m*CSS2M1 z*)u&^nA~q|oFbPm)6?zE_Pvi5JcEcVo@bJcu>g&0cj6Gy#JsqT@AW$U(roe_86#qh zSt<(CT#rLev6)@?Co=wqdc^x^n8*`BZpKrmvo0~l{7l!+Ukh*GT)P|h!>s5OwVize&|l?dRPClcdtA>y2;d6E*CMKC@)|5kO}^665ZF5EozgN#k_N?xb>^zAj8dn2uuFKQHuEzLdP4D%7Gc(92iGmVapua-3ts& zcvE&_bi}{7XS7!kiO1PF-5o@9p&(^8%y)-{A&IZ8mabFQ;Key3ao&B{`&J>&KA@Ut z!7%6(a_jTrvE$+Nlx?KjHqPc=KN?rV0>hdM*u%Nfx>V6f+ilAs{n;57MTwdBbXGAk zm~{l%4s|O&WSRU}Px@`VZLKyr@%@6)9&zGy=56C~u#=akC7D)KD*_rOBrnRL6~X<* zlqH99ri&zNX9Ipm;R`11=&{Jb7PaGp(i%mS8lAaIrbxTE-97^w2@aRDopV|-XBHYs zWTTT@8CB=$%m9xcI0l$|Ejb{2&~g=!`7O}9hkq^2f#QUjyn1o>eyhN#!Nb*b;`Lgt z9OXZ?xBsAhWJhmrRD>w$Pyg(F7yUlY zt7z0am80D{T6NDyXO2kFIMOA4Q(cyonA79H@^3}}R(--5PhFV+a(Lp*C8@xq>Zw5FViFIk$o zYmWii2_g3vIuQpchKtCJ6K_ljm~SkpR=LK)D3Sy(9pllmI80dKhF!LBm1Wy@j1oeh z7Pf=e4JVftnfKdRr)S96d_ zKMFaU>#d-B)7$g#ODQsPHCZ(v}o_4_oa3tM7}_!nLIFGNYoakda*32k%~ z4tP2BNcr%W(pr&-A8E`*ZbYHqjxx(L#c+jh#Ky5mY4=d?(|3JuM7CLZGggM`IR#Ao z`rf|PYxq#Ich#_+S+sIdG1_2<2A1esQKmvYBiBBp?+C*Tu9ta@sC{SYYbodc>hM>J zEH=OlcxT`zWyuKik7>0D=$z_T8WwKMq&)e)#8j1{&?q+AJ#3IaTxfE;o>sdrKi_L& zj8;2+Oa=cO9JoyUgxR=U;na3jper!3_-Lv36X9Ym(nfEIhuxgde_sgClfY|Cul^{} zwcOd@av(5PDJ=toR}p1HQ`P@zxKs@cWn~~rSS`>uy1k;niZVMA(??HfMy$SNjs7;B zoKpLP+AZDhpkdthP~O;8HCzvzc0Ki{92~0`@Vgg}ob215!>*>7w#F2@(A)dAs{RTF z;sb^iggK8AkLz_R2b++_8&8lEqyM$|6%e-a^vCY!B^z!aH?FYzag;TAv)l1--NCXS z!7I@5N(&jWHczO%`mAEl-iC3eotLZd^+?&L>Td`aE+C#WrwnGDf+6H2FC zf&C5fxier$T)9wWi-tZr;%axXca@{~F|3XH@pHDEbJqE=0tgBR8C8oDNU*$N}C z+vC?*M9_qfUDouF&&=bK&^l~u7jQ0gyPu_NJKOo)g5~to@wkY!ROK=I#FE`%3}uzA z9KK$tJu4u1U1=wBQdWj08xmFeEqttk6$yisRB~6DqS^(tLpz~O;9O9wZV#++xrUOV zjO|Kt=BD=4=Vxf^%P)w(GMCSfa#KP7$djc+GfM0Qs;J$};c?MKD;oOYf&R4cPFf|P zL1OXLlH}utr@p2+xt?_|?auMb#KMU2g>-6#($wpH2YQ_~RUTU2LSEVEb{C(?Rle0| zS2vvHnKb^~n7mup)yFNUkX8Jlw%6ZaUQX@|D+311!57xNSA3t(ht z5O!aUhm1~N)Dk^&;X(<Pm^1BhqNqve*vSjG63Dk!P*JAy#&_aZ*GTx|SD6*RE;M8B$RzT3GmOt` z515&ART}Pmx*k)Wm84y`^;Zg-VJb-eK}dPhI#r3`_1xE42nbRasH-D7FFU@PkUkJIe4Zs=s|Kc0Z63+hTHx?T<1-@dGVi z_1Z`^k%cOlXq5!y?iB_-i2>xqRkDbcz#1B(1-Z_rqs5K-S4j5n*H;NxyRPhK-?~3f zRXL#}m@ra1BEj?Bk)AB#8Wudq;91DlDR&n70F+dj0KI^jciwxSVS}0Ok=N zVqJ{9fy=F>RqW-MSE>Xd>m-;pQ()P(H`AVzjA}^+(ohl%Yfcw6wY9#bE>ZohO?|>H z)x(@Wg9c~~EPjweX&1IBKXw?2Q zOW8W8y?17ikUg|)Ibri-KqSC5Z zL;%nL!~6n6;jh;P1OU5>;;74kP3&6yX29vmA7k(D>SKJt^5a$S!{+o7Eh?>QBwnr+ zI0_s4O_t|*&b_NKz4wH1K~V-aUi8Ju^*9kybP7V(nZv%gW<3aMM3v8}wPJaH+1Hr& z2)Ne_616e+ND)Akgde8_iDK|66QOG)tA%}($osUY3AWZuP-x@;J7i9CA$~hl#oQsc ziVz>~B=<_8&TnPvS(B6-pQpx5ozxlU1i5`CWY&+XgDYNBPt`*!X z8c5;Qv_JJh<91@bUvsv@`h);{V2@-;PrHu*5vrN2HTW~@^|f{`i8SXr^>Gc0)y`}n z?p^Gw!F47+a_MqHpi}qEHdzqNW}FG=FLH^o zx17;MRZ4O9nZ=PW%0p2^!G?9~DV6rYb>LxQz!A5FF;Z04Hzh+464=hzF+HRJStU(xSUR@9F!~l27<{EbXI6{} zPWWa2uK!a9DJ4QduOJ4O=IoIWuR1);PlTxu6z*^L%>)!8iD`;W7kR?BkSgY%bXC() zCY8c$Bd?hog)^ZFJknKjKa-y_mOUN`^Y!I}G~nYuSR@Y$T(6oV{X{G;;+dS(X{#E1)1NZQp6Hl{n+I|1?W{*LTPq*3c$aS@hU;?yWqV5=-uwE==SDigT_5L%t?gR?cD4lYOnLDl zzQBY>Eb_tQ+2{!w`aPuFbXh)pvVsOoJOC4yknpnDPU+&z9_Dy{H$IgMHyv)7jx{z| zlD+S-U7zWBN^qj($;ST^mAYPk@mn*fBr+6q@3y7b__@m2k8jCzV{j)U$BFABn)eVo zLEM(u7`SsS(BP6Zu3y4JZ+VM=~#9Iu%U+K2#jVVA%OxD}#=SDK&YO7Jo z|Br7(5+7KpK8~5alZ&1uHE-~(1fGVT)hxv475!u5HvPjckoPScSy3!F^=yH@F!jr!WhP6_r> zaJdg%H&N#lbM1F2kyr;N#o^|vU0L?is4RQL7mq9BN^MVz+Vmsv>!a+Kiu8rn zXulg(i+K)%;LT>d1leKAh%4*j^A+n#o$j9|;%3X>s|`+tH0oLocU&q}D3=V+_SI$s zN^vD^AP(;WSP-omY&pV-#=iW)-sThP(1a()b>J|6Zr( z4Lm1G7QA#_*Lv>TECa95>QreDcw6FPT?jX&=wwSN@{Vc~lf;Qiyh0{}nz`~BW%ZuI zO29_#CXI<Rx2&`Z&?`Pw+GWZQIdQA zd_sAFz9lDy^&%XPJkcBb89wGqvWW@h!xl#3gv3w2gOKY#Z<|AShZDAam9*TfMq%K%QN9FVBAg73=;?D#`!nLUa(5=F_KPKKiHc$ zQP(h9a6R2z0rMBMiX6V0s@CYriILai8TV$>-S>Q^6nrrc${5+66y2Iw9+CX^SvQhk zM`}-9mP6Cyr~!EM-;Xi$t zw9y)xwgyvRqy88HxHdUzSkvSg0rXTzSYw8T(46WIpQE-%5t%+J?BtFj<*nzn0Pfx= z{1X!Aza1z;lJ<~Uqt1OHJ)xf!F8{@D=I>FN0*8+p2czV)MbNk7ddCyd%QrWpRV+1# z@qo5oFa@eLzl-Qqw;(lk)zZH*`aL;}+{IZ$zuqZ>7y7Qke3XC)-dr+9}nvq|v<*wlnAkN=}kzgig84eL!U21Q|Tx72r4|H{A-4K5X z9QT;1|1xOp#KL7cwOWBzr0VoBT?N25m$Sh(4xh%ktF{D{eQyYoGlU>}A9_CygaJkF zU-|njJDffUFaRNNd5i#i=D(~&%dQx&6`qtZOlM_F6|>mNc7EeT=yZ%O z)}%6wydwHbqAcF72=3FCQI%MK$z-v>fKGg$FG~KerA5JQ>0-1qsA9t!gm}AW#D7{Qs}G4 zb3?d9T0m28K9z(XcMxLMi$YAV`pOi3D9}j76-W=JPT>+%5~rwvnasSw?a^Njq(&w72UOo*Cm;oRM9Jlg2Eem|aL z*4*^b-=In!F(1wcPeC0PsvsAsc;vhDW&B22nDH2V+mu3HJ(zCB3ba|nz-MUyzTZU*p_vTFb1$B4SXKr|FR@Z z;4;EWXlrsPdoMlrja;|%u5~HZFO#0B@9h2O$ImA`v;D!2t3d(}Sl!mHZjKkS*am&W zM;Nc4=*QwyeqX@fjR^M|97Zo{{af_N0FJ|1mcgO--n7f@ZgV3f4?>4uoAdRde%<3a zLGfhl{X5uO4>n7w!S`!|MSjS@qvcHE$HMQhGE2bH9Ml=9rJ9}P4*hs4feYCjj|>b1=}v)0&5%Z@g;>t=j4hO!#~Q2 z%T`c$v@+7>rr%P2Xjsci@igKQWQ(bRBh#x%(Y*{mz<&}&-QDH1knP;W*xL>AUr7~K zq58N}jM$T@9Yvf?R_rBECV!{+Mco-}^=9lOfn`Fh#O~v|l%bytN^ia^!nj|swwxk`j zd|(~881oH_Bdd&ObF*yrUg=6VkJhfjSz6i@(c;nDcUh0Kwgl^f0o@;!>tFy3)c+Eo z13Uu;rFHJ8Fvel+&+mmxlppmZC|IyZKP(K~CJHOIpDacmmNe3FW zU&nHV_N?1TDaRgjJ+>fXuVCc;3QnZTKad4KJ)gbGky;G~&dpM`$~Yb>CK8 z_-aND#Ft zDQpRC-Ui^nVNOt}`sqKw!62EWGb6VpGH4+b8=;k|DG--d|2qwgs_` z;2^KdWm7~vSG~;e=gwMB0JtYOm5KJhvgzDi6+RN$RgiRza~_OYq$Nh;*NLAwHDnjz zPxaxxDmxHmy_x>&-MN5Rq^0_z*gS>62MOz)*JkIq~>nd3W2>Fe@}$@wDR%f1wpy$ z&zINSQ15L%=B)0Hk=#x^Ax;a}L*1PhVC(61(xHtIc_xnf3$koZ>i#Tl&Ljj=YQ=%I zI>WV`WA=yC@c!BTchn(N!(KF{fPadzZF*KMULP-rpeoe&)$;H#dX-M-dj8Zv-`D;M z&5}00y&TVZYaTMAckGbv@h6a@-y0s_0Dwx!RaVRXX7CfQ{U~J^$V*A0NLNwoH=j-T zf)~CNgLwWnp4}qcx}ClV+^nERAr$axnnnHf&t&?7A!%8X#`E)$b~p=RTtAY1`pIHZ ziYxwTxo0{pO5||M*XlQAhD>mOYab$;EhHE1u1RsC@W;u0S)0oc0f9(H_p^TJ7e!gj zA#t_Ag@grN3B2?KPCms*-IbqGg?)|%!4WTN*|d1)v0&d#NtU zf7JL@E7#y-@64>g7T~8WEpri-(aOxtcdu{AVWq%_70*#L{8tA%eE4YHFLL=(iON+1 z$B_3@dQ<0R%=%0uYKS1)y=D|D+O+Ou(3-Szt1Igpo@z-0lFGKLImG*v{VmPRpj5Ft zuujMNql)ubiPC4pWZ-trkMIY{1c5cy>SG;=4WtHfE(9{e>kQ>vuR{{!h75c+69&>J zVb6=;00a#8`n$u6;O6?%kg`q3-T8)Ks_ZQ(R(uKoin+j#V)G2~*uGhyeix8;!No*VMMF5#yJhw#oXv&^ zy_W4fD&sJN>qxF38$LWK0R{{`Uvc2fMy2%9wri^)y*BP{Ix&Q;GF{;QP#>&CXdu9* zcWzH1#}rB?x{?87uQ~L5D6o(IIHRy=@g~pX#v6zomdYPBn>_AE)92!wE3IxH3kKb! zkS5#D%l0ea5ba{atcI_rsoW&?mHQE+`m-%JzlG2IQAUyyDEz_1XsnMDDsYd!9N?;U zBqlTYZh|z5jYHAPp!oGDS;%AXBr%qqHO99%2LBK)B0NWq?KRxZd;J>t9ubDQ5$wWI z#IY=lyV{@CI@r$fpF<4{`2PeP_)j5K`kraDXX}Bx%lUB};NM%BJPUX^RBhfY7F#mD z>vk*O;iYX}Z@_G6n;bt`t8-ne+&5u0_ssYbJ(>eTAD0ob0(x$@C|1Al4m7W2RD6M_ zT&Jb2Q-j!0>&idEiEh8F?E)8#eAg4Zi(d^vG*678k6J&z-acYu&*+T`bc6LwVcaE> zSSVUYSk6Dgb#L9cNfvL+d_D4!gXSO!BKp5UkCfZE`{K3sv!NlE3rb1KD8O96B{z@% znxPRQF?gx+y)c16GrZ$AW2a|mm1^@8JUpwM-BjRT#HrH4A{o`=ceB2Jed(oJ-|BIoNi;p24Z#l3C#UE?eol2QL6s87K9XR$}IGp~QT5xmv8 zr|ZdWFOXIlLb>pJrA~H?Q|%z2bKuQrt@(mI^Fdz~|G8}k@1g~zQ@{1WVe)O3$ZR&V z%i#h#QI4%HVIhOe42sZYK>B5fif@o=!KD6y@Vvvm=5&}?3xKjYH9dMLj)v9L%IZ22$BABAuw4H->2Wljvh9eS9w z>0h%&muHoMhp?1Gqbf~&_sY4*t_+xSEtm|)0R0ik4_+l%rnqH!HI7#4{mzj*c1kLZ%v2Okok3A*hNkSrS!QEfVJH%> zgDx3vD=+P5Vb-Zd*c#A8%GC9KK4WAtp{^LxI%)CYb?dnF*>BXFdk-9aOwx-i9yU=v zh&%DTqeB>TL?HuNo9J`T;}Gk=%N}u5Uz3kXNqBX5m`6DsNHr$Anai87E5}iQD0@hf zz^?vuGJxof>-+Ykzram{dbBng8i;2i7zt?GRevectvJL3J?*vV;sGzD#*=<;T(AHS zu+@U|_u=QIMYQ#^(922)!o&TyuRF6u{8RW}4Y+;`t55~0vC(Fl;Nc}$6iawW9NS!y zFQ#_gze+)-P5k*=Nen@b!yygiM!^lL`*m_k6Y!Kcd~RzQ5ve=fuBcNj+P!#h1>&M^ zER}MW4;sJbfBcVKAYH$Kcd)>#dGG7UrG5q8tSqAp3$(~2!gkuX6 zoa^?<&>{j>LkXs%FeW9HB%C2+?L+|owI1KMAD|gR^iCaMsUI)>ahKa3Dp+!o<={Sa zAt6}|1yxleNh&Tvz5d`F2k1aZ>ov!UG1cw(h?%fiLj9_E^=WttZwWq>iE^}+#SDr` zv9C(fo+NJ@f690jZ2K^^nm6D;W63!L2E#Yr4+s_hV{nB(MvGGZhV)OV+?Q>_6m4=W zAN6vHh#m=&oKmXc5>o4mnA+&HIX7N!(k^7oTtZuj>o*Pfvd8AK4=h7(O@o;oXEH zt9Fkjfb)?+0YcqmX5kI*rr&cPam1O;%}2qFNnj&C9D2ZpSLJNh_n^2$-xLI}xVNWwKH3U;nu#%6(CbC}L4tPB`~{P9Dc- z>W!D$zRV;l<>WWq^#{mVQN=V!K0=iWM>yprbj{sgFYi_X1RQWsnppyFKE>ekOrM}% z>#n_{=XN+>--Ikxk3rrFQ%a-L4DVU~Coh*?f*LqG1}$w<6U3s=FPH$kc%;nDqkqRc zXhV_MV5i`g!j4WKtxjw^ewNk-IQngg=u5eA<|1}7@gt>#MDMZZW=tRKEW$ER{(*W1 z@ZSTl!9mQg?zRf?aS9)JH={cFR!$F2dOQzoyXJA)S%{Du(MS-vg&#Ns*)Sw4$OKWW zOY9jDQhN7ewLI6e$T5@EWG0V;1Q-8;t~yeSD6+qjZ-Y*{QBC^%Qq5Y%T1`RS-et@- z3K7pEpuP+|mMl_qTj+_GsV&D1YHJoIY^cYNe-L_#GqIYNxH-d8@q&-zwuw$U!N|rx zE4}_x8~*Pm`9H|nCyL0GuLx;^i>!$be;g~;q_|?km8s}zCQ=KdTBIw0nO{IHiHZpc zrF!{=7!y<3un|kZT}3qrySPzjCLlt?Q>USa<0@~xv;~eJ6tbCcoDaOUlGd-l(`mxCLsIu!{OO?gfyzHb*P6YpH zs%D5f`FAaNfdBKNDJ5Y&{k(?q+9cMWpDDzL=WeCM+;Xnu9-k>Ow#V;Epb{Ifsdw|2 zggUY88q=DY#GxXdD7M2^;}%jh%=Hm?+&%`iANRB017g^Y97%_BzDeNZryLUY{g0*oQ`3nR>Q@0FfL)lQ(e35OSF{?Oo-cdix<#$b9%gc9 z!jX(^_Yxc1*0Oc<_UxI2L(_Z2c0_}AmbF8M*>^|3V=^iHgaS^>JB385K8Mz~%+ydz z@#pYBX;KpK0xvOAtZR%(B`e<@z-kfv)_1U5UQI7v#WEsxc4|zxz0rB|3dT>loWDWtLmNgRz!-TEUDF0+{*RFemBw0hn*x1aUI9Kaj19pgzt4O z+lg58Y*`ri*^QEnbfxR0QVVWHN4aZzuKN=E%;>AHNT{TckBqI*yplk-ego3LwtNZP ziHkkqR{-_Lh!zZ8xcR$zdWL*=fm0@1JNnQp=DfTmk)rC6$_g5sZOsQfMk_^X{0fgutWAT!8gRe8$9lLqY?orcejOYc zbj?gmnUa<~3QTqo1};>3n$|kkDZ5JtQ`fqI>%QSYaD^uIe(6g4TOB}86TAK_F?&R-Rcs7{rS{Q|40ap$W)ngD_quh&^|^R=ub zbMgWx&C%qaXM=p41brD*|A_qM5LivE&*Gz&^T+@;a91P9fq-1GS4+m$23$UkqBH;O zx7+3|q4YKyo4^^Q^eF1OVpazt2j8!41u2oA|XJ#(P0o%06P9iV@Y6JGW^xzBFeA; zc87PQ z=h#EK1ojKv?pcmk13ULG%Md+m)&5kMV7#t14WiDr#)>S;^4%|o+ zr&=13qsbfFBj&^Y4d;AiLUCiW>q^aY6N^DA$VM=b)c*)HKmd48X%FO0lF(&IGeMZL z^$VAQmPRGiCTMu{(^E#P@7P!j1F!eER+3{I4U^%I0V_KWg!uJ>t+l=C?cpVRB*` zFJ%qarNKOFGg6;c2SQ6L4~qg8PJZo5Ue{4r-kj~t2{?9mXLYPNVKLZZ4$m=V>GS!X z3|d!6Q^iJybGUY<-@iK_F1Sc6u)Qu)XWT~9bN=q;m@+jz3TXaQ9!I{NKq|5{T>q=5 z&5nZc@YCQpb@6#H_jRj6PoZ`7$9*XlQG6!q4uo-%gK19Rr2`5nTPontTRHt_V*s+G zk_^{~d`Eenh<1A0_x5aa#dsR8oJ&aK9|`?^4nBiD%G=@QU)Nvx_r?iS-F$9B%Wveb zFCCpqG}z^aTiakBGlM)7K`9EtJ(5eS`LW_9p&*igLy!MzniP1a-PT7WZb>Mu<8o*P zPYqJ`FwcQ(Or&Y?xIa$4;kIuvI_`p`NjvI7?OyW-mw@>T8x@gNlNWmlKtGA!M4R)& z5*~oj1v;ZI7^zWLQ-je?m#M4oMbcs3E=g53iAcl=-L-olW8rz>ZD%iAaT1a~jUjs- zg0D$r4_)a7&-4?>J%6S@^{n5YKrlA#`IEHIu@xp&m@;c+o?a7j8)>8iElleRzPIl| zY_#!^r+Y93e+OZ$cNstjT-E5jYG)(n}@F zMwr1--(U59-~fZ^i%!hdtp?SWQH)BETt-j^s&X^j=DG3R>^5@v$F?5fsog7EC*(M&z#G5`;3q~F4?2nNUT2> z#Ep|2knqM124Rl8IVS0_Jnl1DtVc#l1B5|XRk8@*pcK%(iUR)O*Z&1AvP0sM0MP^exEkc& zM}(+O#SBa+gTVd%^<2;bzkxJ-&L4B7s)bU~pMNj`b>Q^|s5MGxAWqk&967VKcEs9S z>_~NgE$%+eygEI%nmqNnFMIRNN#VC&*#4XzOvW?{az>#~B=aXaBt9UAVr^=m=XM*L_rArW08<1pDBfc`_(bQ`}vysZ**C) zGMzXUqjMS{;z|kl?Jq7Y{Bk;y@s!d^Ufu)nl49RsKyOCSOQ9JnD=V*@cz$zdZ9ZpJ z*~oP_bG)medMDbI)du+LZ448b1bR($e@tD*9^mSJBe80R*KOK|3${?6E6nVzhwm=h zWA1PxT#>9L$MJbIG?k|dD9NP0<8HtMQaW_gk%hj{cl5xie>ju3@hW8tEV4C5=OMnJ z^jLM{_3v0E&XNp1_Hur<^2%C{h9+*HV{tCyTeFYWdWN>8yzpk*UhSD1?LzzJNmeG^ zISo4fzm7Am0ADn7CLHjwJ8@t6F232fk1%!Tr>+t(C=e#_U?O==_A__|HDD7KE`_z0 zTIHY%1PWCC3m0^Sar;}uZht0kZzm6`Lh9d8^Z`^~>s8PJyg(d1szs|*#X`UZT>KB$ zy_q|c;ytM^1yI!xYLXD9Q?d=cdDEv5Kp>x4UhhQaR7wSSBEwMHJi!S07ZTW62fA#4 zL589oQuA7*#UJ5 zzYGW{Aom(s9RF#|+(UHSY43B_vp8yp=c=tk z;uv9Y|O zLF<8_YcvQLW@Jlu8($r!MDO!u8roSJ4i#OGOCgvsp%8FNZL+23u0ESwgF@`- zdy`T)FjS#{-Co$YfB*O{-U0J*;{Ob-4}X-iSJUS)KV1_njP;4!jJx2-!n^Qm29VIo>moHf9UAdne|& zd1syU48MMT`>C)iFJEpHUS^1G86I985!r@HV(q~`QP0)9vz`{A$Z9DZ_UwK0*njrS znHyB|)}K~O&enBjp14>h@bQKZ6i!b9OWzuvMhZbyr!f)}Y10E*_FuEzCOr-(*zfn` zSeq8I)piwFTYl@s{aZ%vUnKx0I9edEE*h7FYg&ly91e8(Qhn(0A0i7AItE)Zw9Lb# zyKfenRwN(&5Q8VJRmnU&J%Gv_Fvs9ApRuPv-f9+r{z)zi|-KwvM88(P)!m zhasnj1B*%Y@90Yv7DM3Bfz0p%rL+*m*Rc@BF=LkZrdYTXrRBZ7tkM3TaZ8R=6V-~Mo-bS#UcG~fQMT|&6n7#d;UA^X5#pLj2Cw$W{o|p3e zDx9_l=LTiv&e%+B$9hEWLg=|$a1i9LGuk$u4?(N|E?WSkfgAr%{Qqzn9jDNzCr>EA`dSlOeJgky=P*#@b}7=+?*!Z0``3JnFC8+F^P~PC zttMq**>QBNzIpYY*G}mg`RkN`i;uOThje07VC8DD$|rl9n;l%pflFM(i=61LmB@db zMfk_l8Fz6K)ENWZ)2+_%eXj}aQ0N&A5*R~JN5WnJmA4~{f~cPissRm74^%3Y2K@tc ziWm5GIRx=*rxYq;X+8obor3@@!*o7;2Gms{S%1*BDHH_6isZP~1DGGHgf_2EoG#l! zU5sDZjV29K#dImG!8dXdupS@05EHoleG~AKMH3AvMw|dnV`Ko778&4TAu$RDJj8?J zx0rEFGLsn4-PpV9Oaz`?06J~xwnx_C=F3>M4tF$QN&4e(o&lXujA^BLk;ce#(9+%f zy~Y$lgh?q`yh34xn5Pa24I>eQ z0UU_#c;vWa(w&8QeUX=_f05&~Xmz&zS5+Bvii0o_l^z#oBE*On(E`6v6kHpCh7l;F zV^8YU(!hW_-CE@UOR?FSP~>;e6hOv7X04O%HM~$LO84b;00|$l+=PYd!*Id@(?F{n zlO%Q0y}=*+GjWvtzgp?PiQ~WL009)9r2>z-tBf8sD+6n55z!}+_or<0^G%Jq+3mvI z%!WzAW_)O1lNlby2T^?ume~C@48pH9gLLY8J}#DuRYY2Phou#6z5fXKjFF=3e|wDnIFQ!vZtU<>Z9LX2^*0-67tK5kV-t<#6+UyoMD>vZ=c=2gZUa5hSI&uEXfM%d z_Nz*-HPs76j}1SRRcVP9sZOg=fg`)`gSE)^+A*l!p2Hk8pJlK6C)XP1s#L{SqO!En zpNJLHm-$oC)ymQHG6vFVixEVlCcJ3Fi~4Vf+j0xPN6FbE zl5@JH!-C)9LIc+uaQFpp$@9f>lG|!6;C|^F0Lzqpoi}CMD3LxDk`KE_t1;- zX|rC1`q+!dlZSGS?6>WTsLjwz4IM*?X|O@yUu3YrE@^u4>k<(f7&Mgyx4goZ!l5#5 zacHUh7;v0~e1oG6JN+$D%h!7_oNS_NhxoZ)4c8VoB_v@ca)Vg!oapryfgBe(hvGKv zDHbz%o&!sy0_MFIm`qL42D82RWQm3-)|j^_xbB~PGa0qRo|{wE(w@~)g=Rzs21-I^ zwkPxCeaW^_vgSjwOoaHr1U`QeQqwf}l7-(fqYG3y{`-*)5N88aKfoUmP!T8KjDz?^ zy?ncUKYU*nD)882nGJrPUk{{icKg2F-^|XR*z@x#QBa;o!fwK^ff3@hu~T`v$zA)0 zWFQ#WratpCD;;(5*Bx%`bxEsJ3#4;h9p8peS}OhG_4;>Gj9^58yx(d64bv3%41=+_f=*Vw$BCJP)I)Cn~?Ie z(J?W1*Os*S4o$2-TnN8jLuE{m(L;jUOv@) zfBtZ;x0$;6+;zJ#)P&Mi-Sy-){ia-z^ULUM@8#MnS9{~_&-43YGKw%`_XqSMzifQRe2M?~ms6~4g0f>_G zCBIZi2-&G3sz*LUQV1S$SaS%sY+@L@87Q`Q$1<yhSBSY3H>Jws7lMFY4FB#qrA zW!Ei4!aWs>DdeqghF`2dBO|HEfR9GfblEB=(YF-Wj(q6O?JN(hTR;6b$M}is>FLVA$Z-f?(>w%`6l zSRx#DcK&+1sM(xyk|1+?L9&UO@&U(XZ55cp0KpECHuyg|jV_!~7Vm?2+k9S7n2hRv zTqjwHK(4KMZ0}fX?U6X7faB`7*l%{ZoM1adQVUR`yulgw6M+gbKxs-)*!md3U>)2Fr+=?t{(_u?_jy(2rKBGyHDM1 z1j0ew9nQ=UwQj^ys!7%)n~<=I9W-=|%WR)qPgb{7d0`P}0gV*SR&p4$BUFCt6?p;9 z;ILve=%jvXHjrY9WZ8zZTo!H(w3+6KHVKS3Z<~1CSzh~FyqkiaNSi)G-Mi+l8y>kW zxD(qgABQ7&T}AGVUG6sSqAC(5GVbq5VW;WaQ0ys~6NW!X@$yq~ds8j!@$N<^-33b0V7Yoemw7t7# zUq>~}^G@FwckU!c+~3F>zBTShM69a$3sN2WR$DV*fpLG9!I%KI1>k)oM=-G|U3?8j zzP}Bj1u;_o3a&vrflYG(4^J-)U7toV{|5Cehtm@%NWt|AyOw)(2VdK#pgLU@i(IW3 zy`l#^nk6Sd%s+&FkyI0xFY=>0H=%{%09Z)R0|Ay++Gn|eW!WF-zE1g^-Xj4TD%e3N zuqyPWKDZbAUaQ4zsBqg|BGbmX;S*Y^Yqp8QL<7vl!~rDVIsAV|X$2w|;e!wfLER{R zB|$>z|E?3hHtmVoy=#LR8;^cq0E0hCx6ILbUuPrS$X5%opi#8SP58k=8vf;w)_2rE z9<+(X5cbvpI$ws|3AP@b`jP`kSym>}kgg#M`+kYoU2cMbNZXIiYYkUT`ELT4eh^fe zzYl>$?Gj|{dO_q$34)2Ud=rr)B=Vzf84w!0?8*Z6)|OLYWUv9h#g8Q}gIm&(+~USb zI2ZFp6Sn`th!OuI|EZEc?19$f`4@@=M`eRfe(DM~Pov)V!1TqM)P-de*q?l-F3S() zT$Re{Zws3 zW3$W?M!#FU?}D%9%H-tXL=gPpt`C0j0l0ndHQ=BJaUl;byY)X!9ybettf37~*Kr5j zWLGl>j7H?j_In3aty0qq*4)+7Bum{^PJ8he-J=~p+ZsE@42>RYA-3?n7@2IESk#%=l)RR*5T2 z{QUK&Pk^KHa=-$rPfW4!cAvcOMp|MoCY(hHky{Cg-kZGR`?H#P(1aKN7vJfGwPDq| z66jO8f6a{3zGgpLqOE@EC3x9#+spl6Ne(*??E{Ot@g)G5>iA6U|D=4F=8~9YHWzvy zRR66t+c%<0J!`iWc35Jn}_2L-B&kWAR}c%wl1sR}Rsy3KjP5m)rI zvdEvlZ^*A#E?D|9G5@b<$-AUfNB_MgJtc160&j72)MlnDuG}Igo~bpg%!OLuP-J0} zE+IZq&mVo|wyBG7G0E~5v>ob{TBfnz0LcBHD1cjSx;zkB?H`R(Wrwp=1ylHq*RV{> zkuOca=ZTd)ek^@2FGQMm`53Gcp{G+W2!yXIO-VLVi~hdoTLm@-B6v0iD!=KNcuHs| z#X?CX+ZF-^C&j^f8c?!J#mJq+Si5BT+;>S4Z##8{EsrYlWbim{92BdW->7{!x*4mFt1$oIb{ z{QnEws{qC=GJmj7B zv==@Y>9vxkEcuH6!YBFpb0%mukvhl6?ApV4D}6R$BO{*gW&F0Sf$eazvuo=t6JfKf z;W69G1i`5fhalysLHMcG{i6Esocz6~7sOikHS&u!%BSb^mK!;}rX*(zc%VOd`FmY0 zZo*z4jAyPk+j@Du2WT2q`De19kMYhyBf}GOJI-M4MZWKw>nqubYBp6lGPK4yuJm0C z9~dQouN=G1^JfqODZ3&9(YH|SiOGpbMvwJw!rrdKk#tgiC)!k9l@qxS2tj`y&bhx2 z-%KL<)LqO5zO_H>#F526ggSieYa3>MO!P7u*)ukz@aq?y+s|zrZIcK7p<5;ve5o>M zfn&A@Z!-R10~iv8E9O@;NuKlZ@@yGC`4zwTN7~?|ZT?bJ ze|;W3oHlaigdnS+kL*G0CrbOf?hF-#F|Pq{%*PP2amgpu(y6VRY@}+aFMj_D^iXhS zOjxH2v#-VM7`?lB)bVt<#XA}$M)TK=Sf{fNgRT#y`5uOsUx9bVhKdTvV4ba?uh{kC z;@2+Dq6W9?5q&`z+#oPQZ4Hl6n5IBe z_BPaTy>is(KaevQ`Ovx$Y2aJG5&hV+#&bOsix=R7LV8_k139vv+f*|&QpNt^98Ji7 zE1>9O0Ru5o(3gPk`^Q^wkA!DNNMx3T?BX%nWOrSfv*DMaR%)e_aVa?`1v|9B{M+0umllsCNYrF}=^E4m-4>awB%q6t}S~f2+8*!2dC3y23$hjoT_x8s;^F2hL%#R93=W@I+m8uE{L4Lpj zi0H310FTGQSR=2!K@RuJKmHp#hBP;?>DNjlFo)8o^~(fJdIo9JO35dt5uFhI*w3qz z0jH@}6vt^Evg709i>?RJ+fP!6r>CEw;d}3`t^D9fF>UX6i_@~!+o;i6s91NOxd!nN zr3?XE^}WSfa0p9NFK#W&6aqD1?H26v;>8pR>PfHJ4go#~=&No)fp9>uX5O;9c!vn> z`Drm#+o@Ncm#owCT+gwAcdCynfpJ*8K5Je7NV|ZcBDIxDo6} zD-n7GMTzeU@~1-D3?h+BFf5IY>*tW`k&(C&`l0wMGq}eiSg{9|)FvIZ2HXq34Bz(e zUH6dS3JKaqj{F?J{dd2gayD7Um-S!_!(v_H6n%OmK2Zu~&;RKR<7j4m0Gh*>`XL0ipr z1m0^cen2xHB?st!n{!Wl6z09Fjy#LY!DsHE@Jv7-Q2X*dYdZjHbj{(E771krk2HFK zKuIeb*K^+QY;QokCv!l}fbO#ifMcxuU89EPDHB zpc&2zu#HEoyR1v5AKB@0aE6uQexRGNYE()^?hkr>>)b>;B?M<5-1#zG7U|>6C+y!p zVXXh1tfg1k_>~X`_eq`qzxR#*i(%#S+GQ&g4~V?G_SIJk_h^+ci$8l!=Wlm2T5DoA zS)Az4=HG4caWYxGFFHDE)N2_%bDiEUcZziR<~08xU4JnCWa2$_wH1fa4<>MKi3#Kg zu%1VcSe7VvvN(C}=&>`8d(fM0oQ~cc8U{nSPxH5k<7~_`{|$#7_Y&@ST!-D2zDTxN z)=AGgDzAXhC@ln9%Xt0vJNip6UY4&X7b^{NQ&Ok`SqY(n2gx^y-nRN;g

njg)f! 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 VspTIZo>?P5qNih|U7_U|{Xb&gT_pej literal 0 HcmV?d00001 From 17a1f53c47c2b6d846cd4cc928428c2824cb0ce5 Mon Sep 17 00:00:00 2001 From: songyu Date: Wed, 6 May 2026 14:37:18 +0800 Subject: [PATCH 118/139] =?UTF-8?q?fix=EF=BC=9Aopenai=202=20kimi=20error?= =?UTF-8?q?=20=20=20Continuous=20function=5Fcall=20=E8=BF=9E=E7=BB=AD?= =?UTF-8?q?=E7=9A=84function=5Fcall=20=E8=BD=AC=E6=8D=A2=20tool=5Fcalls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openai_openai-responses_request.go | 68 +++++++++++++++++-- .../openai_openai-responses_request_test.go | 37 ++++++++++ 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request.go b/internal/translator/openai/openai/responses/openai_openai-responses_request.go index 9164a4116a..15acf7cdb4 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go @@ -57,7 +57,24 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu // Convert input array to messages if input := root.Get("input"); input.Exists() && input.IsArray() { + inputItems := input.Array() + outputCallIDs := make(map[string]struct{}) + for _, item := range inputItems { + if item.Get("type").String() != "function_call_output" { + continue + } + callID := strings.TrimSpace(item.Get("call_id").String()) + if callID == "" { + continue + } + outputCallIDs[callID] = struct{}{} + } + pendingToolCalls := make([]interface{}, 0) + pendingToolCallIDs := make([]string, 0) + awaitingToolOutputs := make(map[string]struct{}) + deferredMessages := make([][]byte, 0) + flushPendingToolCalls := func() { if len(pendingToolCalls) == 0 { return @@ -65,10 +82,40 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu assistantMessage := []byte(`{"role":"assistant","tool_calls":[]}`) assistantMessage, _ = sjson.SetBytes(assistantMessage, "tool_calls", pendingToolCalls) out, _ = sjson.SetRawBytes(out, "messages.-1", assistantMessage) + for _, id := range pendingToolCallIDs { + if strings.TrimSpace(id) == "" { + continue + } + awaitingToolOutputs[id] = struct{}{} + } pendingToolCalls = pendingToolCalls[:0] + pendingToolCallIDs = pendingToolCallIDs[:0] + } + flushDeferredMessages := func() { + for _, message := range deferredMessages { + out, _ = sjson.SetRawBytes(out, "messages.-1", message) + } + deferredMessages = deferredMessages[:0] + } + hasAwaitingToolOutput := func() bool { + for id := range awaitingToolOutputs { + if _, ok := outputCallIDs[id]; ok { + return true + } + } + return false + } + appendRegularMessage := func(message []byte) { + // Keep tool-call adjacency strict for providers that require + // assistant(tool_calls) -> tool(tool_call_id) with no message in between. + if hasAwaitingToolOutput() { + deferredMessages = append(deferredMessages, message) + return + } + out, _ = sjson.SetRawBytes(out, "messages.-1", message) } - input.ForEach(func(_, item gjson.Result) bool { + for _, item := range inputItems { itemType := item.Get("type").String() if itemType == "" && item.Get("role").String() != "" { itemType = "message" @@ -123,7 +170,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu message, _ = sjson.SetBytes(message, "content", content.String()) } - out, _ = sjson.SetRawBytes(out, "messages.-1", message) + appendRegularMessage(message) case "function_call": // Buffer consecutive function calls and emit them as one assistant message. @@ -141,13 +188,18 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", arguments.String()) } pendingToolCalls = append(pendingToolCalls, gjson.ParseBytes(toolCall).Value()) + if callID := strings.TrimSpace(item.Get("call_id").String()); callID != "" { + pendingToolCallIDs = append(pendingToolCallIDs, callID) + } case "function_call_output": // Handle function call output conversion to tool message toolMessage := []byte(`{"role":"tool","tool_call_id":"","content":""}`) + callID := "" if callId := item.Get("call_id"); callId.Exists() { - toolMessage, _ = sjson.SetBytes(toolMessage, "tool_call_id", callId.String()) + callID = strings.TrimSpace(callId.String()) + toolMessage, _ = sjson.SetBytes(toolMessage, "tool_call_id", callID) } if output := item.Get("output"); output.Exists() { @@ -155,11 +207,17 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu } out, _ = sjson.SetRawBytes(out, "messages.-1", toolMessage) + if callID != "" { + delete(awaitingToolOutputs, callID) + } + if len(awaitingToolOutputs) == 0 && len(deferredMessages) > 0 { + flushDeferredMessages() + } } - return true - }) + } flushPendingToolCalls() + flushDeferredMessages() } else if input.Type == gjson.String { msg := []byte(`{}`) msg, _ = sjson.SetBytes(msg, "role", "user") diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go index e9339753a3..9dd0e288b2 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go @@ -85,3 +85,40 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_SplitFunctionCalls t.Fatalf("messages.2.tool_calls.0.id = %q, want %q", got, "call_b") } } + +func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_DefersMessageUntilToolOutput(t *testing.T) { + raw := []byte(`{ + "input": [ + {"type":"function_call","call_id":"call_x","name":"exec_command","arguments":"{\"cmd\":\"echo hi\"}"}, + {"type":"message","role":"user","content":"Approved command prefix saved"}, + {"type":"function_call_output","call_id":"call_x","output":"ok"}, + {"type":"message","role":"user","content":"next"} + ] + }`) + t.Logf("input json:\n%s", prettyJSONForTest(raw)) + + out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k2.6", raw, true) + t.Logf("output json:\n%s", prettyJSONForTest(out)) + + if got := len(gjson.GetBytes(out, "messages").Array()); got != 4 { + t.Fatalf("messages count = %d, want %d", got, 4) + } + if got := gjson.GetBytes(out, "messages.0.role").String(); got != "assistant" { + t.Fatalf("messages.0.role = %q, want %q", got, "assistant") + } + if got := gjson.GetBytes(out, "messages.1.role").String(); got != "tool" { + t.Fatalf("messages.1.role = %q, want %q", got, "tool") + } + if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != "call_x" { + t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "call_x") + } + if got := gjson.GetBytes(out, "messages.2.role").String(); got != "user" { + t.Fatalf("messages.2.role = %q, want %q", got, "user") + } + if got := gjson.GetBytes(out, "messages.2.content").String(); got != "Approved command prefix saved" { + t.Fatalf("messages.2.content = %q, want %q", got, "Approved command prefix saved") + } + if got := gjson.GetBytes(out, "messages.3.content").String(); got != "next" { + t.Fatalf("messages.3.content = %q, want %q", got, "next") + } +} From ad3f4f2ce5791588b5da6e2547d241412275757b Mon Sep 17 00:00:00 2001 From: seakee Date: Wed, 6 May 2026 15:49:57 +0800 Subject: [PATCH 119/139] =?UTF-8?q?=F0=9F=93=9D=20docs(readme):=20add=20CP?= =?UTF-8?q?A-Manager=20usage=20statistics=20recommendation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CPA-Manager to the Usage Statistics recommendations across English, Chinese, and Japanese READMEs. Highlight request-level monitoring, cost estimation, LiteLLM price sync, SQLite persistence, and Codex account-pool operations for multi-account maintenance. --- README.md | 4 ++++ README_CN.md | 4 ++++ README_JA.md | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/README.md b/README.md index b1ddb9c08c..8064db7d77 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,10 @@ Standalone persistence and visualization service for CLIProxyAPI, with periodic Local-first usage and quota dashboard for CLIProxyAPI. It collects per-request token usage from the Redis-compatible usage queue into SQLite, visualizes daily and recent-window usage by account and model, and shows Codex 5h/7d quota remaining in a local web UI. +### [CPA-Manager](https://github.com/seakee/CPA-Manager) + +Full CLIProxyAPI management center with request-level monitoring and cost estimates. CPA-Manager tracks collected requests by account, model, channel, latency, status, and token usage; estimates cost with editable model prices and one-click LiteLLM price sync; persists events in SQLite; and provides Codex account-pool operations with batch inspection, quota detection, unhealthy account discovery, cleanup suggestions, and one-click execution for day-to-day multi-account maintenance. + ## Amp CLI Support CLIProxyAPI includes integrated support for [Amp CLI](https://ampcode.com) and Amp IDE extensions, enabling you to use your Google/ChatGPT/Claude OAuth subscriptions with Amp's coding tools: diff --git a/README_CN.md b/README_CN.md index e7fa787822..c912eb47a1 100644 --- a/README_CN.md +++ b/README_CN.md @@ -78,6 +78,10 @@ CLIProxyAPI 用户手册: [https://help.router-for.me/](https://help.router-fo 面向 CLIProxyAPI 的本地优先使用量与配额看板。它从 Redis 兼容使用量队列采集每次请求的 Token 消耗并写入 SQLite,按账号和模型可视化每日及最近时间窗口的用量,并在本地网页中显示 Codex 5h/7d 配额余量。 +### [CPA-Manager](https://github.com/seakee/CPA-Manager) + +面向 CLIProxyAPI 的完整管理中心,提供请求级监控和费用预估。CPA-Manager 可按账号、模型、渠道、延迟、状态和 token 用量追踪采集到的请求;支持可编辑模型价格与一键同步 LiteLLM 价格来估算费用;用 SQLite 持久化事件;并提供面向 Codex 账号池的批量巡检、配额识别、异常账号定位、清理建议与一键执行能力,适合多账号池的日常运维管理。 + ## Amp CLI 支持 CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支持,可让你使用自己的 Google/ChatGPT/Claude OAuth 订阅来配合 Amp 编码工具: diff --git a/README_JA.md b/README_JA.md index debe4ae5d1..ba96c3c1e5 100644 --- a/README_JA.md +++ b/README_JA.md @@ -76,6 +76,10 @@ CLIProxyAPI向けの独立した使用量永続化・可視化サービス。CLI CLIProxyAPI向けのローカル優先の使用量・クォータダッシュボード。Redis互換の使用量キューからリクエストごとのToken使用量を収集してSQLiteに保存し、アカウント別・モデル別の日次および直近時間枠の使用量を可視化し、Codex 5h/7dクォータ残量をローカルWeb UIで表示します。 +### [CPA-Manager](https://github.com/seakee/CPA-Manager) + +リクエスト単位の監視とコスト推定を備えたCLIProxyAPI向けのフル管理センターです。CPA-Managerは、収集したリクエストをアカウント、モデル、チャネル、レイテンシ、ステータス、Token使用量ごとに追跡し、編集可能なモデル価格とLiteLLM価格のワンクリック同期でコストを推定します。SQLiteでイベントを永続化し、Codexアカウントプール向けに一括検査、クォータ判定、異常アカウント検出、クリーンアップ提案、ワンクリック実行を提供し、日常的なマルチアカウント運用に適しています。 + ## Amp CLIサポート CLIProxyAPIは[Amp CLI](https://ampcode.com)およびAmp IDE拡張機能の統合サポートを含んでおり、Google/ChatGPT/ClaudeのOAuthサブスクリプションをAmpのコーディングツールで使用できます: From fb08b92402187cd87f888d371ee5d6f5107f6d36 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 6 May 2026 22:09:33 +0800 Subject: [PATCH 120/139] feat(executor): add upstream disconnect handling for Codex WebSocket sessions - Introduced `UpstreamDisconnectChan` for Codex WebSocket sessions to notify downstream connections of upstream disconnections. - Implemented `notifyUpstreamDisconnect` to signal errors and close channels on disconnect events. - Added integration tests to validate WebSocket session behavior on upstream disconnect. - Updated OpenAI WebSocket response handlers to properly close connections upon upstream disconnect notifications. --- .../executor/codex_websockets_executor.go | 40 ++++++- .../codex_websockets_executor_test.go | 59 ++++++++++ .../openai/openai_responses_websocket.go | 25 ++++ .../openai/openai_responses_websocket_test.go | 110 ++++++++++++++++++ 4 files changed, 233 insertions(+), 1 deletion(-) diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go index d6f1de86b2..94b78b66d8 100644 --- a/internal/runtime/executor/codex_websockets_executor.go +++ b/internal/runtime/executor/codex_websockets_executor.go @@ -76,6 +76,9 @@ type codexWebsocketSession struct { activeCancel context.CancelFunc readerConn *websocket.Conn + + upstreamDisconnectOnce sync.Once + upstreamDisconnectCh chan error } func NewCodexWebsocketsExecutor(cfg *config.Config) *CodexWebsocketsExecutor { @@ -151,6 +154,22 @@ func (s *codexWebsocketSession) configureConn(conn *websocket.Conn) { }) } +func (s *codexWebsocketSession) notifyUpstreamDisconnect(err error) { + if s == nil { + return + } + s.upstreamDisconnectOnce.Do(func() { + if s.upstreamDisconnectCh == nil { + return + } + select { + case s.upstreamDisconnectCh <- err: + default: + } + close(s.upstreamDisconnectCh) + }) +} + func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { if ctx == nil { ctx = context.Background() @@ -1221,11 +1240,22 @@ func (e *CodexWebsocketsExecutor) getOrCreateSession(sessionID string) *codexWeb if sess, ok := store.sessions[sessionID]; ok && sess != nil { return sess } - sess := &codexWebsocketSession{sessionID: sessionID} + sess := &codexWebsocketSession{ + sessionID: sessionID, + upstreamDisconnectCh: make(chan error, 1), + } store.sessions[sessionID] = sess return sess } +func (e *CodexWebsocketsExecutor) UpstreamDisconnectChan(sessionID string) <-chan error { + sess := e.getOrCreateSession(sessionID) + if sess == nil { + return nil + } + return sess.upstreamDisconnectCh +} + func (e *CodexWebsocketsExecutor) ensureUpstreamConn(ctx context.Context, auth *cliproxyauth.Auth, sess *codexWebsocketSession, authID string, wsURL string, headers http.Header) (*websocket.Conn, *http.Response, error) { if sess == nil { return e.dialCodexWebsocket(ctx, auth, wsURL, headers) @@ -1354,6 +1384,7 @@ func (e *CodexWebsocketsExecutor) invalidateUpstreamConn(sess *codexWebsocketSes sess.connMu.Unlock() logCodexWebsocketDisconnected(sessionID, authID, wsURL, reason, err) + sess.notifyUpstreamDisconnect(err) if errClose := conn.Close(); errClose != nil { log.Errorf("codex websockets executor: close websocket error: %v", errClose) } @@ -1592,6 +1623,13 @@ func (e *CodexAutoExecutor) CloseExecutionSession(sessionID string) { e.wsExec.CloseExecutionSession(sessionID) } +func (e *CodexAutoExecutor) UpstreamDisconnectChan(sessionID string) <-chan error { + if e == nil || e.wsExec == nil { + return nil + } + return e.wsExec.UpstreamDisconnectChan(sessionID) +} + func codexWebsocketsEnabled(auth *cliproxyauth.Auth) bool { if auth == nil { return false diff --git a/internal/runtime/executor/codex_websockets_executor_test.go b/internal/runtime/executor/codex_websockets_executor_test.go index 9c7bb59183..fbcf9c4527 100644 --- a/internal/runtime/executor/codex_websockets_executor_test.go +++ b/internal/runtime/executor/codex_websockets_executor_test.go @@ -3,6 +3,7 @@ package executor import ( "bytes" "context" + "errors" "net/http" "net/http/httptest" "strings" @@ -92,6 +93,64 @@ func TestCodexWebsocketsExecutePreservesPreviousResponseIDUpstream(t *testing.T) } } +func TestCodexWebsocketsUpstreamDisconnectChanSignalsOnInvalidate(t *testing.T) { + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("upgrade websocket: %v", err) + return + } + defer func() { _ = conn.Close() }() + for { + if _, _, errRead := conn.ReadMessage(); errRead != nil { + return + } + } + })) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial websocket: %v", err) + } + defer func() { _ = conn.Close() }() + + exec := NewCodexWebsocketsExecutor(&config.Config{}) + sessionID := "sess-1" + disconnectCh := exec.UpstreamDisconnectChan(sessionID) + if disconnectCh == nil { + t.Fatal("expected disconnect channel") + } + + sess := exec.getOrCreateSession(sessionID) + if sess == nil { + t.Fatal("expected session") + } + sess.connMu.Lock() + sess.conn = conn + sess.authID = "auth-1" + sess.wsURL = "ws://example.test/responses" + sess.readerConn = conn + sess.connMu.Unlock() + + upstreamErr := errors.New("upstream gone") + exec.invalidateUpstreamConn(sess, conn, "test_invalidate", upstreamErr) + + select { + case errRead, ok := <-disconnectCh: + if !ok { + t.Fatal("expected disconnect channel to deliver error before closing") + } + if errRead == nil || errRead.Error() != upstreamErr.Error() { + t.Fatalf("disconnect error = %v, want %v", errRead, upstreamErr) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for disconnect signal") + } +} + func TestApplyCodexWebsocketHeadersDefaultsToCurrentResponsesBeta(t *testing.T) { headers := applyCodexWebsocketHeaders(context.Background(), http.Header{}, nil, "", nil) diff --git a/sdk/api/handlers/openai/openai_responses_websocket.go b/sdk/api/handlers/openai/openai_responses_websocket.go index 7a9d2224f7..c617c94644 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket.go +++ b/sdk/api/handlers/openai/openai_responses_websocket.go @@ -56,6 +56,31 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) { retainResponsesWebsocketToolCaches(downstreamSessionKey) clientIP := websocketClientAddress(c) log.Infof("responses websocket: client connected id=%s remote=%s", passthroughSessionID, clientIP) + + wsDone := make(chan struct{}) + defer close(wsDone) + + if h != nil && h.AuthManager != nil { + if exec, ok := h.AuthManager.Executor("codex"); ok && exec != nil { + type upstreamDisconnectSubscriber interface { + UpstreamDisconnectChan(sessionID string) <-chan error + } + if subscriber, ok := exec.(upstreamDisconnectSubscriber); ok && subscriber != nil { + disconnectCh := subscriber.UpstreamDisconnectChan(passthroughSessionID) + if disconnectCh != nil { + go func() { + select { + case <-wsDone: + return + case <-disconnectCh: + _ = conn.Close() + } + }() + } + } + } + } + var wsTerminateErr error var wsTimelineLog strings.Builder defer func() { diff --git a/sdk/api/handlers/openai/openai_responses_websocket_test.go b/sdk/api/handlers/openai/openai_responses_websocket_test.go index 1d397ecd2a..319127f0e0 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket_test.go +++ b/sdk/api/handlers/openai/openai_responses_websocket_test.go @@ -85,6 +85,79 @@ func (e websocketPinnedFailoverStatusError) Error() string { return e.msg } func (e websocketPinnedFailoverStatusError) StatusCode() int { return e.status } +type websocketUpstreamDisconnectExecutor struct { + mu sync.Mutex + subscribed chan string + sessions map[string]chan error +} + +func (e *websocketUpstreamDisconnectExecutor) Identifier() string { return "codex" } + +func (e *websocketUpstreamDisconnectExecutor) UpstreamDisconnectChan(sessionID string) <-chan error { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return nil + } + e.mu.Lock() + if e.sessions == nil { + e.sessions = make(map[string]chan error) + } + ch, ok := e.sessions[sessionID] + if !ok { + ch = make(chan error, 1) + e.sessions[sessionID] = ch + } + subscribed := e.subscribed + e.mu.Unlock() + + if subscribed != nil { + select { + case subscribed <- sessionID: + default: + } + } + return ch +} + +func (e *websocketUpstreamDisconnectExecutor) TriggerDisconnect(sessionID string, err error) { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return + } + e.mu.Lock() + ch := e.sessions[sessionID] + delete(e.sessions, sessionID) + e.mu.Unlock() + if ch == nil { + return + } + select { + case ch <- err: + default: + } + close(ch) +} + +func (e *websocketUpstreamDisconnectExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) { + return coreexecutor.Response{}, errors.New("not implemented") +} + +func (e *websocketUpstreamDisconnectExecutor) ExecuteStream(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (*coreexecutor.StreamResult, error) { + return nil, errors.New("not implemented") +} + +func (e *websocketUpstreamDisconnectExecutor) Refresh(_ context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) { + return auth, nil +} + +func (e *websocketUpstreamDisconnectExecutor) CountTokens(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) { + return coreexecutor.Response{}, errors.New("not implemented") +} + +func (e *websocketUpstreamDisconnectExecutor) HttpRequest(context.Context, *coreauth.Auth, *http.Request) (*http.Response, error) { + return nil, errors.New("not implemented") +} + func (e *websocketAuthCaptureExecutor) Identifier() string { return "test-provider" } func (e *websocketAuthCaptureExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) { @@ -934,6 +1007,43 @@ func TestResponsesWebsocketTimelineRecordsDisconnectEvent(t *testing.T) { } } +func TestResponsesWebsocketClosesOnCodexUpstreamDisconnect(t *testing.T) { + gin.SetMode(gin.TestMode) + + executor := &websocketUpstreamDisconnectExecutor{subscribed: make(chan string, 1)} + manager := coreauth.NewManager(nil, nil, nil) + manager.RegisterExecutor(executor) + base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager) + h := NewOpenAIResponsesAPIHandler(base) + + router := gin.New() + router.GET("/v1/responses/ws", h.ResponsesWebsocket) + server := httptest.NewServer(router) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/v1/responses/ws" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial websocket: %v", err) + } + defer func() { _ = conn.Close() }() + + var sessionID string + select { + case sessionID = <-executor.subscribed: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for upstream disconnect subscription") + } + + executor.TriggerDisconnect(sessionID, errors.New("upstream disconnected")) + + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, _, err = conn.ReadMessage() + if err == nil { + t.Fatalf("expected downstream websocket to close after upstream disconnect") + } +} + func TestWebsocketUpstreamSupportsIncrementalInputForModel(t *testing.T) { manager := coreauth.NewManager(nil, nil, nil) auth := &coreauth.Auth{ From 01171742a6378cd55f564421abbfc0acb0fccfea Mon Sep 17 00:00:00 2001 From: edlsh Date: Wed, 6 May 2026 13:12:35 -0400 Subject: [PATCH 121/139] fix(amp): proxy thread actors route --- internal/api/modules/amp/routes.go | 1 + internal/api/modules/amp/routes_test.go | 1 + 2 files changed, 2 insertions(+) diff --git a/internal/api/modules/amp/routes.go b/internal/api/modules/amp/routes.go index 456a50ac12..b7253c3458 100644 --- a/internal/api/modules/amp/routes.go +++ b/internal/api/modules/amp/routes.go @@ -199,6 +199,7 @@ func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *ha ampAPI.Any("/telemetry/*path", proxyHandler) ampAPI.Any("/threads", proxyHandler) ampAPI.Any("/threads/*path", proxyHandler) + ampAPI.Any("/thread-actors", proxyHandler) ampAPI.Any("/otel", proxyHandler) ampAPI.Any("/otel/*path", proxyHandler) ampAPI.Any("/tab", proxyHandler) diff --git a/internal/api/modules/amp/routes_test.go b/internal/api/modules/amp/routes_test.go index bae890aec4..2308a153bb 100644 --- a/internal/api/modules/amp/routes_test.go +++ b/internal/api/modules/amp/routes_test.go @@ -49,6 +49,7 @@ func TestRegisterManagementRoutes(t *testing.T) { {"/api/meta", http.MethodGet}, {"/api/telemetry", http.MethodGet}, {"/api/threads", http.MethodGet}, + {"/api/thread-actors", http.MethodPost}, {"/threads/", http.MethodGet}, {"/threads.rss", http.MethodGet}, // Root-level route (no /api prefix) {"/api/otel", http.MethodGet}, From e50cabac4b0dc406ae4bf16d65c524be471d1671 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 8 May 2026 11:46:46 +0800 Subject: [PATCH 122/139] chore: upgrade CLIProxyAPI dependency to v7 across the project - Updated all references from v6 to v7 for `github.com/router-for-me/CLIProxyAPI`. - Ensured consistency in imports within core libraries, tests, and integration tests. - Added missing tests for new features in Redis Protocol integration. --- cmd/fetch_antigravity_models/main.go | 10 +- cmd/server/main.go | 38 +- config.example.yaml | 8 + examples/custom-provider/main.go | 16 +- examples/http-request/main.go | 4 +- examples/translator/main.go | 4 +- go.mod | 8 +- go.sum | 6 + internal/access/config_access/provider.go | 4 +- internal/access/reconcile.go | 6 +- .../api/handlers/management/api_key_usage.go | 2 +- .../handlers/management/api_key_usage_test.go | 4 +- internal/api/handlers/management/api_tools.go | 8 +- .../api/handlers/management/api_tools_test.go | 6 +- .../api/handlers/management/auth_files.go | 22 +- .../management/auth_files_batch_test.go | 4 +- .../management/auth_files_delete_test.go | 4 +- .../management/auth_files_download_test.go | 2 +- .../auth_files_download_windows_test.go | 2 +- .../auth_files_patch_fields_test.go | 4 +- .../auth_files_recent_requests_test.go | 4 +- .../handlers/management/config_auth_index.go | 4 +- .../api/handlers/management/config_basic.go | 6 +- .../api/handlers/management/config_lists.go | 2 +- .../config_lists_delete_keys_test.go | 2 +- internal/api/handlers/management/handler.go | 8 +- .../api/handlers/management/handler_test.go | 2 +- internal/api/handlers/management/logs.go | 2 +- .../handlers/management/model_definitions.go | 2 +- .../handlers/management/test_store_test.go | 2 +- internal/api/handlers/management/usage.go | 2 +- .../api/handlers/management/usage_test.go | 2 +- .../api/handlers/management/vertex_import.go | 4 +- internal/api/middleware/request_logging.go | 4 +- internal/api/middleware/response_writer.go | 4 +- .../api/middleware/response_writer_test.go | 4 +- internal/api/modules/amp/amp.go | 6 +- internal/api/modules/amp/amp_test.go | 8 +- internal/api/modules/amp/fallback_handlers.go | 4 +- .../api/modules/amp/fallback_handlers_test.go | 4 +- internal/api/modules/amp/model_mapping.go | 6 +- .../api/modules/amp/model_mapping_test.go | 4 +- internal/api/modules/amp/proxy.go | 2 +- internal/api/modules/amp/proxy_test.go | 2 +- internal/api/modules/amp/routes.go | 14 +- internal/api/modules/amp/routes_test.go | 2 +- internal/api/modules/amp/secret.go | 2 +- internal/api/modules/amp/secret_test.go | 2 +- internal/api/modules/modules.go | 4 +- internal/api/protocol_multiplexer.go | 6 + internal/api/redis_queue_protocol.go | 8 +- .../redis_queue_protocol_integration_test.go | 39 +- internal/api/server.go | 261 +++++++++- internal/api/server_test.go | 38 +- internal/auth/antigravity/auth.go | 6 +- internal/auth/claude/anthropic_auth.go | 2 +- .../auth/claude/anthropic_auth_proxy_test.go | 2 +- internal/auth/claude/token.go | 2 +- internal/auth/claude/utls_transport.go | 4 +- internal/auth/codex/openai_auth.go | 4 +- internal/auth/codex/openai_auth_test.go | 2 +- internal/auth/codex/token.go | 2 +- internal/auth/gemini/gemini_auth.go | 12 +- internal/auth/gemini/gemini_token.go | 2 +- internal/auth/kimi/kimi.go | 4 +- internal/auth/kimi/kimi_proxy_test.go | 2 +- internal/auth/kimi/token.go | 2 +- internal/auth/vertex/vertex_credentials.go | 2 +- internal/cmd/anthropic_login.go | 6 +- internal/cmd/antigravity_login.go | 4 +- internal/cmd/auth_manager.go | 2 +- internal/cmd/kimi_login.go | 4 +- internal/cmd/login.go | 12 +- internal/cmd/openai_device_login.go | 6 +- internal/cmd/openai_login.go | 6 +- internal/cmd/run.go | 6 +- internal/cmd/vertex_import.go | 10 +- internal/config/config.go | 5 +- internal/config/home.go | 9 + internal/config/parse.go | 89 ++++ internal/home/client.go | 374 +++++++++++++++ internal/home/global.go | 25 + internal/home/requests.go | 13 + internal/interfaces/types.go | 2 +- internal/logging/gin_logger.go | 2 +- internal/logging/global_logger.go | 4 +- internal/logging/request_logger.go | 6 +- internal/managementasset/updater.go | 6 +- internal/redisqueue/plugin.go | 4 +- internal/redisqueue/plugin_test.go | 7 +- internal/registry/model_registry.go | 2 +- .../runtime/executor/aistudio_executor.go | 21 +- .../runtime/executor/antigravity_executor.go | 39 +- .../antigravity_executor_buildrequest_test.go | 2 +- .../antigravity_executor_credits_test.go | 8 +- .../antigravity_executor_signature_test.go | 8 +- internal/runtime/executor/claude_executor.go | 23 +- .../runtime/executor/claude_executor_test.go | 12 +- internal/runtime/executor/claude_signing.go | 4 +- internal/runtime/executor/codex_executor.go | 21 +- .../executor/codex_executor_cache_test.go | 6 +- .../executor/codex_executor_compact_test.go | 8 +- .../executor/codex_executor_imagegen_test.go | 2 +- .../codex_executor_instructions_test.go | 8 +- .../codex_executor_stream_output_test.go | 10 +- .../executor/codex_websockets_executor.go | 18 +- .../codex_websockets_executor_store_test.go | 2 +- .../codex_websockets_executor_test.go | 10 +- .../runtime/executor/gemini_cli_executor.go | 100 ++-- internal/runtime/executor/gemini_executor.go | 19 +- .../executor/gemini_vertex_executor.go | 21 +- .../executor/helps/claude_device_profile.go | 4 +- .../runtime/executor/helps/home_refresh.go | 91 ++++ .../runtime/executor/helps/logging_helpers.go | 6 +- .../runtime/executor/helps/payload_helpers.go | 6 +- ...d_helpers_disable_image_generation_test.go | 2 +- .../runtime/executor/helps/proxy_helpers.go | 6 +- .../executor/helps/proxy_helpers_test.go | 6 +- .../executor/helps/thinking_providers.go | 14 +- .../runtime/executor/helps/usage_helpers.go | 6 +- .../executor/helps/usage_helpers_test.go | 2 +- .../runtime/executor/helps/utls_client.go | 6 +- internal/runtime/executor/kimi_executor.go | 19 +- .../executor/openai_compat_executor.go | 18 +- .../openai_compat_executor_compact_test.go | 8 +- internal/store/gitstore.go | 2 +- internal/store/objectstore.go | 4 +- internal/store/postgresstore.go | 4 +- internal/thinking/apply.go | 2 +- internal/thinking/apply_user_defined_test.go | 6 +- internal/thinking/convert.go | 2 +- .../thinking/provider/antigravity/apply.go | 4 +- internal/thinking/provider/claude/apply.go | 4 +- internal/thinking/provider/codex/apply.go | 4 +- internal/thinking/provider/gemini/apply.go | 4 +- internal/thinking/provider/geminicli/apply.go | 4 +- internal/thinking/provider/kimi/apply.go | 4 +- internal/thinking/provider/kimi/apply_test.go | 4 +- internal/thinking/provider/openai/apply.go | 4 +- internal/thinking/types.go | 2 +- internal/thinking/validate.go | 2 +- .../claude/antigravity_claude_request.go | 8 +- .../claude/antigravity_claude_request_test.go | 2 +- .../claude/antigravity_claude_response.go | 6 +- .../antigravity_claude_response_test.go | 2 +- .../translator/antigravity/claude/init.go | 6 +- .../claude/signature_validation.go | 2 +- .../gemini/antigravity_gemini_request.go | 4 +- .../gemini/antigravity_gemini_response.go | 2 +- .../translator/antigravity/gemini/init.go | 6 +- .../antigravity_openai_request.go | 6 +- .../antigravity_openai_response.go | 4 +- .../openai/chat-completions/init.go | 6 +- .../antigravity_openai-responses_request.go | 4 +- .../antigravity_openai-responses_response.go | 2 +- .../antigravity/openai/responses/init.go | 6 +- .../gemini-cli/claude_gemini-cli_request.go | 2 +- .../gemini-cli/claude_gemini-cli_response.go | 4 +- internal/translator/claude/gemini-cli/init.go | 6 +- .../claude/gemini/claude_gemini_request.go | 6 +- .../claude/gemini/claude_gemini_response.go | 2 +- internal/translator/claude/gemini/init.go | 6 +- .../chat-completions/claude_openai_request.go | 4 +- .../claude/openai/chat-completions/init.go | 6 +- .../claude_openai-responses_request.go | 4 +- .../claude_openai-responses_response.go | 2 +- .../claude/openai/responses/init.go | 6 +- .../codex/claude/codex_claude_request.go | 2 +- .../codex/claude/codex_claude_response.go | 4 +- internal/translator/codex/claude/init.go | 6 +- .../gemini-cli/codex_gemini-cli_request.go | 2 +- .../gemini-cli/codex_gemini-cli_response.go | 4 +- internal/translator/codex/gemini-cli/init.go | 6 +- .../codex/gemini/codex_gemini_request.go | 4 +- .../codex/gemini/codex_gemini_response.go | 2 +- internal/translator/codex/gemini/init.go | 6 +- .../codex/openai/chat-completions/init.go | 6 +- .../translator/codex/openai/responses/init.go | 6 +- .../claude/gemini-cli_claude_request.go | 4 +- .../claude/gemini-cli_claude_response.go | 4 +- internal/translator/gemini-cli/claude/init.go | 6 +- .../gemini/gemini-cli_gemini_request.go | 4 +- .../gemini/gemini-cli_gemini_response.go | 2 +- internal/translator/gemini-cli/gemini/init.go | 6 +- .../gemini-cli_openai_request.go | 6 +- .../gemini-cli_openai_response.go | 4 +- .../openai/chat-completions/init.go | 6 +- .../gemini-cli_openai-responses_request.go | 4 +- .../gemini-cli_openai-responses_response.go | 2 +- .../gemini-cli/openai/responses/init.go | 6 +- .../gemini/claude/gemini_claude_request.go | 6 +- .../gemini/claude/gemini_claude_response.go | 4 +- internal/translator/gemini/claude/init.go | 6 +- .../gemini-cli/gemini_gemini-cli_request.go | 4 +- .../gemini-cli/gemini_gemini-cli_response.go | 2 +- internal/translator/gemini/gemini-cli/init.go | 6 +- .../gemini/gemini/gemini_gemini_request.go | 4 +- .../gemini/gemini/gemini_gemini_response.go | 2 +- internal/translator/gemini/gemini/init.go | 6 +- .../chat-completions/gemini_openai_request.go | 6 +- .../gemini_openai_response.go | 2 +- .../gemini/openai/chat-completions/init.go | 6 +- .../gemini_openai-responses_request.go | 4 +- .../gemini_openai-responses_response.go | 4 +- .../gemini/openai/responses/init.go | 6 +- internal/translator/init.go | 54 +-- internal/translator/openai/claude/init.go | 6 +- .../openai/claude/openai_claude_request.go | 2 +- .../openai/claude/openai_claude_response.go | 4 +- internal/translator/openai/gemini-cli/init.go | 6 +- .../gemini-cli/openai_gemini_request.go | 2 +- .../gemini-cli/openai_gemini_response.go | 4 +- internal/translator/openai/gemini/init.go | 6 +- .../openai/gemini/openai_gemini_request.go | 2 +- .../openai/gemini/openai_gemini_response.go | 2 +- .../openai/openai/chat-completions/init.go | 6 +- .../openai/openai/responses/init.go | 6 +- .../openai_openai-responses_response.go | 2 +- internal/translator/translator/translator.go | 4 +- internal/util/provider.go | 4 +- internal/util/proxy.go | 4 +- internal/util/util.go | 2 +- internal/watcher/clients.go | 10 +- internal/watcher/config_reload.go | 6 +- internal/watcher/diff/auth_diff.go | 2 +- internal/watcher/diff/config_diff.go | 2 +- internal/watcher/diff/config_diff_test.go | 4 +- internal/watcher/diff/model_hash.go | 2 +- internal/watcher/diff/model_hash_test.go | 2 +- internal/watcher/diff/models_summary.go | 2 +- internal/watcher/diff/oauth_excluded.go | 2 +- internal/watcher/diff/oauth_excluded_test.go | 2 +- internal/watcher/diff/oauth_model_alias.go | 2 +- internal/watcher/diff/openai_compat.go | 2 +- internal/watcher/diff/openai_compat_test.go | 2 +- internal/watcher/dispatcher.go | 6 +- internal/watcher/synthesizer/config.go | 4 +- internal/watcher/synthesizer/config_test.go | 4 +- internal/watcher/synthesizer/context.go | 2 +- internal/watcher/synthesizer/file.go | 6 +- internal/watcher/synthesizer/file_test.go | 4 +- internal/watcher/synthesizer/helpers.go | 6 +- internal/watcher/synthesizer/helpers_test.go | 6 +- internal/watcher/synthesizer/interface.go | 2 +- internal/watcher/watcher.go | 6 +- internal/watcher/watcher_test.go | 10 +- sdk/api/handlers/claude/code_handlers.go | 8 +- .../handlers/gemini/gemini-cli_handlers.go | 8 +- sdk/api/handlers/gemini/gemini_handlers.go | 8 +- sdk/api/handlers/handlers.go | 38 +- .../handlers/handlers_error_response_test.go | 6 +- sdk/api/handlers/handlers_metadata_test.go | 2 +- .../handlers/handlers_request_details_test.go | 6 +- .../handlers_stream_bootstrap_test.go | 10 +- sdk/api/handlers/openai/openai_handlers.go | 10 +- .../handlers/openai/openai_images_handlers.go | 6 +- .../openai/openai_images_handlers_test.go | 6 +- .../openai/openai_responses_compact_test.go | 10 +- .../openai/openai_responses_handlers.go | 8 +- ...ai_responses_handlers_stream_error_test.go | 6 +- .../openai_responses_handlers_stream_test.go | 6 +- .../openai/openai_responses_websocket.go | 14 +- .../openai/openai_responses_websocket_test.go | 12 +- sdk/api/handlers/stream_forwarder.go | 2 +- sdk/api/management.go | 6 +- sdk/api/options.go | 8 +- sdk/auth/antigravity.go | 12 +- sdk/auth/claude.go | 12 +- sdk/auth/codex.go | 12 +- sdk/auth/codex_device.go | 10 +- sdk/auth/errors.go | 2 +- sdk/auth/filestore.go | 2 +- sdk/auth/gemini.go | 6 +- sdk/auth/interfaces.go | 4 +- sdk/auth/kimi.go | 8 +- sdk/auth/manager.go | 4 +- sdk/auth/refresh_registry.go | 2 +- sdk/auth/store_registry.go | 2 +- sdk/cliproxy/auth/antigravity_credits_test.go | 6 +- sdk/cliproxy/auth/api_key_model_alias_test.go | 2 +- sdk/cliproxy/auth/conductor.go | 197 +++++++- .../auth/conductor_credits_candidates_test.go | 2 +- .../auth/conductor_executor_replace_test.go | 2 +- .../conductor_oauth_alias_suspension_test.go | 8 +- sdk/cliproxy/auth/conductor_overrides_test.go | 6 +- .../auth/conductor_scheduler_refresh_test.go | 4 +- sdk/cliproxy/auth/oauth_model_alias.go | 4 +- sdk/cliproxy/auth/oauth_model_alias_test.go | 2 +- sdk/cliproxy/auth/openai_compat_pool_test.go | 6 +- sdk/cliproxy/auth/scheduler.go | 4 +- sdk/cliproxy/auth/scheduler_benchmark_test.go | 4 +- sdk/cliproxy/auth/scheduler_test.go | 4 +- sdk/cliproxy/auth/selector.go | 6 +- sdk/cliproxy/auth/selector_test.go | 2 +- sdk/cliproxy/auth/types.go | 2 +- sdk/cliproxy/builder.go | 12 +- sdk/cliproxy/executor/types.go | 2 +- sdk/cliproxy/model_registry.go | 2 +- sdk/cliproxy/pipeline/context.go | 6 +- sdk/cliproxy/pprof_server.go | 2 +- sdk/cliproxy/providers.go | 4 +- sdk/cliproxy/rtprovider.go | 4 +- sdk/cliproxy/rtprovider_test.go | 2 +- sdk/cliproxy/service.go | 445 +++++++++++++----- .../service_codex_executor_binding_test.go | 4 +- sdk/cliproxy/service_excluded_models_test.go | 4 +- .../service_oauth_model_alias_test.go | 2 +- sdk/cliproxy/service_stale_state_test.go | 6 +- sdk/cliproxy/types.go | 6 +- sdk/cliproxy/watcher.go | 6 +- sdk/config/config.go | 4 +- sdk/logging/request_logger.go | 2 +- sdk/translator/builtin/builtin.go | 4 +- test/amp_management_test.go | 4 +- test/builtin_tools_translation_test.go | 4 +- test/thinking_conversion_test.go | 24 +- test/usage_logging_test.go | 12 +- 317 files changed, 2413 insertions(+), 1033 deletions(-) create mode 100644 internal/config/home.go create mode 100644 internal/config/parse.go create mode 100644 internal/home/client.go create mode 100644 internal/home/global.go create mode 100644 internal/home/requests.go create mode 100644 internal/runtime/executor/helps/home_refresh.go diff --git a/cmd/fetch_antigravity_models/main.go b/cmd/fetch_antigravity_models/main.go index d4328eb32f..250bcbdfa3 100644 --- a/cmd/fetch_antigravity_models/main.go +++ b/cmd/fetch_antigravity_models/main.go @@ -25,11 +25,11 @@ import ( "strings" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" - sdkauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil" + "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + sdkauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" ) diff --git a/cmd/server/main.go b/cmd/server/main.go index b10bc9c8dd..44a314aee3 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -17,21 +17,21 @@ import ( "time" "github.com/joho/godotenv" - configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access" - "github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo" - "github.com/router-for-me/CLIProxyAPI/v6/internal/cmd" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" - "github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" - "github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/store" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator" - "github.com/router-for-me/CLIProxyAPI/v6/internal/tui" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + configaccess "github.com/router-for-me/CLIProxyAPI/v7/internal/access/config_access" + "github.com/router-for-me/CLIProxyAPI/v7/internal/buildinfo" + "github.com/router-for-me/CLIProxyAPI/v7/internal/cmd" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" + "github.com/router-for-me/CLIProxyAPI/v7/internal/managementasset" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/store" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator" + "github.com/router-for-me/CLIProxyAPI/v7/internal/tui" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" ) @@ -496,8 +496,10 @@ func main() { // Standalone mode: start an embedded local server and connect TUI client to it. managementasset.StartAutoUpdater(context.Background(), configFilePath) misc.StartAntigravityVersionUpdater(context.Background()) - if !localModel { + if !localModel && !cfg.Home.Enabled { registry.StartModelsUpdater(context.Background()) + } else if cfg.Home.Enabled { + log.Info("Home mode: remote model updates disabled") } hook := tui.NewLogHook(2000) hook.SetFormatter(&logging.LogFormatter{}) @@ -572,8 +574,10 @@ func main() { // Start the main proxy service managementasset.StartAutoUpdater(context.Background(), configFilePath) misc.StartAntigravityVersionUpdater(context.Background()) - if !localModel { + if !localModel && !cfg.Home.Enabled { registry.StartModelsUpdater(context.Background()) + } else if cfg.Home.Enabled { + log.Info("Home mode: remote model updates disabled") } cmd.StartService(cfg, configFilePath, password) } diff --git a/config.example.yaml b/config.example.yaml index d7d5a9f56b..f8e5978eec 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -11,6 +11,13 @@ tls: cert: "" key: "" +# Optional "home" control plane integration over Redis protocol. +home: + enabled: false + host: "127.0.0.1" + port: 6379 + password: "" + # Management API settings remote-management: # Whether to allow remote (non-localhost) management access. @@ -67,6 +74,7 @@ error-logs-max-files: 10 usage-statistics-enabled: false # How long (in seconds) Redis usage queue items are retained in memory for the RESP interface (LPOP/RPOP). +# Note: the in-process Redis RESP usage output is disabled when home.enabled is true. # Default: 60. Max: 3600. redis-usage-queue-retention-seconds: 60 diff --git a/examples/custom-provider/main.go b/examples/custom-provider/main.go index fdbae275e8..6f37c341de 100644 --- a/examples/custom-provider/main.go +++ b/examples/custom-provider/main.go @@ -24,14 +24,14 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api" - sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/logging" - sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + clipexec "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/logging" + sdktr "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" ) const ( diff --git a/examples/http-request/main.go b/examples/http-request/main.go index a667a9ca0c..1e0215ecea 100644 --- a/examples/http-request/main.go +++ b/examples/http-request/main.go @@ -16,8 +16,8 @@ import ( "strings" "time" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + clipexec "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" log "github.com/sirupsen/logrus" ) diff --git a/examples/translator/main.go b/examples/translator/main.go index 88f142a3d2..524a303eb8 100644 --- a/examples/translator/main.go +++ b/examples/translator/main.go @@ -4,8 +4,8 @@ import ( "context" "fmt" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" - _ "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator/builtin" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + _ "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator/builtin" ) func main() { diff --git a/go.mod b/go.mod index 7ad363a716..9ad89ae44c 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/router-for-me/CLIProxyAPI/v6 +module github.com/router-for-me/CLIProxyAPI/v7 go 1.26.0 @@ -31,6 +31,12 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/redis/go-redis/v9 v9.19.0 // indirect + go.uber.org/atomic v1.11.0 // indirect +) + require ( cloud.google.com/go/compute/metadata v0.3.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect diff --git a/go.sum b/go.sum index e811b0123b..5f0a03fbef 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -158,6 +160,8 @@ github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k= +github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= @@ -203,6 +207,8 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= diff --git a/internal/access/config_access/provider.go b/internal/access/config_access/provider.go index 84e8abcb0e..915160b76f 100644 --- a/internal/access/config_access/provider.go +++ b/internal/access/config_access/provider.go @@ -5,8 +5,8 @@ import ( "net/http" "strings" - sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" - sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) // Register ensures the config-access provider is available to the access manager. diff --git a/internal/access/reconcile.go b/internal/access/reconcile.go index 36601f9998..d71e2b8d28 100644 --- a/internal/access/reconcile.go +++ b/internal/access/reconcile.go @@ -6,9 +6,9 @@ import ( "sort" "strings" - configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" + configaccess "github.com/router-for-me/CLIProxyAPI/v7/internal/access/config_access" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access" log "github.com/sirupsen/logrus" ) diff --git a/internal/api/handlers/management/api_key_usage.go b/internal/api/handlers/management/api_key_usage.go index 3361da5d28..dbe6fbd998 100644 --- a/internal/api/handlers/management/api_key_usage.go +++ b/internal/api/handlers/management/api_key_usage.go @@ -6,7 +6,7 @@ import ( "time" "github.com/gin-gonic/gin" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) type apiKeyUsageEntry struct { diff --git a/internal/api/handlers/management/api_key_usage_test.go b/internal/api/handlers/management/api_key_usage_test.go index 2880567f8c..f2be17d7db 100644 --- a/internal/api/handlers/management/api_key_usage_test.go +++ b/internal/api/handlers/management/api_key_usage_test.go @@ -8,8 +8,8 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) func sumRecentRequestBuckets(buckets []coreauth.RecentRequestBucket) (int64, int64) { diff --git a/internal/api/handlers/management/api_tools.go b/internal/api/handlers/management/api_tools.go index 51b08cea4f..f10850701a 100644 --- a/internal/api/handlers/management/api_tools.go +++ b/internal/api/handlers/management/api_tools.go @@ -11,10 +11,10 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/geminicli" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil" log "github.com/sirupsen/logrus" "golang.org/x/oauth2" "golang.org/x/oauth2/google" diff --git a/internal/api/handlers/management/api_tools_test.go b/internal/api/handlers/management/api_tools_test.go index b27fe6395a..b089eb4a6e 100644 --- a/internal/api/handlers/management/api_tools_test.go +++ b/internal/api/handlers/management/api_tools_test.go @@ -5,9 +5,9 @@ import ( "net/http" "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) func TestAPICallTransportDirectBypassesGlobalProxy(t *testing.T) { diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 285b3ae291..d7e798977e 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -22,17 +22,17 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/antigravity" - "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude" - "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex" - geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini" - "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/antigravity" + "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude" + "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex" + geminiAuth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/gemini" + "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/kimi" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "golang.org/x/oauth2" diff --git a/internal/api/handlers/management/auth_files_batch_test.go b/internal/api/handlers/management/auth_files_batch_test.go index 44cdbd5b5f..ec001ae586 100644 --- a/internal/api/handlers/management/auth_files_batch_test.go +++ b/internal/api/handlers/management/auth_files_batch_test.go @@ -12,8 +12,8 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) func TestUploadAuthFile_BatchMultipart(t *testing.T) { diff --git a/internal/api/handlers/management/auth_files_delete_test.go b/internal/api/handlers/management/auth_files_delete_test.go index 7b7b888c4b..a57c9993ad 100644 --- a/internal/api/handlers/management/auth_files_delete_test.go +++ b/internal/api/handlers/management/auth_files_delete_test.go @@ -11,8 +11,8 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) func TestDeleteAuthFile_UsesAuthPathFromManager(t *testing.T) { diff --git a/internal/api/handlers/management/auth_files_download_test.go b/internal/api/handlers/management/auth_files_download_test.go index a2a20d305a..88024fbba5 100644 --- a/internal/api/handlers/management/auth_files_download_test.go +++ b/internal/api/handlers/management/auth_files_download_test.go @@ -9,7 +9,7 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) func TestDownloadAuthFile_ReturnsFile(t *testing.T) { diff --git a/internal/api/handlers/management/auth_files_download_windows_test.go b/internal/api/handlers/management/auth_files_download_windows_test.go index 8c174ccf51..88fc7f1146 100644 --- a/internal/api/handlers/management/auth_files_download_windows_test.go +++ b/internal/api/handlers/management/auth_files_download_windows_test.go @@ -11,7 +11,7 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) func TestDownloadAuthFile_PreventsWindowsSlashTraversal(t *testing.T) { diff --git a/internal/api/handlers/management/auth_files_patch_fields_test.go b/internal/api/handlers/management/auth_files_patch_fields_test.go index 3ca70012c0..568700a0d6 100644 --- a/internal/api/handlers/management/auth_files_patch_fields_test.go +++ b/internal/api/handlers/management/auth_files_patch_fields_test.go @@ -9,8 +9,8 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) func TestPatchAuthFileFields_MergeHeadersAndDeleteEmptyValues(t *testing.T) { diff --git a/internal/api/handlers/management/auth_files_recent_requests_test.go b/internal/api/handlers/management/auth_files_recent_requests_test.go index 979040f58b..404bf4848f 100644 --- a/internal/api/handlers/management/auth_files_recent_requests_test.go +++ b/internal/api/handlers/management/auth_files_recent_requests_test.go @@ -8,8 +8,8 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) func TestListAuthFiles_IncludesRecentRequestsBuckets(t *testing.T) { diff --git a/internal/api/handlers/management/config_auth_index.go b/internal/api/handlers/management/config_auth_index.go index 7b01512559..f2bbc2ff38 100644 --- a/internal/api/handlers/management/config_auth_index.go +++ b/internal/api/handlers/management/config_auth_index.go @@ -4,8 +4,8 @@ import ( "fmt" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/synthesizer" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/synthesizer" ) type geminiKeyWithAuthIndex struct { diff --git a/internal/api/handlers/management/config_basic.go b/internal/api/handlers/management/config_basic.go index f77e91e9ba..a0818aa8ae 100644 --- a/internal/api/handlers/management/config_basic.go +++ b/internal/api/handlers/management/config_basic.go @@ -11,9 +11,9 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" log "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" ) diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index e487627a00..f8ef3203c7 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -6,7 +6,7 @@ import ( "strings" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) // Generic helpers for list[string] diff --git a/internal/api/handlers/management/config_lists_delete_keys_test.go b/internal/api/handlers/management/config_lists_delete_keys_test.go index aaa43910e7..a548805eda 100644 --- a/internal/api/handlers/management/config_lists_delete_keys_test.go +++ b/internal/api/handlers/management/config_lists_delete_keys_test.go @@ -8,7 +8,7 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) func writeTestConfigFile(t *testing.T) string { diff --git a/internal/api/handlers/management/handler.go b/internal/api/handlers/management/handler.go index 9abc8a5c8a..0f884ef05a 100644 --- a/internal/api/handlers/management/handler.go +++ b/internal/api/handlers/management/handler.go @@ -13,10 +13,10 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/buildinfo" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" "golang.org/x/crypto/bcrypt" ) diff --git a/internal/api/handlers/management/handler_test.go b/internal/api/handlers/management/handler_test.go index f3a6086e95..a77dc36f35 100644 --- a/internal/api/handlers/management/handler_test.go +++ b/internal/api/handlers/management/handler_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) func TestAuthenticateManagementKey_LocalhostIPBan_BlocksCorrectKeyDuringBan(t *testing.T) { diff --git a/internal/api/handlers/management/logs.go b/internal/api/handlers/management/logs.go index b64cd61938..ca6d7eda81 100644 --- a/internal/api/handlers/management/logs.go +++ b/internal/api/handlers/management/logs.go @@ -13,7 +13,7 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" + "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" ) const ( diff --git a/internal/api/handlers/management/model_definitions.go b/internal/api/handlers/management/model_definitions.go index 85ff314bf4..0d1b8af437 100644 --- a/internal/api/handlers/management/model_definitions.go +++ b/internal/api/handlers/management/model_definitions.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" ) // GetStaticModelDefinitions returns static model metadata for a given channel. diff --git a/internal/api/handlers/management/test_store_test.go b/internal/api/handlers/management/test_store_test.go index cf7dbaf7d0..2eaacd904f 100644 --- a/internal/api/handlers/management/test_store_test.go +++ b/internal/api/handlers/management/test_store_test.go @@ -4,7 +4,7 @@ import ( "context" "sync" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) type memoryAuthStore struct { diff --git a/internal/api/handlers/management/usage.go b/internal/api/handlers/management/usage.go index dfddf50346..c1602c0423 100644 --- a/internal/api/handlers/management/usage.go +++ b/internal/api/handlers/management/usage.go @@ -8,7 +8,7 @@ import ( "strings" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue" + "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue" ) type usageQueueRecord []byte diff --git a/internal/api/handlers/management/usage_test.go b/internal/api/handlers/management/usage_test.go index ca46d976f5..bdb8aa2e29 100644 --- a/internal/api/handlers/management/usage_test.go +++ b/internal/api/handlers/management/usage_test.go @@ -7,7 +7,7 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue" + "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue" ) func TestGetUsageQueuePopsRequestedRecords(t *testing.T) { diff --git a/internal/api/handlers/management/vertex_import.go b/internal/api/handlers/management/vertex_import.go index bad066a270..bb064b9fb9 100644 --- a/internal/api/handlers/management/vertex_import.go +++ b/internal/api/handlers/management/vertex_import.go @@ -9,8 +9,8 @@ import ( "strings" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/vertex" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/vertex" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) // ImportVertexCredential handles uploading a Vertex service account JSON and saving it as an auth record. diff --git a/internal/api/middleware/request_logging.go b/internal/api/middleware/request_logging.go index b57dd8aa42..7a10fad8a1 100644 --- a/internal/api/middleware/request_logging.go +++ b/internal/api/middleware/request_logging.go @@ -11,8 +11,8 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" ) const maxErrorOnlyCapturedRequestBodyBytes int64 = 1 << 20 // 1 MiB diff --git a/internal/api/middleware/response_writer.go b/internal/api/middleware/response_writer.go index 7f4892674a..5a89ed0fdf 100644 --- a/internal/api/middleware/response_writer.go +++ b/internal/api/middleware/response_writer.go @@ -10,8 +10,8 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" ) const requestBodyOverrideContextKey = "REQUEST_BODY_OVERRIDE" diff --git a/internal/api/middleware/response_writer_test.go b/internal/api/middleware/response_writer_test.go index f5c21deb8a..fa0bd54854 100644 --- a/internal/api/middleware/response_writer_test.go +++ b/internal/api/middleware/response_writer_test.go @@ -7,8 +7,8 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" ) func TestExtractRequestBodyPrefersOverride(t *testing.T) { diff --git a/internal/api/modules/amp/amp.go b/internal/api/modules/amp/amp.go index a12733e2a1..18c8ac1ef0 100644 --- a/internal/api/modules/amp/amp.go +++ b/internal/api/modules/amp/amp.go @@ -9,9 +9,9 @@ import ( "sync" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" + "github.com/router-for-me/CLIProxyAPI/v7/internal/api/modules" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access" log "github.com/sirupsen/logrus" ) diff --git a/internal/api/modules/amp/amp_test.go b/internal/api/modules/amp/amp_test.go index 430c4b62a7..5ca01754a2 100644 --- a/internal/api/modules/amp/amp_test.go +++ b/internal/api/modules/amp/amp_test.go @@ -9,10 +9,10 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" + "github.com/router-for-me/CLIProxyAPI/v7/internal/api/modules" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" ) func TestAmpModule_Name(t *testing.T) { diff --git a/internal/api/modules/amp/fallback_handlers.go b/internal/api/modules/amp/fallback_handlers.go index e4e0f8a650..06e0a035d0 100644 --- a/internal/api/modules/amp/fallback_handlers.go +++ b/internal/api/modules/amp/fallback_handlers.go @@ -8,8 +8,8 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" diff --git a/internal/api/modules/amp/fallback_handlers_test.go b/internal/api/modules/amp/fallback_handlers_test.go index a687fd116b..1aacaae21f 100644 --- a/internal/api/modules/amp/fallback_handlers_test.go +++ b/internal/api/modules/amp/fallback_handlers_test.go @@ -9,8 +9,8 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" ) func TestFallbackHandler_ModelMapping_PreservesThinkingSuffixAndRewritesResponse(t *testing.T) { diff --git a/internal/api/modules/amp/model_mapping.go b/internal/api/modules/amp/model_mapping.go index 4159a2b576..2b68866edf 100644 --- a/internal/api/modules/amp/model_mapping.go +++ b/internal/api/modules/amp/model_mapping.go @@ -7,9 +7,9 @@ import ( "strings" "sync" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" ) diff --git a/internal/api/modules/amp/model_mapping_test.go b/internal/api/modules/amp/model_mapping_test.go index 53165d22c3..dcfb07ee5e 100644 --- a/internal/api/modules/amp/model_mapping_test.go +++ b/internal/api/modules/amp/model_mapping_test.go @@ -3,8 +3,8 @@ package amp import ( "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" ) func TestNewModelMapper(t *testing.T) { diff --git a/internal/api/modules/amp/proxy.go b/internal/api/modules/amp/proxy.go index c8010854f3..54f4b734ba 100644 --- a/internal/api/modules/amp/proxy.go +++ b/internal/api/modules/amp/proxy.go @@ -14,7 +14,7 @@ import ( "strings" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" log "github.com/sirupsen/logrus" ) diff --git a/internal/api/modules/amp/proxy_test.go b/internal/api/modules/amp/proxy_test.go index 49dba956c0..2852efde3a 100644 --- a/internal/api/modules/amp/proxy_test.go +++ b/internal/api/modules/amp/proxy_test.go @@ -11,7 +11,7 @@ import ( "strings" "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) // Helper: compress data with gzip diff --git a/internal/api/modules/amp/routes.go b/internal/api/modules/amp/routes.go index b7253c3458..84023d156d 100644 --- a/internal/api/modules/amp/routes.go +++ b/internal/api/modules/amp/routes.go @@ -9,11 +9,11 @@ import ( "strings" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/claude" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/gemini" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/openai" + "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/claude" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/gemini" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/openai" log "github.com/sirupsen/logrus" ) @@ -21,12 +21,12 @@ import ( // from gin.Context to the request context for SecretSource lookup. type clientAPIKeyContextKey struct{} -// clientAPIKeyMiddleware injects the authenticated client API key from gin.Context["apiKey"] +// clientAPIKeyMiddleware injects the authenticated client API key from gin.Context["userApiKey"] // into the request context so that SecretSource can look it up for per-client upstream routing. func clientAPIKeyMiddleware() gin.HandlerFunc { return func(c *gin.Context) { // Extract the client API key from gin context (set by AuthMiddleware) - if apiKey, exists := c.Get("apiKey"); exists { + if apiKey, exists := c.Get("userApiKey"); exists { if keyStr, ok := apiKey.(string); ok && keyStr != "" { // Inject into request context for SecretSource.Get(ctx) to read ctx := context.WithValue(c.Request.Context(), clientAPIKeyContextKey{}, keyStr) diff --git a/internal/api/modules/amp/routes_test.go b/internal/api/modules/amp/routes_test.go index 2308a153bb..a500f8150c 100644 --- a/internal/api/modules/amp/routes_test.go +++ b/internal/api/modules/amp/routes_test.go @@ -6,7 +6,7 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" ) func TestRegisterManagementRoutes(t *testing.T) { diff --git a/internal/api/modules/amp/secret.go b/internal/api/modules/amp/secret.go index f91c72ba9c..512d263d0c 100644 --- a/internal/api/modules/amp/secret.go +++ b/internal/api/modules/amp/secret.go @@ -10,7 +10,7 @@ import ( "sync" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" log "github.com/sirupsen/logrus" ) diff --git a/internal/api/modules/amp/secret_test.go b/internal/api/modules/amp/secret_test.go index 6a6f6ba265..17a75b15de 100644 --- a/internal/api/modules/amp/secret_test.go +++ b/internal/api/modules/amp/secret_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" log "github.com/sirupsen/logrus" "github.com/sirupsen/logrus/hooks/test" ) diff --git a/internal/api/modules/modules.go b/internal/api/modules/modules.go index 8c5447d96d..5ddfa609c8 100644 --- a/internal/api/modules/modules.go +++ b/internal/api/modules/modules.go @@ -6,8 +6,8 @@ import ( "fmt" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" ) // Context encapsulates the dependencies exposed to routing modules during diff --git a/internal/api/protocol_multiplexer.go b/internal/api/protocol_multiplexer.go index 14068dc556..b83e1164cf 100644 --- a/internal/api/protocol_multiplexer.go +++ b/internal/api/protocol_multiplexer.go @@ -83,6 +83,12 @@ func (s *Server) acceptMuxConnections(listener net.Listener, httpListener *muxLi } if isRedisRESPPrefix(prefix[0]) { + if s.cfg != nil && s.cfg.Home.Enabled { + if errClose := conn.Close(); errClose != nil { + log.Errorf("failed to close redis connection while home mode is enabled: %v", errClose) + } + continue + } if !s.managementRoutesEnabled.Load() { if errClose := conn.Close(); errClose != nil { log.Errorf("failed to close redis connection while management is disabled: %v", errClose) diff --git a/internal/api/redis_queue_protocol.go b/internal/api/redis_queue_protocol.go index caaba2316d..6f3622d7bf 100644 --- a/internal/api/redis_queue_protocol.go +++ b/internal/api/redis_queue_protocol.go @@ -10,7 +10,7 @@ import ( "strconv" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue" + "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue" log "github.com/sirupsen/logrus" ) @@ -45,6 +45,12 @@ func (s *Server) handleRedisConnection(conn net.Conn, reader *bufio.Reader) { return true } + if s.cfg != nil && s.cfg.Home.Enabled { + _ = writeRedisError(writer, "ERR redis usage output disabled in home mode") + _ = writer.Flush() + return + } + for { if !s.managementRoutesEnabled.Load() { return diff --git a/internal/api/redis_queue_protocol_integration_test.go b/internal/api/redis_queue_protocol_integration_test.go index 93bfeb8663..1586d37c85 100644 --- a/internal/api/redis_queue_protocol_integration_test.go +++ b/internal/api/redis_queue_protocol_integration_test.go @@ -12,7 +12,7 @@ import ( "testing" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue" + "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue" ) type remoteAddrConn struct { @@ -204,6 +204,43 @@ func TestRedisProtocol_ManagementDisabled_RejectsConnection(t *testing.T) { } } +func TestRedisProtocol_HomeEnabled_DisablesConnection(t *testing.T) { + t.Setenv("MANAGEMENT_PASSWORD", "test-management-password") + redisqueue.SetEnabled(false) + t.Cleanup(func() { redisqueue.SetEnabled(false) }) + + server := newTestServer(t) + if !server.managementRoutesEnabled.Load() { + t.Fatalf("expected managementRoutesEnabled to be true") + } + if server.cfg == nil { + t.Fatalf("expected server cfg to be non-nil") + } + server.cfg.Home.Enabled = true + redisqueue.SetEnabled(true) + + addr, stop := startRedisMuxListener(t, server) + t.Cleanup(stop) + + conn, errDial := net.DialTimeout("tcp", addr, time.Second) + if errDial != nil { + t.Fatalf("failed to dial redis listener: %v", errDial) + } + t.Cleanup(func() { _ = conn.Close() }) + + _ = conn.SetDeadline(time.Now().Add(2 * time.Second)) + _ = writeTestRESPCommand(conn, "PING") + + buf := make([]byte, 1) + _, errRead := conn.Read(buf) + if errRead == nil { + t.Fatalf("expected connection to be closed when home mode is enabled") + } + if ne, ok := errRead.(net.Error); ok && ne.Timeout() { + t.Fatalf("expected connection to be closed when home mode is enabled, got timeout: %v", errRead) + } +} + func TestRedisProtocol_AUTH_And_PopContracts(t *testing.T) { const managementPassword = "test-management-password" diff --git a/internal/api/server.go b/internal/api/server.go index 487ea571e6..1e29580fd3 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -8,6 +8,7 @@ import ( "context" "crypto/subtle" "crypto/tls" + "encoding/json" "errors" "fmt" "net" @@ -15,30 +16,32 @@ import ( "os" "path/filepath" "reflect" + "sort" "strings" "sync" "sync/atomic" "time" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/access" - managementHandlers "github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/management" - "github.com/router-for-me/CLIProxyAPI/v6/internal/api/middleware" - "github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules" - ampmodule "github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules/amp" - "github.com/router-for-me/CLIProxyAPI/v6/internal/cache" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" - "github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset" - "github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/claude" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/gemini" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/openai" - sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/access" + managementHandlers "github.com/router-for-me/CLIProxyAPI/v7/internal/api/handlers/management" + "github.com/router-for-me/CLIProxyAPI/v7/internal/api/middleware" + "github.com/router-for-me/CLIProxyAPI/v7/internal/api/modules" + ampmodule "github.com/router-for-me/CLIProxyAPI/v7/internal/api/modules/amp" + "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/home" + "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" + "github.com/router-for-me/CLIProxyAPI/v7/internal/managementasset" + "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/claude" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/gemini" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/openai" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" "golang.org/x/net/http2" "gopkg.in/yaml.v3" @@ -284,6 +287,10 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk } s.localPassword = optionState.localPassword + // Home heartbeat gate: when home is enabled, block all endpoints with 503 until the + // subscribe-config heartbeat connection is healthy. + engine.Use(s.homeHeartbeatMiddleware()) + // Setup routes s.setupRoutes() @@ -308,7 +315,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk // or when a local management password is provided (e.g. TUI mode). hasManagementSecret := cfg.RemoteManagement.SecretKey != "" || envManagementSecret || s.localPassword != "" s.managementRoutesEnabled.Store(hasManagementSecret) - redisqueue.SetEnabled(hasManagementSecret) + redisqueue.SetEnabled(hasManagementSecret || (cfg != nil && cfg.Home.Enabled)) if hasManagementSecret { s.registerManagementRoutes() } @@ -326,6 +333,28 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk return s } +func (s *Server) homeHeartbeatMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + if s == nil || s.cfg == nil || !s.cfg.Home.Enabled { + c.Next() + return + } + if c != nil && c.Request != nil { + path := c.Request.URL.Path + if strings.HasPrefix(path, "/v0/management/") || path == "/v0/management" || path == "/management.html" { + c.Next() + return + } + } + client := home.Current() + if client == nil || !client.HeartbeatOK() { + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } + c.Next() + } +} + // setupRoutes configures the API routes for the server. // It defines the endpoints and associates them with their respective handlers. func (s *Server) setupRoutes() { @@ -661,6 +690,14 @@ func (s *Server) registerManagementRoutes() { func (s *Server) managementAvailabilityMiddleware() gin.HandlerFunc { return func(c *gin.Context) { + if s == nil || s.cfg == nil { + c.AbortWithStatus(http.StatusNotFound) + return + } + if s.cfg.Home.Enabled { + c.AbortWithStatus(http.StatusNotFound) + return + } if !s.managementRoutesEnabled.Load() { c.AbortWithStatus(http.StatusNotFound) return @@ -671,7 +708,7 @@ func (s *Server) managementAvailabilityMiddleware() gin.HandlerFunc { func (s *Server) serveManagementControlPanel(c *gin.Context) { cfg := s.cfg - if cfg == nil || cfg.RemoteManagement.DisableControlPanel { + if cfg == nil || cfg.Home.Enabled || cfg.RemoteManagement.DisableControlPanel { c.AbortWithStatus(http.StatusNotFound) return } @@ -783,6 +820,11 @@ func (s *Server) watchKeepAlive() { // otherwise it routes to OpenAI handler. func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, claudeHandler *claude.ClaudeCodeAPIHandler) gin.HandlerFunc { return func(c *gin.Context) { + if s != nil && s.cfg != nil && s.cfg.Home.Enabled { + s.handleHomeModels(c) + return + } + userAgent := c.GetHeader("User-Agent") // Route to Claude handler if User-Agent starts with "claude-cli" @@ -796,6 +838,170 @@ func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, cl } } +type homeModelEntry struct { + id string + created int64 + ownedBy string + displayName string +} + +func (s *Server) handleHomeModels(c *gin.Context) { + if s == nil || c == nil || c.Request == nil { + return + } + client := home.Current() + if client == nil { + c.JSON(http.StatusServiceUnavailable, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: "home control center unavailable", + Type: "server_error", + }, + }) + return + } + + raw, errGet := client.GetModels(c.Request.Context()) + if errGet != nil { + c.JSON(http.StatusBadGateway, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: errGet.Error(), + Type: "server_error", + }, + }) + return + } + + entries, errDecode := decodeHomeModels(raw) + if errDecode != nil { + c.JSON(http.StatusBadGateway, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: errDecode.Error(), + Type: "server_error", + }, + }) + return + } + + userAgent := c.GetHeader("User-Agent") + isClaude := strings.HasPrefix(userAgent, "claude-cli") + + if isClaude { + out := make([]map[string]any, 0, len(entries)) + for _, entry := range entries { + model := map[string]any{ + "id": entry.id, + "object": "model", + "owned_by": entry.ownedBy, + } + if entry.created > 0 { + model["created_at"] = entry.created + } + if entry.displayName != "" { + model["display_name"] = entry.displayName + } + out = append(out, model) + } + firstID := "" + lastID := "" + if len(out) > 0 { + if id, ok := out[0]["id"].(string); ok { + firstID = id + } + if id, ok := out[len(out)-1]["id"].(string); ok { + lastID = id + } + } + c.JSON(http.StatusOK, gin.H{ + "data": out, + "has_more": false, + "first_id": firstID, + "last_id": lastID, + }) + return + } + + filtered := make([]map[string]any, 0, len(entries)) + for _, entry := range entries { + model := map[string]any{ + "id": entry.id, + "object": "model", + } + if entry.created > 0 { + model["created"] = entry.created + } + if entry.ownedBy != "" { + model["owned_by"] = entry.ownedBy + } + filtered = append(filtered, model) + } + c.JSON(http.StatusOK, gin.H{ + "object": "list", + "data": filtered, + }) +} + +func decodeHomeModels(raw []byte) ([]homeModelEntry, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("home models payload is empty") + } + + var bySection map[string][]map[string]any + if err := json.Unmarshal(raw, &bySection); err != nil { + return nil, fmt.Errorf("parse home models payload: %w", err) + } + if len(bySection) == 0 { + return nil, fmt.Errorf("home models payload has no sections") + } + + seen := make(map[string]struct{}) + out := make([]homeModelEntry, 0, 256) + for _, models := range bySection { + for _, model := range models { + id, _ := model["id"].(string) + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + + created := int64(0) + switch v := model["created"].(type) { + case float64: + created = int64(v) + case int64: + created = v + case int: + created = int64(v) + case json.Number: + if n, err := v.Int64(); err == nil { + created = n + } + } + + ownedBy, _ := model["owned_by"].(string) + ownedBy = strings.TrimSpace(ownedBy) + displayName, _ := model["display_name"].(string) + displayName = strings.TrimSpace(displayName) + + out = append(out, homeModelEntry{ + id: id, + created: created, + ownedBy: ownedBy, + displayName: displayName, + }) + } + } + + sort.Slice(out, func(i, j int) bool { return out[i].id < out[j].id }) + if len(out) == 0 { + return nil, fmt.Errorf("home models payload contains no models") + } + return out, nil +} + // Start begins listening for and serving HTTP or HTTPS requests. // It's a blocking call and will only return on an unrecoverable error. // @@ -1061,7 +1267,7 @@ func (s *Server) UpdateClients(cfg *config.Config) { s.managementRoutesEnabled.Store(!newSecretEmpty) } } - redisqueue.SetEnabled(s.managementRoutesEnabled.Load()) + redisqueue.SetEnabled(s.managementRoutesEnabled.Load() || (cfg != nil && cfg.Home.Enabled)) s.applyAccessConfig(oldCfg, cfg) s.cfg = cfg @@ -1094,11 +1300,14 @@ func (s *Server) UpdateClients(cfg *config.Config) { } // Count client sources from configuration and auth store. - tokenStore := sdkAuth.GetTokenStore() - if dirSetter, ok := tokenStore.(interface{ SetBaseDir(string) }); ok { - dirSetter.SetBaseDir(cfg.AuthDir) + authEntries := 0 + if cfg != nil && !cfg.Home.Enabled { + tokenStore := sdkAuth.GetTokenStore() + if dirSetter, ok := tokenStore.(interface{ SetBaseDir(string) }); ok { + dirSetter.SetBaseDir(cfg.AuthDir) + } + authEntries = util.CountAuthFiles(context.Background(), tokenStore) } - authEntries := util.CountAuthFiles(context.Background(), tokenStore) geminiAPIKeyCount := len(cfg.GeminiKey) claudeAPIKeyCount := len(cfg.ClaudeKey) codexAPIKeyCount := len(cfg.CodexKey) @@ -1146,7 +1355,7 @@ func AuthMiddleware(manager *sdkaccess.Manager) gin.HandlerFunc { result, err := manager.Authenticate(c.Request.Context(), c.Request) if err == nil { if result != nil { - c.Set("apiKey", result.Principal) + c.Set("userApiKey", result.Principal) c.Set("accessProvider", result.Provider) if len(result.Metadata) > 0 { c.Set("accessMetadata", result.Metadata) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index fe37cb72ef..e107702a88 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -11,12 +11,12 @@ import ( "time" gin "github.com/gin-gonic/gin" - proxyconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - internallogging "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" - "github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue" - sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + proxyconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + internallogging "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" + "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue" + sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) func newTestServer(t *testing.T) *Server { @@ -147,6 +147,32 @@ func TestManagementUsageRequiresManagementAuthAndPopsArray(t *testing.T) { } } +func TestHomeEnabledHidesManagementEndpointsAndControlPanel(t *testing.T) { + t.Setenv("MANAGEMENT_PASSWORD", "test-management-key") + + server := newTestServer(t) + server.cfg.Home.Enabled = true + + t.Run("management endpoints return 404", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v0/management/config", nil) + req.Header.Set("Authorization", "Bearer test-management-key") + rr := httptest.NewRecorder() + server.engine.ServeHTTP(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusNotFound, rr.Body.String()) + } + }) + + t.Run("management control panel returns 404", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/management.html", nil) + rr := httptest.NewRecorder() + server.engine.ServeHTTP(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusNotFound, rr.Body.String()) + } + }) +} + func TestAmpProviderModelRoutes(t *testing.T) { testCases := []struct { name string diff --git a/internal/auth/antigravity/auth.go b/internal/auth/antigravity/auth.go index 8d3b216fbc..7bee09bb66 100644 --- a/internal/auth/antigravity/auth.go +++ b/internal/auth/antigravity/auth.go @@ -11,9 +11,9 @@ import ( "strings" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" ) diff --git a/internal/auth/claude/anthropic_auth.go b/internal/auth/claude/anthropic_auth.go index 60c71b3512..d7ca154296 100644 --- a/internal/auth/claude/anthropic_auth.go +++ b/internal/auth/claude/anthropic_auth.go @@ -15,7 +15,7 @@ import ( "sync" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" log "github.com/sirupsen/logrus" "golang.org/x/sync/singleflight" ) diff --git a/internal/auth/claude/anthropic_auth_proxy_test.go b/internal/auth/claude/anthropic_auth_proxy_test.go index 50c4875791..7cab9cd2f1 100644 --- a/internal/auth/claude/anthropic_auth_proxy_test.go +++ b/internal/auth/claude/anthropic_auth_proxy_test.go @@ -3,7 +3,7 @@ package claude import ( "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "golang.org/x/net/proxy" ) diff --git a/internal/auth/claude/token.go b/internal/auth/claude/token.go index 6ebb0f2f8c..10aa3b4344 100644 --- a/internal/auth/claude/token.go +++ b/internal/auth/claude/token.go @@ -9,7 +9,7 @@ import ( "os" "path/filepath" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" ) // ClaudeTokenStorage stores OAuth2 token information for Anthropic Claude API authentication. diff --git a/internal/auth/claude/utls_transport.go b/internal/auth/claude/utls_transport.go index 88b69c9bd9..f41087819f 100644 --- a/internal/auth/claude/utls_transport.go +++ b/internal/auth/claude/utls_transport.go @@ -8,8 +8,8 @@ import ( "sync" tls "github.com/refraction-networking/utls" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil" log "github.com/sirupsen/logrus" "golang.org/x/net/http2" "golang.org/x/net/proxy" diff --git a/internal/auth/codex/openai_auth.go b/internal/auth/codex/openai_auth.go index 67b54b172d..681747caf5 100644 --- a/internal/auth/codex/openai_auth.go +++ b/internal/auth/codex/openai_auth.go @@ -14,8 +14,8 @@ import ( "strings" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" ) diff --git a/internal/auth/codex/openai_auth_test.go b/internal/auth/codex/openai_auth_test.go index a7fe83072d..e7d939b0a3 100644 --- a/internal/auth/codex/openai_auth_test.go +++ b/internal/auth/codex/openai_auth_test.go @@ -8,7 +8,7 @@ import ( "sync/atomic" "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) type roundTripFunc func(*http.Request) (*http.Response, error) diff --git a/internal/auth/codex/token.go b/internal/auth/codex/token.go index 7f03207195..b2a7bcf21a 100644 --- a/internal/auth/codex/token.go +++ b/internal/auth/codex/token.go @@ -9,7 +9,7 @@ import ( "os" "path/filepath" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" ) // CodexTokenStorage stores OAuth2 token information for OpenAI Codex API authentication. diff --git a/internal/auth/gemini/gemini_auth.go b/internal/auth/gemini/gemini_auth.go index 2995a1cb5e..5b9ee82d26 100644 --- a/internal/auth/gemini/gemini_auth.go +++ b/internal/auth/gemini/gemini_auth.go @@ -13,12 +13,12 @@ import ( "net/http" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex" - "github.com/router-for-me/CLIProxyAPI/v6/internal/browser" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil" + "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex" + "github.com/router-for-me/CLIProxyAPI/v7/internal/browser" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" diff --git a/internal/auth/gemini/gemini_token.go b/internal/auth/gemini/gemini_token.go index 6848b708e2..a6ea8c5151 100644 --- a/internal/auth/gemini/gemini_token.go +++ b/internal/auth/gemini/gemini_token.go @@ -10,7 +10,7 @@ import ( "path/filepath" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" log "github.com/sirupsen/logrus" ) diff --git a/internal/auth/kimi/kimi.go b/internal/auth/kimi/kimi.go index ccb1a6c2ff..27c5f73b42 100644 --- a/internal/auth/kimi/kimi.go +++ b/internal/auth/kimi/kimi.go @@ -15,8 +15,8 @@ import ( "time" "github.com/google/uuid" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" ) diff --git a/internal/auth/kimi/kimi_proxy_test.go b/internal/auth/kimi/kimi_proxy_test.go index 130f34f52b..a95ba01dba 100644 --- a/internal/auth/kimi/kimi_proxy_test.go +++ b/internal/auth/kimi/kimi_proxy_test.go @@ -4,7 +4,7 @@ import ( "net/http" "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) func TestNewDeviceFlowClientWithDeviceIDAndProxyURL_OverrideDirectDisablesProxy(t *testing.T) { diff --git a/internal/auth/kimi/token.go b/internal/auth/kimi/token.go index 7320d760ef..347b546cbd 100644 --- a/internal/auth/kimi/token.go +++ b/internal/auth/kimi/token.go @@ -10,7 +10,7 @@ import ( "path/filepath" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" ) // KimiTokenStorage stores OAuth2 token information for Kimi API authentication. diff --git a/internal/auth/vertex/vertex_credentials.go b/internal/auth/vertex/vertex_credentials.go index 9f830994ed..db214bd6e2 100644 --- a/internal/auth/vertex/vertex_credentials.go +++ b/internal/auth/vertex/vertex_credentials.go @@ -8,7 +8,7 @@ import ( "os" "path/filepath" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" log "github.com/sirupsen/logrus" ) diff --git a/internal/cmd/anthropic_login.go b/internal/cmd/anthropic_login.go index f7381461a6..cc1bfc8e7c 100644 --- a/internal/cmd/anthropic_login.go +++ b/internal/cmd/anthropic_login.go @@ -6,9 +6,9 @@ import ( "fmt" "os" - "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth" log "github.com/sirupsen/logrus" ) diff --git a/internal/cmd/antigravity_login.go b/internal/cmd/antigravity_login.go index 2efbaeee01..f2bd5505a2 100644 --- a/internal/cmd/antigravity_login.go +++ b/internal/cmd/antigravity_login.go @@ -4,8 +4,8 @@ import ( "context" "fmt" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth" log "github.com/sirupsen/logrus" ) diff --git a/internal/cmd/auth_manager.go b/internal/cmd/auth_manager.go index 2654717901..7896a7023a 100644 --- a/internal/cmd/auth_manager.go +++ b/internal/cmd/auth_manager.go @@ -1,7 +1,7 @@ package cmd import ( - sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth" ) // newAuthManager creates a new authentication manager instance with all supported diff --git a/internal/cmd/kimi_login.go b/internal/cmd/kimi_login.go index eb5f11fb37..ffc470fda0 100644 --- a/internal/cmd/kimi_login.go +++ b/internal/cmd/kimi_login.go @@ -4,8 +4,8 @@ import ( "context" "fmt" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth" log "github.com/sirupsen/logrus" ) diff --git a/internal/cmd/login.go b/internal/cmd/login.go index 22404dac9c..a71bb28263 100644 --- a/internal/cmd/login.go +++ b/internal/cmd/login.go @@ -17,12 +17,12 @@ import ( "strings" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" - sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/gemini" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" ) diff --git a/internal/cmd/openai_device_login.go b/internal/cmd/openai_device_login.go index 1b7351e63a..3fa9307b9c 100644 --- a/internal/cmd/openai_device_login.go +++ b/internal/cmd/openai_device_login.go @@ -6,9 +6,9 @@ import ( "fmt" "os" - "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth" log "github.com/sirupsen/logrus" ) diff --git a/internal/cmd/openai_login.go b/internal/cmd/openai_login.go index 783a948400..ee8a025067 100644 --- a/internal/cmd/openai_login.go +++ b/internal/cmd/openai_login.go @@ -6,9 +6,9 @@ import ( "fmt" "os" - "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth" log "github.com/sirupsen/logrus" ) diff --git a/internal/cmd/run.go b/internal/cmd/run.go index d8c4f01938..38f189b4a9 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -10,9 +10,9 @@ import ( "syscall" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/api" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy" + "github.com/router-for-me/CLIProxyAPI/v7/internal/api" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy" log "github.com/sirupsen/logrus" ) diff --git a/internal/cmd/vertex_import.go b/internal/cmd/vertex_import.go index 4aa0d74b59..ffb6200b1a 100644 --- a/internal/cmd/vertex_import.go +++ b/internal/cmd/vertex_import.go @@ -9,11 +9,11 @@ import ( "os" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/vertex" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/vertex" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" ) diff --git a/internal/config/config.go b/internal/config/config.go index 46ce4f5099..e09f38a8bf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,7 +13,7 @@ import ( "strings" "syscall" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" log "github.com/sirupsen/logrus" "golang.org/x/crypto/bcrypt" "gopkg.in/yaml.v3" @@ -36,6 +36,9 @@ type Config struct { // TLS config controls HTTPS server settings. TLS TLSConfig `yaml:"tls" json:"tls"` + // Home config enables the Redis-based control plane integration. + Home HomeConfig `yaml:"home" json:"-"` + // RemoteManagement nests management-related options under 'remote-management'. RemoteManagement RemoteManagement `yaml:"remote-management" json:"-"` diff --git a/internal/config/home.go b/internal/config/home.go new file mode 100644 index 0000000000..03c9173239 --- /dev/null +++ b/internal/config/home.go @@ -0,0 +1,9 @@ +package config + +// HomeConfig configures the optional "home" control plane integration over Redis protocol. +type HomeConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Host string `yaml:"host" json:"-"` + Port int `yaml:"port" json:"-"` + Password string `yaml:"password" json:"-"` +} diff --git a/internal/config/parse.go b/internal/config/parse.go new file mode 100644 index 0000000000..283740e5f0 --- /dev/null +++ b/internal/config/parse.go @@ -0,0 +1,89 @@ +package config + +import ( + "fmt" + "strings" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/bcrypt" + "gopkg.in/yaml.v3" +) + +// ParseConfigBytes parses a YAML configuration payload into Config and applies the same +// in-memory normalizations as LoadConfigOptional, without persisting any changes to disk. +func ParseConfigBytes(data []byte) (*Config, error) { + if len(data) == 0 { + return nil, fmt.Errorf("config payload is empty") + } + + var cfg Config + // Keep defaults aligned with LoadConfigOptional. + cfg.Host = "" // Default empty: binds to all interfaces (IPv4 + IPv6) + cfg.LoggingToFile = false + cfg.LogsMaxTotalSizeMB = 0 + cfg.ErrorLogsMaxFiles = 10 + cfg.UsageStatisticsEnabled = false + cfg.RedisUsageQueueRetentionSeconds = 60 + cfg.DisableCooling = false + cfg.DisableImageGeneration = DisableImageGenerationOff + cfg.Pprof.Enable = false + cfg.Pprof.Addr = DefaultPprofAddr + cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient + cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository + + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config payload: %w", err) + } + + // Hash remote management key if plaintext is detected (nested), but do NOT persist. + if cfg.RemoteManagement.SecretKey != "" && !looksLikeBcrypt(cfg.RemoteManagement.SecretKey) { + hashed, errHash := bcrypt.GenerateFromPassword([]byte(cfg.RemoteManagement.SecretKey), bcrypt.DefaultCost) + if errHash != nil { + return nil, fmt.Errorf("hash remote management key: %w", errHash) + } + cfg.RemoteManagement.SecretKey = string(hashed) + } + + cfg.RemoteManagement.PanelGitHubRepository = strings.TrimSpace(cfg.RemoteManagement.PanelGitHubRepository) + if cfg.RemoteManagement.PanelGitHubRepository == "" { + cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository + } + + cfg.Pprof.Addr = strings.TrimSpace(cfg.Pprof.Addr) + if cfg.Pprof.Addr == "" { + cfg.Pprof.Addr = DefaultPprofAddr + } + + if cfg.LogsMaxTotalSizeMB < 0 { + cfg.LogsMaxTotalSizeMB = 0 + } + + if cfg.ErrorLogsMaxFiles < 0 { + cfg.ErrorLogsMaxFiles = 10 + } + + if cfg.RedisUsageQueueRetentionSeconds <= 0 { + cfg.RedisUsageQueueRetentionSeconds = 60 + } else if cfg.RedisUsageQueueRetentionSeconds > 3600 { + log.WithField("value", cfg.RedisUsageQueueRetentionSeconds).Warn("redis-usage-queue-retention-seconds too large; clamping to 3600") + cfg.RedisUsageQueueRetentionSeconds = 3600 + } + + if cfg.MaxRetryCredentials < 0 { + cfg.MaxRetryCredentials = 0 + } + + // Apply the same sanitization pipeline. + cfg.SanitizeGeminiKeys() + cfg.SanitizeVertexCompatKeys() + cfg.SanitizeCodexKeys() + cfg.SanitizeCodexHeaderDefaults() + cfg.SanitizeClaudeHeaderDefaults() + cfg.SanitizeClaudeKeys() + cfg.SanitizeOpenAICompatibility() + cfg.OAuthExcludedModels = NormalizeOAuthExcludedModels(cfg.OAuthExcludedModels) + cfg.SanitizeOAuthModelAlias() + cfg.SanitizePayloadRules() + + return &cfg, nil +} diff --git a/internal/home/client.go b/internal/home/client.go new file mode 100644 index 0000000000..22a18b32b9 --- /dev/null +++ b/internal/home/client.go @@ -0,0 +1,374 @@ +package home + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync/atomic" + "time" + + "github.com/redis/go-redis/v9" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + log "github.com/sirupsen/logrus" +) + +const ( + redisKeyConfig = "config" + redisChannelConfig = "config" + redisKeyModels = "models" + redisKeyUsage = "usage" + + homeReconnectInterval = time.Second +) + +var ( + ErrDisabled = errors.New("home client disabled") + ErrNotConnected = errors.New("home not connected") + ErrEmptyResponse = errors.New("home returned empty response") + ErrAuthNotFound = errors.New("home auth not found") + ErrConfigNotFound = errors.New("home config not found") + ErrModelsNotFound = errors.New("home models not found") +) + +type Client struct { + homeCfg config.HomeConfig + + cmd *redis.Client + sub *redis.Client + + heartbeatOK atomic.Bool +} + +func New(homeCfg config.HomeConfig) *Client { + return &Client{homeCfg: homeCfg} +} + +func (c *Client) Enabled() bool { + if c == nil { + return false + } + return c.homeCfg.Enabled +} + +func (c *Client) HeartbeatOK() bool { + if c == nil { + return false + } + if !c.Enabled() { + return false + } + return c.heartbeatOK.Load() +} + +func (c *Client) Close() { + if c == nil { + return + } + c.heartbeatOK.Store(false) + if c.cmd != nil { + _ = c.cmd.Close() + } + if c.sub != nil { + _ = c.sub.Close() + } + c.cmd = nil + c.sub = nil +} + +func (c *Client) addr() (string, bool) { + if c == nil { + return "", false + } + host := strings.TrimSpace(c.homeCfg.Host) + if host == "" { + return "", false + } + if c.homeCfg.Port <= 0 { + return "", false + } + return fmt.Sprintf("%s:%d", host, c.homeCfg.Port), true +} + +func (c *Client) ensureClients() error { + if c == nil { + return ErrDisabled + } + if !c.Enabled() { + return ErrDisabled + } + addr, ok := c.addr() + if !ok { + return fmt.Errorf("home: invalid address (host=%q port=%d)", c.homeCfg.Host, c.homeCfg.Port) + } + + if c.cmd == nil { + c.cmd = redis.NewClient(&redis.Options{ + Addr: addr, + Password: c.homeCfg.Password, + }) + } + if c.sub == nil { + c.sub = redis.NewClient(&redis.Options{ + Addr: addr, + Password: c.homeCfg.Password, + }) + } + return nil +} + +func (c *Client) Ping(ctx context.Context) error { + if err := c.ensureClients(); err != nil { + return err + } + if c.cmd == nil { + return ErrNotConnected + } + return c.cmd.Ping(ctx).Err() +} + +func (c *Client) GetConfig(ctx context.Context) ([]byte, error) { + if err := c.ensureClients(); err != nil { + return nil, err + } + raw, err := c.cmd.Get(ctx, redisKeyConfig).Bytes() + if errors.Is(err, redis.Nil) { + return nil, ErrConfigNotFound + } + if err != nil { + return nil, err + } + if len(raw) == 0 { + return nil, ErrEmptyResponse + } + return raw, nil +} + +func (c *Client) GetModels(ctx context.Context) ([]byte, error) { + if err := c.ensureClients(); err != nil { + return nil, err + } + raw, err := c.cmd.Get(ctx, redisKeyModels).Bytes() + if errors.Is(err, redis.Nil) { + return nil, ErrModelsNotFound + } + if err != nil { + return nil, err + } + if len(raw) == 0 { + return nil, ErrEmptyResponse + } + return raw, nil +} + +func headersToLowerMap(headers http.Header) map[string]string { + if len(headers) == 0 { + return nil + } + out := make(map[string]string, len(headers)) + for key, values := range headers { + k := strings.ToLower(strings.TrimSpace(key)) + if k == "" { + continue + } + if len(values) == 0 { + out[k] = "" + continue + } + trimmed := make([]string, 0, len(values)) + for _, v := range values { + trimmed = append(trimmed, strings.TrimSpace(v)) + } + out[k] = strings.Join(trimmed, ", ") + } + if len(out) == 0 { + return nil + } + return out +} + +func (c *Client) RPopAuth(ctx context.Context, requestedModel string, sessionID string, headers http.Header) ([]byte, error) { + if err := c.ensureClients(); err != nil { + return nil, err + } + requestedModel = strings.TrimSpace(requestedModel) + if requestedModel == "" { + return nil, fmt.Errorf("home: requested model is empty") + } + req := authDispatchRequest{ + Type: "auth", + Model: requestedModel, + SessionID: strings.TrimSpace(sessionID), + Headers: headersToLowerMap(headers), + } + keyBytes, err := json.Marshal(&req) + if err != nil { + return nil, err + } + + raw, err := c.cmd.RPop(ctx, string(keyBytes)).Bytes() + if errors.Is(err, redis.Nil) { + return nil, ErrAuthNotFound + } + if err != nil { + return nil, err + } + if len(raw) == 0 { + return nil, ErrEmptyResponse + } + return raw, nil +} + +func (c *Client) GetRefreshAuth(ctx context.Context, authIndex string) ([]byte, error) { + if err := c.ensureClients(); err != nil { + return nil, err + } + authIndex = strings.TrimSpace(authIndex) + if authIndex == "" { + return nil, fmt.Errorf("home: auth_index is empty") + } + req := refreshRequest{ + Type: "refresh", + AuthIndex: authIndex, + } + keyBytes, err := json.Marshal(&req) + if err != nil { + return nil, err + } + + raw, err := c.cmd.Get(ctx, string(keyBytes)).Bytes() + if errors.Is(err, redis.Nil) { + return nil, ErrAuthNotFound + } + if err != nil { + return nil, err + } + if len(raw) == 0 { + return nil, ErrEmptyResponse + } + return raw, nil +} + +func (c *Client) LPushUsage(ctx context.Context, payload []byte) error { + if err := c.ensureClients(); err != nil { + return err + } + if len(payload) == 0 { + return nil + } + return c.cmd.LPush(ctx, redisKeyUsage, payload).Err() +} + +// StartConfigSubscriber connects to home, fetches config once via GET config, then subscribes to +// the "config" channel to receive runtime config updates. +// +// The subscription connection is treated as the home heartbeat. HeartbeatOK is set to true only +// after the initial GET config succeeds and the SUBSCRIBE connection is established. When the +// subscription ends unexpectedly, HeartbeatOK becomes false and the loop reconnects. +func (c *Client) StartConfigSubscriber(ctx context.Context, onConfig func([]byte) error) { + if c == nil { + return + } + if !c.Enabled() { + return + } + if onConfig == nil { + return + } + + for { + if ctx != nil { + select { + case <-ctx.Done(): + c.heartbeatOK.Store(false) + return + default: + } + } + + c.heartbeatOK.Store(false) + c.Close() + + if errEnsure := c.ensureClients(); errEnsure != nil { + log.Warn("unable to connect to home control center, retrying in 1 second") + sleepWithContext(ctx, homeReconnectInterval) + continue + } + + if errPing := c.Ping(ctx); errPing != nil { + log.Warn("unable to connect to home control center, retrying in 1 second") + sleepWithContext(ctx, homeReconnectInterval) + continue + } + + raw, errGet := c.GetConfig(ctx) + if errGet != nil { + log.Warn("unable to fetch config from home control center, retrying in 1 second") + sleepWithContext(ctx, homeReconnectInterval) + continue + } + if errApply := onConfig(raw); errApply != nil { + log.Warn("unable to apply config from home control center, retrying in 1 second") + sleepWithContext(ctx, homeReconnectInterval) + continue + } + + if c.sub == nil { + sleepWithContext(ctx, homeReconnectInterval) + continue + } + + pubsub := c.sub.Subscribe(ctx, redisChannelConfig) + if pubsub == nil { + sleepWithContext(ctx, homeReconnectInterval) + continue + } + + // Ensure the subscription is established before marking heartbeat OK. + if _, errReceive := pubsub.Receive(ctx); errReceive != nil { + _ = pubsub.Close() + sleepWithContext(ctx, homeReconnectInterval) + continue + } + + c.heartbeatOK.Store(true) + + for { + msg, errMsg := pubsub.ReceiveMessage(ctx) + if errMsg != nil { + _ = pubsub.Close() + c.heartbeatOK.Store(false) + sleepWithContext(ctx, homeReconnectInterval) + break + } + if msg == nil { + continue + } + if payload := strings.TrimSpace(msg.Payload); payload != "" { + if errApply := onConfig([]byte(payload)); errApply != nil { + log.Warn("failed to apply config update from home control center, ignoring") + } + } + } + } +} + +func sleepWithContext(ctx context.Context, d time.Duration) { + if d <= 0 { + return + } + timer := time.NewTimer(d) + defer timer.Stop() + if ctx == nil { + <-timer.C + return + } + select { + case <-ctx.Done(): + return + case <-timer.C: + return + } +} diff --git a/internal/home/global.go b/internal/home/global.go new file mode 100644 index 0000000000..a79121a487 --- /dev/null +++ b/internal/home/global.go @@ -0,0 +1,25 @@ +package home + +import "sync/atomic" + +var currentClient atomic.Value // *Client + +// SetCurrent sets the active home client used by runtime integrations. +func SetCurrent(client *Client) { + currentClient.Store(client) +} + +// Current returns the active home client instance, if any. +func Current() *Client { + if v := currentClient.Load(); v != nil { + if client, ok := v.(*Client); ok { + return client + } + } + return nil +} + +// ClearCurrent removes the active home client. +func ClearCurrent() { + currentClient.Store((*Client)(nil)) +} diff --git a/internal/home/requests.go b/internal/home/requests.go new file mode 100644 index 0000000000..d08f5a5d92 --- /dev/null +++ b/internal/home/requests.go @@ -0,0 +1,13 @@ +package home + +type authDispatchRequest struct { + Type string `json:"type"` + Model string `json:"model"` + SessionID string `json:"session_id,omitempty"` + Headers map[string]string `json:"headers,omitempty"` +} + +type refreshRequest struct { + Type string `json:"type"` + AuthIndex string `json:"auth_index"` +} diff --git a/internal/interfaces/types.go b/internal/interfaces/types.go index 9fb1e7f3b8..dfdfc02a84 100644 --- a/internal/interfaces/types.go +++ b/internal/interfaces/types.go @@ -3,7 +3,7 @@ // transformation operations, maintaining compatibility with the SDK translator package. package interfaces -import sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" +import sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" // Backwards compatible aliases for translator function types. type TranslateRequestFunc = sdktranslator.RequestTransform diff --git a/internal/logging/gin_logger.go b/internal/logging/gin_logger.go index 4d6d088c03..6e3559b8c3 100644 --- a/internal/logging/gin_logger.go +++ b/internal/logging/gin_logger.go @@ -12,7 +12,7 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" ) diff --git a/internal/logging/global_logger.go b/internal/logging/global_logger.go index 372222a545..4b4ef62c85 100644 --- a/internal/logging/global_logger.go +++ b/internal/logging/global_logger.go @@ -10,8 +10,8 @@ import ( "sync" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" "gopkg.in/natefinch/lumberjack.v2" ) diff --git a/internal/logging/request_logger.go b/internal/logging/request_logger.go index 2db2a504d3..d650212f5b 100644 --- a/internal/logging/request_logger.go +++ b/internal/logging/request_logger.go @@ -22,9 +22,9 @@ import ( "github.com/klauspost/compress/zstd" log "github.com/sirupsen/logrus" - "github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/buildinfo" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" ) var requestLogID atomic.Uint64 diff --git a/internal/managementasset/updater.go b/internal/managementasset/updater.go index ae2bc81956..ea7ca3f502 100644 --- a/internal/managementasset/updater.go +++ b/internal/managementasset/updater.go @@ -17,9 +17,9 @@ import ( "sync/atomic" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" log "github.com/sirupsen/logrus" "golang.org/x/sync/singleflight" ) diff --git a/internal/redisqueue/plugin.go b/internal/redisqueue/plugin.go index b33bc8fd95..8a99de83b0 100644 --- a/internal/redisqueue/plugin.go +++ b/internal/redisqueue/plugin.go @@ -6,8 +6,8 @@ import ( "strings" "time" - internallogging "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" - coreusage "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" + internallogging "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" + coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage" ) func init() { diff --git a/internal/redisqueue/plugin_test.go b/internal/redisqueue/plugin_test.go index 8dcade90ee..4d7cb4652a 100644 --- a/internal/redisqueue/plugin_test.go +++ b/internal/redisqueue/plugin_test.go @@ -9,8 +9,8 @@ import ( "time" "github.com/gin-gonic/gin" - internallogging "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" - coreusage "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" + internallogging "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" + coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage" ) func TestUsageQueuePluginPayloadIncludesStableFieldsAndSuccess(t *testing.T) { @@ -44,6 +44,7 @@ func TestUsageQueuePluginPayloadIncludesStableFieldsAndSuccess(t *testing.T) { requireStringField(t, payload, "alias", "client-gpt") requireStringField(t, payload, "endpoint", "POST /v1/chat/completions") requireStringField(t, payload, "auth_type", "apikey") + requireStringField(t, payload, "user_api_key", "test-key") requireStringField(t, payload, "request_id", "ctx-request-id") requireBoolField(t, payload, "failed", false) }) @@ -80,6 +81,7 @@ func TestUsageQueuePluginPayloadIncludesStableFieldsAndFailureAndGinRequestID(t requireStringField(t, payload, "alias", "client-mini") requireStringField(t, payload, "endpoint", "GET /v1/responses") requireStringField(t, payload, "auth_type", "apikey") + requireStringField(t, payload, "user_api_key", "test-key") requireStringField(t, payload, "request_id", "gin-request-id") requireBoolField(t, payload, "failed", true) }) @@ -123,6 +125,7 @@ func TestUsageQueuePluginAsyncIgnoresRecycledGinContext(t *testing.T) { payload := waitForSinglePayload(t, 2*time.Second) requireStringField(t, payload, "endpoint", "POST /v1/chat/completions") requireStringField(t, payload, "alias", "client-gpt") + requireStringField(t, payload, "user_api_key", "test-key") requireStringField(t, payload, "request_id", "ctx-request-id") requireBoolField(t, payload, "failed", true) }) diff --git a/internal/registry/model_registry.go b/internal/registry/model_registry.go index 3f3f530d27..4c215bb7af 100644 --- a/internal/registry/model_registry.go +++ b/internal/registry/model_registry.go @@ -11,7 +11,7 @@ import ( "sync" "time" - misc "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" + misc "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" log "github.com/sirupsen/logrus" ) diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index 37e85377b2..392109b5cd 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -13,14 +13,14 @@ import ( "net/url" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - "github.com/router-for-me/CLIProxyAPI/v6/internal/wsrelay" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/wsrelay" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -414,7 +414,10 @@ func (e *AIStudioExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.A } // Refresh refreshes the authentication credentials (no-op for AI Studio). -func (e *AIStudioExecutor) Refresh(_ context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { +func (e *AIStudioExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { + if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled { + return refreshed, err + } return auth, nil } diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 418ed7b1c5..84ff9de088 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -23,18 +23,18 @@ import ( "time" "github.com/google/uuid" - "github.com/router-for-me/CLIProxyAPI/v6/internal/cache" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - antigravityclaude "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/claude" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + antigravityclaude "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/claude" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -1402,6 +1402,9 @@ attemptLoop: // Refresh refreshes the authentication credentials using the refresh token. func (e *AntigravityExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { + if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled { + return refreshed, err + } if auth == nil { return auth, nil } @@ -1589,6 +1592,18 @@ func (e *AntigravityExecutor) ensureAccessToken(ctx context.Context, auth *clipr refreshCtx = context.WithValue(refreshCtx, "cliproxy.roundtripper", rt) } } + if refreshed, handled, err := helps.RefreshAuthViaHome(refreshCtx, e.cfg, auth); handled { + if err != nil { + return "", nil, err + } + token := metaStringValue(refreshed.Metadata, "access_token") + if strings.TrimSpace(token) == "" { + return "", nil, statusErr{code: http.StatusUnauthorized, msg: "missing access token"} + } + e.maybeRefreshAntigravityCreditsHint(ctx, refreshed, token) + return token, refreshed, nil + } + updated, errRefresh := e.refreshToken(refreshCtx, auth.Clone()) if errRefresh != nil { return "", nil, errRefresh diff --git a/internal/runtime/executor/antigravity_executor_buildrequest_test.go b/internal/runtime/executor/antigravity_executor_buildrequest_test.go index ed2d79e632..f0711752e4 100644 --- a/internal/runtime/executor/antigravity_executor_buildrequest_test.go +++ b/internal/runtime/executor/antigravity_executor_buildrequest_test.go @@ -6,7 +6,7 @@ import ( "io" "testing" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) func TestAntigravityBuildRequest_SanitizesGeminiToolSchema(t *testing.T) { diff --git a/internal/runtime/executor/antigravity_executor_credits_test.go b/internal/runtime/executor/antigravity_executor_credits_test.go index 4569f5dfd7..e16e64434f 100644 --- a/internal/runtime/executor/antigravity_executor_credits_test.go +++ b/internal/runtime/executor/antigravity_executor_credits_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" ) func resetAntigravityCreditsRetryState() { diff --git a/internal/runtime/executor/antigravity_executor_signature_test.go b/internal/runtime/executor/antigravity_executor_signature_test.go index 226daf5c67..7d84bfe890 100644 --- a/internal/runtime/executor/antigravity_executor_signature_test.go +++ b/internal/runtime/executor/antigravity_executor_signature_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/cache" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" ) func testGeminiSignaturePayload() string { diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index b22f4e4486..fe4f22f2e4 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -17,16 +17,16 @@ import ( "github.com/andybalholm/brotli" "github.com/google/uuid" "github.com/klauspost/compress/zstd" - claudeauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + claudeauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -691,6 +691,9 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut func (e *ClaudeExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { log.Debugf("claude executor: refresh called") + if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled { + return refreshed, err + } if auth == nil { return nil, fmt.Errorf("claude executor: auth is nil") } diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 2e91404405..f5bca55ab7 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -17,12 +17,12 @@ import ( "github.com/gin-gonic/gin" "github.com/klauspost/compress/zstd" xxHash64 "github.com/pierrec/xxHash/xxHash64" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/runtime/executor/claude_signing.go b/internal/runtime/executor/claude_signing.go index 697a688265..060e86e846 100644 --- a/internal/runtime/executor/claude_signing.go +++ b/internal/runtime/executor/claude_signing.go @@ -6,8 +6,8 @@ import ( "strings" xxHash64 "github.com/pierrec/xxHash/xxHash64" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 19cc8e7557..36c041b6e6 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -11,15 +11,15 @@ import ( "strings" "time" - codexauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" - "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + codexauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -693,6 +693,9 @@ func countCodexInputTokens(enc tokenizer.Codec, body []byte) (int64, error) { func (e *CodexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { log.Debugf("codex executor: refresh called") + if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled { + return refreshed, err + } if auth == nil { return nil, statusErr{code: 500, msg: "codex executor: auth is nil"} } diff --git a/internal/runtime/executor/codex_executor_cache_test.go b/internal/runtime/executor/codex_executor_cache_test.go index 7a24fd9643..cb96a90289 100644 --- a/internal/runtime/executor/codex_executor_cache_test.go +++ b/internal/runtime/executor/codex_executor_cache_test.go @@ -8,15 +8,15 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" "github.com/tidwall/gjson" ) func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFromAPIKey(t *testing.T) { recorder := httptest.NewRecorder() ginCtx, _ := gin.CreateTestContext(recorder) - ginCtx.Set("apiKey", "test-api-key") + ginCtx.Set("userApiKey", "test-api-key") ctx := context.WithValue(context.Background(), "gin", ginCtx) executor := &CodexExecutor{} diff --git a/internal/runtime/executor/codex_executor_compact_test.go b/internal/runtime/executor/codex_executor_compact_test.go index 02c6db29fd..549cad9e77 100644 --- a/internal/runtime/executor/codex_executor_compact_test.go +++ b/internal/runtime/executor/codex_executor_compact_test.go @@ -7,10 +7,10 @@ import ( "net/http/httptest" "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" "github.com/tidwall/gjson" ) diff --git a/internal/runtime/executor/codex_executor_imagegen_test.go b/internal/runtime/executor/codex_executor_imagegen_test.go index 1657209a91..89d2a1c2a3 100644 --- a/internal/runtime/executor/codex_executor_imagegen_test.go +++ b/internal/runtime/executor/codex_executor_imagegen_test.go @@ -3,7 +3,7 @@ package executor import ( "testing" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" "github.com/tidwall/gjson" ) diff --git a/internal/runtime/executor/codex_executor_instructions_test.go b/internal/runtime/executor/codex_executor_instructions_test.go index c5dc5aa813..b3c8ac18ac 100644 --- a/internal/runtime/executor/codex_executor_instructions_test.go +++ b/internal/runtime/executor/codex_executor_instructions_test.go @@ -7,10 +7,10 @@ import ( "net/http/httptest" "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" "github.com/tidwall/gjson" ) diff --git a/internal/runtime/executor/codex_executor_stream_output_test.go b/internal/runtime/executor/codex_executor_stream_output_test.go index a2da45e199..b814c3e96d 100644 --- a/internal/runtime/executor/codex_executor_stream_output_test.go +++ b/internal/runtime/executor/codex_executor_stream_output_test.go @@ -7,11 +7,11 @@ import ( "net/http/httptest" "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" "github.com/tidwall/gjson" ) diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go index 94b78b66d8..86078aacc9 100644 --- a/internal/runtime/executor/codex_websockets_executor.go +++ b/internal/runtime/executor/codex_websockets_executor.go @@ -18,15 +18,15 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/gorilla/websocket" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" - "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" diff --git a/internal/runtime/executor/codex_websockets_executor_store_test.go b/internal/runtime/executor/codex_websockets_executor_store_test.go index 1a23fa31b5..115ed066d2 100644 --- a/internal/runtime/executor/codex_websockets_executor_store_test.go +++ b/internal/runtime/executor/codex_websockets_executor_store_test.go @@ -3,7 +3,7 @@ package executor import ( "testing" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) func TestCodexWebsocketsExecutor_SessionStoreSurvivesExecutorReplacement(t *testing.T) { diff --git a/internal/runtime/executor/codex_websockets_executor_test.go b/internal/runtime/executor/codex_websockets_executor_test.go index fbcf9c4527..4342ed8882 100644 --- a/internal/runtime/executor/codex_websockets_executor_test.go +++ b/internal/runtime/executor/codex_websockets_executor_test.go @@ -12,11 +12,11 @@ import ( "github.com/gin-gonic/gin" "github.com/gorilla/websocket" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" "github.com/tidwall/gjson" ) diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go index b6210e6a1d..0fa7cbb2d6 100644 --- a/internal/runtime/executor/gemini_cli_executor.go +++ b/internal/runtime/executor/gemini_cli_executor.go @@ -16,15 +16,15 @@ import ( "strings" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" - "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps" - "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/geminicli" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -599,7 +599,10 @@ func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth. } // Refresh refreshes the authentication credentials (no-op for Gemini CLI). -func (e *GeminiCLIExecutor) Refresh(_ context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { +func (e *GeminiCLIExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { + if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled { + return refreshed, err + } return auth, nil } @@ -609,37 +612,43 @@ func prepareGeminiCLITokenSource(ctx context.Context, cfg *config.Config, auth * return nil, nil, fmt.Errorf("gemini-cli auth metadata missing") } - var base map[string]any - if tokenRaw, ok := metadata["token"].(map[string]any); ok && tokenRaw != nil { - base = cloneMap(tokenRaw) - } else { - base = make(map[string]any) - } + buildToken := func(meta map[string]any) (map[string]any, oauth2.Token) { + var base map[string]any + if tokenRaw, ok := meta["token"].(map[string]any); ok && tokenRaw != nil { + base = cloneMap(tokenRaw) + } else { + base = make(map[string]any) + } - var token oauth2.Token - if len(base) > 0 { - if raw, err := json.Marshal(base); err == nil { - _ = json.Unmarshal(raw, &token) + var token oauth2.Token + if len(base) > 0 { + if raw, err := json.Marshal(base); err == nil { + _ = json.Unmarshal(raw, &token) + } } - } - if token.AccessToken == "" { - token.AccessToken = stringValue(metadata, "access_token") - } - if token.RefreshToken == "" { - token.RefreshToken = stringValue(metadata, "refresh_token") - } - if token.TokenType == "" { - token.TokenType = stringValue(metadata, "token_type") - } - if token.Expiry.IsZero() { - if expiry := stringValue(metadata, "expiry"); expiry != "" { - if ts, err := time.Parse(time.RFC3339, expiry); err == nil { - token.Expiry = ts + if token.AccessToken == "" { + token.AccessToken = stringValue(meta, "access_token") + } + if token.RefreshToken == "" { + token.RefreshToken = stringValue(meta, "refresh_token") + } + if token.TokenType == "" { + token.TokenType = stringValue(meta, "token_type") + } + if token.Expiry.IsZero() { + if expiry := stringValue(meta, "expiry"); expiry != "" { + if ts, err := time.Parse(time.RFC3339, expiry); err == nil { + token.Expiry = ts + } } } + + return base, token } + base, token := buildToken(metadata) + conf := &oauth2.Config{ ClientID: geminiOAuthClientID, ClientSecret: geminiOAuthClientSecret, @@ -652,6 +661,29 @@ func prepareGeminiCLITokenSource(ctx context.Context, cfg *config.Config, auth * ctxToken = context.WithValue(ctxToken, oauth2.HTTPClient, httpClient) } + if cfg != nil && cfg.Home.Enabled { + now := time.Now() + if token.AccessToken == "" || (!token.Expiry.IsZero() && token.Expiry.Before(now.Add(30*time.Second))) { + refreshed, handled, errRefresh := helps.RefreshAuthViaHome(ctx, cfg, auth) + if handled { + if errRefresh != nil { + return nil, nil, errRefresh + } + auth = refreshed + metadata = geminiOAuthMetadata(auth) + if metadata == nil { + return nil, nil, fmt.Errorf("gemini-cli auth metadata missing") + } + base, token = buildToken(metadata) + } + } + if token.AccessToken == "" { + return nil, nil, fmt.Errorf("gemini-cli access token missing") + } + updateGeminiCLITokenMetadata(auth, base, &token) + return oauth2.StaticTokenSource(&token), base, nil + } + src := conf.TokenSource(ctxToken, &token) currentToken, err := src.Token() if err != nil { diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go index 2a6e9a6e79..c3f0801070 100644 --- a/internal/runtime/executor/gemini_executor.go +++ b/internal/runtime/executor/gemini_executor.go @@ -12,13 +12,13 @@ import ( "net/http" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -437,7 +437,10 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut } // Refresh refreshes the authentication credentials (no-op for Gemini API key). -func (e *GeminiExecutor) Refresh(_ context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { +func (e *GeminiExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { + if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled { + return refreshed, err + } return auth, nil } diff --git a/internal/runtime/executor/gemini_vertex_executor.go b/internal/runtime/executor/gemini_vertex_executor.go index 17a93d5150..ae0a718b8b 100644 --- a/internal/runtime/executor/gemini_vertex_executor.go +++ b/internal/runtime/executor/gemini_vertex_executor.go @@ -14,14 +14,14 @@ import ( "strings" "time" - vertexauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/vertex" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + vertexauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/vertex" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -294,7 +294,10 @@ func (e *GeminiVertexExecutor) CountTokens(ctx context.Context, auth *cliproxyau } // Refresh refreshes the authentication credentials (no-op for Vertex). -func (e *GeminiVertexExecutor) Refresh(_ context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { +func (e *GeminiVertexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { + if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled { + return refreshed, err + } return auth, nil } diff --git a/internal/runtime/executor/helps/claude_device_profile.go b/internal/runtime/executor/helps/claude_device_profile.go index 154901b53b..09f04929fe 100644 --- a/internal/runtime/executor/helps/claude_device_profile.go +++ b/internal/runtime/executor/helps/claude_device_profile.go @@ -11,8 +11,8 @@ import ( "sync" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) const ( diff --git a/internal/runtime/executor/helps/home_refresh.go b/internal/runtime/executor/helps/home_refresh.go new file mode 100644 index 0000000000..e52fdd2435 --- /dev/null +++ b/internal/runtime/executor/helps/home_refresh.go @@ -0,0 +1,91 @@ +package helps + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/home" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" +) + +type homeStatusErr struct { + code int + msg string +} + +func (e homeStatusErr) Error() string { + if e.msg != "" { + return e.msg + } + return fmt.Sprintf("status %d", e.code) +} + +func (e homeStatusErr) StatusCode() int { return e.code } + +type homeErrorEnvelope struct { + Error *homeErrorDetail `json:"error"` +} + +type homeErrorDetail struct { + Type string `json:"type"` + Message string `json:"message"` + Code string `json:"code,omitempty"` +} + +// RefreshAuthViaHome replaces local refresh logic when home control plane integration is enabled. +// It returns (updatedAuth, true, nil) when home refresh succeeds; (nil, true, err) when home is +// enabled but refresh fails; and (nil, false, nil) when home is disabled. +func RefreshAuthViaHome(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, bool, error) { + if cfg == nil || !cfg.Home.Enabled { + return nil, false, nil + } + if ctx == nil { + ctx = context.Background() + } + if auth == nil { + return nil, true, homeStatusErr{code: http.StatusInternalServerError, msg: "home refresh: auth is nil"} + } + + client := home.Current() + if client == nil || !client.HeartbeatOK() { + return nil, true, homeStatusErr{code: http.StatusServiceUnavailable, msg: "home control center unavailable"} + } + + authIndex := strings.TrimSpace(auth.Index) + if authIndex == "" { + authIndex = strings.TrimSpace(auth.EnsureIndex()) + } + if authIndex == "" { + return nil, true, homeStatusErr{code: http.StatusBadGateway, msg: "home refresh: auth_index is empty"} + } + + raw, err := client.GetRefreshAuth(ctx, authIndex) + if err != nil { + return nil, true, homeStatusErr{code: http.StatusBadGateway, msg: err.Error()} + } + + var env homeErrorEnvelope + if errUnmarshal := json.Unmarshal(raw, &env); errUnmarshal == nil && env.Error != nil { + code := strings.TrimSpace(env.Error.Type) + if code == "" { + code = strings.TrimSpace(env.Error.Code) + } + msg := strings.TrimSpace(env.Error.Message) + if msg == "" { + msg = "home returned error" + } + return nil, true, homeStatusErr{code: http.StatusBadGateway, msg: msg} + } + + var updated cliproxyauth.Auth + if errUnmarshal := json.Unmarshal(raw, &updated); errUnmarshal != nil { + return nil, true, homeStatusErr{code: http.StatusBadGateway, msg: "home returned invalid auth payload"} + } + updated.Index = authIndex + updated.EnsureIndex() + return &updated, true, nil +} diff --git a/internal/runtime/executor/helps/logging_helpers.go b/internal/runtime/executor/helps/logging_helpers.go index a0b30f7099..fa7143347e 100644 --- a/internal/runtime/executor/helps/logging_helpers.go +++ b/internal/runtime/executor/helps/logging_helpers.go @@ -12,9 +12,9 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" ) diff --git a/internal/runtime/executor/helps/payload_helpers.go b/internal/runtime/executor/helps/payload_helpers.go index d6baba275b..af69a488c3 100644 --- a/internal/runtime/executor/helps/payload_helpers.go +++ b/internal/runtime/executor/helps/payload_helpers.go @@ -4,9 +4,9 @@ import ( "encoding/json" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go b/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go index 6fd3a0e055..0faf012b35 100644 --- a/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go +++ b/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go @@ -3,7 +3,7 @@ package helps import ( "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/tidwall/gjson" ) diff --git a/internal/runtime/executor/helps/proxy_helpers.go b/internal/runtime/executor/helps/proxy_helpers.go index 022bc65c17..91fdc9be49 100644 --- a/internal/runtime/executor/helps/proxy_helpers.go +++ b/internal/runtime/executor/helps/proxy_helpers.go @@ -6,9 +6,9 @@ import ( "strings" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil" log "github.com/sirupsen/logrus" ) diff --git a/internal/runtime/executor/helps/proxy_helpers_test.go b/internal/runtime/executor/helps/proxy_helpers_test.go index 3311716765..fb57b6b745 100644 --- a/internal/runtime/executor/helps/proxy_helpers_test.go +++ b/internal/runtime/executor/helps/proxy_helpers_test.go @@ -5,9 +5,9 @@ import ( "net/http" "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) func TestNewProxyAwareHTTPClientDirectBypassesGlobalProxy(t *testing.T) { diff --git a/internal/runtime/executor/helps/thinking_providers.go b/internal/runtime/executor/helps/thinking_providers.go index bbd019624d..a776136fde 100644 --- a/internal/runtime/executor/helps/thinking_providers.go +++ b/internal/runtime/executor/helps/thinking_providers.go @@ -1,11 +1,11 @@ package helps import ( - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/antigravity" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/claude" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/codex" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/gemini" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/geminicli" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/kimi" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/openai" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/antigravity" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/claude" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/codex" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/gemini" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/geminicli" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/kimi" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/openai" ) diff --git a/internal/runtime/executor/helps/usage_helpers.go b/internal/runtime/executor/helps/usage_helpers.go index 312a1d35c3..c72b5c1aeb 100644 --- a/internal/runtime/executor/helps/usage_helpers.go +++ b/internal/runtime/executor/helps/usage_helpers.go @@ -9,8 +9,8 @@ import ( "time" "github.com/gin-gonic/gin" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -177,7 +177,7 @@ func APIKeyFromContext(ctx context.Context) string { if !ok || ginCtx == nil { return "" } - if v, exists := ginCtx.Get("apiKey"); exists { + if v, exists := ginCtx.Get("userApiKey"); exists { switch value := v.(type) { case string: return value diff --git a/internal/runtime/executor/helps/usage_helpers_test.go b/internal/runtime/executor/helps/usage_helpers_test.go index ef2c7de581..840f4223e1 100644 --- a/internal/runtime/executor/helps/usage_helpers_test.go +++ b/internal/runtime/executor/helps/usage_helpers_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage" ) func TestParseOpenAIUsageChatCompletions(t *testing.T) { diff --git a/internal/runtime/executor/helps/utls_client.go b/internal/runtime/executor/helps/utls_client.go index 39512a58de..29174e47b6 100644 --- a/internal/runtime/executor/helps/utls_client.go +++ b/internal/runtime/executor/helps/utls_client.go @@ -8,9 +8,9 @@ import ( "time" tls "github.com/refraction-networking/utls" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil" log "github.com/sirupsen/logrus" "golang.org/x/net/http2" "golang.org/x/net/proxy" diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go index 93125d9fcb..f330321fa2 100644 --- a/internal/runtime/executor/kimi_executor.go +++ b/internal/runtime/executor/kimi_executor.go @@ -13,14 +13,14 @@ import ( "strings" "time" - kimiauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + kimiauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/kimi" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -569,6 +569,9 @@ func fallbackAssistantReasoning(msg gjson.Result, hasLatest bool, latest string) // Refresh refreshes the Kimi token using the refresh token. func (e *KimiExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { log.Debugf("kimi executor: refresh called") + if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled { + return refreshed, err + } if auth == nil { return nil, fmt.Errorf("kimi executor: auth is nil") } diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index 7e81637ca6..de12da3706 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -10,13 +10,13 @@ import ( "strings" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" log "github.com/sirupsen/logrus" "github.com/tidwall/sjson" ) @@ -374,7 +374,9 @@ func (e *OpenAICompatExecutor) CountTokens(ctx context.Context, auth *cliproxyau // Refresh is a no-op for API-key based compatibility providers. func (e *OpenAICompatExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { log.Debugf("openai compat executor: refresh called") - _ = ctx + if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled { + return refreshed, err + } return auth, nil } diff --git a/internal/runtime/executor/openai_compat_executor_compact_test.go b/internal/runtime/executor/openai_compat_executor_compact_test.go index 49b2cccbbb..3aab5c9b01 100644 --- a/internal/runtime/executor/openai_compat_executor_compact_test.go +++ b/internal/runtime/executor/openai_compat_executor_compact_test.go @@ -8,10 +8,10 @@ import ( "strings" "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" "github.com/tidwall/gjson" ) diff --git a/internal/store/gitstore.go b/internal/store/gitstore.go index bd84d99a23..1610211ac9 100644 --- a/internal/store/gitstore.go +++ b/internal/store/gitstore.go @@ -18,7 +18,7 @@ import ( "github.com/go-git/go-git/v6/plumbing/object" "github.com/go-git/go-git/v6/plumbing/transport" "github.com/go-git/go-git/v6/plumbing/transport/http" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) // gcInterval defines minimum time between garbage collection runs. diff --git a/internal/store/objectstore.go b/internal/store/objectstore.go index a33f6ef8f4..aa346a138b 100644 --- a/internal/store/objectstore.go +++ b/internal/store/objectstore.go @@ -17,8 +17,8 @@ import ( "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" ) diff --git a/internal/store/postgresstore.go b/internal/store/postgresstore.go index 527b25cc12..610fc5b630 100644 --- a/internal/store/postgresstore.go +++ b/internal/store/postgresstore.go @@ -14,8 +14,8 @@ import ( "time" _ "github.com/jackc/pgx/v5/stdlib" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" ) diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go index 1edeac874c..d422a8d8b2 100644 --- a/internal/thinking/apply.go +++ b/internal/thinking/apply.go @@ -4,7 +4,7 @@ package thinking import ( "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" ) diff --git a/internal/thinking/apply_user_defined_test.go b/internal/thinking/apply_user_defined_test.go index aa24ab8e9c..c485d2521a 100644 --- a/internal/thinking/apply_user_defined_test.go +++ b/internal/thinking/apply_user_defined_test.go @@ -3,9 +3,9 @@ package thinking_test import ( "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/claude" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/claude" "github.com/tidwall/gjson" ) diff --git a/internal/thinking/convert.go b/internal/thinking/convert.go index b22a0879ed..31945daa7c 100644 --- a/internal/thinking/convert.go +++ b/internal/thinking/convert.go @@ -3,7 +3,7 @@ package thinking import ( "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" ) // levelToBudgetMap defines the standard Level → Budget mapping. diff --git a/internal/thinking/provider/antigravity/apply.go b/internal/thinking/provider/antigravity/apply.go index d202035fc6..0a8f1c4537 100644 --- a/internal/thinking/provider/antigravity/apply.go +++ b/internal/thinking/provider/antigravity/apply.go @@ -9,8 +9,8 @@ package antigravity import ( "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/thinking/provider/claude/apply.go b/internal/thinking/provider/claude/apply.go index 275be46924..140a8135f7 100644 --- a/internal/thinking/provider/claude/apply.go +++ b/internal/thinking/provider/claude/apply.go @@ -9,8 +9,8 @@ package claude import ( - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/thinking/provider/codex/apply.go b/internal/thinking/provider/codex/apply.go index 0f33635950..83f5ae8457 100644 --- a/internal/thinking/provider/codex/apply.go +++ b/internal/thinking/provider/codex/apply.go @@ -7,8 +7,8 @@ package codex import ( - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/thinking/provider/gemini/apply.go b/internal/thinking/provider/gemini/apply.go index 39bb4231d0..8e6e83f330 100644 --- a/internal/thinking/provider/gemini/apply.go +++ b/internal/thinking/provider/gemini/apply.go @@ -12,8 +12,8 @@ package gemini import ( - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/thinking/provider/geminicli/apply.go b/internal/thinking/provider/geminicli/apply.go index 5908b6bce5..e9311e8c18 100644 --- a/internal/thinking/provider/geminicli/apply.go +++ b/internal/thinking/provider/geminicli/apply.go @@ -5,8 +5,8 @@ package geminicli import ( - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/thinking/provider/kimi/apply.go b/internal/thinking/provider/kimi/apply.go index ff47c46d03..ea3ed572f0 100644 --- a/internal/thinking/provider/kimi/apply.go +++ b/internal/thinking/provider/kimi/apply.go @@ -7,8 +7,8 @@ package kimi import ( "fmt" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/thinking/provider/kimi/apply_test.go b/internal/thinking/provider/kimi/apply_test.go index 707f11c758..78069424ed 100644 --- a/internal/thinking/provider/kimi/apply_test.go +++ b/internal/thinking/provider/kimi/apply_test.go @@ -3,8 +3,8 @@ package kimi import ( "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" "github.com/tidwall/gjson" ) diff --git a/internal/thinking/provider/openai/apply.go b/internal/thinking/provider/openai/apply.go index c77c1ab8e4..1e87b72b37 100644 --- a/internal/thinking/provider/openai/apply.go +++ b/internal/thinking/provider/openai/apply.go @@ -6,8 +6,8 @@ package openai import ( - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/thinking/types.go b/internal/thinking/types.go index a31d798197..39868a02f4 100644 --- a/internal/thinking/types.go +++ b/internal/thinking/types.go @@ -4,7 +4,7 @@ // thinking configurations across various AI providers (Claude, Gemini, OpenAI, Codex, Antigravity, Kimi). package thinking -import "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" +import "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" // ThinkingMode represents the type of thinking configuration mode. type ThinkingMode int diff --git a/internal/thinking/validate.go b/internal/thinking/validate.go index 4a3ca97ce8..2baa93f1da 100644 --- a/internal/thinking/validate.go +++ b/internal/thinking/validate.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" log "github.com/sirupsen/logrus" ) diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index 8ae69648db..7f36b11ccb 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -8,10 +8,10 @@ package claude import ( "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/cache" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go index 919e29062a..bb3cdf4f34 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/cache" + "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" "github.com/tidwall/gjson" "google.golang.org/protobuf/encoding/protowire" ) diff --git a/internal/translator/antigravity/claude/antigravity_claude_response.go b/internal/translator/antigravity/claude/antigravity_claude_response.go index 17a31f217f..427551df6c 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_response.go +++ b/internal/translator/antigravity/claude/antigravity_claude_response.go @@ -15,9 +15,9 @@ import ( "sync/atomic" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/cache" - translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" diff --git a/internal/translator/antigravity/claude/antigravity_claude_response_test.go b/internal/translator/antigravity/claude/antigravity_claude_response_test.go index 05a3df899d..1490ab3cbd 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_response_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_response_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/cache" + "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" ) // ============================================================================ diff --git a/internal/translator/antigravity/claude/init.go b/internal/translator/antigravity/claude/init.go index 21fe0b26ed..4d9bd721ff 100644 --- a/internal/translator/antigravity/claude/init.go +++ b/internal/translator/antigravity/claude/init.go @@ -1,9 +1,9 @@ package claude import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/antigravity/claude/signature_validation.go b/internal/translator/antigravity/claude/signature_validation.go index 63203abdce..f82fc2e364 100644 --- a/internal/translator/antigravity/claude/signature_validation.go +++ b/internal/translator/antigravity/claude/signature_validation.go @@ -53,7 +53,7 @@ import ( "strings" "unicode/utf8" - "github.com/router-for-me/CLIProxyAPI/v6/internal/cache" + "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" "github.com/tidwall/gjson" "github.com/tidwall/sjson" "google.golang.org/protobuf/encoding/protowire" diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_request.go b/internal/translator/antigravity/gemini/antigravity_gemini_request.go index 3612c0fb1a..b33b9c40e1 100644 --- a/internal/translator/antigravity/gemini/antigravity_gemini_request.go +++ b/internal/translator/antigravity/gemini/antigravity_gemini_request.go @@ -9,8 +9,8 @@ import ( "fmt" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_response.go b/internal/translator/antigravity/gemini/antigravity_gemini_response.go index 7b43c48db2..b0deb7320a 100644 --- a/internal/translator/antigravity/gemini/antigravity_gemini_response.go +++ b/internal/translator/antigravity/gemini/antigravity_gemini_response.go @@ -9,7 +9,7 @@ import ( "bytes" "context" - translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/antigravity/gemini/init.go b/internal/translator/antigravity/gemini/init.go index 3955824863..dcb331618a 100644 --- a/internal/translator/antigravity/gemini/init.go +++ b/internal/translator/antigravity/gemini/init.go @@ -1,9 +1,9 @@ package gemini import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go index b33be50bd0..0d9ee6fe0a 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go @@ -6,9 +6,9 @@ import ( "fmt" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go index 9188c75a2c..2be24102ff 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go @@ -13,10 +13,10 @@ import ( "sync/atomic" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" - . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/chat-completions" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/chat-completions" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/antigravity/openai/chat-completions/init.go b/internal/translator/antigravity/openai/chat-completions/init.go index 5c5c71e461..2217e7919c 100644 --- a/internal/translator/antigravity/openai/chat-completions/init.go +++ b/internal/translator/antigravity/openai/chat-completions/init.go @@ -1,9 +1,9 @@ package chat_completions import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go index 90bfa14c05..94a6b852b0 100644 --- a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go +++ b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go @@ -1,8 +1,8 @@ package responses import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/gemini" - . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/responses" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/gemini" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/responses" ) func ConvertOpenAIResponsesRequestToAntigravity(modelName string, inputRawJSON []byte, stream bool) []byte { diff --git a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response.go b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response.go index a087e0bd0f..3256950461 100644 --- a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response.go +++ b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response.go @@ -3,7 +3,7 @@ package responses import ( "context" - . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/responses" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/responses" "github.com/tidwall/gjson" ) diff --git a/internal/translator/antigravity/openai/responses/init.go b/internal/translator/antigravity/openai/responses/init.go index 8d13703239..49041f2905 100644 --- a/internal/translator/antigravity/openai/responses/init.go +++ b/internal/translator/antigravity/openai/responses/init.go @@ -1,9 +1,9 @@ package responses import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/claude/gemini-cli/claude_gemini-cli_request.go b/internal/translator/claude/gemini-cli/claude_gemini-cli_request.go index 831d784db3..fd68a957f5 100644 --- a/internal/translator/claude/gemini-cli/claude_gemini-cli_request.go +++ b/internal/translator/claude/gemini-cli/claude_gemini-cli_request.go @@ -6,7 +6,7 @@ package geminiCLI import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/claude/gemini" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/gemini" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/claude/gemini-cli/claude_gemini-cli_response.go b/internal/translator/claude/gemini-cli/claude_gemini-cli_response.go index 62e2650fd9..858886c272 100644 --- a/internal/translator/claude/gemini-cli/claude_gemini-cli_response.go +++ b/internal/translator/claude/gemini-cli/claude_gemini-cli_response.go @@ -7,8 +7,8 @@ package geminiCLI import ( "context" - . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/claude/gemini" - translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/gemini" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" ) // ConvertClaudeResponseToGeminiCLI converts Claude Code streaming response format to Gemini CLI format. diff --git a/internal/translator/claude/gemini-cli/init.go b/internal/translator/claude/gemini-cli/init.go index ca364a6ee0..33a1332daf 100644 --- a/internal/translator/claude/gemini-cli/init.go +++ b/internal/translator/claude/gemini-cli/init.go @@ -1,9 +1,9 @@ package geminiCLI import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go index d2a215e7de..d716d28f35 100644 --- a/internal/translator/claude/gemini/claude_gemini_request.go +++ b/internal/translator/claude/gemini/claude_gemini_request.go @@ -14,9 +14,9 @@ import ( "strings" "github.com/google/uuid" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/claude/gemini/claude_gemini_response.go b/internal/translator/claude/gemini/claude_gemini_response.go index 846c26056f..3f127e3205 100644 --- a/internal/translator/claude/gemini/claude_gemini_response.go +++ b/internal/translator/claude/gemini/claude_gemini_response.go @@ -12,7 +12,7 @@ import ( "strings" "time" - translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/claude/gemini/init.go b/internal/translator/claude/gemini/init.go index 8924f62c87..0ed533cebf 100644 --- a/internal/translator/claude/gemini/init.go +++ b/internal/translator/claude/gemini/init.go @@ -1,9 +1,9 @@ package gemini import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request.go b/internal/translator/claude/openai/chat-completions/claude_openai_request.go index e9d8d35b09..bad56d1273 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -14,8 +14,8 @@ import ( "strings" "github.com/google/uuid" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/claude/openai/chat-completions/init.go b/internal/translator/claude/openai/chat-completions/init.go index a18840bace..7474fb2a38 100644 --- a/internal/translator/claude/openai/chat-completions/init.go +++ b/internal/translator/claude/openai/chat-completions/init.go @@ -1,9 +1,9 @@ package chat_completions import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go index c0479b87ea..1398749573 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -9,8 +9,8 @@ import ( "strings" "github.com/google/uuid" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response.go b/internal/translator/claude/openai/responses/claude_openai-responses_response.go index 10d12c9963..6c6b96b30d 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_response.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_response.go @@ -8,7 +8,7 @@ import ( "strings" "time" - translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/claude/openai/responses/init.go b/internal/translator/claude/openai/responses/init.go index 595fecc6ef..575c9ec71a 100644 --- a/internal/translator/claude/openai/responses/init.go +++ b/internal/translator/claude/openai/responses/init.go @@ -1,9 +1,9 @@ package responses import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index 1e168f0993..029db14e7d 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -11,7 +11,7 @@ import ( "strconv" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index a401a1b7e5..7a40ca4c55 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -11,8 +11,8 @@ import ( "context" "strings" - translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/codex/claude/init.go b/internal/translator/codex/claude/init.go index 7126edc303..af44b9dd49 100644 --- a/internal/translator/codex/claude/init.go +++ b/internal/translator/codex/claude/init.go @@ -1,9 +1,9 @@ package claude import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/codex/gemini-cli/codex_gemini-cli_request.go b/internal/translator/codex/gemini-cli/codex_gemini-cli_request.go index 8b32453d26..b69bab11ee 100644 --- a/internal/translator/codex/gemini-cli/codex_gemini-cli_request.go +++ b/internal/translator/codex/gemini-cli/codex_gemini-cli_request.go @@ -6,7 +6,7 @@ package geminiCLI import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/codex/gemini" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/gemini" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/codex/gemini-cli/codex_gemini-cli_response.go b/internal/translator/codex/gemini-cli/codex_gemini-cli_response.go index 0f0068c842..01dbc0f831 100644 --- a/internal/translator/codex/gemini-cli/codex_gemini-cli_response.go +++ b/internal/translator/codex/gemini-cli/codex_gemini-cli_response.go @@ -7,8 +7,8 @@ package geminiCLI import ( "context" - . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/codex/gemini" - translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/gemini" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" ) // ConvertCodexResponseToGeminiCLI converts Codex streaming response format to Gemini CLI format. diff --git a/internal/translator/codex/gemini-cli/init.go b/internal/translator/codex/gemini-cli/init.go index 8bcd3de5fd..2958e0a825 100644 --- a/internal/translator/codex/gemini-cli/init.go +++ b/internal/translator/codex/gemini-cli/init.go @@ -1,9 +1,9 @@ package geminiCLI import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/codex/gemini/codex_gemini_request.go b/internal/translator/codex/gemini/codex_gemini_request.go index 373997007f..5789890f20 100644 --- a/internal/translator/codex/gemini/codex_gemini_request.go +++ b/internal/translator/codex/gemini/codex_gemini_request.go @@ -12,8 +12,8 @@ import ( "strconv" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/codex/gemini/codex_gemini_response.go b/internal/translator/codex/gemini/codex_gemini_response.go index a2e4e20ea2..ecf9cf4de8 100644 --- a/internal/translator/codex/gemini/codex_gemini_response.go +++ b/internal/translator/codex/gemini/codex_gemini_response.go @@ -11,7 +11,7 @@ import ( "strings" "time" - translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/codex/gemini/init.go b/internal/translator/codex/gemini/init.go index 41d30559a6..b670d8d9b4 100644 --- a/internal/translator/codex/gemini/init.go +++ b/internal/translator/codex/gemini/init.go @@ -1,9 +1,9 @@ package gemini import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/codex/openai/chat-completions/init.go b/internal/translator/codex/openai/chat-completions/init.go index 8f782fdae1..94db2a7db8 100644 --- a/internal/translator/codex/openai/chat-completions/init.go +++ b/internal/translator/codex/openai/chat-completions/init.go @@ -1,9 +1,9 @@ package chat_completions import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/codex/openai/responses/init.go b/internal/translator/codex/openai/responses/init.go index cab759f297..24e7e3561c 100644 --- a/internal/translator/codex/openai/responses/init.go +++ b/internal/translator/codex/openai/responses/init.go @@ -1,9 +1,9 @@ package responses import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go index 57ebbc2cde..3e77b3f757 100644 --- a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go @@ -8,8 +8,8 @@ package claude import ( "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go index 0bf4d6225c..607d6b9fc0 100644 --- a/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go @@ -14,8 +14,8 @@ import ( "sync/atomic" "time" - translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/gemini-cli/claude/init.go b/internal/translator/gemini-cli/claude/init.go index 79ed03c68e..fa2fabdf77 100644 --- a/internal/translator/gemini-cli/claude/init.go +++ b/internal/translator/gemini-cli/claude/init.go @@ -1,9 +1,9 @@ package claude import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/gemini-cli/gemini/gemini-cli_gemini_request.go b/internal/translator/gemini-cli/gemini/gemini-cli_gemini_request.go index 9bdce33973..83dc626041 100644 --- a/internal/translator/gemini-cli/gemini/gemini-cli_gemini_request.go +++ b/internal/translator/gemini-cli/gemini/gemini-cli_gemini_request.go @@ -9,8 +9,8 @@ import ( "fmt" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" diff --git a/internal/translator/gemini-cli/gemini/gemini-cli_gemini_response.go b/internal/translator/gemini-cli/gemini/gemini-cli_gemini_response.go index 8e23f1d3d6..0e100c1489 100644 --- a/internal/translator/gemini-cli/gemini/gemini-cli_gemini_response.go +++ b/internal/translator/gemini-cli/gemini/gemini-cli_gemini_response.go @@ -9,7 +9,7 @@ import ( "bytes" "context" - translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/gemini-cli/gemini/init.go b/internal/translator/gemini-cli/gemini/init.go index fbad4ab50b..1c2f38f215 100644 --- a/internal/translator/gemini-cli/gemini/init.go +++ b/internal/translator/gemini-cli/gemini/init.go @@ -1,9 +1,9 @@ package gemini import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go index 95bca2d7b6..1aa3132b49 100644 --- a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go +++ b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go @@ -6,9 +6,9 @@ import ( "fmt" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" diff --git a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go index 0947371a5a..926040588e 100644 --- a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go +++ b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go @@ -13,8 +13,8 @@ import ( "sync/atomic" "time" - . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/chat-completions" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/chat-completions" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" diff --git a/internal/translator/gemini-cli/openai/chat-completions/init.go b/internal/translator/gemini-cli/openai/chat-completions/init.go index 3bd76c517d..fcd85f2450 100644 --- a/internal/translator/gemini-cli/openai/chat-completions/init.go +++ b/internal/translator/gemini-cli/openai/chat-completions/init.go @@ -1,9 +1,9 @@ package chat_completions import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/gemini-cli/openai/responses/gemini-cli_openai-responses_request.go b/internal/translator/gemini-cli/openai/responses/gemini-cli_openai-responses_request.go index 657e45fdb2..bea4b7a1fe 100644 --- a/internal/translator/gemini-cli/openai/responses/gemini-cli_openai-responses_request.go +++ b/internal/translator/gemini-cli/openai/responses/gemini-cli_openai-responses_request.go @@ -1,8 +1,8 @@ package responses import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini-cli/gemini" - . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/responses" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini-cli/gemini" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/responses" ) func ConvertOpenAIResponsesRequestToGeminiCLI(modelName string, inputRawJSON []byte, stream bool) []byte { diff --git a/internal/translator/gemini-cli/openai/responses/gemini-cli_openai-responses_response.go b/internal/translator/gemini-cli/openai/responses/gemini-cli_openai-responses_response.go index 9bb3ced9ef..29db8c19ef 100644 --- a/internal/translator/gemini-cli/openai/responses/gemini-cli_openai-responses_response.go +++ b/internal/translator/gemini-cli/openai/responses/gemini-cli_openai-responses_response.go @@ -3,7 +3,7 @@ package responses import ( "context" - . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/responses" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/responses" "github.com/tidwall/gjson" ) diff --git a/internal/translator/gemini-cli/openai/responses/init.go b/internal/translator/gemini-cli/openai/responses/init.go index b25d670851..e1d437715f 100644 --- a/internal/translator/gemini-cli/openai/responses/init.go +++ b/internal/translator/gemini-cli/openai/responses/init.go @@ -1,9 +1,9 @@ package responses import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index e230f5fd0d..454668cbc2 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -9,9 +9,9 @@ import ( "fmt" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/gemini/claude/gemini_claude_response.go b/internal/translator/gemini/claude/gemini_claude_response.go index 28722de1db..797636d857 100644 --- a/internal/translator/gemini/claude/gemini_claude_response.go +++ b/internal/translator/gemini/claude/gemini_claude_response.go @@ -13,8 +13,8 @@ import ( "strings" "sync/atomic" - translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/gemini/claude/init.go b/internal/translator/gemini/claude/init.go index 66fe51e739..d03140957c 100644 --- a/internal/translator/gemini/claude/init.go +++ b/internal/translator/gemini/claude/init.go @@ -1,9 +1,9 @@ package claude import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go b/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go index 1b2cdb4636..71e7b4a5fd 100644 --- a/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go +++ b/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go @@ -8,8 +8,8 @@ package geminiCLI import ( "fmt" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/gemini/gemini-cli/gemini_gemini-cli_response.go b/internal/translator/gemini/gemini-cli/gemini_gemini-cli_response.go index d15ea21acc..36fa0d39b5 100644 --- a/internal/translator/gemini/gemini-cli/gemini_gemini-cli_response.go +++ b/internal/translator/gemini/gemini-cli/gemini_gemini-cli_response.go @@ -8,7 +8,7 @@ import ( "bytes" "context" - translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" "github.com/tidwall/sjson" ) diff --git a/internal/translator/gemini/gemini-cli/init.go b/internal/translator/gemini/gemini-cli/init.go index 2c2224f7d0..ed18b5f0af 100644 --- a/internal/translator/gemini/gemini-cli/init.go +++ b/internal/translator/gemini/gemini-cli/init.go @@ -1,9 +1,9 @@ package geminiCLI import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/gemini/gemini/gemini_gemini_request.go b/internal/translator/gemini/gemini/gemini_gemini_request.go index abc176b2e2..35e22d7160 100644 --- a/internal/translator/gemini/gemini/gemini_gemini_request.go +++ b/internal/translator/gemini/gemini/gemini_gemini_request.go @@ -7,8 +7,8 @@ import ( "fmt" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" diff --git a/internal/translator/gemini/gemini/gemini_gemini_response.go b/internal/translator/gemini/gemini/gemini_gemini_response.go index 242dd98059..74669a7e72 100644 --- a/internal/translator/gemini/gemini/gemini_gemini_response.go +++ b/internal/translator/gemini/gemini/gemini_gemini_response.go @@ -4,7 +4,7 @@ import ( "bytes" "context" - translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" ) // PassthroughGeminiResponseStream forwards Gemini responses unchanged. diff --git a/internal/translator/gemini/gemini/init.go b/internal/translator/gemini/gemini/init.go index 28c9708338..ca9de2c672 100644 --- a/internal/translator/gemini/gemini/init.go +++ b/internal/translator/gemini/gemini/init.go @@ -1,9 +1,9 @@ package gemini import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) // Register a no-op response translator and a request normalizer for Gemini→Gemini. diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go index c0c4d329f5..20eaec76f9 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go @@ -6,9 +6,9 @@ import ( "fmt" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go index 3dc5b095c3..cc9117f905 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go @@ -13,7 +13,7 @@ import ( "sync/atomic" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" diff --git a/internal/translator/gemini/openai/chat-completions/init.go b/internal/translator/gemini/openai/chat-completions/init.go index 800e07db3d..2eb673310f 100644 --- a/internal/translator/gemini/openai/chat-completions/init.go +++ b/internal/translator/gemini/openai/chat-completions/init.go @@ -1,9 +1,9 @@ package chat_completions import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go index 8f3a59fa45..e741757641 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go @@ -4,8 +4,8 @@ import ( "encoding/json" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go index 15729aae92..36d30df753 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go @@ -8,8 +8,8 @@ import ( "sync/atomic" "time" - translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/gemini/openai/responses/init.go b/internal/translator/gemini/openai/responses/init.go index b53cac3d81..404dd68ae5 100644 --- a/internal/translator/gemini/openai/responses/init.go +++ b/internal/translator/gemini/openai/responses/init.go @@ -1,9 +1,9 @@ package responses import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/init.go b/internal/translator/init.go index 084ea7ac23..5f88a400ec 100644 --- a/internal/translator/init.go +++ b/internal/translator/init.go @@ -1,36 +1,36 @@ package translator import ( - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/claude/gemini" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/claude/gemini-cli" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/claude/openai/chat-completions" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/claude/openai/responses" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/gemini" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/gemini-cli" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/openai/chat-completions" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/openai/responses" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/codex/claude" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/codex/gemini" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/codex/gemini-cli" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/codex/openai/chat-completions" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/codex/openai/responses" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/claude" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/gemini" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/gemini-cli" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/openai/chat-completions" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/openai/responses" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini-cli/claude" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini-cli/gemini" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini-cli/openai/chat-completions" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini-cli/openai/responses" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini-cli/claude" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini-cli/gemini" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini-cli/openai/chat-completions" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini-cli/openai/responses" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/claude" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/gemini" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/gemini-cli" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/chat-completions" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/responses" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/claude" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/gemini" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/gemini-cli" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/chat-completions" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/responses" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/claude" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/gemini" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/gemini-cli" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/openai/chat-completions" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/openai/responses" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/claude" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/gemini" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/gemini-cli" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/openai/chat-completions" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/openai/responses" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/claude" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/gemini" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/openai/chat-completions" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/openai/responses" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/claude" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/gemini" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/openai/chat-completions" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/openai/responses" ) diff --git a/internal/translator/openai/claude/init.go b/internal/translator/openai/claude/init.go index 0e0f82eae9..baeeca84bc 100644 --- a/internal/translator/openai/claude/init.go +++ b/internal/translator/openai/claude/init.go @@ -1,9 +1,9 @@ package claude import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go index f12dd0c694..99fc2763ff 100644 --- a/internal/translator/openai/claude/openai_claude_request.go +++ b/internal/translator/openai/claude/openai_claude_request.go @@ -8,7 +8,7 @@ package claude import ( "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/openai/claude/openai_claude_response.go b/internal/translator/openai/claude/openai_claude_response.go index af49d306d7..1925539c19 100644 --- a/internal/translator/openai/claude/openai_claude_response.go +++ b/internal/translator/openai/claude/openai_claude_response.go @@ -10,8 +10,8 @@ import ( "context" "strings" - translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/openai/gemini-cli/init.go b/internal/translator/openai/gemini-cli/init.go index 12aec5ec90..7b52d06dc0 100644 --- a/internal/translator/openai/gemini-cli/init.go +++ b/internal/translator/openai/gemini-cli/init.go @@ -1,9 +1,9 @@ package geminiCLI import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/openai/gemini-cli/openai_gemini_request.go b/internal/translator/openai/gemini-cli/openai_gemini_request.go index 847c278f36..c651826669 100644 --- a/internal/translator/openai/gemini-cli/openai_gemini_request.go +++ b/internal/translator/openai/gemini-cli/openai_gemini_request.go @@ -6,7 +6,7 @@ package geminiCLI import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/gemini" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/gemini" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/openai/gemini-cli/openai_gemini_response.go b/internal/translator/openai/gemini-cli/openai_gemini_response.go index a7369dbfe9..e54e08fc27 100644 --- a/internal/translator/openai/gemini-cli/openai_gemini_response.go +++ b/internal/translator/openai/gemini-cli/openai_gemini_response.go @@ -8,8 +8,8 @@ package geminiCLI import ( "context" - translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common" - . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/gemini" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/gemini" ) // ConvertOpenAIResponseToGeminiCLI converts OpenAI Chat Completions streaming response format to Gemini API format. diff --git a/internal/translator/openai/gemini/init.go b/internal/translator/openai/gemini/init.go index 4f056ace9f..24ae281eff 100644 --- a/internal/translator/openai/gemini/init.go +++ b/internal/translator/openai/gemini/init.go @@ -1,9 +1,9 @@ package gemini import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/openai/gemini/openai_gemini_request.go b/internal/translator/openai/gemini/openai_gemini_request.go index b4edbb1df6..7369de88df 100644 --- a/internal/translator/openai/gemini/openai_gemini_request.go +++ b/internal/translator/openai/gemini/openai_gemini_request.go @@ -11,7 +11,7 @@ import ( "math/big" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/openai/gemini/openai_gemini_response.go b/internal/translator/openai/gemini/openai_gemini_response.go index 092a778eac..439ae8fbd7 100644 --- a/internal/translator/openai/gemini/openai_gemini_response.go +++ b/internal/translator/openai/gemini/openai_gemini_response.go @@ -12,7 +12,7 @@ import ( "strconv" "strings" - translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/openai/openai/chat-completions/init.go b/internal/translator/openai/openai/chat-completions/init.go index 90fa3dcd90..bfe82cea72 100644 --- a/internal/translator/openai/openai/chat-completions/init.go +++ b/internal/translator/openai/openai/chat-completions/init.go @@ -1,9 +1,9 @@ package chat_completions import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/openai/openai/responses/init.go b/internal/translator/openai/openai/responses/init.go index e6f60e0e13..c47081bae3 100644 --- a/internal/translator/openai/openai/responses/init.go +++ b/internal/translator/openai/openai/responses/init.go @@ -1,9 +1,9 @@ package responses import ( - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" ) func init() { diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response.go b/internal/translator/openai/openai/responses/openai_openai-responses_response.go index 8a44aede44..8895b68445 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_response.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_response.go @@ -9,7 +9,7 @@ import ( "sync/atomic" "time" - translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/internal/translator/translator/translator.go b/internal/translator/translator/translator.go index ab3f68a99d..88766a83bb 100644 --- a/internal/translator/translator/translator.go +++ b/internal/translator/translator/translator.go @@ -7,8 +7,8 @@ package translator import ( "context" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" ) // registry holds the default translator registry instance. diff --git a/internal/util/provider.go b/internal/util/provider.go index beee9add9d..6313f58e32 100644 --- a/internal/util/provider.go +++ b/internal/util/provider.go @@ -7,8 +7,8 @@ import ( "net/url" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" log "github.com/sirupsen/logrus" ) diff --git a/internal/util/proxy.go b/internal/util/proxy.go index 9b57ca1733..781dd54dc0 100644 --- a/internal/util/proxy.go +++ b/internal/util/proxy.go @@ -6,8 +6,8 @@ package util import ( "net/http" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil" log "github.com/sirupsen/logrus" ) diff --git a/internal/util/util.go b/internal/util/util.go index 9bf630f299..2c066e3ee7 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -11,7 +11,7 @@ import ( "regexp" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" log "github.com/sirupsen/logrus" ) diff --git a/internal/watcher/clients.go b/internal/watcher/clients.go index fb0d7865bc..0a46660e8b 100644 --- a/internal/watcher/clients.go +++ b/internal/watcher/clients.go @@ -13,11 +13,11 @@ import ( "strings" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff" - "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/synthesizer" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff" + "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/synthesizer" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" ) diff --git a/internal/watcher/config_reload.go b/internal/watcher/config_reload.go index 1bbf4ef239..0471f8b3f2 100644 --- a/internal/watcher/config_reload.go +++ b/internal/watcher/config_reload.go @@ -9,9 +9,9 @@ import ( "reflect" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff" "gopkg.in/yaml.v3" log "github.com/sirupsen/logrus" diff --git a/internal/watcher/diff/auth_diff.go b/internal/watcher/diff/auth_diff.go index 4b6e600852..39fe5e886d 100644 --- a/internal/watcher/diff/auth_diff.go +++ b/internal/watcher/diff/auth_diff.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) // BuildAuthChangeDetails computes a redacted, human-readable list of auth field changes. diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go index b414ed5adf..c206049e43 100644 --- a/internal/watcher/diff/config_diff.go +++ b/internal/watcher/diff/config_diff.go @@ -6,7 +6,7 @@ import ( "reflect" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) // BuildConfigChangeDetails computes a redacted, human-readable list of config changes. diff --git a/internal/watcher/diff/config_diff_test.go b/internal/watcher/diff/config_diff_test.go index b9a9153b18..192791ea74 100644 --- a/internal/watcher/diff/config_diff_test.go +++ b/internal/watcher/diff/config_diff_test.go @@ -3,8 +3,8 @@ package diff import ( "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) func TestBuildConfigChangeDetails(t *testing.T) { diff --git a/internal/watcher/diff/model_hash.go b/internal/watcher/diff/model_hash.go index 5779faccd7..fed3386a7a 100644 --- a/internal/watcher/diff/model_hash.go +++ b/internal/watcher/diff/model_hash.go @@ -7,7 +7,7 @@ import ( "sort" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) // ComputeOpenAICompatModelsHash returns a stable hash for OpenAI-compat models. diff --git a/internal/watcher/diff/model_hash_test.go b/internal/watcher/diff/model_hash_test.go index db06ebd12c..b687d4da2e 100644 --- a/internal/watcher/diff/model_hash_test.go +++ b/internal/watcher/diff/model_hash_test.go @@ -3,7 +3,7 @@ package diff import ( "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) func TestComputeOpenAICompatModelsHash_Deterministic(t *testing.T) { diff --git a/internal/watcher/diff/models_summary.go b/internal/watcher/diff/models_summary.go index 9c2aa91ac4..4c9b035a16 100644 --- a/internal/watcher/diff/models_summary.go +++ b/internal/watcher/diff/models_summary.go @@ -6,7 +6,7 @@ import ( "sort" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) type GeminiModelsSummary struct { diff --git a/internal/watcher/diff/oauth_excluded.go b/internal/watcher/diff/oauth_excluded.go index 2039cf4898..d632062840 100644 --- a/internal/watcher/diff/oauth_excluded.go +++ b/internal/watcher/diff/oauth_excluded.go @@ -7,7 +7,7 @@ import ( "sort" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) type ExcludedModelsSummary struct { diff --git a/internal/watcher/diff/oauth_excluded_test.go b/internal/watcher/diff/oauth_excluded_test.go index f5ad391358..8643f59447 100644 --- a/internal/watcher/diff/oauth_excluded_test.go +++ b/internal/watcher/diff/oauth_excluded_test.go @@ -3,7 +3,7 @@ package diff import ( "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) func TestSummarizeExcludedModels_NormalizesAndDedupes(t *testing.T) { diff --git a/internal/watcher/diff/oauth_model_alias.go b/internal/watcher/diff/oauth_model_alias.go index c5a17d2940..8c14089b9f 100644 --- a/internal/watcher/diff/oauth_model_alias.go +++ b/internal/watcher/diff/oauth_model_alias.go @@ -7,7 +7,7 @@ import ( "sort" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) type OAuthModelAliasSummary struct { diff --git a/internal/watcher/diff/openai_compat.go b/internal/watcher/diff/openai_compat.go index 541b35b3d1..31d0bcd99d 100644 --- a/internal/watcher/diff/openai_compat.go +++ b/internal/watcher/diff/openai_compat.go @@ -7,7 +7,7 @@ import ( "sort" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) // DiffOpenAICompatibility produces human-readable change descriptions. diff --git a/internal/watcher/diff/openai_compat_test.go b/internal/watcher/diff/openai_compat_test.go index db33db1487..5683671ae4 100644 --- a/internal/watcher/diff/openai_compat_test.go +++ b/internal/watcher/diff/openai_compat_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) func TestDiffOpenAICompatibility(t *testing.T) { diff --git a/internal/watcher/dispatcher.go b/internal/watcher/dispatcher.go index 3d7d7527b3..d0182e2c25 100644 --- a/internal/watcher/dispatcher.go +++ b/internal/watcher/dispatcher.go @@ -9,9 +9,9 @@ import ( "sync" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/synthesizer" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/synthesizer" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) var snapshotCoreAuthsFunc = snapshotCoreAuths diff --git a/internal/watcher/synthesizer/config.go b/internal/watcher/synthesizer/config.go index 8026b02fa9..ba8fe52edb 100644 --- a/internal/watcher/synthesizer/config.go +++ b/internal/watcher/synthesizer/config.go @@ -5,8 +5,8 @@ import ( "strconv" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) // ConfigSynthesizer generates Auth entries from configuration API keys. diff --git a/internal/watcher/synthesizer/config_test.go b/internal/watcher/synthesizer/config_test.go index 437f18d11e..c57b8fc7f7 100644 --- a/internal/watcher/synthesizer/config_test.go +++ b/internal/watcher/synthesizer/config_test.go @@ -4,8 +4,8 @@ import ( "testing" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) func TestNewConfigSynthesizer(t *testing.T) { diff --git a/internal/watcher/synthesizer/context.go b/internal/watcher/synthesizer/context.go index d973289a3a..f92b41ddaf 100644 --- a/internal/watcher/synthesizer/context.go +++ b/internal/watcher/synthesizer/context.go @@ -3,7 +3,7 @@ package synthesizer import ( "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) // SynthesisContext provides the context needed for auth synthesis. diff --git a/internal/watcher/synthesizer/file.go b/internal/watcher/synthesizer/file.go index 49a635e7e8..47990bc154 100644 --- a/internal/watcher/synthesizer/file.go +++ b/internal/watcher/synthesizer/file.go @@ -10,9 +10,9 @@ import ( "strings" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex" - "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/geminicli" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) // FileSynthesizer generates Auth entries from OAuth JSON files. diff --git a/internal/watcher/synthesizer/file_test.go b/internal/watcher/synthesizer/file_test.go index f3e4497923..63b394aaf5 100644 --- a/internal/watcher/synthesizer/file_test.go +++ b/internal/watcher/synthesizer/file_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) func TestNewFileSynthesizer(t *testing.T) { diff --git a/internal/watcher/synthesizer/helpers.go b/internal/watcher/synthesizer/helpers.go index 102dc77e22..19b4c896f1 100644 --- a/internal/watcher/synthesizer/helpers.go +++ b/internal/watcher/synthesizer/helpers.go @@ -7,9 +7,9 @@ import ( "sort" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) // StableIDGenerator generates stable, deterministic IDs for auth entries. diff --git a/internal/watcher/synthesizer/helpers_test.go b/internal/watcher/synthesizer/helpers_test.go index 46b9c8a053..69ba85d60d 100644 --- a/internal/watcher/synthesizer/helpers_test.go +++ b/internal/watcher/synthesizer/helpers_test.go @@ -5,9 +5,9 @@ import ( "strings" "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) func TestNewStableIDGenerator(t *testing.T) { diff --git a/internal/watcher/synthesizer/interface.go b/internal/watcher/synthesizer/interface.go index 1a9aedc965..e0962c11c9 100644 --- a/internal/watcher/synthesizer/interface.go +++ b/internal/watcher/synthesizer/interface.go @@ -5,7 +5,7 @@ package synthesizer import ( - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) // AuthSynthesizer defines the interface for generating Auth entries from various sources. diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index cf890a4c46..c18cd84d08 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -10,11 +10,11 @@ import ( "time" "github.com/fsnotify/fsnotify" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "gopkg.in/yaml.v3" - sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" ) diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 00a7a14360..bb3b557777 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -14,11 +14,11 @@ import ( "time" "github.com/fsnotify/fsnotify" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff" - "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/synthesizer" - sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff" + "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/synthesizer" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" "gopkg.in/yaml.v3" ) diff --git a/sdk/api/handlers/claude/code_handlers.go b/sdk/api/handlers/claude/code_handlers.go index 074ffc0d07..464f385eb5 100644 --- a/sdk/api/handlers/claude/code_handlers.go +++ b/sdk/api/handlers/claude/code_handlers.go @@ -16,10 +16,10 @@ import ( "net/http" "github.com/gin-gonic/gin" - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" ) diff --git a/sdk/api/handlers/gemini/gemini-cli_handlers.go b/sdk/api/handlers/gemini/gemini-cli_handlers.go index 4c5ddf80f9..de79f05b7c 100644 --- a/sdk/api/handlers/gemini/gemini-cli_handlers.go +++ b/sdk/api/handlers/gemini/gemini-cli_handlers.go @@ -15,10 +15,10 @@ import ( "time" "github.com/gin-gonic/gin" - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" ) diff --git a/sdk/api/handlers/gemini/gemini_handlers.go b/sdk/api/handlers/gemini/gemini_handlers.go index e51ad19bc5..60aed26a55 100644 --- a/sdk/api/handlers/gemini/gemini_handlers.go +++ b/sdk/api/handlers/gemini/gemini_handlers.go @@ -13,10 +13,10 @@ import ( "time" "github.com/gin-gonic/gin" - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" ) // GeminiAPIHandler contains the handlers for Gemini API endpoints. diff --git a/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go index e89227aa70..6e0adb6417 100644 --- a/sdk/api/handlers/handlers.go +++ b/sdk/api/handlers/handlers.go @@ -14,14 +14,14 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" "golang.org/x/net/context" ) @@ -850,14 +850,22 @@ func (h *BaseAPIHandler) getRequestDetails(modelName string) (providers []string resolvedModelName := modelName initialSuffix := thinking.ParseSuffix(modelName) if initialSuffix.ModelName == "auto" { - resolvedBase := util.ResolveAutoModel(initialSuffix.ModelName) - if initialSuffix.HasSuffix { - resolvedModelName = fmt.Sprintf("%s(%s)", resolvedBase, initialSuffix.RawSuffix) + if h != nil && h.AuthManager != nil && h.AuthManager.HomeEnabled() { + resolvedModelName = modelName } else { - resolvedModelName = resolvedBase + resolvedBase := util.ResolveAutoModel(initialSuffix.ModelName) + if initialSuffix.HasSuffix { + resolvedModelName = fmt.Sprintf("%s(%s)", resolvedBase, initialSuffix.RawSuffix) + } else { + resolvedModelName = resolvedBase + } } } else { - resolvedModelName = util.ResolveAutoModel(modelName) + if h != nil && h.AuthManager != nil && h.AuthManager.HomeEnabled() { + resolvedModelName = modelName + } else { + resolvedModelName = util.ResolveAutoModel(modelName) + } } parsed := thinking.ParseSuffix(resolvedModelName) @@ -870,6 +878,10 @@ func (h *BaseAPIHandler) getRequestDetails(modelName string) (providers []string } } + if h != nil && h.AuthManager != nil && h.AuthManager.HomeEnabled() { + return []string{"home"}, resolvedModelName, nil + } + providers = util.GetProviderName(baseModel) // Fallback: if baseModel has no provider but differs from resolvedModelName, // try using the full model name. This handles edge cases where custom models diff --git a/sdk/api/handlers/handlers_error_response_test.go b/sdk/api/handlers/handlers_error_response_test.go index 917971c245..0c206e386f 100644 --- a/sdk/api/handlers/handlers_error_response_test.go +++ b/sdk/api/handlers/handlers_error_response_test.go @@ -9,9 +9,9 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) func TestWriteErrorResponse_AddonHeadersDisabledByDefault(t *testing.T) { diff --git a/sdk/api/handlers/handlers_metadata_test.go b/sdk/api/handlers/handlers_metadata_test.go index 99af872dc0..c5e94f963e 100644 --- a/sdk/api/handlers/handlers_metadata_test.go +++ b/sdk/api/handlers/handlers_metadata_test.go @@ -3,7 +3,7 @@ package handlers import ( "testing" - coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" "golang.org/x/net/context" ) diff --git a/sdk/api/handlers/handlers_request_details_test.go b/sdk/api/handlers/handlers_request_details_test.go index c98580f224..3110cbc561 100644 --- a/sdk/api/handlers/handlers_request_details_test.go +++ b/sdk/api/handlers/handlers_request_details_test.go @@ -7,9 +7,9 @@ import ( "testing" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) func TestGetRequestDetails_PreservesSuffix(t *testing.T) { diff --git a/sdk/api/handlers/handlers_stream_bootstrap_test.go b/sdk/api/handlers/handlers_stream_bootstrap_test.go index f357962f0a..551baac374 100644 --- a/sdk/api/handlers/handlers_stream_bootstrap_test.go +++ b/sdk/api/handlers/handlers_stream_bootstrap_test.go @@ -8,11 +8,11 @@ import ( "sync" "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) type failOnceStreamExecutor struct { diff --git a/sdk/api/handlers/openai/openai_handlers.go b/sdk/api/handlers/openai/openai_handlers.go index 4b4a9833bd..29dc0ea0b1 100644 --- a/sdk/api/handlers/openai/openai_handlers.go +++ b/sdk/api/handlers/openai/openai_handlers.go @@ -14,11 +14,11 @@ import ( "sync" "github.com/gin-gonic/gin" - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - responsesconverter "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/openai/responses" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + responsesconverter "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/openai/responses" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/sdk/api/handlers/openai/openai_images_handlers.go b/sdk/api/handlers/openai/openai_images_handlers.go index 8d22a4f4ed..6e6e8ef6ff 100644 --- a/sdk/api/handlers/openai/openai_images_handlers.go +++ b/sdk/api/handlers/openai/openai_images_handlers.go @@ -14,9 +14,9 @@ import ( "time" "github.com/gin-gonic/gin" - internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" + internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" diff --git a/sdk/api/handlers/openai/openai_images_handlers_test.go b/sdk/api/handlers/openai/openai_images_handlers_test.go index ea65ca3a5d..7796599619 100644 --- a/sdk/api/handlers/openai/openai_images_handlers_test.go +++ b/sdk/api/handlers/openai/openai_images_handlers_test.go @@ -10,9 +10,9 @@ import ( "testing" "github.com/gin-gonic/gin" - internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" - sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" "github.com/tidwall/gjson" ) diff --git a/sdk/api/handlers/openai/openai_responses_compact_test.go b/sdk/api/handlers/openai/openai_responses_compact_test.go index dcfcc99a7c..48b7e3bbde 100644 --- a/sdk/api/handlers/openai/openai_responses_compact_test.go +++ b/sdk/api/handlers/openai/openai_responses_compact_test.go @@ -9,11 +9,11 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) type compactCaptureExecutor struct { diff --git a/sdk/api/handlers/openai/openai_responses_handlers.go b/sdk/api/handlers/openai/openai_responses_handlers.go index 8dd1a0a7b1..5b2c006a30 100644 --- a/sdk/api/handlers/openai/openai_responses_handlers.go +++ b/sdk/api/handlers/openai/openai_responses_handlers.go @@ -16,10 +16,10 @@ import ( "sort" "github.com/gin-gonic/gin" - . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go b/sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go index 771e46b88b..54d1467589 100644 --- a/sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go +++ b/sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go @@ -8,9 +8,9 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" - sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) func TestForwardResponsesStreamTerminalErrorUsesResponsesErrorChunk(t *testing.T) { diff --git a/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go b/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go index 151da9a79f..0742b9b3d3 100644 --- a/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go +++ b/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go @@ -7,9 +7,9 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" - sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" "github.com/tidwall/gjson" ) diff --git a/sdk/api/handlers/openai/openai_responses_websocket.go b/sdk/api/handlers/openai/openai_responses_websocket.go index c617c94644..bfac492167 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket.go +++ b/sdk/api/handlers/openai/openai_responses_websocket.go @@ -13,13 +13,13 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/gorilla/websocket" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" diff --git a/sdk/api/handlers/openai/openai_responses_websocket_test.go b/sdk/api/handlers/openai/openai_responses_websocket_test.go index 319127f0e0..a76c46254d 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket_test.go +++ b/sdk/api/handlers/openai/openai_responses_websocket_test.go @@ -14,12 +14,12 @@ import ( "github.com/gin-gonic/gin" "github.com/gorilla/websocket" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" "github.com/tidwall/gjson" ) diff --git a/sdk/api/handlers/stream_forwarder.go b/sdk/api/handlers/stream_forwarder.go index 401baca8fa..63ddc31e43 100644 --- a/sdk/api/handlers/stream_forwarder.go +++ b/sdk/api/handlers/stream_forwarder.go @@ -5,7 +5,7 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" ) type StreamForwardOptions struct { diff --git a/sdk/api/management.go b/sdk/api/management.go index a5a1cfc490..3ed586d8da 100644 --- a/sdk/api/management.go +++ b/sdk/api/management.go @@ -6,9 +6,9 @@ package api import ( "github.com/gin-gonic/gin" - internalmanagement "github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/management" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + internalmanagement "github.com/router-for-me/CLIProxyAPI/v7/internal/api/handlers/management" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) // ManagementTokenRequester exposes a limited subset of management endpoints for requesting tokens. diff --git a/sdk/api/options.go b/sdk/api/options.go index 8497884bf0..e2bbff78e9 100644 --- a/sdk/api/options.go +++ b/sdk/api/options.go @@ -8,10 +8,10 @@ import ( "time" "github.com/gin-gonic/gin" - internalapi "github.com/router-for-me/CLIProxyAPI/v6/internal/api" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/logging" + internalapi "github.com/router-for-me/CLIProxyAPI/v7/internal/api" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/logging" ) // ServerOption customises HTTP server construction. diff --git a/sdk/auth/antigravity.go b/sdk/auth/antigravity.go index d52bf1d259..0a947b20f0 100644 --- a/sdk/auth/antigravity.go +++ b/sdk/auth/antigravity.go @@ -8,12 +8,12 @@ import ( "strings" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/antigravity" - "github.com/router-for-me/CLIProxyAPI/v6/internal/browser" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/antigravity" + "github.com/router-for-me/CLIProxyAPI/v7/internal/browser" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" ) diff --git a/sdk/auth/claude.go b/sdk/auth/claude.go index d82a718b2d..726fa922ae 100644 --- a/sdk/auth/claude.go +++ b/sdk/auth/claude.go @@ -7,13 +7,13 @@ import ( "strings" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude" - "github.com/router-for-me/CLIProxyAPI/v6/internal/browser" + "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude" + "github.com/router-for-me/CLIProxyAPI/v7/internal/browser" // legacy client removed - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" ) diff --git a/sdk/auth/codex.go b/sdk/auth/codex.go index 269e3d8b21..be58c9c5a6 100644 --- a/sdk/auth/codex.go +++ b/sdk/auth/codex.go @@ -7,13 +7,13 @@ import ( "strings" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex" - "github.com/router-for-me/CLIProxyAPI/v6/internal/browser" + "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex" + "github.com/router-for-me/CLIProxyAPI/v7/internal/browser" // legacy client removed - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" ) diff --git a/sdk/auth/codex_device.go b/sdk/auth/codex_device.go index 10f59fb97b..d7ea4e1fe9 100644 --- a/sdk/auth/codex_device.go +++ b/sdk/auth/codex_device.go @@ -13,11 +13,11 @@ import ( "strings" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex" - "github.com/router-for-me/CLIProxyAPI/v6/internal/browser" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex" + "github.com/router-for-me/CLIProxyAPI/v7/internal/browser" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" ) diff --git a/sdk/auth/errors.go b/sdk/auth/errors.go index 78fe9a17bd..f950e925ff 100644 --- a/sdk/auth/errors.go +++ b/sdk/auth/errors.go @@ -3,7 +3,7 @@ package auth import ( "fmt" - "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" ) // ProjectSelectionError indicates that the user must choose a specific project ID. diff --git a/sdk/auth/filestore.go b/sdk/auth/filestore.go index f8f49f44ba..39be2d8f48 100644 --- a/sdk/auth/filestore.go +++ b/sdk/auth/filestore.go @@ -15,7 +15,7 @@ import ( "sync" "time" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) // FileTokenStore persists token records and auth metadata using the filesystem as backing storage. diff --git a/sdk/auth/gemini.go b/sdk/auth/gemini.go index 2b8f9c2b88..ba7c7728ad 100644 --- a/sdk/auth/gemini.go +++ b/sdk/auth/gemini.go @@ -5,10 +5,10 @@ import ( "fmt" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini" + "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/gemini" // legacy client removed - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) // GeminiAuthenticator implements the login flow for Google Gemini CLI accounts. diff --git a/sdk/auth/interfaces.go b/sdk/auth/interfaces.go index 64cf8ed035..e5582a0cc5 100644 --- a/sdk/auth/interfaces.go +++ b/sdk/auth/interfaces.go @@ -5,8 +5,8 @@ import ( "errors" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) var ErrRefreshNotSupported = errors.New("cliproxy auth: refresh not supported") diff --git a/sdk/auth/kimi.go b/sdk/auth/kimi.go index 12ae101e7d..4dbff1e87e 100644 --- a/sdk/auth/kimi.go +++ b/sdk/auth/kimi.go @@ -6,10 +6,10 @@ import ( "strings" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi" - "github.com/router-for-me/CLIProxyAPI/v6/internal/browser" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/kimi" + "github.com/router-for-me/CLIProxyAPI/v7/internal/browser" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" ) diff --git a/sdk/auth/manager.go b/sdk/auth/manager.go index c6469a7d19..bceb5e196d 100644 --- a/sdk/auth/manager.go +++ b/sdk/auth/manager.go @@ -4,8 +4,8 @@ import ( "context" "fmt" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) // Manager aggregates authenticators and coordinates persistence via a token store. diff --git a/sdk/auth/refresh_registry.go b/sdk/auth/refresh_registry.go index ae60f56a64..fe25231507 100644 --- a/sdk/auth/refresh_registry.go +++ b/sdk/auth/refresh_registry.go @@ -3,7 +3,7 @@ package auth import ( "time" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) func init() { diff --git a/sdk/auth/store_registry.go b/sdk/auth/store_registry.go index 760449f8cf..1971947bc8 100644 --- a/sdk/auth/store_registry.go +++ b/sdk/auth/store_registry.go @@ -3,7 +3,7 @@ package auth import ( "sync" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) var ( diff --git a/sdk/cliproxy/auth/antigravity_credits_test.go b/sdk/cliproxy/auth/antigravity_credits_test.go index 38c08dcfbc..34a475dc6a 100644 --- a/sdk/cliproxy/auth/antigravity_credits_test.go +++ b/sdk/cliproxy/auth/antigravity_credits_test.go @@ -7,9 +7,9 @@ import ( "testing" "time" - internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" ) type antigravityCreditsFallbackExecutor struct { diff --git a/sdk/cliproxy/auth/api_key_model_alias_test.go b/sdk/cliproxy/auth/api_key_model_alias_test.go index 70915d9e37..25da4df4ed 100644 --- a/sdk/cliproxy/auth/api_key_model_alias_test.go +++ b/sdk/cliproxy/auth/api_key_model_alias_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) func TestLookupAPIKeyUpstreamModel(t *testing.T) { diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index ab3eca4957..f9bf0510ae 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -16,13 +16,14 @@ import ( "time" "github.com/google/uuid" - internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - coreusage "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" + internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/home" + "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage" log "github.com/sirupsen/logrus" ) @@ -377,6 +378,15 @@ func (m *Manager) SetConfig(cfg *internalconfig.Config) { m.rebuildAPIKeyModelAliasFromRuntimeConfig() } +// HomeEnabled reports whether the home control plane integration is enabled in the runtime config. +func (m *Manager) HomeEnabled() bool { + if m == nil { + return false + } + cfg, _ := m.runtimeConfig.Load().(*internalconfig.Config) + return cfg != nil && cfg.Home.Enabled +} + func (m *Manager) lookupAPIKeyUpstreamModel(authID, requestedModel string) string { if m == nil { return "" @@ -522,6 +532,11 @@ func preserveRequestedModelSuffix(requestedModel, resolved string) string { } func (m *Manager) executionModelCandidates(auth *Auth, routeModel string) []string { + if auth != nil && auth.Attributes != nil { + if homeModel := strings.TrimSpace(auth.Attributes[homeUpstreamModelAttributeKey]); homeModel != "" { + return []string{homeModel} + } + } requestedModel := rewriteModelForAuth(routeModel, auth) requestedModel = m.applyOAuthModelAlias(auth, requestedModel) if pool := m.resolveOpenAICompatUpstreamModelPool(auth, requestedModel); len(pool) > 0 { @@ -555,6 +570,14 @@ func (m *Manager) selectionModelKeyForAuth(auth *Auth, routeModel string) string } func (m *Manager) stateModelForExecution(auth *Auth, routeModel, upstreamModel string, pooled bool) string { + if auth != nil && auth.Attributes != nil { + if homeModel := strings.TrimSpace(auth.Attributes[homeUpstreamModelAttributeKey]); homeModel != "" { + if resolved := strings.TrimSpace(upstreamModel); resolved != "" { + return resolved + } + return homeModel + } + } stateModel := executionResultModel(routeModel, upstreamModel, pooled) selectionModel := m.selectionModelForAuth(auth, routeModel) if canonicalModelKey(selectionModel) == canonicalModelKey(upstreamModel) && strings.TrimSpace(selectionModel) != "" { @@ -2710,6 +2733,11 @@ func (m *Manager) routeAwareSelectionRequired(auth *Auth, routeModel string) boo } func (m *Manager) pickNextLegacy(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, error) { + if m.HomeEnabled() { + auth, exec, _, err := m.pickNextViaHome(ctx, model, opts) + return auth, exec, err + } + pinnedAuthID := pinnedAuthIDFromMetadata(opts.Metadata) disallowFreeAuth := disallowFreeAuthFromMetadata(opts.Metadata) @@ -2779,6 +2807,11 @@ func (m *Manager) pickNextLegacy(ctx context.Context, provider, model string, op } func (m *Manager) pickNext(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, error) { + if m.HomeEnabled() { + auth, exec, _, err := m.pickNextViaHome(ctx, model, opts) + return auth, exec, err + } + if !m.useSchedulerFastPath() { return m.pickNextLegacy(ctx, provider, model, opts, tried) } @@ -2836,6 +2869,10 @@ func (m *Manager) pickNext(ctx context.Context, provider, model string, opts cli } func (m *Manager) pickNextMixedLegacy(ctx context.Context, providers []string, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, string, error) { + if m.HomeEnabled() { + return m.pickNextViaHome(ctx, model, opts) + } + pinnedAuthID := pinnedAuthIDFromMetadata(opts.Metadata) disallowFreeAuth := disallowFreeAuthFromMetadata(opts.Metadata) @@ -2928,6 +2965,10 @@ func (m *Manager) pickNextMixedLegacy(ctx context.Context, providers []string, m } func (m *Manager) pickNextMixed(ctx context.Context, providers []string, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, string, error) { + if m.HomeEnabled() { + return m.pickNextViaHome(ctx, model, opts) + } + if !m.useSchedulerFastPath() { return m.pickNextMixedLegacy(ctx, providers, model, opts, tried) } @@ -3012,6 +3053,148 @@ func (m *Manager) pickNextMixed(ctx context.Context, providers []string, model s } } +type homeErrorEnvelope struct { + Error *homeErrorDetail `json:"error"` +} + +type homeErrorDetail struct { + Type string `json:"type"` + Message string `json:"message"` + Code string `json:"code,omitempty"` +} + +const homeUpstreamModelAttributeKey = "home_upstream_model" + +type homeAuthDispatchResponse struct { + Model string `json:"model"` + Provider string `json:"provider"` + AuthIndex string `json:"auth_index"` + UserAPIKey string `json:"user_api_key"` + Auth Auth `json:"auth"` +} + +func setHomeUserAPIKeyOnGinContext(ctx context.Context, apiKey string) { + apiKey = strings.TrimSpace(apiKey) + if apiKey == "" || ctx == nil { + return + } + ginCtx, ok := ctx.Value("gin").(interface{ Set(string, any) }) + if !ok || ginCtx == nil { + return + } + ginCtx.Set("userApiKey", apiKey) +} + +func (m *Manager) pickNextViaHome(ctx context.Context, model string, opts cliproxyexecutor.Options) (*Auth, ProviderExecutor, string, error) { + if m == nil { + return nil, nil, "", &Error{Code: "auth_not_found", Message: "no auth available"} + } + if ctx == nil { + ctx = context.Background() + } + client := home.Current() + if client == nil || !client.HeartbeatOK() { + return nil, nil, "", &Error{Code: "home_unavailable", Message: "home control center unavailable", HTTPStatus: http.StatusServiceUnavailable} + } + + requestedModel := requestedModelFromMetadata(opts.Metadata, model) + sessionID := ExtractSessionID(opts.Headers, opts.OriginalRequest, opts.Metadata) + + raw, err := client.RPopAuth(ctx, requestedModel, sessionID, opts.Headers) + if err != nil { + return nil, nil, "", &Error{Code: "auth_not_found", Message: err.Error(), HTTPStatus: http.StatusServiceUnavailable} + } + + var env homeErrorEnvelope + if errUnmarshal := json.Unmarshal(raw, &env); errUnmarshal == nil && env.Error != nil { + code := strings.TrimSpace(env.Error.Type) + if code == "" { + code = strings.TrimSpace(env.Error.Code) + } + msg := strings.TrimSpace(env.Error.Message) + if msg == "" { + msg = "home returned error" + } + status := http.StatusBadGateway + switch strings.ToLower(code) { + case "model_not_found": + status = http.StatusNotFound + case "authentication_error", "unauthorized": + status = http.StatusUnauthorized + } + return nil, nil, "", &Error{Code: code, Message: msg, HTTPStatus: status} + } + + var dispatch homeAuthDispatchResponse + if errUnmarshal := json.Unmarshal(raw, &dispatch); errUnmarshal != nil { + return nil, nil, "", &Error{Code: "invalid_auth", Message: "home returned invalid auth payload", HTTPStatus: http.StatusBadGateway} + } + setHomeUserAPIKeyOnGinContext(ctx, dispatch.UserAPIKey) + auth := dispatch.Auth + if strings.TrimSpace(auth.ID) == "" { + // Backward compatibility: older home instances returned the auth directly. + if errUnmarshal := json.Unmarshal(raw, &auth); errUnmarshal != nil { + return nil, nil, "", &Error{Code: "invalid_auth", Message: "home returned invalid auth payload", HTTPStatus: http.StatusBadGateway} + } + } + if upstreamModel := strings.TrimSpace(dispatch.Model); upstreamModel != "" { + if auth.Attributes == nil { + auth.Attributes = make(map[string]string, 1) + } + auth.Attributes[homeUpstreamModelAttributeKey] = upstreamModel + } + if strings.TrimSpace(auth.ID) == "" { + return nil, nil, "", &Error{Code: "invalid_auth", Message: "home returned auth without id", HTTPStatus: http.StatusBadGateway} + } + providerKey := strings.ToLower(strings.TrimSpace(auth.Provider)) + if providerKey == "" { + return nil, nil, "", &Error{Code: "invalid_auth", Message: "home returned auth without provider", HTTPStatus: http.StatusBadGateway} + } + + homeAuthIndex := strings.TrimSpace(dispatch.AuthIndex) + if homeAuthIndex != "" { + auth.Index = homeAuthIndex + auth.indexAssigned = true + } else { + auth.EnsureIndex() + } + + executor, ok := m.Executor(providerKey) + if !ok && auth.Attributes != nil && strings.TrimSpace(auth.Attributes["base_url"]) != "" { + executor, ok = m.Executor("openai-compatibility") + if ok { + providerKey = "openai-compatibility" + } + } + if !ok { + return nil, nil, "", &Error{Code: "executor_not_found", Message: "executor not registered", HTTPStatus: http.StatusBadGateway} + } + + return auth.Clone(), executor, providerKey, nil +} + +func requestedModelFromMetadata(metadata map[string]any, fallback string) string { + if metadata != nil { + if v, ok := metadata[cliproxyexecutor.RequestedModelMetadataKey]; ok { + switch typed := v.(type) { + case string: + if trimmed := strings.TrimSpace(typed); trimmed != "" { + return trimmed + } + case []byte: + if trimmed := strings.TrimSpace(string(typed)); trimmed != "" { + return trimmed + } + } + } + } + fallback = strings.TrimSpace(fallback) + if fallback == "" { + return "unknown" + } + return fallback +} + func (m *Manager) findAllAntigravityCreditsCandidateAuths(routeModel string, opts cliproxyexecutor.Options) []creditsCandidateEntry { if m == nil { return nil diff --git a/sdk/cliproxy/auth/conductor_credits_candidates_test.go b/sdk/cliproxy/auth/conductor_credits_candidates_test.go index e66798acf6..f9487b0b9b 100644 --- a/sdk/cliproxy/auth/conductor_credits_candidates_test.go +++ b/sdk/cliproxy/auth/conductor_credits_candidates_test.go @@ -4,7 +4,7 @@ import ( "testing" "time" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" ) func TestFindAllAntigravityCreditsCandidateAuths_PrefersKnownCreditsThenUnknown(t *testing.T) { diff --git a/sdk/cliproxy/auth/conductor_executor_replace_test.go b/sdk/cliproxy/auth/conductor_executor_replace_test.go index 2ee91a87c1..99ecf466a6 100644 --- a/sdk/cliproxy/auth/conductor_executor_replace_test.go +++ b/sdk/cliproxy/auth/conductor_executor_replace_test.go @@ -6,7 +6,7 @@ import ( "sync" "testing" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" ) type replaceAwareExecutor struct { diff --git a/sdk/cliproxy/auth/conductor_oauth_alias_suspension_test.go b/sdk/cliproxy/auth/conductor_oauth_alias_suspension_test.go index b4b72204c8..ba8371dc61 100644 --- a/sdk/cliproxy/auth/conductor_oauth_alias_suspension_test.go +++ b/sdk/cliproxy/auth/conductor_oauth_alias_suspension_test.go @@ -7,10 +7,10 @@ import ( "testing" "time" - internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - coreusage "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" + internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage" ) type aliasRoutingExecutor struct { diff --git a/sdk/cliproxy/auth/conductor_overrides_test.go b/sdk/cliproxy/auth/conductor_overrides_test.go index f74621bec7..017602e362 100644 --- a/sdk/cliproxy/auth/conductor_overrides_test.go +++ b/sdk/cliproxy/auth/conductor_overrides_test.go @@ -8,9 +8,9 @@ import ( "time" "github.com/google/uuid" - internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" ) const requestScopedNotFoundMessage = "Item with id 'rs_0b5f3eb6f51f175c0169ca74e4a85881998539920821603a74' not found. Items are not persisted when `store` is set to false. Try again with `store` set to true, or remove this item from your input." diff --git a/sdk/cliproxy/auth/conductor_scheduler_refresh_test.go b/sdk/cliproxy/auth/conductor_scheduler_refresh_test.go index 5c6eff7805..508cdfd137 100644 --- a/sdk/cliproxy/auth/conductor_scheduler_refresh_test.go +++ b/sdk/cliproxy/auth/conductor_scheduler_refresh_test.go @@ -6,8 +6,8 @@ import ( "net/http" "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" ) type schedulerProviderTestExecutor struct { diff --git a/sdk/cliproxy/auth/oauth_model_alias.go b/sdk/cliproxy/auth/oauth_model_alias.go index 46c82a9c53..7e6740d6bb 100644 --- a/sdk/cliproxy/auth/oauth_model_alias.go +++ b/sdk/cliproxy/auth/oauth_model_alias.go @@ -3,8 +3,8 @@ package auth import ( "strings" - internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" ) type modelAliasEntry interface { diff --git a/sdk/cliproxy/auth/oauth_model_alias_test.go b/sdk/cliproxy/auth/oauth_model_alias_test.go index 73ddbe675d..521e158e55 100644 --- a/sdk/cliproxy/auth/oauth_model_alias_test.go +++ b/sdk/cliproxy/auth/oauth_model_alias_test.go @@ -3,7 +3,7 @@ package auth import ( "testing" - internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) func TestResolveOAuthUpstreamModel_SuffixPreservation(t *testing.T) { diff --git a/sdk/cliproxy/auth/openai_compat_pool_test.go b/sdk/cliproxy/auth/openai_compat_pool_test.go index ff2c4dd040..f052c486f4 100644 --- a/sdk/cliproxy/auth/openai_compat_pool_test.go +++ b/sdk/cliproxy/auth/openai_compat_pool_test.go @@ -7,9 +7,9 @@ import ( "sync" "testing" - internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" ) type openAICompatPoolExecutor struct { diff --git a/sdk/cliproxy/auth/scheduler.go b/sdk/cliproxy/auth/scheduler.go index b5a3928286..9947f59c63 100644 --- a/sdk/cliproxy/auth/scheduler.go +++ b/sdk/cliproxy/auth/scheduler.go @@ -7,8 +7,8 @@ import ( "sync" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" ) // schedulerStrategy identifies which built-in routing semantics the scheduler should apply. diff --git a/sdk/cliproxy/auth/scheduler_benchmark_test.go b/sdk/cliproxy/auth/scheduler_benchmark_test.go index 050a7cbd1e..4d160276f2 100644 --- a/sdk/cliproxy/auth/scheduler_benchmark_test.go +++ b/sdk/cliproxy/auth/scheduler_benchmark_test.go @@ -6,8 +6,8 @@ import ( "net/http" "testing" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" ) type schedulerBenchmarkExecutor struct { diff --git a/sdk/cliproxy/auth/scheduler_test.go b/sdk/cliproxy/auth/scheduler_test.go index 8caaa4735b..864fa938e9 100644 --- a/sdk/cliproxy/auth/scheduler_test.go +++ b/sdk/cliproxy/auth/scheduler_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" ) type schedulerTestExecutor struct{} diff --git a/sdk/cliproxy/auth/selector.go b/sdk/cliproxy/auth/selector.go index f0fe237c83..5e23c46f55 100644 --- a/sdk/cliproxy/auth/selector.go +++ b/sdk/cliproxy/auth/selector.go @@ -18,9 +18,9 @@ import ( log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" - "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" ) // RoundRobinSelector provides a simple provider scoped round-robin selection strategy. diff --git a/sdk/cliproxy/auth/selector_test.go b/sdk/cliproxy/auth/selector_test.go index f6682c6fce..99231bdf78 100644 --- a/sdk/cliproxy/auth/selector_test.go +++ b/sdk/cliproxy/auth/selector_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" ) func TestFillFirstSelectorPick_Deterministic(t *testing.T) { diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go index 76f4c396c8..3cbd49b578 100644 --- a/sdk/cliproxy/auth/types.go +++ b/sdk/cliproxy/auth/types.go @@ -12,7 +12,7 @@ import ( "sync" "time" - baseauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth" + baseauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth" ) // PostAuthHook defines a function that is called after an Auth record is created diff --git a/sdk/cliproxy/builder.go b/sdk/cliproxy/builder.go index b8cf991c14..152940a04f 100644 --- a/sdk/cliproxy/builder.go +++ b/sdk/cliproxy/builder.go @@ -8,12 +8,12 @@ import ( "strings" "time" - configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access" - "github.com/router-for-me/CLIProxyAPI/v6/internal/api" - sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" - sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + configaccess "github.com/router-for-me/CLIProxyAPI/v7/internal/access/config_access" + "github.com/router-for-me/CLIProxyAPI/v7/internal/api" + sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) // Builder constructs a Service instance with customizable providers. diff --git a/sdk/cliproxy/executor/types.go b/sdk/cliproxy/executor/types.go index c8bb917d03..fd1da2e537 100644 --- a/sdk/cliproxy/executor/types.go +++ b/sdk/cliproxy/executor/types.go @@ -4,7 +4,7 @@ import ( "net/http" "net/url" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" ) // RequestedModelMetadataKey stores the client-requested model name in Options.Metadata. diff --git a/sdk/cliproxy/model_registry.go b/sdk/cliproxy/model_registry.go index 01cea5b715..9cb928c98a 100644 --- a/sdk/cliproxy/model_registry.go +++ b/sdk/cliproxy/model_registry.go @@ -1,6 +1,6 @@ package cliproxy -import "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" +import "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" // ModelInfo re-exports the registry model info structure. type ModelInfo = registry.ModelInfo diff --git a/sdk/cliproxy/pipeline/context.go b/sdk/cliproxy/pipeline/context.go index fc6754eb97..4cffb0b4d9 100644 --- a/sdk/cliproxy/pipeline/context.go +++ b/sdk/cliproxy/pipeline/context.go @@ -4,9 +4,9 @@ import ( "context" "net/http" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" ) // Context encapsulates execution state shared across middleware, translators, and executors. diff --git a/sdk/cliproxy/pprof_server.go b/sdk/cliproxy/pprof_server.go index 3fafef4cd4..ec30b4bef3 100644 --- a/sdk/cliproxy/pprof_server.go +++ b/sdk/cliproxy/pprof_server.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" log "github.com/sirupsen/logrus" ) diff --git a/sdk/cliproxy/providers.go b/sdk/cliproxy/providers.go index 7ce89f76fe..542b2d9d6a 100644 --- a/sdk/cliproxy/providers.go +++ b/sdk/cliproxy/providers.go @@ -3,8 +3,8 @@ package cliproxy import ( "context" - "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) // NewFileTokenClientProvider returns the default token-backed client loader. diff --git a/sdk/cliproxy/rtprovider.go b/sdk/cliproxy/rtprovider.go index 5c4f579a85..d07b4cb4f9 100644 --- a/sdk/cliproxy/rtprovider.go +++ b/sdk/cliproxy/rtprovider.go @@ -5,8 +5,8 @@ import ( "strings" "sync" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil" log "github.com/sirupsen/logrus" ) diff --git a/sdk/cliproxy/rtprovider_test.go b/sdk/cliproxy/rtprovider_test.go index f907081e29..6ea08432c1 100644 --- a/sdk/cliproxy/rtprovider_test.go +++ b/sdk/cliproxy/rtprovider_test.go @@ -4,7 +4,7 @@ import ( "net/http" "testing" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) func TestRoundTripperForDirectBypassesProxy(t *testing.T) { diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 9f195f5679..3459c0559e 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -12,17 +12,18 @@ import ( "sync" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/api" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor" - "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher" - "github.com/router-for-me/CLIProxyAPI/v6/internal/wsrelay" - sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" - sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/api" + "github.com/router-for-me/CLIProxyAPI/v7/internal/home" + "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor" + "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher" + "github.com/router-for-me/CLIProxyAPI/v7/internal/wsrelay" + sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" log "github.com/sirupsen/logrus" ) @@ -36,6 +37,9 @@ type Service struct { // cfgMu protects concurrent access to the configuration. cfgMu sync.RWMutex + // configUpdateMu serializes config updates across watcher + home. + configUpdateMu sync.Mutex + // configPath is the path to the configuration file. configPath string @@ -89,6 +93,9 @@ type Service struct { // wsGateway manages websocket Gemini providers. wsGateway *wsrelay.Manager + + homeClient *home.Client + homeCancel context.CancelFunc } // RegisterUsagePlugin registers a usage plugin on the global usage manager. @@ -462,6 +469,248 @@ func (s *Service) rebindExecutors() { } } +func (s *Service) applyConfigUpdate(newCfg *config.Config) { + if s == nil { + return + } + + s.configUpdateMu.Lock() + defer s.configUpdateMu.Unlock() + + previousStrategy := "" + var previousSessionAffinity bool + var previousSessionAffinityTTL string + s.cfgMu.RLock() + if s.cfg != nil { + previousStrategy = strings.ToLower(strings.TrimSpace(s.cfg.Routing.Strategy)) + previousSessionAffinity = s.cfg.Routing.ClaudeCodeSessionAffinity || s.cfg.Routing.SessionAffinity + previousSessionAffinityTTL = s.cfg.Routing.SessionAffinityTTL + } + s.cfgMu.RUnlock() + + if newCfg == nil { + s.cfgMu.RLock() + newCfg = s.cfg + s.cfgMu.RUnlock() + } + if newCfg == nil { + return + } + + nextStrategy := strings.ToLower(strings.TrimSpace(newCfg.Routing.Strategy)) + normalizeStrategy := func(strategy string) string { + switch strategy { + case "fill-first", "fillfirst", "ff": + return "fill-first" + default: + return "round-robin" + } + } + previousStrategy = normalizeStrategy(previousStrategy) + nextStrategy = normalizeStrategy(nextStrategy) + + nextSessionAffinity := newCfg.Routing.ClaudeCodeSessionAffinity || newCfg.Routing.SessionAffinity + nextSessionAffinityTTL := newCfg.Routing.SessionAffinityTTL + + selectorChanged := previousStrategy != nextStrategy || + previousSessionAffinity != nextSessionAffinity || + previousSessionAffinityTTL != nextSessionAffinityTTL + + if s.coreManager != nil && selectorChanged { + var selector coreauth.Selector + switch nextStrategy { + case "fill-first": + selector = &coreauth.FillFirstSelector{} + default: + selector = &coreauth.RoundRobinSelector{} + } + + if nextSessionAffinity { + ttl := time.Hour + if ttlStr := strings.TrimSpace(nextSessionAffinityTTL); ttlStr != "" { + if parsed, err := time.ParseDuration(ttlStr); err == nil && parsed > 0 { + ttl = parsed + } + } + selector = coreauth.NewSessionAffinitySelectorWithConfig(coreauth.SessionAffinityConfig{ + Fallback: selector, + TTL: ttl, + }) + } + + s.coreManager.SetSelector(selector) + } + + s.applyRetryConfig(newCfg) + s.applyPprofConfig(newCfg) + if s.server != nil { + s.server.UpdateClients(newCfg) + } + s.cfgMu.Lock() + s.cfg = newCfg + s.cfgMu.Unlock() + if s.coreManager != nil { + s.coreManager.SetConfig(newCfg) + s.coreManager.SetOAuthModelAlias(newCfg.OAuthModelAlias) + } + s.rebindExecutors() +} + +func forceHomeRuntimeConfig(cfg *config.Config) { + if cfg == nil { + return + } + cfg.APIKeys = nil + cfg.DisableCooling = true + cfg.WebsocketAuth = false + cfg.EnableGeminiCLIEndpoint = false + cfg.RemoteManagement.AllowRemote = false + cfg.RemoteManagement.DisableControlPanel = true +} + +func (s *Service) registerHomeExecutors() { + if s == nil || s.coreManager == nil || s.cfg == nil { + return + } + + // Register baseline executors so home-dispatched auth entries can execute without + // requiring any local auth-dir credentials. + s.coreManager.RegisterExecutor(executor.NewCodexAutoExecutor(s.cfg)) + s.coreManager.RegisterExecutor(executor.NewClaudeExecutor(s.cfg)) + s.coreManager.RegisterExecutor(executor.NewGeminiExecutor(s.cfg)) + s.coreManager.RegisterExecutor(executor.NewGeminiVertexExecutor(s.cfg)) + s.coreManager.RegisterExecutor(executor.NewGeminiCLIExecutor(s.cfg)) + s.coreManager.RegisterExecutor(executor.NewAIStudioExecutor(s.cfg, "", s.wsGateway)) + s.coreManager.RegisterExecutor(executor.NewAntigravityExecutor(s.cfg)) + s.coreManager.RegisterExecutor(executor.NewKimiExecutor(s.cfg)) + s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor("openai-compatibility", s.cfg)) +} + +func (s *Service) applyHomeOverlay(remoteCfg *config.Config) { + if s == nil || remoteCfg == nil { + return + } + + s.cfgMu.RLock() + baseCfg := s.cfg + s.cfgMu.RUnlock() + if baseCfg == nil { + return + } + + merged := *remoteCfg + merged.Host = baseCfg.Host + merged.Port = baseCfg.Port + merged.TLS = baseCfg.TLS + merged.Home = baseCfg.Home + forceHomeRuntimeConfig(&merged) + + s.applyConfigUpdate(&merged) +} + +func (s *Service) startHomeUsageForwarder(ctx context.Context, client *home.Client) { + if s == nil || client == nil { + return + } + if ctx == nil { + ctx = context.Background() + } + + sleep := func(d time.Duration) bool { + if d <= 0 { + return true + } + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } + } + + go func() { + for { + select { + case <-ctx.Done(): + return + default: + } + + if !client.HeartbeatOK() { + if !sleep(time.Second) { + return + } + continue + } + + items := redisqueue.PopOldest(64) + if len(items) == 0 { + if !sleep(500 * time.Millisecond) { + return + } + continue + } + + for i := range items { + if errPush := client.LPushUsage(ctx, items[i]); errPush != nil { + for j := i; j < len(items); j++ { + redisqueue.Enqueue(items[j]) + } + if !sleep(time.Second) { + return + } + break + } + } + } + }() +} + +func (s *Service) startHomeSubscriber(ctx context.Context) { + if s == nil { + return + } + s.cfgMu.RLock() + cfg := s.cfg + s.cfgMu.RUnlock() + if cfg == nil || !cfg.Home.Enabled { + return + } + + if s.homeCancel != nil { + s.homeCancel() + s.homeCancel = nil + } + if s.homeClient != nil { + s.homeClient.Close() + s.homeClient = nil + } + + homeCtx := ctx + if homeCtx == nil { + homeCtx = context.Background() + } + homeCtx, cancel := context.WithCancel(homeCtx) + s.homeCancel = cancel + + client := home.New(cfg.Home) + s.homeClient = client + home.SetCurrent(client) + + go client.StartConfigSubscriber(homeCtx, func(raw []byte) error { + parsed, err := config.ParseConfigBytes(raw) + if err != nil { + log.Warnf("failed to parse home config payload: %v", err) + return err + } + s.applyHomeOverlay(parsed) + return nil + }) + s.startHomeUsageForwarder(homeCtx, client) +} + // Run starts the service and blocks until the context is cancelled or the server stops. // It initializes all components including authentication, file watching, HTTP server, // and starts processing requests. The method blocks until the context is cancelled. @@ -480,6 +729,10 @@ func (s *Service) Run(ctx context.Context) error { } usage.StartDefault(ctx) + homeEnabled := s.cfg != nil && s.cfg.Home.Enabled + if homeEnabled { + forceHomeRuntimeConfig(s.cfg) + } shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) defer shutdownCancel() @@ -489,32 +742,36 @@ func (s *Service) Run(ctx context.Context) error { } }() - if err := s.ensureAuthDir(); err != nil { - return err + if !homeEnabled { + if errEnsureAuthDir := s.ensureAuthDir(); errEnsureAuthDir != nil { + return errEnsureAuthDir + } } s.applyRetryConfig(s.cfg) - if s.coreManager != nil { + if s.coreManager != nil && !homeEnabled { if errLoad := s.coreManager.Load(ctx); errLoad != nil { log.Warnf("failed to load auth store: %v", errLoad) } } - tokenResult, err := s.tokenProvider.Load(ctx, s.cfg) - if err != nil && !errors.Is(err, context.Canceled) { - return err - } - if tokenResult == nil { - tokenResult = &TokenClientResult{} - } + if !homeEnabled { + tokenResult, err := s.tokenProvider.Load(ctx, s.cfg) + if err != nil && !errors.Is(err, context.Canceled) { + return err + } + if tokenResult == nil { + tokenResult = &TokenClientResult{} + } - apiKeyResult, err := s.apiKeyProvider.Load(ctx, s.cfg) - if err != nil && !errors.Is(err, context.Canceled) { - return err - } - if apiKeyResult == nil { - apiKeyResult = &APIKeyClientResult{} + apiKeyResult, err := s.apiKeyProvider.Load(ctx, s.cfg) + if err != nil && !errors.Is(err, context.Canceled) { + return err + } + if apiKeyResult == nil { + apiKeyResult = &APIKeyClientResult{} + } } // legacy clients removed; no caches to refresh @@ -526,6 +783,10 @@ func (s *Service) Run(ctx context.Context) error { s.authManager = newDefaultAuthManager() } + if homeEnabled { + s.startHomeSubscriber(ctx) + } + s.ensureWebsocketGateway() if s.server != nil && s.wsGateway != nil { s.server.AttachWebsocketRoute(s.wsGateway.Path(), s.wsGateway.Handler()) @@ -547,6 +808,12 @@ func (s *Service) Run(ctx context.Context) error { }) } + if homeEnabled { + s.registerHomeExecutors() + // Home mode does not expose in-process Redis RESP usage output; usage is forwarded to home instead. + redisqueue.SetEnabled(true) + } + if s.hooks.OnBeforeStart != nil { s.hooks.OnBeforeStart(s.cfg) } @@ -607,107 +874,31 @@ func (s *Service) Run(ctx context.Context) error { s.hooks.OnAfterStart(s) } - var watcherWrapper *WatcherWrapper - reloadCallback := func(newCfg *config.Config) { - previousStrategy := "" - var previousSessionAffinity bool - var previousSessionAffinityTTL string - s.cfgMu.RLock() - if s.cfg != nil { - previousStrategy = strings.ToLower(strings.TrimSpace(s.cfg.Routing.Strategy)) - previousSessionAffinity = s.cfg.Routing.ClaudeCodeSessionAffinity || s.cfg.Routing.SessionAffinity - previousSessionAffinityTTL = s.cfg.Routing.SessionAffinityTTL - } - s.cfgMu.RUnlock() - - if newCfg == nil { - s.cfgMu.RLock() - newCfg = s.cfg - s.cfgMu.RUnlock() - } - if newCfg == nil { - return - } + if !homeEnabled { + var watcherWrapper *WatcherWrapper + reloadCallback := func(newCfg *config.Config) { s.applyConfigUpdate(newCfg) } - nextStrategy := strings.ToLower(strings.TrimSpace(newCfg.Routing.Strategy)) - normalizeStrategy := func(strategy string) string { - switch strategy { - case "fill-first", "fillfirst", "ff": - return "fill-first" - default: - return "round-robin" - } + watcherWrapper, errCreate := s.watcherFactory(s.configPath, s.cfg.AuthDir, reloadCallback) + if errCreate != nil { + return fmt.Errorf("cliproxy: failed to create watcher: %w", errCreate) } - previousStrategy = normalizeStrategy(previousStrategy) - nextStrategy = normalizeStrategy(nextStrategy) - - nextSessionAffinity := newCfg.Routing.ClaudeCodeSessionAffinity || newCfg.Routing.SessionAffinity - nextSessionAffinityTTL := newCfg.Routing.SessionAffinityTTL - - selectorChanged := previousStrategy != nextStrategy || - previousSessionAffinity != nextSessionAffinity || - previousSessionAffinityTTL != nextSessionAffinityTTL - - if s.coreManager != nil && selectorChanged { - var selector coreauth.Selector - switch nextStrategy { - case "fill-first": - selector = &coreauth.FillFirstSelector{} - default: - selector = &coreauth.RoundRobinSelector{} - } - - if nextSessionAffinity { - ttl := time.Hour - if ttlStr := strings.TrimSpace(nextSessionAffinityTTL); ttlStr != "" { - if parsed, err := time.ParseDuration(ttlStr); err == nil && parsed > 0 { - ttl = parsed - } - } - selector = coreauth.NewSessionAffinitySelectorWithConfig(coreauth.SessionAffinityConfig{ - Fallback: selector, - TTL: ttl, - }) - } - - s.coreManager.SetSelector(selector) + s.watcher = watcherWrapper + s.ensureAuthUpdateQueue(ctx) + if s.authUpdates != nil { + watcherWrapper.SetAuthUpdateQueue(s.authUpdates) } + watcherWrapper.SetConfig(s.cfg) - s.applyRetryConfig(newCfg) - s.applyPprofConfig(newCfg) - if s.server != nil { - s.server.UpdateClients(newCfg) + watcherCtx, watcherCancel := context.WithCancel(context.Background()) + s.watcherCancel = watcherCancel + if errStart := watcherWrapper.Start(watcherCtx); errStart != nil { + return fmt.Errorf("cliproxy: failed to start watcher: %w", errStart) } - s.cfgMu.Lock() - s.cfg = newCfg - s.cfgMu.Unlock() - if s.coreManager != nil { - s.coreManager.SetConfig(newCfg) - s.coreManager.SetOAuthModelAlias(newCfg.OAuthModelAlias) - } - s.rebindExecutors() - } - - watcherWrapper, err = s.watcherFactory(s.configPath, s.cfg.AuthDir, reloadCallback) - if err != nil { - return fmt.Errorf("cliproxy: failed to create watcher: %w", err) - } - s.watcher = watcherWrapper - s.ensureAuthUpdateQueue(ctx) - if s.authUpdates != nil { - watcherWrapper.SetAuthUpdateQueue(s.authUpdates) - } - watcherWrapper.SetConfig(s.cfg) - - watcherCtx, watcherCancel := context.WithCancel(context.Background()) - s.watcherCancel = watcherCancel - if err = watcherWrapper.Start(watcherCtx); err != nil { - return fmt.Errorf("cliproxy: failed to start watcher: %w", err) + log.Info("file watcher started for config and auth directory changes") } - log.Info("file watcher started for config and auth directory changes") // Prefer core auth manager auto refresh if available. - if s.coreManager != nil { + if s.coreManager != nil && !homeEnabled { interval := 15 * time.Minute s.coreManager.StartAutoRefresh(context.Background(), interval) log.Infof("core auth auto-refresh started (interval=%s)", interval) @@ -717,8 +908,8 @@ func (s *Service) Run(ctx context.Context) error { case <-ctx.Done(): log.Debug("service context cancelled, shutting down...") return ctx.Err() - case err = <-s.serverErr: - return err + case errServer := <-s.serverErr: + return errServer } } @@ -741,6 +932,16 @@ func (s *Service) Shutdown(ctx context.Context) error { ctx = context.Background() } + if s.homeCancel != nil { + s.homeCancel() + s.homeCancel = nil + } + if s.homeClient != nil { + s.homeClient.Close() + s.homeClient = nil + } + home.ClearCurrent() + // legacy refresh loop removed; only stopping core auth manager below if s.watcherCancel != nil { diff --git a/sdk/cliproxy/service_codex_executor_binding_test.go b/sdk/cliproxy/service_codex_executor_binding_test.go index bb4fc84e10..20a9cd7c86 100644 --- a/sdk/cliproxy/service_codex_executor_binding_test.go +++ b/sdk/cliproxy/service_codex_executor_binding_test.go @@ -3,8 +3,8 @@ package cliproxy import ( "testing" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) func TestEnsureExecutorsForAuth_CodexDoesNotReplaceInNormalMode(t *testing.T) { diff --git a/sdk/cliproxy/service_excluded_models_test.go b/sdk/cliproxy/service_excluded_models_test.go index 198a5bed73..fc16c09561 100644 --- a/sdk/cliproxy/service_excluded_models_test.go +++ b/sdk/cliproxy/service_excluded_models_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) func TestRegisterModelsForAuth_UsesPreMergedExcludedModelsAttribute(t *testing.T) { diff --git a/sdk/cliproxy/service_oauth_model_alias_test.go b/sdk/cliproxy/service_oauth_model_alias_test.go index 2caf7a178f..7405f7caca 100644 --- a/sdk/cliproxy/service_oauth_model_alias_test.go +++ b/sdk/cliproxy/service_oauth_model_alias_test.go @@ -3,7 +3,7 @@ package cliproxy import ( "testing" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) func TestApplyOAuthModelAlias_Rename(t *testing.T) { diff --git a/sdk/cliproxy/service_stale_state_test.go b/sdk/cliproxy/service_stale_state_test.go index 010218d966..8943d67930 100644 --- a/sdk/cliproxy/service_stale_state_test.go +++ b/sdk/cliproxy/service_stale_state_test.go @@ -5,9 +5,9 @@ import ( "testing" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) func TestServiceApplyCoreAuthAddOrUpdate_DeleteReAddDoesNotInheritStaleRuntimeState(t *testing.T) { diff --git a/sdk/cliproxy/types.go b/sdk/cliproxy/types.go index 1521dffee4..c30b712bdd 100644 --- a/sdk/cliproxy/types.go +++ b/sdk/cliproxy/types.go @@ -6,9 +6,9 @@ package cliproxy import ( "context" - "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) // TokenClientProvider loads clients backed by stored authentication tokens. diff --git a/sdk/cliproxy/watcher.go b/sdk/cliproxy/watcher.go index caeadf19b9..e4a9081b41 100644 --- a/sdk/cliproxy/watcher.go +++ b/sdk/cliproxy/watcher.go @@ -3,9 +3,9 @@ package cliproxy import ( "context" - "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) func defaultWatcherFactory(configPath, authDir string, reload func(*config.Config)) (*WatcherWrapper, error) { diff --git a/sdk/config/config.go b/sdk/config/config.go index 14163418f7..d39e512de1 100644 --- a/sdk/config/config.go +++ b/sdk/config/config.go @@ -4,7 +4,7 @@ // embed CLIProxyAPI without importing internal packages. package config -import internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" +import internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" type SDKConfig = internalconfig.SDKConfig @@ -41,6 +41,8 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { return internalconfig.LoadConfigOptional(configFile, optional) } +func ParseConfigBytes(data []byte) (*Config, error) { return internalconfig.ParseConfigBytes(data) } + func SaveConfigPreserveComments(configFile string, cfg *Config) error { return internalconfig.SaveConfigPreserveComments(configFile, cfg) } diff --git a/sdk/logging/request_logger.go b/sdk/logging/request_logger.go index ddbda6b8b0..5f8cf754e1 100644 --- a/sdk/logging/request_logger.go +++ b/sdk/logging/request_logger.go @@ -1,7 +1,7 @@ // Package logging re-exports request logging primitives for SDK consumers. package logging -import internallogging "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" +import internallogging "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" const defaultErrorLogsMaxFiles = 10 diff --git a/sdk/translator/builtin/builtin.go b/sdk/translator/builtin/builtin.go index 798e43f1a9..f95e65870f 100644 --- a/sdk/translator/builtin/builtin.go +++ b/sdk/translator/builtin/builtin.go @@ -2,9 +2,9 @@ package builtin import ( - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator" ) // Registry exposes the default registry populated with all built-in translators. diff --git a/test/amp_management_test.go b/test/amp_management_test.go index e384ef0e8b..6c694db6fa 100644 --- a/test/amp_management_test.go +++ b/test/amp_management_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/management" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/api/handlers/management" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) func init() { diff --git a/test/builtin_tools_translation_test.go b/test/builtin_tools_translation_test.go index 07d7671544..70ee0ac1b9 100644 --- a/test/builtin_tools_translation_test.go +++ b/test/builtin_tools_translation_test.go @@ -3,9 +3,9 @@ package test import ( "testing" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" "github.com/tidwall/gjson" ) diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index 51671a9c5f..9173aa0194 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -5,20 +5,20 @@ import ( "testing" "time" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator" // Import provider packages to trigger init() registration of ProviderAppliers - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/antigravity" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/claude" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/codex" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/gemini" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/geminicli" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/kimi" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/openai" - - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/antigravity" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/claude" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/codex" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/gemini" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/geminicli" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/kimi" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/openai" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/test/usage_logging_test.go b/test/usage_logging_test.go index ee03c4d79c..bcf6d19254 100644 --- a/test/usage_logging_test.go +++ b/test/usage_logging_test.go @@ -9,12 +9,12 @@ import ( "testing" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/internal/redisqueue" - runtimeexecutor "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue" + runtimeexecutor "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" ) func TestGeminiExecutorRecordsSuccessfulZeroUsageInQueue(t *testing.T) { From c883114a4db49f6a143e8484f577a34534d6a9ff Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 8 May 2026 05:12:30 +0000 Subject: [PATCH 123/139] fix responses websocket tool output context --- .../openai/openai_responses_websocket_test.go | 28 +++++++++++++++++++ ...nai_responses_websocket_toolcall_repair.go | 10 +++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/sdk/api/handlers/openai/openai_responses_websocket_test.go b/sdk/api/handlers/openai/openai_responses_websocket_test.go index 319127f0e0..59a2fc875d 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket_test.go +++ b/sdk/api/handlers/openai/openai_responses_websocket_test.go @@ -662,6 +662,34 @@ func TestRepairResponsesWebsocketToolCallsInsertsCachedCallForOrphanOutput(t *te } } +func TestRepairResponsesWebsocketToolCallsInsertsCachedCallForPreviousResponseOutput(t *testing.T) { + outputCache := newWebsocketToolOutputCache(time.Minute, 10) + callCache := newWebsocketToolOutputCache(time.Minute, 10) + sessionKey := "session-1" + + callCache.record(sessionKey, "call-1", []byte(`{"type":"function_call","id":"fc-1","call_id":"call-1","name":"tool"}`)) + + raw := []byte(`{"previous_response_id":"resp-latest","input":[{"type":"function_call_output","call_id":"call-1","id":"tool-out-1","output":"ok"},{"type":"message","id":"msg-1"}]}`) + repaired := repairResponsesWebsocketToolCallsWithCaches(outputCache, callCache, sessionKey, raw) + + if got := gjson.GetBytes(repaired, "previous_response_id").String(); got != "resp-latest" { + t.Fatalf("previous_response_id = %q, want resp-latest", got) + } + input := gjson.GetBytes(repaired, "input").Array() + if len(input) != 3 { + t.Fatalf("repaired input len = %d, want 3: %s", len(input), repaired) + } + if input[0].Get("type").String() != "function_call" || input[0].Get("call_id").String() != "call-1" { + t.Fatalf("missing inserted call: %s", input[0].Raw) + } + if input[1].Get("type").String() != "function_call_output" || input[1].Get("call_id").String() != "call-1" { + t.Fatalf("unexpected output item: %s", input[1].Raw) + } + if input[2].Get("type").String() != "message" || input[2].Get("id").String() != "msg-1" { + t.Fatalf("unexpected trailing item: %s", input[2].Raw) + } +} + func TestRepairResponsesWebsocketToolCallsDropsOrphanOutputWhenCallMissing(t *testing.T) { outputCache := newWebsocketToolOutputCache(time.Minute, 10) callCache := newWebsocketToolOutputCache(time.Minute, 10) diff --git a/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go b/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go index 1a5772ec70..c521bec049 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go +++ b/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go @@ -300,11 +300,6 @@ func repairResponsesToolCallsArray(outputCache, callCache *websocketToolOutputCa continue } - if allowOrphanOutputs { - filtered = append(filtered, item) - continue - } - if _, ok := callPresent[callID]; ok { filtered = append(filtered, item) continue @@ -322,6 +317,11 @@ func repairResponsesToolCallsArray(outputCache, callCache *websocketToolOutputCa } } + if allowOrphanOutputs { + filtered = append(filtered, item) + continue + } + // Drop orphaned function_call_output items; upstream rejects transcripts with missing calls. continue } From 4071fdef8417b052163a192f7d043526c7db9821 Mon Sep 17 00:00:00 2001 From: lihan3238 Date: Fri, 8 May 2026 21:47:41 +0800 Subject: [PATCH 124/139] fix: apply default auth-dir when config value is empty When auth-dir is not specified in config.yaml, ResolveAuthDir returns an empty string which causes os.MkdirAll to fail with no path. Use the documented default ~/.cli-proxy-api instead. Fixes #3272 --- internal/util/util.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/util/util.go b/internal/util/util.go index 9bf630f299..960a588b20 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -75,7 +75,7 @@ func SetLogLevel(cfg *config.Config) { // It expands a leading tilde (~) to the user's home directory and returns a cleaned path. func ResolveAuthDir(authDir string) (string, error) { if authDir == "" { - return "", nil + authDir = "~/.cli-proxy-api" } if strings.HasPrefix(authDir, "~") { home, err := os.UserHomeDir() From 4cbe1729340542bb5759c0925476324875347ab8 Mon Sep 17 00:00:00 2001 From: lihan3238 Date: Fri, 8 May 2026 22:28:38 +0800 Subject: [PATCH 125/139] refactor: extract DefaultAuthDir constant per review feedback --- internal/config/config.go | 1 + internal/util/util.go | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index 46ce4f5099..d3861365c3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,7 @@ import ( const ( DefaultPanelGitHubRepository = "https://github.com/router-for-me/Cli-Proxy-API-Management-Center" DefaultPprofAddr = "127.0.0.1:8316" + DefaultAuthDir = "~/.cli-proxy-api" ) // Config represents the application's configuration, loaded from a YAML file. diff --git a/internal/util/util.go b/internal/util/util.go index 960a588b20..a808c1c5ad 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -73,9 +73,10 @@ func SetLogLevel(cfg *config.Config) { // ResolveAuthDir normalizes the auth directory path for consistent reuse throughout the app. // It expands a leading tilde (~) to the user's home directory and returns a cleaned path. +// If authDir is empty, it defaults to ~/.cli-proxy-api. func ResolveAuthDir(authDir string) (string, error) { if authDir == "" { - authDir = "~/.cli-proxy-api" + authDir = config.DefaultAuthDir } if strings.HasPrefix(authDir, "~") { home, err := os.UserHomeDir() From 1721994111ef247aede7571dc372137e471d7607 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 9 May 2026 00:23:45 +0800 Subject: [PATCH 126/139] feat(management): expose additional OAuth and configuration helpers - Added new helper methods for OAuth session management (`RegisterOAuthSession`, `CompleteOAuthSession`, etc.). - Introduced `WriteConfig` for persisting management configurations. - Exported `Handler` type and `NewHandler` constructors for SDK consumers. --- sdk/api/management.go | 83 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 4 deletions(-) diff --git a/sdk/api/management.go b/sdk/api/management.go index 3ed586d8da..689cda3dca 100644 --- a/sdk/api/management.go +++ b/sdk/api/management.go @@ -1,16 +1,21 @@ // Package api exposes helpers for embedding CLIProxyAPI. // -// It wraps internal management handler types so external projects can integrate -// management endpoints without importing internal packages. +// It wraps internal management handler types and helpers so external projects +// can integrate management endpoints without importing internal packages. package api import ( + "context" + "github.com/gin-gonic/gin" internalmanagement "github.com/router-for-me/CLIProxyAPI/v7/internal/api/handlers/management" coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) +// Handler re-exports the management handler used by the internal HTTP API. +type Handler = internalmanagement.Handler + // ManagementTokenRequester exposes a limited subset of management endpoints for requesting tokens. type ManagementTokenRequester interface { RequestAnthropicToken(*gin.Context) @@ -23,13 +28,23 @@ type ManagementTokenRequester interface { } type managementTokenRequester struct { - handler *internalmanagement.Handler + handler *Handler +} + +// NewHandler creates a management handler for SDK consumers. +func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Manager) *Handler { + return internalmanagement.NewHandler(cfg, configFilePath, manager) +} + +// NewHandlerWithoutConfigFilePath creates a management handler that skips config file persistence. +func NewHandlerWithoutConfigFilePath(cfg *config.Config, manager *coreauth.Manager) *Handler { + return internalmanagement.NewHandlerWithoutConfigFilePath(cfg, manager) } // NewManagementTokenRequester creates a limited management handler exposing only token request endpoints. func NewManagementTokenRequester(cfg *config.Config, manager *coreauth.Manager) ManagementTokenRequester { return &managementTokenRequester{ - handler: internalmanagement.NewHandlerWithoutConfigFilePath(cfg, manager), + handler: NewHandlerWithoutConfigFilePath(cfg, manager), } } @@ -60,3 +75,63 @@ func (m *managementTokenRequester) GetAuthStatus(c *gin.Context) { func (m *managementTokenRequester) PostOAuthCallback(c *gin.Context) { m.handler.PostOAuthCallback(c) } + +// WriteConfig persists management configuration to disk. +func WriteConfig(path string, data []byte) error { + return internalmanagement.WriteConfig(path, data) +} + +// RegisterOAuthSession records a pending OAuth callback state. +func RegisterOAuthSession(state, provider string) { + internalmanagement.RegisterOAuthSession(state, provider) +} + +// SetOAuthSessionError stores an OAuth session error message. +func SetOAuthSessionError(state, message string) { + internalmanagement.SetOAuthSessionError(state, message) +} + +// CompleteOAuthSession marks a single OAuth session as completed. +func CompleteOAuthSession(state string) { + internalmanagement.CompleteOAuthSession(state) +} + +// CompleteOAuthSessionsByProvider removes all pending OAuth sessions for a provider. +func CompleteOAuthSessionsByProvider(provider string) int { + return internalmanagement.CompleteOAuthSessionsByProvider(provider) +} + +// GetOAuthSession returns the current OAuth session state. +func GetOAuthSession(state string) (provider string, status string, ok bool) { + return internalmanagement.GetOAuthSession(state) +} + +// IsOAuthSessionPending reports whether a provider/state pair is still pending. +func IsOAuthSessionPending(state, provider string) bool { + return internalmanagement.IsOAuthSessionPending(state, provider) +} + +// ValidateOAuthState validates an OAuth state token. +func ValidateOAuthState(state string) error { + return internalmanagement.ValidateOAuthState(state) +} + +// NormalizeOAuthProvider normalizes a provider name to its canonical form. +func NormalizeOAuthProvider(provider string) (string, error) { + return internalmanagement.NormalizeOAuthProvider(provider) +} + +// WriteOAuthCallbackFile writes an OAuth callback payload to disk. +func WriteOAuthCallbackFile(authDir, provider, state, code, errorMessage string) (string, error) { + return internalmanagement.WriteOAuthCallbackFile(authDir, provider, state, code, errorMessage) +} + +// WriteOAuthCallbackFileForPendingSession writes an OAuth callback payload for a pending session. +func WriteOAuthCallbackFileForPendingSession(authDir, provider, state, code, errorMessage string) (string, error) { + return internalmanagement.WriteOAuthCallbackFileForPendingSession(authDir, provider, state, code, errorMessage) +} + +// PopulateAuthContext copies auth metadata from a Gin context into a request context. +func PopulateAuthContext(ctx context.Context, c *gin.Context) context.Context { + return internalmanagement.PopulateAuthContext(ctx, c) +} From c67096b6870d3bbb9c6e4ef7a315e77b3d82b375 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 9 May 2026 07:14:44 +0800 Subject: [PATCH 127/139] feat(server): add support for loading configuration from a remote home control plane - Introduced `-home` and `-home-password` flags for specifying home control plane address and authentication. - Implemented fetching and parsing configuration from the home control plane when `-home` is used. - Adjusted server configuration handling to bypass local config files when loading from home. - Ensured compatibility with cloud deploy mode and validation of home configurations. --- cmd/server/main.go | 102 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 87 insertions(+), 15 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 44a314aee3..481103809a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -10,9 +10,11 @@ import ( "fmt" "io" "io/fs" + "net" "net/url" "os" "path/filepath" + "strconv" "strings" "time" @@ -21,6 +23,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/buildinfo" "github.com/router-for-me/CLIProxyAPI/v7/internal/cmd" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/home" "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" "github.com/router-for-me/CLIProxyAPI/v7/internal/managementasset" "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" @@ -70,6 +73,8 @@ func main() { var vertexImportPrefix string var configPath string var password string + var homeAddr string + var homePassword string var tuiMode bool var standalone bool var localModel bool @@ -88,6 +93,8 @@ func main() { flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file") flag.StringVar(&vertexImportPrefix, "vertex-import-prefix", "", "Prefix for Vertex model namespacing (use with -vertex-import)") flag.StringVar(&password, "password", "", "") + flag.StringVar(&homeAddr, "home", "", "Home control plane address in host:port format (loads config from home and skips local config file)") + flag.StringVar(&homePassword, "home-password", "", "Home control plane password (Redis AUTH)") flag.BoolVar(&tuiMode, "tui", false, "Start with terminal management UI") flag.BoolVar(&standalone, "standalone", false, "In TUI mode, start an embedded local server") flag.BoolVar(&localModel, "local-model", false, "Use embedded model catalog only, skip remote model fetching") @@ -126,6 +133,7 @@ func main() { var err error var cfg *config.Config var isCloudDeploy bool + var configLoadedFromHome bool var ( usePostgresStore bool pgStoreDSN string @@ -236,7 +244,67 @@ func main() { // Determine and load the configuration file. // Prefer the Postgres store when configured, otherwise fallback to git or local files. var configFilePath string - if usePostgresStore { + if strings.TrimSpace(homeAddr) != "" { + configLoadedFromHome = true + trimmedHomePassword := strings.TrimSpace(homePassword) + host, portStr, errSplit := net.SplitHostPort(strings.TrimSpace(homeAddr)) + if errSplit != nil { + log.Errorf("invalid -home address %q (expected host:port): %v", homeAddr, errSplit) + return + } + host = strings.TrimSpace(host) + if host == "" { + log.Errorf("invalid -home address %q: host is empty", homeAddr) + return + } + port, errPort := strconv.Atoi(strings.TrimSpace(portStr)) + if errPort != nil || port <= 0 { + log.Errorf("invalid -home address %q: invalid port %q", homeAddr, portStr) + return + } + + homeCfg := config.HomeConfig{ + Enabled: true, + Host: host, + Port: port, + Password: trimmedHomePassword, + } + homeClient := home.New(homeCfg) + defer homeClient.Close() + + ctxHome, cancelHome := context.WithTimeout(context.Background(), 30*time.Second) + raw, errGetConfig := homeClient.GetConfig(ctxHome) + cancelHome() + if errGetConfig != nil { + log.Errorf("failed to fetch config from home: %v", errGetConfig) + return + } + + parsed, errParseConfig := config.ParseConfigBytes(raw) + if errParseConfig != nil { + log.Errorf("failed to parse config payload from home: %v", errParseConfig) + return + } + if parsed == nil { + parsed = &config.Config{} + } + parsed.Home = homeCfg + parsed.Port = 8317 // Default to 8317 for home mode, can be overridden by home config + cfg = parsed + + // Keep a non-empty config path for downstream components (log paths, management assets, etc), + // but do not require the file to exist when loading config from home. + if strings.TrimSpace(configPath) != "" { + configFilePath = configPath + } else { + configFilePath = filepath.Join(wd, "config.yaml") + } + + // Local stores are intentionally disabled when config is loaded from home. + usePostgresStore = false + useObjectStore = false + useGitStore = false + } else if usePostgresStore { if pgStoreLocalPath == "" { pgStoreLocalPath = wd } @@ -400,21 +468,25 @@ func main() { // In cloud deploy mode, check if we have a valid configuration var configFileExists bool if isCloudDeploy { - if info, errStat := os.Stat(configFilePath); errStat != nil { - // Don't mislead: API server will not start until configuration is provided. - log.Info("Cloud deploy mode: No configuration file detected; standing by for configuration") - configFileExists = false - } else if info.IsDir() { - log.Info("Cloud deploy mode: Config path is a directory; standing by for configuration") - configFileExists = false - } else if cfg.Port == 0 { - // LoadConfigOptional returns empty config when file is empty or invalid. - // Config file exists but is empty or invalid; treat as missing config - log.Info("Cloud deploy mode: Configuration file is empty or invalid; standing by for valid configuration") - configFileExists = false + if configLoadedFromHome && cfg != nil { + configFileExists = cfg.Port != 0 } else { - log.Info("Cloud deploy mode: Configuration file detected; starting service") - configFileExists = true + if info, errStat := os.Stat(configFilePath); errStat != nil { + // Don't mislead: API server will not start until configuration is provided. + log.Info("Cloud deploy mode: No configuration file detected; standing by for configuration") + configFileExists = false + } else if info.IsDir() { + log.Info("Cloud deploy mode: Config path is a directory; standing by for configuration") + configFileExists = false + } else if cfg.Port == 0 { + // LoadConfigOptional returns empty config when file is empty or invalid. + // Config file exists but is empty or invalid; treat as missing config + log.Info("Cloud deploy mode: Configuration file is empty or invalid; standing by for valid configuration") + configFileExists = false + } else { + log.Info("Cloud deploy mode: Configuration file detected; starting service") + configFileExists = true + } } } redisqueue.SetUsageStatisticsEnabled(cfg.UsageStatisticsEnabled) From 0f0fcd230488d01e9222e69c15f045a99e89b4c8 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 9 May 2026 10:51:27 +0800 Subject: [PATCH 128/139] feat(config): add per-auth `disable_cooling` override support - Introduced `disable_cooling` metadata field for fine-grained control over cooldown scheduling. - Updated `Auth` object to include `Metadata` with conditional logic for handling empty states. - Added YAML configuration support for `disable_cooling` in API key definitions across providers. - Enhanced unit tests to validate `disable_cooling` behavior in various scenarios. --- config.example.yaml | 4 ++ internal/config/config.go | 18 +++++--- internal/watcher/synthesizer/config.go | 41 +++++++++++++++++ internal/watcher/synthesizer/config_test.go | 51 +++++++++++++++++---- sdk/cliproxy/auth/types.go | 11 ++++- 5 files changed, 108 insertions(+), 17 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index f8e5978eec..886d775a5d 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -157,6 +157,7 @@ nonstream-keepalive-interval: 0 # gemini-api-key: # - api-key: "AIzaSy...01" # prefix: "test" # optional: require calls like "test/gemini-3-pro-preview" to target this credential +# disable-cooling: false # optional: per-auth override for auth/model cooldown scheduling # base-url: "https://generativelanguage.googleapis.com" # headers: # X-Custom-Header: "custom-value" @@ -176,6 +177,7 @@ nonstream-keepalive-interval: 0 # codex-api-key: # - api-key: "sk-atSM..." # prefix: "test" # optional: require calls like "test/gpt-5-codex" to target this credential +# disable-cooling: false # optional: per-auth override for auth/model cooldown scheduling # base-url: "https://www.example.com" # use the custom codex API endpoint # headers: # X-Custom-Header: "custom-value" @@ -195,6 +197,7 @@ nonstream-keepalive-interval: 0 # - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url # - api-key: "sk-atSM..." # prefix: "test" # optional: require calls like "test/claude-sonnet-latest" to target this credential +# disable-cooling: false # optional: per-auth override for auth/model cooldown scheduling # base-url: "https://www.example.com" # use the custom claude API endpoint # headers: # X-Custom-Header: "custom-value" @@ -250,6 +253,7 @@ nonstream-keepalive-interval: 0 # disabled: false # optional: set to true to disable this provider without removing it # prefix: "test" # optional: require calls like "test/kimi-k2" to target this provider's credentials # base-url: "https://openrouter.ai/api/v1" # The base URL of the provider. +# disable-cooling: false # optional: per-provider override for auth/model cooldown scheduling # headers: # X-Custom-Header: "custom-value" # api-key-entries: diff --git a/internal/config/config.go b/internal/config/config.go index e09f38a8bf..6f09f10d74 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -226,12 +226,6 @@ type RoutingConfig struct { // Supported values: "round-robin" (default), "fill-first". Strategy string `yaml:"strategy,omitempty" json:"strategy,omitempty"` - // ClaudeCodeSessionAffinity enables session-sticky routing for Claude Code clients. - // When enabled, requests with the same session ID (extracted from metadata.user_id) - // are routed to the same auth credential when available. - // Deprecated: Use SessionAffinity instead for universal session support. - ClaudeCodeSessionAffinity bool `yaml:"claude-code-session-affinity,omitempty" json:"claude-code-session-affinity,omitempty"` - // SessionAffinity enables universal session-sticky routing for all clients. // Session IDs are extracted from multiple sources: // metadata.user_id (Claude Code session format), X-Session-ID, Session_id (Codex), @@ -403,6 +397,9 @@ type ClaudeKey struct { // ExcludedModels lists model IDs that should be excluded for this provider. ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"` + // DisableCooling disables auth/model cooldown scheduling for this credential when true. + DisableCooling bool `yaml:"disable-cooling,omitempty" json:"disable-cooling,omitempty"` + // Cloak configures request cloaking for non-Claude-Code clients. Cloak *CloakConfig `yaml:"cloak,omitempty" json:"cloak,omitempty"` @@ -458,6 +455,9 @@ type CodexKey struct { // ExcludedModels lists model IDs that should be excluded for this provider. ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"` + + // DisableCooling disables auth/model cooldown scheduling for this credential when true. + DisableCooling bool `yaml:"disable-cooling,omitempty" json:"disable-cooling,omitempty"` } func (k CodexKey) GetAPIKey() string { return k.APIKey } @@ -502,6 +502,9 @@ type GeminiKey struct { // ExcludedModels lists model IDs that should be excluded for this provider. ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"` + + // DisableCooling disables auth/model cooldown scheduling for this credential when true. + DisableCooling bool `yaml:"disable-cooling,omitempty" json:"disable-cooling,omitempty"` } func (k GeminiKey) GetAPIKey() string { return k.APIKey } @@ -546,6 +549,9 @@ type OpenAICompatibility struct { // Headers optionally adds extra HTTP headers for requests sent to this provider. Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` + + // DisableCooling disables auth/model cooldown scheduling for this provider when true. + DisableCooling bool `yaml:"disable-cooling,omitempty" json:"disable-cooling,omitempty"` } // OpenAICompatibilityAPIKey represents an API key configuration with optional proxy setting. diff --git a/internal/watcher/synthesizer/config.go b/internal/watcher/synthesizer/config.go index ba8fe52edb..1eea3dc112 100644 --- a/internal/watcher/synthesizer/config.go +++ b/internal/watcher/synthesizer/config.go @@ -60,6 +60,10 @@ func (s *ConfigSynthesizer) synthesizeGeminiKeys(ctx *SynthesisContext) []*corea "source": fmt.Sprintf("config:gemini[%s]", token), "api_key": key, } + metadata := map[string]any{} + if entry.DisableCooling { + metadata["disable_cooling"] = true + } if entry.Priority != 0 { attrs["priority"] = strconv.Itoa(entry.Priority) } @@ -78,10 +82,14 @@ func (s *ConfigSynthesizer) synthesizeGeminiKeys(ctx *SynthesisContext) []*corea Status: coreauth.StatusActive, ProxyURL: proxyURL, Attributes: attrs, + Metadata: metadata, CreatedAt: now, UpdatedAt: now, } ApplyAuthExcludedModelsMeta(a, cfg, entry.ExcludedModels, "apikey") + if len(a.Metadata) == 0 { + a.Metadata = nil + } out = append(out, a) } return out @@ -107,6 +115,10 @@ func (s *ConfigSynthesizer) synthesizeClaudeKeys(ctx *SynthesisContext) []*corea "source": fmt.Sprintf("config:claude[%s]", token), "api_key": key, } + metadata := map[string]any{} + if ck.DisableCooling { + metadata["disable_cooling"] = true + } if ck.Priority != 0 { attrs["priority"] = strconv.Itoa(ck.Priority) } @@ -126,10 +138,14 @@ func (s *ConfigSynthesizer) synthesizeClaudeKeys(ctx *SynthesisContext) []*corea Status: coreauth.StatusActive, ProxyURL: proxyURL, Attributes: attrs, + Metadata: metadata, CreatedAt: now, UpdatedAt: now, } ApplyAuthExcludedModelsMeta(a, cfg, ck.ExcludedModels, "apikey") + if len(a.Metadata) == 0 { + a.Metadata = nil + } out = append(out, a) } return out @@ -154,6 +170,10 @@ func (s *ConfigSynthesizer) synthesizeCodexKeys(ctx *SynthesisContext) []*coreau "source": fmt.Sprintf("config:codex[%s]", token), "api_key": key, } + metadata := map[string]any{} + if ck.DisableCooling { + metadata["disable_cooling"] = true + } if ck.Priority != 0 { attrs["priority"] = strconv.Itoa(ck.Priority) } @@ -176,10 +196,14 @@ func (s *ConfigSynthesizer) synthesizeCodexKeys(ctx *SynthesisContext) []*coreau Status: coreauth.StatusActive, ProxyURL: proxyURL, Attributes: attrs, + Metadata: metadata, CreatedAt: now, UpdatedAt: now, } ApplyAuthExcludedModelsMeta(a, cfg, ck.ExcludedModels, "apikey") + if len(a.Metadata) == 0 { + a.Metadata = nil + } out = append(out, a) } return out @@ -203,6 +227,7 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor providerName = "openai-compatibility" } base := strings.TrimSpace(compat.BaseURL) + disableCooling := compat.DisableCooling // Handle new APIKeyEntries format (preferred) createdEntries := 0 @@ -218,6 +243,10 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor "compat_name": compat.Name, "provider_key": providerName, } + metadata := map[string]any{} + if disableCooling { + metadata["disable_cooling"] = true + } if compat.Priority != 0 { attrs["priority"] = strconv.Itoa(compat.Priority) } @@ -236,9 +265,13 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor Status: coreauth.StatusActive, ProxyURL: proxyURL, Attributes: attrs, + Metadata: metadata, CreatedAt: now, UpdatedAt: now, } + if len(a.Metadata) == 0 { + a.Metadata = nil + } out = append(out, a) createdEntries++ } @@ -252,6 +285,10 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor "compat_name": compat.Name, "provider_key": providerName, } + metadata := map[string]any{} + if disableCooling { + metadata["disable_cooling"] = true + } if compat.Priority != 0 { attrs["priority"] = strconv.Itoa(compat.Priority) } @@ -266,9 +303,13 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor Prefix: prefix, Status: coreauth.StatusActive, Attributes: attrs, + Metadata: metadata, CreatedAt: now, UpdatedAt: now, } + if len(a.Metadata) == 0 { + a.Metadata = nil + } out = append(out, a) } } diff --git a/internal/watcher/synthesizer/config_test.go b/internal/watcher/synthesizer/config_test.go index c57b8fc7f7..c8526a654a 100644 --- a/internal/watcher/synthesizer/config_test.go +++ b/internal/watcher/synthesizer/config_test.go @@ -68,11 +68,26 @@ func TestConfigSynthesizer_GeminiKeys(t *testing.T) { if auths[0].Attributes["api_key"] != "test-key-123" { t.Errorf("expected api_key test-key-123, got %s", auths[0].Attributes["api_key"]) } + if auths[0].Metadata != nil { + t.Errorf("expected metadata to be nil when disable_cooling not set, got %v", auths[0].Metadata) + } if auths[0].Status != coreauth.StatusActive { t.Errorf("expected status active, got %s", auths[0].Status) } }, }, + { + name: "gemini key disable cooling", + geminiKeys: []config.GeminiKey{ + {APIKey: "test-key-123", Prefix: "team-a", DisableCooling: true}, + }, + wantLen: 1, + validate: func(t *testing.T, auths []*coreauth.Auth) { + if v, ok := auths[0].Metadata["disable_cooling"].(bool); !ok || !v { + t.Errorf("expected disable_cooling=true, got %v", auths[0].Metadata["disable_cooling"]) + } + }, + }, { name: "gemini key with base url and proxy", geminiKeys: []config.GeminiKey{ @@ -160,9 +175,10 @@ func TestConfigSynthesizer_ClaudeKeys(t *testing.T) { Config: &config.Config{ ClaudeKey: []config.ClaudeKey{ { - APIKey: "sk-ant-api-xxx", - Prefix: "main", - BaseURL: "https://api.anthropic.com", + APIKey: "sk-ant-api-xxx", + Prefix: "main", + BaseURL: "https://api.anthropic.com", + DisableCooling: true, Models: []config.ClaudeModel{ {Name: "claude-3-opus"}, {Name: "claude-3-sonnet"}, @@ -197,6 +213,9 @@ func TestConfigSynthesizer_ClaudeKeys(t *testing.T) { if _, ok := auths[0].Attributes["models_hash"]; !ok { t.Error("expected models_hash in attributes") } + if v, ok := auths[0].Metadata["disable_cooling"].(bool); !ok || !v { + t.Errorf("expected disable_cooling=true, got %v", auths[0].Metadata["disable_cooling"]) + } } func TestConfigSynthesizer_ClaudeKeys_SkipsEmptyAndHeaders(t *testing.T) { @@ -231,11 +250,12 @@ func TestConfigSynthesizer_CodexKeys(t *testing.T) { Config: &config.Config{ CodexKey: []config.CodexKey{ { - APIKey: "codex-key-123", - Prefix: "dev", - BaseURL: "https://api.openai.com", - ProxyURL: "http://proxy.local", - Websockets: true, + APIKey: "codex-key-123", + Prefix: "dev", + BaseURL: "https://api.openai.com", + ProxyURL: "http://proxy.local", + Websockets: true, + DisableCooling: true, }, }, }, @@ -263,6 +283,9 @@ func TestConfigSynthesizer_CodexKeys(t *testing.T) { if auths[0].Attributes["websockets"] != "true" { t.Errorf("expected websockets=true, got %s", auths[0].Attributes["websockets"]) } + if v, ok := auths[0].Metadata["disable_cooling"].(bool); !ok || !v { + t.Errorf("expected disable_cooling=true, got %v", auths[0].Metadata["disable_cooling"]) + } } func TestConfigSynthesizer_CodexKeys_SkipsEmptyAndHeaders(t *testing.T) { @@ -301,8 +324,9 @@ func TestConfigSynthesizer_OpenAICompat(t *testing.T) { name: "with APIKeyEntries", compat: []config.OpenAICompatibility{ { - Name: "CustomProvider", - BaseURL: "https://custom.api.com", + Name: "CustomProvider", + BaseURL: "https://custom.api.com", + DisableCooling: true, APIKeyEntries: []config.OpenAICompatibilityAPIKey{ {APIKey: "key-1"}, {APIKey: "key-2"}, @@ -365,6 +389,13 @@ func TestConfigSynthesizer_OpenAICompat(t *testing.T) { if len(auths) != tt.wantLen { t.Fatalf("expected %d auths, got %d", tt.wantLen, len(auths)) } + if tt.name == "with APIKeyEntries" { + for i := range auths { + if v, ok := auths[i].Metadata["disable_cooling"].(bool); !ok || !v { + t.Fatalf("expected auth[%d].disable_cooling=true, got %v", i, auths[i].Metadata["disable_cooling"]) + } + } + } }) } } diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go index 3cbd49b578..44b1565205 100644 --- a/sdk/cliproxy/auth/types.go +++ b/sdk/cliproxy/auth/types.go @@ -355,19 +355,28 @@ func (a *Auth) ProxyInfo() string { return "via proxy" } -// DisableCoolingOverride returns the auth-file scoped disable_cooling override when present. +// DisableCoolingOverride returns the auth scoped disable_cooling override when present. // The value is read from metadata key "disable_cooling" (or legacy "disable-cooling"). +// +// NOTE: This override is intentionally "true-only". When the metadata value is false, it is treated +// as "not set" so the global disable-cooling flag can still take effect. func (a *Auth) DisableCoolingOverride() (bool, bool) { if a == nil || a.Metadata == nil { return false, false } if val, ok := a.Metadata["disable_cooling"]; ok { if parsed, okParse := parseBoolAny(val); okParse { + if !parsed { + return false, false + } return parsed, true } } if val, ok := a.Metadata["disable-cooling"]; ok { if parsed, okParse := parseBoolAny(val); okParse { + if !parsed { + return false, false + } return parsed, true } } From 0dcb8bd71401e25ecc511b401f09bcbae34c4342 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 9 May 2026 10:51:49 +0800 Subject: [PATCH 129/139] refactor(cliproxy): remove `ClaudeCodeSessionAffinity` support and simplify session affinity logic --- sdk/cliproxy/builder.go | 2 +- sdk/cliproxy/service.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sdk/cliproxy/builder.go b/sdk/cliproxy/builder.go index 152940a04f..c7e187ee6b 100644 --- a/sdk/cliproxy/builder.go +++ b/sdk/cliproxy/builder.go @@ -214,7 +214,7 @@ func (b *Builder) Build() (*Service, error) { if b.cfg != nil { strategy = strings.ToLower(strings.TrimSpace(b.cfg.Routing.Strategy)) // Support both legacy ClaudeCodeSessionAffinity and new universal SessionAffinity - sessionAffinity = b.cfg.Routing.ClaudeCodeSessionAffinity || b.cfg.Routing.SessionAffinity + sessionAffinity = b.cfg.Routing.SessionAffinity if ttlStr := strings.TrimSpace(b.cfg.Routing.SessionAffinityTTL); ttlStr != "" { if parsed, err := time.ParseDuration(ttlStr); err == nil && parsed > 0 { sessionAffinityTTL = parsed diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 3459c0559e..6a94878dee 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -483,7 +483,7 @@ func (s *Service) applyConfigUpdate(newCfg *config.Config) { s.cfgMu.RLock() if s.cfg != nil { previousStrategy = strings.ToLower(strings.TrimSpace(s.cfg.Routing.Strategy)) - previousSessionAffinity = s.cfg.Routing.ClaudeCodeSessionAffinity || s.cfg.Routing.SessionAffinity + previousSessionAffinity = s.cfg.Routing.SessionAffinity previousSessionAffinityTTL = s.cfg.Routing.SessionAffinityTTL } s.cfgMu.RUnlock() @@ -509,7 +509,7 @@ func (s *Service) applyConfigUpdate(newCfg *config.Config) { previousStrategy = normalizeStrategy(previousStrategy) nextStrategy = normalizeStrategy(nextStrategy) - nextSessionAffinity := newCfg.Routing.ClaudeCodeSessionAffinity || newCfg.Routing.SessionAffinity + nextSessionAffinity := newCfg.Routing.SessionAffinity nextSessionAffinityTTL := newCfg.Routing.SessionAffinityTTL selectorChanged := previousStrategy != nextStrategy || From c69ff497582b7f60731c4c6f1534f0715c14d4ba Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 9 May 2026 19:48:42 +0800 Subject: [PATCH 130/139] feat(auth): add support for persisting `disabled` flag in token storage - Updated `FileTokenStore` and related stores (`objectstore`, `gitstore`, `postgresstore`) to include the `disabled` flag in metadata for token storage. - Adjusted `Auth` metadata handling to initialize empty maps when absent. - Refined logic in `auto_refresh_loop` and `conductor` to exclude `disabled` tokens from refresh checks. - Added comprehensive unit tests to verify proper handling of the `disabled` flag in storage and retrieval operations. --- internal/store/gitstore.go | 8 +++ internal/store/objectstore.go | 8 +++ internal/store/postgresstore.go | 8 +++ sdk/auth/filestore.go | 4 ++ sdk/auth/filestore_disabled_test.go | 64 +++++++++++++++++++++ sdk/cliproxy/auth/auto_refresh_loop.go | 2 +- sdk/cliproxy/auth/auto_refresh_loop_test.go | 28 ++++++++- sdk/cliproxy/auth/conductor.go | 4 +- 8 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 sdk/auth/filestore_disabled_test.go diff --git a/internal/store/gitstore.go b/internal/store/gitstore.go index 1610211ac9..ba9fe59e2b 100644 --- a/internal/store/gitstore.go +++ b/internal/store/gitstore.go @@ -287,10 +287,18 @@ func (s *GitTokenStore) Save(_ context.Context, auth *cliproxyauth.Auth) (string switch { case auth.Storage != nil: + if auth.Metadata == nil { + auth.Metadata = make(map[string]any) + } + auth.Metadata["disabled"] = auth.Disabled + if setter, ok := auth.Storage.(interface{ SetMetadata(map[string]any) }); ok { + setter.SetMetadata(auth.Metadata) + } if err = auth.Storage.SaveTokenToFile(path); err != nil { return "", err } case auth.Metadata != nil: + auth.Metadata["disabled"] = auth.Disabled raw, errMarshal := json.Marshal(auth.Metadata) if errMarshal != nil { return "", fmt.Errorf("auth filestore: marshal metadata failed: %w", errMarshal) diff --git a/internal/store/objectstore.go b/internal/store/objectstore.go index aa346a138b..5626e6c65b 100644 --- a/internal/store/objectstore.go +++ b/internal/store/objectstore.go @@ -184,10 +184,18 @@ func (s *ObjectTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (s switch { case auth.Storage != nil: + if auth.Metadata == nil { + auth.Metadata = make(map[string]any) + } + auth.Metadata["disabled"] = auth.Disabled + if setter, ok := auth.Storage.(interface{ SetMetadata(map[string]any) }); ok { + setter.SetMetadata(auth.Metadata) + } if err = auth.Storage.SaveTokenToFile(path); err != nil { return "", err } case auth.Metadata != nil: + auth.Metadata["disabled"] = auth.Disabled raw, errMarshal := json.Marshal(auth.Metadata) if errMarshal != nil { return "", fmt.Errorf("object store: marshal metadata: %w", errMarshal) diff --git a/internal/store/postgresstore.go b/internal/store/postgresstore.go index 610fc5b630..43b125003d 100644 --- a/internal/store/postgresstore.go +++ b/internal/store/postgresstore.go @@ -214,10 +214,18 @@ func (s *PostgresStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (stri switch { case auth.Storage != nil: + if auth.Metadata == nil { + auth.Metadata = make(map[string]any) + } + auth.Metadata["disabled"] = auth.Disabled + if setter, ok := auth.Storage.(interface{ SetMetadata(map[string]any) }); ok { + setter.SetMetadata(auth.Metadata) + } if err = auth.Storage.SaveTokenToFile(path); err != nil { return "", err } case auth.Metadata != nil: + auth.Metadata["disabled"] = auth.Disabled raw, errMarshal := json.Marshal(auth.Metadata) if errMarshal != nil { return "", fmt.Errorf("postgres store: marshal metadata: %w", errMarshal) diff --git a/sdk/auth/filestore.go b/sdk/auth/filestore.go index 39be2d8f48..5675caac29 100644 --- a/sdk/auth/filestore.go +++ b/sdk/auth/filestore.go @@ -72,6 +72,10 @@ func (s *FileTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (str switch { case auth.Storage != nil: + if auth.Metadata == nil { + auth.Metadata = make(map[string]any) + } + auth.Metadata["disabled"] = auth.Disabled if setter, ok := auth.Storage.(metadataSetter); ok { setter.SetMetadata(auth.Metadata) } diff --git a/sdk/auth/filestore_disabled_test.go b/sdk/auth/filestore_disabled_test.go new file mode 100644 index 0000000000..665f9ebf1f --- /dev/null +++ b/sdk/auth/filestore_disabled_test.go @@ -0,0 +1,64 @@ +package auth + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" +) + +type testTokenStorage struct { + meta map[string]any +} + +func (s *testTokenStorage) SetMetadata(meta map[string]any) { s.meta = meta } + +func (s *testTokenStorage) SaveTokenToFile(authFilePath string) error { + raw, err := json.Marshal(s.meta) + if err != nil { + return err + } + return os.WriteFile(authFilePath, raw, 0o600) +} + +func TestFileTokenStore_Save_DisabledPersistsFlagForTokenStorage(t *testing.T) { + ctx := context.Background() + baseDir := t.TempDir() + path := filepath.Join(baseDir, "disabled.json") + + if err := os.WriteFile(path, []byte(`{"type":"test","disabled":true}`), 0o600); err != nil { + t.Fatalf("seed auth file: %v", err) + } + + store := NewFileTokenStore() + store.SetBaseDir(baseDir) + storage := &testTokenStorage{} + + auth := &cliproxyauth.Auth{ + ID: "disabled.json", + Provider: "test", + FileName: "disabled.json", + Disabled: true, + Storage: storage, + Metadata: map[string]any{"type": "test"}, + } + + if _, err := store.Save(ctx, auth); err != nil { + t.Fatalf("Save() error: %v", err) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read auth file: %v", err) + } + var meta map[string]any + if err := json.Unmarshal(raw, &meta); err != nil { + t.Fatalf("unmarshal auth file: %v", err) + } + if disabled, _ := meta["disabled"].(bool); !disabled { + t.Fatalf("disabled=%v, want true (raw=%s)", meta["disabled"], string(raw)) + } +} diff --git a/sdk/cliproxy/auth/auto_refresh_loop.go b/sdk/cliproxy/auth/auto_refresh_loop.go index 9767ee5803..2b544631fe 100644 --- a/sdk/cliproxy/auth/auto_refresh_loop.go +++ b/sdk/cliproxy/auth/auto_refresh_loop.go @@ -336,7 +336,7 @@ func (l *authAutoRefreshLoop) remove(authID string) { } func nextRefreshCheckAt(now time.Time, auth *Auth, interval time.Duration) (time.Time, bool) { - if auth == nil || auth.Disabled { + if auth == nil { return time.Time{}, false } diff --git a/sdk/cliproxy/auth/auto_refresh_loop_test.go b/sdk/cliproxy/auth/auto_refresh_loop_test.go index 420aae237a..e4edb2df55 100644 --- a/sdk/cliproxy/auth/auto_refresh_loop_test.go +++ b/sdk/cliproxy/auth/auto_refresh_loop_test.go @@ -34,9 +34,31 @@ func setRefreshLeadFactory(t *testing.T, provider string, factory func() *time.D func TestNextRefreshCheckAt_DisabledUnschedule(t *testing.T) { now := time.Date(2026, 4, 12, 0, 0, 0, 0, time.UTC) - auth := &Auth{ID: "a1", Provider: "test", Disabled: true} - if _, ok := nextRefreshCheckAt(now, auth, 15*time.Minute); ok { - t.Fatalf("nextRefreshCheckAt() ok = true, want false") + expiry := now.Add(time.Hour) + lead := 10 * time.Minute + setRefreshLeadFactory(t, "disabled-schedule", func() *time.Duration { + d := lead + return &d + }) + + auth := &Auth{ + ID: "a1", + Provider: "disabled-schedule", + Disabled: true, + Status: StatusDisabled, + Metadata: map[string]any{ + "email": "x@example.com", + "expires_at": expiry.Format(time.RFC3339), + }, + } + + got, ok := nextRefreshCheckAt(now, auth, 15*time.Minute) + if !ok { + t.Fatalf("nextRefreshCheckAt() ok = false, want true") + } + want := expiry.Add(-lead) + if !got.Equal(want) { + t.Fatalf("nextRefreshCheckAt() = %s, want %s", got, want) } } diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index f9bf0510ae..befdfe2cb7 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -3454,7 +3454,7 @@ func (m *Manager) queueRefreshReschedule(authID string) { } func (m *Manager) shouldRefresh(a *Auth, now time.Time) bool { - if a == nil || a.Disabled { + if a == nil { return false } if !a.NextRefreshAfter.IsZero() && now.Before(a.NextRefreshAfter) { @@ -3661,7 +3661,7 @@ func lookupMetadataTime(meta map[string]any, keys ...string) (time.Time, bool) { func (m *Manager) markRefreshPending(id string, now time.Time) bool { m.mu.Lock() auth, ok := m.auths[id] - if !ok || auth == nil || auth.Disabled { + if !ok || auth == nil { m.mu.Unlock() return false } From 41f4ee7c7d033570c939b296419427675851cff3 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 9 May 2026 21:03:11 +0800 Subject: [PATCH 131/139] feat(auth): enhance auth index generation with improved file path handling - Updated `EnsureIndex` logic to incorporate absolute and cleaned file paths when generating auth indexes. - Refined metadata handling to include OAuth type in auth index seed. - Improved compatibility for `json` file paths as sources in auth attributes. - Added unit tests to validate correct auth index behavior for various path and type scenarios. --- sdk/cliproxy/auth/types.go | 73 +++++++++++++++++++++------------ sdk/cliproxy/auth/types_test.go | 38 ++++++++++++++++- 2 files changed, 83 insertions(+), 28 deletions(-) diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go index 44b1565205..882c25eabd 100644 --- a/sdk/cliproxy/auth/types.go +++ b/sdk/cliproxy/auth/types.go @@ -7,6 +7,7 @@ import ( "encoding/json" "net/http" "net/url" + "path/filepath" "strconv" "strings" "sync" @@ -256,45 +257,65 @@ func (a *Auth) indexSeed() string { return "" } - if fileName := strings.TrimSpace(a.FileName); fileName != "" { - return "file:" + fileName - } - - providerKey := strings.ToLower(strings.TrimSpace(a.Provider)) + provider := strings.ToLower(strings.TrimSpace(a.Provider)) compatName := "" baseURL := "" apiKey := "" - source := "" + filePath := "" if a.Attributes != nil { - if value := strings.TrimSpace(a.Attributes["provider_key"]); value != "" { - providerKey = strings.ToLower(value) - } - compatName = strings.ToLower(strings.TrimSpace(a.Attributes["compat_name"])) + compatName = strings.TrimSpace(a.Attributes["compat_name"]) baseURL = strings.TrimSpace(a.Attributes["base_url"]) apiKey = strings.TrimSpace(a.Attributes["api_key"]) - source = strings.TrimSpace(a.Attributes["source"]) + filePath = strings.TrimSpace(a.Attributes["path"]) + if filePath == "" { + filePath = strings.TrimSpace(a.Attributes["source"]) + } + } + + if filePath == "" { + filePath = strings.TrimSpace(a.FileName) + } + if filePath == "" { + filePath = strings.TrimSpace(a.ID) } - proxyURL := strings.TrimSpace(a.ProxyURL) - hasCredentialIdentity := compatName != "" || baseURL != "" || proxyURL != "" || apiKey != "" || source != "" - if providerKey != "" && hasCredentialIdentity { - parts := []string{"provider=" + providerKey} - if compatName != "" { - parts = append(parts, "compat="+compatName) + if filePath != "" && strings.HasSuffix(strings.ToLower(filePath), ".json") { + abs, errAbs := filepath.Abs(filePath) + if errAbs == nil && strings.TrimSpace(abs) != "" { + filePath = abs } - if baseURL != "" { - parts = append(parts, "base="+baseURL) + filePath = filepath.Clean(filePath) + + authType := "" + if a.Metadata != nil { + if rawType, ok := a.Metadata["type"].(string); ok { + authType = strings.TrimSpace(rawType) + } } - if proxyURL != "" { - parts = append(parts, "proxy="+proxyURL) + if authType == "" { + authType = strings.TrimSpace(provider) } - if apiKey != "" { - parts = append(parts, "api_key="+apiKey) + authType = strings.ToLower(strings.TrimSpace(authType)) + if authType != "" { + return authType + ":" + filePath } - if source != "" { - parts = append(parts, "source="+source) + } + + apiPrefix := "" + if apiKey != "" { + switch { + case compatName != "" || strings.EqualFold(provider, "openai-compatibility"): + apiPrefix = "openai-compatibility" + case strings.EqualFold(provider, "gemini"): + apiPrefix = "gemini-api-key" + case strings.EqualFold(provider, "codex"): + apiPrefix = "codex-api-key" + case strings.EqualFold(provider, "claude"): + apiPrefix = "claude-api-key" } - return "config:" + strings.Join(parts, "\x00") + } + if apiPrefix != "" { + return apiPrefix + ":" + strings.TrimSpace(baseURL) + "+" + strings.TrimSpace(apiKey) } if id := strings.TrimSpace(a.ID); id != "" { diff --git a/sdk/cliproxy/auth/types_test.go b/sdk/cliproxy/auth/types_test.go index 06836da1f2..f579bfda2e 100644 --- a/sdk/cliproxy/auth/types_test.go +++ b/sdk/cliproxy/auth/types_test.go @@ -1,6 +1,8 @@ package auth import ( + "os" + "path/filepath" "strings" "testing" "time" @@ -96,8 +98,40 @@ func TestEnsureIndexUsesCredentialIdentity(t *testing.T) { if geminiIndex == altBaseIndex { t.Fatalf("same provider/key with different base_url produced duplicate auth_index %q", geminiIndex) } - if geminiIndex == duplicateIndex { - t.Fatalf("duplicate config entries should be separated by source-derived seed, got %q", geminiIndex) + if geminiIndex != duplicateIndex { + t.Fatalf("same provider/key with different source should share auth_index, got %q vs %q", geminiIndex, duplicateIndex) + } +} + +func TestEnsureIndexUsesOAuthTypeAndAbsolutePath(t *testing.T) { + t.Parallel() + + wd, errWd := os.Getwd() + if errWd != nil { + t.Fatalf("os.Getwd returned error: %v", errWd) + } + + relPath := "test-oauth.json" + absPath := filepath.Join(wd, relPath) + expectedSeed := "gemini:" + filepath.Clean(absPath) + expectedIndex := stableAuthIndex(expectedSeed) + + a := &Auth{ + Provider: "gemini-cli", + Attributes: map[string]string{ + "path": relPath, + }, + Metadata: map[string]any{ + "type": "gemini", + }, + } + + got := a.EnsureIndex() + if got == "" { + t.Fatal("auth index should not be empty") + } + if got != expectedIndex { + t.Fatalf("auth index = %q, want %q", got, expectedIndex) } } From 1abf8625d8215f113d8644e37bae4fd6a672b6e3 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 9 May 2026 23:39:59 +0800 Subject: [PATCH 132/139] feat(logging): add home request-log forwarding support - Introduced `SetHomeEnabled` to enable/disable request-log forwarding to the home control plane. - Implemented `forwardRequestLogToHome` for non-streaming logs and `homeStreamingLogWriter` for real-time streaming logs. - Enhanced `FileRequestLogger` to bypass local logging when home forwarding is enabled. - Updated server configuration to dynamically toggle home request-log forwarding based on changes. - Added corresponding unit tests to ensure correct forwarding behavior and fallback mechanisms. --- internal/api/server.go | 10 +- internal/home/client.go | 11 + internal/logging/request_logger.go | 276 +++++++++++++++++++ internal/logging/request_logger_home_test.go | 154 +++++++++++ 4 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 internal/logging/request_logger_home_test.go diff --git a/internal/api/server.go b/internal/api/server.go index 1e29580fd3..04f1fb0ab0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -67,7 +67,9 @@ type ServerOption func(*serverOptionConfig) func defaultRequestLoggerFactory(cfg *config.Config, configPath string) logging.RequestLogger { configDir := filepath.Dir(configPath) logsDir := logging.ResolveLogDirectory(cfg) - return logging.NewFileRequestLogger(cfg.RequestLog, logsDir, configDir, cfg.ErrorLogsMaxFiles) + logger := logging.NewFileRequestLogger(cfg.RequestLog, logsDir, configDir, cfg.ErrorLogsMaxFiles) + logger.SetHomeEnabled(cfg != nil && cfg.Home.Enabled) + return logger } // WithMiddleware appends additional Gin middleware during server construction. @@ -1197,6 +1199,12 @@ func (s *Server) UpdateClients(cfg *config.Config) { } } + if oldCfg == nil || oldCfg.Home.Enabled != cfg.Home.Enabled { + if setter, ok := s.requestLogger.(interface{ SetHomeEnabled(bool) }); ok { + setter.SetHomeEnabled(cfg.Home.Enabled) + } + } + if oldCfg == nil || oldCfg.LoggingToFile != cfg.LoggingToFile || oldCfg.LogsMaxTotalSizeMB != cfg.LogsMaxTotalSizeMB { if err := logging.ConfigureLogOutput(cfg); err != nil { log.Errorf("failed to reconfigure log output: %v", err) diff --git a/internal/home/client.go b/internal/home/client.go index 22a18b32b9..e99ef75323 100644 --- a/internal/home/client.go +++ b/internal/home/client.go @@ -20,6 +20,7 @@ const ( redisChannelConfig = "config" redisKeyModels = "models" redisKeyUsage = "usage" + redisKeyRequestLog = "request-log" homeReconnectInterval = time.Second ) @@ -261,6 +262,16 @@ func (c *Client) LPushUsage(ctx context.Context, payload []byte) error { return c.cmd.LPush(ctx, redisKeyUsage, payload).Err() } +func (c *Client) RPushRequestLog(ctx context.Context, payload []byte) error { + if err := c.ensureClients(); err != nil { + return err + } + if len(payload) == 0 { + return nil + } + return c.cmd.RPush(ctx, redisKeyRequestLog, payload).Err() +} + // StartConfigSubscriber connects to home, fetches config once via GET config, then subscribes to // the "config" channel to receive runtime config updates. // diff --git a/internal/logging/request_logger.go b/internal/logging/request_logger.go index d650212f5b..44b2c95264 100644 --- a/internal/logging/request_logger.go +++ b/internal/logging/request_logger.go @@ -8,6 +8,8 @@ import ( "bytes" "compress/flate" "compress/gzip" + "context" + "encoding/json" "fmt" "io" "os" @@ -23,12 +25,22 @@ import ( log "github.com/sirupsen/logrus" "github.com/router-for-me/CLIProxyAPI/v7/internal/buildinfo" + "github.com/router-for-me/CLIProxyAPI/v7/internal/home" "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" ) var requestLogID atomic.Uint64 +type homeRequestLogClient interface { + HeartbeatOK() bool + RPushRequestLog(ctx context.Context, payload []byte) error +} + +var currentHomeRequestLogClient = func() homeRequestLogClient { + return home.Current() +} + // RequestLogger defines the interface for logging HTTP requests and responses. // It provides methods for logging both regular and streaming HTTP request/response cycles. type RequestLogger interface { @@ -148,6 +160,58 @@ type FileRequestLogger struct { // errorLogsMaxFiles limits the number of error log files retained. errorLogsMaxFiles int + + homeEnabled bool +} + +type homeRequestLogPayload struct { + Headers map[string][]string `json:"headers,omitempty"` + RequestLog string `json:"request_log,omitempty"` +} + +func cloneHeaders(headers map[string][]string) map[string][]string { + if len(headers) == 0 { + return nil + } + out := make(map[string][]string, len(headers)) + for key, values := range headers { + if strings.TrimSpace(key) == "" { + continue + } + if values == nil { + out[key] = nil + continue + } + copied := make([]string, len(values)) + copy(copied, values) + out[key] = copied + } + if len(out) == 0 { + return nil + } + return out +} + +func (l *FileRequestLogger) forwardRequestLogToHome(ctx context.Context, headers map[string][]string, logText string) error { + if l == nil || !l.homeEnabled { + return nil + } + client := currentHomeRequestLogClient() + if client == nil || !client.HeartbeatOK() { + return nil + } + payload := homeRequestLogPayload{ + Headers: cloneHeaders(headers), + RequestLog: logText, + } + raw, errMarshal := json.Marshal(&payload) + if errMarshal != nil { + return errMarshal + } + if ctx == nil { + ctx = context.Background() + } + return client.RPushRequestLog(ctx, raw) } // NewFileRequestLogger creates a new file-based request logger. @@ -173,7 +237,17 @@ func NewFileRequestLogger(enabled bool, logsDir string, configDir string, errorL enabled: enabled, logsDir: logsDir, errorLogsMaxFiles: errorLogsMaxFiles, + homeEnabled: false, + } +} + +// SetHomeEnabled toggles home request-log forwarding. +// When enabled, request logs are not written to disk and are instead forwarded to home via Redis RESP. +func (l *FileRequestLogger) SetHomeEnabled(enabled bool) { + if l == nil { + return } + l.homeEnabled = enabled } // IsEnabled returns whether request logging is currently enabled. @@ -231,6 +305,38 @@ func (l *FileRequestLogger) logRequest(url, method string, requestHeaders map[st return nil } + if l.homeEnabled && l.enabled { + responseToWrite, decompressErr := l.decompressResponse(responseHeaders, response) + if decompressErr != nil { + responseToWrite = response + } + + var buf bytes.Buffer + writeErr := l.writeNonStreamingLog( + &buf, + url, + method, + requestHeaders, + body, + "", + websocketTimeline, + apiRequest, + apiResponse, + apiWebsocketTimeline, + apiResponseErrors, + statusCode, + responseHeaders, + responseToWrite, + decompressErr, + requestTimestamp, + apiResponseTimestamp, + ) + if writeErr != nil { + return fmt.Errorf("failed to build request log content: %w", writeErr) + } + return l.forwardRequestLogToHome(context.Background(), requestHeaders, buf.String()) + } + // Ensure logs directory exists if errEnsure := l.ensureLogsDir(); errEnsure != nil { return fmt.Errorf("failed to create logs directory: %w", errEnsure) @@ -321,6 +427,14 @@ func (l *FileRequestLogger) LogStreamingRequest(url, method string, headers map[ return &NoOpStreamingLogWriter{}, nil } + if l.homeEnabled { + client := home.Current() + if client == nil || !client.HeartbeatOK() { + return &NoOpStreamingLogWriter{}, nil + } + return newHomeStreamingLogWriter(url, method, headers, body, requestID), nil + } + // Ensure logs directory exists if err := l.ensureLogsDir(); err != nil { return nil, fmt.Errorf("failed to create logs directory: %w", err) @@ -1498,3 +1612,165 @@ func (w *NoOpStreamingLogWriter) SetFirstChunkTimestamp(_ time.Time) {} // Returns: // - error: Always returns nil func (w *NoOpStreamingLogWriter) Close() error { return nil } + +type homeStreamingLogWriter struct { + url string + method string + timestamp time.Time + + requestHeaders map[string][]string + requestBody []byte + + chunkChan chan []byte + doneChan chan struct{} + + responseStatus int + statusWritten bool + responseHeaders map[string][]string + responseBody bytes.Buffer + apiRequest []byte + apiResponse []byte + apiWebsocketTime []byte + apiResponseTS time.Time + firstChunkTS time.Time +} + +func newHomeStreamingLogWriter(url, method string, headers map[string][]string, body []byte, _ string) *homeStreamingLogWriter { + requestHeaders := make(map[string][]string, len(headers)) + for key, values := range headers { + headerValues := make([]string, len(values)) + copy(headerValues, values) + requestHeaders[key] = headerValues + } + + writer := &homeStreamingLogWriter{ + url: url, + method: method, + timestamp: time.Now(), + requestHeaders: requestHeaders, + requestBody: append([]byte(nil), body...), + chunkChan: make(chan []byte, 100), + doneChan: make(chan struct{}), + } + + go writer.asyncWriter() + return writer +} + +func (w *homeStreamingLogWriter) asyncWriter() { + defer close(w.doneChan) + for chunk := range w.chunkChan { + if len(chunk) == 0 { + continue + } + _, _ = w.responseBody.Write(chunk) + } +} + +func (w *homeStreamingLogWriter) WriteChunkAsync(chunk []byte) { + if w == nil || w.chunkChan == nil || len(chunk) == 0 { + return + } + select { + case w.chunkChan <- append([]byte(nil), chunk...): + default: + } +} + +func (w *homeStreamingLogWriter) WriteStatus(status int, headers map[string][]string) error { + if w == nil || status == 0 { + return nil + } + w.responseStatus = status + w.statusWritten = true + if headers != nil { + w.responseHeaders = make(map[string][]string, len(headers)) + for key, values := range headers { + copied := make([]string, len(values)) + copy(copied, values) + w.responseHeaders[key] = copied + } + } + return nil +} + +func (w *homeStreamingLogWriter) WriteAPIRequest(apiRequest []byte) error { + if w == nil || len(apiRequest) == 0 { + return nil + } + w.apiRequest = bytes.Clone(apiRequest) + return nil +} + +func (w *homeStreamingLogWriter) WriteAPIResponse(apiResponse []byte) error { + if w == nil || len(apiResponse) == 0 { + return nil + } + w.apiResponse = bytes.Clone(apiResponse) + return nil +} + +func (w *homeStreamingLogWriter) WriteAPIWebsocketTimeline(apiWebsocketTimeline []byte) error { + if w == nil || len(apiWebsocketTimeline) == 0 { + return nil + } + w.apiWebsocketTime = bytes.Clone(apiWebsocketTimeline) + return nil +} + +func (w *homeStreamingLogWriter) SetFirstChunkTimestamp(timestamp time.Time) { + if w == nil { + return + } + if !timestamp.IsZero() { + w.firstChunkTS = timestamp + w.apiResponseTS = timestamp + } +} + +func (w *homeStreamingLogWriter) Close() error { + if w == nil { + return nil + } + + client := currentHomeRequestLogClient() + if client == nil || !client.HeartbeatOK() { + return nil + } + + if w.chunkChan != nil { + close(w.chunkChan) + <-w.doneChan + w.chunkChan = nil + } + + responsePayload := w.responseBody.Bytes() + + var buf bytes.Buffer + upstreamTransport := inferUpstreamTransport(w.apiRequest, w.apiResponse, w.apiWebsocketTime, nil) + if errWrite := writeRequestInfoWithBody(&buf, w.url, w.method, w.requestHeaders, w.requestBody, "", w.timestamp, "http", upstreamTransport, true); errWrite != nil { + return errWrite + } + if errWrite := writeAPISection(&buf, "=== API WEBSOCKET TIMELINE ===\n", "=== API WEBSOCKET TIMELINE", w.apiWebsocketTime, time.Time{}); errWrite != nil { + return errWrite + } + if errWrite := writeAPISection(&buf, "=== API REQUEST ===\n", "=== API REQUEST", w.apiRequest, time.Time{}); errWrite != nil { + return errWrite + } + if errWrite := writeAPISection(&buf, "=== API RESPONSE ===\n", "=== API RESPONSE", w.apiResponse, w.apiResponseTS); errWrite != nil { + return errWrite + } + if errWrite := writeResponseSection(&buf, w.responseStatus, w.statusWritten, w.responseHeaders, bytes.NewReader(responsePayload), nil, false); errWrite != nil { + return errWrite + } + + payload := homeRequestLogPayload{ + Headers: cloneHeaders(w.requestHeaders), + RequestLog: buf.String(), + } + raw, errMarshal := json.Marshal(&payload) + if errMarshal != nil { + return errMarshal + } + return client.RPushRequestLog(context.Background(), raw) +} diff --git a/internal/logging/request_logger_home_test.go b/internal/logging/request_logger_home_test.go new file mode 100644 index 0000000000..f8cdf1e453 --- /dev/null +++ b/internal/logging/request_logger_home_test.go @@ -0,0 +1,154 @@ +package logging + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "os" + "testing" + "time" +) + +type stubHomeRequestLogClient struct { + heartbeatOK bool + pushed [][]byte +} + +func (c *stubHomeRequestLogClient) HeartbeatOK() bool { return c.heartbeatOK } + +func (c *stubHomeRequestLogClient) RPushRequestLog(_ context.Context, payload []byte) error { + c.pushed = append(c.pushed, bytes.Clone(payload)) + return nil +} + +func TestFileRequestLogger_HomeEnabled_ForwardsWhenRequestLogEnabled(t *testing.T) { + original := currentHomeRequestLogClient + defer func() { + currentHomeRequestLogClient = original + }() + + stub := &stubHomeRequestLogClient{heartbeatOK: true} + currentHomeRequestLogClient = func() homeRequestLogClient { + return stub + } + + logsDir := t.TempDir() + logger := NewFileRequestLogger(true, logsDir, "", 0) + logger.SetHomeEnabled(true) + + requestHeaders := map[string][]string{ + "Content-Type": {"application/json"}, + "Authorization": {"Bearer secret"}, + } + + errLog := logger.LogRequest( + "/v1/chat/completions", + http.MethodPost, + requestHeaders, + []byte(`{"input":"hello"}`), + http.StatusOK, + map[string][]string{"Content-Type": {"application/json"}}, + []byte(`{"ok":true}`), + nil, + nil, + nil, + nil, + nil, + "req-1", + time.Now(), + time.Now(), + ) + if errLog != nil { + t.Fatalf("LogRequest error: %v", errLog) + } + + entries, errRead := os.ReadDir(logsDir) + if errRead != nil { + t.Fatalf("failed to read logs dir: %v", errRead) + } + if len(entries) != 0 { + t.Fatalf("expected no local request log files, got entries: %+v", entries) + } + + if len(stub.pushed) != 1 { + t.Fatalf("home pushed records = %d, want 1", len(stub.pushed)) + } + + var got struct { + Headers map[string][]string `json:"headers"` + RequestLog string `json:"request_log"` + } + if errUnmarshal := json.Unmarshal(stub.pushed[0], &got); errUnmarshal != nil { + t.Fatalf("unmarshal payload: %v payload=%s", errUnmarshal, string(stub.pushed[0])) + } + if got.Headers == nil || got.Headers["Content-Type"][0] != "application/json" { + t.Fatalf("headers.content-type = %+v, want application/json", got.Headers["Content-Type"]) + } + if got.Headers == nil || got.Headers["Authorization"][0] != "Bearer secret" { + t.Fatalf("headers.authorization = %+v, want Bearer secret", got.Headers["Authorization"]) + } + if got.RequestLog == "" { + t.Fatalf("request_log empty, want non-empty") + } +} + +func TestFileRequestLogger_HomeEnabled_DoesNotForwardForcedErrorLogsWhenRequestLogDisabled(t *testing.T) { + original := currentHomeRequestLogClient + defer func() { + currentHomeRequestLogClient = original + }() + + stub := &stubHomeRequestLogClient{heartbeatOK: true} + currentHomeRequestLogClient = func() homeRequestLogClient { + return stub + } + + logsDir := t.TempDir() + logger := NewFileRequestLogger(false, logsDir, "", 0) + logger.SetHomeEnabled(true) + + errLog := logger.LogRequestWithOptions( + "/v1/chat/completions", + http.MethodPost, + map[string][]string{"Content-Type": {"application/json"}}, + []byte(`{"input":"hello"}`), + http.StatusBadGateway, + map[string][]string{"Content-Type": {"application/json"}}, + []byte(`{"error":"upstream failure"}`), + nil, + nil, + nil, + nil, + nil, + true, + "req-2", + time.Now(), + time.Now(), + ) + if errLog != nil { + t.Fatalf("LogRequestWithOptions error: %v", errLog) + } + + if len(stub.pushed) != 0 { + t.Fatalf("home pushed records = %d, want 0", len(stub.pushed)) + } + + entries, errRead := os.ReadDir(logsDir) + if errRead != nil { + t.Fatalf("failed to read logs dir: %v", errRead) + } + found := false + for _, entry := range entries { + if entry.IsDir() { + continue + } + if entry.Name() != "" { + found = true + break + } + } + if !found { + t.Fatalf("expected local forced error log file when request-log disabled") + } +} From 66c3dae06b2ae5db101683ce3ef76b30361d55c0 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 10 May 2026 01:30:43 +0800 Subject: [PATCH 133/139] feat(home): implement `count` for home auth dispatch requests and enable usage statistics - Added `count` attribute to `homeAuthCount` requests to improve home message batching. - Enabled usage statistics for home mode by default and added config-level enforcement. - Adjusted failure logging to include detailed metadata in `UsageReporter`. - Updated multiple executors to pass error details to `PublishFailure` for better debugging. - Enhanced unit tests to validate `count` behavior and usage statistics enforcement across components. --- cmd/server/main.go | 1 + internal/home/client.go | 22 +++-- internal/home/client_test.go | 32 +++++++ internal/home/requests.go | 1 + internal/redisqueue/plugin.go | 25 ++++++ internal/redisqueue/plugin_test.go | 44 +++++++++- .../runtime/executor/aistudio_executor.go | 4 +- .../runtime/executor/antigravity_executor.go | 4 +- internal/runtime/executor/claude_executor.go | 4 +- internal/runtime/executor/codex_executor.go | 2 +- .../executor/codex_websockets_executor.go | 6 +- .../runtime/executor/gemini_cli_executor.go | 4 +- internal/runtime/executor/gemini_executor.go | 2 +- .../executor/gemini_vertex_executor.go | 4 +- .../runtime/executor/helps/usage_helpers.go | 49 ++++++++--- internal/runtime/executor/kimi_executor.go | 2 +- .../executor/openai_compat_executor.go | 4 +- sdk/cliproxy/auth/conductor.go | 83 ++++++++++++++++--- sdk/cliproxy/service.go | 2 + sdk/cliproxy/service_stale_state_test.go | 29 +++++++ sdk/cliproxy/usage/manager.go | 7 ++ 21 files changed, 280 insertions(+), 51 deletions(-) create mode 100644 internal/home/client_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 481103809a..1ef8300661 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -290,6 +290,7 @@ func main() { } parsed.Home = homeCfg parsed.Port = 8317 // Default to 8317 for home mode, can be overridden by home config + parsed.UsageStatisticsEnabled = true cfg = parsed // Keep a non-empty config path for downstream components (log paths, management assets, etc), diff --git a/internal/home/client.go b/internal/home/client.go index e99ef75323..23082cc69c 100644 --- a/internal/home/client.go +++ b/internal/home/client.go @@ -190,7 +190,20 @@ func headersToLowerMap(headers http.Header) map[string]string { return out } -func (c *Client) RPopAuth(ctx context.Context, requestedModel string, sessionID string, headers http.Header) ([]byte, error) { +func newAuthDispatchRequest(requestedModel string, sessionID string, headers http.Header, count int) authDispatchRequest { + if count <= 0 { + count = 1 + } + return authDispatchRequest{ + Type: "auth", + Model: requestedModel, + Count: count, + SessionID: strings.TrimSpace(sessionID), + Headers: headersToLowerMap(headers), + } +} + +func (c *Client) RPopAuth(ctx context.Context, requestedModel string, sessionID string, headers http.Header, count int) ([]byte, error) { if err := c.ensureClients(); err != nil { return nil, err } @@ -198,12 +211,7 @@ func (c *Client) RPopAuth(ctx context.Context, requestedModel string, sessionID if requestedModel == "" { return nil, fmt.Errorf("home: requested model is empty") } - req := authDispatchRequest{ - Type: "auth", - Model: requestedModel, - SessionID: strings.TrimSpace(sessionID), - Headers: headersToLowerMap(headers), - } + req := newAuthDispatchRequest(requestedModel, sessionID, headers, count) keyBytes, err := json.Marshal(&req) if err != nil { return nil, err diff --git a/internal/home/client_test.go b/internal/home/client_test.go new file mode 100644 index 0000000000..625e77bcac --- /dev/null +++ b/internal/home/client_test.go @@ -0,0 +1,32 @@ +package home + +import ( + "encoding/json" + "net/http" + "testing" +) + +func TestAuthDispatchRequestIncludesCount(t *testing.T) { + req := newAuthDispatchRequest("gpt-5.4", "session-1", http.Header{"Authorization": {"Bearer test"}}, 2) + + raw, err := json.Marshal(&req) + if err != nil { + t.Fatalf("marshal auth dispatch request: %v", err) + } + + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatalf("unmarshal auth dispatch request: %v", err) + } + if got := int(payload["count"].(float64)); got != 2 { + t.Fatalf("count = %d, want 2", got) + } +} + +func TestAuthDispatchRequestDefaultsCountToOne(t *testing.T) { + req := newAuthDispatchRequest("gpt-5.4", "", nil, 0) + + if req.Count != 1 { + t.Fatalf("count = %d, want 1", req.Count) + } +} diff --git a/internal/home/requests.go b/internal/home/requests.go index d08f5a5d92..0757766468 100644 --- a/internal/home/requests.go +++ b/internal/home/requests.go @@ -3,6 +3,7 @@ package home type authDispatchRequest struct { Type string `json:"type"` Model string `json:"model"` + Count int `json:"count"` SessionID string `json:"session_id,omitempty"` Headers map[string]string `json:"headers,omitempty"` } diff --git a/internal/redisqueue/plugin.go b/internal/redisqueue/plugin.go index 8a99de83b0..e5b74cb24b 100644 --- a/internal/redisqueue/plugin.go +++ b/internal/redisqueue/plugin.go @@ -66,6 +66,7 @@ func (p *usageQueuePlugin) HandleUsage(ctx context.Context, record coreusage.Rec if !failed { failed = !resolveSuccess(ctx) } + fail := resolveFail(ctx, record, failed) detail := requestDetail{ Timestamp: timestamp, @@ -74,6 +75,7 @@ func (p *usageQueuePlugin) HandleUsage(ctx context.Context, record coreusage.Rec AuthIndex: record.AuthIndex, Tokens: tokens, Failed: failed, + Fail: fail, } payload, err := json.Marshal(queuedUsageDetail{ @@ -110,6 +112,7 @@ type requestDetail struct { AuthIndex string `json:"auth_index"` Tokens tokenStats `json:"tokens"` Failed bool `json:"failed"` + Fail failDetail `json:"fail"` } type tokenStats struct { @@ -120,6 +123,28 @@ type tokenStats struct { TotalTokens int64 `json:"total_tokens"` } +type failDetail struct { + StatusCode int `json:"status_code"` + Body string `json:"body"` +} + +func resolveFail(ctx context.Context, record coreusage.Record, failed bool) failDetail { + fail := failDetail{ + StatusCode: record.Fail.StatusCode, + Body: strings.TrimSpace(record.Fail.Body), + } + if !failed { + return failDetail{StatusCode: 200} + } + if fail.StatusCode <= 0 { + fail.StatusCode = internallogging.GetResponseStatus(ctx) + } + if fail.StatusCode <= 0 { + fail.StatusCode = 500 + } + return fail +} + func resolveSuccess(ctx context.Context) bool { status := internallogging.GetResponseStatus(ctx) if status == 0 { diff --git a/internal/redisqueue/plugin_test.go b/internal/redisqueue/plugin_test.go index 4d7cb4652a..e2af6af709 100644 --- a/internal/redisqueue/plugin_test.go +++ b/internal/redisqueue/plugin_test.go @@ -44,9 +44,10 @@ func TestUsageQueuePluginPayloadIncludesStableFieldsAndSuccess(t *testing.T) { requireStringField(t, payload, "alias", "client-gpt") requireStringField(t, payload, "endpoint", "POST /v1/chat/completions") requireStringField(t, payload, "auth_type", "apikey") - requireStringField(t, payload, "user_api_key", "test-key") + requireMissingField(t, payload, "user_api_key") requireStringField(t, payload, "request_id", "ctx-request-id") requireBoolField(t, payload, "failed", false) + requireFailField(t, payload, http.StatusOK, "") }) } @@ -68,6 +69,10 @@ func TestUsageQueuePluginPayloadIncludesStableFieldsAndFailureAndGinRequestID(t Source: "user@example.com", RequestedAt: time.Date(2026, 4, 25, 0, 0, 0, 0, time.UTC), Latency: 2500 * time.Millisecond, + Fail: coreusage.Failure{ + StatusCode: http.StatusInternalServerError, + Body: "upstream failed", + }, Detail: coreusage.Detail{ InputTokens: 10, OutputTokens: 20, @@ -81,9 +86,10 @@ func TestUsageQueuePluginPayloadIncludesStableFieldsAndFailureAndGinRequestID(t requireStringField(t, payload, "alias", "client-mini") requireStringField(t, payload, "endpoint", "GET /v1/responses") requireStringField(t, payload, "auth_type", "apikey") - requireStringField(t, payload, "user_api_key", "test-key") + requireMissingField(t, payload, "user_api_key") requireStringField(t, payload, "request_id", "gin-request-id") requireBoolField(t, payload, "failed", true) + requireFailField(t, payload, http.StatusInternalServerError, "upstream failed") }) } @@ -115,6 +121,10 @@ func TestUsageQueuePluginAsyncIgnoresRecycledGinContext(t *testing.T) { Source: "user@example.com", RequestedAt: time.Date(2026, 4, 25, 0, 0, 0, 0, time.UTC), Latency: 1500 * time.Millisecond, + Fail: coreusage.Failure{ + StatusCode: http.StatusBadGateway, + Body: "bad gateway", + }, Detail: coreusage.Detail{ InputTokens: 10, OutputTokens: 20, @@ -125,9 +135,10 @@ func TestUsageQueuePluginAsyncIgnoresRecycledGinContext(t *testing.T) { payload := waitForSinglePayload(t, 2*time.Second) requireStringField(t, payload, "endpoint", "POST /v1/chat/completions") requireStringField(t, payload, "alias", "client-gpt") - requireStringField(t, payload, "user_api_key", "test-key") + requireMissingField(t, payload, "user_api_key") requireStringField(t, payload, "request_id", "ctx-request-id") requireBoolField(t, payload, "failed", true) + requireFailField(t, payload, http.StatusBadGateway, "bad gateway") }) } @@ -217,6 +228,14 @@ func requireStringField(t *testing.T, payload map[string]json.RawMessage, key, w } } +func requireMissingField(t *testing.T, payload map[string]json.RawMessage, key string) { + t.Helper() + + if _, ok := payload[key]; ok { + t.Fatalf("payload unexpectedly contains %q", key) + } +} + type pluginFunc func(context.Context, coreusage.Record) func (fn pluginFunc) HandleUsage(ctx context.Context, record coreusage.Record) { @@ -238,3 +257,22 @@ func requireBoolField(t *testing.T, payload map[string]json.RawMessage, key stri t.Fatalf("%s = %t, want %t", key, got, want) } } + +func requireFailField(t *testing.T, payload map[string]json.RawMessage, wantStatus int, wantBody string) { + t.Helper() + + raw, ok := payload["fail"] + if !ok { + t.Fatalf("payload missing %q", "fail") + } + var got struct { + StatusCode int `json:"status_code"` + Body string `json:"body"` + } + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal fail: %v", err) + } + if got.StatusCode != wantStatus || got.Body != wantBody { + t.Fatalf("fail = {status_code:%d body:%q}, want {status_code:%d body:%q}", got.StatusCode, got.Body, wantStatus, wantBody) + } +} diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index 392109b5cd..41365b5f7a 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -284,7 +284,7 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth processEvent := func(event wsrelay.StreamEvent) bool { if event.Err != nil { helps.RecordAPIResponseError(ctx, e.cfg, event.Err) - reporter.PublishFailure(ctx) + reporter.PublishFailure(ctx, event.Err) select { case out <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf("wsrelay: %v", event.Err)}: case <-ctx.Done(): @@ -336,7 +336,7 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth return false case wsrelay.MessageTypeError: helps.RecordAPIResponseError(ctx, e.cfg, event.Err) - reporter.PublishFailure(ctx) + reporter.PublishFailure(ctx, event.Err) select { case out <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf("wsrelay: %v", event.Err)}: case <-ctx.Done(): diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 84ff9de088..2f8dff927c 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -898,7 +898,7 @@ attemptLoop: } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) - reporter.PublishFailure(ctx) + reporter.PublishFailure(ctx, errScan) out <- cliproxyexecutor.StreamChunk{Err: errScan} } else { reporter.EnsurePublished(ctx) @@ -1374,7 +1374,7 @@ attemptLoop: } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) - reporter.PublishFailure(ctx) + reporter.PublishFailure(ctx, errScan) select { case out <- cliproxyexecutor.StreamChunk{Err: errScan}: case <-ctx.Done(): diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index fe4f22f2e4..eb17864d6e 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -472,7 +472,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) - reporter.PublishFailure(ctx) + reporter.PublishFailure(ctx, errScan) select { case out <- cliproxyexecutor.StreamChunk{Err: errScan}: case <-ctx.Done(): @@ -512,7 +512,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) - reporter.PublishFailure(ctx) + reporter.PublishFailure(ctx, errScan) select { case out <- cliproxyexecutor.StreamChunk{Err: errScan}: case <-ctx.Done(): diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 36c041b6e6..a1bbe6b84a 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -524,7 +524,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) - reporter.PublishFailure(ctx) + reporter.PublishFailure(ctx, errScan) select { case out <- cliproxyexecutor.StreamChunk{Err: errScan}: case <-ctx.Done(): diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go index 86078aacc9..2b56f13b1c 100644 --- a/internal/runtime/executor/codex_websockets_executor.go +++ b/internal/runtime/executor/codex_websockets_executor.go @@ -580,7 +580,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr terminateReason = "read_error" terminateErr = errRead helps.RecordAPIWebsocketError(ctx, e.cfg, "read", errRead) - reporter.PublishFailure(ctx) + reporter.PublishFailure(ctx, errRead) _ = send(cliproxyexecutor.StreamChunk{Err: errRead}) return } @@ -590,7 +590,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr terminateReason = "unexpected_binary" terminateErr = err helps.RecordAPIWebsocketError(ctx, e.cfg, "unexpected_binary", err) - reporter.PublishFailure(ctx) + reporter.PublishFailure(ctx, err) if sess != nil { e.invalidateUpstreamConn(sess, conn, "unexpected_binary", err) } @@ -610,7 +610,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr terminateReason = "upstream_error" terminateErr = wsErr helps.RecordAPIWebsocketError(ctx, e.cfg, "upstream_error", wsErr) - reporter.PublishFailure(ctx) + reporter.PublishFailure(ctx, wsErr) if sess != nil { e.invalidateUpstreamConn(sess, conn, "upstream_error", wsErr) } diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go index 0fa7cbb2d6..a298fe8a0e 100644 --- a/internal/runtime/executor/gemini_cli_executor.go +++ b/internal/runtime/executor/gemini_cli_executor.go @@ -430,7 +430,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) - reporter.PublishFailure(ctx) + reporter.PublishFailure(ctx, errScan) select { case out <- cliproxyexecutor.StreamChunk{Err: errScan}: case <-ctx.Done(): @@ -444,7 +444,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut data, errRead := io.ReadAll(resp.Body) if errRead != nil { helps.RecordAPIResponseError(ctx, e.cfg, errRead) - reporter.PublishFailure(ctx) + reporter.PublishFailure(ctx, errRead) select { case out <- cliproxyexecutor.StreamChunk{Err: errRead}: case <-ctx.Done(): diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go index c3f0801070..e8fa2e405f 100644 --- a/internal/runtime/executor/gemini_executor.go +++ b/internal/runtime/executor/gemini_executor.go @@ -341,7 +341,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) - reporter.PublishFailure(ctx) + reporter.PublishFailure(ctx, errScan) select { case out <- cliproxyexecutor.StreamChunk{Err: errScan}: case <-ctx.Done(): diff --git a/internal/runtime/executor/gemini_vertex_executor.go b/internal/runtime/executor/gemini_vertex_executor.go index ae0a718b8b..b899524c6a 100644 --- a/internal/runtime/executor/gemini_vertex_executor.go +++ b/internal/runtime/executor/gemini_vertex_executor.go @@ -679,7 +679,7 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) - reporter.PublishFailure(ctx) + reporter.PublishFailure(ctx, errScan) select { case out <- cliproxyexecutor.StreamChunk{Err: errScan}: case <-ctx.Done(): @@ -821,7 +821,7 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) - reporter.PublishFailure(ctx) + reporter.PublishFailure(ctx, errScan) select { case out <- cliproxyexecutor.StreamChunk{Err: errScan}: case <-ctx.Done(): diff --git a/internal/runtime/executor/helps/usage_helpers.go b/internal/runtime/executor/helps/usage_helpers.go index c72b5c1aeb..bef0b4eab1 100644 --- a/internal/runtime/executor/helps/usage_helpers.go +++ b/internal/runtime/executor/helps/usage_helpers.go @@ -3,6 +3,7 @@ package helps import ( "bytes" "context" + "errors" "fmt" "strings" "sync" @@ -51,7 +52,7 @@ func NewUsageReporter(ctx context.Context, provider, model string, auth *cliprox } func (r *UsageReporter) Publish(ctx context.Context, detail usage.Detail) { - r.publishWithOutcome(ctx, detail, false) + r.publishWithOutcome(ctx, detail, false, usage.Failure{}) } func (r *UsageReporter) PublishAdditionalModel(ctx context.Context, model string, detail usage.Detail) { @@ -74,11 +75,11 @@ func (r *UsageReporter) buildAdditionalModelRecord(model string, detail usage.De if !hasNonZeroTokenUsage(detail) { return usage.Record{}, false } - return r.buildRecordForModel(model, detail, false), true + return r.buildRecordForModel(model, detail, false, usage.Failure{}), true } -func (r *UsageReporter) PublishFailure(ctx context.Context) { - r.publishWithOutcome(ctx, usage.Detail{}, true) +func (r *UsageReporter) PublishFailure(ctx context.Context, errs ...error) { + r.publishWithOutcome(ctx, usage.Detail{}, true, failFromErrors(errs...)) } func (r *UsageReporter) TrackFailure(ctx context.Context, errPtr *error) { @@ -86,17 +87,17 @@ func (r *UsageReporter) TrackFailure(ctx context.Context, errPtr *error) { return } if *errPtr != nil { - r.PublishFailure(ctx) + r.PublishFailure(ctx, *errPtr) } } -func (r *UsageReporter) publishWithOutcome(ctx context.Context, detail usage.Detail, failed bool) { +func (r *UsageReporter) publishWithOutcome(ctx context.Context, detail usage.Detail, failed bool, fail usage.Failure) { if r == nil { return } detail = normalizeUsageDetailTotal(detail) r.once.Do(func() { - usage.PublishRecord(ctx, r.buildRecord(detail, failed)) + usage.PublishRecord(ctx, r.buildRecord(detail, failed, fail)) }) } @@ -127,20 +128,24 @@ func (r *UsageReporter) EnsurePublished(ctx context.Context) { return } r.once.Do(func() { - usage.PublishRecord(ctx, r.buildRecord(usage.Detail{}, false)) + usage.PublishRecord(ctx, r.buildRecord(usage.Detail{}, false, usage.Failure{})) }) } -func (r *UsageReporter) buildRecord(detail usage.Detail, failed bool) usage.Record { +func (r *UsageReporter) buildRecord(detail usage.Detail, failed bool, failures ...usage.Failure) usage.Record { + var fail usage.Failure + if len(failures) > 0 { + fail = failures[0] + } if r == nil { - return usage.Record{Detail: detail, Failed: failed} + return usage.Record{Detail: detail, Failed: failed, Fail: fail} } - return r.buildRecordForModel(r.model, detail, failed) + return r.buildRecordForModel(r.model, detail, failed, fail) } -func (r *UsageReporter) buildRecordForModel(model string, detail usage.Detail, failed bool) usage.Record { +func (r *UsageReporter) buildRecordForModel(model string, detail usage.Detail, failed bool, fail usage.Failure) usage.Record { if r == nil { - return usage.Record{Model: model, Detail: detail, Failed: failed} + return usage.Record{Model: model, Detail: detail, Failed: failed, Fail: fail} } return usage.Record{ Provider: r.provider, @@ -154,10 +159,28 @@ func (r *UsageReporter) buildRecordForModel(model string, detail usage.Detail, f RequestedAt: r.requestedAt, Latency: r.latency(), Failed: failed, + Fail: fail, Detail: detail, } } +func failFromErrors(errs ...error) usage.Failure { + for _, err := range errs { + if err == nil { + continue + } + fail := usage.Failure{ + Body: strings.TrimSpace(err.Error()), + } + var se interface{ StatusCode() int } + if errors.As(err, &se) && se != nil { + fail.StatusCode = se.StatusCode() + } + return fail + } + return usage.Failure{} +} + func (r *UsageReporter) latency() time.Duration { if r == nil || r.requestedAt.IsZero() { return 0 diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go index f330321fa2..6cfaec2052 100644 --- a/internal/runtime/executor/kimi_executor.go +++ b/internal/runtime/executor/kimi_executor.go @@ -307,7 +307,7 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) - reporter.PublishFailure(ctx) + reporter.PublishFailure(ctx, errScan) select { case out <- cliproxyexecutor.StreamChunk{Err: errScan}: case <-ctx.Done(): diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index de12da3706..82fc9e97d8 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -296,7 +296,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy if bytes.HasPrefix(trimmedLine, []byte("{")) || bytes.HasPrefix(trimmedLine, []byte("[")) { streamErr := statusErr{code: http.StatusBadGateway, msg: string(trimmedLine)} helps.RecordAPIResponseError(ctx, e.cfg, streamErr) - reporter.PublishFailure(ctx) + reporter.PublishFailure(ctx, streamErr) select { case out <- cliproxyexecutor.StreamChunk{Err: streamErr}: case <-ctx.Done(): @@ -318,7 +318,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) - reporter.PublishFailure(ctx) + reporter.PublishFailure(ctx, errScan) select { case out <- cliproxyexecutor.StreamChunk{Err: errScan}: case <-ctx.Done(): diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index befdfe2cb7..d339f56b30 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -51,6 +51,7 @@ type ExecutionSessionCloser interface { } const ( + homeAuthCountMetadataKey = "__cliproxy_home_auth_count" // CloseAllExecutionSessionsID asks an executor to release all active execution sessions. // Executors that do not support this marker may ignore it. CloseAllExecutionSessionsID = "__all_execution_sessions__" @@ -1316,19 +1317,25 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req } routeModel := req.Model opts = ensureRequestedModelMetadata(opts, routeModel) + homeMode := m.HomeEnabled() + homeAuthCount := 1 tried := make(map[string]struct{}) attempted := make(map[string]struct{}) var lastErr error for { - if maxRetryCredentials > 0 && len(attempted) >= maxRetryCredentials { + if !homeMode && maxRetryCredentials > 0 && len(attempted) >= maxRetryCredentials { if lastErr != nil { return cliproxyexecutor.Response{}, lastErr } return cliproxyexecutor.Response{}, &Error{Code: "auth_not_found", Message: "no auth available"} } - auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, opts, tried) + pickOpts := opts + if homeMode { + pickOpts = withHomeAuthCount(opts, homeAuthCount) + } + auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, pickOpts, tried) if errPick != nil { - if lastErr != nil { + if lastErr != nil && !homeMode { return cliproxyexecutor.Response{}, lastErr } return cliproxyexecutor.Response{}, errPick @@ -1384,6 +1391,9 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req return cliproxyexecutor.Response{}, authErr } lastErr = authErr + if homeMode { + homeAuthCount++ + } continue } } @@ -1395,19 +1405,25 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string, } routeModel := req.Model opts = ensureRequestedModelMetadata(opts, routeModel) + homeMode := m.HomeEnabled() + homeAuthCount := 1 tried := make(map[string]struct{}) attempted := make(map[string]struct{}) var lastErr error for { - if maxRetryCredentials > 0 && len(attempted) >= maxRetryCredentials { + if !homeMode && maxRetryCredentials > 0 && len(attempted) >= maxRetryCredentials { if lastErr != nil { return cliproxyexecutor.Response{}, lastErr } return cliproxyexecutor.Response{}, &Error{Code: "auth_not_found", Message: "no auth available"} } - auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, opts, tried) + pickOpts := opts + if homeMode { + pickOpts = withHomeAuthCount(opts, homeAuthCount) + } + auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, pickOpts, tried) if errPick != nil { - if lastErr != nil { + if lastErr != nil && !homeMode { return cliproxyexecutor.Response{}, lastErr } return cliproxyexecutor.Response{}, errPick @@ -1463,6 +1479,9 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string, return cliproxyexecutor.Response{}, authErr } lastErr = authErr + if homeMode { + homeAuthCount++ + } continue } } @@ -1474,19 +1493,25 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string } routeModel := req.Model opts = ensureRequestedModelMetadata(opts, routeModel) + homeMode := m.HomeEnabled() + homeAuthCount := 1 tried := make(map[string]struct{}) attempted := make(map[string]struct{}) var lastErr error for { - if maxRetryCredentials > 0 && len(attempted) >= maxRetryCredentials { + if !homeMode && maxRetryCredentials > 0 && len(attempted) >= maxRetryCredentials { if lastErr != nil { return nil, lastErr } return nil, &Error{Code: "auth_not_found", Message: "no auth available"} } - auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, opts, tried) + pickOpts := opts + if homeMode { + pickOpts = withHomeAuthCount(opts, homeAuthCount) + } + auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, pickOpts, tried) if errPick != nil { - if lastErr != nil { + if lastErr != nil && !homeMode { return nil, lastErr } return nil, errPick @@ -1516,6 +1541,9 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string return nil, errStream } lastErr = errStream + if homeMode { + homeAuthCount++ + } continue } return streamResult, nil @@ -1543,6 +1571,40 @@ func ensureRequestedModelMetadata(opts cliproxyexecutor.Options, requestedModel return opts } +func withHomeAuthCount(opts cliproxyexecutor.Options, count int) cliproxyexecutor.Options { + if count <= 0 { + count = 1 + } + meta := make(map[string]any, len(opts.Metadata)+1) + for k, v := range opts.Metadata { + meta[k] = v + } + meta[homeAuthCountMetadataKey] = count + opts.Metadata = meta + return opts +} + +func homeAuthCountFromMetadata(meta map[string]any) int { + if len(meta) == 0 { + return 1 + } + switch value := meta[homeAuthCountMetadataKey].(type) { + case int: + if value > 0 { + return value + } + case int64: + if value > 0 { + return int(value) + } + case float64: + if value > 0 { + return int(value) + } + } + return 1 +} + func hasRequestedModelMetadata(meta map[string]any) bool { if len(meta) == 0 { return false @@ -3099,8 +3161,9 @@ func (m *Manager) pickNextViaHome(ctx context.Context, model string, opts clipro requestedModel := requestedModelFromMetadata(opts.Metadata, model) sessionID := ExtractSessionID(opts.Headers, opts.OriginalRequest, opts.Metadata) + count := homeAuthCountFromMetadata(opts.Metadata) - raw, err := client.RPopAuth(ctx, requestedModel, sessionID, opts.Headers) + raw, err := client.RPopAuth(ctx, requestedModel, sessionID, opts.Headers, count) if err != nil { return nil, nil, "", &Error{Code: "auth_not_found", Message: err.Error(), HTTPStatus: http.StatusServiceUnavailable} } diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 6a94878dee..89a480b503 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -561,6 +561,7 @@ func forceHomeRuntimeConfig(cfg *config.Config) { return } cfg.APIKeys = nil + cfg.UsageStatisticsEnabled = true cfg.DisableCooling = true cfg.WebsocketAuth = false cfg.EnableGeminiCLIEndpoint = false @@ -732,6 +733,7 @@ func (s *Service) Run(ctx context.Context) error { homeEnabled := s.cfg != nil && s.cfg.Home.Enabled if homeEnabled { forceHomeRuntimeConfig(s.cfg) + redisqueue.SetUsageStatisticsEnabled(true) } shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) diff --git a/sdk/cliproxy/service_stale_state_test.go b/sdk/cliproxy/service_stale_state_test.go index 8943d67930..53849eb349 100644 --- a/sdk/cliproxy/service_stale_state_test.go +++ b/sdk/cliproxy/service_stale_state_test.go @@ -99,3 +99,32 @@ func TestServiceApplyCoreAuthAddOrUpdate_DeleteReAddDoesNotInheritStaleRuntimeSt t.Fatalf("expected re-added auth to re-register models in global registry") } } + +func TestForceHomeRuntimeConfigEnablesUsageStatistics(t *testing.T) { + cfg := &config.Config{ + UsageStatisticsEnabled: false, + } + + forceHomeRuntimeConfig(cfg) + + if !cfg.UsageStatisticsEnabled { + t.Fatal("expected home runtime config to force usage statistics enabled") + } +} + +func TestApplyHomeOverlayForcesUsageStatisticsEnabled(t *testing.T) { + baseCfg := &config.Config{} + baseCfg.Home.Enabled = true + service := &Service{cfg: baseCfg} + + service.applyHomeOverlay(&config.Config{ + UsageStatisticsEnabled: false, + }) + + if service.cfg == nil || !service.cfg.UsageStatisticsEnabled { + t.Fatal("expected home overlay to force usage statistics enabled") + } + if !service.cfg.Home.Enabled { + t.Fatal("expected home overlay to preserve local home settings") + } +} diff --git a/sdk/cliproxy/usage/manager.go b/sdk/cliproxy/usage/manager.go index 72405d7587..2305d9a484 100644 --- a/sdk/cliproxy/usage/manager.go +++ b/sdk/cliproxy/usage/manager.go @@ -22,9 +22,16 @@ type Record struct { RequestedAt time.Time Latency time.Duration Failed bool + Fail Failure Detail Detail } +// Failure holds HTTP failure metadata for an upstream request attempt. +type Failure struct { + StatusCode int + Body string +} + // Detail holds the token usage breakdown. type Detail struct { InputTokens int64 From 67fb4eb98ed7a9b456e5d75e1e727d3c4c644e37 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 10 May 2026 02:09:53 +0800 Subject: [PATCH 134/139] feat(auth): add `shouldReturnLastErrorOnPickFailure` helper and improve error handling in home mode - Introduced `shouldReturnLastErrorOnPickFailure` to streamline error return logic during provider selection. - Added `isHomeRequestRetryExceededError` for better home-specific error classification. - Updated fallback conditions to enhance error handling clarity in `pickNextMixed`. --- sdk/cliproxy/auth/conductor.go | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index d339f56b30..4e72d7c8f8 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -1335,7 +1335,7 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req } auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, pickOpts, tried) if errPick != nil { - if lastErr != nil && !homeMode { + if shouldReturnLastErrorOnPickFailure(homeMode, lastErr, errPick) { return cliproxyexecutor.Response{}, lastErr } return cliproxyexecutor.Response{}, errPick @@ -1423,7 +1423,7 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string, } auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, pickOpts, tried) if errPick != nil { - if lastErr != nil && !homeMode { + if shouldReturnLastErrorOnPickFailure(homeMode, lastErr, errPick) { return cliproxyexecutor.Response{}, lastErr } return cliproxyexecutor.Response{}, errPick @@ -1511,7 +1511,7 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string } auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, pickOpts, tried) if errPick != nil { - if lastErr != nil && !homeMode { + if shouldReturnLastErrorOnPickFailure(homeMode, lastErr, errPick) { return nil, lastErr } return nil, errPick @@ -3125,7 +3125,28 @@ type homeErrorDetail struct { Code string `json:"code,omitempty"` } -const homeUpstreamModelAttributeKey = "home_upstream_model" +const ( + homeUpstreamModelAttributeKey = "home_upstream_model" + homeRequestRetryExceededErrorCode = "request_retry_exceeded" +) + +func isHomeRequestRetryExceededError(err error) bool { + var authErr *Error + if !errors.As(err, &authErr) || authErr == nil { + return false + } + return strings.EqualFold(strings.TrimSpace(authErr.Code), homeRequestRetryExceededErrorCode) +} + +func shouldReturnLastErrorOnPickFailure(homeMode bool, lastErr error, errPick error) bool { + if lastErr == nil { + return false + } + if !homeMode { + return true + } + return isHomeRequestRetryExceededError(errPick) +} type homeAuthDispatchResponse struct { Model string `json:"model"` From dc1cc7f115926f876d81b109c3adacfe20caf70b Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 10 May 2026 13:39:14 +0800 Subject: [PATCH 135/139] feat(auth): add websocket session reuse for home auths with caching support - Introduced `homeRuntimeAuths` to cache home auths for websocket session reuse. - Updated `pickNextViaHome` to prioritize cached auths for pinned websocket sessions. - Implemented automatic clearing of cached home auths when home mode is disabled. - Added unit tests to validate caching behavior, clearing logic, and fallback scenarios. --- sdk/cliproxy/auth/conductor.go | 107 ++++++++++++++++- .../auth/home_websocket_reuse_test.go | 113 ++++++++++++++++++ 2 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 sdk/cliproxy/auth/home_websocket_reuse_test.go diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 4e72d7c8f8..939f1d2b3f 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -151,6 +151,9 @@ type Manager struct { mu sync.RWMutex auths map[string]*Auth scheduler *authScheduler + // homeRuntimeAuths caches auths returned by Home so websocket sessions can + // reuse an established upstream credential without dispatching every turn. + homeRuntimeAuths map[string]*Auth // providerOffsets tracks per-model provider rotation state for multi-provider routing. providerOffsets map[string]int @@ -195,6 +198,7 @@ func NewManager(store Store, selector Selector, hook Hook) *Manager { selector: selector, hook: hook, auths: make(map[string]*Auth), + homeRuntimeAuths: make(map[string]*Auth), providerOffsets: make(map[string]int), modelPoolOffsets: make(map[string]int), } @@ -376,6 +380,9 @@ func (m *Manager) SetConfig(cfg *internalconfig.Config) { cfg = &internalconfig.Config{} } m.runtimeConfig.Store(cfg) + if !cfg.Home.Enabled { + m.clearHomeRuntimeAuths() + } m.rebuildAPIKeyModelAliasFromRuntimeConfig() } @@ -2713,7 +2720,10 @@ func (m *Manager) GetByID(id string) (*Auth, bool) { defer m.mu.RUnlock() auth, ok := m.auths[id] if !ok { - return nil, false + auth, ok = m.homeRuntimeAuths[id] + if !ok { + return nil, false + } } return auth.Clone(), true } @@ -2751,12 +2761,15 @@ func (m *Manager) CloseExecutionSession(sessionID string) { return } - m.mu.RLock() + m.mu.Lock() + if sessionID == CloseAllExecutionSessionsID { + m.clearHomeRuntimeAuthsLocked() + } executors := make([]ProviderExecutor, 0, len(m.executors)) for _, exec := range m.executors { executors = append(executors, exec) } - m.mu.RUnlock() + m.mu.Unlock() for i := range executors { if closer, ok := executors[i].(ExecutionSessionCloser); ok && closer != nil { @@ -3168,6 +3181,80 @@ func setHomeUserAPIKeyOnGinContext(ctx context.Context, apiKey string) { ginCtx.Set("userApiKey", apiKey) } +func homeExecutionSessionIDFromMetadata(meta map[string]any) string { + if len(meta) == 0 { + return "" + } + raw, ok := meta[cliproxyexecutor.ExecutionSessionMetadataKey] + if !ok || raw == nil { + return "" + } + switch value := raw.(type) { + case string: + return strings.TrimSpace(value) + case []byte: + return strings.TrimSpace(string(value)) + default: + return "" + } +} + +func (m *Manager) clearHomeRuntimeAuths() { + if m == nil { + return + } + m.mu.Lock() + m.clearHomeRuntimeAuthsLocked() + m.mu.Unlock() +} + +func (m *Manager) clearHomeRuntimeAuthsLocked() { + if m == nil { + return + } + m.homeRuntimeAuths = make(map[string]*Auth) +} + +func (m *Manager) rememberHomeRuntimeAuth(auth *Auth) { + if m == nil || auth == nil || strings.TrimSpace(auth.ID) == "" || !authWebsocketsEnabled(auth) { + return + } + m.mu.Lock() + if m.homeRuntimeAuths == nil { + m.homeRuntimeAuths = make(map[string]*Auth) + } + m.homeRuntimeAuths[auth.ID] = auth.Clone() + m.mu.Unlock() +} + +func (m *Manager) homeRuntimeAuthByID(authID string) (*Auth, ProviderExecutor, string, bool) { + authID = strings.TrimSpace(authID) + if m == nil || authID == "" { + return nil, nil, "", false + } + m.mu.RLock() + auth := m.homeRuntimeAuths[authID] + m.mu.RUnlock() + if auth == nil || !authWebsocketsEnabled(auth) { + return nil, nil, "", false + } + providerKey := strings.ToLower(strings.TrimSpace(auth.Provider)) + if providerKey == "" { + return nil, nil, "", false + } + executor, ok := m.Executor(providerKey) + if !ok && auth.Attributes != nil && strings.TrimSpace(auth.Attributes["base_url"]) != "" { + executor, ok = m.Executor("openai-compatibility") + if ok { + providerKey = "openai-compatibility" + } + } + if !ok { + return nil, nil, "", false + } + return auth.Clone(), executor, providerKey, true +} + func (m *Manager) pickNextViaHome(ctx context.Context, model string, opts cliproxyexecutor.Options) (*Auth, ProviderExecutor, string, error) { if m == nil { return nil, nil, "", &Error{Code: "auth_not_found", Message: "no auth available"} @@ -3175,6 +3262,14 @@ func (m *Manager) pickNextViaHome(ctx context.Context, model string, opts clipro if ctx == nil { ctx = context.Background() } + if cliproxyexecutor.DownstreamWebsocket(ctx) && homeExecutionSessionIDFromMetadata(opts.Metadata) != "" { + if pinnedAuthID := pinnedAuthIDFromMetadata(opts.Metadata); pinnedAuthID != "" { + if auth, executor, providerKey, ok := m.homeRuntimeAuthByID(pinnedAuthID); ok { + return auth, executor, providerKey, nil + } + } + } + client := home.Current() if client == nil || !client.HeartbeatOK() { return nil, nil, "", &Error{Code: "home_unavailable", Message: "home control center unavailable", HTTPStatus: http.StatusServiceUnavailable} @@ -3254,7 +3349,11 @@ func (m *Manager) pickNextViaHome(ctx context.Context, model string, opts clipro return nil, nil, "", &Error{Code: "executor_not_found", Message: "executor not registered", HTTPStatus: http.StatusBadGateway} } - return auth.Clone(), executor, providerKey, nil + authCopy := auth.Clone() + if cliproxyexecutor.DownstreamWebsocket(ctx) && homeExecutionSessionIDFromMetadata(opts.Metadata) != "" && authWebsocketsEnabled(authCopy) { + m.rememberHomeRuntimeAuth(authCopy) + } + return authCopy, executor, providerKey, nil } func requestedModelFromMetadata(metadata map[string]any, fallback string) string { diff --git a/sdk/cliproxy/auth/home_websocket_reuse_test.go b/sdk/cliproxy/auth/home_websocket_reuse_test.go new file mode 100644 index 0000000000..b3b329ee18 --- /dev/null +++ b/sdk/cliproxy/auth/home_websocket_reuse_test.go @@ -0,0 +1,113 @@ +package auth + +import ( + "context" + "errors" + "net/http" + "testing" + + internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" +) + +func TestPickNextViaHomeReusesPinnedWebsocketAuthWithoutHomeDispatch(t *testing.T) { + manager := NewManager(nil, nil, nil) + manager.SetConfig(&internalconfig.Config{Home: internalconfig.HomeConfig{Enabled: true}}) + manager.RegisterExecutor(schedulerTestExecutor{}) + + auth := &Auth{ + ID: "home-auth-1", + Provider: "test", + Status: StatusActive, + Attributes: map[string]string{ + "websockets": "true", + homeUpstreamModelAttributeKey: "upstream-model", + }, + Metadata: map[string]any{"email": "home@example.com"}, + } + auth.EnsureIndex() + manager.rememberHomeRuntimeAuth(auth) + cachedAuth, ok := manager.GetByID("home-auth-1") + if !ok || cachedAuth == nil || !authWebsocketsEnabled(cachedAuth) { + t.Fatalf("GetByID() did not expose remembered websocket home auth: auth=%#v ok=%v", cachedAuth, ok) + } + + ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background()) + opts := cliproxyexecutor.Options{ + Metadata: map[string]any{ + cliproxyexecutor.ExecutionSessionMetadataKey: "session-1", + cliproxyexecutor.PinnedAuthMetadataKey: "home-auth-1", + }, + Headers: http.Header{"Authorization": {"Bearer client-key"}}, + } + + got, executor, provider, errPick := manager.pickNextViaHome(ctx, "gpt-5.4", opts) + if errPick != nil { + t.Fatalf("pickNextViaHome() error = %v", errPick) + } + if got == nil || got.ID != "home-auth-1" { + t.Fatalf("pickNextViaHome() auth = %#v, want home-auth-1", got) + } + if executor == nil { + t.Fatal("pickNextViaHome() executor is nil") + } + if provider != "test" { + t.Fatalf("pickNextViaHome() provider = %q, want test", provider) + } +} + +func TestPickNextViaHomeDoesNotReusePinnedNonWebsocketAuth(t *testing.T) { + manager := NewManager(nil, nil, nil) + manager.SetConfig(&internalconfig.Config{Home: internalconfig.HomeConfig{Enabled: true}}) + manager.RegisterExecutor(schedulerTestExecutor{}) + + manager.mu.Lock() + manager.homeRuntimeAuths["home-auth-1"] = &Auth{ + ID: "home-auth-1", + Provider: "test", + Status: StatusActive, + } + manager.mu.Unlock() + + ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background()) + opts := cliproxyexecutor.Options{ + Metadata: map[string]any{ + cliproxyexecutor.ExecutionSessionMetadataKey: "session-1", + cliproxyexecutor.PinnedAuthMetadataKey: "home-auth-1", + }, + Headers: http.Header{"Authorization": {"Bearer client-key"}}, + } + + got, executor, provider, errPick := manager.pickNextViaHome(ctx, "gpt-5.4", opts) + if errPick == nil { + t.Fatal("pickNextViaHome() error is nil, want home unavailable error") + } + var authErr *Error + if !errors.As(errPick, &authErr) || authErr.Code != "home_unavailable" { + t.Fatalf("pickNextViaHome() error = %v, want home_unavailable", errPick) + } + if got != nil || executor != nil || provider != "" { + t.Fatalf("pickNextViaHome() reused non-websocket auth: auth=%#v executor=%#v provider=%q", got, executor, provider) + } +} + +func TestHomeRuntimeAuthsClearWhenHomeDisabled(t *testing.T) { + manager := NewManager(nil, nil, nil) + manager.SetConfig(&internalconfig.Config{Home: internalconfig.HomeConfig{Enabled: true}}) + manager.rememberHomeRuntimeAuth(&Auth{ + ID: "home-auth-1", + Provider: "test", + Attributes: map[string]string{ + "websockets": "true", + }, + }) + + if _, ok := manager.GetByID("home-auth-1"); !ok { + t.Fatal("expected remembered home auth before disabling home") + } + + manager.SetConfig(&internalconfig.Config{}) + if _, ok := manager.GetByID("home-auth-1"); ok { + t.Fatal("remembered home auth was not cleared when home was disabled") + } +} From 8300ee8bbee62ce85389e42e76464f6dcb7d4a26 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 10 May 2026 14:00:13 +0800 Subject: [PATCH 136/139] feat(auth): enhance home auth session reuse with scoped caching and ref counting - Added `homeRuntimeAuthSessions` and `homeRuntimeAuthRefs` for scoped caching of home auths per session. - Updated `pickNextViaHome` to prevent reuse of already-tried pinned auths during session retries. - Implemented reference counting for shared auths across multiple sessions to improve memory management. - Enhanced session cleanup logic to clear cached auths only when all referencing sessions are closed. - Added unit tests to validate scoped caching, retry logic, and session cleanup behavior. --- sdk/cliproxy/auth/conductor.go | 110 ++++++++++++++---- .../auth/home_websocket_reuse_test.go | 105 ++++++++++++++++- 2 files changed, 186 insertions(+), 29 deletions(-) diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 939f1d2b3f..64a28d5868 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -153,7 +153,9 @@ type Manager struct { scheduler *authScheduler // homeRuntimeAuths caches auths returned by Home so websocket sessions can // reuse an established upstream credential without dispatching every turn. - homeRuntimeAuths map[string]*Auth + homeRuntimeAuths map[string]*Auth + homeRuntimeAuthSessions map[string]map[string]struct{} + homeRuntimeAuthRefs map[string]int // providerOffsets tracks per-model provider rotation state for multi-provider routing. providerOffsets map[string]int @@ -193,14 +195,16 @@ func NewManager(store Store, selector Selector, hook Hook) *Manager { hook = NoopHook{} } manager := &Manager{ - store: store, - executors: make(map[string]ProviderExecutor), - selector: selector, - hook: hook, - auths: make(map[string]*Auth), - homeRuntimeAuths: make(map[string]*Auth), - providerOffsets: make(map[string]int), - modelPoolOffsets: make(map[string]int), + store: store, + executors: make(map[string]ProviderExecutor), + selector: selector, + hook: hook, + auths: make(map[string]*Auth), + homeRuntimeAuths: make(map[string]*Auth), + homeRuntimeAuthSessions: make(map[string]map[string]struct{}), + homeRuntimeAuthRefs: make(map[string]int), + providerOffsets: make(map[string]int), + modelPoolOffsets: make(map[string]int), } // atomic.Value requires non-nil initial value. manager.runtimeConfig.Store(&internalconfig.Config{}) @@ -2764,6 +2768,8 @@ func (m *Manager) CloseExecutionSession(sessionID string) { m.mu.Lock() if sessionID == CloseAllExecutionSessionsID { m.clearHomeRuntimeAuthsLocked() + } else { + m.clearHomeRuntimeAuthsForSessionLocked(sessionID) } executors := make([]ProviderExecutor, 0, len(m.executors)) for _, exec := range m.executors { @@ -2809,7 +2815,7 @@ func (m *Manager) routeAwareSelectionRequired(auth *Auth, routeModel string) boo func (m *Manager) pickNextLegacy(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, error) { if m.HomeEnabled() { - auth, exec, _, err := m.pickNextViaHome(ctx, model, opts) + auth, exec, _, err := m.pickNextViaHome(ctx, model, opts, tried) return auth, exec, err } @@ -2883,7 +2889,7 @@ func (m *Manager) pickNextLegacy(ctx context.Context, provider, model string, op func (m *Manager) pickNext(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, error) { if m.HomeEnabled() { - auth, exec, _, err := m.pickNextViaHome(ctx, model, opts) + auth, exec, _, err := m.pickNextViaHome(ctx, model, opts, tried) return auth, exec, err } @@ -2945,7 +2951,7 @@ func (m *Manager) pickNext(ctx context.Context, provider, model string, opts cli func (m *Manager) pickNextMixedLegacy(ctx context.Context, providers []string, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, string, error) { if m.HomeEnabled() { - return m.pickNextViaHome(ctx, model, opts) + return m.pickNextViaHome(ctx, model, opts, tried) } pinnedAuthID := pinnedAuthIDFromMetadata(opts.Metadata) @@ -3041,7 +3047,7 @@ func (m *Manager) pickNextMixedLegacy(ctx context.Context, providers []string, m func (m *Manager) pickNextMixed(ctx context.Context, providers []string, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, string, error) { if m.HomeEnabled() { - return m.pickNextViaHome(ctx, model, opts) + return m.pickNextViaHome(ctx, model, opts, tried) } if !m.useSchedulerFastPath() { @@ -3213,26 +3219,76 @@ func (m *Manager) clearHomeRuntimeAuthsLocked() { return } m.homeRuntimeAuths = make(map[string]*Auth) + m.homeRuntimeAuthSessions = make(map[string]map[string]struct{}) + m.homeRuntimeAuthRefs = make(map[string]int) } -func (m *Manager) rememberHomeRuntimeAuth(auth *Auth) { - if m == nil || auth == nil || strings.TrimSpace(auth.ID) == "" || !authWebsocketsEnabled(auth) { +func (m *Manager) clearHomeRuntimeAuthsForSessionLocked(sessionID string) { + sessionID = strings.TrimSpace(sessionID) + if m == nil || sessionID == "" { + return + } + authIDs := m.homeRuntimeAuthSessions[sessionID] + if len(authIDs) == 0 { + delete(m.homeRuntimeAuthSessions, sessionID) + return + } + for authID := range authIDs { + refCount := m.homeRuntimeAuthRefs[authID] + if refCount <= 1 { + delete(m.homeRuntimeAuthRefs, authID) + delete(m.homeRuntimeAuths, authID) + continue + } + m.homeRuntimeAuthRefs[authID] = refCount - 1 + } + delete(m.homeRuntimeAuthSessions, sessionID) +} + +func (m *Manager) rememberHomeRuntimeAuth(sessionID string, auth *Auth) { + sessionID = strings.TrimSpace(sessionID) + authID := "" + if auth != nil { + authID = strings.TrimSpace(auth.ID) + } + if m == nil || auth == nil || sessionID == "" || authID == "" || !authWebsocketsEnabled(auth) { return } m.mu.Lock() if m.homeRuntimeAuths == nil { m.homeRuntimeAuths = make(map[string]*Auth) } - m.homeRuntimeAuths[auth.ID] = auth.Clone() + if m.homeRuntimeAuthSessions == nil { + m.homeRuntimeAuthSessions = make(map[string]map[string]struct{}) + } + if m.homeRuntimeAuthRefs == nil { + m.homeRuntimeAuthRefs = make(map[string]int) + } + m.homeRuntimeAuths[authID] = auth.Clone() + sessionAuths := m.homeRuntimeAuthSessions[sessionID] + if sessionAuths == nil { + sessionAuths = make(map[string]struct{}) + m.homeRuntimeAuthSessions[sessionID] = sessionAuths + } + if _, exists := sessionAuths[authID]; !exists { + sessionAuths[authID] = struct{}{} + m.homeRuntimeAuthRefs[authID]++ + } m.mu.Unlock() } -func (m *Manager) homeRuntimeAuthByID(authID string) (*Auth, ProviderExecutor, string, bool) { +func (m *Manager) homeRuntimeAuthByID(sessionID string, authID string) (*Auth, ProviderExecutor, string, bool) { + sessionID = strings.TrimSpace(sessionID) authID = strings.TrimSpace(authID) - if m == nil || authID == "" { + if m == nil || sessionID == "" || authID == "" { return nil, nil, "", false } m.mu.RLock() + sessionAuths := m.homeRuntimeAuthSessions[sessionID] + if _, ok := sessionAuths[authID]; !ok { + m.mu.RUnlock() + return nil, nil, "", false + } auth := m.homeRuntimeAuths[authID] m.mu.RUnlock() if auth == nil || !authWebsocketsEnabled(auth) { @@ -3255,17 +3311,22 @@ func (m *Manager) homeRuntimeAuthByID(authID string) (*Auth, ProviderExecutor, s return auth.Clone(), executor, providerKey, true } -func (m *Manager) pickNextViaHome(ctx context.Context, model string, opts cliproxyexecutor.Options) (*Auth, ProviderExecutor, string, error) { +func (m *Manager) pickNextViaHome(ctx context.Context, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, string, error) { if m == nil { return nil, nil, "", &Error{Code: "auth_not_found", Message: "no auth available"} } if ctx == nil { ctx = context.Background() } - if cliproxyexecutor.DownstreamWebsocket(ctx) && homeExecutionSessionIDFromMetadata(opts.Metadata) != "" { + executionSessionID := homeExecutionSessionIDFromMetadata(opts.Metadata) + count := homeAuthCountFromMetadata(opts.Metadata) + if cliproxyexecutor.DownstreamWebsocket(ctx) && executionSessionID != "" && count <= 1 { if pinnedAuthID := pinnedAuthIDFromMetadata(opts.Metadata); pinnedAuthID != "" { - if auth, executor, providerKey, ok := m.homeRuntimeAuthByID(pinnedAuthID); ok { - return auth, executor, providerKey, nil + _, alreadyTried := tried[pinnedAuthID] + if !alreadyTried { + if auth, executor, providerKey, ok := m.homeRuntimeAuthByID(executionSessionID, pinnedAuthID); ok { + return auth, executor, providerKey, nil + } } } } @@ -3277,7 +3338,6 @@ func (m *Manager) pickNextViaHome(ctx context.Context, model string, opts clipro requestedModel := requestedModelFromMetadata(opts.Metadata, model) sessionID := ExtractSessionID(opts.Headers, opts.OriginalRequest, opts.Metadata) - count := homeAuthCountFromMetadata(opts.Metadata) raw, err := client.RPopAuth(ctx, requestedModel, sessionID, opts.Headers, count) if err != nil { @@ -3350,8 +3410,8 @@ func (m *Manager) pickNextViaHome(ctx context.Context, model string, opts clipro } authCopy := auth.Clone() - if cliproxyexecutor.DownstreamWebsocket(ctx) && homeExecutionSessionIDFromMetadata(opts.Metadata) != "" && authWebsocketsEnabled(authCopy) { - m.rememberHomeRuntimeAuth(authCopy) + if cliproxyexecutor.DownstreamWebsocket(ctx) && executionSessionID != "" && authWebsocketsEnabled(authCopy) { + m.rememberHomeRuntimeAuth(executionSessionID, authCopy) } return authCopy, executor, providerKey, nil } diff --git a/sdk/cliproxy/auth/home_websocket_reuse_test.go b/sdk/cliproxy/auth/home_websocket_reuse_test.go index b3b329ee18..284dd076ff 100644 --- a/sdk/cliproxy/auth/home_websocket_reuse_test.go +++ b/sdk/cliproxy/auth/home_websocket_reuse_test.go @@ -26,7 +26,7 @@ func TestPickNextViaHomeReusesPinnedWebsocketAuthWithoutHomeDispatch(t *testing. Metadata: map[string]any{"email": "home@example.com"}, } auth.EnsureIndex() - manager.rememberHomeRuntimeAuth(auth) + manager.rememberHomeRuntimeAuth("session-1", auth) cachedAuth, ok := manager.GetByID("home-auth-1") if !ok || cachedAuth == nil || !authWebsocketsEnabled(cachedAuth) { t.Fatalf("GetByID() did not expose remembered websocket home auth: auth=%#v ok=%v", cachedAuth, ok) @@ -41,7 +41,7 @@ func TestPickNextViaHomeReusesPinnedWebsocketAuthWithoutHomeDispatch(t *testing. Headers: http.Header{"Authorization": {"Bearer client-key"}}, } - got, executor, provider, errPick := manager.pickNextViaHome(ctx, "gpt-5.4", opts) + got, executor, provider, errPick := manager.pickNextViaHome(ctx, "gpt-5.4", opts, nil) if errPick != nil { t.Fatalf("pickNextViaHome() error = %v", errPick) } @@ -56,6 +56,79 @@ func TestPickNextViaHomeReusesPinnedWebsocketAuthWithoutHomeDispatch(t *testing. } } +func TestPickNextViaHomeDoesNotReuseTriedPinnedWebsocketAuth(t *testing.T) { + manager := NewManager(nil, nil, nil) + manager.SetConfig(&internalconfig.Config{Home: internalconfig.HomeConfig{Enabled: true}}) + manager.RegisterExecutor(schedulerTestExecutor{}) + + auth := &Auth{ + ID: "home-auth-1", + Provider: "test", + Status: StatusActive, + Attributes: map[string]string{ + "websockets": "true", + }, + } + manager.rememberHomeRuntimeAuth("session-1", auth) + + ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background()) + opts := cliproxyexecutor.Options{ + Metadata: map[string]any{ + cliproxyexecutor.ExecutionSessionMetadataKey: "session-1", + cliproxyexecutor.PinnedAuthMetadataKey: "home-auth-1", + }, + } + tried := map[string]struct{}{"home-auth-1": {}} + + got, executor, provider, errPick := manager.pickNextViaHome(ctx, "gpt-5.4", opts, tried) + if errPick == nil { + t.Fatal("pickNextViaHome() error is nil, want home unavailable error") + } + var authErr *Error + if !errors.As(errPick, &authErr) || authErr.Code != "home_unavailable" { + t.Fatalf("pickNextViaHome() error = %v, want home_unavailable", errPick) + } + if got != nil || executor != nil || provider != "" { + t.Fatalf("pickNextViaHome() reused tried auth: auth=%#v executor=%#v provider=%q", got, executor, provider) + } +} + +func TestPickNextViaHomeDoesNotReusePinnedWebsocketAuthAfterFirstHomeAttempt(t *testing.T) { + manager := NewManager(nil, nil, nil) + manager.SetConfig(&internalconfig.Config{Home: internalconfig.HomeConfig{Enabled: true}}) + manager.RegisterExecutor(schedulerTestExecutor{}) + + auth := &Auth{ + ID: "home-auth-1", + Provider: "test", + Status: StatusActive, + Attributes: map[string]string{ + "websockets": "true", + }, + } + manager.rememberHomeRuntimeAuth("session-1", auth) + + ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background()) + opts := withHomeAuthCount(cliproxyexecutor.Options{ + Metadata: map[string]any{ + cliproxyexecutor.ExecutionSessionMetadataKey: "session-1", + cliproxyexecutor.PinnedAuthMetadataKey: "home-auth-1", + }, + }, 2) + + got, executor, provider, errPick := manager.pickNextViaHome(ctx, "gpt-5.4", opts, nil) + if errPick == nil { + t.Fatal("pickNextViaHome() error is nil, want home unavailable error") + } + var authErr *Error + if !errors.As(errPick, &authErr) || authErr.Code != "home_unavailable" { + t.Fatalf("pickNextViaHome() error = %v, want home_unavailable", errPick) + } + if got != nil || executor != nil || provider != "" { + t.Fatalf("pickNextViaHome() reused auth after first home attempt: auth=%#v executor=%#v provider=%q", got, executor, provider) + } +} + func TestPickNextViaHomeDoesNotReusePinnedNonWebsocketAuth(t *testing.T) { manager := NewManager(nil, nil, nil) manager.SetConfig(&internalconfig.Config{Home: internalconfig.HomeConfig{Enabled: true}}) @@ -78,7 +151,7 @@ func TestPickNextViaHomeDoesNotReusePinnedNonWebsocketAuth(t *testing.T) { Headers: http.Header{"Authorization": {"Bearer client-key"}}, } - got, executor, provider, errPick := manager.pickNextViaHome(ctx, "gpt-5.4", opts) + got, executor, provider, errPick := manager.pickNextViaHome(ctx, "gpt-5.4", opts, nil) if errPick == nil { t.Fatal("pickNextViaHome() error is nil, want home unavailable error") } @@ -94,7 +167,7 @@ func TestPickNextViaHomeDoesNotReusePinnedNonWebsocketAuth(t *testing.T) { func TestHomeRuntimeAuthsClearWhenHomeDisabled(t *testing.T) { manager := NewManager(nil, nil, nil) manager.SetConfig(&internalconfig.Config{Home: internalconfig.HomeConfig{Enabled: true}}) - manager.rememberHomeRuntimeAuth(&Auth{ + manager.rememberHomeRuntimeAuth("session-1", &Auth{ ID: "home-auth-1", Provider: "test", Attributes: map[string]string{ @@ -111,3 +184,27 @@ func TestHomeRuntimeAuthsClearWhenHomeDisabled(t *testing.T) { t.Fatal("remembered home auth was not cleared when home was disabled") } } + +func TestCloseExecutionSessionClearsHomeRuntimeAuthForSession(t *testing.T) { + manager := NewManager(nil, nil, nil) + auth := &Auth{ + ID: "home-auth-1", + Provider: "test", + Attributes: map[string]string{ + "websockets": "true", + }, + } + + manager.rememberHomeRuntimeAuth("session-1", auth) + manager.rememberHomeRuntimeAuth("session-2", auth) + + manager.CloseExecutionSession("session-1") + if _, ok := manager.GetByID("home-auth-1"); !ok { + t.Fatal("shared home auth was cleared while another session still referenced it") + } + + manager.CloseExecutionSession("session-2") + if _, ok := manager.GetByID("home-auth-1"); ok { + t.Fatal("home auth was not cleared when its last session closed") + } +} From 15ac7fb9324095330e60f522147b8a8e81f16ab5 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 10 May 2026 15:21:33 +0800 Subject: [PATCH 137/139] refactor(auth): simplify home auth session management and remove ref counting - Consolidated `homeRuntimeAuths` to store a map of session-scoped auth maps, replacing `homeRuntimeAuthSessions` and `homeRuntimeAuthRefs`. - Adjusted session cleanup logic to directly remove session-scoped auths without reference counting. - Added `GetExecutionSessionAuthByID` to retrieve auths scoped to a specific execution session. - Updated tests to reflect the new session-scoped caching behavior. --- .../openai/openai_responses_websocket.go | 19 +++- sdk/cliproxy/auth/conductor.go | 92 ++++++++----------- .../auth/home_websocket_reuse_test.go | 82 ++++++++++++++--- 3 files changed, 121 insertions(+), 72 deletions(-) diff --git a/sdk/api/handlers/openai/openai_responses_websocket.go b/sdk/api/handlers/openai/openai_responses_websocket.go index bfac492167..574338fd75 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket.go +++ b/sdk/api/handlers/openai/openai_responses_websocket.go @@ -104,6 +104,15 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) { var lastRequest []byte lastResponseOutput := []byte("[]") pinnedAuthID := "" + sessionAuthByID := func(authID string) (*coreauth.Auth, bool) { + if h == nil || h.AuthManager == nil { + return nil, false + } + if auth, ok := h.AuthManager.GetExecutionSessionAuthByID(passthroughSessionID, authID); ok { + return auth, true + } + return h.AuthManager.GetByID(authID) + } forceTranscriptReplayNextRequest := false for { @@ -130,8 +139,8 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) { appendWebsocketTimelineEvent(&wsTimelineLog, "request", payload, time.Now()) allowIncrementalInputWithPreviousResponseID := false - if pinnedAuthID != "" && h != nil && h.AuthManager != nil { - if pinnedAuth, ok := h.AuthManager.GetByID(pinnedAuthID); ok && pinnedAuth != nil { + if pinnedAuthID != "" { + if pinnedAuth, ok := sessionAuthByID(pinnedAuthID); ok && pinnedAuth != nil { allowIncrementalInputWithPreviousResponseID = websocketUpstreamSupportsIncrementalInput(pinnedAuth.Attributes, pinnedAuth.Metadata) } } else { @@ -146,8 +155,8 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) { } allowCompactionReplayBypass := false - if pinnedAuthID != "" && h != nil && h.AuthManager != nil { - if pinnedAuth, ok := h.AuthManager.GetByID(pinnedAuthID); ok && pinnedAuth != nil { + if pinnedAuthID != "" { + if pinnedAuth, ok := sessionAuthByID(pinnedAuthID); ok && pinnedAuth != nil { allowCompactionReplayBypass = responsesWebsocketAuthSupportsCompactionReplay(pinnedAuth) } } else { @@ -228,7 +237,7 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) { if authID == "" || h == nil || h.AuthManager == nil { return } - selectedAuth, ok := h.AuthManager.GetByID(authID) + selectedAuth, ok := sessionAuthByID(authID) if !ok || selectedAuth == nil { return } diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 64a28d5868..5d6a303568 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -153,9 +153,7 @@ type Manager struct { scheduler *authScheduler // homeRuntimeAuths caches auths returned by Home so websocket sessions can // reuse an established upstream credential without dispatching every turn. - homeRuntimeAuths map[string]*Auth - homeRuntimeAuthSessions map[string]map[string]struct{} - homeRuntimeAuthRefs map[string]int + homeRuntimeAuths map[string]map[string]*Auth // providerOffsets tracks per-model provider rotation state for multi-provider routing. providerOffsets map[string]int @@ -195,16 +193,14 @@ func NewManager(store Store, selector Selector, hook Hook) *Manager { hook = NoopHook{} } manager := &Manager{ - store: store, - executors: make(map[string]ProviderExecutor), - selector: selector, - hook: hook, - auths: make(map[string]*Auth), - homeRuntimeAuths: make(map[string]*Auth), - homeRuntimeAuthSessions: make(map[string]map[string]struct{}), - homeRuntimeAuthRefs: make(map[string]int), - providerOffsets: make(map[string]int), - modelPoolOffsets: make(map[string]int), + store: store, + executors: make(map[string]ProviderExecutor), + selector: selector, + hook: hook, + auths: make(map[string]*Auth), + homeRuntimeAuths: make(map[string]map[string]*Auth), + providerOffsets: make(map[string]int), + modelPoolOffsets: make(map[string]int), } // atomic.Value requires non-nil initial value. manager.runtimeConfig.Store(&internalconfig.Config{}) @@ -2724,10 +2720,24 @@ func (m *Manager) GetByID(id string) (*Auth, bool) { defer m.mu.RUnlock() auth, ok := m.auths[id] if !ok { - auth, ok = m.homeRuntimeAuths[id] - if !ok { - return nil, false - } + return nil, false + } + return auth.Clone(), true +} + +// GetExecutionSessionAuthByID retrieves a Home runtime auth scoped to an execution session. +func (m *Manager) GetExecutionSessionAuthByID(sessionID string, authID string) (*Auth, bool) { + sessionID = strings.TrimSpace(sessionID) + authID = strings.TrimSpace(authID) + if m == nil || sessionID == "" || authID == "" { + return nil, false + } + m.mu.RLock() + defer m.mu.RUnlock() + sessionAuths := m.homeRuntimeAuths[sessionID] + auth := sessionAuths[authID] + if auth == nil { + return nil, false } return auth.Clone(), true } @@ -3218,9 +3228,7 @@ func (m *Manager) clearHomeRuntimeAuthsLocked() { if m == nil { return } - m.homeRuntimeAuths = make(map[string]*Auth) - m.homeRuntimeAuthSessions = make(map[string]map[string]struct{}) - m.homeRuntimeAuthRefs = make(map[string]int) + m.homeRuntimeAuths = make(map[string]map[string]*Auth) } func (m *Manager) clearHomeRuntimeAuthsForSessionLocked(sessionID string) { @@ -3228,21 +3236,7 @@ func (m *Manager) clearHomeRuntimeAuthsForSessionLocked(sessionID string) { if m == nil || sessionID == "" { return } - authIDs := m.homeRuntimeAuthSessions[sessionID] - if len(authIDs) == 0 { - delete(m.homeRuntimeAuthSessions, sessionID) - return - } - for authID := range authIDs { - refCount := m.homeRuntimeAuthRefs[authID] - if refCount <= 1 { - delete(m.homeRuntimeAuthRefs, authID) - delete(m.homeRuntimeAuths, authID) - continue - } - m.homeRuntimeAuthRefs[authID] = refCount - 1 - } - delete(m.homeRuntimeAuthSessions, sessionID) + delete(m.homeRuntimeAuths, sessionID) } func (m *Manager) rememberHomeRuntimeAuth(sessionID string, auth *Auth) { @@ -3256,24 +3250,14 @@ func (m *Manager) rememberHomeRuntimeAuth(sessionID string, auth *Auth) { } m.mu.Lock() if m.homeRuntimeAuths == nil { - m.homeRuntimeAuths = make(map[string]*Auth) - } - if m.homeRuntimeAuthSessions == nil { - m.homeRuntimeAuthSessions = make(map[string]map[string]struct{}) + m.homeRuntimeAuths = make(map[string]map[string]*Auth) } - if m.homeRuntimeAuthRefs == nil { - m.homeRuntimeAuthRefs = make(map[string]int) - } - m.homeRuntimeAuths[authID] = auth.Clone() - sessionAuths := m.homeRuntimeAuthSessions[sessionID] + sessionAuths := m.homeRuntimeAuths[sessionID] if sessionAuths == nil { - sessionAuths = make(map[string]struct{}) - m.homeRuntimeAuthSessions[sessionID] = sessionAuths - } - if _, exists := sessionAuths[authID]; !exists { - sessionAuths[authID] = struct{}{} - m.homeRuntimeAuthRefs[authID]++ + sessionAuths = make(map[string]*Auth) + m.homeRuntimeAuths[sessionID] = sessionAuths } + sessionAuths[authID] = auth.Clone() m.mu.Unlock() } @@ -3284,12 +3268,8 @@ func (m *Manager) homeRuntimeAuthByID(sessionID string, authID string) (*Auth, P return nil, nil, "", false } m.mu.RLock() - sessionAuths := m.homeRuntimeAuthSessions[sessionID] - if _, ok := sessionAuths[authID]; !ok { - m.mu.RUnlock() - return nil, nil, "", false - } - auth := m.homeRuntimeAuths[authID] + sessionAuths := m.homeRuntimeAuths[sessionID] + auth := sessionAuths[authID] m.mu.RUnlock() if auth == nil || !authWebsocketsEnabled(auth) { return nil, nil, "", false diff --git a/sdk/cliproxy/auth/home_websocket_reuse_test.go b/sdk/cliproxy/auth/home_websocket_reuse_test.go index 284dd076ff..28d4800429 100644 --- a/sdk/cliproxy/auth/home_websocket_reuse_test.go +++ b/sdk/cliproxy/auth/home_websocket_reuse_test.go @@ -27,9 +27,9 @@ func TestPickNextViaHomeReusesPinnedWebsocketAuthWithoutHomeDispatch(t *testing. } auth.EnsureIndex() manager.rememberHomeRuntimeAuth("session-1", auth) - cachedAuth, ok := manager.GetByID("home-auth-1") + cachedAuth, ok := manager.GetExecutionSessionAuthByID("session-1", "home-auth-1") if !ok || cachedAuth == nil || !authWebsocketsEnabled(cachedAuth) { - t.Fatalf("GetByID() did not expose remembered websocket home auth: auth=%#v ok=%v", cachedAuth, ok) + t.Fatalf("GetExecutionSessionAuthByID() did not expose remembered websocket home auth: auth=%#v ok=%v", cachedAuth, ok) } ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background()) @@ -56,6 +56,61 @@ func TestPickNextViaHomeReusesPinnedWebsocketAuthWithoutHomeDispatch(t *testing. } } +func TestPickNextViaHomeKeepsSameAuthIDPayloadSessionScoped(t *testing.T) { + manager := NewManager(nil, nil, nil) + manager.SetConfig(&internalconfig.Config{Home: internalconfig.HomeConfig{Enabled: true}}) + manager.RegisterExecutor(schedulerTestExecutor{}) + + manager.rememberHomeRuntimeAuth("session-1", &Auth{ + ID: "home-auth-1", + Provider: "test", + Status: StatusActive, + Attributes: map[string]string{ + "websockets": "true", + homeUpstreamModelAttributeKey: "upstream-model-a", + }, + }) + manager.rememberHomeRuntimeAuth("session-2", &Auth{ + ID: "home-auth-1", + Provider: "test", + Status: StatusActive, + Attributes: map[string]string{ + "websockets": "true", + homeUpstreamModelAttributeKey: "upstream-model-b", + }, + }) + + ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background()) + optsSession1 := cliproxyexecutor.Options{ + Metadata: map[string]any{ + cliproxyexecutor.ExecutionSessionMetadataKey: "session-1", + cliproxyexecutor.PinnedAuthMetadataKey: "home-auth-1", + }, + } + optsSession2 := cliproxyexecutor.Options{ + Metadata: map[string]any{ + cliproxyexecutor.ExecutionSessionMetadataKey: "session-2", + cliproxyexecutor.PinnedAuthMetadataKey: "home-auth-1", + }, + } + + gotSession1, _, _, errSession1 := manager.pickNextViaHome(ctx, "gpt-5.4", optsSession1, nil) + if errSession1 != nil { + t.Fatalf("pickNextViaHome(session-1) error = %v", errSession1) + } + if got := gotSession1.Attributes[homeUpstreamModelAttributeKey]; got != "upstream-model-a" { + t.Fatalf("pickNextViaHome(session-1) upstream model = %q, want upstream-model-a", got) + } + + gotSession2, _, _, errSession2 := manager.pickNextViaHome(ctx, "gpt-5.4", optsSession2, nil) + if errSession2 != nil { + t.Fatalf("pickNextViaHome(session-2) error = %v", errSession2) + } + if got := gotSession2.Attributes[homeUpstreamModelAttributeKey]; got != "upstream-model-b" { + t.Fatalf("pickNextViaHome(session-2) upstream model = %q, want upstream-model-b", got) + } +} + func TestPickNextViaHomeDoesNotReuseTriedPinnedWebsocketAuth(t *testing.T) { manager := NewManager(nil, nil, nil) manager.SetConfig(&internalconfig.Config{Home: internalconfig.HomeConfig{Enabled: true}}) @@ -135,10 +190,12 @@ func TestPickNextViaHomeDoesNotReusePinnedNonWebsocketAuth(t *testing.T) { manager.RegisterExecutor(schedulerTestExecutor{}) manager.mu.Lock() - manager.homeRuntimeAuths["home-auth-1"] = &Auth{ - ID: "home-auth-1", - Provider: "test", - Status: StatusActive, + manager.homeRuntimeAuths["session-1"] = map[string]*Auth{ + "home-auth-1": &Auth{ + ID: "home-auth-1", + Provider: "test", + Status: StatusActive, + }, } manager.mu.Unlock() @@ -175,12 +232,12 @@ func TestHomeRuntimeAuthsClearWhenHomeDisabled(t *testing.T) { }, }) - if _, ok := manager.GetByID("home-auth-1"); !ok { + if _, ok := manager.GetExecutionSessionAuthByID("session-1", "home-auth-1"); !ok { t.Fatal("expected remembered home auth before disabling home") } manager.SetConfig(&internalconfig.Config{}) - if _, ok := manager.GetByID("home-auth-1"); ok { + if _, ok := manager.GetExecutionSessionAuthByID("session-1", "home-auth-1"); ok { t.Fatal("remembered home auth was not cleared when home was disabled") } } @@ -199,12 +256,15 @@ func TestCloseExecutionSessionClearsHomeRuntimeAuthForSession(t *testing.T) { manager.rememberHomeRuntimeAuth("session-2", auth) manager.CloseExecutionSession("session-1") - if _, ok := manager.GetByID("home-auth-1"); !ok { - t.Fatal("shared home auth was cleared while another session still referenced it") + if _, ok := manager.GetExecutionSessionAuthByID("session-1", "home-auth-1"); ok { + t.Fatal("home auth for closed session was not cleared") + } + if _, ok := manager.GetExecutionSessionAuthByID("session-2", "home-auth-1"); !ok { + t.Fatal("home auth for another session was cleared") } manager.CloseExecutionSession("session-2") - if _, ok := manager.GetByID("home-auth-1"); ok { + if _, ok := manager.GetExecutionSessionAuthByID("session-2", "home-auth-1"); ok { t.Fatal("home auth was not cleared when its last session closed") } } From 5e5b1bce3559a8e8efdf7582adbc45d58aa35e49 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 10 May 2026 15:28:49 +0800 Subject: [PATCH 138/139] feat(config): add detailed logging for home config changes - Introduced `logHomeConfigChanges` to compare old and new configs, logging detected differences. - Leveraged `diff.BuildConfigChangeDetails` for structured change detection. - Adjusted logging behavior to enable debug-level logs dynamically when required. --- sdk/cliproxy/service.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 89a480b503..8685872e0f 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -17,7 +17,9 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher" + "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff" "github.com/router-for-me/CLIProxyAPI/v7/internal/wsrelay" sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access" sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth" @@ -606,9 +608,30 @@ func (s *Service) applyHomeOverlay(remoteCfg *config.Config) { merged.Home = baseCfg.Home forceHomeRuntimeConfig(&merged) + logHomeConfigChanges(baseCfg, &merged) s.applyConfigUpdate(&merged) } +func logHomeConfigChanges(oldCfg, newCfg *config.Config) { + if oldCfg == nil || newCfg == nil || !newCfg.Home.Enabled || (!oldCfg.Debug && !newCfg.Debug) { + return + } + + details := diff.BuildConfigChangeDetails(oldCfg, newCfg) + if len(details) == 0 { + return + } + + if newCfg.Debug && !log.IsLevelEnabled(log.DebugLevel) { + util.SetLogLevel(newCfg) + } + + log.Debugf("home config changes detected:") + for _, detail := range details { + log.Debugf(" %s", detail) + } +} + func (s *Service) startHomeUsageForwarder(ctx context.Context, client *home.Client) { if s == nil || client == nil { return From 971d43b244b47eeb9a98920dea7e796a0acd91d7 Mon Sep 17 00:00:00 2001 From: hpy_ubuntu <2757652611@qq.com> Date: Mon, 11 May 2026 11:04:03 +0800 Subject: [PATCH 139/139] docs: add kiro oauth-model-alias examples to config.example.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add example configuration for mapping Kiro OAuth models to standard Claude model names (without the kiro- prefix). This enables clients to use familiar model names like 'claude-opus-4-7' instead of 'kiro-claude-opus-4-7'. Without this alias configuration, Kiro models are only accessible via their kiro-prefixed names (e.g. kiro-claude-opus-4-7), which may not be recognized by clients expecting standard Anthropic model names. Example mappings added: - kiro-claude-opus-4-7 → claude-opus-4-7 - kiro-claude-opus-4-7-agentic → claude-opus-4-7-agentic - kiro-claude-sonnet-4-6 → claude-sonnet-4-6 - kiro-claude-sonnet-4-5 → claude-sonnet-4-5 --- config.example.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/config.example.yaml b/config.example.yaml index 886d775a5d..53efec44ee 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -359,6 +359,19 @@ nonstream-keepalive-interval: 0 # codex: # - name: "gpt-5" # alias: "g5" +# kiro: +# - name: "kiro-claude-opus-4-7" +# alias: "claude-opus-4-7" +# fork: true +# - name: "kiro-claude-opus-4-7-agentic" +# alias: "claude-opus-4-7-agentic" +# fork: true +# - name: "kiro-claude-sonnet-4-6" +# alias: "claude-sonnet-4-6" +# fork: true +# - name: "kiro-claude-sonnet-4-5" +# alias: "claude-sonnet-4-5" +# fork: true # kimi: # - name: "kimi-k2.5" # alias: "k2.5"

PackyCodePackyCodeのスポンサーシップに感謝します!PackyCodeは信頼性が高く効率的なAPIリレーサービスプロバイダーで、Claude Code、Codex、Geminiなどのリレーサービスを提供しています。PackyCodeは当ソフトウェアのユーザーに特別割引を提供しています:こちらのリンクから登録し、チャージ時にプロモーションコード「cliproxyapi」を入力すると10%割引になります。
AICodeMirror AICodeMirrorのスポンサーシップに感謝します!AICodeMirrorはClaude Code / Codex / Gemini CLI向けの公式高安定性リレーサービスを提供しており、エンタープライズグレードの同時接続、迅速な請求書発行、24時間365日の専任技術サポートを備えています。Claude Code / Codex / Geminiの公式チャネルが元の価格の38% / 2% / 9%で利用でき、チャージ時にはさらに割引があります!CLIProxyAPIユーザー向けの特別特典:こちらのリンクから登録すると、初回チャージが20%割引になり、エンタープライズのお客様は最大25%割引を受けられます!
本プロジェクトにご支援いただいた BmoPlus に感謝いたします!BmoPlusは、AIサブスクリプションのヘビーユーザー向けに特化した信頼性の高いAIアカウントサービスプロバイダーであり、安定した ChatGPT Plus / ChatGPT Pro (完全保証) / Claude Pro / Super Grok / Gemini Pro の公式代行チャージおよび即納アカウントを提供しています。こちらのBmoPlus AIアカウント専門店/代行チャージ経由でご登録・ご注文いただいたユーザー様は、GPTを 公式サイト価格の約1割(90% OFF) という驚異的な価格でご利用いただけます!
PoixeAIPoixe AIのスポンサーシップに感謝します!Poixe AIは信頼できるAIモデルAPIサービスを提供しており、プラットフォームが提供するLLM APIを使って簡単にAI製品を構築できます。また、サプライヤーとしてプラットフォームに大規模モデルのリソースを提供し、収益を得ることも可能です。CLIProxyAPIの専用リンクから登録すると、チャージ時に追加で$5が付与されます。
VisionCoder VisionCoderのご支援に感謝します!VisionCoder 開発プラットフォーム は、信頼性が高く効率的なAPIリレーサービスプロバイダーで、Claude Code、Codex、Geminiなどの主要AIモデルを提供し、開発者やチームがより簡単にAI機能を統合して生産性を向上できるよう支援します。さらに、VisionCoderはユーザー向けに Token Plan の期間限定キャンペーン(1か月購入で1か月分プレゼント)も提供しています。