feat(compat): preserve Claude thinking/tool-call content for is-compat OpenAI compatibility models

- Add `is-compat` support to OpenAI compatibility model config, capabilities, hashing, and example config.
- Propagate `IsCompat` through API-key model resolution and switch OpenAI-compat executor translation to compatibility-aware routing.
- Keep Claude assistant thinking content in compatibility mode while keeping default behavior unchanged when `is-compat` is disabled.

Closes: #4776
This commit is contained in:
Luis Pater
2026-08-07 06:26:33 +08:00
parent 5e25566c24
commit 0a95fa62a1
13 changed files with 157 additions and 17 deletions

View File

@@ -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.

View File

@@ -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")
}
}

View File

@@ -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 }

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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": &registry.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)
}
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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")
}
}

View File

@@ -216,7 +216,7 @@ func compileOpenAICompatibleModelCapabilities(out map[string][]apiKeyModelCapabi
if support == nil && !models[i].Image {
support = &registry.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)
}
}

View File

@@ -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: &registry.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) {