From dcee14dd3c53378446ff487fc3e6ae53b40c8590 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 6 Aug 2026 16:05:58 +0800 Subject: [PATCH] feat(compat): preserve compat-mode thinking/signature blocks for API-key models - Added `is-compat` model metadata plumbing from config through executor and helpers, including hash computation. - Introduced a compatibility-aware translation path (`TranslateRequestWithAPIKeyModelCompatibility`) and wired it into Claude/Gemini/Codex/Interactions request flows. - Updated Claude message sanitization/translation behavior to keep empty-thinking compatibility blocks (including signatures) when `is-compat` is enabled, while keeping default behavior unchanged. --- config.example.yaml | 9 ++- internal/config/api_key_is_compat_test.go | 62 +++++++++++++++++++ internal/config/config_types.go | 13 +++- internal/modelconfig/model_hash.go | 4 +- internal/registry/model_registry.go | 4 ++ internal/runtime/executor/claude_executor.go | 7 ++- .../executor/claude_executor_execute.go | 6 +- .../executor/claude_executor_stream.go | 6 +- .../executor/claude_executor_tokens.go | 8 +-- .../executor/codex_executor_execute.go | 4 +- .../executor/codex_executor_request.go | 15 +++-- .../runtime/executor/codex_executor_stream.go | 2 +- .../runtime/executor/codex_executor_tokens.go | 2 +- internal/runtime/executor/gemini_executor.go | 26 ++++---- .../executor/helps/codex_multi_agent_v2.go | 39 ++++++++++++ .../executor/helps/model_capabilities.go | 7 +++ .../runtime/executor/xai_executor_request.go | 4 +- .../signature/claude_messages_sanitize.go | 16 ++++- .../claude_messages_sanitize_compat_test.go | 37 +++++++++++ .../claude_openai_compat_test.go | 22 +++++++ .../chat-completions/claude_openai_request.go | 19 +++++- .../claude_openai-responses_request.go | 22 ++++++- .../claude_openai_responses_compat_test.go | 29 +++++++++ .../codex/claude/codex_claude_compat_test.go | 24 +++++++ .../codex/claude/codex_claude_request.go | 28 ++++++--- .../claude/gemini_claude_compat_test.go | 25 ++++++++ .../gemini/claude/gemini_claude_request.go | 21 ++++++- .../claude/interactions_claude_compat_test.go | 21 +++++++ .../claude/interactions_claude_request.go | 21 +++++-- .../claude/openai_claude_compat_test.go | 21 +++++++ .../openai/claude/openai_claude_request.go | 17 ++++- .../watcher/diff/model_compat_hash_test.go | 16 +++++ internal/watcher/diff/models_summary.go | 12 +++- .../auth/api_key_model_capabilities.go | 11 +++- .../auth/api_key_model_compat_test.go | 29 +++++++++ sdk/cliproxy/service_models.go | 7 +++ 36 files changed, 547 insertions(+), 69 deletions(-) create mode 100644 internal/config/api_key_is_compat_test.go create mode 100644 internal/signature/claude_messages_sanitize_compat_test.go create mode 100644 internal/translator/claude/openai/chat-completions/claude_openai_compat_test.go create mode 100644 internal/translator/claude/openai/responses/claude_openai_responses_compat_test.go create mode 100644 internal/translator/codex/claude/codex_claude_compat_test.go create mode 100644 internal/translator/gemini/claude/gemini_claude_compat_test.go create mode 100644 internal/translator/interactions/claude/interactions_claude_compat_test.go create mode 100644 internal/translator/openai/claude/openai_claude_compat_test.go create mode 100644 internal/watcher/diff/model_compat_hash_test.go create mode 100644 sdk/cliproxy/auth/api_key_model_compat_test.go diff --git a/config.example.yaml b/config.example.yaml index 2db55c4d4..fff76b95e 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -305,7 +305,8 @@ nonstream-keepalive-interval: 0 # alias: "gemini-flash" # client alias mapped to the upstream model # display-name: "Gemini Flash" # optional catalog display name # max-context-length: 1048576 # optional: override Codex client context window metadata -# thinking: # optional: exact thinking capability for this configured model +# is-compat: false # optional: preserve thinking blocks with empty signatures for compatible upstreams +# thinking: # optional: exact thinking capability for this configured model # levels: ["high", "medium", "low", "none", "auto"] # excluded-models: # - "gemini-2.5-pro" # exclude specific models from this provider (exact match) @@ -331,7 +332,8 @@ nonstream-keepalive-interval: 0 # - name: "gemini-2.5-flash" # upstream model name # alias: "native-gemini-flash" # client alias mapped to the upstream model # max-context-length: 1048576 # optional: override Codex client context window metadata -# thinking: # optional: exact thinking capability for this configured model +# is-compat: false # optional: preserve thinking blocks with empty signatures for compatible upstreams +# thinking: # optional: exact thinking capability for this configured model # levels: ["high", "medium", "low", "none", "auto"] # excluded-models: # - "gemini-2.5-pro" @@ -358,6 +360,7 @@ nonstream-keepalive-interval: 0 # # MultiAgentV2 agent_message items into portable Responses message/user input # # for third-party Responses-compatible endpoints that reject agent_message. # # Default false keeps agent_message unchanged for native OpenAI/Codex endpoints. +# # It also preserves thinking blocks with empty signatures for compatible upstreams. # is-compat: false # thinking: # optional: exact thinking capability for this configured model # levels: ["xhigh", "high", "medium", "low"] @@ -386,6 +389,7 @@ nonstream-keepalive-interval: 0 # display-name: "Grok Latest" # optional catalog display name # max-context-length: 1048576 # optional: override Codex client context window metadata # force-mapping: true # optional: rewrite response model fields back to the alias +# is-compat: false # optional: preserve thinking blocks with empty signatures for compatible upstreams # thinking: # optional: exact thinking capability for this configured model # levels: ["xhigh", "high", "medium", "low"] # excluded-models: @@ -410,6 +414,7 @@ nonstream-keepalive-interval: 0 # display-name: "Claude Sonnet" # optional catalog display name # max-context-length: 1048576 # optional: override Codex client context window metadata # force-mapping: true # optional: rewrite response model fields back to the alias +# is-compat: false # optional: preserve thinking blocks with empty signatures for compatible upstreams # thinking: # optional: exact thinking capability for this configured model # levels: ["max", "xhigh", "high", "medium", "low", "minimal", "none", "auto"] # excluded-models: diff --git a/internal/config/api_key_is_compat_test.go b/internal/config/api_key_is_compat_test.go new file mode 100644 index 000000000..2463bd1db --- /dev/null +++ b/internal/config/api_key_is_compat_test.go @@ -0,0 +1,62 @@ +package config + +import ( + "testing" + + "gopkg.in/yaml.v3" +) + +func TestAPIKeyModelIsCompatConfigDecoding(t *testing.T) { + const yamlConfig = `gemini-api-key: + - models: + - name: gemini-upstream + alias: gemini-alias + is-compat: true + - name: gemini-native + alias: gemini-native +interactions-api-key: + - models: + - name: interactions-upstream + alias: interactions-alias + is-compat: true +xai-api-key: + - models: + - name: xai-upstream + alias: xai-alias + is-compat: true +claude-api-key: + - models: + - name: claude-upstream + alias: claude-alias + is-compat: true +codex-api-key: + - models: + - name: codex-upstream + alias: codex-alias + is-compat: true +` + + var cfg Config + if errDecode := yaml.Unmarshal([]byte(yamlConfig), &cfg); errDecode != nil { + t.Fatalf("decode error: %v", errDecode) + } + + if len(cfg.GeminiKey) != 1 || !cfg.GeminiKey[0].Models[0].IsCompat { + t.Fatalf("gemini-api-key IsCompat = %+v, want true", cfg.GeminiKey) + } + if cfg.GeminiKey[0].Models[1].IsCompat { + t.Fatal("gemini-api-key omitted IsCompat = true, want default false") + } + if len(cfg.InteractionsKey) != 1 || !cfg.InteractionsKey[0].Models[0].IsCompat { + t.Fatalf("interactions-api-key IsCompat = %+v, want true", cfg.InteractionsKey) + } + if len(cfg.XAIKey) != 1 || !cfg.XAIKey[0].Models[0].IsCompat { + t.Fatalf("xai-api-key IsCompat = %+v, want true", cfg.XAIKey) + } + if len(cfg.ClaudeKey) != 1 || !cfg.ClaudeKey[0].Models[0].IsCompat { + t.Fatalf("claude-api-key IsCompat = %+v, want true", cfg.ClaudeKey) + } + if len(cfg.CodexKey) != 1 || !cfg.CodexKey[0].Models[0].IsCompat { + t.Fatalf("codex-api-key IsCompat = %+v, want true", cfg.CodexKey) + } +} diff --git a/internal/config/config_types.go b/internal/config/config_types.go index c2c0a5478..93dc8dee4 100644 --- a/internal/config/config_types.go +++ b/internal/config/config_types.go @@ -388,6 +388,10 @@ type ClaudeModel struct { // ForceMapping rewrites upstream response model fields back to Alias. ForceMapping bool `yaml:"force-mapping,omitempty" json:"force-mapping,omitempty"` + // IsCompat preserves thinking blocks with empty signatures for compatible upstreams. + // Default false keeps the normal signature validation behavior. + IsCompat bool `yaml:"is-compat,omitempty" json:"is-compat,omitempty"` + // Thinking configures the thinking/reasoning capability for this model. Thinking *registry.ThinkingSupport `yaml:"thinking,omitempty" json:"thinking,omitempty"` } @@ -399,6 +403,7 @@ func (m ClaudeModel) GetAlias() string { return m.Alias } func (m ClaudeModel) GetDisplayName() string { return m.DisplayName } func (m ClaudeModel) GetMaxContextLength() int { return m.MaxContextLength } func (m ClaudeModel) GetForceMapping() bool { return m.ForceMapping } +func (m ClaudeModel) GetIsCompat() bool { return m.IsCompat } func (m ClaudeModel) GetThinking() *registry.ThinkingSupport { return m.Thinking } @@ -473,7 +478,8 @@ type CodexModel struct { // IsCompat converts Codex MultiAgentV2 agent_message items into portable // Responses message/user input when codex.optimize-multi-agent-v2 is also true. // Use this for third-party Responses-compatible endpoints that do not accept - // native agent_message items. Default false keeps agent_message unchanged. + // native agent_message items or empty-signature thinking blocks. Default false + // keeps the native behavior unchanged. IsCompat bool `yaml:"is-compat,omitempty" json:"is-compat,omitempty"` // Thinking configures the thinking/reasoning capability for this model. @@ -558,6 +564,10 @@ type GeminiModel struct { // ForceMapping rewrites upstream response model fields back to Alias. ForceMapping bool `yaml:"force-mapping,omitempty" json:"force-mapping,omitempty"` + // IsCompat preserves thinking blocks with empty signatures for compatible upstreams. + // Default false keeps the normal signature validation behavior. + IsCompat bool `yaml:"is-compat,omitempty" json:"is-compat,omitempty"` + // Thinking configures the thinking/reasoning capability for this model. Thinking *registry.ThinkingSupport `yaml:"thinking,omitempty" json:"thinking,omitempty"` } @@ -569,6 +579,7 @@ func (m GeminiModel) GetAlias() string { return m.Alias } func (m GeminiModel) GetDisplayName() string { return m.DisplayName } func (m GeminiModel) GetMaxContextLength() int { return m.MaxContextLength } func (m GeminiModel) GetForceMapping() bool { return m.ForceMapping } +func (m GeminiModel) GetIsCompat() bool { return m.IsCompat } func (m GeminiModel) GetThinking() *registry.ThinkingSupport { return m.Thinking } diff --git a/internal/modelconfig/model_hash.go b/internal/modelconfig/model_hash.go index ed5575125..679d4e18f 100644 --- a/internal/modelconfig/model_hash.go +++ b/internal/modelconfig/model_hash.go @@ -50,7 +50,7 @@ func ComputeClaudeModelsHash(models []config.ClaudeModel) string { if name == "" && alias == "" { continue } - out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|" + fmt.Sprintf("force-mapping=%t", model.ForceMapping) + thinkingHashSuffix(model.Thinking)) + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|" + fmt.Sprintf("force-mapping=%t", model.ForceMapping) + "|" + fmt.Sprintf("is-compat=%t", model.IsCompat) + thinkingHashSuffix(model.Thinking)) } }) return hashJoined(keys) @@ -80,7 +80,7 @@ func ComputeGeminiModelsHash(models []config.GeminiModel) string { if name == "" && alias == "" { continue } - out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|" + fmt.Sprintf("force-mapping=%t", model.ForceMapping) + thinkingHashSuffix(model.Thinking)) + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|" + fmt.Sprintf("force-mapping=%t", model.ForceMapping) + "|" + fmt.Sprintf("is-compat=%t", model.IsCompat) + thinkingHashSuffix(model.Thinking)) } }) return hashJoined(keys) diff --git a/internal/registry/model_registry.go b/internal/registry/model_registry.go index ee7ed86b1..2b5035bda 100644 --- a/internal/registry/model_registry.go +++ b/internal/registry/model_registry.go @@ -77,6 +77,10 @@ type ModelInfo struct { // array (e.g., openai-compatibility.*.models[], *-api-key.models[]). // UserDefined models have thinking configuration passed through without validation. UserDefined bool `json:"-"` + + // IsCompat enables compatibility handling for this configured API-key model. + // It is internal metadata and is not exposed in model listings. + IsCompat bool `json:"-"` } // ModelConfig holds optional runtime overrides for a model definition. diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index aeb059d3e..d2aef64bc 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -67,11 +67,12 @@ func shouldSanitizeClaudeMessagesForUpstream(baseModel string) bool { return sigcompat.SignatureProviderFromModelName(baseModel) == sigcompat.SignatureProviderClaude } -func sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx context.Context, body []byte, baseModel string) []byte { +func sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx context.Context, body []byte, baseModel string, preserveEmptyThinkingBlocks ...bool) []byte { sanitized := body - if shouldSanitizeClaudeMessagesForUpstream(baseModel) { + preserveEmpty := len(preserveEmptyThinkingBlocks) > 0 && preserveEmptyThinkingBlocks[0] + if shouldSanitizeClaudeMessagesForUpstream(baseModel) || preserveEmpty { var report sigcompat.SignatureSanitizeReport - sanitized, report = sigcompat.SanitizeClaudeMessagesForClaudeUpstream(body, baseModel) + sanitized, report = sigcompat.SanitizeClaudeMessagesForClaudeUpstream(body, baseModel, preserveEmptyThinkingBlocks...) logClaudeSignatureSanitizeReport(ctx, baseModel, report) } return sanitizeClaudeWebSearchDomains(sanitized) diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go index 30a87c68c..c8436e481 100644 --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -50,8 +50,8 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if oauthToken { claudeSessionID = helps.ClaudeAgentSessionUUIDForRequest(incomingHeaders, originalPayload, req.Payload, confirmedClaudeCode, opts.Metadata, req.Metadata) } - originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, upstreamStream) - body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, upstreamStream) + originalTranslated := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, upstreamStream, helps.APIKeyModelIsCompat(req)) + body := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, upstreamStream, helps.APIKeyModelIsCompat(req)) body = helps.SetStringIfDifferent(body, "model", upstreamModel) body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) @@ -128,7 +128,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r mcpAliases := resolveClaudeMCPAliasOptions(ctx) bodyForUpstream, oauthToolNamesReverseMap = prepareClaudeOAuthToolNamesForUpstream(bodyForUpstream, mcpAliases) } - bodyForUpstream = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, bodyForUpstream, baseModel) + bodyForUpstream = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, bodyForUpstream, baseModel, helps.APIKeyModelIsCompat(req)) if oauthToken { bodyForUpstream, _, err = helps.ApplyClaudeCredentialMetadata(bodyForUpstream, auth, claudeSessionID) if err != nil { diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go index 755a1f629..93caef89c 100644 --- a/internal/runtime/executor/claude_executor_stream.go +++ b/internal/runtime/executor/claude_executor_stream.go @@ -54,8 +54,8 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if oauthToken { claudeSessionID = helps.ClaudeAgentSessionUUIDForRequest(incomingHeaders, originalPayload, req.Payload, confirmedClaudeCode, opts.Metadata, req.Metadata) } - originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) - body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) + originalTranslated := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true, helps.APIKeyModelIsCompat(req)) + body := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true, helps.APIKeyModelIsCompat(req)) body = helps.SetStringIfDifferent(body, "model", upstreamModel) body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) @@ -126,7 +126,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A mcpAliases := resolveClaudeMCPAliasOptions(ctx) bodyForUpstream, oauthToolNamesReverseMap = prepareClaudeOAuthToolNamesForUpstream(bodyForUpstream, mcpAliases) } - bodyForUpstream = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, bodyForUpstream, baseModel) + bodyForUpstream = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, bodyForUpstream, baseModel, helps.APIKeyModelIsCompat(req)) if oauthToken { bodyForUpstream, _, err = helps.ApplyClaudeCredentialMetadata(bodyForUpstream, auth, claudeSessionID) if err != nil { diff --git a/internal/runtime/executor/claude_executor_tokens.go b/internal/runtime/executor/claude_executor_tokens.go index ce452d1ac..8af3f418f 100644 --- a/internal/runtime/executor/claude_executor_tokens.go +++ b/internal/runtime/executor/claude_executor_tokens.go @@ -37,7 +37,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut // Use streaming translation to preserve function calling, except for claude. stream := from != to - body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream) + body := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream, helps.APIKeyModelIsCompat(req)) var errThinking error body, errThinking = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if errThinking != nil { @@ -46,7 +46,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut if rebuildMidSystemMessageEnabled(e.cfg, auth) { body = rebuildMidSystemMessagesToTopLevel(body) } - body = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, body, baseModel) + body = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, body, baseModel, helps.APIKeyModelIsCompat(req)) if errValidate := validateClaudeTokenCountRequest(body); errValidate != nil { return cliproxyexecutor.Response{}, errValidate } @@ -142,7 +142,7 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy } // Use streaming translation to preserve function calling, except for claude. stream := from != to - body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream) + body := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream, helps.APIKeyModelIsCompat(req)) body = helps.SetStringIfDifferent(body, "model", upstreamModel) var errThinking error body, errThinking = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) @@ -202,7 +202,7 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy mcpAliases := resolveClaudeMCPAliasOptions(ctx) body, _ = prepareClaudeOAuthToolNamesForUpstream(body, mcpAliases) } - body = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, body, baseModel) + body = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, body, baseModel, helps.APIKeyModelIsCompat(req)) // Claude Code never sends metadata on count_tokens, and Anthropic rejects the // field outright there ("metadata: Extra inputs are not permitted"). The // Messages path still carries the credential identity; this endpoint must not. diff --git a/internal/runtime/executor/codex_executor_execute.go b/internal/runtime/executor/codex_executor_execute.go index c6b96edbb..d2a1ac2fd 100644 --- a/internal/runtime/executor/codex_executor_execute.go +++ b/internal/runtime/executor/codex_executor_execute.go @@ -43,7 +43,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, false) + originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, false, helps.APIKeyModelIsCompat(req)) body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { @@ -212,7 +212,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, false) + originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, false, helps.APIKeyModelIsCompat(req)) body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { diff --git a/internal/runtime/executor/codex_executor_request.go b/internal/runtime/executor/codex_executor_request.go index e079a846c..806063abb 100644 --- a/internal/runtime/executor/codex_executor_request.go +++ b/internal/runtime/executor/codex_executor_request.go @@ -32,13 +32,20 @@ const ( var dataTag = []byte("data:") -func translateCodexRequestPair(from, to sdktranslator.Format, model string, originalPayload, payload []byte, stream bool) ([]byte, []byte) { +func translateCodexRequestPair(from, to sdktranslator.Format, model string, originalPayload, payload []byte, stream bool, preserveEmptyThinkingBlocks ...bool) ([]byte, []byte) { + isCompat := len(preserveEmptyThinkingBlocks) > 0 && preserveEmptyThinkingBlocks[0] + translate := func(raw []byte) []byte { + if isCompat && from == sdktranslator.FormatClaude && to == sdktranslator.FormatCodex { + return helps.TranslateRequestWithAPIKeyModelCompatibility(context.Background(), nil, nil, from, to, model, raw, stream, true) + } + return sdktranslator.TranslateRequest(from, to, model, raw, stream) + } if bytes.Equal(originalPayload, payload) { - body := sdktranslator.TranslateRequest(from, to, model, payload, stream) + body := translate(payload) return body, body } - originalTranslated := sdktranslator.TranslateRequest(from, to, model, originalPayload, stream) - body := sdktranslator.TranslateRequest(from, to, model, payload, stream) + originalTranslated := translate(originalPayload) + body := translate(payload) return originalTranslated, body } diff --git a/internal/runtime/executor/codex_executor_stream.go b/internal/runtime/executor/codex_executor_stream.go index 70f5dc8e0..098311683 100644 --- a/internal/runtime/executor/codex_executor_stream.go +++ b/internal/runtime/executor/codex_executor_stream.go @@ -44,7 +44,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, true) + originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, true, helps.APIKeyModelIsCompat(req)) body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { diff --git a/internal/runtime/executor/codex_executor_tokens.go b/internal/runtime/executor/codex_executor_tokens.go index 46722e8a3..a72dcb39a 100644 --- a/internal/runtime/executor/codex_executor_tokens.go +++ b/internal/runtime/executor/codex_executor_tokens.go @@ -21,7 +21,7 @@ func (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth from := opts.SourceFormat responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) to := sdktranslator.FromString("codex") - body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false) + body := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false, helps.APIKeyModelIsCompat(req)) body, err := helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go index 028dc6997..20a4a47d9 100644 --- a/internal/runtime/executor/gemini_executor.go +++ b/internal/runtime/executor/gemini_executor.go @@ -145,8 +145,8 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, false) - body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) + originalTranslated := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, false, helps.APIKeyModelIsCompat(req)) + body := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false, helps.APIKeyModelIsCompat(req)) body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { @@ -258,8 +258,8 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) - body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) + originalTranslated := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true, helps.APIKeyModelIsCompat(req)) + body := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true, helps.APIKeyModelIsCompat(req)) body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { @@ -387,7 +387,7 @@ func (e *GeminiExecutor) executeInteractions(ctx context.Context, auth *cliproxy reporter := helps.NewExecutorUsageReporter(ctx, e, targetName, auth) defer reporter.TrackFailure(ctx, &err) - body := translateGeminiInteractionsRequestBody(ctx, e.cfg, targetName, req.Payload, opts, false) + body := translateGeminiInteractionsRequestBody(ctx, e.cfg, targetName, req.Payload, opts, false, helps.APIKeyModelIsCompat(req)) if gjson.GetBytes(body, "model").Exists() && targetName != "" { body = helps.SetStringIfDifferent(body, "model", targetName) } @@ -398,7 +398,7 @@ func (e *GeminiExecutor) executeInteractions(ctx context.Context, auth *cliproxy requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) fromProtocol := opts.SourceFormat.String() - originalTranslated := geminiInteractionsPayloadConfigSource(ctx, e.cfg, targetName, req.Payload, opts, false) + originalTranslated := geminiInteractionsPayloadConfigSource(ctx, e.cfg, targetName, req.Payload, opts, false, helps.APIKeyModelIsCompat(req)) body = helps.ApplyPayloadConfigWithRequest(e.cfg, targetName, "interactions", fromProtocol, "", body, originalTranslated, requestedModel, requestPath, opts.Headers) baseURL := resolveGeminiBaseURL(auth) @@ -463,7 +463,7 @@ func (e *GeminiExecutor) executeInteractionsStream(ctx context.Context, auth *cl reporter := helps.NewExecutorUsageReporter(ctx, e, targetName, auth) defer reporter.TrackFailure(ctx, &err) - body := translateGeminiInteractionsRequestBody(ctx, e.cfg, targetName, req.Payload, opts, true) + body := translateGeminiInteractionsRequestBody(ctx, e.cfg, targetName, req.Payload, opts, true, helps.APIKeyModelIsCompat(req)) if gjson.GetBytes(body, "model").Exists() && targetName != "" { body = helps.SetStringIfDifferent(body, "model", targetName) } @@ -474,7 +474,7 @@ func (e *GeminiExecutor) executeInteractionsStream(ctx context.Context, auth *cl requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) fromProtocol := opts.SourceFormat.String() - originalTranslated := geminiInteractionsPayloadConfigSource(ctx, e.cfg, targetName, req.Payload, opts, true) + originalTranslated := geminiInteractionsPayloadConfigSource(ctx, e.cfg, targetName, req.Payload, opts, true, helps.APIKeyModelIsCompat(req)) body = helps.ApplyPayloadConfigWithRequest(e.cfg, targetName, "interactions", fromProtocol, "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body = helps.SetBoolIfDifferent(body, "stream", true) baseURL := resolveGeminiBaseURL(auth) @@ -619,7 +619,7 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut from := opts.SourceFormat responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) to := sdktranslator.FromString("gemini") - translatedReq := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) + translatedReq := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false, helps.APIKeyModelIsCompat(req)) translatedReq, err := helps.ApplyRequestThinking(translatedReq, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { @@ -779,19 +779,19 @@ func nativeInteractionsSourceFormat(format sdktranslator.Format) bool { } } -func translateGeminiInteractionsRequestBody(ctx context.Context, cfg *config.Config, model string, payload []byte, opts cliproxyexecutor.Options, stream bool) []byte { +func translateGeminiInteractionsRequestBody(ctx context.Context, cfg *config.Config, model string, payload []byte, opts cliproxyexecutor.Options, stream, isCompat bool) []byte { if opts.SourceFormat == "" || opts.SourceFormat == sdktranslator.FormatInteractions { return bytes.Clone(payload) } - return helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, cfg, opts.SourceFormat, sdktranslator.FormatInteractions, model, payload, stream) + return helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, cfg, opts.SourceFormat, sdktranslator.FormatInteractions, model, payload, stream, isCompat) } -func geminiInteractionsPayloadConfigSource(ctx context.Context, cfg *config.Config, model string, payload []byte, opts cliproxyexecutor.Options, stream bool) []byte { +func geminiInteractionsPayloadConfigSource(ctx context.Context, cfg *config.Config, model string, payload []byte, opts cliproxyexecutor.Options, stream, isCompat bool) []byte { source := opts.OriginalRequest if len(source) == 0 { source = payload } - return translateGeminiInteractionsRequestBody(ctx, cfg, model, source, opts, stream) + return translateGeminiInteractionsRequestBody(ctx, cfg, model, source, opts, stream, isCompat) } func isNativeInteractionsAuth(auth *cliproxyauth.Auth) bool { diff --git a/internal/runtime/executor/helps/codex_multi_agent_v2.go b/internal/runtime/executor/helps/codex_multi_agent_v2.go index 4b6b2399b..0ff0e459d 100644 --- a/internal/runtime/executor/helps/codex_multi_agent_v2.go +++ b/internal/runtime/executor/helps/codex_multi_agent_v2.go @@ -6,6 +6,13 @@ import ( multiagentv2 "github.com/router-for-me/CLIProxyAPI/v7/internal/client/codex/optimize-multi-agent-v2" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + openaichatclaude "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/openai/chat-completions" + responsesclaude "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/openai/responses" + codexclaude "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/claude" + geminiclaude "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/claude" + interactionsclaude "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/interactions/claude" + openaiclaude "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/claude" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" ) @@ -28,6 +35,38 @@ func TranslateRequestWithCodexMultiAgentV2(ctx context.Context, headers http.Hea return multiagentv2.TranslateRequestWithCodexMultiAgentV2(ctx, headers, cfg, from, to, model, payload, stream) } +// TranslateRequestWithAPIKeyModelCompatibility preserves empty Claude thinking +// blocks when a configured API-key model explicitly enables compatibility mode. +func TranslateRequestWithAPIKeyModelCompatibility(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, model string, payload []byte, stream, isCompat bool) []byte { + if !isCompat { + return TranslateRequestWithCodexMultiAgentV2(ctx, headers, cfg, from, to, model, payload, stream) + } + if from == sdktranslator.FormatOpenAIResponse && to != sdktranslator.FormatCodex && to != sdktranslator.FormatOpenAIResponse { + payload = multiagentv2.RewriteCodexMultiAgentV2Input(ctx, headers, payload, cfg) + } + + var translated []byte + switch { + case from == sdktranslator.FormatClaude && to == sdktranslator.FormatCodex: + translated = codexclaude.ConvertClaudeRequestToCodexWithCompat(model, payload, stream) + case from == sdktranslator.FormatClaude && to == sdktranslator.FormatGemini: + translated = geminiclaude.ConvertClaudeRequestToGeminiWithCompat(model, payload, stream) + case from == sdktranslator.FormatClaude && to == sdktranslator.FormatInteractions: + translated = interactionsclaude.ConvertClaudeRequestToInteractionsWithCompat(model, payload, stream) + case from == sdktranslator.FormatClaude && to == sdktranslator.FormatOpenAI: + translated = openaiclaude.ConvertClaudeRequestToOpenAIWithCompat(model, payload, stream) + case from == sdktranslator.FormatOpenAI && to == sdktranslator.FormatClaude: + translated = openaichatclaude.ConvertOpenAIRequestToClaudeWithCompat(model, payload, stream) + case from == sdktranslator.FormatOpenAIResponse && to == sdktranslator.FormatClaude: + translated = responsesclaude.ConvertOpenAIResponsesRequestToClaudeWithCompat(model, payload, stream) + default: + return TranslateRequestWithCodexMultiAgentV2(ctx, headers, cfg, from, to, model, payload, stream) + } + + summaryConfig := thinking.ExtractSummaryConfig(payload, from.String()) + return thinking.ApplySummaryConfigForModel(translated, to.String(), model, summaryConfig) +} + // OptimizeCodexMultiAgentV2Request rewrites an eligible spawn_agent request and // reports whether the collaboration namespace was renamed for upstream use. func OptimizeCodexMultiAgentV2Request(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config) ([]byte, bool) { diff --git a/internal/runtime/executor/helps/model_capabilities.go b/internal/runtime/executor/helps/model_capabilities.go index fea97c5d1..bfd26c1f5 100644 --- a/internal/runtime/executor/helps/model_capabilities.go +++ b/internal/runtime/executor/helps/model_capabilities.go @@ -6,6 +6,13 @@ import ( cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" ) +// APIKeyModelIsCompat reports whether the selected API-key model enables +// compatibility handling for empty thinking signatures. +func APIKeyModelIsCompat(req cliproxyexecutor.Request) bool { + modelInfo, ok := cliproxyauth.ResolvedAPIKeyModelInfo(req) + return ok && modelInfo != nil && modelInfo.IsCompat +} + // ApplyRequestThinking preserves the registry lookup path unless the auth // manager bound an exact configured API-key model definition to this attempt. func ApplyRequestThinking(body []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, fromFormat, toFormat, provider string) ([]byte, error) { diff --git a/internal/runtime/executor/xai_executor_request.go b/internal/runtime/executor/xai_executor_request.go index c7c46f512..cc2ff93c2 100644 --- a/internal/runtime/executor/xai_executor_request.go +++ b/internal/runtime/executor/xai_executor_request.go @@ -67,9 +67,9 @@ func (e *XAIExecutor) prepareResponsesRequestTo(ctx context.Context, req cliprox originalPayloadSource = opts.OriginalRequest } originalPayload := bytes.Clone(originalPayloadSource) - originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, stream) + originalTranslated := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, stream, helps.APIKeyModelIsCompat(req)) originalTranslated = preserveXAIResponsesOutputControls(originalTranslated, originalPayload, from) - body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), stream) + body := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, bytes.Clone(req.Payload), stream, helps.APIKeyModelIsCompat(req)) body = preserveXAIResponsesOutputControls(body, req.Payload, from) var err error diff --git a/internal/signature/claude_messages_sanitize.go b/internal/signature/claude_messages_sanitize.go index 11f3ca950..3baea48ef 100644 --- a/internal/signature/claude_messages_sanitize.go +++ b/internal/signature/claude_messages_sanitize.go @@ -14,6 +14,9 @@ type ClaudeMessagesSignatureSanitizeOptions struct { DropEmptyMessages bool DropToolSignatures bool DropEmptyThinkingPlaceholders bool + // PreserveEmptyThinkingBlocks preserves compatibility-mode thinking blocks + // together with their original signatures, including opaque signatures. + PreserveEmptyThinkingBlocks bool } type SignatureSanitizeReport struct { @@ -41,13 +44,15 @@ func SanitizeClaudeMessagesSignaturesForModel(payload []byte, targetModel string // provider-native E-form, valid Claude CAIS signatures are kept, // incompatible thinking blocks are dropped, and tool_use blocks keep only their // tool-call payload. -func SanitizeClaudeMessagesForClaudeUpstream(payload []byte, targetModel string) ([]byte, SignatureSanitizeReport) { +func SanitizeClaudeMessagesForClaudeUpstream(payload []byte, targetModel string, preserveEmptyThinkingBlocks ...bool) ([]byte, SignatureSanitizeReport) { + preserveEmpty := len(preserveEmptyThinkingBlocks) > 0 && preserveEmptyThinkingBlocks[0] return SanitizeClaudeMessagesSignaturesForTarget(payload, ClaudeMessagesSignatureSanitizeOptions{ TargetProvider: SignatureProviderClaude, TargetModel: targetModel, DropEmptyMessages: true, DropToolSignatures: true, - DropEmptyThinkingPlaceholders: true, + DropEmptyThinkingPlaceholders: !preserveEmpty, + PreserveEmptyThinkingBlocks: preserveEmpty, }) } @@ -119,12 +124,17 @@ func SanitizeClaudeMessagesSignaturesForTarget(payload []byte, opts ClaudeMessag continue } + rawSignature := part.Get("signature").String() + if opts.PreserveEmptyThinkingBlocks { + report.Preserved++ + keptParts = append(keptParts, part.Raw) + continue + } if targetProvider == SignatureProviderClaude && isEmptyClaudeThinkingPlaceholder(part) && !opts.DropEmptyThinkingPlaceholders { keptParts = append(keptParts, part.Raw) continue } - rawSignature := part.Get("signature").String() decision := DecideSignatureCompatibilityForModel(targetProvider, opts.TargetModel, rawSignature, SignatureBlockKindClaudeThinking) decision.Reason = fmt.Sprintf("messages[%d].content[%d]: %s", i, j, decision.Reason) report.Decisions = append(report.Decisions, decision) diff --git a/internal/signature/claude_messages_sanitize_compat_test.go b/internal/signature/claude_messages_sanitize_compat_test.go new file mode 100644 index 000000000..4de4c7dc1 --- /dev/null +++ b/internal/signature/claude_messages_sanitize_compat_test.go @@ -0,0 +1,37 @@ +package signature + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestSanitizeClaudeMessagesForClaudeUpstreamPreservesEmptyThinkingInCompatMode(t *testing.T) { + input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":""}]}]}`) + + withoutCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "deepseek-v4") + if gjson.GetBytes(withoutCompat, "messages.0.content.#").Int() != 0 { + t.Fatalf("default sanitizer preserved empty thinking: %s", withoutCompat) + } + + withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "deepseek-v4", true) + part := gjson.GetBytes(withCompat, "messages.0.content.0") + if part.Get("type").String() != "thinking" || part.Get("signature").String() != "" { + t.Fatalf("compat sanitizer dropped empty thinking: %s", withCompat) + } +} + +func TestSanitizeClaudeMessagesForClaudeUpstreamPreservesOpaqueThinkingSignatureInCompatMode(t *testing.T) { + input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"opaque-deepseek-id"}]}]}`) + + withoutCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "deepseek-v4") + if gjson.GetBytes(withoutCompat, "messages.0.content.0.signature").String() != "" { + t.Fatalf("default sanitizer preserved opaque signature: %s", withoutCompat) + } + + withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "deepseek-v4", true) + part := gjson.GetBytes(withCompat, "messages.0.content.0") + if part.Get("type").String() != "thinking" || part.Get("signature").String() != "opaque-deepseek-id" { + t.Fatalf("compat sanitizer dropped opaque signature: %s", withCompat) + } +} diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_compat_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_compat_test.go new file mode 100644 index 000000000..cf1b84c1c --- /dev/null +++ b/internal/translator/claude/openai/chat-completions/claude_openai_compat_test.go @@ -0,0 +1,22 @@ +package chat_completions + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertOpenAIRequestToClaudeWithCompatPreservesReasoningContent(t *testing.T) { + payload := []byte(`{"messages":[{"role":"assistant","content":"answer","reasoning_content":"reason"}]}`) + + withoutCompat := ConvertOpenAIRequestToClaude("deepseek-v4", payload, false) + if gjson.GetBytes(withoutCompat, "messages.0.content.#(type=thinking)").Exists() { + t.Fatalf("default translation preserved reasoning_content: %s", withoutCompat) + } + + withCompat := ConvertOpenAIRequestToClaudeWithCompat("deepseek-v4", payload, false) + part := gjson.GetBytes(withCompat, "messages.0.content.#(type=thinking)") + if part.Get("thinking").String() != "reason" || part.Get("signature").String() != "" { + t.Fatalf("compat translation missing unsigned thinking block: %s", withCompat) + } +} diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request.go b/internal/translator/claude/openai/chat-completions/claude_openai_request.go index ea52f6975..c225125d1 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -46,6 +46,16 @@ var ( // Returns: // - []byte: The transformed request data in Claude Code API format func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte { + return convertOpenAIRequestToClaude(modelName, inputRawJSON, stream, false) +} + +// ConvertOpenAIRequestToClaudeWithCompat preserves assistant reasoning content +// as an unsigned thinking block for configured compatibility endpoints. +func ConvertOpenAIRequestToClaudeWithCompat(modelName string, inputRawJSON []byte, stream bool) []byte { + return convertOpenAIRequestToClaude(modelName, inputRawJSON, stream, true) +} + +func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream, preserveEmptyThinkingBlocks bool) []byte { rawJSON := inputRawJSON if account == "" { @@ -204,8 +214,15 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream } case "user", "assistant": contentBlocks := make([][]byte, 0, 4) + if preserveEmptyThinkingBlocks && role == "assistant" { + if reasoningContent := message.Get("reasoning_content"); reasoningContent.Type == gjson.String && strings.TrimSpace(reasoningContent.String()) != "" { + part := []byte(`{"type":"thinking","thinking":"","signature":""}`) + part, _ = sjson.SetBytes(part, "thinking", reasoningContent.String()) + contentBlocks = append(contentBlocks, part) + } + } - // Handle content based on its type (string or array) + // Handle content based on its type if contentResult.Exists() && contentResult.Type == gjson.String && contentResult.String() != "" { part := []byte(`{"type":"text","text":""}`) part, _ = sjson.SetBytes(part, "text", contentResult.String()) diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go index 6e506db21..eaa971b6e 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -36,6 +36,16 @@ var ( // - max_output_tokens -> max_tokens // - stream passthrough via parameter func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte { + return convertOpenAIResponsesRequestToClaude(modelName, inputRawJSON, stream, false) +} + +// ConvertOpenAIResponsesRequestToClaudeWithCompat preserves reasoning items +// whose encrypted content is empty for configured compatibility endpoints. +func ConvertOpenAIResponsesRequestToClaudeWithCompat(modelName string, inputRawJSON []byte, stream bool) []byte { + return convertOpenAIResponsesRequestToClaude(modelName, inputRawJSON, stream, true) +} + +func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte, stream, preserveEmptyThinkingBlocks bool) []byte { rawJSON := inputRawJSON if account == "" { @@ -380,7 +390,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte } case "reasoning": - if thinkingPart := convertResponsesReasoningToClaudeThinking(item); len(thinkingPart) > 0 { + if thinkingPart := convertResponsesReasoningToClaudeThinking(item, preserveEmptyThinkingBlocks); len(thinkingPart) > 0 { pendingReasoningParts = append(pendingReasoningParts, thinkingPart) } @@ -568,10 +578,13 @@ func responsesSystemUnsupportedBlock(part gjson.Result) []byte { // thought. Anthropic requires a signature on every thinking block and rejects an // absent or empty one, so an item whose encrypted_content is missing or belongs // to another provider is dropped rather than replayed as an unsigned block. +// Compatibility mode explicitly keeps the original opaque value as the +// signature for upstreams that use a provider-specific signature format. // Anthropic does not verify the text against the signature, which is what makes // the summarized text safe to restore alongside it. -func convertResponsesReasoningToClaudeThinking(item gjson.Result) []byte { +func convertResponsesReasoningToClaudeThinking(item gjson.Result, preserveEmptyThinkingBlocks ...bool) []byte { encrypted := item.Get("encrypted_content").String() + preserveEmpty := len(preserveEmptyThinkingBlocks) > 0 && preserveEmptyThinkingBlocks[0] if data, isRedacted := responsesRedactedThinkingData(encrypted); isRedacted { if data == "" { return nil @@ -583,7 +596,10 @@ func convertResponsesReasoningToClaudeThinking(item gjson.Result) []byte { signature, ok := sigcompat.CompatibleSignatureForProvider(sigcompat.SignatureProviderClaude, encrypted) if !ok { - return nil + if !preserveEmpty { + return nil + } + signature = encrypted } thinkingText := responsesReasoningText(item) diff --git a/internal/translator/claude/openai/responses/claude_openai_responses_compat_test.go b/internal/translator/claude/openai/responses/claude_openai_responses_compat_test.go new file mode 100644 index 000000000..adef67188 --- /dev/null +++ b/internal/translator/claude/openai/responses/claude_openai_responses_compat_test.go @@ -0,0 +1,29 @@ +package responses + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertOpenAIResponsesRequestToClaudeWithCompatPreservesEmptyReasoning(t *testing.T) { + payload := []byte(`{"input":[{"type":"reasoning","summary":[{"type":"summary_text","text":"reason"}],"encrypted_content":""}]}`) + + withoutCompat := ConvertOpenAIResponsesRequestToClaude("deepseek-v4", payload, false) + if gjson.GetBytes(withoutCompat, "messages.#").Int() != 0 { + t.Fatalf("default translation preserved empty reasoning: %s", withoutCompat) + } + + withCompat := ConvertOpenAIResponsesRequestToClaudeWithCompat("deepseek-v4", payload, false) + part := gjson.GetBytes(withCompat, "messages.0.content.0") + if part.Get("type").String() != "thinking" || part.Get("signature").String() != "" { + t.Fatalf("compat translation missing unsigned thinking block: %s", withCompat) + } + + opaquePayload := []byte(`{"input":[{"type":"reasoning","summary":[{"type":"summary_text","text":"reason"}],"encrypted_content":"opaque-deepseek-id"}]}`) + opaqueCompat := ConvertOpenAIResponsesRequestToClaudeWithCompat("deepseek-v4", opaquePayload, false) + opaquePart := gjson.GetBytes(opaqueCompat, "messages.0.content.0") + if opaquePart.Get("type").String() != "thinking" || opaquePart.Get("thinking").String() != "reason" || opaquePart.Get("signature").String() != "opaque-deepseek-id" { + t.Fatalf("compat translation dropped invalid-signature thinking block: %s", opaqueCompat) + } +} diff --git a/internal/translator/codex/claude/codex_claude_compat_test.go b/internal/translator/codex/claude/codex_claude_compat_test.go new file mode 100644 index 000000000..cbc28aa1d --- /dev/null +++ b/internal/translator/codex/claude/codex_claude_compat_test.go @@ -0,0 +1,24 @@ +package claude + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertClaudeRequestToCodexWithCompatPreservesEmptyThinking(t *testing.T) { + payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":""}]}]}`) + + withoutCompat := ConvertClaudeRequestToCodex("deepseek-v4", payload, false) + if gjson.GetBytes(withoutCompat, "input.#").Int() != 0 { + t.Fatalf("default translation preserved empty-signature thinking: %s", withoutCompat) + } + + withCompat := ConvertClaudeRequestToCodexWithCompat("deepseek-v4", payload, false) + if !gjson.GetBytes(withCompat, "input.0.type").Exists() || gjson.GetBytes(withCompat, "input.0.type").String() != "reasoning" { + t.Fatalf("compat translation missing reasoning item: %s", withCompat) + } + if !gjson.GetBytes(withCompat, "input.0.encrypted_content").Exists() { + t.Fatalf("compat translation missing empty encrypted_content: %s", withCompat) + } +} diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index 6b5bfb0bc..906ae6684 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -38,7 +38,17 @@ import ( // // Returns: // - []byte: The transformed request data in internal client format -func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) []byte { +func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, stream bool) []byte { + return convertClaudeRequestToCodex(modelName, inputRawJSON, stream, false) +} + +// ConvertClaudeRequestToCodexWithCompat preserves assistant thinking blocks with +// empty signatures for configured compatibility endpoints. +func ConvertClaudeRequestToCodexWithCompat(modelName string, inputRawJSON []byte, stream bool) []byte { + return convertClaudeRequestToCodex(modelName, inputRawJSON, stream, true) +} + +func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, preserveEmptyThinkingBlocks bool) []byte { rawJSON := inputRawJSON template := []byte(`{"model":"","instructions":"","input":[]}`) @@ -143,13 +153,17 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) rawSignature := part.Get("signature").String() signature, ok := sigcompat.CompatibleSignatureForProvider(sigcompat.SignatureProviderGPT, rawSignature) if !ok { - if !codexClaudeTargetAcceptsGrokSignature(modelName) { - return + if preserveEmptyThinkingBlocks && strings.TrimSpace(rawSignature) == "" { + signature = rawSignature + } else { + if !codexClaudeTargetAcceptsGrokSignature(modelName) { + return + } + if _, err := sigcompat.InspectGrokEncryptedContent(rawSignature); err != nil { + return + } + signature = rawSignature } - if _, err := sigcompat.InspectGrokEncryptedContent(rawSignature); err != nil { - return - } - signature = rawSignature } flushMessage() diff --git a/internal/translator/gemini/claude/gemini_claude_compat_test.go b/internal/translator/gemini/claude/gemini_claude_compat_test.go new file mode 100644 index 000000000..a4ec625cf --- /dev/null +++ b/internal/translator/gemini/claude/gemini_claude_compat_test.go @@ -0,0 +1,25 @@ +package claude + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertClaudeRequestToGeminiWithCompatPreservesEmptyThinking(t *testing.T) { + payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":""}]}]}`) + + withoutCompat := ConvertClaudeRequestToGemini("deepseek-v4", payload, false) + if gjson.GetBytes(withoutCompat, "contents.0.parts.#").Int() != 0 { + t.Fatalf("default translation preserved thinking: %s", withoutCompat) + } + + withCompat := ConvertClaudeRequestToGeminiWithCompat("deepseek-v4", payload, false) + part := gjson.GetBytes(withCompat, "contents.0.parts.0") + if !part.Get("thought").Bool() || part.Get("text").String() != "reason" { + t.Fatalf("compat translation missing thought part: %s", withCompat) + } + if !part.Get("thoughtSignature").Exists() || part.Get("thoughtSignature").String() != "" { + t.Fatalf("compat translation did not preserve empty signature: %s", withCompat) + } +} diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index 8a2259b7a..4cf0ecaa4 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -29,7 +29,17 @@ const geminiClaudeThoughtSignature = "skip_thought_signature_validator" // // Returns: // - []byte: The transformed request in Gemini format. -func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) []byte { +func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, stream bool) []byte { + return convertClaudeRequestToGemini(modelName, inputRawJSON, stream, false) +} + +// ConvertClaudeRequestToGeminiWithCompat preserves assistant thinking blocks +// with empty signatures for configured compatibility endpoints. +func ConvertClaudeRequestToGeminiWithCompat(modelName string, inputRawJSON []byte, stream bool) []byte { + return convertClaudeRequestToGemini(modelName, inputRawJSON, stream, true) +} + +func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool, preserveEmptyThinkingBlocks bool) []byte { rawJSON := inputRawJSON // Build output Gemini request JSON out := []byte(`{"contents":[]}`) @@ -99,6 +109,15 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) part, _ = sjson.SetBytes(part, "text", text) partItems = append(partItems, part) + case "thinking": + if !preserveEmptyThinkingBlocks { + return true + } + part := []byte(`{"text":"","thought":true,"thoughtSignature":""}`) + part, _ = sjson.SetBytes(part, "text", contentResult.Get("thinking").String()) + part, _ = sjson.SetBytes(part, "thoughtSignature", contentResult.Get("signature").String()) + partItems = append(partItems, part) + case "tool_use": functionName := contentResult.Get("name").String() if toolUseID := contentResult.Get("id").String(); toolUseID != "" { diff --git a/internal/translator/interactions/claude/interactions_claude_compat_test.go b/internal/translator/interactions/claude/interactions_claude_compat_test.go new file mode 100644 index 000000000..b12bd7074 --- /dev/null +++ b/internal/translator/interactions/claude/interactions_claude_compat_test.go @@ -0,0 +1,21 @@ +package claude + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertClaudeRequestToInteractionsWithCompatPreservesEmptyThinking(t *testing.T) { + payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":""}]}]}`) + + withoutCompat := ConvertClaudeRequestToInteractions("deepseek-v4", payload, false) + if gjson.GetBytes(withoutCompat, "input.#").Int() != 0 { + t.Fatalf("default translation preserved empty thinking: %s", withoutCompat) + } + + withCompat := ConvertClaudeRequestToInteractionsWithCompat("deepseek-v4", payload, false) + if gjson.GetBytes(withCompat, "input.0.type").String() != "thought" { + t.Fatalf("compat translation missing thought step: %s", withCompat) + } +} diff --git a/internal/translator/interactions/claude/interactions_claude_request.go b/internal/translator/interactions/claude/interactions_claude_request.go index 8f6c2746b..0d684fec8 100644 --- a/internal/translator/interactions/claude/interactions_claude_request.go +++ b/internal/translator/interactions/claude/interactions_claude_request.go @@ -9,6 +9,16 @@ import ( ) func ConvertClaudeRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) []byte { + return convertClaudeRequestToInteractions(modelName, inputRawJSON, stream, false) +} + +// ConvertClaudeRequestToInteractionsWithCompat preserves empty assistant +// thinking blocks for configured compatibility endpoints. +func ConvertClaudeRequestToInteractionsWithCompat(modelName string, inputRawJSON []byte, stream bool) []byte { + return convertClaudeRequestToInteractions(modelName, inputRawJSON, stream, true) +} + +func convertClaudeRequestToInteractions(modelName string, inputRawJSON []byte, stream, preserveEmptyThinkingBlocks bool) []byte { root := gjson.ParseBytes(inputRawJSON) out := []byte(`{"model":"","input":[]}`) out, _ = sjson.SetBytes(out, "model", firstNonEmpty(modelName, root.Get("model").String())) @@ -17,7 +27,7 @@ func ConvertClaudeRequestToInteractions(modelName string, inputRawJSON []byte, s } out = copyClaudeSystemToInteractions(out, root) out = copyClaudeGenerationConfigToInteractions(out, root) - out = appendClaudeMessagesToInteractions(out, root.Get("messages")) + out = appendClaudeMessagesToInteractions(out, root.Get("messages"), preserveEmptyThinkingBlocks) out = copyClaudeToolsToInteractions(out, root) return out } @@ -112,20 +122,20 @@ func copyClaudeToolChoiceToInteractions(out []byte, toolChoice gjson.Result) []b return out } -func appendClaudeMessagesToInteractions(out []byte, messages gjson.Result) []byte { +func appendClaudeMessagesToInteractions(out []byte, messages gjson.Result, preserveEmptyThinkingBlocks bool) []byte { if !messages.Exists() || !messages.IsArray() { return out } inputItems := translatorcommon.NewRawArrayItems(messages.Get("#").Int()) messages.ForEach(func(_, message gjson.Result) bool { - appendClaudeMessageToInteractions(&inputItems, message) + appendClaudeMessageToInteractions(&inputItems, message, preserveEmptyThinkingBlocks) return true }) out = translatorcommon.SetRawArrayItems(out, "input", inputItems) return out } -func appendClaudeMessageToInteractions(items *[][]byte, message gjson.Result) { +func appendClaudeMessageToInteractions(items *[][]byte, message gjson.Result, preserveEmptyThinkingBlocks bool) { role := strings.ToLower(strings.TrimSpace(message.Get("role").String())) defaultStepType := "user_input" if role == "assistant" { @@ -164,7 +174,8 @@ func appendClaudeMessageToInteractions(items *[][]byte, message gjson.Result) { } case "thinking": flushContent() - if text := part.Get("thinking").String(); text != "" { + text := part.Get("thinking").String() + if text != "" || preserveEmptyThinkingBlocks { step := []byte(`{"type":"thought","content":[{"type":"text","text":""}]}`) step, _ = sjson.SetBytes(step, "content.0.text", text) *items = append(*items, step) diff --git a/internal/translator/openai/claude/openai_claude_compat_test.go b/internal/translator/openai/claude/openai_claude_compat_test.go new file mode 100644 index 000000000..5630fd9b3 --- /dev/null +++ b/internal/translator/openai/claude/openai_claude_compat_test.go @@ -0,0 +1,21 @@ +package claude + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertClaudeRequestToOpenAIWithCompatPreservesEmptySignatureThinking(t *testing.T) { + payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":""}]}]}`) + + withoutCompat := ConvertClaudeRequestToOpenAI("deepseek-v4", payload, false) + if gjson.GetBytes(withoutCompat, "messages.0.reasoning_content").Exists() { + t.Fatalf("default translation preserved empty-signature reasoning: %s", withoutCompat) + } + + withCompat := ConvertClaudeRequestToOpenAIWithCompat("deepseek-v4", payload, false) + if gjson.GetBytes(withCompat, "messages.0.reasoning_content").String() != "reason" { + t.Fatalf("compat translation missing reasoning_content: %s", withCompat) + } +} diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go index 68848854f..b04629e8a 100644 --- a/internal/translator/openai/claude/openai_claude_request.go +++ b/internal/translator/openai/claude/openai_claude_request.go @@ -20,6 +20,16 @@ import ( // It extracts the model name, system instruction, message contents, and tool declarations // from the raw JSON request and returns them in the format expected by the OpenAI API. func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool) []byte { + return convertClaudeRequestToOpenAI(modelName, inputRawJSON, stream, false) +} + +// ConvertClaudeRequestToOpenAIWithCompat preserves assistant thinking text +// when its signature is empty for configured compatibility endpoints. +func ConvertClaudeRequestToOpenAIWithCompat(modelName string, inputRawJSON []byte, stream bool) []byte { + return convertClaudeRequestToOpenAI(modelName, inputRawJSON, stream, true) +} + +func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool, preserveEmptyThinkingBlocks bool) []byte { rawJSON := inputRawJSON // Base OpenAI Chat Completions API template out := []byte(`{"model":"","messages":[]}`) @@ -168,7 +178,7 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream case "thinking": // Only map thinking to reasoning_content for assistant messages (security: prevent injection) if role == "assistant" { - if !shouldMapClaudeThinkingToGPTReasoning(part) { + if !shouldMapClaudeThinkingToGPTReasoning(part, preserveEmptyThinkingBlocks) { return true } thinkingText := thinking.GetThinkingText(part) @@ -363,10 +373,11 @@ func normalizeObjectSchemaProperties(schema any) any { } } -func shouldMapClaudeThinkingToGPTReasoning(part gjson.Result) bool { +func shouldMapClaudeThinkingToGPTReasoning(part gjson.Result, preserveEmptyThinkingBlocks ...bool) bool { + preserveEmpty := len(preserveEmptyThinkingBlocks) > 0 && preserveEmptyThinkingBlocks[0] signature := part.Get("signature") if !signature.Exists() || strings.TrimSpace(signature.String()) == "" { - return false + return preserveEmpty } _, ok := sigcompat.CompatibleSignatureForProvider(sigcompat.SignatureProviderGPT, signature.String()) return ok diff --git a/internal/watcher/diff/model_compat_hash_test.go b/internal/watcher/diff/model_compat_hash_test.go new file mode 100644 index 000000000..0a46aa892 --- /dev/null +++ b/internal/watcher/diff/model_compat_hash_test.go @@ -0,0 +1,16 @@ +package diff + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" +) + +func TestModelHashesIncludeIsCompat(t *testing.T) { + if ComputeClaudeModelsHash([]config.ClaudeModel{{Name: "m"}}) == ComputeClaudeModelsHash([]config.ClaudeModel{{Name: "m", IsCompat: true}}) { + t.Fatal("Claude model hash did not change when IsCompat changed") + } + if ComputeGeminiModelsHash([]config.GeminiModel{{Name: "m"}}) == ComputeGeminiModelsHash([]config.GeminiModel{{Name: "m", IsCompat: true}}) { + t.Fatal("Gemini model hash did not change when IsCompat changed") + } +} diff --git a/internal/watcher/diff/models_summary.go b/internal/watcher/diff/models_summary.go index 67953b83c..2fbeabbef 100644 --- a/internal/watcher/diff/models_summary.go +++ b/internal/watcher/diff/models_summary.go @@ -41,7 +41,11 @@ func SummarizeGeminiModels(models []config.GeminiModel) GeminiModelsSummary { if name == "" && alias == "" { continue } - out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + thinkingHashSuffix(model.Thinking)) + isCompat := "false" + if model.IsCompat { + isCompat = "true" + } + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|is-compat=" + isCompat + thinkingHashSuffix(model.Thinking)) } }) return GeminiModelsSummary{ @@ -62,7 +66,11 @@ func SummarizeClaudeModels(models []config.ClaudeModel) ClaudeModelsSummary { if name == "" && alias == "" { continue } - out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + thinkingHashSuffix(model.Thinking)) + isCompat := "false" + if model.IsCompat { + isCompat = "true" + } + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|is-compat=" + isCompat + thinkingHashSuffix(model.Thinking)) } }) return ClaudeModelsSummary{ diff --git a/sdk/cliproxy/auth/api_key_model_capabilities.go b/sdk/cliproxy/auth/api_key_model_capabilities.go index 4a109e9a0..934c8f87f 100644 --- a/sdk/cliproxy/auth/api_key_model_capabilities.go +++ b/sdk/cliproxy/auth/api_key_model_capabilities.go @@ -202,7 +202,11 @@ func compileConfiguredModelCapabilities[T interface { GetThinking() *registry.ThinkingSupport }](out map[string][]apiKeyModelCapabilityRoute, models []T, modelType string) { for i := range models { - addConfiguredModelCapability(out, models[i].GetName(), models[i].GetAlias(), modelType, models[i].GetThinking()) + isCompat := false + if compatModel, okCompat := any(models[i]).(interface{ GetIsCompat() bool }); okCompat { + isCompat = compatModel.GetIsCompat() + } + addConfiguredModelCapability(out, models[i].GetName(), models[i].GetAlias(), modelType, models[i].GetThinking(), isCompat) } } @@ -212,11 +216,11 @@ func compileOpenAICompatibleModelCapabilities(out map[string][]apiKeyModelCapabi if support == nil && !models[i].Image { support = ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high"}} } - addConfiguredModelCapability(out, models[i].Name, models[i].Alias, "openai-compatibility", support) + addConfiguredModelCapability(out, models[i].Name, models[i].Alias, "openai-compatibility", support, false) } } -func addConfiguredModelCapability(out map[string][]apiKeyModelCapabilityRoute, name, alias, modelType string, support *registry.ThinkingSupport) { +func addConfiguredModelCapability(out map[string][]apiKeyModelCapabilityRoute, name, alias, modelType string, support *registry.ThinkingSupport, isCompat bool) { name = strings.TrimSpace(name) alias = strings.TrimSpace(alias) if name == "" { @@ -229,6 +233,7 @@ func addConfiguredModelCapability(out map[string][]apiKeyModelCapabilityRoute, n return } modelInfo := modelconfig.ResolveModelInfo(name, modelType, support) + modelInfo.IsCompat = isCompat route := apiKeyModelCapabilityRoute{upstreamModel: name, modelInfo: modelInfo} seenKeys := make(map[string]struct{}) for _, routeModel := range []string{alias, name} { diff --git a/sdk/cliproxy/auth/api_key_model_compat_test.go b/sdk/cliproxy/auth/api_key_model_compat_test.go new file mode 100644 index 000000000..f61a78e63 --- /dev/null +++ b/sdk/cliproxy/auth/api_key_model_compat_test.go @@ -0,0 +1,29 @@ +package auth + +import ( + "testing" + + internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" +) + +func TestResolvedAPIKeyModelInfoPropagatesIsCompat(t *testing.T) { + manager := NewManager(nil, nil, nil) + manager.SetConfig(&internalconfig.Config{ClaudeKey: []internalconfig.ClaudeKey{{ + APIKey: "compat-key", + Prefix: "tenant", + Models: []internalconfig.ClaudeModel{{ + Name: "deepseek-upstream", + Alias: "deepseek-alias", + IsCompat: true, + }}, + }}}) + auth := configuredCapabilityTestAuth("compat-auth", "compat-key") + registerCapabilityTestAuth(t, manager, auth) + + req := manager.attachResolvedAPIKeyModelInfo(cliproxyexecutor.Request{}, auth, "tenant/deepseek-alias", "deepseek-upstream") + info, ok := ResolvedAPIKeyModelInfo(req) + if !ok || info == nil || !info.IsCompat { + t.Fatalf("ResolvedAPIKeyModelInfo() = (%+v, %t), want IsCompat=true", info, ok) + } +} diff --git a/sdk/cliproxy/service_models.go b/sdk/cliproxy/service_models.go index 8d07eed9f..553123b5f 100644 --- a/sdk/cliproxy/service_models.go +++ b/sdk/cliproxy/service_models.go @@ -670,6 +670,10 @@ type modelMaxContextLengthEntry interface { GetMaxContextLength() int } +type modelCompatEntry interface { + GetIsCompat() bool +} + func buildConfiguredModelInfo(model modelEntry, ownedBy, modelType string, created int64, fallbackDisplayName string, userDefined bool) *ModelInfo { name := strings.TrimSpace(model.GetName()) alias := strings.TrimSpace(model.GetAlias()) @@ -701,6 +705,9 @@ func buildConfiguredModelInfo(model modelEntry, ownedBy, modelType string, creat info.MaxContextLength = maxContextLength } } + if compatModel, okCompat := any(model).(modelCompatEntry); okCompat { + info.IsCompat = compatModel.GetIsCompat() + } return info }