diff --git a/internal/api/server.go b/internal/api/server.go index 1a0147f6..4b825af3 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -60,10 +60,8 @@ type ServerOption func(*serverOptionConfig) func defaultRequestLoggerFactory(cfg *config.Config, configPath string) logging.RequestLogger { configDir := filepath.Dir(configPath) - if base := util.WritablePath(); base != "" { - return logging.NewFileRequestLogger(cfg.RequestLog, filepath.Join(base, "logs"), configDir, cfg.ErrorLogsMaxFiles) - } - return logging.NewFileRequestLogger(cfg.RequestLog, "logs", configDir, cfg.ErrorLogsMaxFiles) + logsDir := logging.ResolveLogDirectory(cfg) + return logging.NewFileRequestLogger(cfg.RequestLog, logsDir, configDir, cfg.ErrorLogsMaxFiles) } // WithMiddleware appends additional Gin middleware during server construction. diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 06653210..f5c18aa1 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -7,9 +7,11 @@ import ( "path/filepath" "strings" "testing" + "time" gin "github.com/gin-gonic/gin" proxyconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + internallogging "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" @@ -109,3 +111,100 @@ func TestAmpProviderModelRoutes(t *testing.T) { }) } } + +func TestDefaultRequestLoggerFactory_UsesResolvedLogDirectory(t *testing.T) { + t.Setenv("WRITABLE_PATH", "") + t.Setenv("writable_path", "") + + originalWD, errGetwd := os.Getwd() + if errGetwd != nil { + t.Fatalf("failed to get current working directory: %v", errGetwd) + } + + tmpDir := t.TempDir() + if errChdir := os.Chdir(tmpDir); errChdir != nil { + t.Fatalf("failed to switch working directory: %v", errChdir) + } + defer func() { + if errChdirBack := os.Chdir(originalWD); errChdirBack != nil { + t.Fatalf("failed to restore working directory: %v", errChdirBack) + } + }() + + // Force ResolveLogDirectory to fallback to auth-dir/logs by making ./logs not a writable directory. + if errWriteFile := os.WriteFile(filepath.Join(tmpDir, "logs"), []byte("not-a-directory"), 0o644); errWriteFile != nil { + t.Fatalf("failed to create blocking logs file: %v", errWriteFile) + } + + configDir := filepath.Join(tmpDir, "config") + if errMkdirConfig := os.MkdirAll(configDir, 0o755); errMkdirConfig != nil { + t.Fatalf("failed to create config dir: %v", errMkdirConfig) + } + configPath := filepath.Join(configDir, "config.yaml") + + authDir := filepath.Join(tmpDir, "auth") + if errMkdirAuth := os.MkdirAll(authDir, 0o700); errMkdirAuth != nil { + t.Fatalf("failed to create auth dir: %v", errMkdirAuth) + } + + cfg := &proxyconfig.Config{ + SDKConfig: proxyconfig.SDKConfig{ + RequestLog: false, + }, + AuthDir: authDir, + ErrorLogsMaxFiles: 10, + } + + logger := defaultRequestLoggerFactory(cfg, configPath) + fileLogger, ok := logger.(*internallogging.FileRequestLogger) + if !ok { + t.Fatalf("expected *FileRequestLogger, got %T", logger) + } + + errLog := fileLogger.LogRequestWithOptions( + "/v1/chat/completions", + http.MethodPost, + map[string][]string{"Content-Type": []string{"application/json"}}, + []byte(`{"input":"hello"}`), + http.StatusBadGateway, + map[string][]string{"Content-Type": []string{"application/json"}}, + []byte(`{"error":"upstream failure"}`), + nil, + nil, + nil, + true, + "issue-1711", + time.Now(), + time.Now(), + ) + if errLog != nil { + t.Fatalf("failed to write forced error request log: %v", errLog) + } + + authLogsDir := filepath.Join(authDir, "logs") + authEntries, errReadAuthDir := os.ReadDir(authLogsDir) + if errReadAuthDir != nil { + t.Fatalf("failed to read auth logs dir %s: %v", authLogsDir, errReadAuthDir) + } + foundErrorLogInAuthDir := false + for _, entry := range authEntries { + if strings.HasPrefix(entry.Name(), "error-") && strings.HasSuffix(entry.Name(), ".log") { + foundErrorLogInAuthDir = true + break + } + } + if !foundErrorLogInAuthDir { + t.Fatalf("expected forced error log in auth fallback dir %s, got entries: %+v", authLogsDir, authEntries) + } + + configLogsDir := filepath.Join(configDir, "logs") + configEntries, errReadConfigDir := os.ReadDir(configLogsDir) + if errReadConfigDir != nil && !os.IsNotExist(errReadConfigDir) { + t.Fatalf("failed to inspect config logs dir %s: %v", configLogsDir, errReadConfigDir) + } + for _, entry := range configEntries { + if strings.HasPrefix(entry.Name(), "error-") && strings.HasSuffix(entry.Name(), ".log") { + t.Fatalf("unexpected forced error log in config dir %s", configLogsDir) + } + } +} diff --git a/internal/auth/claude/utls_transport.go b/internal/auth/claude/utls_transport.go index 2cb840b2..27ec87e1 100644 --- a/internal/auth/claude/utls_transport.go +++ b/internal/auth/claude/utls_transport.go @@ -15,7 +15,7 @@ import ( "golang.org/x/net/proxy" ) -// utlsRoundTripper implements http.RoundTripper using utls with Firefox fingerprint +// utlsRoundTripper implements http.RoundTripper using utls with Chrome fingerprint // to bypass Cloudflare's TLS fingerprinting on Anthropic domains. type utlsRoundTripper struct { // mu protects the connections map and pending map @@ -100,7 +100,9 @@ func (t *utlsRoundTripper) getOrCreateConnection(host, addr string) (*http2.Clie return h2Conn, nil } -// createConnection creates a new HTTP/2 connection with Firefox TLS fingerprint +// createConnection creates a new HTTP/2 connection with Chrome TLS fingerprint. +// Chrome's TLS fingerprint is closer to Node.js/OpenSSL (which real Claude Code uses) +// than Firefox, reducing the mismatch between TLS layer and HTTP headers. func (t *utlsRoundTripper) createConnection(host, addr string) (*http2.ClientConn, error) { conn, err := t.dialer.Dial("tcp", addr) if err != nil { @@ -108,7 +110,7 @@ func (t *utlsRoundTripper) createConnection(host, addr string) (*http2.ClientCon } tlsConfig := &tls.Config{ServerName: host} - tlsConn := tls.UClient(conn, tlsConfig, tls.HelloFirefox_Auto) + tlsConn := tls.UClient(conn, tlsConfig, tls.HelloChrome_Auto) if err := tlsConn.Handshake(); err != nil { conn.Close() @@ -156,7 +158,7 @@ func (t *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) } // NewAnthropicHttpClient creates an HTTP client that bypasses TLS fingerprinting -// for Anthropic domains by using utls with Firefox fingerprint. +// for Anthropic domains by using utls with Chrome fingerprint. // It accepts optional SDK configuration for proxy settings. func NewAnthropicHttpClient(cfg *config.SDKConfig) *http.Client { return &http.Client{ diff --git a/internal/misc/claude_code_instructions.txt b/internal/misc/claude_code_instructions.txt index 25bf2ab7..f771b4e1 100644 --- a/internal/misc/claude_code_instructions.txt +++ b/internal/misc/claude_code_instructions.txt @@ -1 +1 @@ -[{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude.","cache_control":{"type":"ephemeral"}}] \ No newline at end of file +[{"type":"text","text":"You are a Claude agent, built on Anthropic's Claude Agent SDK.","cache_control":{"type":"ephemeral","ttl":"1h"}}] \ No newline at end of file diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 681e7b8d..fcb3a9c9 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -6,6 +6,9 @@ import ( "compress/flate" "compress/gzip" "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" "fmt" "io" "net/http" @@ -36,7 +39,9 @@ type ClaudeExecutor struct { cfg *config.Config } -const claudeToolPrefix = "proxy_" +// claudeToolPrefix is empty to match real Claude Code behavior (no tool name prefix). +// Previously "proxy_" was used but this is a detectable fingerprint difference. +const claudeToolPrefix = "" func NewClaudeExecutor(cfg *config.Config) *ClaudeExecutor { return &ClaudeExecutor{cfg: cfg} } @@ -696,17 +701,13 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, ginHeaders = ginCtx.Request.Header } - promptCachingBeta := "prompt-caching-2024-07-31" - baseBetas := "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14," + promptCachingBeta + baseBetas := "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05" if val := strings.TrimSpace(ginHeaders.Get("Anthropic-Beta")); val != "" { baseBetas = val if !strings.Contains(val, "oauth") { baseBetas += ",oauth-2025-04-20" } } - if !strings.Contains(baseBetas, promptCachingBeta) { - baseBetas += "," + promptCachingBeta - } // Merge extra betas from request body if len(extraBetas) > 0 { @@ -727,8 +728,7 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, misc.EnsureHeader(r.Header, ginHeaders, "Anthropic-Version", "2023-06-01") misc.EnsureHeader(r.Header, ginHeaders, "Anthropic-Dangerous-Direct-Browser-Access", "true") misc.EnsureHeader(r.Header, ginHeaders, "X-App", "cli") - // Values below match Claude Code 2.1.44 / @anthropic-ai/sdk 0.74.0 (captured 2026-02-17). - misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Helper-Method", "stream") + // 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-Version", hdrDefault(hd.RuntimeVersion, "v24.3.0")) misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Package-Version", hdrDefault(hd.PackageVersion, "0.74.0")) @@ -737,7 +737,18 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Arch", mapStainlessArch()) misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Os", mapStainlessOS()) misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Timeout", hdrDefault(hd.Timeout, "600")) - misc.EnsureHeader(r.Header, ginHeaders, "User-Agent", hdrDefault(hd.UserAgent, "claude-cli/2.1.44 (external, sdk-cli)")) + // For User-Agent, only forward the client's header if it's already a Claude Code client. + // Non-Claude-Code clients (e.g. curl, OpenAI SDKs) get the default Claude Code User-Agent + // to avoid leaking the real client identity during cloaking. + clientUA := "" + if ginHeaders != nil { + clientUA = ginHeaders.Get("User-Agent") + } + if isClaudeCodeClient(clientUA) { + r.Header.Set("User-Agent", clientUA) + } else { + r.Header.Set("User-Agent", hdrDefault(hd.UserAgent, "claude-cli/2.1.63 (external, cli)")) + } r.Header.Set("Connection", "keep-alive") r.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") if stream { @@ -771,22 +782,7 @@ func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { } func checkSystemInstructions(payload []byte) []byte { - system := gjson.GetBytes(payload, "system") - claudeCodeInstructions := `[{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude."}]` - if system.IsArray() { - if gjson.GetBytes(payload, "system.0.text").String() != "You are Claude Code, Anthropic's official CLI for Claude." { - system.ForEach(func(_, part gjson.Result) bool { - if part.Get("type").String() == "text" { - claudeCodeInstructions, _ = sjson.SetRaw(claudeCodeInstructions, "-1", part.Raw) - } - return true - }) - payload, _ = sjson.SetRawBytes(payload, "system", []byte(claudeCodeInstructions)) - } - } else { - payload, _ = sjson.SetRawBytes(payload, "system", []byte(claudeCodeInstructions)) - } - return payload + return checkSystemInstructionsWithMode(payload, false) } func isClaudeOAuthToken(apiKey string) bool { @@ -1060,33 +1056,67 @@ func injectFakeUserID(payload []byte, apiKey string, useCache bool) []byte { return payload } -// checkSystemInstructionsWithMode injects Claude Code system prompt. -// In strict mode, it replaces all user system messages. -// In non-strict mode (default), it prepends to existing system messages. +// generateBillingHeader creates the x-anthropic-billing-header text block that +// real Claude Code prepends to every system prompt array. +// Format: x-anthropic-billing-header: cc_version=.; cc_entrypoint=cli; cch=; +func generateBillingHeader(payload []byte) string { + // Generate a deterministic cch hash from the payload content (system + messages + tools). + // Real Claude Code uses a 5-char hex hash that varies per request. + h := sha256.Sum256(payload) + cch := hex.EncodeToString(h[:])[:5] + + // Build hash: 3-char hex, matches the pattern seen in real requests (e.g. "a43") + buildBytes := make([]byte, 2) + _, _ = rand.Read(buildBytes) + buildHash := hex.EncodeToString(buildBytes)[:3] + + return fmt.Sprintf("x-anthropic-billing-header: cc_version=2.1.63.%s; cc_entrypoint=cli; cch=%s;", buildHash, cch) +} + +// checkSystemInstructionsWithMode injects Claude Code system prompt to match +// the real Claude Code request format: +// system[0]: billing header (no cache_control) +// system[1]: "You are a Claude agent, built on Anthropic's Claude Agent SDK." (with cache_control) +// system[2..]: user's system messages (with cache_control on last) func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte { system := gjson.GetBytes(payload, "system") - claudeCodeInstructions := `[{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude."}]` + + billingText := generateBillingHeader(payload) + billingBlock := fmt.Sprintf(`{"type":"text","text":"%s"}`, billingText) + agentBlock := `{"type":"text","text":"You are a Claude agent, built on Anthropic's Claude Agent SDK.","cache_control":{"type":"ephemeral","ttl":"1h"}}` if strictMode { - // Strict mode: replace all system messages with Claude Code prompt only - payload, _ = sjson.SetRawBytes(payload, "system", []byte(claudeCodeInstructions)) + // Strict mode: billing header + agent identifier only + result := "[" + billingBlock + "," + agentBlock + "]" + payload, _ = sjson.SetRawBytes(payload, "system", []byte(result)) return payload } - // Non-strict mode (default): prepend Claude Code prompt to existing system messages - if system.IsArray() { - if gjson.GetBytes(payload, "system.0.text").String() != "You are Claude Code, Anthropic's official CLI for Claude." { - system.ForEach(func(_, part gjson.Result) bool { - if part.Get("type").String() == "text" { - claudeCodeInstructions, _ = sjson.SetRaw(claudeCodeInstructions, "-1", part.Raw) - } - return true - }) - payload, _ = sjson.SetRawBytes(payload, "system", []byte(claudeCodeInstructions)) - } - } else { - payload, _ = sjson.SetRawBytes(payload, "system", []byte(claudeCodeInstructions)) + // Non-strict mode: billing header + agent identifier + user system messages + // Skip if already injected + firstText := gjson.GetBytes(payload, "system.0.text").String() + if strings.HasPrefix(firstText, "x-anthropic-billing-header:") { + return payload } + + result := "[" + billingBlock + "," + agentBlock + if system.IsArray() { + system.ForEach(func(_, part gjson.Result) bool { + if part.Get("type").String() == "text" { + // Add cache_control with ttl to user system messages if not present + partJSON := part.Raw + if !part.Get("cache_control").Exists() { + partJSON, _ = sjson.Set(partJSON, "cache_control.type", "ephemeral") + partJSON, _ = sjson.Set(partJSON, "cache_control.ttl", "1h") + } + result += "," + partJSON + } + return true + }) + } + result += "]" + + payload, _ = sjson.SetRawBytes(payload, "system", []byte(result)) return payload } diff --git a/internal/runtime/executor/cloak_utils.go b/internal/runtime/executor/cloak_utils.go index 560ff880..2a3433ac 100644 --- a/internal/runtime/executor/cloak_utils.go +++ b/internal/runtime/executor/cloak_utils.go @@ -9,17 +9,18 @@ import ( "github.com/google/uuid" ) -// userIDPattern matches Claude Code format: user_[64-hex]_account__session_[uuid-v4] -var userIDPattern = regexp.MustCompile(`^user_[a-fA-F0-9]{64}_account__session_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) +// userIDPattern matches Claude Code format: user_[64-hex]_account_[uuid]_session_[uuid] +var userIDPattern = regexp.MustCompile(`^user_[a-fA-F0-9]{64}_account_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_session_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) // generateFakeUserID generates a fake user ID in Claude Code format. -// Format: user_[64-hex-chars]_account__session_[UUID-v4] +// Format: user_[64-hex-chars]_account_[UUID-v4]_session_[UUID-v4] func generateFakeUserID() string { hexBytes := make([]byte, 32) _, _ = rand.Read(hexBytes) hexPart := hex.EncodeToString(hexBytes) - uuidPart := uuid.New().String() - return "user_" + hexPart + "_account__session_" + uuidPart + accountUUID := uuid.New().String() + sessionUUID := uuid.New().String() + return "user_" + hexPart + "_account_" + accountUUID + "_session_" + sessionUUID } // isValidUserID checks if a user ID matches Claude Code format. diff --git a/internal/thinking/provider/kimi/apply.go b/internal/thinking/provider/kimi/apply.go index 4e68eaa2..ff47c46d 100644 --- a/internal/thinking/provider/kimi/apply.go +++ b/internal/thinking/provider/kimi/apply.go @@ -1,8 +1,7 @@ // Package kimi implements thinking configuration for Kimi (Moonshot AI) models. // -// Kimi models use the OpenAI-compatible reasoning_effort format with discrete levels -// (low/medium/high). The provider strips any existing thinking config and applies -// the unified ThinkingConfig in OpenAI format. +// Kimi models use the OpenAI-compatible reasoning_effort format for enabled thinking +// levels, but use thinking.type=disabled when thinking is explicitly turned off. package kimi import ( @@ -17,8 +16,8 @@ import ( // Applier implements thinking.ProviderApplier for Kimi models. // // Kimi-specific behavior: -// - Output format: reasoning_effort (string: low/medium/high) -// - Uses OpenAI-compatible format +// - Enabled thinking: reasoning_effort (string levels) +// - Disabled thinking: thinking.type="disabled" // - Supports budget-to-level conversion type Applier struct{} @@ -35,11 +34,19 @@ func init() { // Apply applies thinking configuration to Kimi request body. // -// Expected output format: +// Expected output format (enabled): // // { // "reasoning_effort": "high" // } +// +// Expected output format (disabled): +// +// { +// "thinking": { +// "type": "disabled" +// } +// } func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) { if thinking.IsUserDefinedModel(modelInfo) { return applyCompatibleKimi(body, config) @@ -60,8 +67,13 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo * } effort = string(config.Level) case thinking.ModeNone: - // Kimi uses "none" to disable thinking - effort = string(thinking.LevelNone) + // Respect clamped fallback level for models that cannot disable thinking. + if config.Level != "" && config.Level != thinking.LevelNone { + effort = string(config.Level) + break + } + // Kimi requires explicit disabled thinking object. + return applyDisabledThinking(body) case thinking.ModeBudget: // Convert budget to level using threshold mapping level, ok := thinking.ConvertBudgetToLevel(config.Budget) @@ -79,12 +91,7 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo * if effort == "" { return body, nil } - - result, err := sjson.SetBytes(body, "reasoning_effort", effort) - if err != nil { - return body, fmt.Errorf("kimi thinking: failed to set reasoning_effort: %w", err) - } - return result, nil + return applyReasoningEffort(body, effort) } // applyCompatibleKimi applies thinking config for user-defined Kimi models. @@ -101,7 +108,9 @@ func applyCompatibleKimi(body []byte, config thinking.ThinkingConfig) ([]byte, e } effort = string(config.Level) case thinking.ModeNone: - effort = string(thinking.LevelNone) + if config.Level == "" || config.Level == thinking.LevelNone { + return applyDisabledThinking(body) + } if config.Level != "" { effort = string(config.Level) } @@ -118,9 +127,33 @@ func applyCompatibleKimi(body []byte, config thinking.ThinkingConfig) ([]byte, e return body, nil } - result, err := sjson.SetBytes(body, "reasoning_effort", effort) - if err != nil { - return body, fmt.Errorf("kimi thinking: failed to set reasoning_effort: %w", err) + return applyReasoningEffort(body, effort) +} + +func applyReasoningEffort(body []byte, effort string) ([]byte, error) { + result, errDeleteThinking := sjson.DeleteBytes(body, "thinking") + if errDeleteThinking != nil { + return body, fmt.Errorf("kimi thinking: failed to clear thinking object: %w", errDeleteThinking) + } + result, errSetEffort := sjson.SetBytes(result, "reasoning_effort", effort) + if errSetEffort != nil { + return body, fmt.Errorf("kimi thinking: failed to set reasoning_effort: %w", errSetEffort) + } + return result, nil +} + +func applyDisabledThinking(body []byte) ([]byte, error) { + result, errDeleteThinking := sjson.DeleteBytes(body, "thinking") + if errDeleteThinking != nil { + return body, fmt.Errorf("kimi thinking: failed to clear thinking object: %w", errDeleteThinking) + } + result, errDeleteEffort := sjson.DeleteBytes(result, "reasoning_effort") + if errDeleteEffort != nil { + return body, fmt.Errorf("kimi thinking: failed to clear reasoning_effort: %w", errDeleteEffort) + } + result, errSetType := sjson.SetBytes(result, "thinking.type", "disabled") + if errSetType != nil { + return body, fmt.Errorf("kimi thinking: failed to set thinking.type: %w", errSetType) } return result, nil } diff --git a/internal/thinking/provider/kimi/apply_test.go b/internal/thinking/provider/kimi/apply_test.go new file mode 100644 index 00000000..707f11c7 --- /dev/null +++ b/internal/thinking/provider/kimi/apply_test.go @@ -0,0 +1,72 @@ +package kimi + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + "github.com/tidwall/gjson" +) + +func TestApply_ModeNone_UsesDisabledThinking(t *testing.T) { + applier := NewApplier() + modelInfo := ®istry.ModelInfo{ + ID: "kimi-k2.5", + Thinking: ®istry.ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true}, + } + body := []byte(`{"model":"kimi-k2.5","reasoning_effort":"none","thinking":{"type":"enabled","budget_tokens":2048}}`) + + out, errApply := applier.Apply(body, thinking.ThinkingConfig{Mode: thinking.ModeNone}, modelInfo) + if errApply != nil { + t.Fatalf("Apply() error = %v", errApply) + } + if got := gjson.GetBytes(out, "thinking.type").String(); got != "disabled" { + t.Fatalf("thinking.type = %q, want %q, body=%s", got, "disabled", string(out)) + } + if gjson.GetBytes(out, "thinking.budget_tokens").Exists() { + t.Fatalf("thinking.budget_tokens should be removed, body=%s", string(out)) + } + if gjson.GetBytes(out, "reasoning_effort").Exists() { + t.Fatalf("reasoning_effort should be removed in ModeNone, body=%s", string(out)) + } +} + +func TestApply_ModeLevel_UsesReasoningEffort(t *testing.T) { + applier := NewApplier() + modelInfo := ®istry.ModelInfo{ + ID: "kimi-k2.5", + Thinking: ®istry.ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true}, + } + body := []byte(`{"model":"kimi-k2.5","thinking":{"type":"disabled"}}`) + + out, errApply := applier.Apply(body, thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelHigh}, modelInfo) + if errApply != nil { + t.Fatalf("Apply() error = %v", errApply) + } + if got := gjson.GetBytes(out, "reasoning_effort").String(); got != "high" { + t.Fatalf("reasoning_effort = %q, want %q, body=%s", got, "high", string(out)) + } + if gjson.GetBytes(out, "thinking").Exists() { + t.Fatalf("thinking should be removed when reasoning_effort is used, body=%s", string(out)) + } +} + +func TestApply_UserDefinedModeNone_UsesDisabledThinking(t *testing.T) { + applier := NewApplier() + modelInfo := ®istry.ModelInfo{ + ID: "custom-kimi-model", + UserDefined: true, + } + body := []byte(`{"model":"custom-kimi-model","reasoning_effort":"none"}`) + + out, errApply := applier.Apply(body, thinking.ThinkingConfig{Mode: thinking.ModeNone}, modelInfo) + if errApply != nil { + t.Fatalf("Apply() error = %v", errApply) + } + if got := gjson.GetBytes(out, "thinking.type").String(); got != "disabled" { + t.Fatalf("thinking.type = %q, want %q, body=%s", got, "disabled", string(out)) + } + if gjson.GetBytes(out, "reasoning_effort").Exists() { + t.Fatalf("reasoning_effort should be removed in ModeNone, body=%s", string(out)) + } +} diff --git a/internal/thinking/strip.go b/internal/thinking/strip.go index eb691715..514ab3f8 100644 --- a/internal/thinking/strip.go +++ b/internal/thinking/strip.go @@ -37,6 +37,11 @@ func StripThinkingConfig(body []byte, provider string) []byte { paths = []string{"request.generationConfig.thinkingConfig"} case "openai": paths = []string{"reasoning_effort"} + case "kimi": + paths = []string{ + "reasoning_effort", + "thinking", + } case "codex": paths = []string{"reasoning.effort"} case "iflow": diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index 223a2559..64e41fb5 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -46,15 +46,23 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) if systemsResult.IsArray() { systemResults := systemsResult.Array() message := `{"type":"message","role":"developer","content":[]}` + contentIndex := 0 for i := 0; i < len(systemResults); i++ { systemResult := systemResults[i] systemTypeResult := systemResult.Get("type") if systemTypeResult.String() == "text" { - message, _ = sjson.Set(message, fmt.Sprintf("content.%d.type", i), "input_text") - message, _ = sjson.Set(message, fmt.Sprintf("content.%d.text", i), systemResult.Get("text").String()) + text := systemResult.Get("text").String() + if strings.HasPrefix(text, "x-anthropic-billing-header: ") { + continue + } + message, _ = sjson.Set(message, fmt.Sprintf("content.%d.type", contentIndex), "input_text") + message, _ = sjson.Set(message, fmt.Sprintf("content.%d.text", contentIndex), text) + contentIndex++ } } - template, _ = sjson.SetRaw(template, "input.-1", message) + if contentIndex > 0 { + template, _ = sjson.SetRaw(template, "input.-1", message) + } } // Process messages and transform their contents to appropriate formats.