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,