mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user