diff --git a/config.example.yaml b/config.example.yaml index fff76b95e..acb689062 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -501,6 +501,7 @@ nonstream-keepalive-interval: 0 # image: false # optional: set true to allow this model on /v1/images/generations and /v1/images/edits (not chat/responses image input) # input-modalities: [text, image] # optional: declare /v1/chat/completions and /v1/responses multimodal input for Codex clients. Use [text] for upstreams that reject multimodal tool result content. # output-modalities: [text] # optional: declare output modalities when known +# is-compat: false # optional: preserve Claude thinking blocks for compatible upstreams # thinking: # optional: omit to default to levels ["low","medium","high"] # levels: ["low", "medium", "high"] # # You may repeat the same alias to build an internal model pool. diff --git a/internal/config/api_key_is_compat_test.go b/internal/config/api_key_is_compat_test.go index 2463bd1db..d3a46fd2c 100644 --- a/internal/config/api_key_is_compat_test.go +++ b/internal/config/api_key_is_compat_test.go @@ -34,6 +34,14 @@ codex-api-key: - name: codex-upstream alias: codex-alias is-compat: true +openai-compatibility: + - name: deepseek + models: + - name: deepseek-upstream + alias: deepseek-alias + is-compat: true + - name: openai-native + alias: openai-native ` var cfg Config @@ -59,4 +67,10 @@ codex-api-key: if len(cfg.CodexKey) != 1 || !cfg.CodexKey[0].Models[0].IsCompat { t.Fatalf("codex-api-key IsCompat = %+v, want true", cfg.CodexKey) } + if len(cfg.OpenAICompatibility) != 1 || !cfg.OpenAICompatibility[0].Models[0].IsCompat { + t.Fatalf("openai-compatibility IsCompat = %+v, want true", cfg.OpenAICompatibility) + } + if cfg.OpenAICompatibility[0].Models[1].IsCompat { + t.Fatal("openai-compatibility omitted IsCompat = true, want default false") + } } diff --git a/internal/config/config_types.go b/internal/config/config_types.go index 93dc8dee4..134109fd7 100644 --- a/internal/config/config_types.go +++ b/internal/config/config_types.go @@ -659,6 +659,10 @@ type OpenAICompatibilityModel struct { // OutputModalities declares supported output modalities when known (e.g. text, image). OutputModalities []string `yaml:"output-modalities,omitempty" json:"output-modalities,omitempty"` + // IsCompat preserves Claude thinking blocks 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. // If nil, the model defaults to level-based reasoning with levels ["low", "medium", "high"]. Thinking *registry.ThinkingSupport `yaml:"thinking,omitempty" json:"thinking,omitempty"` @@ -671,5 +675,6 @@ func (m OpenAICompatibilityModel) GetAlias() string { return m.Alias } func (m OpenAICompatibilityModel) GetDisplayName() string { return m.DisplayName } func (m OpenAICompatibilityModel) GetMaxContextLength() int { return m.MaxContextLength } func (m OpenAICompatibilityModel) GetForceMapping() bool { return m.ForceMapping } +func (m OpenAICompatibilityModel) GetIsCompat() bool { return m.IsCompat } func (m OpenAICompatibilityModel) GetThinking() *registry.ThinkingSupport { return m.Thinking } diff --git a/internal/modelconfig/model_hash.go b/internal/modelconfig/model_hash.go index 679d4e18f..8e35abbaa 100644 --- a/internal/modelconfig/model_hash.go +++ b/internal/modelconfig/model_hash.go @@ -20,7 +20,7 @@ func ComputeOpenAICompatModelsHash(models []config.OpenAICompatibilityModel) str if name == "" && alias == "" { continue } - out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|" + fmt.Sprintf("image=%t", model.Image) + "|" + fmt.Sprintf("force-mapping=%t", model.ForceMapping) + "|input=" + strings.Join(normalizeModalities(model.InputModalities), ",") + "|output=" + strings.Join(normalizeModalities(model.OutputModalities), ",") + thinkingHashSuffix(model.Thinking)) + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|" + fmt.Sprintf("image=%t", model.Image) + "|" + fmt.Sprintf("force-mapping=%t", model.ForceMapping) + "|" + fmt.Sprintf("is-compat=%t", model.IsCompat) + "|input=" + strings.Join(normalizeModalities(model.InputModalities), ",") + "|output=" + strings.Join(normalizeModalities(model.OutputModalities), ",") + thinkingHashSuffix(model.Thinking)) } }) return hashJoined(keys) diff --git a/internal/runtime/executor/helps/codex_multi_agent_v2.go b/internal/runtime/executor/helps/codex_multi_agent_v2.go index 0ff0e459d..fe4d5a328 100644 --- a/internal/runtime/executor/helps/codex_multi_agent_v2.go +++ b/internal/runtime/executor/helps/codex_multi_agent_v2.go @@ -35,8 +35,8 @@ 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. +// TranslateRequestWithAPIKeyModelCompatibility applies compatibility-aware +// request translators when a configured API-key model 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) diff --git a/internal/runtime/executor/helps/model_capabilities.go b/internal/runtime/executor/helps/model_capabilities.go index bfd26c1f5..e69c9a15b 100644 --- a/internal/runtime/executor/helps/model_capabilities.go +++ b/internal/runtime/executor/helps/model_capabilities.go @@ -7,7 +7,7 @@ import ( ) // APIKeyModelIsCompat reports whether the selected API-key model enables -// compatibility handling for empty thinking signatures. +// compatibility handling for Claude thinking blocks. func APIKeyModelIsCompat(req cliproxyexecutor.Request) bool { modelInfo, ok := cliproxyauth.ResolvedAPIKeyModelInfo(req) return ok && modelInfo != nil && modelInfo.IsCompat diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index 7ea1ab498..f425eeacb 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -114,8 +114,9 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, opts.Stream) - translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, opts.Stream) + isCompat := helps.APIKeyModelIsCompat(req) + originalTranslated := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, opts.Stream, isCompat) + translated := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, opts.Stream, isCompat) translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { @@ -324,8 +325,9 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) - translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) + isCompat := helps.APIKeyModelIsCompat(req) + originalTranslated := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true, isCompat) + translated := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true, isCompat) translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { @@ -615,7 +617,8 @@ func (e *OpenAICompatExecutor) CountTokens(ctx context.Context, auth *cliproxyau from := opts.SourceFormat responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) to := sdktranslator.FromString("openai") - translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) + isCompat := helps.APIKeyModelIsCompat(req) + translated := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false, isCompat) modelForCounting := baseModel diff --git a/internal/runtime/executor/openai_compat_executor_reasoning_test.go b/internal/runtime/executor/openai_compat_executor_reasoning_test.go new file mode 100644 index 000000000..905591162 --- /dev/null +++ b/internal/runtime/executor/openai_compat_executor_reasoning_test.go @@ -0,0 +1,58 @@ +package executor + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" +) + +func TestOpenAICompatExecutorUsesCompatibleClaudeTranslation(t *testing.T) { + var upstreamBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"chatcmpl-test","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`)) + })) + defer server.Close() + + executor := NewOpenAICompatExecutor("openai-compatibility", &config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "openai-compatibility", + Attributes: map[string]string{ + "base_url": server.URL, + "api_key": "test-key", + }, + } + request := cliproxyexecutor.Request{ + Model: "deepseek-v4-flash", + Payload: []byte(`{"model":"deepseek-v4-flash","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"prior reasoning","signature":""},{"type":"tool_use","id":"call_1","name":"Read","input":{}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"ok"}]}]}`), + Metadata: map[string]any{ + "cliproxy.resolved_api_key_model_info": ®istry.ModelInfo{IsCompat: true}, + }, + } + options := cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + ResponseFormat: sdktranslator.FormatOpenAI, + } + + if _, errExecute := executor.Execute(context.Background(), auth, request, options); errExecute != nil { + t.Fatalf("Execute error: %v", errExecute) + } + + assistant := gjson.GetBytes(upstreamBody, "messages.0") + if got := assistant.Get("reasoning_content").String(); got != "prior reasoning" { + t.Fatalf("reasoning_content = %q, want %q; body=%s", got, "prior reasoning", upstreamBody) + } + if !assistant.Get("tool_calls").Exists() { + t.Fatalf("tool_calls missing from upstream request: %s", upstreamBody) + } +} diff --git a/internal/translator/openai/claude/openai_claude_compat_test.go b/internal/translator/openai/claude/openai_claude_compat_test.go index 5630fd9b3..984b2bcb9 100644 --- a/internal/translator/openai/claude/openai_claude_compat_test.go +++ b/internal/translator/openai/claude/openai_claude_compat_test.go @@ -19,3 +19,51 @@ func TestConvertClaudeRequestToOpenAIWithCompatPreservesEmptySignatureThinking(t t.Fatalf("compat translation missing reasoning_content: %s", withCompat) } } + +func TestConvertClaudeRequestToOpenAIWithCompatPreservesThinkingWithToolCalls(t *testing.T) { + payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":""},{"type":"text","text":"Reading files."},{"type":"tool_use","id":"call_1","name":"Read","input":{"path":"main.go"}}]}]}`) + + result := ConvertClaudeRequestToOpenAIWithCompat("deepseek-v4", payload, false) + assistant := gjson.GetBytes(result, "messages.0") + if got := assistant.Get("reasoning_content").String(); got != "reason" { + t.Fatalf("reasoning_content = %q, want %q; output: %s", got, "reason", result) + } + if !assistant.Get("tool_calls").Exists() { + t.Fatalf("tool_calls missing from compatible translation: %s", result) + } +} + +func TestConvertClaudeRequestToOpenAIWithCompatDoesNotAddReasoningWithoutThinking(t *testing.T) { + payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"Read","input":{}}]}]}`) + + result := ConvertClaudeRequestToOpenAIWithCompat("deepseek-v4", payload, false) + assistant := gjson.GetBytes(result, "messages.0") + if assistant.Get("reasoning_content").Exists() { + t.Fatalf("compatible translation added reasoning_content without thinking: %s", result) + } + if !assistant.Get("tool_calls").Exists() { + t.Fatalf("tool_calls missing from compatible translation: %s", result) + } +} + +func TestConvertClaudeRequestToOpenAIWithCompatPreservesIncompatibleThinking(t *testing.T) { + payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"claude#opaque"},{"type":"tool_use","id":"call_1","name":"Read","input":{}}]}]}`) + + result := ConvertClaudeRequestToOpenAIWithCompat("deepseek-v4", payload, false) + assistant := gjson.GetBytes(result, "messages.0") + if got := assistant.Get("reasoning_content").String(); got != "reason" { + t.Fatalf("reasoning_content = %q, want %q; output: %s", got, "reason", result) + } + if !assistant.Get("tool_calls").Exists() { + t.Fatalf("tool_calls missing from compatible translation: %s", result) + } +} + +func TestConvertClaudeRequestToOpenAIWithoutCompatDoesNotAddReasoningForToolCalls(t *testing.T) { + payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"Read","input":{}}]}]}`) + + result := ConvertClaudeRequestToOpenAI("deepseek-v4", payload, false) + if gjson.GetBytes(result, "messages.0.reasoning_content").Exists() { + t.Fatalf("default translation added reasoning_content: %s", result) + } +} diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go index b04629e8a..769c22752 100644 --- a/internal/translator/openai/claude/openai_claude_request.go +++ b/internal/translator/openai/claude/openai_claude_request.go @@ -24,12 +24,12 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream } // ConvertClaudeRequestToOpenAIWithCompat preserves assistant thinking text -// when its signature is empty for configured compatibility endpoints. +// 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 { +func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool, preserveThinkingBlocks bool) []byte { rawJSON := inputRawJSON // Base OpenAI Chat Completions API template out := []byte(`{"model":"","messages":[]}`) @@ -178,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, preserveEmptyThinkingBlocks) { + if !shouldMapClaudeThinkingToGPTReasoning(part, preserveThinkingBlocks) { return true } thinkingText := thinking.GetThinkingText(part) @@ -373,11 +373,15 @@ func normalizeObjectSchemaProperties(schema any) any { } } -func shouldMapClaudeThinkingToGPTReasoning(part gjson.Result, preserveEmptyThinkingBlocks ...bool) bool { - preserveEmpty := len(preserveEmptyThinkingBlocks) > 0 && preserveEmptyThinkingBlocks[0] +func shouldMapClaudeThinkingToGPTReasoning(part gjson.Result, preserveThinkingBlocks ...bool) bool { + preserveThinking := len(preserveThinkingBlocks) > 0 && preserveThinkingBlocks[0] + if preserveThinking { + return true + } + signature := part.Get("signature") if !signature.Exists() || strings.TrimSpace(signature.String()) == "" { - return preserveEmpty + return false } _, 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 index 0a46aa892..a36eb3a62 100644 --- a/internal/watcher/diff/model_compat_hash_test.go +++ b/internal/watcher/diff/model_compat_hash_test.go @@ -13,4 +13,7 @@ func TestModelHashesIncludeIsCompat(t *testing.T) { if ComputeGeminiModelsHash([]config.GeminiModel{{Name: "m"}}) == ComputeGeminiModelsHash([]config.GeminiModel{{Name: "m", IsCompat: true}}) { t.Fatal("Gemini model hash did not change when IsCompat changed") } + if ComputeOpenAICompatModelsHash([]config.OpenAICompatibilityModel{{Name: "m"}}) == ComputeOpenAICompatModelsHash([]config.OpenAICompatibilityModel{{Name: "m", IsCompat: true}}) { + t.Fatal("OpenAI compatibility model hash did not change when IsCompat changed") + } } diff --git a/sdk/cliproxy/auth/api_key_model_capabilities.go b/sdk/cliproxy/auth/api_key_model_capabilities.go index 934c8f87f..8d4fb3385 100644 --- a/sdk/cliproxy/auth/api_key_model_capabilities.go +++ b/sdk/cliproxy/auth/api_key_model_capabilities.go @@ -216,7 +216,7 @@ 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, false) + addConfiguredModelCapability(out, models[i].Name, models[i].Alias, "openai-compatibility", support, models[i].IsCompat) } } diff --git a/sdk/cliproxy/auth/api_key_model_capabilities_test.go b/sdk/cliproxy/auth/api_key_model_capabilities_test.go index 27fbd4b1a..0f99639c0 100644 --- a/sdk/cliproxy/auth/api_key_model_capabilities_test.go +++ b/sdk/cliproxy/auth/api_key_model_capabilities_test.go @@ -156,7 +156,7 @@ func TestAttachResolvedAPIKeyModelInfoSupportsKeylessOpenAICompatibility(t *test BaseURL: "https://example.com/v1", Models: []internalconfig.OpenAICompatibilityModel{ { - Name: "shared-upstream", Alias: "public-model", ForceMapping: true, + Name: "shared-upstream", Alias: "public-model", ForceMapping: true, IsCompat: true, Thinking: ®istry.ThinkingSupport{Levels: []string{"high"}}, }, { @@ -189,6 +189,10 @@ func TestAttachResolvedAPIKeyModelInfoSupportsKeylessOpenAICompatibility(t *test } req := attachResolvedAPIKeyModelInfo(routing, cliproxyexecutor.Request{}, auth, "tenant/public-model", models[0]) assertResolvedThinkingLevels(t, req, "high") + info, ok := ResolvedAPIKeyModelInfo(req) + if !ok || info == nil || !info.IsCompat { + t.Fatal("OpenAI compatibility model IsCompat = false, want true") + } } func TestAttachResolvedAPIKeyModelInfoBindsUnknownConfiguredCapability(t *testing.T) {