From c405398a48df32c7a8ceb831124300d9b52c6c5d Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:07:57 -0400 Subject: [PATCH 01/31] fix: align Claude headers with upstream streaming --- .../executor/claude_executor_execute.go | 16 +-- .../executor/claude_executor_request.go | 6 +- .../runtime/executor/claude_executor_test.go | 99 +++++++++++++++++++ 3 files changed, 112 insertions(+), 9 deletions(-) diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go index da100536..0255a03a 100644 --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -32,15 +32,16 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r from := opts.SourceFormat responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) to := sdktranslator.FromString("claude") - // Use streaming translation to preserve function calling, except for claude. - stream := from != to + // Use an upstream stream whenever the downstream response needs translation + // from Claude events. Native Claude responses use the JSON response path. + upstreamStream := responseFormat != to originalPayloadSource := req.Payload if len(opts.OriginalRequest) > 0 { originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, stream) - body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream) + originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, upstreamStream) + body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, upstreamStream) body = helps.SetStringIfDifferent(body, "model", upstreamModel) body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) @@ -83,6 +84,9 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r // Normalize TTL values to prevent ordering violations under prompt-caching-scope-2026-01-05. // A 1h-TTL block must not appear after a 5m-TTL block in evaluation order (tools→system→messages). body = normalizeCacheControlTTL(body) + // Payload rules and other request processing may rewrite stream. Keep the + // upstream body, transport headers, and response parser on one authority. + body = helps.SetBoolIfDifferent(body, "stream", upstreamStream) // Extract betas from body and convert to header var extraBetas []string @@ -107,7 +111,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if err != nil { return resp, err } - if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, false, extraBetas, e.cfg, opts.Headers); errHeaders != nil { + if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, upstreamStream, extraBetas, e.cfg, opts.Headers); errHeaders != nil { return resp, errHeaders } var authID, authLabel, authType, authValue string @@ -181,7 +185,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r return resp, err } helps.AppendAPIResponseChunk(ctx, e.cfg, data) - if stream { + if upstreamStream { if errValidate := validateClaudeStreamingResponse(data); errValidate != nil { helps.RecordAPIResponseError(ctx, e.cfg, errValidate) return resp, errValidate diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go index 847132e9..86874440 100644 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -344,10 +344,10 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, attrs = auth.Attributes } util.ApplyCustomHeadersFromAttrs(r, attrs) - // Re-enforce Accept-Encoding: identity after ApplyCustomHeadersFromAttrs, which - // may override it with a user-configured value. Compressed SSE breaks the line - // scanner regardless of user preference, so this is non-negotiable for streams. + // Re-enforce the SSE transport contract after custom headers. A custom Accept + // value can disable event negotiation, while compressed SSE breaks line parsing. if stream { + r.Header.Set("Accept", "text/event-stream") r.Header.Set("Accept-Encoding", "identity") } return nil diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 9f6e9013..3e2946f7 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -1622,6 +1622,105 @@ func TestClaudeExecutor_ExecuteOpenAINonStreamConvertsValidClaudeStream(t *testi } } +func TestClaudeExecutor_ExecuteTransportMatchesResponseFormat(t *testing.T) { + const model = "claude-3-5-sonnet-20241022" + streamResponse := 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") + jsonResponse := `{"id":"msg_123","type":"message","role":"assistant","model":"claude-3-5-sonnet-20241022","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":2,"output_tokens":1}}` + + tests := []struct { + name string + sourceFormat sdktranslator.Format + responseFormat sdktranslator.Format + wantStream bool + }{ + {name: "OpenAI to OpenAI uses SSE", sourceFormat: sdktranslator.FormatOpenAI, responseFormat: sdktranslator.FormatOpenAI, wantStream: true}, + {name: "OpenAI to Claude uses JSON", sourceFormat: sdktranslator.FormatOpenAI, responseFormat: sdktranslator.FormatClaude, wantStream: false}, + {name: "Claude to OpenAI uses SSE", sourceFormat: sdktranslator.FormatClaude, responseFormat: sdktranslator.FormatOpenAI, wantStream: true}, + {name: "Claude to Claude uses JSON", sourceFormat: sdktranslator.FormatClaude, responseFormat: sdktranslator.FormatClaude, wantStream: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var seenBody []byte + var seenHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenBody, _ = io.ReadAll(r.Body) + seenHeaders = r.Header.Clone() + if tt.wantStream { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(streamResponse)) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(jsonResponse)) + })) + defer server.Close() + + executor := NewClaudeExecutor(&config.Config{ + Payload: config.PayloadConfig{ + Override: []config.PayloadRule{{ + Models: []config.PayloadModelRule{{Name: model, Protocol: "claude"}}, + Params: map[string]any{"stream": !tt.wantStream}, + }}, + }, + }) + attributes := map[string]string{ + "api_key": "key-123", + "base_url": server.URL, + } + if tt.wantStream { + attributes["header:Accept"] = "application/json" + attributes["header:Accept-Encoding"] = "gzip, deflate, br, zstd" + } + auth := &cliproxyauth.Auth{Attributes: attributes} + payload := []byte(`{"model":"claude-3-5-sonnet-20241022","stream":false,"messages":[{"role":"user","content":"hi"}]}`) + + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: model, + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: tt.sourceFormat, + ResponseFormat: tt.responseFormat, + Headers: http.Header{ + "Anthropic-Beta": []string{"client-beta"}, + }, + }) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + stream := gjson.GetBytes(seenBody, "stream") + if !stream.Exists() || stream.Bool() != tt.wantStream { + t.Fatalf("upstream stream = %s, want %t; body=%s", stream.Raw, tt.wantStream, string(seenBody)) + } + wantAccept := "application/json" + wantEncoding := "gzip, deflate, br, zstd" + if tt.wantStream { + wantAccept = "text/event-stream" + wantEncoding = "identity" + } + if got := seenHeaders.Get("Accept"); got != wantAccept { + t.Fatalf("Accept = %q, want %q", got, wantAccept) + } + if got := seenHeaders.Get("Accept-Encoding"); got != wantEncoding { + t.Fatalf("Accept-Encoding = %q, want %q", got, wantEncoding) + } + if got := seenHeaders.Get("Anthropic-Beta"); !strings.Contains(got, "client-beta") { + t.Fatalf("Anthropic-Beta = %q, want client beta preserved", got) + } + }) + } +} + func executeOpenAIChatCompletionThroughClaude(t *testing.T, upstreamBody string) (cliproxyexecutor.Response, error) { t.Helper() -- 2.51.2 From 20784c67ff520fbc4f88116582f17d9f39939246 Mon Sep 17 00:00:00 2001 From: sususu Date: Tue, 28 Jul 2026 23:20:56 +0800 Subject: [PATCH 02/31] fix(home): use Home's CAS command instead of EVAL for replay compare-and-swap Client.KVCompareAndSwap sent Redis EVAL with a Lua script, but the Home RESP subset does not implement EVAL, so Home replied "ERR unknown command 'eval'". That broke the Antigravity and Codex reasoning replay caches in Home mode. Switch the transport to Home's dedicated CAS command: CAS [PX ] The semantics match the old script argument for argument, so KVCompareAndSwap's signature and all its callers are unchanged. Omitting PX when ttl <= 0 mirrors the script's SET-without-PX branch, which clears the TTL. Deployments that predate CAS reject the command. Detect that by matching the unsupported-command error, latch ErrCompareAndSwapUnsupported for the client lifetime so later calls skip the round trip, and warn exactly once. The latch is deliberately not carried across NewLifetime, so a Home upgrade takes effect on the next reconnect rather than requiring a CPA restart. Also stop replay-state failures from failing the request. A bare replay error has no HTTP status, so resultErrorFromError does not classify it as request-scoped and MarkResult marks the credential unavailable for that model, walking every candidate credential until alias resolution has nothing left and returns 503. A ledger miss is already a tolerated outcome, so degrade to "no replay this turn" instead. The pairing failure still returns its 400. Verified end to end against a real Home over RESP/mTLS on PostgreSQL with Antigravity OAuth credentials: patched Home recreates the replay row through CAS with its TTL, while a pre-CAS Home latches once, keeps returning 200 instead of 503, and records no credential error attributable to the replay path. Refs router-for-me/CLIProxyAPIHome#79 --- ...antigravity_reasoning_replay_cache_test.go | 30 ++++++++ internal/home/client.go | 72 +++++++++++++------ internal/home/client_test.go | 70 ++++++++++++++++-- .../executor/antigravity_executor_execute.go | 8 +-- .../executor/antigravity_executor_stream.go | 4 +- .../executor/antigravity_reasoning_replay.go | 31 +++++++- .../antigravity_reasoning_replay_test.go | 24 +++++++ 7 files changed, 207 insertions(+), 32 deletions(-) diff --git a/internal/cache/antigravity_reasoning_replay_cache_test.go b/internal/cache/antigravity_reasoning_replay_cache_test.go index 354aee8b..114ca2da 100644 --- a/internal/cache/antigravity_reasoning_replay_cache_test.go +++ b/internal/cache/antigravity_reasoning_replay_cache_test.go @@ -17,6 +17,7 @@ type fakeAntigravityReasoningReplayKVClient struct { mu sync.Mutex values map[string][]byte expireCount int + casErr error } func newFakeAntigravityReasoningReplayKVClient() *fakeAntigravityReasoningReplayKVClient { @@ -40,6 +41,9 @@ func (c *fakeAntigravityReasoningReplayKVClient) KVSet(_ context.Context, key st func (c *fakeAntigravityReasoningReplayKVClient) KVCompareAndSwap(_ context.Context, key string, expected []byte, expectedExists bool, value []byte, _ time.Duration) (bool, error) { c.mu.Lock() defer c.mu.Unlock() + if c.casErr != nil { + return false, c.casErr + } current, exists := c.values[key] if exists != expectedExists || (exists && !bytes.Equal(current, expected)) { return false, nil @@ -390,6 +394,32 @@ func TestAntigravityReasoningReplayHomeGenerationRejectsSuccessfulValueABA(t *te } } +func TestAntigravityReasoningReplayHomeReportsCASErrors(t *testing.T) { + // The cache layer keeps reporting CAS failures honestly. Deciding that a + // replay failure must not fail the request is the executor's job, so this + // layer must not start swallowing errors. + client := newFakeAntigravityReasoningReplayKVClient() + client.casErr = fmt.Errorf("ERR unknown command 'cas'") + useFakeAntigravityReasoningReplayKVClient(t, client, true) + const model, session = "gemini-3.6-flash-high", "home-cas-error" + + _, _, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session) + if errGet == nil { + t.Fatal("GetAntigravityReasoningReplayItemsWithSnapshotRequired() error = nil, want the CAS error") + } + if found { + t.Fatal("GetAntigravityReasoningReplayItemsWithSnapshotRequired() found = true, want false") + } + + snapshot := AntigravityReasoningReplaySnapshot{loaded: true} + if _, errReplace := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, snapshot, [][]byte{antigravityReplayTestItem("home-cas-error-sig-1")}); errReplace == nil { + t.Fatal("ReplaceAntigravityReasoningReplayItemsIfUnchanged() error = nil, want the CAS error") + } + if _, errDelete := DeleteAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, snapshot); errDelete == nil { + t.Fatal("DeleteAntigravityReasoningReplayItemsIfUnchanged() error = nil, want the CAS error") + } +} + func TestAntigravityReasoningReplayHomeCASRetryRejectsOversizedValue(t *testing.T) { client := newFakeAntigravityReasoningReplayKVClient() useFakeAntigravityReasoningReplayKVClient(t, client, true) diff --git a/internal/home/client.go b/internal/home/client.go index 92b8cac5..f4a295c6 100644 --- a/internal/home/client.go +++ b/internal/home/client.go @@ -94,8 +94,24 @@ var ( ErrModelsNotFound = errors.New("home models not found") ErrPluginSyncUnsupported = errors.New("home plugin sync is unsupported") ErrDispatchFenced = errors.New("home auth dispatch is fenced") + // ErrCompareAndSwapUnsupported reports that this Home predates the CAS command. + ErrCompareAndSwapUnsupported = errors.New("home compare-and-swap is unsupported") ) +// isHomeCommandUnsupported reports whether Home rejected a command it does not +// implement. It mirrors isHomeAppLogUnsupported in internal/logging; the two are +// kept separate so the packages stay decoupled. +func isHomeCommandUnsupported(err error) bool { + for err != nil { + message := strings.ToLower(strings.TrimSpace(err.Error())) + if strings.Contains(message, "unknown command") || strings.Contains(message, "unsupported command") { + return true + } + err = errors.Unwrap(err) + } + return false +} + // IsMembershipTakeoverUnavailableError reports whether Home cannot preserve the previous membership state. func IsMembershipTakeoverUnavailableError(err error) bool { if err == nil { @@ -177,6 +193,13 @@ type Client struct { heartbeatOK atomic.Bool dispatchFenced atomic.Bool ambiguousDispatch atomic.Bool + // casUnsupported latches when Home does not implement the CAS command. + // It is deliberately NOT carried across NewLifetime: CAS support is a + // property of the Home deployment, so re-probing once per client lifetime + // lets a Home upgrade take effect on the next reconnect instead of + // requiring a CPA restart. The probe costs one round trip that returns an + // error without performing any write. + casUnsupported atomic.Bool recoveryState atomic.Uint32 instanceID string legacyMembership bool @@ -1033,35 +1056,44 @@ func (c *Client) KVSetNX(ctx context.Context, key string, value []byte, ttl time } // KVCompareAndSwap atomically replaces a value only when its current state matches the expected state. +// +// It uses Home's dedicated CAS command: +// +// CAS [PX ] +// +// Omitting PX stores the value without a TTL. Home replies integer 1 when the +// swap happened and integer 0 when the state did not match. Deployments that +// predate CAS reject the command, which latches ErrCompareAndSwapUnsupported for +// this client lifetime so later calls skip the round trip. func (c *Client) KVCompareAndSwap(ctx context.Context, key string, expected []byte, expectedExists bool, value []byte, ttl time.Duration) (bool, error) { + if c == nil { + return false, ErrNotConnected + } + if c.casUnsupported.Load() { + return false, ErrCompareAndSwapUnsupported + } cmd, errClient := c.commandClient() if errClient != nil { return false, errClient } - const script = ` -local current = redis.call("GET", KEYS[1]) -if ARGV[1] == "1" then - if not current or current ~= ARGV[2] then - return 0 - end -elseif current then - return 0 -end -local ttl = tonumber(ARGV[4]) -if ttl and ttl > 0 then - redis.call("SET", KEYS[1], ARGV[3], "PX", ttl) -else - redis.call("SET", KEYS[1], ARGV[3]) -end -return 1 -` expectedFlag := "0" if expectedExists { expectedFlag = "1" } - result, errEval := cmd.Eval(ctx, script, []string{key}, expectedFlag, expected, value, durationCeil(ttl, time.Millisecond)).Int64() - if errEval != nil { - return false, errEval + args := make([]any, 0, 7) + args = append(args, "CAS", key, expectedFlag, expected, value) + if milliseconds := durationCeil(ttl, time.Millisecond); milliseconds > 0 { + args = append(args, "PX", milliseconds) + } + result, errCAS := cmd.Do(ctx, args...).Int64() + if errCAS != nil { + if isHomeCommandUnsupported(errCAS) { + if c.casUnsupported.CompareAndSwap(false, true) { + log.Warnf("home kv: this Home does not implement the CAS command; Antigravity and Codex reasoning replay are disabled until Home is upgraded") + } + return false, ErrCompareAndSwapUnsupported + } + return false, errCAS } return result == 1, nil } diff --git a/internal/home/client_test.go b/internal/home/client_test.go index 9d0bc69a..66f87d75 100644 --- a/internal/home/client_test.go +++ b/internal/home/client_test.go @@ -534,9 +534,9 @@ func TestKVSetConditionUnmetReturnsFalse(t *testing.T) { } } -func TestKVCompareAndSwapReturnsScriptResult(t *testing.T) { +func TestKVCompareAndSwapSendsCASCommand(t *testing.T) { client, commands := newRedisCommandTestClient(t, func(args []string) string { - if len(args) > 0 && strings.EqualFold(args[0], "EVAL") { + if len(args) > 0 && strings.EqualFold(args[0], "CAS") { return ":1\r\n" } return "-ERR unexpected command\r\n" @@ -549,8 +549,70 @@ func TestKVCompareAndSwapReturnsScriptResult(t *testing.T) { if !swapped { t.Fatal("KVCompareAndSwap() swapped = false, want true") } - if lastCommand := commands.Last(); len(lastCommand) < 2 || !strings.EqualFold(lastCommand[0], "EVAL") { - t.Fatalf("last command = %#v, want EVAL", lastCommand) + want := []string{"CAS", "key", "1", "old", "new", "PX", "1500"} + if lastCommand := commands.Last(); !reflect.DeepEqual(lastCommand, want) { + t.Fatalf("last command = %#v, want %#v", lastCommand, want) + } +} + +func TestKVCompareAndSwapOmitsPXWithoutTTL(t *testing.T) { + client, commands := newRedisCommandTestClient(t, func(args []string) string { + if len(args) > 0 && strings.EqualFold(args[0], "CAS") { + return ":1\r\n" + } + return "-ERR unexpected command\r\n" + }) + + if _, errCAS := client.KVCompareAndSwap(context.Background(), "key", nil, false, []byte("new"), 0); errCAS != nil { + t.Fatalf("KVCompareAndSwap() error = %v", errCAS) + } + // An absent expected value is sent as an empty bulk string, and no TTL means + // no PX, which tells Home to store the value without an expiry. + want := []string{"CAS", "key", "0", "", "new"} + if lastCommand := commands.Last(); !reflect.DeepEqual(lastCommand, want) { + t.Fatalf("last command = %#v, want %#v", lastCommand, want) + } +} + +func TestKVCompareAndSwapReportsMismatch(t *testing.T) { + client, _ := newRedisCommandTestClient(t, func(args []string) string { + if len(args) > 0 && strings.EqualFold(args[0], "CAS") { + return ":0\r\n" + } + return "-ERR unexpected command\r\n" + }) + + swapped, errCAS := client.KVCompareAndSwap(context.Background(), "key", []byte("old"), true, []byte("new"), time.Minute) + if errCAS != nil { + t.Fatalf("KVCompareAndSwap() error = %v", errCAS) + } + if swapped { + t.Fatal("KVCompareAndSwap() swapped = true, want false") + } +} + +func TestKVCompareAndSwapLatchesUnsupportedHome(t *testing.T) { + client, commands := newRedisCommandTestClient(t, func(args []string) string { + if len(args) > 0 && strings.EqualFold(args[0], "CAS") { + return "-ERR unknown command 'cas'\r\n" + } + return "-ERR unexpected command\r\n" + }) + + _, errFirst := client.KVCompareAndSwap(context.Background(), "key", nil, false, []byte("new"), time.Minute) + if !errors.Is(errFirst, ErrCompareAndSwapUnsupported) { + t.Fatalf("KVCompareAndSwap() first error = %v, want ErrCompareAndSwapUnsupported", errFirst) + } + if sent := commands.CountCommandKey("CAS", "key"); sent != 1 { + t.Fatalf("CAS sent %d times, want 1", sent) + } + + _, errSecond := client.KVCompareAndSwap(context.Background(), "key", nil, false, []byte("new"), time.Minute) + if !errors.Is(errSecond, ErrCompareAndSwapUnsupported) { + t.Fatalf("KVCompareAndSwap() second error = %v, want ErrCompareAndSwapUnsupported", errSecond) + } + if sent := commands.CountCommandKey("CAS", "key"); sent != 1 { + t.Fatalf("CAS sent %d times after latching, want 1", sent) } } diff --git a/internal/runtime/executor/antigravity_executor_execute.go b/internal/runtime/executor/antigravity_executor_execute.go index 415914ae..471c9dc3 100644 --- a/internal/runtime/executor/antigravity_executor_execute.go +++ b/internal/runtime/executor/antigravity_executor_execute.go @@ -217,8 +217,8 @@ attemptLoop: } } if errClear := clearAntigravityReasoningReplayOnInvalidSignature(ctx, replayScope, httpResp.StatusCode, bodyBytes); errClear != nil { - err = errClear - return resp, err + // Report the upstream failure rather than the cleanup failure. + logAntigravityReasoningReplayDegraded(replayScope, "invalidate", errClear) } err = newAntigravityStatusErr(httpResp.StatusCode, bodyBytes) return resp, err @@ -452,8 +452,8 @@ attemptLoop: } } if errClear := clearAntigravityReasoningReplayOnInvalidSignature(ctx, replayScope, httpResp.StatusCode, bodyBytes); errClear != nil { - err = errClear - return resp, err + // Report the upstream failure rather than the cleanup failure. + logAntigravityReasoningReplayDegraded(replayScope, "invalidate", errClear) } err = newAntigravityStatusErr(httpResp.StatusCode, bodyBytes) return resp, err diff --git a/internal/runtime/executor/antigravity_executor_stream.go b/internal/runtime/executor/antigravity_executor_stream.go index 4990946b..b90fc84f 100644 --- a/internal/runtime/executor/antigravity_executor_stream.go +++ b/internal/runtime/executor/antigravity_executor_stream.go @@ -225,8 +225,8 @@ attemptLoop: } } if errClear := clearAntigravityReasoningReplayOnInvalidSignature(ctx, replayScope, httpResp.StatusCode, bodyBytes); errClear != nil { - err = errClear - return nil, err + // Report the upstream failure rather than the cleanup failure. + logAntigravityReasoningReplayDegraded(replayScope, "invalidate", errClear) } err = newAntigravityStatusErr(httpResp.StatusCode, bodyBytes) return nil, err diff --git a/internal/runtime/executor/antigravity_reasoning_replay.go b/internal/runtime/executor/antigravity_reasoning_replay.go index 7c8e93a0..51733113 100644 --- a/internal/runtime/executor/antigravity_reasoning_replay.go +++ b/internal/runtime/executor/antigravity_reasoning_replay.go @@ -5,12 +5,14 @@ import ( "context" "crypto/sha256" "encoding/json" + "errors" "fmt" "net/http" "reflect" "strings" internalcache "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" + homekv "github.com/router-for-me/CLIProxyAPI/v7/internal/home" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" internalsignature "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" @@ -230,13 +232,36 @@ func antigravityReasoningReplayResolveContentIndex(payload []byte, cached int) i return -1 } +// logAntigravityReasoningReplayDegraded reports that a replay-state operation +// failed and the request continued without it. A Home that predates the CAS +// command fails every call, and the Home client already warns once about that, +// so those are logged at debug level to avoid one warning per request. +func logAntigravityReasoningReplayDegraded(scope antigravityReasoningReplayScope, stage string, err error) { + if err == nil { + return + } + if errors.Is(err, homekv.ErrCompareAndSwapUnsupported) { + log.Debugf("antigravity executor: reasoning replay %s unavailable on this Home (session=%s): %v", + stage, antigravityReplayLogKey(scope.sessionKey), err) + return + } + log.Warnf("antigravity executor: reasoning replay %s failed; continuing without replay (session=%s): %v", + stage, antigravityReplayLogKey(scope.sessionKey), err) +} + func prepareAntigravityGeminiReasoningReplayPayload(ctx context.Context, modelName string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, payload []byte) ([]byte, antigravityReasoningReplayScope, error) { if !antigravityUsesReasoningReplayCache(modelName) { return payload, antigravityReasoningReplayScope{}, nil } updated, scope, replayApplied, errReplay := applyAntigravityReasoningReplayCache(ctx, modelName, req, opts, payload) if errReplay != nil { - return payload, scope, errReplay + // Replay state is an optimization, not a correctness requirement: a ledger + // miss is already a tolerated outcome below. Failing the request here would + // surface as an untyped executor error, which MarkResult treats as a + // credential fault and uses to mark every candidate credential unavailable. + // Degrade to "no replay this turn" instead. + logAntigravityReasoningReplayDegraded(scope, "read", errReplay) + updated = payload } updated = normalizeAntigravityGeminiFunctionResponseRoles(updated) if antigravityPayloadHasClaudeToolProvenanceID(updated) { @@ -255,7 +280,9 @@ func prepareAntigravityGeminiReasoningReplayPayload(ctx context.Context, modelNa originalPairingValid := internalsignature.ValidateGeminiFunctionCallPairing(payload) == nil if replayApplied && originalPairingValid && scope.valid() { if _, errDelete := internalcache.DeleteAntigravityReasoningReplayItemsIfUnchanged(ctx, scope.modelName, scope.sessionKey, scope.cacheSnapshot); errDelete != nil { - return payload, scope, errDelete + // Invalidation is best-effort cleanup. Returning it here would replace + // the pairing diagnosis below with an untyped error. + logAntigravityReasoningReplayDegraded(scope, "invalidate", errDelete) } } return payload, scope, statusErr{code: http.StatusBadRequest, msg: fmt.Sprintf("antigravity executor: invalid Gemini function call history: %v", errPairing)} diff --git a/internal/runtime/executor/antigravity_reasoning_replay_test.go b/internal/runtime/executor/antigravity_reasoning_replay_test.go index bfa508eb..7ab99fce 100644 --- a/internal/runtime/executor/antigravity_reasoning_replay_test.go +++ b/internal/runtime/executor/antigravity_reasoning_replay_test.go @@ -8,6 +8,8 @@ import ( "testing" internalcache "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + homekv "github.com/router-for-me/CLIProxyAPI/v7/internal/home" internalsignature "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" @@ -49,6 +51,28 @@ func TestAntigravityReasoningReplayAccumulatorMultiToolSSEChunks(t *testing.T) { } } +func TestPrepareAntigravityGeminiReasoningReplayPayloadToleratesHomeKVFailure(t *testing.T) { + // An enabled Home client with no heartbeat makes CurrentKVClient report home + // mode with an error, which is how every Home-side KV failure reaches the + // replay cache — including the "unknown command 'cas'" case from an older + // Home. The request must proceed without replay rather than fail, because a + // bare executor error would make MarkResult mark the credential unavailable. + homekv.SetCurrent(homekv.New(config.HomeConfig{Enabled: true})) + t.Cleanup(func() { homekv.SetCurrent(nil) }) + + payload := []byte(`{"sessionId":"kv-failure","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`) + out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3-flash-agent", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload) + if errPrepare != nil { + t.Fatalf("prepare error = %v, want nil so the request proceeds without replay", errPrepare) + } + if len(out) == 0 { + t.Fatal("prepare returned an empty payload") + } + if got := gjson.GetBytes(out, "sessionId").String(); got != "kv-failure" { + t.Fatalf("payload sessionId = %q, want kv-failure", got) + } +} + func TestPrepareAntigravityGeminiReasoningReplayPayloadRejectsToolOutputsAcrossUserBoundary(t *testing.T) { payload := []byte(`{"sessionId":"tool-output-boundary","request":{"contents":[{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"run","args":{}}},{"functionCall":{"id":"call-2","name":"run","args":{}}}]},{"role":"model","parts":[{"functionResponse":{"id":"call-1","name":"run","response":{"result":"one"}}}]},{"role":"user","parts":[{"text":"boundary"}]},{"role":"model","parts":[{"functionResponse":{"id":"call-2","name":"run","response":{"result":"two"}}}]}]}}`) _, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload) -- 2.51.2 From f32291436ad9fbbd42b4875e3a4ee63eb1f9191b Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 29 Jul 2026 14:14:00 +0800 Subject: [PATCH 03/31] refactor(executor): consolidate `thinking.ApplyThinking` into `helps.ApplyRequestThinking` - Replaced instances of `thinking.ApplyThinking` with `helps.ApplyRequestThinking` across all executors for consistency. - Updated `applyGeminiInteractionsThinking` to accept `cliproxyexecutor.Request` and `Options`. - Centralized logic for request thinking application to `helps` package for improved maintainability. Closes: #4618 --- config.example.yaml | 12 + internal/config/config_types.go | 29 ++ internal/config/vertex_compat.go | 18 +- internal/modelconfig/model_hash.go | 125 ++++++++ internal/modelconfig/model_info.go | 55 ++++ internal/modelconfig/model_info_test.go | 61 ++++ .../executor/claude_executor_execute.go | 2 +- .../executor/claude_executor_stream.go | 2 +- .../executor/claude_executor_tokens.go | 10 + .../executor/codex_executor_execute.go | 4 +- .../runtime/executor/codex_executor_stream.go | 2 +- .../runtime/executor/codex_executor_tokens.go | 2 +- .../executor/codex_websockets_execute.go | 2 +- .../executor/codex_websockets_stream.go | 2 +- internal/runtime/executor/gemini_executor.go | 18 +- .../executor/gemini_vertex_executor.go | 12 +- .../executor/helps/model_capabilities.go | 20 ++ .../executor/helps/model_capabilities_test.go | 149 ++++++++++ .../executor/openai_compat_executor.go | 6 +- .../runtime/executor/xai_executor_request.go | 2 +- internal/thinking/apply.go | 71 ++++- .../thinking/apply_configured_api_key_test.go | 110 +++++++ internal/watcher/diff/model_hash.go | 68 +---- internal/watcher/diff/model_hash_test.go | 76 ++++- internal/watcher/diff/models_summary.go | 8 +- internal/watcher/synthesizer/config.go | 18 +- internal/watcher/synthesizer/config_test.go | 6 + .../auth/api_key_model_capabilities.go | 217 ++++++++++++++ .../auth/api_key_model_capabilities_test.go | 253 ++++++++++++++++ sdk/cliproxy/auth/classification.go | 1 + sdk/cliproxy/auth/conductor.go | 7 +- sdk/cliproxy/auth/conductor_cooldown.go | 2 + sdk/cliproxy/auth/conductor_execution.go | 20 +- sdk/cliproxy/auth/conductor_home.go | 9 +- sdk/cliproxy/auth/conductor_home_execution.go | 8 +- sdk/cliproxy/auth/conductor_models.go | 269 +++++++++++------- sdk/cliproxy/auth/conductor_stream.go | 8 +- sdk/cliproxy/auth/oauth_model_alias.go | 68 ++--- sdk/cliproxy/auth/oauth_model_alias_test.go | 16 ++ sdk/cliproxy/auth/openai_compat_pool_test.go | 36 +++ sdk/cliproxy/service_executors.go | 26 +- sdk/cliproxy/service_models.go | 90 ++++-- .../service_models_config_index_test.go | 41 +++ 43 files changed, 1664 insertions(+), 297 deletions(-) create mode 100644 internal/modelconfig/model_hash.go create mode 100644 internal/modelconfig/model_info.go create mode 100644 internal/modelconfig/model_info_test.go create mode 100644 internal/runtime/executor/helps/model_capabilities.go create mode 100644 internal/runtime/executor/helps/model_capabilities_test.go create mode 100644 internal/thinking/apply_configured_api_key_test.go create mode 100644 sdk/cliproxy/auth/api_key_model_capabilities.go create mode 100644 sdk/cliproxy/auth/api_key_model_capabilities_test.go create mode 100644 sdk/cliproxy/service_models_config_index_test.go diff --git a/config.example.yaml b/config.example.yaml index fedefab3..3a44af69 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -293,6 +293,8 @@ nonstream-keepalive-interval: 0 # - name: "gemini-2.5-flash" # upstream model name # alias: "gemini-flash" # client alias mapped to the upstream model # display-name: "Gemini Flash" # optional catalog display name +# thinking: # optional: exact thinking capability for this configured model +# levels: ["high", "medium", "low", "none", "auto"] # excluded-models: # - "gemini-2.5-pro" # exclude specific models from this provider (exact match) # - "gemini-2.5-*" # wildcard matching prefix (e.g. gemini-2.5-flash, gemini-2.5-pro) @@ -316,6 +318,8 @@ nonstream-keepalive-interval: 0 # models: # - name: "gemini-2.5-flash" # upstream model name # alias: "native-gemini-flash" # client alias mapped to the upstream model +# thinking: # optional: exact thinking capability for this configured model +# levels: ["high", "medium", "low", "none", "auto"] # excluded-models: # - "gemini-2.5-pro" @@ -335,6 +339,8 @@ nonstream-keepalive-interval: 0 # alias: "codex-latest" # client alias mapped to the upstream model # display-name: "Codex Latest" # optional catalog display name # force-mapping: true # optional: rewrite response model fields back to the alias +# thinking: # optional: exact thinking capability for this configured model +# levels: ["xhigh", "high", "medium", "low"] # excluded-models: # - "gpt-5.1" # exclude specific models (exact match) # - "gpt-5-*" # wildcard matching prefix (e.g. gpt-5-medium, gpt-5-codex) @@ -359,6 +365,8 @@ nonstream-keepalive-interval: 0 # alias: "grok-latest" # client alias mapped to the upstream model # display-name: "Grok Latest" # optional catalog display name # force-mapping: true # optional: rewrite response model fields back to the alias +# thinking: # optional: exact thinking capability for this configured model +# levels: ["xhigh", "high", "medium", "low"] # excluded-models: # - "grok-4.1" # exclude specific models (exact match) # - "grok-3-*" # wildcard matching prefix @@ -380,6 +388,8 @@ nonstream-keepalive-interval: 0 # alias: "claude-sonnet-latest" # client alias mapped to the upstream model # display-name: "Claude Sonnet" # optional catalog display name # force-mapping: true # optional: rewrite response model fields back to the alias +# thinking: # optional: exact thinking capability for this configured model +# levels: ["max", "xhigh", "high", "medium", "low", "minimal", "none", "auto"] # excluded-models: # - "claude-opus-4-5-20251101" # exclude specific models (exact match) # - "claude-3-*" # wildcard matching prefix (e.g. claude-3-7-sonnet-20250219) @@ -476,6 +486,8 @@ nonstream-keepalive-interval: 0 # - name: "gemini-2.5-flash" # upstream model name # alias: "vertex-flash" # client-visible alias # display-name: "Vertex Flash" # optional catalog display name +# thinking: # optional: exact thinking capability for this configured model +# levels: ["high", "medium", "low", "none", "auto"] # - name: "gemini-2.5-pro" # alias: "vertex-pro" # excluded-models: # optional: models to exclude from listing diff --git a/internal/config/config_types.go b/internal/config/config_types.go index fd7f6f69..cb4e63ed 100644 --- a/internal/config/config_types.go +++ b/internal/config/config_types.go @@ -359,6 +359,10 @@ func (k ClaudeKey) GetAPIKey() string { return k.APIKey } func (k ClaudeKey) GetBaseURL() string { return k.BaseURL } +func (k ClaudeKey) GetPrefix() string { return k.Prefix } + +func (k ClaudeKey) GetProxyURL() string { return k.ProxyURL } + // ClaudeModel describes a mapping between an alias and the actual upstream model name. type ClaudeModel struct { // Name is the upstream model identifier used when issuing requests. @@ -372,6 +376,9 @@ type ClaudeModel struct { // ForceMapping rewrites upstream response model fields back to Alias. ForceMapping bool `yaml:"force-mapping,omitempty" json:"force-mapping,omitempty"` + + // Thinking configures the thinking/reasoning capability for this model. + Thinking *registry.ThinkingSupport `yaml:"thinking,omitempty" json:"thinking,omitempty"` } func (m ClaudeModel) GetName() string { return m.Name } @@ -382,6 +389,8 @@ func (m ClaudeModel) GetDisplayName() string { return m.DisplayName } func (m ClaudeModel) GetForceMapping() bool { return m.ForceMapping } +func (m ClaudeModel) GetThinking() *registry.ThinkingSupport { return m.Thinking } + // CodexKey represents the configuration for a Codex API key, // including the API key itself and an optional base URL for the API endpoint. type CodexKey struct { @@ -426,6 +435,10 @@ func (k CodexKey) GetAPIKey() string { return k.APIKey } func (k CodexKey) GetBaseURL() string { return k.BaseURL } +func (k CodexKey) GetPrefix() string { return k.Prefix } + +func (k CodexKey) GetProxyURL() string { return k.ProxyURL } + // CodexModel describes a mapping between an alias and the actual upstream model name. type CodexModel struct { // Name is the upstream model identifier used when issuing requests. @@ -439,6 +452,9 @@ type CodexModel struct { // ForceMapping rewrites upstream response model fields back to Alias. ForceMapping bool `yaml:"force-mapping,omitempty" json:"force-mapping,omitempty"` + + // Thinking configures the thinking/reasoning capability for this model. + Thinking *registry.ThinkingSupport `yaml:"thinking,omitempty" json:"thinking,omitempty"` } func (m CodexModel) GetName() string { return m.Name } @@ -449,6 +465,8 @@ func (m CodexModel) GetDisplayName() string { return m.DisplayName } func (m CodexModel) GetForceMapping() bool { return m.ForceMapping } +func (m CodexModel) GetThinking() *registry.ThinkingSupport { return m.Thinking } + // XAIKey uses the Codex API key structure for native xAI execution. type XAIKey = CodexKey @@ -495,6 +513,10 @@ func (k GeminiKey) GetAPIKey() string { return k.APIKey } func (k GeminiKey) GetBaseURL() string { return k.BaseURL } +func (k GeminiKey) GetPrefix() string { return k.Prefix } + +func (k GeminiKey) GetProxyURL() string { return k.ProxyURL } + // GeminiModel describes a mapping between an alias and the actual upstream model name. type GeminiModel struct { // Name is the upstream model identifier used when issuing requests. @@ -508,6 +530,9 @@ type GeminiModel struct { // ForceMapping rewrites upstream response model fields back to Alias. ForceMapping bool `yaml:"force-mapping,omitempty" json:"force-mapping,omitempty"` + + // Thinking configures the thinking/reasoning capability for this model. + Thinking *registry.ThinkingSupport `yaml:"thinking,omitempty" json:"thinking,omitempty"` } func (m GeminiModel) GetName() string { return m.Name } @@ -518,6 +543,8 @@ func (m GeminiModel) GetDisplayName() string { return m.DisplayName } func (m GeminiModel) GetForceMapping() bool { return m.ForceMapping } +func (m GeminiModel) GetThinking() *registry.ThinkingSupport { return m.Thinking } + // OpenAICompatibility represents the configuration for OpenAI API compatibility // with external providers, allowing model aliases to be routed through OpenAI API format. type OpenAICompatibility struct { @@ -600,3 +627,5 @@ func (m OpenAICompatibilityModel) GetAlias() string { return m.Alias } func (m OpenAICompatibilityModel) GetDisplayName() string { return m.DisplayName } func (m OpenAICompatibilityModel) GetForceMapping() bool { return m.ForceMapping } + +func (m OpenAICompatibilityModel) GetThinking() *registry.ThinkingSupport { return m.Thinking } diff --git a/internal/config/vertex_compat.go b/internal/config/vertex_compat.go index 4a73f98c..b9212096 100644 --- a/internal/config/vertex_compat.go +++ b/internal/config/vertex_compat.go @@ -1,6 +1,10 @@ package config -import "strings" +import ( + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" +) // VertexCompatKey represents the configuration for Vertex AI-compatible API keys. // This supports third-party services that use Vertex AI-style endpoint paths @@ -43,8 +47,10 @@ type VertexCompatKey struct { ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"` } -func (k VertexCompatKey) GetAPIKey() string { return k.APIKey } -func (k VertexCompatKey) GetBaseURL() string { return k.BaseURL } +func (k VertexCompatKey) GetAPIKey() string { return k.APIKey } +func (k VertexCompatKey) GetBaseURL() string { return k.BaseURL } +func (k VertexCompatKey) GetPrefix() string { return k.Prefix } +func (k VertexCompatKey) GetProxyURL() string { return k.ProxyURL } // VertexCompatModel represents a model configuration for Vertex compatibility, // including the actual model name and its alias for API routing. @@ -60,12 +66,18 @@ type VertexCompatModel struct { // ForceMapping rewrites upstream response model fields back to Alias. ForceMapping bool `yaml:"force-mapping,omitempty" json:"force-mapping,omitempty"` + + // Thinking configures the thinking/reasoning capability for this model. + Thinking *registry.ThinkingSupport `yaml:"thinking,omitempty" json:"thinking,omitempty"` } func (m VertexCompatModel) GetName() string { return m.Name } func (m VertexCompatModel) GetAlias() string { return m.Alias } func (m VertexCompatModel) GetDisplayName() string { return m.DisplayName } func (m VertexCompatModel) GetForceMapping() bool { return m.ForceMapping } +func (m VertexCompatModel) GetThinking() *registry.ThinkingSupport { + return m.Thinking +} // SanitizeVertexCompatKeys deduplicates and normalizes Vertex-compatible API key credentials. func (cfg *Config) SanitizeVertexCompatKeys() { diff --git a/internal/modelconfig/model_hash.go b/internal/modelconfig/model_hash.go new file mode 100644 index 00000000..348204be --- /dev/null +++ b/internal/modelconfig/model_hash.go @@ -0,0 +1,125 @@ +package modelconfig + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" +) + +// ComputeOpenAICompatModelsHash returns a stable hash for OpenAI-compatible models. +func ComputeOpenAICompatModelsHash(models []config.OpenAICompatibilityModel) string { + keys := modelRoutingKeys(func(out func(key string)) { + for _, model := range models { + name := strings.TrimSpace(model.Name) + alias := strings.TrimSpace(model.Alias) + if name == "" && alias == "" { + continue + } + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|" + fmt.Sprintf("image=%t", model.Image) + "|" + fmt.Sprintf("force-mapping=%t", model.ForceMapping) + "|input=" + strings.Join(normalizeModalities(model.InputModalities), ",") + "|output=" + strings.Join(normalizeModalities(model.OutputModalities), ",") + thinkingHashSuffix(model.Thinking)) + } + }) + return hashJoined(keys) +} + +// ComputeVertexCompatModelsHash returns a stable hash for Vertex-compatible models. +func ComputeVertexCompatModelsHash(models []config.VertexCompatModel) string { + keys := modelRoutingKeys(func(out func(key string)) { + for _, model := range models { + name := strings.TrimSpace(model.Name) + alias := strings.TrimSpace(model.Alias) + if name == "" && alias == "" { + continue + } + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|" + fmt.Sprintf("force-mapping=%t", model.ForceMapping) + thinkingHashSuffix(model.Thinking)) + } + }) + return hashJoined(keys) +} + +// ComputeClaudeModelsHash returns a stable hash for Claude model aliases. +func ComputeClaudeModelsHash(models []config.ClaudeModel) string { + keys := modelRoutingKeys(func(out func(key string)) { + for _, model := range models { + name := strings.TrimSpace(model.Name) + alias := strings.TrimSpace(model.Alias) + if name == "" && alias == "" { + continue + } + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|" + fmt.Sprintf("force-mapping=%t", model.ForceMapping) + thinkingHashSuffix(model.Thinking)) + } + }) + return hashJoined(keys) +} + +// ComputeCodexModelsHash returns a stable hash for Codex model aliases. +func ComputeCodexModelsHash(models []config.CodexModel) string { + keys := modelRoutingKeys(func(out func(key string)) { + for _, model := range models { + name := strings.TrimSpace(model.Name) + alias := strings.TrimSpace(model.Alias) + if name == "" && alias == "" { + continue + } + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|" + fmt.Sprintf("force-mapping=%t", model.ForceMapping) + thinkingHashSuffix(model.Thinking)) + } + }) + return hashJoined(keys) +} + +// ComputeGeminiModelsHash returns a stable hash for Gemini model aliases. +func ComputeGeminiModelsHash(models []config.GeminiModel) string { + keys := modelRoutingKeys(func(out func(key string)) { + for _, model := range models { + name := strings.TrimSpace(model.Name) + alias := strings.TrimSpace(model.Alias) + if name == "" && alias == "" { + continue + } + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|" + fmt.Sprintf("force-mapping=%t", model.ForceMapping) + thinkingHashSuffix(model.Thinking)) + } + }) + return hashJoined(keys) +} + +func normalizeModalities(raw []string) []string { + seen := make(map[string]struct{}, len(raw)) + out := make([]string, 0, len(raw)) + for _, value := range raw { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + continue + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +func thinkingHashSuffix(support *registry.ThinkingSupport) string { + data, _ := json.Marshal(support) + return "|thinking=" + string(data) +} + +func modelRoutingKeys(collect func(out func(key string))) []string { + keys := make([]string, 0) + collect(func(key string) { + keys = append(keys, key) + }) + return keys +} + +func hashJoined(keys []string) string { + if len(keys) == 0 { + return "" + } + sum := sha256.Sum256([]byte(strings.Join(keys, "\n"))) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/modelconfig/model_info.go b/internal/modelconfig/model_info.go new file mode 100644 index 00000000..7c5b9b1d --- /dev/null +++ b/internal/modelconfig/model_info.go @@ -0,0 +1,55 @@ +package modelconfig + +import ( + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" +) + +// ResolveModelInfo returns a private capability snapshot for a configured model. +// Static capabilities come from the suffix-free upstream name, while explicit +// configuration takes precedence. +func ResolveModelInfo(name, modelType string, support *registry.ThinkingSupport) *registry.ModelInfo { + trimmedName := strings.TrimSpace(name) + baseName := strings.TrimSpace(thinking.ParseSuffix(trimmedName).ModelName) + info := registry.LookupStaticModelInfo(baseName) + if info == nil { + info = ®istry.ModelInfo{} + } + info.ID = trimmedName + info.Type = strings.TrimSpace(modelType) + if support != nil { + info.Thinking = NormalizeThinkingSupport(support) + } + info.UserDefined = false + return info +} + +// NormalizeThinkingSupport clones and normalizes configured reasoning levels. +func NormalizeThinkingSupport(raw *registry.ThinkingSupport) *registry.ThinkingSupport { + if raw == nil { + return nil + } + normalized := *raw + normalized.Levels = nil + seen := make(map[string]struct{}, len(raw.Levels)) + for _, value := range raw.Levels { + level := strings.ToLower(strings.TrimSpace(value)) + if level == "" { + continue + } + switch level { + case "none": + normalized.ZeroAllowed = true + case "auto": + normalized.DynamicAllowed = true + } + if _, exists := seen[level]; exists { + continue + } + seen[level] = struct{}{} + normalized.Levels = append(normalized.Levels, level) + } + return &normalized +} diff --git a/internal/modelconfig/model_info_test.go b/internal/modelconfig/model_info_test.go new file mode 100644 index 00000000..5945f94f --- /dev/null +++ b/internal/modelconfig/model_info_test.go @@ -0,0 +1,61 @@ +package modelconfig + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" +) + +func TestResolveModelInfoUsesSuffixFreeStaticCapabilities(t *testing.T) { + info := ResolveModelInfo("claude-opus-4-6(high)", "claude", nil) + if info == nil || info.Thinking == nil { + t.Fatalf("ResolveModelInfo() = %+v, want inherited thinking support", info) + } + if info.ID != "claude-opus-4-6(high)" { + t.Fatalf("model ID = %q, want configured upstream name", info.ID) + } + if info.UserDefined { + t.Fatal("resolved capability snapshot must not be user-defined") + } +} + +func TestResolveModelInfoExplicitThinkingOverridesAndClones(t *testing.T) { + support := ®istry.ThinkingSupport{Levels: []string{" XHIGH ", "xhigh", " High "}} + info := ResolveModelInfo("custom-model", "codex", support) + if info == nil || info.Thinking == nil { + t.Fatalf("ResolveModelInfo() = %+v, want explicit thinking support", info) + } + if got := info.Thinking.Levels; len(got) != 2 || got[0] != "xhigh" || got[1] != "high" { + t.Fatalf("normalized levels = %v, want [xhigh high]", got) + } + support.Levels[0] = "low" + if info.Thinking.Levels[0] != "xhigh" { + t.Fatal("resolved thinking support shares mutable config storage") + } +} + +func TestNormalizeThinkingSupportDerivesSpecialLevelFlags(t *testing.T) { + support := NormalizeThinkingSupport(®istry.ThinkingSupport{Levels: []string{"low", "none", "auto"}}) + if support == nil { + t.Fatal("NormalizeThinkingSupport() = nil") + } + if !support.ZeroAllowed { + t.Fatal("none level did not enable ZeroAllowed") + } + if !support.DynamicAllowed { + t.Fatal("auto level did not enable DynamicAllowed") + } +} + +func TestResolveModelInfoUnknownModelKeepsMissingCapability(t *testing.T) { + info := ResolveModelInfo("unknown-configured-model", "claude", nil) + if info == nil { + t.Fatal("ResolveModelInfo() = nil") + } + if info.Thinking != nil { + t.Fatalf("unknown model thinking = %+v, want nil", info.Thinking) + } + if info.UserDefined { + t.Fatal("unknown configured model must use its exact bound capability") + } +} diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go index 0255a03a..8f84ec6e 100644 --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -44,7 +44,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, upstreamStream) body = helps.SetStringIfDifferent(body, "model", upstreamModel) - body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) + body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return resp, err } diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go index 7f549933..9167e056 100644 --- a/internal/runtime/executor/claude_executor_stream.go +++ b/internal/runtime/executor/claude_executor_stream.go @@ -44,7 +44,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) body = helps.SetStringIfDifferent(body, "model", upstreamModel) - body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) + body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return nil, err } diff --git a/internal/runtime/executor/claude_executor_tokens.go b/internal/runtime/executor/claude_executor_tokens.go index aabf07f6..b4bd57dd 100644 --- a/internal/runtime/executor/claude_executor_tokens.go +++ b/internal/runtime/executor/claude_executor_tokens.go @@ -26,6 +26,11 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut // Use streaming translation to preserve function calling, except for claude. stream := from != to body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream) + var errThinking error + body, errThinking = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) + if errThinking != nil { + return cliproxyexecutor.Response{}, errThinking + } if rebuildMidSystemMessageEnabled(e.cfg, auth) { body = rebuildMidSystemMessagesToTopLevel(body) } @@ -113,6 +118,11 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy stream := from != to body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream) body = helps.SetStringIfDifferent(body, "model", upstreamModel) + var errThinking error + body, errThinking = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) + if errThinking != nil { + return cliproxyexecutor.Response{}, errThinking + } if rebuildMidSystemMessageEnabled(e.cfg, auth) { body = rebuildMidSystemMessagesToTopLevel(body) } diff --git a/internal/runtime/executor/codex_executor_execute.go b/internal/runtime/executor/codex_executor_execute.go index 87279814..a5305ff6 100644 --- a/internal/runtime/executor/codex_executor_execute.go +++ b/internal/runtime/executor/codex_executor_execute.go @@ -45,7 +45,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re originalPayload := originalPayloadSource originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, false) - body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) + body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return resp, err } @@ -214,7 +214,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A originalPayload := originalPayloadSource originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, false) - body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) + body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return resp, err } diff --git a/internal/runtime/executor/codex_executor_stream.go b/internal/runtime/executor/codex_executor_stream.go index 8d5c8993..ddc1e81e 100644 --- a/internal/runtime/executor/codex_executor_stream.go +++ b/internal/runtime/executor/codex_executor_stream.go @@ -46,7 +46,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au originalPayload := originalPayloadSource originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, true) - body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) + body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return nil, err } diff --git a/internal/runtime/executor/codex_executor_tokens.go b/internal/runtime/executor/codex_executor_tokens.go index 9a687780..46722e8a 100644 --- a/internal/runtime/executor/codex_executor_tokens.go +++ b/internal/runtime/executor/codex_executor_tokens.go @@ -23,7 +23,7 @@ func (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth to := sdktranslator.FromString("codex") body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false) - body, err := thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) + body, err := helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return cliproxyexecutor.Response{}, err } diff --git a/internal/runtime/executor/codex_websockets_execute.go b/internal/runtime/executor/codex_websockets_execute.go index 43f86ad8..8f46812e 100644 --- a/internal/runtime/executor/codex_websockets_execute.go +++ b/internal/runtime/executor/codex_websockets_execute.go @@ -46,7 +46,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut originalPayload := originalPayloadSource originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, false) - body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) + body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return resp, err } diff --git a/internal/runtime/executor/codex_websockets_stream.go b/internal/runtime/executor/codex_websockets_stream.go index 719e1a36..60f87738 100644 --- a/internal/runtime/executor/codex_websockets_stream.go +++ b/internal/runtime/executor/codex_websockets_stream.go @@ -46,7 +46,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr originalPayload := originalPayloadSource originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, true) - body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) + body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return nil, err } diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go index a3e7d23c..028dc699 100644 --- a/internal/runtime/executor/gemini_executor.go +++ b/internal/runtime/executor/gemini_executor.go @@ -148,7 +148,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, false) body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) - body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) + body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return resp, err } @@ -261,7 +261,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) - body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) + body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return nil, err } @@ -391,7 +391,7 @@ func (e *GeminiExecutor) executeInteractions(ctx context.Context, auth *cliproxy if gjson.GetBytes(body, "model").Exists() && targetName != "" { body = helps.SetStringIfDifferent(body, "model", targetName) } - body, err = applyGeminiInteractionsThinking(body, req.Model) + body, err = applyGeminiInteractionsThinking(body, req, opts) if err != nil { return resp, err } @@ -467,7 +467,7 @@ func (e *GeminiExecutor) executeInteractionsStream(ctx context.Context, auth *cl if gjson.GetBytes(body, "model").Exists() && targetName != "" { body = helps.SetStringIfDifferent(body, "model", targetName) } - body, err = applyGeminiInteractionsThinking(body, req.Model) + body, err = applyGeminiInteractionsThinking(body, req, opts) if err != nil { return nil, err } @@ -621,7 +621,7 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut to := sdktranslator.FromString("gemini") translatedReq := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) - translatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, from.String(), to.String(), e.Identifier()) + translatedReq, err := helps.ApplyRequestThinking(translatedReq, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return cliproxyexecutor.Response{}, err } @@ -801,8 +801,12 @@ func isNativeInteractionsAuth(auth *cliproxyauth.Auth) bool { return strings.EqualFold(strings.TrimSpace(auth.Provider), "gemini-interactions") } -func applyGeminiInteractionsThinking(body []byte, model string) ([]byte, error) { - return thinking.ApplyThinking(body, model, sdktranslator.FormatInteractions.String(), sdktranslator.FormatInteractions.String(), "gemini") +func applyGeminiInteractionsThinking(body []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) ([]byte, error) { + fromFormat := opts.SourceFormat.String() + if strings.TrimSpace(fromFormat) == "" { + fromFormat = sdktranslator.FormatInteractions.String() + } + return helps.ApplyRequestThinking(body, req, opts, fromFormat, sdktranslator.FormatInteractions.String(), "gemini") } func applyGeminiInteractionsRevisionHeader(req *http.Request) { diff --git a/internal/runtime/executor/gemini_vertex_executor.go b/internal/runtime/executor/gemini_vertex_executor.go index c54f7a5b..84e5dc07 100644 --- a/internal/runtime/executor/gemini_vertex_executor.go +++ b/internal/runtime/executor/gemini_vertex_executor.go @@ -331,7 +331,7 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, false) body = helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) - body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) + body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return resp, err } @@ -456,7 +456,7 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, false) body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) - body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) + body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return resp, err } @@ -571,7 +571,7 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) - body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) + body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return nil, err } @@ -717,7 +717,7 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) - body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) + body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return nil, err } @@ -854,7 +854,7 @@ func (e *GeminiVertexExecutor) countTokensWithServiceAccount(ctx context.Context translatedReq := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) - translatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, from.String(), to.String(), e.Identifier()) + translatedReq, err := helps.ApplyRequestThinking(translatedReq, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return cliproxyexecutor.Response{}, err } @@ -945,7 +945,7 @@ func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth * translatedReq := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) - translatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, from.String(), to.String(), e.Identifier()) + translatedReq, err := helps.ApplyRequestThinking(translatedReq, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return cliproxyexecutor.Response{}, err } diff --git a/internal/runtime/executor/helps/model_capabilities.go b/internal/runtime/executor/helps/model_capabilities.go new file mode 100644 index 00000000..8021561c --- /dev/null +++ b/internal/runtime/executor/helps/model_capabilities.go @@ -0,0 +1,20 @@ +package helps + +import ( + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" +) + +// ApplyRequestThinking preserves the registry lookup path unless the auth +// manager bound an exact configured API-key model definition to this attempt. +func ApplyRequestThinking(body []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, fromFormat, toFormat, provider string) ([]byte, error) { + if modelInfo, ok := cliproxyauth.ResolvedAPIKeyModelInfo(req); ok { + sourceBody := opts.OriginalRequest + if len(sourceBody) == 0 { + sourceBody = req.Payload + } + return thinking.ApplyThinkingWithModelInfo(body, sourceBody, req.Model, fromFormat, toFormat, provider, modelInfo) + } + return thinking.ApplyThinking(body, req.Model, fromFormat, toFormat, provider) +} diff --git a/internal/runtime/executor/helps/model_capabilities_test.go b/internal/runtime/executor/helps/model_capabilities_test.go new file mode 100644 index 00000000..c1e0b371 --- /dev/null +++ b/internal/runtime/executor/helps/model_capabilities_test.go @@ -0,0 +1,149 @@ +package helps_test + +import ( + "context" + "net/http" + "testing" + + internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + helps "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/claude" + 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" +) + +type configuredThinkingExecutor struct { + seenModel string + resolved bool +} + +func (*configuredThinkingExecutor) Identifier() string { return "claude" } + +func (e *configuredThinkingExecutor) Execute(_ context.Context, _ *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + e.seenModel = req.Model + modelInfo, resolved := cliproxyauth.ResolvedAPIKeyModelInfo(req) + e.resolved = resolved && modelInfo != nil + body := []byte(`{"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}}`) + out, err := helps.ApplyRequestThinking(body, req, opts, opts.SourceFormat.String(), "claude", "claude") + return cliproxyexecutor.Response{Payload: out}, err +} + +func (e *configuredThinkingExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) { + response, err := e.Execute(ctx, auth, req, opts) + if err != nil { + return nil, err + } + chunks := make(chan cliproxyexecutor.StreamChunk, 1) + chunks <- cliproxyexecutor.StreamChunk{Payload: response.Payload} + close(chunks) + return &cliproxyexecutor.StreamResult{Chunks: chunks}, nil +} + +func (*configuredThinkingExecutor) Refresh(_ context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { + return auth, nil +} + +func (e *configuredThinkingExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + return e.Execute(ctx, auth, req, opts) +} + +func (*configuredThinkingExecutor) HttpRequest(context.Context, *cliproxyauth.Auth, *http.Request) (*http.Response, error) { + return nil, nil +} + +func TestApplyRequestThinkingUsesSelectedPrefixedAPIKeyModel(t *testing.T) { + manager := cliproxyauth.NewManager(nil, nil, nil) + manager.SetConfig(&internalconfig.Config{ + SDKConfig: internalconfig.SDKConfig{ForceModelPrefix: true}, + ClaudeKey: []internalconfig.ClaudeKey{{ + APIKey: "selected-key", + Prefix: "tenant", + Models: []internalconfig.ClaudeModel{{ + Name: "shared-upstream", Alias: "public-model", + Thinking: ®istry.ThinkingSupport{Levels: []string{"high"}}, + }}, + }}, + }) + executor := &configuredThinkingExecutor{} + manager.RegisterExecutor(executor) + auth := &cliproxyauth.Auth{ + ID: "selected-auth", + Provider: "claude", + Prefix: "tenant", + Attributes: map[string]string{ + cliproxyauth.AttributeAuthKind: cliproxyauth.AuthKindAPIKey, + cliproxyauth.AttributeAPIKey: "selected-key", + cliproxyauth.AttributeSource: "config:claude[0]", + }, + } + + modelRegistry := registry.GetGlobalRegistry() + modelRegistry.RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: "tenant/public-model", Type: "claude"}}) + modelRegistry.RegisterClient("unrelated-auth", auth.Provider, []*registry.ModelInfo{{ + ID: "shared-upstream", Type: "claude", + Thinking: ®istry.ThinkingSupport{Levels: []string{"max"}}, + }}) + t.Cleanup(func() { + modelRegistry.UnregisterClient(auth.ID) + modelRegistry.UnregisterClient("unrelated-auth") + }) + ctx := t.Context() + registered, errRegister := manager.Register(ctx, auth) + if errRegister != nil { + t.Fatalf("Register() error = %v", errRegister) + } + if registered == nil { + t.Fatal("Register() returned nil auth") + } + + original := []byte(`{"model":"tenant/public-model","reasoning_effort":"max","messages":[{"role":"user","content":"hello"}]}`) + req := cliproxyexecutor.Request{ + Model: "tenant/public-model", + Payload: original, + Format: sdktranslator.FormatOpenAI, + } + opts := cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatOpenAI, + OriginalRequest: original, + } + assertResponse := func(path string, payload []byte) { + t.Helper() + if executor.seenModel != "shared-upstream" { + t.Fatalf("%s executor model = %q, want shared-upstream", path, executor.seenModel) + } + if !executor.resolved { + t.Fatalf("%s request did not receive selected model capabilities", path) + } + if got := gjson.GetBytes(payload, "output_config.effort").String(); got != "high" { + t.Fatalf("%s output effort = %q, want selected credential capability high; body=%s", path, got, payload) + } + } + + response, errExecute := manager.Execute(ctx, []string{"claude"}, req, opts) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + assertResponse("execute", response.Payload) + + countResponse, errCount := manager.ExecuteCount(ctx, []string{"claude"}, req, opts) + if errCount != nil { + t.Fatalf("ExecuteCount() error = %v", errCount) + } + assertResponse("count", countResponse.Payload) + + streamResult, errStream := manager.ExecuteStream(ctx, []string{"claude"}, req, opts) + if errStream != nil { + t.Fatalf("ExecuteStream() error = %v", errStream) + } + var streamPayload []byte + for chunk := range streamResult.Chunks { + if chunk.Err != nil { + t.Fatalf("ExecuteStream() chunk error = %v", chunk.Err) + } + streamPayload = append(streamPayload, chunk.Payload...) + } + assertResponse("stream", streamPayload) +} diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index b3cf20b1..763141cc 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -114,7 +114,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, opts.Stream) translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, opts.Stream) - translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) + translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return resp, err } @@ -315,7 +315,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) - translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) + translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return nil, err } @@ -587,7 +587,7 @@ func (e *OpenAICompatExecutor) CountTokens(ctx context.Context, auth *cliproxyau modelForCounting := baseModel - translated, err := thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) + translated, err := helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return cliproxyexecutor.Response{}, err } diff --git a/internal/runtime/executor/xai_executor_request.go b/internal/runtime/executor/xai_executor_request.go index ef1eae8f..88c0f286 100644 --- a/internal/runtime/executor/xai_executor_request.go +++ b/internal/runtime/executor/xai_executor_request.go @@ -73,7 +73,7 @@ func (e *XAIExecutor) prepareResponsesRequestTo(ctx context.Context, req cliprox body = preserveXAIResponsesOutputControls(body, req.Payload, from) var err error - body, err = thinking.ApplyThinking(body, req.Model, from.String(), e.Identifier(), e.Identifier()) + body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), e.Identifier(), e.Identifier()) if err != nil { return nil, err } diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go index 8a6f873e..1d25de7e 100644 --- a/internal/thinking/apply.go +++ b/internal/thinking/apply.go @@ -162,7 +162,20 @@ func IsUserDefinedModel(modelInfo *registry.ModelInfo) bool { // // Without suffix - uses body config // result, err := thinking.ApplyThinking(body, "gemini-2.5-pro", "gemini", "gemini", "gemini") func ApplyThinking(body []byte, model string, fromFormat string, toFormat string, providerKey string) ([]byte, error) { + return applyThinking(body, nil, model, fromFormat, toFormat, providerKey, nil, false) +} + +// ApplyThinkingWithModelInfo applies thinking with the exact configured model +// definition selected for an API-key execution attempt. +func ApplyThinkingWithModelInfo(body, sourceBody []byte, model string, fromFormat string, toFormat string, providerKey string, modelInfo *registry.ModelInfo) ([]byte, error) { + return applyThinking(body, sourceBody, model, fromFormat, toFormat, providerKey, modelInfo, true) +} + +func applyThinking(body, sourceBody []byte, model string, fromFormat string, toFormat string, providerKey string, resolvedModelInfo *registry.ModelInfo, modelInfoResolved bool) ([]byte, error) { providerFormat := strings.ToLower(strings.TrimSpace(toFormat)) + if modelInfoResolved && providerFormat == "openai-response" { + providerFormat = "codex" + } providerKey = strings.ToLower(strings.TrimSpace(providerKey)) if providerKey == "" { providerKey = providerFormat @@ -185,7 +198,10 @@ func ApplyThinking(body []byte, model string, fromFormat string, toFormat string suffixResult := ParseSuffix(model) baseModel := suffixResult.ModelName // Use provider-specific lookup to handle capability differences across providers. - modelInfo := registry.LookupModelInfo(baseModel, providerKey) + modelInfo := resolvedModelInfo + if !modelInfoResolved { + modelInfo = registry.LookupModelInfo(baseModel, providerKey) + } // 3. Model capability check // Unknown models are treated as user-defined so thinking config can still be applied. @@ -221,7 +237,12 @@ func ApplyThinking(body []byte, model string, fromFormat string, toFormat string "level": config.Level, }).Debug("thinking: config from model suffix |") } else { - config = extractThinkingConfig(body, providerFormat) + if modelInfoResolved && len(sourceBody) > 0 { + config = extractSourceThinkingConfig(sourceBody, fromFormat) + } + if !hasThinkingConfig(config) { + config = extractThinkingConfig(body, providerFormat) + } if hasThinkingConfig(config) { log.WithFields(log.Fields{ "provider": providerFormat, @@ -240,6 +261,9 @@ func ApplyThinking(body []byte, model string, fromFormat string, toFormat string }).Debug("thinking: no config found, passthrough |") return body, nil } + if modelInfoResolved && config.Mode == ModeLevel && modelInfo != nil && modelInfo.Thinking != nil && shouldMapConfiguredHighIntent(fromFormat, providerFormat, modelInfo) { + config.Level = mapConfiguredHighIntent(config.Level, modelInfo) + } // 5. Validate and normalize configuration validated, err := ValidateConfig(config, modelInfo, fromFormat, providerFormat, suffixResult.HasSuffix) @@ -276,6 +300,49 @@ func ApplyThinking(body []byte, model string, fromFormat string, toFormat string return applier.Apply(body, *validated, modelInfo) } +func shouldMapConfiguredHighIntent(fromFormat, toFormat string, modelInfo *registry.ModelInfo) bool { + fromFormat = strings.ToLower(strings.TrimSpace(fromFormat)) + toFormat = strings.ToLower(strings.TrimSpace(toFormat)) + if fromFormat != toFormat { + return true + } + if modelInfo == nil { + return false + } + modelType := strings.ToLower(strings.TrimSpace(modelInfo.Type)) + return modelType != "" && !isSameProviderFamily(toFormat, modelType) +} + +func mapConfiguredHighIntent(level ThinkingLevel, modelInfo *registry.ModelInfo) ThinkingLevel { + if modelInfo == nil || modelInfo.Thinking == nil || len(modelInfo.Thinking.Levels) == 0 { + return level + } + level = ThinkingLevel(strings.ToLower(strings.TrimSpace(string(level)))) + var candidates []ThinkingLevel + switch level { + case LevelXHigh: + candidates = []ThinkingLevel{LevelXHigh, LevelMax, LevelHigh} + case LevelMax: + candidates = []ThinkingLevel{LevelMax, LevelXHigh, LevelHigh} + default: + return level + } + for _, candidate := range candidates { + if isLevelSupported(string(candidate), modelInfo.Thinking.Levels) { + return candidate + } + } + return level +} + +func extractSourceThinkingConfig(body []byte, provider string) ThinkingConfig { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "openai-response" { + return extractCodexConfig(body) + } + return extractThinkingConfig(body, provider) +} + // parseSuffixToConfig converts a raw suffix string to ThinkingConfig. // // Parsing priority: diff --git a/internal/thinking/apply_configured_api_key_test.go b/internal/thinking/apply_configured_api_key_test.go new file mode 100644 index 00000000..81e908fb --- /dev/null +++ b/internal/thinking/apply_configured_api_key_test.go @@ -0,0 +1,110 @@ +package thinking_test + +import ( + "testing" + + "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/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/codex" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/openai" + "github.com/tidwall/gjson" +) + +func TestApplyThinkingWithModelInfoMapsCrossFamilyHighIntent(t *testing.T) { + tests := []struct { + name string + source string + supported []string + want string + }{ + {name: "xhigh stays xhigh", source: "xhigh", supported: []string{"high", "max", "xhigh"}, want: "xhigh"}, + {name: "xhigh prefers max", source: "xhigh", supported: []string{"high", "max"}, want: "max"}, + {name: "xhigh falls back to high", source: "xhigh", supported: []string{"high"}, want: "high"}, + {name: "max stays max", source: "max", supported: []string{"high", "xhigh", "max"}, want: "max"}, + {name: "max prefers xhigh", source: "max", supported: []string{"high", "xhigh"}, want: "xhigh"}, + {name: "max falls back to high", source: "max", supported: []string{"high"}, want: "high"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + modelInfo := ®istry.ModelInfo{ + ID: "claude-upstream", + Type: "claude", + Thinking: ®istry.ThinkingSupport{Levels: tc.supported}, + } + body := []byte(`{"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}}`) + source := []byte(`{"reasoning_effort":"` + tc.source + `"}`) + out, err := thinking.ApplyThinkingWithModelInfo(body, source, "claude-upstream", "openai", "claude", "claude", modelInfo) + if err != nil { + t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err) + } + if got := gjson.GetBytes(out, "output_config.effort").String(); got != tc.want { + t.Fatalf("output effort = %q, want %q; body=%s", got, tc.want, out) + } + }) + } +} + +func TestApplyThinkingWithModelInfoMapsOpenAICompatibilityHighIntent(t *testing.T) { + modelInfo := ®istry.ModelInfo{ + ID: "compat-upstream", + Type: "openai-compatibility", + Thinking: ®istry.ThinkingSupport{Levels: []string{"high", "max"}}, + } + body := []byte(`{"reasoning_effort":"high"}`) + source := []byte(`{"reasoning_effort":"xhigh"}`) + out, err := thinking.ApplyThinkingWithModelInfo(body, source, "compat-upstream", "openai", "openai", "compat-provider", modelInfo) + if err != nil { + t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err) + } + if got := gjson.GetBytes(out, "reasoning_effort").String(); got != "max" { + t.Fatalf("reasoning_effort = %q, want max; body=%s", got, out) + } +} + +func TestApplyThinkingWithModelInfoMapsResponsesToCodexHighIntent(t *testing.T) { + modelInfo := ®istry.ModelInfo{ + ID: "codex-upstream", + Type: "codex", + Thinking: ®istry.ThinkingSupport{Levels: []string{"high", "xhigh"}}, + } + body := []byte(`{"reasoning":{"effort":"high"}}`) + source := []byte(`{"reasoning":{"effort":"max"}}`) + out, err := thinking.ApplyThinkingWithModelInfo(body, source, "codex-upstream", "openai-response", "codex", "codex", modelInfo) + if err != nil { + t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err) + } + if got := gjson.GetBytes(out, "reasoning.effort").String(); got != "xhigh" { + t.Fatalf("reasoning.effort = %q, want xhigh; body=%s", got, out) + } +} + +func TestApplyThinkingWithModelInfoKeepsSameFamilyValidationStrict(t *testing.T) { + modelInfo := ®istry.ModelInfo{ + ID: "openai-upstream", + Type: "openai", + Thinking: ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high"}}, + } + body := []byte(`{"reasoning_effort":"xhigh"}`) + out, err := thinking.ApplyThinkingWithModelInfo(body, body, "openai-upstream", "openai", "openai", "openai", modelInfo) + if err == nil { + t.Fatalf("ApplyThinkingWithModelInfo() error = nil, want unsupported xhigh error; body=%s", out) + } +} + +func TestApplyThinkingWithModelInfoUsesOriginalResponsesEffort(t *testing.T) { + modelInfo := ®istry.ModelInfo{ + ID: "claude-upstream", + Type: "claude", + Thinking: ®istry.ThinkingSupport{Levels: []string{"high", "max"}}, + } + body := []byte(`{"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}}`) + source := []byte(`{"reasoning":{"effort":"xhigh"}}`) + out, err := thinking.ApplyThinkingWithModelInfo(body, source, "claude-upstream", "openai-response", "claude", "claude", modelInfo) + if err != nil { + t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err) + } + if got := gjson.GetBytes(out, "output_config.effort").String(); got != "max" { + t.Fatalf("output effort = %q, want max; body=%s", got, out) + } +} diff --git a/internal/watcher/diff/model_hash.go b/internal/watcher/diff/model_hash.go index f3823cd0..5c3fbdbf 100644 --- a/internal/watcher/diff/model_hash.go +++ b/internal/watcher/diff/model_hash.go @@ -4,87 +4,38 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" - "fmt" "sort" "strings" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/modelconfig" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" ) // ComputeOpenAICompatModelsHash returns a stable hash for OpenAI-compat models. // Used to detect model list changes during hot reload. func ComputeOpenAICompatModelsHash(models []config.OpenAICompatibilityModel) string { - keys := normalizeModelPairs(func(out func(key string)) { - for _, model := range models { - name := strings.TrimSpace(model.Name) - alias := strings.TrimSpace(model.Alias) - if name == "" && alias == "" { - continue - } - out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|" + fmt.Sprintf("image=%t", model.Image)) - } - }) - return hashJoined(keys) + return modelconfig.ComputeOpenAICompatModelsHash(models) } // ComputeVertexCompatModelsHash returns a stable hash for Vertex-compatible models. func ComputeVertexCompatModelsHash(models []config.VertexCompatModel) string { - keys := normalizeModelPairs(func(out func(key string)) { - for _, model := range models { - name := strings.TrimSpace(model.Name) - alias := strings.TrimSpace(model.Alias) - if name == "" && alias == "" { - continue - } - out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName)) - } - }) - return hashJoined(keys) + return modelconfig.ComputeVertexCompatModelsHash(models) } // ComputeClaudeModelsHash returns a stable hash for Claude model aliases. func ComputeClaudeModelsHash(models []config.ClaudeModel) string { - keys := normalizeModelPairs(func(out func(key string)) { - for _, model := range models { - name := strings.TrimSpace(model.Name) - alias := strings.TrimSpace(model.Alias) - if name == "" && alias == "" { - continue - } - out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName)) - } - }) - return hashJoined(keys) + return modelconfig.ComputeClaudeModelsHash(models) } // ComputeCodexModelsHash returns a stable hash for Codex model aliases. func ComputeCodexModelsHash(models []config.CodexModel) string { - keys := normalizeModelPairs(func(out func(key string)) { - for _, model := range models { - name := strings.TrimSpace(model.Name) - alias := strings.TrimSpace(model.Alias) - if name == "" && alias == "" { - continue - } - out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|" + fmt.Sprintf("force-mapping=%t", model.ForceMapping)) - } - }) - return hashJoined(keys) + return modelconfig.ComputeCodexModelsHash(models) } // ComputeGeminiModelsHash returns a stable hash for Gemini model aliases. func ComputeGeminiModelsHash(models []config.GeminiModel) string { - keys := normalizeModelPairs(func(out func(key string)) { - for _, model := range models { - name := strings.TrimSpace(model.Name) - alias := strings.TrimSpace(model.Alias) - if name == "" && alias == "" { - continue - } - out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName)) - } - }) - return hashJoined(keys) + return modelconfig.ComputeGeminiModelsHash(models) } // ComputeExcludedModelsHash returns a normalized hash for excluded model lists. @@ -107,6 +58,11 @@ func ComputeExcludedModelsHash(excluded []string) string { return hex.EncodeToString(sum[:]) } +func thinkingHashSuffix(support *registry.ThinkingSupport) string { + data, _ := json.Marshal(support) + return "|thinking=" + string(data) +} + func normalizeModelPairs(collect func(out func(key string))) []string { seen := make(map[string]struct{}) keys := make([]string, 0) diff --git a/internal/watcher/diff/model_hash_test.go b/internal/watcher/diff/model_hash_test.go index b51ba5bc..7a5e6ac2 100644 --- a/internal/watcher/diff/model_hash_test.go +++ b/internal/watcher/diff/model_hash_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" ) func TestComputeOpenAICompatModelsHash_Deterministic(t *testing.T) { @@ -36,7 +37,20 @@ func TestComputeOpenAICompatModelsHash_IncludesImageFlag(t *testing.T) { } } -func TestComputeOpenAICompatModelsHash_NormalizesAndDedups(t *testing.T) { +func TestComputeOpenAICompatModelsHashIncludesModalities(t *testing.T) { + base := []config.OpenAICompatibilityModel{{Name: "model", InputModalities: []string{"text"}, OutputModalities: []string{"text"}}} + inputChanged := []config.OpenAICompatibilityModel{{Name: "model", InputModalities: []string{"text", "image"}, OutputModalities: []string{"text"}}} + outputChanged := []config.OpenAICompatibilityModel{{Name: "model", InputModalities: []string{"text"}, OutputModalities: []string{"text", "image"}}} + baseHash := ComputeOpenAICompatModelsHash(base) + if baseHash == ComputeOpenAICompatModelsHash(inputChanged) { + t.Fatal("input modalities did not change model hash") + } + if baseHash == ComputeOpenAICompatModelsHash(outputChanged) { + t.Fatal("output modalities did not change model hash") + } +} + +func TestComputeOpenAICompatModelsHashPreservesRoutingOrderAndDuplicates(t *testing.T) { a := []config.OpenAICompatibilityModel{ {Name: "gpt-4", Alias: "gpt4"}, {Name: " "}, @@ -52,8 +66,8 @@ func TestComputeOpenAICompatModelsHash_NormalizesAndDedups(t *testing.T) { if h1 == "" || h2 == "" { t.Fatal("expected non-empty hashes for non-empty model sets") } - if h1 != h2 { - t.Fatalf("expected normalized hashes to match, got %s / %s", h1, h2) + if h1 == h2 { + t.Fatalf("expected routing order and duplicates to change hashes, got %s", h1) } } @@ -69,7 +83,7 @@ func TestComputeVertexCompatModelsHash_DifferentInputs(t *testing.T) { } } -func TestComputeVertexCompatModelsHash_IgnoresBlankAndOrder(t *testing.T) { +func TestComputeVertexCompatModelsHashPreservesDuplicates(t *testing.T) { a := []config.VertexCompatModel{ {Name: "m1", Alias: "a1"}, {Name: " "}, @@ -78,8 +92,8 @@ func TestComputeVertexCompatModelsHash_IgnoresBlankAndOrder(t *testing.T) { b := []config.VertexCompatModel{ {Name: "m1", Alias: "a1"}, } - if h1, h2 := ComputeVertexCompatModelsHash(a), ComputeVertexCompatModelsHash(b); h1 == "" || h1 != h2 { - t.Fatalf("expected same hash ignoring blanks/dupes, got %q / %q", h1, h2) + if h1, h2 := ComputeVertexCompatModelsHash(a), ComputeVertexCompatModelsHash(b); h1 == "" || h1 == h2 { + t.Fatalf("expected duplicate routing entries to change hash, got %q / %q", h1, h2) } } @@ -101,7 +115,7 @@ func TestComputeCodexModelsHash_Empty(t *testing.T) { } } -func TestComputeClaudeModelsHash_IgnoresBlankAndDedup(t *testing.T) { +func TestComputeClaudeModelsHashPreservesDuplicates(t *testing.T) { a := []config.ClaudeModel{ {Name: "m1", Alias: "a1"}, {Name: " "}, @@ -110,12 +124,12 @@ func TestComputeClaudeModelsHash_IgnoresBlankAndDedup(t *testing.T) { b := []config.ClaudeModel{ {Name: "m1", Alias: "a1"}, } - if h1, h2 := ComputeClaudeModelsHash(a), ComputeClaudeModelsHash(b); h1 == "" || h1 != h2 { - t.Fatalf("expected same hash ignoring blanks/dupes, got %q / %q", h1, h2) + if h1, h2 := ComputeClaudeModelsHash(a), ComputeClaudeModelsHash(b); h1 == "" || h1 == h2 { + t.Fatalf("expected duplicate routing entries to change hash, got %q / %q", h1, h2) } } -func TestComputeCodexModelsHash_IgnoresBlankAndDedup(t *testing.T) { +func TestComputeCodexModelsHashPreservesDuplicates(t *testing.T) { a := []config.CodexModel{ {Name: "m1", Alias: "a1"}, {Name: " "}, @@ -124,8 +138,8 @@ func TestComputeCodexModelsHash_IgnoresBlankAndDedup(t *testing.T) { b := []config.CodexModel{ {Name: "m1", Alias: "a1"}, } - if h1, h2 := ComputeCodexModelsHash(a), ComputeCodexModelsHash(b); h1 == "" || h1 != h2 { - t.Fatalf("expected same hash ignoring blanks/dupes, got %q / %q", h1, h2) + if h1, h2 := ComputeCodexModelsHash(a), ComputeCodexModelsHash(b); h1 == "" || h1 == h2 { + t.Fatalf("expected duplicate routing entries to change hash, got %q / %q", h1, h2) } } @@ -179,6 +193,21 @@ func TestComputeCodexModelsHashIncludesForceMapping(t *testing.T) { } } +func TestComputeOtherModelHashesIncludeForceMapping(t *testing.T) { + if ComputeOpenAICompatModelsHash([]config.OpenAICompatibilityModel{{Name: "m"}}) == ComputeOpenAICompatModelsHash([]config.OpenAICompatibilityModel{{Name: "m", ForceMapping: true}}) { + t.Fatal("OpenAI compatibility force-mapping did not change model hash") + } + if ComputeVertexCompatModelsHash([]config.VertexCompatModel{{Name: "m"}}) == ComputeVertexCompatModelsHash([]config.VertexCompatModel{{Name: "m", ForceMapping: true}}) { + t.Fatal("Vertex force-mapping did not change model hash") + } + if ComputeClaudeModelsHash([]config.ClaudeModel{{Name: "m"}}) == ComputeClaudeModelsHash([]config.ClaudeModel{{Name: "m", ForceMapping: true}}) { + t.Fatal("Claude force-mapping did not change model hash") + } + if ComputeGeminiModelsHash([]config.GeminiModel{{Name: "m"}}) == ComputeGeminiModelsHash([]config.GeminiModel{{Name: "m", ForceMapping: true}}) { + t.Fatal("Gemini force-mapping did not change model hash") + } +} + func TestComputeExcludedModelsHash_Normalizes(t *testing.T) { hash1 := ComputeExcludedModelsHash([]string{" A ", "b", "a"}) hash2 := ComputeExcludedModelsHash([]string{"a", " b", "A"}) @@ -253,3 +282,26 @@ func TestComputeCodexModelsHash_Deterministic(t *testing.T) { t.Fatalf("expected different hash when models change, got %s", h3) } } + +func TestComputeModelHashesIncludeThinking(t *testing.T) { + low := ®istry.ThinkingSupport{Levels: []string{"low"}} + high := ®istry.ThinkingSupport{Levels: []string{"high"}} + tests := []struct { + name string + low string + high string + }{ + {name: "openai compatibility", low: ComputeOpenAICompatModelsHash([]config.OpenAICompatibilityModel{{Name: "m", Thinking: low}}), high: ComputeOpenAICompatModelsHash([]config.OpenAICompatibilityModel{{Name: "m", Thinking: high}})}, + {name: "vertex", low: ComputeVertexCompatModelsHash([]config.VertexCompatModel{{Name: "m", Thinking: low}}), high: ComputeVertexCompatModelsHash([]config.VertexCompatModel{{Name: "m", Thinking: high}})}, + {name: "claude", low: ComputeClaudeModelsHash([]config.ClaudeModel{{Name: "m", Thinking: low}}), high: ComputeClaudeModelsHash([]config.ClaudeModel{{Name: "m", Thinking: high}})}, + {name: "codex", low: ComputeCodexModelsHash([]config.CodexModel{{Name: "m", Thinking: low}}), high: ComputeCodexModelsHash([]config.CodexModel{{Name: "m", Thinking: high}})}, + {name: "gemini", low: ComputeGeminiModelsHash([]config.GeminiModel{{Name: "m", Thinking: low}}), high: ComputeGeminiModelsHash([]config.GeminiModel{{Name: "m", Thinking: high}})}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.low == "" || tc.low == tc.high { + t.Fatalf("thinking capability must change model hash: %q / %q", tc.low, tc.high) + } + }) + } +} diff --git a/internal/watcher/diff/models_summary.go b/internal/watcher/diff/models_summary.go index 544f7485..40b1fd9e 100644 --- a/internal/watcher/diff/models_summary.go +++ b/internal/watcher/diff/models_summary.go @@ -41,7 +41,7 @@ func SummarizeGeminiModels(models []config.GeminiModel) GeminiModelsSummary { if name == "" && alias == "" { continue } - out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName)) + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + thinkingHashSuffix(model.Thinking)) } }) return GeminiModelsSummary{ @@ -62,7 +62,7 @@ func SummarizeClaudeModels(models []config.ClaudeModel) ClaudeModelsSummary { if name == "" && alias == "" { continue } - out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName)) + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + thinkingHashSuffix(model.Thinking)) } }) return ClaudeModelsSummary{ @@ -87,7 +87,7 @@ func SummarizeCodexModels(models []config.CodexModel) CodexModelsSummary { if model.ForceMapping { forceMapping = "true" } - out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|force-mapping=" + forceMapping) + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|force-mapping=" + forceMapping + thinkingHashSuffix(model.Thinking)) } }) return CodexModelsSummary{ @@ -111,7 +111,7 @@ func SummarizeVertexModels(models []config.VertexCompatModel) VertexModelsSummar if alias != "" { name = alias } - names = append(names, name+"|"+strings.TrimSpace(model.DisplayName)) + names = append(names, name+"|"+strings.TrimSpace(model.DisplayName)+thinkingHashSuffix(model.Thinking)) } if len(names) == 0 { return VertexModelsSummary{} diff --git a/internal/watcher/synthesizer/config.go b/internal/watcher/synthesizer/config.go index 3b003a9c..f15284bd 100644 --- a/internal/watcher/synthesizer/config.go +++ b/internal/watcher/synthesizer/config.go @@ -87,8 +87,9 @@ func (s *ConfigSynthesizer) synthesizeGeminiKeyEntries(ctx *SynthesisContext, en proxyURL := strings.TrimSpace(entry.ProxyURL) id, token := idGen.Next(idKind, key, base) attrs := map[string]string{ - "source": fmt.Sprintf("config:%s[%s]", sourceName, token), - "api_key": key, + "source": fmt.Sprintf("config:%s[%s]", sourceName, token), + "api_key": key, + "config_index": strconv.Itoa(i), } metadata := map[string]any{} if entry.DisableCooling { @@ -143,8 +144,9 @@ func (s *ConfigSynthesizer) synthesizeClaudeKeys(ctx *SynthesisContext) []*corea base := strings.TrimSpace(ck.BaseURL) id, token := idGen.Next("claude:apikey", key, base) attrs := map[string]string{ - "source": fmt.Sprintf("config:claude[%s]", token), - "api_key": key, + "source": fmt.Sprintf("config:claude[%s]", token), + "api_key": key, + "config_index": strconv.Itoa(i), } metadata := map[string]any{} if ck.DisableCooling { @@ -212,8 +214,9 @@ func (s *ConfigSynthesizer) synthesizeCodexStyleKeys(ctx *SynthesisContext, entr baseURL := strings.TrimSpace(entry.BaseURL) id, token := idGen.Next(provider+":apikey", key, baseURL) attrs := map[string]string{ - "source": fmt.Sprintf("config:%s[%s]", provider, token), - "api_key": key, + "source": fmt.Sprintf("config:%s[%s]", provider, token), + "api_key": key, + "config_index": strconv.Itoa(i), } metadata := map[string]any{} if entry.DisableCooling { @@ -288,6 +291,7 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor "base_url": base, "compat_name": compat.Name, "provider_key": internalProviderKey, + "config_index": strconv.Itoa(i), } metadata := map[string]any{} if disableCooling { @@ -331,6 +335,7 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor "base_url": base, "compat_name": compat.Name, "provider_key": internalProviderKey, + "config_index": strconv.Itoa(i), } metadata := map[string]any{} if disableCooling { @@ -384,6 +389,7 @@ func (s *ConfigSynthesizer) synthesizeVertexCompat(ctx *SynthesisContext) []*cor "source": fmt.Sprintf("config:vertex-apikey[%s]", token), "base_url": base, "provider_key": providerName, + "config_index": strconv.Itoa(i), } if compat.Priority != 0 { attrs["priority"] = strconv.Itoa(compat.Priority) diff --git a/internal/watcher/synthesizer/config_test.go b/internal/watcher/synthesizer/config_test.go index 2ce96079..ac5d1e1c 100644 --- a/internal/watcher/synthesizer/config_test.go +++ b/internal/watcher/synthesizer/config_test.go @@ -260,6 +260,9 @@ func TestConfigSynthesizer_ClaudeKeys(t *testing.T) { if auths[0].Attributes["api_key"] != "sk-ant-api-xxx" { t.Errorf("expected api_key sk-ant-api-xxx, got %s", auths[0].Attributes["api_key"]) } + if auths[0].Attributes["config_index"] != "0" { + t.Errorf("expected config_index 0, got %s", auths[0].Attributes["config_index"]) + } if _, ok := auths[0].Attributes["models_hash"]; !ok { t.Error("expected models_hash in attributes") } @@ -541,6 +544,9 @@ func TestConfigSynthesizer_OpenAICompat_UsesNamespacedProviderKey(t *testing.T) if auth.Attributes["compat_name"] != "kimi" { t.Fatalf("compat_name = %q, want kimi", auth.Attributes["compat_name"]) } + if auth.Attributes["config_index"] != "0" { + t.Fatalf("config_index = %q, want 0", auth.Attributes["config_index"]) + } } func TestConfigSynthesizer_VertexCompat(t *testing.T) { diff --git a/sdk/cliproxy/auth/api_key_model_capabilities.go b/sdk/cliproxy/auth/api_key_model_capabilities.go new file mode 100644 index 00000000..c88dcbf2 --- /dev/null +++ b/sdk/cliproxy/auth/api_key_model_capabilities.go @@ -0,0 +1,217 @@ +package auth + +import ( + "maps" + "strings" + + internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/modelconfig" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" +) + +const resolvedAPIKeyModelInfoMetadataKey = "cliproxy.resolved_api_key_model_info" + +type apiKeyModelCapabilityRoute struct { + upstreamModel string + modelInfo *registry.ModelInfo +} + +type apiKeyModelCapabilityTable map[string]map[string][]apiKeyModelCapabilityRoute + +type apiKeyModelRoutingSnapshot struct { + config *internalconfig.Config + aliases apiKeyModelAliasTable + capabilities apiKeyModelCapabilityTable +} + +func isConfiguredModelRoutingAuth(auth *Auth) bool { + if auth != nil && auth.AuthKind() == AuthKindAPIKey { + return true + } + if auth == nil || auth.AuthSourceKind() != AuthSourceConfig || auth.Attributes == nil { + return false + } + return strings.TrimSpace(auth.Attributes["compat_name"]) != "" +} + +func (m *Manager) loadAPIKeyModelRouting() *apiKeyModelRoutingSnapshot { + if m == nil { + return &apiKeyModelRoutingSnapshot{config: &internalconfig.Config{}} + } + snapshot, _ := m.apiKeyModelRouting.Load().(*apiKeyModelRoutingSnapshot) + if snapshot == nil { + return &apiKeyModelRoutingSnapshot{config: &internalconfig.Config{}} + } + return snapshot +} + +// ResolvedAPIKeyModelInfo returns the exact configured model definition bound to +// this API-key execution attempt. +func ResolvedAPIKeyModelInfo(req cliproxyexecutor.Request) (*registry.ModelInfo, bool) { + modelInfo, ok := req.Metadata[resolvedAPIKeyModelInfoMetadataKey].(*registry.ModelInfo) + if !ok || modelInfo == nil { + return nil, false + } + return modelInfo, true +} + +func (m *Manager) attachResolvedAPIKeyModelInfo(req cliproxyexecutor.Request, auth *Auth, routeModel, upstreamModel string) cliproxyexecutor.Request { + return attachResolvedAPIKeyModelInfo(m.loadAPIKeyModelRouting(), req, auth, routeModel, upstreamModel) +} + +func attachResolvedAPIKeyModelInfo(routing *apiKeyModelRoutingSnapshot, req cliproxyexecutor.Request, auth *Auth, routeModel, upstreamModel string) cliproxyexecutor.Request { + modelInfo, ok := lookupAPIKeyModelCapability(routing, auth, routeModel, upstreamModel) + if !ok { + return req + } + metadata := make(map[string]any, len(req.Metadata)+1) + maps.Copy(metadata, req.Metadata) + metadata[resolvedAPIKeyModelInfoMetadataKey] = modelInfo + req.Metadata = metadata + return req +} + +func lookupAPIKeyModelCapability(routing *apiKeyModelRoutingSnapshot, auth *Auth, routeModel, upstreamModel string) (*registry.ModelInfo, bool) { + if !isConfiguredModelRoutingAuth(auth) || routing == nil { + return nil, false + } + byRoute := routing.capabilities[strings.TrimSpace(auth.ID)] + if len(byRoute) == 0 { + return nil, false + } + requestedModel := rewriteModelForAuth(strings.TrimSpace(routeModel), auth) + _, candidates := modelAliasLookupCandidates(requestedModel) + routes := make([]apiKeyModelCapabilityRoute, 0) + for _, candidate := range candidates { + routes = append(routes, byRoute[strings.ToLower(strings.TrimSpace(candidate))]...) + } + selected := strings.TrimSpace(upstreamModel) + for _, route := range routes { + if strings.EqualFold(strings.TrimSpace(route.upstreamModel), selected) { + return route.modelInfo, route.modelInfo != nil + } + } + for _, route := range routes { + if configuredUpstreamFallbackMatches(route.upstreamModel, selected) { + return route.modelInfo, route.modelInfo != nil + } + } + return nil, false +} + +func configuredUpstreamFallbackMatches(configured, selected string) bool { + configuredResult := thinking.ParseSuffix(strings.TrimSpace(configured)) + if configuredResult.HasSuffix { + return false + } + selectedResult := thinking.ParseSuffix(strings.TrimSpace(selected)) + return strings.EqualFold(strings.TrimSpace(configuredResult.ModelName), strings.TrimSpace(selectedResult.ModelName)) +} + +func compileAPIKeyModelCapabilitiesForAuth(cfg *internalconfig.Config, auth *Auth) map[string][]apiKeyModelCapabilityRoute { + if cfg == nil || !isConfiguredModelRoutingAuth(auth) { + return nil + } + out := make(map[string][]apiKeyModelCapabilityRoute) + switch strings.ToLower(strings.TrimSpace(auth.Provider)) { + case "gemini": + if entry := resolveGeminiAPIKeyConfig(cfg, auth); entry != nil { + compileConfiguredModelCapabilities(out, entry.Models, "gemini") + } + case "gemini-interactions": + if entry := resolveInteractionsAPIKeyConfig(cfg, auth); entry != nil { + compileConfiguredModelCapabilities(out, entry.Models, "interactions") + } + case "claude": + if entry := resolveClaudeAPIKeyConfig(cfg, auth); entry != nil { + compileConfiguredModelCapabilities(out, entry.Models, "claude") + } + case "codex": + if entry := resolveCodexAPIKeyConfig(cfg, auth); entry != nil { + compileConfiguredModelCapabilities(out, entry.Models, "codex") + } + case "xai": + if entry := resolveXAIAPIKeyConfig(cfg, auth); entry != nil { + compileConfiguredModelCapabilities(out, entry.Models, "xai") + } + case "vertex": + if entry := resolveVertexAPIKeyConfig(cfg, auth); entry != nil { + compileConfiguredModelCapabilities(out, entry.Models, "gemini") + } + default: + providerKey, compatName := "", "" + if auth.Attributes != nil { + providerKey = strings.TrimSpace(auth.Attributes["provider_key"]) + compatName = strings.TrimSpace(auth.Attributes["compat_name"]) + } + if entry := resolveOpenAICompatConfigForAuth(cfg, auth, providerKey, compatName); entry != nil { + compileOpenAICompatibleModelCapabilities(out, entry.Models) + } + } + if len(out) == 0 { + return nil + } + return out +} + +func compileConfiguredModelCapabilities[T interface { + GetName() string + GetAlias() string + GetThinking() *registry.ThinkingSupport +}](out map[string][]apiKeyModelCapabilityRoute, models []T, modelType string) { + for i := range models { + addConfiguredModelCapability(out, models[i].GetName(), models[i].GetAlias(), modelType, models[i].GetThinking()) + } +} + +func compileOpenAICompatibleModelCapabilities(out map[string][]apiKeyModelCapabilityRoute, models []internalconfig.OpenAICompatibilityModel) { + for i := range models { + support := models[i].Thinking + if support == nil && !models[i].Image { + support = ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high"}} + } + addConfiguredModelCapability(out, models[i].Name, models[i].Alias, "openai-compatibility", support) + } +} + +func addConfiguredModelCapability(out map[string][]apiKeyModelCapabilityRoute, name, alias, modelType string, support *registry.ThinkingSupport) { + name = strings.TrimSpace(name) + alias = strings.TrimSpace(alias) + if name == "" { + name = alias + } + if alias == "" { + alias = name + } + if name == "" { + return + } + modelInfo := modelconfig.ResolveModelInfo(name, modelType, support) + route := apiKeyModelCapabilityRoute{upstreamModel: name, modelInfo: modelInfo} + seenKeys := make(map[string]struct{}) + for _, routeModel := range []string{alias, name} { + _, candidates := modelAliasLookupCandidates(routeModel) + for _, candidate := range candidates { + key := strings.ToLower(strings.TrimSpace(candidate)) + if key == "" { + continue + } + if _, exists := seenKeys[key]; exists { + continue + } + seenKeys[key] = struct{}{} + duplicate := false + for _, existing := range out[key] { + if strings.EqualFold(existing.upstreamModel, route.upstreamModel) { + duplicate = true + break + } + } + if !duplicate { + out[key] = append(out[key], route) + } + } + } +} diff --git a/sdk/cliproxy/auth/api_key_model_capabilities_test.go b/sdk/cliproxy/auth/api_key_model_capabilities_test.go new file mode 100644 index 00000000..91d1a674 --- /dev/null +++ b/sdk/cliproxy/auth/api_key_model_capabilities_test.go @@ -0,0 +1,253 @@ +package auth + +import ( + "testing" + + 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" +) + +func TestAttachResolvedAPIKeyModelInfoUsesSelectedCredential(t *testing.T) { + manager := NewManager(nil, nil, nil) + manager.SetConfig(&internalconfig.Config{ClaudeKey: []internalconfig.ClaudeKey{ + { + APIKey: "key-high", + Prefix: "tenant", + Models: []internalconfig.ClaudeModel{{ + Name: "shared-upstream", Alias: "public-model", + Thinking: ®istry.ThinkingSupport{Levels: []string{"high"}}, + }}, + }, + { + APIKey: "key-max", + Prefix: "tenant", + Models: []internalconfig.ClaudeModel{{ + Name: "shared-upstream", Alias: "public-model", + Thinking: ®istry.ThinkingSupport{Levels: []string{"max"}}, + }}, + }, + }}) + + authHigh := configuredCapabilityTestAuth("auth-high", "key-high") + authMax := configuredCapabilityTestAuth("auth-max", "key-max") + registerCapabilityTestAuth(t, manager, authHigh) + registerCapabilityTestAuth(t, manager, authMax) + + assertResolvedThinkingLevels(t, manager.attachResolvedAPIKeyModelInfo(cliproxyexecutor.Request{}, authHigh, "tenant/public-model", "shared-upstream"), "high") + assertResolvedThinkingLevels(t, manager.attachResolvedAPIKeyModelInfo(cliproxyexecutor.Request{}, authMax, "tenant/public-model", "shared-upstream"), "max") +} + +func TestAttachResolvedAPIKeyModelInfoUsesExactDuplicateCredentialConfig(t *testing.T) { + manager := NewManager(nil, nil, nil) + highModels := []internalconfig.ClaudeModel{{ + Name: "shared-upstream", Alias: "public-model", + Thinking: ®istry.ThinkingSupport{Levels: []string{"high"}}, + }} + maxModels := []internalconfig.ClaudeModel{{ + Name: "shared-upstream", Alias: "public-model", + Thinking: ®istry.ThinkingSupport{Levels: []string{"max"}}, + }} + manager.SetConfig(&internalconfig.Config{ClaudeKey: []internalconfig.ClaudeKey{ + {APIKey: "shared-key", Prefix: "tenant", Models: highModels}, + {APIKey: "shared-key", Prefix: "tenant", Models: maxModels}, + }}) + + authHigh := configuredCapabilityTestAuth("auth-duplicate-high", "shared-key") + authHigh.Attributes[AttributeConfigIndex] = "0" + authMax := configuredCapabilityTestAuth("auth-duplicate-max", "shared-key") + authMax.Attributes[AttributeConfigIndex] = "1" + registerCapabilityTestAuth(t, manager, authHigh) + registerCapabilityTestAuth(t, manager, authMax) + + assertResolvedThinkingLevels(t, manager.attachResolvedAPIKeyModelInfo(cliproxyexecutor.Request{}, authHigh, "tenant/public-model", "shared-upstream"), "high") + assertResolvedThinkingLevels(t, manager.attachResolvedAPIKeyModelInfo(cliproxyexecutor.Request{}, authMax, "tenant/public-model", "shared-upstream"), "max") +} + +func TestAttachResolvedAPIKeyModelInfoPrefersExactConfiguredSuffix(t *testing.T) { + manager := NewManager(nil, nil, nil) + auth := configuredCapabilityTestAuth("auth-suffix", "key-suffix") + manager.SetConfig(&internalconfig.Config{ClaudeKey: []internalconfig.ClaudeKey{{ + APIKey: "key-suffix", + Prefix: "tenant", + Models: []internalconfig.ClaudeModel{ + {Name: "shared-upstream(high)", Alias: "public-high", Thinking: ®istry.ThinkingSupport{Levels: []string{"high"}}}, + {Name: "shared-upstream(low)", Alias: "public-low", Thinking: ®istry.ThinkingSupport{Levels: []string{"low"}}}, + {Name: "alias-upstream", Alias: "public(high)", Thinking: ®istry.ThinkingSupport{Levels: []string{"high"}}}, + {Name: "alias-upstream", Alias: "public(low)", Thinking: ®istry.ThinkingSupport{Levels: []string{"low"}}}, + }, + }}}) + registerCapabilityTestAuth(t, manager, auth) + + req := manager.attachResolvedAPIKeyModelInfo(cliproxyexecutor.Request{}, auth, "tenant/public-low", "shared-upstream(low)") + assertResolvedThinkingLevels(t, req, "low") + + models, _, _, routing := manager.executionModelCandidatesWithAlias(auth, "tenant/shared-upstream(low)") + if len(models) != 1 || models[0] != "shared-upstream(low)" { + t.Fatalf("direct suffixed models = %v, want [shared-upstream(low)]", models) + } + directReq := attachResolvedAPIKeyModelInfo(routing, cliproxyexecutor.Request{}, auth, "tenant/shared-upstream(low)", models[0]) + assertResolvedThinkingLevels(t, directReq, "low") + + aliasModels, _, _, aliasRouting := manager.executionModelCandidatesWithAlias(auth, "tenant/public(low)") + if len(aliasModels) != 1 || aliasModels[0] != "alias-upstream(low)" { + t.Fatalf("suffixed alias models = %v, want [alias-upstream(low)]", aliasModels) + } + aliasReq := attachResolvedAPIKeyModelInfo(aliasRouting, cliproxyexecutor.Request{}, auth, "tenant/public(low)", aliasModels[0]) + assertResolvedThinkingLevels(t, aliasReq, "low") +} + +func TestAPIKeyModelRoutingClonesPublishedConfig(t *testing.T) { + manager := NewManager(nil, nil, nil) + cfg := &internalconfig.Config{ClaudeKey: []internalconfig.ClaudeKey{{ + APIKey: "key-clone", + Prefix: "tenant", + Models: []internalconfig.ClaudeModel{{ + Name: "shared-upstream", Alias: "public", + Thinking: ®istry.ThinkingSupport{Levels: []string{"high"}}, + }}, + }}} + manager.SetConfig(cfg) + cfg.ClaudeKey[0].Models[0].Alias = "mutated" + cfg.ClaudeKey[0].Models[0].Thinking.Levels[0] = "max" + + auth := configuredCapabilityTestAuth("auth-clone", "key-clone") + registerCapabilityTestAuth(t, manager, auth) + models, _, _, routing := manager.executionModelCandidatesWithAlias(auth, "tenant/public") + if len(models) != 1 || models[0] != "shared-upstream" { + t.Fatalf("cloned execution models = %v, want [shared-upstream]", models) + } + req := attachResolvedAPIKeyModelInfo(routing, cliproxyexecutor.Request{}, auth, "tenant/public", models[0]) + assertResolvedThinkingLevels(t, req, "high") +} + +func TestAPIKeyModelRoutingKeepsOneExecutionSnapshotAcrossReload(t *testing.T) { + manager := NewManager(nil, nil, nil) + auth := configuredCapabilityTestAuth("auth-reload", "key-reload") + buildConfig := func(level string) *internalconfig.Config { + return &internalconfig.Config{ClaudeKey: []internalconfig.ClaudeKey{{ + APIKey: "key-reload", + Prefix: "tenant", + Models: []internalconfig.ClaudeModel{{ + Name: "shared-upstream", Alias: "public", + Thinking: ®istry.ThinkingSupport{Levels: []string{level}}, + }}, + }}} + } + manager.SetConfig(buildConfig("high")) + registerCapabilityTestAuth(t, manager, auth) + models, _, _, oldRouting := manager.executionModelCandidatesWithAlias(auth, "tenant/public") + if len(models) != 1 || models[0] != "shared-upstream" { + t.Fatalf("execution models = %v, want [shared-upstream]", models) + } + + manager.SetConfig(buildConfig("max")) + oldReq := attachResolvedAPIKeyModelInfo(oldRouting, cliproxyexecutor.Request{}, auth, "tenant/public", models[0]) + assertResolvedThinkingLevels(t, oldReq, "high") + newReq := manager.attachResolvedAPIKeyModelInfo(cliproxyexecutor.Request{}, auth, "tenant/public", models[0]) + assertResolvedThinkingLevels(t, newReq, "max") +} + +func TestAttachResolvedAPIKeyModelInfoSupportsKeylessOpenAICompatibility(t *testing.T) { + manager := NewManager(nil, nil, nil) + manager.SetConfig(&internalconfig.Config{OpenAICompatibility: []internalconfig.OpenAICompatibility{{ + Name: "keyless", + Prefix: "tenant", + BaseURL: "https://example.com/v1", + Models: []internalconfig.OpenAICompatibilityModel{ + { + Name: "shared-upstream", Alias: "public-model", ForceMapping: true, + Thinking: ®istry.ThinkingSupport{Levels: []string{"high"}}, + }, + { + Name: "fallback-upstream", Alias: "public-model", + Thinking: ®istry.ThinkingSupport{Levels: []string{"high"}}, + }, + }, + }}}) + auth := &Auth{ + ID: "auth-keyless", + Provider: "openai-compatibility:keyless", + Prefix: "tenant", + Attributes: map[string]string{ + AttributeSource: "config:keyless[0]", + "compat_name": "keyless", + "provider_key": "openai-compatibility:keyless", + }, + } + registerCapabilityTestAuth(t, manager, auth) + models, _, aliasResult, routing := manager.executionModelCandidatesWithAlias(auth, "tenant/public-model") + if len(models) != 2 || models[0] != "shared-upstream" || models[1] != "fallback-upstream" { + t.Fatalf("keyless execution models = %v, want [shared-upstream fallback-upstream]", models) + } + if !aliasResult.ForceMapping || aliasResult.UpstreamModel != "shared-upstream" { + t.Fatalf("keyless force mapping result = %+v, want shared-upstream force mapping", aliasResult) + } + fallbackAliasResult := resolveAttemptAliasResult(routing, auth, "tenant/public-model", "fallback-upstream", aliasResult) + if fallbackAliasResult.ForceMapping { + t.Fatalf("fallback alias result = %+v, want force mapping disabled", fallbackAliasResult) + } + req := attachResolvedAPIKeyModelInfo(routing, cliproxyexecutor.Request{}, auth, "tenant/public-model", models[0]) + assertResolvedThinkingLevels(t, req, "high") +} + +func TestAttachResolvedAPIKeyModelInfoBindsUnknownConfiguredCapability(t *testing.T) { + manager := NewManager(nil, nil, nil) + auth := configuredCapabilityTestAuth("auth-fallback", "key-fallback") + manager.SetConfig(&internalconfig.Config{ClaudeKey: []internalconfig.ClaudeKey{{ + APIKey: "key-fallback", + Prefix: "tenant", + Models: []internalconfig.ClaudeModel{{Name: "unknown-upstream", Alias: "unknown-public"}}, + }}}) + registerCapabilityTestAuth(t, manager, auth) + + req := manager.attachResolvedAPIKeyModelInfo(cliproxyexecutor.Request{}, auth, "tenant/unknown-public", "unknown-upstream") + info, ok := ResolvedAPIKeyModelInfo(req) + if !ok || info == nil || info.UserDefined || info.Thinking != nil { + t.Fatalf("ResolvedAPIKeyModelInfo() = (%+v, %t), want authoritative empty capability", info, ok) + } + fallbackReq := manager.attachResolvedAPIKeyModelInfo(cliproxyexecutor.Request{}, auth, "tenant/not-configured", "not-configured") + if fallbackInfo, fallbackOK := ResolvedAPIKeyModelInfo(fallbackReq); fallbackOK || fallbackInfo != nil { + t.Fatalf("unconfigured model info = (%+v, %t), want registry fallback", fallbackInfo, fallbackOK) + } +} + +func registerCapabilityTestAuth(t *testing.T, manager *Manager, auth *Auth) { + t.Helper() + registered, errRegister := manager.Register(t.Context(), auth) + if errRegister != nil { + t.Fatalf("Register() error = %v", errRegister) + } + if registered == nil { + t.Fatal("Register() returned nil auth") + } +} + +func configuredCapabilityTestAuth(id, apiKey string) *Auth { + return &Auth{ + ID: id, + Provider: "claude", + Prefix: "tenant", + Attributes: map[string]string{ + AttributeAuthKind: AuthKindAPIKey, + AttributeAPIKey: apiKey, + AttributeSource: "config:claude[0]", + }, + } +} + +func assertResolvedThinkingLevels(t *testing.T, req cliproxyexecutor.Request, want ...string) { + t.Helper() + info, ok := ResolvedAPIKeyModelInfo(req) + if !ok || info == nil || info.Thinking == nil { + t.Fatalf("ResolvedAPIKeyModelInfo() = (%+v, %t), want thinking levels %v", info, ok, want) + } + if len(info.Thinking.Levels) != len(want) { + t.Fatalf("thinking levels = %v, want %v", info.Thinking.Levels, want) + } + for i := range want { + if info.Thinking.Levels[i] != want[i] { + t.Fatalf("thinking levels = %v, want %v", info.Thinking.Levels, want) + } + } +} diff --git a/sdk/cliproxy/auth/classification.go b/sdk/cliproxy/auth/classification.go index f39864bd..f1344fa9 100644 --- a/sdk/cliproxy/auth/classification.go +++ b/sdk/cliproxy/auth/classification.go @@ -15,6 +15,7 @@ const ( AttributeAPIKey = "api_key" AttributeAuthKind = "auth_kind" + AttributeConfigIndex = "config_index" AttributePath = "path" AttributeRuntimeOnly = "runtime_only" AttributeSource = "source" diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index d25524a6..2c08f1f7 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -135,9 +135,8 @@ type Manager struct { // oauthModelAlias stores global OAuth model alias mappings (alias -> upstream name) keyed by channel. oauthModelAlias atomic.Value - // apiKeyModelAlias caches resolved model alias mappings for API-key auths. - // Keyed by auth.ID, value is alias(lower) -> upstream model (including suffix). - apiKeyModelAlias atomic.Value + // apiKeyModelRouting atomically publishes per-auth aliases and configured capabilities. + apiKeyModelRouting atomic.Value // modelPoolOffsets tracks per-auth alias pool rotation state. modelPoolOffsets map[string]int @@ -181,7 +180,7 @@ func NewManager(store Store, selector Selector, hook Hook) *Manager { } // atomic.Value requires non-nil initial value. manager.runtimeConfig.Store(&internalconfig.Config{}) - manager.apiKeyModelAlias.Store(apiKeyModelAliasTable(nil)) + manager.apiKeyModelRouting.Store(&apiKeyModelRoutingSnapshot{config: &internalconfig.Config{}}) defaultInFlightConfig, errInFlightConfig := HomeInFlightPublisherConfigFromConfig(internalconfig.DefaultCredentialInFlightConfig()) if errInFlightConfig == nil { manager.ApplyHomeInFlightPublisherConfig(defaultInFlightConfig) diff --git a/sdk/cliproxy/auth/conductor_cooldown.go b/sdk/cliproxy/auth/conductor_cooldown.go index 7e143ed3..dd7ddc8a 100644 --- a/sdk/cliproxy/auth/conductor_cooldown.go +++ b/sdk/cliproxy/auth/conductor_cooldown.go @@ -120,6 +120,8 @@ func (m *Manager) SetConfigSnapshot(cfg *internalconfig.Config) bool { func (m *Manager) setConfigSnapshotLocked(cfg *internalconfig.Config) bool { if cfg == nil { cfg = &internalconfig.Config{} + } else { + cfg = cfg.CloneForRuntime() } m.mu.RLock() oldCooldownStore := m.cooldownStore diff --git a/sdk/cliproxy/auth/conductor_execution.go b/sdk/cliproxy/auth/conductor_execution.go index 7958604d..a9ca5165 100644 --- a/sdk/cliproxy/auth/conductor_execution.go +++ b/sdk/cliproxy/auth/conductor_execution.go @@ -302,7 +302,7 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req } execCtx = contextWithRequestedModelAlias(execCtx, opts, routeModel) - models, pooled, aliasResult := m.preparedExecutionModelsWithAlias(auth, routeModel) + models, pooled, aliasResult, routing := m.preparedExecutionModelsWithAlias(auth, routeModel) if len(models) == 0 { continue } @@ -330,6 +330,9 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req if errIntercept != nil { return cliproxyexecutor.Response{}, errIntercept } + if !restoreExecutionModel { + execReq = attachResolvedAPIKeyModelInfo(routing, execReq, auth, routeModel, upstreamModel) + } resp, errExec := executor.Execute(execCtx, auth, execReq, execOpts) if errExec != nil { if errCtx := execCtx.Err(); errCtx != nil { @@ -360,7 +363,8 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req continue } m.MarkResult(execCtx, result) - rewriteForceMappedResponse(&resp, aliasResult) + attemptAliasResult := resolveAttemptAliasResult(routing, auth, routeModel, upstreamModel, aliasResult) + rewriteForceMappedResponse(&resp, attemptAliasResult) return resp, nil } if authErr != nil { @@ -419,7 +423,7 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string, } execCtx = contextWithRequestedModelAlias(execCtx, opts, routeModel) - models, pooled, aliasResult := m.preparedExecutionModelsWithAlias(auth, routeModel) + models, pooled, aliasResult, routing := m.preparedExecutionModelsWithAlias(auth, routeModel) if len(models) == 0 { continue } @@ -447,6 +451,9 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string, if errIntercept != nil { return cliproxyexecutor.Response{}, errIntercept } + if !restoreExecutionModel { + execReq = attachResolvedAPIKeyModelInfo(routing, execReq, auth, routeModel, upstreamModel) + } resp, errExec := executor.CountTokens(execCtx, auth, execReq, execOpts) if errExec != nil { if errCtx := execCtx.Err(); errCtx != nil { @@ -485,7 +492,8 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string, continue } m.MarkResult(execCtx, result) - rewriteForceMappedResponse(&resp, aliasResult) + attemptAliasResult := resolveAttemptAliasResult(routing, auth, routeModel, upstreamModel, aliasResult) + rewriteForceMappedResponse(&resp, attemptAliasResult) return resp, nil } if authErr != nil { @@ -579,7 +587,7 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string execCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt) execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt) } - models, pooled, aliasResult := m.preparedExecutionModelsWithAlias(auth, routeModel) + models, pooled, aliasResult, routing := m.preparedExecutionModelsWithAlias(auth, routeModel) if selection != nil && aliasResult.ForceMapping && responseAlias != "" { aliasResult.OriginalAlias = responseAlias } @@ -628,7 +636,7 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string models = models[:1] pooled = false } - streamResult, errStream := m.executeStreamWithModelPool(execCtx, executor, auth, provider, execReq, execOpts, routeModel, streamExecutionModel, models, pooled, aliasResult, !homeMode, selection != nil) + streamResult, errStream := m.executeStreamWithModelPool(execCtx, executor, auth, provider, execReq, execOpts, routeModel, streamExecutionModel, models, pooled, aliasResult, routing, !homeMode, selection != nil) if errStream != nil { if selection != nil { releaseAttempt() diff --git a/sdk/cliproxy/auth/conductor_home.go b/sdk/cliproxy/auth/conductor_home.go index 45ede889..324d2f7e 100644 --- a/sdk/cliproxy/auth/conductor_home.go +++ b/sdk/cliproxy/auth/conductor_home.go @@ -1045,7 +1045,7 @@ func (m *Manager) tryAntigravityCreditsExecute(ctx context.Context, req cliproxy } c.auth = preparedAuth publishSelectedAuthMetadata(creditsOpts.Metadata, c.auth) - models, pooled, aliasResult := m.executionModelCandidatesWithAlias(c.auth, routeModel) + models, pooled, aliasResult, routing := m.executionModelCandidatesWithAlias(c.auth, routeModel) if len(models) == 0 { continue } @@ -1064,7 +1064,8 @@ func (m *Manager) tryAntigravityCreditsExecute(ctx context.Context, req cliproxy continue } m.MarkResult(creditsCtx, result) - rewriteForceMappedResponse(&resp, aliasResult) + attemptAliasResult := resolveAttemptAliasResult(routing, c.auth, routeModel, upstreamModel, aliasResult) + rewriteForceMappedResponse(&resp, attemptAliasResult) return resp, true, nil } } @@ -1099,11 +1100,11 @@ func (m *Manager) tryAntigravityCreditsExecuteStream(ctx context.Context, req cl } c.auth = preparedAuth publishSelectedAuthMetadata(creditsOpts.Metadata, c.auth) - models, pooled, aliasResult := m.executionModelCandidatesWithAlias(c.auth, routeModel) + models, pooled, aliasResult, routing := m.executionModelCandidatesWithAlias(c.auth, routeModel) if len(models) == 0 { continue } - result, errStream := m.executeStreamWithModelPool(creditsCtx, c.executor, c.auth, c.provider, req, creditsOpts, routeModel, "", models, pooled, aliasResult, true, false) + result, errStream := m.executeStreamWithModelPool(creditsCtx, c.executor, c.auth, c.provider, req, creditsOpts, routeModel, "", models, pooled, aliasResult, routing, true, false) if errStream != nil { continue } diff --git a/sdk/cliproxy/auth/conductor_home_execution.go b/sdk/cliproxy/auth/conductor_home_execution.go index c16b909e..dfd14ee0 100644 --- a/sdk/cliproxy/auth/conductor_home_execution.go +++ b/sdk/cliproxy/auth/conductor_home_execution.go @@ -55,7 +55,7 @@ func (m *Manager) executeHome(ctx context.Context, providers []string, req clipr execCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt) execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt) } - models, pooled, aliasResult := m.preparedExecutionModelsWithAlias(auth, routeModel) + models, pooled, aliasResult, routing := m.preparedExecutionModelsWithAlias(auth, routeModel) if aliasResult.ForceMapping && responseAlias != "" { aliasResult.OriginalAlias = responseAlias } @@ -97,6 +97,9 @@ func (m *Manager) executeHome(ctx context.Context, providers []string, req clipr selection.End("request_intercepted") return cliproxyexecutor.Response{}, errIntercept } + if !restoreExecutionModel { + execReq = attachResolvedAPIKeyModelInfo(routing, execReq, preparedAuth, routeModel, upstreamModel) + } if errCtx := execCtx.Err(); errCtx != nil { releaseAttempt() selection.End("attempt_canceled") @@ -113,7 +116,8 @@ func (m *Manager) executeHome(ctx context.Context, providers []string, req clipr if errExecute == nil { m.reportHomeResult(execCtx, result, preparedAuth) releaseAttempt() - rewriteForceMappedResponse(&response, aliasResult) + attemptAliasResult := resolveAttemptAliasResult(routing, preparedAuth, routeModel, upstreamModel, aliasResult) + rewriteForceMappedResponse(&response, attemptAliasResult) if !m.retainHomeWebsocketSelection(ctx, opts, routeModel, selection) { selection.End("completed") } diff --git a/sdk/cliproxy/auth/conductor_models.go b/sdk/cliproxy/auth/conductor_models.go index 900c7343..69c58111 100644 --- a/sdk/cliproxy/auth/conductor_models.go +++ b/sdk/cliproxy/auth/conductor_models.go @@ -2,6 +2,7 @@ package auth import ( "bytes" + "strconv" "strings" "time" @@ -12,7 +13,11 @@ import ( ) func (m *Manager) lookupAPIKeyUpstreamModel(authID, requestedModel string) string { - if m == nil { + return lookupAPIKeyUpstreamModel(m.loadAPIKeyModelRouting(), authID, requestedModel) +} + +func lookupAPIKeyUpstreamModel(routing *apiKeyModelRoutingSnapshot, authID, requestedModel string) string { + if routing == nil { return "" } authID = strings.TrimSpace(authID) @@ -23,23 +28,21 @@ func (m *Manager) lookupAPIKeyUpstreamModel(authID, requestedModel string) strin if requestedModel == "" { return "" } - table, _ := m.apiKeyModelAlias.Load().(apiKeyModelAliasTable) - if table == nil { - return "" - } - byAlias := table[authID] + byAlias := routing.aliases[authID] if len(byAlias) == 0 { return "" } - key := strings.ToLower(thinking.ParseSuffix(requestedModel).ModelName) - if key == "" { - key = strings.ToLower(requestedModel) + keys := []string{strings.ToLower(requestedModel)} + baseKey := strings.ToLower(strings.TrimSpace(thinking.ParseSuffix(requestedModel).ModelName)) + if baseKey != "" && baseKey != keys[0] { + keys = append(keys, baseKey) } - resolved := strings.TrimSpace(byAlias[key]) - if resolved == "" { - return "" + for _, key := range keys { + if resolved := strings.TrimSpace(byAlias[key]); resolved != "" { + return preserveRequestedModelSuffix(requestedModel, resolved) + } } - return preserveRequestedModelSuffix(requestedModel, resolved) + return "" } func isAPIKeyAuth(auth *Auth) bool { @@ -49,8 +52,8 @@ func isAPIKeyAuth(auth *Auth) bool { return auth.AuthKind() == AuthKindAPIKey } -func isOpenAICompatAPIKeyAuth(auth *Auth) bool { - if !isAPIKeyAuth(auth) { +func isConfiguredOpenAICompatAuth(auth *Auth) bool { + if !isConfiguredModelRoutingAuth(auth) { return false } if strings.EqualFold(strings.TrimSpace(auth.Provider), "openai-compatibility") { @@ -126,14 +129,17 @@ func rotateStrings(values []string, offset int) []string { } func (m *Manager) resolveOpenAICompatUpstreamModelPool(auth *Auth, requestedModel string) []string { - if m == nil || !isOpenAICompatAPIKeyAuth(auth) { + return resolveOpenAICompatUpstreamModelPool(m.loadAPIKeyModelRouting().config, auth, requestedModel) +} + +func resolveOpenAICompatUpstreamModelPool(cfg *internalconfig.Config, auth *Auth, requestedModel string) []string { + if !isConfiguredOpenAICompatAuth(auth) { return nil } requestedModel = strings.TrimSpace(requestedModel) if requestedModel == "" { return nil } - cfg, _ := m.runtimeConfig.Load().(*internalconfig.Config) if cfg == nil { cfg = &internalconfig.Config{} } @@ -143,7 +149,7 @@ func (m *Manager) resolveOpenAICompatUpstreamModelPool(auth *Auth, requestedMode providerKey = strings.TrimSpace(auth.Attributes["provider_key"]) compatName = strings.TrimSpace(auth.Attributes["compat_name"]) } - entry := resolveOpenAICompatConfig(cfg, providerKey, compatName, auth.Provider) + entry := resolveOpenAICompatConfigForAuth(cfg, auth, providerKey, compatName) if entry == nil { return nil } @@ -244,14 +250,15 @@ func (m *Manager) preparedExecutionModels(auth *Auth, routeModel string) ([]stri return m.filterExecutionModels(auth, routeModel, candidates, pooled), pooled } -func (m *Manager) preparedExecutionModelsWithAlias(auth *Auth, routeModel string) ([]string, bool, OAuthModelAliasResult) { - candidates, pooled, aliasResult := m.executionModelCandidatesWithAlias(auth, routeModel) - return m.filterExecutionModels(auth, routeModel, candidates, pooled), pooled, aliasResult +func (m *Manager) preparedExecutionModelsWithAlias(auth *Auth, routeModel string) ([]string, bool, OAuthModelAliasResult, *apiKeyModelRoutingSnapshot) { + candidates, pooled, aliasResult, routing := m.executionModelCandidatesWithAlias(auth, routeModel) + return m.filterExecutionModels(auth, routeModel, candidates, pooled), pooled, aliasResult, routing } -func (m *Manager) executionModelCandidatesWithAlias(auth *Auth, routeModel string) ([]string, bool, OAuthModelAliasResult) { +func (m *Manager) executionModelCandidatesWithAlias(auth *Auth, routeModel string) ([]string, bool, OAuthModelAliasResult, *apiKeyModelRoutingSnapshot) { + routing := m.loadAPIKeyModelRouting() requestedModel := rewriteModelForAuth(routeModel, auth) - aliasResult := m.resolveExecutionAliasResultForRequested(auth, requestedModel) + aliasResult := m.resolveExecutionAliasResultForRequestedWithRouting(routing, auth, requestedModel) if aliasResult.ForceMapping && auth != nil && auth.Attributes != nil && strings.EqualFold(strings.TrimSpace(auth.Attributes[homeForceMappingAttributeKey]), "true") { aliasResult.OriginalAlias = strings.TrimSpace(routeModel) } @@ -264,7 +271,7 @@ func (m *Manager) executionModelCandidatesWithAlias(auth *Auth, routeModel strin } } if len(candidates) == 0 { - if pool := m.resolveOpenAICompatUpstreamModelPool(auth, upstreamModel); len(pool) > 0 { + if pool := resolveOpenAICompatUpstreamModelPool(routing.config, auth, upstreamModel); len(pool) > 0 { if len(pool) == 1 { candidates = pool } else { @@ -272,7 +279,7 @@ func (m *Manager) executionModelCandidatesWithAlias(auth *Auth, routeModel strin candidates = rotateStrings(pool, offset) } } else { - resolved := m.applyAPIKeyModelAlias(auth, upstreamModel) + resolved := m.applyAPIKeyModelAliasWithRouting(routing, auth, upstreamModel) if strings.TrimSpace(resolved) == "" { resolved = upstreamModel } @@ -280,7 +287,7 @@ func (m *Manager) executionModelCandidatesWithAlias(auth *Auth, routeModel strin } } pooled := len(candidates) > 1 - return candidates, pooled, aliasResult + return candidates, pooled, aliasResult, routing } func (m *Manager) resolveExecutionAliasResult(auth *Auth, routeModel string) OAuthModelAliasResult { @@ -289,11 +296,15 @@ func (m *Manager) resolveExecutionAliasResult(auth *Auth, routeModel string) OAu } func (m *Manager) resolveExecutionAliasResultForRequested(auth *Auth, requestedModel string) OAuthModelAliasResult { + return m.resolveExecutionAliasResultForRequestedWithRouting(m.loadAPIKeyModelRouting(), auth, requestedModel) +} + +func (m *Manager) resolveExecutionAliasResultForRequestedWithRouting(routing *apiKeyModelRoutingSnapshot, auth *Auth, requestedModel string) OAuthModelAliasResult { if result := homeForceMappingAliasResult(auth, requestedModel); result.ForceMapping { return result } - if auth != nil && auth.AuthKind() == AuthKindAPIKey { - return m.resolveAPIKeyModelAliasWithResult(auth, requestedModel) + if isConfiguredModelRoutingAuth(auth) { + return resolveAPIKeyModelAliasWithResult(routing.config, auth, requestedModel) } return m.applyOAuthModelAliasWithResult(auth, requestedModel) } @@ -320,7 +331,7 @@ func homeForceMappingAliasResult(auth *Auth, requestedModel string) OAuthModelAl } func executionAliasPoolModel(auth *Auth, requestedModel string, aliasResult OAuthModelAliasResult) string { - if auth != nil && auth.AuthKind() == AuthKindAPIKey { + if isConfiguredModelRoutingAuth(auth) { if strings.TrimSpace(requestedModel) != "" { return requestedModel } @@ -332,17 +343,35 @@ func executionAliasPoolModel(auth *Auth, requestedModel string, aliasResult OAut } func (m *Manager) resolveAPIKeyModelAliasWithResult(auth *Auth, requestedModel string) OAuthModelAliasResult { - if m == nil || auth == nil { + return resolveAPIKeyModelAliasWithResult(m.loadAPIKeyModelRouting().config, auth, requestedModel) +} + +func resolveAPIKeyModelAliasWithResult(cfg *internalconfig.Config, auth *Auth, requestedModel string) OAuthModelAliasResult { + if auth == nil { return OAuthModelAliasResult{} } requestedModel = strings.TrimSpace(requestedModel) if requestedModel == "" { return OAuthModelAliasResult{} } - cfg, _ := m.runtimeConfig.Load().(*internalconfig.Config) if cfg == nil { cfg = &internalconfig.Config{} } + models := configuredModelAliasEntries(cfg, auth) + if len(models) == 0 { + return OAuthModelAliasResult{UpstreamModel: requestedModel} + } + result := resolveModelAliasResultFromConfigModels(requestedModel, models) + if strings.TrimSpace(result.UpstreamModel) == "" { + return OAuthModelAliasResult{UpstreamModel: requestedModel} + } + return result +} + +func configuredModelAliasEntries(cfg *internalconfig.Config, auth *Auth) []modelAliasEntry { + if cfg == nil || auth == nil { + return nil + } provider := strings.ToLower(strings.TrimSpace(auth.Provider)) var models []modelAliasEntry switch provider { @@ -378,17 +407,46 @@ func (m *Manager) resolveAPIKeyModelAliasWithResult(auth *Auth, requestedModel s compatName = strings.TrimSpace(auth.Attributes["compat_name"]) } if compatName != "" || strings.EqualFold(strings.TrimSpace(auth.Provider), "openai-compatibility") { - if entry := resolveOpenAICompatConfig(cfg, providerKey, compatName, auth.Provider); entry != nil { + if entry := resolveOpenAICompatConfigForAuth(cfg, auth, providerKey, compatName); entry != nil { models = asModelAliasEntries(entry.Models) } } } - if len(models) == 0 { - return OAuthModelAliasResult{UpstreamModel: requestedModel} + return models +} + +func resolveModelAliasResultForUpstream(cfg *internalconfig.Config, auth *Auth, requestedModel, upstreamModel string) OAuthModelAliasResult { + requestedModel = strings.TrimSpace(requestedModel) + upstreamModel = strings.TrimSpace(upstreamModel) + if requestedModel == "" || upstreamModel == "" { + return OAuthModelAliasResult{} } - result := resolveModelAliasResultFromConfigModels(requestedModel, models) + requestResult := thinking.ParseSuffix(requestedModel) + models := configuredModelAliasEntries(cfg, auth) + filtered := make([]modelAliasEntry, 0, 1) + for _, model := range models { + name := strings.TrimSpace(model.GetName()) + if name != "" && strings.EqualFold(preserveResolvedModelSuffix(name, requestResult), upstreamModel) { + filtered = append(filtered, model) + } + } + if len(filtered) == 0 { + return OAuthModelAliasResult{} + } + return resolveModelAliasResultFromConfigModels(requestedModel, filtered) +} + +func resolveAttemptAliasResult(routing *apiKeyModelRoutingSnapshot, auth *Auth, routeModel, upstreamModel string, fallback OAuthModelAliasResult) OAuthModelAliasResult { + if routing == nil || !isConfiguredModelRoutingAuth(auth) { + return fallback + } + requestedModel := rewriteModelForAuth(routeModel, auth) + result := resolveModelAliasResultForUpstream(routing.config, auth, requestedModel, upstreamModel) if strings.TrimSpace(result.UpstreamModel) == "" { - return OAuthModelAliasResult{UpstreamModel: requestedModel} + return fallback + } + if result.ForceMapping && fallback.ForceMapping && strings.TrimSpace(fallback.OriginalAlias) != "" { + result.OriginalAlias = fallback.OriginalAlias } return result } @@ -435,12 +493,12 @@ func (m *Manager) rebuildAPIKeyModelAliasFromRuntimeConfig() { if m == nil { return } + m.mu.Lock() + defer m.mu.Unlock() cfg, _ := m.runtimeConfig.Load().(*internalconfig.Config) if cfg == nil { cfg = &internalconfig.Config{} } - m.mu.Lock() - defer m.mu.Unlock() m.rebuildAPIKeyModelAliasLocked(cfg) } @@ -458,6 +516,7 @@ func (m *Manager) rebuildAPIKeyModelAliasLocked(cfg *internalconfig.Config) { } out := make(apiKeyModelAliasTable) + capabilities := make(apiKeyModelCapabilityTable) for _, auth := range m.auths { if auth == nil { continue @@ -465,7 +524,7 @@ func (m *Manager) rebuildAPIKeyModelAliasLocked(cfg *internalconfig.Config) { if strings.TrimSpace(auth.ID) == "" { continue } - if auth.AuthKind() != AuthKindAPIKey { + if !isConfiguredModelRoutingAuth(auth) { continue } @@ -505,7 +564,7 @@ func (m *Manager) rebuildAPIKeyModelAliasLocked(cfg *internalconfig.Config) { compatName = strings.TrimSpace(auth.Attributes["compat_name"]) } if compatName != "" || strings.EqualFold(strings.TrimSpace(auth.Provider), "openai-compatibility") { - if entry := resolveOpenAICompatConfig(cfg, providerKey, compatName, auth.Provider); entry != nil { + if entry := resolveOpenAICompatConfigForAuth(cfg, auth, providerKey, compatName); entry != nil { compileAPIKeyModelAliasForModels(byAlias, entry.Models) } } @@ -514,9 +573,16 @@ func (m *Manager) rebuildAPIKeyModelAliasLocked(cfg *internalconfig.Config) { if len(byAlias) > 0 { out[auth.ID] = byAlias } + if byCapability := compileAPIKeyModelCapabilitiesForAuth(cfg, auth); len(byCapability) > 0 { + capabilities[auth.ID] = byCapability + } } - m.apiKeyModelAlias.Store(out) + m.apiKeyModelRouting.Store(&apiKeyModelRoutingSnapshot{ + config: cfg, + aliases: out, + capabilities: capabilities, + }) } func compileAPIKeyModelAliasForModels[T interface { @@ -526,42 +592,27 @@ func compileAPIKeyModelAliasForModels[T interface { if out == nil { return } + add := func(key, name string) { + key = strings.ToLower(strings.TrimSpace(key)) + if key == "" { + return + } + if _, exists := out[key]; !exists { + out[key] = name + } + } for i := range models { alias := strings.TrimSpace(models[i].GetAlias()) name := strings.TrimSpace(models[i].GetName()) if alias == "" || name == "" { continue } - aliasKey := strings.ToLower(thinking.ParseSuffix(alias).ModelName) - if aliasKey == "" { - aliasKey = strings.ToLower(alias) - } - // Config priority: first alias wins. - if _, exists := out[aliasKey]; exists { - continue - } - out[aliasKey] = name - // Also allow direct lookup by upstream name (case-insensitive), so lookups on already-upstream - // models remain a cheap no-op. - nameKey := strings.ToLower(thinking.ParseSuffix(name).ModelName) - if nameKey == "" { - nameKey = strings.ToLower(name) - } - if nameKey != "" { - if _, exists := out[nameKey]; !exists { - out[nameKey] = name - } - } - // Preserve config suffix priority by seeding a base-name lookup when name already has suffix. - nameResult := thinking.ParseSuffix(name) - if nameResult.HasSuffix { - baseKey := strings.ToLower(strings.TrimSpace(nameResult.ModelName)) - if baseKey != "" { - if _, exists := out[baseKey]; !exists { - out[baseKey] = name - } - } - } + // Exact suffix routes are retained alongside first-entry base fallbacks. + add(alias, name) + add(thinking.ParseSuffix(alias).ModelName, name) + // Direct upstream requests use the same exact-first lookup behavior. + add(name, name) + add(thinking.ParseSuffix(name).ModelName, name) } } @@ -581,7 +632,11 @@ func rewriteModelForAuth(model string, auth *Auth) string { } func (m *Manager) applyAPIKeyModelAlias(auth *Auth, requestedModel string) string { - if m == nil || auth == nil { + return m.applyAPIKeyModelAliasWithRouting(m.loadAPIKeyModelRouting(), auth, requestedModel) +} + +func (m *Manager) applyAPIKeyModelAliasWithRouting(routing *apiKeyModelRoutingSnapshot, auth *Auth, requestedModel string) string { + if auth == nil { return requestedModel } @@ -595,13 +650,12 @@ func (m *Manager) applyAPIKeyModelAlias(auth *Auth, requestedModel string) strin } // Fast path: lookup per-auth mapping table (keyed by auth.ID). - if resolved := m.lookupAPIKeyUpstreamModel(auth.ID, requestedModel); resolved != "" { + if resolved := lookupAPIKeyUpstreamModel(routing, auth.ID, requestedModel); resolved != "" { return resolved } - // Slow path: scan config for the matching credential entry and resolve alias. - // This acts as a safety net if mappings are stale or auth.ID is missing. - cfg, _ := m.runtimeConfig.Load().(*internalconfig.Config) + // Slow path: scan the same config snapshot used to compile the alias table. + cfg := routing.config if cfg == nil { cfg = &internalconfig.Config{} } @@ -636,6 +690,8 @@ func (m *Manager) applyAPIKeyModelAlias(auth *Auth, requestedModel string) strin type APIKeyConfigEntry interface { GetAPIKey() string GetBaseURL() string + GetPrefix() string + GetProxyURL() string } func resolveAPIKeyConfig[T APIKeyConfigEntry](entries []T, auth *Auth) *T { @@ -644,33 +700,40 @@ func resolveAPIKeyConfig[T APIKeyConfigEntry](entries []T, auth *Auth) *T { } attrKey, attrBase := "", "" if auth.Attributes != nil { - attrKey = strings.TrimSpace(auth.Attributes["api_key"]) + attrKey = strings.TrimSpace(auth.Attributes[AttributeAPIKey]) attrBase = strings.TrimSpace(auth.Attributes["base_url"]) } - for i := range entries { - entry := &entries[i] - cfgKey := strings.TrimSpace((*entry).GetAPIKey()) - cfgBase := strings.TrimSpace((*entry).GetBaseURL()) + matchesCredentials := func(entry T) bool { + cfgKey := strings.TrimSpace(entry.GetAPIKey()) + cfgBase := strings.TrimSpace(entry.GetBaseURL()) if attrKey != "" && attrBase != "" { - if strings.EqualFold(cfgKey, attrKey) && strings.EqualFold(cfgBase, attrBase) { - return entry - } - continue + return strings.EqualFold(cfgKey, attrKey) && strings.EqualFold(cfgBase, attrBase) } - if attrKey != "" && strings.EqualFold(cfgKey, attrKey) { - if cfgBase == "" || strings.EqualFold(cfgBase, attrBase) { - return entry - } + if attrKey != "" { + return strings.EqualFold(cfgKey, attrKey) && (cfgBase == "" || strings.EqualFold(cfgBase, attrBase)) } - if attrKey == "" && attrBase != "" && strings.EqualFold(cfgBase, attrBase) { - return entry + return attrBase != "" && strings.EqualFold(cfgBase, attrBase) + } + if auth.AuthSourceKind() == AuthSourceConfig && auth.Attributes != nil { + if index, errIndex := strconv.Atoi(strings.TrimSpace(auth.Attributes[AttributeConfigIndex])); errIndex == nil && index >= 0 && index < len(entries) && matchesCredentials(entries[index]) { + return &entries[index] + } + } + for i := range entries { + entry := entries[i] + if matchesCredentials(entry) && strings.EqualFold(strings.TrimSpace(entry.GetPrefix()), strings.TrimSpace(auth.Prefix)) && strings.EqualFold(strings.TrimSpace(entry.GetProxyURL()), strings.TrimSpace(auth.ProxyURL)) { + return &entries[i] + } + } + for i := range entries { + if matchesCredentials(entries[i]) { + return &entries[i] } } if attrKey != "" { for i := range entries { - entry := &entries[i] - if strings.EqualFold(strings.TrimSpace((*entry).GetAPIKey()), attrKey) { - return entry + if strings.EqualFold(strings.TrimSpace(entries[i].GetAPIKey()), attrKey) { + return &entries[i] } } } @@ -777,7 +840,7 @@ func resolveUpstreamModelForOpenAICompatAPIKey(cfg *internalconfig.Config, auth if compatName == "" && !strings.EqualFold(strings.TrimSpace(auth.Provider), "openai-compatibility") { return "" } - entry := resolveOpenAICompatConfig(cfg, providerKey, compatName, auth.Provider) + entry := resolveOpenAICompatConfigForAuth(cfg, auth, providerKey, compatName) if entry == nil { return "" } @@ -786,6 +849,22 @@ func resolveUpstreamModelForOpenAICompatAPIKey(cfg *internalconfig.Config, auth type apiKeyModelAliasTable map[string]map[string]string +func resolveOpenAICompatConfigForAuth(cfg *internalconfig.Config, auth *Auth, providerKey, compatName string) *internalconfig.OpenAICompatibility { + if cfg == nil { + return nil + } + if auth != nil && auth.AuthSourceKind() == AuthSourceConfig && auth.Attributes != nil { + if index, errIndex := strconv.Atoi(strings.TrimSpace(auth.Attributes[AttributeConfigIndex])); errIndex == nil && index >= 0 && index < len(cfg.OpenAICompatibility) && !cfg.OpenAICompatibility[index].Disabled { + return &cfg.OpenAICompatibility[index] + } + } + authProvider := "" + if auth != nil { + authProvider = auth.Provider + } + return resolveOpenAICompatConfig(cfg, providerKey, compatName, authProvider) +} + func resolveOpenAICompatConfig(cfg *internalconfig.Config, providerKey, compatName, authProvider string) *internalconfig.OpenAICompatibility { if cfg == nil { return nil diff --git a/sdk/cliproxy/auth/conductor_stream.go b/sdk/cliproxy/auth/conductor_stream.go index 551dc3ef..be6784af 100644 --- a/sdk/cliproxy/auth/conductor_stream.go +++ b/sdk/cliproxy/auth/conductor_stream.go @@ -180,7 +180,7 @@ func (m *Manager) wrapStreamResult(ctx context.Context, auth *Auth, provider, re return &cliproxyexecutor.StreamResult{Headers: headers, Chunks: out} } -func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor ProviderExecutor, auth *Auth, provider string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, routeModel, executionModel string, execModels []string, pooled bool, aliasResult OAuthModelAliasResult, allowRetry bool, ephemeralResult bool) (*cliproxyexecutor.StreamResult, error) { +func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor ProviderExecutor, auth *Auth, provider string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, routeModel, executionModel string, execModels []string, pooled bool, aliasResult OAuthModelAliasResult, routing *apiKeyModelRoutingSnapshot, allowRetry bool, ephemeralResult bool) (*cliproxyexecutor.StreamResult, error) { if executor == nil { return nil, &Error{Code: "executor_not_found", Message: "executor not registered"} } @@ -200,6 +200,9 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi if errIntercept != nil { return nil, errIntercept } + if executionModel == "" { + execReq = attachResolvedAPIKeyModelInfo(routing, execReq, auth, routeModel, execModel) + } if errCtx := ctx.Err(); errCtx != nil { return nil, errCtx } @@ -304,7 +307,8 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi close(closedCh) remaining = closedCh } - return m.wrapStreamResult(ctx, auth.Clone(), provider, resultModel, streamResult.Headers, buffered, remaining, aliasResult, ephemeralResult), nil + attemptAliasResult := resolveAttemptAliasResult(routing, auth, routeModel, execModel, aliasResult) + return m.wrapStreamResult(ctx, auth.Clone(), provider, resultModel, streamResult.Headers, buffered, remaining, attemptAliasResult, ephemeralResult), nil } if lastErr == nil { lastErr = &Error{Code: "auth_not_found", Message: "no upstream model available"} diff --git a/sdk/cliproxy/auth/oauth_model_alias.go b/sdk/cliproxy/auth/oauth_model_alias.go index 25b8a2ea..f6f853a6 100644 --- a/sdk/cliproxy/auth/oauth_model_alias.go +++ b/sdk/cliproxy/auth/oauth_model_alias.go @@ -112,9 +112,9 @@ func modelAliasLookupCandidates(requestedModel string) (thinking.SuffixResult, [ if base == "" { base = requestedModel } - candidates := []string{base} + candidates := []string{requestedModel} if base != requestedModel { - candidates = append(candidates, requestedModel) + candidates = append(candidates, base) } return requestResult, candidates } @@ -151,12 +151,12 @@ func resolveModelAliasPoolFromConfigModels(requestedModel string, models []model return nil } - out := make([]string, 0) - seen := make(map[string]struct{}) - for i := range models { - name := strings.TrimSpace(models[i].GetName()) - alias := strings.TrimSpace(models[i].GetAlias()) - for _, candidate := range candidates { + for _, candidate := range candidates { + out := make([]string, 0) + seen := make(map[string]struct{}) + for i := range models { + name := strings.TrimSpace(models[i].GetName()) + alias := strings.TrimSpace(models[i].GetAlias()) if candidate == "" || alias == "" || !strings.EqualFold(alias, candidate) { continue } @@ -167,23 +167,22 @@ func resolveModelAliasPoolFromConfigModels(requestedModel string, models []model resolved = preserveResolvedModelSuffix(resolved, requestResult) key := strings.ToLower(strings.TrimSpace(resolved)) if key == "" { - break + continue } if _, exists := seen[key]; exists { - break + continue } seen[key] = struct{}{} out = append(out, resolved) - break } - } - if len(out) > 0 { - return out + if len(out) > 0 { + return out + } } - for i := range models { - name := strings.TrimSpace(models[i].GetName()) - for _, candidate := range candidates { + for _, candidate := range candidates { + for i := range models { + name := strings.TrimSpace(models[i].GetName()) if candidate == "" || name == "" || !strings.EqualFold(name, candidate) { continue } @@ -214,15 +213,15 @@ func resolveModelAliasResultFromConfigModels(requestedModel string, models []mod if baseModel == "" { baseModel = requestedModel } - for i := range models { - original := strings.TrimSpace(models[i].GetName()) - alias := strings.TrimSpace(models[i].GetAlias()) - if original == "" || alias == "" { + for _, candidate := range candidates { + key := strings.TrimSpace(candidate) + if key == "" { continue } - for _, candidate := range candidates { - key := strings.TrimSpace(candidate) - if key == "" || !strings.EqualFold(alias, key) { + for i := range models { + original := strings.TrimSpace(models[i].GetName()) + alias := strings.TrimSpace(models[i].GetAlias()) + if original == "" || alias == "" || !strings.EqualFold(alias, key) { continue } if strings.EqualFold(original, baseModel) { @@ -343,15 +342,15 @@ func resolveUpstreamModelFromAliases(aliases []internalconfig.OAuthModelAlias, r if baseModel == "" { baseModel = strings.TrimSpace(requestedModel) } - for _, entry := range aliases { - original := strings.TrimSpace(entry.Name) - alias := strings.TrimSpace(entry.Alias) - if original == "" || alias == "" { + for _, candidate := range candidates { + key := strings.TrimSpace(candidate) + if key == "" { continue } - for _, candidate := range candidates { - key := strings.TrimSpace(candidate) - if key == "" || !strings.EqualFold(alias, key) { + for _, entry := range aliases { + original := strings.TrimSpace(entry.Name) + alias := strings.TrimSpace(entry.Alias) + if original == "" || alias == "" || !strings.EqualFold(alias, key) { continue } if strings.EqualFold(original, baseModel) { @@ -394,14 +393,9 @@ func resolveUpstreamModelFromAliasTable(m *Manager, auth *Auth, requestedModel, return OAuthModelAliasResult{} } - requestResult := thinking.ParseSuffix(requestedModel) + requestResult, candidates := modelAliasLookupCandidates(requestedModel) baseModel := requestResult.ModelName - candidates := []string{baseModel} - if baseModel != requestedModel { - candidates = append(candidates, requestedModel) - } - raw := m.oauthModelAlias.Load() table, _ := raw.(*oauthModelAliasTable) if table == nil || table.reverse == nil { diff --git a/sdk/cliproxy/auth/oauth_model_alias_test.go b/sdk/cliproxy/auth/oauth_model_alias_test.go index e329b525..6a393f8d 100644 --- a/sdk/cliproxy/auth/oauth_model_alias_test.go +++ b/sdk/cliproxy/auth/oauth_model_alias_test.go @@ -352,6 +352,22 @@ func TestApplyOAuthModelAliasWithResult_ForceMappingUsesConfigAliasNotRequestSuf t.Fatalf("OriginalAlias = %q want gpt-5.4-fast", res.OriginalAlias) } } +func TestApplyOAuthModelAliasWithResultPrefersExactSuffixedAlias(t *testing.T) { + t.Parallel() + manager := NewManager(nil, nil, nil) + manager.SetOAuthModelAlias(map[string][]internalconfig.OAuthModelAlias{ + "codex": { + {Name: "base-upstream", Alias: "public", Fork: true}, + {Name: "low-upstream", Alias: "public(low)", Fork: true, ForceMapping: true}, + }, + }) + auth := &Auth{ID: "exact-suffix", Provider: "codex"} + result := manager.applyOAuthModelAliasWithResult(auth, "public(low)") + if result.UpstreamModel != "low-upstream(low)" || !result.ForceMapping { + t.Fatalf("exact suffixed alias result = %+v, want low-upstream(low) with force mapping", result) + } +} + func TestApplyOAuthModelAliasWithResult_NoForceMappingPreservesRequestedModelInOriginalAlias(t *testing.T) { t.Parallel() mgr := NewManager(nil, nil, nil) diff --git a/sdk/cliproxy/auth/openai_compat_pool_test.go b/sdk/cliproxy/auth/openai_compat_pool_test.go index d421a9e8..bce2306a 100644 --- a/sdk/cliproxy/auth/openai_compat_pool_test.go +++ b/sdk/cliproxy/auth/openai_compat_pool_test.go @@ -256,6 +256,21 @@ func TestResolveModelAliasPoolFromConfigModels(t *testing.T) { } } +func TestResolveModelAliasPoolPrefersExactSuffixedAlias(t *testing.T) { + models := []modelAliasEntry{ + internalconfig.OpenAICompatibilityModel{Name: "base-model", Alias: "public"}, + internalconfig.OpenAICompatibilityModel{Name: "low-model", Alias: "public(low)", ForceMapping: true}, + } + got := resolveModelAliasPoolFromConfigModels("public(low)", models) + if len(got) != 1 || got[0] != "low-model(low)" { + t.Fatalf("exact suffixed pool = %v, want [low-model(low)]", got) + } + result := resolveModelAliasResultFromConfigModels("public(low)", models) + if result.UpstreamModel != "low-model(low)" || !result.ForceMapping { + t.Fatalf("exact suffixed alias result = %+v, want low-model(low) with force mapping", result) + } +} + func TestManagerExecute_OpenAICompatAliasPoolRotatesWithinAuth(t *testing.T) { alias := "claude-opus-4.66" executor := &openAICompatPoolExecutor{id: openAICompatPoolProviderKey} @@ -453,6 +468,27 @@ func TestManagerExecute_OpenAICompatAliasPoolFallsBackWithinSameAuth(t *testing. } } +func TestManagerExecute_OpenAICompatAliasPoolUsesSelectedModelForceMapping(t *testing.T) { + alias := "public-model" + executor := &openAICompatPoolExecutor{ + id: openAICompatPoolProviderKey, + executeErrors: map[string]error{"first-upstream": &Error{HTTPStatus: http.StatusTooManyRequests, Message: "quota"}}, + executePayloads: map[string][]byte{"second-upstream": []byte(`{"model":"second-upstream"}`)}, + } + manager := newOpenAICompatPoolTestManager(t, alias, []internalconfig.OpenAICompatibilityModel{ + {Name: "first-upstream", Alias: alias, ForceMapping: true}, + {Name: "second-upstream", Alias: alias}, + }, executor) + + response, errExecute := manager.Execute(context.Background(), []string{openAICompatPoolProviderKey}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + if got := string(response.Payload); got != `{"model":"second-upstream"}` { + t.Fatalf("payload = %s, want selected model without force mapping", got) + } +} + func TestManagerExecuteStream_OpenAICompatAliasPoolRetriesOnEmptyBootstrap(t *testing.T) { alias := "claude-opus-4.66" executor := &openAICompatPoolExecutor{ diff --git a/sdk/cliproxy/service_executors.go b/sdk/cliproxy/service_executors.go index 1676a38e..371ed6a9 100644 --- a/sdk/cliproxy/service_executors.go +++ b/sdk/cliproxy/service_executors.go @@ -2,6 +2,7 @@ package cliproxy import ( "context" + "strconv" "strings" "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" @@ -12,7 +13,8 @@ import ( ) type openAICompatibilityRegistrationCache struct { - byName map[string]*openAICompatibilityRegistrationEntry + byName map[string]*openAICompatibilityRegistrationEntry + byIndex map[int]*openAICompatibilityRegistrationEntry } type openAICompatibilityRegistrationEntry struct { @@ -32,7 +34,8 @@ func (s *Service) newOpenAICompatibilityRegistrationCache() *openAICompatibility } cache := &openAICompatibilityRegistrationCache{ - byName: make(map[string]*openAICompatibilityRegistrationEntry, len(cfg.OpenAICompatibility)), + byName: make(map[string]*openAICompatibilityRegistrationEntry, len(cfg.OpenAICompatibility)), + byIndex: make(map[int]*openAICompatibilityRegistrationEntry, len(cfg.OpenAICompatibility)), } for i := range cfg.OpenAICompatibility { compat := &cfg.OpenAICompatibility[i] @@ -41,17 +44,18 @@ func (s *Service) newOpenAICompatibilityRegistrationCache() *openAICompatibility } compatName := strings.TrimSpace(compat.Name) key := strings.ToLower(compatName) - if _, exists := cache.byName[key]; exists { - continue - } providerName := strings.ToLower(compatName) if providerName == "" { providerName = "openai-compatibility" } - cache.byName[key] = &openAICompatibilityRegistrationEntry{ + entry := &openAICompatibilityRegistrationEntry{ providerKey: util.OpenAICompatibleProviderKey(providerName), models: buildOpenAICompatibilityConfigModels(compat), } + cache.byIndex[i] = entry + if _, exists := cache.byName[key]; !exists { + cache.byName[key] = entry + } } if len(cache.byName) == 0 { return nil @@ -59,10 +63,16 @@ func (s *Service) newOpenAICompatibilityRegistrationCache() *openAICompatibility return cache } -func (c *openAICompatibilityRegistrationCache) lookup(compatName string) (*openAICompatibilityRegistrationEntry, bool) { - if c == nil || len(c.byName) == 0 { +func (c *openAICompatibilityRegistrationCache) lookup(auth *coreauth.Auth, compatName string) (*openAICompatibilityRegistrationEntry, bool) { + if c == nil { return nil, false } + if auth != nil && auth.AuthSourceKind() == coreauth.AuthSourceConfig && auth.Attributes != nil { + if index, errIndex := strconv.Atoi(strings.TrimSpace(auth.Attributes[coreauth.AttributeConfigIndex])); errIndex == nil { + entry, ok := c.byIndex[index] + return entry, ok + } + } entry, ok := c.byName[strings.ToLower(strings.TrimSpace(compatName))] return entry, ok } diff --git a/sdk/cliproxy/service_models.go b/sdk/cliproxy/service_models.go index f54aa069..34994a5e 100644 --- a/sdk/cliproxy/service_models.go +++ b/sdk/cliproxy/service_models.go @@ -2,10 +2,12 @@ package cliproxy import ( "context" + "strconv" "strings" "time" "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/modelconfig" "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" @@ -191,7 +193,29 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut isCompatAuth = true } } - if cached, ok := compatCache.lookup(compatName); ok { + registerCompat := func(compat *config.OpenAICompatibility) bool { + if compat == nil || compat.Disabled { + return false + } + isCompatAuth = true + ms := buildOpenAICompatibilityConfigModels(compat) + if providerKey == "" { + providerKey = "openai-compatibility" + } + if len(ms) > 0 { + ms = s.appendPluginModels(providerKey, ms) + s.registerResolvedModelsForAuth(a, providerKey, applyModelPrefixes(ms, a.Prefix, s.cfg.ForceModelPrefix)) + } else { + ms = s.appendPluginModels(providerKey, nil) + if len(ms) > 0 { + s.registerResolvedModelsForAuth(a, providerKey, applyModelPrefixes(ms, a.Prefix, s.cfg.ForceModelPrefix)) + } else { + GlobalModelRegistry().UnregisterClient(a.ID) + } + } + return true + } + if cached, ok := compatCache.lookup(a, compatName); ok { isCompatAuth = true if providerKey == "" { providerKey = cached.providerKey @@ -213,30 +237,12 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut } return } + if indexed := configEntryForAuthIndex(a, s.cfg.OpenAICompatibility); indexed != nil && registerCompat(indexed) { + return + } for i := range s.cfg.OpenAICompatibility { compat := &s.cfg.OpenAICompatibility[i] - if compat.Disabled { - continue - } - if strings.EqualFold(compat.Name, compatName) { - isCompatAuth = true - ms := buildOpenAICompatibilityConfigModels(compat) - // Register and return - if len(ms) > 0 { - if providerKey == "" { - providerKey = "openai-compatibility" - } - ms = s.appendPluginModels(providerKey, ms) - s.registerResolvedModelsForAuth(a, providerKey, applyModelPrefixes(ms, a.Prefix, s.cfg.ForceModelPrefix)) - } else { - // Ensure stale registrations are cleared when model list becomes empty. - ms = s.appendPluginModels(providerKey, nil) - if len(ms) > 0 { - s.registerResolvedModelsForAuth(a, providerKey, applyModelPrefixes(ms, a.Prefix, s.cfg.ForceModelPrefix)) - } else { - GlobalModelRegistry().UnregisterClient(a.ID) - } - } + if strings.EqualFold(compat.Name, compatName) && registerCompat(compat) { return } } @@ -340,10 +346,24 @@ func (s *Service) latestAuthForModelRegistration(authID string) (*coreauth.Auth, return auth, true } +func configEntryForAuthIndex[T any](auth *coreauth.Auth, entries []T) *T { + if auth == nil || auth.AuthSourceKind() != coreauth.AuthSourceConfig || auth.Attributes == nil { + return nil + } + index, errIndex := strconv.Atoi(strings.TrimSpace(auth.Attributes[coreauth.AttributeConfigIndex])) + if errIndex != nil || index < 0 || index >= len(entries) { + return nil + } + return &entries[index] +} + func (s *Service) resolveConfigClaudeKey(auth *coreauth.Auth) *config.ClaudeKey { if auth == nil || s.cfg == nil { return nil } + if entry := configEntryForAuthIndex(auth, s.cfg.ClaudeKey); entry != nil { + return entry + } var attrKey, attrBase string if auth.Attributes != nil { attrKey = strings.TrimSpace(auth.Attributes["api_key"]) @@ -397,6 +417,9 @@ func (s *Service) resolveConfigGeminiKeyEntry(auth *coreauth.Auth, entries []con if auth == nil || s.cfg == nil { return nil } + if entry := configEntryForAuthIndex(auth, entries); entry != nil { + return entry + } var attrKey, attrBase string if auth.Attributes != nil { attrKey = strings.TrimSpace(auth.Attributes["api_key"]) @@ -423,6 +446,9 @@ func (s *Service) resolveConfigVertexCompatKey(auth *coreauth.Auth) *config.Vert if auth == nil || s.cfg == nil { return nil } + if entry := configEntryForAuthIndex(auth, s.cfg.VertexCompatAPIKey); entry != nil { + return entry + } var attrKey, attrBase string if auth.Attributes != nil { attrKey = strings.TrimSpace(auth.Attributes["api_key"]) @@ -471,6 +497,9 @@ func resolveConfigCodexStyleKey(auth *coreauth.Auth, entries []config.CodexKey) if auth == nil { return nil } + if entry := configEntryForAuthIndex(auth, entries); entry != nil { + return entry + } var attrKey, attrBase string if auth.Attributes != nil { attrKey = strings.TrimSpace(auth.Attributes["api_key"]) @@ -631,6 +660,7 @@ type modelEntry interface { GetName() string GetAlias() string GetDisplayName() string + GetThinking() *registry.ThinkingSupport } func buildConfiguredModelInfo(model modelEntry, ownedBy, modelType string, created int64, fallbackDisplayName string, userDefined bool) *ModelInfo { @@ -676,11 +706,11 @@ func buildOpenAICompatibilityConfigModels(compat *config.OpenAICompatibility) [] if info == nil { continue } - thinking := model.Thinking - if thinking == nil && !model.Image { - thinking = ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high"}} + thinkingSupport := model.Thinking + if thinkingSupport == nil && !model.Image { + thinkingSupport = ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high"}} } - info.Thinking = thinking + info.Thinking = modelconfig.NormalizeThinkingSupport(thinkingSupport) info.SupportedInputModalities = normalizeCompatConfigModalities(model.InputModalities) info.SupportedOutputModalities = normalizeCompatConfigModalities(model.OutputModalities) models = append(models, info) @@ -731,10 +761,8 @@ func buildConfigModels[T modelEntry](models []T, ownedBy, modelType string) []*M continue } seen[key] = struct{}{} - if name != "" { - if upstream := registry.LookupStaticModelInfo(name); upstream != nil && upstream.Thinking != nil { - info.Thinking = upstream.Thinking - } + if resolved := modelconfig.ResolveModelInfo(name, modelType, model.GetThinking()); resolved.Thinking != nil { + info.Thinking = resolved.Thinking } out = append(out, info) } diff --git a/sdk/cliproxy/service_models_config_index_test.go b/sdk/cliproxy/service_models_config_index_test.go new file mode 100644 index 00000000..004b8262 --- /dev/null +++ b/sdk/cliproxy/service_models_config_index_test.go @@ -0,0 +1,41 @@ +package cliproxy + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" +) + +func TestOpenAICompatibilityRegistrationCacheUsesConfigIndex(t *testing.T) { + service := &Service{cfg: &config.Config{OpenAICompatibility: []config.OpenAICompatibility{ + {Name: "shared", Models: []config.OpenAICompatibilityModel{{Name: "first"}}}, + {Name: "shared", Models: []config.OpenAICompatibilityModel{{Name: "second"}}}, + }}} + cache := service.newOpenAICompatibilityRegistrationCache() + auth := &coreauth.Auth{Attributes: map[string]string{ + coreauth.AttributeSource: "config:shared[token-1]", + coreauth.AttributeConfigIndex: "1", + }} + entry, ok := cache.lookup(auth, "shared") + if !ok || entry == nil || len(entry.models) != 1 || entry.models[0].ID != "second" { + t.Fatalf("cached config entry = %+v, want second model", entry) + } +} + +func TestResolveConfigClaudeKeyUsesConfigIndex(t *testing.T) { + service := &Service{cfg: &config.Config{ClaudeKey: []config.ClaudeKey{ + {APIKey: "shared-key", Models: []config.ClaudeModel{{Name: "first"}}}, + {APIKey: "shared-key", Models: []config.ClaudeModel{{Name: "second"}}}, + }}} + auth := &coreauth.Auth{Attributes: map[string]string{ + coreauth.AttributeAPIKey: "shared-key", + coreauth.AttributeSource: "config:claude[token-1]", + coreauth.AttributeConfigIndex: "1", + }} + + entry := service.resolveConfigClaudeKey(auth) + if entry == nil || len(entry.Models) != 1 || entry.Models[0].Name != "second" { + t.Fatalf("resolved config entry = %+v, want second entry", entry) + } +} -- 2.51.2 From a432d763058a32a0f3121d2530dbc0bcf04bd108 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 29 Jul 2026 16:53:33 +0800 Subject: [PATCH 04/31] feat(models): remove Gemini 3.5 Flash Lite entry from models.json - Deleted the `gemini-3.5-flash-lite` model specification from `models.json`. Closes: #4636 --- 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 86f65d91..0f8998d5 100644 --- a/internal/registry/models/models.json +++ b/internal/registry/models/models.json @@ -2617,29 +2617,6 @@ ] } }, - { - "id": "gemini-3.5-flash-lite", - "object": "model", - "owned_by": "antigravity", - "type": "antigravity", - "display_name": "Gemini 3.5 Flash Lite", - "name": "gemini-3.5-flash-lite", - "description": "Gemini 3.5 Flash Lite", - "context_length": 1048576, - "max_completion_tokens": 65535, - "thinking": { - "min": 1, - "max": 65535, - "zero_allowed": true, - "dynamic_allowed": true, - "levels": [ - "minimal", - "low", - "medium", - "high" - ] - } - }, { "id": "gemini-3.5-flash-low", "object": "model", -- 2.51.2 From 5dedb303f1a40a9abf7d88f72d1f67660f87c2d3 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 29 Jul 2026 17:35:29 +0800 Subject: [PATCH 05/31] feat(util, executor, translator): enhance Antigravity schema handling and response formatting - Introduced `CleanJSONSchemaForAntigravityResponse` to process schemas without adding tool-specific placeholders. - Updated `cleanJSONSchema` with `removeGeminiMetadata` parameter for improved schema control. - Refactored Antigravity schema sanitization to handle declaration and generation paths independently. - Mapped OpenAI `response_format` to Antigravity settings, ensuring proper response schema cleaning. - Expanded tests to verify proper placeholder-free response schema handling and metadata preservation. Closes: #4652 --- .../executor/antigravity_executor_request.go | 37 ++++++--- .../antigravity_schema_sanitize_test.go | 79 +++++++++++++++++++ .../antigravity_openai_request.go | 16 ++++ .../antigravity_openai_request_test.go | 76 ++++++++++++++++++ internal/util/gemini_schema.go | 18 +++-- internal/util/gemini_schema_test.go | 31 ++++++++ 6 files changed, 242 insertions(+), 15 deletions(-) diff --git a/internal/runtime/executor/antigravity_executor_request.go b/internal/runtime/executor/antigravity_executor_request.go index ae0f51a4..451571f2 100644 --- a/internal/runtime/executor/antigravity_executor_request.go +++ b/internal/runtime/executor/antigravity_executor_request.go @@ -173,24 +173,33 @@ func sanitizeAntigravityRequestSchemas(payloadStr string, useAntigravitySchema b payloadStr = renamed } - clean := util.CleanJSONSchemaForGemini + toolSchemaCleaner := util.CleanJSONSchemaForGemini if useAntigravitySchema { - clean = util.CleanJSONSchemaForAntigravity + toolSchemaCleaner = util.CleanJSONSchemaForAntigravity } + responseSchemaCleaner := util.CleanJSONSchemaForAntigravityResponse - for _, schemaPath := range antigravitySchemaPaths(payloadStr) { + cleanNestedToolSchema := func(schemaRaw string) string { + return cleanNestedSchema(toolSchemaCleaner, schemaRaw) + } + payloadStr = cleanAntigravitySchemasAtPaths(payloadStr, antigravityDeclarationSchemaPaths(payloadStr), cleanNestedToolSchema) + payloadStr = cleanAntigravitySchemasAtPaths(payloadStr, antigravityGenerationSchemaPaths(payloadStr), responseSchemaCleaner) + return payloadStr +} + +func cleanAntigravitySchemasAtPaths(payloadStr string, schemaPaths []string, clean func(string) string) string { + for _, schemaPath := range schemaPaths { schema := gjson.Get(payloadStr, schemaPath) if !schema.Exists() { continue } - updated, errSet := sjson.SetRawBytes([]byte(payloadStr), schemaPath, []byte(cleanNestedSchema(clean, schema.Raw))) + updated, errSet := sjson.SetRawBytes([]byte(payloadStr), schemaPath, []byte(clean(schema.Raw))) if errSet != nil { log.Debugf("antigravity: failed to write cleaned schema at %s: %v", schemaPath, errSet) continue } payloadStr = string(updated) } - return payloadStr } @@ -241,7 +250,12 @@ func antigravityFunctionDeclarationPaths(payloadStr string) []string { // A function declaration may carry a schema for its parameters and for its result, so all of // them must be cleaned; anything omitted here reaches the upstream API uncleaned. func antigravitySchemaPaths(payloadStr string) []string { - paths := make([]string, 0, 12) + paths := antigravityDeclarationSchemaPaths(payloadStr) + return append(paths, antigravityGenerationSchemaPaths(payloadStr)...) +} + +func antigravityDeclarationSchemaPaths(payloadStr string) []string { + paths := make([]string, 0, 8) for _, base := range antigravityFunctionDeclarationPaths(payloadStr) { for _, key := range antigravityDeclarationSchemaKeys { if gjson.Get(payloadStr, base+"."+key).IsObject() { @@ -249,11 +263,16 @@ func antigravitySchemaPaths(payloadStr string) []string { } } } + return paths +} + +func antigravityGenerationSchemaPaths(payloadStr string) []string { + paths := make([]string, 0, len(antigravityGenerationConfigContainers)*len(antigravityGenerationSchemaKeys)) for _, container := range antigravityGenerationConfigContainers { for _, key := range antigravityGenerationSchemaKeys { - p := container + "." + key - if gjson.Get(payloadStr, p).IsObject() { - paths = append(paths, p) + path := container + "." + key + if gjson.Get(payloadStr, path).IsObject() { + paths = append(paths, path) } } } diff --git a/internal/runtime/executor/antigravity_schema_sanitize_test.go b/internal/runtime/executor/antigravity_schema_sanitize_test.go index bd038932..6151ae4c 100644 --- a/internal/runtime/executor/antigravity_schema_sanitize_test.go +++ b/internal/runtime/executor/antigravity_schema_sanitize_test.go @@ -243,6 +243,85 @@ func TestSanitizeAntigravityRequestSchemasMatchesWholePayloadCleaning(t *testing } } +func TestSanitizeAntigravityRequestSchemasKeepsResponseSchemasPlaceholderFree(t *testing.T) { + payload := `{"request":{ + "tools":[{"functionDeclarations":[{"name":"tool","parameters":{"type":"object","properties":{"value":{"type":"string"}}}}]}], + "generationConfig":{"responseSchema":{"type":"object","properties":{ + "empty":{"type":"object"}, + "optional":{"type":"object","properties":{"value":{"type":"string"}}} + }}} + }}` + + got := sanitizeAntigravityRequestSchemas(payload, true) + toolSchema := gjson.Get(got, "request.tools.0.functionDeclarations.0.parameters") + if required := toolSchema.Get("required.0").String(); required != "_" { + t.Fatalf("tool schema lost VALIDATED placeholder, required[0] = %q: %s", required, got) + } + + responseSchema := gjson.Get(got, "request.generationConfig.responseSchema") + for _, path := range []string{ + "required", + "properties._", + "properties.reason", + "properties.empty.required", + "properties.empty.properties.reason", + "properties.optional.required", + "properties.optional.properties._", + } { + if responseSchema.Get(path).Exists() { + t.Errorf("response schema gained tool-only field %s: %s", path, responseSchema.Raw) + } + } +} + +func TestAntigravityBuildRequestKeepsJSONObjectSchemaPlaceholderFree(t *testing.T) { + input := []byte(`{"model":"gemini-3.1-pro-low","messages":[{"role":"user","content":"hi"}],"response_format":{"type":"json_object"}}`) + translated := antigravitychat.ConvertOpenAIRequestToAntigravity("gemini-3.1-pro-low", input, false) + body := buildRequestBodyFromRawPayload(t, "gemini-3.1-pro-low", translated) + encoded, errMarshal := json.Marshal(body) + if errMarshal != nil { + t.Fatal(errMarshal) + } + + schema := gjson.GetBytes(encoded, "request.generationConfig.responseSchema") + if got := schema.Get("type").String(); got != "object" { + t.Fatalf("responseSchema.type = %q, want object: %s", got, encoded) + } + if schema.Get("properties.reason").Exists() || schema.Get("required").Exists() { + t.Fatalf("json_object schema gained tool placeholders: %s", schema.Raw) + } +} + +func TestAntigravityBuildRequestPreservesGenerationResponseSchemaMetadata(t *testing.T) { + payload := []byte(`{"request":{"generationConfig":{"responseSchema":{ + "type":"object", + "nullable":true, + "properties":{"_":{"type":"string","nullable":true}}, + "required":["_"] + }}}}`) + + for _, modelName := range []string{"gemini-3.6-flash-high", "gemini-3.1-pro-low"} { + t.Run(modelName, func(t *testing.T) { + body := buildRequestBodyFromRawPayload(t, modelName, payload) + encoded, errMarshal := json.Marshal(body) + if errMarshal != nil { + t.Fatal(errMarshal) + } + + schema := gjson.GetBytes(encoded, "request.generationConfig.responseSchema") + if !schema.Get("nullable").Bool() || !schema.Get("properties._.nullable").Bool() { + t.Fatalf("response schema nullable metadata was removed: %s", schema.Raw) + } + if !schema.Get("properties._").Exists() { + t.Fatalf("legitimate underscore property was removed: %s", schema.Raw) + } + if required := schema.Get("required.0").String(); required != "_" { + t.Fatalf("required[0] = %q, want underscore: %s", required, schema.Raw) + } + }) + } +} + func TestAntigravityBuildRequestSanitizesSnakeCaseGenerationResponseSchemas(t *testing.T) { for _, testCase := range []struct { alias string 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 af0afa5d..c0a953e5 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go @@ -74,6 +74,22 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ out, _ = sjson.SetBytes(out, "request.generationConfig.maxOutputTokens", maxTok.Num) } + // Map OpenAI response_format to Antigravity structured output settings. + if responseFormat := gjson.GetBytes(rawJSON, "response_format"); responseFormat.Exists() { + switch responseFormatType := strings.ToLower(strings.TrimSpace(responseFormat.Get("type").String())); responseFormatType { + case "json_object", "json_schema": + for _, schemaKey := range []string{"responseSchema", "responseJsonSchema", "response_schema", "response_json_schema"} { + out, _ = sjson.DeleteBytes(out, "request.generationConfig."+schemaKey) + } + out, _ = sjson.SetBytes(out, "request.generationConfig.responseMimeType", "application/json") + if responseFormatType == "json_object" { + out, _ = sjson.SetRawBytes(out, "request.generationConfig.responseSchema", []byte(`{"type":"object"}`)) + } else if schema := responseFormat.Get("json_schema.schema"); schema.Exists() { + out, _ = sjson.SetRawBytes(out, "request.generationConfig.responseSchema", []byte(schema.Raw)) + } + } + } + // Candidate count (OpenAI 'n' parameter) if n := gjson.GetBytes(rawJSON, "n"); n.Exists() && n.Type == gjson.Number { if val := n.Int(); val > 1 { diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go index 0bf1a0fd..845e7b63 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go @@ -314,3 +314,79 @@ func TestConvertOpenAIRequestToAntigravityMapsToolChoiceModes(t *testing.T) { }) } } + +func TestConvertOpenAIRequestToAntigravityMapsResponseFormatJSONObject(t *testing.T) { + inputJSON := []byte(`{ + "model":"gemini-3.6-flash-high", + "messages":[{"role":"user","content":"hi"}], + "generationConfig":{ + "responseSchema":{"type":"string","description":"stale"}, + "responseJsonSchema":{"type":"string"}, + "response_schema":{"type":"string"}, + "response_json_schema":{"type":"string"} + }, + "response_format":{"type":"json_object"} + }`) + + out := ConvertOpenAIRequestToAntigravity("gemini-3.6-flash-high", inputJSON, false) + if got := gjson.GetBytes(out, "request.generationConfig.responseMimeType").String(); got != "application/json" { + t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, out) + } + schema := gjson.GetBytes(out, "request.generationConfig.responseSchema") + if got := schema.Get("type").String(); got != "object" { + t.Fatalf("responseSchema.type = %q, want object. Output: %s", got, out) + } + if schema.Get("description").Exists() { + t.Fatalf("stale responseSchema survived. Output: %s", out) + } + assertNoResponseSchemaAliases(t, out) +} + +func TestConvertOpenAIRequestToAntigravityMapsResponseFormatJSONSchema(t *testing.T) { + inputJSON := []byte(`{ + "model":"gemini-3.6-flash-high", + "messages":[{"role":"user","content":"hi"}], + "generationConfig":{ + "responseSchema":{"type":"string","description":"stale"}, + "responseJsonSchema":{"type":"string"}, + "response_schema":{"type":"string"}, + "response_json_schema":{"type":"string"} + }, + "response_format":{ + "type":"json_schema", + "json_schema":{ + "name":"verdict", + "schema":{ + "type":"object", + "properties":{"score":{"type":"integer"}}, + "required":["score"] + } + } + } + }`) + + out := ConvertOpenAIRequestToAntigravity("gemini-3.6-flash-high", inputJSON, false) + if got := gjson.GetBytes(out, "request.generationConfig.responseMimeType").String(); got != "application/json" { + t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, out) + } + schema := gjson.GetBytes(out, "request.generationConfig.responseSchema") + if !schema.Exists() { + t.Fatalf("responseSchema missing. Output: %s", out) + } + if got := schema.Get("properties.score.type").String(); got != "integer" { + t.Fatalf("responseSchema.properties.score.type = %q, want integer. Output: %s", got, out) + } + if schema.Get("description").Exists() { + t.Fatalf("stale responseSchema survived. Output: %s", out) + } + assertNoResponseSchemaAliases(t, out) +} + +func assertNoResponseSchemaAliases(t *testing.T, out []byte) { + t.Helper() + for _, schemaKey := range []string{"responseJsonSchema", "response_schema", "response_json_schema"} { + if gjson.GetBytes(out, "request.generationConfig."+schemaKey).Exists() { + t.Errorf("stale %s survived response_format mapping. Output: %s", schemaKey, out) + } + } +} diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index 467bc134..51a414f7 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -24,21 +24,27 @@ const placeholderReasonDescription = "Brief explanation of why you are calling t // and replacements such as "enum" and "type" are fabricated. That regression reached production // once already; scope every call site to the schema itself. -// CleanJSONSchemaForAntigravity transforms a JSON schema to be compatible with Antigravity API. +// CleanJSONSchemaForAntigravity transforms a tool schema to be compatible with Antigravity API. // It handles unsupported keywords, type flattening, and schema simplification while preserving -// semantic information as description hints. +// semantic information as description hints and adding placeholders required by VALIDATED mode. func CleanJSONSchemaForAntigravity(jsonStr string) string { - return cleanJSONSchema(jsonStr, true) + return cleanJSONSchema(jsonStr, true, false) +} + +// CleanJSONSchemaForAntigravityResponse transforms a response schema without adding tool-only +// placeholders that would alter the client's structured output contract. +func CleanJSONSchemaForAntigravityResponse(jsonStr string) string { + return cleanJSONSchema(jsonStr, false, false) } // CleanJSONSchemaForGemini transforms a JSON schema to be compatible with Gemini tool calling. // It removes unsupported keywords and simplifies schemas, without adding empty-schema placeholders. func CleanJSONSchemaForGemini(jsonStr string) string { - return cleanJSONSchema(jsonStr, false) + return cleanJSONSchema(jsonStr, false, true) } // cleanJSONSchema performs the core cleaning operations on the JSON schema. -func cleanJSONSchema(jsonStr string, addPlaceholder bool) string { +func cleanJSONSchema(jsonStr string, addPlaceholder, removeGeminiMetadata bool) string { // Phase 1: Convert and add hints jsonStr = convertRefsToHints(jsonStr) jsonStr = convertConstToEnum(jsonStr) @@ -54,7 +60,7 @@ func cleanJSONSchema(jsonStr string, addPlaceholder bool) string { // Phase 3: Cleanup jsonStr = removeUnsupportedKeywords(jsonStr) - if !addPlaceholder { + if removeGeminiMetadata { // Gemini schema cleanup: remove nullable/title and placeholder-only fields. jsonStr = removeKeywords(jsonStr, []string{"nullable", "title"}) jsonStr = removePlaceholderFields(jsonStr) diff --git a/internal/util/gemini_schema_test.go b/internal/util/gemini_schema_test.go index bb581cdc..20d10b4d 100644 --- a/internal/util/gemini_schema_test.go +++ b/internal/util/gemini_schema_test.go @@ -733,6 +733,37 @@ func TestCleanJSONSchemaForAntigravity_EmptySchemaWithDescription(t *testing.T) } } +func TestCleanJSONSchemaForAntigravityResponseDoesNotAddToolPlaceholders(t *testing.T) { + bare := gjson.Parse(CleanJSONSchemaForAntigravityResponse(`{"type":"object"}`)) + if bare.Get("properties.reason").Exists() || bare.Get("required").Exists() { + t.Fatalf("bare response schema gained tool placeholders: %s", bare.Raw) + } + + input := `{ + "type":"object", + "title":"Response", + "nullable":true, + "properties":{ + "empty":{"type":"object"}, + "optional":{"type":"object","properties":{"value":{"type":"string"}}} + } + }` + result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input)) + for _, path := range []string{ + "properties.empty.properties.reason", + "properties.empty.required", + "properties.optional.properties._", + "properties.optional.required", + } { + if result.Get(path).Exists() { + t.Errorf("response schema gained tool-only field %s: %s", path, result.Raw) + } + } + if result.Get("title").String() != "Response" || !result.Get("nullable").Bool() { + t.Errorf("Antigravity response metadata was removed: %s", result.Raw) + } +} + // ============================================================================ // Format field handling (ad-hoc patch removal) // ============================================================================ -- 2.51.2 From 74d38e0999bf3031c631d86d54ae93cfcec6113c Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 29 Jul 2026 22:07:24 +0800 Subject: [PATCH 06/31] feat(translator): add support for `cached_creation_tokens` in usage details - Updated `OpenAIUsage` to include `cachedCreationTokens` for improved token tracking. - Adjusted token calculations and usage mapping to incorporate `cachedCreationTokens`. - Expanded tests to verify inclusion of `cachedCreationTokens` in usage details. --- .../chat-completions/claude_openai_response.go | 13 ++++++++----- .../claude_openai_response_test.go | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 5 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 002d3166..f8b5be0e 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_response.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_response.go @@ -64,12 +64,13 @@ func (u *claudeUsageTokens) Merge(usage gjson.Result) { } } -func (u claudeUsageTokens) OpenAIUsage() (promptTokens, completionTokens, totalTokens, cachedTokens int64) { +func (u claudeUsageTokens) OpenAIUsage() (promptTokens, completionTokens, totalTokens, cachedTokens, cachedCreationTokens int64) { cachedTokens = u.CacheReadInputTokens - promptTokens = u.InputTokens + u.CacheCreationInputTokens + cachedTokens + cachedCreationTokens = u.CacheCreationInputTokens + promptTokens = u.InputTokens + cachedCreationTokens + cachedTokens completionTokens = u.OutputTokens totalTokens = promptTokens + completionTokens - return promptTokens, completionTokens, totalTokens, cachedTokens + return promptTokens, completionTokens, totalTokens, cachedTokens, cachedCreationTokens } // ConvertClaudeResponseToOpenAI converts Claude Code streaming response format to OpenAI Chat Completions format. @@ -241,11 +242,12 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original // Handle usage information for token counts if usage := root.Get("usage"); usage.Exists() { (*param).(*ConvertAnthropicResponseToOpenAIParams).Usage.Merge(usage) - promptTokens, completionTokens, totalTokens, cachedTokens := (*param).(*ConvertAnthropicResponseToOpenAIParams).Usage.OpenAIUsage() + promptTokens, completionTokens, totalTokens, cachedTokens, cachedCreationTokens := (*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) template, _ = sjson.SetBytes(template, "usage.prompt_tokens_details.cached_tokens", cachedTokens) + template, _ = sjson.SetBytes(template, "usage.prompt_tokens_details.cached_creation_tokens", cachedCreationTokens) } return [][]byte{template} @@ -405,11 +407,12 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina } if usageTokens.HasUsage { - promptTokens, completionTokens, totalTokens, cachedTokens := usageTokens.OpenAIUsage() + promptTokens, completionTokens, totalTokens, cachedTokens, cachedCreationTokens := 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) + out, _ = sjson.SetBytes(out, "usage.prompt_tokens_details.cached_creation_tokens", cachedCreationTokens) } // Set basic response fields including message ID, creation time, and model 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 5a9a6d3a..e8b3843a 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 @@ -7,6 +7,18 @@ import ( "github.com/tidwall/gjson" ) +func assertCachedCreationTokens(t *testing.T, payload []byte, want int64) { + t.Helper() + + got := gjson.GetBytes(payload, "usage.prompt_tokens_details.cached_creation_tokens") + if !got.Exists() { + t.Fatalf("expected cached_creation_tokens to exist, payload=%s", string(payload)) + } + if got.Int() != want { + t.Fatalf("expected cached_creation_tokens %d, got %d", want, got.Int()) + } +} + func TestConvertClaudeResponseToOpenAI_StreamUsageIncludesCachedTokens(t *testing.T) { ctx := context.Background() var param any @@ -35,6 +47,7 @@ func TestConvertClaudeResponseToOpenAI_StreamUsageIncludesCachedTokens(t *testin 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) } + assertCachedCreationTokens(t, out[0], 31) } func TestConvertClaudeResponseToOpenAI_StreamUsageMergesMessageStartUsage(t *testing.T) { @@ -73,6 +86,7 @@ func TestConvertClaudeResponseToOpenAI_StreamUsageMergesMessageStartUsage(t *tes 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) } + assertCachedCreationTokens(t, out[0], 31) } func TestConvertClaudeResponseToOpenAINonStream_UsageIncludesCachedTokens(t *testing.T) { @@ -93,6 +107,7 @@ func TestConvertClaudeResponseToOpenAINonStream_UsageIncludesCachedTokens(t *tes if gotCachedTokens := gjson.GetBytes(out, "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 22000 { t.Fatalf("expected cached_tokens %d, got %d", 22000, gotCachedTokens) } + assertCachedCreationTokens(t, out, 31) } func TestConvertClaudeResponseToOpenAINonStream_UsageMergesMessageStartUsage(t *testing.T) { @@ -113,4 +128,5 @@ func TestConvertClaudeResponseToOpenAINonStream_UsageMergesMessageStartUsage(t * if gotCachedTokens := gjson.GetBytes(out, "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 22000 { t.Fatalf("expected cached_tokens %d, got %d", 22000, gotCachedTokens) } + assertCachedCreationTokens(t, out, 31) } -- 2.51.2 From fecebcca5908b310a25d23718b73c1b835bce667 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 29 Jul 2026 23:33:11 +0800 Subject: [PATCH 07/31] refactor(translator): overhaul function call handling in Codex response conversion - Replaced `pendingCodexFunctionCall` with `codexFunctionCallStream` for enhanced function call tracking. - Introduced `DeferredStreamEvents` to handle deferred event processing. - Simplified and standardized codex function call state management with consolidated methods. - Enhanced reasoning and thinking block handling to ensure proper closure and new block initiation. - Removed redundant methods, improving maintainability. Closes: #4655 --- ...dex_claude_parallel_function_calls_test.go | 305 ++++++++++ .../codex/claude/codex_claude_response.go | 533 +++++++++--------- .../claude/codex_claude_response_test.go | 2 +- .../codex_claude_response_web_search.go | 1 + ...dex_claude_parallel_function_calls_test.go | 125 ++++ 5 files changed, 701 insertions(+), 265 deletions(-) create mode 100644 internal/translator/codex/claude/codex_claude_parallel_function_calls_test.go create mode 100644 test/codex_claude_parallel_function_calls_test.go diff --git a/internal/translator/codex/claude/codex_claude_parallel_function_calls_test.go b/internal/translator/codex/claude/codex_claude_parallel_function_calls_test.go new file mode 100644 index 00000000..b92fd52a --- /dev/null +++ b/internal/translator/codex/claude/codex_claude_parallel_function_calls_test.go @@ -0,0 +1,305 @@ +package claude + +import ( + "context" + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +type codexClaudeContentBlock struct { + Index int64 + Type string + ID string + Name string + Text string + Arguments string +} + +func translateCodexClaudeChunks(t *testing.T, chunks [][]byte) [][]byte { + t.Helper() + + originalRequest := []byte(`{"stream":true,"tools":[{"name":"Read"}]}`) + var state any + var outputs [][]byte + for _, chunk := range chunks { + outputs = append(outputs, ConvertCodexResponseToClaude(context.Background(), "gpt-5", originalRequest, nil, chunk, &state)...) + } + return outputs +} + +func assertCodexClaudeContentBlockLifecycle(t *testing.T, outputs [][]byte) []*codexClaudeContentBlock { + t.Helper() + + open := make(map[int64]*codexClaudeContentBlock) + started := make(map[int64]struct{}) + blocks := make([]*codexClaudeContentBlock, 0) + messageState := 0 + for _, output := range outputs { + for _, line := range strings.Split(string(output), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + event := gjson.Parse(strings.TrimPrefix(line, "data: ")) + if messageState == 2 { + t.Fatalf("event emitted after message_stop: %s", event.Raw) + } + index := event.Get("index").Int() + switch event.Get("type").String() { + case "content_block_start": + if messageState != 0 { + t.Fatalf("content block started after message terminal events: %s", event.Raw) + } + if len(open) != 0 { + t.Fatalf("content block start emitted while another block remains open: %v", open) + } + if _, exists := started[index]; exists { + t.Fatalf("content block index %d was reused", index) + } + block := &codexClaudeContentBlock{ + Index: index, + Type: event.Get("content_block.type").String(), + ID: event.Get("content_block.id").String(), + Name: event.Get("content_block.name").String(), + } + open[index] = block + started[index] = struct{}{} + blocks = append(blocks, block) + case "content_block_delta": + block := open[index] + if block == nil { + t.Fatalf("content block delta targets unopened index %d", index) + } + switch event.Get("delta.type").String() { + case "input_json_delta": + block.Arguments += event.Get("delta.partial_json").String() + case "text_delta": + block.Text += event.Get("delta.text").String() + } + case "content_block_stop": + if open[index] == nil { + t.Fatalf("content block stop targets unopened index %d", index) + } + delete(open, index) + case "message_delta": + if len(open) != 0 { + t.Fatalf("message_delta emitted while content blocks remain open: %v", open) + } + if messageState != 0 { + t.Fatalf("duplicate or out-of-order message_delta: %s", event.Raw) + } + messageState = 1 + case "message_stop": + if len(open) != 0 { + t.Fatalf("message_stop emitted while content blocks remain open: %v", open) + } + if messageState != 1 { + t.Fatalf("message_stop emitted before message_delta: %s", event.Raw) + } + messageState = 2 + } + } + } + if len(open) != 0 { + t.Fatalf("content blocks remain open: %v", open) + } + return blocks +} + +func assertParallelCodexClaudeToolCalls(t *testing.T, blocks []*codexClaudeContentBlock) { + t.Helper() + + if len(blocks) != 2 { + t.Fatalf("content block count = %d, want 2", len(blocks)) + } + expectedIDs := []string{"call_a", "call_b"} + expectedArguments := []string{`{"file_path":"a"}`, `{"file_path":"b"}`} + for index, block := range blocks { + if block.Index != int64(index) { + t.Fatalf("block %d index = %d, want %d", index, block.Index, index) + } + if block.Type != "tool_use" || block.Name != "Read" { + t.Fatalf("block %d = %#v, want Read tool_use", index, block) + } + if block.ID != expectedIDs[index] { + t.Fatalf("block %d ID = %q, want %q", index, block.ID, expectedIDs[index]) + } + if block.Arguments != expectedArguments[index] { + t.Fatalf("block %d arguments = %q, want %q", index, block.Arguments, expectedArguments[index]) + } + } +} + +func TestConvertCodexResponseToClaude_StreamSerializesInterleavedNamedFunctionCalls(t *testing.T) { + tests := []struct { + name string + chunks [][]byte + }{ + { + name: "first call finishes first", + chunks: [][]byte{ + []byte(`data: {"type":"response.created","response":{"id":"resp_parallel","model":"gpt-5"}}`), + []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_a","name":"Read"},"output_index":1}`), + []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_b","name":"Read"},"output_index":2}`), + []byte(`data: {"type":"response.function_call_arguments.delta","delta":"{\"file_path\":\"a\"}","output_index":1}`), + []byte(`data: {"type":"response.function_call_arguments.delta","delta":"{\"file_path\":\"b\"}","output_index":2}`), + []byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"file_path\":\"a\"}","output_index":1}`), + []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_a","name":"Read","arguments":"{\"file_path\":\"a\"}"},"output_index":1}`), + []byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"file_path\":\"b\"}","output_index":2}`), + []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_b","name":"Read","arguments":"{\"file_path\":\"b\"}"},"output_index":2}`), + }, + }, + { + name: "second call finishes first", + chunks: [][]byte{ + []byte(`data: {"type":"response.created","response":{"id":"resp_parallel","model":"gpt-5"}}`), + []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_a","name":"Read"},"output_index":1}`), + []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_b","name":"Read"},"output_index":2}`), + []byte(`data: {"type":"response.function_call_arguments.delta","delta":"{\"file_path\":\"b\"}","output_index":2}`), + []byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"file_path\":\"b\"}","output_index":2}`), + []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_b","name":"Read","arguments":"{\"file_path\":\"b\"}"},"output_index":2}`), + []byte(`data: {"type":"response.function_call_arguments.delta","delta":"{\"file_path\":\"a\"}","output_index":1}`), + []byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"file_path\":\"a\"}","output_index":1}`), + []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_a","name":"Read","arguments":"{\"file_path\":\"a\"}"},"output_index":1}`), + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + blocks := assertCodexClaudeContentBlockLifecycle(t, translateCodexClaudeChunks(t, test.chunks)) + assertParallelCodexClaudeToolCalls(t, blocks) + }) + } +} + +func TestConvertCodexResponseToClaude_StreamDefersOtherContentUntilFunctionCallsClose(t *testing.T) { + tests := []struct { + name string + functionCall []byte + firstBlock string + secondBlock string + }{ + { + name: "named active call", + functionCall: []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_a","name":"Read"},"output_index":0}`), + firstBlock: "tool_use", + secondBlock: "text", + }, + { + name: "unnamed pending call", + functionCall: []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_a"},"output_index":0}`), + firstBlock: "text", + secondBlock: "tool_use", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + chunks := [][]byte{ + []byte(`data: {"type":"response.created","response":{"id":"resp_mixed","model":"gpt-5"}}`), + test.functionCall, + []byte(`data: {"type":"response.output_item.added","item":{"type":"message","status":"in_progress"},"output_index":1}`), + []byte(`data: {"type":"response.content_part.added","part":{"type":"output_text"},"content_index":0,"output_index":1}`), + []byte(`data: {"type":"response.output_text.delta","delta":"done","output_index":1}`), + []byte(`data: {"type":"response.content_part.done","part":{"type":"output_text"},"content_index":0,"output_index":1}`), + []byte(`data: {"type":"response.output_item.done","item":{"type":"message","status":"completed"},"output_index":1}`), + []byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"file_path\":\"a\"}","output_index":0}`), + []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_a","name":"Read","arguments":"{\"file_path\":\"a\"}"},"output_index":0}`), + []byte(`data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}`), + } + + blocks := assertCodexClaudeContentBlockLifecycle(t, translateCodexClaudeChunks(t, chunks)) + if len(blocks) != 2 { + t.Fatalf("content block count = %d, want 2", len(blocks)) + } + if blocks[0].Index != 0 || blocks[0].Type != test.firstBlock { + t.Fatalf("unexpected first block: %#v", blocks[0]) + } + if blocks[1].Index != 1 || blocks[1].Type != test.secondBlock { + t.Fatalf("unexpected second block: %#v", blocks[1]) + } + for _, block := range blocks { + switch block.Type { + case "tool_use": + if block.Arguments != `{"file_path":"a"}` { + t.Fatalf("unexpected tool block: %#v", block) + } + case "text": + if block.Text != "done" { + t.Fatalf("unexpected text block: %#v", block) + } + } + } + }) + } +} + +func TestConvertCodexResponseToClaude_StreamDeferredTextClosesBeforeThinkingStarts(t *testing.T) { + chunks := [][]byte{ + []byte(`data: {"type":"response.created","response":{"id":"resp_mixed","model":"gpt-5"}}`), + []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_a","name":"Read"},"output_index":0}`), + []byte(`data: {"type":"response.content_part.added","part":{"type":"output_text"},"content_index":0,"output_index":1}`), + []byte(`data: {"type":"response.output_text.delta","delta":"answer","output_index":1}`), + []byte(`data: {"type":"response.output_item.added","item":{"type":"reasoning","encrypted_content":"enc_initial"},"output_index":2}`), + []byte(`data: {"type":"response.reasoning_summary_part.added","output_index":2}`), + []byte(`data: {"type":"response.reasoning_summary_text.delta","delta":"thought","output_index":2}`), + []byte(`data: {"type":"response.output_item.done","item":{"type":"reasoning","encrypted_content":"enc_final"},"output_index":2}`), + []byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"file_path\":\"a\"}","output_index":0}`), + []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_a","name":"Read","arguments":"{\"file_path\":\"a\"}"},"output_index":0}`), + []byte(`data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}`), + } + + blocks := assertCodexClaudeContentBlockLifecycle(t, translateCodexClaudeChunks(t, chunks)) + if len(blocks) != 3 { + t.Fatalf("content block count = %d, want 3", len(blocks)) + } + if blocks[0].Index != 0 || blocks[0].Type != "tool_use" || blocks[0].Arguments != `{"file_path":"a"}` { + t.Fatalf("unexpected tool block: %#v", blocks[0]) + } + if blocks[1].Index != 1 || blocks[1].Type != "text" || blocks[1].Text != "answer" { + t.Fatalf("unexpected text block: %#v", blocks[1]) + } + if blocks[2].Index != 2 || blocks[2].Type != "thinking" { + t.Fatalf("unexpected thinking block: %#v", blocks[2]) + } +} + +func TestConvertCodexResponseToClaude_StreamTerminalMatchesFunctionCallsByOutputIndex(t *testing.T) { + chunks := [][]byte{ + []byte(`data: {"type":"response.created","response":{"id":"resp_parallel","model":"gpt-5"}}`), + []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","name":"Read"},"output_index":0}`), + []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","name":"Read"},"output_index":1}`), + []byte(`data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1},"output":[{"type":"function_call","name":"Read","arguments":"{\"file_path\":\"a\"}"},{"type":"function_call","name":"Read","arguments":"{\"file_path\":\"b\"}"}]}}`), + } + + blocks := assertCodexClaudeContentBlockLifecycle(t, translateCodexClaudeChunks(t, chunks)) + if len(blocks) != 2 { + t.Fatalf("content block count = %d, want 2", len(blocks)) + } + if blocks[0].Index != 0 || blocks[0].Arguments != `{"file_path":"a"}` { + t.Fatalf("unexpected first function call: %#v", blocks[0]) + } + if blocks[1].Index != 1 || blocks[1].Arguments != `{"file_path":"b"}` { + t.Fatalf("unexpected second function call: %#v", blocks[1]) + } +} + +func TestConvertCodexResponseToClaude_StreamTerminalHydratesInterleavedFunctionCalls(t *testing.T) { + for _, terminalType := range []string{"response.completed", "response.incomplete"} { + t.Run(terminalType, func(t *testing.T) { + terminal := `data: {"type":"` + terminalType + `","response":{"usage":{"input_tokens":1,"output_tokens":1},"output":[{"type":"function_call","call_id":"call_a","name":"Read","arguments":"{\"file_path\":\"a\"}"},{"type":"function_call","call_id":"call_b","name":"Read","arguments":"{\"file_path\":\"b\"}"}]}}` + chunks := [][]byte{ + []byte(`data: {"type":"response.created","response":{"id":"resp_parallel","model":"gpt-5"}}`), + []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_a","name":"Read"},"output_index":0}`), + []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_b","name":"Read"},"output_index":1}`), + []byte(`data: {"type":"response.function_call_arguments.delta","delta":"{\"file_path\":","output_index":0}`), + []byte(terminal), + } + + blocks := assertCodexClaudeContentBlockLifecycle(t, translateCodexClaudeChunks(t, chunks)) + assertParallelCodexClaudeToolCalls(t, blocks) + }) + } +} diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index 69d49583..45efd8a6 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -27,29 +27,34 @@ const codexThinkingSummaryPartSeparator = "\n\n" // ConvertCodexResponseToClaudeParams holds parameters for response conversion. type ConvertCodexResponseToClaudeParams struct { - HasEmittedToolUse bool - BlockIndex int - HasReceivedArgumentsDelta bool - FunctionCallBlockOpen bool - FunctionCallBlockCallID string - FunctionCallBlockIndex int - HasTextDelta bool - TextBlockOpen bool - ThinkingBlockOpen bool - ThinkingSignature string - ThinkingSummarySeen bool - WebSearchToolUseIDs map[string]struct{} - WebSearchToolResultIDs map[string]struct{} - LastWebSearchToolUseID string - PendingFunctionCalls map[string]*pendingCodexFunctionCall - LastPendingFunctionCallKey string -} - -type pendingCodexFunctionCall struct { + HasEmittedToolUse bool + BlockIndex int + HasTextDelta bool + TextBlockOpen bool + ThinkingBlockOpen bool + ThinkingSignature string + ThinkingSummarySeen bool + WebSearchToolUseIDs map[string]struct{} + WebSearchToolResultIDs map[string]struct{} + LastWebSearchToolUseID string + FunctionCalls map[string]*codexFunctionCallStream + FunctionCallQueue []*codexFunctionCallStream + ActiveFunctionCall *codexFunctionCallStream + LastFunctionCall *codexFunctionCallStream + DeferredStreamEvents [][]byte +} + +type codexFunctionCallStream struct { CallID string + Name string + BlockIndex int Arguments string + EmittedArgumentsLength int HasReceivedArgumentsDelta bool - StartEmitted bool + EmitInitialEmptyDelta bool + Started bool + Done bool + Closed bool } // ConvertCodexResponseToClaude performs sophisticated streaming response format conversion. @@ -78,6 +83,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa if !bytes.HasPrefix(rawJSON, dataTag) { return [][]byte{} } + streamEventRawJSON := bytes.Clone(rawJSON) rawJSON = bytes.TrimSpace(rawJSON[5:]) output := make([]byte, 0, 512) @@ -86,6 +92,10 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa typeResult := rootResult.Get("type") typeStr := typeResult.String() + if params.ActiveFunctionCall != nil && shouldDeferCodexStreamEvent(typeStr, rootResult) { + params.DeferredStreamEvents = append(params.DeferredStreamEvents, streamEventRawJSON) + return [][]byte{} + } var template []byte switch typeStr { @@ -98,6 +108,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa output = translatorcommon.AppendSSEEventBytes(output, "message_start", template, 2) case "response.reasoning_summary_part.added": + output = append(output, stopCodexTextBlock(params)...) // Codex splits a single reasoning item into several summary parts, but only // output_item.done carries that item's final encrypted_content. Keep one // thinking block open for the whole item and separate the parts with a blank @@ -109,6 +120,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa } params.ThinkingSummarySeen = true case "response.reasoning_summary_text.delta": + output = append(output, stopCodexTextBlock(params)...) output = append(output, startCodexThinkingBlock(params)...) output = append(output, appendCodexThinkingDelta(params, rootResult.Get("delta").String())...) case "response.reasoning_summary_part.done": @@ -137,9 +149,12 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa case "response.completed", "response.incomplete": template = []byte(`{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`) responseData := rootResult.Get("response") - output = hydrateOpenCodexFunctionCallFromTerminal(output, params, responseData) - output = append(output, finalizeCodexOpenContentBlocks(params)...) - output = appendPendingCodexFunctionCallsFromTerminal(output, params, originalRequestRawJSON, responseData) + output = append(output, finalizeCodexThinkingBlock(params)...) + output = append(output, stopCodexTextBlock(params)...) + output = appendCodexFunctionCallsFromTerminal(output, params, originalRequestRawJSON, responseData) + output = appendDeferredCodexStreamEvents(output, originalRequestRawJSON, param) + output = append(output, finalizeCodexThinkingBlock(params)...) + output = append(output, stopCodexTextBlock(params)...) template, _ = sjson.SetBytes(template, "delta.stop_reason", mapCodexStopReasonToClaude(codexStopReason(responseData), params.HasEmittedToolUse)) template = setClaudeStopSequence(template, "delta.stop_sequence", responseData) inputTokens, outputTokens, cachedTokens := extractResponsesUsage(responseData.Get("usage")) @@ -158,26 +173,15 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa case "function_call": output = append(output, finalizeCodexThinkingBlock(params)...) output = append(output, stopCodexTextBlock(params)...) - params.HasReceivedArgumentsDelta = false - - callID := codexFunctionCallID(itemResult) - name := itemResult.Get("name").String() - if name == "" { - recordPendingCodexFunctionCall(params, rootResult, itemResult) - break - } - if pending, pendingKeys := pendingCodexFunctionCallForDone(params, rootResult, itemResult); pending != nil { - deletePendingCodexFunctionCallAliases(params, pendingKeys) + call := recordCodexFunctionCall(params, rootResult, itemResult) + updateCodexFunctionCallIdentity(params, call, rootResult, itemResult) + if call.Name != "" { + call.EmitInitialEmptyDelta = true } - blockIndex := params.BlockIndex - output = appendCodexFunctionCallStart(output, originalRequestRawJSON, callID, name, blockIndex) - params.HasEmittedToolUse = true - output = appendCodexFunctionCallArgumentDelta(output, "", blockIndex) - params.FunctionCallBlockOpen = true - params.FunctionCallBlockCallID = callID - params.FunctionCallBlockIndex = blockIndex + output = appendCodexFunctionCallQueue(output, params, originalRequestRawJSON) case "reasoning": + output = append(output, stopCodexTextBlock(params)...) // A previous reasoning item that never reported output_item.done must not // leak its still-open block into this one. output = append(output, finalizeCodexThinkingBlock(params)...) @@ -226,41 +230,18 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa output = append(output, stopCodexTextBlock(params)...) params.HasTextDelta = true case "function_call": - if pending, pendingKeys := pendingCodexFunctionCallForDone(params, rootResult, itemResult); pending != nil && !pending.StartEmitted { - name := itemResult.Get("name").String() - if name == "" { - return [][]byte{output} - } - callID := pending.CallID - if callID == "" { - callID = codexFunctionCallID(itemResult) - } - blockIndex := params.BlockIndex - output = appendCodexFunctionCallStart(output, originalRequestRawJSON, callID, name, blockIndex) - params.HasEmittedToolUse = true - pending.StartEmitted = true - - args := pending.Arguments - if args == "" { - args = itemResult.Get("arguments").String() - } - if args != "" { - output = appendCodexFunctionCallArgumentDelta(output, args, blockIndex) - } - output = appendCodexFunctionCallStop(output, blockIndex) - params.BlockIndex++ - - deletePendingCodexFunctionCallAliases(params, pendingKeys) - } else if params.FunctionCallBlockOpen { - if !params.HasReceivedArgumentsDelta { - if args := itemResult.Get("arguments").String(); args != "" { - output = appendCodexFunctionCallArgumentDelta(output, args, params.FunctionCallBlockIndex) - params.HasReceivedArgumentsDelta = true - } - } - output = appendCodexOpenFunctionCallStop(output, params) + output = append(output, finalizeCodexThinkingBlock(params)...) + output = append(output, stopCodexTextBlock(params)...) + call := codexFunctionCallForEvent(params, rootResult, itemResult) + if call == nil { + call = recordCodexFunctionCall(params, rootResult, itemResult) } + updateCodexFunctionCallIdentity(params, call, rootResult, itemResult) + updateCodexFunctionCallArguments(call, itemResult.Get("arguments").String(), false) + call.Done = true + output = appendCodexFunctionCallQueue(output, params, originalRequestRawJSON) case "reasoning": + output = append(output, stopCodexTextBlock(params)...) if signature := itemResult.Get("encrypted_content").String(); signature != "" { params.ThinkingSignature = signature } @@ -275,36 +256,58 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa output = appendCodexWebSearchToolResult(output, params, rootResult, itemResult) } case "response.function_call_arguments.delta": - delta := rootResult.Get("delta").String() - key := codexArgumentsFunctionCallKey(params, rootResult) - if pending, _ := pendingCodexFunctionCallForKey(params, key); pending != nil && !pending.StartEmitted { - pending.HasReceivedArgumentsDelta = true - pending.Arguments += delta - break + call := codexFunctionCallForEvent(params, rootResult, gjson.Result{}) + if call == nil { + call = recordCodexFunctionCall(params, rootResult, gjson.Result{}) } - - params.HasReceivedArgumentsDelta = true - output = appendCodexFunctionCallArgumentDelta(output, delta, params.BlockIndex) + updateCodexFunctionCallArguments(call, rootResult.Get("delta").String(), true) + output = appendCodexFunctionCallBufferedArguments(output, params, call) case "response.function_call_arguments.done": - key := codexArgumentsFunctionCallKey(params, rootResult) - if pending, _ := pendingCodexFunctionCallForKey(params, key); pending != nil && !pending.StartEmitted { - if !pending.HasReceivedArgumentsDelta { - pending.Arguments = rootResult.Get("arguments").String() - } - break - } - - if !params.HasReceivedArgumentsDelta { - if args := rootResult.Get("arguments").String(); args != "" { - output = appendCodexFunctionCallArgumentDelta(output, args, params.BlockIndex) - params.HasReceivedArgumentsDelta = true - } + call := codexFunctionCallForEvent(params, rootResult, gjson.Result{}) + if call == nil { + call = recordCodexFunctionCall(params, rootResult, gjson.Result{}) } + updateCodexFunctionCallArguments(call, rootResult.Get("arguments").String(), false) + output = appendCodexFunctionCallBufferedArguments(output, params, call) } + if len(params.FunctionCallQueue) == 0 { + output = appendDeferredCodexStreamEvents(output, originalRequestRawJSON, param) + } return [][]byte{output} } +func shouldDeferCodexStreamEvent(typeStr string, rootResult gjson.Result) bool { + switch typeStr { + case "error", "response.completed", "response.incomplete", "response.function_call_arguments.delta", "response.function_call_arguments.done": + return false + case "response.output_item.added", "response.output_item.done": + return rootResult.Get("item.type").String() != "function_call" + default: + return true + } +} + +func appendDeferredCodexStreamEvents(output []byte, originalRequestRawJSON []byte, param *any) []byte { + if param == nil || *param == nil { + return output + } + params := (*param).(*ConvertCodexResponseToClaudeParams) + if len(params.DeferredStreamEvents) == 0 { + return output + } + + events := params.DeferredStreamEvents + params.DeferredStreamEvents = nil + for _, event := range events { + translated := ConvertCodexResponseToClaude(context.Background(), "", originalRequestRawJSON, nil, event, param) + for _, chunk := range translated { + output = append(output, chunk...) + } + } + return output +} + func codexStreamErrorToClaudeError(rootResult gjson.Result) []byte { errorResult := rootResult.Get("error") errType := strings.TrimSpace(errorResult.Get("type").String()) @@ -515,78 +518,28 @@ func setClaudeStopSequence(out []byte, path string, responseData gjson.Result) [ return out } -func codexFunctionCallKey(rootResult, itemResult gjson.Result) string { - if outputIndex := rootResult.Get("output_index"); outputIndex.Exists() { - return "output:" + outputIndex.Raw - } - if callID := codexFunctionCallID(itemResult); callID != "" { - return "call:" + callID - } - return "last" -} - func codexFunctionCallID(itemResult gjson.Result) string { return itemResult.Get("call_id").String() } -func codexFunctionCallIDKey(callID string) string { - if callID == "" { - return "" - } - return "call:" + callID -} - -func codexArgumentsFunctionCallKey(params *ConvertCodexResponseToClaudeParams, rootResult gjson.Result) string { +func codexFunctionCallKeys(rootResult, itemResult gjson.Result) []string { + keys := make([]string, 0, 5) if outputIndex := rootResult.Get("output_index"); outputIndex.Exists() { - return "output:" + outputIndex.Raw - } - return params.LastPendingFunctionCallKey -} - -func recordPendingCodexFunctionCall(params *ConvertCodexResponseToClaudeParams, rootResult, itemResult gjson.Result) { - if params.PendingFunctionCalls == nil { - params.PendingFunctionCalls = map[string]*pendingCodexFunctionCall{} - } - - pending := &pendingCodexFunctionCall{CallID: codexFunctionCallID(itemResult)} - key := codexFunctionCallKey(rootResult, itemResult) - params.PendingFunctionCalls[key] = pending - if callIDKey := codexFunctionCallIDKey(pending.CallID); callIDKey != "" { - params.PendingFunctionCalls[callIDKey] = pending - } - params.LastPendingFunctionCallKey = key -} - -func pendingCodexFunctionCallForKey(params *ConvertCodexResponseToClaudeParams, key string) (*pendingCodexFunctionCall, string) { - if params == nil || params.PendingFunctionCalls == nil || key == "" { - return nil, "" + keys = appendUniqueCodexFunctionCallKey(keys, "output:"+outputIndex.Raw) } - pending, ok := params.PendingFunctionCalls[key] - if !ok { - return nil, "" + if callID := codexFunctionCallID(itemResult); callID != "" { + keys = appendUniqueCodexFunctionCallKey(keys, "call:"+callID) } - return pending, key -} - -func pendingCodexFunctionCallForDone(params *ConvertCodexResponseToClaudeParams, rootResult, itemResult gjson.Result) (*pendingCodexFunctionCall, []string) { - if params == nil || params.PendingFunctionCalls == nil { - return nil, nil + if callID := rootResult.Get("call_id").String(); callID != "" { + keys = appendUniqueCodexFunctionCallKey(keys, "call:"+callID) } - - keys := []string{codexFunctionCallKey(rootResult, itemResult)} - callID := codexFunctionCallID(itemResult) - if callID != "" { - keys = appendUniqueCodexFunctionCallKey(keys, codexFunctionCallIDKey(callID)) - } else if !rootResult.Get("output_index").Exists() && params.LastPendingFunctionCallKey != "" { - keys = appendUniqueCodexFunctionCallKey(keys, params.LastPendingFunctionCallKey) + if itemID := itemResult.Get("id").String(); itemID != "" { + keys = appendUniqueCodexFunctionCallKey(keys, "item:"+itemID) } - - for _, key := range keys { - if pending, ok := params.PendingFunctionCalls[key]; ok { - return pending, keysForPendingCodexFunctionCall(params, pending) - } + if itemID := rootResult.Get("item_id").String(); itemID != "" { + keys = appendUniqueCodexFunctionCallKey(keys, "item:"+itemID) } - return nil, nil + return keys } func appendUniqueCodexFunctionCallKey(keys []string, key string) []string { @@ -601,29 +554,81 @@ func appendUniqueCodexFunctionCallKey(keys []string, key string) []string { return append(keys, key) } -func keysForPendingCodexFunctionCall(params *ConvertCodexResponseToClaudeParams, pending *pendingCodexFunctionCall) []string { - if params == nil || pending == nil || params.PendingFunctionCalls == nil { +func codexFunctionCallForKeys(params *ConvertCodexResponseToClaudeParams, keys []string) *codexFunctionCallStream { + if params == nil || params.FunctionCalls == nil { return nil } - - keys := make([]string, 0, 2) - for key, candidate := range params.PendingFunctionCalls { - if candidate == pending { - keys = append(keys, key) + for _, key := range keys { + if call := params.FunctionCalls[key]; call != nil { + return call } } - return keys + return nil +} + +func codexFunctionCallForEvent(params *ConvertCodexResponseToClaudeParams, rootResult, itemResult gjson.Result) *codexFunctionCallStream { + keys := codexFunctionCallKeys(rootResult, itemResult) + if len(keys) > 0 { + return codexFunctionCallForKeys(params, keys) + } + if params == nil { + return nil + } + return params.LastFunctionCall +} + +func recordCodexFunctionCall(params *ConvertCodexResponseToClaudeParams, rootResult, itemResult gjson.Result) *codexFunctionCallStream { + keys := codexFunctionCallKeys(rootResult, itemResult) + call := codexFunctionCallForKeys(params, keys) + if call == nil { + call = &codexFunctionCallStream{BlockIndex: -1} + params.FunctionCallQueue = append(params.FunctionCallQueue, call) + } + addCodexFunctionCallAliases(params, call, keys) + params.LastFunctionCall = call + return call } -func deletePendingCodexFunctionCallAliases(params *ConvertCodexResponseToClaudeParams, keys []string) { - if params == nil || params.PendingFunctionCalls == nil { +func addCodexFunctionCallAliases(params *ConvertCodexResponseToClaudeParams, call *codexFunctionCallStream, keys []string) { + if params == nil || call == nil { return } + if params.FunctionCalls == nil { + params.FunctionCalls = map[string]*codexFunctionCallStream{} + } for _, key := range keys { - delete(params.PendingFunctionCalls, key) - if params.LastPendingFunctionCallKey == key { - params.LastPendingFunctionCallKey = "" - } + params.FunctionCalls[key] = call + } +} + +func updateCodexFunctionCallIdentity(params *ConvertCodexResponseToClaudeParams, call *codexFunctionCallStream, rootResult, itemResult gjson.Result) { + if call == nil { + return + } + if callID := codexFunctionCallID(itemResult); callID != "" { + call.CallID = callID + } + if name := itemResult.Get("name").String(); name != "" { + call.Name = name + } + addCodexFunctionCallAliases(params, call, codexFunctionCallKeys(rootResult, itemResult)) +} + +func updateCodexFunctionCallArguments(call *codexFunctionCallStream, arguments string, delta bool) { + if call == nil || arguments == "" { + return + } + if delta { + call.Arguments += arguments + call.HasReceivedArgumentsDelta = true + return + } + if !call.HasReceivedArgumentsDelta { + call.Arguments = arguments + return + } + if strings.HasPrefix(arguments, call.Arguments) { + call.Arguments = arguments } } @@ -648,42 +653,78 @@ func appendCodexFunctionCallStop(output []byte, blockIndex int) []byte { return translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2) } -func appendCodexOpenFunctionCallStop(output []byte, params *ConvertCodexResponseToClaudeParams) []byte { - if params == nil || !params.FunctionCallBlockOpen { +func appendCodexFunctionCallBufferedArguments(output []byte, params *ConvertCodexResponseToClaudeParams, call *codexFunctionCallStream) []byte { + if params == nil || call == nil || params.ActiveFunctionCall != call || !call.Started || call.Closed { return output } - - blockIndex := params.FunctionCallBlockIndex - output = appendCodexFunctionCallStop(output, blockIndex) - if params.BlockIndex <= blockIndex { - params.BlockIndex = blockIndex + 1 + if call.EmittedArgumentsLength >= len(call.Arguments) { + return output } - params.FunctionCallBlockOpen = false - params.FunctionCallBlockCallID = "" - params.FunctionCallBlockIndex = 0 + + output = appendCodexFunctionCallArgumentDelta(output, call.Arguments[call.EmittedArgumentsLength:], call.BlockIndex) + call.EmittedArgumentsLength = len(call.Arguments) return output } -func hydrateOpenCodexFunctionCallFromTerminal(output []byte, params *ConvertCodexResponseToClaudeParams, responseData gjson.Result) []byte { - if params == nil || !params.FunctionCallBlockOpen || params.HasReceivedArgumentsDelta { +func appendCodexFunctionCallQueue(output []byte, params *ConvertCodexResponseToClaudeParams, originalRequestRawJSON []byte) []byte { + if params == nil { return output } - responseData.Get("output").ForEach(func(_, item gjson.Result) bool { - if item.Get("type").String() != "function_call" || codexFunctionCallID(item) != params.FunctionCallBlockCallID { - return true + for { + if active := params.ActiveFunctionCall; active != nil { + output = appendCodexFunctionCallBufferedArguments(output, params, active) + if !active.Done { + return output + } + output = appendCodexFunctionCallStop(output, active.BlockIndex) + if params.BlockIndex <= active.BlockIndex { + params.BlockIndex = active.BlockIndex + 1 + } + active.Closed = true + params.ActiveFunctionCall = nil + removeCodexFunctionCallFromQueue(params, active) } - if args := item.Get("arguments").String(); args != "" { - output = appendCodexFunctionCallArgumentDelta(output, args, params.FunctionCallBlockIndex) - params.HasReceivedArgumentsDelta = true + + for len(params.FunctionCallQueue) > 0 && params.FunctionCallQueue[0].Closed { + params.FunctionCallQueue = params.FunctionCallQueue[1:] } - return false - }) - return output + if len(params.FunctionCallQueue) == 0 { + return output + } + + call := params.FunctionCallQueue[0] + if call.Name == "" { + return output + } + + call.BlockIndex = params.BlockIndex + output = appendCodexFunctionCallStart(output, originalRequestRawJSON, call.CallID, call.Name, call.BlockIndex) + if call.EmitInitialEmptyDelta { + output = appendCodexFunctionCallArgumentDelta(output, "", call.BlockIndex) + } + call.Started = true + params.ActiveFunctionCall = call + params.HasEmittedToolUse = true + output = appendCodexFunctionCallBufferedArguments(output, params, call) + } } -func appendPendingCodexFunctionCallsFromTerminal(output []byte, params *ConvertCodexResponseToClaudeParams, originalRequestRawJSON []byte, responseData gjson.Result) []byte { - if params == nil || len(params.PendingFunctionCalls) == 0 { +func removeCodexFunctionCallFromQueue(params *ConvertCodexResponseToClaudeParams, call *codexFunctionCallStream) { + if params == nil || call == nil { + return + } + for index, queued := range params.FunctionCallQueue { + if queued != call { + continue + } + params.FunctionCallQueue = append(params.FunctionCallQueue[:index], params.FunctionCallQueue[index+1:]...) + return + } +} + +func appendCodexFunctionCallsFromTerminal(output []byte, params *ConvertCodexResponseToClaudeParams, originalRequestRawJSON []byte, responseData gjson.Result) []byte { + if params == nil { return output } @@ -692,88 +733,52 @@ func appendPendingCodexFunctionCallsFromTerminal(output []byte, params *ConvertC return true } - pending, pendingKeys := pendingCodexFunctionCallForTerminalItem(params, index, item) - if pending == nil { - return true + keys := codexFunctionCallKeys(gjson.Result{}, item) + if itemOutputIndex := item.Get("output_index"); itemOutputIndex.Exists() { + keys = appendUniqueCodexFunctionCallKey(keys, "output:"+itemOutputIndex.Raw) } - if pending.StartEmitted { - deletePendingCodexFunctionCallAliases(params, pendingKeys) - return true + if index.Exists() { + keys = appendUniqueCodexFunctionCallKey(keys, "output:"+index.String()) } - - name := item.Get("name").String() - if name == "" { - deletePendingCodexFunctionCallAliases(params, pendingKeys) - return true + call := codexFunctionCallForKeys(params, keys) + if call == nil { + call = &codexFunctionCallStream{BlockIndex: -1} + params.FunctionCallQueue = append(params.FunctionCallQueue, call) } - callID := pending.CallID - if callID == "" { - callID = codexFunctionCallID(item) - } - - blockIndex := params.BlockIndex - output = appendCodexFunctionCallStart(output, originalRequestRawJSON, callID, name, blockIndex) - params.HasEmittedToolUse = true - pending.StartEmitted = true - - args := item.Get("arguments").String() - if args == "" { - args = pending.Arguments - } - if args != "" { - output = appendCodexFunctionCallArgumentDelta(output, args, blockIndex) - } - output = appendCodexFunctionCallStop(output, blockIndex) - params.BlockIndex++ - - deletePendingCodexFunctionCallAliases(params, pendingKeys) + addCodexFunctionCallAliases(params, call, keys) + updateCodexFunctionCallIdentity(params, call, gjson.Result{}, item) + updateCodexFunctionCallArguments(call, item.Get("arguments").String(), false) + call.Done = true return true }) - clearPendingCodexFunctionCalls(params) - return output -} - -func pendingCodexFunctionCallForTerminalItem(params *ConvertCodexResponseToClaudeParams, outputIndex, item gjson.Result) (*pendingCodexFunctionCall, []string) { - if params == nil || params.PendingFunctionCalls == nil { - return nil, nil - } - - keys := make([]string, 0, 3) - if callID := codexFunctionCallID(item); callID != "" { - keys = appendUniqueCodexFunctionCallKey(keys, codexFunctionCallIDKey(callID)) - } - if itemOutputIndex := item.Get("output_index"); itemOutputIndex.Exists() { - keys = appendUniqueCodexFunctionCallKey(keys, "output:"+itemOutputIndex.Raw) - } - if outputIndex.Exists() { - keys = appendUniqueCodexFunctionCallKey(keys, "output:"+outputIndex.Raw) - } - - for _, key := range keys { - if pending, ok := params.PendingFunctionCalls[key]; ok { - return pending, keysForPendingCodexFunctionCall(params, pending) + queuedCalls := params.FunctionCallQueue[:0] + for _, call := range params.FunctionCallQueue { + if call.Closed { + continue } + if call.Name == "" { + call.Closed = true + continue + } + call.Done = true + queuedCalls = append(queuedCalls, call) } - return nil, nil + params.FunctionCallQueue = queuedCalls + output = appendCodexFunctionCallQueue(output, params, originalRequestRawJSON) + + clearCodexFunctionCalls(params) + return output } -func clearPendingCodexFunctionCalls(params *ConvertCodexResponseToClaudeParams) { - if params == nil || params.PendingFunctionCalls == nil { +func clearCodexFunctionCalls(params *ConvertCodexResponseToClaudeParams) { + if params == nil { return } - for key := range params.PendingFunctionCalls { - delete(params.PendingFunctionCalls, key) - } - params.LastPendingFunctionCallKey = "" -} - -func finalizeCodexOpenContentBlocks(params *ConvertCodexResponseToClaudeParams) []byte { - output := make([]byte, 0, 256) - output = append(output, finalizeCodexThinkingBlock(params)...) - output = append(output, stopCodexTextBlock(params)...) - output = appendCodexOpenFunctionCallStop(output, params) - return output + clear(params.FunctionCalls) + params.FunctionCallQueue = nil + params.ActiveFunctionCall = nil + params.LastFunctionCall = nil } func resolveCodexClaudeToolUseName(originalRequestRawJSON []byte, name string) string { diff --git a/internal/translator/codex/claude/codex_claude_response_test.go b/internal/translator/codex/claude/codex_claude_response_test.go index bc59cec3..3ed49a4d 100644 --- a/internal/translator/codex/claude/codex_claude_response_test.go +++ b/internal/translator/codex/claude/codex_claude_response_test.go @@ -879,7 +879,7 @@ func TestConvertCodexResponseToClaude_StreamUnresolvedPendingFunctionCallDoesNot t.Fatalf("stop_reason = %q, want end_turn. Outputs=%q", gotReason, outputs) } params, ok := param.(*ConvertCodexResponseToClaudeParams) - if !ok || len(params.PendingFunctionCalls) != 0 || params.LastPendingFunctionCallKey != "" { + if !ok || len(params.FunctionCalls) != 0 || len(params.FunctionCallQueue) != 0 || params.LastFunctionCall != nil { t.Fatalf("pending function calls were not cleared: %#v", param) } } diff --git a/internal/translator/codex/claude/codex_claude_response_web_search.go b/internal/translator/codex/claude/codex_claude_response_web_search.go index 1f9c59a7..c5c8f866 100644 --- a/internal/translator/codex/claude/codex_claude_response_web_search.go +++ b/internal/translator/codex/claude/codex_claude_response_web_search.go @@ -28,6 +28,7 @@ func appendCodexWebSearchServerToolUse(output []byte, params *ConvertCodexRespon } if !alreadyStarted { + output = append(output, stopCodexTextBlock(params)...) output = append(output, finalizeCodexThinkingBlock(params)...) template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"","name":"web_search","input":{}}}`) template, _ = sjson.SetBytes(template, "index", params.BlockIndex) diff --git a/test/codex_claude_parallel_function_calls_test.go b/test/codex_claude_parallel_function_calls_test.go new file mode 100644 index 00000000..78255190 --- /dev/null +++ b/test/codex_claude_parallel_function_calls_test.go @@ -0,0 +1,125 @@ +package test + +import ( + "context" + "strings" + "testing" + + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" +) + +func TestCodexToClaudeParallelFunctionCallsHaveValidLifecycle(t *testing.T) { + chunks := [][]byte{ + []byte(`data: {"type":"response.created","response":{"id":"resp_parallel","model":"gpt-5"}}`), + []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_a","name":"Read"},"output_index":1}`), + []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_b","name":"Read"},"output_index":2}`), + []byte(`data: {"type":"response.function_call_arguments.delta","delta":"{\"file_path\":\"a\"}","output_index":1}`), + []byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"file_path\":\"a\"}","output_index":1}`), + []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_a","name":"Read","arguments":"{\"file_path\":\"a\"}"},"output_index":1}`), + []byte(`data: {"type":"response.function_call_arguments.delta","delta":"{\"file_path\":\"b\"}","output_index":2}`), + []byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"file_path\":\"b\"}","output_index":2}`), + []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_b","name":"Read","arguments":"{\"file_path\":\"b\"}"},"output_index":2}`), + []byte(`data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1},"output":[{"type":"function_call","call_id":"call_a","name":"Read","arguments":"{\"file_path\":\"a\"}"},{"type":"function_call","call_id":"call_b","name":"Read","arguments":"{\"file_path\":\"b\"}"}]}}`), + } + + originalRequest := []byte(`{"stream":true,"tools":[{"name":"Read"}]}`) + var state any + open := make(map[int64]struct{}) + started := make(map[int64]struct{}) + toolIDs := make(map[int64]string) + arguments := make(map[int64]string) + var startIndices []int64 + var stopIndices []int64 + messageState := 0 + + for _, chunk := range chunks { + outputs := sdktranslator.TranslateStream( + context.Background(), + sdktranslator.FormatCodex, + sdktranslator.FormatClaude, + "gpt-5", + originalRequest, + nil, + chunk, + &state, + ) + for _, output := range outputs { + for _, line := range strings.Split(string(output), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + event := gjson.Parse(strings.TrimPrefix(line, "data: ")) + if messageState == 2 { + t.Fatalf("event emitted after message_stop: %s", event.Raw) + } + index := event.Get("index").Int() + switch event.Get("type").String() { + case "content_block_start": + if messageState != 0 { + t.Fatalf("content block started after message terminal events: %s", event.Raw) + } + if len(open) != 0 { + t.Fatalf("content block start emitted while another block remains open: %v", open) + } + if _, exists := started[index]; exists { + t.Fatalf("content block index %d was reused", index) + } + open[index] = struct{}{} + started[index] = struct{}{} + startIndices = append(startIndices, index) + toolIDs[index] = event.Get("content_block.id").String() + case "content_block_delta": + if _, exists := open[index]; !exists { + t.Fatalf("content block delta targets unopened index %d", index) + } + if event.Get("delta.type").String() == "input_json_delta" { + arguments[index] += event.Get("delta.partial_json").String() + } + case "content_block_stop": + if _, exists := open[index]; !exists { + t.Fatalf("content block stop targets unopened index %d", index) + } + delete(open, index) + stopIndices = append(stopIndices, index) + case "message_delta": + if len(open) != 0 { + t.Fatalf("message_delta emitted while content blocks remain open: %v", open) + } + if messageState != 0 { + t.Fatalf("duplicate or out-of-order message_delta: %s", event.Raw) + } + messageState = 1 + case "message_stop": + if len(open) != 0 { + t.Fatalf("message_stop emitted while content blocks remain open: %v", open) + } + if messageState != 1 { + t.Fatalf("message_stop emitted before message_delta: %s", event.Raw) + } + messageState = 2 + } + } + } + } + + if len(open) != 0 { + t.Fatalf("content blocks remain open: %v", open) + } + if messageState != 2 { + t.Fatalf("terminal message event state = %d, want message_delta followed by message_stop", messageState) + } + if len(startIndices) != 2 || startIndices[0] != 0 || startIndices[1] != 1 { + t.Fatalf("start indices = %v, want [0 1]", startIndices) + } + if len(stopIndices) != 2 || stopIndices[0] != 0 || stopIndices[1] != 1 { + t.Fatalf("stop indices = %v, want [0 1]", stopIndices) + } + if toolIDs[0] != "call_a" || toolIDs[1] != "call_b" { + t.Fatalf("tool IDs = %v, want call_a and call_b", toolIDs) + } + if arguments[0] != `{"file_path":"a"}` || arguments[1] != `{"file_path":"b"}` { + t.Fatalf("tool arguments = %v", arguments) + } +} -- 2.51.2 From 4a2eb54dc6bf943196be4fb515e6a9407a4db143 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 29 Jul 2026 23:47:37 +0800 Subject: [PATCH 08/31] feat(translator): group consecutive tool results in Claude request conversion - Added logic to merge consecutive tool responses into a single user message for better grouping. - Updated `ConvertOpenAIRequestToClaude` to track previous roles and adjust message blocks accordingly. - Introduced a comprehensive test to validate tool result grouping behavior and content preservation. Closes: #4656 --- .../chat-completions/claude_openai_request.go | 10 ++- .../claude_openai_request_test.go | 64 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) 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 e0957b2d..9c483598 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -165,6 +165,7 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream if messages := root.Get("messages"); messages.Exists() && messages.IsArray() { systemBlocks := make([][]byte, 0) messageBlocks := make([][]byte, 0) + previousRole := "" messages.ForEach(func(_, message gjson.Result) bool { role := message.Get("role").String() contentResult := message.Get("content") @@ -274,8 +275,15 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream msg, _ = sjson.SetBytes(msg, "content.0.content", toolResultContent) } msg = common.AttachMessageCacheControl(msg, message) - messageBlocks = append(messageBlocks, msg) + if previousRole == "tool" && len(messageBlocks) > 0 { + toolResult := gjson.GetBytes(msg, "content.0") + lastIdx := len(messageBlocks) - 1 + messageBlocks[lastIdx], _ = sjson.SetRawBytes(messageBlocks[lastIdx], "content.-1", []byte(toolResult.Raw)) + } else { + messageBlocks = append(messageBlocks, msg) + } } + previousRole = role return true }) diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go index 801b8e39..7c19f246 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go @@ -44,6 +44,70 @@ func TestConvertOpenAIRequestToClaude_SanitizesToolCallIDsForClaude(t *testing.T } } +func TestConvertOpenAIRequestToClaude_GroupsConsecutiveParallelToolResults(t *testing.T) { + inputJSON := `{ + "model": "gpt-4.1", + "messages": [ + {"role": "user", "content": "Use both tools."}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + {"id": "call_1", "type": "function", "function": {"name": "tool_a", "arguments": "{}"}}, + {"id": "call_2", "type": "function", "function": {"name": "tool_b", "arguments": "{}"}} + ] + }, + { + "role": "tool", + "tool_call_id": "call_1", + "content": "one", + "cache_control": {"type": "ephemeral"} + }, + {"role": "tool", "tool_call_id": "call_2", "content": "two"}, + {"role": "assistant", "content": "Done."} + ] + }` + + result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + messages := resultJSON.Get("messages").Array() + + if len(messages) != 4 { + t.Fatalf("Expected 4 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw) + } + if got := messages[2].Get("role").String(); got != "user" { + t.Fatalf("Expected grouped tool result role %q, got %q", "user", got) + } + toolResults := messages[2].Get("content").Array() + if len(toolResults) != 2 { + t.Fatalf("Expected 2 grouped tool results, got %d. Content: %s", len(toolResults), messages[2].Get("content").Raw) + } + wants := []struct { + id string + content string + }{ + {id: "call_1", content: "one"}, + {id: "call_2", content: "two"}, + } + for i, want := range wants { + if got := toolResults[i].Get("type").String(); got != "tool_result" { + t.Fatalf("tool result %d type = %q, want tool_result", i, got) + } + if got := toolResults[i].Get("tool_use_id").String(); got != want.id { + t.Fatalf("tool result %d tool_use_id = %q, want %q", i, got, want.id) + } + if got := toolResults[i].Get("content").String(); got != want.content { + t.Fatalf("tool result %d content = %q, want %q", i, got, want.content) + } + } + if got := toolResults[0].Get("cache_control.type").String(); got != "ephemeral" { + t.Fatalf("first tool result cache_control.type = %q, want ephemeral", got) + } + if got := messages[3].Get("content.0.text").String(); got != "Done." { + t.Fatalf("following assistant message text = %q, want Done.", got) + } +} + func TestConvertOpenAIRequestToClaude_DropsTemperature(t *testing.T) { inputJSON := `{ "model": "gpt-4.1", -- 2.51.2 From 2c8e5ba46334d4d50096b8d263f031ecde99d1cc Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Thu, 30 Jul 2026 01:03:04 +0800 Subject: [PATCH 09/31] fix(codex-api): stop force-injecting built-in model IDs --- .../config_model_display_name_test.go | 33 +++++++------------ sdk/cliproxy/service_models.go | 21 +++++++----- 2 files changed, 23 insertions(+), 31 deletions(-) diff --git a/sdk/cliproxy/config_model_display_name_test.go b/sdk/cliproxy/config_model_display_name_test.go index 452dbae1..1c47ef36 100644 --- a/sdk/cliproxy/config_model_display_name_test.go +++ b/sdk/cliproxy/config_model_display_name_test.go @@ -68,31 +68,20 @@ func TestBuildConfigModelsDisplayName(t *testing.T) { } } -func TestBuildCodexConfigModelsPreservesBuiltinDisplayNames(t *testing.T) { - models := buildCodexConfigModels(&config.CodexKey{Models: []config.CodexModel{ - {Name: "gpt-image-1.5", DisplayName: "Configured Image 1.5"}, - {Name: "gpt-image-2", DisplayName: "Configured Image 2"}, - }}) +func TestBuildCodexConfigModelsOnlyIncludesConfiguredModels(t *testing.T) { + models := buildCodexConfigModels(&config.CodexKey{Models: []config.CodexModel{{ + Name: "upstream-codex", Alias: "configured-codex", + }}}) - wantDisplayNames := map[string]string{ - "gpt-image-1.5": "Configured Image 1.5", - "gpt-image-2": "Configured Image 2", + if len(models) != 1 { + t.Fatalf("model count = %d, want 1", len(models)) } - for _, model := range models { - wantDisplayName, ok := wantDisplayNames[model.ID] - if !ok { - continue - } - if model.DisplayName != wantDisplayName { - t.Errorf("%s DisplayName = %q, want %q", model.ID, model.DisplayName, wantDisplayName) - } - if model.Object != "model" || model.OwnedBy != "openai" || model.Type != "openai" || model.Created != 1704067200 || model.Version != model.ID || model.UserDefined { - t.Errorf("%s builtin metadata was not preserved: %#v", model.ID, model) - } - delete(wantDisplayNames, model.ID) + if models[0].ID != "configured-codex" { + t.Fatalf("model ID = %q, want configured-codex", models[0].ID) } - for modelID := range wantDisplayNames { - t.Errorf("missing builtin model %s", modelID) + + if models := buildCodexConfigModels(&config.CodexKey{}); len(models) != 0 { + t.Fatalf("model count without configuration = %d, want 0", len(models)) } } diff --git a/sdk/cliproxy/service_models.go b/sdk/cliproxy/service_models.go index f54aa069..4645cd39 100644 --- a/sdk/cliproxy/service_models.go +++ b/sdk/cliproxy/service_models.go @@ -113,6 +113,17 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut } models = applyExcludedModels(models, excluded) case "codex": + if authKind == "apikey" { + if entry := s.resolveConfigCodexKey(a); entry != nil { + models = buildCodexConfigModels(entry) + excluded = entry.ExcludedModels + } else { + models = nil + } + models = applyExcludedModels(models, excluded) + break + } + codexPlanType := "" if a.Attributes != nil { codexPlanType = strings.TrimSpace(a.Attributes["plan_type"]) @@ -129,14 +140,6 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut default: models = registry.GetCodexProModels() } - if entry := s.resolveConfigCodexKey(a); entry != nil { - if len(entry.Models) > 0 { - models = buildCodexConfigModels(entry) - } - if authKind == "apikey" { - excluded = entry.ExcludedModels - } - } models = applyExcludedModels(models, excluded) case "kimi": models = registry.GetKimiModels() @@ -774,7 +777,7 @@ func buildCodexConfigModels(entry *config.CodexKey) []*ModelInfo { return nil } - models := registry.WithCodexBuiltins(buildConfigModels(entry.Models, "openai", "openai")) + models := buildConfigModels(entry.Models, "openai", "openai") configuredDisplayNames := make(map[string]string, len(entry.Models)) seenConfiguredModels := make(map[string]struct{}, len(entry.Models)) for i := range entry.Models { -- 2.51.2 From 2b63d6bcda136af1d3638be8e0038658911fb217 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 30 Jul 2026 03:39:48 +0800 Subject: [PATCH 10/31] refactor(util): use structured options for JSON schema cleaning - Replaced boolean parameters with a `jsonSchemaCleanOptions` struct in `cleanJSONSchema` to improve readability and scalability. - Updated `CleanJSONSchemaForAntigravity` and related methods to utilize the new options struct. - Enhanced flexibility for schema transformations with fine-grained control over operations like union flattening, enum type enforcement, and metadata removal. - Added comprehensive tests to verify correct handling of unions and enum types in schemas. Closes: #4666 --- .../antigravity_schema_sanitize_test.go | 40 ++++++++++ internal/util/gemini_schema.go | 54 ++++++++----- internal/util/gemini_schema_test.go | 78 +++++++++++++++++++ 3 files changed, 154 insertions(+), 18 deletions(-) diff --git a/internal/runtime/executor/antigravity_schema_sanitize_test.go b/internal/runtime/executor/antigravity_schema_sanitize_test.go index 6151ae4c..139374bb 100644 --- a/internal/runtime/executor/antigravity_schema_sanitize_test.go +++ b/internal/runtime/executor/antigravity_schema_sanitize_test.go @@ -274,6 +274,46 @@ func TestSanitizeAntigravityRequestSchemasKeepsResponseSchemasPlaceholderFree(t } } +func TestSanitizeAntigravityRequestSchemasPreservesResponseUnionAndEnumType(t *testing.T) { + payload := `{"request":{ + "tools":[{"functionDeclarations":[{"name":"tool","parameters":{"type":"object","properties":{ + "choice":{"anyOf":[{"type":"string"},{"type":"null"}]}, + "level":{"type":"number","enum":[1,2]} + }}}]}], + "generationConfig":{"responseSchema":{"type":"object","properties":{ + "action":{"anyOf":[ + {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}, + {"type":"null"} + ]}, + "conviction":{"type":"number","enum":[0.25,0.5,1]} + }}} + }}` + + got := sanitizeAntigravityRequestSchemas(payload, true) + responseSchema := gjson.Get(got, "request.generationConfig.responseSchema") + union := responseSchema.Get("properties.action.anyOf") + if !union.IsArray() || len(union.Array()) != 2 || union.Get("1.type").String() != "null" { + t.Errorf("response anyOf union was flattened: %s", responseSchema.Raw) + } + conviction := responseSchema.Get("properties.conviction") + if gotType := conviction.Get("type").String(); gotType != "number" { + t.Errorf("response enum type = %q, want number: %s", gotType, responseSchema.Raw) + } + for _, enumValue := range conviction.Get("enum").Array() { + if enumValue.Type != gjson.String { + t.Errorf("response enum value is not a string: %s", conviction.Raw) + } + } + + toolSchema := gjson.Get(got, "request.tools.0.functionDeclarations.0.parameters") + if toolSchema.Get("properties.choice.anyOf").Exists() { + t.Errorf("tool anyOf union was not flattened: %s", toolSchema.Raw) + } + if gotType := toolSchema.Get("properties.level.type").String(); gotType != "string" { + t.Errorf("tool enum type = %q, want string: %s", gotType, toolSchema.Raw) + } +} + func TestAntigravityBuildRequestKeepsJSONObjectSchemaPlaceholderFree(t *testing.T) { input := []byte(`{"model":"gemini-3.1-pro-low","messages":[{"role":"user","content":"hi"}],"response_format":{"type":"json_object"}}`) translated := antigravitychat.ConvertOpenAIRequestToAntigravity("gemini-3.1-pro-low", input, false) diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index 51a414f7..f3b99d29 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -24,50 +24,67 @@ const placeholderReasonDescription = "Brief explanation of why you are calling t // and replacements such as "enum" and "type" are fabricated. That regression reached production // once already; scope every call site to the schema itself. +type jsonSchemaCleanOptions struct { + addPlaceholder bool + removeGeminiMetadata bool + flattenUnions bool + forceEnumStringType bool +} + // CleanJSONSchemaForAntigravity transforms a tool schema to be compatible with Antigravity API. // It handles unsupported keywords, type flattening, and schema simplification while preserving // semantic information as description hints and adding placeholders required by VALIDATED mode. func CleanJSONSchemaForAntigravity(jsonStr string) string { - return cleanJSONSchema(jsonStr, true, false) + return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{ + addPlaceholder: true, + flattenUnions: true, + forceEnumStringType: true, + }) } -// CleanJSONSchemaForAntigravityResponse transforms a response schema without adding tool-only -// placeholders that would alter the client's structured output contract. +// CleanJSONSchemaForAntigravityResponse transforms a response schema without applying tool-only +// compatibility rewrites that would alter the client's structured output contract. func CleanJSONSchemaForAntigravityResponse(jsonStr string) string { - return cleanJSONSchema(jsonStr, false, false) + return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{}) } // CleanJSONSchemaForGemini transforms a JSON schema to be compatible with Gemini tool calling. // It removes unsupported keywords and simplifies schemas, without adding empty-schema placeholders. func CleanJSONSchemaForGemini(jsonStr string) string { - return cleanJSONSchema(jsonStr, false, true) + return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{ + removeGeminiMetadata: true, + flattenUnions: true, + forceEnumStringType: true, + }) } // cleanJSONSchema performs the core cleaning operations on the JSON schema. -func cleanJSONSchema(jsonStr string, addPlaceholder, removeGeminiMetadata bool) string { +func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string { // Phase 1: Convert and add hints jsonStr = convertRefsToHints(jsonStr) jsonStr = convertConstToEnum(jsonStr) - jsonStr = convertEnumValuesToStrings(jsonStr) + jsonStr = convertEnumValuesToStrings(jsonStr, options.forceEnumStringType) jsonStr = addEnumHints(jsonStr) jsonStr = addAdditionalPropertiesHints(jsonStr) jsonStr = moveConstraintsToDescription(jsonStr) // Phase 2: Flatten complex structures jsonStr = mergeAllOf(jsonStr) - jsonStr = flattenAnyOfOneOf(jsonStr) + if options.flattenUnions { + jsonStr = flattenAnyOfOneOf(jsonStr) + } jsonStr = flattenTypeArrays(jsonStr) // Phase 3: Cleanup jsonStr = removeUnsupportedKeywords(jsonStr) - if removeGeminiMetadata { + if options.removeGeminiMetadata { // Gemini schema cleanup: remove nullable/title and placeholder-only fields. jsonStr = removeKeywords(jsonStr, []string{"nullable", "title"}) jsonStr = removePlaceholderFields(jsonStr) } jsonStr = cleanupRequiredFields(jsonStr) // Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement) - if addPlaceholder { + if options.addPlaceholder { jsonStr = addEmptySchemaPlaceholder(jsonStr) } @@ -201,9 +218,10 @@ func convertConstToEnum(jsonStr string) string { return jsonStr } -// convertEnumValuesToStrings ensures all enum values are strings and the schema type is set to string. -// Gemini API requires enum values to be of type string, not numbers or booleans. -func convertEnumValuesToStrings(jsonStr string) string { +// convertEnumValuesToStrings ensures all enum values use the string representation required by +// Gemini's proto schema. Tool schemas also require a string type, while response schemas preserve +// their declared type because the upstream decoder uses it to select the emitted JSON value type. +func convertEnumValuesToStrings(jsonStr string, forceStringType bool) string { for _, p := range findPaths(jsonStr, "enum") { arr := gjson.Get(jsonStr, p) if !arr.IsArray() { @@ -215,13 +233,13 @@ func convertEnumValuesToStrings(jsonStr string) string { stringVals = append(stringVals, item.String()) } - // Always update enum values to strings and set type to "string" - // This ensures compatibility with Antigravity Gemini which only allows enum for STRING type updated, _ := sjson.SetBytes([]byte(jsonStr), p, stringVals) jsonStr = string(updated) - parentPath := trimSuffix(p, ".enum") - updated, _ = sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "type"), "string") - jsonStr = string(updated) + if forceStringType { + parentPath := trimSuffix(p, ".enum") + updated, _ = sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "type"), "string") + jsonStr = string(updated) + } } return jsonStr } diff --git a/internal/util/gemini_schema_test.go b/internal/util/gemini_schema_test.go index 20d10b4d..50a66bbd 100644 --- a/internal/util/gemini_schema_test.go +++ b/internal/util/gemini_schema_test.go @@ -764,6 +764,76 @@ func TestCleanJSONSchemaForAntigravityResponseDoesNotAddToolPlaceholders(t *test } } +func TestCleanJSONSchemaForAntigravityResponsePreservesUnions(t *testing.T) { + input := `{ + "type":"object", + "properties":{ + "action":{"anyOf":[ + {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}, + {"type":"null"} + ]}, + "label":{"oneOf":[{"type":"string"},{"type":"null"}]} + } + }` + + result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input)) + for _, testCase := range []struct { + path string + wantTypes []string + }{ + {path: "properties.action.anyOf", wantTypes: []string{"object", "null"}}, + {path: "properties.label.oneOf", wantTypes: []string{"string", "null"}}, + } { + union := result.Get(testCase.path) + if !union.IsArray() { + t.Errorf("response union %s was flattened: %s", testCase.path, result.Raw) + continue + } + var gotTypes []string + for _, branch := range union.Array() { + gotTypes = append(gotTypes, branch.Get("type").String()) + } + if !reflect.DeepEqual(gotTypes, testCase.wantTypes) { + t.Errorf("response union %s types = %v, want %v: %s", testCase.path, gotTypes, testCase.wantTypes, result.Raw) + } + } +} + +func TestCleanJSONSchemaForAntigravityResponsePreservesEnumType(t *testing.T) { + input := `{ + "type":"object", + "properties":{ + "conviction":{"type":"number","enum":[0.25,0.5,1]}, + "count":{"type":"integer","enum":[1,2]} + } + }` + + result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input)) + for _, testCase := range []struct { + path string + wantType string + wantValues []string + }{ + {path: "properties.conviction", wantType: "number", wantValues: []string{"0.25", "0.5", "1"}}, + {path: "properties.count", wantType: "integer", wantValues: []string{"1", "2"}}, + } { + schema := result.Get(testCase.path) + if gotType := schema.Get("type").String(); gotType != testCase.wantType { + t.Errorf("%s type = %q, want %q: %s", testCase.path, gotType, testCase.wantType, result.Raw) + } + var gotValues []string + for _, enumValue := range schema.Get("enum").Array() { + if enumValue.Type != gjson.String { + t.Errorf("%s enum value is not a string: %s", testCase.path, enumValue.Raw) + } + gotValues = append(gotValues, enumValue.String()) + } + if !reflect.DeepEqual(gotValues, testCase.wantValues) { + t.Errorf("%s enum values = %v, want %v: %s", testCase.path, gotValues, testCase.wantValues, result.Raw) + } + } +} + // ============================================================================ // Format field handling (ad-hoc patch removal) // ============================================================================ @@ -862,6 +932,14 @@ func TestCleanJSONSchemaForAntigravity_NumericEnumToString(t *testing.T) { }` result := CleanJSONSchemaForAntigravity(input) + parsed := gjson.Parse(result) + + // Tool enum schemas require both string values and a string type. + for _, path := range []string{"properties.priority", "properties.level"} { + if gotType := parsed.Get(path + ".type").String(); gotType != "string" { + t.Errorf("Tool enum type at %s = %q, want string: %s", path, gotType, result) + } + } // Numeric enum values should be converted to strings if strings.Contains(result, `"enum":[0,1,2]`) { -- 2.51.2 From a2ff6914bb49d125132c80169590939ac1b2e8d8 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 30 Jul 2026 05:46:44 +0800 Subject: [PATCH 11/31] feat(store): enhance repository handling and corruption recovery logic - Added safeguards against repository corruption with improved recovery mechanisms, including remote re-cloning and worktree restoration. - Introduced fine-grained repository integrity checks and missing file restoration logic. - Centralized repository locking with `ensureRepositoryLocked` to standardize synchronization. - Enhanced conflict resolution during pulls with automated reconciliation of remote and local changes. - Improved worktree and index reset workflows to handle dirty and missing paths gracefully. - Increased robustness for garbage collection with extended grace period and recovery paths. Closes: #4629 --- internal/store/gitstore.go | 916 +++++++++++++++++++++++--- internal/store/gitstore_test.go | 1059 +++++++++++++++++++++++++++++++ 2 files changed, 1897 insertions(+), 78 deletions(-) diff --git a/internal/store/gitstore.go b/internal/store/gitstore.go index 222130a6..7d01363d 100644 --- a/internal/store/gitstore.go +++ b/internal/store/gitstore.go @@ -8,6 +8,7 @@ import ( "io/fs" "os" "path/filepath" + "sort" "strings" "sync" "time" @@ -20,11 +21,16 @@ 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" + "github.com/go-git/go-git/v6/storage/filesystem/dotgit" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) -// gcInterval defines minimum time between garbage collection runs. -const gcInterval = 5 * time.Minute +const ( + // gcInterval defines minimum time between garbage collection runs. + gcInterval = 5 * time.Minute + // gcPruneGracePeriod keeps recently orphaned objects available for recovery. + gcPruneGracePeriod = 24 * time.Hour +) // GitTokenStore persists token records and auth metadata using git as the backing storage. type GitTokenStore struct { @@ -59,6 +65,9 @@ func NewGitTokenStore(remote, username, password, branch string) *GitTokenStore // SetBaseDir updates the default directory used for auth JSON persistence when no explicit path is provided. func (s *GitTokenStore) SetBaseDir(dir string) { + s.mu.Lock() + defer s.mu.Unlock() + clean := strings.TrimSpace(dir) if clean == "" { s.dirLock.Lock() @@ -100,6 +109,12 @@ func (s *GitTokenStore) ConfigPath() string { // EnsureRepository prepares the local git working tree by cloning or opening the repository. func (s *GitTokenStore) EnsureRepository() error { + s.mu.Lock() + defer s.mu.Unlock() + return s.ensureRepositoryLocked() +} + +func (s *GitTokenStore) ensureRepositoryLocked() error { s.dirLock.Lock() if s.remote == "" { s.dirLock.Unlock() @@ -197,6 +212,26 @@ func (s *GitTokenStore) EnsureRepository() error { s.dirLock.Unlock() return fmt.Errorf("git token store: worktree: %w", errWorktree) } + if errVerify := verifyRepositoryHead(repo); errVerify != nil { + if !isRepositoryCorruptionError(errVerify) { + s.dirLock.Unlock() + return fmt.Errorf("git token store: verify repository before pull: %w", errVerify) + } + if errRecover := s.recoverRepositoryLocked(repoDir, authMethod, nil, nil); errRecover != nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: verify repository before pull: %w; recovery failed: %v", errVerify, errRecover) + } + repo, errOpen = git.PlainOpen(repoDir) + if errOpen != nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: open recovered repo: %w", errOpen) + } + worktree, errWorktree = repo.Worktree() + if errWorktree != nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: recovered worktree: %w", errWorktree) + } + } if s.branch != "" { if errCheckout := s.checkoutConfiguredBranch(repo, worktree, authMethod); errCheckout != nil { s.dirLock.Unlock() @@ -215,12 +250,60 @@ func (s *GitTokenStore) EnsureRepository() error { if s.branch != "" { pullOpts.ReferenceName = plumbing.NewBranchReferenceName(s.branch) } + prePullHead, errPrePullHead := repo.Head() + if errPrePullHead != nil && !errors.Is(errPrePullHead, plumbing.ErrReferenceNotFound) { + s.dirLock.Unlock() + return fmt.Errorf("git token store: get head before pull: %w", errPrePullHead) + } + var prePullTree *object.Tree + if prePullHead != nil { + prePullCommit, errPrePullCommit := repo.CommitObject(prePullHead.Hash()) + if errPrePullCommit != nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: inspect head before pull: %w", errPrePullCommit) + } + prePullTree, errPrePullCommit = prePullCommit.Tree() + if errPrePullCommit != nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: inspect tree before pull: %w", errPrePullCommit) + } + } + dirtyPaths, errDirtyPaths := worktreeDirtyPaths(worktree) + if errDirtyPaths != nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: inspect worktree before pull: %w", errDirtyPaths) + } + repositoryRecovered := false if errPull := worktree.Pull(pullOpts); errPull != nil { switch { - case errors.Is(errPull, git.NoErrAlreadyUpToDate), - errors.Is(errPull, git.ErrUnstagedChanges), - errors.Is(errPull, git.ErrNonFastForwardUpdate): - // Ignore clean syncs, local edits, and remote divergence—local changes win. + case errors.Is(errPull, git.NoErrAlreadyUpToDate): + if errReset := resetIndexToHead(repo, worktree); errReset != nil { + if !isRepositoryCorruptionError(errReset) { + s.dirLock.Unlock() + return fmt.Errorf("git token store: repair index after up-to-date pull: %w", errReset) + } + if errRecover := s.recoverRepositoryLocked(repoDir, authMethod, prePullTree, dirtyPaths); errRecover != nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: repair index after up-to-date pull: %w; recovery failed: %v", errReset, errRecover) + } + repositoryRecovered = true + } + case errors.Is(errPull, git.ErrUnstagedChanges), errors.Is(errPull, git.ErrNonFastForwardUpdate): + if prePullHead == nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: reconcile pull without a local branch") + } + if errReconcile := reconcileRemoteWorktree(repo, worktree, repoDir, prePullHead, dirtyPaths); errReconcile != nil { + if !isRepositoryCorruptionError(errReconcile) { + s.dirLock.Unlock() + return fmt.Errorf("git token store: reconcile remote changes: %w", errReconcile) + } + if errRecover := s.recoverRepositoryLocked(repoDir, authMethod, prePullTree, dirtyPaths); errRecover != nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: reconcile remote changes: %w; recovery failed: %v", errReconcile, errRecover) + } + repositoryRecovered = true + } case errors.Is(errPull, transport.ErrAuthenticationRequired), errors.Is(errPull, transport.ErrEmptyRemoteRepository): // Ignore authentication prompts and empty remote references on initial sync. @@ -230,11 +313,36 @@ func (s *GitTokenStore) EnsureRepository() error { return fmt.Errorf("git token store: pull: %w", errPull) } // Ignore missing references only when following the remote default branch. + case isRepositoryCorruptionError(errPull): + if errRecover := s.recoverRepositoryLocked(repoDir, authMethod, prePullTree, dirtyPaths); errRecover != nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: pull: %w; recovery failed: %v", errPull, errRecover) + } + repositoryRecovered = true default: s.dirLock.Unlock() return fmt.Errorf("git token store: pull: %w", errPull) } } + if !repositoryRecovered { + if errVerify := verifyRepositoryHead(repo); errVerify != nil { + if !isRepositoryCorruptionError(errVerify) { + s.dirLock.Unlock() + return fmt.Errorf("git token store: verify repository after pull: %w", errVerify) + } + if errRecover := s.recoverRepositoryLocked(repoDir, authMethod, prePullTree, dirtyPaths); errRecover != nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: verify repository after pull: %w; recovery failed: %v", errVerify, errRecover) + } + repositoryRecovered = true + } + } + if !repositoryRecovered { + if errRestore := restoreMissingTrackedFiles(repo, repoDir); errRestore != nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: restore tracked worktree files: %w", errRestore) + } + } } if err := disableGitCommitSigning(repoDir); err != nil { s.dirLock.Unlock() @@ -250,11 +358,8 @@ func (s *GitTokenStore) EnsureRepository() error { } s.dirLock.Unlock() if len(initPaths) > 0 { - s.mu.Lock() - err := s.commitAndPushLocked("Initialize git token store", initPaths...) - s.mu.Unlock() - if err != nil { - return err + if errCommit := s.commitAndPushInitialLocked("Initialize git token store", initPaths...); errCommit != nil { + return errCommit } } return nil @@ -269,6 +374,9 @@ func (s *GitTokenStore) Save(_ context.Context, auth *cliproxyauth.Auth) (string return "", fmt.Errorf("auth filestore: %w", errWeight) } + s.mu.Lock() + defer s.mu.Unlock() + path, err := s.resolveAuthPath(auth) if err != nil { return "", err @@ -283,13 +391,13 @@ func (s *GitTokenStore) Save(_ context.Context, auth *cliproxyauth.Auth) (string } } - if err = s.EnsureRepository(); err != nil { + if err = s.ensureRepositoryLocked(); err != nil { return "", err } - - s.mu.Lock() - defer s.mu.Unlock() - + relPath, errRel := s.relativeToRepo(path) + if errRel != nil { + return "", errRel + } if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return "", fmt.Errorf("auth filestore: create dir failed: %w", err) } @@ -312,19 +420,20 @@ func (s *GitTokenStore) Save(_ context.Context, auth *cliproxyauth.Auth) (string if errMarshal != nil { return "", fmt.Errorf("auth filestore: marshal metadata failed: %w", errMarshal) } + contentsMatch := false if existing, errRead := os.ReadFile(path); errRead == nil { - if jsonEqual(existing, raw) { - return path, nil - } + contentsMatch = jsonEqual(existing, raw) } else if !os.IsNotExist(errRead) { return "", fmt.Errorf("auth filestore: read existing failed: %w", errRead) } - tmp := path + ".tmp" - if errWrite := os.WriteFile(tmp, raw, 0o600); errWrite != nil { - return "", fmt.Errorf("auth filestore: write temp failed: %w", errWrite) - } - if errRename := os.Rename(tmp, path); errRename != nil { - return "", fmt.Errorf("auth filestore: rename failed: %w", errRename) + if !contentsMatch { + tmp := path + ".tmp" + if errWrite := os.WriteFile(tmp, raw, 0o600); errWrite != nil { + return "", fmt.Errorf("auth filestore: write temp failed: %w", errWrite) + } + if errRename := os.Rename(tmp, path); errRename != nil { + return "", fmt.Errorf("auth filestore: rename failed: %w", errRename) + } } default: return "", fmt.Errorf("auth filestore: nothing to persist for %s", auth.ID) @@ -340,10 +449,6 @@ func (s *GitTokenStore) Save(_ context.Context, auth *cliproxyauth.Auth) (string auth.FileName = auth.ID } - relPath, errRel := s.relativeToRepo(path) - if errRel != nil { - return "", errRel - } messageID := auth.ID if strings.TrimSpace(messageID) == "" { messageID = filepath.Base(path) @@ -357,7 +462,10 @@ func (s *GitTokenStore) Save(_ context.Context, auth *cliproxyauth.Auth) (string // List enumerates all auth JSON files under the configured directory. func (s *GitTokenStore) List(_ context.Context) ([]*cliproxyauth.Auth, error) { - if err := s.EnsureRepository(); err != nil { + s.mu.Lock() + defer s.mu.Unlock() + + if err := s.ensureRepositoryLocked(); err != nil { return nil, err } dir := s.baseDirSnapshot() @@ -396,24 +504,24 @@ func (s *GitTokenStore) Delete(_ context.Context, id string) error { if id == "" { return fmt.Errorf("auth filestore: id is empty") } + + s.mu.Lock() + defer s.mu.Unlock() + path, err := s.resolveDeletePath(id) if err != nil { return err } - if err = s.EnsureRepository(); err != nil { + if err = s.ensureRepositoryLocked(); err != nil { return err } - - s.mu.Lock() - defer s.mu.Unlock() - - if err = os.Remove(path); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("auth filestore: delete failed: %w", err) - } rel, errRel := s.relativeToRepo(path) if errRel != nil { return errRel } + if err = os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("auth filestore: delete failed: %w", err) + } messageID := id if errCommit := s.commitAndPushLocked(fmt.Sprintf("Delete auth %s", messageID), rel); errCommit != nil { return errCommit @@ -427,9 +535,9 @@ func (s *GitTokenStore) PersistAuthFiles(_ context.Context, message string, path if len(paths) == 0 { return nil } - if err := s.EnsureRepository(); err != nil { - return err - } + + s.mu.Lock() + defer s.mu.Unlock() filtered := make([]string, 0, len(paths)) for _, p := range paths { @@ -446,13 +554,22 @@ func (s *GitTokenStore) PersistAuthFiles(_ context.Context, message string, path if len(filtered) == 0 { return nil } - - s.mu.Lock() - defer s.mu.Unlock() - if strings.TrimSpace(message) == "" { message = "Sync watcher updates" } + + // Inspect watcher removals before EnsureRepository restores missing tracked + // files so an unexpected filesystem event remains distinguishable from Delete. + if _, errStat := os.Stat(filepath.Join(s.repoDirSnapshot(), ".git")); errStat == nil { + if handled, errGuard := s.guardWatcherAuthRemovalLocked(message, filtered); handled || errGuard != nil { + return errGuard + } + } else if !errors.Is(errStat, fs.ErrNotExist) { + return fmt.Errorf("git token store: stat repository before watcher removal guard: %w", errStat) + } + if err := s.ensureRepositoryLocked(); err != nil { + return err + } if handled, errGuard := s.guardWatcherAuthRemovalLocked(message, filtered); handled || errGuard != nil { return errGuard } @@ -676,17 +793,17 @@ func (s *GitTokenStore) relativeToRepo(path string) (string, error) { if repoDir == "" { return "", fmt.Errorf("git token store: repository path not configured") } - absRepo := repoDir - if abs, err := filepath.Abs(repoDir); err == nil { - absRepo = abs + absRepo, errRepo := filepath.Abs(repoDir) + if errRepo != nil { + return "", fmt.Errorf("git token store: resolve repository path: %w", errRepo) } - cleanPath := path - if abs, err := filepath.Abs(path); err == nil { - cleanPath = abs + absPath, errPath := filepath.Abs(path) + if errPath != nil { + return "", fmt.Errorf("git token store: resolve path: %w", errPath) } - rel, err := filepath.Rel(absRepo, cleanPath) - if err != nil { - return "", fmt.Errorf("git token store: relative path: %w", err) + rel, errRel := filepath.Rel(absRepo, absPath) + if errRel != nil { + return "", fmt.Errorf("git token store: relative path: %w", errRel) } if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { return "", fmt.Errorf("git token store: path outside repository") @@ -821,6 +938,517 @@ func normalizeRemoteBranchReference(name plumbing.ReferenceName) (plumbing.Refer } } +func resetIndexToHead(repo *git.Repository, worktree *git.Worktree) error { + if repo == nil || worktree == nil { + return fmt.Errorf("repository or worktree is nil") + } + head, errHead := repo.Head() + if errHead != nil { + if errors.Is(errHead, plumbing.ErrReferenceNotFound) { + return nil + } + return errHead + } + return worktree.Reset(&git.ResetOptions{Mode: git.MixedReset, Commit: head.Hash()}) +} + +func worktreeDirtyPaths(worktree *git.Worktree) (map[string]struct{}, error) { + if worktree == nil { + return nil, fmt.Errorf("worktree is nil") + } + status, errStatus := worktree.Status() + if errStatus != nil { + return nil, errStatus + } + dirtyPaths := make(map[string]struct{}, len(status)) + for path, fileStatus := range status { + if fileStatus.Staging == git.Unmodified && fileStatus.Worktree == git.Unmodified { + continue + } + dirtyPaths[filepath.ToSlash(filepath.Clean(path))] = struct{}{} + } + return dirtyPaths, nil +} + +func reconcileRemoteWorktree(repo *git.Repository, worktree *git.Worktree, repoDir string, baseRef *plumbing.Reference, dirtyPaths map[string]struct{}) error { + if repo == nil || worktree == nil || baseRef == nil { + return fmt.Errorf("repository, worktree, or base reference is nil") + } + if !baseRef.Name().IsBranch() { + return fmt.Errorf("head %s is not a branch", baseRef.Name()) + } + remoteName := plumbing.NewRemoteReferenceName("origin", baseRef.Name().Short()) + remoteRef, errRemote := repo.Reference(remoteName, true) + if errRemote != nil { + return fmt.Errorf("resolve remote branch %s: %w", remoteName, errRemote) + } + baseCommit, errBaseCommit := repo.CommitObject(baseRef.Hash()) + if errBaseCommit != nil { + return fmt.Errorf("inspect pre-pull commit: %w", errBaseCommit) + } + baseTree, errBaseTree := baseCommit.Tree() + if errBaseTree != nil { + return fmt.Errorf("inspect pre-pull tree: %w", errBaseTree) + } + remoteCommit, errRemoteCommit := repo.CommitObject(remoteRef.Hash()) + if errRemoteCommit != nil { + return fmt.Errorf("inspect remote commit: %w", errRemoteCommit) + } + remoteTree, errRemoteTree := remoteCommit.Tree() + if errRemoteTree != nil { + return fmt.Errorf("inspect remote tree: %w", errRemoteTree) + } + changedPaths, errChangedPaths := changedTreePaths(baseTree, remoteTree) + if errChangedPaths != nil { + return errChangedPaths + } + for _, changedPath := range changedPaths { + if dirtyPath, conflict := overlappingDirtyPath(changedPath, dirtyPaths); conflict { + if errRestore := restoreHeadAndIndex(repo, worktree, baseRef); errRestore != nil { + return errors.Join( + fmt.Errorf("remote path %s conflicts with local change %s", changedPath, dirtyPath), + fmt.Errorf("restore pre-pull head after conflict: %w", errRestore), + ) + } + return fmt.Errorf("remote path %s conflicts with local change %s", changedPath, dirtyPath) + } + } + + // Pull moves HEAD before reporting unstaged changes. Return to the pre-pull + // tree before applying only remote changes that do not overlap local edits. + if errRestore := restoreHeadAndIndex(repo, worktree, baseRef); errRestore != nil { + return fmt.Errorf("restore pre-pull head: %w", errRestore) + } + if errApply := applyTreePaths(remoteTree, repoDir, changedPaths); errApply != nil { + if errRollback := applyTreePaths(baseTree, repoDir, changedPaths); errRollback != nil { + return errors.Join( + fmt.Errorf("apply remote worktree changes: %w", errApply), + fmt.Errorf("restore pre-pull worktree: %w", errRollback), + ) + } + return fmt.Errorf("apply remote worktree changes: %w", errApply) + } + if errReference := repo.Storer.SetReference(plumbing.NewHashReference(baseRef.Name(), remoteRef.Hash())); errReference != nil { + if errRollback := applyTreePaths(baseTree, repoDir, changedPaths); errRollback != nil { + return errors.Join( + fmt.Errorf("update branch %s: %w", baseRef.Name(), errReference), + fmt.Errorf("restore pre-pull worktree: %w", errRollback), + ) + } + return fmt.Errorf("update branch %s: %w", baseRef.Name(), errReference) + } + if errReset := worktree.Reset(&git.ResetOptions{Mode: git.MixedReset, Commit: remoteRef.Hash()}); errReset != nil { + return fmt.Errorf("reset index to remote branch %s: %w", remoteName, errReset) + } + return nil +} + +func changedTreePaths(baseTree, remoteTree *object.Tree) ([]string, error) { + changes, errDiff := baseTree.Diff(remoteTree) + if errDiff != nil { + return nil, fmt.Errorf("compare pre-pull and remote trees: %w", errDiff) + } + paths := make(map[string]struct{}, len(changes)) + for _, change := range changes { + for _, path := range []string{change.From.Name, change.To.Name} { + if path == "" { + continue + } + paths[filepath.ToSlash(filepath.Clean(path))] = struct{}{} + } + } + changedPaths := make([]string, 0, len(paths)) + for path := range paths { + changedPaths = append(changedPaths, path) + } + sort.Strings(changedPaths) + return changedPaths, nil +} + +func overlappingDirtyPath(path string, dirtyPaths map[string]struct{}) (string, bool) { + for dirtyPath := range dirtyPaths { + if path == dirtyPath || strings.HasPrefix(path, dirtyPath+"/") || strings.HasPrefix(dirtyPath, path+"/") { + return dirtyPath, true + } + } + return "", false +} + +func applyTreePaths(tree *object.Tree, repoDir string, paths []string) error { + for _, path := range paths { + destination := filepath.Join(repoDir, filepath.FromSlash(path)) + file, errFile := tree.File(path) + if errors.Is(errFile, object.ErrFileNotFound) { + if errRemove := os.Remove(destination); errRemove != nil && !errors.Is(errRemove, fs.ErrNotExist) { + return fmt.Errorf("remove %s: %w", path, errRemove) + } + continue + } + if errFile != nil { + return fmt.Errorf("inspect %s: %w", path, errFile) + } + contents, errContents := file.Contents() + if errContents != nil { + return fmt.Errorf("read %s: %w", path, errContents) + } + if errMkdir := os.MkdirAll(filepath.Dir(destination), 0o700); errMkdir != nil { + return fmt.Errorf("create parent for %s: %w", path, errMkdir) + } + if errWrite := os.WriteFile(destination, []byte(contents), 0o600); errWrite != nil { + return fmt.Errorf("write %s: %w", path, errWrite) + } + } + return nil +} + +func (s *GitTokenStore) recoverRepositoryLocked(repoDir string, authMethod []client.Option, baselineTree *object.Tree, dirtyPaths map[string]struct{}) (errRecovery error) { + parentDir := filepath.Dir(repoDir) + recoveryRoot, errTemp := os.MkdirTemp(parentDir, ".gitstore-recovery-") + if errTemp != nil { + return fmt.Errorf("create recovery directory: %w", errTemp) + } + cleanupRecovery := true + defer func() { + if !cleanupRecovery { + return + } + if errRemove := os.RemoveAll(recoveryRoot); errRemove != nil { + errCleanup := fmt.Errorf("remove recovery directory: %w", errRemove) + if errRecovery == nil { + errRecovery = errCleanup + } else { + errRecovery = errors.Join(errRecovery, errCleanup) + } + } + }() + + if baselineTree == nil { + inspectedTree, inspectedDirtyPaths, errInspect := inspectRecoveryBaseline(repoDir) + if errInspect != nil { + return fmt.Errorf("inspect recovery baseline: %w", errInspect) + } + baselineTree = inspectedTree + dirtyPaths = inspectedDirtyPaths + } + cloneDir := filepath.Join(recoveryRoot, "clone") + cloneOpts := &git.CloneOptions{ClientOptions: authMethod, URL: s.remote} + if s.branch != "" { + cloneOpts.ReferenceName = plumbing.NewBranchReferenceName(s.branch) + } + clonedRepo, errClone := git.PlainClone(cloneDir, cloneOpts) + if errClone != nil { + return fmt.Errorf("clone remote repository: %w", errClone) + } + if errVerify := verifyRepositoryHead(clonedRepo); errVerify != nil { + return fmt.Errorf("verify cloned repository: %w", errVerify) + } + clonedHead, errHead := clonedRepo.Head() + if errHead != nil { + return fmt.Errorf("get cloned repository head: %w", errHead) + } + clonedCommit, errCommit := clonedRepo.CommitObject(clonedHead.Hash()) + if errCommit != nil { + return fmt.Errorf("inspect cloned repository head: %w", errCommit) + } + remoteTree, errTree := clonedCommit.Tree() + if errTree != nil { + return fmt.Errorf("inspect cloned repository tree: %w", errTree) + } + preservedPaths, errPreserve := recoveryPreservedPaths(baselineTree, remoteTree, dirtyPaths) + if errPreserve != nil { + return errPreserve + } + if errApply := applyRecoveryLocalChanges(repoDir, cloneDir, preservedPaths); errApply != nil { + return fmt.Errorf("preserve local worktree changes: %w", errApply) + } + + backupWorktreeDir := filepath.Join(recoveryRoot, "worktree") + if errBackup := moveWorktreeEntries(repoDir, backupWorktreeDir); errBackup != nil { + return fmt.Errorf("backup existing worktree: %w", errBackup) + } + gitDir := filepath.Join(repoDir, ".git") + clonedGitDir := filepath.Join(cloneDir, ".git") + backupGitDir := filepath.Join(recoveryRoot, "corrupt.git") + retainRecovery, errInstall := installRecoveredGitDirectory(gitDir, clonedGitDir, backupGitDir, os.Rename) + if retainRecovery { + cleanupRecovery = false + } + if errInstall != nil { + if errRestore := moveWorktreeEntries(backupWorktreeDir, repoDir); errRestore != nil { + cleanupRecovery = false + return errors.Join(errInstall, fmt.Errorf("restore worktree; backup retained at %s: %w", backupWorktreeDir, errRestore)) + } + return errInstall + } + if errMove := moveWorktreeEntries(cloneDir, repoDir); errMove != nil { + errMoveWorktree := fmt.Errorf("install recovered worktree: %w", errMove) + if errRollback := rollbackRecoveredRepository(repoDir, gitDir, backupGitDir, backupWorktreeDir); errRollback != nil { + cleanupRecovery = false + return errors.Join(errMoveWorktree, fmt.Errorf("rollback recovered repository; backup retained at %s: %w", recoveryRoot, errRollback)) + } + return errMoveWorktree + } + recoveredRepo, errOpen := git.PlainOpen(repoDir) + if errOpen == nil { + errOpen = verifyRepositoryHead(recoveredRepo) + } + if errOpen != nil { + errRecovered := fmt.Errorf("verify recovered repository: %w", errOpen) + if errRollback := rollbackRecoveredRepository(repoDir, gitDir, backupGitDir, backupWorktreeDir); errRollback != nil { + cleanupRecovery = false + return errors.Join(errRecovered, fmt.Errorf("rollback recovered repository; backup retained at %s: %w", recoveryRoot, errRollback)) + } + return errRecovered + } + return nil +} + +func inspectRecoveryBaseline(repoDir string) (*object.Tree, map[string]struct{}, error) { + repo, errOpen := git.PlainOpen(repoDir) + if errOpen != nil { + return nil, nil, fmt.Errorf("open repository: %w", errOpen) + } + worktree, errWorktree := repo.Worktree() + if errWorktree != nil { + return nil, nil, fmt.Errorf("open worktree: %w", errWorktree) + } + dirtyPaths, errDirty := worktreeDirtyPaths(worktree) + if errDirty != nil { + return nil, nil, fmt.Errorf("inspect worktree changes: %w", errDirty) + } + head, errHead := repo.Head() + if errHead != nil { + return nil, nil, fmt.Errorf("inspect head: %w", errHead) + } + commit, errCommit := repo.CommitObject(head.Hash()) + if errCommit != nil { + return nil, nil, fmt.Errorf("inspect head commit: %w", errCommit) + } + tree, errTree := commit.Tree() + if errTree != nil { + return nil, nil, fmt.Errorf("inspect head tree: %w", errTree) + } + return tree, dirtyPaths, nil +} + +func recoveryPreservedPaths(baselineTree, remoteTree *object.Tree, dirtyPaths map[string]struct{}) (map[string]struct{}, error) { + if baselineTree == nil || len(dirtyPaths) == 0 { + return nil, nil + } + changedPaths, errChanged := changedTreePaths(baselineTree, remoteTree) + if errChanged != nil { + return nil, fmt.Errorf("verify local changes against recovered remote: %w", errChanged) + } + for _, changedPath := range changedPaths { + if dirtyPath, conflict := overlappingDirtyPath(changedPath, dirtyPaths); conflict { + return nil, fmt.Errorf("remote path %s conflicts with local change %s during repository recovery", changedPath, dirtyPath) + } + } + return dirtyPaths, nil +} + +func applyRecoveryLocalChanges(sourceDir, targetDir string, paths map[string]struct{}) error { + sortedPaths := make([]string, 0, len(paths)) + for path := range paths { + sortedPaths = append(sortedPaths, path) + } + sort.Strings(sortedPaths) + for _, path := range sortedPaths { + source := filepath.Join(sourceDir, filepath.FromSlash(path)) + target := filepath.Join(targetDir, filepath.FromSlash(path)) + info, errStat := os.Lstat(source) + if errors.Is(errStat, fs.ErrNotExist) { + if errRemove := os.RemoveAll(target); errRemove != nil { + return fmt.Errorf("preserve deletion %s: %w", path, errRemove) + } + continue + } + if errStat != nil { + return fmt.Errorf("inspect local change %s: %w", path, errStat) + } + if errRemove := os.RemoveAll(target); errRemove != nil { + return fmt.Errorf("replace recovered path %s: %w", path, errRemove) + } + if errMkdir := os.MkdirAll(filepath.Dir(target), 0o700); errMkdir != nil { + return fmt.Errorf("create recovered parent for %s: %w", path, errMkdir) + } + switch { + case info.Mode().IsRegular(): + contents, errRead := os.ReadFile(source) + if errRead != nil { + return fmt.Errorf("read local change %s: %w", path, errRead) + } + if errWrite := os.WriteFile(target, contents, info.Mode().Perm()); errWrite != nil { + return fmt.Errorf("write local change %s: %w", path, errWrite) + } + case info.Mode()&os.ModeSymlink != 0: + linkTarget, errReadlink := os.Readlink(source) + if errReadlink != nil { + return fmt.Errorf("read local symlink %s: %w", path, errReadlink) + } + if errSymlink := os.Symlink(linkTarget, target); errSymlink != nil { + return fmt.Errorf("write local symlink %s: %w", path, errSymlink) + } + default: + return fmt.Errorf("local change %s has unsupported file mode %s", path, info.Mode()) + } + } + return nil +} + +func moveWorktreeEntries(sourceDir, targetDir string) error { + if errMkdir := os.MkdirAll(targetDir, 0o700); errMkdir != nil { + return errMkdir + } + entries, errRead := os.ReadDir(sourceDir) + if errRead != nil { + return errRead + } + moved := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.Name() == ".git" { + continue + } + source := filepath.Join(sourceDir, entry.Name()) + target := filepath.Join(targetDir, entry.Name()) + if errRename := os.Rename(source, target); errRename != nil { + errMove := fmt.Errorf("move %s: %w", entry.Name(), errRename) + for index := len(moved) - 1; index >= 0; index-- { + name := moved[index] + if errRestore := os.Rename(filepath.Join(targetDir, name), filepath.Join(sourceDir, name)); errRestore != nil { + errMove = errors.Join(errMove, fmt.Errorf("restore %s: %w", name, errRestore)) + } + } + return errMove + } + moved = append(moved, entry.Name()) + } + return nil +} + +func removeWorktreeEntries(repoDir string) error { + entries, errRead := os.ReadDir(repoDir) + if errRead != nil { + return errRead + } + for _, entry := range entries { + if entry.Name() == ".git" { + continue + } + if errRemove := os.RemoveAll(filepath.Join(repoDir, entry.Name())); errRemove != nil { + return errRemove + } + } + return nil +} + +func rollbackRecoveredRepository(repoDir, gitDir, backupGitDir, backupWorktreeDir string) error { + if errRemove := removeWorktreeEntries(repoDir); errRemove != nil { + return fmt.Errorf("remove recovered worktree: %w", errRemove) + } + if errRollback := rollbackRecoveredGitDirectory(gitDir, backupGitDir); errRollback != nil { + return errRollback + } + if errRestore := moveWorktreeEntries(backupWorktreeDir, repoDir); errRestore != nil { + return fmt.Errorf("restore original worktree: %w", errRestore) + } + return nil +} + +func installRecoveredGitDirectory(gitDir, clonedGitDir, backupGitDir string, rename func(string, string) error) (bool, error) { + if errRename := rename(gitDir, backupGitDir); errRename != nil { + return false, fmt.Errorf("backup corrupt git directory: %w", errRename) + } + if errRename := rename(clonedGitDir, gitDir); errRename != nil { + if errRestore := rename(backupGitDir, gitDir); errRestore != nil { + return true, errors.Join( + fmt.Errorf("install recovered git directory: %w", errRename), + fmt.Errorf("restore corrupt git directory; backup retained at %s: %w", backupGitDir, errRestore), + ) + } + return false, fmt.Errorf("install recovered git directory: %w", errRename) + } + return false, nil +} + +func rollbackRecoveredGitDirectory(gitDir, backupGitDir string) error { + if errRemove := os.RemoveAll(gitDir); errRemove != nil { + return fmt.Errorf("remove recovered git directory: %w", errRemove) + } + if errRename := os.Rename(backupGitDir, gitDir); errRename != nil { + return fmt.Errorf("restore original git directory: %w", errRename) + } + return nil +} + +func isRepositoryCorruptionError(err error) bool { + return errors.Is(err, dotgit.ErrPackfileNotFound) || errors.Is(err, plumbing.ErrObjectNotFound) +} + +func verifyRepositoryHead(repo *git.Repository) error { + if repo == nil { + return fmt.Errorf("repository is nil") + } + head, errHead := repo.Head() + if errHead != nil { + if errors.Is(errHead, plumbing.ErrReferenceNotFound) { + return nil + } + return errHead + } + commit, errCommit := repo.CommitObject(head.Hash()) + if errCommit != nil { + return errCommit + } + tree, errTree := commit.Tree() + if errTree != nil { + return errTree + } + files := tree.Files() + return files.ForEach(func(file *object.File) error { + _, errContents := file.Contents() + return errContents + }) +} + +func restoreMissingTrackedFiles(repo *git.Repository, repoDir string) error { + if repo == nil { + return fmt.Errorf("repository is nil") + } + head, errHead := repo.Head() + if errHead != nil { + if errors.Is(errHead, plumbing.ErrReferenceNotFound) { + return nil + } + return errHead + } + commit, errCommit := repo.CommitObject(head.Hash()) + if errCommit != nil { + return errCommit + } + tree, errTree := commit.Tree() + if errTree != nil { + return errTree + } + files := tree.Files() + return files.ForEach(func(file *object.File) error { + destination := filepath.Join(repoDir, filepath.FromSlash(file.Name)) + if _, errStat := os.Lstat(destination); errStat == nil { + return nil + } else if !errors.Is(errStat, fs.ErrNotExist) { + return errStat + } + contents, errContents := file.Contents() + if errContents != nil { + return errContents + } + if errMkdir := os.MkdirAll(filepath.Dir(destination), 0o700); errMkdir != nil { + return errMkdir + } + return os.WriteFile(destination, []byte(contents), 0o600) + }) +} + func shouldFallbackToCurrentBranch(repo *git.Repository, err error) bool { if !errors.Is(err, transport.ErrAuthenticationRequired) && !errors.Is(err, transport.ErrEmptyRemoteRepository) { return false @@ -881,6 +1509,14 @@ func checkoutRemoteDefaultBranch(repo *git.Repository, worktree *git.Worktree, a } func (s *GitTokenStore) commitAndPushLocked(message string, relPaths ...string) error { + return s.commitAndPushWithOptionsLocked(message, false, relPaths...) +} + +func (s *GitTokenStore) commitAndPushInitialLocked(message string, relPaths ...string) error { + return s.commitAndPushWithOptionsLocked(message, true, relPaths...) +} + +func (s *GitTokenStore) commitAndPushWithOptionsLocked(message string, allowMissingRemote bool, relPaths ...string) error { repoDir := s.repoDirSnapshot() if repoDir == "" { return fmt.Errorf("git token store: repository path not configured") @@ -893,11 +1529,26 @@ func (s *GitTokenStore) commitAndPushLocked(message string, relPaths ...string) if err != nil { return fmt.Errorf("git token store: worktree: %w", err) } - added := false - for _, rel := range relPaths { - if strings.TrimSpace(rel) == "" { - continue + managedPaths, errPaths := normalizeManagedPaths(relPaths) + if errPaths != nil { + return fmt.Errorf("git token store: validate commit paths: %w", errPaths) + } + if len(managedPaths) == 0 { + return nil + } + + baseRef, errHead := repo.Head() + if errHead != nil && !errors.Is(errHead, plumbing.ErrReferenceNotFound) { + return fmt.Errorf("git token store: get base head: %w", errHead) + } + if errHead == nil { + if errReset := resetIndexToHead(repo, worktree); errReset != nil { + return fmt.Errorf("git token store: reset index before commit: %w", errReset) } + } + + added := false + for _, rel := range managedPaths { if _, err = worktree.Add(rel); err != nil { if errors.Is(err, gitindex.ErrEntryNotFound) { continue @@ -942,18 +1593,111 @@ func (s *GitTokenStore) commitAndPushLocked(message string, relPaths ...string) } return fmt.Errorf("git token store: commit: %w", err) } - headRef, errHead := repo.Head() - if errHead != nil { - if !errors.Is(errHead, plumbing.ErrReferenceNotFound) { - return fmt.Errorf("git token store: get head: %w", errHead) + if baseRef != nil { + if errValidate := validateManagedTreeChanges(repo, baseRef.Hash(), commitHash, managedPaths); errValidate != nil { + errRestore := restoreHeadAndIndex(repo, worktree, baseRef) + if errRestore != nil { + return errors.Join( + fmt.Errorf("git token store: validate commit tree: %w", errValidate), + fmt.Errorf("git token store: restore head after rejected commit: %w", errRestore), + ) + } + return fmt.Errorf("git token store: validate commit tree: %w", errValidate) } - } else if errRewrite := s.rewriteHeadAsSingleCommit(repo, headRef.Name(), commitHash, message, signature); errRewrite != nil { + } + headRef, errCommittedHead := repo.Head() + if errCommittedHead != nil { + return fmt.Errorf("git token store: get committed head: %w", errCommittedHead) + } + if errRewrite := s.rewriteHeadAsSingleCommit(repo, headRef.Name(), commitHash, message, signature); errRewrite != nil { return errRewrite } - return s.pushRepositoryLocked(repo, repoDir) + if errPush := s.pushRepositoryLocked(repo, repoDir, allowMissingRemote); errPush != nil { + if baseRef == nil { + return errPush + } + if errRestore := restoreHeadAndIndex(repo, worktree, baseRef); errRestore != nil { + return errors.Join(errPush, fmt.Errorf("git token store: restore head after rejected push: %w", errRestore)) + } + return errPush + } + return nil +} + +func normalizeManagedPaths(paths []string) ([]string, error) { + normalized := make([]string, 0, len(paths)) + seen := make(map[string]struct{}, len(paths)) + for _, path := range paths { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + continue + } + clean := filepath.ToSlash(filepath.Clean(trimmed)) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || filepath.IsAbs(trimmed) { + return nil, fmt.Errorf("path %q is not a repository-relative file", path) + } + if _, ok := seen[clean]; ok { + continue + } + seen[clean] = struct{}{} + normalized = append(normalized, clean) + } + return normalized, nil } -func (s *GitTokenStore) pushRepositoryLocked(repo *git.Repository, repoDir string) error { +func validateManagedTreeChanges(repo *git.Repository, baseHash, commitHash plumbing.Hash, managedPaths []string) error { + baseCommit, errBase := repo.CommitObject(baseHash) + if errBase != nil { + return fmt.Errorf("inspect base commit: %w", errBase) + } + baseTree, errBaseTree := baseCommit.Tree() + if errBaseTree != nil { + return fmt.Errorf("inspect base tree: %w", errBaseTree) + } + commit, errCommit := repo.CommitObject(commitHash) + if errCommit != nil { + return fmt.Errorf("inspect candidate commit: %w", errCommit) + } + candidateTree, errCandidateTree := commit.Tree() + if errCandidateTree != nil { + return fmt.Errorf("inspect candidate tree: %w", errCandidateTree) + } + changes, errDiff := baseTree.Diff(candidateTree) + if errDiff != nil { + return fmt.Errorf("compare candidate tree: %w", errDiff) + } + for _, change := range changes { + for _, changedPath := range []string{change.From.Name, change.To.Name} { + if changedPath == "" || isManagedTreePath(changedPath, managedPaths) { + continue + } + return fmt.Errorf("unexpected indexed change outside requested paths: %s", changedPath) + } + } + return nil +} + +func isManagedTreePath(path string, managedPaths []string) bool { + cleanPath := filepath.ToSlash(filepath.Clean(path)) + for _, managedPath := range managedPaths { + if cleanPath == managedPath || strings.HasPrefix(cleanPath, managedPath+"/") { + return true + } + } + return false +} + +func restoreHeadAndIndex(repo *git.Repository, worktree *git.Worktree, head *plumbing.Reference) error { + if repo == nil || worktree == nil || head == nil { + return fmt.Errorf("repository, worktree, or head is nil") + } + if errReference := repo.Storer.SetReference(plumbing.NewHashReference(head.Name(), head.Hash())); errReference != nil { + return errReference + } + return worktree.Reset(&git.ResetOptions{Mode: git.MixedReset, Commit: head.Hash()}) +} + +func (s *GitTokenStore) pushRepositoryLocked(repo *git.Repository, repoDir string, allowMissingRemote bool) error { if repo == nil { return fmt.Errorf("git token store: repository is nil") } @@ -964,18 +1708,33 @@ func (s *GitTokenStore) pushRepositoryLocked(repo *git.Repository, repoDir strin } return fmt.Errorf("git token store: get head for push: %w", errHead) } - pushOpts := &git.PushOptions{ClientOptions: s.gitClientOptions(), Force: true} - if s.branch != "" { - pushOpts.RefSpecs = []config.RefSpec{config.RefSpec("refs/heads/" + s.branch + ":refs/heads/" + s.branch)} - } else { - // When branch is unset, pin push to the currently checked-out branch. - pushOpts.RefSpecs = []config.RefSpec{config.RefSpec(headRef.Name().String() + ":" + headRef.Name().String())} + if !headRef.Name().IsBranch() { + return fmt.Errorf("git token store: head %s is not a branch", headRef.Name()) + } + branchName := headRef.Name() + remoteName := plumbing.NewRemoteReferenceName("origin", branchName.Short()) + pushOpts := &git.PushOptions{ + ClientOptions: s.gitClientOptions(), + RefSpecs: []config.RefSpec{config.RefSpec(branchName.String() + ":" + branchName.String())}, + } + remoteRef, errRemote := repo.Reference(remoteName, true) + switch { + case errRemote == nil: + pushOpts.ForceWithLease = &git.ForceWithLease{RefName: branchName, Hash: remoteRef.Hash()} + case errors.Is(errRemote, plumbing.ErrReferenceNotFound) && allowMissingRemote: + // A normal branch-creation push fails if another initializer wins the race. + case errors.Is(errRemote, plumbing.ErrReferenceNotFound): + return fmt.Errorf("git token store: remote tracking branch %s not found", remoteName) + default: + return fmt.Errorf("git token store: inspect remote tracking branch %s: %w", remoteName, errRemote) } if errPush := repo.Push(pushOpts); errPush != nil { - if errors.Is(errPush, git.NoErrAlreadyUpToDate) { - return nil + if !errors.Is(errPush, git.NoErrAlreadyUpToDate) { + return fmt.Errorf("git token store: push: %w", errPush) } - return fmt.Errorf("git token store: push: %w", errPush) + } + if errReference := repo.Storer.SetReference(plumbing.NewHashReference(remoteName, headRef.Hash())); errReference != nil { + return fmt.Errorf("git token store: update remote tracking branch %s: %w", remoteName, errReference) } s.maybeRunGC(repoDir) return nil @@ -1024,7 +1783,7 @@ func (s *GitTokenStore) maybeRunGC(repoDir string) { } pruneOpts := git.PruneOptions{ - OnlyObjectsOlderThan: now, + OnlyObjectsOlderThan: now.Add(-gcPruneGracePeriod), Handler: repo.DeleteObject, } if err := repo.Prune(pruneOpts); err != nil && !errors.Is(err, git.ErrLooseObjectsNotSupported) { @@ -1035,7 +1794,10 @@ func (s *GitTokenStore) maybeRunGC(repoDir string) { // PersistConfig commits and pushes configuration changes to git. func (s *GitTokenStore) PersistConfig(_ context.Context) error { - if err := s.EnsureRepository(); err != nil { + s.mu.Lock() + defer s.mu.Unlock() + + if err := s.ensureRepositoryLocked(); err != nil { return err } configPath := s.ConfigPath() @@ -1048,8 +1810,6 @@ func (s *GitTokenStore) PersistConfig(_ context.Context) error { } return fmt.Errorf("git token store: stat config: %w", err) } - s.mu.Lock() - defer s.mu.Unlock() rel, err := s.relativeToRepo(configPath) if err != nil { return err diff --git a/internal/store/gitstore_test.go b/internal/store/gitstore_test.go index 0c10c53c..df82ff8f 100644 --- a/internal/store/gitstore_test.go +++ b/internal/store/gitstore_test.go @@ -2,6 +2,7 @@ package store import ( "context" + "encoding/json" "errors" "net/http" "net/http/httptest" @@ -23,6 +24,14 @@ type testBranchSpec struct { contents string } +type callbackTokenStorage struct { + save func(string) error +} + +func (s *callbackTokenStorage) SaveTokenToFile(path string) error { + return s.save(path) +} + func TestEnsureRepositoryUsesRemoteDefaultBranchWhenBranchNotConfigured(t *testing.T) { root := t.TempDir() remoteDir := setupGitRemoteRepository(t, root, "trunk", @@ -376,6 +385,911 @@ func TestGitTokenStoreRepeatedDeleteDoesNotOverwriteRemoteOnlyChanges(t *testing assertRemoteTreePath(t, remoteDir, "master", "auths/b.json", true) } +func TestGitTokenStoreRejectsPathsOutsideRepositoryBeforeMutation(t *testing.T) { + t.Parallel() + + root := t.TempDir() + remoteDir := setupGitRemoteRepository(t, root, "master", + testBranchSpec{name: "master", contents: "remote master branch\n"}, + ) + store := NewGitTokenStore(remoteDir, "", "", "") + baseDir := filepath.Join(root, "workspace", "auths") + store.SetBaseDir(baseDir) + if err := store.EnsureRepository(); err != nil { + t.Fatalf("EnsureRepository: %v", err) + } + + outsidePath := filepath.Join(root, "outside.json") + outsideContents := []byte("outside\n") + if err := os.WriteFile(outsidePath, outsideContents, 0o600); err != nil { + t.Fatalf("write outside file: %v", err) + } + if err := store.Delete(context.Background(), outsidePath); err == nil { + t.Fatal("Delete outside repository error = nil, want rejection") + } + if got, errRead := os.ReadFile(outsidePath); errRead != nil { + t.Fatalf("read outside file after delete rejection: %v", errRead) + } else if string(got) != string(outsideContents) { + t.Fatalf("outside file contents = %q, want %q", got, outsideContents) + } + + outsideSavePath := filepath.Join(root, "outside-save.json") + auth := &cliproxyauth.Auth{ + ID: "outside-save.json", + FileName: "outside-save.json", + Provider: "codex", + Attributes: map[string]string{ + cliproxyauth.AttributePath: outsideSavePath, + }, + Metadata: map[string]any{"type": "codex", "access_token": "token"}, + } + if _, err := store.Save(context.Background(), auth); err == nil { + t.Fatal("Save outside repository error = nil, want rejection") + } + if _, errStat := os.Stat(outsideSavePath); !errors.Is(errStat, os.ErrNotExist) { + t.Fatalf("outside save path stat error = %v, want not exist", errStat) + } +} + +func TestGitTokenStorePersistConfigDropsUnrelatedStagedDeletions(t *testing.T) { + t.Parallel() + + root := t.TempDir() + remoteDir := setupGitRemoteRepository(t, root, "master", + testBranchSpec{name: "master", contents: "remote master branch\n"}, + ) + store := NewGitTokenStore(remoteDir, "", "", "") + baseDir := filepath.Join(root, "workspace", "auths") + store.SetBaseDir(baseDir) + if err := store.EnsureRepository(); err != nil { + t.Fatalf("EnsureRepository: %v", err) + } + + auth := &cliproxyauth.Auth{ + ID: "protected.json", + FileName: "protected.json", + Provider: "codex", + Metadata: map[string]any{"type": "codex", "access_token": "token"}, + } + authPath, err := store.Save(context.Background(), auth) + if err != nil { + t.Fatalf("Save: %v", err) + } + configPath := store.ConfigPath() + if err := os.WriteFile(configPath, []byte("version: one\n"), 0o600); err != nil { + t.Fatalf("write initial config: %v", err) + } + if err := store.PersistConfig(context.Background()); err != nil { + t.Fatalf("PersistConfig initial: %v", err) + } + + repo, err := git.PlainOpen(filepath.Join(root, "workspace")) + if err != nil { + t.Fatalf("open workspace repo: %v", err) + } + worktree, err := repo.Worktree() + if err != nil { + t.Fatalf("open workspace worktree: %v", err) + } + if _, err := worktree.Remove("auths/protected.json"); err != nil { + t.Fatalf("stage unexpected auth removal: %v", err) + } + if _, err := os.Stat(authPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("removed auth stat error = %v, want not exist", err) + } + if err := os.WriteFile(configPath, []byte("version: two\n"), 0o600); err != nil { + t.Fatalf("write updated config: %v", err) + } + + if err := store.PersistConfig(context.Background()); err != nil { + t.Fatalf("PersistConfig with corrupt index: %v", err) + } + assertRemoteTreePath(t, remoteDir, "master", "auths/protected.json", true) + assertRemoteFileContents(t, remoteDir, "master", "config/config.yaml", "version: two\n") +} + +func TestGitTokenStorePersistConfigRepairsIndexAfterUnstagedPull(t *testing.T) { + t.Parallel() + + root := t.TempDir() + remoteDir := setupGitRemoteRepository(t, root, "master", + testBranchSpec{name: "master", contents: "remote master branch\n"}, + ) + store := NewGitTokenStore(remoteDir, "", "", "") + store.SetBaseDir(filepath.Join(root, "workspace", "auths")) + if err := store.EnsureRepository(); err != nil { + t.Fatalf("EnsureRepository: %v", err) + } + configPath := store.ConfigPath() + if err := os.WriteFile(configPath, []byte("source: local-config\n"), 0o600); err != nil { + t.Fatalf("write local config: %v", err) + } + advanceRemoteBranch(t, filepath.Join(root, "seed"), remoteDir, "master", "remote branch advanced\n", "advance remote") + + if err := store.PersistConfig(context.Background()); err != nil { + t.Fatalf("PersistConfig after unstaged pull: %v", err) + } + assertRemoteBranchContents(t, remoteDir, "master", "remote branch advanced\n") + assertRemoteFileContents(t, remoteDir, "master", "config/config.yaml", "source: local-config\n") +} + +func TestGitTokenStorePersistConfigPreservesRemoteOnlyAuthAfterDivergence(t *testing.T) { + t.Parallel() + + root := t.TempDir() + remoteDir := setupGitRemoteRepository(t, root, "master", + testBranchSpec{name: "master", contents: "remote master branch\n"}, + ) + storeA := NewGitTokenStore(remoteDir, "", "", "") + storeA.SetBaseDir(filepath.Join(root, "workspace-a", "auths")) + if err := storeA.EnsureRepository(); err != nil { + t.Fatalf("EnsureRepository A: %v", err) + } + + storeB := NewGitTokenStore(remoteDir, "", "", "") + storeB.SetBaseDir(filepath.Join(root, "workspace-b", "auths")) + if err := storeB.EnsureRepository(); err != nil { + t.Fatalf("EnsureRepository B: %v", err) + } + authB := &cliproxyauth.Auth{ + ID: "remote-only.json", + FileName: "remote-only.json", + Provider: "codex", + Metadata: map[string]any{"type": "codex", "access_token": "remote"}, + } + if _, err := storeB.Save(context.Background(), authB); err != nil { + t.Fatalf("Save B: %v", err) + } + assertRemoteTreePath(t, remoteDir, "master", "auths/remote-only.json", true) + + configPathA := storeA.ConfigPath() + if err := os.WriteFile(configPathA, []byte("source: store-a\n"), 0o600); err != nil { + t.Fatalf("write config A: %v", err) + } + if err := storeA.PersistConfig(context.Background()); err != nil { + t.Fatalf("PersistConfig A after divergence: %v", err) + } + + assertRemoteTreePath(t, remoteDir, "master", "auths/remote-only.json", true) + assertRemoteFileContents(t, remoteDir, "master", "config/config.yaml", "source: store-a\n") +} + +func TestGitTokenStoreRejectsStaleForcePush(t *testing.T) { + t.Parallel() + + root := t.TempDir() + remoteDir := setupGitRemoteRepository(t, root, "master", + testBranchSpec{name: "master", contents: "remote master branch\n"}, + ) + storeA := NewGitTokenStore(remoteDir, "", "", "") + storeA.SetBaseDir(filepath.Join(root, "workspace-a", "auths")) + if err := storeA.EnsureRepository(); err != nil { + t.Fatalf("EnsureRepository A: %v", err) + } + storeB := NewGitTokenStore(remoteDir, "", "", "") + storeB.SetBaseDir(filepath.Join(root, "workspace-b", "auths")) + if err := storeB.EnsureRepository(); err != nil { + t.Fatalf("EnsureRepository B: %v", err) + } + + authB := &cliproxyauth.Auth{ + ID: "concurrent.json", + FileName: "concurrent.json", + Provider: "codex", + Metadata: map[string]any{"type": "codex", "access_token": "remote"}, + } + if _, err := storeB.Save(context.Background(), authB); err != nil { + t.Fatalf("Save B: %v", err) + } + configPathA := storeA.ConfigPath() + if err := os.WriteFile(configPathA, []byte("source: stale-a\n"), 0o600); err != nil { + t.Fatalf("write stale config A: %v", err) + } + + storeA.mu.Lock() + errPush := storeA.commitAndPushLocked("Update stale config", "config/config.yaml") + storeA.mu.Unlock() + if errPush == nil { + t.Fatal("stale force push error = nil, want lease rejection") + } + assertRemoteTreePath(t, remoteDir, "master", "auths/concurrent.json", true) + assertRemoteTreePath(t, remoteDir, "master", "config/config.yaml", false) + + if err := storeA.PersistConfig(context.Background()); err != nil { + t.Fatalf("PersistConfig A after lease rejection: %v", err) + } + assertRemoteTreePath(t, remoteDir, "master", "auths/concurrent.json", true) + assertRemoteFileContents(t, remoteDir, "master", "config/config.yaml", "source: stale-a\n") +} + +func TestGitTokenStoreSaveRetryAfterLeaseConflictCommitsMatchingContent(t *testing.T) { + root := t.TempDir() + remoteDir := setupGitRemoteRepository(t, root, "master", + testBranchSpec{name: "master", contents: "remote master branch\n"}, + ) + storeA := NewGitTokenStore(remoteDir, "", "", "") + storeA.SetBaseDir(filepath.Join(root, "workspace-a", "auths")) + if errEnsure := storeA.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository A: %v", errEnsure) + } + storeB := NewGitTokenStore(remoteDir, "", "", "") + storeB.SetBaseDir(filepath.Join(root, "workspace-b", "auths")) + if errEnsure := storeB.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository B: %v", errEnsure) + } + + authA := &cliproxyauth.Auth{ + ID: "local.json", + FileName: "local.json", + Provider: "codex", + Metadata: map[string]any{"type": "codex", "access_token": "local"}, + } + remoteAdvanced := false + authA.Storage = &callbackTokenStorage{save: func(path string) error { + raw, errMarshal := json.Marshal(authA.Metadata) + if errMarshal != nil { + return errMarshal + } + if errWrite := os.WriteFile(path, raw, 0o600); errWrite != nil { + return errWrite + } + if remoteAdvanced { + return nil + } + remoteAdvanced = true + _, errSave := storeB.Save(context.Background(), &cliproxyauth.Auth{ + ID: "concurrent.json", + FileName: "concurrent.json", + Provider: "codex", + Metadata: map[string]any{"type": "codex", "access_token": "remote"}, + }) + return errSave + }} + if _, errSave := storeA.Save(context.Background(), authA); errSave == nil { + t.Fatal("first Save error = nil, want lease rejection") + } + assertRemoteTreePath(t, remoteDir, "master", "auths/local.json", false) + assertRemoteTreePath(t, remoteDir, "master", "auths/concurrent.json", true) + + authA.Storage = nil + if _, errSave := storeA.Save(context.Background(), authA); errSave != nil { + t.Fatalf("second Save after lease rejection: %v", errSave) + } + assertRemoteFileContents(t, remoteDir, "master", "auths/local.json", `{"access_token":"local","disabled":false,"type":"codex"}`) + assertRemoteTreePath(t, remoteDir, "master", "auths/concurrent.json", true) +} + +func TestGitTokenStoreConcurrentInitializationDoesNotOverwriteCreatedBranch(t *testing.T) { + root := t.TempDir() + remoteDir := filepath.Join(root, "remote.git") + remoteRepo, errInitRemote := git.PlainInit(remoteDir, true) + if errInitRemote != nil { + t.Fatalf("init bare remote: %v", errInitRemote) + } + if errHead := remoteRepo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("master"))); errHead != nil { + t.Fatalf("set remote HEAD: %v", errHead) + } + + workspaceDir := filepath.Join(root, "workspace") + localRepo, errInitLocal := git.PlainInit(workspaceDir, false) + if errInitLocal != nil { + t.Fatalf("init local repository: %v", errInitLocal) + } + if errSigning := disableGitCommitSigning(workspaceDir); errSigning != nil { + t.Fatalf("disable local commit signing: %v", errSigning) + } + if _, errRemote := localRepo.CreateRemote(&gitconfig.RemoteConfig{Name: "origin", URLs: []string{remoteDir}}); errRemote != nil { + t.Fatalf("create local origin: %v", errRemote) + } + for _, path := range []string{"auths/.gitkeep", "config/.gitkeep"} { + fullPath := filepath.Join(workspaceDir, filepath.FromSlash(path)) + if errMkdir := os.MkdirAll(filepath.Dir(fullPath), 0o700); errMkdir != nil { + t.Fatalf("create local placeholder parent: %v", errMkdir) + } + if errWrite := os.WriteFile(fullPath, nil, 0o600); errWrite != nil { + t.Fatalf("write local placeholder: %v", errWrite) + } + } + + winnerDir := filepath.Join(root, "winner") + winnerRepo, errInitWinner := git.PlainInit(winnerDir, false) + if errInitWinner != nil { + t.Fatalf("init winning repository: %v", errInitWinner) + } + if errSigning := disableGitCommitSigning(winnerDir); errSigning != nil { + t.Fatalf("disable winner commit signing: %v", errSigning) + } + if errHead := winnerRepo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("master"))); errHead != nil { + t.Fatalf("set winner HEAD: %v", errHead) + } + winnerFiles := map[string]string{ + "auths/remote.json": `{"type":"codex","access_token":"remote"}`, + "config/config.yaml": "source: winner\n", + } + winnerWorktree, errWinnerWorktree := winnerRepo.Worktree() + if errWinnerWorktree != nil { + t.Fatalf("open winning worktree: %v", errWinnerWorktree) + } + for path, contents := range winnerFiles { + fullPath := filepath.Join(winnerDir, filepath.FromSlash(path)) + if errMkdir := os.MkdirAll(filepath.Dir(fullPath), 0o700); errMkdir != nil { + t.Fatalf("create winning file parent: %v", errMkdir) + } + if errWrite := os.WriteFile(fullPath, []byte(contents), 0o600); errWrite != nil { + t.Fatalf("write winning file: %v", errWrite) + } + if _, errAdd := winnerWorktree.Add(path); errAdd != nil { + t.Fatalf("add winning file: %v", errAdd) + } + } + if _, errCommit := winnerWorktree.Commit("Initialize complete store", &git.CommitOptions{Author: &object.Signature{ + Name: "CLIProxyAPI", Email: "cliproxy@local", When: time.Unix(1711929600, 0), + }}); errCommit != nil { + t.Fatalf("commit winning repository: %v", errCommit) + } + if _, errRemote := winnerRepo.CreateRemote(&gitconfig.RemoteConfig{Name: "origin", URLs: []string{remoteDir}}); errRemote != nil { + t.Fatalf("create winner origin: %v", errRemote) + } + if errPush := winnerRepo.Push(&git.PushOptions{RemoteName: "origin", RefSpecs: []gitconfig.RefSpec{"refs/heads/master:refs/heads/master"}}); errPush != nil { + t.Fatalf("push winning initialization: %v", errPush) + } + + store := NewGitTokenStore(remoteDir, "", "", "master") + store.SetBaseDir(filepath.Join(workspaceDir, "auths")) + store.mu.Lock() + errInitialize := store.commitAndPushInitialLocked("Initialize git token store", "auths/.gitkeep", "config/.gitkeep") + store.mu.Unlock() + if errInitialize == nil { + t.Fatal("late initialization push error = nil, want branch-creation rejection") + } + assertRemoteFileContents(t, remoteDir, "master", "auths/remote.json", winnerFiles["auths/remote.json"]) + assertRemoteFileContents(t, remoteDir, "master", "config/config.yaml", winnerFiles["config/config.yaml"]) + + if errEnsure := store.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository after initialization race: %v", errEnsure) + } + assertLocalFileContents(t, filepath.Join(workspaceDir, "auths", "remote.json"), winnerFiles["auths/remote.json"]) + assertLocalFileContents(t, filepath.Join(workspaceDir, "config", "config.yaml"), winnerFiles["config/config.yaml"]) +} + +func TestEnsureRepositoryRetryRestoresTrackedAuthOnUpToDatePull(t *testing.T) { + root := t.TempDir() + remoteDir := setupGitRemoteRepository(t, root, "master", + testBranchSpec{name: "master", contents: "remote master branch\n"}, + ) + store := NewGitTokenStore(remoteDir, "", "", "") + baseDir := filepath.Join(root, "workspace", "auths") + store.SetBaseDir(baseDir) + if errEnsure := store.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository: %v", errEnsure) + } + authPath, errSave := store.Save(context.Background(), &cliproxyauth.Auth{ + ID: "retry.json", + FileName: "retry.json", + Provider: "codex", + Metadata: map[string]any{"type": "codex", "access_token": "remote"}, + }) + if errSave != nil { + t.Fatalf("Save: %v", errSave) + } + + repo, errOpen := git.PlainOpen(filepath.Join(root, "workspace")) + if errOpen != nil { + t.Fatalf("open workspace repository: %v", errOpen) + } + worktree, errWorktree := repo.Worktree() + if errWorktree != nil { + t.Fatalf("open workspace worktree: %v", errWorktree) + } + if _, errRemove := worktree.Remove("auths/retry.json"); errRemove != nil { + t.Fatalf("stage missing auth: %v", errRemove) + } + cfg, errConfig := repo.Config() + if errConfig != nil { + t.Fatalf("read workspace config: %v", errConfig) + } + cfg.Remotes["origin"].URLs = []string{filepath.Join(root, "missing.git")} + if errSetConfig := repo.SetConfig(cfg); errSetConfig != nil { + t.Fatalf("break workspace origin: %v", errSetConfig) + } + if errEnsure := store.EnsureRepository(); errEnsure == nil { + t.Fatal("EnsureRepository with unavailable remote error = nil, want retryable failure") + } + if _, errStat := os.Stat(authPath); !errors.Is(errStat, os.ErrNotExist) { + t.Fatalf("missing auth stat error = %v, want not exist", errStat) + } + + cfg.Remotes["origin"].URLs = []string{remoteDir} + if errSetConfig := repo.SetConfig(cfg); errSetConfig != nil { + t.Fatalf("restore workspace origin: %v", errSetConfig) + } + if errEnsure := store.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository retry: %v", errEnsure) + } + assertLocalFileContents(t, authPath, `{"access_token":"remote","disabled":false,"type":"codex"}`) + auths, errList := store.List(context.Background()) + if errList != nil { + t.Fatalf("List after retry: %v", errList) + } + if len(auths) != 1 || auths[0].ID != "retry.json" { + t.Fatalf("List after retry = %#v, want retry.json", auths) + } + + if errDelete := store.Delete(context.Background(), authPath); errDelete != nil { + t.Fatalf("explicit Delete after retry: %v", errDelete) + } + assertRemoteTreePath(t, remoteDir, "master", "auths/retry.json", false) + auths, errList = store.List(context.Background()) + if errList != nil { + t.Fatalf("List after explicit Delete: %v", errList) + } + if len(auths) != 0 { + t.Fatalf("List after explicit Delete = %#v, want empty", auths) + } +} + +func TestEnsureRepositoryReconcilesRemoteAuthChangesAroundLocalConfig(t *testing.T) { + root := t.TempDir() + remoteDir := setupGitRemoteRepository(t, root, "master", + testBranchSpec{name: "master", contents: "remote master branch\n"}, + ) + owner := NewGitTokenStore(remoteDir, "", "", "") + owner.SetBaseDir(filepath.Join(root, "owner", "auths")) + if errEnsure := owner.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository owner: %v", errEnsure) + } + for _, id := range []string{"modified.json", "deleted.json"} { + if _, errSave := owner.Save(context.Background(), &cliproxyauth.Auth{ + ID: id, FileName: id, Provider: "codex", + Metadata: map[string]any{"type": "codex", "access_token": "old"}, + }); errSave != nil { + t.Fatalf("Save owner %s: %v", id, errSave) + } + } + if errWrite := os.WriteFile(owner.ConfigPath(), []byte("source: original\n"), 0o600); errWrite != nil { + t.Fatalf("write owner config: %v", errWrite) + } + if errPersist := owner.PersistConfig(context.Background()); errPersist != nil { + t.Fatalf("PersistConfig owner: %v", errPersist) + } + + storeA := NewGitTokenStore(remoteDir, "", "", "") + storeA.SetBaseDir(filepath.Join(root, "workspace-a", "auths")) + if errEnsure := storeA.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository A: %v", errEnsure) + } + storeB := NewGitTokenStore(remoteDir, "", "", "") + storeB.SetBaseDir(filepath.Join(root, "workspace-b", "auths")) + if errEnsure := storeB.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository B: %v", errEnsure) + } + if errWrite := os.WriteFile(storeA.ConfigPath(), []byte("source: local-a\n"), 0o600); errWrite != nil { + t.Fatalf("write local config A: %v", errWrite) + } + if _, errSave := storeB.Save(context.Background(), &cliproxyauth.Auth{ + ID: "modified.json", FileName: "modified.json", Provider: "codex", + Metadata: map[string]any{"type": "codex", "access_token": "new"}, + }); errSave != nil { + t.Fatalf("Save remote auth update: %v", errSave) + } + if errDelete := storeB.Delete(context.Background(), filepath.Join(storeB.AuthDir(), "deleted.json")); errDelete != nil { + t.Fatalf("Delete remote auth: %v", errDelete) + } + + if errEnsure := storeA.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository A after remote auth changes: %v", errEnsure) + } + assertLocalFileContents(t, storeA.ConfigPath(), "source: local-a\n") + assertLocalJSONValue(t, filepath.Join(storeA.AuthDir(), "modified.json"), "access_token", "new") + if _, errStat := os.Stat(filepath.Join(storeA.AuthDir(), "deleted.json")); !errors.Is(errStat, os.ErrNotExist) { + t.Fatalf("deleted local auth stat error = %v, want not exist", errStat) + } +} + +func TestEnsureRepositoryReconcilesRemoteConfigChangesAroundLocalAuth(t *testing.T) { + root := t.TempDir() + remoteDir := setupGitRemoteRepository(t, root, "master", + testBranchSpec{name: "master", contents: "remote master branch\n"}, + ) + owner := NewGitTokenStore(remoteDir, "", "", "") + owner.SetBaseDir(filepath.Join(root, "owner", "auths")) + if errEnsure := owner.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository owner: %v", errEnsure) + } + if _, errSave := owner.Save(context.Background(), &cliproxyauth.Auth{ + ID: "local.json", FileName: "local.json", Provider: "codex", + Metadata: map[string]any{"type": "codex", "access_token": "old"}, + }); errSave != nil { + t.Fatalf("Save owner auth: %v", errSave) + } + if errWrite := os.WriteFile(owner.ConfigPath(), []byte("source: original\n"), 0o600); errWrite != nil { + t.Fatalf("write owner config: %v", errWrite) + } + if errPersist := owner.PersistConfig(context.Background()); errPersist != nil { + t.Fatalf("PersistConfig owner: %v", errPersist) + } + + storeA := NewGitTokenStore(remoteDir, "", "", "") + storeA.SetBaseDir(filepath.Join(root, "workspace-a", "auths")) + if errEnsure := storeA.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository A: %v", errEnsure) + } + storeB := NewGitTokenStore(remoteDir, "", "", "") + storeB.SetBaseDir(filepath.Join(root, "workspace-b", "auths")) + if errEnsure := storeB.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository B: %v", errEnsure) + } + localAuthPath := filepath.Join(storeA.AuthDir(), "local.json") + localAuthContents := `{"type":"codex","access_token":"local-dirty"}` + if errWrite := os.WriteFile(localAuthPath, []byte(localAuthContents), 0o600); errWrite != nil { + t.Fatalf("write local dirty auth: %v", errWrite) + } + if errWrite := os.WriteFile(storeB.ConfigPath(), []byte("source: remote-modified\n"), 0o600); errWrite != nil { + t.Fatalf("write remote config update: %v", errWrite) + } + if errPersist := storeB.PersistConfig(context.Background()); errPersist != nil { + t.Fatalf("PersistConfig B: %v", errPersist) + } + + if errEnsure := storeA.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository A after remote config update: %v", errEnsure) + } + assertLocalFileContents(t, storeA.ConfigPath(), "source: remote-modified\n") + assertLocalFileContents(t, localAuthPath, localAuthContents) + + if errRemove := os.Remove(storeB.ConfigPath()); errRemove != nil { + t.Fatalf("remove config B: %v", errRemove) + } + storeB.mu.Lock() + errDeleteConfig := storeB.commitAndPushLocked("Delete config", "config/config.yaml") + storeB.mu.Unlock() + if errDeleteConfig != nil { + t.Fatalf("commit remote config deletion: %v", errDeleteConfig) + } + if errEnsure := storeA.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository A after remote config deletion: %v", errEnsure) + } + if _, errStat := os.Stat(storeA.ConfigPath()); !errors.Is(errStat, os.ErrNotExist) { + t.Fatalf("deleted local config stat error = %v, want not exist", errStat) + } + assertLocalFileContents(t, localAuthPath, localAuthContents) +} + +func TestEnsureRepositoryFailsClosedOnSamePathConflict(t *testing.T) { + root := t.TempDir() + remoteDir := setupGitRemoteRepository(t, root, "master", + testBranchSpec{name: "master", contents: "remote master branch\n"}, + ) + owner := NewGitTokenStore(remoteDir, "", "", "") + owner.SetBaseDir(filepath.Join(root, "owner", "auths")) + if errEnsure := owner.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository owner: %v", errEnsure) + } + if errWrite := os.WriteFile(owner.ConfigPath(), []byte("source: original\n"), 0o600); errWrite != nil { + t.Fatalf("write owner config: %v", errWrite) + } + if errPersist := owner.PersistConfig(context.Background()); errPersist != nil { + t.Fatalf("PersistConfig owner: %v", errPersist) + } + + storeA := NewGitTokenStore(remoteDir, "", "", "") + storeA.SetBaseDir(filepath.Join(root, "workspace-a", "auths")) + if errEnsure := storeA.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository A: %v", errEnsure) + } + storeB := NewGitTokenStore(remoteDir, "", "", "") + storeB.SetBaseDir(filepath.Join(root, "workspace-b", "auths")) + if errEnsure := storeB.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository B: %v", errEnsure) + } + if errWrite := os.WriteFile(storeA.ConfigPath(), []byte("source: local\n"), 0o600); errWrite != nil { + t.Fatalf("write local config: %v", errWrite) + } + if errWrite := os.WriteFile(storeB.ConfigPath(), []byte("source: remote\n"), 0o600); errWrite != nil { + t.Fatalf("write remote config: %v", errWrite) + } + if errPersist := storeB.PersistConfig(context.Background()); errPersist != nil { + t.Fatalf("PersistConfig B: %v", errPersist) + } + + errEnsure := storeA.EnsureRepository() + if errEnsure == nil || !strings.Contains(errEnsure.Error(), "conflicts with local change") { + t.Fatalf("EnsureRepository conflict error = %v, want fail-closed conflict", errEnsure) + } + assertLocalFileContents(t, storeA.ConfigPath(), "source: local\n") + assertRemoteFileContents(t, remoteDir, "master", "config/config.yaml", "source: remote\n") +} + +func TestInstallRecoveredGitDirectoryRetainsBackupWhenRestoreFails(t *testing.T) { + backupPath := filepath.Join("recovery", "corrupt.git") + installErr := errors.New("install failed") + restoreErr := errors.New("restore failed") + calls := 0 + rename := func(_, _ string) error { + calls++ + switch calls { + case 1: + return nil + case 2: + return installErr + default: + return restoreErr + } + } + + retain, errInstall := installRecoveredGitDirectory("repo/.git", "clone/.git", backupPath, rename) + if !retain { + t.Fatal("retain recovery = false, want true after failed rollback") + } + if !errors.Is(errInstall, installErr) || !errors.Is(errInstall, restoreErr) { + t.Fatalf("install error = %v, want install and restore failures", errInstall) + } + if !strings.Contains(errInstall.Error(), backupPath) { + t.Fatalf("install error = %q, want retained backup path %q", errInstall, backupPath) + } +} + +func TestGitTokenStoreCorruptionRecoveryUsesLatestRemoteAuthTree(t *testing.T) { + tests := []struct { + name string + updateRemote func(*testing.T, *GitTokenStore) + wantExists bool + wantAuthToken string + }{ + { + name: "modification", + updateRemote: func(t *testing.T, store *GitTokenStore) { + t.Helper() + if _, errSave := store.Save(context.Background(), &cliproxyauth.Auth{ + ID: "victim.json", FileName: "victim.json", Provider: "codex", + Metadata: map[string]any{"type": "codex", "access_token": "remote-new"}, + }); errSave != nil { + t.Fatalf("update remote auth: %v", errSave) + } + }, + wantExists: true, + wantAuthToken: "remote-new", + }, + { + name: "deletion", + updateRemote: func(t *testing.T, store *GitTokenStore) { + t.Helper() + if errDelete := store.Delete(context.Background(), filepath.Join(store.AuthDir(), "victim.json")); errDelete != nil { + t.Fatalf("delete remote auth: %v", errDelete) + } + }, + wantExists: false, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + remoteDir := setupGitRemoteRepository(t, root, "master", + testBranchSpec{name: "master", contents: "remote master branch\n"}, + ) + owner := NewGitTokenStore(remoteDir, "", "", "") + owner.SetBaseDir(filepath.Join(root, "owner", "auths")) + if errEnsure := owner.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository owner: %v", errEnsure) + } + if _, errSave := owner.Save(context.Background(), &cliproxyauth.Auth{ + ID: "victim.json", FileName: "victim.json", Provider: "codex", + Metadata: map[string]any{"type": "codex", "access_token": "remote-old"}, + }); errSave != nil { + t.Fatalf("save initial auth: %v", errSave) + } + + store := NewGitTokenStore(remoteDir, "", "", "") + store.SetBaseDir(filepath.Join(root, "workspace", "auths")) + if errEnsure := store.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository workspace: %v", errEnsure) + } + test.updateRemote(t, owner) + removeHeadFileObject(t, filepath.Join(root, "workspace"), "corrupt-object.txt") + + if errEnsure := store.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository recovery: %v", errEnsure) + } + victimPath := filepath.Join(store.AuthDir(), "victim.json") + if test.wantExists { + assertLocalJSONValue(t, victimPath, "access_token", test.wantAuthToken) + } else if _, errStat := os.Stat(victimPath); !errors.Is(errStat, os.ErrNotExist) { + t.Fatalf("deleted local auth stat error = %v, want not exist", errStat) + } + + if _, errSave := store.Save(context.Background(), &cliproxyauth.Auth{ + ID: "unrelated.json", FileName: "unrelated.json", Provider: "codex", + Metadata: map[string]any{"type": "codex", "access_token": "local"}, + }); errSave != nil { + t.Fatalf("Save after recovery: %v", errSave) + } + assertRemoteTreePath(t, remoteDir, "master", "auths/victim.json", test.wantExists) + if test.wantExists { + assertRemoteFileContents(t, remoteDir, "master", "auths/victim.json", `{"access_token":"remote-new","disabled":false,"type":"codex"}`) + } + }) + } +} + +func TestGitTokenStoreCorruptionRecoveryPreservesOnlyNonConflictingLocalChanges(t *testing.T) { + setup := func(t *testing.T) (string, *GitTokenStore, *GitTokenStore) { + t.Helper() + root := t.TempDir() + remoteDir := setupGitRemoteRepository(t, root, "master", + testBranchSpec{name: "master", contents: "remote master branch\n"}, + ) + owner := NewGitTokenStore(remoteDir, "", "", "") + owner.SetBaseDir(filepath.Join(root, "owner", "auths")) + if errEnsure := owner.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository owner: %v", errEnsure) + } + if _, errSave := owner.Save(context.Background(), &cliproxyauth.Auth{ + ID: "victim.json", FileName: "victim.json", Provider: "codex", + Metadata: map[string]any{"type": "codex", "access_token": "remote-old"}, + }); errSave != nil { + t.Fatalf("save initial auth: %v", errSave) + } + store := NewGitTokenStore(remoteDir, "", "", "") + store.SetBaseDir(filepath.Join(root, "workspace", "auths")) + if errEnsure := store.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository workspace: %v", errEnsure) + } + return filepath.Join(root, "workspace"), owner, store + } + + t.Run("non-conflicting change", func(t *testing.T) { + workspaceDir, owner, store := setup(t) + if errWrite := os.WriteFile(store.ConfigPath(), []byte("source: local\n"), 0o600); errWrite != nil { + t.Fatalf("write local config: %v", errWrite) + } + if _, errSave := owner.Save(context.Background(), &cliproxyauth.Auth{ + ID: "victim.json", FileName: "victim.json", Provider: "codex", + Metadata: map[string]any{"type": "codex", "access_token": "remote-new"}, + }); errSave != nil { + t.Fatalf("update remote auth: %v", errSave) + } + removeHeadFileObject(t, workspaceDir, "corrupt-object.txt") + + if errEnsure := store.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository recovery: %v", errEnsure) + } + assertLocalFileContents(t, store.ConfigPath(), "source: local\n") + assertLocalJSONValue(t, filepath.Join(store.AuthDir(), "victim.json"), "access_token", "remote-new") + }) + + t.Run("same-path conflict", func(t *testing.T) { + workspaceDir, owner, store := setup(t) + victimPath := filepath.Join(store.AuthDir(), "victim.json") + localContents := `{"type":"codex","access_token":"local"}` + if errWrite := os.WriteFile(victimPath, []byte(localContents), 0o600); errWrite != nil { + t.Fatalf("write local auth: %v", errWrite) + } + if _, errSave := owner.Save(context.Background(), &cliproxyauth.Auth{ + ID: "victim.json", FileName: "victim.json", Provider: "codex", + Metadata: map[string]any{"type": "codex", "access_token": "remote-new"}, + }); errSave != nil { + t.Fatalf("update remote auth: %v", errSave) + } + removeHeadFileObject(t, workspaceDir, "corrupt-object.txt") + + errEnsure := store.EnsureRepository() + if errEnsure == nil || !strings.Contains(errEnsure.Error(), "conflicts with local change") { + t.Fatalf("EnsureRepository conflict error = %v, want fail-closed conflict", errEnsure) + } + assertLocalFileContents(t, victimPath, localContents) + assertRemoteFileContents(t, owner.remote, "master", "auths/victim.json", `{"access_token":"remote-new","disabled":false,"type":"codex"}`) + }) +} + +func TestGitTokenStoreFullPackfileCorruptionFailsClosedWithDirtyManagedFile(t *testing.T) { + setup := func(t *testing.T) (string, string, *GitTokenStore) { + t.Helper() + root := t.TempDir() + remoteDir := setupGitRemoteRepository(t, root, "master", + testBranchSpec{name: "master", contents: "remote master branch\n"}, + ) + workspaceDir := filepath.Join(root, "workspace") + store := NewGitTokenStore(remoteDir, "", "", "") + store.SetBaseDir(filepath.Join(workspaceDir, "auths")) + if errEnsure := store.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository: %v", errEnsure) + } + return remoteDir, workspaceDir, store + } + + t.Run("config", func(t *testing.T) { + remoteDir, workspaceDir, store := setup(t) + configPath := store.ConfigPath() + if errWrite := os.WriteFile(configPath, []byte("source: remote\n"), 0o600); errWrite != nil { + t.Fatalf("write initial config: %v", errWrite) + } + if errPersist := store.PersistConfig(context.Background()); errPersist != nil { + t.Fatalf("PersistConfig initial config: %v", errPersist) + } + + localContents := "source: local-dirty\n" + if errWrite := os.WriteFile(configPath, []byte(localContents), 0o600); errWrite != nil { + t.Fatalf("write dirty config: %v", errWrite) + } + corruptGitRepository(t, workspaceDir) + + errPersist := store.PersistConfig(context.Background()) + if errPersist == nil || !strings.Contains(errPersist.Error(), "inspect recovery baseline") { + t.Fatalf("PersistConfig error = %v, want fail-closed recovery baseline error", errPersist) + } + assertLocalFileContents(t, configPath, localContents) + assertRemoteFileContents(t, remoteDir, "master", "config/config.yaml", "source: remote\n") + }) + + t.Run("auth", func(t *testing.T) { + remoteDir, workspaceDir, store := setup(t) + authPath, errSave := store.Save(context.Background(), &cliproxyauth.Auth{ + ID: "dirty.json", FileName: "dirty.json", Provider: "codex", + Metadata: map[string]any{"type": "codex", "access_token": "remote"}, + }) + if errSave != nil { + t.Fatalf("Save initial auth: %v", errSave) + } + + localContents := `{"type":"codex","access_token":"local-dirty"}` + if errWrite := os.WriteFile(authPath, []byte(localContents), 0o600); errWrite != nil { + t.Fatalf("write dirty auth: %v", errWrite) + } + corruptGitRepository(t, workspaceDir) + + _, errSave = store.Save(context.Background(), &cliproxyauth.Auth{ + ID: "unrelated.json", FileName: "unrelated.json", Provider: "codex", + Metadata: map[string]any{"type": "codex", "access_token": "unrelated"}, + }) + if errSave == nil || !strings.Contains(errSave.Error(), "inspect recovery baseline") { + t.Fatalf("Save error = %v, want fail-closed recovery baseline error", errSave) + } + assertLocalFileContents(t, authPath, localContents) + assertRemoteFileContents(t, remoteDir, "master", "auths/dirty.json", `{"access_token":"remote","disabled":false,"type":"codex"}`) + assertRemoteTreePath(t, remoteDir, "master", "auths/unrelated.json", false) + }) +} + +func TestGitTokenStoreMissingPackfileRecoveryFailsClosedWithoutBaseline(t *testing.T) { + root := t.TempDir() + remoteDir := setupGitRemoteRepository(t, root, "master", + testBranchSpec{name: "master", contents: "remote master branch\n"}, + ) + store := NewGitTokenStore(remoteDir, "", "", "") + baseDir := filepath.Join(root, "workspace", "auths") + store.SetBaseDir(baseDir) + if errEnsure := store.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository: %v", errEnsure) + } + auth := &cliproxyauth.Auth{ + ID: "recover.json", + FileName: "recover.json", + Provider: "codex", + Metadata: map[string]any{"type": "codex", "access_token": "remote"}, + } + authPath, errSave := store.Save(context.Background(), auth) + if errSave != nil { + t.Fatalf("Save: %v", errSave) + } + + repo := corruptGitRepository(t, filepath.Join(root, "workspace")) + if errRemove := os.Remove(authPath); errRemove != nil { + t.Fatalf("remove local auth before recovery: %v", errRemove) + } + if errVerify := verifyRepositoryHead(repo); !isRepositoryCorruptionError(errVerify) { + t.Fatalf("verifyRepositoryHead error = %v, want repository corruption", errVerify) + } + + errEnsure := store.EnsureRepository() + if errEnsure == nil || !strings.Contains(errEnsure.Error(), "inspect recovery baseline") { + t.Fatalf("EnsureRepository error = %v, want fail-closed recovery baseline error", errEnsure) + } + if _, errStat := os.Stat(authPath); !errors.Is(errStat, os.ErrNotExist) { + t.Fatalf("local deleted auth stat error = %v, want not exist", errStat) + } + assertRemoteTreePath(t, remoteDir, "master", "auths/recover.json", true) +} + func TestCommitAndPushLockedPushesBeforeRunningGC(t *testing.T) { root := t.TempDir() remoteDir := setupGitRemoteRepository(t, root, "master", @@ -482,6 +1396,91 @@ func TestEnsureRepositoryKeepsCurrentBranchWhenRemoteDefaultCannotBeResolved(t * assertRepositoryHeadBranch(t, filepath.Join(root, "workspace"), "develop") } +func removeHeadFileObject(t *testing.T, repoDir, path string) { + t.Helper() + + repo, errOpen := git.PlainOpen(repoDir) + if errOpen != nil { + t.Fatalf("open repository before object removal: %v", errOpen) + } + worktree, errWorktree := repo.Worktree() + if errWorktree != nil { + t.Fatalf("open worktree before object removal: %v", errWorktree) + } + fullPath := filepath.Join(repoDir, filepath.FromSlash(path)) + if errWrite := os.WriteFile(fullPath, []byte("corrupt me\n"), 0o600); errWrite != nil { + t.Fatalf("write corruption marker: %v", errWrite) + } + if _, errAdd := worktree.Add(path); errAdd != nil { + t.Fatalf("add corruption marker: %v", errAdd) + } + if _, errCommit := worktree.Commit("Add corruption marker", &git.CommitOptions{Author: &object.Signature{ + Name: "CLIProxyAPI", Email: "cliproxy@local", When: time.Unix(1711929600, 0), + }}); errCommit != nil { + t.Fatalf("commit corruption marker: %v", errCommit) + } + head, errHead := repo.Head() + if errHead != nil { + t.Fatalf("read repository head: %v", errHead) + } + commit, errCommit := repo.CommitObject(head.Hash()) + if errCommit != nil { + t.Fatalf("read repository commit: %v", errCommit) + } + tree, errTree := commit.Tree() + if errTree != nil { + t.Fatalf("read repository tree: %v", errTree) + } + file, errFile := tree.File(path) + if errFile != nil { + t.Fatalf("read repository file %s: %v", path, errFile) + } + objectPath := filepath.Join(repoDir, ".git", "objects", file.Hash.String()[:2], file.Hash.String()[2:]) + if errRemove := os.Remove(objectPath); errRemove != nil { + t.Fatalf("remove repository object for %s: %v", path, errRemove) + } + if errVerify := verifyRepositoryHead(repo); !isRepositoryCorruptionError(errVerify) { + t.Fatalf("verifyRepositoryHead error = %v, want repository corruption", errVerify) + } +} + +func corruptGitRepository(t *testing.T, repoDir string) *git.Repository { + t.Helper() + + repo, errOpen := git.PlainOpen(repoDir) + if errOpen != nil { + t.Fatalf("open repository before corruption: %v", errOpen) + } + if errRepack := repo.RepackObjects(&git.RepackConfig{}); errRepack != nil { + t.Fatalf("repack repository objects: %v", errRepack) + } + objectsDir := filepath.Join(repoDir, ".git", "objects") + objectEntries, errReadDir := os.ReadDir(objectsDir) + if errReadDir != nil { + t.Fatalf("read object directory: %v", errReadDir) + } + for _, entry := range objectEntries { + if entry.IsDir() && len(entry.Name()) == 2 { + if errRemove := os.RemoveAll(filepath.Join(objectsDir, entry.Name())); errRemove != nil { + t.Fatalf("remove loose object directory %s: %v", entry.Name(), errRemove) + } + } + } + packfiles, errGlob := filepath.Glob(filepath.Join(objectsDir, "pack", "*.pack")) + if errGlob != nil { + t.Fatalf("glob packfiles: %v", errGlob) + } + if len(packfiles) == 0 { + t.Fatal("no packfiles found to corrupt") + } + for _, packfile := range packfiles { + if errRemove := os.Remove(packfile); errRemove != nil { + t.Fatalf("remove packfile %s: %v", filepath.Base(packfile), errRemove) + } + } + return repo +} + func setupGitRemoteRepository(t *testing.T, root, defaultBranch string, branches ...testBranchSpec) string { t.Helper() @@ -634,6 +1633,34 @@ func findBranchSpec(branches []testBranchSpec, name string) (testBranchSpec, boo return testBranchSpec{}, false } +func assertLocalFileContents(t *testing.T, path, wantContents string) { + t.Helper() + + contents, errRead := os.ReadFile(path) + if errRead != nil { + t.Fatalf("read local file %s: %v", path, errRead) + } + if string(contents) != wantContents { + t.Fatalf("local file %s contents = %q, want %q", path, contents, wantContents) + } +} + +func assertLocalJSONValue(t *testing.T, path, key, wantValue string) { + t.Helper() + + contents, errRead := os.ReadFile(path) + if errRead != nil { + t.Fatalf("read local JSON file %s: %v", path, errRead) + } + metadata := make(map[string]any) + if errUnmarshal := json.Unmarshal(contents, &metadata); errUnmarshal != nil { + t.Fatalf("unmarshal local JSON file %s: %v", path, errUnmarshal) + } + if gotValue, _ := metadata[key].(string); gotValue != wantValue { + t.Fatalf("local JSON file %s value %s = %q, want %q", path, key, gotValue, wantValue) + } +} + func assertRemoteTreePath(t *testing.T, remoteDir, branch, path string, want bool) { t.Helper() @@ -663,6 +1690,38 @@ func assertRemoteTreePath(t *testing.T, remoteDir, branch, path string, want boo } } +func assertRemoteFileContents(t *testing.T, remoteDir, branch, path, wantContents string) { + t.Helper() + + repo, err := git.PlainOpen(remoteDir) + if err != nil { + t.Fatalf("open remote repo: %v", err) + } + ref, err := repo.Reference(plumbing.NewBranchReferenceName(branch), true) + if err != nil { + t.Fatalf("read remote branch %s: %v", branch, err) + } + commit, err := repo.CommitObject(ref.Hash()) + if err != nil { + t.Fatalf("read remote commit: %v", err) + } + tree, err := commit.Tree() + if err != nil { + t.Fatalf("read remote tree: %v", err) + } + file, err := tree.File(filepath.ToSlash(path)) + if err != nil { + t.Fatalf("read remote file %s: %v", path, err) + } + contents, err := file.Contents() + if err != nil { + t.Fatalf("read remote file %s contents: %v", path, err) + } + if contents != wantContents { + t.Fatalf("remote file %s contents = %q, want %q", path, contents, wantContents) + } +} + func assertRepositoryBranchAndContents(t *testing.T, repoDir, branch, wantContents string) { t.Helper() -- 2.51.2 From 1c1d8efdd5a41fd2b8eda022250f9169da1b30ab Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 30 Jul 2026 05:57:06 +0800 Subject: [PATCH 12/31] fix(translator): correct handling of `json_schema` and `json_object` response formats - Adjusted `responseSchema` logic to only set schema for `json_schema` type while ensuring it is excluded for `json_object`. - Updated tests to reflect changes in `responseSchema` handling, ensuring correct validation for both response formats. - Renamed test function for better clarity regarding `json_object` behavior. Closes: #4667 --- .../executor/antigravity_schema_sanitize_test.go | 12 ++++++------ .../chat-completions/antigravity_openai_request.go | 8 ++++---- .../antigravity_openai_request_test.go | 8 ++------ 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/internal/runtime/executor/antigravity_schema_sanitize_test.go b/internal/runtime/executor/antigravity_schema_sanitize_test.go index 139374bb..ff8c0c21 100644 --- a/internal/runtime/executor/antigravity_schema_sanitize_test.go +++ b/internal/runtime/executor/antigravity_schema_sanitize_test.go @@ -314,7 +314,7 @@ func TestSanitizeAntigravityRequestSchemasPreservesResponseUnionAndEnumType(t *t } } -func TestAntigravityBuildRequestKeepsJSONObjectSchemaPlaceholderFree(t *testing.T) { +func TestAntigravityBuildRequestKeepsJSONObjectMimeOnly(t *testing.T) { input := []byte(`{"model":"gemini-3.1-pro-low","messages":[{"role":"user","content":"hi"}],"response_format":{"type":"json_object"}}`) translated := antigravitychat.ConvertOpenAIRequestToAntigravity("gemini-3.1-pro-low", input, false) body := buildRequestBodyFromRawPayload(t, "gemini-3.1-pro-low", translated) @@ -323,12 +323,12 @@ func TestAntigravityBuildRequestKeepsJSONObjectSchemaPlaceholderFree(t *testing. t.Fatal(errMarshal) } - schema := gjson.GetBytes(encoded, "request.generationConfig.responseSchema") - if got := schema.Get("type").String(); got != "object" { - t.Fatalf("responseSchema.type = %q, want object: %s", got, encoded) + generationConfig := gjson.GetBytes(encoded, "request.generationConfig") + if got := generationConfig.Get("responseMimeType").String(); got != "application/json" { + t.Fatalf("responseMimeType = %q, want application/json: %s", got, encoded) } - if schema.Get("properties.reason").Exists() || schema.Get("required").Exists() { - t.Fatalf("json_object schema gained tool placeholders: %s", schema.Raw) + if generationConfig.Get("responseSchema").Exists() { + t.Fatalf("responseSchema should not be set for json_object: %s", encoded) } } 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 c0a953e5..6c99515f 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go @@ -82,10 +82,10 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ out, _ = sjson.DeleteBytes(out, "request.generationConfig."+schemaKey) } out, _ = sjson.SetBytes(out, "request.generationConfig.responseMimeType", "application/json") - if responseFormatType == "json_object" { - out, _ = sjson.SetRawBytes(out, "request.generationConfig.responseSchema", []byte(`{"type":"object"}`)) - } else if schema := responseFormat.Get("json_schema.schema"); schema.Exists() { - out, _ = sjson.SetRawBytes(out, "request.generationConfig.responseSchema", []byte(schema.Raw)) + if responseFormatType == "json_schema" { + if schema := responseFormat.Get("json_schema.schema"); schema.Exists() { + out, _ = sjson.SetRawBytes(out, "request.generationConfig.responseSchema", []byte(schema.Raw)) + } } } } diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go index 845e7b63..81907cbb 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go @@ -332,12 +332,8 @@ func TestConvertOpenAIRequestToAntigravityMapsResponseFormatJSONObject(t *testin if got := gjson.GetBytes(out, "request.generationConfig.responseMimeType").String(); got != "application/json" { t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, out) } - schema := gjson.GetBytes(out, "request.generationConfig.responseSchema") - if got := schema.Get("type").String(); got != "object" { - t.Fatalf("responseSchema.type = %q, want object. Output: %s", got, out) - } - if schema.Get("description").Exists() { - t.Fatalf("stale responseSchema survived. Output: %s", out) + if gjson.GetBytes(out, "request.generationConfig.responseSchema").Exists() { + t.Fatalf("responseSchema should not be set for json_object. Output: %s", out) } assertNoResponseSchemaAliases(t, out) } -- 2.51.2 From b4d94d58efe6bc581edecad942e3527f4c271a04 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 30 Jul 2026 06:53:46 +0800 Subject: [PATCH 13/31] fix(cliproxy): refine Codex model resolution and credential validation - Adjusted `resolveConfigCodexStyleKey` to include `validateIndexCredentials` for stricter credential checks. - Enhanced logic in `buildCodexConfigModels` to handle empty model lists more effectively. - Standardized credential matching with helper functions for improved maintainability. --- sdk/cliproxy/service_codex_models_test.go | 188 ++++++++++++++++++++++ sdk/cliproxy/service_models.go | 37 +++-- 2 files changed, 208 insertions(+), 17 deletions(-) create mode 100644 sdk/cliproxy/service_codex_models_test.go diff --git a/sdk/cliproxy/service_codex_models_test.go b/sdk/cliproxy/service_codex_models_test.go new file mode 100644 index 00000000..5bbfea5b --- /dev/null +++ b/sdk/cliproxy/service_codex_models_test.go @@ -0,0 +1,188 @@ +package cliproxy + +import ( + "context" + "fmt" + "testing" + + internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + internalregistry "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 TestRegisterModelsForAuthCodexAPIKeyModels(t *testing.T) { + defaultModels := internalregistry.GetCodexProModels() + if len(defaultModels) == 0 { + t.Fatal("expected Codex Pro default models") + } + + excludedModelID := defaultModels[0].ID + tests := []struct { + name string + entry config.CodexKey + wantIDs map[string]struct{} + }{ + { + name: "defaults without configuration", + entry: config.CodexKey{APIKey: "default-key"}, + wantIDs: codexModelIDSet(defaultModels), + }, + { + name: "explicit configuration replaces defaults", + entry: config.CodexKey{ + APIKey: "configured-key", + Models: []internalconfig.CodexModel{{ + Name: "upstream-codex", Alias: "configured-codex", + }}, + }, + wantIDs: map[string]struct{}{"configured-codex": {}}, + }, + { + name: "exclusions apply to defaults", + entry: config.CodexKey{ + APIKey: "excluded-key", + ExcludedModels: []string{excludedModelID}, + }, + wantIDs: codexModelIDSet(defaultModels[1:]), + }, + } + + for index := range tests { + testCase := tests[index] + t.Run(testCase.name, func(t *testing.T) { + authID := fmt.Sprintf("codex-api-key-models-%d", index) + modelRegistry := internalregistry.GetGlobalRegistry() + modelRegistry.UnregisterClient(authID) + t.Cleanup(func() { modelRegistry.UnregisterClient(authID) }) + + service := &Service{cfg: &config.Config{CodexKey: []config.CodexKey{testCase.entry}}} + auth := &coreauth.Auth{ + ID: authID, + Provider: "codex", + Status: coreauth.StatusActive, + Attributes: map[string]string{ + coreauth.AttributeAPIKey: testCase.entry.APIKey, + coreauth.AttributeConfigIndex: "0", + coreauth.AttributeSource: "config:codex:test", + }, + } + + service.registerModelsForAuth(context.Background(), auth) + gotIDs := codexModelIDSet(modelRegistry.GetModelsForClient(authID)) + if len(gotIDs) != len(testCase.wantIDs) { + t.Fatalf("registered model IDs = %#v, want %#v", gotIDs, testCase.wantIDs) + } + for modelID := range testCase.wantIDs { + if _, ok := gotIDs[modelID]; !ok { + t.Errorf("missing registered model %q", modelID) + } + } + }) + } +} + +func TestRegisterModelsForAuthCodexAPIKeyDefaultRequiresConfigMatch(t *testing.T) { + defaultIDs := codexModelIDSet(internalregistry.GetCodexProModels()) + tests := []struct { + name string + config config.Config + attributes map[string]string + wantIDs map[string]struct{} + }{ + { + name: "valid index with unmatched API key", + config: config.Config{CodexKey: []config.CodexKey{{ + APIKey: "configured-key", + }}}, + attributes: map[string]string{ + coreauth.AttributeAPIKey: "stale-key", + coreauth.AttributeConfigIndex: "0", + coreauth.AttributeSource: "config:codex:stale", + }, + wantIDs: map[string]struct{}{}, + }, + { + name: "valid index with unmatched base URL", + config: config.Config{CodexKey: []config.CodexKey{{ + APIKey: "configured-key", BaseURL: "https://new.example.com", + }}}, + attributes: map[string]string{ + coreauth.AttributeAPIKey: "configured-key", + coreauth.AttributeConfigIndex: "0", + coreauth.AttributeSource: "config:codex:stale", + "base_url": "https://old.example.com", + }, + wantIDs: map[string]struct{}{}, + }, + { + name: "stale index falls back to matching credentials", + config: config.Config{CodexKey: []config.CodexKey{ + { + APIKey: "wrong-key", + Models: []internalconfig.CodexModel{{Name: "wrong-model"}}, + }, + {APIKey: "configured-key"}, + }}, + attributes: map[string]string{ + coreauth.AttributeAPIKey: "configured-key", + coreauth.AttributeConfigIndex: "0", + coreauth.AttributeSource: "config:codex:stale", + }, + wantIDs: defaultIDs, + }, + { + name: "API key ignores OAuth plan type", + config: config.Config{CodexKey: []config.CodexKey{{ + APIKey: "configured-key", + }}}, + attributes: map[string]string{ + coreauth.AttributeAPIKey: "configured-key", + coreauth.AttributeConfigIndex: "0", + coreauth.AttributeSource: "config:codex:test", + "plan_type": "free", + }, + wantIDs: defaultIDs, + }, + } + + for index := range tests { + testCase := tests[index] + t.Run(testCase.name, func(t *testing.T) { + authID := fmt.Sprintf("codex-api-key-config-match-%d", index) + modelRegistry := internalregistry.GetGlobalRegistry() + modelRegistry.UnregisterClient(authID) + modelRegistry.RegisterClient(authID, "codex", []*internalregistry.ModelInfo{{ID: "stale-model"}}) + t.Cleanup(func() { modelRegistry.UnregisterClient(authID) }) + + service := &Service{cfg: &testCase.config} + auth := &coreauth.Auth{ + ID: authID, + Provider: "codex", + Status: coreauth.StatusActive, + Attributes: testCase.attributes, + } + + service.registerModelsForAuth(context.Background(), auth) + gotIDs := codexModelIDSet(modelRegistry.GetModelsForClient(authID)) + if len(gotIDs) != len(testCase.wantIDs) { + t.Fatalf("registered model IDs = %#v, want %#v", gotIDs, testCase.wantIDs) + } + for modelID := range testCase.wantIDs { + if _, ok := gotIDs[modelID]; !ok { + t.Errorf("missing registered model %q", modelID) + } + } + }) + } +} + +func codexModelIDSet(models []*internalregistry.ModelInfo) map[string]struct{} { + ids := make(map[string]struct{}, len(models)) + for _, model := range models { + if model != nil && model.ID != "" { + ids[model.ID] = struct{}{} + } + } + return ids +} diff --git a/sdk/cliproxy/service_models.go b/sdk/cliproxy/service_models.go index b53c0df5..af9cc0d6 100644 --- a/sdk/cliproxy/service_models.go +++ b/sdk/cliproxy/service_models.go @@ -117,10 +117,11 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut case "codex": if authKind == "apikey" { if entry := s.resolveConfigCodexKey(a); entry != nil { - models = buildCodexConfigModels(entry) + models = registry.GetCodexProModels() + if len(entry.Models) > 0 { + models = buildCodexConfigModels(entry) + } excluded = entry.ExcludedModels - } else { - models = nil } models = applyExcludedModels(models, excluded) break @@ -486,39 +487,41 @@ func (s *Service) resolveConfigCodexKey(auth *coreauth.Auth) *config.CodexKey { if s == nil || s.cfg == nil { return nil } - return resolveConfigCodexStyleKey(auth, s.cfg.CodexKey) + return resolveConfigCodexStyleKey(auth, s.cfg.CodexKey, true) } func (s *Service) resolveConfigXAIKey(auth *coreauth.Auth) *config.XAIKey { if s == nil || s.cfg == nil { return nil } - return resolveConfigCodexStyleKey(auth, s.cfg.XAIKey) + return resolveConfigCodexStyleKey(auth, s.cfg.XAIKey, false) } -func resolveConfigCodexStyleKey(auth *coreauth.Auth, entries []config.CodexKey) *config.CodexKey { +func resolveConfigCodexStyleKey(auth *coreauth.Auth, entries []config.CodexKey, validateIndexCredentials bool) *config.CodexKey { if auth == nil { return nil } - if entry := configEntryForAuthIndex(auth, entries); entry != nil { - return entry - } var attrKey, attrBase string if auth.Attributes != nil { attrKey = strings.TrimSpace(auth.Attributes["api_key"]) attrBase = strings.TrimSpace(auth.Attributes["base_url"]) } - for i := range entries { - entry := &entries[i] + matchesCredentials := func(entry *config.CodexKey) bool { + if entry == nil { + return false + } cfgKey := strings.TrimSpace(entry.APIKey) cfgBase := strings.TrimSpace(entry.BaseURL) - if attrKey != "" && strings.EqualFold(cfgKey, attrKey) { - if cfgBase == "" || strings.EqualFold(cfgBase, attrBase) { - return entry - } - continue + if attrKey != "" { + return strings.EqualFold(cfgKey, attrKey) && (cfgBase == "" || strings.EqualFold(cfgBase, attrBase)) } - if attrKey == "" && attrBase != "" && strings.EqualFold(cfgBase, attrBase) { + return attrBase != "" && strings.EqualFold(cfgBase, attrBase) + } + if entry := configEntryForAuthIndex(auth, entries); entry != nil && (!validateIndexCredentials || matchesCredentials(entry)) { + return entry + } + for i := range entries { + if entry := &entries[i]; matchesCredentials(entry) { return entry } } -- 2.51.2 From e8e39526b3c9f80e42214da2ea00aaffc3619a68 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Thu, 30 Jul 2026 07:36:58 +0800 Subject: [PATCH 14/31] feat(auth): add support for credential weight parsing in auth file handling --- .../api/handlers/management/auth_files.go | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 80801d7e..04927d61 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -15,6 +15,7 @@ import ( "github.com/gin-gonic/gin" "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/credentialweight" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" @@ -260,6 +261,20 @@ func (h *Handler) listAuthFilesFromDisk(c *gin.Context) { } } } + if wv := gjson.GetBytes(data, coreauth.AttributeWeight); wv.Exists() { + var rawWeight string + switch wv.Type { + case gjson.Number: + rawWeight = wv.Raw + case gjson.String: + rawWeight = wv.String() + } + if rawWeight != "" { + if weight, errWeight := credentialweight.ParseString(rawWeight); errWeight == nil { + fileData[coreauth.AttributeWeight] = weight + } + } + } if nv := gjson.GetBytes(data, "note"); nv.Exists() && nv.Type == gjson.String { if trimmed := strings.TrimSpace(nv.String()); trimmed != "" { fileData["note"] = trimmed @@ -403,12 +418,34 @@ func (h *Handler) buildAuthFileEntryLocked(auth *coreauth.Auth) gin.H { } } } + if weight, ok := authWeightValue(auth); ok { + entry[coreauth.AttributeWeight] = weight + } if websockets, ok := authWebsocketsValue(auth); ok { entry["websockets"] = websockets } return entry } +func authWeightValue(auth *coreauth.Auth) (int64, bool) { + if auth == nil { + return 0, false + } + if rawWeight := strings.TrimSpace(authAttribute(auth, coreauth.AttributeWeight)); rawWeight != "" { + weight, errWeight := credentialweight.ParseString(rawWeight) + return weight, errWeight == nil + } + if auth.Metadata == nil { + return 0, false + } + rawWeight, ok := auth.Metadata[coreauth.AttributeWeight] + if !ok || rawWeight == nil { + return 0, false + } + weight, errWeight := credentialweight.ParseValue(rawWeight) + return weight, errWeight == nil +} + func authWebsocketsValue(auth *coreauth.Auth) (bool, bool) { if auth == nil { return false, false -- 2.51.2 From 928478e4b91533cec05a763bfac3edad9c3e76cf Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 30 Jul 2026 07:38:21 +0800 Subject: [PATCH 15/31] fix(cliproxy): simplify Codex model resolution and enhance test coverage - Updated `buildCodexConfigModels` to default to `CodexProModels` when no models are explicitly configured, ensuring consistent behavior. - Refined logic in `resolveConfigCodexKey` to streamline model selection and exclusion handling. - Added comprehensive tests for default, explicit, and exclusion-based model configurations. - Introduced `TestRegisterConfigAPIKeyAuthsCodexModelModes` to validate runtime model registration scenarios. - Enhanced test utility functions for improved readability and reuse. --- .../config_model_display_name_test.go | 31 +++-- sdk/cliproxy/service_codex_models_test.go | 109 ++++++++++++++++-- sdk/cliproxy/service_models.go | 8 +- 3 files changed, 127 insertions(+), 21 deletions(-) diff --git a/sdk/cliproxy/config_model_display_name_test.go b/sdk/cliproxy/config_model_display_name_test.go index 1c47ef36..f7e78dc9 100644 --- a/sdk/cliproxy/config_model_display_name_test.go +++ b/sdk/cliproxy/config_model_display_name_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" ) func TestBuildConfigModelsDisplayName(t *testing.T) { @@ -68,20 +69,32 @@ func TestBuildConfigModelsDisplayName(t *testing.T) { } } -func TestBuildCodexConfigModelsOnlyIncludesConfiguredModels(t *testing.T) { - models := buildCodexConfigModels(&config.CodexKey{Models: []config.CodexModel{{ +func TestBuildCodexConfigModelsSelectsDefaultsOrConfiguredModels(t *testing.T) { + configured := buildCodexConfigModels(&config.CodexKey{Models: []config.CodexModel{{ Name: "upstream-codex", Alias: "configured-codex", }}}) - - if len(models) != 1 { - t.Fatalf("model count = %d, want 1", len(models)) + if len(configured) != 1 { + t.Fatalf("configured model count = %d, want 1", len(configured)) } - if models[0].ID != "configured-codex" { - t.Fatalf("model ID = %q, want configured-codex", models[0].ID) + if configured[0].ID != "configured-codex" { + t.Fatalf("configured model ID = %q, want configured-codex", configured[0].ID) } - if models := buildCodexConfigModels(&config.CodexKey{}); len(models) != 0 { - t.Fatalf("model count without configuration = %d, want 0", len(models)) + defaults := buildCodexConfigModels(&config.CodexKey{}) + wantDefaults := registry.GetCodexProModels() + if len(defaults) != len(wantDefaults) { + t.Fatalf("default model count = %d, want %d", len(defaults), len(wantDefaults)) + } + defaultIDs := make(map[string]struct{}, len(defaults)) + for _, model := range defaults { + if model != nil { + defaultIDs[model.ID] = struct{}{} + } + } + for _, modelID := range []string{"gpt-image-1.5", "gpt-image-2"} { + if _, ok := defaultIDs[modelID]; !ok { + t.Errorf("missing default model %q", modelID) + } } } diff --git a/sdk/cliproxy/service_codex_models_test.go b/sdk/cliproxy/service_codex_models_test.go index 5bbfea5b..ae5abc34 100644 --- a/sdk/cliproxy/service_codex_models_test.go +++ b/sdk/cliproxy/service_codex_models_test.go @@ -19,24 +19,28 @@ func TestRegisterModelsForAuthCodexAPIKeyModels(t *testing.T) { excludedModelID := defaultModels[0].ID tests := []struct { - name string - entry config.CodexKey - wantIDs map[string]struct{} + name string + entry config.CodexKey + wantIDs map[string]struct{} + wantPresent []string + wantAbsent []string }{ { - name: "defaults without configuration", - entry: config.CodexKey{APIKey: "default-key"}, - wantIDs: codexModelIDSet(defaultModels), + name: "defaults without explicit models", + entry: config.CodexKey{APIKey: "default-key"}, + wantIDs: codexModelIDSet(defaultModels), + wantPresent: []string{"gpt-image-1.5", "gpt-image-2"}, }, { - name: "explicit configuration replaces defaults", + name: "only explicitly configured models", entry: config.CodexKey{ APIKey: "configured-key", Models: []internalconfig.CodexModel{{ Name: "upstream-codex", Alias: "configured-codex", }}, }, - wantIDs: map[string]struct{}{"configured-codex": {}}, + wantIDs: map[string]struct{}{"configured-codex": {}}, + wantAbsent: []string{"gpt-image-1.5", "gpt-image-2"}, }, { name: "exclusions apply to defaults", @@ -78,6 +82,16 @@ func TestRegisterModelsForAuthCodexAPIKeyModels(t *testing.T) { t.Errorf("missing registered model %q", modelID) } } + for _, modelID := range testCase.wantPresent { + if _, ok := gotIDs[modelID]; !ok { + t.Errorf("missing required registered model %q", modelID) + } + } + for _, modelID := range testCase.wantAbsent { + if _, ok := gotIDs[modelID]; ok { + t.Errorf("unexpected registered model %q", modelID) + } + } }) } } @@ -177,6 +191,75 @@ func TestRegisterModelsForAuthCodexAPIKeyDefaultRequiresConfigMatch(t *testing.T } } +func TestRegisterConfigAPIKeyAuthsCodexModelModes(t *testing.T) { + defaultIDs := codexModelIDSet(internalregistry.GetCodexProModels()) + tests := []struct { + name string + models []internalconfig.CodexModel + wantIDs map[string]struct{} + wantImages bool + }{ + { + name: "empty models uses defaults with images", + wantIDs: defaultIDs, + wantImages: true, + }, + { + name: "configured models replace defaults", + models: []internalconfig.CodexModel{{ + Name: "runtime-upstream", Alias: "runtime-configured", + }}, + wantIDs: map[string]struct{}{"runtime-configured": {}}, + }, + } + + for index := range tests { + testCase := tests[index] + t.Run(testCase.name, func(t *testing.T) { + cfg := &config.Config{CodexKey: []config.CodexKey{{ + APIKey: fmt.Sprintf("runtime-key-%d", index), + Models: testCase.models, + }}} + manager := coreauth.NewManager(nil, nil, nil) + service := &Service{cfg: cfg, coreManager: manager} + service.registerConfigAPIKeyAuths(context.Background(), cfg) + + auths := manager.List() + modelRegistry := internalregistry.GetGlobalRegistry() + for _, auth := range auths { + if auth != nil { + authID := auth.ID + t.Cleanup(func() { modelRegistry.UnregisterClient(authID) }) + } + } + if len(auths) != 1 { + t.Fatalf("runtime auth count = %d, want 1", len(auths)) + } + + registeredIDs := codexModelIDSet(modelRegistry.GetModelsForClient(auths[0].ID)) + if len(registeredIDs) != len(testCase.wantIDs) { + t.Fatalf("registered model IDs = %#v, want %#v", registeredIDs, testCase.wantIDs) + } + for modelID := range testCase.wantIDs { + if _, ok := registeredIDs[modelID]; !ok { + t.Errorf("missing registered model %q", modelID) + } + } + for _, modelID := range []string{"gpt-image-1.5", "gpt-image-2"} { + _, registered := registeredIDs[modelID] + if registered != testCase.wantImages { + t.Errorf("registered model %q = %t, want %t", modelID, registered, testCase.wantImages) + } + if testCase.wantImages { + if _, available := openAIModelIDSet(modelRegistry.GetAvailableModels("openai"))[modelID]; !available { + t.Errorf("/v1/models source is missing %q", modelID) + } + } + } + }) + } +} + func codexModelIDSet(models []*internalregistry.ModelInfo) map[string]struct{} { ids := make(map[string]struct{}, len(models)) for _, model := range models { @@ -186,3 +269,13 @@ func codexModelIDSet(models []*internalregistry.ModelInfo) map[string]struct{} { } return ids } + +func openAIModelIDSet(models []map[string]any) map[string]struct{} { + ids := make(map[string]struct{}, len(models)) + for _, model := range models { + if modelID, ok := model["id"].(string); ok && modelID != "" { + ids[modelID] = struct{}{} + } + } + return ids +} diff --git a/sdk/cliproxy/service_models.go b/sdk/cliproxy/service_models.go index af9cc0d6..b0394dc3 100644 --- a/sdk/cliproxy/service_models.go +++ b/sdk/cliproxy/service_models.go @@ -117,10 +117,7 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut case "codex": if authKind == "apikey" { if entry := s.resolveConfigCodexKey(a); entry != nil { - models = registry.GetCodexProModels() - if len(entry.Models) > 0 { - models = buildCodexConfigModels(entry) - } + models = buildCodexConfigModels(entry) excluded = entry.ExcludedModels } models = applyExcludedModels(models, excluded) @@ -807,6 +804,9 @@ func buildCodexConfigModels(entry *config.CodexKey) []*ModelInfo { if entry == nil { return nil } + if len(entry.Models) == 0 { + return registry.GetCodexProModels() + } models := buildConfigModels(entry.Models, "openai", "openai") configuredDisplayNames := make(map[string]string, len(entry.Models)) -- 2.51.2 From f179a0f464c274f7de66da36ec95c3d2ec249e64 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Thu, 30 Jul 2026 09:27:17 +0800 Subject: [PATCH 16/31] fix(auth): refresh Home credentials before 401 retry --- sdk/cliproxy/auth/conductor_execution.go | 2 +- sdk/cliproxy/auth/conductor_home_execution.go | 21 +- sdk/cliproxy/auth/conductor_refresh.go | 43 +++- sdk/cliproxy/auth/conductor_stream.go | 10 +- .../auth/home_unauthorized_refresh_test.go | 240 ++++++++++++++++++ 5 files changed, 307 insertions(+), 9 deletions(-) create mode 100644 sdk/cliproxy/auth/home_unauthorized_refresh_test.go diff --git a/sdk/cliproxy/auth/conductor_execution.go b/sdk/cliproxy/auth/conductor_execution.go index a9ca5165..68dca5a5 100644 --- a/sdk/cliproxy/auth/conductor_execution.go +++ b/sdk/cliproxy/auth/conductor_execution.go @@ -636,7 +636,7 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string models = models[:1] pooled = false } - streamResult, errStream := m.executeStreamWithModelPool(execCtx, executor, auth, provider, execReq, execOpts, routeModel, streamExecutionModel, models, pooled, aliasResult, routing, !homeMode, selection != nil) + streamResult, errStream := m.executeStreamWithModelPool(execCtx, executor, auth, provider, execReq, execOpts, routeModel, streamExecutionModel, models, pooled, aliasResult, routing, true, selection != nil) if errStream != nil { if selection != nil { releaseAttempt() diff --git a/sdk/cliproxy/auth/conductor_home_execution.go b/sdk/cliproxy/auth/conductor_home_execution.go index dfd14ee0..d7590d13 100644 --- a/sdk/cliproxy/auth/conductor_home_execution.go +++ b/sdk/cliproxy/auth/conductor_home_execution.go @@ -81,6 +81,7 @@ func (m *Manager) executeHome(ctx context.Context, providers []string, req clipr lastErr = errPrepare continue } + didRefreshOnUnauthorized := false for _, upstreamModel := range models { resultModel := m.stateModelForExecution(preparedAuth, routeModel, upstreamModel, pooled) execReq := req @@ -107,10 +108,22 @@ func (m *Manager) executeHome(ctx context.Context, providers []string, req clipr } var response cliproxyexecutor.Response var errExecute error - if countTokens { - response, errExecute = selection.Executor.CountTokens(execCtx, preparedAuth, execReq, execOpts) - } else { - response, errExecute = selection.Executor.Execute(execCtx, preparedAuth, execReq, execOpts) + execute := func() (cliproxyexecutor.Response, error) { + if countTokens { + return selection.Executor.CountTokens(execCtx, preparedAuth, execReq, execOpts) + } + return selection.Executor.Execute(execCtx, preparedAuth, execReq, execOpts) + } + response, errExecute = execute() + if errExecute != nil { + if refreshed, okRefresh, errRefresh := m.tryRefreshExecutionAuthAfterUnauthorized(execCtx, selection.Executor, preparedAuth, errExecute, didRefreshOnUnauthorized, true); errRefresh != nil { + errExecute = errRefresh + } else if okRefresh { + preparedAuth = refreshed + didRefreshOnUnauthorized = true + publishSelectedAuthMetadata(opts.Metadata, preparedAuth) + response, errExecute = execute() + } } result := Result{AuthID: preparedAuth.ID, Provider: selection.Provider, Model: resultModel, Success: errExecute == nil} if errExecute == nil { diff --git a/sdk/cliproxy/auth/conductor_refresh.go b/sdk/cliproxy/auth/conductor_refresh.go index 4d9385d4..7cd23203 100644 --- a/sdk/cliproxy/auth/conductor_refresh.go +++ b/sdk/cliproxy/auth/conductor_refresh.go @@ -377,8 +377,47 @@ func clearUnauthorizedModelStates(auth *Auth, now time.Time) []string { return resumed } -// tryRefreshAfterUnauthorized refreshes OAuth credentials once after a 401 so the -// current auth can be retried before fallback/suspend. +// tryRefreshExecutionAuthAfterUnauthorized refreshes OAuth credentials once for +// either a local auth or an ephemeral Home dispatch auth. +func (m *Manager) tryRefreshExecutionAuthAfterUnauthorized(ctx context.Context, executor ProviderExecutor, auth *Auth, execErr error, alreadyTried bool, homeDispatch bool) (*Auth, bool, error) { + if !homeDispatch { + refreshed, ok := m.tryRefreshAfterUnauthorized(ctx, auth, execErr, alreadyTried) + return refreshed, ok, nil + } + if m == nil || executor == nil || auth == nil || alreadyTried || execErr == nil { + return auth, false, nil + } + if !isUnauthorizedError(execErr) || !authHasRefreshCredential(auth) { + return auth, false, nil + } + + log.Debugf("unauthorized Home response for %s (%s), refreshing credentials before redispatch", auth.Provider, auth.ID) + target := auth.Clone() + updated, errRefresh := executor.Refresh(ctx, target) + if errRefresh != nil { + log.Debugf("Home credential refresh before redispatch failed for %s (%s): %v", auth.Provider, auth.ID, errRefresh) + return auth, false, errRefresh + } + if updated == nil { + updated = target + } + if updated.ID == "" { + updated.ID = auth.ID + } + if updated.Index == "" { + updated.Index = auth.Index + } + if updated.Provider == "" { + updated.Provider = auth.Provider + } + if updated.Runtime == nil { + updated.Runtime = auth.Runtime + } + return updated, true, nil +} + +// tryRefreshAfterUnauthorized refreshes local OAuth credentials once after a +// 401 so the current auth can be retried before fallback/suspend. func (m *Manager) tryRefreshAfterUnauthorized(ctx context.Context, auth *Auth, execErr error, alreadyTried bool) (*Auth, bool) { if m == nil || auth == nil || alreadyTried || execErr == nil { return auth, false diff --git a/sdk/cliproxy/auth/conductor_stream.go b/sdk/cliproxy/auth/conductor_stream.go index be6784af..156682cf 100644 --- a/sdk/cliproxy/auth/conductor_stream.go +++ b/sdk/cliproxy/auth/conductor_stream.go @@ -212,7 +212,9 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi return nil, errCtx } if allowRetry { - if refreshed, okRefresh := m.tryRefreshAfterUnauthorized(ctx, auth, errStream, didRefreshOnUnauthorized); okRefresh { + if refreshed, okRefresh, errRefresh := m.tryRefreshExecutionAuthAfterUnauthorized(ctx, executor, auth, errStream, didRefreshOnUnauthorized, ephemeralResult); errRefresh != nil { + errStream = errRefresh + } else if okRefresh { auth = refreshed didRefreshOnUnauthorized = true streamResult, errStream = executor.ExecuteStream(ctx, auth, execReq, execOpts) @@ -246,7 +248,11 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi return nil, errCtx } if allowRetry { - if refreshed, okRefresh := m.tryRefreshAfterUnauthorized(ctx, auth, bootstrapErr, didRefreshOnUnauthorized); okRefresh { + if refreshed, okRefresh, errRefresh := m.tryRefreshExecutionAuthAfterUnauthorized(ctx, executor, auth, bootstrapErr, didRefreshOnUnauthorized, ephemeralResult); errRefresh != nil { + discardStreamChunks(streamResult.Chunks) + bootstrapErr = errRefresh + streamResult = &cliproxyexecutor.StreamResult{} + } else if okRefresh { discardStreamChunks(streamResult.Chunks) auth = refreshed didRefreshOnUnauthorized = true diff --git a/sdk/cliproxy/auth/home_unauthorized_refresh_test.go b/sdk/cliproxy/auth/home_unauthorized_refresh_test.go new file mode 100644 index 00000000..c20b0d13 --- /dev/null +++ b/sdk/cliproxy/auth/home_unauthorized_refresh_test.go @@ -0,0 +1,240 @@ +package auth + +import ( + "context" + "encoding/json" + "net/http" + "sync/atomic" + "testing" + + internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executionregistry" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" +) + +const homeUnauthorizedRefreshProvider = "home-unauthorized-refresh" + +type homeUnauthorizedRefreshDispatcher struct { + calls atomic.Int32 +} + +func (*homeUnauthorizedRefreshDispatcher) HeartbeatOK() bool { return true } + +func (d *homeUnauthorizedRefreshDispatcher) RPopAuth(context.Context, string, string, http.Header, int) ([]byte, error) { + d.calls.Add(1) + return json.Marshal(homeAuthDispatchResponse{Auth: Auth{ + ID: "home-refresh-auth", + Provider: homeUnauthorizedRefreshProvider, + Status: StatusActive, + Metadata: map[string]any{ + "access_token": "stale-access-token", + "refresh_token": "refresh-token", + }, + }}) +} + +func (*homeUnauthorizedRefreshDispatcher) AbortAmbiguousDispatch() {} + +type homeUnauthorizedRefreshExecutor struct { + streamMode string + refreshErr error + executeCalls atomic.Int32 + countCalls atomic.Int32 + streamCalls atomic.Int32 + refreshCalls atomic.Int32 +} + +func (*homeUnauthorizedRefreshExecutor) Identifier() string { return homeUnauthorizedRefreshProvider } + +func (e *homeUnauthorizedRefreshExecutor) Execute(_ context.Context, auth *Auth, _ cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + e.executeCalls.Add(1) + if authAccessToken(auth) == "stale-access-token" { + return cliproxyexecutor.Response{}, &Error{HTTPStatus: http.StatusUnauthorized, Message: "expired access token"} + } + return cliproxyexecutor.Response{Payload: []byte("ok")}, nil +} + +func (e *homeUnauthorizedRefreshExecutor) ExecuteStream(_ context.Context, auth *Auth, _ cliproxyexecutor.Request, _ cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) { + e.streamCalls.Add(1) + if authAccessToken(auth) == "stale-access-token" { + switch e.streamMode { + case "bootstrap": + chunks := make(chan cliproxyexecutor.StreamChunk, 1) + chunks <- cliproxyexecutor.StreamChunk{Err: &Error{HTTPStatus: http.StatusUnauthorized, Message: "expired access token"}} + close(chunks) + return &cliproxyexecutor.StreamResult{Chunks: chunks}, nil + case "started": + chunks := make(chan cliproxyexecutor.StreamChunk, 2) + chunks <- cliproxyexecutor.StreamChunk{Payload: []byte("started")} + chunks <- cliproxyexecutor.StreamChunk{Err: &Error{HTTPStatus: http.StatusUnauthorized, Message: "expired access token"}} + close(chunks) + return &cliproxyexecutor.StreamResult{Chunks: chunks}, nil + default: + return nil, &Error{HTTPStatus: http.StatusUnauthorized, Message: "expired access token"} + } + } + chunks := make(chan cliproxyexecutor.StreamChunk, 1) + chunks <- cliproxyexecutor.StreamChunk{Payload: []byte("ok")} + close(chunks) + return &cliproxyexecutor.StreamResult{Chunks: chunks}, nil +} + +func (e *homeUnauthorizedRefreshExecutor) Refresh(_ context.Context, auth *Auth) (*Auth, error) { + e.refreshCalls.Add(1) + if e.refreshErr != nil { + return nil, e.refreshErr + } + updated := auth.Clone() + if updated.Metadata == nil { + updated.Metadata = make(map[string]any) + } + updated.Metadata["access_token"] = "fresh-access-token" + return updated, nil +} + +func (e *homeUnauthorizedRefreshExecutor) CountTokens(_ context.Context, auth *Auth, _ cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + e.countCalls.Add(1) + if authAccessToken(auth) == "stale-access-token" { + return cliproxyexecutor.Response{}, &Error{HTTPStatus: http.StatusUnauthorized, Message: "expired access token"} + } + return cliproxyexecutor.Response{Payload: []byte("ok")}, nil +} + +func (*homeUnauthorizedRefreshExecutor) HttpRequest(context.Context, *Auth, *http.Request) (*http.Response, error) { + return nil, nil +} + +func newHomeUnauthorizedRefreshManager(dispatcher *homeUnauthorizedRefreshDispatcher, executor *homeUnauthorizedRefreshExecutor) *Manager { + manager := NewManager(nil, nil, nil) + manager.SetConfig(&internalconfig.Config{Home: internalconfig.HomeConfig{Enabled: true}}) + manager.PublishHomeDispatch(dispatcher, executionregistry.New(), 1) + manager.RegisterExecutor(executor) + return manager +} + +func TestHomeUnauthorizedRefreshesSameSelectionBeforeRedispatch(t *testing.T) { + for _, test := range []struct { + name string + run func(*Manager) error + }{ + { + name: "execute", + run: func(manager *Manager) error { + _, errExecute := manager.Execute(context.Background(), []string{homeUnauthorizedRefreshProvider}, cliproxyexecutor.Request{Model: "model-a"}, cliproxyexecutor.Options{}) + return errExecute + }, + }, + { + name: "count_tokens", + run: func(manager *Manager) error { + _, errCount := manager.ExecuteCount(context.Background(), []string{homeUnauthorizedRefreshProvider}, cliproxyexecutor.Request{Model: "model-a"}, cliproxyexecutor.Options{}) + return errCount + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + dispatcher := &homeUnauthorizedRefreshDispatcher{} + executor := &homeUnauthorizedRefreshExecutor{} + manager := newHomeUnauthorizedRefreshManager(dispatcher, executor) + + if errRun := test.run(manager); errRun != nil { + t.Fatalf("execution error = %v", errRun) + } + if got := dispatcher.calls.Load(); got != 1 { + t.Fatalf("Home dispatch calls = %d, want 1", got) + } + if got := executor.refreshCalls.Load(); got != 1 { + t.Fatalf("refresh calls = %d, want 1", got) + } + if test.name == "execute" && executor.executeCalls.Load() != 2 { + t.Fatalf("execute calls = %d, want 2", executor.executeCalls.Load()) + } + if test.name == "count_tokens" && executor.countCalls.Load() != 2 { + t.Fatalf("count calls = %d, want 2", executor.countCalls.Load()) + } + }) + } +} + +func TestHomeUnauthorizedTransientRefreshFailureIsReturned(t *testing.T) { + dispatcher := &homeUnauthorizedRefreshDispatcher{} + executor := &homeUnauthorizedRefreshExecutor{ + refreshErr: &Error{HTTPStatus: http.StatusServiceUnavailable, Message: "Home refresh temporarily unavailable"}, + } + manager := newHomeUnauthorizedRefreshManager(dispatcher, executor) + + _, errExecute := manager.Execute(context.Background(), []string{homeUnauthorizedRefreshProvider}, cliproxyexecutor.Request{Model: "model-a"}, cliproxyexecutor.Options{}) + if statusCodeFromError(errExecute) != http.StatusServiceUnavailable { + t.Fatalf("Execute() error = %v, want transient 503", errExecute) + } + if got := executor.executeCalls.Load(); got != 1 { + t.Fatalf("execute calls = %d, want 1", got) + } + if got := executor.refreshCalls.Load(); got != 1 { + t.Fatalf("refresh calls = %d, want 1", got) + } +} + +func TestHomeUnauthorizedStartedStreamDoesNotReplay(t *testing.T) { + dispatcher := &homeUnauthorizedRefreshDispatcher{} + executor := &homeUnauthorizedRefreshExecutor{streamMode: "started"} + manager := newHomeUnauthorizedRefreshManager(dispatcher, executor) + + result, errStream := manager.ExecuteStream(context.Background(), []string{homeUnauthorizedRefreshProvider}, cliproxyexecutor.Request{Model: "model-a"}, cliproxyexecutor.Options{Stream: true}) + if errStream != nil { + t.Fatalf("ExecuteStream() error = %v", errStream) + } + sawPayload := false + sawUnauthorized := false + for chunk := range result.Chunks { + if string(chunk.Payload) == "started" { + sawPayload = true + } + if statusCodeFromError(chunk.Err) == http.StatusUnauthorized { + sawUnauthorized = true + } + } + if !sawPayload || !sawUnauthorized { + t.Fatalf("stream results = payload %v unauthorized %v, want both", sawPayload, sawUnauthorized) + } + if got := executor.refreshCalls.Load(); got != 0 { + t.Fatalf("refresh calls = %d, want 0 after stream started", got) + } + if got := executor.streamCalls.Load(); got != 1 { + t.Fatalf("stream calls = %d, want 1", got) + } +} + +func TestHomeUnauthorizedStreamRefreshesBeforeRedispatch(t *testing.T) { + for _, mode := range []string{"synchronous", "bootstrap"} { + t.Run(mode, func(t *testing.T) { + dispatcher := &homeUnauthorizedRefreshDispatcher{} + executor := &homeUnauthorizedRefreshExecutor{streamMode: mode} + manager := newHomeUnauthorizedRefreshManager(dispatcher, executor) + + result, errStream := manager.ExecuteStream(context.Background(), []string{homeUnauthorizedRefreshProvider}, cliproxyexecutor.Request{Model: "model-a"}, cliproxyexecutor.Options{Stream: true}) + if errStream != nil { + t.Fatalf("ExecuteStream() error = %v", errStream) + } + var payload string + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatalf("stream chunk error = %v", chunk.Err) + } + payload += string(chunk.Payload) + } + if payload != "ok" { + t.Fatalf("stream payload = %q, want ok", payload) + } + if got := dispatcher.calls.Load(); got != 1 { + t.Fatalf("Home dispatch calls = %d, want 1", got) + } + if got := executor.refreshCalls.Load(); got != 1 { + t.Fatalf("refresh calls = %d, want 1", got) + } + if got := executor.streamCalls.Load(); got != 2 { + t.Fatalf("stream calls = %d, want 2", got) + } + }) + } +} -- 2.51.2 From a80e8082ef759aa172d23e948fe51578e0b90abf Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 30 Jul 2026 16:37:40 +0800 Subject: [PATCH 17/31] feat(codex): add `disable-codex-cloaking` config option and refine header management - Introduced `disable-codex-cloaking` to allow disabling enforced `User-Agent` and `Originator` headers for Codex requests. - Updated header application logic to conditionally include `codexUserAgent` and `codexOriginator` based on configuration. - Enhanced config diff tracking to detect changes in `disable-codex-cloaking`. - Expanded tests to cover new config behavior and header application scenarios. --- config.example.yaml | 2 + .../codex_websocket_header_defaults_test.go | 7 ++++ internal/config/config_types.go | 2 + .../executor/codex_executor_request.go | 6 ++- .../executor/codex_openai_images_test.go | 4 +- .../codex_websockets_executor_test.go | 39 ++++++++++++++++++- internal/watcher/diff/config_diff.go | 3 ++ internal/watcher/diff/config_diff_test.go | 2 + 8 files changed, 61 insertions(+), 4 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 3a44af69..027b7acb 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -220,6 +220,8 @@ codex: # Some superstitious users believe request tracking identifiers can be used # as evidence for TOS enforcement bans; this option only satisfies those odd concerns. identity-confuse: false + # Disable forcing the official Codex User-Agent and Originator headers on HTTP requests. + disable-codex-cloaking: false # When true, optimize Codex Desktop and codex-tui requests for multi-agent v2. # This refreshes Codex spawn_agent model details, removes message parameter encryption, # normalizes encrypted agent_message content for Codex, and converts agent_message input diff --git a/internal/config/codex_websocket_header_defaults_test.go b/internal/config/codex_websocket_header_defaults_test.go index 6eb0e65a..86bf610e 100644 --- a/internal/config/codex_websocket_header_defaults_test.go +++ b/internal/config/codex_websocket_header_defaults_test.go @@ -29,6 +29,9 @@ codex-header-defaults: if got := cfg.CodexHeaderDefaults.BetaFeatures; got != "feature-a,feature-b" { t.Fatalf("BetaFeatures = %q, want %q", got, "feature-a,feature-b") } + if cfg.Codex.DisableCodexCloaking { + t.Fatal("DisableCodexCloaking = true, want default false") + } } func TestLoadConfigOptional_CodexIdentityConfuse(t *testing.T) { @@ -37,6 +40,7 @@ func TestLoadConfigOptional_CodexIdentityConfuse(t *testing.T) { configYAML := []byte(` codex: identity-confuse: true + disable-codex-cloaking: true optimize-multi-agent-v2: true `) if err := os.WriteFile(configPath, configYAML, 0o600); err != nil { @@ -51,6 +55,9 @@ codex: if !cfg.Codex.IdentityConfuse { t.Fatalf("IdentityConfuse = false, want true") } + if !cfg.Codex.DisableCodexCloaking { + t.Fatal("DisableCodexCloaking = false, want true") + } if !cfg.Codex.OptimizeMultiAgentV2 { t.Fatalf("OptimizeMultiAgentV2 = false, want true") } diff --git a/internal/config/config_types.go b/internal/config/config_types.go index cb4e63ed..a75a4c70 100644 --- a/internal/config/config_types.go +++ b/internal/config/config_types.go @@ -127,6 +127,8 @@ type XAIConfig struct { // CodexConfig configures provider-wide Codex request behavior. type CodexConfig struct { IdentityConfuse bool `yaml:"identity-confuse" json:"identity-confuse"` + // DisableCodexCloaking disables forcing the official Codex identity headers on HTTP requests. + DisableCodexCloaking bool `yaml:"disable-codex-cloaking" json:"disable-codex-cloaking"` // OptimizeMultiAgentV2 optimizes official Codex multi-agent requests. OptimizeMultiAgentV2 bool `yaml:"optimize-multi-agent-v2" json:"optimize-multi-agent-v2"` // LiveMediaRelay terminates and relays Codex Live WebRTC media in this process. diff --git a/internal/runtime/executor/codex_executor_request.go b/internal/runtime/executor/codex_executor_request.go index 7a5f8634..8a76620c 100644 --- a/internal/runtime/executor/codex_executor_request.go +++ b/internal/runtime/executor/codex_executor_request.go @@ -23,7 +23,7 @@ import ( ) const ( - codexUserAgent = "codex-tui/0.135.0 (Mac OS 26.5.0; arm64) iTerm.app/3.6.10 (codex-tui; 0.135.0)" + codexUserAgent = "codex-tui/0.146.0 (Mac OS 26.5.0; arm64) iTerm.app/3.6.10 (codex-tui; 0.146.0)" codexOriginator = "codex-tui" codexDefaultImageToolModel = "gpt-image-2" codexResponsesLiteHeader = "X-OpenAI-Internal-Codex-Responses-Lite" @@ -352,6 +352,10 @@ func applyCodexHeadersFromSources(r *http.Request, auth *cliproxyauth.Auth, toke attrs = auth.Attributes } util.ApplyCustomHeadersFromAttrs(r, attrs) + if cfg != nil && !cfg.Codex.DisableCodexCloaking { + r.Header.Set("User-Agent", codexUserAgent) + r.Header.Set("Originator", codexOriginator) + } } func normalizeCodexInstructions(body []byte) []byte { diff --git a/internal/runtime/executor/codex_openai_images_test.go b/internal/runtime/executor/codex_openai_images_test.go index 6bc5b638..bd1818d4 100644 --- a/internal/runtime/executor/codex_openai_images_test.go +++ b/internal/runtime/executor/codex_openai_images_test.go @@ -105,8 +105,8 @@ func TestCodexExecutorDirectOpenAIImageGenerationUsesImagesEndpoint(t *testing.T if gotClientRequestID != "client-request-1" { t.Fatalf("X-Client-Request-Id = %q, want %q", gotClientRequestID, "client-request-1") } - if gotOriginator != "Codex Desktop" { - t.Fatalf("Originator = %q, want %q", gotOriginator, "Codex Desktop") + if gotOriginator != codexOriginator { + t.Fatalf("Originator = %q, want %q", gotOriginator, codexOriginator) } if got := gjson.GetBytes(gotBody, "model").String(); got != "gpt-image-1.5" { t.Fatalf("model = %q, want gpt-image-1.5; body=%s", got, string(gotBody)) diff --git a/internal/runtime/executor/codex_websockets_executor_test.go b/internal/runtime/executor/codex_websockets_executor_test.go index 17bbdcbe..3d6b2ade 100644 --- a/internal/runtime/executor/codex_websockets_executor_test.go +++ b/internal/runtime/executor/codex_websockets_executor_test.go @@ -1510,6 +1510,7 @@ func TestApplyCodexHeadersUsesConfigUserAgentForOAuth(t *testing.T) { t.Fatalf("NewRequest() error = %v", err) } cfg := &config.Config{ + Codex: config.CodexConfig{DisableCodexCloaking: true}, CodexHeaderDefaults: config.CodexHeaderDefaults{ UserAgent: "config-ua", BetaFeatures: "config-beta", @@ -1533,6 +1534,41 @@ func TestApplyCodexHeadersUsesConfigUserAgentForOAuth(t *testing.T) { } } +func TestApplyCodexHeadersDefaultsToCodexCloaking(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "https://example.com/responses", nil) + if err != nil { + t.Fatalf("NewRequest() error = %v", err) + } + req.Header.Set("User-Agent", "existing-ua") + req.Header.Set("Originator", "existing-origin") + cfg := &config.Config{ + CodexHeaderDefaults: config.CodexHeaderDefaults{ + UserAgent: "config-ua", + }, + } + auth := &cliproxyauth.Auth{ + Provider: "codex", + Attributes: map[string]string{ + "api_key": "api-key", + "header:User-Agent": "custom-ua", + "header:Originator": "custom-origin", + }, + } + ginHeaders := http.Header{ + "User-Agent": []string{"client-ua"}, + "Originator": []string{"client-origin"}, + } + + applyCodexHeadersFromSources(req, auth, "api-key", false, cfg, ginHeaders) + + if got := req.Header.Get("User-Agent"); got != codexUserAgent { + t.Fatalf("User-Agent = %q, want %q", got, codexUserAgent) + } + if got := req.Header.Get("Originator"); got != codexOriginator { + t.Fatalf("Originator = %q, want %q", got, codexOriginator) + } +} + func TestApplyModelHeaderOverridesFromModelConfig(t *testing.T) { const wantUA = "codex-tui/0.144.0 (Mac OS 26.5.1; arm64) iTerm.app/3.6.11 (codex-tui; 0.144.0)" req, err := http.NewRequest(http.MethodPost, "https://example.com/responses", nil) @@ -1614,7 +1650,8 @@ func TestApplyCodexHeadersPassesThroughClientIdentityHeaders(t *testing.T) { "X-Client-Request-Id": "019d2233-e240-7162-992d-38df0a2a0e0d", })) - applyCodexHeaders(req, auth, "oauth-token", true, nil) + cfg := &config.Config{Codex: config.CodexConfig{DisableCodexCloaking: true}} + applyCodexHeaders(req, auth, "oauth-token", true, cfg) if got := req.Header.Get("Originator"); got != "Codex Desktop" { t.Fatalf("Originator = %s, want %s", got, "Codex Desktop") diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go index e89eeed9..cc3a0507 100644 --- a/internal/watcher/diff/config_diff.go +++ b/internal/watcher/diff/config_diff.go @@ -108,6 +108,9 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if oldCfg.Codex.IdentityConfuse != newCfg.Codex.IdentityConfuse { changes = append(changes, fmt.Sprintf("codex.identity-confuse: %t -> %t", oldCfg.Codex.IdentityConfuse, newCfg.Codex.IdentityConfuse)) } + if oldCfg.Codex.DisableCodexCloaking != newCfg.Codex.DisableCodexCloaking { + changes = append(changes, fmt.Sprintf("codex.disable-codex-cloaking: %t -> %t", oldCfg.Codex.DisableCodexCloaking, newCfg.Codex.DisableCodexCloaking)) + } if oldCfg.Codex.OptimizeMultiAgentV2 != newCfg.Codex.OptimizeMultiAgentV2 { changes = append(changes, fmt.Sprintf("codex.optimize-multi-agent-v2: %t -> %t", oldCfg.Codex.OptimizeMultiAgentV2, newCfg.Codex.OptimizeMultiAgentV2)) } diff --git a/internal/watcher/diff/config_diff_test.go b/internal/watcher/diff/config_diff_test.go index 4a365b2a..2fe86540 100644 --- a/internal/watcher/diff/config_diff_test.go +++ b/internal/watcher/diff/config_diff_test.go @@ -39,6 +39,7 @@ func TestBuildConfigChangeDetails(t *testing.T) { newCfg := &config.Config{ Port: 9090, AuthDir: "/tmp/auth-new", + Codex: config.CodexConfig{DisableCodexCloaking: true}, GeminiKey: []config.GeminiKey{ {APIKey: "old", BaseURL: "http://old", ExcludedModels: []string{"old-model", "extra"}}, }, @@ -78,6 +79,7 @@ func TestBuildConfigChangeDetails(t *testing.T) { expectContains(t, details, "remote-management.allow-remote: false -> true") expectContains(t, details, "remote-management.disable-auto-update-panel: false -> true") expectContains(t, details, "remote-management.secret-key: updated") + expectContains(t, details, "codex.disable-codex-cloaking: false -> true") expectContains(t, details, "oauth-excluded-models[providera]: updated (1 -> 2 entries)") expectContains(t, details, "oauth-excluded-models[providerb]: added (1 entries)") expectContains(t, details, "openai-compatibility:") -- 2.51.2 From b3046d29b9859c22c797b86cc99c52ac6ca1a2fc Mon Sep 17 00:00:00 2001 From: sususu Date: Thu, 30 Jul 2026 21:53:30 +0800 Subject: [PATCH 18/31] feat(thinking): preserve cross-protocol summary visibility --- .../runtime/executor/aistudio_executor.go | 2 +- .../executor/antigravity_executor_execute.go | 4 +- .../executor/antigravity_executor_stream.go | 2 +- .../executor/antigravity_executor_tokens.go | 2 +- .../executor/claude_executor_execute.go | 3 - .../executor/claude_executor_request.go | 21 - .../executor/claude_executor_stream.go | 3 - .../runtime/executor/claude_executor_test.go | 39 -- .../runtime/executor/codex_openai_images.go | 2 +- .../runtime/executor/gemini_executor_test.go | 4 +- .../executor/helps/model_capabilities.go | 11 +- internal/runtime/executor/helps/thinking.go | 12 + internal/runtime/executor/kimi_executor.go | 4 +- internal/thinking/apply.go | 51 +- .../thinking/apply_configured_api_key_test.go | 33 ++ .../thinking/provider/antigravity/apply.go | 77 ++- internal/thinking/provider/claude/apply.go | 4 + internal/thinking/provider/gemini/apply.go | 77 +-- .../thinking/provider/interactions/apply.go | 67 ++- internal/thinking/strip.go | 4 +- internal/thinking/summary.go | 456 ++++++++++++++++++ internal/thinking/summary_test.go | 215 +++++++++ .../claude/antigravity_claude_request.go | 2 - .../claude/antigravity_claude_request_test.go | 4 +- .../interactions_antigravity_request.go | 19 +- .../antigravity_openai_request.go | 64 ++- .../antigravity_openai_request_test.go | 61 ++- .../codex/claude/codex_claude_request.go | 4 +- .../codex/gemini/codex_gemini_request.go | 4 +- .../interactions_codex_request.go | 24 +- .../chat-completions/codex_openai_request.go | 4 +- .../gemini/claude/gemini_claude_request.go | 2 - .../interactions_gemini_common.go | 19 +- .../chat-completions/gemini_openai_request.go | 2 - .../gemini_openai-responses_request.go | 2 - sdk/translator/registry.go | 5 +- sdk/translator/registry_summary_test.go | 127 +++++ test/summary_intent_translation_test.go | 226 +++++++++ test/thinking_conversion_test.go | 373 ++++++++++++-- 39 files changed, 1678 insertions(+), 357 deletions(-) create mode 100644 internal/runtime/executor/helps/thinking.go create mode 100644 internal/thinking/summary.go create mode 100644 internal/thinking/summary_test.go create mode 100644 sdk/translator/registry_summary_test.go create mode 100644 test/summary_intent_translation_test.go diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index ba8b006a..d2e78eca 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -461,7 +461,7 @@ func (e *AIStudioExecutor) translateRequest(ctx context.Context, req cliproxyexe originalPayload := originalPayloadSource originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, stream) payload := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream) - payload, err := thinking.ApplyThinking(payload, req.Model, from.String(), to.String(), e.Identifier()) + payload, err := helps.ApplyThinkingWithSourcePayload(payload, req.Payload, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return nil, translatedPayload{}, err } diff --git a/internal/runtime/executor/antigravity_executor_execute.go b/internal/runtime/executor/antigravity_executor_execute.go index 471c9dc3..77bce648 100644 --- a/internal/runtime/executor/antigravity_executor_execute.go +++ b/internal/runtime/executor/antigravity_executor_execute.go @@ -68,7 +68,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, false) translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) - translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) + translated, err = helps.ApplyThinkingWithSourcePayload(translated, req.Payload, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return resp, err } @@ -290,7 +290,7 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) - translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) + translated, err = helps.ApplyThinkingWithSourcePayload(translated, req.Payload, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return resp, err } diff --git a/internal/runtime/executor/antigravity_executor_stream.go b/internal/runtime/executor/antigravity_executor_stream.go index b90fc84f..d0aa0725 100644 --- a/internal/runtime/executor/antigravity_executor_stream.go +++ b/internal/runtime/executor/antigravity_executor_stream.go @@ -63,7 +63,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) - translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) + translated, err = helps.ApplyThinkingWithSourcePayload(translated, req.Payload, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return nil, err } diff --git a/internal/runtime/executor/antigravity_executor_tokens.go b/internal/runtime/executor/antigravity_executor_tokens.go index 98d1d561..45867ee8 100644 --- a/internal/runtime/executor/antigravity_executor_tokens.go +++ b/internal/runtime/executor/antigravity_executor_tokens.go @@ -50,7 +50,7 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut // Prepare payload once (doesn't depend on baseURL) payload := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) - payload, err := thinking.ApplyThinking(payload, req.Model, from.String(), to.String(), e.Identifier()) + payload, err := helps.ApplyThinkingWithSourcePayload(payload, req.Payload, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return cliproxyexecutor.Response{}, err } diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go index 8f84ec6e..0a57fad1 100644 --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -67,9 +67,6 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r // Disable thinking if tool_choice forces tool use (Anthropic API constraint) body = disableThinkingIfToolChoiceForced(body) body = normalizeClaudeSamplingForUpstream(body) - // Claude OAuth (and this executor's redact-thinking beta) returns signature-only - // thinking blocks unless display is set to "summarized". - body = ensureClaudeThinkingDisplay(body) // Auto-inject cache_control if missing (optimization for ClawdBot/clients without caching support) if countCacheControls(body) == 0 { diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go index 86874440..fdded306 100644 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -78,27 +78,6 @@ func normalizeClaudeSamplingForUpstream(body []byte) []byte { return body } -// ensureClaudeThinkingDisplay defaults thinking.display to "summarized" when thinking -// is active and the client did not set display. Without this, Claude backends that -// enable redact-thinking return signature-only thinking blocks (empty thinking text). -// Explicit client values such as "omitted" are preserved. -func ensureClaudeThinkingDisplay(body []byte) []byte { - thinkingType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String())) - switch thinkingType { - case "enabled", "adaptive", "auto": - default: - return body - } - if display := strings.TrimSpace(gjson.GetBytes(body, "thinking.display").String()); display != "" { - return body - } - out, err := sjson.SetBytes(body, "thinking.display", "summarized") - if err != nil { - return body - } - return out -} - type compositeReadCloser struct { io.Reader closers []func() error diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go index 9167e056..83dc7cfb 100644 --- a/internal/runtime/executor/claude_executor_stream.go +++ b/internal/runtime/executor/claude_executor_stream.go @@ -67,9 +67,6 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A // Disable thinking if tool_choice forces tool use (Anthropic API constraint) body = disableThinkingIfToolChoiceForced(body) body = normalizeClaudeSamplingForUpstream(body) - // Claude OAuth (and this executor's redact-thinking beta) returns signature-only - // thinking blocks unless display is set to "summarized". - body = ensureClaudeThinkingDisplay(body) // Auto-inject cache_control if missing (optimization for ClawdBot/clients without caching support) if countCacheControls(body) == 0 { diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 3e2946f7..45831b18 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -3110,45 +3110,6 @@ func TestClaudeExecutor_ExecuteOpenAINonStreamRestoresOAuthToolNames(t *testing. } } -func TestEnsureClaudeThinkingDisplay_SetsSummarizedWhenMissing(t *testing.T) { - payload := []byte(`{"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`) - out := ensureClaudeThinkingDisplay(payload) - - if got := gjson.GetBytes(out, "thinking.display").String(); got != "summarized" { - t.Fatalf("thinking.display = %q, want summarized", got) - } - if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" { - t.Fatalf("thinking.type = %q, want adaptive", got) - } -} - -func TestEnsureClaudeThinkingDisplay_PreservesExplicitValue(t *testing.T) { - payload := []byte(`{"thinking":{"type":"enabled","budget_tokens":2048,"display":"omitted"}}`) - out := ensureClaudeThinkingDisplay(payload) - - if got := gjson.GetBytes(out, "thinking.display").String(); got != "omitted" { - t.Fatalf("thinking.display = %q, want omitted", got) - } -} - -func TestEnsureClaudeThinkingDisplay_SkipsWhenThinkingDisabled(t *testing.T) { - payload := []byte(`{"thinking":{"type":"disabled"}}`) - out := ensureClaudeThinkingDisplay(payload) - - if gjson.GetBytes(out, "thinking.display").Exists() { - t.Fatalf("thinking.display should not be set when thinking is disabled: %s", out) - } -} - -func TestEnsureClaudeThinkingDisplay_SkipsWhenThinkingMissing(t *testing.T) { - payload := []byte(`{"messages":[{"role":"user","content":"hi"}]}`) - out := ensureClaudeThinkingDisplay(payload) - - if gjson.GetBytes(out, "thinking").Exists() { - t.Fatalf("thinking should remain absent: %s", out) - } -} - func TestPrependToFirstUserMessage_KeepsToolResultBlocksFirst(t *testing.T) { // A conversation that opens on an assistant tool_use makes the first user // message a tool_result carrier. Anthropic requires those blocks to stay at diff --git a/internal/runtime/executor/codex_openai_images.go b/internal/runtime/executor/codex_openai_images.go index 6a514a6a..3251489e 100644 --- a/internal/runtime/executor/codex_openai_images.go +++ b/internal/runtime/executor/codex_openai_images.go @@ -674,7 +674,7 @@ func (e *CodexExecutor) prepareCodexOpenAIImageBody(body []byte, req cliproxyexe mainModel = codexOpenAIImagesMainModel } var errThinking error - out, errThinking = thinking.ApplyThinking(out, mainModel, codexOpenAIImageSourceFormat, "codex", e.Identifier()) + out, errThinking = helps.ApplyThinkingWithSourcePayload(out, body, mainModel, codexOpenAIImageSourceFormat, "codex", e.Identifier()) if errThinking != nil { return nil, errThinking } diff --git a/internal/runtime/executor/gemini_executor_test.go b/internal/runtime/executor/gemini_executor_test.go index 6a22e4e7..4b2a720f 100644 --- a/internal/runtime/executor/gemini_executor_test.go +++ b/internal/runtime/executor/gemini_executor_test.go @@ -671,8 +671,8 @@ func TestGeminiExecutorNativeInteractionsAppliesThinkingSuffix(t *testing.T) { if got := gjson.GetBytes(upstreamBody, "generation_config.thinking_level").String(); got != "high" { t.Fatalf("thinking_level = %q, want high. Body: %s", got, string(upstreamBody)) } - if got := gjson.GetBytes(upstreamBody, "generation_config.thinking_summaries").String(); got != "auto" { - t.Fatalf("thinking_summaries = %q, want auto. Body: %s", got, string(upstreamBody)) + if gjson.GetBytes(upstreamBody, "generation_config.thinking_summaries").Exists() { + t.Fatalf("thinking_summaries should be absent without explicit summary intent. Body: %s", string(upstreamBody)) } } diff --git a/internal/runtime/executor/helps/model_capabilities.go b/internal/runtime/executor/helps/model_capabilities.go index 8021561c..8bf6723d 100644 --- a/internal/runtime/executor/helps/model_capabilities.go +++ b/internal/runtime/executor/helps/model_capabilities.go @@ -9,12 +9,13 @@ import ( // ApplyRequestThinking preserves the registry lookup path unless the auth // manager bound an exact configured API-key model definition to this attempt. func ApplyRequestThinking(body []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, fromFormat, toFormat, provider string) ([]byte, error) { + sourceBody := opts.OriginalRequest + if len(sourceBody) == 0 { + sourceBody = req.Payload + } if modelInfo, ok := cliproxyauth.ResolvedAPIKeyModelInfo(req); ok { - sourceBody := opts.OriginalRequest - if len(sourceBody) == 0 { - sourceBody = req.Payload - } return thinking.ApplyThinkingWithModelInfo(body, sourceBody, req.Model, fromFormat, toFormat, provider, modelInfo) } - return thinking.ApplyThinking(body, req.Model, fromFormat, toFormat, provider) + summaryConfig := thinking.ExtractSummaryConfig(sourceBody, fromFormat) + return thinking.ApplyThinkingWithSummary(body, req.Model, fromFormat, toFormat, provider, summaryConfig) } diff --git a/internal/runtime/executor/helps/thinking.go b/internal/runtime/executor/helps/thinking.go new file mode 100644 index 00000000..49f3155c --- /dev/null +++ b/internal/runtime/executor/helps/thinking.go @@ -0,0 +1,12 @@ +package helps + +import "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + +// ApplyThinkingWithSourcePayload preserves summary visibility from the original +// client payload while applying thinking configuration to its translated target +// payload. A target representation alone can lose an explicit disabled summary +// before a model suffix changes Claude thinking from disabled to adaptive. +func ApplyThinkingWithSourcePayload(body, sourcePayload []byte, model, fromFormat, toFormat, providerKey string) ([]byte, error) { + summary := thinking.ExtractSummaryConfig(sourcePayload, fromFormat) + return thinking.ApplyThinkingWithSummary(body, model, fromFormat, toFormat, providerKey, summary) +} diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go index ec270705..d3c88145 100644 --- a/internal/runtime/executor/kimi_executor.go +++ b/internal/runtime/executor/kimi_executor.go @@ -113,7 +113,7 @@ func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req return resp, fmt.Errorf("kimi executor: failed to set model in payload: %w", err) } - body, err = thinking.ApplyThinking(body, req.Model, from.String(), "kimi", e.Identifier()) + body, err = helps.ApplyThinkingWithSourcePayload(body, req.Payload, req.Model, from.String(), "kimi", e.Identifier()) if err != nil { return resp, err } @@ -222,7 +222,7 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut return nil, fmt.Errorf("kimi executor: failed to set model in payload: %w", err) } - body, err = thinking.ApplyThinking(body, req.Model, from.String(), "kimi", e.Identifier()) + body, err = helps.ApplyThinkingWithSourcePayload(body, req.Payload, req.Model, from.String(), "kimi", e.Identifier()) if err != nil { return nil, err } diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go index 1d25de7e..c19369c7 100644 --- a/internal/thinking/apply.go +++ b/internal/thinking/apply.go @@ -162,16 +162,31 @@ func IsUserDefinedModel(modelInfo *registry.ModelInfo) bool { // // Without suffix - uses body config // result, err := thinking.ApplyThinking(body, "gemini-2.5-pro", "gemini", "gemini", "gemini") func ApplyThinking(body []byte, model string, fromFormat string, toFormat string, providerKey string) ([]byte, error) { - return applyThinking(body, nil, model, fromFormat, toFormat, providerKey, nil, false) + summaryConfig := ExtractSummaryConfig(body, toFormat) + return applyThinking(body, nil, model, fromFormat, toFormat, providerKey, nil, false, summaryConfig) +} + +// ApplyThinkingWithSummary applies canonical thinking effort while preserving +// summary visibility extracted from the original source request. Callers that +// translate before applying thinking must pass the source config explicitly: +// a target Claude body can temporarily lack display while disabled thinking is +// being rewritten by a model suffix. +func ApplyThinkingWithSummary(body []byte, model string, fromFormat string, toFormat string, providerKey string, summaryConfig SummaryConfig) ([]byte, error) { + return applyThinking(body, nil, model, fromFormat, toFormat, providerKey, nil, false, summaryConfig) } // ApplyThinkingWithModelInfo applies thinking with the exact configured model -// definition selected for an API-key execution attempt. +// definition selected for an API-key execution attempt while preserving summary +// visibility from the original source body. func ApplyThinkingWithModelInfo(body, sourceBody []byte, model string, fromFormat string, toFormat string, providerKey string, modelInfo *registry.ModelInfo) ([]byte, error) { - return applyThinking(body, sourceBody, model, fromFormat, toFormat, providerKey, modelInfo, true) + summaryConfig := ExtractSummaryConfig(sourceBody, fromFormat) + if len(sourceBody) == 0 { + summaryConfig = ExtractSummaryConfig(body, toFormat) + } + return applyThinking(body, sourceBody, model, fromFormat, toFormat, providerKey, modelInfo, true, summaryConfig) } -func applyThinking(body, sourceBody []byte, model string, fromFormat string, toFormat string, providerKey string, resolvedModelInfo *registry.ModelInfo, modelInfoResolved bool) ([]byte, error) { +func applyThinking(body, sourceBody []byte, model string, fromFormat string, toFormat string, providerKey string, resolvedModelInfo *registry.ModelInfo, modelInfoResolved bool, summaryConfig SummaryConfig) ([]byte, error) { providerFormat := strings.ToLower(strings.TrimSpace(toFormat)) if modelInfoResolved && providerFormat == "openai-response" { providerFormat = "codex" @@ -184,6 +199,9 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF if fromFormat == "" { fromFormat = providerFormat } + // Summary visibility is orthogonal to thinking effort. Keep the original + // source intent before a suffix-specific applier rewrites provider fields, + // then restore it after the canonical effort has been applied. // 1. Route check: Get provider applier applier := GetProviderApplier(providerFormat) if applier == nil { @@ -207,11 +225,11 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF // Unknown models are treated as user-defined so thinking config can still be applied. // The upstream service is responsible for validating the configuration. if IsUserDefinedModel(modelInfo) { - return applyUserDefinedModel(body, modelInfo, fromFormat, providerFormat, suffixResult) + return applyUserDefinedModel(body, modelInfo, fromFormat, providerFormat, suffixResult, summaryConfig) } if modelInfo.Thinking == nil { config := extractThinkingConfig(body, providerFormat) - if hasThinkingConfig(config) { + if hasThinkingConfig(config) || summaryConfig.Mode != SummaryUnspecified { log.WithFields(log.Fields{ "model": baseModel, "provider": providerFormat, @@ -259,7 +277,7 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF "provider": providerFormat, "model": modelInfo.ID, }).Debug("thinking: no config found, passthrough |") - return body, nil + return applySummaryConfigForModel(body, providerFormat, baseModel, modelInfo, summaryConfig), nil } if modelInfoResolved && config.Mode == ModeLevel && modelInfo != nil && modelInfo.Thinking != nil && shouldMapConfiguredHighIntent(fromFormat, providerFormat, modelInfo) { config.Level = mapConfiguredHighIntent(config.Level, modelInfo) @@ -296,8 +314,13 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF "level": validated.Level, }).Debug("thinking: processed config to apply |") - // 6. Apply configuration using provider-specific applier - return applier.Apply(body, *validated, modelInfo) + // 6. Apply configuration using provider-specific applier, then restore the + // target summary intent that was explicit before suffix processing. + applied, err := applier.Apply(body, *validated, modelInfo) + if err != nil { + return applied, err + } + return applySummaryConfigForModel(applied, providerFormat, baseModel, modelInfo, summaryConfig), nil } func shouldMapConfiguredHighIntent(fromFormat, toFormat string, modelInfo *registry.ModelInfo) bool { @@ -386,7 +409,7 @@ func parseSuffixToConfig(rawSuffix, provider, model string) ThinkingConfig { // applyUserDefinedModel applies thinking configuration for user-defined models // without ThinkingSupport validation. -func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromFormat, toFormat string, suffixResult SuffixResult) ([]byte, error) { +func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromFormat, toFormat string, suffixResult SuffixResult, summaryConfig SummaryConfig) ([]byte, error) { // Get model ID for logging modelID := "" if modelInfo != nil { @@ -427,7 +450,7 @@ func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromForma "model": modelID, "provider": toFormat, }).Debug("thinking: user-defined model, passthrough (no config) |") - return body, nil + return applySummaryConfigForModel(body, toFormat, modelID, modelInfo, summaryConfig), nil } applier := GetProviderApplier(toFormat) @@ -447,7 +470,11 @@ func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromForma "budget": config.Budget, "level": config.Level, }).Debug("thinking: processed config to apply |") - return applier.Apply(body, config, modelInfo) + applied, err := applier.Apply(body, config, modelInfo) + if err != nil { + return applied, err + } + return applySummaryConfigForModel(applied, toFormat, modelID, modelInfo, summaryConfig), nil } func normalizeUserDefinedConfig(config ThinkingConfig, fromFormat, toFormat string) ThinkingConfig { diff --git a/internal/thinking/apply_configured_api_key_test.go b/internal/thinking/apply_configured_api_key_test.go index 81e908fb..5aa3ce9d 100644 --- a/internal/thinking/apply_configured_api_key_test.go +++ b/internal/thinking/apply_configured_api_key_test.go @@ -92,6 +92,39 @@ func TestApplyThinkingWithModelInfoKeepsSameFamilyValidationStrict(t *testing.T) } } +func TestApplyThinkingWithModelInfoAppliesSummaryOnlyClaudeVisibility(t *testing.T) { + modelInfo := ®istry.ModelInfo{ + ID: "private-claude", + Type: "claude", + Thinking: ®istry.ThinkingSupport{Levels: []string{"high"}}, + } + for _, test := range []struct { + name string + source string + display string + }{ + {name: "enabled", source: `{"reasoning":{"summary":"auto"}}`, display: "summarized"}, + {name: "disabled", source: `{"reasoning":{"summary":null}}`, display: "omitted"}, + } { + t.Run(test.name, func(t *testing.T) { + out, err := thinking.ApplyThinkingWithModelInfo( + []byte(`{"model":"private-claude","max_tokens":32000}`), + []byte(test.source), + "private-claude", "openai-response", "claude", "claude", modelInfo, + ) + if err != nil { + t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err) + } + if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" { + t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out) + } + if got := gjson.GetBytes(out, "thinking.display").String(); got != test.display { + t.Fatalf("thinking.display = %q, want %q; body=%s", got, test.display, out) + } + }) + } +} + func TestApplyThinkingWithModelInfoUsesOriginalResponsesEffort(t *testing.T) { modelInfo := ®istry.ModelInfo{ ID: "claude-upstream", diff --git a/internal/thinking/provider/antigravity/apply.go b/internal/thinking/provider/antigravity/apply.go index cb0659f1..968ee09d 100644 --- a/internal/thinking/provider/antigravity/apply.go +++ b/internal/thinking/provider/antigravity/apply.go @@ -98,19 +98,19 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingBudget") result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_budget") result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_level") - // Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing. + // Normalize includeThoughts field name and retain only documented booleans. + result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.includeThoughts") result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts") if config.Mode == thinking.ModeNone { if config.Budget == 0 && config.Level == "" { result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig") - return result, nil + return applyAntigravityIncludeThoughts(result, body), nil } - result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", false) if config.Level != "" { result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel", string(config.Level)) } - return result, nil + return applyAntigravityIncludeThoughts(result, body), nil } // Only handle ModeLevel - budget conversion should be done by upper layer @@ -120,17 +120,7 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) level := string(config.Level) result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel", level) - - // Respect user's explicit includeThoughts setting from original body; default to true if not set - // Support both camelCase and snake_case variants - includeThoughts := true - if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts"); inc.Exists() { - includeThoughts = inc.Bool() - } else if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.include_thoughts"); inc.Exists() { - includeThoughts = inc.Bool() - } - result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts) - return result, nil + return applyAntigravityIncludeThoughts(result, body), nil } func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo, isClaude bool) ([]byte, error) { @@ -138,7 +128,8 @@ func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig, result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingLevel") result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_level") result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_budget") - // Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing. + // Normalize includeThoughts field name and retain only documented booleans. + result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.includeThoughts") result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts") budget := config.Budget @@ -146,46 +137,32 @@ func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig, // Apply Claude-specific constraints first to get the final budget value if isClaude && modelInfo != nil { budget, result = a.normalizeClaudeBudget(budget, result, modelInfo) - // Check if budget was removed entirely + // Check if the thinking amount was removed entirely. Summary visibility is + // independent, so retain an explicit includeThoughts control if present. if budget == -2 { - return result, nil + return applyAntigravityIncludeThoughts(result, body), nil } } - // For ModeNone, always set includeThoughts to false regardless of user setting. - // This ensures that when user requests budget=0 (disable thinking output), - // the includeThoughts is correctly set to false even if budget is clamped to min. - if config.Mode == thinking.ModeNone { - result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget) - result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", false) - return result, nil - } - - // Determine includeThoughts: respect user's explicit setting from original body if provided - // Support both camelCase and snake_case variants - var includeThoughts bool - var userSetIncludeThoughts bool - if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts"); inc.Exists() { - includeThoughts = inc.Bool() - userSetIncludeThoughts = true - } else if inc := gjson.GetBytes(body, "request.generationConfig.thinkingConfig.include_thoughts"); inc.Exists() { - includeThoughts = inc.Bool() - userSetIncludeThoughts = true - } - - if !userSetIncludeThoughts { - // No explicit setting, use default logic based on mode - switch config.Mode { - case thinking.ModeAuto: - includeThoughts = true - default: - includeThoughts = budget > 0 + result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget) + return applyAntigravityIncludeThoughts(result, body), nil +} + +func applyAntigravityIncludeThoughts(result, original []byte) []byte { + for _, path := range []string{ + "request.generationConfig.thinkingConfig.includeThoughts", + "request.generationConfig.thinkingConfig.include_thoughts", + } { + switch value := gjson.GetBytes(original, path); value.Type { + case gjson.True: + result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", true) + return result + case gjson.False: + result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", false) + return result } } - - result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget) - result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts) - return result, nil + return result } // normalizeClaudeBudget applies Claude-specific constraints to thinking budget. diff --git a/internal/thinking/provider/claude/apply.go b/internal/thinking/provider/claude/apply.go index 140a8135..97f02849 100644 --- a/internal/thinking/provider/claude/apply.go +++ b/internal/thinking/provider/claude/apply.go @@ -87,6 +87,8 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo * case thinking.ModeNone: result, _ := sjson.SetBytes(body, "thinking.type", "disabled") result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens") + // Summary display only applies to an active thinking block. + result, _ = sjson.DeleteBytes(result, "thinking.display") result, _ = sjson.DeleteBytes(result, "output_config.effort") if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 { result, _ = sjson.DeleteBytes(result, "output_config") @@ -231,6 +233,8 @@ func applyCompatibleClaude(body []byte, config thinking.ThinkingConfig) ([]byte, case thinking.ModeNone: result, _ := sjson.SetBytes(body, "thinking.type", "disabled") result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens") + // Summary display only applies to an active thinking block. + result, _ = sjson.DeleteBytes(result, "thinking.display") result, _ = sjson.DeleteBytes(result, "output_config.effort") if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 { result, _ = sjson.DeleteBytes(result, "output_config") diff --git a/internal/thinking/provider/gemini/apply.go b/internal/thinking/provider/gemini/apply.go index 92a8d7ec..c332e9ef 100644 --- a/internal/thinking/provider/gemini/apply.go +++ b/internal/thinking/provider/gemini/apply.go @@ -114,27 +114,27 @@ func (a *Applier) applyCompatible(body []byte, config thinking.ThinkingConfig) ( func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) { // ModeNone semantics: - // - ModeNone + Budget=0: remove thinkingConfig to disable thinking - // - ModeNone + Budget>0: forced to think but hide output (includeThoughts=false) - // ValidateConfig sets config.Level to the lowest level when ModeNone + Budget > 0. + // - ModeNone + Budget=0: remove the thinking amount configuration. + // - ModeNone + Budget>0: clamp to the model's lowest supported amount. + // Summary visibility remains independent and is restored only when explicitly set. // Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output result, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig.thinkingBudget") result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_budget") result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_level") - // Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing. + // Normalize includeThoughts field name and retain only documented booleans. + result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.includeThoughts") result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.include_thoughts") if config.Mode == thinking.ModeNone { if config.Budget == 0 && config.Level == "" { result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig") - return result, nil + return applyGeminiIncludeThoughts(result, body), nil } - result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", false) if config.Level != "" { result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingLevel", string(config.Level)) } - return result, nil + return applyGeminiIncludeThoughts(result, body), nil } // Only handle ModeLevel - budget conversion should be done by upper layer @@ -144,17 +144,7 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) level := string(config.Level) result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingLevel", level) - - // Respect user's explicit includeThoughts setting from original body; default to true if not set - // Support both camelCase and snake_case variants - includeThoughts := true - if inc := gjson.GetBytes(body, "generationConfig.thinkingConfig.includeThoughts"); inc.Exists() { - includeThoughts = inc.Bool() - } else if inc := gjson.GetBytes(body, "generationConfig.thinkingConfig.include_thoughts"); inc.Exists() { - includeThoughts = inc.Bool() - } - result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", includeThoughts) - return result, nil + return applyGeminiIncludeThoughts(result, body), nil } func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) { @@ -162,43 +152,28 @@ func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) result, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig.thinkingLevel") result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_level") result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_budget") - // Normalize includeThoughts field name to avoid oneof conflicts in upstream JSON parsing. + // Normalize includeThoughts field name and retain only documented booleans. + result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.includeThoughts") result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.include_thoughts") budget := config.Budget + result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingBudget", budget) + return applyGeminiIncludeThoughts(result, body), nil +} - // For ModeNone, always set includeThoughts to false regardless of user setting. - // This ensures that when user requests budget=0 (disable thinking output), - // the includeThoughts is correctly set to false even if budget is clamped to min. - if config.Mode == thinking.ModeNone { - result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingBudget", budget) - result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", false) - return result, nil - } - - // Determine includeThoughts: respect user's explicit setting from original body if provided - // Support both camelCase and snake_case variants - var includeThoughts bool - var userSetIncludeThoughts bool - if inc := gjson.GetBytes(body, "generationConfig.thinkingConfig.includeThoughts"); inc.Exists() { - includeThoughts = inc.Bool() - userSetIncludeThoughts = true - } else if inc := gjson.GetBytes(body, "generationConfig.thinkingConfig.include_thoughts"); inc.Exists() { - includeThoughts = inc.Bool() - userSetIncludeThoughts = true - } - - if !userSetIncludeThoughts { - // No explicit setting, use default logic based on mode - switch config.Mode { - case thinking.ModeAuto: - includeThoughts = true - default: - includeThoughts = budget > 0 +func applyGeminiIncludeThoughts(result, original []byte) []byte { + for _, path := range []string{ + "generationConfig.thinkingConfig.includeThoughts", + "generationConfig.thinkingConfig.include_thoughts", + } { + switch value := gjson.GetBytes(original, path); value.Type { + case gjson.True: + result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", true) + return result + case gjson.False: + result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", false) + return result } } - - result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingBudget", budget) - result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", includeThoughts) - return result, nil + return result } diff --git a/internal/thinking/provider/interactions/apply.go b/internal/thinking/provider/interactions/apply.go index 2951b511..c644f5ad 100644 --- a/internal/thinking/provider/interactions/apply.go +++ b/internal/thinking/provider/interactions/apply.go @@ -34,11 +34,11 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo * result := stripInteractionsThinkingFields(body) switch config.Mode { case thinking.ModeLevel: - return applyInteractionsLevel(result, body, string(config.Level), modelInfo, "auto"), nil + return applyInteractionsLevel(result, body, string(config.Level), modelInfo), nil case thinking.ModeBudget: - return applyInteractionsBudget(result, body, config.Budget, modelInfo, "auto"), nil + return applyInteractionsBudget(result, body, config.Budget, modelInfo), nil case thinking.ModeAuto: - return setInteractionsThinkingSummaries(result, body, "auto"), nil + return setInteractionsThinkingSummaries(result, body), nil case thinking.ModeNone: return applyInteractionsNone(result, body, config, modelInfo), nil default: @@ -46,38 +46,38 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo * } } -func applyInteractionsBudget(result, original []byte, budget int, modelInfo *registry.ModelInfo, summariesFallback string) []byte { +func applyInteractionsBudget(result, original []byte, budget int, modelInfo *registry.ModelInfo) []byte { level, ok := thinking.ConvertBudgetToLevel(budget) if !ok { - return result + return setInteractionsThinkingSummaries(result, original) } switch level { - case string(thinking.LevelNone): - return setInteractionsThinkingSummaries(result, original, "none") - case string(thinking.LevelAuto): - return setInteractionsThinkingSummaries(result, original, "auto") + case string(thinking.LevelNone), string(thinking.LevelAuto): + // Thinking amount and summary visibility are independent. Interactions has + // no wire-level "none" thinking level, so preserve only explicit summary + // intent and otherwise let the target model use its documented default. + return setInteractionsThinkingSummaries(result, original) default: - return applyInteractionsLevel(result, original, level, modelInfo, summariesFallback) + return applyInteractionsLevel(result, original, level, modelInfo) } } -func applyInteractionsLevel(result, original []byte, level string, modelInfo *registry.ModelInfo, summariesFallback string) []byte { +func applyInteractionsLevel(result, original []byte, level string, modelInfo *registry.ModelInfo) []byte { level = normalizeInteractionsLevel(level, modelInfo) - if level == "" { - return result + if level != "" { + result, _ = sjson.SetBytes(result, "generation_config.thinking_level", level) } - result, _ = sjson.SetBytes(result, "generation_config.thinking_level", level) - return setInteractionsThinkingSummaries(result, original, summariesFallback) + return setInteractionsThinkingSummaries(result, original) } func applyInteractionsNone(result, original []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) []byte { if config.Level != "" { - result = applyInteractionsLevel(result, original, string(config.Level), modelInfo, "none") - } else if config.Budget > 0 { - result = applyInteractionsBudget(result, original, config.Budget, modelInfo, "none") + return applyInteractionsLevel(result, original, string(config.Level), modelInfo) } - result, _ = sjson.SetBytes(result, "generation_config.thinking_summaries", "none") - return result + if config.Budget > 0 { + return applyInteractionsBudget(result, original, config.Budget, modelInfo) + } + return setInteractionsThinkingSummaries(result, original) } func stripInteractionsThinkingFields(body []byte) []byte { @@ -104,7 +104,7 @@ func stripInteractionsThinkingFields(body []byte) []byte { return result } -func setInteractionsThinkingSummaries(result, original []byte, fallback string) []byte { +func setInteractionsThinkingSummaries(result, original []byte) []byte { if value, okValue := originalInteractionsThinkingSummaries(original); okValue { result, _ = sjson.SetBytes(result, "generation_config.thinking_summaries", value) return result @@ -112,16 +112,9 @@ func setInteractionsThinkingSummaries(result, original []byte, fallback string) if includeThoughts, okValue := originalInteractionsIncludeThoughts(original); okValue { value := "none" if includeThoughts { - value = fallback - if value == "" { - value = "auto" - } + value = "auto" } result, _ = sjson.SetBytes(result, "generation_config.thinking_summaries", value) - return result - } - if fallback != "" { - result, _ = sjson.SetBytes(result, "generation_config.thinking_summaries", fallback) } return result } @@ -132,8 +125,12 @@ func originalInteractionsThinkingSummaries(body []byte) (string, bool) { "generation_config.thinkingSummaries", } { value := gjson.GetBytes(body, path) - if value.Exists() && value.Type == gjson.String { - return strings.ToLower(strings.TrimSpace(value.String())), true + if value.Type != gjson.String { + continue + } + switch normalized := strings.ToLower(strings.TrimSpace(value.String())); normalized { + case "auto", "none": + return normalized, true } } return "", false @@ -146,9 +143,11 @@ func originalInteractionsIncludeThoughts(body []byte) (bool, bool) { "generation_config.thinkingConfig.include_thoughts", "generation_config.thinkingConfig.includeThoughts", } { - value := gjson.GetBytes(body, path) - if value.Exists() { - return value.Bool(), true + switch value := gjson.GetBytes(body, path); value.Type { + case gjson.True: + return true, true + case gjson.False: + return false, true } } return false, false diff --git a/internal/thinking/strip.go b/internal/thinking/strip.go index f514a7bd..f60b7ff2 100644 --- a/internal/thinking/strip.go +++ b/internal/thinking/strip.go @@ -47,14 +47,14 @@ func StripThinkingConfig(body []byte, provider string) []byte { "generation_config.thinkingConfig", } case "openai": - paths = []string{"reasoning_effort"} + paths = []string{"reasoning_effort", "reasoning"} case "kimi": paths = []string{ "reasoning_effort", "thinking", } case "codex", "xai": - paths = []string{"reasoning.effort"} + paths = []string{"reasoning"} default: return body } diff --git a/internal/thinking/summary.go b/internal/thinking/summary.go new file mode 100644 index 00000000..4a19dae1 --- /dev/null +++ b/internal/thinking/summary.go @@ -0,0 +1,456 @@ +package thinking + +import ( + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// SummaryMode represents whether the client explicitly requested reasoning summaries. +type SummaryMode int + +const ( + SummaryUnspecified SummaryMode = iota + SummaryDisabled + SummaryEnabled +) + +// SummaryConfig is the provider-neutral reasoning-summary visibility intent. +// Detail preserves protocols that distinguish auto, concise, and detailed summaries. +type SummaryConfig struct { + Mode SummaryMode + Detail string +} + +// ExtractSummaryConfig reads protocol-specific summary visibility intent. +// +// OpenAI Chat is the one protocol where effort implies summaries: chat +// completions has no summary field of its own, and clients that send +// reasoning_effort have always received reasoning summaries here, so treating a +// non-none effort as an explicit request preserves that contract. Every other +// protocol carries a dedicated summary field, so effort alone means nothing. +func ExtractSummaryConfig(body []byte, format string) SummaryConfig { + normalized := strings.ToLower(strings.TrimSpace(format)) + // Check the format first so unsupported targets skip whole-body validation. + if !summaryFormatSupported(normalized) || len(body) == 0 || !gjson.ValidBytes(body) { + return SummaryConfig{} + } + + switch normalized { + case "openai": + if config, ok := extractOpenAIExplicitSummaryConfig(body); ok { + return config + } + if effort := gjson.GetBytes(body, "reasoning_effort"); effort.Type == gjson.String { + value := strings.ToLower(strings.TrimSpace(effort.String())) + if value == "" { + return SummaryConfig{} + } + if value == "none" { + return SummaryConfig{Mode: SummaryDisabled} + } + return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"} + } + case "openai-response", "codex": + if config, ok := responsesSummaryConfig(body, "reasoning.summary"); ok { + return config + } + if config, ok := responsesSummaryConfig(body, "reasoning.generate_summary"); ok { + return config + } + case "claude": + // Anthropic only accepts display alongside active adaptive/manual thinking. + if !claudeThinkingAcceptsDisplay(body) { + return SummaryConfig{} + } + if config, ok := claudeSummaryConfig(body, "thinking.display"); ok { + return config + } + case "gemini": + if config, ok := firstSummaryBoolConfig(body, []string{ + "generationConfig.thinkingConfig.includeThoughts", + "generationConfig.thinkingConfig.include_thoughts", + "generation_config.thinking_config.include_thoughts", + "generation_config.thinking_config.includeThoughts", + }); ok { + return config + } + case "antigravity": + if config, ok := firstSummaryBoolConfig(body, []string{ + "request.generationConfig.thinkingConfig.includeThoughts", + "request.generationConfig.thinkingConfig.include_thoughts", + "request.generationConfig.thinking_config.includeThoughts", + "request.generationConfig.thinking_config.include_thoughts", + }); ok { + return config + } + case "interactions": + for _, path := range []string{ + "generation_config.thinking_summaries", + "generation_config.thinkingSummaries", + } { + if config, ok := interactionsSummaryConfig(body, path); ok { + return config + } + } + } + + return SummaryConfig{} +} + +// ApplySummaryConfig writes canonical summary intent in the target protocol. +func ApplySummaryConfig(body []byte, format string, config SummaryConfig) []byte { + return ApplySummaryConfigForModel(body, format, "", config) +} + +// ApplySummaryConfigForModel writes canonical summary intent in the target +// protocol and uses target model capabilities when a valid target request must +// activate thinking before it can request summaries. +func ApplySummaryConfigForModel(body []byte, format, model string, config SummaryConfig) []byte { + return applySummaryConfigForModel(body, format, model, nil, config) +} + +// applySummaryConfigForModel uses the resolved model definition when execution +// selected a configured API-key model whose capability is not globally visible. +func applySummaryConfigForModel(body []byte, format, model string, modelInfo *registry.ModelInfo, config SummaryConfig) []byte { + normalized := strings.ToLower(strings.TrimSpace(format)) + if config.Mode == SummaryUnspecified || !summaryFormatSupported(normalized) || len(body) == 0 || !gjson.ValidBytes(body) { + return body + } + + enabled := config.Mode == SummaryEnabled + switch normalized { + case "openai": + body = applyOpenAIChatSummaryConfig(body, model, enabled) + case "claude": + // Anthropic documents display as invalid with thinking.type=disabled and + // requires it alongside adaptive or enabled thinking. An explicit source + // visibility request is independent of thinking effort, so activate the + // target model's documented thinking mode before writing either + // summarized or omitted. Unspecified intent returns above and leaves the + // target's default untouched. + if !gjson.GetBytes(body, "thinking.type").Exists() { + body = enableClaudeThinkingForSummary(body, model, modelInfo) + } + if !claudeThinkingAcceptsDisplay(body) { + return body + } + value := "omitted" + if enabled { + value = "summarized" + } + body, _ = sjson.SetBytes(body, "thinking.display", value) + case "gemini": + body, _ = sjson.SetBytes(body, "generationConfig.thinkingConfig.includeThoughts", enabled) + for _, path := range []string{ + "generationConfig.thinkingConfig.include_thoughts", + "generation_config.thinking_config.include_thoughts", + "generation_config.thinking_config.includeThoughts", + } { + body, _ = sjson.DeleteBytes(body, path) + } + case "antigravity": + body, _ = sjson.SetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts", enabled) + for _, path := range []string{ + "request.generationConfig.thinkingConfig.include_thoughts", + "request.generationConfig.thinking_config.include_thoughts", + "request.generationConfig.thinking_config.includeThoughts", + } { + body, _ = sjson.DeleteBytes(body, path) + } + case "interactions": + // Google Interactions only accepts auto or none. OpenAI's concise and + // detailed selectors therefore collapse to the supported enabled value. + value := "none" + if enabled { + value = "auto" + } + body, _ = sjson.SetBytes(body, "generation_config.thinking_summaries", value) + body, _ = sjson.DeleteBytes(body, "generation_config.thinkingSummaries") + case "openai-response", "codex": + if enabled { + body, _ = sjson.SetBytes(body, "reasoning.summary", normalizedSummaryDetail(config.Detail)) + body, _ = sjson.DeleteBytes(body, "reasoning.generate_summary") + break + } + // Omitting the field is the documented way to disable summaries; an + // explicit null is not accepted by every Responses-compatible backend. + body, _ = sjson.DeleteBytes(body, "reasoning.summary") + body, _ = sjson.DeleteBytes(body, "reasoning.generate_summary") + if reasoning := gjson.GetBytes(body, "reasoning"); reasoning.IsObject() && len(reasoning.Map()) == 0 { + body, _ = sjson.DeleteBytes(body, "reasoning") + } + } + return body +} + +// summaryFormatSupported reports whether a protocol carries summary visibility +// intent that this package can read or write. +func summaryFormatSupported(format string) bool { + switch format { + case "openai", "openai-response", "codex", "claude", "gemini", "antigravity", "interactions": + return true + default: + return false + } +} + +// claudeThinkingAcceptsDisplay reports whether the body carries an active +// thinking block that can hold a display field. +func claudeThinkingAcceptsDisplay(body []byte) bool { + switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String())) { + case "adaptive": + return true + case "enabled": + // This runs before ApplyThinking normalizes the request, so a missing + // budget_tokens is an unfinished body rather than inactive thinking. + // Only an explicit non-positive budget means thinking is off. + budget := gjson.GetBytes(body, "thinking.budget_tokens") + return budget.Type != gjson.Number || budget.Int() > 0 + default: + return false + } +} + +// applyOpenAIChatSummaryConfig writes summary visibility intent for the Chat +// Completions protocol. +// +// Four dialects share this protocol and only OpenAI's is authoritative. OpenAI +// documents no reasoning-visibility field at all (Chat Completions never returns +// reasoning text) and rejects unknown body parameters, so reasoning_effort is the +// only field that is always safe to write here. OpenRouter's documented +// "reason but hide" bits (reasoning.exclude and its legacy include_reasoning +// alias) are updated only when the body already carries them, which is exactly +// when the upstream is known to understand them. +func applyOpenAIChatSummaryConfig(body []byte, model string, enabled bool) []byte { + if gjson.GetBytes(body, "reasoning").IsObject() { + body, _ = sjson.SetBytes(body, "reasoning.exclude", !enabled) + } + if gjson.GetBytes(body, "include_reasoning").IsBool() { + body, _ = sjson.SetBytes(body, "include_reasoning", enabled) + } + if !enabled { + // Chat has no portable way to keep reasoning while hiding its summary. + // reasoning_effort:"none" would disable reasoning instead of hiding it, + // and Google documents that it is not even honored on Gemini 2.5 Pro or + // 3 models, so leave the effort the client asked for untouched. + return body + } + effort := gjson.GetBytes(body, "reasoning_effort") + if effort.Type != gjson.String || strings.TrimSpace(effort.String()) == "" || strings.EqualFold(strings.TrimSpace(effort.String()), "none") { + body, _ = sjson.SetBytes(body, "reasoning_effort", openAIChatSummaryEffort(body, model)) + } + return body +} + +// openAIChatSummaryEffort picks an active reasoning effort that the target model +// documents. Chat exposes reasoning only while an effort is active, so a summary +// request has to select one when the client left it unset. +func openAIChatSummaryEffort(body []byte, model string) string { + baseModel := ParseSuffix(model).ModelName + if baseModel == "" { + baseModel = ParseSuffix(gjson.GetBytes(body, "model").String()).ModelName + } + modelInfo := registry.LookupModelInfo(baseModel, "openai") + if modelInfo == nil || modelInfo.Thinking == nil || len(modelInfo.Thinking.Levels) == 0 { + return "medium" + } + + levels := make([]string, 0, len(modelInfo.Thinking.Levels)) + for _, level := range modelInfo.Thinking.Levels { + normalized := strings.ToLower(strings.TrimSpace(level)) + if normalized == "" || normalized == "none" { + continue + } + if normalized == "medium" { + return "medium" + } + levels = append(levels, normalized) + } + if len(levels) == 0 { + return "medium" + } + return levels[len(levels)/2] +} + +func extractOpenAIExplicitSummaryConfig(body []byte) (SummaryConfig, bool) { + // Google's documented Chat Completions extension is the authoritative + // explicit visibility control when present, ahead of CPA compatibility + // aliases and Chat's reasoning_effort fallback. + for _, path := range []string{ + "extra_body.google.thinking_config.include_thoughts", + "extra_body.google.thinking_config.includeThoughts", + "extra_body.google.thinkingConfig.include_thoughts", + "extra_body.google.thinkingConfig.includeThoughts", + "extra_body.extra_body.google.thinking_config.include_thoughts", + "extra_body.extra_body.google.thinking_config.includeThoughts", + "google.thinking_config.include_thoughts", + "google.thinking_config.includeThoughts", + "thinking.includeThoughts", + "thinking.include_thoughts", + "reasoning.includeThoughts", + "reasoning.include_thoughts", + "generationConfig.thinkingConfig.includeThoughts", + "generationConfig.thinkingConfig.include_thoughts", + "generation_config.thinking_config.include_thoughts", + "generation_config.thinking_config.includeThoughts", + } { + if config, ok := summaryBoolConfig(body, path); ok { + return config, true + } + } + + for _, path := range []string{ + "reasoning.summary", + "reasoning.generate_summary", + } { + if config, ok := responsesSummaryConfig(body, path); ok { + return config, true + } + } + + // reasoning.exclude is OpenRouter's documented "reason but hide" bit, not an + // OpenAI wire field; include_reasoning is its documented legacy alias + // (include_reasoning: false is equivalent to reasoning: {exclude: true}). + // Only accept actual JSON booleans. + if exclude := gjson.GetBytes(body, "reasoning.exclude"); exclude.IsBool() { + if exclude.Bool() { + return SummaryConfig{Mode: SummaryDisabled}, true + } + return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}, true + } + if include := gjson.GetBytes(body, "include_reasoning"); include.IsBool() { + if include.Bool() { + return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}, true + } + return SummaryConfig{Mode: SummaryDisabled}, true + } + // OpenRouter's reasoning.enabled turns reasoning on "with no exclusions", so + // it also decides visibility when no dedicated bit was sent. + if enabled := gjson.GetBytes(body, "reasoning.enabled"); enabled.IsBool() { + if enabled.Bool() { + return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}, true + } + return SummaryConfig{Mode: SummaryDisabled}, true + } + return SummaryConfig{}, false +} + +func firstSummaryBoolConfig(body []byte, paths []string) (SummaryConfig, bool) { + for _, path := range paths { + if config, ok := summaryBoolConfig(body, path); ok { + return config, true + } + } + return SummaryConfig{}, false +} + +func summaryBoolConfig(body []byte, path string) (SummaryConfig, bool) { + switch value := gjson.GetBytes(body, path); value.Type { + case gjson.True: + return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}, true + case gjson.False: + return SummaryConfig{Mode: SummaryDisabled}, true + default: + return SummaryConfig{}, false + } +} + +func responsesSummaryConfig(body []byte, path string) (SummaryConfig, bool) { + value := gjson.GetBytes(body, path) + if value.Raw == "" { + return SummaryConfig{}, false + } + if value.Type == gjson.Null { + return SummaryConfig{Mode: SummaryDisabled}, true + } + if value.Type != gjson.String { + return SummaryConfig{}, false + } + + raw := strings.ToLower(strings.TrimSpace(value.String())) + switch raw { + case "auto", "concise", "detailed": + return SummaryConfig{Mode: SummaryEnabled, Detail: raw}, true + case "none": + // Compatibility with clients that expose a none enum; the OpenAI wire + // representation disables summaries by omitting the field. + return SummaryConfig{Mode: SummaryDisabled}, true + default: + return SummaryConfig{}, false + } +} + +func claudeSummaryConfig(body []byte, path string) (SummaryConfig, bool) { + value := gjson.GetBytes(body, path) + if value.Type != gjson.String { + return SummaryConfig{}, false + } + switch strings.ToLower(strings.TrimSpace(value.String())) { + case "summarized": + return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}, true + case "omitted": + return SummaryConfig{Mode: SummaryDisabled}, true + default: + return SummaryConfig{}, false + } +} + +func interactionsSummaryConfig(body []byte, path string) (SummaryConfig, bool) { + value := gjson.GetBytes(body, path) + if value.Type != gjson.String { + return SummaryConfig{}, false + } + switch strings.ToLower(strings.TrimSpace(value.String())) { + case "auto": + return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}, true + case "none": + return SummaryConfig{Mode: SummaryDisabled}, true + default: + return SummaryConfig{}, false + } +} + +func enableClaudeThinkingForSummary(body []byte, model string, resolvedModelInfo *registry.ModelInfo) []byte { + modelInfo := resolvedModelInfo + if modelInfo == nil { + baseModel := ParseSuffix(model).ModelName + if baseModel == "" { + baseModel = ParseSuffix(gjson.GetBytes(body, "model").String()).ModelName + } + modelInfo = registry.LookupModelInfo(baseModel, "claude") + } + if modelInfo == nil || modelInfo.Thinking == nil { + return body + } + + if len(modelInfo.Thinking.Levels) > 0 { + body, _ = sjson.SetBytes(body, "thinking.type", "adaptive") + body, _ = sjson.DeleteBytes(body, "thinking.budget_tokens") + return body + } + + budget := modelInfo.Thinking.Min + if budget <= 0 { + return body + } + if maxTokens := gjson.GetBytes(body, "max_tokens"); maxTokens.Exists() && maxTokens.Int() <= int64(budget) { + return body + } + body, _ = sjson.SetBytes(body, "thinking.type", "enabled") + body, _ = sjson.SetBytes(body, "thinking.budget_tokens", budget) + return body +} + +func normalizedSummaryDetail(detail string) string { + switch strings.ToLower(strings.TrimSpace(detail)) { + case "concise": + return "concise" + case "detailed": + return "detailed" + default: + return "auto" + } +} diff --git a/internal/thinking/summary_test.go b/internal/thinking/summary_test.go new file mode 100644 index 00000000..6c9011f1 --- /dev/null +++ b/internal/thinking/summary_test.go @@ -0,0 +1,215 @@ +package thinking + +import ( + "bytes" + "testing" + + "github.com/tidwall/gjson" +) + +func TestExtractSummaryConfig(t *testing.T) { + tests := []struct { + name string + format string + body string + wantMode SummaryMode + wantDetail string + }{ + {name: "chat effort enables", format: "openai", body: `{"reasoning_effort":"high"}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "chat none disables", format: "openai", body: `{"reasoning_effort":"none"}`, wantMode: SummaryDisabled}, + {name: "chat missing unspecified", format: "openai", body: `{}`, wantMode: SummaryUnspecified}, + {name: "chat null effort unspecified", format: "openai", body: `{"reasoning_effort":null}`, wantMode: SummaryUnspecified}, + {name: "chat non-string effort unspecified", format: "openai", body: `{"reasoning_effort":17}`, wantMode: SummaryUnspecified}, + {name: "chat google extension false overrides effort", format: "openai", body: `{"reasoning_effort":"high","extra_body":{"google":{"thinking_config":{"include_thoughts":false}}}}`, wantMode: SummaryDisabled}, + {name: "chat google extension true", format: "openai", body: `{"extra_body":{"google":{"thinking_config":{"include_thoughts":true}}}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "chat exclude disables", format: "openai", body: `{"reasoning_effort":"high","reasoning":{"exclude":true}}`, wantMode: SummaryDisabled}, + {name: "chat exclude false enables", format: "openai", body: `{"reasoning":{"effort":"high","exclude":false}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "chat legacy include_reasoning false disables", format: "openai", body: `{"reasoning_effort":"high","include_reasoning":false}`, wantMode: SummaryDisabled}, + {name: "chat legacy include_reasoning true enables", format: "openai", body: `{"include_reasoning":true}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "chat reasoning enabled false disables", format: "openai", body: `{"reasoning":{"enabled":false}}`, wantMode: SummaryDisabled}, + {name: "chat reasoning enabled true enables", format: "openai", body: `{"reasoning":{"enabled":true}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "chat exclude wins over include_reasoning", format: "openai", body: `{"reasoning":{"exclude":true},"include_reasoning":true}`, wantMode: SummaryDisabled}, + {name: "chat non-boolean include_reasoning unspecified", format: "openai", body: `{"include_reasoning":"false"}`, wantMode: SummaryUnspecified}, + {name: "responses effort alone unspecified", format: "openai-response", body: `{"reasoning":{"effort":"high"}}`, wantMode: SummaryUnspecified}, + {name: "responses summary auto", format: "openai-response", body: `{"reasoning":{"effort":"high","summary":"auto"}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "responses summary concise", format: "openai-response", body: `{"reasoning":{"summary":"concise"}}`, wantMode: SummaryEnabled, wantDetail: "concise"}, + {name: "responses summary null", format: "openai-response", body: `{"reasoning":{"summary":null}}`, wantMode: SummaryDisabled}, + {name: "responses boolean summary invalid", format: "openai-response", body: `{"reasoning":{"summary":true}}`, wantMode: SummaryUnspecified}, + {name: "responses deprecated generate summary", format: "openai-response", body: `{"reasoning":{"generate_summary":"detailed"}}`, wantMode: SummaryEnabled, wantDetail: "detailed"}, + {name: "claude summarized", format: "claude", body: `{"thinking":{"type":"adaptive","display":"summarized"}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "claude omitted", format: "claude", body: `{"thinking":{"type":"enabled","budget_tokens":2048,"display":"omitted"}}`, wantMode: SummaryDisabled}, + {name: "claude display without type is invalid", format: "claude", body: `{"thinking":{"display":"summarized"}}`, wantMode: SummaryUnspecified}, + {name: "claude display with auto type is invalid", format: "claude", body: `{"thinking":{"type":"auto","display":"summarized"}}`, wantMode: SummaryUnspecified}, + // ApplySummaryConfig runs before ApplyThinking fills budget_tokens, so an + // absent budget must not be read as inactive thinking. + {name: "claude enabled display without budget is valid", format: "claude", body: `{"thinking":{"type":"enabled","display":"summarized"}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "claude enabled display with zero budget is invalid", format: "claude", body: `{"thinking":{"type":"enabled","budget_tokens":0,"display":"summarized"}}`, wantMode: SummaryUnspecified}, + {name: "gemini include true", format: "gemini", body: `{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "gemini include false", format: "gemini", body: `{"generationConfig":{"thinkingConfig":{"includeThoughts":false}}}`, wantMode: SummaryDisabled}, + {name: "antigravity include true", format: "antigravity", body: `{"request":{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "interactions auto", format: "interactions", body: `{"generation_config":{"thinking_summaries":"auto"}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "interactions none", format: "interactions", body: `{"generation_config":{"thinking_summaries":"none"}}`, wantMode: SummaryDisabled}, + {name: "interactions detailed is invalid", format: "interactions", body: `{"generation_config":{"thinking_summaries":"detailed"}}`, wantMode: SummaryUnspecified}, + {name: "interactions boolean is invalid", format: "interactions", body: `{"generation_config":{"thinking_summaries":true}}`, wantMode: SummaryUnspecified}, + {name: "gemini string bool is invalid", format: "gemini", body: `{"generationConfig":{"thinkingConfig":{"includeThoughts":"true"}}}`, wantMode: SummaryUnspecified}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := ExtractSummaryConfig([]byte(test.body), test.format) + if got.Mode != test.wantMode || got.Detail != test.wantDetail { + t.Fatalf("ExtractSummaryConfig() = %+v, want mode=%v detail=%q", got, test.wantMode, test.wantDetail) + } + }) + } +} + +func TestApplySummaryConfig(t *testing.T) { + tests := []struct { + name string + format string + body string + config SummaryConfig + path string + want string + }{ + {name: "chat enabled creates compatibility effort", format: "openai", config: SummaryConfig{Mode: SummaryEnabled}, path: "reasoning_effort", want: "medium"}, + {name: "chat enabled preserves active effort", format: "openai", body: `{"reasoning_effort":"high"}`, config: SummaryConfig{Mode: SummaryEnabled}, path: "reasoning_effort", want: "high"}, + // Chat cannot express "reason but hide", so disabling must not fall back to + // reasoning_effort:"none", which would disable reasoning altogether. + {name: "chat disabled preserves requested effort", format: "openai", body: `{"reasoning_effort":"high"}`, config: SummaryConfig{Mode: SummaryDisabled}, path: "reasoning_effort", want: "high"}, + {name: "chat disabled sets openrouter exclude when present", format: "openai", body: `{"reasoning":{"effort":"high","exclude":false}}`, config: SummaryConfig{Mode: SummaryDisabled}, path: "reasoning.exclude", want: "true"}, + {name: "chat enabled clears openrouter exclude when present", format: "openai", body: `{"reasoning":{"effort":"high","exclude":true}}`, config: SummaryConfig{Mode: SummaryEnabled}, path: "reasoning.exclude", want: "false"}, + {name: "chat disabled updates legacy include_reasoning when present", format: "openai", body: `{"reasoning_effort":"high","include_reasoning":true}`, config: SummaryConfig{Mode: SummaryDisabled}, path: "include_reasoning", want: "false"}, + {name: "chat disabled invents no openrouter field", format: "openai", body: `{"reasoning_effort":"high"}`, config: SummaryConfig{Mode: SummaryDisabled}, path: "reasoning", want: ""}, + {name: "claude enabled", format: "claude", body: `{"thinking":{"type":"adaptive"}}`, config: SummaryConfig{Mode: SummaryEnabled}, path: "thinking.display", want: "summarized"}, + {name: "claude disabled", format: "claude", body: `{"thinking":{"type":"enabled","budget_tokens":2048}}`, config: SummaryConfig{Mode: SummaryDisabled}, path: "thinking.display", want: "omitted"}, + {name: "gemini enabled", format: "gemini", config: SummaryConfig{Mode: SummaryEnabled}, path: "generationConfig.thinkingConfig.includeThoughts", want: "true"}, + {name: "gemini disabled", format: "gemini", config: SummaryConfig{Mode: SummaryDisabled}, path: "generationConfig.thinkingConfig.includeThoughts", want: "false"}, + {name: "antigravity enabled", format: "antigravity", config: SummaryConfig{Mode: SummaryEnabled}, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "true"}, + {name: "interactions detail collapses to auto", format: "interactions", config: SummaryConfig{Mode: SummaryEnabled, Detail: "detailed"}, path: "generation_config.thinking_summaries", want: "auto"}, + {name: "interactions disabled", format: "interactions", config: SummaryConfig{Mode: SummaryDisabled}, path: "generation_config.thinking_summaries", want: "none"}, + {name: "responses concise", format: "openai-response", config: SummaryConfig{Mode: SummaryEnabled, Detail: "concise"}, path: "reasoning.summary", want: "concise"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := test.body + if body == "" { + body = `{}` + } + out := ApplySummaryConfig([]byte(body), test.format, test.config) + if got := gjson.GetBytes(out, test.path).String(); got != test.want { + t.Fatalf("%s = %q, want %q; body=%s", test.path, got, test.want, out) + } + }) + } +} + +func TestApplySummaryConfigNormalizesTargetAliases(t *testing.T) { + tests := []struct { + format string + body string + canonical string + alias string + }{ + {format: "gemini", body: `{"generationConfig":{"thinkingConfig":{"include_thoughts":true}}}`, canonical: "generationConfig.thinkingConfig.includeThoughts", alias: "generationConfig.thinkingConfig.include_thoughts"}, + {format: "antigravity", body: `{"request":{"generationConfig":{"thinkingConfig":{"include_thoughts":true}}}}`, canonical: "request.generationConfig.thinkingConfig.includeThoughts", alias: "request.generationConfig.thinkingConfig.include_thoughts"}, + {format: "interactions", body: `{"generation_config":{"thinkingSummaries":"auto"}}`, canonical: "generation_config.thinking_summaries", alias: "generation_config.thinkingSummaries"}, + } + for _, test := range tests { + out := ApplySummaryConfig([]byte(test.body), test.format, SummaryConfig{Mode: SummaryEnabled}) + if !gjson.GetBytes(out, test.canonical).Exists() { + t.Fatalf("%s missing canonical field: %s", test.format, out) + } + if gjson.GetBytes(out, test.alias).Exists() { + t.Fatalf("%s retained alias %s: %s", test.format, test.alias, out) + } + } +} + +// Anthropic requires thinking.type, and rejects display on a disabled block, so +// display must never be written unless thinking is already active. +func TestApplySummaryConfig_ClaudeDisplayRequiresActiveThinking(t *testing.T) { + bodies := []string{ + `{}`, + `{"messages":[{"role":"user","content":"hi"}]}`, + `{"thinking":{"type":"disabled"}}`, + } + for _, mode := range []SummaryMode{SummaryEnabled, SummaryDisabled} { + for _, body := range bodies { + out := ApplySummaryConfig([]byte(body), "claude", SummaryConfig{Mode: mode}) + if gjson.GetBytes(out, "thinking.display").Exists() { + t.Fatalf("mode %v wrote display without active thinking: %s", mode, out) + } + if !bytes.Equal(out, []byte(body)) { + t.Fatalf("mode %v changed body: got %s, want %s", mode, out, body) + } + } + } +} + +func TestApplySummaryConfigForModel_ClaudeExplicitVisibilityUsesValidThinkingMode(t *testing.T) { + tests := []struct { + name string + model string + body string + mode SummaryMode + wantType string + wantDisplay string + wantBudget int64 + }{ + {name: "adaptive model summarized", model: "claude-opus-5", body: `{"model":"claude-opus-5","max_tokens":32000}`, mode: SummaryEnabled, wantType: "adaptive", wantDisplay: "summarized"}, + {name: "adaptive model omitted", model: "claude-opus-5", body: `{"model":"claude-opus-5","max_tokens":32000}`, mode: SummaryDisabled, wantType: "adaptive", wantDisplay: "omitted"}, + {name: "manual model summarized", model: "claude-haiku-4-5-20251001", body: `{"model":"claude-haiku-4-5-20251001","max_tokens":32000}`, mode: SummaryEnabled, wantType: "enabled", wantDisplay: "summarized", wantBudget: 1024}, + {name: "manual model omitted", model: "claude-haiku-4-5-20251001", body: `{"model":"claude-haiku-4-5-20251001","max_tokens":32000}`, mode: SummaryDisabled, wantType: "enabled", wantDisplay: "omitted", wantBudget: 1024}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + out := ApplySummaryConfigForModel([]byte(test.body), "claude", test.model, SummaryConfig{Mode: test.mode}) + if got := gjson.GetBytes(out, "thinking.type").String(); got != test.wantType { + t.Fatalf("thinking.type = %q, want %q; body=%s", got, test.wantType, out) + } + if got := gjson.GetBytes(out, "thinking.display").String(); got != test.wantDisplay { + t.Fatalf("thinking.display = %q, want %q; body=%s", got, test.wantDisplay, out) + } + if test.wantBudget > 0 && gjson.GetBytes(out, "thinking.budget_tokens").Int() != test.wantBudget { + t.Fatalf("thinking.budget_tokens = %d, want %d; body=%s", gjson.GetBytes(out, "thinking.budget_tokens").Int(), test.wantBudget, out) + } + }) + } +} + +func TestApplySummaryConfig_ResponsesNormalizesDeprecatedGenerateSummary(t *testing.T) { + out := ApplySummaryConfig([]byte(`{"reasoning":{"generate_summary":"detailed"}}`), "openai-response", SummaryConfig{Mode: SummaryEnabled, Detail: "detailed"}) + if got := gjson.GetBytes(out, "reasoning.summary").String(); got != "detailed" { + t.Fatalf("reasoning.summary = %q, want detailed; body=%s", got, out) + } + if gjson.GetBytes(out, "reasoning.generate_summary").Exists() { + t.Fatalf("deprecated reasoning.generate_summary remained: %s", out) + } +} + +func TestApplySummaryConfig_ResponsesDisabledOmitsSummary(t *testing.T) { + out := ApplySummaryConfig([]byte(`{"reasoning":{"effort":"high","summary":"auto"}}`), "openai-response", SummaryConfig{Mode: SummaryDisabled}) + if result := gjson.GetBytes(out, "reasoning.summary"); result.Exists() { + t.Fatalf("reasoning.summary = %s, want absent; body=%s", result.Raw, out) + } + if got := gjson.GetBytes(out, "reasoning.effort").String(); got != "high" { + t.Fatalf("reasoning.effort = %q, want high; body=%s", got, out) + } +} + +func TestApplySummaryConfig_ResponsesDisabledDropsEmptyReasoning(t *testing.T) { + out := ApplySummaryConfig([]byte(`{"model":"gpt-5.4","reasoning":{"summary":"auto"}}`), "openai-response", SummaryConfig{Mode: SummaryDisabled}) + if gjson.GetBytes(out, "reasoning").Exists() { + t.Fatalf("empty reasoning object left behind: %s", out) + } +} + +func TestApplySummaryConfig_UnspecifiedLeavesBodyUnchanged(t *testing.T) { + body := []byte(`{"thinking":{"type":"adaptive"}}`) + if got := ApplySummaryConfig(body, "claude", SummaryConfig{}); !bytes.Equal(got, body) { + t.Fatalf("unspecified summary changed body: got %s, want %s", got, body) + } +} diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index 58a1068e..b575679a 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -885,7 +885,6 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number { budget := int(b.Int()) out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget) - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", true) } case "adaptive", "auto": // For adaptive thinking: @@ -901,7 +900,6 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ } else { out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel", "high") } - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", true) } } if v := gjson.GetBytes(rawJSON, "temperature"); v.Exists() && v.Type == gjson.Number { diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go index 52586f29..c41aa752 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go @@ -2238,8 +2238,8 @@ func TestConvertClaudeRequestToAntigravity_ThinkingConfig(t *testing.T) { if thinkingConfig.Get("thinkingBudget").Int() != 8000 { t.Errorf("Expected thinkingBudget 8000, got %d", thinkingConfig.Get("thinkingBudget").Int()) } - if !thinkingConfig.Get("includeThoughts").Bool() { - t.Error("includeThoughts should be true") + if thinkingConfig.Get("includeThoughts").Exists() { + t.Error("includeThoughts should be absent without explicit Claude display intent") } } else { t.Log("thinkingConfig not present - model may not be registered in test registry") diff --git a/internal/translator/antigravity/interactions/interactions_antigravity_request.go b/internal/translator/antigravity/interactions/interactions_antigravity_request.go index 123b42dc..2d00a4f3 100644 --- a/internal/translator/antigravity/interactions/interactions_antigravity_request.go +++ b/internal/translator/antigravity/interactions/interactions_antigravity_request.go @@ -707,20 +707,17 @@ func antigravityInputAudioMimeType(format string) string { } func antigravityThinkingSummariesIncludeThoughts(summary gjson.Result) (bool, bool) { - switch summary.Type { - case gjson.True: + if summary.Type != gjson.String { + return false, false + } + switch strings.ToLower(strings.TrimSpace(summary.String())) { + case "auto": return true, true - case gjson.False: + case "none": return false, true - case gjson.String: - switch strings.ToLower(strings.TrimSpace(summary.String())) { - case "", "none", "off", "false", "disabled": - return false, true - default: - return true, true - } + default: + return false, false } - return false, false } func convertSnakeCaseKeysToCamelCaseForAntigravity(raw []byte) []byte { 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 6c99515f..ee936642 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go @@ -5,6 +5,7 @@ package chat_completions import ( "strings" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/gemini" translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" @@ -51,14 +52,12 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ thinkingPath := "request.generationConfig.thinkingConfig" if effort == "auto" { out, _ = sjson.SetBytes(out, thinkingPath+".thinkingBudget", -1) - out, _ = sjson.SetBytes(out, thinkingPath+".includeThoughts", true) } else { out, _ = sjson.SetBytes(out, thinkingPath+".thinkingLevel", effort) - out, _ = sjson.SetBytes(out, thinkingPath+".includeThoughts", effort != "none") } } } - out = applyOpenAIThinkingCompatibilityToAntigravity(out, rawJSON, modelName) + out = applyOpenAIThinkingCompatibilityToAntigravity(out, rawJSON) // Temperature/top_p/top_k/max_tokens if tr := gjson.GetBytes(rawJSON, "temperature"); tr.Exists() && tr.Type == gjson.Number { @@ -513,29 +512,10 @@ func applyOpenAIToolChoiceToAntigravity(out, rawJSON []byte, functionNameMap map return out } -func applyOpenAIThinkingCompatibilityToAntigravity(out []byte, rawJSON []byte, modelName string) []byte { +func applyOpenAIThinkingCompatibilityToAntigravity(out []byte, rawJSON []byte) []byte { out = normalizeAntigravityOpenAIThinkingConfig(out) - - for _, path := range []string{ - "thinking.includeThoughts", - "thinking.include_thoughts", - "reasoning.includeThoughts", - "reasoning.include_thoughts", - } { - if value := gjson.GetBytes(rawJSON, path); value.Exists() { - out = setAntigravityOpenAIBoolIfDifferent(out, "request.generationConfig.thinkingConfig.includeThoughts", value.Bool()) - } - } - - if exclude := gjson.GetBytes(rawJSON, "reasoning.exclude"); exclude.Exists() { - out = setAntigravityOpenAIBoolIfDifferent(out, "request.generationConfig.thinkingConfig.includeThoughts", !exclude.Bool()) - } - - if !gjson.GetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts").Exists() && antigravityOpenAIDefaultIncludeThoughts(modelName) { - out = setAntigravityOpenAIBoolIfDifferent(out, "request.generationConfig.thinkingConfig.includeThoughts", true) - } - - return normalizeAntigravityOpenAIThinkingConfig(out) + config := thinking.ExtractSummaryConfig(rawJSON, "openai") + return thinking.ApplySummaryConfig(out, "antigravity", config) } func normalizeAntigravityOpenAIThinkingConfig(out []byte) []byte { @@ -543,11 +523,19 @@ func normalizeAntigravityOpenAIThinkingConfig(out []byte) []byte { "request.generationConfig.thinking_config", "request.generationConfig.thinkingConfig", } { - if includeThoughts := gjson.GetBytes(out, prefix+".includeThoughts"); includeThoughts.Exists() { - out = setAntigravityOpenAIBoolIfDifferent(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts.Bool()) + if sourcePath := prefix + ".includeThoughts"; gjson.GetBytes(out, sourcePath).Exists() { + includeThoughts := gjson.GetBytes(out, sourcePath) + out = setAntigravityOpenAIBoolResultIfValid(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts) + if includeThoughts.Type != gjson.True && includeThoughts.Type != gjson.False { + out, _ = sjson.DeleteBytes(out, sourcePath) + } } - if includeThoughts := gjson.GetBytes(out, prefix+".include_thoughts"); includeThoughts.Exists() { - out = setAntigravityOpenAIBoolIfDifferent(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts.Bool()) + if sourcePath := prefix + ".include_thoughts"; gjson.GetBytes(out, sourcePath).Exists() { + includeThoughts := gjson.GetBytes(out, sourcePath) + out = setAntigravityOpenAIBoolResultIfValid(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts) + if includeThoughts.Type != gjson.True && includeThoughts.Type != gjson.False { + out, _ = sjson.DeleteBytes(out, sourcePath) + } } if thinkingLevel := gjson.GetBytes(out, prefix+".thinkingLevel"); thinkingLevel.Exists() { out = setAntigravityOpenAIRawIfDifferent(out, "request.generationConfig.thinkingConfig.thinkingLevel", thinkingLevel) @@ -568,7 +556,7 @@ func normalizeAntigravityOpenAIThinkingConfig(out []byte) []byte { "request.generationConfig.include_thoughts", } { if includeThoughts := gjson.GetBytes(out, path); includeThoughts.Exists() { - out = setAntigravityOpenAIBoolIfDifferent(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts.Bool()) + out = setAntigravityOpenAIBoolResultIfValid(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts) } } @@ -588,6 +576,17 @@ func normalizeAntigravityOpenAIThinkingConfig(out []byte) []byte { return out } +func setAntigravityOpenAIBoolResultIfValid(out []byte, path string, value gjson.Result) []byte { + switch value.Type { + case gjson.True: + return setAntigravityOpenAIBoolIfDifferent(out, path, true) + case gjson.False: + return setAntigravityOpenAIBoolIfDifferent(out, path, false) + default: + return out + } +} + func setAntigravityOpenAIBoolIfDifferent(out []byte, path string, value bool) []byte { current := gjson.GetBytes(out, path) if value && current.Type == gjson.True || !value && current.Type == gjson.False { @@ -611,8 +610,3 @@ func setAntigravityOpenAIRawIfDifferent(out []byte, path string, value gjson.Res } return updated } - -func antigravityOpenAIDefaultIncludeThoughts(modelName string) bool { - modelName = strings.ToLower(modelName) - return strings.Contains(modelName, "gemini-3") -} diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go index 81907cbb..33bd0e4c 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go @@ -191,17 +191,27 @@ func TestConvertOpenAIRequestToAntigravitySkipsEmptyAssistantMessages(t *testing func TestConvertOpenAIRequestToAntigravityThinkingAliases(t *testing.T) { tests := []struct { - name string - body string - want bool + name string + body string + wantExists bool + want bool }{ { - name: "Default Gemini include thoughts", + name: "Missing summary intent leaves include thoughts absent", body: `{ "model":"gemini-3.1-pro-low", "messages":[{"role":"user","content":"hi"}] }`, - want: true, + }, + { + name: "Reasoning effort enables thoughts", + body: `{ + "model":"gemini-3.1-pro-low", + "messages":[{"role":"user","content":"hi"}], + "reasoning_effort":"high" + }`, + wantExists: true, + want: true, }, { name: "GenerationConfig snake include thoughts", @@ -210,7 +220,16 @@ func TestConvertOpenAIRequestToAntigravityThinkingAliases(t *testing.T) { "messages":[{"role":"user","content":"hi"}], "generationConfig":{"thinkingConfig":{"include_thoughts":true}} }`, - want: true, + wantExists: true, + want: true, + }, + { + name: "String include thoughts is ignored", + body: `{ + "model":"gemini-3.1-pro-low", + "messages":[{"role":"user","content":"hi"}], + "generationConfig":{"thinkingConfig":{"includeThoughts":"true"}} + }`, }, { name: "Top-level thinking include thoughts", @@ -219,7 +238,8 @@ func TestConvertOpenAIRequestToAntigravityThinkingAliases(t *testing.T) { "messages":[{"role":"user","content":"hi"}], "thinking":{"include_thoughts":true} }`, - want: true, + wantExists: true, + want: true, }, { name: "Reasoning exclude false includes thoughts", @@ -228,7 +248,8 @@ func TestConvertOpenAIRequestToAntigravityThinkingAliases(t *testing.T) { "messages":[{"role":"user","content":"hi"}], "reasoning":{"exclude":false} }`, - want: true, + wantExists: true, + want: true, }, { name: "Reasoning exclude true hides thoughts", @@ -237,7 +258,19 @@ func TestConvertOpenAIRequestToAntigravityThinkingAliases(t *testing.T) { "messages":[{"role":"user","content":"hi"}], "reasoning":{"exclude":true} }`, - want: false, + wantExists: true, + want: false, + }, + { + name: "Google extension disables thoughts", + body: `{ + "model":"gemini-3.1-pro-low", + "messages":[{"role":"user","content":"hi"}], + "reasoning_effort":"high", + "extra_body":{"google":{"thinking_config":{"include_thoughts":false}}} + }`, + wantExists: true, + want: false, }, } @@ -245,11 +278,13 @@ func TestConvertOpenAIRequestToAntigravityThinkingAliases(t *testing.T) { t.Run(tt.name, func(t *testing.T) { result := ConvertOpenAIRequestToAntigravity("gemini-3.1-pro-low", []byte(tt.body), false) includeThoughts := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts") - if !includeThoughts.Exists() { - t.Fatalf("includeThoughts missing. Output: %s", result) + if includeThoughts.Exists() != tt.wantExists { + t.Fatalf("includeThoughts exists = %v, want %v. Output: %s", includeThoughts.Exists(), tt.wantExists, result) } - if got := includeThoughts.Bool(); got != tt.want { - t.Fatalf("includeThoughts = %v, want %v. Output: %s", got, tt.want, result) + if tt.wantExists { + if got := includeThoughts.Bool(); got != tt.want { + t.Fatalf("includeThoughts = %v, want %v. Output: %s", got, tt.want, result) + } } if snake := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.include_thoughts"); snake.Exists() { t.Fatalf("include_thoughts should be normalized away. Output: %s", result) diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index 3a27e850..20241bf2 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -341,7 +341,9 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) } } template, _ = sjson.SetBytes(template, "reasoning.effort", reasoningEffort) - template, _ = sjson.SetBytes(template, "reasoning.summary", "auto") + // OpenAI documents reasoning summaries as explicit opt-in output. Leave + // reasoning.summary to the source request's canonical summary intent instead + // of coupling it to reasoning effort. serviceTier := normalizeCodexServiceTier(rootResult.Get("service_tier")) if speed := rootResult.Get("speed"); speed.Type == gjson.String && speed.String() == "fast" { serviceTier = "priority" diff --git a/internal/translator/codex/gemini/codex_gemini_request.go b/internal/translator/codex/gemini/codex_gemini_request.go index e61dc975..f5a03bdf 100644 --- a/internal/translator/codex/gemini/codex_gemini_request.go +++ b/internal/translator/codex/gemini/codex_gemini_request.go @@ -322,7 +322,9 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) // No thinking config, set default effort out, _ = sjson.SetBytes(out, "reasoning.effort", "medium") } - out, _ = sjson.SetBytes(out, "reasoning.summary", "auto") + // OpenAI documents reasoning summaries as explicit opt-in output. Leave + // reasoning.summary to the source request's canonical summary intent instead + // of coupling it to reasoning effort. out, _ = sjson.SetBytes(out, "stream", true) out, _ = sjson.SetBytes(out, "store", false) out, _ = sjson.SetBytes(out, "include", []string{"reasoning.encrypted_content"}) diff --git a/internal/translator/codex/interactions/interactions_codex_request.go b/internal/translator/codex/interactions/interactions_codex_request.go index 89b083f2..25287e89 100644 --- a/internal/translator/codex/interactions/interactions_codex_request.go +++ b/internal/translator/codex/interactions/interactions_codex_request.go @@ -155,17 +155,11 @@ func interactionsCodexReasoningSummary(cfg gjson.Result) string { "thinkingSummaries", "reasoning.summary", } { - if value := cfg.Get(path); value.Exists() { - switch value.Type { - case gjson.True: - return "auto" - case gjson.False: - return "none" - case gjson.String: - summary := strings.ToLower(strings.TrimSpace(value.String())) - if summary != "" { - return summary - } + if value := cfg.Get(path); value.Type == gjson.String { + summary := strings.ToLower(strings.TrimSpace(value.String())) + switch summary { + case "auto", "none": + return summary } } } @@ -177,10 +171,10 @@ func interactionsCodexReasoningSummary(cfg gjson.Result) string { "thinkingConfig.include_thoughts", "thinkingConfig.includeThoughts", } { - if value := cfg.Get(path); value.Exists() { - if value.Bool() { - return "auto" - } + switch value := cfg.Get(path); value.Type { + case gjson.True: + return "auto" + case gjson.False: return "none" } } 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 051d26ef..307df55d 100644 --- a/internal/translator/codex/openai/chat-completions/codex_openai_request.go +++ b/internal/translator/codex/openai/chat-completions/codex_openai_request.go @@ -64,7 +64,9 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b out, _ = sjson.SetBytes(out, "reasoning.effort", "medium") } out, _ = sjson.SetBytes(out, "parallel_tool_calls", true) - out, _ = sjson.SetBytes(out, "reasoning.summary", "auto") + // OpenAI documents reasoning summaries as explicit opt-in output. Leave + // reasoning.summary to the source request's canonical summary intent instead + // of coupling it to reasoning effort. out, _ = sjson.SetBytes(out, "include", []string{"reasoning.encrypted_content"}) // Model diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index 2df2009e..8a2259b7 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -266,7 +266,6 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number { budget := int(b.Int()) out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", budget) - out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.includeThoughts", true) } case "adaptive", "auto": // For adaptive thinking: @@ -290,7 +289,6 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingLevel", "high") } } - out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.includeThoughts", true) } } if v := gjson.GetBytes(rawJSON, "temperature"); v.Exists() && v.Type == gjson.Number { diff --git a/internal/translator/gemini/interactions/interactions_gemini_common.go b/internal/translator/gemini/interactions/interactions_gemini_common.go index e27e815e..59db6641 100644 --- a/internal/translator/gemini/interactions/interactions_gemini_common.go +++ b/internal/translator/gemini/interactions/interactions_gemini_common.go @@ -449,20 +449,17 @@ func normalizeInteractionsGenerationConfig(out []byte) []byte { } func interactionsThinkingSummariesIncludeThoughts(summary gjson.Result) (bool, bool) { - switch summary.Type { - case gjson.True: + if summary.Type != gjson.String { + return false, false + } + switch strings.ToLower(strings.TrimSpace(summary.String())) { + case "auto": return true, true - case gjson.False: + case "none": return false, true - case gjson.String: - switch strings.ToLower(strings.TrimSpace(summary.String())) { - case "", "none", "off", "false", "disabled": - return false, true - default: - return true, true - } + default: + return false, false } - return false, false } func copyInteractionsResponseModalities(out []byte, root gjson.Result) []byte { 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 0eea0925..64731dc4 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go @@ -48,10 +48,8 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) thinkingPath := "generationConfig.thinkingConfig" if effort == "auto" { out, _ = sjson.SetBytes(out, thinkingPath+".thinkingBudget", -1) - out, _ = sjson.SetBytes(out, thinkingPath+".includeThoughts", true) } else { out, _ = sjson.SetBytes(out, thinkingPath+".thinkingLevel", effort) - out, _ = sjson.SetBytes(out, thinkingPath+".includeThoughts", effort != "none") } } } 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 8ee3186a..6ebb4336 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go @@ -379,10 +379,8 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte thinkingPath := "generationConfig.thinkingConfig" if effort == "auto" { out, _ = sjson.SetBytes(out, thinkingPath+".thinkingBudget", -1) - out, _ = sjson.SetBytes(out, thinkingPath+".includeThoughts", true) } else { out, _ = sjson.SetBytes(out, thinkingPath+".thinkingLevel", effort) - out, _ = sjson.SetBytes(out, thinkingPath+".includeThoughts", effort != "none") } } } diff --git a/sdk/translator/registry.go b/sdk/translator/registry.go index ad4d351d..e9ef609f 100644 --- a/sdk/translator/registry.go +++ b/sdk/translator/registry.go @@ -4,6 +4,7 @@ import ( "context" "sync" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -56,6 +57,8 @@ func (r *Registry) SetPluginHooks(hooks PluginHooks) { // "model" field is still updated to match the resolved model name so that // client-side prefixes (e.g. "copilot/gpt-5-mini") are not leaked upstream. func (r *Registry) TranslateRequest(from, to Format, model string, rawJSON []byte, stream bool) []byte { + summaryConfig := thinking.ExtractSummaryConfig(rawJSON, from.String()) + r.mu.RLock() var fn RequestTransform if byTarget, ok := r.requests[from]; ok { @@ -85,7 +88,7 @@ func (r *Registry) TranslateRequest(from, to Format, model string, rawJSON []byt } } } - return body + return thinking.ApplySummaryConfigForModel(body, to.String(), model, summaryConfig) } // HasRequestTransformer indicates whether a request translator exists. diff --git a/sdk/translator/registry_summary_test.go b/sdk/translator/registry_summary_test.go new file mode 100644 index 00000000..79312dd8 --- /dev/null +++ b/sdk/translator/registry_summary_test.go @@ -0,0 +1,127 @@ +package translator + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestRegistryTranslateRequestAppliesSummaryIntent(t *testing.T) { + tests := []struct { + name string + from Format + to Format + input string + translated string + path string + want string + wantExists bool + }{ + { + name: "chat effort enables Claude summary", + from: FormatOpenAI, + to: FormatClaude, + input: `{"reasoning_effort":"high"}`, + translated: `{"thinking":{"type":"adaptive"}}`, + path: "thinking.display", + want: "summarized", + wantExists: true, + }, + { + name: "responses effort alone leaves Claude display absent", + from: FormatOpenAIResponse, + to: FormatClaude, + input: `{"reasoning":{"effort":"high"}}`, + translated: `{"thinking":{"type":"adaptive"}}`, + path: "thinking.display", + }, + { + name: "responses summary enables Claude summary", + from: FormatOpenAIResponse, + to: FormatClaude, + input: `{"reasoning":{"effort":"high","summary":"auto"}}`, + translated: `{"thinking":{"type":"adaptive"}}`, + path: "thinking.display", + want: "summarized", + wantExists: true, + }, + { + name: "responses null summary disables Gemini summaries", + from: FormatOpenAIResponse, + to: FormatGemini, + input: `{"reasoning":{"effort":"high","summary":null}}`, + translated: `{"generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}}}`, + path: "generationConfig.thinkingConfig.includeThoughts", + want: "false", + wantExists: true, + }, + { + name: "Google Chat extension overrides effort", + from: FormatOpenAI, + to: FormatGemini, + input: `{"reasoning_effort":"high","extra_body":{"google":{"thinking_config":{"include_thoughts":false}}}}`, + translated: `{"generationConfig":{"thinkingConfig":{"thinkingLevel":"high","includeThoughts":true}}}`, + path: "generationConfig.thinkingConfig.includeThoughts", + want: "false", + wantExists: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + registry := NewRegistry() + registry.Register(test.from, test.to, func(_ string, _ []byte, _ bool) []byte { + return []byte(test.translated) + }, ResponseTransform{}) + out := registry.TranslateRequest(test.from, test.to, "model", []byte(test.input), false) + result := gjson.GetBytes(out, test.path) + if result.Exists() != test.wantExists { + t.Fatalf("%s exists = %v, want %v; body=%s", test.path, result.Exists(), test.wantExists, out) + } + if test.wantExists && result.String() != test.want { + t.Fatalf("%s = %q, want %q; body=%s", test.path, result.String(), test.want, out) + } + }) + } +} + +func TestRegistryTranslateRequestMakesExplicitClaudeVisibilityValid(t *testing.T) { + tests := []struct { + name string + input string + wantDisplay string + }{ + {name: "summary auto is visible", input: `{"reasoning":{"summary":"auto"},"input":"hi"}`, wantDisplay: "summarized"}, + {name: "summary null is hidden", input: `{"reasoning":{"summary":null},"input":"hi"}`, wantDisplay: "omitted"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + registry := NewRegistry() + registry.Register(FormatOpenAIResponse, FormatClaude, func(_ string, _ []byte, _ bool) []byte { + return []byte(`{"model":"claude-opus-5","max_tokens":32000}`) + }, ResponseTransform{}) + out := registry.TranslateRequest( + FormatOpenAIResponse, + FormatClaude, + "claude-opus-5", + []byte(test.input), + false, + ) + if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" { + t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out) + } + if got := gjson.GetBytes(out, "thinking.display").String(); got != test.wantDisplay { + t.Fatalf("thinking.display = %q, want %q; body=%s", got, test.wantDisplay, out) + } + }) + } +} + +func TestRegistryTranslateRequestPreservesNativeClaudeMissingDisplay(t *testing.T) { + registry := NewRegistry() + body := []byte(`{"model":"claude-opus-5","thinking":{"type":"adaptive"}}`) + out := registry.TranslateRequest(FormatClaude, FormatClaude, "claude-opus-5", body, true) + if gjson.GetBytes(out, "thinking.display").Exists() { + t.Fatalf("native Claude request without display gained one: %s", out) + } +} diff --git a/test/summary_intent_translation_test.go b/test/summary_intent_translation_test.go new file mode 100644 index 00000000..e2cfdd99 --- /dev/null +++ b/test/summary_intent_translation_test.go @@ -0,0 +1,226 @@ +package test + +import ( + "fmt" + "testing" + "time" + + "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/antigravity" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" +) + +func TestSummaryIntentTranslation(t *testing.T) { + tests := []struct { + name string + from sdktranslator.Format + to sdktranslator.Format + body string + path string + want string + wantExists bool + }{ + {name: "Chat effort enables Claude summary", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatClaude, body: `{"model":"claude-opus-5","reasoning_effort":"high","messages":[{"role":"user","content":"hi"}]}`, path: "thinking.display", want: "summarized", wantExists: true}, + // Anthropic rejects display next to a disabled thinking block, so a "none" + // effort must leave the field off rather than write "omitted". + {name: "Chat none leaves disabled Claude thinking without display", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatClaude, body: `{"model":"claude-opus-5","reasoning_effort":"none","messages":[{"role":"user","content":"hi"}]}`, path: "thinking.display"}, + // Anthropic requires thinking.type. For an unregistered target CPA cannot + // safely guess adaptive versus manual thinking, so it must not emit an + // invalid display-only object. Registered targets are covered below. + {name: "Unknown Claude target does not get display only thinking", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatClaude, body: `{"model":"unregistered-claude-model","reasoning":{"exclude":false},"messages":[{"role":"user","content":"hi"}]}`, path: "thinking"}, + {name: "Unknown Claude target from Interactions stays valid", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatClaude, body: `{"model":"unregistered-claude-model","generation_config":{"thinking_summaries":"auto"},"input":"hi"}`, path: "thinking"}, + {name: "Chat none omits Codex summary", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatCodex, body: `{"model":"gpt-5.4","reasoning_effort":"none","messages":[{"role":"user","content":"hi"}]}`, path: "reasoning.summary"}, + // The Responses API makes reasoning.summary an explicit opt-in, so an + // absent source intent must remain absent when translated to Codex. + {name: "Claude absent display leaves Codex summary absent", from: sdktranslator.FormatClaude, to: sdktranslator.FormatCodex, body: `{"model":"gpt-5.4","max_tokens":1024,"thinking":{"type":"adaptive"},"output_config":{"effort":"high"},"messages":[{"role":"user","content":"hi"}]}`, path: "reasoning.summary"}, + {name: "Gemini absent includeThoughts leaves Codex summary absent", from: sdktranslator.FormatGemini, to: sdktranslator.FormatCodex, body: `{"model":"gpt-5.4","generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, path: "reasoning.summary"}, + {name: "Claude summarized enables Codex summary", from: sdktranslator.FormatClaude, to: sdktranslator.FormatCodex, body: `{"model":"gpt-5.4","max_tokens":1024,"thinking":{"type":"adaptive","display":"summarized"},"output_config":{"effort":"high"},"messages":[{"role":"user","content":"hi"}]}`, path: "reasoning.summary", want: "auto", wantExists: true}, + {name: "Interactions none omits Codex summary", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatCodex, body: `{"model":"gpt-5.4","generation_config":{"thinking_level":"high","thinking_summaries":"none"},"input":"hi"}`, path: "reasoning.summary"}, + {name: "Chat effort enables Codex summary", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatCodex, body: `{"model":"gpt-5.4","reasoning_effort":"high","messages":[{"role":"user","content":"hi"}]}`, path: "reasoning.summary", want: "auto", wantExists: true}, + {name: "Responses summary only enables Chat compatibility effort", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatOpenAI, body: `{"model":"gpt-5.4","reasoning":{"summary":"auto"},"input":"hi"}`, path: "reasoning_effort", want: "medium", wantExists: true}, + // Chat has no field for "reason but hide": OpenAI documents none and rejects + // unknown parameters, so a disabled summary must leave the requested effort + // alone instead of turning reasoning off upstream. + {name: "Responses disabled summary keeps Chat effort", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatOpenAI, body: `{"model":"gpt-5.4","reasoning":{"effort":"high","summary":null},"input":"hi"}`, path: "reasoning_effort", want: "high", wantExists: true}, + {name: "Gemini disabled summary keeps Chat effort", from: sdktranslator.FormatGemini, to: sdktranslator.FormatOpenAI, body: `{"model":"gpt-5.4","generationConfig":{"thinkingConfig":{"thinkingLevel":"high","includeThoughts":false}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, path: "reasoning_effort", want: "high", wantExists: true}, + {name: "Claude omitted display keeps Chat effort", from: sdktranslator.FormatClaude, to: sdktranslator.FormatOpenAI, body: `{"model":"gpt-5.4","thinking":{"type":"adaptive","display":"omitted"},"output_config":{"effort":"high"},"messages":[{"role":"user","content":"hi"}]}`, path: "reasoning_effort", want: "high", wantExists: true}, + {name: "Chat without effort leaves Claude display absent", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatClaude, body: `{"model":"claude-opus-5","messages":[{"role":"user","content":"hi"}]}`, path: "thinking.display"}, + {name: "Responses effort alone leaves Claude display absent", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, body: `{"model":"claude-opus-5","reasoning":{"effort":"high"},"input":"hi"}`, path: "thinking.display"}, + {name: "Responses summary enables Claude summary", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, body: `{"model":"claude-opus-5","reasoning":{"effort":"high","summary":"auto"},"input":"hi"}`, path: "thinking.display", want: "summarized", wantExists: true}, + {name: "Responses null summary disables Claude summary", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, body: `{"model":"claude-opus-5","reasoning":{"effort":"high","summary":null},"input":"hi"}`, path: "thinking.display", want: "omitted", wantExists: true}, + {name: "Chat effort enables Gemini summary", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatGemini, body: `{"model":"gemini-3.6-flash","reasoning_effort":"high","messages":[{"role":"user","content":"hi"}]}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, + {name: "Chat none disables Gemini summary", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatGemini, body: `{"model":"gemini-3.6-flash","reasoning_effort":"none","messages":[{"role":"user","content":"hi"}]}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "false", wantExists: true}, + {name: "Chat effort enables Antigravity summary", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatAntigravity, body: `{"model":"gemini-3.6-flash","reasoning_effort":"high","messages":[{"role":"user","content":"hi"}]}`, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, + {name: "Google Chat extension overrides Gemini summary", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatGemini, body: `{"model":"gemini-3.6-flash","reasoning_effort":"high","extra_body":{"google":{"thinking_config":{"include_thoughts":false}}},"messages":[{"role":"user","content":"hi"}]}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "false", wantExists: true}, + {name: "Responses effort alone leaves Gemini summary absent", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatGemini, body: `{"model":"gemini-3.6-flash","reasoning":{"effort":"high"},"input":"hi"}`, path: "generationConfig.thinkingConfig.includeThoughts"}, + {name: "Responses detailed summary enables Gemini summary", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatGemini, body: `{"model":"gemini-3.6-flash","reasoning":{"effort":"high","summary":"detailed"},"input":"hi"}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, + {name: "Responses effort alone leaves Antigravity summary absent", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatAntigravity, body: `{"model":"gemini-3.6-flash","reasoning":{"effort":"high"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts"}, + {name: "Responses summary enables Antigravity summary", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatAntigravity, body: `{"model":"gemini-3.6-flash","reasoning":{"effort":"high","summary":"auto"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, + {name: "Chat effort enables Interactions summary", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatInteractions, body: `{"model":"gemini-3.6-flash","reasoning_effort":"high","messages":[{"role":"user","content":"hi"}]}`, path: "generation_config.thinking_summaries", want: "auto", wantExists: true}, + {name: "Responses concise summary maps to Interactions auto", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatInteractions, body: `{"model":"gemini-3.6-flash","reasoning":{"effort":"high","summary":"concise"},"input":"hi"}`, path: "generation_config.thinking_summaries", want: "auto", wantExists: true}, + {name: "Native Claude summarized enables Gemini summary", from: sdktranslator.FormatClaude, to: sdktranslator.FormatGemini, body: `{"model":"claude-opus-5","thinking":{"type":"adaptive","display":"summarized"},"messages":[{"role":"user","content":"hi"}]}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, + {name: "Native Gemini disabled omits Claude summary", from: sdktranslator.FormatGemini, to: sdktranslator.FormatClaude, body: `{"model":"gemini-3.6-flash","generationConfig":{"thinkingConfig":{"thinkingLevel":"high","includeThoughts":false}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, path: "thinking.display", want: "omitted", wantExists: true}, + {name: "Native Gemini absent summary leaves Claude display absent", from: sdktranslator.FormatGemini, to: sdktranslator.FormatClaude, body: `{"model":"gemini-3.6-flash","generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, path: "thinking.display"}, + {name: "Native Interactions auto enables Gemini summary", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatGemini, body: `{"model":"gemini-3.6-flash","generation_config":{"thinking_level":"high","thinking_summaries":"auto"},"input":"hi"}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, + {name: "Native Interactions none omits Claude summary", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatClaude, body: `{"model":"claude-opus-5","generation_config":{"thinking_level":"high","thinking_summaries":"none"},"input":"hi"}`, path: "thinking.display", want: "omitted", wantExists: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + out := sdktranslator.TranslateRequest(test.from, test.to, "", []byte(test.body), true) + result := gjson.GetBytes(out, test.path) + if result.Exists() != test.wantExists { + t.Fatalf("%s exists = %v, want %v; body=%s", test.path, result.Exists(), test.wantExists, out) + } + if test.wantExists && result.String() != test.want { + t.Fatalf("%s = %q, want %q; body=%s", test.path, result.String(), test.want, out) + } + }) + } +} + +func TestInvalidInteractionsSummaryDoesNotWriteTargetControl(t *testing.T) { + body := []byte(`{"model":"model","generation_config":{"thinking_summaries":"banana"},"input":"hi"}`) + for _, test := range []struct { + name string + to sdktranslator.Format + path string + }{ + {name: "Gemini", to: sdktranslator.FormatGemini, path: "generationConfig.thinkingConfig.includeThoughts"}, + {name: "Antigravity", to: sdktranslator.FormatAntigravity, path: "request.generationConfig.thinkingConfig.includeThoughts"}, + {name: "Codex", to: sdktranslator.FormatCodex, path: "reasoning.summary"}, + } { + t.Run(test.name, func(t *testing.T) { + out := sdktranslator.TranslateRequest(sdktranslator.FormatInteractions, test.to, "model", body, false) + if result := gjson.GetBytes(out, test.path); result.Exists() { + t.Fatalf("invalid Interactions summary wrote %s=%s; body=%s", test.path, result.Raw, out) + } + }) + } +} + +func TestSummaryIntentFinalPipeline(t *testing.T) { + reg := registry.GetGlobalRegistry() + uid := fmt.Sprintf("summary-final-pipeline-%d", time.Now().UnixNano()) + reg.RegisterClient(uid, "test", getTestModels()) + defer reg.UnregisterClient(uid) + + tests := []struct { + name string + from sdktranslator.Format + to sdktranslator.Format + model string + body string + path string + want string + wantExists bool + }{ + {name: "Responses summary only activates visible Claude thinking", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"summary":"auto"},"input":"hi"}`, path: "thinking.display", want: "summarized", wantExists: true}, + {name: "Responses null summary only activates hidden Claude thinking", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"summary":null},"input":"hi"}`, path: "thinking.display", want: "omitted", wantExists: true}, + {name: "Responses default keeps Claude display default", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","input":"hi"}`, path: "thinking.display"}, + {name: "Chat summary alias only activates valid Claude thinking", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"exclude":false},"messages":[{"role":"user","content":"hi"}]}`, path: "thinking.display", want: "summarized", wantExists: true}, + {name: "Interactions summary only activates valid Claude thinking", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","generation_config":{"thinking_summaries":"auto"},"input":"hi"}`, path: "thinking.display", want: "summarized", wantExists: true}, + {name: "Claude suffix none removes otherwise enabled display", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model(none)", body: `{"model":"claude-sonnet-4-6-model(none)","reasoning":{"summary":"auto"},"input":"hi"}`, path: "thinking.display"}, + {name: "Claude suffix preserves explicit disabled summary", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model(high)", body: `{"model":"claude-sonnet-4-6-model(high)","reasoning":{"summary":null},"input":"hi"}`, path: "thinking.display", want: "omitted", wantExists: true}, + {name: "Responses effort alone stays omitted on Antigravity", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"antigravity-budget-model","reasoning":{"effort":"medium"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts"}, + {name: "Responses summary reaches Antigravity", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"antigravity-budget-model","reasoning":{"effort":"medium","summary":"auto"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, + {name: "Google Chat extension false survives Gemini applier", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatGemini, model: "gemini-mixed-model", body: `{"model":"gemini-mixed-model","reasoning_effort":"high","extra_body":{"google":{"thinking_config":{"include_thoughts":false}}},"messages":[{"role":"user","content":"hi"}]}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "false", wantExists: true}, + // Captured from isolated Claude Code 2.1.220 with + // alwaysThinkingEnabled:true. Sonnet uses adaptive thinking, while Haiku + // uses manual enabled thinking with a budget; both explicitly omit text. + {name: "Claude Code Sonnet omitted thinking reaches Gemini", from: sdktranslator.FormatClaude, to: sdktranslator.FormatGemini, model: "gemini-mixed-model", body: `{"model":"claude-sonnet-4-6","thinking":{"type":"adaptive","display":"omitted"},"output_config":{"effort":"high"},"messages":[{"role":"user","content":"hi"}]}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "false", wantExists: true}, + {name: "Claude Code Sonnet omitted thinking reaches Antigravity", from: sdktranslator.FormatClaude, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"claude-sonnet-4-6","thinking":{"type":"adaptive","display":"omitted"},"output_config":{"effort":"high"},"messages":[{"role":"user","content":"hi"}]}`, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "false", wantExists: true}, + {name: "Claude Code Haiku omitted thinking reaches Gemini", from: sdktranslator.FormatClaude, to: sdktranslator.FormatGemini, model: "gemini-mixed-model", body: `{"model":"claude-haiku-4-5-20251001","thinking":{"type":"enabled","budget_tokens":31999,"display":"omitted"},"messages":[{"role":"user","content":"hi"}]}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "false", wantExists: true}, + {name: "Claude Code Haiku omitted thinking reaches Antigravity", from: sdktranslator.FormatClaude, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"claude-haiku-4-5-20251001","thinking":{"type":"enabled","budget_tokens":31999,"display":"omitted"},"messages":[{"role":"user","content":"hi"}]}`, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "false", wantExists: true}, + {name: "Summary-only control is stripped for non-thinking Gemini model", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatGemini, model: "no-thinking-model", body: `{"model":"no-thinking-model","reasoning":{"summary":"auto"},"input":"hi"}`, path: "generationConfig.thinkingConfig"}, + {name: "Interactions level alone keeps summaries omitted", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatInteractions, model: "level-model", body: `{"model":"level-model","generation_config":{"thinking_level":"high"},"input":"hi"}`, path: "generation_config.thinking_summaries"}, + {name: "Interactions auto survives its applier", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatInteractions, model: "level-model", body: `{"model":"level-model","generation_config":{"thinking_level":"high","thinking_summaries":"auto"},"input":"hi"}`, path: "generation_config.thinking_summaries", want: "auto", wantExists: true}, + {name: "Deprecated Responses detail reaches Codex", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatCodex, model: "level-model", body: `{"model":"level-model","reasoning":{"effort":"high","generate_summary":"detailed"},"input":"hi"}`, path: "reasoning.summary", want: "detailed", wantExists: true}, + {name: "Gemini missing includeThoughts stays omitted on Claude", from: sdktranslator.FormatGemini, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, path: "thinking.display"}, + {name: "Gemini true includeThoughts reaches Claude", from: sdktranslator.FormatGemini, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","generationConfig":{"thinkingConfig":{"thinkingLevel":"high","includeThoughts":true}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, path: "thinking.display", want: "summarized", wantExists: true}, + {name: "Native Antigravity budget keeps visibility omitted", from: sdktranslator.FormatAntigravity, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"antigravity-budget-model","request":{"generationConfig":{"thinkingConfig":{"thinkingBudget":8192}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`, path: "request.generationConfig.thinkingConfig.includeThoughts"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + baseModel := thinking.ParseSuffix(test.model).ModelName + out := sdktranslator.TranslateRequest(test.from, test.to, baseModel, []byte(test.body), true) + var err error + out, err = thinking.ApplyThinkingWithSummary(out, test.model, test.from.String(), test.to.String(), test.to.String(), thinking.ExtractSummaryConfig([]byte(test.body), test.from.String())) + if err != nil { + t.Fatalf("ApplyThinking() error = %v; body=%s", err, out) + } + result := gjson.GetBytes(out, test.path) + if result.Exists() != test.wantExists { + t.Fatalf("%s exists = %v, want %v; body=%s", test.path, result.Exists(), test.wantExists, out) + } + if test.wantExists && result.String() != test.want { + t.Fatalf("%s = %q, want %q; body=%s", test.path, result.String(), test.want, out) + } + if test.to == sdktranslator.FormatClaude && gjson.GetBytes(out, "thinking.type").String() == "disabled" && gjson.GetBytes(out, "thinking.display").Exists() { + t.Fatalf("disabled Claude thinking retained display: %s", out) + } + }) + } +} + +func TestNativeClaudeMissingDisplayPreservesSignatureOnlyHistory(t *testing.T) { + body := []byte(`{"model":"claude-opus-5","thinking":{"type":"adaptive"},"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"opus-signature"}]},{"role":"user","content":"continue"}]}`) + out := sdktranslator.TranslateRequest(sdktranslator.FormatClaude, sdktranslator.FormatClaude, "claude-opus-5", body, true) + if gjson.GetBytes(out, "thinking.display").Exists() { + t.Fatalf("native Claude request without display gained one: %s", out) + } + if got := gjson.GetBytes(out, "messages").Raw; got != gjson.GetBytes(body, "messages").Raw { + t.Fatalf("signature-only history changed: got %s, want %s", got, gjson.GetBytes(body, "messages").Raw) + } +} + +// Antigravity wraps Gemini generateContent, where includeThoughts is an +// independent opt-in. Thinking level/budget changes must preserve explicit +// booleans and leave an omitted visibility control omitted. +func TestAntigravityIncludeThoughtsPreservesExplicitness(t *testing.T) { + reg := registry.GetGlobalRegistry() + uid := fmt.Sprintf("antigravity-summary-default-%d", time.Now().UnixNano()) + reg.RegisterClient(uid, "test", getTestModels()) + defer reg.UnregisterClient(uid) + + const contents = `"contents":[{"role":"user","parts":[{"text":"hi"}]}]` + tests := []struct { + name string + model string + body string + want string + wantExists bool + }{ + {name: "suffix thinking without intent stays omitted", model: "antigravity-budget-model(medium)", body: `{"request":{` + contents + `}}`}, + {name: "native budget without intent stays omitted", model: "antigravity-budget-model", body: `{"request":{"generationConfig":{"thinkingConfig":{"thinkingBudget":8192}},` + contents + `}}`}, + {name: "explicit true is preserved", model: "antigravity-budget-model(medium)", body: `{"request":{"generationConfig":{"thinkingConfig":{"includeThoughts":true}},` + contents + `}}`, want: "true", wantExists: true}, + {name: "explicit false is preserved", model: "antigravity-budget-model(medium)", body: `{"request":{"generationConfig":{"thinkingConfig":{"includeThoughts":false}},` + contents + `}}`, want: "false", wantExists: true}, + {name: "explicit snake case false is preserved", model: "antigravity-budget-model(medium)", body: `{"request":{"generationConfig":{"thinkingConfig":{"include_thoughts":false}},` + contents + `}}`, want: "false", wantExists: true}, + {name: "disabled thinking without summary intent stays omitted", model: "antigravity-budget-model(none)", body: `{"request":{` + contents + `}}`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + out, err := thinking.ApplyThinking([]byte(test.body), test.model, "antigravity", "antigravity", "antigravity") + if err != nil { + t.Fatalf("ApplyThinking() error = %v; body=%s", err, out) + } + result := gjson.GetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts") + if result.Exists() != test.wantExists { + t.Fatalf("includeThoughts exists = %v, want %v; body=%s", result.Exists(), test.wantExists, out) + } + if test.wantExists { + if got := fmt.Sprintf("%v", result.Bool()); got != test.want { + t.Fatalf("includeThoughts = %s, want %s; body=%s", got, test.want, out) + } + } + if gjson.GetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts").Exists() { + t.Fatalf("snake_case includeThoughts left in payload: %s", out) + } + }) + } +} diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index 2a95d107..07dfb039 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -1456,25 +1456,30 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { includeThoughts: "false", expectErr: false, }, - // Case 31A: reasoning_effort=none with zero allowed → delete thinkingConfig + // Case 31A: reasoning_effort=none with zero allowed removes the amount but + // preserves Chat's explicit disabled summary intent. { - name: "31A", - from: "openai", - to: "gemini", - model: "gemini-toggle-mixed-model", - inputJSON: `{"model":"gemini-toggle-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, - expectField: "", - expectErr: false, + name: "31A", + from: "openai", + to: "gemini", + model: "gemini-toggle-mixed-model", + inputJSON: `{"model":"gemini-toggle-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, + expectField: "generationConfig.thinkingConfig.includeThoughts", + expectValue: "false", + includeThoughts: "false", + expectErr: false, }, - // Case 31B: reasoning_effort=none with zero allowed to Antigravity → delete thinkingConfig + // Case 31B: the same explicit disabled intent survives Antigravity. { - name: "31B", - from: "openai", - to: "antigravity", - model: "gemini-toggle-mixed-model", - inputJSON: `{"model":"gemini-toggle-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, - expectField: "", - expectErr: false, + name: "31B", + from: "openai", + to: "antigravity", + model: "gemini-toggle-mixed-model", + inputJSON: `{"model":"gemini-toggle-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, + expectField: "request.generationConfig.thinkingConfig.includeThoughts", + expectValue: "false", + includeThoughts: "false", + expectErr: false, }, // Case 31C: reasoning.effort=none with zero allowed → delete thinkingConfig { @@ -2448,7 +2453,7 @@ func TestThinkingE2EProviderTargets(t *testing.T) { expectValue: "high", }, - // Interactions target: native API uses generation_config.thinking_level and thinking_summaries. + // Interactions target: native API uses generation_config.thinking_level and optional thinking_summaries. { name: "I1", from: "interactions", @@ -2461,16 +2466,310 @@ func TestThinkingE2EProviderTargets(t *testing.T) { expectValue2: "auto", }, { - name: "I2", + name: "I2", + from: "interactions", + to: "interactions", + model: "level-model(8192)", + inputJSON: `{"model":"level-model(8192)","input":"hi"}`, + expectField: "generation_config.thinking_level", + expectValue: "medium", + }, + // Responses client against a chat-shaped provider. Because thinking is read + // back off the translated body, this pair only works if the request translator + // rewrites reasoning.effort as reasoning_effort; nothing else covered it. + { + name: "R1", + from: "openai-response", + to: "openai", + model: "level-model", + inputJSON: `{"model":"level-model","input":"hi","reasoning":{"effort":"high"}}`, + expectField: "reasoning_effort", + expectValue: "high", + }, + { + name: "R2", + from: "openai-response", + to: "openai", + model: "level-model", + inputJSON: `{"model":"level-model","input":"hi","reasoning":{"effort":"none"}}`, + expectField: "reasoning_effort", + expectValue: "minimal", + }, + { + name: "R3", + from: "openai-response", + to: "kimi", + model: "kimi-toggle-thinking-model", + inputJSON: `{"model":"kimi-toggle-thinking-model","input":"hi","reasoning":{"effort":"high"}}`, + expectField: "thinking.type", + expectValue: "enabled", + expectField2: "thinking.effort", + expectValue2: "high", + expectAbsent: []string{"reasoning_effort"}, + }, + { + name: "R4", + from: "openai-response", + to: "antigravity", + model: "antigravity-budget-model", + inputJSON: `{"model":"antigravity-budget-model","input":"hi","reasoning":{"effort":"medium"}}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + }, + } + + runThinkingTests(t, cases) +} + +// TestThinkingE2EInteractionsMatrix covers the Interactions protocol in both +// directions, which the suffix and body matrices above barely touch. +// +// Interactions expresses thinking through generation_config.thinking_level and the +// independent auto/none generation_config.thinking_summaries control. Compatibility +// thinking_budget and none/auto level inputs map onto a documented target level. The +// IN cases drive Interactions +// as the provider from every client protocol; the OUT cases drive an Interactions +// client against every provider, so an explicit on/off request has to survive the +// round trip in both roles. +func TestThinkingE2EInteractionsMatrix(t *testing.T) { + reg := registry.GetGlobalRegistry() + uid := fmt.Sprintf("thinking-e2e-interactions-%d", time.Now().UnixNano()) + + reg.RegisterClient(uid, "test", getTestModels()) + defer reg.UnregisterClient(uid) + + cases := []thinkingTestCase{ + // Interactions as provider: explicit on from every client protocol. + { + name: "IN1", + from: "claude", + to: "interactions", + model: "level-model", + inputJSON: `{"model":"level-model","max_tokens":1024,"messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":10000}}`, + expectField: "generation_config.thinking_level", + expectValue: "high", + }, + { + name: "IN2", + from: "openai", + to: "interactions", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"minimal"}`, + expectField: "generation_config.thinking_level", + expectValue: "minimal", + }, + { + name: "IN3", + from: "openai-response", + to: "interactions", + model: "level-model", + inputJSON: `{"model":"level-model","input":"hi","reasoning":{"effort":"low"}}`, + expectField: "generation_config.thinking_level", + expectValue: "low", + }, + { + name: "IN4", + from: "gemini", + to: "interactions", + model: "level-model", + inputJSON: `{"model":"level-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"includeThoughts":true,"thinkingBudget":20000}}}`, + expectField: "generation_config.thinking_level", + expectValue: "high", + }, + // A level the model does not publish falls back to its highest level. + { + name: "IN5", + from: "openai", + to: "interactions", + model: "level-subset-model", + inputJSON: `{"model":"level-subset-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"}`, + expectField: "generation_config.thinking_level", + expectValue: "high", + }, + // Interactions cannot fully disable this model, so thinking clamps to the + // lowest documented level. Summary visibility remains omitted unless the + // source independently requested it. + { + name: "IN6", + from: "claude", + to: "interactions", + model: "level-model", + inputJSON: `{"model":"level-model","max_tokens":1024,"messages":[{"role":"user","content":"hi"}],"thinking":{"type":"disabled"}}`, + expectField: "generation_config.thinking_level", + expectValue: "minimal", + }, + { + name: "IN7", + from: "openai", + to: "interactions", + model: "level-model(none)", + inputJSON: `{"model":"level-model(none)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "generation_config.thinking_level", + expectValue: "minimal", + }, + { + name: "IN8", + from: "interactions", + to: "interactions", + model: "level-model", + inputJSON: `{"model":"level-model","generation_config":{"thinking_level":"none"},"input":"hi"}`, + expectField: "generation_config.thinking_level", + expectValue: "minimal", + }, + // Interactions supports auto as its only enabled summary selector. + { + name: "IN9", from: "interactions", to: "interactions", - model: "level-model(8192)", - inputJSON: `{"model":"level-model(8192)","input":"hi"}`, + model: "level-model", + inputJSON: `{"model":"level-model","generation_config":{"thinking_level":"low","thinking_summaries":"auto"},"input":"hi"}`, expectField: "generation_config.thinking_level", - expectValue: "medium", + expectValue: "low", expectField2: "generation_config.thinking_summaries", expectValue2: "auto", }, + // A legacy thinking_budget maps onto the level enum. + { + name: "IN10", + from: "interactions", + to: "interactions", + model: "level-model", + inputJSON: `{"model":"level-model","generation_config":{"thinking_budget":400},"input":"hi"}`, + expectField: "generation_config.thinking_level", + expectValue: "minimal", + }, + // Auto on a model without dynamic thinking resolves to the mid-range level, + // the same normalization every other target gets. + { + name: "IN11", + from: "interactions", + to: "interactions", + model: "level-model", + inputJSON: `{"model":"level-model","generation_config":{"thinking_budget":-1},"input":"hi"}`, + expectField: "generation_config.thinking_level", + expectValue: "medium", + }, + + // Interactions as client: explicit on has to reach every provider's own knob. + { + name: "OUT1", + from: "interactions", + to: "claude", + model: "claude-budget-model", + inputJSON: `{"model":"claude-budget-model","generation_config":{"thinking_level":"medium"},"input":"hi"}`, + expectField: "thinking.budget_tokens", + expectValue: "8192", + }, + { + name: "OUT2", + from: "interactions", + to: "openai", + model: "level-model", + inputJSON: `{"model":"level-model","generation_config":{"thinking_level":"high"},"input":"hi"}`, + expectField: "reasoning_effort", + expectValue: "high", + }, + { + name: "OUT3", + from: "interactions", + to: "codex", + model: "level-model", + inputJSON: `{"model":"level-model","generation_config":{"thinking_level":"low"},"input":"hi"}`, + expectField: "reasoning.effort", + expectValue: "low", + }, + { + name: "OUT4", + from: "interactions", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","generation_config":{"thinking_level":"medium"},"input":"hi"}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + }, + { + name: "OUT5", + from: "interactions", + to: "antigravity", + model: "antigravity-budget-model", + inputJSON: `{"model":"antigravity-budget-model","generation_config":{"thinking_level":"medium"},"input":"hi"}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + }, + { + name: "OUT6", + from: "interactions", + to: "kimi", + model: "kimi-toggle-thinking-model", + inputJSON: `{"model":"kimi-toggle-thinking-model","generation_config":{"thinking_level":"high"},"input":"hi"}`, + expectField: "thinking.type", + expectValue: "enabled", + expectField2: "thinking.effort", + expectValue2: "high", + }, + { + name: "OUT7", + from: "interactions", + to: "xai", + model: "xai-level-model", + inputJSON: `{"model":"xai-level-model","generation_config":{"thinking_level":"high"},"input":"hi"}`, + expectField: "reasoning.effort", + expectValue: "high", + }, + // Interactions as client: explicit off has to reach every provider's own way + // of saying no thinking. + { + name: "OUT8", + from: "interactions", + to: "claude", + model: "claude-budget-model", + inputJSON: `{"model":"claude-budget-model","generation_config":{"thinking_level":"none"},"input":"hi"}`, + expectField: "thinking.type", + expectValue: "disabled", + }, + { + name: "OUT9", + from: "interactions", + to: "antigravity", + model: "antigravity-budget-model", + inputJSON: `{"model":"antigravity-budget-model","generation_config":{"thinking_level":"none"},"input":"hi"}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "0", + includeThoughts: "false", + }, + { + name: "OUT10", + from: "interactions", + to: "kimi", + model: "kimi-toggle-thinking-model", + inputJSON: `{"model":"kimi-toggle-thinking-model","generation_config":{"thinking_level":"none"},"input":"hi"}`, + expectField: "thinking.type", + expectValue: "disabled", + expectAbsent: []string{"thinking.effort", "reasoning_effort"}, + }, + // A level+budget model that allows zero expresses off by dropping + // thinkingConfig entirely, so an Interactions client reaches the same shape a + // chat or Responses client does. + { + name: "OUT11", + from: "interactions", + to: "gemini", + model: "gemini-toggle-mixed-model", + inputJSON: `{"model":"gemini-toggle-mixed-model","generation_config":{"thinking_level":"none"},"input":"hi"}`, + expectAbsent: []string{"generationConfig.thinkingConfig"}, + }, + // Auto reaches a dynamic-capable provider as dynamic thinking. + { + name: "OUT12", + from: "interactions", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","generation_config":{"thinking_level":"auto"},"input":"hi"}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "-1", + }, } runThinkingTests(t, cases) @@ -3204,18 +3503,36 @@ func runThinkingTests(t *testing.T, cases []thinkingTestCase) { assertField(tc.expectField3, tc.expectValue3) } - if tc.includeThoughts != "" && (tc.to == "gemini" || tc.to == "antigravity") { + if tc.to == "gemini" || tc.to == "antigravity" { path := "generationConfig.thinkingConfig.includeThoughts" if tc.to == "antigravity" { path = "request.generationConfig.thinkingConfig.includeThoughts" } - itVal := gjson.GetBytes(body, path) - if !itVal.Exists() { - t.Fatalf("expected includeThoughts field not found, body=%s", string(body)) + wantIncludeThoughts := "" + summaryConfig := thinking.ExtractSummaryConfig([]byte(tc.inputJSON), tc.from) + switch summaryConfig.Mode { + case thinking.SummaryEnabled: + wantIncludeThoughts = "true" + case thinking.SummaryDisabled: + wantIncludeThoughts = "false" + default: + // Thinking amount does not imply summary visibility. Keep the + // provider field absent when the source omitted its summary control. } - actual := fmt.Sprintf("%v", itVal.Bool()) - if actual != tc.includeThoughts { - t.Fatalf("includeThoughts: expected %s, got %s, body=%s", tc.includeThoughts, actual, string(body)) + + itVal := gjson.GetBytes(body, path) + if wantIncludeThoughts == "" { + if itVal.Exists() { + t.Fatalf("includeThoughts should be absent without summary intent, body=%s", string(body)) + } + } else { + if !itVal.Exists() { + t.Fatalf("expected includeThoughts field not found, body=%s", string(body)) + } + actual := fmt.Sprintf("%v", itVal.Bool()) + if actual != wantIncludeThoughts { + t.Fatalf("includeThoughts: expected %s, got %s, body=%s", wantIncludeThoughts, actual, string(body)) + } } } }) -- 2.51.2 From b63a38d566ab04bf2237084771d7bc303475031c Mon Sep 17 00:00:00 2001 From: sususu Date: Thu, 30 Jul 2026 22:38:07 +0800 Subject: [PATCH 19/31] fix(thinking): avoid enabling Claude for hidden summaries --- .../thinking/apply_configured_api_key_test.go | 58 ++++++++++-------- internal/thinking/summary.go | 12 ++-- internal/thinking/summary_test.go | 38 +++++++----- sdk/translator/registry_summary_test.go | 61 ++++++++++--------- test/summary_intent_translation_test.go | 4 +- 5 files changed, 98 insertions(+), 75 deletions(-) diff --git a/internal/thinking/apply_configured_api_key_test.go b/internal/thinking/apply_configured_api_key_test.go index 5aa3ce9d..a443491e 100644 --- a/internal/thinking/apply_configured_api_key_test.go +++ b/internal/thinking/apply_configured_api_key_test.go @@ -92,36 +92,44 @@ func TestApplyThinkingWithModelInfoKeepsSameFamilyValidationStrict(t *testing.T) } } -func TestApplyThinkingWithModelInfoAppliesSummaryOnlyClaudeVisibility(t *testing.T) { +func TestApplyThinkingWithModelInfoAppliesEnabledSummaryOnlyClaudeVisibility(t *testing.T) { modelInfo := ®istry.ModelInfo{ ID: "private-claude", Type: "claude", Thinking: ®istry.ThinkingSupport{Levels: []string{"high"}}, } - for _, test := range []struct { - name string - source string - display string - }{ - {name: "enabled", source: `{"reasoning":{"summary":"auto"}}`, display: "summarized"}, - {name: "disabled", source: `{"reasoning":{"summary":null}}`, display: "omitted"}, - } { - t.Run(test.name, func(t *testing.T) { - out, err := thinking.ApplyThinkingWithModelInfo( - []byte(`{"model":"private-claude","max_tokens":32000}`), - []byte(test.source), - "private-claude", "openai-response", "claude", "claude", modelInfo, - ) - if err != nil { - t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err) - } - if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" { - t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out) - } - if got := gjson.GetBytes(out, "thinking.display").String(); got != test.display { - t.Fatalf("thinking.display = %q, want %q; body=%s", got, test.display, out) - } - }) + out, err := thinking.ApplyThinkingWithModelInfo( + []byte(`{"model":"private-claude","max_tokens":32000}`), + []byte(`{"reasoning":{"summary":"auto"}}`), + "private-claude", "openai-response", "claude", "claude", modelInfo, + ) + if err != nil { + t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err) + } + if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" { + t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out) + } + if got := gjson.GetBytes(out, "thinking.display").String(); got != "summarized" { + t.Fatalf("thinking.display = %q, want summarized; body=%s", got, out) + } +} + +func TestApplyThinkingWithModelInfoDoesNotActivateClaudeForDisabledSummary(t *testing.T) { + modelInfo := ®istry.ModelInfo{ + ID: "private-claude", + Type: "claude", + Thinking: ®istry.ThinkingSupport{Levels: []string{"high"}}, + } + out, err := thinking.ApplyThinkingWithModelInfo( + []byte(`{"model":"private-claude","max_tokens":32000}`), + []byte(`{"reasoning":{"summary":null}}`), + "private-claude", "openai-response", "claude", "claude", modelInfo, + ) + if err != nil { + t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err) + } + if gjson.GetBytes(out, "thinking").Exists() { + t.Fatalf("disabled summary activated Claude thinking: %s", out) } } diff --git a/internal/thinking/summary.go b/internal/thinking/summary.go index 4a19dae1..430a35d8 100644 --- a/internal/thinking/summary.go +++ b/internal/thinking/summary.go @@ -126,12 +126,12 @@ func applySummaryConfigForModel(body []byte, format, model string, modelInfo *re body = applyOpenAIChatSummaryConfig(body, model, enabled) case "claude": // Anthropic documents display as invalid with thinking.type=disabled and - // requires it alongside adaptive or enabled thinking. An explicit source - // visibility request is independent of thinking effort, so activate the - // target model's documented thinking mode before writing either - // summarized or omitted. Unspecified intent returns above and leaves the - // target's default untouched. - if !gjson.GetBytes(body, "thinking.type").Exists() { + // requires it alongside adaptive or enabled thinking. An enabled source + // summary needs an active target thinking mode. A disabled summary only + // hides an already-active target thinking mode; it must not enable thinking + // merely to hide a summary that would not otherwise exist. Unspecified + // intent returns above and leaves the target's default untouched. + if enabled && !gjson.GetBytes(body, "thinking.type").Exists() { body = enableClaudeThinkingForSummary(body, model, modelInfo) } if !claudeThinkingAcceptsDisplay(body) { diff --git a/internal/thinking/summary_test.go b/internal/thinking/summary_test.go index 6c9011f1..59bd2eb4 100644 --- a/internal/thinking/summary_test.go +++ b/internal/thinking/summary_test.go @@ -149,29 +149,25 @@ func TestApplySummaryConfig_ClaudeDisplayRequiresActiveThinking(t *testing.T) { } } -func TestApplySummaryConfigForModel_ClaudeExplicitVisibilityUsesValidThinkingMode(t *testing.T) { +func TestApplySummaryConfigForModel_ClaudeEnabledSummaryUsesValidThinkingMode(t *testing.T) { tests := []struct { - name string - model string - body string - mode SummaryMode - wantType string - wantDisplay string - wantBudget int64 + name string + model string + body string + wantType string + wantBudget int64 }{ - {name: "adaptive model summarized", model: "claude-opus-5", body: `{"model":"claude-opus-5","max_tokens":32000}`, mode: SummaryEnabled, wantType: "adaptive", wantDisplay: "summarized"}, - {name: "adaptive model omitted", model: "claude-opus-5", body: `{"model":"claude-opus-5","max_tokens":32000}`, mode: SummaryDisabled, wantType: "adaptive", wantDisplay: "omitted"}, - {name: "manual model summarized", model: "claude-haiku-4-5-20251001", body: `{"model":"claude-haiku-4-5-20251001","max_tokens":32000}`, mode: SummaryEnabled, wantType: "enabled", wantDisplay: "summarized", wantBudget: 1024}, - {name: "manual model omitted", model: "claude-haiku-4-5-20251001", body: `{"model":"claude-haiku-4-5-20251001","max_tokens":32000}`, mode: SummaryDisabled, wantType: "enabled", wantDisplay: "omitted", wantBudget: 1024}, + {name: "adaptive model", model: "claude-opus-5", body: `{"model":"claude-opus-5","max_tokens":32000}`, wantType: "adaptive"}, + {name: "manual model", model: "claude-haiku-4-5-20251001", body: `{"model":"claude-haiku-4-5-20251001","max_tokens":32000}`, wantType: "enabled", wantBudget: 1024}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - out := ApplySummaryConfigForModel([]byte(test.body), "claude", test.model, SummaryConfig{Mode: test.mode}) + out := ApplySummaryConfigForModel([]byte(test.body), "claude", test.model, SummaryConfig{Mode: SummaryEnabled}) if got := gjson.GetBytes(out, "thinking.type").String(); got != test.wantType { t.Fatalf("thinking.type = %q, want %q; body=%s", got, test.wantType, out) } - if got := gjson.GetBytes(out, "thinking.display").String(); got != test.wantDisplay { - t.Fatalf("thinking.display = %q, want %q; body=%s", got, test.wantDisplay, out) + if got := gjson.GetBytes(out, "thinking.display").String(); got != "summarized" { + t.Fatalf("thinking.display = %q, want summarized; body=%s", got, out) } if test.wantBudget > 0 && gjson.GetBytes(out, "thinking.budget_tokens").Int() != test.wantBudget { t.Fatalf("thinking.budget_tokens = %d, want %d; body=%s", gjson.GetBytes(out, "thinking.budget_tokens").Int(), test.wantBudget, out) @@ -180,6 +176,18 @@ func TestApplySummaryConfigForModel_ClaudeExplicitVisibilityUsesValidThinkingMod } } +// Disabling summaries must not activate Claude thinking. Doing so would add +// reasoning tokens, latency, and cost to a request that asked only to hide output. +func TestApplySummaryConfigForModel_ClaudeDisabledSummaryDoesNotEnableThinking(t *testing.T) { + for _, model := range []string{"claude-opus-5", "claude-haiku-4-5-20251001"} { + body := []byte(`{"model":"` + model + `","max_tokens":32000}`) + out := ApplySummaryConfigForModel(body, "claude", model, SummaryConfig{Mode: SummaryDisabled}) + if gjson.GetBytes(out, "thinking").Exists() { + t.Fatalf("model %s gained thinking for a disabled summary: %s", model, out) + } + } +} + func TestApplySummaryConfig_ResponsesNormalizesDeprecatedGenerateSummary(t *testing.T) { out := ApplySummaryConfig([]byte(`{"reasoning":{"generate_summary":"detailed"}}`), "openai-response", SummaryConfig{Mode: SummaryEnabled, Detail: "detailed"}) if got := gjson.GetBytes(out, "reasoning.summary").String(); got != "detailed" { diff --git a/sdk/translator/registry_summary_test.go b/sdk/translator/registry_summary_test.go index 79312dd8..312ee57b 100644 --- a/sdk/translator/registry_summary_test.go +++ b/sdk/translator/registry_summary_test.go @@ -85,35 +85,40 @@ func TestRegistryTranslateRequestAppliesSummaryIntent(t *testing.T) { } } -func TestRegistryTranslateRequestMakesExplicitClaudeVisibilityValid(t *testing.T) { - tests := []struct { - name string - input string - wantDisplay string - }{ - {name: "summary auto is visible", input: `{"reasoning":{"summary":"auto"},"input":"hi"}`, wantDisplay: "summarized"}, - {name: "summary null is hidden", input: `{"reasoning":{"summary":null},"input":"hi"}`, wantDisplay: "omitted"}, +func TestRegistryTranslateRequestActivatesClaudeForEnabledSummary(t *testing.T) { + registry := NewRegistry() + registry.Register(FormatOpenAIResponse, FormatClaude, func(_ string, _ []byte, _ bool) []byte { + return []byte(`{"model":"claude-opus-5","max_tokens":32000}`) + }, ResponseTransform{}) + out := registry.TranslateRequest( + FormatOpenAIResponse, + FormatClaude, + "claude-opus-5", + []byte(`{"reasoning":{"summary":"auto"},"input":"hi"}`), + false, + ) + if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" { + t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out) } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - registry := NewRegistry() - registry.Register(FormatOpenAIResponse, FormatClaude, func(_ string, _ []byte, _ bool) []byte { - return []byte(`{"model":"claude-opus-5","max_tokens":32000}`) - }, ResponseTransform{}) - out := registry.TranslateRequest( - FormatOpenAIResponse, - FormatClaude, - "claude-opus-5", - []byte(test.input), - false, - ) - if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" { - t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out) - } - if got := gjson.GetBytes(out, "thinking.display").String(); got != test.wantDisplay { - t.Fatalf("thinking.display = %q, want %q; body=%s", got, test.wantDisplay, out) - } - }) + if got := gjson.GetBytes(out, "thinking.display").String(); got != "summarized" { + t.Fatalf("thinking.display = %q, want summarized; body=%s", got, out) + } +} + +func TestRegistryTranslateRequestDoesNotActivateClaudeForDisabledSummary(t *testing.T) { + registry := NewRegistry() + registry.Register(FormatOpenAIResponse, FormatClaude, func(_ string, _ []byte, _ bool) []byte { + return []byte(`{"model":"claude-opus-5","max_tokens":32000}`) + }, ResponseTransform{}) + out := registry.TranslateRequest( + FormatOpenAIResponse, + FormatClaude, + "claude-opus-5", + []byte(`{"reasoning":{"summary":null},"input":"hi"}`), + false, + ) + if gjson.GetBytes(out, "thinking").Exists() { + t.Fatalf("disabled summary activated Claude thinking: %s", out) } } diff --git a/test/summary_intent_translation_test.go b/test/summary_intent_translation_test.go index e2cfdd99..06edfdad 100644 --- a/test/summary_intent_translation_test.go +++ b/test/summary_intent_translation_test.go @@ -119,7 +119,9 @@ func TestSummaryIntentFinalPipeline(t *testing.T) { wantExists bool }{ {name: "Responses summary only activates visible Claude thinking", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"summary":"auto"},"input":"hi"}`, path: "thinking.display", want: "summarized", wantExists: true}, - {name: "Responses null summary only activates hidden Claude thinking", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"summary":null},"input":"hi"}`, path: "thinking.display", want: "omitted", wantExists: true}, + // Disabling summaries alone must not activate Claude thinking: doing so adds + // reasoning tokens, latency, and cost to a request with no thinking effort. + {name: "Responses null summary alone keeps Claude thinking disabled", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"summary":null},"input":"hi"}`, path: "thinking"}, {name: "Responses default keeps Claude display default", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","input":"hi"}`, path: "thinking.display"}, {name: "Chat summary alias only activates valid Claude thinking", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"exclude":false},"messages":[{"role":"user","content":"hi"}]}`, path: "thinking.display", want: "summarized", wantExists: true}, {name: "Interactions summary only activates valid Claude thinking", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","generation_config":{"thinking_summaries":"auto"},"input":"hi"}`, path: "thinking.display", want: "summarized", wantExists: true}, -- 2.51.2 From 76008b4720823747560705a8b0f2baa2a10bd1df Mon Sep 17 00:00:00 2001 From: sususu Date: Thu, 30 Jul 2026 22:44:01 +0800 Subject: [PATCH 20/31] fix(thinking): decouple Interactions effort summaries --- .../interactions_antigravity_request.go | 5 ++-- .../interactions_antigravity_test.go | 29 +++++++++++++++++++ test/summary_intent_translation_test.go | 3 ++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/internal/translator/antigravity/interactions/interactions_antigravity_request.go b/internal/translator/antigravity/interactions/interactions_antigravity_request.go index 2d00a4f3..53d9df0e 100644 --- a/internal/translator/antigravity/interactions/interactions_antigravity_request.go +++ b/internal/translator/antigravity/interactions/interactions_antigravity_request.go @@ -195,12 +195,13 @@ func copyInteractionsReasoningToAntigravity(out []byte, root gjson.Result) []byt effort = strings.ToLower(strings.TrimSpace(reasoning.Get("thinking_level").String())) } if effort != "" { + // Thinking amount and summary visibility are independent. This OpenAI-style + // compatibility alias controls only the amount; includeThoughts is written + // below only for an explicit Interactions summary selector. if effort == "auto" { out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", -1) - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", true) } else { out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel", effort) - out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", effort != "none") } } if summary := reasoning.Get("summary"); summary.Exists() { diff --git a/internal/translator/antigravity/interactions/interactions_antigravity_test.go b/internal/translator/antigravity/interactions/interactions_antigravity_test.go index d6baa800..d0052a7b 100644 --- a/internal/translator/antigravity/interactions/interactions_antigravity_test.go +++ b/internal/translator/antigravity/interactions/interactions_antigravity_test.go @@ -68,6 +68,35 @@ func TestConvertInteractionsRequestToAntigravityPreservesGenerationConfig(t *tes } } +func TestConvertInteractionsReasoningToAntigravityKeepsSummaryIndependent(t *testing.T) { + tests := []struct { + name string + reasoning string + want bool + wantExists bool + }{ + {name: "effort only leaves summaries unspecified", reasoning: `{"effort":"high"}`}, + {name: "explicit auto enables summaries", reasoning: `{"effort":"high","summary":"auto"}`, want: true, wantExists: true}, + {name: "explicit none disables summaries", reasoning: `{"effort":"high","summary":"none"}`, wantExists: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := []byte(`{"model":"antigravity-test","input":"hi","reasoning":` + test.reasoning + `}`) + out := ConvertInteractionsRequestToAntigravity("antigravity-test", body, false) + if got := gjson.GetBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel").String(); got != "high" { + t.Fatalf("thinkingLevel = %q, want high. Output: %s", got, out) + } + includeThoughts := gjson.GetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts") + if includeThoughts.Exists() != test.wantExists { + t.Fatalf("includeThoughts exists = %v, want %v. Output: %s", includeThoughts.Exists(), test.wantExists, out) + } + if test.wantExists && includeThoughts.Bool() != test.want { + t.Fatalf("includeThoughts = %v, want %v. Output: %s", includeThoughts.Bool(), test.want, out) + } + }) + } +} + func TestConvertAntigravityResponseToInteractionsNonStream(t *testing.T) { raw := []byte(`{"response":{"responseId":"resp_1","candidates":[{"content":{"role":"model","parts":[{"text":"ok"},{"functionCall":{"name":"lookup","id":"call_1","args":{"q":"x"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":2,"totalTokenCount":5}}}`) out := ConvertAntigravityResponseToInteractionsNonStream(context.Background(), "antigravity-test", nil, nil, raw, nil) diff --git a/test/summary_intent_translation_test.go b/test/summary_intent_translation_test.go index 06edfdad..5cb01362 100644 --- a/test/summary_intent_translation_test.go +++ b/test/summary_intent_translation_test.go @@ -140,6 +140,9 @@ func TestSummaryIntentFinalPipeline(t *testing.T) { {name: "Summary-only control is stripped for non-thinking Gemini model", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatGemini, model: "no-thinking-model", body: `{"model":"no-thinking-model","reasoning":{"summary":"auto"},"input":"hi"}`, path: "generationConfig.thinkingConfig"}, {name: "Interactions level alone keeps summaries omitted", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatInteractions, model: "level-model", body: `{"model":"level-model","generation_config":{"thinking_level":"high"},"input":"hi"}`, path: "generation_config.thinking_summaries"}, {name: "Interactions auto survives its applier", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatInteractions, model: "level-model", body: `{"model":"level-model","generation_config":{"thinking_level":"high","thinking_summaries":"auto"},"input":"hi"}`, path: "generation_config.thinking_summaries", want: "auto", wantExists: true}, + {name: "Interactions reasoning effort leaves Antigravity summaries unspecified", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"antigravity-budget-model","reasoning":{"effort":"high"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts"}, + {name: "Interactions reasoning summary auto reaches Antigravity", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"antigravity-budget-model","reasoning":{"effort":"high","summary":"auto"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, + {name: "Interactions reasoning summary none reaches Antigravity", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"antigravity-budget-model","reasoning":{"effort":"high","summary":"none"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "false", wantExists: true}, {name: "Deprecated Responses detail reaches Codex", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatCodex, model: "level-model", body: `{"model":"level-model","reasoning":{"effort":"high","generate_summary":"detailed"},"input":"hi"}`, path: "reasoning.summary", want: "detailed", wantExists: true}, {name: "Gemini missing includeThoughts stays omitted on Claude", from: sdktranslator.FormatGemini, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, path: "thinking.display"}, {name: "Gemini true includeThoughts reaches Claude", from: sdktranslator.FormatGemini, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","generationConfig":{"thinkingConfig":{"thinkingLevel":"high","includeThoughts":true}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, path: "thinking.display", want: "summarized", wantExists: true}, -- 2.51.2 From 92b6bc4a868a3e5ecf2804f24239c213c9d6f575 Mon Sep 17 00:00:00 2001 From: sususu Date: Thu, 30 Jul 2026 23:05:14 +0800 Subject: [PATCH 21/31] docs(thinking): clarify Claude model defaults --- internal/thinking/summary.go | 18 +++++++++++++----- internal/thinking/summary_test.go | 5 +++-- test/summary_intent_translation_test.go | 7 ++++--- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/internal/thinking/summary.go b/internal/thinking/summary.go index 430a35d8..5cca0a9b 100644 --- a/internal/thinking/summary.go +++ b/internal/thinking/summary.go @@ -126,11 +126,19 @@ func applySummaryConfigForModel(body []byte, format, model string, modelInfo *re body = applyOpenAIChatSummaryConfig(body, model, enabled) case "claude": // Anthropic documents display as invalid with thinking.type=disabled and - // requires it alongside adaptive or enabled thinking. An enabled source - // summary needs an active target thinking mode. A disabled summary only - // hides an already-active target thinking mode; it must not enable thinking - // merely to hide a summary that would not otherwise exist. Unspecified - // intent returns above and leaves the target's default untouched. + // requires it alongside adaptive or enabled thinking. Model defaults differ: + // Opus 5 and Sonnet 5 default to adaptive thinking; Fable/Mythos 5 are always + // on. Opus 4.8/4.7/4.6, Sonnet 4.6, and the 4.5 models default to thinking + // off. The newest models also default display to omitted. Keeping a missing + // thinking block absent therefore preserves both kinds of model default; + // absence does not mean every Claude model runs without thinking. Only an + // enabled summary may activate a valid target thinking mode so that summarized + // text can be returned. A disabled summary only adds omitted to an + // already-active target mode. + // + // Anthropic docs: + // https://platform.claude.com/docs/en/build-with-claude/thinking + // https://platform.claude.com/docs/en/build-with-claude/thinking-troubleshooting#supported-models if enabled && !gjson.GetBytes(body, "thinking.type").Exists() { body = enableClaudeThinkingForSummary(body, model, modelInfo) } diff --git a/internal/thinking/summary_test.go b/internal/thinking/summary_test.go index 59bd2eb4..1fed4147 100644 --- a/internal/thinking/summary_test.go +++ b/internal/thinking/summary_test.go @@ -176,8 +176,9 @@ func TestApplySummaryConfigForModel_ClaudeEnabledSummaryUsesValidThinkingMode(t } } -// Disabling summaries must not activate Claude thinking. Doing so would add -// reasoning tokens, latency, and cost to a request that asked only to hide output. +// Disabling summaries must not make CPA add a Claude thinking block. Absence +// preserves the per-model default: newer models may still think by default, +// while older models remain off. func TestApplySummaryConfigForModel_ClaudeDisabledSummaryDoesNotEnableThinking(t *testing.T) { for _, model := range []string{"claude-opus-5", "claude-haiku-4-5-20251001"} { body := []byte(`{"model":"` + model + `","max_tokens":32000}`) diff --git a/test/summary_intent_translation_test.go b/test/summary_intent_translation_test.go index 5cb01362..b53c7ca3 100644 --- a/test/summary_intent_translation_test.go +++ b/test/summary_intent_translation_test.go @@ -119,9 +119,10 @@ func TestSummaryIntentFinalPipeline(t *testing.T) { wantExists bool }{ {name: "Responses summary only activates visible Claude thinking", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"summary":"auto"},"input":"hi"}`, path: "thinking.display", want: "summarized", wantExists: true}, - // Disabling summaries alone must not activate Claude thinking: doing so adds - // reasoning tokens, latency, and cost to a request with no thinking effort. - {name: "Responses null summary alone keeps Claude thinking disabled", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"summary":null},"input":"hi"}`, path: "thinking"}, + // Summary visibility must not override Claude's per-model thinking default. + // Sonnet 4.6 defaults off; newer default-on models remain default-on without + // CPA injecting an explicit thinking block. + {name: "Responses null summary alone preserves Claude thinking default", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"summary":null},"input":"hi"}`, path: "thinking"}, {name: "Responses default keeps Claude display default", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","input":"hi"}`, path: "thinking.display"}, {name: "Chat summary alias only activates valid Claude thinking", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"exclude":false},"messages":[{"role":"user","content":"hi"}]}`, path: "thinking.display", want: "summarized", wantExists: true}, {name: "Interactions summary only activates valid Claude thinking", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","generation_config":{"thinking_summaries":"auto"},"input":"hi"}`, path: "thinking.display", want: "summarized", wantExists: true}, -- 2.51.2 From 5d307c195dd23c533f9c5ca9f59216e192101eab Mon Sep 17 00:00:00 2001 From: sususu Date: Thu, 30 Jul 2026 23:26:20 +0800 Subject: [PATCH 22/31] fix(thinking): close summary translation gaps --- .../runtime/executor/aistudio_executor.go | 2 +- .../executor/aistudio_executor_test.go | 20 +++++ internal/thinking/summary.go | 18 ++++- internal/thinking/summary_test.go | 8 ++ .../claude/gemini/claude_gemini_request.go | 4 - sdk/translator/registry.go | 38 ++++++---- sdk/translator/registry_summary_test.go | 73 +++++++++++++++++++ test/summary_intent_translation_test.go | 38 ++++++++++ 8 files changed, 179 insertions(+), 22 deletions(-) diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index d2e78eca..3cabe5da 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -461,7 +461,7 @@ func (e *AIStudioExecutor) translateRequest(ctx context.Context, req cliproxyexe originalPayload := originalPayloadSource originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, stream) payload := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream) - payload, err := helps.ApplyThinkingWithSourcePayload(payload, req.Payload, req.Model, from.String(), to.String(), e.Identifier()) + payload, err := helps.ApplyThinkingWithSourcePayload(payload, originalPayloadSource, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return nil, translatedPayload{}, err } diff --git a/internal/runtime/executor/aistudio_executor_test.go b/internal/runtime/executor/aistudio_executor_test.go index 52ce6147..ea5bd8df 100644 --- a/internal/runtime/executor/aistudio_executor_test.go +++ b/internal/runtime/executor/aistudio_executor_test.go @@ -17,8 +17,28 @@ import ( cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage" sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" ) +func TestAIStudioTranslateRequestPreservesSummaryFromOriginalRequest(t *testing.T) { + executor := NewAIStudioExecutor(&config.Config{}, "aistudio", nil) + req := cliproxyexecutor.Request{ + Model: "gemini-3.6-flash", + Payload: []byte(`{"model":"gemini-3.6-flash","input":"hi"}`), + } + opts := cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatOpenAIResponse, + OriginalRequest: []byte(`{"model":"gemini-3.6-flash","reasoning":{"summary":"auto"},"input":"hi"}`), + } + payload, _, err := executor.translateRequest(context.Background(), req, opts, false) + if err != nil { + t.Fatalf("translateRequest() error = %v", err) + } + if !gjson.GetBytes(payload, "generationConfig.thinkingConfig.includeThoughts").Bool() { + t.Fatalf("original request summary intent was lost: %s", payload) + } +} + func TestAIStudioExecutorExecuteStartsTTFTBeforeRelayWait(t *testing.T) { const authID = "aistudio-ttft-auth" delay := 40 * time.Millisecond diff --git a/internal/thinking/summary.go b/internal/thinking/summary.go index 5cca0a9b..5f7d0ead 100644 --- a/internal/thinking/summary.go +++ b/internal/thinking/summary.go @@ -95,6 +95,14 @@ func ExtractSummaryConfig(body []byte, format string) SummaryConfig { return config } } + if config, ok := firstSummaryBoolConfig(body, []string{ + "generation_config.thinking_config.include_thoughts", + "generation_config.thinking_config.includeThoughts", + "generation_config.thinkingConfig.include_thoughts", + "generation_config.thinkingConfig.includeThoughts", + }); ok { + return config + } } return SummaryConfig{} @@ -213,10 +221,14 @@ func claudeThinkingAcceptsDisplay(body []byte) bool { return true case "enabled": // This runs before ApplyThinking normalizes the request, so a missing - // budget_tokens is an unfinished body rather than inactive thinking. - // Only an explicit non-positive budget means thinking is off. + // budget_tokens is an unfinished body rather than inactive thinking. CPA + // also accepts -1 as its compatibility representation for auto thinking. budget := gjson.GetBytes(body, "thinking.budget_tokens") - return budget.Type != gjson.Number || budget.Int() > 0 + if budget.Type != gjson.Number { + return true + } + value := budget.Int() + return value == -1 || value > 0 default: return false } diff --git a/internal/thinking/summary_test.go b/internal/thinking/summary_test.go index 1fed4147..da7d7bb2 100644 --- a/internal/thinking/summary_test.go +++ b/internal/thinking/summary_test.go @@ -44,11 +44,19 @@ func TestExtractSummaryConfig(t *testing.T) { // absent budget must not be read as inactive thinking. {name: "claude enabled display without budget is valid", format: "claude", body: `{"thinking":{"type":"enabled","display":"summarized"}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, {name: "claude enabled display with zero budget is invalid", format: "claude", body: `{"thinking":{"type":"enabled","budget_tokens":0,"display":"summarized"}}`, wantMode: SummaryUnspecified}, + {name: "claude auto compatibility budget summarized", format: "claude", body: `{"thinking":{"type":"enabled","budget_tokens":-1,"display":"summarized"}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "claude auto compatibility budget omitted", format: "claude", body: `{"thinking":{"type":"enabled","budget_tokens":-1,"display":"omitted"}}`, wantMode: SummaryDisabled}, {name: "gemini include true", format: "gemini", body: `{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, {name: "gemini include false", format: "gemini", body: `{"generationConfig":{"thinkingConfig":{"includeThoughts":false}}}`, wantMode: SummaryDisabled}, {name: "antigravity include true", format: "antigravity", body: `{"request":{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, {name: "interactions auto", format: "interactions", body: `{"generation_config":{"thinking_summaries":"auto"}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, {name: "interactions none", format: "interactions", body: `{"generation_config":{"thinking_summaries":"none"}}`, wantMode: SummaryDisabled}, + {name: "interactions nested snake include false", format: "interactions", body: `{"generation_config":{"thinking_config":{"include_thoughts":false}}}`, wantMode: SummaryDisabled}, + {name: "interactions nested camel include true", format: "interactions", body: `{"generation_config":{"thinking_config":{"includeThoughts":true}}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "interactions camel config snake include true", format: "interactions", body: `{"generation_config":{"thinkingConfig":{"include_thoughts":true}}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "interactions camel config camel include false", format: "interactions", body: `{"generation_config":{"thinkingConfig":{"includeThoughts":false}}}`, wantMode: SummaryDisabled}, + {name: "interactions enum wins over include alias", format: "interactions", body: `{"generation_config":{"thinking_summaries":"none","thinking_config":{"include_thoughts":true}}}`, wantMode: SummaryDisabled}, + {name: "interactions string include alias is invalid", format: "interactions", body: `{"generation_config":{"thinking_config":{"include_thoughts":"false"}}}`, wantMode: SummaryUnspecified}, {name: "interactions detailed is invalid", format: "interactions", body: `{"generation_config":{"thinking_summaries":"detailed"}}`, wantMode: SummaryUnspecified}, {name: "interactions boolean is invalid", format: "interactions", body: `{"generation_config":{"thinking_summaries":true}}`, wantMode: SummaryUnspecified}, {name: "gemini string bool is invalid", format: "gemini", body: `{"generationConfig":{"thinkingConfig":{"includeThoughts":"true"}}}`, wantMode: SummaryUnspecified}, diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go index ccbaa9d0..b7c7bfa8 100644 --- a/internal/translator/claude/gemini/claude_gemini_request.go +++ b/internal/translator/claude/gemini/claude_gemini_request.go @@ -217,10 +217,6 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream out, _ = sjson.SetBytes(out, "thinking.budget_tokens", budget) } } - } else if includeThoughts := thinkingConfig.Get("includeThoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True { - out, _ = sjson.SetBytes(out, "thinking.type", "enabled") - } else if includeThoughts := thinkingConfig.Get("include_thoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True { - out, _ = sjson.SetBytes(out, "thinking.type", "enabled") } } } diff --git a/sdk/translator/registry.go b/sdk/translator/registry.go index e9ef609f..830d0355 100644 --- a/sdk/translator/registry.go +++ b/sdk/translator/registry.go @@ -70,25 +70,35 @@ func (r *Registry) TranslateRequest(from, to Format, model string, rawJSON []byt body := rawJSON if fn != nil { body = fn(model, body, stream) - } else { - if model != "" && gjson.GetBytes(body, "model").String() != model { - if updated, err := sjson.SetBytes(body, "model", model); err != nil { - log.Warnf("translator: failed to normalize model in request fallback: %v", err) - } else { - body = updated - } + body = thinking.ApplySummaryConfigForModel(body, to.String(), model, summaryConfig) + if hooks != nil { + // Request normalizers run after native translation and own the final + // provider payload, including any summary field they remove. + body = hooks.NormalizeRequest(context.Background(), from, to, model, body, stream) } + return body } - if hooks != nil { - body = hooks.NormalizeRequest(context.Background(), from, to, model, body, stream) - if fn == nil { - if translated, ok := hooks.TranslateRequest(context.Background(), from, to, model, body, stream); ok { - body = translated - } + if model != "" && gjson.GetBytes(body, "model").String() != model { + if updated, err := sjson.SetBytes(body, "model", model); err != nil { + log.Warnf("translator: failed to normalize model in request fallback: %v", err) + } else { + body = updated } } - return thinking.ApplySummaryConfigForModel(body, to.String(), model, summaryConfig) + if hooks == nil { + // No translation occurred. Preserve the documented fallback shape instead + // of mixing target-protocol summary fields into the source payload. + return body + } + + // Plugin request normalizers canonicalize the source before a plugin request + // translator gets a chance to handle a missing native route. + body = hooks.NormalizeRequest(context.Background(), from, to, model, body, stream) + if translated, ok := hooks.TranslateRequest(context.Background(), from, to, model, body, stream); ok { + body = thinking.ApplySummaryConfigForModel(translated, to.String(), model, summaryConfig) + } + return body } // HasRequestTransformer indicates whether a request translator exists. diff --git a/sdk/translator/registry_summary_test.go b/sdk/translator/registry_summary_test.go index 312ee57b..1b77b951 100644 --- a/sdk/translator/registry_summary_test.go +++ b/sdk/translator/registry_summary_test.go @@ -1,9 +1,11 @@ package translator import ( + "bytes" "testing" "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) func TestRegistryTranslateRequestAppliesSummaryIntent(t *testing.T) { @@ -130,3 +132,74 @@ func TestRegistryTranslateRequestPreservesNativeClaudeMissingDisplay(t *testing. t.Fatalf("native Claude request without display gained one: %s", out) } } + +func TestRegistryTranslateRequestDoesNotMixSummaryIntoFallback(t *testing.T) { + registry := NewRegistry() + body := []byte(`{"model":"gemini-3.6-flash","reasoning":{"summary":"auto"},"input":"hi"}`) + out := registry.TranslateRequest(FormatOpenAIResponse, FormatGemini, "gemini-3.6-flash", body, false) + if !bytes.Equal(out, body) { + t.Fatalf("missing translator changed fallback body: got %s, want %s", out, body) + } + if gjson.GetBytes(out, "generationConfig").Exists() { + t.Fatalf("missing translator mixed Gemini fields into Responses body: %s", out) + } +} + +func TestRegistryTranslateRequestPluginMissDoesNotMixSummary(t *testing.T) { + registry := NewRegistry() + hooks := &fakePluginHooks{requestTranslateOK: false} + registry.SetPluginHooks(hooks) + body := []byte(`{"model":"gemini-3.6-flash","reasoning":{"summary":"auto"},"input":"hi"}`) + out := registry.TranslateRequest(FormatOpenAIResponse, FormatGemini, "gemini-3.6-flash", body, false) + if !bytes.Equal(out, body) { + t.Fatalf("plugin translation miss changed fallback body: got %s, want %s", out, body) + } + if gjson.GetBytes(out, "generationConfig").Exists() { + t.Fatalf("plugin translation miss mixed Gemini fields into Responses body: %s", out) + } +} + +func TestRegistryTranslateRequestAppliesSummaryAfterPluginTranslation(t *testing.T) { + registry := NewRegistry() + hooks := &fakePluginHooks{ + requestTranslateBody: []byte(`{"generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}}}`), + requestTranslateOK: true, + } + registry.SetPluginHooks(hooks) + out := registry.TranslateRequest( + FormatOpenAIResponse, + FormatGemini, + "gemini-3.6-flash", + []byte(`{"reasoning":{"summary":"auto"},"input":"hi"}`), + false, + ) + if !gjson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts").Bool() { + t.Fatalf("plugin-translated request lost canonical summary: %s", out) + } +} + +func TestRegistryTranslateRequestNormalizerOwnsFinalSummaryField(t *testing.T) { + registry := NewRegistry() + registry.Register(FormatOpenAIResponse, FormatGemini, func(_ string, _ []byte, _ bool) []byte { + return []byte(`{"generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}}}`) + }, ResponseTransform{}) + hooks := &fakePluginHooks{normalizeRequest: func(body []byte) []byte { + if !gjson.GetBytes(body, "generationConfig.thinkingConfig.includeThoughts").Bool() { + t.Fatalf("normalizer did not receive canonical enabled summary: %s", body) + } + out, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig.includeThoughts") + return out + }} + registry.SetPluginHooks(hooks) + + out := registry.TranslateRequest( + FormatOpenAIResponse, + FormatGemini, + "gemini-3.6-flash", + []byte(`{"reasoning":{"effort":"high","summary":"auto"},"input":"hi"}`), + false, + ) + if gjson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts").Exists() { + t.Fatalf("summary post-processing overrode request normalizer: %s", out) + } +} diff --git a/test/summary_intent_translation_test.go b/test/summary_intent_translation_test.go index b53c7ca3..59ad4b50 100644 --- a/test/summary_intent_translation_test.go +++ b/test/summary_intent_translation_test.go @@ -62,6 +62,7 @@ func TestSummaryIntentTranslation(t *testing.T) { {name: "Chat effort enables Interactions summary", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatInteractions, body: `{"model":"gemini-3.6-flash","reasoning_effort":"high","messages":[{"role":"user","content":"hi"}]}`, path: "generation_config.thinking_summaries", want: "auto", wantExists: true}, {name: "Responses concise summary maps to Interactions auto", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatInteractions, body: `{"model":"gemini-3.6-flash","reasoning":{"effort":"high","summary":"concise"},"input":"hi"}`, path: "generation_config.thinking_summaries", want: "auto", wantExists: true}, {name: "Native Claude summarized enables Gemini summary", from: sdktranslator.FormatClaude, to: sdktranslator.FormatGemini, body: `{"model":"claude-opus-5","thinking":{"type":"adaptive","display":"summarized"},"messages":[{"role":"user","content":"hi"}]}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, + {name: "Claude auto compatibility budget keeps Gemini summary", from: sdktranslator.FormatClaude, to: sdktranslator.FormatGemini, body: `{"model":"gemini-3.6-flash","thinking":{"type":"enabled","budget_tokens":-1,"display":"summarized"},"messages":[{"role":"user","content":"hi"}]}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, {name: "Native Gemini disabled omits Claude summary", from: sdktranslator.FormatGemini, to: sdktranslator.FormatClaude, body: `{"model":"gemini-3.6-flash","generationConfig":{"thinkingConfig":{"thinkingLevel":"high","includeThoughts":false}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, path: "thinking.display", want: "omitted", wantExists: true}, {name: "Native Gemini absent summary leaves Claude display absent", from: sdktranslator.FormatGemini, to: sdktranslator.FormatClaude, body: `{"model":"gemini-3.6-flash","generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, path: "thinking.display"}, {name: "Native Interactions auto enables Gemini summary", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatGemini, body: `{"model":"gemini-3.6-flash","generation_config":{"thinking_level":"high","thinking_summaries":"auto"},"input":"hi"}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, @@ -173,6 +174,43 @@ func TestSummaryIntentFinalPipeline(t *testing.T) { } } +func TestGeminiSummaryOnlyProducesValidClaudeThinking(t *testing.T) { + reg := registry.GetGlobalRegistry() + uid := fmt.Sprintf("gemini-summary-only-claude-%d", time.Now().UnixNano()) + reg.RegisterClient(uid, "test", getTestModels()) + defer reg.UnregisterClient(uid) + + tests := []struct { + name string + model string + wantType string + wantBudget int64 + }{ + {name: "adaptive model", model: "claude-sonnet-4-6-model", wantType: "adaptive"}, + {name: "manual model", model: "claude-budget-model", wantType: "enabled", wantBudget: 1024}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := []byte(`{"model":"` + test.model + `","generationConfig":{"thinkingConfig":{"includeThoughts":true}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`) + out := sdktranslator.TranslateRequest(sdktranslator.FormatGemini, sdktranslator.FormatClaude, test.model, body, false) + if got := gjson.GetBytes(out, "thinking.type").String(); got != test.wantType { + t.Fatalf("thinking.type = %q, want %q; body=%s", got, test.wantType, out) + } + if got := gjson.GetBytes(out, "thinking.display").String(); got != "summarized" { + t.Fatalf("thinking.display = %q, want summarized; body=%s", got, out) + } + budget := gjson.GetBytes(out, "thinking.budget_tokens") + if test.wantBudget > 0 { + if budget.Int() != test.wantBudget { + t.Fatalf("thinking.budget_tokens = %d, want %d; body=%s", budget.Int(), test.wantBudget, out) + } + } else if budget.Exists() { + t.Fatalf("adaptive model retained budget_tokens: %s", out) + } + }) + } +} + func TestNativeClaudeMissingDisplayPreservesSignatureOnlyHistory(t *testing.T) { body := []byte(`{"model":"claude-opus-5","thinking":{"type":"adaptive"},"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"opus-signature"}]},{"role":"user","content":"continue"}]}`) out := sdktranslator.TranslateRequest(sdktranslator.FormatClaude, sdktranslator.FormatClaude, "claude-opus-5", body, true) -- 2.51.2 From 87ceaf83bb702ab4053623ffad78205df7f37d08 Mon Sep 17 00:00:00 2001 From: sususu Date: Fri, 31 Jul 2026 00:05:59 +0800 Subject: [PATCH 23/31] fix(thinking): honor provider visibility semantics --- internal/thinking/apply.go | 25 ++++-- .../thinking/apply_configured_api_key_test.go | 55 ++++++++++++ .../thinking/provider/antigravity/apply.go | 5 +- internal/thinking/provider/gemini/apply.go | 5 +- internal/thinking/summary.go | 88 +++++++++---------- internal/thinking/summary_test.go | 46 +++++++++- test/summary_intent_translation_test.go | 4 +- test/thinking_conversion_test.go | 38 ++++---- 8 files changed, 187 insertions(+), 79 deletions(-) diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go index c19369c7..a349f269 100644 --- a/internal/thinking/apply.go +++ b/internal/thinking/apply.go @@ -225,7 +225,7 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF // Unknown models are treated as user-defined so thinking config can still be applied. // The upstream service is responsible for validating the configuration. if IsUserDefinedModel(modelInfo) { - return applyUserDefinedModel(body, modelInfo, fromFormat, providerFormat, suffixResult, summaryConfig) + return applyUserDefinedModel(body, modelInfo, fromFormat, providerFormat, providerKey, suffixResult, summaryConfig) } if modelInfo.Thinking == nil { config := extractThinkingConfig(body, providerFormat) @@ -277,7 +277,7 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF "provider": providerFormat, "model": modelInfo.ID, }).Debug("thinking: no config found, passthrough |") - return applySummaryConfigForModel(body, providerFormat, baseModel, modelInfo, summaryConfig), nil + return applySummaryConfigForProvider(body, providerFormat, baseModel, providerKey, modelInfo, summaryConfig), nil } if modelInfoResolved && config.Mode == ModeLevel && modelInfo != nil && modelInfo.Thinking != nil && shouldMapConfiguredHighIntent(fromFormat, providerFormat, modelInfo) { config.Level = mapConfiguredHighIntent(config.Level, modelInfo) @@ -320,7 +320,17 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF if err != nil { return applied, err } - return applySummaryConfigForModel(applied, providerFormat, baseModel, modelInfo, summaryConfig), nil + // A fully disabled amount takes precedence over visibility. Re-applying a + // summary-only field can recreate an otherwise removed provider config and + // make a default-on model think again. + if thinkingIsFullyDisabled(*validated) { + return applied, nil + } + return applySummaryConfigForProvider(applied, providerFormat, baseModel, providerKey, modelInfo, summaryConfig), nil +} + +func thinkingIsFullyDisabled(config ThinkingConfig) bool { + return config.Mode == ModeNone && config.Budget == 0 && config.Level == "" } func shouldMapConfiguredHighIntent(fromFormat, toFormat string, modelInfo *registry.ModelInfo) bool { @@ -409,7 +419,7 @@ func parseSuffixToConfig(rawSuffix, provider, model string) ThinkingConfig { // applyUserDefinedModel applies thinking configuration for user-defined models // without ThinkingSupport validation. -func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromFormat, toFormat string, suffixResult SuffixResult, summaryConfig SummaryConfig) ([]byte, error) { +func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromFormat, toFormat, providerKey string, suffixResult SuffixResult, summaryConfig SummaryConfig) ([]byte, error) { // Get model ID for logging modelID := "" if modelInfo != nil { @@ -450,7 +460,7 @@ func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromForma "model": modelID, "provider": toFormat, }).Debug("thinking: user-defined model, passthrough (no config) |") - return applySummaryConfigForModel(body, toFormat, modelID, modelInfo, summaryConfig), nil + return applySummaryConfigForProvider(body, toFormat, modelID, providerKey, modelInfo, summaryConfig), nil } applier := GetProviderApplier(toFormat) @@ -474,7 +484,10 @@ func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromForma if err != nil { return applied, err } - return applySummaryConfigForModel(applied, toFormat, modelID, modelInfo, summaryConfig), nil + if thinkingIsFullyDisabled(config) { + return applied, nil + } + return applySummaryConfigForProvider(applied, toFormat, modelID, providerKey, modelInfo, summaryConfig), nil } func normalizeUserDefinedConfig(config ThinkingConfig, fromFormat, toFormat string) ThinkingConfig { diff --git a/internal/thinking/apply_configured_api_key_test.go b/internal/thinking/apply_configured_api_key_test.go index a443491e..b056139e 100644 --- a/internal/thinking/apply_configured_api_key_test.go +++ b/internal/thinking/apply_configured_api_key_test.go @@ -133,6 +133,61 @@ func TestApplyThinkingWithModelInfoDoesNotActivateClaudeForDisabledSummary(t *te } } +func TestApplyThinkingWithModelInfoSummaryOnlyDoesNotInventOpenAIEffort(t *testing.T) { + modelInfo := ®istry.ModelInfo{ + ID: "private-openai", + Type: "openai", + Thinking: ®istry.ThinkingSupport{Levels: []string{"high", "max"}}, + } + out, err := thinking.ApplyThinkingWithModelInfo( + []byte(`{"model":"private-openai","messages":[{"role":"user","content":"hi"}]}`), + []byte(`{"model":"private-openai","reasoning":{"summary":"auto"},"input":"hi"}`), + "private-openai", "openai-response", "openai", "openai", modelInfo, + ) + if err != nil { + t.Fatalf("ApplyThinkingWithModelInfo() error = %v; body=%s", err, out) + } + if gjson.GetBytes(out, "reasoning_effort").Exists() { + t.Fatalf("summary-only request invented reasoning_effort: %s", out) + } +} + +func TestApplyThinkingWithSummaryKeepsOpenAIChatSuffixNone(t *testing.T) { + out, err := thinking.ApplyThinkingWithSummary( + []byte(`{"model":"private-openai","messages":[{"role":"user","content":"hi"}]}`), + "private-openai(none)", "openai-response", "openai", "openai", + thinking.SummaryConfig{Mode: thinking.SummaryEnabled, Detail: "auto"}, + ) + if err != nil { + t.Fatalf("ApplyThinkingWithSummary() error = %v; body=%s", err, out) + } + if got := gjson.GetBytes(out, "reasoning_effort").String(); got != "none" { + t.Fatalf("reasoning_effort = %q, want none; body=%s", got, out) + } +} + +func TestApplyThinkingWithModelInfoUsesOpenRouterVisibility(t *testing.T) { + modelInfo := ®istry.ModelInfo{ + ID: "openrouter-model", + Type: "openai-compatibility", + Thinking: ®istry.ThinkingSupport{Levels: []string{"high", "max"}}, + } + out, err := thinking.ApplyThinkingWithModelInfo( + []byte(`{"model":"openrouter-model","messages":[{"role":"user","content":"hi"}]}`), + []byte(`{"model":"openrouter-model","reasoning":{"summary":"auto"},"input":"hi"}`), + "openrouter-model", "openai-response", "openai", "openrouter", modelInfo, + ) + if err != nil { + t.Fatalf("ApplyThinkingWithModelInfo() error = %v; body=%s", err, out) + } + if exclude := gjson.GetBytes(out, "reasoning.exclude"); !exclude.Exists() || exclude.Bool() { + t.Fatalf("OpenRouter summary visibility not enabled: %s", out) + } + if gjson.GetBytes(out, "reasoning_effort").Exists() { + t.Fatalf("OpenRouter summary visibility invented reasoning_effort: %s", out) + } +} + func TestApplyThinkingWithModelInfoUsesOriginalResponsesEffort(t *testing.T) { modelInfo := ®istry.ModelInfo{ ID: "claude-upstream", diff --git a/internal/thinking/provider/antigravity/apply.go b/internal/thinking/provider/antigravity/apply.go index 968ee09d..6d2edbfa 100644 --- a/internal/thinking/provider/antigravity/apply.go +++ b/internal/thinking/provider/antigravity/apply.go @@ -104,8 +104,11 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) if config.Mode == thinking.ModeNone { if config.Budget == 0 && config.Level == "" { + // With the amount fully disabled, visibility is irrelevant. Restoring + // includeThoughts alone would recreate thinkingConfig and let a + // default-on model think again. result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig") - return applyAntigravityIncludeThoughts(result, body), nil + return result, nil } if config.Level != "" { result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel", string(config.Level)) diff --git a/internal/thinking/provider/gemini/apply.go b/internal/thinking/provider/gemini/apply.go index c332e9ef..cc4f071e 100644 --- a/internal/thinking/provider/gemini/apply.go +++ b/internal/thinking/provider/gemini/apply.go @@ -128,8 +128,11 @@ func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) if config.Mode == thinking.ModeNone { if config.Budget == 0 && config.Level == "" { + // With the amount fully disabled, visibility is irrelevant. Restoring + // includeThoughts alone would recreate thinkingConfig and let a + // default-on model think again. result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig") - return applyGeminiIncludeThoughts(result, body), nil + return result, nil } if config.Level != "" { result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingLevel", string(config.Level)) diff --git a/internal/thinking/summary.go b/internal/thinking/summary.go index 5f7d0ead..17951990 100644 --- a/internal/thinking/summary.go +++ b/internal/thinking/summary.go @@ -95,6 +95,12 @@ func ExtractSummaryConfig(body []byte, format string) SummaryConfig { return config } } + // Existing Interactions translators accept the OpenAI-style top-level + // compatibility object. Keep the official generation_config selector + // authoritative when both are present. + if config, ok := interactionsSummaryConfig(body, "reasoning.summary"); ok { + return config + } if config, ok := firstSummaryBoolConfig(body, []string{ "generation_config.thinking_config.include_thoughts", "generation_config.thinking_config.includeThoughts", @@ -123,6 +129,12 @@ func ApplySummaryConfigForModel(body []byte, format, model string, config Summar // applySummaryConfigForModel uses the resolved model definition when execution // selected a configured API-key model whose capability is not globally visible. func applySummaryConfigForModel(body []byte, format, model string, modelInfo *registry.ModelInfo, config SummaryConfig) []byte { + return applySummaryConfigForProvider(body, format, model, "", modelInfo, config) +} + +// applySummaryConfigForProvider uses the execution provider identity for Chat +// dialects whose visibility controls are not part of the OpenAI wire format. +func applySummaryConfigForProvider(body []byte, format, model, provider string, modelInfo *registry.ModelInfo, config SummaryConfig) []byte { normalized := strings.ToLower(strings.TrimSpace(format)) if config.Mode == SummaryUnspecified || !summaryFormatSupported(normalized) || len(body) == 0 || !gjson.ValidBytes(body) { return body @@ -131,7 +143,7 @@ func applySummaryConfigForModel(body []byte, format, model string, modelInfo *re enabled := config.Mode == SummaryEnabled switch normalized { case "openai": - body = applyOpenAIChatSummaryConfig(body, model, enabled) + body = applyOpenAIChatSummaryConfig(body, provider, enabled) case "claude": // Anthropic documents display as invalid with thinking.type=disabled and // requires it alongside adaptive or enabled thinking. Model defaults differ: @@ -234,65 +246,45 @@ func claudeThinkingAcceptsDisplay(body []byte) bool { } } -// applyOpenAIChatSummaryConfig writes summary visibility intent for the Chat -// Completions protocol. +// applyOpenAIChatSummaryConfig writes only documented Chat visibility controls. // -// Four dialects share this protocol and only OpenAI's is authoritative. OpenAI -// documents no reasoning-visibility field at all (Chat Completions never returns -// reasoning text) and rejects unknown body parameters, so reasoning_effort is the -// only field that is always safe to write here. OpenRouter's documented -// "reason but hide" bits (reasoning.exclude and its legacy include_reasoning -// alias) are updated only when the body already carries them, which is exactly -// when the upstream is known to understand them. -func applyOpenAIChatSummaryConfig(body []byte, model string, enabled bool) []byte { - if gjson.GetBytes(body, "reasoning").IsObject() { +// OpenAI Chat Completions exposes reasoning_effort but no reasoning summary or +// visibility parameter. DeepSeek and Kimi Chat return reasoning_content while +// thinking is active, but likewise document no independent hide/show switch. +// Summary intent must therefore never invent or overwrite thinking effort for +// those dialects. OpenRouter is the exception: reasoning.exclude is its +// documented "reason but hide" control, and include_reasoning is its deprecated +// inverse alias. Unknown OpenAI-compatible providers are handled conservatively +// by updating those fields only when the payload already carries them. +// +// Docs: +// https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create +// https://openrouter.ai/docs/guides/best-practices/reasoning-tokens +// https://api-docs.deepseek.com/guides/thinking_mode +// https://platform.kimi.ai/docs/api/chat +func applyOpenAIChatSummaryConfig(body []byte, provider string, enabled bool) []byte { + if isOpenRouterProvider(provider) || gjson.GetBytes(body, "reasoning.exclude").IsBool() { body, _ = sjson.SetBytes(body, "reasoning.exclude", !enabled) } if gjson.GetBytes(body, "include_reasoning").IsBool() { body, _ = sjson.SetBytes(body, "include_reasoning", enabled) } - if !enabled { - // Chat has no portable way to keep reasoning while hiding its summary. - // reasoning_effort:"none" would disable reasoning instead of hiding it, - // and Google documents that it is not even honored on Gemini 2.5 Pro or - // 3 models, so leave the effort the client asked for untouched. - return body - } - effort := gjson.GetBytes(body, "reasoning_effort") - if effort.Type != gjson.String || strings.TrimSpace(effort.String()) == "" || strings.EqualFold(strings.TrimSpace(effort.String()), "none") { - body, _ = sjson.SetBytes(body, "reasoning_effort", openAIChatSummaryEffort(body, model)) - } return body } -// openAIChatSummaryEffort picks an active reasoning effort that the target model -// documents. Chat exposes reasoning only while an effort is active, so a summary -// request has to select one when the client left it unset. -func openAIChatSummaryEffort(body []byte, model string) string { - baseModel := ParseSuffix(model).ModelName - if baseModel == "" { - baseModel = ParseSuffix(gjson.GetBytes(body, "model").String()).ModelName - } - modelInfo := registry.LookupModelInfo(baseModel, "openai") - if modelInfo == nil || modelInfo.Thinking == nil || len(modelInfo.Thinking.Levels) == 0 { - return "medium" +func isOpenRouterProvider(provider string) bool { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "openrouter" { + return true } - - levels := make([]string, 0, len(modelInfo.Thinking.Levels)) - for _, level := range modelInfo.Thinking.Levels { - normalized := strings.ToLower(strings.TrimSpace(level)) - if normalized == "" || normalized == "none" { - continue - } - if normalized == "medium" { - return "medium" + for _, part := range strings.FieldsFunc(provider, func(r rune) bool { + return r == '-' || r == '_' || r == '/' || r == '.' || r == ':' + }) { + if part == "openrouter" { + return true } - levels = append(levels, normalized) - } - if len(levels) == 0 { - return "medium" } - return levels[len(levels)/2] + return false } func extractOpenAIExplicitSummaryConfig(body []byte) (SummaryConfig, bool) { diff --git a/internal/thinking/summary_test.go b/internal/thinking/summary_test.go index da7d7bb2..84c110c9 100644 --- a/internal/thinking/summary_test.go +++ b/internal/thinking/summary_test.go @@ -55,6 +55,9 @@ func TestExtractSummaryConfig(t *testing.T) { {name: "interactions nested camel include true", format: "interactions", body: `{"generation_config":{"thinking_config":{"includeThoughts":true}}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, {name: "interactions camel config snake include true", format: "interactions", body: `{"generation_config":{"thinkingConfig":{"include_thoughts":true}}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, {name: "interactions camel config camel include false", format: "interactions", body: `{"generation_config":{"thinkingConfig":{"includeThoughts":false}}}`, wantMode: SummaryDisabled}, + {name: "interactions enum wins over compatibility reasoning", format: "interactions", body: `{"generation_config":{"thinking_summaries":"none"},"reasoning":{"summary":"auto"}}`, wantMode: SummaryDisabled}, + {name: "interactions compatibility reasoning auto", format: "interactions", body: `{"reasoning":{"summary":"auto"}}`, wantMode: SummaryEnabled, wantDetail: "auto"}, + {name: "interactions compatibility reasoning none", format: "interactions", body: `{"reasoning":{"summary":"none"}}`, wantMode: SummaryDisabled}, {name: "interactions enum wins over include alias", format: "interactions", body: `{"generation_config":{"thinking_summaries":"none","thinking_config":{"include_thoughts":true}}}`, wantMode: SummaryDisabled}, {name: "interactions string include alias is invalid", format: "interactions", body: `{"generation_config":{"thinking_config":{"include_thoughts":"false"}}}`, wantMode: SummaryUnspecified}, {name: "interactions detailed is invalid", format: "interactions", body: `{"generation_config":{"thinking_summaries":"detailed"}}`, wantMode: SummaryUnspecified}, @@ -81,8 +84,9 @@ func TestApplySummaryConfig(t *testing.T) { path string want string }{ - {name: "chat enabled creates compatibility effort", format: "openai", config: SummaryConfig{Mode: SummaryEnabled}, path: "reasoning_effort", want: "medium"}, + {name: "chat enabled invents no effort", format: "openai", config: SummaryConfig{Mode: SummaryEnabled}, path: "reasoning_effort", want: ""}, {name: "chat enabled preserves active effort", format: "openai", body: `{"reasoning_effort":"high"}`, config: SummaryConfig{Mode: SummaryEnabled}, path: "reasoning_effort", want: "high"}, + {name: "chat enabled preserves disabled effort", format: "openai", body: `{"reasoning_effort":"none"}`, config: SummaryConfig{Mode: SummaryEnabled}, path: "reasoning_effort", want: "none"}, // Chat cannot express "reason but hide", so disabling must not fall back to // reasoning_effort:"none", which would disable reasoning altogether. {name: "chat disabled preserves requested effort", format: "openai", body: `{"reasoning_effort":"high"}`, config: SummaryConfig{Mode: SummaryDisabled}, path: "reasoning_effort", want: "high"}, @@ -114,6 +118,46 @@ func TestApplySummaryConfig(t *testing.T) { } } +func TestApplySummaryConfig_OpenAIChatProviderDialects(t *testing.T) { + tests := []struct { + name string + provider string + body string + mode SummaryMode + wantExclude string + wantExisting bool + wantEffort string + }{ + {name: "OpenAI does not invent visibility", provider: "openai", body: `{}`, mode: SummaryEnabled}, + {name: "OpenRouter enables visibility", provider: "openrouter", body: `{}`, mode: SummaryEnabled, wantExclude: "false", wantExisting: true}, + {name: "OpenRouter disables visibility", provider: "prod-openrouter", body: `{}`, mode: SummaryDisabled, wantExclude: "true", wantExisting: true}, + {name: "DeepSeek preserves documented effort", provider: "deepseek", body: `{"reasoning_effort":"high"}`, mode: SummaryDisabled, wantEffort: "high"}, + {name: "Kimi preserves documented K3 effort", provider: "kimi", body: `{"reasoning_effort":"max"}`, mode: SummaryEnabled, wantEffort: "max"}, + {name: "Moonshot does not invent visibility", provider: "moonshot", body: `{"thinking":{"type":"enabled"}}`, mode: SummaryEnabled}, + {name: "generic provider updates existing OpenRouter field", provider: "openai-compatibility", body: `{"reasoning":{"exclude":false}}`, mode: SummaryDisabled, wantExclude: "true", wantExisting: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + out := applySummaryConfigForProvider([]byte(test.body), "openai", "model", test.provider, nil, SummaryConfig{Mode: test.mode}) + exclude := gjson.GetBytes(out, "reasoning.exclude") + if exclude.Exists() != test.wantExisting { + t.Fatalf("reasoning.exclude exists = %v, want %v; body=%s", exclude.Exists(), test.wantExisting, out) + } + if test.wantExisting && exclude.String() != test.wantExclude { + t.Fatalf("reasoning.exclude = %q, want %q; body=%s", exclude.String(), test.wantExclude, out) + } + effort := gjson.GetBytes(out, "reasoning_effort") + if test.wantEffort == "" { + if effort.Exists() { + t.Fatalf("summary visibility invented reasoning_effort: %s", out) + } + } else if effort.String() != test.wantEffort { + t.Fatalf("reasoning_effort = %q, want %q; body=%s", effort.String(), test.wantEffort, out) + } + }) + } +} + func TestApplySummaryConfigNormalizesTargetAliases(t *testing.T) { tests := []struct { format string diff --git a/test/summary_intent_translation_test.go b/test/summary_intent_translation_test.go index 59ad4b50..cc1724f5 100644 --- a/test/summary_intent_translation_test.go +++ b/test/summary_intent_translation_test.go @@ -40,7 +40,7 @@ func TestSummaryIntentTranslation(t *testing.T) { {name: "Claude summarized enables Codex summary", from: sdktranslator.FormatClaude, to: sdktranslator.FormatCodex, body: `{"model":"gpt-5.4","max_tokens":1024,"thinking":{"type":"adaptive","display":"summarized"},"output_config":{"effort":"high"},"messages":[{"role":"user","content":"hi"}]}`, path: "reasoning.summary", want: "auto", wantExists: true}, {name: "Interactions none omits Codex summary", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatCodex, body: `{"model":"gpt-5.4","generation_config":{"thinking_level":"high","thinking_summaries":"none"},"input":"hi"}`, path: "reasoning.summary"}, {name: "Chat effort enables Codex summary", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatCodex, body: `{"model":"gpt-5.4","reasoning_effort":"high","messages":[{"role":"user","content":"hi"}]}`, path: "reasoning.summary", want: "auto", wantExists: true}, - {name: "Responses summary only enables Chat compatibility effort", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatOpenAI, body: `{"model":"gpt-5.4","reasoning":{"summary":"auto"},"input":"hi"}`, path: "reasoning_effort", want: "medium", wantExists: true}, + {name: "Responses summary only invents no Chat effort", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatOpenAI, body: `{"model":"gpt-5.4","reasoning":{"summary":"auto"},"input":"hi"}`, path: "reasoning_effort"}, // Chat has no field for "reason but hide": OpenAI documents none and rejects // unknown parameters, so a disabled summary must leave the requested effort // alone instead of turning reasoning off upstream. @@ -127,10 +127,12 @@ func TestSummaryIntentFinalPipeline(t *testing.T) { {name: "Responses default keeps Claude display default", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","input":"hi"}`, path: "thinking.display"}, {name: "Chat summary alias only activates valid Claude thinking", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"exclude":false},"messages":[{"role":"user","content":"hi"}]}`, path: "thinking.display", want: "summarized", wantExists: true}, {name: "Interactions summary only activates valid Claude thinking", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","generation_config":{"thinking_summaries":"auto"},"input":"hi"}`, path: "thinking.display", want: "summarized", wantExists: true}, + {name: "Interactions compatibility summary activates valid Claude thinking", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model", body: `{"model":"claude-sonnet-4-6-model","reasoning":{"summary":"auto"},"input":"hi"}`, path: "thinking.display", want: "summarized", wantExists: true}, {name: "Claude suffix none removes otherwise enabled display", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model(none)", body: `{"model":"claude-sonnet-4-6-model(none)","reasoning":{"summary":"auto"},"input":"hi"}`, path: "thinking.display"}, {name: "Claude suffix preserves explicit disabled summary", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatClaude, model: "claude-sonnet-4-6-model(high)", body: `{"model":"claude-sonnet-4-6-model(high)","reasoning":{"summary":null},"input":"hi"}`, path: "thinking.display", want: "omitted", wantExists: true}, {name: "Responses effort alone stays omitted on Antigravity", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"antigravity-budget-model","reasoning":{"effort":"medium"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts"}, {name: "Responses summary reaches Antigravity", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"antigravity-budget-model","reasoning":{"effort":"medium","summary":"auto"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, + {name: "Responses null summary alone hides default Gemini thoughts", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatGemini, model: "gemini-mixed-model", body: `{"model":"gemini-mixed-model","reasoning":{"summary":null},"input":"hi"}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "false", wantExists: true}, {name: "Google Chat extension false survives Gemini applier", from: sdktranslator.FormatOpenAI, to: sdktranslator.FormatGemini, model: "gemini-mixed-model", body: `{"model":"gemini-mixed-model","reasoning_effort":"high","extra_body":{"google":{"thinking_config":{"include_thoughts":false}}},"messages":[{"role":"user","content":"hi"}]}`, path: "generationConfig.thinkingConfig.includeThoughts", want: "false", wantExists: true}, // Captured from isolated Claude Code 2.1.220 with // alwaysThinkingEnabled:true. Sonnet uses adaptive thinking, while Haiku diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index 07dfb039..d71d6e35 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -1456,30 +1456,26 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { includeThoughts: "false", expectErr: false, }, - // Case 31A: reasoning_effort=none with zero allowed removes the amount but - // preserves Chat's explicit disabled summary intent. + // Case 31A: reasoning_effort=none with zero allowed removes the entire + // thinking config. includeThoughts alone would restore the model default. { - name: "31A", - from: "openai", - to: "gemini", - model: "gemini-toggle-mixed-model", - inputJSON: `{"model":"gemini-toggle-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, - expectField: "generationConfig.thinkingConfig.includeThoughts", - expectValue: "false", - includeThoughts: "false", - expectErr: false, + name: "31A", + from: "openai", + to: "gemini", + model: "gemini-toggle-mixed-model", + inputJSON: `{"model":"gemini-toggle-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, + expectField: "", + expectErr: false, }, - // Case 31B: the same explicit disabled intent survives Antigravity. + // Case 31B: Antigravity keeps the same fully disabled representation. { - name: "31B", - from: "openai", - to: "antigravity", - model: "gemini-toggle-mixed-model", - inputJSON: `{"model":"gemini-toggle-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, - expectField: "request.generationConfig.thinkingConfig.includeThoughts", - expectValue: "false", - includeThoughts: "false", - expectErr: false, + name: "31B", + from: "openai", + to: "antigravity", + model: "gemini-toggle-mixed-model", + inputJSON: `{"model":"gemini-toggle-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, + expectField: "", + expectErr: false, }, // Case 31C: reasoning.effort=none with zero allowed → delete thinkingConfig { -- 2.51.2 From 7d00936acc2eac8184424eb3d0e9903f6d05102a Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 31 Jul 2026 00:53:23 +0800 Subject: [PATCH 24/31] feat(models): add Kimi K3 256K and extend Kimi K3 configuration - Added new model `Kimi K3 256K` with 256K context support and image input capability. - Enhanced `Kimi K3` configuration by introducing `thinking` options and increasing context length. Closes: #4612 --- internal/registry/models/models.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json index 0f8998d5..4ba10e22 100644 --- a/internal/registry/models/models.json +++ b/internal/registry/models/models.json @@ -2410,6 +2410,25 @@ "type": "kimi", "display_name": "Kimi K3", "description": "Kimi K3 - Moonshot AI's next-generation flagship model (~2.8T MoE) with multimodal input", + "context_length": 1048576, + "max_completion_tokens": 65536, + "thinking": { + "zero_allowed": false, + "levels": [ + "low", + "high", + "max" + ] + } + }, + { + "id": "kimi-k3-256k", + "object": "model", + "created": 1785110400, + "owned_by": "moonshot", + "type": "kimi", + "display_name": "Kimi K3 256K", + "description": "Kimi K3 256K - 256K context version of Kimi K3 delivering the same results within 256K context at reduced quota consumption; supports image input only (no video)", "context_length": 262144, "max_completion_tokens": 65536, "thinking": { -- 2.51.2 From 4db8e1202942184275688d405e13197414c1313b Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Fri, 31 Jul 2026 01:34:11 +0800 Subject: [PATCH 25/31] fix: recover Home OAuth credentials after 401 --- internal/home/client.go | 6 +- internal/home/requests.go | 6 +- internal/redisqueue/plugin.go | 2 + .../runtime/executor/helps/home_refresh.go | 50 +++++++++++++- .../executor/helps/home_refresh_test.go | 65 ++++++++++++++++--- .../runtime/executor/helps/usage_helpers.go | 39 ++++++----- sdk/cliproxy/auth/conductor_home.go | 55 +++++++++++++--- sdk/cliproxy/auth/conductor_home_execution.go | 1 + sdk/cliproxy/auth/conductor_refresh.go | 2 +- sdk/cliproxy/auth/conductor_selection.go | 7 +- sdk/cliproxy/auth/conductor_stream.go | 10 +++ sdk/cliproxy/auth/home_selection.go | 29 ++++++++- sdk/cliproxy/auth/home_selection_test.go | 64 ++++++++++++++++++ .../auth/home_unauthorized_refresh_test.go | 53 ++++++++++++--- sdk/cliproxy/usage/manager.go | 6 +- 15 files changed, 338 insertions(+), 57 deletions(-) diff --git a/internal/home/client.go b/internal/home/client.go index f4a295c6..b5115d43 100644 --- a/internal/home/client.go +++ b/internal/home/client.go @@ -1327,7 +1327,7 @@ func isAmbiguousIssuedRPopAuthError(err error) bool { return !errors.As(err, &redisErr) } -func (c *Client) GetRefreshAuth(ctx context.Context, authIndex string) ([]byte, error) { +func (c *Client) GetRefreshAuth(ctx context.Context, authIndex string, lastRefreshedAt time.Time, accessTokenSHA256 string) ([]byte, error) { cmd, errClient := c.commandClient() if errClient != nil { return nil, errClient @@ -1340,6 +1340,10 @@ func (c *Client) GetRefreshAuth(ctx context.Context, authIndex string) ([]byte, Type: "refresh", AuthIndex: authIndex, } + if !lastRefreshedAt.IsZero() { + req.LastRefreshedAt = lastRefreshedAt.UTC().Format(time.RFC3339Nano) + } + req.ObservedAccessTokenSHA256 = strings.TrimSpace(accessTokenSHA256) keyBytes, err := json.Marshal(&req) if err != nil { return nil, err diff --git a/internal/home/requests.go b/internal/home/requests.go index eca63742..c0ce7a75 100644 --- a/internal/home/requests.go +++ b/internal/home/requests.go @@ -18,8 +18,10 @@ type modelsRequest struct { } type refreshRequest struct { - Type string `json:"type"` - AuthIndex string `json:"auth_index"` + Type string `json:"type"` + AuthIndex string `json:"auth_index"` + LastRefreshedAt string `json:"last_refreshed_at,omitempty"` + ObservedAccessTokenSHA256 string `json:"access_token_sha256,omitempty"` } type InFlightFrameKind string diff --git a/internal/redisqueue/plugin.go b/internal/redisqueue/plugin.go index 915f8894..d91c8a28 100644 --- a/internal/redisqueue/plugin.go +++ b/internal/redisqueue/plugin.go @@ -90,6 +90,7 @@ func (p *usageQueuePlugin) HandleUsage(ctx context.Context, record coreusage.Rec TTFTMs: record.TTFT.Milliseconds(), Source: record.Source, AuthIndex: record.AuthIndex, + AccessTokenHash: record.AccessTokenSHA256, ClientIP: clientRequestMetadata.ClientIP, XForwardedFor: clientRequestMetadata.XForwardedFor, UserAgent: clientRequestMetadata.UserAgent, @@ -145,6 +146,7 @@ type requestDetail struct { TTFTMs int64 `json:"ttft_ms"` Source string `json:"source"` AuthIndex string `json:"auth_index"` + AccessTokenHash string `json:"access_token_sha256,omitempty"` ClientIP string `json:"client_ip"` XForwardedFor string `json:"x_forwarded_for"` UserAgent string `json:"user_agent"` diff --git a/internal/runtime/executor/helps/home_refresh.go b/internal/runtime/executor/helps/home_refresh.go index 7c971992..020d5f4f 100644 --- a/internal/runtime/executor/helps/home_refresh.go +++ b/internal/runtime/executor/helps/home_refresh.go @@ -2,10 +2,14 @@ package helps import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" + "errors" "fmt" "net/http" "strings" + "time" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/home" @@ -43,7 +47,7 @@ type homeErrorDetail struct { type homeRefreshClient interface { HeartbeatOK() bool - GetRefreshAuth(ctx context.Context, authIndex string) ([]byte, error) + GetRefreshAuth(ctx context.Context, authIndex string, lastRefreshedAt time.Time, accessTokenSHA256 string) ([]byte, error) } var currentHomeRefreshClient = func() homeRefreshClient { @@ -77,8 +81,11 @@ func RefreshAuthViaHome(ctx context.Context, cfg *config.Config, auth *cliproxya return nil, true, homeStatusErr{code: http.StatusBadGateway, msg: "home refresh: auth_index is empty"} } - raw, err := client.GetRefreshAuth(ctx, authIndex) + raw, err := client.GetRefreshAuth(ctx, authIndex, auth.LastRefreshedAt, authAccessTokenSHA256(auth)) if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil, true, err + } return nil, true, homeStatusErr{code: http.StatusBadGateway, msg: err.Error()} } @@ -107,6 +114,43 @@ func RefreshAuthViaHome(ctx context.Context, cfg *config.Config, auth *cliproxya return updated, true, nil } +func authAccessTokenSHA256(auth *cliproxyauth.Auth) string { + accessToken := authAccessTokenForFingerprint(auth) + if accessToken == "" { + return "" + } + digest := sha256.Sum256([]byte(accessToken)) + return hex.EncodeToString(digest[:]) +} + +func authAccessTokenForFingerprint(auth *cliproxyauth.Auth) string { + if auth == nil || auth.Metadata == nil { + return "" + } + for _, key := range []string{"access_token", "accessToken"} { + if value, ok := auth.Metadata[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + for _, key := range []string{"token", "Token"} { + switch token := auth.Metadata[key].(type) { + case map[string]any: + for _, tokenKey := range []string{"access_token", "accessToken"} { + if value, ok := token[tokenKey].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + case map[string]string: + for _, tokenKey := range []string{"access_token", "accessToken"} { + if value := strings.TrimSpace(token[tokenKey]); value != "" { + return value + } + } + } + } + return "" +} + func parseHomeRefreshAuth(raw []byte) (*cliproxyauth.Auth, string, error) { var rawObject map[string]json.RawMessage if errUnmarshal := json.Unmarshal(raw, &rawObject); errUnmarshal != nil { @@ -132,6 +176,8 @@ func statusFromHomeErrorCode(code string) int { return http.StatusUnauthorized case "model_not_found": return http.StatusNotFound + case "refresh_temporarily_unavailable", "home_unavailable": + return http.StatusServiceUnavailable default: return http.StatusBadGateway } diff --git a/internal/runtime/executor/helps/home_refresh_test.go b/internal/runtime/executor/helps/home_refresh_test.go index ca758273..26cc51af 100644 --- a/internal/runtime/executor/helps/home_refresh_test.go +++ b/internal/runtime/executor/helps/home_refresh_test.go @@ -3,9 +3,11 @@ package helps import ( "context" "encoding/json" + "errors" "net/http" "sync/atomic" "testing" + "time" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" @@ -18,22 +20,60 @@ func TestStatusFromHomeErrorCodeMapsAuthenticationErrorToUnauthorized(t *testing if got := statusFromHomeErrorCode("unauthorized"); got != http.StatusUnauthorized { t.Fatalf("statusFromHomeErrorCode(unauthorized) = %d, want %d", got, http.StatusUnauthorized) } + if got := statusFromHomeErrorCode("refresh_temporarily_unavailable"); got != http.StatusServiceUnavailable { + t.Fatalf("statusFromHomeErrorCode(refresh_temporarily_unavailable) = %d, want %d", got, http.StatusServiceUnavailable) + } } type fakeHomeRefreshClient struct { - calls atomic.Int32 - authIndex string - raw []byte + calls atomic.Int32 + authIndex string + lastRefreshedAt time.Time + accessTokenHash string + raw []byte + err error } func (c *fakeHomeRefreshClient) HeartbeatOK() bool { return true } -func (c *fakeHomeRefreshClient) GetRefreshAuth(_ context.Context, authIndex string) ([]byte, error) { +func (c *fakeHomeRefreshClient) GetRefreshAuth(_ context.Context, authIndex string, lastRefreshedAt time.Time, accessTokenHash string) ([]byte, error) { c.calls.Add(1) c.authIndex = authIndex - return c.raw, nil + c.lastRefreshedAt = lastRefreshedAt + c.accessTokenHash = accessTokenHash + return c.raw, c.err +} + +func TestRefreshAuthViaHomePreservesContextErrors(t *testing.T) { + client := &fakeHomeRefreshClient{err: context.DeadlineExceeded} + oldCurrentHomeRefreshClient := currentHomeRefreshClient + currentHomeRefreshClient = func() homeRefreshClient { return client } + t.Cleanup(func() { currentHomeRefreshClient = oldCurrentHomeRefreshClient }) + + cfg := &config.Config{Home: config.HomeConfig{Enabled: true}} + auth := &cliproxyauth.Auth{ID: "home-auth", Index: "home-auth", Provider: "codex"} + _, handled, errRefresh := RefreshAuthViaHome(context.Background(), cfg, auth) + if !handled || !errors.Is(errRefresh, context.DeadlineExceeded) { + t.Fatalf("RefreshAuthViaHome() = handled %v err %v, want true/context.DeadlineExceeded", handled, errRefresh) + } +} + +func TestAuthAccessTokenSHA256SupportsKnownMetadataShapes(t *testing.T) { + want := authAccessTokenSHA256(&cliproxyauth.Auth{Metadata: map[string]any{"access_token": "same-token"}}) + cases := map[string]*cliproxyauth.Auth{ + "camel case": {Metadata: map[string]any{"accessToken": "same-token"}}, + "nested any map": {Metadata: map[string]any{"token": map[string]any{"access_token": "same-token"}}}, + "nested string map": {Metadata: map[string]any{"Token": map[string]string{"accessToken": "same-token"}}}, + } + for name, auth := range cases { + t.Run(name, func(t *testing.T) { + if got := authAccessTokenSHA256(auth); got == "" || got != want { + t.Fatalf("token hash = %q, want %q", got, want) + } + }) + } } func TestRefreshAuthViaHomeAcceptsAuthEnvelope(t *testing.T) { @@ -64,11 +104,14 @@ func TestRefreshAuthViaHomeAcceptsAuthEnvelope(t *testing.T) { }) cfg := &config.Config{Home: config.HomeConfig{Enabled: true}} + observedRefreshAt := time.Now().UTC() auth := &cliproxyauth.Auth{ - ID: "home-auth-1", - Provider: "antigravity", - Index: "home-index-1", + ID: "home-auth-1", + Provider: "antigravity", + Index: "home-index-1", + LastRefreshedAt: observedRefreshAt, Metadata: map[string]any{ + "access_token": "old-access-token", "refresh_token": "refresh-token", }, } @@ -86,6 +129,12 @@ func TestRefreshAuthViaHomeAcceptsAuthEnvelope(t *testing.T) { if client.authIndex != "home-index-1" { t.Fatalf("home refresh auth_index = %q, want home-index-1", client.authIndex) } + if !client.lastRefreshedAt.Equal(observedRefreshAt) { + t.Fatalf("home refresh last_refreshed_at = %v, want %v", client.lastRefreshedAt, observedRefreshAt) + } + if client.accessTokenHash != authAccessTokenSHA256(auth) { + t.Fatalf("home refresh access token hash = %q, want %q", client.accessTokenHash, authAccessTokenSHA256(auth)) + } if updated == nil { t.Fatal("updated auth = nil") } diff --git a/internal/runtime/executor/helps/usage_helpers.go b/internal/runtime/executor/helps/usage_helpers.go index 52e1687f..39a320fe 100644 --- a/internal/runtime/executor/helps/usage_helpers.go +++ b/internal/runtime/executor/helps/usage_helpers.go @@ -22,24 +22,25 @@ import ( ) type UsageReporter struct { - provider string - executorType string - model string - alias string - authID string - authIndex string - authType string - apiKey string - source string - reasoning string - serviceTier string - generate bool - requestedAt time.Time - ttftMu sync.RWMutex - ttft time.Duration - ttftStart time.Time - ttftSet bool - once sync.Once + provider string + executorType string + model string + alias string + authID string + authIndex string + accessTokenHash string + authType string + apiKey string + source string + reasoning string + serviceTier string + generate bool + requestedAt time.Time + ttftMu sync.RWMutex + ttft time.Duration + ttftStart time.Time + ttftSet bool + once sync.Once } type usageExecutor interface { @@ -77,6 +78,7 @@ func NewUsageReporter(ctx context.Context, provider, model string, auth *cliprox if auth != nil { reporter.authID = auth.ID reporter.authIndex = auth.EnsureIndex() + reporter.accessTokenHash = authAccessTokenSHA256(auth) } return reporter } @@ -264,6 +266,7 @@ func (r *UsageReporter) buildRecordForModel(model string, detail usage.Detail, f APIKey: r.apiKey, AuthID: r.authID, AuthIndex: r.authIndex, + AccessTokenSHA256: r.accessTokenHash, AuthType: r.authType, ReasoningEffort: r.reasoning, ServiceTier: r.serviceTier, diff --git a/sdk/cliproxy/auth/conductor_home.go b/sdk/cliproxy/auth/conductor_home.go index 324d2f7e..a4cfed50 100644 --- a/sdk/cliproxy/auth/conductor_home.go +++ b/sdk/cliproxy/auth/conductor_home.go @@ -431,14 +431,18 @@ func (m *Manager) endHomeSelectionBeforeRedispatch(ctx context.Context, selectio } func (m *Manager) retainHomeWebsocketSelection(ctx context.Context, opts cliproxyexecutor.Options, model string, selection *HomeDispatchSelection) bool { - if m == nil || selection == nil || !selection.Retained() || !cliproxyexecutor.DownstreamWebsocket(ctx) || selection.Auth == nil { + if m == nil || selection == nil || !selection.Retained() || !cliproxyexecutor.DownstreamWebsocket(ctx) { + return false + } + selectionAuth := selection.CloneAuth() + if selectionAuth == nil { return false } sessionID := homeExecutionSessionIDFromMetadata(opts.Metadata) - credentialID := strings.TrimSpace(selection.Auth.ID) + credentialID := strings.TrimSpace(selectionAuth.ID) routeModel, validRouteModel := validCanonicalHomeConcurrencyModelKey(model) if selection.accountedModel == "" { - selection.accountedModel, _ = m.predictedHomeConcurrencyModel(selection.Auth, model) + selection.accountedModel, _ = m.predictedHomeConcurrencyModel(selectionAuth, model) } if sessionID == "" || credentialID == "" || !validRouteModel || selection.accountedModel == "" { return false @@ -457,7 +461,7 @@ func (m *Manager) retainHomeWebsocketSelection(ctx context.Context, opts cliprox previous := selections[key] selections[key] = selection m.mu.Unlock() - m.rememberHomeRuntimeAuth(sessionID, selection.Auth) + m.rememberHomeRuntimeAuth(sessionID, selectionAuth) if previous != nil && previous != selection { previous.End("target_replaced") } @@ -533,11 +537,15 @@ func (m *Manager) clearHomeRuntimeAuthsForSessionLocked(sessionID string) { } func (m *Manager) bindHomeSelectionRuntimeAuth(ctx context.Context, opts cliproxyexecutor.Options, selection *HomeDispatchSelection) error { - if m == nil || selection == nil || !cliproxyexecutor.DownstreamWebsocket(ctx) || selection.Auth == nil || !authWebsocketsEnabled(selection.Auth) { + if m == nil || selection == nil || !cliproxyexecutor.DownstreamWebsocket(ctx) { + return nil + } + selectionAuth := selection.CloneAuth() + if selectionAuth == nil || !authWebsocketsEnabled(selectionAuth) { return nil } sessionID := homeExecutionSessionIDFromMetadata(opts.Metadata) - authID := strings.TrimSpace(selection.Auth.ID) + authID := strings.TrimSpace(selectionAuth.ID) if sessionID == "" || authID == "" || !selection.runtimeAuthBound.CompareAndSwap(false, true) { return nil } @@ -554,11 +562,15 @@ func (m *Manager) bindHomeSelectionRuntimeAuth(ctx context.Context, opts cliprox } func (m *Manager) rememberHomeSelectionRuntimeAuth(sessionID string, selection *HomeDispatchSelection) { - if m == nil || selection == nil || selection.Auth == nil { + if m == nil || selection == nil { + return + } + selectionAuth := selection.CloneAuth() + if selectionAuth == nil { return } sessionID = strings.TrimSpace(sessionID) - authID := strings.TrimSpace(selection.Auth.ID) + authID := strings.TrimSpace(selectionAuth.ID) if sessionID == "" || authID == "" { return } @@ -575,11 +587,33 @@ func (m *Manager) rememberHomeSelectionRuntimeAuth(sessionID string, selection * if m.homeRuntimeAuthOwners[sessionID] == nil { m.homeRuntimeAuthOwners[sessionID] = make(map[string]*HomeDispatchSelection) } - m.homeRuntimeAuths[sessionID][authID] = selection.Auth.Clone() + m.homeRuntimeAuths[sessionID][authID] = selectionAuth m.homeRuntimeAuthOwners[sessionID][authID] = selection m.mu.Unlock() } +func (m *Manager) replaceHomeSelectionAuth(selection *HomeDispatchSelection, auth *Auth) { + if m == nil || selection == nil || auth == nil { + return + } + m.mu.Lock() + selection.ReplaceAuth(auth) + updated := selection.CloneAuth() + if updated == nil { + m.mu.Unlock() + return + } + for sessionID, owners := range m.homeRuntimeAuthOwners { + for authID, owner := range owners { + if owner != selection || m.homeRuntimeAuths[sessionID] == nil { + continue + } + m.homeRuntimeAuths[sessionID][authID] = updated.Clone() + } + } + m.mu.Unlock() +} + func (m *Manager) forgetHomeRuntimeAuth(sessionID string, authID string, owner *HomeDispatchSelection) { sessionID = strings.TrimSpace(sessionID) authID = strings.TrimSpace(authID) @@ -665,7 +699,8 @@ func (m *Manager) pickNextViaHome(ctx context.Context, model string, opts clipro if errSelection != nil { return nil, nil, "", errSelection } - if selection.Auth == nil || homeAuthAlreadyTried(tried, selection.Auth.ID) { + selectionAuth := selection.CloneAuth() + if selectionAuth == nil || homeAuthAlreadyTried(tried, selectionAuth.ID) { selection.End("repeated_auth") return nil, nil, "", repeatedHomeAuthError() } diff --git a/sdk/cliproxy/auth/conductor_home_execution.go b/sdk/cliproxy/auth/conductor_home_execution.go index d7590d13..709b874f 100644 --- a/sdk/cliproxy/auth/conductor_home_execution.go +++ b/sdk/cliproxy/auth/conductor_home_execution.go @@ -120,6 +120,7 @@ func (m *Manager) executeHome(ctx context.Context, providers []string, req clipr errExecute = errRefresh } else if okRefresh { preparedAuth = refreshed + m.replaceHomeSelectionAuth(selection, preparedAuth) didRefreshOnUnauthorized = true publishSelectedAuthMetadata(opts.Metadata, preparedAuth) response, errExecute = execute() diff --git a/sdk/cliproxy/auth/conductor_refresh.go b/sdk/cliproxy/auth/conductor_refresh.go index 7cd23203..e7ed65cb 100644 --- a/sdk/cliproxy/auth/conductor_refresh.go +++ b/sdk/cliproxy/auth/conductor_refresh.go @@ -387,7 +387,7 @@ func (m *Manager) tryRefreshExecutionAuthAfterUnauthorized(ctx context.Context, if m == nil || executor == nil || auth == nil || alreadyTried || execErr == nil { return auth, false, nil } - if !isUnauthorizedError(execErr) || !authHasRefreshCredential(auth) { + if !isUnauthorizedError(execErr) || auth.AuthKind() != AuthKindOAuth { return auth, false, nil } diff --git a/sdk/cliproxy/auth/conductor_selection.go b/sdk/cliproxy/auth/conductor_selection.go index 81e41b38..30cbb4ca 100644 --- a/sdk/cliproxy/auth/conductor_selection.go +++ b/sdk/cliproxy/auth/conductor_selection.go @@ -1087,14 +1087,15 @@ func (m *Manager) SelectHomeAuthByKind(ctx context.Context, provider string, mod return nil, errSelection } providerMatches := strings.TrimSpace(provider) == "" || strings.EqualFold(strings.TrimSpace(selection.Provider), strings.TrimSpace(provider)) - kindMatches := selection.Auth != nil && selection.Auth.AuthKind() == requiredKind + selectionAuth := selection.CloneAuth() + kindMatches := selectionAuth != nil && selectionAuth.AuthKind() == requiredKind if providerMatches && kindMatches { return selection, nil } authID := "" - if selection.Auth != nil { - authID = strings.TrimSpace(selection.Auth.ID) + if selectionAuth != nil { + authID = strings.TrimSpace(selectionAuth.ID) } reason := "auth_kind_mismatch" if !providerMatches { diff --git a/sdk/cliproxy/auth/conductor_stream.go b/sdk/cliproxy/auth/conductor_stream.go index 156682cf..0d83a987 100644 --- a/sdk/cliproxy/auth/conductor_stream.go +++ b/sdk/cliproxy/auth/conductor_stream.go @@ -180,6 +180,14 @@ func (m *Manager) wrapStreamResult(ctx context.Context, auth *Auth, provider, re return &cliproxyexecutor.StreamResult{Headers: headers, Chunks: out} } +func (m *Manager) replaceHomeExecutionLifecycleAuth(lifecycle cliproxyexecutor.ExecutionLifecycle, auth *Auth) { + selection, ok := lifecycle.(*HomeDispatchSelection) + if !ok || selection == nil { + return + } + m.replaceHomeSelectionAuth(selection, auth) +} + func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor ProviderExecutor, auth *Auth, provider string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, routeModel, executionModel string, execModels []string, pooled bool, aliasResult OAuthModelAliasResult, routing *apiKeyModelRoutingSnapshot, allowRetry bool, ephemeralResult bool) (*cliproxyexecutor.StreamResult, error) { if executor == nil { return nil, &Error{Code: "executor_not_found", Message: "executor not registered"} @@ -216,6 +224,7 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi errStream = errRefresh } else if okRefresh { auth = refreshed + m.replaceHomeExecutionLifecycleAuth(execOpts.ExecutionLifecycle, auth) didRefreshOnUnauthorized = true streamResult, errStream = executor.ExecuteStream(ctx, auth, execReq, execOpts) if errStream != nil { @@ -255,6 +264,7 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi } else if okRefresh { discardStreamChunks(streamResult.Chunks) auth = refreshed + m.replaceHomeExecutionLifecycleAuth(execOpts.ExecutionLifecycle, auth) didRefreshOnUnauthorized = true retryStream, retryErr := executor.ExecuteStream(ctx, auth, execReq, execOpts) if retryErr != nil { diff --git a/sdk/cliproxy/auth/home_selection.go b/sdk/cliproxy/auth/home_selection.go index 01a39b32..a9326a80 100644 --- a/sdk/cliproxy/auth/home_selection.go +++ b/sdk/cliproxy/auth/home_selection.go @@ -141,6 +141,7 @@ type HomeDispatchSelection struct { Executor ProviderExecutor Provider string + authMu sync.RWMutex scope *executionregistry.Scope accountedModel string resources *executionResources @@ -249,9 +250,35 @@ func (s *HomeDispatchSelection) EndWithRelease(reason string) *executionregistry return s.scope.EndWithRelease("") } +// ReplaceAuth updates the selection after Home returns refreshed credentials. +func (s *HomeDispatchSelection) ReplaceAuth(auth *Auth) { + if s == nil || auth == nil { + return + } + updated := auth.Clone() + s.authMu.Lock() + defer s.authMu.Unlock() + if s.Auth != nil { + if updated.Attributes == nil { + updated.Attributes = make(map[string]string) + } + for _, key := range []string{homeUpstreamModelAttributeKey, homeForceMappingAttributeKey, homeOriginalAliasAttributeKey} { + if value := strings.TrimSpace(s.Auth.Attributes[key]); value != "" { + updated.Attributes[key] = value + } + } + } + s.Auth = updated +} + // CloneAuth returns a standalone auth copy without the selection handle. func (s *HomeDispatchSelection) CloneAuth() *Auth { - if s == nil || s.Auth == nil { + if s == nil { + return nil + } + s.authMu.RLock() + defer s.authMu.RUnlock() + if s.Auth == nil { return nil } return s.Auth.Clone() diff --git a/sdk/cliproxy/auth/home_selection_test.go b/sdk/cliproxy/auth/home_selection_test.go index 1f02fc5c..56cbe29d 100644 --- a/sdk/cliproxy/auth/home_selection_test.go +++ b/sdk/cliproxy/auth/home_selection_test.go @@ -42,6 +42,70 @@ func TestHomeDispatchSelectionOwnsScopeOutsideAuth(t *testing.T) { } } +func TestHomeDispatchSelectionReplaceAuthPreservesRoutingAttributes(t *testing.T) { + selection := &HomeDispatchSelection{Auth: &Auth{ + ID: "cred-1", + Provider: "codex", + Attributes: map[string]string{ + homeUpstreamModelAttributeKey: "gpt-5-upstream", + homeForceMappingAttributeKey: "true", + homeOriginalAliasAttributeKey: "team/gpt-5", + }, + Metadata: map[string]any{"access_token": "old"}, + }} + + selection.ReplaceAuth(&Auth{ + ID: "cred-1", + Provider: "codex", + Attributes: map[string]string{AttributeAuthKind: AuthKindOAuth}, + Metadata: map[string]any{"access_token": "fresh"}, + }) + + updated := selection.CloneAuth() + if updated == nil || updated.Metadata["access_token"] != "fresh" { + t.Fatalf("updated auth = %#v", updated) + } + if updated.Attributes[homeUpstreamModelAttributeKey] != "gpt-5-upstream" || updated.Attributes[homeForceMappingAttributeKey] != "true" || updated.Attributes[homeOriginalAliasAttributeKey] != "team/gpt-5" { + t.Fatalf("routing attributes were not preserved: %#v", updated.Attributes) + } +} + +func TestHomeDispatchSelectionReplaceAuthConcurrentClone(t *testing.T) { + selection := &HomeDispatchSelection{Auth: &Auth{ID: "cred-1", Metadata: map[string]any{"access_token": "old"}}} + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 1000; i++ { + selection.ReplaceAuth(&Auth{ID: "cred-1", Metadata: map[string]any{"access_token": "fresh"}}) + } + }() + for i := 0; i < 1000; i++ { + if auth := selection.CloneAuth(); auth == nil || auth.ID != "cred-1" { + t.Fatalf("CloneAuth() = %#v", auth) + } + } + <-done +} + +func TestReplaceHomeSelectionAuthUpdatesRetainedRuntimeAuth(t *testing.T) { + selection := &HomeDispatchSelection{Auth: &Auth{ID: "cred-1", Provider: "codex", Metadata: map[string]any{"access_token": "old"}}} + manager := &Manager{ + homeRuntimeAuths: map[string]map[string]*Auth{ + "session-1": {"cred-1": selection.Auth.Clone()}, + }, + homeRuntimeAuthOwners: map[string]map[string]*HomeDispatchSelection{ + "session-1": {"cred-1": selection}, + }, + } + + manager.replaceHomeSelectionAuth(selection, &Auth{ID: "cred-1", Provider: "codex", Metadata: map[string]any{"access_token": "fresh"}}) + + retained := manager.homeRuntimeAuths["session-1"]["cred-1"] + if retained == nil || retained.Metadata["access_token"] != "fresh" { + t.Fatalf("retained runtime auth = %#v, want fresh token", retained) + } +} + func TestHomeDispatchSelectionDrainsResourcesAddedDuringEnd(t *testing.T) { registry := executionregistry.New() pending, errBegin := registry.BeginDispatch() diff --git a/sdk/cliproxy/auth/home_unauthorized_refresh_test.go b/sdk/cliproxy/auth/home_unauthorized_refresh_test.go index c20b0d13..538046ce 100644 --- a/sdk/cliproxy/auth/home_unauthorized_refresh_test.go +++ b/sdk/cliproxy/auth/home_unauthorized_refresh_test.go @@ -26,9 +26,12 @@ func (d *homeUnauthorizedRefreshDispatcher) RPopAuth(context.Context, string, st ID: "home-refresh-auth", Provider: homeUnauthorizedRefreshProvider, Status: StatusActive, + Attributes: map[string]string{ + AttributeAuthKind: AuthKindOAuth, + "websockets": "true", + }, Metadata: map[string]any{ - "access_token": "stale-access-token", - "refresh_token": "refresh-token", + "access_token": "stale-access-token", }, }}) } @@ -36,18 +39,24 @@ func (d *homeUnauthorizedRefreshDispatcher) RPopAuth(context.Context, string, st func (*homeUnauthorizedRefreshDispatcher) AbortAmbiguousDispatch() {} type homeUnauthorizedRefreshExecutor struct { - streamMode string - refreshErr error - executeCalls atomic.Int32 - countCalls atomic.Int32 - streamCalls atomic.Int32 - refreshCalls atomic.Int32 + streamMode string + refreshErr error + retainSelection bool + executeCalls atomic.Int32 + countCalls atomic.Int32 + streamCalls atomic.Int32 + refreshCalls atomic.Int32 } func (*homeUnauthorizedRefreshExecutor) Identifier() string { return homeUnauthorizedRefreshProvider } -func (e *homeUnauthorizedRefreshExecutor) Execute(_ context.Context, auth *Auth, _ cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { +func (e *homeUnauthorizedRefreshExecutor) Execute(_ context.Context, auth *Auth, _ cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { e.executeCalls.Add(1) + if e.retainSelection { + if lifecycle, ok := opts.ExecutionLifecycle.(interface{ Retain() }); ok { + lifecycle.Retain() + } + } if authAccessToken(auth) == "stale-access-token" { return cliproxyexecutor.Response{}, &Error{HTTPStatus: http.StatusUnauthorized, Message: "expired access token"} } @@ -156,6 +165,32 @@ func TestHomeUnauthorizedRefreshesSameSelectionBeforeRedispatch(t *testing.T) { } } +func TestHomeUnauthorizedRefreshUpdatesRetainedSelection(t *testing.T) { + dispatcher := &homeUnauthorizedRefreshDispatcher{} + executor := &homeUnauthorizedRefreshExecutor{retainSelection: true} + manager := newHomeUnauthorizedRefreshManager(dispatcher, executor) + ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background()) + opts := cliproxyexecutor.Options{Metadata: map[string]any{ + cliproxyexecutor.ExecutionSessionMetadataKey: "refresh-session", + cliproxyexecutor.PinnedAuthMetadataKey: "home-refresh-auth", + }} + + for range 2 { + if _, errExecute := manager.Execute(ctx, []string{homeUnauthorizedRefreshProvider}, cliproxyexecutor.Request{Model: "model-a"}, opts); errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + } + if got := dispatcher.calls.Load(); got != 1 { + t.Fatalf("Home dispatch calls = %d, want one retained selection", got) + } + if got := executor.refreshCalls.Load(); got != 1 { + t.Fatalf("refresh calls = %d, want refreshed token reused by retained selection", got) + } + if got := executor.executeCalls.Load(); got != 3 { + t.Fatalf("execute calls = %d, want stale attempt, retry, and retained reuse", got) + } +} + func TestHomeUnauthorizedTransientRefreshFailureIsReturned(t *testing.T) { dispatcher := &homeUnauthorizedRefreshDispatcher{} executor := &homeUnauthorizedRefreshExecutor{ diff --git a/sdk/cliproxy/usage/manager.go b/sdk/cliproxy/usage/manager.go index 7fa60416..ca36dc55 100644 --- a/sdk/cliproxy/usage/manager.go +++ b/sdk/cliproxy/usage/manager.go @@ -28,8 +28,10 @@ type Record struct { APIKey string AuthID string AuthIndex string - AuthType string - Source string + // AccessTokenSHA256 identifies the OAuth token version without exposing the token. + AccessTokenSHA256 string + AuthType string + Source string // ReasoningEffort stores the translated upstream thinking level for request event logs. ReasoningEffort string // ServiceTier stores the client-requested service tier. -- 2.51.2 From 0c2ec7da235d4f5b1bcd9ab1c03ec77bccfb93f3 Mon Sep 17 00:00:00 2001 From: sususu Date: Fri, 31 Jul 2026 12:45:13 +0800 Subject: [PATCH 26/31] fix(thinking): honor normalized summary payloads --- .../runtime/executor/aistudio_executor.go | 2 +- .../executor/antigravity_executor_execute.go | 4 +- .../executor/antigravity_executor_stream.go | 2 +- .../executor/antigravity_executor_tokens.go | 2 +- .../runtime/executor/codex_openai_images.go | 2 +- .../executor/helps/model_capabilities.go | 10 +- internal/runtime/executor/helps/thinking.go | 62 ++++++- .../runtime/executor/helps/thinking_test.go | 100 ++++++++++++ internal/runtime/executor/kimi_executor.go | 4 +- internal/thinking/apply.go | 7 + internal/thinking/summary.go | 16 ++ internal/thinking/summary_test.go | 12 ++ test/thinking_conversion_test.go | 153 +++++++++--------- 13 files changed, 277 insertions(+), 99 deletions(-) create mode 100644 internal/runtime/executor/helps/thinking_test.go diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index 3cabe5da..3cc37a09 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -461,7 +461,7 @@ func (e *AIStudioExecutor) translateRequest(ctx context.Context, req cliproxyexe originalPayload := originalPayloadSource originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, stream) payload := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream) - payload, err := helps.ApplyThinkingWithSourcePayload(payload, originalPayloadSource, req.Model, from.String(), to.String(), e.Identifier()) + payload, err := helps.ApplyThinkingWithSourcePayload(payload, req.Payload, originalPayloadSource, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return nil, translatedPayload{}, err } diff --git a/internal/runtime/executor/antigravity_executor_execute.go b/internal/runtime/executor/antigravity_executor_execute.go index 77bce648..6721bb01 100644 --- a/internal/runtime/executor/antigravity_executor_execute.go +++ b/internal/runtime/executor/antigravity_executor_execute.go @@ -68,7 +68,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, false) translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) - translated, err = helps.ApplyThinkingWithSourcePayload(translated, req.Payload, req.Model, from.String(), to.String(), e.Identifier()) + translated, err = helps.ApplyThinkingWithSourcePayload(translated, req.Payload, originalPayloadSource, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return resp, err } @@ -290,7 +290,7 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) - translated, err = helps.ApplyThinkingWithSourcePayload(translated, req.Payload, req.Model, from.String(), to.String(), e.Identifier()) + translated, err = helps.ApplyThinkingWithSourcePayload(translated, req.Payload, originalPayloadSource, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return resp, err } diff --git a/internal/runtime/executor/antigravity_executor_stream.go b/internal/runtime/executor/antigravity_executor_stream.go index d0aa0725..98c7177c 100644 --- a/internal/runtime/executor/antigravity_executor_stream.go +++ b/internal/runtime/executor/antigravity_executor_stream.go @@ -63,7 +63,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) - translated, err = helps.ApplyThinkingWithSourcePayload(translated, req.Payload, req.Model, from.String(), to.String(), e.Identifier()) + translated, err = helps.ApplyThinkingWithSourcePayload(translated, req.Payload, originalPayloadSource, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return nil, err } diff --git a/internal/runtime/executor/antigravity_executor_tokens.go b/internal/runtime/executor/antigravity_executor_tokens.go index 45867ee8..523d7d2c 100644 --- a/internal/runtime/executor/antigravity_executor_tokens.go +++ b/internal/runtime/executor/antigravity_executor_tokens.go @@ -50,7 +50,7 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut // Prepare payload once (doesn't depend on baseURL) payload := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) - payload, err := helps.ApplyThinkingWithSourcePayload(payload, req.Payload, req.Model, from.String(), to.String(), e.Identifier()) + payload, err := helps.ApplyThinkingWithSourcePayload(payload, req.Payload, originalPayloadSource, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { return cliproxyexecutor.Response{}, err } diff --git a/internal/runtime/executor/codex_openai_images.go b/internal/runtime/executor/codex_openai_images.go index 3251489e..18ef4418 100644 --- a/internal/runtime/executor/codex_openai_images.go +++ b/internal/runtime/executor/codex_openai_images.go @@ -674,7 +674,7 @@ func (e *CodexExecutor) prepareCodexOpenAIImageBody(body []byte, req cliproxyexe mainModel = codexOpenAIImagesMainModel } var errThinking error - out, errThinking = helps.ApplyThinkingWithSourcePayload(out, body, mainModel, codexOpenAIImageSourceFormat, "codex", e.Identifier()) + out, errThinking = helps.ApplyThinkingWithSourcePayload(out, body, body, mainModel, codexOpenAIImageSourceFormat, "codex", e.Identifier()) if errThinking != nil { return nil, errThinking } diff --git a/internal/runtime/executor/helps/model_capabilities.go b/internal/runtime/executor/helps/model_capabilities.go index 8bf6723d..fea97c5d 100644 --- a/internal/runtime/executor/helps/model_capabilities.go +++ b/internal/runtime/executor/helps/model_capabilities.go @@ -9,13 +9,13 @@ import ( // ApplyRequestThinking preserves the registry lookup path unless the auth // manager bound an exact configured API-key model definition to this attempt. func ApplyRequestThinking(body []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, fromFormat, toFormat, provider string) ([]byte, error) { - sourceBody := opts.OriginalRequest - if len(sourceBody) == 0 { - sourceBody = req.Payload + originalSource := opts.OriginalRequest + if len(originalSource) == 0 { + originalSource = req.Payload } + summaryConfig := translatedRequestSummaryConfig(body, req.Payload, originalSource, req.Model, fromFormat, toFormat) if modelInfo, ok := cliproxyauth.ResolvedAPIKeyModelInfo(req); ok { - return thinking.ApplyThinkingWithModelInfo(body, sourceBody, req.Model, fromFormat, toFormat, provider, modelInfo) + return thinking.ApplyThinkingWithModelInfoAndSummary(body, originalSource, req.Model, fromFormat, toFormat, provider, modelInfo, summaryConfig) } - summaryConfig := thinking.ExtractSummaryConfig(sourceBody, fromFormat) return thinking.ApplyThinkingWithSummary(body, req.Model, fromFormat, toFormat, provider, summaryConfig) } diff --git a/internal/runtime/executor/helps/thinking.go b/internal/runtime/executor/helps/thinking.go index 49f3155c..9ad7a2e6 100644 --- a/internal/runtime/executor/helps/thinking.go +++ b/internal/runtime/executor/helps/thinking.go @@ -1,12 +1,64 @@ package helps -import "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" +import ( + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" +) // ApplyThinkingWithSourcePayload preserves summary visibility from the original // client payload while applying thinking configuration to its translated target -// payload. A target representation alone can lose an explicit disabled summary -// before a model suffix changes Claude thinking from disabled to adaptive. -func ApplyThinkingWithSourcePayload(body, sourcePayload []byte, model, fromFormat, toFormat, providerKey string) ([]byte, error) { - summary := thinking.ExtractSummaryConfig(sourcePayload, fromFormat) +// payload. currentSourcePayload is the payload that was translated, while +// originalSourcePayload retains intent removed by an earlier interceptor. +func ApplyThinkingWithSourcePayload(body, currentSourcePayload, originalSourcePayload []byte, model, fromFormat, toFormat, providerKey string) ([]byte, error) { + summary := translatedRequestSummaryConfig(body, currentSourcePayload, originalSourcePayload, model, fromFormat, toFormat) return thinking.ApplyThinkingWithSummary(body, model, fromFormat, toFormat, providerKey, summary) } + +// translatedRequestSummaryConfig gives the translated target payload precedence +// so a plugin request normalizer can remove or rewrite a canonical summary field. +// The original source is consulted only when the payload that was translated no +// longer carries the inbound intent, or when the target could not represent that +// intent until model-aware thinking is applied later (notably Claude). +func translatedRequestSummaryConfig(body, currentSourcePayload, originalSourcePayload []byte, model, fromFormat, toFormat string) thinking.SummaryConfig { + fromFormat = strings.ToLower(strings.TrimSpace(fromFormat)) + toFormat = strings.ToLower(strings.TrimSpace(toFormat)) + + var targetSummary thinking.SummaryConfig + if fromFormat == toFormat { + targetSummary = thinking.ExtractSummaryConfig(body, toFormat) + } else { + targetSummary = thinking.ExtractExplicitSummaryConfig(body, toFormat) + } + if targetSummary.Mode != thinking.SummaryUnspecified { + return targetSummary + } + + currentSummary := thinking.ExtractSummaryConfig(currentSourcePayload, fromFormat) + originalSummary := thinking.ExtractSummaryConfig(originalSourcePayload, fromFormat) + if currentSummary.Mode == thinking.SummaryUnspecified { + return originalSummary + } + + from := sdktranslator.FromString(fromFormat) + to := sdktranslator.FromString(toFormat) + if !sdktranslator.HasRequestTransformer(from, to) { + // A missing translation must remain source-shaped. Same-format requests + // were handled by targetSummary above, including explicit native aliases. + return thinking.SummaryConfig{} + } + + candidate := thinking.ApplySummaryConfigForModel(body, toFormat, model, currentSummary) + if thinking.ExtractExplicitSummaryConfig(candidate, toFormat).Mode != thinking.SummaryUnspecified { + // Registry translation applied this field before plugin normalization. If + // it is absent now but can be represented on the normalized body, the + // normalizer deliberately removed it and must remain authoritative. + return thinking.SummaryConfig{} + } + + // Some intents cannot be represented until the final model-aware pass. For + // example, Claude display is invalid on disabled thinking, but a suffix can + // subsequently activate adaptive thinking. Preserve the source in that case. + return currentSummary +} diff --git a/internal/runtime/executor/helps/thinking_test.go b/internal/runtime/executor/helps/thinking_test.go new file mode 100644 index 00000000..69b18fda --- /dev/null +++ b/internal/runtime/executor/helps/thinking_test.go @@ -0,0 +1,100 @@ +package helps_test + +import ( + "context" + "testing" + + helps "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/gemini" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +type summaryRemovingPluginHooks struct { + t *testing.T +} + +func (h *summaryRemovingPluginHooks) NormalizeRequest(_ context.Context, _, _ sdktranslator.Format, _ string, body []byte, _ bool) []byte { + h.t.Helper() + const path = "generationConfig.thinkingConfig.includeThoughts" + if !gjson.GetBytes(body, path).Bool() { + h.t.Fatalf("request normalizer did not receive enabled summary: %s", body) + } + out, _ := sjson.DeleteBytes(body, path) + return out +} + +func (*summaryRemovingPluginHooks) TranslateRequest(context.Context, sdktranslator.Format, sdktranslator.Format, string, []byte, bool) ([]byte, bool) { + return nil, false +} + +func (*summaryRemovingPluginHooks) NormalizeResponseBefore(context.Context, sdktranslator.Format, sdktranslator.Format, string, []byte, []byte, []byte, bool) []byte { + return nil +} + +func (*summaryRemovingPluginHooks) TranslateResponse(context.Context, sdktranslator.Format, sdktranslator.Format, string, []byte, []byte, []byte, bool) ([]byte, bool) { + return nil, false +} + +func (*summaryRemovingPluginHooks) NormalizeResponseAfter(context.Context, sdktranslator.Format, sdktranslator.Format, string, []byte, []byte, []byte, bool) []byte { + return nil +} + +func TestApplyThinkingWithSourcePayloadPreservesNormalizerSummaryRemoval(t *testing.T) { + hooks := &summaryRemovingPluginHooks{t: t} + sdktranslator.SetPluginHooks(hooks) + t.Cleanup(func() { sdktranslator.SetPluginHooks(nil) }) + + source := []byte(`{"model":"gemini-3.6-flash","reasoning":{"effort":"high","summary":"auto"},"input":"hi"}`) + translated := sdktranslator.TranslateRequest( + sdktranslator.FormatOpenAIResponse, + sdktranslator.FormatGemini, + "gemini-3.6-flash", + source, + false, + ) + const summaryPath = "generationConfig.thinkingConfig.includeThoughts" + if gjson.GetBytes(translated, summaryPath).Exists() { + t.Fatalf("request normalizer did not remove summary: %s", translated) + } + + out, err := helps.ApplyThinkingWithSourcePayload( + translated, + source, + source, + "gemini-3.6-flash", + sdktranslator.FormatOpenAIResponse.String(), + sdktranslator.FormatGemini.String(), + "gemini", + ) + if err != nil { + t.Fatalf("ApplyThinkingWithSourcePayload() error = %v", err) + } + if gjson.GetBytes(out, summaryPath).Exists() { + t.Fatalf("executor restored summary removed by request normalizer: %s", out) + } +} + +func TestApplyThinkingWithSourcePayloadPreservesOriginalOnlySummary(t *testing.T) { + currentSource := []byte(`{"model":"gemini-3.6-flash","input":"hi"}`) + originalSource := []byte(`{"model":"gemini-3.6-flash","reasoning":{"summary":null},"input":"hi"}`) + body := []byte(`{"generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}}}`) + + out, err := helps.ApplyThinkingWithSourcePayload( + body, + currentSource, + originalSource, + "gemini-3.6-flash", + sdktranslator.FormatOpenAIResponse.String(), + sdktranslator.FormatGemini.String(), + "gemini", + ) + if err != nil { + t.Fatalf("ApplyThinkingWithSourcePayload() error = %v", err) + } + if include := gjson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts"); !include.Exists() || include.Bool() { + t.Fatalf("original disabled summary was not preserved: %s", out) + } +} diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go index d3c88145..b9a89425 100644 --- a/internal/runtime/executor/kimi_executor.go +++ b/internal/runtime/executor/kimi_executor.go @@ -113,7 +113,7 @@ func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req return resp, fmt.Errorf("kimi executor: failed to set model in payload: %w", err) } - body, err = helps.ApplyThinkingWithSourcePayload(body, req.Payload, req.Model, from.String(), "kimi", e.Identifier()) + body, err = helps.ApplyThinkingWithSourcePayload(body, req.Payload, originalPayloadSource, req.Model, from.String(), "kimi", e.Identifier()) if err != nil { return resp, err } @@ -222,7 +222,7 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut return nil, fmt.Errorf("kimi executor: failed to set model in payload: %w", err) } - body, err = helps.ApplyThinkingWithSourcePayload(body, req.Payload, req.Model, from.String(), "kimi", e.Identifier()) + body, err = helps.ApplyThinkingWithSourcePayload(body, req.Payload, originalPayloadSource, req.Model, from.String(), "kimi", e.Identifier()) if err != nil { return nil, err } diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go index a349f269..e9e3d34d 100644 --- a/internal/thinking/apply.go +++ b/internal/thinking/apply.go @@ -183,6 +183,13 @@ func ApplyThinkingWithModelInfo(body, sourceBody []byte, model string, fromForma if len(sourceBody) == 0 { summaryConfig = ExtractSummaryConfig(body, toFormat) } + return ApplyThinkingWithModelInfoAndSummary(body, sourceBody, model, fromFormat, toFormat, providerKey, modelInfo, summaryConfig) +} + +// ApplyThinkingWithModelInfoAndSummary applies the exact configured model +// definition with a summary intent already resolved across source translation +// and plugin normalization. +func ApplyThinkingWithModelInfoAndSummary(body, sourceBody []byte, model string, fromFormat string, toFormat string, providerKey string, modelInfo *registry.ModelInfo, summaryConfig SummaryConfig) ([]byte, error) { return applyThinking(body, sourceBody, model, fromFormat, toFormat, providerKey, modelInfo, true, summaryConfig) } diff --git a/internal/thinking/summary.go b/internal/thinking/summary.go index 17951990..72977ab4 100644 --- a/internal/thinking/summary.go +++ b/internal/thinking/summary.go @@ -114,6 +114,22 @@ func ExtractSummaryConfig(body []byte, format string) SummaryConfig { return SummaryConfig{} } +// ExtractExplicitSummaryConfig reads only explicit visibility controls from a +// provider payload. Unlike ExtractSummaryConfig, OpenAI Chat reasoning_effort +// is not treated as a summary proxy. This lets executor post-processing tell +// whether a request normalizer retained or removed the translated target field. +func ExtractExplicitSummaryConfig(body []byte, format string) SummaryConfig { + normalized := strings.ToLower(strings.TrimSpace(format)) + if normalized != "openai" { + return ExtractSummaryConfig(body, normalized) + } + if len(body) == 0 || !gjson.ValidBytes(body) { + return SummaryConfig{} + } + config, _ := extractOpenAIExplicitSummaryConfig(body) + return config +} + // ApplySummaryConfig writes canonical summary intent in the target protocol. func ApplySummaryConfig(body []byte, format string, config SummaryConfig) []byte { return ApplySummaryConfigForModel(body, format, "", config) diff --git a/internal/thinking/summary_test.go b/internal/thinking/summary_test.go index 84c110c9..e038fc14 100644 --- a/internal/thinking/summary_test.go +++ b/internal/thinking/summary_test.go @@ -75,6 +75,18 @@ func TestExtractSummaryConfig(t *testing.T) { } } +func TestExtractExplicitSummaryConfigDoesNotUseChatEffort(t *testing.T) { + body := []byte(`{"reasoning_effort":"high"}`) + if got := ExtractExplicitSummaryConfig(body, "openai"); got.Mode != SummaryUnspecified { + t.Fatalf("ExtractExplicitSummaryConfig() = %+v, want unspecified", got) + } + + body = []byte(`{"reasoning_effort":"high","reasoning":{"exclude":true}}`) + if got := ExtractExplicitSummaryConfig(body, "openai"); got.Mode != SummaryDisabled { + t.Fatalf("ExtractExplicitSummaryConfig() = %+v, want disabled", got) + } +} + func TestApplySummaryConfig(t *testing.T) { tests := []struct { name string diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index d71d6e35..45d709e3 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -241,7 +241,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"level-subset-model(1)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingLevel", expectValue: "low", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 17A: auto → medium → clamped to low when low/high are equally close @@ -277,7 +277,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(medium)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 20: Effort xhigh → clamped to 20000 (max) @@ -289,10 +289,10 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(xhigh)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, - // Case 21: Effort none → clamped to 128 (min) → includeThoughts=false + // Case 21: Effort none → clamped to 128 (min) { name: "21", from: "openai", @@ -301,7 +301,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(none)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "128", - includeThoughts: "false", + includeThoughts: "", expectErr: false, }, // Case 22: Effort auto → DynamicAllowed=true → -1 @@ -313,7 +313,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(auto)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "-1", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 23: Claude source no suffix → passthrough @@ -335,7 +335,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(8192)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 25: Budget 64000 → clamped to 20000 (max) @@ -347,10 +347,10 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(64000)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, - // Case 26: Budget 0 → clamped to 128 (min) → includeThoughts=false + // Case 26: Budget 0 → clamped to 128 (min) { name: "26", from: "claude", @@ -359,7 +359,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(0)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "128", - includeThoughts: "false", + includeThoughts: "", expectErr: false, }, // Case 27: Budget -1 → DynamicAllowed=true → -1 @@ -371,7 +371,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(-1)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "-1", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, @@ -396,7 +396,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model(high)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingLevel", expectValue: "high", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 30: Effort xhigh → clamped to high @@ -408,10 +408,10 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model(xhigh)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingLevel", expectValue: "high", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, - // Case 31: Effort none → clamped to low (min supported) → includeThoughts=false + // Case 31: Effort none → clamped to low (min supported) { name: "31", from: "openai", @@ -420,7 +420,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model(none)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingLevel", expectValue: "low", - includeThoughts: "false", + includeThoughts: "", expectErr: false, }, // Case 32: Effort auto → DynamicAllowed=true → -1 (budget) @@ -432,7 +432,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model(auto)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "-1", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 33: Claude source no suffix → passthrough @@ -454,7 +454,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model(8192)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 35: Budget 64000 → clamped to 32768 (max) @@ -466,10 +466,10 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model(64000)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "32768", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, - // Case 36: Budget 0 → minimal → clamped to low (min level) → includeThoughts=false + // Case 36: Budget 0 → minimal → clamped to low (min level) { name: "36", from: "claude", @@ -478,7 +478,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model(0)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingLevel", expectValue: "low", - includeThoughts: "false", + includeThoughts: "", expectErr: false, }, // Case 37: Budget -1 → DynamicAllowed=true → -1 (budget) @@ -490,7 +490,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model(-1)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "-1", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, @@ -626,7 +626,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model(medium)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 50: Effort xhigh → clamped to 20000 (max) @@ -638,10 +638,10 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model(xhigh)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, - // Case 51: Effort none → ZeroAllowed=true → 0 → includeThoughts=false + // Case 51: Effort none → ZeroAllowed=true → 0 { name: "51", from: "gemini", @@ -650,7 +650,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model(none)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "0", - includeThoughts: "false", + includeThoughts: "", expectErr: false, }, // Case 52: Effort auto → DynamicAllowed=true → -1 @@ -662,7 +662,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model(auto)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "-1", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 53: Claude to Antigravity no suffix → passthrough @@ -684,7 +684,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model(8192)","messages":[{"role":"user","content":"hi"}]}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 55: Budget 64000 → clamped to 20000 (max) @@ -696,10 +696,10 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model(64000)","messages":[{"role":"user","content":"hi"}]}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, - // Case 56: Budget 0 → ZeroAllowed=true → 0 → includeThoughts=false + // Case 56: Budget 0 → ZeroAllowed=true → 0 { name: "56", from: "claude", @@ -708,7 +708,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model(0)","messages":[{"role":"user","content":"hi"}]}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "0", - includeThoughts: "false", + includeThoughts: "", expectErr: false, }, // Case 57: Budget -1 → DynamicAllowed=true → -1 @@ -720,7 +720,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model(-1)","messages":[{"role":"user","content":"hi"}]}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "-1", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, @@ -927,7 +927,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"user-defined-model(8192)","messages":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 77: OpenAI to Claude budget 8192 → passthrough → 8192 @@ -950,7 +950,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"user-defined-model(8192)","input":[{"role":"user","content":"hi"}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 79: OpenAI-Response to Claude budget 8192 → passthrough → 8192 @@ -1018,7 +1018,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(8192)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 85: Gemini to Gemini, budget 64000 → clamped to Max @@ -1030,7 +1030,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(64000)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 86: Claude to Claude, budget 8192 → passthrough thinking.budget_tokens @@ -1067,7 +1067,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(64000)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 89: Gemini to Antigravity, budget 8192 → passthrough (normal value) @@ -1079,7 +1079,7 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { inputJSON: `{"model":"gemini-budget-model(8192)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, } @@ -1285,7 +1285,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"level-subset-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":1}}`, expectField: "generationConfig.thinkingConfig.thinkingLevel", expectValue: "low", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, @@ -1368,7 +1368,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":8192}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 25: thinking.budget_tokens=64000 → clamped to 20000 @@ -1380,10 +1380,10 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":64000}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, - // Case 26: thinking.budget_tokens=0 → clamped to 128 → includeThoughts=false + // Case 26: thinking.budget_tokens=0 → clamped to 128 { name: "26", from: "claude", @@ -1392,7 +1392,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":0}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "128", - includeThoughts: "false", + includeThoughts: "", expectErr: false, }, // Case 27: thinking.budget_tokens=-1 → -1 (DynamicAllowed=true) @@ -1404,7 +1404,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":-1}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "-1", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, @@ -1528,7 +1528,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":8192}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 35: thinking.budget_tokens=64000 → clamped to 32768 (keeps budget) @@ -1540,10 +1540,10 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":64000}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "32768", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, - // Case 36: thinking.budget_tokens=0 → clamped to low → includeThoughts=false + // Case 36: thinking.budget_tokens=0 → clamped to low { name: "36", from: "claude", @@ -1552,7 +1552,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":0}}`, expectField: "generationConfig.thinkingConfig.thinkingLevel", expectValue: "low", - includeThoughts: "false", + includeThoughts: "", expectErr: false, }, // Case 37: thinking.budget_tokens=-1 → -1 (DynamicAllowed=true) @@ -1564,7 +1564,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":-1}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "-1", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, @@ -1700,7 +1700,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"medium"}}}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 50: thinkingLevel=xhigh → clamped to 20000 @@ -1712,7 +1712,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"xhigh"}}}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 51: thinkingLevel=none → 0 (ZeroAllowed=true) @@ -1724,7 +1724,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"none"}}}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "0", - includeThoughts: "false", + includeThoughts: "", expectErr: false, }, // Case 52: thinkingBudget=-1 → -1 (DynamicAllowed=true) @@ -1736,7 +1736,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "-1", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 53: Claude no param → passthrough @@ -1758,7 +1758,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":8192}}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 55: thinking.budget_tokens=64000 → clamped to 20000 @@ -1770,7 +1770,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":64000}}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 56: thinking.budget_tokens=0 → 0 (ZeroAllowed=true) @@ -1782,7 +1782,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":0}}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "0", - includeThoughts: "false", + includeThoughts: "", expectErr: false, }, // Case 57: thinking.budget_tokens=-1 → -1 (DynamicAllowed=true) @@ -1794,7 +1794,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":-1}}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "-1", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, @@ -2024,7 +2024,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"user-defined-model","input":[{"role":"user","content":"hi"}],"reasoning":{"effort":"medium"}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 79: OpenAI-Response reasoning.effort=medium to Claude → 8192 @@ -2092,7 +2092,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"gemini-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":8192}}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, // Case 85: Gemini to Gemini, thinkingBudget=64000 → exceeds Max error @@ -2148,7 +2148,7 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { inputJSON: `{"model":"gemini-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":8192}}}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, } @@ -2511,7 +2511,7 @@ func TestThinkingE2EProviderTargets(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","input":"hi","reasoning":{"effort":"medium"}}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", }, } @@ -2692,7 +2692,7 @@ func TestThinkingE2EInteractionsMatrix(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","generation_config":{"thinking_level":"medium"},"input":"hi"}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", }, { name: "OUT6", @@ -2733,7 +2733,7 @@ func TestThinkingE2EInteractionsMatrix(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","generation_config":{"thinking_level":"none"},"input":"hi"}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "0", - includeThoughts: "false", + includeThoughts: "", }, { name: "OUT10", @@ -3084,7 +3084,7 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) { inputJSON: `{"model":"level-subset-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`, expectField: "generationConfig.thinkingConfig.thinkingLevel", expectValue: "high", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, { @@ -3095,7 +3095,7 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) { inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "1024", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, { @@ -3106,7 +3106,7 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) { inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"medium"}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "8192", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, { @@ -3117,7 +3117,7 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) { inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, { @@ -3128,7 +3128,7 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) { inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`, expectField: "generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, { @@ -3139,7 +3139,7 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) { inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`, expectField: "generationConfig.thinkingConfig.thinkingLevel", expectValue: "high", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, @@ -3201,7 +3201,7 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) { inputJSON: `{"model":"antigravity-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`, expectField: "request.generationConfig.thinkingConfig.thinkingBudget", expectValue: "20000", - includeThoughts: "true", + includeThoughts: "", expectErr: false, }, @@ -3504,18 +3504,9 @@ func runThinkingTests(t *testing.T, cases []thinkingTestCase) { if tc.to == "antigravity" { path = "request.generationConfig.thinkingConfig.includeThoughts" } - wantIncludeThoughts := "" - summaryConfig := thinking.ExtractSummaryConfig([]byte(tc.inputJSON), tc.from) - switch summaryConfig.Mode { - case thinking.SummaryEnabled: - wantIncludeThoughts = "true" - case thinking.SummaryDisabled: - wantIncludeThoughts = "false" - default: - // Thinking amount does not imply summary visibility. Keep the - // provider field absent when the source omitted its summary control. - } - + // Each case declares its expected visibility independently from the + // extractor under test. Empty means the provider field must be absent. + wantIncludeThoughts := tc.includeThoughts itVal := gjson.GetBytes(body, path) if wantIncludeThoughts == "" { if itVal.Exists() { -- 2.51.2 From c4dcd8703ad964ab7ca1f0c98b74948d7de7d1ce Mon Sep 17 00:00:00 2001 From: sususu Date: Fri, 31 Jul 2026 13:28:14 +0800 Subject: [PATCH 27/31] fix(thinking): respect final summary authority --- .../executor/helps/model_capabilities_test.go | 88 ++++++++++++++++++- internal/thinking/apply.go | 11 +++ .../thinking/apply_configured_api_key_test.go | 20 +++++ internal/thinking/summary.go | 28 ++++++ sdk/translator/registry.go | 7 +- sdk/translator/registry_summary_test.go | 53 +++++++++++ 6 files changed, 202 insertions(+), 5 deletions(-) diff --git a/internal/runtime/executor/helps/model_capabilities_test.go b/internal/runtime/executor/helps/model_capabilities_test.go index c1e0b371..826c82e9 100644 --- a/internal/runtime/executor/helps/model_capabilities_test.go +++ b/internal/runtime/executor/helps/model_capabilities_test.go @@ -9,6 +9,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" helps "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/claude" + _ "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" @@ -16,8 +17,10 @@ import ( ) type configuredThinkingExecutor struct { - seenModel string - resolved bool + seenModel string + resolved bool + translateRequest bool + translatedBody []byte } func (*configuredThinkingExecutor) Identifier() string { return "claude" } @@ -27,6 +30,10 @@ func (e *configuredThinkingExecutor) Execute(_ context.Context, _ *cliproxyauth. modelInfo, resolved := cliproxyauth.ResolvedAPIKeyModelInfo(req) e.resolved = resolved && modelInfo != nil body := []byte(`{"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}}`) + if e.translateRequest { + body = sdktranslator.TranslateRequest(opts.SourceFormat, sdktranslator.FormatClaude, req.Model, req.Payload, opts.Stream) + e.translatedBody = append(e.translatedBody[:0], body...) + } out, err := helps.ApplyRequestThinking(body, req, opts, opts.SourceFormat.String(), "claude", "claude") return cliproxyexecutor.Response{Payload: out}, err } @@ -54,6 +61,83 @@ func (*configuredThinkingExecutor) HttpRequest(context.Context, *cliproxyauth.Au return nil, nil } +func TestApplyRequestThinkingUsesExactClaudeModeForSummaryOnlyRequest(t *testing.T) { + manager := cliproxyauth.NewManager(nil, nil, nil) + manager.SetConfig(&internalconfig.Config{ + SDKConfig: internalconfig.SDKConfig{ForceModelPrefix: true}, + ClaudeKey: []internalconfig.ClaudeKey{{ + APIKey: "summary-selected-key", + Prefix: "summary-tenant", + Models: []internalconfig.ClaudeModel{{ + Name: "summary-shared-upstream", + Alias: "summary-public-model", + Thinking: ®istry.ThinkingSupport{ + Min: 1024, + Max: 16000, + }, + }}, + }}, + }) + executor := &configuredThinkingExecutor{translateRequest: true} + manager.RegisterExecutor(executor) + auth := &cliproxyauth.Auth{ + ID: "summary-selected-auth", + Provider: "claude", + Prefix: "summary-tenant", + Attributes: map[string]string{ + cliproxyauth.AttributeAuthKind: cliproxyauth.AuthKindAPIKey, + cliproxyauth.AttributeAPIKey: "summary-selected-key", + cliproxyauth.AttributeSource: "config:claude[0]", + }, + } + + modelRegistry := registry.GetGlobalRegistry() + modelRegistry.RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ + ID: "summary-tenant/summary-public-model", Type: "claude", + }}) + modelRegistry.RegisterClient("summary-unrelated-auth", auth.Provider, []*registry.ModelInfo{{ + ID: "summary-shared-upstream", Type: "claude", + Thinking: ®istry.ThinkingSupport{Levels: []string{"high"}}, + }}) + t.Cleanup(func() { + modelRegistry.UnregisterClient(auth.ID) + modelRegistry.UnregisterClient("summary-unrelated-auth") + }) + if registered, errRegister := manager.Register(t.Context(), auth); errRegister != nil { + t.Fatalf("Register() error = %v", errRegister) + } else if registered == nil { + t.Fatal("Register() returned nil auth") + } + + original := []byte(`{"model":"summary-tenant/summary-public-model","reasoning":{"summary":"auto"},"input":"hi"}`) + response, errExecute := manager.Execute(t.Context(), []string{"claude"}, cliproxyexecutor.Request{ + Model: "summary-tenant/summary-public-model", + Payload: original, + Format: sdktranslator.FormatOpenAIResponse, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatOpenAIResponse, + OriginalRequest: original, + }) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + if got := gjson.GetBytes(executor.translatedBody, "thinking.type").String(); got != "adaptive" { + t.Fatalf("pre-executor thinking.type = %q, want global adaptive trigger; body=%s", got, executor.translatedBody) + } + if got := gjson.GetBytes(response.Payload, "thinking.type").String(); got != "enabled" { + t.Fatalf("thinking.type = %q, want exact manual mode; body=%s", got, response.Payload) + } + if got := gjson.GetBytes(response.Payload, "thinking.budget_tokens").Int(); got != 1024 { + t.Fatalf("thinking.budget_tokens = %d, want exact minimum 1024; body=%s", got, response.Payload) + } + if got := gjson.GetBytes(response.Payload, "thinking.display").String(); got != "summarized" { + t.Fatalf("thinking.display = %q, want summarized; body=%s", got, response.Payload) + } + if gjson.GetBytes(response.Payload, "output_config.effort").Exists() { + t.Fatalf("manual thinking retained adaptive effort: %s", response.Payload) + } +} + func TestApplyRequestThinkingUsesSelectedPrefixedAPIKeyModel(t *testing.T) { manager := cliproxyauth.NewManager(nil, nil, nil) manager.SetConfig(&internalconfig.Config{ diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go index e9e3d34d..92e6161c 100644 --- a/internal/thinking/apply.go +++ b/internal/thinking/apply.go @@ -284,6 +284,17 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF "provider": providerFormat, "model": modelInfo.ID, }).Debug("thinking: no config found, passthrough |") + if modelInfoResolved && providerFormat == "claude" && fromFormat != providerFormat && ExtractSummaryConfig(sourceBody, fromFormat).Mode == SummaryEnabled { + // Registry translation can only see aggregate model capabilities. For a + // cross-protocol summary-only request it may have activated adaptive + // thinking solely to make display valid. The selected API-key model is + // authoritative at execution time, so discard that inferred activation + // when the exact model supports only manual extended thinking. Use the + // source intent here even if a target normalizer removed display; in that + // case the inferred amount must disappear with it. Explicit native Claude + // thinking never reaches this cross-protocol branch. + body = stripInferredClaudeSummaryActivation(body, modelInfo) + } return applySummaryConfigForProvider(body, providerFormat, baseModel, providerKey, modelInfo, summaryConfig), nil } if modelInfoResolved && config.Mode == ModeLevel && modelInfo != nil && modelInfo.Thinking != nil && shouldMapConfiguredHighIntent(fromFormat, providerFormat, modelInfo) { diff --git a/internal/thinking/apply_configured_api_key_test.go b/internal/thinking/apply_configured_api_key_test.go index b056139e..9c48c36d 100644 --- a/internal/thinking/apply_configured_api_key_test.go +++ b/internal/thinking/apply_configured_api_key_test.go @@ -114,6 +114,26 @@ func TestApplyThinkingWithModelInfoAppliesEnabledSummaryOnlyClaudeVisibility(t * } } +func TestApplyThinkingWithModelInfoAndSummaryDropsInferredClaudeModeWhenSummaryRemoved(t *testing.T) { + modelInfo := ®istry.ModelInfo{ + ID: "private-manual-claude", + Type: "claude", + Thinking: ®istry.ThinkingSupport{Min: 1024, Max: 16000}, + } + out, err := thinking.ApplyThinkingWithModelInfoAndSummary( + []byte(`{"model":"private-manual-claude","max_tokens":32000,"thinking":{"type":"adaptive"}}`), + []byte(`{"reasoning":{"summary":"auto"}}`), + "private-manual-claude", "openai-response", "claude", "claude", modelInfo, + thinking.SummaryConfig{}, + ) + if err != nil { + t.Fatalf("ApplyThinkingWithModelInfoAndSummary() error = %v", err) + } + if gjson.GetBytes(out, "thinking").Exists() { + t.Fatalf("removed summary retained globally inferred adaptive thinking: %s", out) + } +} + func TestApplyThinkingWithModelInfoDoesNotActivateClaudeForDisabledSummary(t *testing.T) { modelInfo := ®istry.ModelInfo{ ID: "private-claude", diff --git a/internal/thinking/summary.go b/internal/thinking/summary.go index 72977ab4..34ae9010 100644 --- a/internal/thinking/summary.go +++ b/internal/thinking/summary.go @@ -441,6 +441,34 @@ func interactionsSummaryConfig(body []byte, path string) (SummaryConfig, bool) { } } +// stripInferredClaudeSummaryActivation removes a globally inferred adaptive +// mode when the selected API-key model supports only manual extended thinking. +// The exact model-aware summary pass can then activate enabled thinking with a +// valid budget, or leave thinking absent when max_tokens cannot accommodate it. +func stripInferredClaudeSummaryActivation(body []byte, modelInfo *registry.ModelInfo) []byte { + if modelInfo == nil || modelInfo.Thinking == nil || len(modelInfo.Thinking.Levels) > 0 || modelInfo.Thinking.Min <= 0 { + return body + } + if !strings.EqualFold(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String()), "adaptive") { + return body + } + + for _, path := range []string{ + "thinking.type", + "thinking.budget_tokens", + "thinking.display", + "output_config.effort", + } { + body, _ = sjson.DeleteBytes(body, path) + } + for _, path := range []string{"thinking", "output_config"} { + if object := gjson.GetBytes(body, path); object.Exists() && object.IsObject() && len(object.Map()) == 0 { + body, _ = sjson.DeleteBytes(body, path) + } + } + return body +} + func enableClaudeThinkingForSummary(body []byte, model string, resolvedModelInfo *registry.ModelInfo) []byte { modelInfo := resolvedModelInfo if modelInfo == nil { diff --git a/sdk/translator/registry.go b/sdk/translator/registry.go index 830d0355..6e9f0eed 100644 --- a/sdk/translator/registry.go +++ b/sdk/translator/registry.go @@ -57,8 +57,6 @@ func (r *Registry) SetPluginHooks(hooks PluginHooks) { // "model" field is still updated to match the resolved model name so that // client-side prefixes (e.g. "copilot/gpt-5-mini") are not leaked upstream. func (r *Registry) TranslateRequest(from, to Format, model string, rawJSON []byte, stream bool) []byte { - summaryConfig := thinking.ExtractSummaryConfig(rawJSON, from.String()) - r.mu.RLock() var fn RequestTransform if byTarget, ok := r.requests[from]; ok { @@ -69,6 +67,7 @@ func (r *Registry) TranslateRequest(from, to Format, model string, rawJSON []byt body := rawJSON if fn != nil { + summaryConfig := thinking.ExtractSummaryConfig(rawJSON, from.String()) body = fn(model, body, stream) body = thinking.ApplySummaryConfigForModel(body, to.String(), model, summaryConfig) if hooks != nil { @@ -93,8 +92,10 @@ func (r *Registry) TranslateRequest(from, to Format, model string, rawJSON []byt } // Plugin request normalizers canonicalize the source before a plugin request - // translator gets a chance to handle a missing native route. + // translator gets a chance to handle a missing native route. Extract summary + // intent from that normalized source so a normalizer can remove or rewrite it. body = hooks.NormalizeRequest(context.Background(), from, to, model, body, stream) + summaryConfig := thinking.ExtractSummaryConfig(body, from.String()) if translated, ok := hooks.TranslateRequest(context.Background(), from, to, model, body, stream); ok { body = thinking.ApplySummaryConfigForModel(translated, to.String(), model, summaryConfig) } diff --git a/sdk/translator/registry_summary_test.go b/sdk/translator/registry_summary_test.go index 1b77b951..16b03216 100644 --- a/sdk/translator/registry_summary_test.go +++ b/sdk/translator/registry_summary_test.go @@ -178,6 +178,59 @@ func TestRegistryTranslateRequestAppliesSummaryAfterPluginTranslation(t *testing } } +func TestRegistryTranslateRequestPluginNormalizerOwnsSourceSummaryIntent(t *testing.T) { + tests := []struct { + name string + normalize func([]byte) []byte + wantExists bool + want bool + }{ + { + name: "removed summary remains absent", + normalize: func(body []byte) []byte { + out, _ := sjson.DeleteBytes(body, "reasoning.summary") + return out + }, + }, + { + name: "disabled summary replaces enabled intent", + normalize: func(body []byte) []byte { + out, _ := sjson.SetBytes(body, "reasoning.summary", nil) + return out + }, + wantExists: true, + want: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + registry := NewRegistry() + hooks := &fakePluginHooks{ + normalizeRequest: test.normalize, + requestTranslateBody: []byte(`{"generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}}}`), + requestTranslateOK: true, + } + registry.SetPluginHooks(hooks) + + out := registry.TranslateRequest( + FormatOpenAIResponse, + FormatGemini, + "gemini-3.6-flash", + []byte(`{"reasoning":{"summary":"auto"},"input":"hi"}`), + false, + ) + result := gjson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts") + if result.Exists() != test.wantExists { + t.Fatalf("includeThoughts exists = %v, want %v; body=%s", result.Exists(), test.wantExists, out) + } + if test.wantExists && result.Bool() != test.want { + t.Fatalf("includeThoughts = %v, want %v; body=%s", result.Bool(), test.want, out) + } + }) + } +} + func TestRegistryTranslateRequestNormalizerOwnsFinalSummaryField(t *testing.T) { registry := NewRegistry() registry.Register(FormatOpenAIResponse, FormatGemini, func(_ string, _ []byte, _ bool) []byte { -- 2.51.2 From 24323ee4b7c33ec7268abf15728fe712888f90c4 Mon Sep 17 00:00:00 2001 From: sususu Date: Fri, 31 Jul 2026 13:40:41 +0800 Subject: [PATCH 28/31] fix(thinking): drop disabled Interactions summaries --- internal/thinking/provider/interactions/apply.go | 5 ++++- test/summary_intent_translation_test.go | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/thinking/provider/interactions/apply.go b/internal/thinking/provider/interactions/apply.go index c644f5ad..b23f0d74 100644 --- a/internal/thinking/provider/interactions/apply.go +++ b/internal/thinking/provider/interactions/apply.go @@ -77,7 +77,10 @@ func applyInteractionsNone(result, original []byte, config thinking.ThinkingConf if config.Budget > 0 { return applyInteractionsBudget(result, original, config.Budget, modelInfo) } - return setInteractionsThinkingSummaries(result, original) + // With the amount fully disabled, visibility is irrelevant. Restoring + // thinking_summaries alone could make a default-on model reason and return a + // summary despite the explicit none override. + return result } func stripInteractionsThinkingFields(body []byte) []byte { diff --git a/test/summary_intent_translation_test.go b/test/summary_intent_translation_test.go index cc1724f5..b19f0129 100644 --- a/test/summary_intent_translation_test.go +++ b/test/summary_intent_translation_test.go @@ -144,6 +144,7 @@ func TestSummaryIntentFinalPipeline(t *testing.T) { {name: "Summary-only control is stripped for non-thinking Gemini model", from: sdktranslator.FormatOpenAIResponse, to: sdktranslator.FormatGemini, model: "no-thinking-model", body: `{"model":"no-thinking-model","reasoning":{"summary":"auto"},"input":"hi"}`, path: "generationConfig.thinkingConfig"}, {name: "Interactions level alone keeps summaries omitted", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatInteractions, model: "level-model", body: `{"model":"level-model","generation_config":{"thinking_level":"high"},"input":"hi"}`, path: "generation_config.thinking_summaries"}, {name: "Interactions auto survives its applier", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatInteractions, model: "level-model", body: `{"model":"level-model","generation_config":{"thinking_level":"high","thinking_summaries":"auto"},"input":"hi"}`, path: "generation_config.thinking_summaries", want: "auto", wantExists: true}, + {name: "Interactions suffix none removes summary visibility", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatInteractions, model: "gemini-toggle-mixed-model(none)", body: `{"model":"gemini-toggle-mixed-model(none)","generation_config":{"thinking_summaries":"auto"},"input":"hi"}`, path: "generation_config.thinking_summaries"}, {name: "Interactions reasoning effort leaves Antigravity summaries unspecified", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"antigravity-budget-model","reasoning":{"effort":"high"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts"}, {name: "Interactions reasoning summary auto reaches Antigravity", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"antigravity-budget-model","reasoning":{"effort":"high","summary":"auto"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "true", wantExists: true}, {name: "Interactions reasoning summary none reaches Antigravity", from: sdktranslator.FormatInteractions, to: sdktranslator.FormatAntigravity, model: "antigravity-budget-model", body: `{"model":"antigravity-budget-model","reasoning":{"effort":"high","summary":"none"},"input":"hi"}`, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "false", wantExists: true}, -- 2.51.2 From 3a995bd801e4ac57d8aed65bcf0491c0dd254ab9 Mon Sep 17 00:00:00 2001 From: sususu Date: Fri, 31 Jul 2026 15:29:02 +0800 Subject: [PATCH 29/31] chore: ignore root logs directory --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 728fa959..93adba64 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ config.yaml # Generated content bin/* -logs/* +/logs conv/* temp/* refs/* -- 2.51.2 From a63da8ae76b1a4e0c0486c3eb0fb7ccf8f33e69d Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:35:33 +0800 Subject: [PATCH 30/31] Revert "Merge pull request #4687 from router-for-me/fix/home-401-refresh-recovery" This reverts commit 4a315136730baa8b3a436d12b74e5a702c70be5c, reversing changes made to 7d00936acc2eac8184424eb3d0e9903f6d05102a. --- internal/home/client.go | 6 +- internal/home/requests.go | 6 +- internal/redisqueue/plugin.go | 2 - .../runtime/executor/helps/home_refresh.go | 50 +--- .../executor/helps/home_refresh_test.go | 65 +---- .../runtime/executor/helps/usage_helpers.go | 39 ++- sdk/cliproxy/auth/conductor_execution.go | 2 +- sdk/cliproxy/auth/conductor_home.go | 55 +--- sdk/cliproxy/auth/conductor_home_execution.go | 22 +- sdk/cliproxy/auth/conductor_refresh.go | 43 +-- sdk/cliproxy/auth/conductor_selection.go | 7 +- sdk/cliproxy/auth/conductor_stream.go | 20 +- sdk/cliproxy/auth/home_selection.go | 29 +- sdk/cliproxy/auth/home_selection_test.go | 64 ---- .../auth/home_unauthorized_refresh_test.go | 275 ------------------ sdk/cliproxy/usage/manager.go | 6 +- 16 files changed, 56 insertions(+), 635 deletions(-) delete mode 100644 sdk/cliproxy/auth/home_unauthorized_refresh_test.go diff --git a/internal/home/client.go b/internal/home/client.go index b5115d43..f4a295c6 100644 --- a/internal/home/client.go +++ b/internal/home/client.go @@ -1327,7 +1327,7 @@ func isAmbiguousIssuedRPopAuthError(err error) bool { return !errors.As(err, &redisErr) } -func (c *Client) GetRefreshAuth(ctx context.Context, authIndex string, lastRefreshedAt time.Time, accessTokenSHA256 string) ([]byte, error) { +func (c *Client) GetRefreshAuth(ctx context.Context, authIndex string) ([]byte, error) { cmd, errClient := c.commandClient() if errClient != nil { return nil, errClient @@ -1340,10 +1340,6 @@ func (c *Client) GetRefreshAuth(ctx context.Context, authIndex string, lastRefre Type: "refresh", AuthIndex: authIndex, } - if !lastRefreshedAt.IsZero() { - req.LastRefreshedAt = lastRefreshedAt.UTC().Format(time.RFC3339Nano) - } - req.ObservedAccessTokenSHA256 = strings.TrimSpace(accessTokenSHA256) keyBytes, err := json.Marshal(&req) if err != nil { return nil, err diff --git a/internal/home/requests.go b/internal/home/requests.go index c0ce7a75..eca63742 100644 --- a/internal/home/requests.go +++ b/internal/home/requests.go @@ -18,10 +18,8 @@ type modelsRequest struct { } type refreshRequest struct { - Type string `json:"type"` - AuthIndex string `json:"auth_index"` - LastRefreshedAt string `json:"last_refreshed_at,omitempty"` - ObservedAccessTokenSHA256 string `json:"access_token_sha256,omitempty"` + Type string `json:"type"` + AuthIndex string `json:"auth_index"` } type InFlightFrameKind string diff --git a/internal/redisqueue/plugin.go b/internal/redisqueue/plugin.go index d91c8a28..915f8894 100644 --- a/internal/redisqueue/plugin.go +++ b/internal/redisqueue/plugin.go @@ -90,7 +90,6 @@ func (p *usageQueuePlugin) HandleUsage(ctx context.Context, record coreusage.Rec TTFTMs: record.TTFT.Milliseconds(), Source: record.Source, AuthIndex: record.AuthIndex, - AccessTokenHash: record.AccessTokenSHA256, ClientIP: clientRequestMetadata.ClientIP, XForwardedFor: clientRequestMetadata.XForwardedFor, UserAgent: clientRequestMetadata.UserAgent, @@ -146,7 +145,6 @@ type requestDetail struct { TTFTMs int64 `json:"ttft_ms"` Source string `json:"source"` AuthIndex string `json:"auth_index"` - AccessTokenHash string `json:"access_token_sha256,omitempty"` ClientIP string `json:"client_ip"` XForwardedFor string `json:"x_forwarded_for"` UserAgent string `json:"user_agent"` diff --git a/internal/runtime/executor/helps/home_refresh.go b/internal/runtime/executor/helps/home_refresh.go index 020d5f4f..7c971992 100644 --- a/internal/runtime/executor/helps/home_refresh.go +++ b/internal/runtime/executor/helps/home_refresh.go @@ -2,14 +2,10 @@ package helps import ( "context" - "crypto/sha256" - "encoding/hex" "encoding/json" - "errors" "fmt" "net/http" "strings" - "time" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/home" @@ -47,7 +43,7 @@ type homeErrorDetail struct { type homeRefreshClient interface { HeartbeatOK() bool - GetRefreshAuth(ctx context.Context, authIndex string, lastRefreshedAt time.Time, accessTokenSHA256 string) ([]byte, error) + GetRefreshAuth(ctx context.Context, authIndex string) ([]byte, error) } var currentHomeRefreshClient = func() homeRefreshClient { @@ -81,11 +77,8 @@ func RefreshAuthViaHome(ctx context.Context, cfg *config.Config, auth *cliproxya return nil, true, homeStatusErr{code: http.StatusBadGateway, msg: "home refresh: auth_index is empty"} } - raw, err := client.GetRefreshAuth(ctx, authIndex, auth.LastRefreshedAt, authAccessTokenSHA256(auth)) + raw, err := client.GetRefreshAuth(ctx, authIndex) if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return nil, true, err - } return nil, true, homeStatusErr{code: http.StatusBadGateway, msg: err.Error()} } @@ -114,43 +107,6 @@ func RefreshAuthViaHome(ctx context.Context, cfg *config.Config, auth *cliproxya return updated, true, nil } -func authAccessTokenSHA256(auth *cliproxyauth.Auth) string { - accessToken := authAccessTokenForFingerprint(auth) - if accessToken == "" { - return "" - } - digest := sha256.Sum256([]byte(accessToken)) - return hex.EncodeToString(digest[:]) -} - -func authAccessTokenForFingerprint(auth *cliproxyauth.Auth) string { - if auth == nil || auth.Metadata == nil { - return "" - } - for _, key := range []string{"access_token", "accessToken"} { - if value, ok := auth.Metadata[key].(string); ok && strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - for _, key := range []string{"token", "Token"} { - switch token := auth.Metadata[key].(type) { - case map[string]any: - for _, tokenKey := range []string{"access_token", "accessToken"} { - if value, ok := token[tokenKey].(string); ok && strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - case map[string]string: - for _, tokenKey := range []string{"access_token", "accessToken"} { - if value := strings.TrimSpace(token[tokenKey]); value != "" { - return value - } - } - } - } - return "" -} - func parseHomeRefreshAuth(raw []byte) (*cliproxyauth.Auth, string, error) { var rawObject map[string]json.RawMessage if errUnmarshal := json.Unmarshal(raw, &rawObject); errUnmarshal != nil { @@ -176,8 +132,6 @@ func statusFromHomeErrorCode(code string) int { return http.StatusUnauthorized case "model_not_found": return http.StatusNotFound - case "refresh_temporarily_unavailable", "home_unavailable": - return http.StatusServiceUnavailable default: return http.StatusBadGateway } diff --git a/internal/runtime/executor/helps/home_refresh_test.go b/internal/runtime/executor/helps/home_refresh_test.go index 26cc51af..ca758273 100644 --- a/internal/runtime/executor/helps/home_refresh_test.go +++ b/internal/runtime/executor/helps/home_refresh_test.go @@ -3,11 +3,9 @@ package helps import ( "context" "encoding/json" - "errors" "net/http" "sync/atomic" "testing" - "time" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" @@ -20,60 +18,22 @@ func TestStatusFromHomeErrorCodeMapsAuthenticationErrorToUnauthorized(t *testing if got := statusFromHomeErrorCode("unauthorized"); got != http.StatusUnauthorized { t.Fatalf("statusFromHomeErrorCode(unauthorized) = %d, want %d", got, http.StatusUnauthorized) } - if got := statusFromHomeErrorCode("refresh_temporarily_unavailable"); got != http.StatusServiceUnavailable { - t.Fatalf("statusFromHomeErrorCode(refresh_temporarily_unavailable) = %d, want %d", got, http.StatusServiceUnavailable) - } } type fakeHomeRefreshClient struct { - calls atomic.Int32 - authIndex string - lastRefreshedAt time.Time - accessTokenHash string - raw []byte - err error + calls atomic.Int32 + authIndex string + raw []byte } func (c *fakeHomeRefreshClient) HeartbeatOK() bool { return true } -func (c *fakeHomeRefreshClient) GetRefreshAuth(_ context.Context, authIndex string, lastRefreshedAt time.Time, accessTokenHash string) ([]byte, error) { +func (c *fakeHomeRefreshClient) GetRefreshAuth(_ context.Context, authIndex string) ([]byte, error) { c.calls.Add(1) c.authIndex = authIndex - c.lastRefreshedAt = lastRefreshedAt - c.accessTokenHash = accessTokenHash - return c.raw, c.err -} - -func TestRefreshAuthViaHomePreservesContextErrors(t *testing.T) { - client := &fakeHomeRefreshClient{err: context.DeadlineExceeded} - oldCurrentHomeRefreshClient := currentHomeRefreshClient - currentHomeRefreshClient = func() homeRefreshClient { return client } - t.Cleanup(func() { currentHomeRefreshClient = oldCurrentHomeRefreshClient }) - - cfg := &config.Config{Home: config.HomeConfig{Enabled: true}} - auth := &cliproxyauth.Auth{ID: "home-auth", Index: "home-auth", Provider: "codex"} - _, handled, errRefresh := RefreshAuthViaHome(context.Background(), cfg, auth) - if !handled || !errors.Is(errRefresh, context.DeadlineExceeded) { - t.Fatalf("RefreshAuthViaHome() = handled %v err %v, want true/context.DeadlineExceeded", handled, errRefresh) - } -} - -func TestAuthAccessTokenSHA256SupportsKnownMetadataShapes(t *testing.T) { - want := authAccessTokenSHA256(&cliproxyauth.Auth{Metadata: map[string]any{"access_token": "same-token"}}) - cases := map[string]*cliproxyauth.Auth{ - "camel case": {Metadata: map[string]any{"accessToken": "same-token"}}, - "nested any map": {Metadata: map[string]any{"token": map[string]any{"access_token": "same-token"}}}, - "nested string map": {Metadata: map[string]any{"Token": map[string]string{"accessToken": "same-token"}}}, - } - for name, auth := range cases { - t.Run(name, func(t *testing.T) { - if got := authAccessTokenSHA256(auth); got == "" || got != want { - t.Fatalf("token hash = %q, want %q", got, want) - } - }) - } + return c.raw, nil } func TestRefreshAuthViaHomeAcceptsAuthEnvelope(t *testing.T) { @@ -104,14 +64,11 @@ func TestRefreshAuthViaHomeAcceptsAuthEnvelope(t *testing.T) { }) cfg := &config.Config{Home: config.HomeConfig{Enabled: true}} - observedRefreshAt := time.Now().UTC() auth := &cliproxyauth.Auth{ - ID: "home-auth-1", - Provider: "antigravity", - Index: "home-index-1", - LastRefreshedAt: observedRefreshAt, + ID: "home-auth-1", + Provider: "antigravity", + Index: "home-index-1", Metadata: map[string]any{ - "access_token": "old-access-token", "refresh_token": "refresh-token", }, } @@ -129,12 +86,6 @@ func TestRefreshAuthViaHomeAcceptsAuthEnvelope(t *testing.T) { if client.authIndex != "home-index-1" { t.Fatalf("home refresh auth_index = %q, want home-index-1", client.authIndex) } - if !client.lastRefreshedAt.Equal(observedRefreshAt) { - t.Fatalf("home refresh last_refreshed_at = %v, want %v", client.lastRefreshedAt, observedRefreshAt) - } - if client.accessTokenHash != authAccessTokenSHA256(auth) { - t.Fatalf("home refresh access token hash = %q, want %q", client.accessTokenHash, authAccessTokenSHA256(auth)) - } if updated == nil { t.Fatal("updated auth = nil") } diff --git a/internal/runtime/executor/helps/usage_helpers.go b/internal/runtime/executor/helps/usage_helpers.go index 39a320fe..52e1687f 100644 --- a/internal/runtime/executor/helps/usage_helpers.go +++ b/internal/runtime/executor/helps/usage_helpers.go @@ -22,25 +22,24 @@ import ( ) type UsageReporter struct { - provider string - executorType string - model string - alias string - authID string - authIndex string - accessTokenHash string - authType string - apiKey string - source string - reasoning string - serviceTier string - generate bool - requestedAt time.Time - ttftMu sync.RWMutex - ttft time.Duration - ttftStart time.Time - ttftSet bool - once sync.Once + provider string + executorType string + model string + alias string + authID string + authIndex string + authType string + apiKey string + source string + reasoning string + serviceTier string + generate bool + requestedAt time.Time + ttftMu sync.RWMutex + ttft time.Duration + ttftStart time.Time + ttftSet bool + once sync.Once } type usageExecutor interface { @@ -78,7 +77,6 @@ func NewUsageReporter(ctx context.Context, provider, model string, auth *cliprox if auth != nil { reporter.authID = auth.ID reporter.authIndex = auth.EnsureIndex() - reporter.accessTokenHash = authAccessTokenSHA256(auth) } return reporter } @@ -266,7 +264,6 @@ func (r *UsageReporter) buildRecordForModel(model string, detail usage.Detail, f APIKey: r.apiKey, AuthID: r.authID, AuthIndex: r.authIndex, - AccessTokenSHA256: r.accessTokenHash, AuthType: r.authType, ReasoningEffort: r.reasoning, ServiceTier: r.serviceTier, diff --git a/sdk/cliproxy/auth/conductor_execution.go b/sdk/cliproxy/auth/conductor_execution.go index 68dca5a5..a9ca5165 100644 --- a/sdk/cliproxy/auth/conductor_execution.go +++ b/sdk/cliproxy/auth/conductor_execution.go @@ -636,7 +636,7 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string models = models[:1] pooled = false } - streamResult, errStream := m.executeStreamWithModelPool(execCtx, executor, auth, provider, execReq, execOpts, routeModel, streamExecutionModel, models, pooled, aliasResult, routing, true, selection != nil) + streamResult, errStream := m.executeStreamWithModelPool(execCtx, executor, auth, provider, execReq, execOpts, routeModel, streamExecutionModel, models, pooled, aliasResult, routing, !homeMode, selection != nil) if errStream != nil { if selection != nil { releaseAttempt() diff --git a/sdk/cliproxy/auth/conductor_home.go b/sdk/cliproxy/auth/conductor_home.go index a4cfed50..324d2f7e 100644 --- a/sdk/cliproxy/auth/conductor_home.go +++ b/sdk/cliproxy/auth/conductor_home.go @@ -431,18 +431,14 @@ func (m *Manager) endHomeSelectionBeforeRedispatch(ctx context.Context, selectio } func (m *Manager) retainHomeWebsocketSelection(ctx context.Context, opts cliproxyexecutor.Options, model string, selection *HomeDispatchSelection) bool { - if m == nil || selection == nil || !selection.Retained() || !cliproxyexecutor.DownstreamWebsocket(ctx) { - return false - } - selectionAuth := selection.CloneAuth() - if selectionAuth == nil { + if m == nil || selection == nil || !selection.Retained() || !cliproxyexecutor.DownstreamWebsocket(ctx) || selection.Auth == nil { return false } sessionID := homeExecutionSessionIDFromMetadata(opts.Metadata) - credentialID := strings.TrimSpace(selectionAuth.ID) + credentialID := strings.TrimSpace(selection.Auth.ID) routeModel, validRouteModel := validCanonicalHomeConcurrencyModelKey(model) if selection.accountedModel == "" { - selection.accountedModel, _ = m.predictedHomeConcurrencyModel(selectionAuth, model) + selection.accountedModel, _ = m.predictedHomeConcurrencyModel(selection.Auth, model) } if sessionID == "" || credentialID == "" || !validRouteModel || selection.accountedModel == "" { return false @@ -461,7 +457,7 @@ func (m *Manager) retainHomeWebsocketSelection(ctx context.Context, opts cliprox previous := selections[key] selections[key] = selection m.mu.Unlock() - m.rememberHomeRuntimeAuth(sessionID, selectionAuth) + m.rememberHomeRuntimeAuth(sessionID, selection.Auth) if previous != nil && previous != selection { previous.End("target_replaced") } @@ -537,15 +533,11 @@ func (m *Manager) clearHomeRuntimeAuthsForSessionLocked(sessionID string) { } func (m *Manager) bindHomeSelectionRuntimeAuth(ctx context.Context, opts cliproxyexecutor.Options, selection *HomeDispatchSelection) error { - if m == nil || selection == nil || !cliproxyexecutor.DownstreamWebsocket(ctx) { - return nil - } - selectionAuth := selection.CloneAuth() - if selectionAuth == nil || !authWebsocketsEnabled(selectionAuth) { + if m == nil || selection == nil || !cliproxyexecutor.DownstreamWebsocket(ctx) || selection.Auth == nil || !authWebsocketsEnabled(selection.Auth) { return nil } sessionID := homeExecutionSessionIDFromMetadata(opts.Metadata) - authID := strings.TrimSpace(selectionAuth.ID) + authID := strings.TrimSpace(selection.Auth.ID) if sessionID == "" || authID == "" || !selection.runtimeAuthBound.CompareAndSwap(false, true) { return nil } @@ -562,15 +554,11 @@ func (m *Manager) bindHomeSelectionRuntimeAuth(ctx context.Context, opts cliprox } func (m *Manager) rememberHomeSelectionRuntimeAuth(sessionID string, selection *HomeDispatchSelection) { - if m == nil || selection == nil { - return - } - selectionAuth := selection.CloneAuth() - if selectionAuth == nil { + if m == nil || selection == nil || selection.Auth == nil { return } sessionID = strings.TrimSpace(sessionID) - authID := strings.TrimSpace(selectionAuth.ID) + authID := strings.TrimSpace(selection.Auth.ID) if sessionID == "" || authID == "" { return } @@ -587,33 +575,11 @@ func (m *Manager) rememberHomeSelectionRuntimeAuth(sessionID string, selection * if m.homeRuntimeAuthOwners[sessionID] == nil { m.homeRuntimeAuthOwners[sessionID] = make(map[string]*HomeDispatchSelection) } - m.homeRuntimeAuths[sessionID][authID] = selectionAuth + m.homeRuntimeAuths[sessionID][authID] = selection.Auth.Clone() m.homeRuntimeAuthOwners[sessionID][authID] = selection m.mu.Unlock() } -func (m *Manager) replaceHomeSelectionAuth(selection *HomeDispatchSelection, auth *Auth) { - if m == nil || selection == nil || auth == nil { - return - } - m.mu.Lock() - selection.ReplaceAuth(auth) - updated := selection.CloneAuth() - if updated == nil { - m.mu.Unlock() - return - } - for sessionID, owners := range m.homeRuntimeAuthOwners { - for authID, owner := range owners { - if owner != selection || m.homeRuntimeAuths[sessionID] == nil { - continue - } - m.homeRuntimeAuths[sessionID][authID] = updated.Clone() - } - } - m.mu.Unlock() -} - func (m *Manager) forgetHomeRuntimeAuth(sessionID string, authID string, owner *HomeDispatchSelection) { sessionID = strings.TrimSpace(sessionID) authID = strings.TrimSpace(authID) @@ -699,8 +665,7 @@ func (m *Manager) pickNextViaHome(ctx context.Context, model string, opts clipro if errSelection != nil { return nil, nil, "", errSelection } - selectionAuth := selection.CloneAuth() - if selectionAuth == nil || homeAuthAlreadyTried(tried, selectionAuth.ID) { + if selection.Auth == nil || homeAuthAlreadyTried(tried, selection.Auth.ID) { selection.End("repeated_auth") return nil, nil, "", repeatedHomeAuthError() } diff --git a/sdk/cliproxy/auth/conductor_home_execution.go b/sdk/cliproxy/auth/conductor_home_execution.go index 709b874f..dfd14ee0 100644 --- a/sdk/cliproxy/auth/conductor_home_execution.go +++ b/sdk/cliproxy/auth/conductor_home_execution.go @@ -81,7 +81,6 @@ func (m *Manager) executeHome(ctx context.Context, providers []string, req clipr lastErr = errPrepare continue } - didRefreshOnUnauthorized := false for _, upstreamModel := range models { resultModel := m.stateModelForExecution(preparedAuth, routeModel, upstreamModel, pooled) execReq := req @@ -108,23 +107,10 @@ func (m *Manager) executeHome(ctx context.Context, providers []string, req clipr } var response cliproxyexecutor.Response var errExecute error - execute := func() (cliproxyexecutor.Response, error) { - if countTokens { - return selection.Executor.CountTokens(execCtx, preparedAuth, execReq, execOpts) - } - return selection.Executor.Execute(execCtx, preparedAuth, execReq, execOpts) - } - response, errExecute = execute() - if errExecute != nil { - if refreshed, okRefresh, errRefresh := m.tryRefreshExecutionAuthAfterUnauthorized(execCtx, selection.Executor, preparedAuth, errExecute, didRefreshOnUnauthorized, true); errRefresh != nil { - errExecute = errRefresh - } else if okRefresh { - preparedAuth = refreshed - m.replaceHomeSelectionAuth(selection, preparedAuth) - didRefreshOnUnauthorized = true - publishSelectedAuthMetadata(opts.Metadata, preparedAuth) - response, errExecute = execute() - } + if countTokens { + response, errExecute = selection.Executor.CountTokens(execCtx, preparedAuth, execReq, execOpts) + } else { + response, errExecute = selection.Executor.Execute(execCtx, preparedAuth, execReq, execOpts) } result := Result{AuthID: preparedAuth.ID, Provider: selection.Provider, Model: resultModel, Success: errExecute == nil} if errExecute == nil { diff --git a/sdk/cliproxy/auth/conductor_refresh.go b/sdk/cliproxy/auth/conductor_refresh.go index e7ed65cb..4d9385d4 100644 --- a/sdk/cliproxy/auth/conductor_refresh.go +++ b/sdk/cliproxy/auth/conductor_refresh.go @@ -377,47 +377,8 @@ func clearUnauthorizedModelStates(auth *Auth, now time.Time) []string { return resumed } -// tryRefreshExecutionAuthAfterUnauthorized refreshes OAuth credentials once for -// either a local auth or an ephemeral Home dispatch auth. -func (m *Manager) tryRefreshExecutionAuthAfterUnauthorized(ctx context.Context, executor ProviderExecutor, auth *Auth, execErr error, alreadyTried bool, homeDispatch bool) (*Auth, bool, error) { - if !homeDispatch { - refreshed, ok := m.tryRefreshAfterUnauthorized(ctx, auth, execErr, alreadyTried) - return refreshed, ok, nil - } - if m == nil || executor == nil || auth == nil || alreadyTried || execErr == nil { - return auth, false, nil - } - if !isUnauthorizedError(execErr) || auth.AuthKind() != AuthKindOAuth { - return auth, false, nil - } - - log.Debugf("unauthorized Home response for %s (%s), refreshing credentials before redispatch", auth.Provider, auth.ID) - target := auth.Clone() - updated, errRefresh := executor.Refresh(ctx, target) - if errRefresh != nil { - log.Debugf("Home credential refresh before redispatch failed for %s (%s): %v", auth.Provider, auth.ID, errRefresh) - return auth, false, errRefresh - } - if updated == nil { - updated = target - } - if updated.ID == "" { - updated.ID = auth.ID - } - if updated.Index == "" { - updated.Index = auth.Index - } - if updated.Provider == "" { - updated.Provider = auth.Provider - } - if updated.Runtime == nil { - updated.Runtime = auth.Runtime - } - return updated, true, nil -} - -// tryRefreshAfterUnauthorized refreshes local OAuth credentials once after a -// 401 so the current auth can be retried before fallback/suspend. +// tryRefreshAfterUnauthorized refreshes OAuth credentials once after a 401 so the +// current auth can be retried before fallback/suspend. func (m *Manager) tryRefreshAfterUnauthorized(ctx context.Context, auth *Auth, execErr error, alreadyTried bool) (*Auth, bool) { if m == nil || auth == nil || alreadyTried || execErr == nil { return auth, false diff --git a/sdk/cliproxy/auth/conductor_selection.go b/sdk/cliproxy/auth/conductor_selection.go index 30cbb4ca..81e41b38 100644 --- a/sdk/cliproxy/auth/conductor_selection.go +++ b/sdk/cliproxy/auth/conductor_selection.go @@ -1087,15 +1087,14 @@ func (m *Manager) SelectHomeAuthByKind(ctx context.Context, provider string, mod return nil, errSelection } providerMatches := strings.TrimSpace(provider) == "" || strings.EqualFold(strings.TrimSpace(selection.Provider), strings.TrimSpace(provider)) - selectionAuth := selection.CloneAuth() - kindMatches := selectionAuth != nil && selectionAuth.AuthKind() == requiredKind + kindMatches := selection.Auth != nil && selection.Auth.AuthKind() == requiredKind if providerMatches && kindMatches { return selection, nil } authID := "" - if selectionAuth != nil { - authID = strings.TrimSpace(selectionAuth.ID) + if selection.Auth != nil { + authID = strings.TrimSpace(selection.Auth.ID) } reason := "auth_kind_mismatch" if !providerMatches { diff --git a/sdk/cliproxy/auth/conductor_stream.go b/sdk/cliproxy/auth/conductor_stream.go index 0d83a987..be6784af 100644 --- a/sdk/cliproxy/auth/conductor_stream.go +++ b/sdk/cliproxy/auth/conductor_stream.go @@ -180,14 +180,6 @@ func (m *Manager) wrapStreamResult(ctx context.Context, auth *Auth, provider, re return &cliproxyexecutor.StreamResult{Headers: headers, Chunks: out} } -func (m *Manager) replaceHomeExecutionLifecycleAuth(lifecycle cliproxyexecutor.ExecutionLifecycle, auth *Auth) { - selection, ok := lifecycle.(*HomeDispatchSelection) - if !ok || selection == nil { - return - } - m.replaceHomeSelectionAuth(selection, auth) -} - func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor ProviderExecutor, auth *Auth, provider string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, routeModel, executionModel string, execModels []string, pooled bool, aliasResult OAuthModelAliasResult, routing *apiKeyModelRoutingSnapshot, allowRetry bool, ephemeralResult bool) (*cliproxyexecutor.StreamResult, error) { if executor == nil { return nil, &Error{Code: "executor_not_found", Message: "executor not registered"} @@ -220,11 +212,8 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi return nil, errCtx } if allowRetry { - if refreshed, okRefresh, errRefresh := m.tryRefreshExecutionAuthAfterUnauthorized(ctx, executor, auth, errStream, didRefreshOnUnauthorized, ephemeralResult); errRefresh != nil { - errStream = errRefresh - } else if okRefresh { + if refreshed, okRefresh := m.tryRefreshAfterUnauthorized(ctx, auth, errStream, didRefreshOnUnauthorized); okRefresh { auth = refreshed - m.replaceHomeExecutionLifecycleAuth(execOpts.ExecutionLifecycle, auth) didRefreshOnUnauthorized = true streamResult, errStream = executor.ExecuteStream(ctx, auth, execReq, execOpts) if errStream != nil { @@ -257,14 +246,9 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi return nil, errCtx } if allowRetry { - if refreshed, okRefresh, errRefresh := m.tryRefreshExecutionAuthAfterUnauthorized(ctx, executor, auth, bootstrapErr, didRefreshOnUnauthorized, ephemeralResult); errRefresh != nil { - discardStreamChunks(streamResult.Chunks) - bootstrapErr = errRefresh - streamResult = &cliproxyexecutor.StreamResult{} - } else if okRefresh { + if refreshed, okRefresh := m.tryRefreshAfterUnauthorized(ctx, auth, bootstrapErr, didRefreshOnUnauthorized); okRefresh { discardStreamChunks(streamResult.Chunks) auth = refreshed - m.replaceHomeExecutionLifecycleAuth(execOpts.ExecutionLifecycle, auth) didRefreshOnUnauthorized = true retryStream, retryErr := executor.ExecuteStream(ctx, auth, execReq, execOpts) if retryErr != nil { diff --git a/sdk/cliproxy/auth/home_selection.go b/sdk/cliproxy/auth/home_selection.go index a9326a80..01a39b32 100644 --- a/sdk/cliproxy/auth/home_selection.go +++ b/sdk/cliproxy/auth/home_selection.go @@ -141,7 +141,6 @@ type HomeDispatchSelection struct { Executor ProviderExecutor Provider string - authMu sync.RWMutex scope *executionregistry.Scope accountedModel string resources *executionResources @@ -250,35 +249,9 @@ func (s *HomeDispatchSelection) EndWithRelease(reason string) *executionregistry return s.scope.EndWithRelease("") } -// ReplaceAuth updates the selection after Home returns refreshed credentials. -func (s *HomeDispatchSelection) ReplaceAuth(auth *Auth) { - if s == nil || auth == nil { - return - } - updated := auth.Clone() - s.authMu.Lock() - defer s.authMu.Unlock() - if s.Auth != nil { - if updated.Attributes == nil { - updated.Attributes = make(map[string]string) - } - for _, key := range []string{homeUpstreamModelAttributeKey, homeForceMappingAttributeKey, homeOriginalAliasAttributeKey} { - if value := strings.TrimSpace(s.Auth.Attributes[key]); value != "" { - updated.Attributes[key] = value - } - } - } - s.Auth = updated -} - // CloneAuth returns a standalone auth copy without the selection handle. func (s *HomeDispatchSelection) CloneAuth() *Auth { - if s == nil { - return nil - } - s.authMu.RLock() - defer s.authMu.RUnlock() - if s.Auth == nil { + if s == nil || s.Auth == nil { return nil } return s.Auth.Clone() diff --git a/sdk/cliproxy/auth/home_selection_test.go b/sdk/cliproxy/auth/home_selection_test.go index 56cbe29d..1f02fc5c 100644 --- a/sdk/cliproxy/auth/home_selection_test.go +++ b/sdk/cliproxy/auth/home_selection_test.go @@ -42,70 +42,6 @@ func TestHomeDispatchSelectionOwnsScopeOutsideAuth(t *testing.T) { } } -func TestHomeDispatchSelectionReplaceAuthPreservesRoutingAttributes(t *testing.T) { - selection := &HomeDispatchSelection{Auth: &Auth{ - ID: "cred-1", - Provider: "codex", - Attributes: map[string]string{ - homeUpstreamModelAttributeKey: "gpt-5-upstream", - homeForceMappingAttributeKey: "true", - homeOriginalAliasAttributeKey: "team/gpt-5", - }, - Metadata: map[string]any{"access_token": "old"}, - }} - - selection.ReplaceAuth(&Auth{ - ID: "cred-1", - Provider: "codex", - Attributes: map[string]string{AttributeAuthKind: AuthKindOAuth}, - Metadata: map[string]any{"access_token": "fresh"}, - }) - - updated := selection.CloneAuth() - if updated == nil || updated.Metadata["access_token"] != "fresh" { - t.Fatalf("updated auth = %#v", updated) - } - if updated.Attributes[homeUpstreamModelAttributeKey] != "gpt-5-upstream" || updated.Attributes[homeForceMappingAttributeKey] != "true" || updated.Attributes[homeOriginalAliasAttributeKey] != "team/gpt-5" { - t.Fatalf("routing attributes were not preserved: %#v", updated.Attributes) - } -} - -func TestHomeDispatchSelectionReplaceAuthConcurrentClone(t *testing.T) { - selection := &HomeDispatchSelection{Auth: &Auth{ID: "cred-1", Metadata: map[string]any{"access_token": "old"}}} - done := make(chan struct{}) - go func() { - defer close(done) - for i := 0; i < 1000; i++ { - selection.ReplaceAuth(&Auth{ID: "cred-1", Metadata: map[string]any{"access_token": "fresh"}}) - } - }() - for i := 0; i < 1000; i++ { - if auth := selection.CloneAuth(); auth == nil || auth.ID != "cred-1" { - t.Fatalf("CloneAuth() = %#v", auth) - } - } - <-done -} - -func TestReplaceHomeSelectionAuthUpdatesRetainedRuntimeAuth(t *testing.T) { - selection := &HomeDispatchSelection{Auth: &Auth{ID: "cred-1", Provider: "codex", Metadata: map[string]any{"access_token": "old"}}} - manager := &Manager{ - homeRuntimeAuths: map[string]map[string]*Auth{ - "session-1": {"cred-1": selection.Auth.Clone()}, - }, - homeRuntimeAuthOwners: map[string]map[string]*HomeDispatchSelection{ - "session-1": {"cred-1": selection}, - }, - } - - manager.replaceHomeSelectionAuth(selection, &Auth{ID: "cred-1", Provider: "codex", Metadata: map[string]any{"access_token": "fresh"}}) - - retained := manager.homeRuntimeAuths["session-1"]["cred-1"] - if retained == nil || retained.Metadata["access_token"] != "fresh" { - t.Fatalf("retained runtime auth = %#v, want fresh token", retained) - } -} - func TestHomeDispatchSelectionDrainsResourcesAddedDuringEnd(t *testing.T) { registry := executionregistry.New() pending, errBegin := registry.BeginDispatch() diff --git a/sdk/cliproxy/auth/home_unauthorized_refresh_test.go b/sdk/cliproxy/auth/home_unauthorized_refresh_test.go deleted file mode 100644 index 538046ce..00000000 --- a/sdk/cliproxy/auth/home_unauthorized_refresh_test.go +++ /dev/null @@ -1,275 +0,0 @@ -package auth - -import ( - "context" - "encoding/json" - "net/http" - "sync/atomic" - "testing" - - internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executionregistry" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" -) - -const homeUnauthorizedRefreshProvider = "home-unauthorized-refresh" - -type homeUnauthorizedRefreshDispatcher struct { - calls atomic.Int32 -} - -func (*homeUnauthorizedRefreshDispatcher) HeartbeatOK() bool { return true } - -func (d *homeUnauthorizedRefreshDispatcher) RPopAuth(context.Context, string, string, http.Header, int) ([]byte, error) { - d.calls.Add(1) - return json.Marshal(homeAuthDispatchResponse{Auth: Auth{ - ID: "home-refresh-auth", - Provider: homeUnauthorizedRefreshProvider, - Status: StatusActive, - Attributes: map[string]string{ - AttributeAuthKind: AuthKindOAuth, - "websockets": "true", - }, - Metadata: map[string]any{ - "access_token": "stale-access-token", - }, - }}) -} - -func (*homeUnauthorizedRefreshDispatcher) AbortAmbiguousDispatch() {} - -type homeUnauthorizedRefreshExecutor struct { - streamMode string - refreshErr error - retainSelection bool - executeCalls atomic.Int32 - countCalls atomic.Int32 - streamCalls atomic.Int32 - refreshCalls atomic.Int32 -} - -func (*homeUnauthorizedRefreshExecutor) Identifier() string { return homeUnauthorizedRefreshProvider } - -func (e *homeUnauthorizedRefreshExecutor) Execute(_ context.Context, auth *Auth, _ cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { - e.executeCalls.Add(1) - if e.retainSelection { - if lifecycle, ok := opts.ExecutionLifecycle.(interface{ Retain() }); ok { - lifecycle.Retain() - } - } - if authAccessToken(auth) == "stale-access-token" { - return cliproxyexecutor.Response{}, &Error{HTTPStatus: http.StatusUnauthorized, Message: "expired access token"} - } - return cliproxyexecutor.Response{Payload: []byte("ok")}, nil -} - -func (e *homeUnauthorizedRefreshExecutor) ExecuteStream(_ context.Context, auth *Auth, _ cliproxyexecutor.Request, _ cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) { - e.streamCalls.Add(1) - if authAccessToken(auth) == "stale-access-token" { - switch e.streamMode { - case "bootstrap": - chunks := make(chan cliproxyexecutor.StreamChunk, 1) - chunks <- cliproxyexecutor.StreamChunk{Err: &Error{HTTPStatus: http.StatusUnauthorized, Message: "expired access token"}} - close(chunks) - return &cliproxyexecutor.StreamResult{Chunks: chunks}, nil - case "started": - chunks := make(chan cliproxyexecutor.StreamChunk, 2) - chunks <- cliproxyexecutor.StreamChunk{Payload: []byte("started")} - chunks <- cliproxyexecutor.StreamChunk{Err: &Error{HTTPStatus: http.StatusUnauthorized, Message: "expired access token"}} - close(chunks) - return &cliproxyexecutor.StreamResult{Chunks: chunks}, nil - default: - return nil, &Error{HTTPStatus: http.StatusUnauthorized, Message: "expired access token"} - } - } - chunks := make(chan cliproxyexecutor.StreamChunk, 1) - chunks <- cliproxyexecutor.StreamChunk{Payload: []byte("ok")} - close(chunks) - return &cliproxyexecutor.StreamResult{Chunks: chunks}, nil -} - -func (e *homeUnauthorizedRefreshExecutor) Refresh(_ context.Context, auth *Auth) (*Auth, error) { - e.refreshCalls.Add(1) - if e.refreshErr != nil { - return nil, e.refreshErr - } - updated := auth.Clone() - if updated.Metadata == nil { - updated.Metadata = make(map[string]any) - } - updated.Metadata["access_token"] = "fresh-access-token" - return updated, nil -} - -func (e *homeUnauthorizedRefreshExecutor) CountTokens(_ context.Context, auth *Auth, _ cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { - e.countCalls.Add(1) - if authAccessToken(auth) == "stale-access-token" { - return cliproxyexecutor.Response{}, &Error{HTTPStatus: http.StatusUnauthorized, Message: "expired access token"} - } - return cliproxyexecutor.Response{Payload: []byte("ok")}, nil -} - -func (*homeUnauthorizedRefreshExecutor) HttpRequest(context.Context, *Auth, *http.Request) (*http.Response, error) { - return nil, nil -} - -func newHomeUnauthorizedRefreshManager(dispatcher *homeUnauthorizedRefreshDispatcher, executor *homeUnauthorizedRefreshExecutor) *Manager { - manager := NewManager(nil, nil, nil) - manager.SetConfig(&internalconfig.Config{Home: internalconfig.HomeConfig{Enabled: true}}) - manager.PublishHomeDispatch(dispatcher, executionregistry.New(), 1) - manager.RegisterExecutor(executor) - return manager -} - -func TestHomeUnauthorizedRefreshesSameSelectionBeforeRedispatch(t *testing.T) { - for _, test := range []struct { - name string - run func(*Manager) error - }{ - { - name: "execute", - run: func(manager *Manager) error { - _, errExecute := manager.Execute(context.Background(), []string{homeUnauthorizedRefreshProvider}, cliproxyexecutor.Request{Model: "model-a"}, cliproxyexecutor.Options{}) - return errExecute - }, - }, - { - name: "count_tokens", - run: func(manager *Manager) error { - _, errCount := manager.ExecuteCount(context.Background(), []string{homeUnauthorizedRefreshProvider}, cliproxyexecutor.Request{Model: "model-a"}, cliproxyexecutor.Options{}) - return errCount - }, - }, - } { - t.Run(test.name, func(t *testing.T) { - dispatcher := &homeUnauthorizedRefreshDispatcher{} - executor := &homeUnauthorizedRefreshExecutor{} - manager := newHomeUnauthorizedRefreshManager(dispatcher, executor) - - if errRun := test.run(manager); errRun != nil { - t.Fatalf("execution error = %v", errRun) - } - if got := dispatcher.calls.Load(); got != 1 { - t.Fatalf("Home dispatch calls = %d, want 1", got) - } - if got := executor.refreshCalls.Load(); got != 1 { - t.Fatalf("refresh calls = %d, want 1", got) - } - if test.name == "execute" && executor.executeCalls.Load() != 2 { - t.Fatalf("execute calls = %d, want 2", executor.executeCalls.Load()) - } - if test.name == "count_tokens" && executor.countCalls.Load() != 2 { - t.Fatalf("count calls = %d, want 2", executor.countCalls.Load()) - } - }) - } -} - -func TestHomeUnauthorizedRefreshUpdatesRetainedSelection(t *testing.T) { - dispatcher := &homeUnauthorizedRefreshDispatcher{} - executor := &homeUnauthorizedRefreshExecutor{retainSelection: true} - manager := newHomeUnauthorizedRefreshManager(dispatcher, executor) - ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background()) - opts := cliproxyexecutor.Options{Metadata: map[string]any{ - cliproxyexecutor.ExecutionSessionMetadataKey: "refresh-session", - cliproxyexecutor.PinnedAuthMetadataKey: "home-refresh-auth", - }} - - for range 2 { - if _, errExecute := manager.Execute(ctx, []string{homeUnauthorizedRefreshProvider}, cliproxyexecutor.Request{Model: "model-a"}, opts); errExecute != nil { - t.Fatalf("Execute() error = %v", errExecute) - } - } - if got := dispatcher.calls.Load(); got != 1 { - t.Fatalf("Home dispatch calls = %d, want one retained selection", got) - } - if got := executor.refreshCalls.Load(); got != 1 { - t.Fatalf("refresh calls = %d, want refreshed token reused by retained selection", got) - } - if got := executor.executeCalls.Load(); got != 3 { - t.Fatalf("execute calls = %d, want stale attempt, retry, and retained reuse", got) - } -} - -func TestHomeUnauthorizedTransientRefreshFailureIsReturned(t *testing.T) { - dispatcher := &homeUnauthorizedRefreshDispatcher{} - executor := &homeUnauthorizedRefreshExecutor{ - refreshErr: &Error{HTTPStatus: http.StatusServiceUnavailable, Message: "Home refresh temporarily unavailable"}, - } - manager := newHomeUnauthorizedRefreshManager(dispatcher, executor) - - _, errExecute := manager.Execute(context.Background(), []string{homeUnauthorizedRefreshProvider}, cliproxyexecutor.Request{Model: "model-a"}, cliproxyexecutor.Options{}) - if statusCodeFromError(errExecute) != http.StatusServiceUnavailable { - t.Fatalf("Execute() error = %v, want transient 503", errExecute) - } - if got := executor.executeCalls.Load(); got != 1 { - t.Fatalf("execute calls = %d, want 1", got) - } - if got := executor.refreshCalls.Load(); got != 1 { - t.Fatalf("refresh calls = %d, want 1", got) - } -} - -func TestHomeUnauthorizedStartedStreamDoesNotReplay(t *testing.T) { - dispatcher := &homeUnauthorizedRefreshDispatcher{} - executor := &homeUnauthorizedRefreshExecutor{streamMode: "started"} - manager := newHomeUnauthorizedRefreshManager(dispatcher, executor) - - result, errStream := manager.ExecuteStream(context.Background(), []string{homeUnauthorizedRefreshProvider}, cliproxyexecutor.Request{Model: "model-a"}, cliproxyexecutor.Options{Stream: true}) - if errStream != nil { - t.Fatalf("ExecuteStream() error = %v", errStream) - } - sawPayload := false - sawUnauthorized := false - for chunk := range result.Chunks { - if string(chunk.Payload) == "started" { - sawPayload = true - } - if statusCodeFromError(chunk.Err) == http.StatusUnauthorized { - sawUnauthorized = true - } - } - if !sawPayload || !sawUnauthorized { - t.Fatalf("stream results = payload %v unauthorized %v, want both", sawPayload, sawUnauthorized) - } - if got := executor.refreshCalls.Load(); got != 0 { - t.Fatalf("refresh calls = %d, want 0 after stream started", got) - } - if got := executor.streamCalls.Load(); got != 1 { - t.Fatalf("stream calls = %d, want 1", got) - } -} - -func TestHomeUnauthorizedStreamRefreshesBeforeRedispatch(t *testing.T) { - for _, mode := range []string{"synchronous", "bootstrap"} { - t.Run(mode, func(t *testing.T) { - dispatcher := &homeUnauthorizedRefreshDispatcher{} - executor := &homeUnauthorizedRefreshExecutor{streamMode: mode} - manager := newHomeUnauthorizedRefreshManager(dispatcher, executor) - - result, errStream := manager.ExecuteStream(context.Background(), []string{homeUnauthorizedRefreshProvider}, cliproxyexecutor.Request{Model: "model-a"}, cliproxyexecutor.Options{Stream: true}) - if errStream != nil { - t.Fatalf("ExecuteStream() error = %v", errStream) - } - var payload string - for chunk := range result.Chunks { - if chunk.Err != nil { - t.Fatalf("stream chunk error = %v", chunk.Err) - } - payload += string(chunk.Payload) - } - if payload != "ok" { - t.Fatalf("stream payload = %q, want ok", payload) - } - if got := dispatcher.calls.Load(); got != 1 { - t.Fatalf("Home dispatch calls = %d, want 1", got) - } - if got := executor.refreshCalls.Load(); got != 1 { - t.Fatalf("refresh calls = %d, want 1", got) - } - if got := executor.streamCalls.Load(); got != 2 { - t.Fatalf("stream calls = %d, want 2", got) - } - }) - } -} diff --git a/sdk/cliproxy/usage/manager.go b/sdk/cliproxy/usage/manager.go index ca36dc55..7fa60416 100644 --- a/sdk/cliproxy/usage/manager.go +++ b/sdk/cliproxy/usage/manager.go @@ -28,10 +28,8 @@ type Record struct { APIKey string AuthID string AuthIndex string - // AccessTokenSHA256 identifies the OAuth token version without exposing the token. - AccessTokenSHA256 string - AuthType string - Source string + AuthType string + Source string // ReasoningEffort stores the translated upstream thinking level for request event logs. ReasoningEffort string // ServiceTier stores the client-requested service tier. -- 2.51.2 From d9460a8df6c15175342ede3dc5c423eb2df11f58 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Fri, 31 Jul 2026 20:31:28 +0800 Subject: [PATCH 31/31] feat: add sponser LMU --- README.md | 4 ++++ README_CN.md | 4 ++++ README_JA.md | 4 ++++ assets/lmuai.png | Bin 0 -> 16145 bytes 4 files changed, 12 insertions(+) create mode 100644 assets/lmuai.png diff --git a/README.md b/README.md index a83d1ac0..010d7c06 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,10 @@ PackyCode provides special discounts for our software users: register using FastAIToken Thanks to FastAIToken for sponsoring this project! FastAIToken is an AI API aggregation platform built for developers, focused on speed and stability. It supports leading AI models including OpenAI, Claude, Gemini, and more. With a 1:1 recharge ratio (¥1 = $1 in API credits), developers can access the world's top AI models at lower cost and with greater convenience. Telegram Support Group
The platform offers multiple channels to suit different needs: an ultra-low-cost 0.02× OpenAI promotional tier (limited time), OpenAI channels starting from 0.25×, 0.7× Claude with 95% fixed cache, and 1.2× Claude Max channels. It also provides a public status page displaying real-time availability, latency, and operational status for every channel, ensuring transparent and reliable service. In addition, FastAIToken offers 24/7 human technical support (no bots) for rapid response to developers' needs. For enterprise customers, dedicated SLA-backed channel pools are available with guaranteed stability, contract support, invoicing, and dedicated maintenance. + +LMU +Thanks to LMU (灵眸 AI) for sponsoring this project! LMU is an Anthropic- and OpenAI-compatible relay for Claude Code, Codex, and other coding agents, covering both domestic models (DeepSeek, GLM, Qwen, and more) and major overseas providers. Point ANTHROPIC_BASE_URL at the LMU endpoint and connect over the standard /v1/messages API with no code changes. Real-world Prompt Cache hit rates run above 90% in Claude Code sessions, cutting long-session costs. Unused recharge balance is refundable on request. Enterprise plans include grouped, team-managed API keys with configurable IP/quota limits, rate windows, and expiry, plus traffic monitoring and invoicing. Register through the LMU CLIProxyAPI exclusive link to claim free test credits. + diff --git a/README_CN.md b/README_CN.md index 3c8b9d7b..6132dc5d 100644 --- a/README_CN.md +++ b/README_CN.md @@ -97,6 +97,10 @@ PackyCode 为本软件用户提供了特别优惠:使用FastAIToken 感谢 FastAIToken 对本项目的赞助! FastAIToken 是面向开发者的 AI API 聚合平台,追求极速、稳定。支持 OpenAI、Claude、Gemini 等主流大模型,充值 1:1,1 元 = 1 美元 API 额度,让开发者以更低成本、更便捷地使用全球领先的大模型服务,QQ服务群1054566214。
平台提供多种渠道自由选择:超级低价的0.02x OpenAI 福利分组(限时)、低至 0.25x OpenAI 分组、0.7x Claude 95%固定缓存、1.2x Claude Max 渠道;同时提供公开状态页,实时展示各分组的可用率、延迟及运行状态,服务透明可靠,并提供 7×24 小时真人技术支持(非机器人),快速响应开发者需求。针对企业用户可以构建SLA专线号池,包稳定,可签合同开票专人维护。 + +LMU +感谢 LMU(灵眸 AI) 对本项目的赞助!LMU 是兼容 Anthropic 和 OpenAI 协议的 AI 中转服务,适用于 Claude Code、Codex 及其他编程智能体,覆盖国内模型(DeepSeek、GLM、Qwen 等)和主流海外提供商。只需将 ANTHROPIC_BASE_URL 指向 LMU 端点,即可无需修改代码,通过标准 /v1/messages API 接入。Claude Code 实际会话中的 Prompt Cache 命中率超过 90%,可有效降低长会话成本。未使用的充值余额可申请退款。企业版提供分组及团队管理的 API Key,可配置 IP/额度限制、速率窗口和有效期,并支持流量监控与开票。通过 LMU CLIProxyAPI 专属链接注册,即可领取免费测试额度。 + diff --git a/README_JA.md b/README_JA.md index f18d12eb..05d2363f 100644 --- a/README_JA.md +++ b/README_JA.md @@ -97,6 +97,10 @@ PackyCodeは当ソフトウェアのユーザーに特別割引を提供して FastAIToken FastAIToken のスポンサーシップに感謝します!FastAIToken は開発者向けの AI API 集約プラットフォームで、速度と安定性を重視しています。OpenAI、Claude、Gemini などの主要 AI モデルに対応し、チャージ比率は 1:1(1元 = 1ドル分の API クレジット)のため、開発者はより低コストで便利に世界トップクラスの AI モデルを利用できます。Telegram サポートグループ
プラットフォームでは用途に応じて複数のチャネルを選択できます:超低価格の 0.02× OpenAI プロモーション枠(期間限定)、0.25× からの OpenAI チャネル、95% 固定キャッシュの 0.7× Claude、1.2× Claude Max チャネル。また、各チャネルの稼働率、遅延、運用状況をリアルタイム表示する公開ステータスページも提供しており、透明で信頼性の高いサービスを実現しています。さらに FastAIToken は 24時間365日の真人テクニカルサポート(ボットではありません)を提供し、開発者のニーズに迅速に対応します。エンタープライズ顧客向けには、安定性を保証する SLA 対応の専用チャネルプールを提供し、契約対応、請求書発行、専任保守にも対応しています。 + +LMU +LMU(灵眸 AI)による本プロジェクトへのご支援に感謝します!LMUは、Claude Code、Codex、その他のコーディングエージェント向けのAnthropicおよびOpenAI互換リレーサービスで、中国国内モデル(DeepSeek、GLM、Qwenなど)と主要な海外プロバイダーの両方に対応しています。ANTHROPIC_BASE_URLをLMUエンドポイントに設定するだけで、コードを変更せずに標準の/v1/messages API経由で接続できます。実際のClaude CodeセッションではPrompt Cacheのヒット率が90%を超えており、長時間のセッションにかかるコストを削減できます。未使用のチャージ残高は申請により返金可能です。エンタープライズプランでは、グループ化されたチーム管理のAPIキーを利用でき、IP・クォータ制限、レートウィンドウ、有効期限を設定できるほか、トラフィック監視と請求書発行にも対応しています。LMU CLIProxyAPI専用リンクから登録すると、無料テストクレジットを受け取れます。 + diff --git a/assets/lmuai.png b/assets/lmuai.png new file mode 100644 index 0000000000000000000000000000000000000000..9936686d5d790834f842404d0cadb9c1a17da05e GIT binary patch literal 16145 zcmeAS@N?(olHy`uVBq!ia0y~yU}ykg4mJh`#x0hCu?!3hjKx9jP7LeL$-HD>U~ox| z@J#ddWzb?^VBlb2Y|mt10V!c%V31+}0p^mBox_$|W)(ckVOk#Ls zY-2cyg+;z;#fl#+jsYgGic>ikDA+ipggSf^P?#`b!kxeOe(qoY>dMQ@w#Co=@(wL3 zD_VX&X!)zv>tD@z_wwaSLCKWYGYsEO-|FJUT)NICd;VAHD(QLp_cu?TqIO?@ZE^le zt>~>AlaK97t*pp?nI*5M{k!T*{+Y7#-nVbf740hGDp64QdUx~UH80*y{PyqOiiWOR zzpI`eXr88(;l6~+D&6OQPUI=^>F;W#*}fzxtZ_Q%rS;-%nCtHNkD@>CFZSrw z&4`|iM;M`b4h`l3TNELm>cIB(~N$I1SaRc&P%1t&@#;$ro#Ubuka?-BRBN%}d4 zu9gOVK87b_8S}IRS2X3RPTF^V&W`FN$>xj4>~|fo-Og5fl-p8)>vs7`>-71JRpJlY z%f2m~_fJ%$#A!#co!S500hbh~sS3Q_1S}S2Yh$ zJHI2XvL!R1*W@(2n%E6~&TDnQOwKkZseKE6S-tP`l(v(#sYRB*Py63*SGu1+clU#2 z4GAW(OTC(R!}U&oi{G!G64$R|nVT>AQN-^lyH4=Mo7MSY-z<$5Ja|>|#KJ>VbS)pt zUMEJjGRMH+|9h=h{?kk@@Mky@Gnqrp-?NmLFGKu)V*C zNh!g-a;@3Vn@gv+A5@uae6gtEcZuWDNiPm%y7L78;Et~gJiJ_+|M1KmM@6$a??l(! zisMtTpF1;P{=U40HhX!zYm%-Cd^u&#e>dpf{duv?5 z<;xr6(i5jLJlS(-&HJO9ZuYjk)%@;VAH00c=Fh=-+xX><^L#v(^?MdWLD?5qo!gsB z9(bMEyWg*Aqn+uEyZ8AYhp9E>ZON8>yJmmwe(}Y7r)?8e_S*a|v!B;MZ{{3F?`G27e#>Wg@80z|tux^d_$h~)1u44A* zh}R}L34D8>FaLBYwtnLM`cFR0QO^Im-M1ZG86dbkbw-A5qubXRCc!g}7nFYA5nt6l z^~s6d^=kL;K9s$<;q><;pK!-{ejN#>{L!oo>rQ)__kQB{TH$e;t4D6~LOaR**VlbI zmtPn8uln|1UX@!(E4X-O=~*2vZqHfGv_ROcc~O%P-#f0z`VX%0Q#{%IC*O?rGd(|5 zk>T7^mM7enhyRtVW;n3a>hmFiYz>>!i7&RW=TABI@bJ?$XYb27I~RO0lAV1`w>@Xu z6_x{`cX`^@EWhOP(X#)(V)(z~&!4XQx!=svi0QGNTd&$Dui6*kk|&l&vohOo%~xlNZ(3-Q_U%5 zfB0AYyw}s#-`#SBCz#Xmpz&PMdBU#0n3FR#!XCuj+Wv0dJHy5Ldy+e^Yco`|zrQ@S zaDTq-tkXr8ZucthYwxT+_q#M&`sqpGP0jZmo-f^EsKLN~$o9aUmb-O2@nzq+i_Wd} zcYWOLe=~ZPly9u_%00oK=S0gdxRY>Oi%~<%`{v50sfBl>R+rDSEM0vy)GUQ9=sjoB z3Z6Aa3=+?h@*d0G2|FdNH|dhqdMm;E%oF;nm(Hub%W%to{=ct5D{GpxVz*8EVsB^Y z<39WK>vtcwXRKddc;iN3{LYV4Zv-qh&}jJ7S>5w0CpS{8^v-9qgVxJ-*!*@%{kbINnes^wmWznAvmrAc6 zo!oT7f%UG*nWbX)e%Q^ETsZgMubC6S`&<2U-t@K5{bYWhU8LCE{~f9;<+R^Oe3EZJ zzCEe%)-%SEEnY7tu3Z!uJf|^XZrfLz{-+VE!^fT5oWuS{rM7~!Mp^Mguil9-5;;+-s^7lv2DTv4ui?dnOPj%ls}8{?-65| z@>Qx`(xA(9BL`Pks3xO|+^0oK+eKyHUgS9bod33*5%X&1L*9H0_ghL>^p&^`-n_+A{e%DV8o}UD13EZ3`YO42pU><9_#(Uk~!vP58a9d~V5) z%7uCXJdR?;?5}?Y2{#yCSZ!#Q?-@I-nQ}B3 zdUW$F*?vr0Bm5$nr#WbK^Q9&Gj(f^a?=G`fdFFFwB8SE7WtkS)ObHwnfvp*j7ffid zQ@&AmCVzd1?Rvdh(^E@7y{xdc_WN>`<$%BqlQYK+%-=I6%=IKDPxA_8fmdN!a|R>bKo8-_shz zjWcwfOi;^RvANcOA@!LfAQ2g?RQ6Ea_Xml)u;O|FF)m1k$uhVRNtMR z_j|YB)thhira)!+`Z#wiwHZXmt+w;=sT-lXcuInrYOv@Q!u8J@SCV#kUkXO2RLavo| zlY5p+a*kY{agk3!!>7bdH~WL3+>T`pVlVSp8;tK?*U9cbWXS07h2Qev(UY&w2(JGA zLg@(impQxCxGmqiTc3^zzw!O^;tzt+75x!f)6bWyJ^I;TzC>N^cYtPPmcOfP@}2)Dx9|Sd^}1b@L1X8mFE3x4PCM0IbLHXv+NhV8-(1r%uaZ6C z@O$dX-AVS0g>(0<=Q?0*o&K&`_fUn(>-|4(7tP$8Fi|zEW2JvE^MuAn>LG!JEAua3 zv#hT;Rx0*AaNUiG*Dh~(^Z9N2lc|1dL>V3`e*9a>o}+jC{jNlbsDJmb?c6Rm?bXV# zb=(d7K8-mg59dyM%HdhO_{5^gZy%eU;&a*l+VlHrmImhA>2E%Nn`Cll<l+WtjZ!y~F;> zR`&BbDxW?{{MA^(Z}%-$mS3$`mUM8FW&*M(t$-etb!?@^g>ZWf;w5C^heJXf8CwklZ z4TeYBuBt64KDO_>f`9qi4f_jfCyF1>=DVyo?@ii8z1Ju8ZW}4hU{0b?_}uJ5TK_V}?=LE6ZRX2ku(LX}=77igC~l$H5P9U zl&ujKeX*nLO~cm53s22kv-FffjDl6|f8A3{_Z>IhxTIyLj%|f}@>|?;TjLBQvXkuUj2~6#Nw!N zVgBV1wT2xHk_RLr`wqtE?|-}2R@y&l+pjO|sjvNS)=H$F_*?MYS-J9+#aH*)SHJGI zy}rLFHS*u*TX{b(1uAp*rCy!Ia>3yK+m(q{fis#7&t81@)Wc^&xk2QYO(NIrPWbLm zTGvx~+pO!=KJPk(@3Cc}1!9tiP1qGz$!}7bnc8=ip=Fy#(2MVO@3M1EIUfY<`&+ue zYQHGY;_2~Mv}AaeNCbG7%bs89@PWav!2QlQ6MGpdJC;r zoPxKeUq5fSb=xc!0fzhMcPBEZyr~X*^4#<{$33N^D-(~f1YB4yx#(}I<+H~4WW%kg zFD||1YtT`WQYEiW!j7N)Y4fo_ zOsaU3#QT{-73+3Mn=o5VWL($5Rx|PRobx=HzmDr{yrRow`*c0G$cfYawwm{Co-CF+ z8M6PS#|du1(&shuvTd)Y#j2?J_a!cQav<85X~hm@8P-M0p-MRnhwktzY|&-3=P@sz zS7iRoW}AKOd-qSPi}laA>b~M0i!m$9PU{N=o?7J^zv@>q!Ouyk!^9 zol3O3tiZNxLD578MQQ(=u6DngyF>pzb(hz#|9{z5F-mNsgzwd<{V6IIjqK}dM zdJ~py3)}g3mHg?g^XK#4Y!nNe|5?F5{QrX*)4!Ql7M@qm^SiCClv6B}vH4oqWCnYl zRmoC^_%$q0o^6kOr3oH(O=smyTp|4Taon4#W zwS3(4@R@Mx?Ym_sUoK>x?C#(3nJqns7c5H^ZDUM% z+O2x)R2kq2uJdjw`(>CRVXT7;?R1TYWZqMvZ;U=%cS|1v)EjW z6?`UI_y(@un)+~+l4;qL6D!;KC(o99Gc{WNC8walfwVJ5irSm?U-v(F;Nj*{!0;{U zO4H)F=XRGPr@fE;wpL?x?Uu%mzvd~r*xcg!*8W)SSgZOwRpr?qHhT81eN@$O`cc$< zM~Tk>|d;vIybd@!`Wz7(Yf3!rIgQTM1?I`k}8?Ba*oxqU!Ht( z+ZJnjsyzDnSJpe z6ZiD)Hp)%@`E=^uPw&I+C%b5Pe-_zcIj?+;gzx;rKE<`dA~N<#vp0VFTb>(ja`sno z$ERagt+KZAb~c10{+Q7%uh#E>JJRm)_VXvt`s zaSER67ybK-@AYZ%GFy`aS=YbS&CS#9)jt~*@bhC}Ye_9{_MC<*IX{ooXTQtIt(VLd z6@SXN_m|_&$3Ii2@fQ5K*8fgt_nPT<-wS=6^7QlC&v`RtUuW<5uuCZ5NLT)Xx!vb0 z?c#IvuUJR_ntgoQx&L=hIsJYeRO`&?VXJZ8rl2H!XS(67w#Tb(wEi&pab?T@_(f;Z zCD(MmS6IUrneW9`GRN-k*wk`Ua_Sr)RAM)P27rPYSes=fMS5+zD;m=Cd8J1tT&w3(R zyFo8D+RHyrtM>5jC5gxPhm|Z|c*lNC{b9k)`Q7DjLWQI5ZoA&wux{q>z3j_3+OKA~ z(QUrqdwzS$hTX1zbT%Zu5z4%`X4QKC2O`@a*?P3zQ#4ys$TV$!<$)OwYxn+fDn0u9 z!X@Y19y?cC&iiv?<%YS8zp7gWqiwa%z7DDTE4BT4%q6q@gX!%ro;Mu6rokv{{JoZA z;o17lQMPQH{N>8LU(4K2e|%e5F`b>Y=KS`9@BFC(alWv6ad64LibUqW z0nfx;YtHQO4Lk8mSi6?>NA<@WQ%{P13tyP%!7?S?H}7?h{7<7dA^Y6_zMi&Y;WpmR zH}5|=Whp=U6{7rV^0@9Qr7VbWv&aLpp)O1I>PL$YSaJy_DWTc=c)>J^&#aj#=N_;X5+P+r%p zUGIf5PrM843-~21RVH0{@9g&@ZB|^R!VR-0`?r6%!!>tDuiJ;M_IW=~L|R!_%;$>R zzG4zfcgeNyO?$A2C^;JVY=#>lB5Uzw8p{K)jUra!w{4VC&If4l#*?4wfmlI3dzA096) zKBeRtS#D=7ljgU1iFr^UUuy9jKlZySt152rERo%ozr9E9q|BN8AHTk)M?bq$<+;yv zb*L&seE!eKl_7@?Kjpgp$k1Y0{*vB5(dzC_zy7Y9E%PI2XJOFkJ+uGK%XV4X#4u-f_@x_n zRX!cq7I;fWjgduEN$&D>rS#g!un$rRX0~&5io1SY31ld$75;YH*{tGth~)0ueV6vU z%Q#y5)UREFbIZzYt6$IW*!tswtyWC)=B2{X&-6CvXR)glB-x$##^Cp+s!e;f9Hae8 z&&5-xatA)<0O5$ic$yZ6Oy9P7G&yTCJ5EubyG3v+O%HgSBLePH=q5tlT&0(u^w$yC=9G`NeB+dyWVDscpUYclx(j z{NFqO+!yEVCmq%K>jU?14KBa3@^5F|qozHIeXq2+OgRgd%bI+@so2FPXNa-r)GdkC0 zkE{Rsy4giYxI_2EIyJ^=Wn!L8i7HJSj|hJIvG#*p?+b&5r^lSHl{|>vX#PjR?SGeM z`)!lpy`9D@v@bSts5^fRzV>yae70<7!QUyeIjb#Me=LodU9MQfrR05l2IGcF{b8b; zILs^#o{37ot-EC7NxSRyzouC)oxS=W!>^ZYo>$IvRB;{jxOJK5sbn^&^<`7|amv*b zY-N8_+G;a*8@@jm{bcR@$irI?7_}OIj=i3yo?`$1%gkl!g@=@9_nOxpZ8i9&E!JH( zOLgmA`6o6F{6b#ATE=|g7NT43rvxczv7f#spQ6dn#F^LqT}Gt2=<*tF-LJ{}QzLT! zo;);b$)4BO9W)e|>@s6DHFj9;;1S)Ma4}QkyY&g3L(i5l1*AXZId+l-|l_Qhex@Vi7~>ldEONErK5`vi<0%@3MBq{S!-) zSJt`B+SOENk7IAH(uAFW_+*_Uk11_OgYvg*e?<%=2HhCs^>h$9E8y(GVuuea< z(RSa1vc107N^jbAFX`_+$Fkg~K9y~kukxK=xms%&ncmDeCE`2dYU|m)zb}>2&or~d zTX;^G=4$u5{mrz!@->f4j!Y6vJ$38eDL*F9)_E3kRZcT6xAJ9wNv@9zD@?H2dULnq zrMTMFJ2^h>)Rf}2cz-;#->*mN_m=s;PnD$HU*T&$;acgNRp<22%}YMVa?@vi@7Y(& z^t-cG?RRJrUKDXA_xPbTE4N-%Y4BN9>{!U9$2?8FbK+(ngCvQSkL&Awj}#o|{~3D2 z#$#(^#>%>_sga4>oUIqH?MwS*$}KbdTl>nHFWk$|<_a(wT`OheQIMG-UnEk&kl=Tt zaE6NSzYl_f2P%W_uRTA{@Byok!t1xquiDr6`l?H+9PE&#aQ z($CM!j-I#e|H$gj{dG~@S(A(lTh-jmxO-kT^V#|b z)~uWFvSItTu*nSbnYfx%9Og20F5vZGiGA_^%OCAUU-M9Ckf`|n{-P4-rUK2eEqe~38@YR2A=l4 z4c0NODot}MXWM>xdd0K)i10j_J?>}F;J>So-I=|_>-~+X)Pn0Qu`8`S(he4ED)7BgHmP8r|IZ78o8H!y-9Eq>Uh918qwdC|=WUkX zm#QxPczV;L(3oBbM3u~3B$w)ce4_BNQ@@|r2G$in zO#Rf`KR093gv(c0JpZuR*6%sDXo<=vDY+*$v7zgQe53!KnDy|}-`REA;&W`dSDxE= z?cGZM=QUkI(QSr@d!EgT_GS7Z;TbH`z%(VU;)qM~uDzcQbsK%ZZ16j~%SFQQ!9VJv=F{$?+@nc-A_` z$czQO`SEFw~k1yiw}Ax{&n(}8n#XEer=b&t@gkC zP2N?R_j%Liy%)DOP+pzCBk+;qmk^t+Z>#=vT{v6+_QcuH@-rR}ObpU5i!8nTOvU4n z$pynR0=knelp8l+J8!NrS#OcwrtPVJE1t;ZF;{%sTz%@rv#uGR^d>YH+CO~1y3$I4 zQ6#SH)V$Yby!nr6okYL?txNnT6uIS&{h>BxhS_e{l;$t3n771fnZc`8%lYS?=d3-c zH22m1cO7q^Uhv#eyL>~U;~tUo-|ZJ(n6vtt$^q5e_S@e-T*|id-s^q!YlZn;c79{+ z-NBfY(p>E2;nu)xrsTx7vtZ{_@#_G!nzKDktWOSh^|^UeQ0>mzES zw^oI{RyyGRC^|m;fzV~cxbJ`8`j?$xHP`2hOmKMFvP|Q9iJsMzi9#p2Hu)`jzxu1O zv021w&wt-u+TN+zb$q?$G&2dy{Da(?$!kAp2|noBU}w|4>8{?h-W%ul{#4!h^S$;a zSFQx(8J9J*l$|vukMx?Vhp)wSo}(D z$CckD7X|!X`u-dg-n2uOKVFehlhY@89{Z7;6O$K6KIUH1-+K8>i}(GVOaE90?c4EC z_eklFQ+ET8eye)ztagC+fi=_dgY)w%?|$c=UVi%b`YM@;rAv#UuEy1I zUS{x5Jd;0v#{V66K3y@2l}J`=;kmFHs+mdg6;tFVftbX|8`p}X!Zil5* z4-}T1owF^uerL)4>$Ufr&u=Z;^(HRz-dq0_o0VA?`v;wyxozg0UB{2<&3l)BEHU^0 zmPFsF@#0J}F~?v2p4WPLgMRhh@89?DaQe3Y)4vT4kKDD*B~;C2a?MkB`P^8a@iId? zde@KZv8~@s!)jwW3#M4)+-v+Kvi^77x3~VMPwvj2Q|+|-yQ2BGjM>$=ge#_@z zWQqTG=b*Bc^sXPKv!~rUH)UVX`t$Q*qtniQ-IvH>@ucta_uJuKf4TR(tJaxQf3WiR zxw!hp-|{UO-%0HA{u8>UdijETmxHA2)|otSOTG8q?cIG7rh6Xh!O=AZ&f)dnZ|-?xVos62_r+Pn~9QFLc z^0@yex7VLyx$u5#SHD^_DbN{)la^RHl zx4U~6#{baEJiqJ5vGoogp6h?%TgBO3=s9(|%&S}P4qTY*HmCgb|EsEh#l(NL`!d)q zTD`dR>B%2*3jQUy2sr(`?@H zI@Rl)?w`oH;SVazd%u}}@jBhM-s5u1(x9vaZ`q8e6O&odqtcL?tytX{-rLA9dkq*IMbN4iA;^Lwc| zC1pk#K5c)_n03xjkjVb~GcuRaJz8e|`}DJ4#f}T}-<~0Ic-HTf#b?7$%-}4Y?@{!_ zH0agr$k*YCnqR7N@*|kGoS*a0F(!95xB9BO2f+_zn9m+Jd?PU&_ux2-wbbUyItw~epMPd$s?;vHOk zH{W>v(a=+pxy52Ji%-QrpMG7JF>FrhftF+Y?%hmylKg>j`Qy?Y?~P0B+pCj*E)nZU zcTBjMrm)rG18hVbN8NMm^<}h#r(dLr^IZe9JG8Z2bjEzPYEm=JUEy2YC{6F2AAY+NU}T4Rfw@PguFTgsEu zVi|Z!?@m#vS)w-mYxs`_EeFNm8>x;SOiW6>MU!3{UF%=PI-518ZjyL2!_k?3vrfLc z7bp2brQ^cPb!^M7U-(h+etE5$aB7g{=9Bjl@z0)F z{rddtN$Z?5whA?D=i|%In$^l#VUrVjZ%PF3fucVl?Dv;_bm5a`oZ!M;qp{idFw3nA zOI5OyLME--tUvc}V~upYWX`Q6`_{;p-sy6&{iq^fBxQ5d;b-GL+lU2~)A{pmG|YJ! zCf#R$Be=Ftcb%8nyb8Cz7t>?6y`9Q??0W5r7p%|nA4s!rc8jk#E_6%g)V5Qc7k=)1 z$v#c`)DDT@%EvjO3^m!?I5yA8zk0&Re8a<*$r7_=&9pCzIxw>q*;vN({ho3mLhYbo zph0-R_a})rpS-a%zIH?WLB`F=hi+fp*SG1wF4^hDbJumhy>lh)Mtbhd`BTbc@4S}Z z*jBbJ$9SX8?wq^od#rb#d%G~G{^gTtpT+M4EZ@_`JBtCl5=`~5J^zV6Z+3mV`z>!~ zfB#X{@XY+JnsT?M8nAU3i{-vDU|#IF*-ZVa2TzeHlJQky}H(!4~I$h^w_xmbe zYo`9zR-@y~jH1H!PB|-dG$%6p2$v=IjF-m_|4xypQ`^qCKR5c&dT-t8N&S51-voDk zvpu-@eV5{kvL43izm3?spWUx%l)_nX*Lfg!KKVT0X8j@3HCM zDWl^vY~|N~n`|b%IAGF(*}k{(kBIL$_I78t&y0U%lIBlyFKsbfB)H9$``r}v=l|oT z+5MXGWzCV>b}aQjZj`3H@r!DgSn?&qu(`~mrH4x5SclU#S)mEMBchmlT z{(h}|Zpb9tZFvI!Dy}d{pVK;JEV%pmh5ug}Q?^uIV38;=@P8mTUt^Py{00lYO+A|? zuVU;zZk=zgUi&R@n)LU3&s1(yoLD@6{krQZn+s>oIk_vwN=-Mqo;Nb#=G#a5Cw}ZL z56w9`{rI{$`rps}-u7tIXXXd$`@$PO&usnl$Ch6s?&wh=F4y0gi_(ii4UT?17HF<^ zAh-X4d4`UzuVk9ds4em zwiny4wRcN0cPdS6l8ad$Uc2+-I_>!;%k1wz<9z)?I%-zx{@3~wuYJ|JetYWq{Tlm! zUpRT_`CSRWJ#8)ezmD^LKm9>?^24IYqicU$T=!#g-HX)Or!Ki!Enw|Yn5)1%XFj9Vv~#SMlg~~%RHD8shNuNpJH0ko0JjaUK76hhGoF1yja^SXx@G)z>&H9_Z^+lij z_rKWg>T|PyLf_Wg*L3aq^dY%Te(#k>#zt(SyF};TUc{5y?7MZbvZY*w?T3YHwd!6; zWbE12Ubk4aefPan6NDSgeun54w@rBGZ_{K51?*miJ7LT;$Wo zViGmiw$ezq)5-d2iODy|{c64v>ko4Nm6kOL{Wd4Ma^CJgE1y3}cdv>1wX6C{m}=eO z^l-tm^KKWN5&dE7=#=o%`B-~m=>7}xAKCpx>Sgl3otU~YVQFY>>{^xw(?dmd))YPL z@?(DKtvH5x1jyueZmF4E%RrI4mcINY=rAzH1El()U zRQ~rs_&86gbQRM9&S*xXBuW=V{Zu4Eajc*v#j%S}iL`dVX@UP|nH3B>`)1 z2T8ZKC^$u(OVQCSw|pzS>8|1X;NLG#$HwoCdH4HVf5^PIK6&!ztN8Zs6e*c)uXXxl zBa?3U;rG9^Hmp>=;ls@9I@9Lwi4Pm*);r&Ao5!$0;yh!9t*UItm5&T}m~#`H*`Cj7 zDlwWTchOboMfI!471vBB*x7Aaowp@&em{3|Q_dAersZ|1Z8>!}{<+M%UHA3>xk>3C zrY^}~-z#%`amTF-Mo}v+r|H=CHaB1p1r@>j7{9WM|;()!}q@y-t^_=bEgNB3?~#tTg~odtnWMc zhC%jrhWx$_y4#K$-JZPh*U!e2v+Sm5AGYWe5a>6E*=>dzuxMSNOS^M9`V#C1=#r=HH)^HNF7`l7S#4|mpYel5oH-yU$< zy=7%oQ|`8Q!!G~RYL50hQXg}B3YXlhkWNm$?rHMljPi}()YU(XzOXO|*~M2s_vUB%U|2Mt3%9A5N@-|m)$>)Ic>H#aXy z*ne8%--_F{hvjxj)LFAUiHTZz>Vhys+NI(Kt_6k*7`HH$u^TO#6EdIG<-OzLEiZX< z8#&aMZ@S;fJX4@(r`R8{i|zHFnKKWS{w=9~^z8Q2Dx1Cco(AdUqb3Cn`&nk^fwJ#VkERM?j8=)gZc z=iIFwCE~wIZMGx{{Ld_7mt7TnoZVLI@g_-6t8>3Yx=*a|HJif!|I_Xtt>^ZvE{`u< zd&lCooqDJE)Qs&0a-mx;=2@In_M86D?qKRB7e>jg{hRvui*{LkNY2`~egjM3%>&%E z{kxd!UL{JKzu&yK@JLyo^s8y_o-{m{`aN4b+y*cwYd@lRcxLVtu=l5I7_^H+FciFu<*}Gi#_(9wM zHYYA*Zey-{YyLacmXmMO%Zl%UKP1o64MWr#H$mCniU*DZjW@ck4St=KPyC z9xbSSa#y_G^X*do@cKU?m4CKbe|F@1ETS(QV#wEEzlO!nZmF-`o0|(C3;%iL-6g+i zWqs$~qz|IPaVugcD;%BmB4Dv|g=7`e0gL-=Spf+*JT@%tHB~?Hz2rbjld`95)*Ajp zD`WP}Iib?0t$DjZ9aO^^N-6Mv#@)`Yxm1y`{EzXf7kfz{jvOQn)B&3kNao8zIaL? zu5BKJ!87&7E#3;2K^NURRyN0(MfRQI__8nOjKkyAFI#dp?EgP={?zUAzZtdqqkmrt zf11rMqbYkjuA8xR&6d+=4N_C58R=vy7@xW3@7tHTz%b|hvCyxk56}PVJasHXXwx&J zef+s?eVw*s3<^5~q%>KB9yH-p7NyIg)X%+kSUsc%Y zW3wbB<@Umc^s9UL6hHm*PCtE(-S_Z~oa%=Yk3X4IF0Wi$Teoi#f6HFh&)XV5q^ma^ znSS`a_a?TZAB+QDvBxD>GW@BMZAmWmNt^#T@^i|Ez1uH3KNb0ASaRa_1mOmUF9Hh^ zy_yfn{Zrm>hH+bp-wvBw8twLMlciJFqkF%oSj*j)UfkAdBM*x$Q1(9KUp<^dYk^EDf>tED|j$8Vskl-Qfva&bx5?WFzH<&3DrbReO3C zEbh{~D%+7AS-s`Y@!soRuQPs|ZHN^3`BC!usaX42^30;1=UEMuLr;b^9nNW=c%?6K zn;C!G!}hcnSFUy>Z}G|g(Z1`~quhhy*~^a|nE3ll-E`O4w>K{F{;{;iV!DBMhQuR~ z3mnxL=5|akdDvi`W8D3LiF4QTnmWs+E%UD~=ZhAa@Zn71w!+8rW?ehJzF6k}<@b+% z|Gp{SZ}s8GzsmPN81hcVtVz~>vsm%!*?)ija@_Pui@#{6=(6YD`hy-!$I6bL_&DMF z>UlD29%PpL*rp-7RwJi2(&bL~d(w8?9Hs4tH zH9=W+{o_00Hx&OZ%yaf*kh<#ZV30q5BC}6{7Q0M5;Zoz{lUwg-N^o2Es3)>CiFtuY8SQqZ@EGm*F#u#F3ej!k>R9mT5i-bU&^*WE+y!=~c5u{+rG83|U(* zw+AhoO0Ps+YTJKpqH;<_@n)XG>;D8y%2(gLd)JcHo4vzy;`L5X)0rwRW~Z+mSIxVt zS#N4d^%usc3G;7UNefzD>l@UUZp3EW*B#~Se&KxGU+zDP>Q{jBDcj?}3=EH5tX`Bz zE?@IiozI}UvS^9<rceZe+Oze z$}4C09=$)KH08;zABXF7RZel#6fa+yS}M=$ab)#A+g}HoKRsN@K5M<=gjeN8!BgeG z-nLjMdP>4RQfbL`_7kf7fpTg4mpy8i&5tiV^0jX9_O7%xq3z8I9daSeFZu*@qt~2c zh@P8Z+4Q)q_Wr^mmAblBJNE8+;Trkn#r>zMG27PKzn)|GcXLB!Vc@H$PnE*&9{K*RavHI&E7uGI6miiP^0}Bk8S-&`S-_`|7MzLmuda- zXgZ%@Fzdet{p)!vEYu!xM-=T@kQA)bKfmtozm2h}?S;xB)xVnbJLcUmDtCN(d|~H> z3H*#3R`1VFQQ(z|-)!B|b0Ef^E!9{!wPe}fuxA$kK0Dlqo9?LM>tn!Sp;+j7WW|)4 z%ksA@c3ZrznRbz_WX4<;d&5g+|2*>ETz3vlvwvG(_t5EP_T{xJ4AbL(HZUcf`k;Q@ zafN)vo1aW-%Qt1+&&htoX8HZ>w-@bEPlZ1@-))=2@c4tt^x%F8HGZ4B>fCZdk@xt@ zIYm4~76{88IBv(HXtijH;I6Gl%c{hVZT3B#Fz1$}%k<>A-|j3sb!p!+wobSD4f6b< zF_i`mrJ~2&d+zZ$US-tzvnT%T1-svY8>cPkPXCwJJ@<&GkhlPihx$ z>Y0*Jdg;WHIYLMG3T5^!&DDC`81{+VDDh{i_K_(w-n5)ZJ^Zkze*)Jl$KPuRZ+Kx^nhO_42s=uimS#JamXV=l`aK3roCCS8~hgmxQjp zHkaY$ix0m&<~MHCUo_XhLaL-Xv2y;OC-2HCGXld^di$&!HBa6PQ!(OQrOukVvvB^T z>62Dfv28S+@b}a=9^{_DRj$ zlajq;iHmjQ6>0tZ#|-5dyd+;z3|t(Fkk1=oNOD$F0=4+GxkL{PxbCi{_@mjf(`rk4-$mViTjBM@Q~8SDlsWe=ZF{uG{L}Kxdly(V ze)Y`{yTg7x{716i&-^SgR_DEYm&fm`f3N-N@$cy#>V7H`;%psHmU~WQy;HS!e%6iW zjzv3bs>MF|TzkiUAyQ7L$tw5g29+nmyZh$vVeET8`&5)~g;|oU^ojq*59VC4dM{?f z{O0V2w_b#27eCB)V-`4Av>^X|R^LP1)mW0l(a`UFCz+zk%i486FKP z6T~XwR5o2b%pAYxTYwLjT9ER&OZJW5pQOLvyK>Ftpzo$z+`X2z1xG!*^XGQfj{9%6 zZO>s^azw}ebA#dG-&N<1Ua{#b^!UxDb^pT=qnWk0H-CDvdhSiH1)TzXjI$UL&6-(# zTPA3{d}UZTS8S$jZLwvJkX&i>{WgBj)A!$H%G7;ZvHs2vuUhAOHY@8pSTrtOwoASx z_+v?T|Cd=2Z&ruZOYTp%|Gh2!i95gkv4u?s(>hkO%Rg5s`tnWq%l0+L`&ZP&#F+lg zs20*b_WahfdsTlK{^s9&R)2!U<7Q0dHM`g5Urgt;chrfzE&cg<@_xN(|MxuAtbAu$ zoxbs6)Y+nQ`(~dEy!|L}``6q(W?!2B9s6hOc4l90V6F4LSwW?O->$X9b#iUqb;Yvy zSNld5W5o}rg|iE^PdcrzUz~cbX|;ot_=NhdxAV0ay98faB>zfV`tOsC%j>^K_f@@8 zcz*n8+ohLlpX*kvlKWZjGPC{_iwASivGtQnOMR@@#pr#se*E+Kk(8!x@1>u_XGQ1# zEz6v?Fa1o_KTq%fMw|gX@y~nyZaemyao^b^4`uQ%KfCv5`=T@Zo&Nr3wrQI$BlqXW QXV7+ZPgg&ebxsLQ007(f+5i9m literal 0 HcmV?d00001 -- 2.51.2