diff --git a/docs/claude-compaction.md b/docs/claude-compaction.md new file mode 100644 index 00000000000..93882ed4a0e --- /dev/null +++ b/docs/claude-compaction.md @@ -0,0 +1,31 @@ +# Claude compaction bridge + +Claude requests compaction through its normal messages endpoint. The bridge +translates the transcript and custom summary instructions into Codex input, +appends a compaction_trigger item, and dispatches through CPA's ordinary Responses +execution. CPA handles upstream routing, credentials, transport and response +assembly. + +The bridge returns only the compaction item's encrypted_content as Claude text, +including for SSE clients. It adds no capsule wrapper or cache reference. On +replay, a complete Fernet-shaped ciphertext line is removed from the summary +message and restored as a Responses compaction input item. The newest raw block +replaces the preceding conversation window; messages after that boundary remain. +Quoted or inline ciphertext examples are left as ordinary text. + +This recognition checks transport shape, not decryptability or origin. Raw +ciphertext carries no explicit type tag, so a standalone valid-looking ciphertext +line in ordinary prose is ambiguous and will be interpreted as compaction state. +The upstream validates the encrypted state. + +Credential selection uses CPA's normal router. No auth ID is stored or pinned. +There is no synthetic 200k rejection; Claude uses its configured window and the +upstream enforces the model's actual context limit. + +Compaction requires no server cache or persistent volume. Only raw ciphertext is +supported; legacy inline capsules and KV cache references are not decoded. + +Three manual compact/resume cycles with real Luna OAuth output and unmodified +Claude Code 2.1.211 established that ciphertext text is replayed unchanged and each +replacement supersedes the previous block. This does not establish automatic +compaction or Claude Desktop behavior. diff --git a/internal/constant/constant.go b/internal/constant/constant.go index 0efbc87d056..6f50486f371 100644 --- a/internal/constant/constant.go +++ b/internal/constant/constant.go @@ -3,6 +3,9 @@ // ensuring consistent naming across the application. package constant +// ClaudeBridgeUsageContextKey scopes bridge-specific context accounting to its requests. +type ClaudeBridgeUsageContextKey struct{} + const ( // Gemini represents the Google Gemini provider identifier. Gemini = "gemini" @@ -27,4 +30,16 @@ const ( // Interactions represents the Google Interactions API format identifier. Interactions = "interactions" + + // ClaudeResponsesBridgeAlt identifies Claude /messages requests that must use + // the Codex Responses API while preserving a Claude-compatible response. + ClaudeResponsesBridgeAlt = "claude/responses" + + // ClaudeResponsesCompactBridgeAlt identifies Claude compaction requests that + // must use the Codex /responses/compact endpoint. + ClaudeResponsesCompactBridgeAlt = "claude/responses/compact" + + // ClaudeResponsesCompactionField carries validated compacted Responses items + // from the Claude handler to the Codex executor. It is never sent upstream. + ClaudeResponsesCompactionField = "cpa_responses_compaction" ) diff --git a/internal/runtime/executor/codex_claude_bootstrap_test.go b/internal/runtime/executor/codex_claude_bootstrap_test.go new file mode 100644 index 00000000000..c520fa73f00 --- /dev/null +++ b/internal/runtime/executor/codex_claude_bootstrap_test.go @@ -0,0 +1,63 @@ +package executor + +import ( + "context" + "net/http/httptest" + "strings" + "testing" + + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" +) + +func TestClaudeBridgeBootstrapPreservesUpstreamFailureDelivery(t *testing.T) { + for _, transport := range []string{"http", "websocket"} { + for _, scenario := range []string{"overload", "frame_budget", "empty_incomplete"} { + t.Run(transport+"/"+scenario, func(t *testing.T) { + events := []string{codexCreatedEvent, codexInProgressEvent} + if scenario == "frame_budget" { + for i := 0; i <= codexBootstrapMaxBufferedFrames; i++ { + events = append(events, codexInProgressEvent) + } + } + if scenario == "empty_incomplete" { + events = append(events, `{"type":"response.incomplete","response":{"id":"resp_1","output":[],"usage":{"input_tokens":1,"output_tokens":0,"total_tokens":1}}}`) + } else { + events = append(events, codexOverloadEvent) + } + var server *httptest.Server + if transport == "http" { + server = codexSSEServer(events...) + } else { + server = codexWebsocketServer(t, events...) + } + defer server.Close() + body := []byte(`{"model":"gpt-5.6-sol","stream":true,"max_tokens":64,"messages":[{"role":"user","content":"hello"}]}`) + req := cliproxyexecutor.Request{Model: "gpt-5.6-sol", Payload: body} + opts := claudeResponsesBridgeOptions(body, true) + var result *cliproxyexecutor.StreamResult + var err error + if transport == "http" { + result, err = NewCodexExecutor(codexBufferingConfig(true)).ExecuteStream(context.Background(), codexTestAuth(server.URL), req, opts) + } else { + result, err = NewCodexWebsocketsExecutor(codexBufferingConfig(true)).ExecuteStream(context.Background(), codexTestAuth(server.URL), req, opts) + } + if scenario == "overload" { + if err == nil || result != nil { + t.Fatalf("overload must fail before exposing usage: result=%v err=%v", result, err) + } + return + } + if err != nil || result == nil { + t.Fatalf("expected in-stream failure: result=%v err=%v", result, err) + } + payload, streamErr := drainChunks(result) + if streamErr == nil { + t.Fatal("expected terminal stream error") + } + if !strings.Contains(payload, "message_start") || !strings.Contains(payload, "input_tokens") { + t.Fatalf("buffered Claude start and usage were lost: %s", payload) + } + }) + } + } +} diff --git a/internal/runtime/executor/codex_executor_execute.go b/internal/runtime/executor/codex_executor_execute.go index 49ef1a127ff..0bd25e140ad 100644 --- a/internal/runtime/executor/codex_executor_execute.go +++ b/internal/runtime/executor/codex_executor_execute.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" @@ -20,12 +21,15 @@ import ( func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { ctx = helps.EnsureSessionContext(ctx, opts, req.Payload) - if opts.Alt == "responses/compact" { + if opts.Alt == "responses/compact" || opts.Alt == constant.ClaudeResponsesCompactBridgeAlt { return e.executeCompact(ctx, auth, req, opts) } if isCodexOpenAIImageRequest(opts) { return e.executeOpenAIImage(ctx, auth, req, opts) } + if opts.Alt == constant.ClaudeResponsesBridgeAlt { + ctx = context.WithValue(ctx, constant.ClaudeBridgeUsageContextKey{}, true) + } baseModel := thinking.ParseSuffix(req.Model).ModelName apiKey, baseURL := codexCreds(auth) @@ -45,6 +49,8 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re } originalPayload := originalPayloadSource originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, false, helps.APIKeyModelIsCompat(req)) + originalTranslated = applyClaudeResponsesCompactionReplay(originalTranslated, originalPayload, opts) + body = applyClaudeResponsesCompactionReplay(body, req.Payload, opts) body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { @@ -73,6 +79,9 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re if errReplay != nil { return resp, errReplay } + if _, errContext := validateClaudeBridgeContextWindow(baseModel, body, opts); errContext != nil { + return resp, errContext + } reporter.SetTranslatedReasoningEffort(body, to.String()) url := strings.TrimSuffix(baseURL, "/") + "/responses" @@ -221,22 +230,28 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A from := opts.SourceFormat responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) - to := sdktranslator.FromString("openai-response") + requestFormat := sdktranslator.FromString("openai-response") + if opts.Alt == constant.ClaudeResponsesCompactBridgeAlt { + requestFormat = sdktranslator.FromString("codex") + } + responseSourceFormat := sdktranslator.FromString("openai-response") originalPayloadSource := req.Payload if len(opts.OriginalRequest) > 0 { originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, false, helps.APIKeyModelIsCompat(req)) + originalTranslated, body := translateCodexRequestPair(from, requestFormat, baseModel, originalPayload, req.Payload, false, helps.APIKeyModelIsCompat(req)) + originalTranslated = applyClaudeResponsesCompactionReplay(originalTranslated, originalPayload, opts) + body = applyClaudeResponsesCompactionReplay(body, req.Payload, opts) - body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) + body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), requestFormat.String(), e.Identifier()) if err != nil { return resp, err } requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) + body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, requestFormat.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body = helps.SetStringIfDifferent(body, "model", baseModel) body, _ = sjson.DeleteBytes(body, "stream") body = normalizeCodexInstructions(body, helps.IsNativeCodexRequest(req.Payload, opts)) @@ -244,7 +259,8 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A body = normalizeCodexParallelToolCalls(body, opts.Headers) body = helps.NormalizeCodexToolSchemas(body) body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, baseModel) - reporter.SetTranslatedReasoningEffort(body, to.String()) + body = codexCompactRequestPayload(body) + reporter.SetTranslatedReasoningEffort(body, requestFormat.String()) url := strings.TrimSuffix(baseURL, "/") + "/responses/compact" var identityState codexIdentityConfuseState @@ -305,7 +321,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A reporter.EnsurePublished(ctx) var param any clientData := applyCodexIdentityExposeResponsePayload(upstreamData, identityState) - out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, originalPayload, body, clientData, ¶m) + out := sdktranslator.TranslateNonStream(ctx, responseSourceFormat, responseFormat, req.Model, originalPayload, body, clientData, ¶m) if responseFormat == sdktranslator.FormatOpenAIResponse { out = helps.EnsureResponsesUsageDetails(out) } diff --git a/internal/runtime/executor/codex_executor_request.go b/internal/runtime/executor/codex_executor_request.go index b67eda88c13..70fffa91a00 100644 --- a/internal/runtime/executor/codex_executor_request.go +++ b/internal/runtime/executor/codex_executor_request.go @@ -10,6 +10,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" @@ -48,6 +49,55 @@ func translateCodexRequestPair(from, to sdktranslator.Format, model string, orig return originalTranslated, body } +func applyClaudeResponsesCompactionReplay(translated, source []byte, opts cliproxyexecutor.Options) []byte { + if opts.Alt != constant.ClaudeResponsesBridgeAlt && opts.Alt != constant.ClaudeResponsesCompactBridgeAlt { + return translated + } + replay := gjson.GetBytes(source, constant.ClaudeResponsesCompactionField+".output") + if !replay.IsArray() || len(replay.Array()) == 0 { + return translated + } + + input := gjson.GetBytes(translated, "input") + var combined bytes.Buffer + combined.WriteByte('[') + needsComma := false + for _, item := range replay.Array() { + if needsComma { + combined.WriteByte(',') + } + combined.WriteString(item.Raw) + needsComma = true + } + if input.IsArray() { + for _, item := range input.Array() { + if needsComma { + combined.WriteByte(',') + } + combined.WriteString(item.Raw) + needsComma = true + } + } + combined.WriteByte(']') + updated, errSet := sjson.SetRawBytes(translated, "input", combined.Bytes()) + if errSet != nil { + return translated + } + return updated +} + +func codexCompactRequestPayload(body []byte) []byte { + out := []byte(`{"model":"","instructions":"","input":[]}`) + out, _ = sjson.SetBytes(out, "model", gjson.GetBytes(body, "model").String()) + if instructions := gjson.GetBytes(body, "instructions"); instructions.Type == gjson.String { + out, _ = sjson.SetBytes(out, "instructions", instructions.String()) + } + if input := gjson.GetBytes(body, "input"); input.IsArray() { + out, _ = sjson.SetRawBytes(out, "input", []byte(input.Raw)) + } + return out +} + // PrepareRequest injects Codex credentials into the outgoing HTTP request. func (e *CodexExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { if req == nil { diff --git a/internal/runtime/executor/codex_executor_responses_bridge_test.go b/internal/runtime/executor/codex_executor_responses_bridge_test.go new file mode 100644 index 00000000000..8c08e74cfb2 --- /dev/null +++ b/internal/runtime/executor/codex_executor_responses_bridge_test.go @@ -0,0 +1,451 @@ +package executor + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" +) + +func TestCodexExecutorClaudeResponsesBridgeUsesOAuthToken(t *testing.T) { + var gotPath string + var gotAuthorization string + var gotAccountID string + var gotBody []byte + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuthorization = r.Header.Get("Authorization") + gotAccountID = r.Header.Get("Chatgpt-Account-Id") + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read request body: %v", errRead) + } + gotBody = body + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"completed\",\"model\":\"gpt-5.6-sol\",\"output\":[{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"hello\"}]}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n")) + })) + defer upstream.Close() + + executor := NewCodexExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Attributes: map[string]string{"base_url": upstream.URL}, + Metadata: map[string]any{"access_token": "oauth-token", "account_id": "oauth-account"}, + } + requestBody := []byte(`{"model":"gpt-5.6-sol","max_tokens":128,"messages":[{"role":"user","content":"hello"}]}`) + opts := claudeResponsesBridgeOptions(requestBody, false) + opts.Headers = http.Header{"Authorization": []string{"Bearer local-proxy-token"}, "X-Api-Key": []string{"local-proxy-key"}} + response, errExecute := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "gpt-5.6-sol", + Payload: requestBody, + }, opts) + if errExecute != nil { + t.Fatalf("Execute error: %v", errExecute) + } + + if gotPath != "/responses" { + t.Fatalf("path = %q, want /responses", gotPath) + } + if gotAuthorization != "Bearer oauth-token" { + t.Fatalf("Authorization = %q, want OAuth token", gotAuthorization) + } + if gotAccountID != "oauth-account" { + t.Fatalf("Chatgpt-Account-Id = %q, want OAuth account", gotAccountID) + } + if got := gjson.GetBytes(gotBody, "model").String(); got != "gpt-5.6-sol" { + t.Fatalf("upstream model = %q, want gpt-5.6-sol; body=%s", got, gotBody) + } + if got := gjson.GetBytes(gotBody, "input.0.content.0.text").String(); got != "hello" { + t.Fatalf("upstream input text = %q, want hello; body=%s", got, gotBody) + } + if gjson.GetBytes(gotBody, "context_management").Exists() { + t.Fatalf("normal bridge injected context_management: %s", gotBody) + } + if got := gjson.GetBytes(response.Payload, "content.0.text").String(); got != "hello" { + t.Fatalf("translated response text = %q, want hello; response=%s", got, response.Payload) + } +} + +func TestCodexExecutorClaudeResponsesBridgeStreamUsesOAuthToken(t *testing.T) { + t.Run("unbuffered", func(t *testing.T) { testClaudeHTTPBridgeUsage(t, false) }) + t.Run("buffered", func(t *testing.T) { testClaudeHTTPBridgeUsage(t, true) }) +} + +func testClaudeHTTPBridgeUsage(t *testing.T, buffering bool) { + var gotAuthorization string + var gotBody []byte + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuthorization = r.Header.Get("Authorization") + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read request body: %v", errRead) + } + gotBody = body + w.Header().Set("Content-Type", "text/event-stream") + for _, event := range claudeBridgeCodexEvents(t) { + chunk := append([]byte("data: "), event...) + chunk = append(chunk, '\n', '\n') + _, _ = w.Write(chunk) + } + })) + defer upstream.Close() + + executor := NewCodexExecutor(&config.Config{Codex: config.CodexConfig{StreamBootstrapBuffering: buffering}}) + auth := &cliproxyauth.Auth{ + Attributes: map[string]string{"base_url": upstream.URL}, + Metadata: map[string]any{"access_token": "oauth-token"}, + } + requestBody := []byte(`{"model":"gpt-5.6-sol","stream":true,"max_tokens":128,"messages":[{"role":"user","content":"hello"}]}`) + opts := claudeResponsesBridgeOptions(requestBody, true) + opts.Headers = http.Header{"X-Api-Key": []string{"local-proxy-key"}, "Anthropic-Beta": []string{"thinking-token-count-2026-05-13"}} + stream, errExecute := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ + Model: "gpt-5.6-sol", + Payload: requestBody, + }, opts) + if errExecute != nil { + t.Fatalf("ExecuteStream error: %v", errExecute) + } + var output strings.Builder + for chunk := range stream.Chunks { + if chunk.Err != nil { + t.Fatalf("stream error: %v", chunk.Err) + } + output.Write(chunk.Payload) + } + if gotAuthorization != "Bearer oauth-token" { + t.Fatalf("Authorization = %q, want OAuth token", gotAuthorization) + } + if gjson.GetBytes(gotBody, "context_management").Exists() { + t.Fatalf("normal stream bridge injected context_management: %s", gotBody) + } + assertClaudeBridgeUsageStream(t, output.String()) +} + +func TestCodexExecutorExecuteStreamCancellationClosesIdleStream(t *testing.T) { + tests := []struct { + name string + alt string + }{ + {name: "Claude bridge usage ticks", alt: constant.ClaudeResponsesBridgeAlt}, + {name: "nil usage ticks"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + started := make(chan struct{}) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + w.(http.Flusher).Flush() + close(started) + <-r.Context().Done() + })) + defer upstream.Close() + + executor := NewCodexExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Attributes: map[string]string{"base_url": upstream.URL}, + Metadata: map[string]any{"access_token": "oauth-token"}, + } + requestBody := []byte(`{"model":"gpt-5.6-sol","stream":true,"max_tokens":128,"messages":[{"role":"user","content":"hello"}]}`) + ctx, cancel := context.WithCancel(context.Background()) + opts := claudeResponsesBridgeOptions(requestBody, true) + opts.Alt = tt.alt + stream, errExecute := executor.ExecuteStream(ctx, auth, cliproxyexecutor.Request{ + Model: "gpt-5.6-sol", + Payload: requestBody, + }, opts) + if errExecute != nil { + cancel() + t.Fatalf("ExecuteStream error: %v", errExecute) + } + select { + case <-started: + case <-time.After(2 * time.Second): + cancel() + t.Fatal("timed out waiting for idle upstream stream") + } + + cancel() + closed := make(chan struct{}) + go func() { + for range stream.Chunks { + } + close(closed) + }() + select { + case <-closed: + case <-time.After(2 * time.Second): + t.Fatal("stream chunks did not close after context cancellation") + } + }) + } +} + +func TestCodexAutoExecutorClaudeResponsesBridgeUsesHTTPWithoutWebsocketAuth(t *testing.T) { + var gotMethod string + var gotUpgrade string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotUpgrade = r.Header.Get("Upgrade") + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_http\",\"status\":\"completed\",\"model\":\"gpt-5.6-sol\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n")) + })) + defer upstream.Close() + + executor := NewCodexAutoExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Attributes: map[string]string{"base_url": upstream.URL}, + Metadata: map[string]any{"access_token": "oauth-token"}, + } + requestBody := []byte(`{"model":"gpt-5.6-sol","stream":true,"max_tokens":128,"messages":[{"role":"user","content":"hello"}]}`) + stream, errExecute := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ + Model: "gpt-5.6-sol", + Payload: requestBody, + }, claudeResponsesBridgeOptions(requestBody, true)) + if errExecute != nil { + t.Fatalf("ExecuteStream error: %v", errExecute) + } + for chunk := range stream.Chunks { + if chunk.Err != nil { + t.Fatalf("stream error: %v", chunk.Err) + } + } + if gotMethod != http.MethodPost { + t.Fatalf("method = %q, want POST", gotMethod) + } + if gotUpgrade != "" { + t.Fatalf("Upgrade = %q, want normal HTTP request", gotUpgrade) + } +} + +func TestCodexExecutorCountTokensReturnsExactInputCount(t *testing.T) { + executor := NewCodexExecutor(&config.Config{}) + requestBody := []byte(`{"model":"gpt-5.6-sol","max_tokens":128,"messages":[{"role":"user","content":"count these exact tokens"}]}`) + translatedBody := sdktranslator.TranslateRequest(sdktranslator.FormatClaude, sdktranslator.FromString("codex"), "gpt-5.6-sol", requestBody, false) + enc, errTokenizer := tokenizerForCodexModel("gpt-5.6-sol") + if errTokenizer != nil { + t.Fatalf("tokenizer: %v", errTokenizer) + } + want, errCount := countCodexInputTokens(enc, translatedBody) + if errCount != nil { + t.Fatalf("count exact tokens: %v", errCount) + } + + response, errPublic := executor.CountTokens(context.Background(), nil, cliproxyexecutor.Request{ + Model: "gpt-5.6-sol", + Payload: requestBody, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + ResponseFormat: sdktranslator.FormatClaude, + }) + if errPublic != nil { + t.Fatalf("CountTokens error: %v", errPublic) + } + if got := gjson.GetBytes(response.Payload, "input_tokens").Int(); got != want { + t.Fatalf("input_tokens = %d, want exact %d; response=%s", got, want, response.Payload) + } +} + +func TestValidateClaudeBridgeContextWindowAllowsClientConfiguredWindow(t *testing.T) { + body := []byte(`{"model":"gpt-5.6-luna","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":` + string(mustJSONMarshalExecutorTest(t, strings.Repeat("x ", 200_500))) + `}]}]}`) + count, errContext := validateClaudeBridgeContextWindow("gpt-5.6-luna", body, cliproxyexecutor.Options{Alt: constant.ClaudeResponsesBridgeAlt}) + if errContext != nil { + t.Fatalf("input above 200k rejected: %v", errContext) + } + if count <= 200_000 { + t.Fatalf("count = %d, want > 200000", count) + } +} + +func TestValidateClaudeBridgeContextWindowAllowsCompactedReplayAboveSyntheticLimit(t *testing.T) { + body := []byte(`{"model":"gpt-5.6-luna","input":[{"type":"compaction","encrypted_content":"opaque-state"},{"type":"message","role":"user","content":[{"type":"input_text","text":` + string(mustJSONMarshalExecutorTest(t, strings.Repeat("x ", 200_500))) + `}]}]}`) + count, errContext := validateClaudeBridgeContextWindow("gpt-5.6-luna", body, cliproxyexecutor.Options{Alt: constant.ClaudeResponsesBridgeAlt}) + if errContext != nil { + t.Fatalf("compacted replay rejected at synthetic limit: %v", errContext) + } + if count <= int64(200_000) { + t.Fatalf("context count = %d, want > %d to exercise compacted replay exception", count, int64(200_000)) + } +} + +func TestClaudeThinkingTokenCountRequested(t *testing.T) { + if !claudeThinkingTokenCountRequested(http.Header{"Anthropic-Beta": []string{"thinking-token-count-2026-05-13"}}) { + t.Fatal("explicit thinking-token-count beta was not detected") + } + if !claudeThinkingTokenCountRequested(http.Header{ + "X-App": []string{"cli"}, + "X-Claude-Code-Session-Id": []string{"session-1"}, + }) { + t.Fatal("Claude workflow session was not enabled for thinking token progress") + } + if claudeThinkingTokenCountRequested(http.Header{"X-App": []string{"cli"}}) { + t.Fatal("generic CLI request without a Claude session ID enabled thinking token progress") + } +} + +func claudeResponsesBridgeOptions(requestBody []byte, stream bool) cliproxyexecutor.Options { + return cliproxyexecutor.Options{ + Alt: constant.ClaudeResponsesBridgeAlt, + SourceFormat: sdktranslator.FormatClaude, + ResponseFormat: sdktranslator.FormatClaude, + OriginalRequest: requestBody, + Stream: stream, + } +} + +const claudeThinkingTokenQuantumForTest = int64(64) + +func claudeSSEDataEvents(stream string) []gjson.Result { + var events []gjson.Result + for _, block := range strings.Split(stream, "\n\n") { + for _, line := range strings.Split(block, "\n") { + if data, ok := strings.CutPrefix(line, "data:"); ok { + events = append(events, gjson.Parse(strings.TrimSpace(data))) + break + } + } + } + return events +} + +func mustJSONMarshalExecutorTest(t *testing.T, value any) []byte { + t.Helper() + encoded, errMarshal := json.Marshal(value) + if errMarshal != nil { + t.Fatalf("marshal test value: %v", errMarshal) + } + return encoded +} + +func claudeBridgeCodexEvents(t *testing.T) [][]byte { + t.Helper() + reasoningCipher := base64.URLEncoding.EncodeToString(make([]byte, 1801)) + longOutput := strings.Repeat("visible output ", 100) + return [][]byte{ + []byte(`{"type":"response.created","response":{"id":"resp_bridge","model":"gpt-5.6-sol"}}`), + []byte(`{"type":"response.reasoning_summary_part.added"}`), + []byte(`{"type":"response.output_item.done","item":{"id":"rs_1","type":"reasoning","encrypted_content":` + string(mustJSONMarshalExecutorTest(t, reasoningCipher)) + `}}`), + []byte(`{"type":"response.output_text.delta","delta":` + string(mustJSONMarshalExecutorTest(t, longOutput)) + `}`), + []byte(`{"type":"response.completed","response":{"id":"resp_bridge","status":"completed","model":"gpt-5.6-sol","output":[],"usage":{"input_tokens":80000,"input_tokens_details":{"cache_write_tokens":1000,"cached_tokens":60000},"output_tokens":500,"output_tokens_details":{"reasoning_tokens":250},"total_tokens":80500}}}`), + } +} + +func assertClaudeBridgeUsageStream(t *testing.T, output string) { + t.Helper() + if !strings.Contains(output, "event: message_start") || !strings.Contains(output, "visible output") { + t.Fatalf("unexpected Claude stream: %s", output) + } + var inputTokens int64 + var usage []gjson.Result + var thinkingIncrements []int64 + for _, event := range claudeSSEDataEvents(output) { + switch event.Get("type").String() { + case "message_start": + inputTokens = event.Get("message.usage.input_tokens").Int() + case "message_delta": + if event.Get("usage").Exists() { + usage = append(usage, event.Get("usage")) + } + case "content_block_delta": + if event.Get("delta.type").String() == "thinking_delta" { + if estimated := event.Get("delta.estimated_tokens").Int(); estimated > 0 { + thinkingIncrements = append(thinkingIncrements, estimated) + } + } + } + } + if inputTokens <= 0 { + t.Fatalf("message_start input tokens = %d, want positive estimate; stream=%s", inputTokens, output) + } + if len(usage) < 2 { + t.Fatalf("message_delta usage events = %d, want live output and terminal; stream=%s", len(usage), output) + } + live := usage[len(usage)-2] + if outputTokens := live.Get("output_tokens").Int(); outputTokens <= 0 || outputTokens >= 500 || live.Get("output_tokens_details.thinking_tokens").Int() <= 0 || live.Get("input_tokens").Int() != 0 { + t.Fatalf("live output usage = %s, want output progress with nullable input", live.Raw) + } + terminal := usage[len(usage)-1] + if terminal.Get("input_tokens").Int() != 80000 || terminal.Get("cache_creation_input_tokens").Int() != 0 || terminal.Get("cache_read_input_tokens").Int() != 0 || terminal.Get("output_tokens").Int() != 500 || terminal.Get("output_tokens_details.thinking_tokens").Int() != 250 { + t.Fatalf("terminal usage = %s, want full Codex input without false Claude cache attribution", terminal.Raw) + } + if len(thinkingIncrements) == 0 || thinkingIncrements[0] <= 0 || thinkingIncrements[0]%claudeThinkingTokenQuantumForTest != 0 { + t.Fatalf("thinking token increments = %v, want positive quantized beta progress; stream=%s", thinkingIncrements, output) + } +} + +func TestCodexExecutorClaudeResponsesCompactBridgeUsesOAuthToken(t *testing.T) { + var gotPath string + var gotAuthorization string + var gotBody []byte + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuthorization = r.Header.Get("Authorization") + body, _ := io.ReadAll(r.Body) + gotBody = body + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"resp_compact","object":"response.compaction","output":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]},{"type":"compaction","encrypted_content":"encrypted"}],"usage":{"input_tokens":10,"output_tokens":2,"total_tokens":12}}`)) + })) + defer upstream.Close() + + executor := NewCodexExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Attributes: map[string]string{"base_url": upstream.URL}, + Metadata: map[string]any{"access_token": "oauth-token"}, + } + requestBody := []byte(`{"model":"gpt-5.6-sol","messages":[{"role":"user","content":"Your task is to create a detailed summary of the conversation so far."}]}`) + response, errExecute := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "gpt-5.6-sol", + Payload: requestBody, + }, cliproxyexecutor.Options{ + Alt: constant.ClaudeResponsesCompactBridgeAlt, + SourceFormat: sdktranslator.FormatClaude, + ResponseFormat: sdktranslator.FormatOpenAIResponse, + OriginalRequest: requestBody, + Headers: http.Header{"Authorization": []string{"Bearer local-proxy-token"}}, + }) + if errExecute != nil { + t.Fatalf("Execute compact error: %v", errExecute) + } + if gotPath != "/responses/compact" { + t.Fatalf("path = %q, want /responses/compact", gotPath) + } + if gotAuthorization != "Bearer oauth-token" { + t.Fatalf("Authorization = %q, want OAuth token", gotAuthorization) + } + if got := gjson.GetBytes(gotBody, "model").String(); got != "gpt-5.6-sol" { + t.Fatalf("compact upstream model = %q; body=%s", got, gotBody) + } + if got := gjson.GetBytes(gotBody, "input.0.content.0.text").String(); !strings.Contains(got, "detailed summary") { + t.Fatalf("compact upstream input = %q; body=%s", got, gotBody) + } + if got := gjson.GetBytes(response.Payload, "object").String(); got != "response.compaction" { + t.Fatalf("compact response object = %q; response=%s", got, response.Payload) + } +} + +func TestApplyClaudeResponsesCompactionReplayPrependsOpaqueItems(t *testing.T) { + source := []byte(`{"cpa_responses_compaction":{"output":[{"type":"message","role":"user","content":[{"type":"input_text","text":"old"}]},{"type":"compaction","encrypted_content":"encrypted"}]}}`) + translated := []byte(`{"model":"gpt-5.6-sol","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"new"}]}]}`) + got := applyClaudeResponsesCompactionReplay(translated, source, cliproxyexecutor.Options{Alt: constant.ClaudeResponsesBridgeAlt}) + if itemType := gjson.GetBytes(got, "input.1.type").String(); itemType != "compaction" { + t.Fatalf("input.1.type = %q, want compaction; body=%s", itemType, got) + } + if text := gjson.GetBytes(got, "input.2.content.0.text").String(); text != "new" { + t.Fatalf("new input text = %q, want new; body=%s", text, got) + } + if gjson.GetBytes(got, constant.ClaudeResponsesCompactionField).Exists() { + t.Fatalf("internal compaction field leaked upstream: %s", got) + } +} diff --git a/internal/runtime/executor/codex_executor_stream.go b/internal/runtime/executor/codex_executor_stream.go index 8d2c88d47ea..c3f307ad1ea 100644 --- a/internal/runtime/executor/codex_executor_stream.go +++ b/internal/runtime/executor/codex_executor_stream.go @@ -11,6 +11,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/client/grokbuild" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" @@ -23,12 +24,15 @@ import ( func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) { ctx = helps.EnsureSessionContext(ctx, opts, req.Payload) - if opts.Alt == "responses/compact" { + if opts.Alt == "responses/compact" || opts.Alt == constant.ClaudeResponsesCompactBridgeAlt { return nil, statusErr{code: http.StatusBadRequest, msg: "streaming not supported for /responses/compact"} } if isCodexOpenAIImageRequest(opts) { return e.executeOpenAIImageStream(ctx, auth, req, opts) } + if opts.Alt == constant.ClaudeResponsesBridgeAlt { + ctx = context.WithValue(ctx, constant.ClaudeBridgeUsageContextKey{}, true) + } baseModel := thinking.ParseSuffix(req.Model).ModelName apiKey, baseURL := codexCreds(auth) @@ -50,6 +54,8 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au } originalPayload := originalPayloadSource originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, true, helps.APIKeyModelIsCompat(req)) + originalTranslated = applyClaudeResponsesCompactionReplay(originalTranslated, originalPayload, opts) + body = applyClaudeResponsesCompactionReplay(body, req.Payload, opts) body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { @@ -81,6 +87,10 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au if errReplay != nil { return nil, errReplay } + estimatedClaudeInputTokens, errContext := validateClaudeBridgeContextWindow(baseModel, body, opts) + if errContext != nil { + return nil, errContext + } reporter.SetTranslatedReasoningEffort(body, to.String()) url := strings.TrimSuffix(baseURL, "/") + "/responses" @@ -137,6 +147,16 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au return nil, err } + var usageEstimator *helps.ClaudeStreamUsageEstimator + if opts.Alt == constant.ClaudeResponsesBridgeAlt { + var errEstimator error + usageEstimator, errEstimator = helps.NewClaudeStreamUsageEstimator(baseModel, estimatedClaudeInputTokens) + if errEstimator != nil { + log.WithError(errEstimator).WithField("model", baseModel).Warn("Claude Responses bridge live usage estimation is unavailable") + } + } + thinkingTokenEmitter := helps.NewClaudeThinkingTokenCountEmitter(claudeThinkingTokenCountRequested(opts.Headers)) + buffering := e.cfg != nil && e.cfg.Codex.StreamBootstrapBuffering var bootstrapTimeout time.Duration var bootstrapStart time.Time @@ -271,6 +291,9 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au translatedLine = applyCodexIdentityExposeResponsePayload(translatedLine, identityState) chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, originalPayload, body, translatedLine, ¶m, claudeInputTokens) + if bytes.HasPrefix(translatedLine, dataTag) { + chunks = helps.ClaudeBootstrapUsageChunks(usageEstimator, thinkingTokenEmitter, bytes.TrimSpace(translatedLine[5:]), chunks) + } if isHandshake && !terminalSuccess { frameBytes := len(line) for i := range chunks { @@ -354,43 +377,71 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au log.Errorf("codex executor: close response body error: %v", errClose) } }() - for scanner.Scan() { - line := applyCodexIdentityConfuseResponsePayload(scanner.Bytes(), identityState) - helps.AppendAPIResponseChunk(ctx, e.cfg, line) - translatedLine := bytes.Clone(line) - terminalSuccess := false - - if transformed, ok := grokbuild.TransformKeepaliveSSELine(translatedLine, isGrokClient); ok { - translatedLine = transformed - } else if bytes.HasPrefix(line, dataTag) { - data := bytes.TrimSpace(line[5:]) - data = helps.RestoreCodexMultiAgentV2Response(data, optimizeMultiAgentV2) - observeCodexTokenEvent(reporter, data) - translatedLine = append([]byte("data: "), data...) - eventType := gjson.GetBytes(data, "type").String() - if streamErr, terminalBody, ok := codexTerminalFailureErrWithCooling(data, e.modelLevelCooling()); ok { - if errClearReplay := clearCodexReasoningReplayOnInvalidSignature(ctx, replayScope, streamErr.StatusCode(), terminalBody); errClearReplay != nil { - helps.RecordAPIResponseError(ctx, e.cfg, errClearReplay) - reporter.PublishFailure(ctx, errClearReplay) + type scanResult struct { + line []byte + err error + done bool + } + scanResults := make(chan scanResult, 1) + scanStop := make(chan struct{}) + defer close(scanStop) + go func() { + for scanner.Scan() { + result := scanResult{line: bytes.Clone(scanner.Bytes())} + select { + case scanResults <- result: + case <-scanStop: + return + case <-ctx.Done(): + return + } + } + select { + case scanResults <- scanResult{err: scanner.Err(), done: true}: + case <-scanStop: + case <-ctx.Done(): + } + }() + var usageTicker *time.Ticker + var usageTicks <-chan time.Time + if usageEstimator != nil { + usageTicker = time.NewTicker(claudeLiveUsageTickInterval) + usageTicks = usageTicker.C + defer usageTicker.Stop() + } + for { + select { + case <-ctx.Done(): + return + case now := <-usageTicks: + if snapshot, emit := usageEstimator.ObserveTime(now); emit { + thinkingTokenUpdate := thinkingTokenEmitter.Event(snapshot) + if len(thinkingTokenUpdate) > 0 { select { - case out <- cliproxyexecutor.StreamChunk{Err: errClearReplay}: + case out <- cliproxyexecutor.StreamChunk{Payload: thinkingTokenUpdate}: case <-ctx.Done(): + return } - return } - helps.RecordAPIResponseError(ctx, e.cfg, streamErr) - reporter.PublishFailure(ctx, streamErr) - select { - case out <- cliproxyexecutor.StreamChunk{Err: streamErr}: - case <-ctx.Done(): + usageUpdate := helps.ClaudeCumulativeUsageEvent(snapshot) + if len(usageUpdate) > 0 { + select { + case out <- cliproxyexecutor.StreamChunk{Payload: usageUpdate}: + case <-ctx.Done(): + return + } } - return } - if helps.HasMeaningfulCodexOutputDelta(data) { - sawOutputDelta = true - } - if helps.IsCodexTerminalEmptyIncomplete(data, len(outputItemsByIndex)+len(outputItemsFallback), sawOutputDelta) { - streamErr := newCodexEmptyIncompleteStreamError() + continue + case result := <-scanResults: + if result.done { + if result.err != nil { + if ctx.Err() != nil { + return + } + helps.RecordAPIResponseError(ctx, e.cfg, result.err) + } + streamErr := newCodexIncompleteStreamError() helps.RecordAPIResponseError(ctx, e.cfg, streamErr) reporter.PublishFailure(ctx, streamErr) select { @@ -399,53 +450,116 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au } return } - switch eventType { - case "response.output_item.done": - collectCodexOutputItemDone(data, outputItemsByIndex, &outputItemsFallback) - case "response.completed", "response.incomplete", "response.done": - terminalSuccess = true + line := applyCodexIdentityConfuseResponsePayload(result.line, identityState) + helps.AppendAPIResponseChunk(ctx, e.cfg, line) + translatedLine := bytes.Clone(line) + terminalSuccess := false + var usageUpdate []byte + var usageSnapshot helps.ClaudeUsageSnapshot + usageSnapshotEmitted := false + + if transformed, ok := grokbuild.TransformKeepaliveSSELine(translatedLine, isGrokClient); ok { + translatedLine = transformed + } else if bytes.HasPrefix(line, dataTag) { + data := bytes.TrimSpace(line[5:]) + data = helps.RestoreCodexMultiAgentV2Response(data, optimizeMultiAgentV2) + observeCodexTokenEvent(reporter, data) data = normalizeCodexWebsocketCompletion(data) - if detail, ok := helps.ParseCodexUsage(data); ok { - reporter.Publish(ctx, detail) - } else { - reporter.EnsurePublished(ctx) + translatedLine = append([]byte("data: "), data...) + if usageEstimator != nil { + if snapshot, emit := usageEstimator.ObserveCodexEvent(data); emit { + usageSnapshot = snapshot + usageSnapshotEmitted = true + usageUpdate = helps.ClaudeCumulativeUsageEvent(snapshot) + } } - publishCodexImageToolUsage(ctx, reporter, body, data) - if !preserveNativeOutput { - data = patchCodexCompletedOutput(data, outputItemsByIndex, outputItemsFallback) + eventType := gjson.GetBytes(data, "type").String() + if streamErr, terminalBody, ok := codexTerminalFailureErrWithCooling(data, e.modelLevelCooling()); ok { + if errClearReplay := clearCodexReasoningReplayOnInvalidSignature(ctx, replayScope, streamErr.StatusCode(), terminalBody); errClearReplay != nil { + helps.RecordAPIResponseError(ctx, e.cfg, errClearReplay) + reporter.PublishFailure(ctx, errClearReplay) + select { + case out <- cliproxyexecutor.StreamChunk{Err: errClearReplay}: + case <-ctx.Done(): + } + return + } + helps.RecordAPIResponseError(ctx, e.cfg, streamErr) + reporter.PublishFailure(ctx, streamErr) + select { + case out <- cliproxyexecutor.StreamChunk{Err: streamErr}: + case <-ctx.Done(): + } + return } - if eventType == "response.completed" || eventType == "response.done" { - cacheCodexReasoningReplayFromCompleted(replayScope, data) + if helps.HasMeaningfulCodexOutputDelta(data) { + sawOutputDelta = true + } + if helps.IsCodexTerminalEmptyIncomplete(data, len(outputItemsByIndex)+len(outputItemsFallback), sawOutputDelta) { + streamErr := newCodexEmptyIncompleteStreamError() + helps.RecordAPIResponseError(ctx, e.cfg, streamErr) + reporter.PublishFailure(ctx, streamErr) + select { + case out <- cliproxyexecutor.StreamChunk{Err: streamErr}: + case <-ctx.Done(): + } + return + } + switch eventType { + case "response.output_item.done": + collectCodexOutputItemDone(data, outputItemsByIndex, &outputItemsFallback) + case "response.completed", "response.incomplete", "response.done": + terminalSuccess = true + if detail, ok := helps.ParseCodexUsage(data); ok { + reporter.Publish(ctx, detail) + } else { + reporter.EnsurePublished(ctx) + } + publishCodexImageToolUsage(ctx, reporter, body, data) + if !preserveNativeOutput { + data = patchCodexCompletedOutput(data, outputItemsByIndex, outputItemsFallback) + } + if eventType == "response.completed" || eventType == "response.done" { + cacheCodexReasoningReplayFromCompleted(replayScope, data) + } + translatedLine = append([]byte("data: "), data...) } - translatedLine = append([]byte("data: "), data...) } - } - translatedLine = applyCodexIdentityExposeResponsePayload(translatedLine, identityState) - chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, originalPayload, body, translatedLine, ¶m, claudeInputTokens) - for i := range chunks { - select { - case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}: - case <-ctx.Done(): + translatedLine = applyCodexIdentityExposeResponsePayload(translatedLine, identityState) + chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, originalPayload, body, translatedLine, ¶m, claudeInputTokens) + if usageSnapshotEmitted && usageSnapshot.OutputTokens == 0 && helps.ClaudeApplyMessageStartUsage(chunks, usageSnapshot) { + usageUpdate = nil + } + if usageSnapshotEmitted { + thinkingTokenUpdate := thinkingTokenEmitter.Event(usageSnapshot) + if len(thinkingTokenUpdate) > 0 { + select { + case out <- cliproxyexecutor.StreamChunk{Payload: thinkingTokenUpdate}: + case <-ctx.Done(): + return + } + } + } + for i := range chunks { + select { + case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}: + case <-ctx.Done(): + return + } + } + thinkingTokenEmitter.ObserveTranslatedChunks(chunks) + if len(usageUpdate) > 0 { + select { + case out <- cliproxyexecutor.StreamChunk{Payload: usageUpdate}: + case <-ctx.Done(): + return + } + } + if terminalSuccess { return } } - if terminalSuccess { - return - } - } - if errScan := scanner.Err(); errScan != nil { - if ctx.Err() != nil { - return - } - helps.RecordAPIResponseError(ctx, e.cfg, errScan) - } - streamErr := newCodexIncompleteStreamError() - helps.RecordAPIResponseError(ctx, e.cfg, streamErr) - reporter.PublishFailure(ctx, streamErr) - select { - case out <- cliproxyexecutor.StreamChunk{Err: streamErr}: - case <-ctx.Done(): } }() return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil diff --git a/internal/runtime/executor/codex_executor_tokens.go b/internal/runtime/executor/codex_executor_tokens.go index 43f165ad5cb..259c1638131 100644 --- a/internal/runtime/executor/codex_executor_tokens.go +++ b/internal/runtime/executor/codex_executor_tokens.go @@ -3,8 +3,11 @@ package executor import ( "context" "fmt" + "net/http" "strings" + "time" + "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" @@ -15,6 +18,29 @@ import ( "github.com/tiktoken-go/tokenizer" ) +const ( + claudeLiveUsageTickInterval = 2 * time.Second + claudeThinkingTokenCountBeta = "thinking-token-count-2026-05-13" +) + +func claudeThinkingTokenCountRequested(headers http.Header) bool { + if strings.Contains(strings.ToLower(headers.Get("Anthropic-Beta")), claudeThinkingTokenCountBeta) { + return true + } + // Claude App workflow workers currently omit the beta header while retaining + // the Claude CLI session headers and support for estimated_tokens deltas. + return strings.EqualFold(strings.TrimSpace(headers.Get("X-App")), "cli") && strings.TrimSpace(headers.Get("X-Claude-Code-Session-Id")) != "" +} + +func validateClaudeBridgeContextWindow(model string, body []byte, opts cliproxyexecutor.Options) (int64, error) { + if opts.Alt != constant.ClaudeResponsesBridgeAlt { + return 0, nil + } + // Claude decides when to compact using its configured window. Let the + // upstream enforce its actual context limit instead of imposing 200k here. + return estimateCodexInputTokens(model, body) +} + func (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { baseModel := thinking.ParseSuffix(req.Model).ModelName @@ -41,7 +67,6 @@ func (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth if err != nil { return cliproxyexecutor.Response{}, fmt.Errorf("codex executor: tokenizer init failed: %w", err) } - count, err := countCodexInputTokens(enc, body) if err != nil { return cliproxyexecutor.Response{}, fmt.Errorf("codex executor: token counting failed: %w", err) @@ -52,6 +77,23 @@ func (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth return cliproxyexecutor.Response{Payload: translated}, nil } +func estimateCodexInputTokens(model string, body []byte) (int64, error) { + enc, err := tokenizerForCodexModel(model) + if err != nil { + return 0, fmt.Errorf("tokenizer init failed: %w", err) + } + count, err := countCodexInputTokens(enc, body) + if err != nil { + return 0, fmt.Errorf("token counting failed: %w", err) + } + // Account conservatively for the Codex request framing and provider prompt that + // are included in terminal upstream usage but are not present in the JSON body. + if count > 0 { + count += 256 + } + return count, nil +} + func tokenizerForCodexModel(model string) (tokenizer.Codec, error) { sanitized := strings.ToLower(strings.TrimSpace(model)) switch { diff --git a/internal/runtime/executor/codex_websockets_connection.go b/internal/runtime/executor/codex_websockets_connection.go index 54b3695c3ff..0d5b404c21a 100644 --- a/internal/runtime/executor/codex_websockets_connection.go +++ b/internal/runtime/executor/codex_websockets_connection.go @@ -181,6 +181,67 @@ func readCodexWebsocketMessage(ctx context.Context, sess *codexWebsocketSession, } } +func startStandaloneCodexWebsocketReader(ctx context.Context, conn *websocket.Conn) chan codexWebsocketRead { + readCh := make(chan codexWebsocketRead, 64) + var ctxDone <-chan struct{} + if ctx != nil { + ctxDone = ctx.Done() + } + go func() { + defer close(readCh) + for { + _ = conn.SetReadDeadline(time.Now().Add(codexResponsesWebsocketIdleTimeout)) + msgType, payload, errRead := conn.ReadMessage() + event := codexWebsocketRead{conn: conn, msgType: msgType, payload: payload, err: errRead} + select { + case readCh <- event: + case <-ctxDone: + return + } + if errRead != nil { + return + } + } + }() + return readCh +} + +func readCodexWebsocketMessageOrTick(ctx context.Context, sess *codexWebsocketSession, conn *websocket.Conn, readCh chan codexWebsocketRead, usageTicks <-chan time.Time) (msgType int, payload []byte, tickAt time.Time, tick bool, err error) { + if usageTicks == nil { + msgType, payload, err = readCodexWebsocketMessage(ctx, sess, conn, readCh) + return msgType, payload, time.Time{}, false, err + } + if conn == nil { + return 0, nil, time.Time{}, false, fmt.Errorf("codex websockets executor: websocket conn is nil") + } + if readCh == nil { + return 0, nil, time.Time{}, false, fmt.Errorf("codex websockets executor: usage-aware read channel is nil") + } + var ctxDone <-chan struct{} + if ctx != nil { + ctxDone = ctx.Done() + } + for { + select { + case <-ctxDone: + return 0, nil, time.Time{}, false, ctx.Err() + case now := <-usageTicks: + return 0, nil, now, true, nil + case event, ok := <-readCh: + if !ok { + return 0, nil, time.Time{}, false, fmt.Errorf("codex websockets executor: read channel closed") + } + if event.conn != conn { + continue + } + if event.err != nil { + return 0, nil, time.Time{}, false, event.err + } + return event.msgType, event.payload, time.Time{}, false, nil + } + } +} + func newProxyAwareWebsocketDialer(cfg *config.Config, auth *cliproxyauth.Auth) *websocket.Dialer { dialer := &websocket.Dialer{ Proxy: http.ProxyFromEnvironment, diff --git a/internal/runtime/executor/codex_websockets_execute.go b/internal/runtime/executor/codex_websockets_execute.go index 70a214f2496..aef3d0eea18 100644 --- a/internal/runtime/executor/codex_websockets_execute.go +++ b/internal/runtime/executor/codex_websockets_execute.go @@ -9,6 +9,7 @@ import ( "github.com/gorilla/websocket" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" @@ -22,10 +23,13 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut if ctx == nil { ctx = context.Background() } - if opts.Alt == "responses/compact" { + if opts.Alt == "responses/compact" || opts.Alt == constant.ClaudeResponsesCompactBridgeAlt { return e.CodexExecutor.executeCompact(ctx, auth, req, opts) } + if opts.Alt == constant.ClaudeResponsesBridgeAlt { + ctx = context.WithValue(ctx, constant.ClaudeBridgeUsageContextKey{}, true) + } baseModel := thinking.ParseSuffix(req.Model).ModelName apiKey, baseURL := codexCreds(auth) if baseURL == "" { @@ -45,6 +49,8 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut } originalPayload := originalPayloadSource originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, false) + originalTranslated = applyClaudeResponsesCompactionReplay(originalTranslated, originalPayload, opts) + body = applyClaudeResponsesCompactionReplay(body, req.Payload, opts) body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { @@ -71,6 +77,9 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut if errReplay != nil { return resp, errReplay } + if _, errContext := validateClaudeBridgeContextWindow(baseModel, body, opts); errContext != nil { + return resp, errContext + } httpURL := strings.TrimSuffix(baseURL, "/") + "/responses" wsURL, err := buildCodexResponsesWebsocketURL(httpURL) diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go index 84c40698a2d..f1608794b38 100644 --- a/internal/runtime/executor/codex_websockets_executor.go +++ b/internal/runtime/executor/codex_websockets_executor.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" ) @@ -31,9 +32,9 @@ func NewCodexWebsocketsExecutor(cfg *config.Config) *CodexWebsocketsExecutor { } } -// CodexAutoExecutor routes Codex requests to the websocket transport only when: -// 1. The downstream transport is websocket, and -// 2. The selected auth enables websockets. +// CodexAutoExecutor routes Codex requests to the websocket transport when the +// selected auth enables websockets and either the Claude Responses bridge is +// streaming or the downstream transport is websocket. // // For non-websocket downstream requests, it always uses the legacy HTTP implementation. type CodexAutoExecutor struct { @@ -81,7 +82,7 @@ func (e *CodexAutoExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut if e == nil || e.httpExec == nil || e.wsExec == nil { return nil, fmt.Errorf("codex auto executor: executor is nil") } - if cliproxyexecutor.DownstreamWebsocket(ctx) && codexWebsocketsEnabled(auth) { + if codexWebsocketsEnabled(auth) && (opts.Alt == constant.ClaudeResponsesBridgeAlt || cliproxyexecutor.DownstreamWebsocket(ctx)) { return e.wsExec.ExecuteStream(ctx, auth, req, opts) } if cliproxyexecutor.RequiredUpstreamWebsocket(ctx) { diff --git a/internal/runtime/executor/codex_websockets_responses_bridge_test.go b/internal/runtime/executor/codex_websockets_responses_bridge_test.go new file mode 100644 index 00000000000..406361cc68d --- /dev/null +++ b/internal/runtime/executor/codex_websockets_responses_bridge_test.go @@ -0,0 +1,126 @@ +package executor + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + _ "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" + "github.com/tidwall/gjson" +) + +func TestCodexAutoExecutorClaudeResponsesBridgeStreamsOverWebsocket(t *testing.T) { + t.Run("unbuffered", func(t *testing.T) { testClaudeWebsocketBridgeUsage(t, false) }) + t.Run("buffered", func(t *testing.T) { testClaudeWebsocketBridgeUsage(t, true) }) +} + +func testClaudeWebsocketBridgeUsage(t *testing.T, buffering bool) { + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + capturedAuthorization := make(chan string, 1) + capturedPayload := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/responses" { + t.Errorf("request path = %q, want /responses", r.URL.Path) + } + capturedAuthorization <- r.Header.Get("Authorization") + conn, errUpgrade := upgrader.Upgrade(w, r, nil) + if errUpgrade != nil { + t.Errorf("upgrade websocket: %v", errUpgrade) + return + } + defer func() { _ = conn.Close() }() + + _, payload, errRead := conn.ReadMessage() + if errRead != nil { + t.Errorf("read websocket request: %v", errRead) + return + } + capturedPayload <- bytes.Clone(payload) + for _, event := range claudeBridgeCodexEvents(t) { + if errWrite := conn.WriteMessage(websocket.TextMessage, event); errWrite != nil { + t.Errorf("write websocket event: %v", errWrite) + return + } + } + })) + defer server.Close() + + exec := NewCodexAutoExecutor(&config.Config{Codex: config.CodexConfig{StreamBootstrapBuffering: buffering}, SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}}) + auth := &cliproxyauth.Auth{ + ID: "bridge-ws-auth", + Provider: constant.Codex, + Attributes: map[string]string{"base_url": server.URL, "websockets": "true"}, + Metadata: map[string]any{"access_token": "oauth-token"}, + } + requestBody := []byte(`{"model":"gpt-5.6-sol","stream":true,"max_tokens":64,"messages":[{"role":"user","content":"hello"}]}`) + opts := claudeResponsesBridgeOptions(requestBody, true) + opts.Headers = http.Header{"Authorization": []string{"Bearer local-proxy-token"}, "Anthropic-Beta": []string{"thinking-token-count-2026-05-13"}} + stream, errExecute := exec.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ + Model: "gpt-5.6-sol", + Payload: requestBody, + }, opts) + if errExecute != nil { + t.Fatalf("ExecuteStream error: %v", errExecute) + } + var output strings.Builder + for chunk := range stream.Chunks { + if chunk.Err != nil { + t.Fatalf("stream error: %v", chunk.Err) + } + output.Write(chunk.Payload) + } + + select { + case authorization := <-capturedAuthorization: + if authorization != "Bearer oauth-token" { + t.Fatalf("Authorization = %q, want OAuth token", authorization) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for websocket authorization") + } + select { + case payload := <-capturedPayload: + if got := gjson.GetBytes(payload, "input.0.content.0.text").String(); got != "hello" { + t.Fatalf("translated websocket input = %q, want hello; payload=%s", got, payload) + } + if gjson.GetBytes(payload, "context_management").Exists() { + t.Fatalf("normal websocket bridge injected context_management: %s", payload) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for websocket payload") + } + assertClaudeBridgeUsageStream(t, output.String()) +} + +func TestReadCodexWebsocketMessageOrTickReturnsUsageTick(t *testing.T) { + wantTick := time.Unix(123, 456) + usageTicks := make(chan time.Time, 1) + usageTicks <- wantTick + readCh := make(chan codexWebsocketRead) + + msgType, payload, tickAt, tick, errRead := readCodexWebsocketMessageOrTick( + context.Background(), + nil, + &websocket.Conn{}, + readCh, + usageTicks, + ) + if errRead != nil { + t.Fatalf("read with usage tick: %v", errRead) + } + if !tick || !tickAt.Equal(wantTick) { + t.Fatalf("tick = (%v, %v), want (true, %v)", tick, tickAt, wantTick) + } + if msgType != 0 || payload != nil { + t.Fatalf("message = (%d, %q), want zero and nil", msgType, payload) + } +} diff --git a/internal/runtime/executor/codex_websockets_stream.go b/internal/runtime/executor/codex_websockets_stream.go index ea9e7bd0b45..f9447037255 100644 --- a/internal/runtime/executor/codex_websockets_stream.go +++ b/internal/runtime/executor/codex_websockets_stream.go @@ -10,6 +10,7 @@ import ( "github.com/gorilla/websocket" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" @@ -24,10 +25,13 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr if ctx == nil { ctx = context.Background() } - if opts.Alt == "responses/compact" { + if opts.Alt == "responses/compact" || opts.Alt == constant.ClaudeResponsesCompactBridgeAlt { return nil, statusErr{code: http.StatusBadRequest, msg: "streaming not supported for /responses/compact"} } + if opts.Alt == constant.ClaudeResponsesBridgeAlt { + ctx = context.WithValue(ctx, constant.ClaudeBridgeUsageContextKey{}, true) + } baseModel := thinking.ParseSuffix(req.Model).ModelName apiKey, baseURL := codexCreds(auth) if baseURL == "" { @@ -47,6 +51,10 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr } originalPayload := originalPayloadSource originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, true) + if opts.Alt == constant.ClaudeResponsesBridgeAlt { + originalTranslated = applyClaudeResponsesCompactionReplay(originalTranslated, originalPayload, opts) + body = applyClaudeResponsesCompactionReplay(body, req.Payload, opts) + } body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { @@ -70,6 +78,10 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr if errReplay != nil { return nil, errReplay } + estimatedClaudeInputTokens, errContext := validateClaudeBridgeContextWindow(baseModel, body, opts) + if errContext != nil { + return nil, errContext + } httpURL := strings.TrimSuffix(baseURL, "/") + "/responses" wsURL, err := buildCodexResponsesWebsocketURL(httpURL) @@ -277,6 +289,16 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr sess.setMultiAgentV2Optimized(conn, optimizeMultiAgentV2 && !multiAgentV2Conflict) } + var usageEstimator *helps.ClaudeStreamUsageEstimator + if opts.Alt == constant.ClaudeResponsesBridgeAlt { + var errEstimator error + usageEstimator, errEstimator = helps.NewClaudeStreamUsageEstimator(baseModel, estimatedClaudeInputTokens) + if errEstimator != nil { + log.WithError(errEstimator).WithField("model", baseModel).Warn("Claude Responses bridge live usage estimation is unavailable") + } + } + thinkingTokenEmitter := helps.NewClaudeThinkingTokenCountEmitter(claudeThinkingTokenCountRequested(opts.Headers)) + buffering := e.cfg != nil && e.cfg.Codex.StreamBootstrapBuffering var bootstrapTimeout time.Duration var bootstrapStart time.Time @@ -515,6 +537,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr currentChunks = helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, originalPayload, clientBody, line, ¶m, claudeInputTokens) } + currentChunks = helps.ClaudeBootstrapUsageChunks(usageEstimator, thinkingTokenEmitter, payload, currentChunks) // !isTerminalEvent is redundant against the closed allow-list, which admits no terminal // type, and the empty-payload rule cannot fire on a payload already known non-empty. It // stays as the guard a reader expects to find, and its SSE counterpart is !terminalSuccess. @@ -610,6 +633,16 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr } } + var usageTicker *time.Ticker + var usageTicks <-chan time.Time + if usageEstimator != nil { + usageTicker = time.NewTicker(claudeLiveUsageTickInterval) + usageTicks = usageTicker.C + defer usageTicker.Stop() + if sess == nil { + readCh = startStandaloneCodexWebsocketReader(ctx, conn) + } + } for { if ctx != nil && ctx.Err() != nil { terminateReason = "context_done" @@ -617,7 +650,24 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr _ = send(cliproxyexecutor.StreamChunk{Err: ctx.Err()}) return } - msgType, payload, errRead := readCodexWebsocketMessage(ctx, sess, conn, readCh) + msgType, payload, tickAt, usageTick, errRead := readCodexWebsocketMessageOrTick(ctx, sess, conn, readCh, usageTicks) + if usageTick { + if snapshot, emit := usageEstimator.ObserveTime(tickAt); emit { + thinkingTokenUpdate := thinkingTokenEmitter.Event(snapshot) + if len(thinkingTokenUpdate) > 0 && !send(cliproxyexecutor.StreamChunk{Payload: thinkingTokenUpdate}) { + terminateReason = "context_done" + terminateErr = ctx.Err() + return + } + usageUpdate := helps.ClaudeCumulativeUsageEvent(snapshot) + if len(usageUpdate) > 0 && !send(cliproxyexecutor.StreamChunk{Payload: usageUpdate}) { + terminateReason = "context_done" + terminateErr = ctx.Err() + return + } + } + continue + } if errRead != nil { if sess != nil && ctx != nil && ctx.Err() != nil { terminateReason = "context_done" @@ -757,8 +807,29 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr } eventType = gjson.GetBytes(payload, "type").String() clientPayload = applyCodexIdentityExposeResponsePayload(payload, identityState) + var usageUpdate []byte + var usageSnapshot helps.ClaudeUsageSnapshot + usageSnapshotEmitted := false + if usageEstimator != nil { + if snapshot, emit := usageEstimator.ObserveCodexEvent(clientPayload); emit { + usageSnapshot = snapshot + usageSnapshotEmitted = true + usageUpdate = helps.ClaudeCumulativeUsageEvent(snapshot) + } + } line := encodeCodexWebsocketAsSSE(clientPayload) chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, originalPayload, clientBody, line, ¶m, claudeInputTokens) + if usageSnapshotEmitted && usageSnapshot.OutputTokens == 0 && helps.ClaudeApplyMessageStartUsage(chunks, usageSnapshot) { + usageUpdate = nil + } + if usageSnapshotEmitted { + thinkingTokenUpdate := thinkingTokenEmitter.Event(usageSnapshot) + if len(thinkingTokenUpdate) > 0 && !send(cliproxyexecutor.StreamChunk{Payload: thinkingTokenUpdate}) { + terminateReason = "context_done" + terminateErr = ctx.Err() + return + } + } for i := range chunks { if !send(cliproxyexecutor.StreamChunk{Payload: chunks[i]}) { terminateReason = "context_done" @@ -766,6 +837,12 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr return } } + thinkingTokenEmitter.ObserveTranslatedChunks(chunks) + if len(usageUpdate) > 0 && !send(cliproxyexecutor.StreamChunk{Payload: usageUpdate}) { + terminateReason = "context_done" + terminateErr = ctx.Err() + return + } if isTerminalEvent || eventType == "response.completed" || eventType == "response.done" || eventType == "response.incomplete" { return } diff --git a/internal/runtime/executor/helps/claude_stream_usage.go b/internal/runtime/executor/helps/claude_stream_usage.go new file mode 100644 index 00000000000..99c4f83da4a --- /dev/null +++ b/internal/runtime/executor/helps/claude_stream_usage.go @@ -0,0 +1,436 @@ +package helps + +import ( + "bytes" + "encoding/base64" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "github.com/tiktoken-go/tokenizer" +) + +const ( + claudeUsageEmitStep = int64(64) + claudeThinkingTokenQuantum = int64(64) + claudeUsageTailTarget = 256 + claudeUsageTailLimit = 512 + // Local Codex traces produced roughly 31-55 reasoning tokens per second once + // generation started. A warmup plus 24 tokens per second keeps the in-flight + // estimate conservative while still advancing during an otherwise silent item. + claudeReasoningEstimateWarmup = 5 * time.Second + claudeReasoningTokensPerSecond = int64(24) +) + +// ClaudeUsageSnapshot is a cumulative Claude Messages usage update. +type ClaudeUsageSnapshot struct { + InputTokens int64 + OutputTokens int64 + ThinkingTokens int64 +} + +// ClaudeThinkingTokenCountEmitter produces the per-block cumulative +// estimated_tokens values used by Anthropic's thinking-token-count streaming beta. +type ClaudeThinkingTokenCountEmitter struct { + enabled bool + thinkingBlockOpen bool + thinkingBlockIndex int64 + thinkingBlockBase int64 + emittedThinkingTokens int64 +} + +type claudeRollingTokenEstimator struct { + encoder tokenizer.Codec + tail string + finalized int64 + estimated int64 +} + +type ClaudeStreamUsageEstimator struct { + model string + visible claudeRollingTokenEstimator + reasoningSummary claudeRollingTokenEstimator + reasoningCipherByItem map[string]int64 + inputTokens int64 + estimatedOutputTokens int64 + estimatedThinkingTokens int64 + lastEmitted ClaudeUsageSnapshot + started bool + completed bool + startedAt time.Time + reasoningEndedAt time.Time +} + +func NewClaudeStreamUsageEstimator(model string, inputTokens ...int64) (*ClaudeStreamUsageEstimator, error) { + encoder, err := TokenizerForModel(model) + if err != nil { + return nil, fmt.Errorf("create Claude stream usage tokenizer: %w", err) + } + estimatedInput := int64(0) + if len(inputTokens) > 0 && inputTokens[0] > 0 { + estimatedInput = inputTokens[0] + } + return &ClaudeStreamUsageEstimator{ + model: model, + visible: claudeRollingTokenEstimator{encoder: encoder}, + reasoningSummary: claudeRollingTokenEstimator{encoder: encoder}, + reasoningCipherByItem: make(map[string]int64), + inputTokens: estimatedInput, + }, nil +} + +func NewClaudeThinkingTokenCountEmitter(enabled bool) *ClaudeThinkingTokenCountEmitter { + return &ClaudeThinkingTokenCountEmitter{enabled: enabled} +} + +// ClaudeBootstrapUsageChunks preserves live usage state while the executor holds +// initial events for overload detection. The same state continues after release. +func ClaudeBootstrapUsageChunks(estimator *ClaudeStreamUsageEstimator, emitter *ClaudeThinkingTokenCountEmitter, data []byte, chunks [][]byte) [][]byte { + if estimator == nil { + return chunks + } + if snapshot, emit := estimator.ObserveCodexEvent(data); emit { + if update := emitter.Event(snapshot); len(update) > 0 { + chunks = append([][]byte{update}, chunks...) + } + if !ClaudeApplyMessageStartUsage(chunks, snapshot) { + if update := ClaudeCumulativeUsageEvent(snapshot); len(update) > 0 { + chunks = append(chunks, update) + } + } + } + emitter.ObserveTranslatedChunks(chunks) + return chunks +} + +func (e *ClaudeThinkingTokenCountEmitter) ObserveTranslatedChunks(chunks [][]byte) { + if e == nil || !e.enabled { + return + } + for _, chunk := range chunks { + for _, line := range strings.Split(string(chunk), "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "data:") { + continue + } + event := gjson.Parse(strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + switch event.Get("type").String() { + case "content_block_start": + if event.Get("content_block.type").String() == "thinking" { + e.thinkingBlockOpen = true + e.thinkingBlockIndex = event.Get("index").Int() + e.thinkingBlockBase = e.emittedThinkingTokens + } + case "content_block_stop": + if e.thinkingBlockOpen && event.Get("index").Int() == e.thinkingBlockIndex { + e.thinkingBlockOpen = false + } + } + } + } +} + +func (e *ClaudeThinkingTokenCountEmitter) Event(snapshot ClaudeUsageSnapshot) []byte { + if e == nil || !e.enabled || !e.thinkingBlockOpen { + return nil + } + available := snapshot.ThinkingTokens - e.emittedThinkingTokens + increment := available / claudeThinkingTokenQuantum * claudeThinkingTokenQuantum + if increment <= 0 { + return nil + } + e.emittedThinkingTokens += increment + blockEstimate := e.emittedThinkingTokens - e.thinkingBlockBase + return []byte(fmt.Sprintf("event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":%d,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\",\"estimated_tokens\":%d}}\n\n", e.thinkingBlockIndex, blockEstimate)) +} + +func ClaudeApplyMessageStartUsage(chunks [][]byte, snapshot ClaudeUsageSnapshot) bool { + if snapshot.InputTokens <= 0 { + return false + } + patched := false + for i := range chunks { + chunk := chunks[i] + searchFrom := 0 + for searchFrom < len(chunk) { + markerOffset := bytes.Index(chunk[searchFrom:], []byte("data:")) + if markerOffset < 0 { + break + } + lineStart := searchFrom + markerOffset + len("data:") + lineEnd := len(chunk) + if newlineOffset := bytes.IndexByte(chunk[lineStart:], '\n'); newlineOffset >= 0 { + lineEnd = lineStart + newlineOffset + } + dataStart := lineStart + for dataStart < lineEnd && (chunk[dataStart] == ' ' || chunk[dataStart] == '\t') { + dataStart++ + } + dataEnd := lineEnd + for dataEnd > dataStart && (chunk[dataEnd-1] == ' ' || chunk[dataEnd-1] == '\t' || chunk[dataEnd-1] == '\r') { + dataEnd-- + } + data := chunk[dataStart:dataEnd] + if gjson.GetBytes(data, "type").String() != "message_start" { + searchFrom = lineEnd + 1 + continue + } + updated, errSet := sjson.SetBytes(bytes.Clone(data), "message.usage.input_tokens", snapshot.InputTokens) + if errSet != nil { + break + } + patchedChunk := make([]byte, 0, len(chunk)+len(updated)-len(data)) + patchedChunk = append(patchedChunk, chunk[:dataStart]...) + patchedChunk = append(patchedChunk, updated...) + patchedChunk = append(patchedChunk, chunk[dataEnd:]...) + chunks[i] = patchedChunk + patched = true + break + } + } + return patched +} + +func (e *ClaudeStreamUsageEstimator) ObserveCodexEvent(payload []byte) (ClaudeUsageSnapshot, bool) { + return e.observeCodexEventAt(payload, time.Now()) +} + +func (e *ClaudeStreamUsageEstimator) observeCodexEventAt(payload []byte, now time.Time) (ClaudeUsageSnapshot, bool) { + if e == nil || e.visible.encoder == nil || len(payload) == 0 { + return ClaudeUsageSnapshot{}, false + } + event := gjson.ParseBytes(payload) + eventType := event.Get("type").String() + if eventType == "response.created" { + e.started = true + e.completed = false + e.startedAt = now + e.reasoningEndedAt = time.Time{} + if model := strings.TrimSpace(event.Get("response.model").String()); model != "" { + e.model = model + } + snapshot := e.snapshot() + if snapshot.InputTokens > 0 { + e.lastEmitted = snapshot + return snapshot, true + } + return snapshot, false + } + if !e.started { + return ClaudeUsageSnapshot{}, false + } + if eventType == "response.completed" || eventType == "response.incomplete" { + e.completed = true + return e.snapshot(), false + } + + switch eventType { + case "response.output_text.delta", "response.function_call_arguments.delta": + e.markReasoningEnded(now) + e.visible.append(event.Get("delta").String()) + case "response.reasoning_summary_text.delta": + e.reasoningSummary.append(event.Get("delta").String()) + case "response.output_item.added", "response.output_item.done": + e.observeReasoningCipher(event) + if eventType == "response.output_item.done" && event.Get("item.type").String() == "reasoning" { + e.markReasoningEnded(now) + } + } + + e.updateEstimate(now) + snapshot := e.snapshot() + force := false + switch eventType { + case "response.content_part.done", "response.reasoning_summary_part.done", "response.function_call_arguments.done", "response.output_item.done": + force = true + } + if snapshot == e.lastEmitted { + return snapshot, false + } + if !force && snapshot.OutputTokens-e.lastEmitted.OutputTokens < claudeUsageEmitStep { + return snapshot, false + } + e.lastEmitted = snapshot + return snapshot, true +} + +// ObserveTime advances the conservative live reasoning estimate while Codex is +// generating a long reasoning item without emitting content deltas. Exact usage +// from response.completed remains authoritative. +func (e *ClaudeStreamUsageEstimator) ObserveTime(now time.Time) (ClaudeUsageSnapshot, bool) { + if e == nil || !e.started || e.completed { + return ClaudeUsageSnapshot{}, false + } + e.updateEstimate(now) + snapshot := e.snapshot() + if snapshot == e.lastEmitted || snapshot.OutputTokens-e.lastEmitted.OutputTokens < claudeUsageEmitStep { + return snapshot, false + } + e.lastEmitted = snapshot + return snapshot, true +} + +func (e *ClaudeStreamUsageEstimator) observeReasoningCipher(event gjson.Result) { + item := event.Get("item") + if item.Get("type").String() != "reasoning" { + return + } + encrypted := item.Get("encrypted_content").String() + if encrypted == "" { + return + } + decoded, errDecode := base64.URLEncoding.DecodeString(encrypted) + if errDecode != nil { + decoded, errDecode = base64.RawURLEncoding.DecodeString(strings.TrimRight(encrypted, "=")) + } + if errDecode != nil || len(decoded) == 0 { + return + } + itemID := item.Get("id").String() + if itemID == "" { + itemID = event.Get("output_index").String() + } + if itemID == "" { + return + } + decodedLength := int64(len(decoded)) + if decodedLength > e.reasoningCipherByItem[itemID] { + e.reasoningCipherByItem[itemID] = decodedLength + } +} + +func (e *ClaudeStreamUsageEstimator) updateEstimate(now time.Time) { + cipherEstimate := estimateClaudeReasoningTokensFromCipher(e.model, e.reasoningCipherByItem) + thinkingEstimate := e.reasoningSummary.estimated + if cipherEstimate > thinkingEstimate { + thinkingEstimate = cipherEstimate + } + if elapsedEstimate := e.estimateReasoningTokensFromElapsed(now); elapsedEstimate > thinkingEstimate { + thinkingEstimate = elapsedEstimate + } + if thinkingEstimate > e.estimatedThinkingTokens { + e.estimatedThinkingTokens = thinkingEstimate + } + outputEstimate := e.visible.estimated + e.estimatedThinkingTokens + if outputEstimate > e.estimatedOutputTokens { + e.estimatedOutputTokens = outputEstimate + } +} + +func (e *ClaudeStreamUsageEstimator) markReasoningEnded(now time.Time) { + if e == nil || !e.reasoningEndedAt.IsZero() { + return + } + e.reasoningEndedAt = now +} + +func (e *ClaudeStreamUsageEstimator) estimateReasoningTokensFromElapsed(now time.Time) int64 { + if e == nil || e.startedAt.IsZero() { + return 0 + } + end := now + if !e.reasoningEndedAt.IsZero() && e.reasoningEndedAt.Before(end) { + end = e.reasoningEndedAt + } + elapsed := end.Sub(e.startedAt) - claudeReasoningEstimateWarmup + if elapsed <= 0 { + return 0 + } + return int64(elapsed) * claudeReasoningTokensPerSecond / int64(time.Second) +} + +func (e *ClaudeStreamUsageEstimator) snapshot() ClaudeUsageSnapshot { + return ClaudeUsageSnapshot{ + InputTokens: e.inputTokens, + OutputTokens: e.estimatedOutputTokens, + ThinkingTokens: e.estimatedThinkingTokens, + } +} + +func (e *claudeRollingTokenEstimator) append(delta string) { + if e == nil || e.encoder == nil || delta == "" { + return + } + combined := e.tail + delta + if len(combined) > claudeUsageTailLimit { + cut := len(combined) - claudeUsageTailTarget + for cut < len(combined) && !utf8.RuneStart(combined[cut]) { + cut++ + } + newTail := combined[cut:] + combinedTokens, errCombined := e.encoder.Count(combined) + tailTokens, errTail := e.encoder.Count(newTail) + if errCombined == nil && errTail == nil { + settled := int64(combinedTokens - tailTokens) + if settled > 0 { + e.finalized += settled + } + e.tail = newTail + } else { + e.tail = combined + } + } else { + e.tail = combined + } + tailTokens, errTail := e.encoder.Count(e.tail) + if errTail != nil { + return + } + estimate := e.finalized + int64(tailTokens) + if estimate > e.estimated { + e.estimated = estimate + } +} + +func estimateClaudeReasoningTokensFromCipher(model string, cipherByItem map[string]int64) int64 { + if len(cipherByItem) == 0 { + return 0 + } + // Codex exposes exact reasoning_tokens only in the terminal usage object. During + // generation, the opaque encrypted_content length provides a conservative progress + // signal at each reasoning-item boundary. The terminal Claude usage event remains + // authoritative and replaces this estimate when the response completes. + isSol := strings.Contains(strings.ToLower(strings.TrimSpace(model)), "sol") + estimate := int64(0) + for _, decodedLength := range cipherByItem { + itemEstimate := int64(0) + if decodedLength < 850 { + itemEstimate = (decodedLength - 625) / 8 + } else { + itemEstimate = decodedLength*5/12 - 243 - 48 + } + if itemEstimate < 0 { + itemEstimate = 0 + } + if isSol { + // Sol reasoning capsules are roughly twice the size per token observed for + // Luna and Terra, so keep its live estimate on the conservative side. + itemEstimate = itemEstimate * 9 / 20 + } else if decodedLength >= 850 { + itemEstimate = itemEstimate * 9 / 10 + } + estimate += itemEstimate + } + return estimate +} + +func ClaudeCumulativeUsageEvent(snapshot ClaudeUsageSnapshot) []byte { + if snapshot.InputTokens <= 0 && snapshot.OutputTokens <= 0 { + return nil + } + return []byte(fmt.Sprintf("event: message_delta\ndata: {\"type\":\"message_delta\",\"context_management\":null,\"delta\":{\"container\":null,\"stop_details\":null,\"stop_reason\":null,\"stop_sequence\":null},\"usage\":{\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"input_tokens\":null,\"iterations\":null,\"output_tokens\":%d,\"output_tokens_details\":{\"thinking_tokens\":%d},\"server_tool_use\":{\"web_fetch_requests\":0,\"web_search_requests\":0}}}\n\n", snapshot.OutputTokens, snapshot.ThinkingTokens)) +} + +func claudeSSEEventData(payload []byte) []byte { + trimmed := strings.TrimSpace(string(payload)) + for _, line := range strings.Split(trimmed, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "data:") { + return []byte(strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + } + } + return payload +} diff --git a/internal/runtime/executor/helps/claude_stream_usage_test.go b/internal/runtime/executor/helps/claude_stream_usage_test.go new file mode 100644 index 00000000000..200ec025cbc --- /dev/null +++ b/internal/runtime/executor/helps/claude_stream_usage_test.go @@ -0,0 +1,268 @@ +package helps + +import ( + "encoding/base64" + "fmt" + "strings" + "testing" + "time" + + "github.com/tidwall/gjson" +) + +func newClaudeStreamUsageEstimatorForTest(t *testing.T, model string, inputTokens ...int64) *ClaudeStreamUsageEstimator { + t.Helper() + estimator, err := NewClaudeStreamUsageEstimator(model, inputTokens...) + if err != nil { + t.Fatalf("new estimator: %v", err) + } + return estimator +} + +func TestClaudeStreamUsageEstimatorEmitsInputAtResponseCreated(t *testing.T) { + estimator := newClaudeStreamUsageEstimatorForTest(t, "gpt-5.6-luna", 20_053) + snapshot, emit := estimator.ObserveCodexEvent([]byte(`{"type":"response.created","response":{"model":"gpt-5.6-luna"}}`)) + if !emit { + t.Fatal("response.created did not emit input usage") + } + if snapshot.InputTokens != 20_053 || snapshot.OutputTokens != 0 || snapshot.ThinkingTokens != 0 { + t.Fatalf("response.created usage = %+v", snapshot) + } +} + +func TestClaudeStreamUsageEstimatorEmitsCumulativeProgress(t *testing.T) { + estimator := newClaudeStreamUsageEstimatorForTest(t, "gpt-5.6-sol", 1234) + estimator.ObserveCodexEvent([]byte(`{"type":"response.created"}`)) + last := int64(0) + emissions := 0 + for i := 0; i < 20; i++ { + payload := []byte(fmt.Sprintf(`{"type":"response.output_text.delta","delta":%q}`, strings.Repeat("visible output ", 32))) + snapshot, emit := estimator.ObserveCodexEvent(payload) + if !emit { + continue + } + if snapshot.OutputTokens <= last { + t.Fatalf("usage estimate did not increase: previous=%d current=%d", last, snapshot.OutputTokens) + } + if snapshot.InputTokens != 1234 { + t.Fatalf("input tokens = %d, want 1234", snapshot.InputTokens) + } + last = snapshot.OutputTokens + emissions++ + } + if emissions == 0 { + t.Fatal("expected at least one cumulative usage emission") + } +} + +func TestClaudeStreamUsageEstimatorWaitsForResponseCreated(t *testing.T) { + estimator := newClaudeStreamUsageEstimatorForTest(t, "gpt-5.6-sol", 1234) + payload := []byte(fmt.Sprintf(`{"type":"response.output_text.delta","delta":%q}`, strings.Repeat("visible output ", 100))) + if snapshot, emit := estimator.ObserveCodexEvent(payload); emit || snapshot != (ClaudeUsageSnapshot{}) { + t.Fatalf("pre-start usage = (%+v, %v), want zero and false", snapshot, emit) + } +} + +func TestClaudeStreamUsageEstimatorEmitsDuringSilentReasoning(t *testing.T) { + estimator := newClaudeStreamUsageEstimatorForTest(t, "gpt-5.6-luna", 20_053) + startedAt := time.Unix(100, 0) + estimator.observeCodexEventAt([]byte(`{"type":"response.created"}`), startedAt) + if snapshot, emit := estimator.ObserveTime(startedAt.Add(claudeReasoningEstimateWarmup)); emit || snapshot.OutputTokens != 0 { + t.Fatalf("warmup usage = (%+v, %v), want zero and false", snapshot, emit) + } + snapshot, emit := estimator.ObserveTime(startedAt.Add(10 * time.Second)) + if !emit { + t.Fatal("silent reasoning did not emit live usage") + } + wantThinking := int64(5) * claudeReasoningTokensPerSecond + if snapshot.InputTokens != 20_053 || snapshot.OutputTokens != wantThinking || snapshot.ThinkingTokens != wantThinking { + t.Fatalf("silent reasoning usage = %+v, want input=20053 output=thinking=%d", snapshot, wantThinking) + } +} + +func TestClaudeStreamUsageEstimatorStopsElapsedReasoningAtVisibleOutput(t *testing.T) { + estimator := newClaudeStreamUsageEstimatorForTest(t, "gpt-5.6-luna") + startedAt := time.Unix(100, 0) + estimator.observeCodexEventAt([]byte(`{"type":"response.created"}`), startedAt) + estimator.observeCodexEventAt([]byte(`{"type":"response.output_text.delta","delta":"done"}`), startedAt.Add(10*time.Second)) + before, _ := estimator.ObserveTime(startedAt.Add(11 * time.Second)) + after, emit := estimator.ObserveTime(startedAt.Add(60 * time.Second)) + if emit { + t.Fatalf("elapsed estimate continued after visible output: before=%+v after=%+v", before, after) + } + if after.ThinkingTokens != before.ThinkingTokens { + t.Fatalf("thinking tokens changed after reasoning ended: before=%d after=%d", before.ThinkingTokens, after.ThinkingTokens) + } +} + +func TestClaudeStreamUsageEstimatorStopsAfterCompletion(t *testing.T) { + estimator := newClaudeStreamUsageEstimatorForTest(t, "gpt-5.6-luna") + startedAt := time.Unix(100, 0) + estimator.observeCodexEventAt([]byte(`{"type":"response.created"}`), startedAt) + estimator.observeCodexEventAt([]byte(`{"type":"response.completed"}`), startedAt.Add(10*time.Second)) + if _, emit := estimator.ObserveTime(startedAt.Add(60 * time.Second)); emit { + t.Fatal("completed estimator emitted additional usage") + } +} + +func TestClaudeStreamUsageEstimatorCoversTranslatedDeltaTypes(t *testing.T) { + tests := []struct { + name string + eventType string + wantThinking bool + }{ + {name: "output text", eventType: "response.output_text.delta"}, + {name: "reasoning summary", eventType: "response.reasoning_summary_text.delta", wantThinking: true}, + {name: "function arguments", eventType: "response.function_call_arguments.delta"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + estimator := newClaudeStreamUsageEstimatorForTest(t, "gpt-5.6-sol") + estimator.ObserveCodexEvent([]byte(`{"type":"response.created"}`)) + payload := []byte(fmt.Sprintf(`{"type":%q,"delta":%q}`, tt.eventType, strings.Repeat("visible output ", 100))) + snapshot, emit := estimator.ObserveCodexEvent(payload) + if !emit || snapshot.OutputTokens <= 0 { + t.Fatalf("usage = (%+v, %v), want positive emission", snapshot, emit) + } + if tt.wantThinking != (snapshot.ThinkingTokens > 0) { + t.Fatalf("thinking tokens = %d, wantThinking=%v", snapshot.ThinkingTokens, tt.wantThinking) + } + }) + } +} + +func TestClaudeStreamUsageEstimatorUsesEncryptedReasoningSize(t *testing.T) { + tests := []struct { + model string + min int64 + max int64 + }{ + {model: "gpt-5.6-luna", min: 380, max: 440}, + {model: "gpt-5.6-terra", min: 380, max: 440}, + {model: "gpt-5.6-sol", min: 180, max: 220}, + } + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + estimator := newClaudeStreamUsageEstimatorForTest(t, tt.model) + estimator.ObserveCodexEvent([]byte(fmt.Sprintf(`{"type":"response.created","response":{"model":%q}}`, tt.model))) + cipher := base64.URLEncoding.EncodeToString(make([]byte, 1801)) + payload := []byte(fmt.Sprintf(`{"type":"response.output_item.done","item":{"id":"rs_1","type":"reasoning","encrypted_content":%q}}`, cipher)) + snapshot, emit := estimator.ObserveCodexEvent(payload) + if !emit { + t.Fatal("reasoning item did not emit usage") + } + if snapshot.ThinkingTokens < tt.min || snapshot.ThinkingTokens > tt.max { + t.Fatalf("thinking estimate = %d, want [%d,%d]", snapshot.ThinkingTokens, tt.min, tt.max) + } + if snapshot.OutputTokens != snapshot.ThinkingTokens { + t.Fatalf("output tokens = %d, thinking tokens = %d", snapshot.OutputTokens, snapshot.ThinkingTokens) + } + }) + } +} + +func TestClaudeCumulativeUsageEvent(t *testing.T) { + event := ClaudeCumulativeUsageEvent(ClaudeUsageSnapshot{InputTokens: 20_053, OutputTokens: 1_535, ThinkingTokens: 1_115}) + data := claudeSSEEventData(event) + if got := gjson.GetBytes(data, "type").String(); got != "message_delta" { + t.Fatalf("event type = %q, want message_delta; event=%s", got, event) + } + if !gjson.GetBytes(data, "delta").Exists() || !gjson.GetBytes(data, "delta").IsObject() { + t.Fatalf("message delta is missing a delta object; event=%s", event) + } + for _, path := range []string{ + "context_management", + "delta.container", + "delta.stop_details", + "delta.stop_reason", + "delta.stop_sequence", + "usage.iterations", + } { + if got := gjson.GetBytes(data, path).Raw; got != "null" { + t.Fatalf("%s = %q, want null; event=%s", path, got, event) + } + } + if got := gjson.GetBytes(data, "usage.input_tokens").Raw; got != "null" { + t.Fatalf("input tokens = %q, want null because message_start owns input usage; event=%s", got, event) + } + if got := gjson.GetBytes(data, "usage.output_tokens").Int(); got != 1_535 { + t.Fatalf("output tokens = %d, want 1535; event=%s", got, event) + } + if got := gjson.GetBytes(data, "usage.output_tokens_details.thinking_tokens").Int(); got != 1_115 { + t.Fatalf("thinking tokens = %d, want 1115; event=%s", got, event) + } + for _, path := range []string{ + "usage.cache_creation_input_tokens", + "usage.cache_read_input_tokens", + "usage.server_tool_use.web_fetch_requests", + "usage.server_tool_use.web_search_requests", + } { + if !gjson.GetBytes(data, path).Exists() { + t.Fatalf("%s is missing; event=%s", path, event) + } + } +} + +func TestClaudeApplyMessageStartUsagePreservesFollowingEvents(t *testing.T) { + thinkingStart := "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n" + chunks := [][]byte{[]byte("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}}\n\n" + thinkingStart)} + + if !ClaudeApplyMessageStartUsage(chunks, ClaudeUsageSnapshot{InputTokens: 20_053}) { + t.Fatal("expected message_start usage patch") + } + data := claudeSSEEventData(chunks[0]) + if got := gjson.GetBytes(data, "message.usage.input_tokens").Int(); got != 20_053 { + t.Fatalf("message_start input tokens = %d, want 20053; chunk=%s", got, chunks[0]) + } + if !strings.Contains(string(chunks[0]), thinkingStart) { + t.Fatalf("message_start patch discarded following thinking event: %s", chunks[0]) + } +} + +func TestClaudeThinkingTokenCountEmitter(t *testing.T) { + emitter := NewClaudeThinkingTokenCountEmitter(true) + emitter.ObserveTranslatedChunks([][]byte{[]byte("event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":3,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n")}) + + event := emitter.Event(ClaudeUsageSnapshot{ThinkingTokens: 130}) + data := claudeSSEEventData(event) + if got := gjson.GetBytes(data, "type").String(); got != "content_block_delta" { + t.Fatalf("event type = %q, want content_block_delta; event=%s", got, event) + } + if got := gjson.GetBytes(data, "index").Int(); got != 3 { + t.Fatalf("block index = %d, want 3; event=%s", got, event) + } + if got := gjson.GetBytes(data, "delta.type").String(); got != "thinking_delta" { + t.Fatalf("delta type = %q, want thinking_delta; event=%s", got, event) + } + if got := gjson.GetBytes(data, "delta.estimated_tokens").Int(); got != 128 { + t.Fatalf("estimated token total = %d, want 128; event=%s", got, event) + } + if got := gjson.GetBytes(data, "delta.thinking").String(); got != "" { + t.Fatalf("thinking = %q, want empty", got) + } + + if event = emitter.Event(ClaudeUsageSnapshot{ThinkingTokens: 190}); len(event) != 0 { + t.Fatalf("sub-quantum progress emitted an event: %s", event) + } + if event = emitter.Event(ClaudeUsageSnapshot{ThinkingTokens: 260}); gjson.GetBytes(claudeSSEEventData(event), "delta.estimated_tokens").Int() != 256 { + t.Fatalf("second estimated token event = %s, want cumulative 256", event) + } + + emitter.ObserveTranslatedChunks([][]byte{[]byte("event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":3}\n\n")}) + if event = emitter.Event(ClaudeUsageSnapshot{ThinkingTokens: 512}); len(event) != 0 { + t.Fatalf("closed thinking block emitted an event: %s", event) + } + + emitter.ObserveTranslatedChunks([][]byte{[]byte("event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":4,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n")}) + if event = emitter.Event(ClaudeUsageSnapshot{ThinkingTokens: 390}); gjson.GetBytes(claudeSSEEventData(event), "delta.estimated_tokens").Int() != 128 { + t.Fatalf("new block estimated token event = %s, want block-local cumulative 128", event) + } + + t.Run("disabled", func(t *testing.T) { + disabled := NewClaudeThinkingTokenCountEmitter(false) + disabled.ObserveTranslatedChunks([][]byte{[]byte("event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\"}}\n\n")}) + if event := disabled.Event(ClaudeUsageSnapshot{ThinkingTokens: 128}); len(event) != 0 { + t.Fatalf("disabled emitter produced an event: %s", event) + } + }) +} diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index 59d6f7482f8..d95b1b48995 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -11,6 +11,7 @@ import ( "context" "strings" + "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" @@ -73,7 +74,10 @@ type codexFunctionCallStream struct { // // Returns: // - [][]byte: A slice of Claude Code-compatible JSON responses -func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRawJSON, _ []byte, rawJSON []byte, param *any) [][]byte { +func ConvertCodexResponseToClaude(ctx context.Context, _ string, originalRequestRawJSON, _ []byte, rawJSON []byte, param *any) [][]byte { + if ctx == nil { + ctx = context.Background() + } if *param == nil { *param = &ConvertCodexResponseToClaudeParams{ BlockIndex: 0, @@ -102,11 +106,17 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa case "error": output = append(output, codexStreamErrorToClaudeError(rootResult)...) case "response.created": - template = []byte(`{"type":"message_start","message":{"id":"","type":"message","role":"assistant","model":"claude-opus-4-1-20250805","stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0},"content":[],"stop_reason":null}}`) + template = []byte(`{"type":"message_start","message":{"id":"","type":"message","role":"assistant","content":[],"model":"claude-opus-4-1-20250805","stop_reason":null,"stop_sequence":null,"usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":0,"output_tokens":0,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_fetch_requests":0,"web_search_requests":0}}}}`) template, _ = sjson.SetBytes(template, "message.model", rootResult.Get("response.model").String()) template, _ = sjson.SetBytes(template, "message.id", rootResult.Get("response.id").String()) output = translatorcommon.AppendSSEEventBytes(output, "message_start", template, 2) + if claudeRequestStreamsThinking(originalRequestRawJSON) { + // Claude opens the thinking block before its otherwise silent reasoning phase. + // Opening it here lets estimated_tokens progress events remain valid until + // Codex emits its first reasoning summary delta. + output = append(output, startCodexThinkingBlock(params)...) + } case "response.reasoning_summary_part.added": output = append(output, stopCodexTextBlock(params)...) // Codex splits a single reasoning item into several summary parts, but only @@ -147,7 +157,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa case "response.web_search_call.searching", "response.web_search_call.completed", "response.web_search_call.in_progress": // Wait for populated web_search_call items on output_item.done. 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}}`) + template = []byte(`{"type":"message_delta","context_management":null,"delta":{"container":null,"stop_details":null,"stop_reason":"tool_use","stop_sequence":null},"usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":0,"iterations":null,"output_tokens":0,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_fetch_requests":0,"web_search_requests":0}}}`) responseData := rootResult.Get("response") output = append(output, finalizeCodexThinkingBlock(params)...) output = append(output, stopCodexTextBlock(params)...) @@ -157,17 +167,22 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa 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, cacheWriteTokens := extractResponsesUsage(responseData.Get("usage")) - template, _ = sjson.SetBytes(template, "usage.input_tokens", inputTokens) - template, _ = sjson.SetBytes(template, "usage.output_tokens", outputTokens) - if cachedTokens > 0 { - template, _ = sjson.SetBytes(template, "usage.cache_read_input_tokens", cachedTokens) - } - if cacheWriteTokens > 0 { - template, _ = sjson.SetBytes(template, "usage.cache_creation_input_tokens", cacheWriteTokens) + usage := extractResponsesUsage(ctx, responseData) + template, _ = sjson.SetBytes(template, "usage.input_tokens", usage.InputTokens) + template, _ = sjson.SetBytes(template, "usage.cache_creation_input_tokens", usage.CacheCreationInputTokens) + template, _ = sjson.SetBytes(template, "usage.cache_read_input_tokens", usage.CacheReadInputTokens) + template, _ = sjson.SetBytes(template, "usage.output_tokens", usage.OutputTokens) + if ctx.Value(constant.ClaudeBridgeUsageContextKey{}) == true { + template, _ = sjson.SetBytes(template, "usage.output_tokens_details.thinking_tokens", usage.ThinkingTokens) + } else { + template, _ = sjson.DeleteBytes(template, "usage.output_tokens_details") + template = setClaudeReasoningUsage(template, responseData.Get("usage")) } - template = setClaudeReasoningUsage(template, responseData.Get("usage")) + template, _ = sjson.SetBytes(template, "usage.server_tool_use.web_search_requests", usage.WebSearchRequests) + if ctx.Value(constant.ClaudeBridgeUsageContextKey{}) != true && usage.CacheCreationInputTokens == 0 { + template, _ = sjson.DeleteBytes(template, "usage.cache_creation_input_tokens") + } output = translatorcommon.AppendSSEEventBytes(output, "message_delta", template, 2) output = translatorcommon.AppendSSEEventBytes(output, "message_stop", []byte(`{"type":"message_stop"}`), 2) case "response.output_item.added": @@ -281,6 +296,15 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa return [][]byte{output} } +func claudeRequestStreamsThinking(originalRequestRawJSON []byte) bool { + switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(originalRequestRawJSON, "thinking.type").String())) { + case "adaptive", "enabled": + return true + default: + return false + } +} + 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": @@ -348,7 +372,10 @@ func codexStreamErrorToClaudeError(rootResult gjson.Result) []byte { // This function processes the complete Codex response and transforms it into a single Claude Code-compatible // JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all // the information into a single response that matches the Claude Code API format. -func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, _ []byte, rawJSON []byte, _ *any) []byte { +func ConvertCodexResponseToClaudeNonStream(ctx context.Context, _ string, originalRequestRawJSON, _ []byte, rawJSON []byte, _ *any) []byte { + if ctx == nil { + ctx = context.Background() + } revNames := buildReverseMapFromClaudeOriginalShortToOriginal(originalRequestRawJSON) rootResult := gjson.ParseBytes(rawJSON) @@ -362,20 +389,25 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original return []byte{} } - out := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`) + out := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":0,"output_tokens":0,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_fetch_requests":0,"web_search_requests":0}}}`) out, _ = sjson.SetBytes(out, "id", responseData.Get("id").String()) out, _ = sjson.SetBytes(out, "model", responseData.Get("model").String()) - inputTokens, outputTokens, cachedTokens, cacheWriteTokens := extractResponsesUsage(responseData.Get("usage")) - out, _ = sjson.SetBytes(out, "usage.input_tokens", inputTokens) - out, _ = sjson.SetBytes(out, "usage.output_tokens", outputTokens) - if cachedTokens > 0 { - out, _ = sjson.SetBytes(out, "usage.cache_read_input_tokens", cachedTokens) - } - if cacheWriteTokens > 0 { - out, _ = sjson.SetBytes(out, "usage.cache_creation_input_tokens", cacheWriteTokens) + usage := extractResponsesUsage(ctx, responseData) + out, _ = sjson.SetBytes(out, "usage.input_tokens", usage.InputTokens) + out, _ = sjson.SetBytes(out, "usage.cache_creation_input_tokens", usage.CacheCreationInputTokens) + out, _ = sjson.SetBytes(out, "usage.cache_read_input_tokens", usage.CacheReadInputTokens) + out, _ = sjson.SetBytes(out, "usage.output_tokens", usage.OutputTokens) + if ctx.Value(constant.ClaudeBridgeUsageContextKey{}) == true { + out, _ = sjson.SetBytes(out, "usage.output_tokens_details.thinking_tokens", usage.ThinkingTokens) + } else { + out, _ = sjson.DeleteBytes(out, "usage.output_tokens_details") + out = setClaudeReasoningUsage(out, responseData.Get("usage")) } - out = setClaudeReasoningUsage(out, responseData.Get("usage")) + out, _ = sjson.SetBytes(out, "usage.server_tool_use.web_search_requests", usage.WebSearchRequests) + if ctx.Value(constant.ClaudeBridgeUsageContextKey{}) != true && usage.CacheCreationInputTokens == 0 { + out, _ = sjson.DeleteBytes(out, "usage.cache_creation_input_tokens") + } hasToolCall := false webSearchSeen := make(map[string]struct{}) var contentBlocks [][]byte @@ -802,28 +834,41 @@ func resolveCodexClaudeToolUseName(originalRequestRawJSON []byte, name string) s return name } -func extractResponsesUsage(usage gjson.Result) (int64, int64, int64, int64) { +type claudeResponsesUsage struct { + InputTokens int64 + CacheCreationInputTokens int64 + CacheReadInputTokens int64 + OutputTokens int64 + ThinkingTokens int64 + WebSearchRequests int64 +} + +func extractResponsesUsage(ctx context.Context, responseData gjson.Result) claudeResponsesUsage { + usage := responseData.Get("usage") if !usage.Exists() || usage.Type == gjson.Null { - return 0, 0, 0, 0 + return claudeResponsesUsage{} } - inputTokens := usage.Get("input_tokens").Int() - outputTokens := usage.Get("output_tokens").Int() - cachedTokens := usage.Get("input_tokens_details.cached_tokens").Int() - cacheWriteTokens := usage.Get("input_tokens_details.cache_write_tokens").Int() - if cacheWriteTokens == 0 { - cacheWriteTokens = usage.Get("input_tokens_details.cache_creation_tokens").Int() + detail := claudeResponsesUsage{ + InputTokens: usage.Get("input_tokens").Int(), + OutputTokens: usage.Get("output_tokens").Int(), + ThinkingTokens: usage.Get("output_tokens_details.reasoning_tokens").Int(), + WebSearchRequests: responseData.Get("tool_usage.web_search.num_requests").Int(), } - - if cachedTokens > 0 { - if inputTokens >= cachedTokens { - inputTokens -= cachedTokens - } else { - inputTokens = 0 + if ctx.Value(constant.ClaudeBridgeUsageContextKey{}) != true { + detail.CacheReadInputTokens = usage.Get("input_tokens_details.cached_tokens").Int() + detail.CacheCreationInputTokens = usage.Get("input_tokens_details.cache_write_tokens").Int() + if detail.CacheCreationInputTokens == 0 { + detail.CacheCreationInputTokens = usage.Get("input_tokens_details.cache_creation_tokens").Int() + } + if detail.CacheReadInputTokens > 0 { + detail.InputTokens = max(int64(0), detail.InputTokens-detail.CacheReadInputTokens) } } - - return inputTokens, outputTokens, cachedTokens, cacheWriteTokens + // Codex cached_tokens describes ChatGPT's internal prompt cache, not an + // Anthropic cache-control entry. Keep the full context count in input_tokens so + // Claude clients can make correct context and auto-compaction decisions. + return detail } func setClaudeReasoningUsage(out []byte, usage gjson.Result) []byte { @@ -919,7 +964,7 @@ func appendCodexThinkingDelta(params *ConvertCodexResponseToClaudeParams, text s return nil } - template := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}`) + template := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"","estimated_tokens":null}}`) template, _ = sjson.SetBytes(template, "index", params.BlockIndex) template, _ = sjson.SetBytes(template, "delta.thinking", text) diff --git a/internal/translator/codex/claude/codex_claude_response_test.go b/internal/translator/codex/claude/codex_claude_response_test.go index 813545d4855..9943926a31a 100644 --- a/internal/translator/codex/claude/codex_claude_response_test.go +++ b/internal/translator/codex/claude/codex_claude_response_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" "strings" "testing" @@ -29,6 +30,7 @@ func TestConvertCodexResponseToClaude_StreamThinkingIncludesSignature(t *testing } startFound := false + thinkingDeltaFound := false signatureDeltaFound := false stopFound := false @@ -47,6 +49,12 @@ func TestConvertCodexResponseToClaude_StreamThinkingIncludesSignature(t *testing } } case "content_block_delta": + if data.Get("delta.type").String() == "thinking_delta" { + thinkingDeltaFound = true + if got := data.Get("delta.estimated_tokens").Raw; got != "null" { + t.Fatalf("thinking delta estimated_tokens = %q, want null: %s", got, line) + } + } if data.Get("delta.type").String() == "signature_delta" { signatureDeltaFound = true if got := data.Get("delta.signature").String(); got != "enc_sig_123" { @@ -62,6 +70,9 @@ func TestConvertCodexResponseToClaude_StreamThinkingIncludesSignature(t *testing if !startFound { t.Fatal("expected thinking content_block_start event") } + if !thinkingDeltaFound { + t.Fatal("expected thinking_delta event") + } if !signatureDeltaFound { t.Fatal("expected signature_delta event for thinking block") } @@ -70,6 +81,83 @@ func TestConvertCodexResponseToClaude_StreamThinkingIncludesSignature(t *testing } } +func TestConvertCodexResponseToClaude_AdaptiveThinkingStartsWithMessage(t *testing.T) { + ctx := context.Background() + var param any + originalRequest := []byte(`{"messages":[],"thinking":{"type":"adaptive"}}`) + + outputs := ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, []byte(`data: {"type":"response.created","response":{"id":"resp_123","model":"gpt-5.6-luna"}}`), ¶m) + stream := string(bytes.Join(outputs, nil)) + messageStart := strings.Index(stream, "event: message_start") + thinkingStart := strings.Index(stream, `"content_block":{"type":"thinking"`) + if messageStart < 0 || thinkingStart < 0 { + t.Fatalf("adaptive response start must include message and thinking block starts: %s", stream) + } + if messageStart >= thinkingStart { + t.Fatalf("thinking block must start after message_start: %s", stream) + } + messageStartData := gjson.Parse(claudeSSEDataForTest(t, outputs[0])) + if !messageStartData.Get("message.content").IsArray() { + t.Fatalf("message_start content is not nested under message: %s", outputs[0]) + } + if messageStartData.Get("content").Exists() || messageStartData.Get("stop_reason").Exists() { + t.Fatalf("message_start leaked message fields to the event root: %s", outputs[0]) + } + params := param.(*ConvertCodexResponseToClaudeParams) + if !params.ThinkingBlockOpen || params.BlockIndex != 0 { + t.Fatalf("thinking state = open %v index %d, want open index 0", params.ThinkingBlockOpen, params.BlockIndex) + } +} + +func claudeSSEDataForTest(t *testing.T, chunk []byte) string { + t.Helper() + for _, line := range strings.Split(string(chunk), "\n") { + if strings.HasPrefix(line, "data: ") { + return strings.TrimPrefix(line, "data: ") + } + } + t.Fatalf("SSE chunk has no data line: %s", chunk) + return "" +} + +func TestConvertCodexResponseToClaude_NoThinkingDoesNotStartEmptyBlock(t *testing.T) { + ctx := context.Background() + var param any + + outputs := ConvertCodexResponseToClaude(ctx, "", []byte(`{"messages":[]}`), nil, []byte(`data: {"type":"response.created","response":{"id":"resp_123","model":"gpt-5.6-luna"}}`), ¶m) + stream := string(bytes.Join(outputs, nil)) + if strings.Contains(stream, `"content_block":{"type":"thinking"`) { + t.Fatalf("request without thinking unexpectedly opened a thinking block: %s", stream) + } +} + +func TestConvertCodexResponseToClaude_AdaptiveThinkingClosesBeforeTextPart(t *testing.T) { + ctx := context.Background() + var param any + originalRequest := []byte(`{"messages":[],"thinking":{"type":"adaptive"}}`) + inputs := [][]byte{ + []byte(`data: {"type":"response.created","response":{"id":"resp_123","model":"gpt-5.6-sol"}}`), + []byte(`data: {"type":"response.content_part.added","part":{"type":"output_text","text":""}}`), + []byte(`data: {"type":"response.output_text.delta","delta":"done"}`), + } + + var outputs [][]byte + for _, input := range inputs { + outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, input, ¶m)...) + } + stream := string(bytes.Join(outputs, nil)) + thinkingStart := strings.Index(stream, `"index":0,"content_block":{"type":"thinking"`) + thinkingStop := strings.Index(stream, `{"type":"content_block_stop","index":0}`) + textStart := strings.Index(stream, `"index":1,"content_block":{"type":"text"`) + textDelta := strings.Index(stream, `"index":1,"delta":{"type":"text_delta","text":"done"`) + if thinkingStart < 0 || thinkingStop < 0 || textStart < 0 || textDelta < 0 { + t.Fatalf("missing adaptive thinking/text lifecycle event: %s", stream) + } + if !(thinkingStart < thinkingStop && thinkingStop < textStart && textStart < textDelta) { + t.Fatalf("invalid adaptive thinking/text lifecycle order: %s", stream) + } +} + func TestConvertCodexResponseToClaude_StreamCyberPolicyError(t *testing.T) { ctx := context.Background() var param any @@ -1292,6 +1380,72 @@ func TestConvertCodexResponseToClaudeNonStream_StopSequenceMapping(t *testing.T) } } +func TestConvertCodexResponseToClaude_StreamUsageBreakdown(t *testing.T) { + ctx := context.WithValue(context.Background(), constant.ClaudeBridgeUsageContextKey{}, true) + var param any + outputs := ConvertCodexResponseToClaude(ctx, "", []byte(`{"messages":[]}`), nil, []byte(`data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5.6-terra"}}`), ¶m) + outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", []byte(`{"messages":[]}`), nil, []byte(`data: {"type":"response.completed","response":{"usage":{"input_tokens":80000,"input_tokens_details":{"cache_write_tokens":1000,"cached_tokens":60000},"output_tokens":500,"output_tokens_details":{"reasoning_tokens":250}},"tool_usage":{"web_search":{"num_requests":2}},"output":[]}}`), ¶m)...) + + messageStart, okStart := firstClaudeStreamPayloadForEvent(string(bytes.Join(outputs, nil)), "message_start") + if !okStart { + t.Fatalf("missing message_start: %q", outputs) + } + for _, path := range []string{ + "message.usage.cache_creation_input_tokens", + "message.usage.cache_read_input_tokens", + "message.usage.output_tokens_details.thinking_tokens", + "message.usage.server_tool_use.web_fetch_requests", + "message.usage.server_tool_use.web_search_requests", + } { + if !messageStart.Get(path).Exists() { + t.Fatalf("message_start missing %s: %s", path, messageStart.Raw) + } + } + + messageDelta, okDelta := findClaudeStreamMessageDelta(outputs) + if !okDelta { + t.Fatalf("missing message_delta: %q", outputs) + } + for _, path := range []string{ + "context_management", + "delta.container", + "delta.stop_details", + "delta.stop_sequence", + "usage.iterations", + } { + if got := messageDelta.Get(path).Raw; got != "null" { + t.Fatalf("%s = %q, want null: %s", path, got, messageDelta.Raw) + } + } + assertClaudeUsageBreakdown(t, messageDelta.Get("usage")) +} + +func TestConvertCodexResponseToClaudeNonStreamUsageBreakdown(t *testing.T) { + response := []byte(`{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.6-terra","usage":{"input_tokens":80000,"input_tokens_details":{"cache_write_tokens":1000,"cached_tokens":60000},"output_tokens":500,"output_tokens_details":{"reasoning_tokens":250}},"tool_usage":{"web_search":{"num_requests":2}},"output":[]}}`) + out := ConvertCodexResponseToClaudeNonStream(context.WithValue(context.Background(), constant.ClaudeBridgeUsageContextKey{}, true), "", []byte(`{"messages":[]}`), nil, response, nil) + assertClaudeUsageBreakdown(t, gjson.GetBytes(out, "usage")) +} + +func assertClaudeUsageBreakdown(t *testing.T, usage gjson.Result) { + t.Helper() + checks := []struct { + path string + want int64 + }{ + {path: "input_tokens", want: 80000}, + {path: "cache_creation_input_tokens", want: 0}, + {path: "cache_read_input_tokens", want: 0}, + {path: "output_tokens", want: 500}, + {path: "output_tokens_details.thinking_tokens", want: 250}, + {path: "server_tool_use.web_search_requests", want: 2}, + } + for _, check := range checks { + if got := usage.Get(check.path).Int(); got != check.want { + t.Fatalf("%s = %d, want %d: %s", check.path, got, check.want, usage.Raw) + } + } +} + func findClaudeStreamStopReason(outputs [][]byte) (string, bool) { messageDelta, ok := findClaudeStreamMessageDelta(outputs) if !ok { diff --git a/sdk/api/handlers/claude/code_handlers.go b/sdk/api/handlers/claude/code_handlers.go index c7226315704..8fab61e0789 100644 --- a/sdk/api/handlers/claude/code_handlers.go +++ b/sdk/api/handlers/claude/code_handlers.go @@ -81,8 +81,15 @@ func (h *ClaudeCodeAPIHandler) ClaudeMessages(c *gin.Context) { return } + clientModel := gjson.GetBytes(rawJSON, "model").String() + // Decode claude-fable-5-dd- model IDs back to the real model name for routing. rawJSON = rewriteClaudeDDModelInBody(rawJSON) + upstreamModel := gjson.GetBytes(rawJSON, "model").String() + if shouldUseClaudeResponsesBridge(clientModel, upstreamModel) { + h.handleResponsesBridge(c, rawJSON, clientModel) + return + } // Check if the client requested a streaming response. streamResult := gjson.GetBytes(rawJSON, "stream") diff --git a/sdk/api/handlers/claude/compact_bridge.go b/sdk/api/handlers/claude/compact_bridge.go new file mode 100644 index 00000000000..b07993bbfa7 --- /dev/null +++ b/sdk/api/handlers/claude/compact_bridge.go @@ -0,0 +1,438 @@ +package claude + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const ( + claudeCompactionMaxSize = 8 << 20 + claudeCompactionSSEChunkSize = 16 << 10 +) + +type claudeCompactionReplay struct { + Output []json.RawMessage +} + +type responsesCompactionResource struct { + ID string `json:"id"` + Object string `json:"object"` + Output []json.RawMessage `json:"output"` + Usage struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + } `json:"usage"` +} + +func isClaudeCompactRequest(rawJSON []byte) bool { + messages := gjson.GetBytes(rawJSON, "messages") + if !messages.IsArray() { + return false + } + items := messages.Array() + if len(items) == 0 || !strings.EqualFold(strings.TrimSpace(items[len(items)-1].Get("role").String()), "user") { + return false + } + text := normalizedClaudeCompactPrompt(claudeMessageText(items[len(items)-1].Get("content"))) + if !strings.Contains(text, "critical: respond with text only") || strings.Count(text, "do not call any tools") < 2 { + return false + } + if !strings.Contains(text, "your task is to create a detailed summary") { + return false + } + if strings.Contains(text, "conversation so far") || strings.Contains(text, "recent portion of the conversation") { + return true + } + return strings.Contains(text, "conversation") && (strings.Contains(text, "up to this point") || strings.Contains(text, "up to and including")) +} + +func claudeMessageText(content gjson.Result) string { + if content.Type == gjson.String { + return content.String() + } + if !content.IsArray() { + return "" + } + var parts []string + content.ForEach(func(_, part gjson.Result) bool { + if part.Get("type").String() == "text" { + if text := part.Get("text").String(); text != "" { + parts = append(parts, text) + } + } + return true + }) + return strings.Join(parts, "\n") +} + +func normalizedClaudeCompactPrompt(text string) string { + return strings.ToLower(strings.Join(strings.Fields(text), " ")) +} + +func prepareClaudeCompactionReplay(rawJSON []byte, _ string) ([]byte, *claudeCompactionReplay, error) { + var root map[string]any + if errUnmarshal := json.Unmarshal(rawJSON, &root); errUnmarshal != nil { + return nil, nil, fmt.Errorf("decode Claude request for compaction replay: %w", errUnmarshal) + } + messages, okMessages := root["messages"].([]any) + if !okMessages { + return nil, nil, fmt.Errorf("compaction replay requires a messages array") + } + + var replay *claudeCompactionReplay + updatedMessages := make([]any, 0, len(messages)) + for _, rawMessage := range messages { + message, okMessage := rawMessage.(map[string]any) + if !okMessage { + updatedMessages = append(updatedMessages, rawMessage) + continue + } + keepMessage, found, errRewrite := rewriteClaudeMessageCompaction(message) + if errRewrite != nil { + return nil, nil, errRewrite + } + if found != nil { + // The newest compact result replaces the previous context window. + updatedMessages = updatedMessages[:0] + replay = found + } + if keepMessage { + updatedMessages = append(updatedMessages, message) + } + } + if replay == nil { + return rawJSON, nil, nil + } + root["messages"] = updatedMessages + root[ClaudeResponsesCompactionField] = map[string]any{"output": replay.Output} + updated, errMarshal := json.Marshal(root) + if errMarshal != nil { + return nil, nil, fmt.Errorf("encode Claude request with compaction replay: %w", errMarshal) + } + return updated, replay, nil +} + +func rewriteClaudeMessageCompaction(message map[string]any) (bool, *claudeCompactionReplay, error) { + content, exists := message["content"] + if !exists { + return true, nil, nil + } + switch value := content.(type) { + case string: + text, replay, found, errStrip := stripClaudeCompactionCiphertext(value) + if errStrip != nil { + return false, nil, errStrip + } + if !found { + return true, nil, nil + } + if text == "" { + return false, replay, nil + } + message["content"] = text + return true, replay, nil + case []any: + var replay *claudeCompactionReplay + parts := make([]any, 0, len(value)) + for _, rawPart := range value { + part, okPart := rawPart.(map[string]any) + if !okPart || part["type"] != "text" { + parts = append(parts, rawPart) + continue + } + text, _ := part["text"].(string) + updatedText, foundReplay, found, errStrip := stripClaudeCompactionCiphertext(text) + if errStrip != nil { + return false, nil, errStrip + } + if found { + if replay != nil { + return false, nil, fmt.Errorf("multiple compaction blocks in one message") + } + replay = foundReplay + if updatedText == "" { + continue + } + part["text"] = updatedText + } + parts = append(parts, part) + } + if replay == nil { + return true, nil, nil + } + if len(parts) == 0 { + return false, replay, nil + } + message["content"] = parts + return true, replay, nil + default: + return true, nil, nil + } +} + +func stripClaudeCompactionCiphertext(text string) (string, *claudeCompactionReplay, bool, error) { + lines := strings.Split(text, "\n") + var found *claudeCompactionReplay + for i, line := range lines { + token := strings.TrimSpace(line) + if len(token) > claudeCompactionMaxSize || !signature.IsValidGPTReasoningSignature(token) { + continue + } + if found != nil { + return "", nil, false, fmt.Errorf("multiple raw compaction blocks in one message") + } + item, _ := json.Marshal(map[string]string{"type": "compaction", "encrypted_content": token}) + found = &claudeCompactionReplay{Output: []json.RawMessage{item}} + lines[i] = "" + } + if found == nil { + return text, nil, false, nil + } + return strings.TrimSpace(strings.Join(lines, "\n")), found, true, nil +} + +func validateResponsesCompactionOutput(output []json.RawMessage) error { + if len(output) == 0 { + return fmt.Errorf("compaction output is empty") + } + hasCompaction := false + for i, item := range output { + itemType := gjson.GetBytes(item, "type").String() + switch itemType { + case "compaction", "compaction_summary": + if strings.TrimSpace(gjson.GetBytes(item, "encrypted_content").String()) == "" { + return fmt.Errorf("compaction output item %d has no encrypted_content", i) + } + hasCompaction = true + case "message": + role := gjson.GetBytes(item, "role").String() + if role != "user" && role != "assistant" && role != "developer" { + return fmt.Errorf("compaction message item %d has unsupported role %q", i, role) + } + content := gjson.GetBytes(item, "content") + if !content.IsArray() { + return fmt.Errorf("compaction message item %d has invalid content", i) + } + for j, part := range content.Array() { + partType := part.Get("type").String() + if partType != "input_text" && partType != "output_text" { + return fmt.Errorf("compaction message item %d content %d has unsupported type %q", i, j, partType) + } + if part.Get("text").Type != gjson.String { + return fmt.Errorf("compaction message item %d content %d has invalid text", i, j) + } + } + default: + return fmt.Errorf("compaction output item %d has unsupported type %q", i, itemType) + } + } + if !hasCompaction { + return fmt.Errorf("compaction output has no opaque compaction item") + } + return nil +} + +func (h *ClaudeCodeAPIHandler) handleCompactResponsesBridge(c *gin.Context, rawJSON []byte, clientModel string) { + clientWantsStream := gjson.GetBytes(rawJSON, "stream").Bool() + if clientWantsStream { + if _, okFlusher := c.Writer.(http.Flusher); !okFlusher { + c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{Message: "Streaming not supported", Type: "server_error"}, + }) + return + } + } + + cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background()) + modelName := gjson.GetBytes(rawJSON, "model").String() + // Compaction is a normal Codex Responses request with a final trigger item. + // Leave authentication, transport, and response assembly to CPA's executor. + translated := sdktranslator.TranslateRequest(sdktranslator.FromString(Claude), sdktranslator.FromString(Codex), modelName, rawJSON, false) + var input []json.RawMessage + for _, item := range gjson.GetBytes(rawJSON, ClaudeResponsesCompactionField+".output").Array() { + input = append(input, json.RawMessage(item.Raw)) + } + for _, item := range gjson.GetBytes(translated, "input").Array() { + input = append(input, json.RawMessage(item.Raw)) + } + input = append(input, json.RawMessage(`{"type":"compaction_trigger"}`)) + inputJSON, _ := json.Marshal(input) + translated, _ = sjson.SetRawBytes(translated, "input", inputJSON) + stopKeepAlive := func() {} + if !clientWantsStream { + stopKeepAlive = h.StartNonStreamingKeepAlive(c, cliCtx) + } + response, errMsg := h.ExecuteProtocolWithAuthManager(cliCtx, handlers.ProtocolExecutionRequest{ + EntryProtocol: Codex, + ExitProtocol: OpenaiResponse, + ForcedProvider: Codex, + Model: modelName, + Body: translated, + Headers: c.Request.Header.Clone(), + Query: c.Request.URL.Query(), + }) + stopKeepAlive() + if errMsg != nil { + h.WriteErrorResponse(c, errMsg) + cliCancel(errMsg.Error) + return + } + clientResponse, marker, errBuild := buildClaudeCompactResponse(response.Body, clientModel, modelName) + if errBuild != nil { + c.JSON(http.StatusBadGateway, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{Message: errBuild.Error(), Type: "api_error"}, + }) + cliCancel(errBuild) + return + } + handlers.WriteUpstreamHeaders(c.Writer.Header(), response.Headers) + if !clientWantsStream { + c.Header("Content-Type", "application/json") + _, _ = c.Writer.Write(clientResponse) + cliCancel() + return + } + + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("Access-Control-Allow-Origin", "*") + _, _ = c.Writer.Write(buildClaudeCompactSSE(clientResponse, marker)) + c.Writer.(http.Flusher).Flush() + cliCancel() +} + +func buildClaudeCompactResponse(rawCompact []byte, clientModel string, _ string) ([]byte, string, error) { + var compact responsesCompactionResource + if errUnmarshal := json.Unmarshal(rawCompact, &compact); errUnmarshal != nil { + return nil, "", fmt.Errorf("decode upstream compaction response: %w", errUnmarshal) + } + if compact.Object != "response.compaction" && compact.Object != "response" { + return nil, "", fmt.Errorf("unexpected upstream compaction object %q", compact.Object) + } + if errValidate := validateResponsesCompactionOutput(compact.Output); errValidate != nil { + return nil, "", errValidate + } + var marker string + for _, item := range compact.Output { + kind := gjson.GetBytes(item, "type").String() + if kind != "compaction" && kind != "compaction_summary" { + continue + } + if marker != "" { + return nil, "", fmt.Errorf("multiple upstream compaction blocks") + } + marker = gjson.GetBytes(item, "encrypted_content").String() + } + if len(marker) > claudeCompactionMaxSize || !signature.IsValidGPTReasoningSignature(marker) { + return nil, "", fmt.Errorf("invalid upstream compaction ciphertext") + } + response := map[string]any{ + "id": compact.ID, + "type": "message", + "role": "assistant", + "model": clientModel, + "content": []map[string]any{{"type": "text", "text": marker}}, + "stop_reason": "end_turn", + "stop_sequence": nil, + "usage": map[string]any{ + "cache_creation_input_tokens": int64(0), + "cache_read_input_tokens": int64(0), + "input_tokens": compact.Usage.InputTokens, + "output_tokens": compact.Usage.OutputTokens, + "output_tokens_details": map[string]any{"thinking_tokens": int64(0)}, + "server_tool_use": map[string]any{"web_fetch_requests": int64(0), "web_search_requests": int64(0)}, + }, + } + body, errMarshal := json.Marshal(response) + if errMarshal != nil { + return nil, "", fmt.Errorf("encode Claude compact response: %w", errMarshal) + } + return body, marker, nil +} + +func buildClaudeCompactSSE(clientResponse []byte, marker string) []byte { + response := gjson.ParseBytes(clientResponse) + messageStart := map[string]any{ + "type": "message_start", + "message": map[string]any{ + "id": response.Get("id").String(), + "type": "message", + "role": "assistant", + "model": response.Get("model").String(), + "content": []any{}, + "stop_reason": nil, + "stop_sequence": nil, + "usage": map[string]any{ + "cache_creation_input_tokens": int64(0), + "cache_read_input_tokens": int64(0), + "input_tokens": response.Get("usage.input_tokens").Int(), + "output_tokens": int64(0), + "output_tokens_details": map[string]any{"thinking_tokens": int64(0)}, + "server_tool_use": map[string]any{"web_fetch_requests": int64(0), "web_search_requests": int64(0)}, + }, + }, + } + var out bytes.Buffer + appendClaudeSSEEvent(&out, "message_start", messageStart) + appendClaudeSSEEvent(&out, "content_block_start", map[string]any{ + "type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""}, + }) + for len(marker) > 0 { + chunkSize := claudeCompactionSSEChunkSize + if len(marker) < chunkSize { + chunkSize = len(marker) + } + appendClaudeSSEEvent(&out, "content_block_delta", map[string]any{ + "type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": marker[:chunkSize]}, + }) + marker = marker[chunkSize:] + } + appendClaudeSSEEvent(&out, "content_block_stop", map[string]any{"type": "content_block_stop", "index": 0}) + appendClaudeSSEEvent(&out, "message_delta", map[string]any{ + "type": "message_delta", + "context_management": nil, + "delta": map[string]any{ + "container": nil, + "stop_details": nil, + "stop_reason": "end_turn", + "stop_sequence": nil, + }, + "usage": map[string]any{ + "cache_creation_input_tokens": int64(0), + "cache_read_input_tokens": int64(0), + "input_tokens": response.Get("usage.input_tokens").Int(), + "iterations": nil, + "output_tokens": response.Get("usage.output_tokens").Int(), + "output_tokens_details": map[string]any{"thinking_tokens": int64(0)}, + "server_tool_use": map[string]any{"web_fetch_requests": int64(0), "web_search_requests": int64(0)}, + }, + }) + appendClaudeSSEEvent(&out, "message_stop", map[string]any{"type": "message_stop"}) + return out.Bytes() +} + +func appendClaudeSSEEvent(out *bytes.Buffer, event string, payload any) { + data, errMarshal := json.Marshal(payload) + if errMarshal != nil { + return + } + out.WriteString("event: ") + out.WriteString(event) + out.WriteByte('\n') + out.WriteString("data: ") + out.Write(data) + out.WriteString("\n\n") +} diff --git a/sdk/api/handlers/claude/raw_compact_replay_test.go b/sdk/api/handlers/claude/raw_compact_replay_test.go new file mode 100644 index 00000000000..bcfa023a71b --- /dev/null +++ b/sdk/api/handlers/claude/raw_compact_replay_test.go @@ -0,0 +1,45 @@ +package claude + +import ( + "net/http" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + "github.com/tidwall/gjson" +) + +func TestCompactResponseReturnsRawCiphertextAndResumesWithoutPinning(t *testing.T) { + opaque := rawCiphertextForTest(50000) + raw := mustJSONMarshalForTest(t, map[string]any{ + "id": "compact-test", "object": "response.compaction", + "output": []any{map[string]any{"type": "compaction", "encrypted_content": opaque}}, + }) + response, marker, err := buildClaudeCompactResponse(raw, responsesBridgeClientModel, responsesBridgeUpstreamModel) + if err != nil { + t.Fatal(err) + } + if marker != opaque || gjson.GetBytes(response, "content.0.text").String() != opaque { + t.Fatal("ciphertext was wrapped or changed") + } + for _, stream := range []bool{false, true} { + handler, executor := newResponsesBridgeHandler(t) + body := mustJSONMarshalForTest(t, map[string]any{"model": responsesBridgeClientModel, "stream": stream, "max_tokens": 128, "messages": []any{ + map[string]any{"role": "user", "content": marker}, map[string]any{"role": "user", "content": "continue"}, + }}) + recorder := serveClaudeMessages(t, handler, "/v1/messages", string(body)) + if recorder.Code != http.StatusOK { + t.Fatalf("resume: %d %s", recorder.Code, recorder.Body.String()) + } + if executor.options.Metadata[coreexecutor.PinnedAuthMetadataKey] != nil { + t.Fatal("resume pinned a credential") + } + if strings.Contains(gjson.GetBytes(executor.request.Payload, "messages").Raw, opaque) { + t.Fatal("reference leaked into ordinary messages") + } + if gjson.GetBytes(executor.request.Payload, constant.ClaudeResponsesCompactionField+".output.0.encrypted_content").String() != opaque { + t.Fatal("resume lost compact state") + } + } +} diff --git a/sdk/api/handlers/claude/raw_compact_test.go b/sdk/api/handlers/claude/raw_compact_test.go new file mode 100644 index 00000000000..c407a5ed13f --- /dev/null +++ b/sdk/api/handlers/claude/raw_compact_test.go @@ -0,0 +1,65 @@ +package claude + +import ( + "encoding/base64" + "encoding/json" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/tidwall/gjson" +) + +func rawCiphertextForTest(blocks int) string { + raw := make([]byte, 57+16*blocks) + raw[0] = 0x80 + return base64.URLEncoding.EncodeToString(raw) +} + +func TestRawCompactThreeGenerationsReplaceHistory(t *testing.T) { + var previous string + for generation := 1; generation <= 3; generation++ { + token := rawCiphertextForTest(generation) + compact := mustJSONMarshalForTest(t, map[string]any{"object": "response.compaction", "output": []any{map[string]string{"type": "compaction", "encrypted_content": token}}}) + _, text, err := buildClaudeCompactResponse(compact, responsesBridgeClientModel, responsesBridgeUpstreamModel) + if err != nil || text != token { + t.Fatalf("generation %d: %v", generation, err) + } + messages := []any{map[string]any{"role": "user", "content": "old history"}} + if previous != "" { + messages = append(messages, map[string]any{"role": "user", "content": previous}) + } + messages = append(messages, map[string]any{"role": "user", "content": []any{map[string]string{"type": "text", "text": "Summary follows:\n\n" + text + "\n\nContinue."}}}, map[string]any{"role": "user", "content": "new turn"}) + body := mustJSONMarshalForTest(t, map[string]any{"model": responsesBridgeUpstreamModel, "messages": messages}) + prepared, replay, err := prepareClaudeCompactionReplay(body, responsesBridgeUpstreamModel) + if err != nil { + t.Fatal(err) + } + if len(replay.Output) != 1 || gjson.GetBytes(prepared, constant.ClaudeResponsesCompactionField+".output.0.encrypted_content").String() != token { + t.Fatal("replacement lost") + } + remaining := gjson.GetBytes(prepared, "messages").Raw + if strings.Contains(remaining, token) || strings.Contains(remaining, "old history") || !strings.Contains(remaining, "new turn") { + t.Fatal("context boundary not replaced") + } + previous = text + } +} + +func TestRawCompactLeavesQuotedAndMalformedText(t *testing.T) { + token := rawCiphertextForTest(1) + quoted, _ := json.Marshal(token) + for _, text := range []string{string(quoted), "example: " + token, "gAAAA-not-valid", "`" + token + "`"} { + got, _, found, err := stripClaudeCompactionCiphertext(text) + if err != nil || found || got != text { + t.Fatalf("ordinary text interpreted as state: %q", text) + } + } +} + +func TestRawCompactRejectsMultipleBlocksInOneMessage(t *testing.T) { + token := rawCiphertextForTest(1) + if _, _, _, err := stripClaudeCompactionCiphertext(token + "\n" + token); err == nil { + t.Fatal("ambiguous compact blocks accepted") + } +} diff --git a/sdk/api/handlers/claude/responses_bridge.go b/sdk/api/handlers/claude/responses_bridge.go new file mode 100644 index 00000000000..9a0d2be9693 --- /dev/null +++ b/sdk/api/handlers/claude/responses_bridge.go @@ -0,0 +1,180 @@ +package claude + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func shouldUseClaudeResponsesBridge(clientModel, upstreamModel string) bool { + if clientModel == "" || clientModel == upstreamModel { + return false + } + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(upstreamModel)), "gpt-") +} + +func (h *ClaudeCodeAPIHandler) handleResponsesBridge(c *gin.Context, rawJSON []byte, clientModel string) { + compactRequest := isClaudeCompactRequest(rawJSON) + upstreamModel := gjson.GetBytes(rawJSON, "model").String() + preparedJSON, _, errPrepare := prepareClaudeCompactionReplay(rawJSON, upstreamModel) + if errPrepare != nil { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{Message: errPrepare.Error(), Type: "invalid_request_error"}, + }) + return + } + if compactRequest { + h.handleCompactResponsesBridge(c, preparedJSON, clientModel) + return + } + if gjson.GetBytes(rawJSON, "stream").Bool() { + h.handleStreamingResponsesBridge(c, preparedJSON, clientModel) + return + } + h.handleNonStreamingResponsesBridge(c, preparedJSON, clientModel) +} + +func (h *ClaudeCodeAPIHandler) handleNonStreamingResponsesBridge(c *gin.Context, rawJSON []byte, clientModel string) { + c.Header("Content-Type", "application/json") + cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background()) + modelName := gjson.GetBytes(rawJSON, "model").String() + stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx) + + response, errMsg := h.ExecuteProtocolWithAuthManager(cliCtx, handlers.ProtocolExecutionRequest{ + EntryProtocol: Claude, + ExitProtocol: Claude, + ForcedProvider: Codex, + Model: modelName, + Body: rawJSON, + Headers: c.Request.Header.Clone(), + Query: c.Request.URL.Query(), + Alt: ClaudeResponsesBridgeAlt, + }) + stopKeepAlive() + if errMsg != nil { + h.WriteErrorResponse(c, errMsg) + cliCancel(errMsg.Error) + return + } + + handlers.WriteUpstreamHeaders(c.Writer.Header(), response.Headers) + _, _ = c.Writer.Write(rewriteClaudeBridgeResponseModel(response.Body, clientModel)) + cliCancel() +} + +func (h *ClaudeCodeAPIHandler) handleStreamingResponsesBridge(c *gin.Context, rawJSON []byte, clientModel string) { + flusher, ok := c.Writer.(http.Flusher) + if !ok { + c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{Message: "Streaming not supported", Type: "server_error"}, + }) + return + } + + cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background()) + modelName := gjson.GetBytes(rawJSON, "model").String() + stream, errMsg := h.ExecuteProtocolStreamWithAuthManager(cliCtx, handlers.ProtocolExecutionRequest{ + EntryProtocol: Claude, + ExitProtocol: Claude, + ForcedProvider: Codex, + Model: modelName, + Stream: true, + Body: rawJSON, + Headers: c.Request.Header.Clone(), + Query: c.Request.URL.Query(), + Alt: ClaudeResponsesBridgeAlt, + }) + if errMsg != nil { + h.WriteErrorResponse(c, errMsg) + cliCancel(errMsg.Error) + return + } + + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("Access-Control-Allow-Origin", "*") + handlers.WriteUpstreamHeaders(c.Writer.Header(), stream.Headers) + + for { + select { + case <-c.Request.Context().Done(): + cliCancel(c.Request.Context().Err()) + return + case chunk, okChunk := <-stream.Chunks: + if !okChunk { + flusher.Flush() + cliCancel() + return + } + if chunk.Err != nil { + h.writeResponsesBridgeStreamError(c, chunk.Err) + flusher.Flush() + cliCancel(chunk.Err) + return + } + if len(chunk.Payload) == 0 { + continue + } + _, _ = c.Writer.Write(rewriteClaudeBridgeResponseModel(chunk.Payload, clientModel)) + flusher.Flush() + } + } +} + +func (h *ClaudeCodeAPIHandler) writeResponsesBridgeStreamError(c *gin.Context, streamErr *handlers.ModelExecutionStreamError) { + if streamErr == nil { + return + } + errMsg := &interfaces.ErrorMessage{StatusCode: streamErr.StatusCode, Error: streamErr} + errorBytes, errMarshal := json.Marshal(h.toClaudeError(errMsg)) + if errMarshal != nil { + errorBytes = []byte(`{"type":"error","error":{"type":"api_error","message":"stream failed"}}`) + } + _, _ = fmt.Fprintf(c.Writer, "event: error\ndata: %s\n\n", errorBytes) +} + +func rewriteClaudeBridgeResponseModel(body []byte, clientModel string) []byte { + if len(body) == 0 || clientModel == "" { + return body + } + if gjson.ValidBytes(body) && gjson.GetBytes(body, "type").String() == "message" { + if updated, errSet := sjson.SetBytes(body, "model", clientModel); errSet == nil { + return updated + } + return body + } + + lines := bytes.Split(body, []byte("\n")) + changed := false + for i, line := range lines { + trimmed := bytes.TrimSpace(line) + if !bytes.HasPrefix(trimmed, []byte("data:")) { + continue + } + payload := bytes.TrimSpace(trimmed[len("data:"):]) + if !gjson.ValidBytes(payload) || gjson.GetBytes(payload, "type").String() != "message_start" { + continue + } + updated, errSet := sjson.SetBytes(payload, "message.model", clientModel) + if errSet != nil { + continue + } + lines[i] = append([]byte("data: "), updated...) + changed = true + } + if !changed { + return body + } + return bytes.Join(lines, []byte("\n")) +} diff --git a/sdk/api/handlers/claude/responses_bridge_test.go b/sdk/api/handlers/claude/responses_bridge_test.go new file mode 100644 index 00000000000..f84afaf774a --- /dev/null +++ b/sdk/api/handlers/claude/responses_bridge_test.go @@ -0,0 +1,399 @@ +package claude + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" +) + +const ( + responsesBridgeClientModel = "claude-fable-5-dd-los-6.5-tpg" + responsesBridgeUpstreamModel = "gpt-5.6-sol" +) + +type responsesBridgeCaptureExecutor struct { + request coreexecutor.Request + options coreexecutor.Options + executeCalls int + streamCalls int +} + +func (e *responsesBridgeCaptureExecutor) Identifier() string { return constant.Codex } + +func (e *responsesBridgeCaptureExecutor) Execute(_ context.Context, _ *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) { + e.capture(req, opts) + e.executeCalls++ + if gjson.GetBytes(req.Payload, "input.@reverse.0.type").String() == "compaction_trigger" { + return coreexecutor.Response{ + Payload: []byte(`{"id":"resp_compact_1","object":"response.compaction","output":[{"id":"msg_1","type":"message","status":"completed","role":"user","content":[{"type":"input_text","text":"hello"}]},{"id":"cmp_1","type":"compaction_summary","encrypted_content":"gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="}],"usage":{"input_tokens":100,"output_tokens":20,"total_tokens":120}}`), + Headers: http.Header{"X-Upstream": []string{"compact"}}, + }, nil + } + return coreexecutor.Response{ + Payload: []byte(`{"id":"msg_1","type":"message","role":"assistant","model":"gpt-5.6-sol","content":[{"type":"text","text":"hello"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}`), + Headers: http.Header{"X-Upstream": []string{"responses"}}, + }, nil +} + +func (e *responsesBridgeCaptureExecutor) ExecuteStream(_ context.Context, _ *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (*coreexecutor.StreamResult, error) { + e.capture(req, opts) + e.streamCalls++ + chunks := make(chan coreexecutor.StreamChunk, 1) + chunks <- coreexecutor.StreamChunk{Payload: []byte("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"gpt-5.6-sol\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\n")} + close(chunks) + return &coreexecutor.StreamResult{Headers: http.Header{"X-Upstream": []string{"responses"}}, Chunks: chunks}, nil +} + +func (e *responsesBridgeCaptureExecutor) Refresh(_ context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) { + return auth, nil +} + +func (e *responsesBridgeCaptureExecutor) CountTokens(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) { + return coreexecutor.Response{}, nil +} + +func (e *responsesBridgeCaptureExecutor) HttpRequest(context.Context, *coreauth.Auth, *http.Request) (*http.Response, error) { + return nil, errors.New("not implemented") +} + +func (e *responsesBridgeCaptureExecutor) capture(req coreexecutor.Request, opts coreexecutor.Options) { + e.request = req + e.options = opts +} + +func newResponsesBridgeHandler(t *testing.T) (*ClaudeCodeAPIHandler, *responsesBridgeCaptureExecutor) { + t.Helper() + gin.SetMode(gin.TestMode) + executor := &responsesBridgeCaptureExecutor{} + manager := coreauth.NewManager(nil, nil, nil) + manager.RegisterExecutor(executor) + oauth := &coreauth.Auth{ + ID: "responses-bridge-auth", + Provider: constant.Codex, + Status: coreauth.StatusActive, + Metadata: map[string]any{"access_token": "oauth-token", "account_id": "account-id"}, + } + if _, errRegister := manager.Register(context.Background(), oauth); errRegister != nil { + t.Fatalf("register OAuth auth: %v", errRegister) + } + registry.GetGlobalRegistry().RegisterClient(oauth.ID, oauth.Provider, []*registry.ModelInfo{{ID: responsesBridgeUpstreamModel}}) + t.Cleanup(func() { + registry.GetGlobalRegistry().UnregisterClient(oauth.ID) + }) + base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{PassthroughHeaders: true}, manager) + return NewClaudeCodeAPIHandler(base), executor +} + +func serveClaudeMessages(t *testing.T, handler *ClaudeCodeAPIHandler, target, body string) *httptest.ResponseRecorder { + t.Helper() + router := gin.New() + router.POST("/v1/messages", handler.ClaudeMessages) + req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, req) + return recorder +} + +func TestShouldUseClaudeResponsesBridge(t *testing.T) { + tests := []struct { + name string + clientModel string + upstreamModel string + wantResponses bool + }{ + {name: "encoded GPT model", clientModel: responsesBridgeClientModel, upstreamModel: responsesBridgeUpstreamModel, wantResponses: true}, + {name: "plain GPT model", clientModel: responsesBridgeUpstreamModel, upstreamModel: responsesBridgeUpstreamModel, wantResponses: false}, + {name: "native Claude model", clientModel: "claude-sonnet-4-6", upstreamModel: "claude-sonnet-4-6", wantResponses: false}, + {name: "encoded non-GPT model", clientModel: "claude-fable-5-dd-orp-5.2-inimeg", upstreamModel: "gemini-2.5-pro", wantResponses: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldUseClaudeResponsesBridge(tt.clientModel, tt.upstreamModel); got != tt.wantResponses { + t.Fatalf("shouldUseClaudeResponsesBridge(%q, %q) = %v, want %v", tt.clientModel, tt.upstreamModel, got, tt.wantResponses) + } + }) + } +} + +func TestIsClaudeCompactRequest(t *testing.T) { + tests := []struct { + name string + body string + want bool + }{ + { + name: "summary wording without internal sentinels", + body: `{"messages":[{"role":"user","content":"hello"},{"role":"user","content":"Your task is to create a detailed summary of the conversation so far. Do not use tools. Preserve the API details."}]}`, + want: false, + }, + { + name: "recent portion array content", + body: `{"messages":[{"role":"user","content":[{"type":"text","text":"CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.\n\nYour task is to create a detailed summary of the recent portion of the conversation.\n\nREMINDER: Do NOT call any tools."}]}]}`, + want: true, + }, + { + name: "Claude Code automatic compact prompt", + body: `{"messages":[{"role":"user","content":"hello"},{"role":"assistant","content":"hi"},{"role":"user","content":"CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.\n\nYour task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions.\n\nREMINDER: Do NOT call any tools."}]}`, + want: true, + }, + { + name: "ordinary summary request", + body: `{"messages":[{"role":"user","content":"Please create a detailed summary of this conversation."}]}`, + want: false, + }, + { + name: "ordinary request matching compact summary wording", + body: `{"messages":[{"role":"user","content":"Your task is to create a detailed summary of the conversation so far."}]}`, + want: false, + }, + { + name: "compact text not final", + body: `{"messages":[{"role":"user","content":"CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.\n\nYour task is to create a detailed summary of the conversation so far.\n\nREMINDER: Do NOT call any tools."},{"role":"user","content":"continue"}]}`, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isClaudeCompactRequest([]byte(tt.body)); got != tt.want { + t.Fatalf("isClaudeCompactRequest() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestClaudeMessagesResponsesBridgeNonStreaming(t *testing.T) { + handler, executor := newResponsesBridgeHandler(t) + body := `{"model":"claude-fable-5-dd-los-6.5-tpg","max_tokens":128,"messages":[{"role":"user","content":"hello"}]}` + recorder := serveClaudeMessages(t, handler, "/v1/messages?source=localhost", body) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + if got := gjson.Get(recorder.Body.String(), "model").String(); got != responsesBridgeClientModel { + t.Fatalf("response model = %q, want %q; body=%s", got, responsesBridgeClientModel, recorder.Body.String()) + } + if got := recorder.Header().Get("X-Upstream"); got != "responses" { + t.Fatalf("X-Upstream = %q, want responses", got) + } + + gotReq, gotOpts := executor.request, executor.options + if gotReq.Model != responsesBridgeUpstreamModel { + t.Fatalf("executor model = %q, want %q", gotReq.Model, responsesBridgeUpstreamModel) + } + if got := gjson.GetBytes(gotReq.Payload, "model").String(); got != responsesBridgeUpstreamModel { + t.Fatalf("payload model = %q, want %q; payload=%s", got, responsesBridgeUpstreamModel, gotReq.Payload) + } + if gotOpts.Alt != constant.ClaudeResponsesBridgeAlt { + t.Fatalf("Alt = %q, want %q", gotOpts.Alt, constant.ClaudeResponsesBridgeAlt) + } + if gotOpts.SourceFormat != sdktranslator.FormatClaude || gotOpts.ResponseFormat != sdktranslator.FormatClaude { + t.Fatalf("formats = %q -> %q, want claude -> claude", gotOpts.SourceFormat, gotOpts.ResponseFormat) + } + if got := gotOpts.Query.Get("source"); got != "localhost" { + t.Fatalf("query source = %q, want localhost", got) + } + if _, pinned := gotOpts.Metadata[coreexecutor.PinnedAuthMetadataKey]; pinned { + t.Fatalf("bridge unexpectedly pinned an auth: %#v", gotOpts.Metadata) + } + + body = `{"model":"claude-fable-5-dd-los-6.5-tpg","max_tokens":128,"messages":[{"role":"user","content":"Your task is to create a detailed summary of the conversation so far."}]}` + recorder = serveClaudeMessages(t, handler, "/v1/messages", body) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + if strings.Contains(recorder.Body.String(), "") { + t.Fatalf("ordinary summary request returned compaction marker: %s", recorder.Body.String()) + } + gotOpts = executor.options + if gotOpts.Alt != constant.ClaudeResponsesBridgeAlt { + t.Fatalf("Alt = %q, want %q", gotOpts.Alt, constant.ClaudeResponsesBridgeAlt) + } +} + +func TestClaudeMessagesResponsesBridgeStreaming(t *testing.T) { + handler, executor := newResponsesBridgeHandler(t) + body := `{"model":"claude-fable-5-dd-los-6.5-tpg","stream":true,"max_tokens":128,"messages":[{"role":"user","content":"hello"}]}` + recorder := serveClaudeMessages(t, handler, "/v1/messages", body) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + if !strings.Contains(recorder.Body.String(), `"model":"`+responsesBridgeClientModel+`"`) { + t.Fatalf("stream did not restore client model; body=%s", recorder.Body.String()) + } + if got := recorder.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/event-stream") { + t.Fatalf("Content-Type = %q, want text/event-stream", got) + } + + gotOpts := executor.options + if !gotOpts.Stream { + t.Fatal("executor stream option = false, want true") + } + if _, pinned := gotOpts.Metadata[coreexecutor.PinnedAuthMetadataKey]; pinned { + t.Fatalf("stream bridge unexpectedly pinned an auth: %#v", gotOpts.Metadata) + } +} + +func TestClaudeMessagesResponsesBridgeCompactNonStreaming(t *testing.T) { + handler, executor := newResponsesBridgeHandler(t) + body := `{"model":"claude-fable-5-dd-los-6.5-tpg","max_tokens":128,"messages":[{"role":"user","content":"hello"},{"role":"assistant","content":"hi"},{"role":"user","content":"CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.\n\nYour task is to create a detailed summary of the conversation so far. Preserve the API details.\n\nREMINDER: Do NOT call any tools."}]}` + recorder := serveClaudeMessages(t, handler, "/v1/messages", body) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + marker := gjson.Get(recorder.Body.String(), "content.0.text").String() + _, capsule, found, errStrip := stripClaudeCompactionCiphertext(marker) + if errStrip != nil || !found { + t.Fatalf("decode compact marker: found=%v err=%v marker=%q", found, errStrip, marker) + } + if len(capsule.Output) != 1 { + t.Fatal("expected one compact block") + } + if got := gjson.Get(recorder.Body.String(), "model").String(); got != responsesBridgeClientModel { + t.Fatalf("response model = %q, want %q", got, responsesBridgeClientModel) + } + + gotReq, gotOpts := executor.request, executor.options + if gotOpts.Alt != "" || gotOpts.SourceFormat != sdktranslator.FormatCodex { + t.Fatalf("compact must use normal Codex execution: %#v", gotOpts) + } + if gotOpts.ResponseFormat != sdktranslator.FormatOpenAIResponse { + t.Fatalf("ResponseFormat = %q, want %q", gotOpts.ResponseFormat, sdktranslator.FormatOpenAIResponse) + } + if got := gjson.GetBytes(gotReq.Payload, "input.2.content.0.text").String(); !strings.Contains(got, "Preserve the API details") { + t.Fatalf("custom compact instruction was not preserved; payload=%s", gotReq.Payload) + } + if _, pinned := gotOpts.Metadata[coreexecutor.PinnedAuthMetadataKey]; pinned { + t.Fatalf("initial compact request unexpectedly pinned an auth: %#v", gotOpts.Metadata) + } +} + +func TestClaudeMessagesResponsesBridgeCompactStreamingUsesBufferedCompactEndpoint(t *testing.T) { + handler, executor := newResponsesBridgeHandler(t) + body := `{"model":"claude-fable-5-dd-los-6.5-tpg","stream":true,"max_tokens":128,"messages":[{"role":"user","content":"CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.\n\nYour task is to create a detailed summary of the conversation so far.\n\nREMINDER: Do NOT call any tools."}]}` + recorder := serveClaudeMessages(t, handler, "/v1/messages", body) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + if !strings.Contains(recorder.Body.String(), "event: message_start") || !strings.Contains(recorder.Body.String(), "gAAAA") { + t.Fatalf("unexpected compact SSE: %s", recorder.Body.String()) + } + if executor.executeCalls != 1 || executor.streamCalls != 0 { + t.Fatalf("executor calls = execute:%d stream:%d, want 1/0", executor.executeCalls, executor.streamCalls) + } +} + +func TestClaudeMessagesResponsesBridgeRehydratesCompactCapsule(t *testing.T) { + handler, executor := newResponsesBridgeHandler(t) + compactBody := `{"model":"claude-fable-5-dd-los-6.5-tpg","max_tokens":128,"messages":[{"role":"user","content":"CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.\n\nYour task is to create a detailed summary of the conversation so far.\n\nREMINDER: Do NOT call any tools."}]}` + compactRecorder := serveClaudeMessages(t, handler, "/v1/messages", compactBody) + marker := gjson.Get(compactRecorder.Body.String(), "content.0.text").String() + if marker == "" { + t.Fatalf("compact response has no marker: %s", compactRecorder.Body.String()) + } + + followupBody := `{"model":"claude-fable-5-dd-los-6.5-tpg","max_tokens":128,"messages":[{"role":"assistant","content":` + string(mustJSONMarshalForTest(t, marker)) + `},{"role":"user","content":"continue"}]}` + followupRecorder := serveClaudeMessages(t, handler, "/v1/messages", followupBody) + + if followupRecorder.Code != http.StatusOK { + t.Fatalf("follow-up status = %d; body=%s", followupRecorder.Code, followupRecorder.Body.String()) + } + gotReq, gotOpts := executor.request, executor.options + if gotOpts.Alt != constant.ClaudeResponsesBridgeAlt { + t.Fatalf("follow-up Alt = %q, want %q", gotOpts.Alt, constant.ClaudeResponsesBridgeAlt) + } + if pinned := gotOpts.Metadata[coreexecutor.PinnedAuthMetadataKey]; pinned != nil { + t.Fatalf("compaction replay pinned auth = %#v, want no pin", pinned) + } + if got := gjson.GetBytes(gotReq.Payload, constant.ClaudeResponsesCompactionField+".output.0.type").String(); got != "compaction" { + t.Fatalf("replay compaction item type = %q; payload=%s", got, gotReq.Payload) + } + if got := gjson.GetBytes(gotReq.Payload, "messages.0.content").String(); got != "continue" { + t.Fatalf("capsule message was not removed; payload=%s", gotReq.Payload) + } +} + +func TestPrepareClaudeCompactionReplayUsesNewestCanonicalWindow(t *testing.T) { + firstMarker := mustClaudeCompactionMarkerForTest(t) + secondCompactRequest := map[string]any{ + "model": responsesBridgeUpstreamModel, + "messages": []any{ + map[string]any{"role": "assistant", "content": firstMarker}, + map[string]any{"role": "user", "content": "after first compaction"}, + }, + } + preparedSecond, firstReplay, errPrepareSecond := prepareClaudeCompactionReplay(mustJSONMarshalForTest(t, secondCompactRequest), responsesBridgeUpstreamModel) + if errPrepareSecond != nil { + t.Fatalf("prepare second compaction: %v", errPrepareSecond) + } + if firstReplay == nil || !strings.Contains(string(mustJSONMarshalForTest(t, firstReplay.Output)), "encrypted") { + t.Fatalf("first replay was not recovered: %#v", firstReplay) + } + if got := gjson.GetBytes(preparedSecond, "messages.0.content").String(); got != "after first compaction" { + t.Fatalf("second compact input retained capsule message: %s", preparedSecond) + } + + secondCompact := []byte(`{"id":"resp_compact_2","object":"response.compaction","output":[{"type":"message","role":"user","content":[{"type":"input_text","text":"new canonical window"}]},{"type":"compaction","encrypted_content":"gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="}],"usage":{"input_tokens":100,"output_tokens":20}}`) + _, secondMarker, errBuild := buildClaudeCompactResponse(secondCompact, responsesBridgeClientModel, responsesBridgeUpstreamModel) + if errBuild != nil { + t.Fatalf("build second compact response: %v", errBuild) + } + followup := map[string]any{ + "model": responsesBridgeUpstreamModel, + "messages": []any{ + map[string]any{"role": "assistant", "content": secondMarker}, + map[string]any{"role": "user", "content": "continue"}, + }, + } + _, secondReplay, errPrepareFollowup := prepareClaudeCompactionReplay(mustJSONMarshalForTest(t, followup), responsesBridgeUpstreamModel) + if errPrepareFollowup != nil { + t.Fatalf("prepare follow-up after second compaction: %v", errPrepareFollowup) + } + encodedReplay := string(mustJSONMarshalForTest(t, secondReplay.Output)) + if !strings.Contains(encodedReplay, "gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==") { + t.Fatalf("new canonical window missing: %s", encodedReplay) + } + if strings.Contains(encodedReplay, firstMarker) { + t.Fatalf("prior compacted window accumulated into replacement: %s", encodedReplay) + } +} + +func mustClaudeCompactionMarkerForTest(t *testing.T) string { + t.Helper() + return rawCiphertextForTest(2) +} + +func mustJSONMarshalForTest(t *testing.T, value any) []byte { + t.Helper() + encoded, errMarshal := json.Marshal(value) + if errMarshal != nil { + t.Fatalf("marshal test value: %v", errMarshal) + } + return encoded +} + +func TestRewriteClaudeBridgeResponseModelLeavesOtherEventsAlone(t *testing.T) { + input := []byte("event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n\n") + if got := rewriteClaudeBridgeResponseModel(input, responsesBridgeClientModel); string(got) != string(input) { + t.Fatalf("non-message_start event changed:\ngot=%s\nwant=%s", got, input) + } +}