From 17bbd0ff2444ca30ce9f18cfae8df91408c68321 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 2 Oct 2026 17:31:04 +0800 Subject: [PATCH] fix(accounts): reasoning effort in every account test that can send one The test dialog offered a reasoning effort only on OpenAI rows: a single OpenAI-only loop annotated rows, the resolver had no Anthropic wire, and the Claude test payloads never carried an effort. Rows of every platform are now annotated in one place from one wire-gated resolver, and each test path sends the effort the way production forwarding does: - Claude (API key, third-party base URL, OAuth, Vertex): output_config.effort, adaptive thinking where the family pairs effort with it, no temperature, max_tokens 64000, validated like forwarding. Levels and default come from the effort catalog for the upstream ID the test actually sends. - Bedrock: only where InvokeModel keeps output_config.effort (one predicate shared with request preparation), body prepared by the production transform. - Grok: only models whose forwarding keeps the effort; the body goes through the same normalisation. Chat tests apply the glm-* effort normalisation. - The effective effort reported to the dialog is read from the final body. OpenRouter's catalog states reasoning as an object; decoding it into a bool stored false and hid efforts (and the Codex levels) for those models. The object's efforts and default are now read, lightest first; any other shape leaves reasoning unknown, never false. Adds the official Claude Opus 5.5 and Sonnet 5.5 capacity rows (1M / 128K, checked 2026-10-02); advertised values are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../internal/handler/admin/account_handler.go | 25 ++--- .../handler/admin/account_test_plan.go | 35 ++++++- .../handler/admin/account_test_plan_test.go | 46 ++++++++++ backend/internal/pkg/claude/effort_catalog.go | 72 +++++++++++---- .../pkg/claude/effort_catalog_test.go | 10 ++ .../service/account_test_reasoning.go | 92 ++++++++++++++++++- .../service/account_test_reasoning_test.go | 83 ++++++++++++++++- .../account_test_reasoning_wire_test.go | 47 ++++++++++ .../internal/service/account_test_service.go | 80 +++++++++++----- .../account_test_service_opencode_go.go | 3 +- backend/internal/service/bedrock_request.go | 15 ++- .../model_context_official_catalog_data.go | 20 ++++ backend/internal/service/upstream_models.go | 65 ++++++++++++- .../internal/service/upstream_models_test.go | 34 +++++++ 14 files changed, 558 insertions(+), 69 deletions(-) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index eced5b7ce704..ce664e76c22a 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -2874,8 +2874,19 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) { response.Success(c, models) } -// accountTestModels lists the models the single-account test can select. -func (h *AccountHandler) accountTestModels(ctx context.Context, account *service.Account) (any, error) { +// accountTestModels lists the models the single-account test can select, each +// row stating the reasoning efforts the test transmits for it. Both the raw +// model list and the test plan view are built from these rows. +func (h *AccountHandler) accountTestModels(ctx context.Context, account *service.Account) ([]map[string]any, error) { + catalog, err := h.accountTestCatalog(ctx, account) + if err != nil { + return nil, err + } + return accountTestReasoningRows(account, catalog) +} + +// accountTestCatalog lists the platform's selectable test models. +func (h *AccountHandler) accountTestCatalog(ctx context.Context, account *service.Account) (any, error) { // Handle OpenAI accounts if account.IsOpenAI() { return h.openAIAccountTestModels(ctx, account) @@ -3016,15 +3027,7 @@ func (h *AccountHandler) accountTestModels(ctx context.Context, account *service // openAIAccountTestModels selects request-side model IDs from this account's // saved configuration. Discovery enumerates unrestricted accounts and wildcard // candidates; it is not an availability gate for explicitly configured models. -func (h *AccountHandler) openAIAccountTestModels(ctx context.Context, account *service.Account) (result []openai.Model, resultErr error) { - defer func() { - if resultErr != nil { - return - } - for i := range result { - result[i].ReasoningEfforts, result[i].DefaultReasoningEffort = service.AccountTestReasoningOptions(account, result[i].ID) - } - }() +func (h *AccountHandler) openAIAccountTestModels(ctx context.Context, account *service.Account) ([]openai.Model, error) { mapping := account.GetModelMapping() if account.IsOpenAIPassthroughEnabled() || len(mapping) == 0 { return h.discoverOpenAIAccountTestModels(ctx, account) diff --git a/backend/internal/handler/admin/account_test_plan.go b/backend/internal/handler/admin/account_test_plan.go index 7db45ced4bfc..7e45bafaaee7 100644 --- a/backend/internal/handler/admin/account_test_plan.go +++ b/backend/internal/handler/admin/account_test_plan.go @@ -51,7 +51,8 @@ func (h *AccountHandler) accountTestPlan(ctx context.Context, account *service.A return ordinaryAccountTestPlan(account, raw) } -func accountTestPlanModels(raw any) ([]map[string]any, error) { +// accountTestModelRows decodes a typed model catalog into its JSON rows. +func accountTestModelRows(raw any) ([]map[string]any, error) { encoded, err := json.Marshal(raw) if err != nil { return nil, err @@ -60,6 +61,38 @@ func accountTestPlanModels(raw any) ([]map[string]any, error) { if json.Unmarshal(encoded, &models) != nil { return nil, errors.New("invalid account test model catalog") } + return models, nil +} + +// accountTestReasoningRows decodes any platform's catalog into rows and gives +// each row the reasoning efforts the account test transmits for that model, +// under openai.Model's JSON keys. Rows without efforts carry neither key. +func accountTestReasoningRows(account *service.Account, raw any) ([]map[string]any, error) { + models, err := accountTestModelRows(raw) + if err != nil { + return nil, err + } + for _, model := range models { + delete(model, "reasoning_efforts") + delete(model, "default_reasoning_effort") + id, _ := model["id"].(string) + levels, defaultLevel := service.AccountTestReasoningOptions(account, id) + if len(levels) == 0 { + continue + } + model["reasoning_efforts"] = levels + if defaultLevel != "" { + model["default_reasoning_effort"] = defaultLevel + } + } + return models, nil +} + +func accountTestPlanModels(raw any) ([]map[string]any, error) { + models, err := accountTestModelRows(raw) + if err != nil { + return nil, err + } if models == nil { models = []map[string]any{} } diff --git a/backend/internal/handler/admin/account_test_plan_test.go b/backend/internal/handler/admin/account_test_plan_test.go index f57fe05debbc..ddacc7502c27 100644 --- a/backend/internal/handler/admin/account_test_plan_test.go +++ b/backend/internal/handler/admin/account_test_plan_test.go @@ -8,6 +8,7 @@ import ( "testing" extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/testextensions" "github.com/gin-gonic/gin" @@ -135,3 +136,48 @@ func TestAccountTestPlanCoreSelectionSmallMatrix(t *testing.T) { _, err = ordinaryAccountTestPlan(&service.Account{ID: 42}, []map[string]any{{"id": "same"}, {"id": "same"}}) require.Error(t, err) } + +func TestAccountTestModelsCarryReasoningOnEveryPlatform(t *testing.T) { + gin.SetMode(gin.TestMode) + read := func(account *service.Account, suffix string) json.RawMessage { + h := &AccountHandler{adminService: testPlanAdmin{account: account}} + router := gin.New() + router.GET("/accounts/:id/models", h.GetAvailableModels) + out := httptest.NewRecorder() + router.ServeHTTP(out, httptest.NewRequest(http.MethodGet, "/accounts/42/models"+suffix, nil)) + require.Equal(t, http.StatusOK, out.Code, out.Body.String()) + var body struct { + Data json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(out.Body.Bytes(), &body)) + return body.Data + } + + anthropic := &service.Account{ID: 42, Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"model_mapping": map[string]any{"opus": "claude-opus-4-7", "haiku": "claude-haiku-4-5-20251001"}}} + var plan accountTestPlanView + require.NoError(t, json.Unmarshal(read(anthropic, "?view=account-test-plan-v1"), &plan)) + rows := make(map[string]map[string]any, len(plan.Models)) + for _, row := range plan.Models { + id, _ := row["id"].(string) + rows[id] = row + } + require.Equal(t, []any{"low", "medium", "high", "xhigh", "max"}, rows["opus"]["reasoning_efforts"]) + require.Equal(t, "high", rows["opus"]["default_reasoning_effort"]) + require.Contains(t, rows, "haiku") + require.NotContains(t, rows["haiku"], "reasoning_efforts") + require.NotContains(t, rows["haiku"], "default_reasoning_effort") + var raw []map[string]any + require.NoError(t, json.Unmarshal(read(anthropic, ""), &raw)) + require.ElementsMatch(t, plan.Models, raw, "the raw list carries the same rows") + + // OpenAI rows keep the openai.Model reasoning fields they had before. + openAI := &service.Account{ID: 42, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, + Credentials: map[string]any{"model_mapping": map[string]any{"friendly": "gpt-6-astra"}}} + levels, defaultLevel := service.AccountTestReasoningOptions(openAI, "friendly") + require.NotEmpty(t, levels) + var openAIRows []openai.Model + require.NoError(t, json.Unmarshal(read(openAI, ""), &openAIRows)) + require.Equal(t, []openai.Model{{ID: "friendly", Object: "model", Type: "model", DisplayName: "friendly", + ReasoningEfforts: levels, DefaultReasoningEffort: defaultLevel}}, openAIRows) +} diff --git a/backend/internal/pkg/claude/effort_catalog.go b/backend/internal/pkg/claude/effort_catalog.go index a3039faac744..9cad2571b1b5 100644 --- a/backend/internal/pkg/claude/effort_catalog.go +++ b/backend/internal/pkg/claude/effort_catalog.go @@ -11,36 +11,71 @@ var ( effortLowMediumHighXHighMax = []string{"low", "medium", "high", "xhigh", "max"} ) -var effortFamilies = []struct { +type effortFamily struct { family string levels []string -}{ + // adaptive pairs output_config.effort with thinking {type: "adaptive"}. + // Opus 4.5 thinks only through enabled+budget_tokens; Mythos Preview's + // thinking mode is unverified. + adaptive bool +} + +var effortFamilies = []effortFamily{ {family: "claude-mythos-preview", levels: effortLowMediumHighMax}, - {family: "claude-mythos-5", levels: effortLowMediumHighXHighMax}, - {family: "claude-fable-5", levels: effortLowMediumHighXHighMax}, - {family: "claude-sonnet-4-6", levels: effortLowMediumHighMax}, - {family: "claude-sonnet-5-5", levels: effortLowMediumHighXHighMax}, - {family: "claude-sonnet-5", levels: effortLowMediumHighXHighMax}, - {family: "claude-opus-4-8", levels: effortLowMediumHighXHighMax}, - {family: "claude-opus-4-7", levels: effortLowMediumHighXHighMax}, - {family: "claude-opus-4-6", levels: effortLowMediumHighMax}, + {family: "claude-mythos-5", levels: effortLowMediumHighXHighMax, adaptive: true}, + {family: "claude-fable-5", levels: effortLowMediumHighXHighMax, adaptive: true}, + {family: "claude-sonnet-4-6", levels: effortLowMediumHighMax, adaptive: true}, + {family: "claude-sonnet-5-5", levels: effortLowMediumHighXHighMax, adaptive: true}, + {family: "claude-sonnet-5", levels: effortLowMediumHighXHighMax, adaptive: true}, + {family: "claude-opus-4-8", levels: effortLowMediumHighXHighMax, adaptive: true}, + {family: "claude-opus-4-7", levels: effortLowMediumHighXHighMax, adaptive: true}, + {family: "claude-opus-4-6", levels: effortLowMediumHighMax, adaptive: true}, {family: "claude-opus-4-5", levels: effortLowMediumHigh}, - {family: "claude-opus-5-5", levels: effortLowMediumHighXHighMax}, - {family: "claude-opus-5", levels: effortLowMediumHighXHighMax}, + {family: "claude-opus-5-5", levels: effortLowMediumHighXHighMax, adaptive: true}, + {family: "claude-opus-5", levels: effortLowMediumHighXHighMax, adaptive: true}, +} + +func findEffortFamily(model string) *effortFamily { + id := normalizeEffortModelID(model) + for i := range effortFamilies { + if entry := &effortFamilies[i]; id == entry.family || strings.HasPrefix(id, entry.family+"-") { + return entry + } + } + return nil } // EffortLevelsForModel returns the output_config.effort values accepted by a // Claude model, ordered from the lightest to the deepest reasoning level. func EffortLevelsForModel(model string) []string { - id := normalizeEffortModelID(model) - for _, entry := range effortFamilies { - if id == entry.family || strings.HasPrefix(id, entry.family+"-") { - return append([]string(nil), entry.levels...) - } + if entry := findEffortFamily(model); entry != nil { + return append([]string(nil), entry.levels...) } return nil } +// EffortUsesAdaptiveThinking reports whether a Claude model pairs +// output_config.effort with thinking {type: "adaptive"}. +func EffortUsesAdaptiveThinking(model string) bool { + entry := findEffortFamily(model) + return entry != nil && entry.adaptive +} + +// DefaultEffortForModel returns the effort a Claude model applies when +// output_config.effort is omitted, or "" when the model has no effort levels. +func DefaultEffortForModel(model string) string { + entry := findEffortFamily(model) + switch { + case entry == nil: + return "" + case entry.family == "claude-opus-5-5": + // Opus 5.5 defaults one level below every other effort family. + return "medium" + default: + return "high" + } +} + // IsOpus55 identifies the fixed Opus 5.5 ID after provider/local suffix normalization. func IsOpus55(model string) bool { return normalizeEffortModelID(model) == "claude-opus-5-5" @@ -73,9 +108,10 @@ func normalizeEffortModelID(model string) string { if mapped, ok := ModelIDReverseOverrides[id]; ok { id = mapped } + // Dated snapshots: "-YYYYMMDD" (Anthropic API) and "@YYYYMMDD" (Vertex AI). if len(id) >= 9 { suffix := id[len(id)-9:] - if suffix[0] == '-' { + if suffix[0] == '-' || suffix[0] == '@' { digits := true for _, r := range suffix[1:] { if !unicode.IsDigit(r) { diff --git a/backend/internal/pkg/claude/effort_catalog_test.go b/backend/internal/pkg/claude/effort_catalog_test.go index e8f8e2f15ad3..2bf55a414f90 100644 --- a/backend/internal/pkg/claude/effort_catalog_test.go +++ b/backend/internal/pkg/claude/effort_catalog_test.go @@ -20,6 +20,7 @@ func TestEffortLevelsForModel(t *testing.T) { {model: "claude-sonnet-5-5", want: []string{"low", "medium", "high", "xhigh", "max"}}, {model: "us.anthropic.claude-sonnet-5-5", want: []string{"low", "medium", "high", "xhigh", "max"}}, {model: "claude-opus-4-5-20251101", want: []string{"low", "medium", "high"}}, + {model: "claude-opus-4-5@20251101", want: []string{"low", "medium", "high"}}, {model: "claude-haiku-4-5-20251001", want: nil}, {model: "gpt-5.6", want: nil}, } @@ -31,6 +32,15 @@ func TestEffortLevelsForModel(t *testing.T) { } } +func TestEffortThinkingModeAndDefault(t *testing.T) { + t.Parallel() + require.True(t, EffortUsesAdaptiveThinking("claude-opus-4-7")) + require.False(t, EffortUsesAdaptiveThinking("claude-opus-4-5-20251101"), "Opus 4.5 thinks only with budget_tokens") + require.Equal(t, "medium", DefaultEffortForModel("claude-opus-5-5")) + require.Equal(t, "high", DefaultEffortForModel("claude-opus-4-8")) + require.Empty(t, DefaultEffortForModel("claude-haiku-4-5-20251001")) +} + func TestIsOpus55OpenRouterExactAlias(t *testing.T) { t.Parallel() for _, model := range []string{"claude-opus-5-5", "anthropic/claude-opus-5.5"} { diff --git a/backend/internal/service/account_test_reasoning.go b/backend/internal/service/account_test_reasoning.go index 8e630e223745..74daef7dadfb 100644 --- a/backend/internal/service/account_test_reasoning.go +++ b/backend/internal/service/account_test_reasoning.go @@ -2,12 +2,16 @@ package service import ( "context" + "encoding/json" "errors" "strings" extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" ) const accountTestReasoningContextKey = "account_test_reasoning_effort" @@ -18,11 +22,20 @@ func AccountTestReasoningOptions(account *Account, model string) ([]string, stri if account == nil || strings.TrimSpace(model) == "" { return nil, "" } - model = account.GetMappedModel(strings.TrimSpace(model)) - if !accountTestSupportsReasoningWire(account, model) || isOpenAIImageModel(model) || isGrokVideoGenerationModel(model) { + model, ok := accountTestUpstreamModel(account, strings.TrimSpace(model)) + if !ok || !accountTestSupportsReasoningWire(account, model) || isOpenAIImageModel(model) || isGrokVideoGenerationModel(model) { return nil, "" } - if metadata, ok := account.GetUpstreamModelMetadata(model); ok && metadata.Reasoning != nil { + metadata, known := account.GetUpstreamModelMetadata(model) + if account.Platform == PlatformAnthropic { + // Claude effort levels are a property of the model; the account's + // upstream metadata can only rule the model out. + if known && metadata.Reasoning != nil && !*metadata.Reasoning { + return nil, "" + } + return claude.EffortLevelsForModel(model), claude.DefaultEffortForModel(model) + } + if known && metadata.Reasoning != nil { if !*metadata.Reasoning { return nil, "" } @@ -53,6 +66,21 @@ func AccountTestReasoningOptions(account *Account, model string) ([]string, stri return out, "" } +// accountTestUpstreamModel resolves the model ID the account test sends +// upstream; ok is false when the test cannot send the model at all. +func accountTestUpstreamModel(account *Account, model string) (string, bool) { + if account.Platform == PlatformAnthropic { + return accountTestClaudeUpstreamModel(account, model) + } + return account.GetMappedModel(model), true +} + +// accountTestGrokUpstreamModel resolves Grok aliases the way forwardGrokResponses +// does. Effort rules apply to this ID, not to the mapped alias the test sends. +func accountTestGrokUpstreamModel(model string) string { + return xai.ResolveGrokTextResponsesModelID(model, grokDefaultResponsesModel) +} + func accountTestSupportsReasoningWire(account *Account, model string) bool { switch { case account.IsOpenCodeGo(): @@ -64,8 +92,13 @@ func accountTestSupportsReasoningWire(account *Account, model string) bool { // whose effort contract differs. Never silently omit a chosen effort on // one leg of that test. return protocol == APIProtocolResponses || protocol == APIProtocolChatCompletions - case account.IsOpenAI(), account.Platform == PlatformGrok: + case account.IsOpenAI(): return true + case account.Platform == PlatformGrok: + // Responses forwarding removes the effort for every other model. + return grokSupportsReasoningEffort(accountTestGrokUpstreamModel(model)) + case account.Platform == PlatformAnthropic: + return !account.IsBedrock() || bedrockKeepsOutputConfigEffort(model) default: return false } @@ -96,3 +129,54 @@ func applyAccountTestReasoning(c *gin.Context, payload map[string]any, chat bool c.Set("account_test_effective_reasoning_effort", effort) } } + +// accountTestChatBody marshals a Chat Completions test payload. Chat +// forwarding moves a glm-* model's effort onto z.ai's high/max scale, so the +// effective effort is read from the final body. +func accountTestChatBody(c *gin.Context, payload map[string]any, model string) []byte { + applyAccountTestReasoning(c, payload, true) + body, _ := json.Marshal(payload) + body, _ = NormalizeGLMOpenAIReasoningEffort(body, model) + markAccountTestEffectiveReasoning(c, body, "reasoning_effort") + return body +} + +// markAccountTestEffectiveReasoning reports the effort the final wire body +// carries at path; forwarding rules may have rewritten or removed the +// selected one. +func markAccountTestEffectiveReasoning(c *gin.Context, body []byte, path string) { + if c.GetString(accountTestReasoningContextKey) == "" { + return + } + if effort := gjson.GetBytes(body, path).String(); effort != "" { + c.Set("account_test_effective_reasoning_effort", effort) + } +} + +// accountTestClaudeEffortMaxTokens leaves room for a visible answer after +// adaptive thinking, whose tokens count toward max_tokens. 64000 is the +// documented guidance for xhigh/max effort and the smallest output cap among +// the effort families (Opus 4.5). +const accountTestClaudeEffortMaxTokens = 64000 + +// accountTestClaudeBody marshals a Claude Messages test payload. A selected +// effort is written the way clients send it through production forwarding: +// output_config.effort, adaptive thinking where the model pairs effort with +// it, and no temperature (1 is the API default and newer families reject the +// field). Without a selected effort the payload is sent as built. +func accountTestClaudeBody(c *gin.Context, payload map[string]any, model string) ([]byte, error) { + effort := c.GetString(accountTestReasoningContextKey) + if effort == "" { + body, _ := json.Marshal(payload) + return body, nil + } + payload["output_config"] = map[string]any{"effort": effort} + if claude.EffortUsesAdaptiveThinking(model) { + payload["thinking"] = map[string]any{"type": "adaptive"} + } + delete(payload, "temperature") + payload["max_tokens"] = accountTestClaudeEffortMaxTokens + body, _ := json.Marshal(payload) + // Gateway forwarding rejects the same settings before any upstream request. + return body, validateClaude55Request(body, model) +} diff --git a/backend/internal/service/account_test_reasoning_test.go b/backend/internal/service/account_test_reasoning_test.go index 5a51d6e7d446..c02f42ba4a65 100644 --- a/backend/internal/service/account_test_reasoning_test.go +++ b/backend/internal/service/account_test_reasoning_test.go @@ -1,11 +1,13 @@ package service import ( + "encoding/json" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) func TestAccountTestReasoningValidatedAgainstMappedModel(t *testing.T) { @@ -37,7 +39,8 @@ func TestAccountTestReasoningFinalPayload(t *testing.T) { } func TestAccountTestReasoningDoesNotAdvertiseUnsupportedProtocol(t *testing.T) { - for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformDeepseek} { + // Anthropic accounts transmit output_config.effort; see the Claude tests below. + for _, platform := range []string{PlatformGemini, PlatformDeepseek} { account := &Account{Platform: platform, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_protocol": APIProtocolAnthropic}} supported := true account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{"test-model": {Reasoning: &supported, SupportedReasoningLevels: []string{"high"}}}}) @@ -47,3 +50,81 @@ func TestAccountTestReasoningDoesNotAdvertiseUnsupportedProtocol(t *testing.T) { require.NoError(t, ValidateAccountTestReasoning(account, "test-model", "default", "")) } } + +func TestAccountTestReasoningClaudeReadsTestedUpstreamModel(t *testing.T) { + allLevels := []string{"low", "medium", "high", "xhigh", "max"} + apiKey := &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Credentials: map[string]any{"model_mapping": map[string]any{"friendly": "claude-opus-4-5-20251101"}}} + levels, defaultLevel := AccountTestReasoningOptions(apiKey, "friendly") + require.Equal(t, []string{"low", "medium", "high"}, levels, "levels of the mapped upstream model") + require.Equal(t, "high", defaultLevel) + disabled := false + apiKey.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{"claude-opus-4-5-20251101": {Reasoning: &disabled}}}) + levels, _ = AccountTestReasoningOptions(apiKey, "friendly") + require.Empty(t, levels, "an explicit upstream reasoning=false rules the model out") + + oauth := &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth} + levels, defaultLevel = AccountTestReasoningOptions(oauth, "claude-opus-5-5") + require.Equal(t, allLevels, levels) + require.Equal(t, "medium", defaultLevel) + for _, model := range []string{"claude-sonnet-4-5-20250929", "claude-haiku-4-5-20251001"} { + levels, _ = AccountTestReasoningOptions(oauth, model) + require.Empty(t, levels, model) + } + + bedrock := &Account{Platform: PlatformAnthropic, Type: AccountTypeBedrock, Credentials: map[string]any{"aws_region": "us-east-1"}} + levels, _ = AccountTestReasoningOptions(bedrock, "claude-opus-4-7") + require.Empty(t, levels, "Bedrock request preparation removes output_config for this model") + levels, defaultLevel = AccountTestReasoningOptions(bedrock, "claude-sonnet-5-5") + require.Equal(t, allLevels, levels) + require.Equal(t, "high", defaultLevel) +} + +func TestAccountTestReasoningGrokFollowsForwardingEffortRules(t *testing.T) { + account := &Account{Platform: PlatformGrok, Type: AccountTypeAPIKey, Credentials: map[string]any{"model_mapping": map[string]any{"agents": "grok-4.20-multi-agent"}}} + levels, _ := AccountTestReasoningOptions(account, "grok-4.7") + require.Equal(t, []string{"low", "medium", "high", "xhigh"}, levels) + levels, _ = AccountTestReasoningOptions(account, "agents") + require.Equal(t, []string{"low", "medium", "high"}, levels, "an alias is judged by the canonical ID forwarding sends") + for _, model := range []string{"grok-4.20-0309-non-reasoning", "grok-composer-2.5-fast", "grok-imagine-image"} { + levels, _ = AccountTestReasoningOptions(account, model) + require.Empty(t, levels, "forwarding removes the effort for %s", model) + } +} + +func TestAccountTestClaudeBodyWritesEffortLikeProduction(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + payload, err := createTestPayload("claude-opus-4-7", "test") + require.NoError(t, err) + built, err := json.Marshal(payload) + require.NoError(t, err) + body, err := accountTestClaudeBody(c, payload, "claude-opus-4-7") + require.NoError(t, err) + require.Equal(t, built, body, "without a selected effort the payload is unchanged") + + c.Set(accountTestReasoningContextKey, "xhigh") + body, err = accountTestClaudeBody(c, payload, "claude-opus-4-7") + require.NoError(t, err) + require.Equal(t, "xhigh", gjson.GetBytes(body, "output_config.effort").String()) + require.JSONEq(t, `{"type":"adaptive"}`, gjson.GetBytes(body, "thinking").Raw) + require.False(t, gjson.GetBytes(body, "temperature").Exists()) + require.Equal(t, int64(64000), gjson.GetBytes(body, "max_tokens").Int()) + + c.Set(accountTestReasoningContextKey, "high") + payload, err = createTestPayload("claude-opus-4-5-20251101", "test") + require.NoError(t, err) + body, err = accountTestClaudeBody(c, payload, "claude-opus-4-5-20251101") + require.NoError(t, err) + require.Equal(t, "high", gjson.GetBytes(body, "output_config.effort").String()) + require.False(t, gjson.GetBytes(body, "thinking").Exists(), "Opus 4.5 has no adaptive thinking") + + c.Set(accountTestReasoningContextKey, "max") + for _, model := range []string{"claude-opus-5-5", "claude-sonnet-5-5"} { + payload, err = createTestPayload(model, "test") + require.NoError(t, err) + _, err = accountTestClaudeBody(c, payload, model) + require.NoError(t, err, model) + } + payload["tool_choice"] = map[string]any{"type": "any"} + _, err = accountTestClaudeBody(c, payload, "claude-sonnet-5-5") + require.Error(t, err, "the 5.5 request validation runs on the test body") +} diff --git a/backend/internal/service/account_test_reasoning_wire_test.go b/backend/internal/service/account_test_reasoning_wire_test.go index 25051bc97217..0d18ca35a910 100644 --- a/backend/internal/service/account_test_reasoning_wire_test.go +++ b/backend/internal/service/account_test_reasoning_wire_test.go @@ -67,3 +67,50 @@ func TestAccountTestReasoningOpenCodeGoSerializesNativeProtocol(t *testing.T) { }) } } + +func TestAccountTestReasoningBedrockSonnet55EffortSurvivesRequestPreparation(t *testing.T) { + account := &Account{ID: 45, Platform: PlatformAnthropic, Type: AccountTypeBedrock, Status: StatusActive, + Credentials: map[string]any{"auth_mode": "apikey", "api_key": "test-key", "aws_region": "us-east-1"}} + upstream := &queuedHTTPUpstream{responses: []*http.Response{newJSONResponse(200, `{"content":[{"type":"text","text":"OK"}],"stop_reason":"end_turn"}`)}} + svc := &AccountTestService{accountRepo: &reasoningTestRepo{account: account}, httpUpstream: upstream} + c, recorded := newTestContext() + require.NoError(t, svc.TestAccountConnection(c, account.ID, "claude-sonnet-5-5", "", AccountTestModeDefault, AccountTestOptions{ReasoningEffort: "high"})) + require.Len(t, upstream.requests, 1) + require.Contains(t, upstream.requests[0].URL.Path, "global.anthropic.claude-sonnet-5-5") + body, err := io.ReadAll(upstream.requests[0].Body) + require.NoError(t, err) + require.Equal(t, "high", gjson.GetBytes(body, "output_config.effort").String()) + require.Equal(t, "adaptive", gjson.GetBytes(body, "thinking.type").String()) + require.Equal(t, int64(64000), gjson.GetBytes(body, "max_tokens").Int()) + require.False(t, gjson.GetBytes(body, "temperature").Exists()) + require.Contains(t, recorded.Body.String(), `"effective_reasoning_effort":"high"`) +} + +func TestAccountTestReasoningGrokSendsForwardedEffort(t *testing.T) { + account := &Account{ID: 46, Platform: PlatformGrok, Type: AccountTypeAPIKey, Status: StatusActive, Credentials: map[string]any{"api_key": "test-key"}} + supported := true + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{"grok-4.3": {Reasoning: &supported, SupportedReasoningLevels: []string{"low", "high", "xhigh"}}}}) + upstream := &queuedHTTPUpstream{responses: []*http.Response{newJSONResponse(200, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"OK\"}\n\ndata: {\"type\":\"response.completed\"}\n\n")}} + svc := &AccountTestService{accountRepo: &reasoningTestRepo{account: account}, httpUpstream: upstream} + c, recorded := newTestContext() + require.NoError(t, svc.TestAccountConnection(c, account.ID, "grok-4.3", "", AccountTestModeDefault, AccountTestOptions{ReasoningEffort: "xhigh"})) + require.Len(t, upstream.requests, 1) + body, err := io.ReadAll(upstream.requests[0].Body) + require.NoError(t, err) + require.Equal(t, "high", gjson.GetBytes(body, "reasoning.effort").String(), "Responses forwarding sends high for this model") + require.Contains(t, recorded.Body.String(), `"effective_reasoning_effort":"high"`) +} + +func TestAccountTestReasoningChatSendsForwardedGLMEffort(t *testing.T) { + account := adaptiveCNAccountTestAccount(47, PlatformZhipu) + account.Credentials["api_protocol"] = APIProtocolChatCompletions + account.Credentials["base_url"] = "http://chat.example/v1" + supported := true + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{"glm-5": {Reasoning: &supported, SupportedReasoningLevels: []string{"low", "medium", "high"}}}}) + svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNChatTestResponse()) + c, recorded := newTestContext() + require.NoError(t, svc.TestAccountConnection(c, account.ID, "glm-5", "test", AccountTestModeDefault, AccountTestOptions{ReasoningEffort: "low"})) + require.Len(t, upstream.requests, 1) + require.Equal(t, "high", gjson.GetBytes(upstream.lastBody, "reasoning_effort").String(), "chat forwarding moves GLM efforts onto high/max") + require.Contains(t, recorded.Body.String(), `"effective_reasoning_effort":"high"`) +} diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 256e2807aa52..1327b6316033 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -493,6 +493,26 @@ func (s *AccountTestService) testCNProviderChatCompletionsConnection(c *gin.Cont return s.testOpenAIChatCompletionsConnection(c, account, testModelID, prompt, normalizedBaseURL, authToken) } +// accountTestClaudeUpstreamModel resolves the model ID that the Claude account +// test sends upstream on the account's credential path; ok is false when the +// test cannot send the model. Test reasoning options are read from this ID. +func accountTestClaudeUpstreamModel(account *Account, model string) (string, bool) { + switch { + case account.IsBedrock(): + return ResolveBedrockModelID(account, model) + case account.Type == AccountTypeServiceAccount: + if mappedModel, matched := account.ResolveMappedModel(model); matched { + return mappedModel, true + } + return normalizeVertexAnthropicModelID(claude.NormalizeModelID(model)), true + case account.Type == AccountTypeAPIKey: + // API Key 账号测试连接时也需要应用通配符模型映射。 + return account.GetMappedModel(model), true + default: + return model, account.IsOAuth() + } +} + // testClaudeAccountConnection tests an Anthropic Claude account's connection func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account *Account, modelID string) error { ctx := c.Request.Context() @@ -502,19 +522,19 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account if testModelID == "" { testModelID = claude.DefaultTestModel } - - // API Key 账号测试连接时也需要应用通配符模型映射。 - if account.Type == "apikey" { - testModelID = account.GetMappedModel(testModelID) - } + upstreamModelID, supported := accountTestClaudeUpstreamModel(account, testModelID) // Bedrock accounts use a separate test path if account.IsBedrock() { - return s.testBedrockAccountConnection(c, ctx, account, testModelID) + if !supported { + return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported Bedrock model: %s", testModelID)) + } + return s.testBedrockAccountConnection(c, ctx, account, upstreamModelID) } if account.Type == AccountTypeServiceAccount { - return s.testClaudeVertexServiceAccountConnection(c, ctx, account, testModelID) + return s.testClaudeVertexServiceAccountConnection(c, ctx, account, upstreamModelID) } + testModelID = upstreamModelID // Determine authentication method and API URL var authToken string @@ -557,7 +577,11 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account if err != nil { return s.sendErrorAndEnd(c, "Failed to create test payload") } - payloadBytes, _ := json.Marshal(payload) + payloadBytes, err := accountTestClaudeBody(c, payload, testModelID) + if err != nil { + return s.sendErrorAndEnd(c, err.Error()) + } + markAccountTestEffectiveReasoning(c, payloadBytes, "output_config.effort") // Send test_start event s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID}) @@ -619,12 +643,6 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account } func (s *AccountTestService) testClaudeVertexServiceAccountConnection(c *gin.Context, ctx context.Context, account *Account, testModelID string) error { - if mappedModel, matched := account.ResolveMappedModel(testModelID); matched { - testModelID = mappedModel - } else { - testModelID = normalizeVertexAnthropicModelID(claude.NormalizeModelID(testModelID)) - } - c.Writer.Header().Set("Content-Type", "text/event-stream") c.Writer.Header().Set("Cache-Control", "no-cache") c.Writer.Header().Set("Connection", "keep-alive") @@ -635,11 +653,15 @@ func (s *AccountTestService) testClaudeVertexServiceAccountConnection(c *gin.Con if err != nil { return s.sendErrorAndEnd(c, "Failed to create test payload") } - payloadBytes, _ := json.Marshal(payload) + payloadBytes, err := accountTestClaudeBody(c, payload, testModelID) + if err != nil { + return s.sendErrorAndEnd(c, err.Error()) + } vertexBody, err := buildVertexAnthropicRequestBody(payloadBytes) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to create Vertex request body: %s", err.Error())) } + markAccountTestEffectiveReasoning(c, vertexBody, "output_config.effort") if s.claudeTokenProvider == nil { return s.sendErrorAndEnd(c, "Claude token provider not configured") @@ -689,11 +711,6 @@ func (s *AccountTestService) testClaudeVertexServiceAccountConnection(c *gin.Con // testBedrockAccountConnection tests a Bedrock (SigV4 or API Key) account using non-streaming invoke func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx context.Context, account *Account, testModelID string) error { region := bedrockRuntimeRegion(account) - resolvedModelID, ok := ResolveBedrockModelID(account, testModelID) - if !ok { - return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported Bedrock model: %s", testModelID)) - } - testModelID = resolvedModelID // Set SSE headers (test UI expects SSE) c.Writer.Header().Set("Content-Type", "text/event-stream") @@ -719,7 +736,18 @@ func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx co "max_tokens": 256, "temperature": 1, } - bedrockBody, _ := json.Marshal(bedrockPayload) + bedrockBody, err := accountTestClaudeBody(c, bedrockPayload, testModelID) + if err != nil { + return s.sendErrorAndEnd(c, err.Error()) + } + if c.GetString(accountTestReasoningContextKey) != "" { + // Send the effort exactly as production forwarding prepares it for InvokeModel. + bedrockBody, err = PrepareBedrockRequestBody(bedrockBody, testModelID, "") + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to prepare Bedrock request body: %s", err.Error())) + } + } + markAccountTestEffectiveReasoning(c, bedrockBody, "output_config.effort") // Use non-streaming endpoint (response is standard Claude JSON) apiURL := BuildBedrockURL(region, testModelID, false) @@ -1291,7 +1319,12 @@ func (s *AccountTestService) testGrokResponsesConnection(c *gin.Context, ctx con if err != nil { return s.sendErrorAndEnd(c, "Failed to set test reasoning effort") } - c.Set("account_test_effective_reasoning_effort", effort) + // Send the effort as Responses forwarding keeps, rewrites or removes it. + payloadBytes, err = normalizeGrokResponsesReasoningEffort(payloadBytes, accountTestGrokUpstreamModel(testModelID)) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to set test reasoning effort") + } + markAccountTestEffectiveReasoning(c, payloadBytes, "reasoning.effort") } if custom := c.GetString(accountTestPromptContextKey); strings.TrimSpace(custom) != "" { payloadBytes, err = sjson.SetBytes(payloadBytes, "input", custom) @@ -2159,8 +2192,7 @@ func (s *AccountTestService) testOpenAIChatCompletionsConnection( c.Writer.Flush() payload := createOpenAIChatCompletionsTestPayload(testModelID, prompt) - applyAccountTestReasoning(c, payload, true) - payloadBytes, _ := json.Marshal(payload) + payloadBytes := accountTestChatBody(c, payload, testModelID) s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID}) s.sendEvent(c, TestEvent{Type: "status", Text: "正在通过 /v1/chat/completions 测试连接"}) diff --git a/backend/internal/service/account_test_service_opencode_go.go b/backend/internal/service/account_test_service_opencode_go.go index abbdd7b573c3..bc4f08709538 100644 --- a/backend/internal/service/account_test_service_opencode_go.go +++ b/backend/internal/service/account_test_service_opencode_go.go @@ -57,8 +57,7 @@ func (s *AccountTestService) testOpenCodeGoConnection(c *gin.Context, account *A default: apiURL = buildOpenAIChatCompletionsURL(normalizedBaseURL) payload := createOpenAIChatCompletionsTestPayload(testModelID, prompt) - applyAccountTestReasoning(c, payload, true) - payloadBytes, err = json.Marshal(payload) + payloadBytes = accountTestChatBody(c, payload, testModelID) protocol = APIProtocolChatCompletions } if err != nil { diff --git a/backend/internal/service/bedrock_request.go b/backend/internal/service/bedrock_request.go index 4fc7fd7213a5..8c0f0741d809 100644 --- a/backend/internal/service/bedrock_request.go +++ b/backend/internal/service/bedrock_request.go @@ -249,10 +249,10 @@ func PrepareBedrockRequestBodyWithTokens(body []byte, modelID string, betaTokens // 参考 litellm: _convert_output_format_to_inline_schema() body = convertOutputFormatToInlineSchema(body) - // InvokeModel accepts output_config.effort for Sonnet 5.5. Keep just that - // field; output_config.format has already been inlined above, and older - // models retain the existing output_config stripping behavior. - if claude.IsSonnet55(modelID) { + // Keep just output_config.effort where InvokeModel accepts it; + // output_config.format has already been inlined above, and older models + // retain the existing output_config stripping behavior. + if bedrockKeepsOutputConfigEffort(modelID) { if effort := gjson.GetBytes(body, "output_config.effort"); effort.Exists() { body, err = sjson.SetRawBytes(body, "output_config", []byte(`{"effort":`+effort.Raw+`}`)) } else { @@ -282,6 +282,13 @@ func PrepareBedrockRequestBodyWithTokens(body []byte, modelID string, betaTokens return body, nil } +// bedrockKeepsOutputConfigEffort reports whether Bedrock InvokeModel accepts +// output_config.effort for a model. Request preparation removes output_config +// for every other model, so no effort reaches them. +func bedrockKeepsOutputConfigEffort(modelID string) bool { + return claude.IsSonnet55(modelID) +} + // ResolveBedrockBetaTokens computes the final Bedrock beta token list before policy filtering. func ResolveBedrockBetaTokens(betaHeader string, body []byte, modelID string) []string { betaTokens := parseAnthropicBetaHeader(betaHeader) diff --git a/backend/internal/service/model_context_official_catalog_data.go b/backend/internal/service/model_context_official_catalog_data.go index 70c05a38520d..05c9979b1a7f 100644 --- a/backend/internal/service/model_context_official_catalog_data.go +++ b/backend/internal/service/model_context_official_catalog_data.go @@ -53,6 +53,10 @@ const ( doubaoUnitsBasis = "跨官方页面单位推导:模型表默认回答 4k,官方 API 的 max_tokens 默认值 4096,据此按 k=1024 归一;1024k=1048576,256k=262144,224k=229376。不是上下文原文直接整数证据。" ) +// Opus 5.5 and Sonnet 5.5 both read "Context window 1M tokens" and "Max output +// 128K tokens" on https://platform.claude.com/docs/en/about-claude/models/overview. +const claude55CapacityVerifiedAt = "2026-10-02" + func codexModelContextCapacityReference(window, maximum int64) *ModelContextCapacityReference { return &ModelContextCapacityReference{ Product: "codex_subscription", SourceURL: gptContextCapacityReferenceSource, @@ -226,6 +230,22 @@ var officialModelContextCapacityCatalog = []OfficialModelContextCapacity{ }, // Claude API: ordinary Messages limits, excluding Batch-only beta output caps. + { + ModelID: "claude-opus-5-5", Provider: "anthropic", Product: "messages_api", + ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxOutputTokens: 128000, CapacityBasis: "total_context"}, + SourceURL: "https://platform.claude.com/docs/en/models/opus-5-5/overview", + SourceURLs: []string{claudeContextSource, claudeIntegerSource, claudeOutputSource}, + VerifiedAt: claude55CapacityVerifiedAt, OriginalText: "1M; 128K", + NormalizationBasis: claudeUnitsBasis, + }, + { + ModelID: "claude-sonnet-5-5", Provider: "anthropic", Product: "messages_api", + ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxOutputTokens: 128000, CapacityBasis: "total_context"}, + SourceURL: "https://platform.claude.com/docs/en/models/sonnet-5-5/overview", + SourceURLs: []string{claudeContextSource, claudeIntegerSource, claudeOutputSource}, + VerifiedAt: claude55CapacityVerifiedAt, OriginalText: "1M; 128K", + NormalizationBasis: claudeUnitsBasis, + }, { ModelID: "claude-fable-5-1", Provider: "anthropic", Product: "messages_api", ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxOutputTokens: 128000, CapacityBasis: "total_context"}, diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 51b948065e84..552056fa8e4c 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -9,6 +9,7 @@ import ( "log/slog" "net/http" "net/url" + "slices" "sort" "strings" "time" @@ -1564,7 +1565,7 @@ type upstreamModelCapabilityEntry struct { upstreamModelEntry DisplayName string `json:"display_name"` Description string `json:"description"` - Reasoning *bool `json:"reasoning"` + Reasoning json.RawMessage `json:"reasoning"` DefaultReasoningLevel string `json:"default_reasoning_level"` SupportedReasoningLevels []json.RawMessage `json:"supported_reasoning_levels"` ReasoningOptions []modelsDevReasoningOption `json:"reasoning_options"` @@ -1639,11 +1640,19 @@ func extractUpstreamModelRawEntries(body []byte) ([]json.RawMessage, error) { } func upstreamMetadataFromCapabilityEntry(modelID string, entry upstreamModelCapabilityEntry) UpstreamModelMetadata { + reasoning, declaredLevels, declaredDefault := upstreamReasoningCapability(entry.Reasoning) levels := reasoningLevelsFromRawEntries(entry.SupportedReasoningLevels) if len(levels) == 0 { levels = reasoningLevelsFromModelsDevOptions(entry.ReasoningOptions) } - reasoning := entry.Reasoning + // A level list defaults to its first entry; the reasoning object's efforts + // default only to its declared default_effort. + listedDefault := "" + if len(levels) > 0 { + listedDefault = levels[0] + } else { + levels = declaredLevels + } if reasoning == nil && len(levels) > 0 { inferred := len(levels) != 1 || levels[0] != "none" reasoning = &inferred @@ -1664,8 +1673,11 @@ func upstreamMetadataFromCapabilityEntry(modelID string, entry upstreamModelCapa maxOutputTokens = entry.Limit.Output } defaultReasoningLevel := normalizeReasoningLevel(entry.DefaultReasoningLevel) - if defaultReasoningLevel == "" && len(levels) > 0 { - defaultReasoningLevel = levels[0] + if defaultReasoningLevel == "" { + defaultReasoningLevel = declaredDefault + } + if defaultReasoningLevel == "" { + defaultReasoningLevel = listedDefault } displayName := strings.TrimSpace(entry.DisplayName) if displayName == "" && strings.TrimSpace(entry.Name) != "" && strings.TrimSpace(entry.Name) != modelID { @@ -1685,6 +1697,47 @@ func upstreamMetadataFromCapabilityEntry(modelID string, entry upstreamModelCapa } } +// upstreamReasoningCapability reads a catalog entry's "reasoning" field. A +// boolean states the capability. An OpenRouter object, e.g. +// {"mandatory":false,"default_enabled":true,"supported_efforts":["high","low"], +// "default_effort":"high"}, lists the efforts and states reasoning only when +// it is mandatory, enabled by default or offers an effort other than "none". +// Anything else leaves the capability unknown, never false. +func upstreamReasoningCapability(raw json.RawMessage) (*bool, []string, string) { + if len(raw) == 0 { + return nil, nil, "" + } + // A mistyped value still leaves the pointer allocated as false, so only a + // successful decode (true, false or null) states the capability. + var flag *bool + if json.Unmarshal(raw, &flag) == nil { + return flag, nil, "" + } + var declared struct { + Mandatory bool `json:"mandatory"` + DefaultEnabled bool `json:"default_enabled"` + SupportedEfforts []string `json:"supported_efforts"` + DefaultEffort string `json:"default_effort"` + } + if json.Unmarshal(raw, &declared) != nil { + return nil, nil, "" + } + levels := normalizeReasoningLevels(declared.SupportedEfforts) + // OpenRouter lists efforts deepest first; level lists run lightest first. + slices.SortFunc(levels, func(a, b string) int { + return slices.Index(reasoningLevelOrder, a) - slices.Index(reasoningLevelOrder, b) + }) + defaultLevel := normalizeReasoningLevel(declared.DefaultEffort) + enabled := declared.Mandatory || declared.DefaultEnabled + for _, level := range levels { + enabled = enabled || level != "none" + } + if !enabled { + return nil, levels, defaultLevel + } + return &enabled, levels, defaultLevel +} + func reasoningLevelsFromRawEntries(entries []json.RawMessage) []string { levels := make([]string, 0, len(entries)) for _, raw := range entries { @@ -1720,6 +1773,10 @@ func normalizeReasoningLevels(levels []string) []string { return normalized } +// reasoningLevelOrder ranks the levels normalizeReasoningLevel returns, from +// the lightest to the deepest. +var reasoningLevelOrder = []string{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"} + func normalizeReasoningLevel(level string) string { level = strings.ToLower(strings.TrimSpace(level)) switch level { diff --git a/backend/internal/service/upstream_models_test.go b/backend/internal/service/upstream_models_test.go index 007ca0c2d519..237b4f03da42 100644 --- a/backend/internal/service/upstream_models_test.go +++ b/backend/internal/service/upstream_models_test.go @@ -732,6 +732,40 @@ func TestSyncUpstreamModelCatalogPersistsExplicitNonReasoningCapability(t *testi require.NotNil(t, repo.updates) } +// Scenario: OpenRouter 以对象声明 reasoning,档位按从轻到重保存,默认档只取 +// default_effort;其他类型保持未知,而不是 false。 +func TestExtractUpstreamModelCatalogReadsReasoningObject(t *testing.T) { + _, metadata, err := extractUpstreamModelCatalog([]byte(`{"data":[ + {"id":"openai/gpt-5.5","name":"GPT-5.5","reasoning":{"mandatory":false,"default_enabled":true,"supported_efforts":["xhigh","high","medium","low","none"],"default_effort":"medium"}}, + {"id":"efforts-only","name":"Efforts Only","reasoning":{"mandatory":true,"supported_efforts":["max","xhigh","high","medium","low"]}}, + {"id":"listed","name":"Listed","supported_reasoning_levels":["low","high"]}, + {"id":"deepseek/deepseek-v3.2","name":"DeepSeek V3.2","reasoning":{"mandatory":false,"default_enabled":false}}, + {"id":"flag-on","name":"Flag On","reasoning":true}, + {"id":"flag-off","name":"Flag Off","reasoning":false}, + {"id":"text","name":"Text","reasoning":"yes"} + ]}`), false) + require.NoError(t, err) + openRouter := metadata["openai/gpt-5.5"] + require.NotNil(t, openRouter.Reasoning) + require.True(t, *openRouter.Reasoning) + require.Equal(t, []string{"none", "low", "medium", "high", "xhigh"}, openRouter.SupportedReasoningLevels) + require.Equal(t, "medium", openRouter.DefaultReasoningLevel) + effortsOnly := metadata["efforts-only"] + require.NotNil(t, effortsOnly.Reasoning) + require.True(t, *effortsOnly.Reasoning) + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsOnly.SupportedReasoningLevels) + require.Empty(t, effortsOnly.DefaultReasoningLevel, "no default is derived from the effort order") + require.Equal(t, "low", metadata["listed"].DefaultReasoningLevel, "a level list still defaults to its first entry") + require.Contains(t, metadata, "deepseek/deepseek-v3.2") + require.Nil(t, metadata["deepseek/deepseek-v3.2"].Reasoning) + require.NotNil(t, metadata["flag-on"].Reasoning) + require.True(t, *metadata["flag-on"].Reasoning) + require.NotNil(t, metadata["flag-off"].Reasoning) + require.False(t, *metadata["flag-off"].Reasoning) + require.Contains(t, metadata, "text") + require.Nil(t, metadata["text"].Reasoning) +} + func TestSyncUpstreamModelCatalogClassifiesSnapshotPersistenceFailureAsInternal(t *testing.T) { upstream := &httpUpstreamRecorder{resp: &http.Response{ StatusCode: http.StatusOK,