diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 2a32526b..a5984385 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -38,7 +38,8 @@ import ( // ClaudeExecutor is a stateless executor for Anthropic Claude over the messages API. // If api_key is unavailable on auth, it falls back to legacy via ClientAdapter. type ClaudeExecutor struct { - cfg *config.Config + cfg *config.Config + requestLogProvider string } // claudeToolPrefix is empty to match real Claude Code behavior (no tool name prefix). @@ -153,6 +154,13 @@ func NewClaudeExecutor(cfg *config.Config) *ClaudeExecutor { return &ClaudeExecu func (e *ClaudeExecutor) Identifier() string { return "claude" } +func (e *ClaudeExecutor) upstreamRequestLogProvider() string { + if provider := strings.TrimSpace(e.requestLogProvider); provider != "" { + return provider + } + return e.Identifier() +} + // PrepareRequest injects Claude credentials into the outgoing HTTP request. func (e *ClaudeExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { if req == nil { @@ -286,7 +294,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if err != nil { return resp, err } - if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, false, extraBetas, e.cfg); errHeaders != nil { + if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, false, extraBetas, e.cfg, opts.Headers); errHeaders != nil { return resp, errHeaders } var authID, authLabel, authType, authValue string @@ -300,7 +308,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r Method: http.MethodPost, Headers: httpReq.Header.Clone(), Body: bodyForUpstream, - Provider: e.Identifier(), + Provider: e.upstreamRequestLogProvider(), AuthID: authID, AuthLabel: authLabel, AuthType: authType, @@ -475,7 +483,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if err != nil { return nil, err } - if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, true, extraBetas, e.cfg); errHeaders != nil { + if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, true, extraBetas, e.cfg, opts.Headers); errHeaders != nil { return nil, errHeaders } var authID, authLabel, authType, authValue string @@ -489,7 +497,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A Method: http.MethodPost, Headers: httpReq.Header.Clone(), Body: bodyForUpstream, - Provider: e.Identifier(), + Provider: e.upstreamRequestLogProvider(), AuthID: authID, AuthLabel: authLabel, AuthType: authType, @@ -731,7 +739,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut if err != nil { return cliproxyexecutor.Response{}, err } - if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, false, extraBetas, e.cfg); errHeaders != nil { + if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, false, extraBetas, e.cfg, opts.Headers); errHeaders != nil { return cliproxyexecutor.Response{}, errHeaders } var authID, authLabel, authType, authValue string @@ -745,7 +753,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut Method: http.MethodPost, Headers: httpReq.Header.Clone(), Body: body, - Provider: e.Identifier(), + Provider: e.upstreamRequestLogProvider(), AuthID: authID, AuthLabel: authLabel, AuthType: authType, @@ -1048,7 +1056,7 @@ func decodeResponseBody(body io.ReadCloser, contentEncoding string) (io.ReadClos return body, nil } -func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, stream bool, extraBetas []string, cfg *config.Config) error { +func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, stream bool, extraBetas []string, cfg *config.Config, incomingHeaders http.Header) error { if r == nil { return nil } @@ -1074,22 +1082,23 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, } r.Header.Set("Content-Type", "application/json") - var ginHeaders http.Header - if ginCtx, ok := r.Context().Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil { - ginHeaders = ginCtx.Request.Header + if incomingHeaders == nil { + if ginCtx, ok := r.Context().Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil { + incomingHeaders = ginCtx.Request.Header + } } stabilizeDeviceProfile := helps.ClaudeDeviceProfileStabilizationEnabled(cfg) var deviceProfile helps.ClaudeDeviceProfile if stabilizeDeviceProfile { var errDeviceProfile error - deviceProfile, errDeviceProfile = helps.ResolveClaudeDeviceProfileRequired(r.Context(), auth, apiKey, ginHeaders, cfg) + deviceProfile, errDeviceProfile = helps.ResolveClaudeDeviceProfileRequired(r.Context(), auth, apiKey, incomingHeaders, cfg) if errDeviceProfile != nil { return errDeviceProfile } } baseBetas := "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28" - if val := strings.TrimSpace(ginHeaders.Get("Anthropic-Beta")); val != "" { + if val := strings.TrimSpace(strings.Join(incomingHeaders.Values("Anthropic-Beta"), ",")); val != "" { baseBetas = val if !strings.Contains(val, "oauth") { baseBetas += ",oauth-2025-04-20" @@ -1118,26 +1127,26 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, } r.Header.Set("Anthropic-Beta", baseBetas) - misc.EnsureHeader(r.Header, ginHeaders, "Anthropic-Version", "2023-06-01") + misc.EnsureHeader(r.Header, incomingHeaders, "Anthropic-Version", "2023-06-01") // Only set browser access header for API key mode; real Claude Code CLI does not send it. if useAPIKey { - misc.EnsureHeader(r.Header, ginHeaders, "Anthropic-Dangerous-Direct-Browser-Access", "true") + misc.EnsureHeader(r.Header, incomingHeaders, "Anthropic-Dangerous-Direct-Browser-Access", "true") } - misc.EnsureHeader(r.Header, ginHeaders, "X-App", "cli") + misc.EnsureHeader(r.Header, incomingHeaders, "X-App", "cli") // Values below match Claude Code 2.1.63 / @anthropic-ai/sdk 0.74.0 (updated 2026-02-28). - misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Retry-Count", "0") - misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Runtime", "node") - misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Lang", "js") - misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Timeout", hdrDefault(hd.Timeout, "600")) + misc.EnsureHeader(r.Header, incomingHeaders, "X-Stainless-Retry-Count", "0") + misc.EnsureHeader(r.Header, incomingHeaders, "X-Stainless-Runtime", "node") + misc.EnsureHeader(r.Header, incomingHeaders, "X-Stainless-Lang", "js") + misc.EnsureHeader(r.Header, incomingHeaders, "X-Stainless-Timeout", hdrDefault(hd.Timeout, "600")) // Session ID: stable per auth/apiKey, matches Claude Code's X-Claude-Code-Session-Id header. sessionID, errSessionID := helps.CachedSessionIDRequired(r.Context(), apiKey) if errSessionID != nil { return errSessionID } - misc.EnsureHeader(r.Header, ginHeaders, "X-Claude-Code-Session-Id", sessionID) + misc.EnsureHeader(r.Header, incomingHeaders, "X-Claude-Code-Session-Id", sessionID) // Per-request UUID, matches Claude Code's x-client-request-id for first-party API. if isAnthropicBase { - misc.EnsureHeader(r.Header, ginHeaders, "x-client-request-id", uuid.New().String()) + misc.EnsureHeader(r.Header, incomingHeaders, "x-client-request-id", uuid.New().String()) } r.Header.Set("Connection", "keep-alive") if stream { @@ -1156,7 +1165,7 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, if stabilizeDeviceProfile { helps.ApplyClaudeDeviceProfileHeaders(r, deviceProfile) } else { - helps.ApplyClaudeLegacyDeviceHeaders(r, ginHeaders, cfg) + helps.ApplyClaudeLegacyDeviceHeaders(r, incomingHeaders, cfg) } var attrs map[string]string if auth != nil { diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index e5046388..ccb9e7df 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -104,7 +104,7 @@ func TestApplyClaudeHeaders_UsesConfiguredBaselineFingerprint(t *testing.T) { } req := newClaudeHeaderTestRequest(t, incoming) - applyClaudeHeaders(req, auth, "key-baseline", false, nil, cfg) + applyClaudeHeaders(req, auth, "key-baseline", false, nil, cfg, nil) assertClaudeFingerprint(t, req.Header, "evil-client/9.9", "9.9.9", "v24.5.0", "Linux", "x64") if got := req.Header.Get("X-Stainless-Timeout"); got != "900" { @@ -140,7 +140,7 @@ func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) { "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(firstReq, auth, "key-upgrade", false, nil, cfg) + applyClaudeHeaders(firstReq, auth, "key-upgrade", false, nil, cfg, nil) assertClaudeFingerprint(t, firstReq.Header, "claude-cli/2.1.62 (external, cli)", "0.74.0", "v24.3.0", "MacOS", "arm64") thirdPartyReq := newClaudeHeaderTestRequest(t, http.Header{ @@ -150,7 +150,7 @@ func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) { "X-Stainless-Os": []string{"Windows"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(thirdPartyReq, auth, "key-upgrade", false, nil, cfg) + applyClaudeHeaders(thirdPartyReq, auth, "key-upgrade", false, nil, cfg, nil) assertClaudeFingerprint(t, thirdPartyReq.Header, "claude-cli/2.1.62 (external, cli)", "0.74.0", "v24.3.0", "MacOS", "arm64") higherReq := newClaudeHeaderTestRequest(t, http.Header{ @@ -160,7 +160,7 @@ func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) { "X-Stainless-Os": []string{"MacOS"}, "X-Stainless-Arch": []string{"arm64"}, }) - applyClaudeHeaders(higherReq, auth, "key-upgrade", false, nil, cfg) + applyClaudeHeaders(higherReq, auth, "key-upgrade", false, nil, cfg, nil) assertClaudeFingerprint(t, higherReq.Header, "claude-cli/2.1.63 (external, cli)", "0.75.0", "v24.4.0", "MacOS", "arm64") lowerReq := newClaudeHeaderTestRequest(t, http.Header{ @@ -170,7 +170,7 @@ func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) { "X-Stainless-Os": []string{"Windows"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(lowerReq, auth, "key-upgrade", false, nil, cfg) + applyClaudeHeaders(lowerReq, auth, "key-upgrade", false, nil, cfg, nil) assertClaudeFingerprint(t, lowerReq.Header, "claude-cli/2.1.63 (external, cli)", "0.75.0", "v24.4.0", "MacOS", "arm64") } @@ -202,7 +202,7 @@ func TestApplyClaudeHeaders_DoesNotDowngradeConfiguredBaselineOnFirstClaudeClien "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(olderClaudeReq, auth, "key-baseline-floor", false, nil, cfg) + applyClaudeHeaders(olderClaudeReq, auth, "key-baseline-floor", false, nil, cfg, nil) assertClaudeFingerprint(t, olderClaudeReq.Header, "claude-cli/2.1.70 (external, cli)", "0.80.0", "v24.5.0", "MacOS", "arm64") newerClaudeReq := newClaudeHeaderTestRequest(t, http.Header{ @@ -212,7 +212,7 @@ func TestApplyClaudeHeaders_DoesNotDowngradeConfiguredBaselineOnFirstClaudeClien "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(newerClaudeReq, auth, "key-baseline-floor", false, nil, cfg) + applyClaudeHeaders(newerClaudeReq, auth, "key-baseline-floor", false, nil, cfg, nil) assertClaudeFingerprint(t, newerClaudeReq.Header, "claude-cli/2.1.71 (external, cli)", "0.81.0", "v24.6.0", "MacOS", "arm64") } @@ -254,7 +254,7 @@ func TestApplyClaudeHeaders_UpgradesCachedSoftwareFingerprintWhenBaselineAdvance "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(officialReq, auth, "key-baseline-reload", false, nil, oldCfg) + applyClaudeHeaders(officialReq, auth, "key-baseline-reload", false, nil, oldCfg, nil) assertClaudeFingerprint(t, officialReq.Header, "claude-cli/2.1.71 (external, cli)", "0.81.0", "v24.6.0", "MacOS", "arm64") thirdPartyReq := newClaudeHeaderTestRequest(t, http.Header{ @@ -264,7 +264,7 @@ func TestApplyClaudeHeaders_UpgradesCachedSoftwareFingerprintWhenBaselineAdvance "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(thirdPartyReq, auth, "key-baseline-reload", false, nil, newCfg) + applyClaudeHeaders(thirdPartyReq, auth, "key-baseline-reload", false, nil, newCfg, nil) assertClaudeFingerprint(t, thirdPartyReq.Header, "claude-cli/2.1.77 (external, cli)", "0.87.0", "v24.8.0", "MacOS", "arm64") } @@ -296,7 +296,7 @@ func TestApplyClaudeHeaders_LearnsOfficialFingerprintAfterCustomBaselineFallback "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(thirdPartyReq, auth, "key-custom-baseline-learning", false, nil, cfg) + applyClaudeHeaders(thirdPartyReq, auth, "key-custom-baseline-learning", false, nil, cfg, nil) assertClaudeFingerprint(t, thirdPartyReq.Header, "my-gateway/1.0", "custom-pkg", "custom-runtime", "MacOS", "arm64") officialReq := newClaudeHeaderTestRequest(t, http.Header{ @@ -306,7 +306,7 @@ func TestApplyClaudeHeaders_LearnsOfficialFingerprintAfterCustomBaselineFallback "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(officialReq, auth, "key-custom-baseline-learning", false, nil, cfg) + applyClaudeHeaders(officialReq, auth, "key-custom-baseline-learning", false, nil, cfg, nil) assertClaudeFingerprint(t, officialReq.Header, "claude-cli/2.1.77 (external, cli)", "0.87.0", "v24.8.0", "MacOS", "arm64") postLearningThirdPartyReq := newClaudeHeaderTestRequest(t, http.Header{ @@ -316,7 +316,7 @@ func TestApplyClaudeHeaders_LearnsOfficialFingerprintAfterCustomBaselineFallback "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(postLearningThirdPartyReq, auth, "key-custom-baseline-learning", false, nil, cfg) + applyClaudeHeaders(postLearningThirdPartyReq, auth, "key-custom-baseline-learning", false, nil, cfg, nil) assertClaudeFingerprint(t, postLearningThirdPartyReq.Header, "claude-cli/2.1.77 (external, cli)", "0.87.0", "v24.8.0", "MacOS", "arm64") } @@ -448,7 +448,7 @@ func TestApplyClaudeHeaders_ThirdPartyBaselineThenOfficialUpgradeKeepsPinnedPlat "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(thirdPartyReq, auth, "key-third-party-then-official", false, nil, cfg) + applyClaudeHeaders(thirdPartyReq, auth, "key-third-party-then-official", false, nil, cfg, nil) assertClaudeFingerprint(t, thirdPartyReq.Header, "claude-cli/2.1.70 (external, cli)", "0.80.0", "v24.5.0", "MacOS", "arm64") officialReq := newClaudeHeaderTestRequest(t, http.Header{ @@ -458,7 +458,7 @@ func TestApplyClaudeHeaders_ThirdPartyBaselineThenOfficialUpgradeKeepsPinnedPlat "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(officialReq, auth, "key-third-party-then-official", false, nil, cfg) + applyClaudeHeaders(officialReq, auth, "key-third-party-then-official", false, nil, cfg, nil) assertClaudeFingerprint(t, officialReq.Header, "claude-cli/2.1.77 (external, cli)", "0.87.0", "v24.8.0", "MacOS", "arm64") } @@ -490,7 +490,7 @@ func TestApplyClaudeHeaders_DisableDeviceProfileStabilization(t *testing.T) { "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(firstReq, auth, "key-disable-stability", false, nil, cfg) + applyClaudeHeaders(firstReq, auth, "key-disable-stability", false, nil, cfg, nil) assertClaudeFingerprint(t, firstReq.Header, "claude-cli/2.1.62 (external, cli)", "0.74.0", "v24.3.0", "Linux", "x64") thirdPartyReq := newClaudeHeaderTestRequest(t, http.Header{ @@ -500,7 +500,7 @@ func TestApplyClaudeHeaders_DisableDeviceProfileStabilization(t *testing.T) { "X-Stainless-Os": []string{"Windows"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(thirdPartyReq, auth, "key-disable-stability", false, nil, cfg) + applyClaudeHeaders(thirdPartyReq, auth, "key-disable-stability", false, nil, cfg, nil) assertClaudeFingerprint(t, thirdPartyReq.Header, "claude-cli/2.1.60 (external, cli)", "0.10.0", "v18.0.0", "Windows", "x64") lowerReq := newClaudeHeaderTestRequest(t, http.Header{ @@ -510,7 +510,7 @@ func TestApplyClaudeHeaders_DisableDeviceProfileStabilization(t *testing.T) { "X-Stainless-Os": []string{"Windows"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(lowerReq, auth, "key-disable-stability", false, nil, cfg) + applyClaudeHeaders(lowerReq, auth, "key-disable-stability", false, nil, cfg, nil) assertClaudeFingerprint(t, lowerReq.Header, "claude-cli/2.1.61 (external, cli)", "0.73.0", "v24.2.0", "Windows", "x64") } @@ -541,7 +541,7 @@ func TestApplyClaudeHeaders_LegacyModePreservesConfiguredUserAgentOverrideForCla "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }) - applyClaudeHeaders(req, auth, "key-legacy-ua-override", false, nil, cfg) + applyClaudeHeaders(req, auth, "key-legacy-ua-override", false, nil, cfg, nil) assertClaudeFingerprint(t, req.Header, "config-ua/1.0", "0.74.0", "v24.3.0", "Linux", "x64") } @@ -570,7 +570,7 @@ func TestApplyClaudeHeaders_LegacyModeFallsBackToRuntimeOSArchWhenMissing(t *tes req := newClaudeHeaderTestRequest(t, http.Header{ "User-Agent": []string{"curl/8.7.1"}, }) - applyClaudeHeaders(req, auth, "key-legacy-runtime-os-arch", false, nil, cfg) + applyClaudeHeaders(req, auth, "key-legacy-runtime-os-arch", false, nil, cfg, nil) assertClaudeFingerprint(t, req.Header, "claude-cli/2.1.60 (external, cli)", "0.70.0", "v22.0.0", helps.MapStainlessOS(), helps.MapStainlessArch()) } @@ -597,7 +597,7 @@ func TestApplyClaudeHeaders_UnsetStabilizationAlsoUsesLegacyRuntimeOSArchFallbac req := newClaudeHeaderTestRequest(t, http.Header{ "User-Agent": []string{"curl/8.7.1"}, }) - applyClaudeHeaders(req, auth, "key-unset-runtime-os-arch", false, nil, cfg) + applyClaudeHeaders(req, auth, "key-unset-runtime-os-arch", false, nil, cfg, nil) assertClaudeFingerprint(t, req.Header, "claude-cli/2.1.60 (external, cli)", "0.70.0", "v22.0.0", helps.MapStainlessOS(), helps.MapStainlessArch()) } diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go index 3d7b13e2..13966b7e 100644 --- a/internal/runtime/executor/kimi_executor.go +++ b/internal/runtime/executor/kimi_executor.go @@ -34,7 +34,12 @@ type KimiExecutor struct { } // NewKimiExecutor creates a new Kimi executor. -func NewKimiExecutor(cfg *config.Config) *KimiExecutor { return &KimiExecutor{cfg: cfg} } +func NewKimiExecutor(cfg *config.Config) *KimiExecutor { + return &KimiExecutor{ + ClaudeExecutor: ClaudeExecutor{cfg: cfg, requestLogProvider: "kimi"}, + cfg: cfg, + } +} // Identifier returns the executor identifier. func (e *KimiExecutor) Identifier() string { return "kimi" } diff --git a/internal/runtime/executor/kimi_executor_test.go b/internal/runtime/executor/kimi_executor_test.go index f3de70f1..824fb192 100644 --- a/internal/runtime/executor/kimi_executor_test.go +++ b/internal/runtime/executor/kimi_executor_test.go @@ -1,11 +1,136 @@ package executor import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" "testing" + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" "github.com/tidwall/gjson" ) +func TestNewKimiExecutorInitializesDelegatedClaudeConfig(t *testing.T) { + cfg := &config.Config{SDKConfig: config.SDKConfig{RequestLog: true}} + executor := NewKimiExecutor(cfg) + + if executor.cfg != cfg { + t.Fatal("Kimi executor config was not initialized") + } + if executor.ClaudeExecutor.cfg != cfg { + t.Fatal("delegated Claude executor config was not initialized") + } +} + +func TestKimiExecutorClaudeStreamForwardsAnthropicBetaAndLogsUpstream(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages?beta=true", nil) + + var upstreamRequest *http.Request + ctx := context.WithValue(context.Background(), "gin", ginCtx) + ctx = context.WithValue(ctx, "cliproxy.roundtripper", kimiRoundTripperFunc(func(req *http.Request) (*http.Response, error) { + upstreamRequest = req.Clone(req.Context()) + upstreamRequest.Header = req.Header.Clone() + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "event: message_start\n" + + `data: {"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","model":"kimi-k3","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":0}}}` + "\n\n" + + "event: message_stop\n" + + `data: {"type":"message_stop"}` + "\n\n", + )), + }, nil + })) + + cfg := &config.Config{SDKConfig: config.SDKConfig{RequestLog: true}} + executor := NewKimiExecutor(cfg) + auth := &cliproxyauth.Auth{ + ID: "kimi-test-auth", + Attributes: map[string]string{}, + Metadata: map[string]any{"access_token": "test-token"}, + } + payload := []byte(`{"model":"kimi-k3","max_tokens":32,"messages":[{"role":"user","content":"hello"}]}`) + result, err := executor.ExecuteStream(ctx, auth, cliproxyexecutor.Request{ + Model: "kimi-k3", + Payload: payload, + }, cliproxyexecutor.Options{ + Stream: true, + SourceFormat: sdktranslator.FormatClaude, + OriginalRequest: payload, + Headers: http.Header{ + "Anthropic-Beta": []string{"client-beta-one", "client-beta-two"}, + }, + }) + if err != nil { + t.Fatalf("ExecuteStream() error = %v", err) + } + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatalf("stream chunk error = %v", chunk.Err) + } + } + if upstreamRequest == nil { + t.Fatal("upstream request was not captured") + } + if got := upstreamRequest.URL.String(); got != "https://api.kimi.com/coding/v1/messages?beta=true" { + t.Fatalf("upstream URL = %q, want Kimi messages endpoint", got) + } + upstreamBetas := upstreamRequest.Header.Get("Anthropic-Beta") + for _, beta := range []string{"client-beta-one", "client-beta-two", "oauth-2025-04-20", "interleaved-thinking-2025-05-14"} { + if !strings.Contains(upstreamBetas, beta) { + t.Fatalf("Anthropic-Beta = %q, want %q", upstreamBetas, beta) + } + } + + rawAPIRequest, existsRequest := ginCtx.Get("API_REQUEST") + apiRequest, okRequest := rawAPIRequest.([]byte) + if !existsRequest || !okRequest { + t.Fatalf("API_REQUEST = %#v, want captured bytes", rawAPIRequest) + } + apiRequestText := string(apiRequest) + for _, want := range []string{ + "=== API REQUEST 1 ===", + "Upstream URL: https://api.kimi.com/coding/v1/messages?beta=true", + "Auth: provider=kimi", + "Anthropic-Beta: " + upstreamBetas, + `"model":"kimi-k3"`, + } { + if !strings.Contains(apiRequestText, want) { + t.Fatalf("API_REQUEST = %q, want %q", apiRequestText, want) + } + } + if strings.Contains(apiRequestText, "") { + t.Fatalf("API_REQUEST = %q, want captured upstream request", apiRequestText) + } + + rawAPIResponse, existsResponse := ginCtx.Get("API_RESPONSE") + apiResponse, okResponse := rawAPIResponse.([]byte) + if !existsResponse || !okResponse { + t.Fatalf("API_RESPONSE = %#v, want captured bytes", rawAPIResponse) + } + apiResponseText := string(apiResponse) + for _, want := range []string{"=== API RESPONSE 1 ===", "Status: 200", `data: {"type":"message_stop"}`} { + if !strings.Contains(apiResponseText, want) { + t.Fatalf("API_RESPONSE = %q, want %q", apiResponseText, want) + } + } +} + +type kimiRoundTripperFunc func(*http.Request) (*http.Response, error) + +func (f kimiRoundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + func TestNormalizeKimiToolMessageLinks_UsesCallIDFallback(t *testing.T) { body := []byte(`{ "messages":[