diff --git a/internal/runtime/executor/claude_executor_t3_test.go b/internal/runtime/executor/claude_executor_t3_test.go new file mode 100644 index 00000000..903febf4 --- /dev/null +++ b/internal/runtime/executor/claude_executor_t3_test.go @@ -0,0 +1,106 @@ +package executor + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" + "github.com/tidwall/gjson" +) + +func TestClaudeExecutorT3CodePreservesNativeWire(t *testing.T) { + for _, mode := range []string{"execute", "stream", "count"} { + for _, scheduled := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/scheduled=%t", mode, scheduled), func(t *testing.T) { + helps.ResetClaudeDeviceProfileCache() + var body []byte + var headers http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ = io.ReadAll(r.Body) + headers = r.Header.Clone() + if mode == "count" { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"input_tokens":42}`) + } else if mode == "stream" { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_t3\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-5-5\",\"content\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n") + } else { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"msg_t3","type":"message","role":"assistant","model":"claude-sonnet-5-5","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`) + } + })) + defer server.Close() + suffix := "" + if scheduled { + suffix = ", workload/cron" + } + userAgent := "claude-cli/2.1.316 (external, t3-code, agent-sdk/0.3.276" + suffix + ")" + incoming := claudeNativeHelperHeaders("claude-code-20250219,"+claudeNativeHelperCoreBetas+",extended-cache-ttl-2025-04-11", "gzip, deflate, br, zstd") + incoming.Set("User-Agent", userAgent) + payload := []byte(`{"model":"claude-sonnet-5-5","max_tokens":1024,"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.316; cc_entrypoint=t3-code;"},{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK.","cache_control":{"type":"ephemeral","ttl":"1h"}},{"type":"text","text":"Caller instructions","cache_control":{"type":"ephemeral","ttl":"1h"}}],"tools":[{"name":"Bash","input_schema":{"type":"object"}}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}],"metadata":{"user_id":` + fmt.Sprintf("%q", claudeNativeHelperUserID) + `}}`) + executor := NewClaudeExecutor(&config.Config{}) + request := cliproxyexecutor.Request{Model: "claude-sonnet-5-5", Payload: payload} + options := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude, OriginalRequest: payload, Headers: incoming} + auth := claudeNativeHelperOAuthAuth(server.URL) + switch mode { + case "execute": + if _, errExecute := executor.Execute(context.Background(), auth, request, options); errExecute != nil { + t.Fatal(errExecute) + } + case "stream": + result, errStream := executor.ExecuteStream(context.Background(), auth, request, options) + if errStream != nil { + t.Fatal(errStream) + } + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatal(chunk.Err) + } + } + case "count": + if _, errCount := executor.countTokensUpstream(context.Background(), auth, request, options); errCount != nil { + t.Fatal(errCount) + } + } + if got := headers.Get("User-Agent"); got != userAgent { + t.Fatalf("User-Agent = %q, want %q", got, userAgent) + } + if mode == "count" { + if got := gjson.GetBytes(body, "system.#").Int(); got != 2 { + t.Fatalf("count system blocks = %d, want 2 (billing excluded)", got) + } + for i := 0; i < 2; i++ { + if got, want := gjson.GetBytes(body, fmt.Sprintf("system.%d", i)).Raw, gjson.GetBytes(payload, fmt.Sprintf("system.%d", i+1)).Raw; got != want { + t.Fatalf("count system block changed: got %s, want %s", got, want) + } + } + } else { + if got := gjson.GetBytes(body, "system.0.text").String(); !strings.Contains(got, "cc_entrypoint=t3-code;") { + t.Fatalf("billing attribution changed: %q", got) + } + if got := gjson.GetBytes(body, "system.#").Int(); got != 3 { + t.Fatalf("system blocks = %d, want 3", got) + } + for _, path := range []string{"system.1", "system.2"} { + if got, want := gjson.GetBytes(body, path).Raw, gjson.GetBytes(payload, path).Raw; got != want { + t.Fatalf("%s changed: got %s, want %s", path, got, want) + } + } + } + for _, path := range []string{"tools", "messages"} { + if got, want := gjson.GetBytes(body, path).Raw, gjson.GetBytes(payload, path).Raw; got != want { + t.Fatalf("%s changed: got %s, want %s", path, got, want) + } + } + }) + } + } +} diff --git a/internal/runtime/executor/helps/claude_client_detection.go b/internal/runtime/executor/helps/claude_client_detection.go index 5c734c31..5ff296ed 100644 --- a/internal/runtime/executor/helps/claude_client_detection.go +++ b/internal/runtime/executor/helps/claude_client_detection.go @@ -38,6 +38,7 @@ var claudeCodeSubclientByEntrypoint = map[string]string{ "bench": "claude-code-bench", "sdk-cli": "claude-code-cli-sdk", "sdk-ts": "claude-code-sdk-ts", + "t3-code": "t3-code", "sdk-py": "claude-code-sdk-py", "claude-vscode": "claude-code-vscode", "claude-code-github-action": "claude-code-gh-action", @@ -67,6 +68,7 @@ var nativeClaudeEntrypoints = map[string]bool{ "cli": true, "sdk-cli": true, "sdk-ts": true, + "t3-code": true, "claude-vscode": true, } diff --git a/internal/runtime/executor/helps/claude_client_detection_test.go b/internal/runtime/executor/helps/claude_client_detection_test.go index 1f99f905..5bf202ed 100644 --- a/internal/runtime/executor/helps/claude_client_detection_test.go +++ b/internal/runtime/executor/helps/claude_client_detection_test.go @@ -145,6 +145,9 @@ func TestDetectClaudeCodeRequestClassifiesEntrypoints(t *testing.T) { {name: "vscode-agent-sdk", userAgent: "claude-cli/2.1.280 (external, claude-vscode, agent-sdk/0.3.220)", entrypoint: "claude-vscode", subclient: "claude-code-vscode", agentSDKVersion: "0.3.220", native: true}, {name: "sdk-cli", userAgent: "claude-cli/2.1.280 (external, sdk-cli)", entrypoint: "sdk-cli", subclient: "claude-code-cli-sdk", native: true}, {name: "sdk-ts", userAgent: "claude-cli/2.1.280 (external, sdk-ts, agent-sdk/0.3.220)", entrypoint: "sdk-ts", subclient: "claude-code-sdk-ts", agentSDKVersion: "0.3.220", native: true}, + {name: "t3-code", userAgent: "claude-cli/2.1.316 (external, t3-code)", entrypoint: "t3-code", subclient: "t3-code", native: true}, + {name: "t3-code-sdk", userAgent: "claude-cli/2.1.316 (external, t3-code, agent-sdk/0.3.276)", entrypoint: "t3-code", subclient: "t3-code", agentSDKVersion: "0.3.276", native: true}, + {name: "t3-code-scheduled", userAgent: "claude-cli/2.1.316 (external, t3-code, agent-sdk/0.3.276, workload/cron)", entrypoint: "t3-code", subclient: "t3-code", agentSDKVersion: "0.3.276", native: true}, {name: "sdk-py", userAgent: "claude-cli/2.1.280 (external, sdk-py, agent-sdk/0.1.0)", entrypoint: "sdk-py", subclient: "claude-code-sdk-py", agentSDKVersion: "0.1.0"}, {name: "desktop", userAgent: "claude-cli/2.1.280 (external, claude-desktop)", entrypoint: "claude-desktop", subclient: "claude-desktop"}, {name: "desktop-third-party-inference", userAgent: "claude-cli/2.1.280 (external, claude-desktop-3p)", entrypoint: "claude-desktop-3p", subclient: "claude-desktop-3p"}, @@ -625,26 +628,50 @@ func TestDetectClaudeCodeRequestRecognizesAgentSDKHaiku55Helpers(t *testing.T) { webSearchBetas := "interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,per-turn-control-2026-07-01,mid-conversation-tool-changes-2026-07-01,inline-tools-2026-09-15,advisor-tool-2026-03-01,effort-2025-11-24,dangerous-tool-use-2026-09-03,extended-cache-ttl-2025-04-11" webSearchPayload := []byte(`{"max_tokens":1024,"messages":[{"role":"user","content":[{"type":"text","text":"search query"}]}],"metadata":{"user_id":` + string(encodedUserID) + `},"model":"claude-haiku-5-5","output_config":{"effort":"medium"},"stream":true,"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.304.2e0; cc_entrypoint=sdk-ts"},{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK."},{"type":"text","text":"Perform web search."}],"tool_choice":{"type":"auto"},"tools":[{"type":"web_search_20250305","name":"web_search"}]}`) - for _, tc := range []struct { - name string - betas string - payload []byte - }{ - {name: "sdk-ts title helper", betas: titleBetas, payload: titlePayload}, - {name: "sdk-ts web_search helper", betas: webSearchBetas, payload: webSearchPayload}, - } { - t.Run(tc.name, func(t *testing.T) { - headers := measuredClaudeCodeHelperHeaders(tc.betas) - headers.Set("User-Agent", "claude-cli/2.1.304 (external, sdk-ts, agent-sdk/0.3.276)") - headers.Set("X-Stainless-Package-Version", "0.128.0") - headers.Del("X-Client-Request-Id") + for _, entrypoint := range []string{"sdk-ts", "t3-code"} { + for _, tc := range []struct { + name string + betas string + payload []byte + }{ + {name: "sdk-ts title helper", betas: titleBetas, payload: titlePayload}, + {name: "sdk-ts web_search helper", betas: webSearchBetas, payload: webSearchPayload}, + } { + t.Run(entrypoint+"/"+tc.name, func(t *testing.T) { + payload := []byte(strings.ReplaceAll(string(tc.payload), "cc_entrypoint=sdk-ts", "cc_entrypoint="+entrypoint)) + headers := measuredClaudeCodeHelperHeaders(tc.betas) + headers.Set("User-Agent", "claude-cli/2.1.304 (external, "+entrypoint+", agent-sdk/0.3.276)") + headers.Set("X-Stainless-Package-Version", "0.128.0") + headers.Del("X-Client-Request-Id") + + detection := DetectClaudeCodeRequest(headers, payload, false) + if !detection.Confirmed || !detection.StrongSignals || !detection.NativeClient || !detection.HelperProfile { + t.Fatalf("detection = %#v, want confirmed Agent SDK Haiku 5.5 helper", detection) + } + if detection.Entrypoint != entrypoint || detection.Subclient != claudeCodeSubclientByEntrypoint[entrypoint] || detection.AgentSDKVersion != "0.3.276" { + t.Fatalf("detection identity = %#v, want matching Agent SDK identity / 0.3.276", detection) + } + }) + } + } +} - detection := DetectClaudeCodeRequest(headers, tc.payload, false) - if !detection.Confirmed || !detection.StrongSignals || !detection.NativeClient || !detection.HelperProfile { - t.Fatalf("detection = %#v, want confirmed sdk-ts Haiku 5.5 helper", detection) +func TestDetectT3CodeRequiresNativeSignals(t *testing.T) { + for _, missing := range []string{"X-App", "Anthropic-Beta", "metadata", "version"} { + t.Run(missing, func(t *testing.T) { + headers := confirmedClaudeCodeHeaders() + headers.Set("User-Agent", "claude-cli/2.1.316 (external, t3-code, agent-sdk/0.3.276)") + payload := claudeCodeDetectionPayload(validClaudeCodeMetadataUserID) + switch missing { + case "metadata": + payload = []byte(`{"messages":[]}`) + case "version": + headers.Set("User-Agent", "claude-cli/999.0.0 (external, t3-code)") + default: + headers.Del(missing) } - if detection.Entrypoint != "sdk-ts" || detection.Subclient != "claude-code-sdk-ts" || detection.AgentSDKVersion != "0.3.276" { - t.Fatalf("detection identity = %#v, want sdk-ts / claude-code-sdk-ts / 0.3.276", detection) + if detection := DetectClaudeCodeRequest(headers, payload, false); detection.Confirmed { + t.Fatalf("confirmed with missing %s: %#v", missing, detection) } }) }