diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index 5bfba83dff..e03d3303b3 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -46,6 +46,22 @@ func NewOpenAICompatExecutor(provider string, cfg *config.Config) *OpenAICompatE return &OpenAICompatExecutor{provider: provider, cfg: cfg} } +func openAICompatThinkingModel(requestedModel, resolvedModel string) string { + requestedModel = strings.TrimSpace(requestedModel) + if requestedModel == "" { + return strings.TrimSpace(resolvedModel) + } + resolvedSuffix := thinking.ParseSuffix(resolvedModel) + if resolvedSuffix.HasSuffix && resolvedSuffix.RawSuffix != "" { + requestedBase := strings.TrimSpace(thinking.ParseSuffix(requestedModel).ModelName) + if requestedBase == "" { + requestedBase = requestedModel + } + return requestedBase + "(" + resolvedSuffix.RawSuffix + ")" + } + return requestedModel +} + // Identifier implements cliproxyauth.ProviderExecutor. func (e *OpenAICompatExecutor) Identifier() string { return e.provider } @@ -113,13 +129,14 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A originalPayload := originalPayloadSource originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, opts.Stream) translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, opts.Stream) + requestedModel := helps.PayloadRequestedModel(opts, req.Model) + thinkingModel := openAICompatThinkingModel(requestedModel, req.Model) - translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) + translated, err = thinking.ApplyThinking(translated, thinkingModel, from.String(), to.String(), e.Identifier()) if err != nil { return resp, err } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", translated, originalTranslated, requestedModel, requestPath, opts.Headers) if opts.Alt == "responses/compact" { @@ -314,13 +331,14 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy originalPayload := originalPayloadSource originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true) translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true) + requestedModel := helps.PayloadRequestedModel(opts, req.Model) + thinkingModel := openAICompatThinkingModel(requestedModel, req.Model) - translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) + translated, err = thinking.ApplyThinking(translated, thinkingModel, from.String(), to.String(), e.Identifier()) if err != nil { return nil, err } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", translated, originalTranslated, requestedModel, requestPath, opts.Headers) @@ -585,7 +603,9 @@ func (e *OpenAICompatExecutor) CountTokens(ctx context.Context, auth *cliproxyau modelForCounting := baseModel - translated, err := thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) + requestedModel := helps.PayloadRequestedModel(opts, req.Model) + thinkingModel := openAICompatThinkingModel(requestedModel, req.Model) + translated, err := thinking.ApplyThinking(translated, thinkingModel, from.String(), to.String(), e.Identifier()) if err != nil { return cliproxyexecutor.Response{}, err } diff --git a/internal/runtime/executor/openai_compat_executor_compact_test.go b/internal/runtime/executor/openai_compat_executor_compact_test.go index cf5fe636b2..3b1b47f71c 100644 --- a/internal/runtime/executor/openai_compat_executor_compact_test.go +++ b/internal/runtime/executor/openai_compat_executor_compact_test.go @@ -13,6 +13,7 @@ import ( "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" @@ -106,6 +107,96 @@ func TestOpenAICompatExecutorPayloadOverrideWinsOverThinkingSuffix(t *testing.T) } } +func TestOpenAICompatExecutorThinkingUsesRequestedAliasCapabilities(t *testing.T) { + var gotBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + gotBody = body + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"chatcmpl_1","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`)) + })) + defer server.Close() + + reg := registry.GetGlobalRegistry() + reg.RegisterClient("test-openai-compat-requested-alias-thinking", "openai-compatibility", []*registry.ModelInfo{{ + ID: "shared-model", + Object: "model", + Type: "openai-compatibility", + Thinking: ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high"}}, + }}) + t.Cleanup(func() { + reg.UnregisterClient("test-openai-compat-requested-alias-thinking") + }) + + executor := NewOpenAICompatExecutor("openai-compatibility", &config.Config{}) + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "base_url": server.URL + "/v1", + "api_key": "test", + }} + payload := []byte(`{"model":"shared-model(xhigh)","messages":[{"role":"user","content":"hi"}]}`) + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "gpt-5.5(xhigh)", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai"), + Stream: false, + Metadata: map[string]any{ + cliproxyexecutor.RequestedModelMetadataKey: "shared-model(xhigh)", + }, + }) + if err == nil { + t.Fatalf("Execute error = nil, want xhigh rejected for requested alias") + } + if gotBody != nil { + t.Fatalf("upstream received body despite invalid requested alias thinking: %s", string(gotBody)) + } +} + +func TestOpenAICompatExecutorThinkingPreservesResolvedSuffixForAlias(t *testing.T) { + var gotBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + gotBody = body + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"chatcmpl_1","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`)) + })) + defer server.Close() + + reg := registry.GetGlobalRegistry() + reg.RegisterClient("test-openai-compat-resolved-suffix-thinking", "openai-compatibility", []*registry.ModelInfo{{ + ID: "fast", + Object: "model", + Type: "openai-compatibility", + Thinking: ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high", "xhigh"}}, + }}) + t.Cleanup(func() { + reg.UnregisterClient("test-openai-compat-resolved-suffix-thinking") + }) + + executor := NewOpenAICompatExecutor("openai-compatibility", &config.Config{}) + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "base_url": server.URL + "/v1", + "api_key": "test", + }} + payload := []byte(`{"model":"fast","messages":[{"role":"user","content":"hi"}]}`) + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "gpt-5.5(xhigh)", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai"), + Stream: false, + Metadata: map[string]any{ + cliproxyexecutor.RequestedModelMetadataKey: "fast", + }, + }) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if got := gjson.GetBytes(gotBody, "reasoning_effort").String(); got != "xhigh" { + t.Fatalf("reasoning_effort = %q, want xhigh; body=%s", got, string(gotBody)) + } +} + func TestOpenAICompatExecutorImagesGenerationsPassthrough(t *testing.T) { var gotPath string var gotBody []byte diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index bedbffb800..bb6d8769af 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -19,6 +19,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher" "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff" @@ -2237,14 +2238,18 @@ func buildOpenAICompatibilityConfigModels(compat *config.OpenAICompatibility) [] if compat == nil || len(compat.Models) == 0 { return nil } + modelCounts := make(map[string]int, len(compat.Models)) + for i := range compat.Models { + if modelID := openAICompatibilityModelID(compat.Models[i]); modelID != "" { + modelCounts[modelID]++ + } + } + pooledThinkingLevels := openAICompatibilityPooledThinkingLevels(compat.Models, modelCounts) now := time.Now().Unix() models := make([]*ModelInfo, 0, len(compat.Models)) for i := range compat.Models { model := compat.Models[i] - modelID := strings.TrimSpace(model.Alias) - if modelID == "" { - modelID = strings.TrimSpace(model.Name) - } + modelID := openAICompatibilityModelID(model) if modelID == "" { continue } @@ -2252,9 +2257,9 @@ func buildOpenAICompatibilityConfigModels(compat *config.OpenAICompatibility) [] if model.Image { modelType = registry.OpenAIImageModelType } - thinking := model.Thinking - if thinking == nil && !model.Image { - thinking = ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high"}} + thinking := openAICompatibilityModelThinking(model, modelID) + if levels, ok := pooledThinkingLevels[modelID]; ok && !model.Image { + thinking = ®istry.ThinkingSupport{Levels: append([]string(nil), levels...)} } models = append(models, &ModelInfo{ ID: modelID, @@ -2270,6 +2275,104 @@ func buildOpenAICompatibilityConfigModels(compat *config.OpenAICompatibility) [] return models } +func openAICompatibilityModelID(model config.OpenAICompatibilityModel) string { + modelID := strings.TrimSpace(model.Alias) + if modelID == "" { + modelID = strings.TrimSpace(model.Name) + } + return modelID +} + +func openAICompatibilityPooledThinkingLevels(models []config.OpenAICompatibilityModel, modelCounts map[string]int) map[string][]string { + pooled := make(map[string][]string) + for i := range models { + model := models[i] + modelID := openAICompatibilityModelID(model) + if modelID == "" || modelCounts[modelID] <= 1 || model.Image { + continue + } + levels := openAICompatibilityModelThinkingLevels(model, modelID) + if existing, ok := pooled[modelID]; ok { + pooled[modelID] = intersectThinkingLevels(existing, levels) + } else { + pooled[modelID] = append([]string(nil), levels...) + } + } + for modelID, levels := range pooled { + if len(levels) == 0 { + pooled[modelID] = defaultOpenAICompatibilityThinkingLevels() + } + } + return pooled +} + +func openAICompatibilityModelThinking(model config.OpenAICompatibilityModel, modelID string) *registry.ThinkingSupport { + if model.Image { + return nil + } + if model.Thinking != nil { + return cloneThinkingSupport(model.Thinking) + } + staticModelID := strings.TrimSpace(model.Name) + if staticModelID == "" { + staticModelID = strings.TrimSpace(modelID) + } + staticModelID = strings.TrimSpace(thinking.ParseSuffix(staticModelID).ModelName) + if upstream := registry.LookupStaticModelInfo(staticModelID); upstream != nil && upstream.Thinking != nil { + if len(upstream.Thinking.Levels) > 0 { + return cloneThinkingLevels(upstream.Thinking) + } + } + return ®istry.ThinkingSupport{Levels: defaultOpenAICompatibilityThinkingLevels()} +} + +func openAICompatibilityModelThinkingLevels(model config.OpenAICompatibilityModel, modelID string) []string { + thinking := openAICompatibilityModelThinking(model, modelID) + if thinking == nil || len(thinking.Levels) == 0 { + return defaultOpenAICompatibilityThinkingLevels() + } + return append([]string(nil), thinking.Levels...) +} + +func intersectThinkingLevels(left, right []string) []string { + out := make([]string, 0, len(left)) + for _, candidate := range left { + candidate = strings.TrimSpace(candidate) + if candidate == "" { + continue + } + for _, supported := range right { + if strings.EqualFold(candidate, strings.TrimSpace(supported)) { + out = append(out, candidate) + break + } + } + } + return out +} + +func defaultOpenAICompatibilityThinkingLevels() []string { + return []string{"low", "medium", "high"} +} + +func cloneThinkingLevels(thinking *registry.ThinkingSupport) *registry.ThinkingSupport { + if thinking == nil { + return nil + } + return ®istry.ThinkingSupport{Levels: append([]string(nil), thinking.Levels...)} +} + +func cloneThinkingSupport(thinking *registry.ThinkingSupport) *registry.ThinkingSupport { + if thinking == nil { + return nil + } + cloned := *thinking + if thinking.Levels != nil { + cloned.Levels = append([]string(nil), thinking.Levels...) + } + return &cloned +} + func buildConfigModels[T modelEntry](models []T, ownedBy, modelType string) []*ModelInfo { if len(models) == 0 { return nil diff --git a/sdk/cliproxy/service_excluded_models_test.go b/sdk/cliproxy/service_excluded_models_test.go index fd44436fac..5827f7cf0d 100644 --- a/sdk/cliproxy/service_excluded_models_test.go +++ b/sdk/cliproxy/service_excluded_models_test.go @@ -136,6 +136,183 @@ func TestRegisterModelsForAuth_OpenAICompatibilityImageModelType(t *testing.T) { } } +func TestBuildOpenAICompatibilityConfigModelsInheritsStaticThinkingSupport(t *testing.T) { + staticModel := internalregistry.LookupStaticModelInfo("gpt-5.5") + if staticModel == nil || staticModel.Thinking == nil { + t.Fatal("expected static gpt-5.5 thinking support") + } + originalLevels := append([]string(nil), staticModel.Thinking.Levels...) + defer func() { + staticModel.Thinking.Levels = originalLevels + }() + + models := buildOpenAICompatibilityConfigModels(&config.OpenAICompatibility{ + Name: "compat", + Models: []config.OpenAICompatibilityModel{ + {Name: "gpt-5.5"}, + }, + }) + + if len(models) != 1 { + t.Fatalf("models length = %d, want 1", len(models)) + } + requireThinkingLevels(t, models[0].Thinking, []string{"low", "medium", "high", "xhigh"}) + if models[0].Thinking == staticModel.Thinking { + t.Fatal("expected inherited thinking support to be cloned") + } + models[0].Thinking.Levels[0] = "mutated" + if staticModel.Thinking.Levels[0] != originalLevels[0] { + t.Fatal("mutating inherited thinking support should not change static registry") + } +} + +func TestBuildOpenAICompatibilityConfigModelsInheritsStaticThinkingSupportFromAliasOnlyModel(t *testing.T) { + models := buildOpenAICompatibilityConfigModels(&config.OpenAICompatibility{ + Name: "compat", + Models: []config.OpenAICompatibilityModel{ + {Alias: "gpt-5.5"}, + }, + }) + + if len(models) != 1 { + t.Fatalf("models length = %d, want 1", len(models)) + } + if models[0].ID != "gpt-5.5" { + t.Fatalf("model ID = %q, want gpt-5.5", models[0].ID) + } + requireThinkingLevels(t, models[0].Thinking, []string{"low", "medium", "high", "xhigh"}) +} + +func TestBuildOpenAICompatibilityConfigModelsStripsSuffixForStaticThinkingLookup(t *testing.T) { + models := buildOpenAICompatibilityConfigModels(&config.OpenAICompatibility{ + Name: "compat", + Models: []config.OpenAICompatibilityModel{ + {Name: "gpt-5.5(xhigh)", Alias: "fast"}, + }, + }) + + if len(models) != 1 { + t.Fatalf("models length = %d, want 1", len(models)) + } + if models[0].ID != "fast" { + t.Fatalf("model ID = %q, want fast", models[0].ID) + } + requireThinkingLevels(t, models[0].Thinking, []string{"low", "medium", "high", "xhigh"}) +} + +func TestBuildOpenAICompatibilityConfigModelsUsesSharedThinkingForDuplicateAlias(t *testing.T) { + models := buildOpenAICompatibilityConfigModels(&config.OpenAICompatibility{ + Name: "compat", + Models: []config.OpenAICompatibilityModel{ + {Name: "gpt-5.5", Alias: "shared-model"}, + {Name: "not-a-static-model", Alias: "shared-model"}, + }, + }) + + if len(models) != 2 { + t.Fatalf("models length = %d, want 2", len(models)) + } + for _, model := range models { + if model.ID != "shared-model" { + t.Fatalf("model ID = %q, want shared-model", model.ID) + } + requireThinkingLevels(t, model.Thinking, []string{"low", "medium", "high"}) + } +} + +func TestBuildOpenAICompatibilityConfigModelsUsesDefaultThinkingForStaticBudgetOnlyModel(t *testing.T) { + staticModel := internalregistry.LookupStaticModelInfo("gemini-2.5-pro") + if staticModel == nil || staticModel.Thinking == nil || len(staticModel.Thinking.Levels) > 0 || staticModel.Thinking.Max == 0 { + t.Fatal("expected static gemini-2.5-pro to be budget-only") + } + + models := buildOpenAICompatibilityConfigModels(&config.OpenAICompatibility{ + Name: "compat", + Models: []config.OpenAICompatibilityModel{ + {Name: "gemini-2.5-pro"}, + }, + }) + + if len(models) != 1 { + t.Fatalf("models length = %d, want 1", len(models)) + } + requireThinkingLevels(t, models[0].Thinking, []string{"low", "medium", "high"}) + if models[0].Thinking.Min != 0 || models[0].Thinking.Max != 0 { + t.Fatalf("thinking budget range = [%d,%d], want default level-only support", models[0].Thinking.Min, models[0].Thinking.Max) + } +} + +func TestBuildOpenAICompatibilityConfigModelsInheritsOnlyStaticThinkingLevels(t *testing.T) { + staticModel := internalregistry.LookupStaticModelInfo("gemini-3.1-pro-preview") + if staticModel == nil || staticModel.Thinking == nil || len(staticModel.Thinking.Levels) == 0 || staticModel.Thinking.Max == 0 || !staticModel.Thinking.DynamicAllowed { + t.Fatal("expected static gemini-3.1-pro-preview to have hybrid thinking support") + } + + models := buildOpenAICompatibilityConfigModels(&config.OpenAICompatibility{ + Name: "compat", + Models: []config.OpenAICompatibilityModel{ + {Name: "gemini-3.1-pro-preview"}, + }, + }) + + if len(models) != 1 { + t.Fatalf("models length = %d, want 1", len(models)) + } + requireThinkingLevels(t, models[0].Thinking, staticModel.Thinking.Levels) + if models[0].Thinking.Min != 0 || models[0].Thinking.Max != 0 || models[0].Thinking.DynamicAllowed || models[0].Thinking.ZeroAllowed { + t.Fatalf("thinking support = %+v, want levels-only static inheritance", models[0].Thinking) + } +} + +func TestBuildOpenAICompatibilityConfigModelsUsesDefaultThinkingForUnknownModel(t *testing.T) { + models := buildOpenAICompatibilityConfigModels(&config.OpenAICompatibility{ + Name: "compat", + Models: []config.OpenAICompatibilityModel{ + {Name: "not-a-static-model"}, + }, + }) + + if len(models) != 1 { + t.Fatalf("models length = %d, want 1", len(models)) + } + requireThinkingLevels(t, models[0].Thinking, []string{"low", "medium", "high"}) +} + +func TestBuildOpenAICompatibilityConfigModelsKeepsExplicitThinkingSupport(t *testing.T) { + explicitThinking := &internalregistry.ThinkingSupport{Levels: []string{"low"}} + models := buildOpenAICompatibilityConfigModels(&config.OpenAICompatibility{ + Name: "compat", + Models: []config.OpenAICompatibilityModel{ + { + Name: "gpt-5.5", + Thinking: explicitThinking, + }, + }, + }) + + if len(models) != 1 { + t.Fatalf("models length = %d, want 1", len(models)) + } + requireThinkingLevels(t, models[0].Thinking, []string{"low"}) + if models[0].Thinking == explicitThinking { + t.Fatal("expected explicit thinking support to be cloned") + } + models[0].Thinking.Levels[0] = "mutated" + if explicitThinking.Levels[0] != "low" { + t.Fatal("mutating registered thinking support should not change explicit config") + } +} + +func requireThinkingLevels(t *testing.T, thinking *internalregistry.ThinkingSupport, want []string) { + t.Helper() + if thinking == nil { + t.Fatalf("thinking = nil, want levels %v", want) + } + if got := strings.Join(thinking.Levels, ","); got != strings.Join(want, ",") { + t.Fatalf("thinking levels = [%s], want %v", got, want) + } +} + func TestRegisterModelsForAuth_AntigravityFetchesWebSearchCapability(t *testing.T) { var sawFetch bool server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {