From f7da28382c507c238a470a5681e788259fed789e Mon Sep 17 00:00:00 2001 From: whtsky Date: Fri, 26 Jun 2026 21:58:25 +0800 Subject: [PATCH 1/2] refactor!: remove model auto-upgrade logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upgradeModel() helper rewrote base Claude model ids (e.g. claude-opus-4.7) to extended variants like -1m-internal / -1m when they appeared in the upstream /models list. The modern Copilot model catalog no longer ships those suffixed ids — base models carry the full context window directly via capabilities.limits — so the rewrite is solving a problem that no longer exists and was hiding the actual model id from callers. Removes the helper, the COPILOT2API_NO_MODEL_UPGRADE env var, the noModelUpgrade flag threaded through NewHandler, the related tests, and the README/CHANGELOG references. BREAKING CHANGE: requests for a base model id are no longer silently rewritten. Pass the variant id explicitly (e.g. claude-opus-4.6-1m on older accounts that still expose it) if you want a different model. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 7 ------- anthropic/handler.go | 23 ++++++++--------------- anthropic/models.go | 33 ++++++--------------------------- anthropic/models_test.go | 29 ----------------------------- main.go | 8 +------- 5 files changed, 15 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index 2239ef7..620db8d 100644 --- a/README.md +++ b/README.md @@ -108,12 +108,6 @@ Add to `~/.claude/settings.json`: } ``` -### Model Auto-Upgrade - -copilot2api automatically upgrades models to the best available variant in the upstream model list. For example, if `claude-opus-4.7-1m-internal` is available, a request for `claude-opus-4.7` will automatically use it. This enables features like 1M context windows and `effort: high` that are only supported by extended variants. - -The upgrade priority is: `-1m-internal` > `-1m` > base model. - ## Usage with Codex Add to `~/.codex/config.toml`: @@ -268,7 +262,6 @@ Environment variables are used as defaults when flags are not provided: | `COPILOT2API_PORT` | Server port | `7777` | | `COPILOT2API_TOKEN_DIR` | Token storage directory | `~/.config/copilot2api` | | `COPILOT2API_DEBUG` | Enable debug logging (`true`/`false`, `1`/`0`) | `false` | -| `COPILOT2API_NO_MODEL_UPGRADE` | Disable model auto-upgrade (`true`/`false`, `1`/`0`) | `false` | | `JINA_API_KEY` | Jina API key for amp page extraction (optional) | — | CLI flags take precedence over environment variables. diff --git a/anthropic/handler.go b/anthropic/handler.go index c9af710..c40df58 100644 --- a/anthropic/handler.go +++ b/anthropic/handler.go @@ -19,18 +19,16 @@ import ( // Handler handles Anthropic Messages API requests type Handler struct { - upstream *upstream.Client - models *models.Cache - noModelUpgrade bool + upstream *upstream.Client + models *models.Cache } // NewHandler creates a new Anthropic handler. // The transport is used for upstream HTTP requests (pass nil to create a new one). -func NewHandler(authClient upstream.TokenProvider, transport *http.Transport, mc *models.Cache, noModelUpgrade bool, debug bool) *Handler { +func NewHandler(authClient upstream.TokenProvider, transport *http.Transport, mc *models.Cache, debug bool) *Handler { return &Handler{ - upstream: upstream.NewClient(authClient, transport, debug), - models: mc, - noModelUpgrade: noModelUpgrade, + upstream: upstream.NewClient(authClient, transport, debug), + models: mc, } } @@ -77,12 +75,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { slog.Info("anthropic request", "endpoint", "/v1/messages", "model", anthropicReq.Model, "stream", anthropicReq.Stream, "messages", len(anthropicReq.Messages), "route", route, "duration_ms", time.Since(start).Milliseconds()) }() - upgradedModel, modelInfo, capabilityFetchFailed := h.getModelInfoWithUpgrade(r.Context(), anthropicReq.Model, h.noModelUpgrade) - modelUpgraded := upgradedModel != anthropicReq.Model - if modelUpgraded { - slog.Debug("auto-upgraded model", "from", anthropicReq.Model, "to", upgradedModel) - anthropicReq.Model = upgradedModel - } + modelInfo, capabilityFetchFailed := h.getModelInfo(r.Context(), anthropicReq.Model) if modelSupportsEndpoint(modelInfo, "/v1/messages") { route = "native" @@ -92,8 +85,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Only re-encode the body for native passthrough (the only path that // sends raw reqBody). Responses and Chat Completions paths use the // parsed struct, so they skip this JSON round-trip. - if modelChanged || modelUpgraded || cacheControlInfo.ScopeCount > 0 || topLevelInfo.HasContextManagement { - newBody, err := normalizeNativeMessagesBody(reqBody, anthropicReq.Model, modelChanged || modelUpgraded) + if modelChanged || cacheControlInfo.ScopeCount > 0 || topLevelInfo.HasContextManagement { + newBody, err := normalizeNativeMessagesBody(reqBody, anthropicReq.Model, modelChanged) if err != nil { WriteAnthropicError(w, http.StatusBadRequest, AnthropicErrorTypeInvalidRequest, fmt.Sprintf("Invalid JSON: %v", err)) return diff --git a/anthropic/models.go b/anthropic/models.go index 6e257ab..ae15bcb 100644 --- a/anthropic/models.go +++ b/anthropic/models.go @@ -53,37 +53,16 @@ func resolveModelAlias(modelID string) string { return modelID } -// modelUpgradeSuffixes lists suffixes to try (in order) when upgrading a model -// to the best available variant. The first match wins. -var modelUpgradeSuffixes = []string{"-1m-internal", "-1m"} - -// upgradeModel returns the best available variant of modelID by checking for -// known suffixes in the upstream model list. Returns the original if no better variant exists. -func upgradeModel(modelID string, available map[string]*models.Info) string { - for _, suffix := range modelUpgradeSuffixes { - candidate := modelID + suffix - if _, ok := available[candidate]; ok { - slog.Debug("auto-upgraded model", "from", modelID, "to", candidate) - return candidate - } - } - return modelID -} - -// getModelInfoWithUpgrade fetches model info and auto-upgrades the model -// to the best available variant (e.g. appending "-1m-internal" if available). -// Set skipUpgrade to true to disable auto-upgrade. -func (h *Handler) getModelInfoWithUpgrade(ctx context.Context, modelID string, skipUpgrade bool) (string, *models.Info, bool) { +// getModelInfo fetches model info for the given model ID. The returned bool +// reports whether the upstream models fetch failed (in which case callers +// should fall back to a default route). +func (h *Handler) getModelInfo(ctx context.Context, modelID string) (*models.Info, bool) { infoMap, err := h.models.GetInfo(ctx) if err != nil { slog.Error("failed to fetch models for capability detection", "error", err) - return modelID, nil, true - } - - if !skipUpgrade { - modelID = upgradeModel(modelID, infoMap) + return nil, true } - return modelID, infoMap[modelID], false + return infoMap[modelID], false } func modelSupportsEndpoint(info *models.Info, endpoint string) bool { diff --git a/anthropic/models_test.go b/anthropic/models_test.go index bd64cd7..7fb6da0 100644 --- a/anthropic/models_test.go +++ b/anthropic/models_test.go @@ -82,32 +82,3 @@ func TestResolveModelAlias(t *testing.T) { }) } } - -func TestUpgradeModel(t *testing.T) { - available := map[string]*models.Info{ - "claude-opus-4.7": {ID: "claude-opus-4.7"}, - "claude-opus-4.7-1m-internal": {ID: "claude-opus-4.7-1m-internal"}, - "claude-opus-4.6": {ID: "claude-opus-4.6"}, - "claude-opus-4.6-1m": {ID: "claude-opus-4.6-1m"}, - "claude-sonnet-4.6": {ID: "claude-sonnet-4.6"}, - } - - tests := []struct { - input string - want string - }{ - {"claude-opus-4.7", "claude-opus-4.7-1m-internal"}, - {"claude-opus-4.6", "claude-opus-4.6-1m"}, - {"claude-sonnet-4.6", "claude-sonnet-4.6"}, - {"claude-sonnet-5.0", "claude-sonnet-5.0"}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - got := upgradeModel(tt.input, available) - if got != tt.want { - t.Errorf("upgradeModel(%q) = %q, want %q", tt.input, got, tt.want) - } - }) - } -} diff --git a/main.go b/main.go index 9f538cb..12c395d 100644 --- a/main.go +++ b/main.go @@ -125,13 +125,7 @@ func main() { proxyHandler := proxy.NewHandler(authClient, transport, modelsCache, *debug) // Initialize Anthropic handler - noModelUpgrade := false - if v := os.Getenv("COPILOT2API_NO_MODEL_UPGRADE"); v != "" { - if enabled, err := strconv.ParseBool(v); err == nil { - noModelUpgrade = enabled - } - } - anthropicHandler := anthropic.NewHandler(authClient, transport, modelsCache, noModelUpgrade, *debug) + anthropicHandler := anthropic.NewHandler(authClient, transport, modelsCache, *debug) // Initialize Gemini handler geminiHandler := gemini.NewHandler(authClient, transport, modelsCache, *debug) From 2cab3969ef536ec2de2e3b82a0f9f0cb13f032c4 Mon Sep 17 00:00:00 2001 From: whtsky Date: Fri, 26 Jun 2026 21:59:36 +0800 Subject: [PATCH 2/2] feat: forward reasoning_effort and output_config.effort verbatim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot's modern /models listing declares per-model accepted effort values in capabilities.supports.reasoning_effort (e.g. xhigh on GPT-5, minimal on Gemini Flash, max on Anthropic). The previous request plumbing only knew the legacy thinking_budget numeric and bucketed it into low|medium|high at 8k/16k thresholds, so modern enum values were either dropped or flattened. - Add ReasoningEffort *string to OpenAIChatCompletionsRequest. Accept any string and forward to upstream; per-model validation is upstream's job (the supported enum varies per model). - Both proxy smart-route converters now forward the effort string directly instead of round-tripping through ThinkingBudget. Legacy thinking_budget remains as a fallback when no effort is supplied. - Anthropic→OpenAI bridge (ConvertAnthropicToOpenAI) now honors output_config.effort with priority over thinking.budget_tokens. - Stop flattening output_config.effort: "max" → "high" in the Responses path. max (and xhigh) are first-class effort values per the current Anthropic SDK and Copilot model capabilities. Verified end-to-end against the live Copilot upstream: pre-fix smart-routed bodies for gpt-5.5 with reasoning_effort low/high/xhigh were identical (field dropped); post-fix the upstream body contains reasoning: {effort, summary: "detailed"} and completion tokens scale 197→248→276 with effort. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 12 ++++- anthropic/convert.go | 9 +++- anthropic/convert_test.go | 59 +++++++++++++++++++++++ anthropic/responses_convert.go | 9 ++-- anthropic/responses_convert_test.go | 3 +- anthropic/types.go | 2 +- internal/types/openai.go | 5 ++ proxy/convert.go | 21 ++++++--- proxy/convert_test.go | 73 +++++++++++++++++++++++------ 9 files changed, 163 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c537502..3cb5e4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,17 @@ ### Features - Add local amp search support (`webSearch2`) using the Copilot Responses API with `web_search` tool (`gpt-5-mini` by default), and page extraction (`extractWebPageContent`) via Jina Reader, so amp CLI web search works without a paid ampcode.com account -- Auto-upgrade models to the best available variant (e.g. `claude-opus-4.7` → `claude-opus-4.7-1m-internal`) based on upstream model list, enabling features like `effort: high` that require extended variants +- Forward `reasoning_effort` verbatim on OpenAI `/v1/chat/completions` requests instead of dropping it during smart-routing to `/responses`. Values like `xhigh`, `minimal`, and `none` now reach the model — accept any string and let upstream validate against the per-model `capabilities.supports.reasoning_effort` enum +- Honor `output_config.effort` on Anthropic `/v1/messages` requests routed to OpenAI Chat Completions, in addition to the existing Responses path + +### Bug Fixes + +- Stop flattening Anthropic `output_config.effort: "max"` to `"high"` when routing to OpenAI Responses — `max` (and `xhigh`) are first-class effort values per current Anthropic SDK and Copilot model capabilities +- Stop bucketing OpenAI Responses `reasoning.effort` into the lossy `thinking_budget` numeric when smart-routing to `/chat/completions`; forward the effort string directly + +### Breaking Changes + +- Remove model auto-upgrade and the `COPILOT2API_NO_MODEL_UPGRADE` env var. Requests for a base model id (e.g. `claude-opus-4.7`) are no longer silently rewritten to `-1m-internal` / `-1m` variants — pass the variant id explicitly if you want it ## [0.3.1] - 2026-04-26 diff --git a/anthropic/convert.go b/anthropic/convert.go index c100e57..78d6263 100644 --- a/anthropic/convert.go +++ b/anthropic/convert.go @@ -22,8 +22,13 @@ func ConvertAnthropicToOpenAI(req AnthropicMessagesRequest) (OpenAIChatCompletio openAIReq.StreamOptions = &OpenAIStreamOptions{IncludeUsage: true} } - // Map thinking configuration - if req.Thinking != nil && req.Thinking.BudgetTokens != nil { + // Reasoning effort: prefer output_config.effort (modern Anthropic enum) + // over thinking.budget_tokens (legacy numeric). Forward effort verbatim; + // fall back to bucketing the budget when only the legacy field is set. + if req.OutputConfig != nil && req.OutputConfig.Effort != "" { + effort := req.OutputConfig.Effort + openAIReq.ReasoningEffort = &effort + } else if req.Thinking != nil && req.Thinking.BudgetTokens != nil { openAIReq.ThinkingBudget = req.Thinking.BudgetTokens } diff --git a/anthropic/convert_test.go b/anthropic/convert_test.go index 7666504..3fab08a 100644 --- a/anthropic/convert_test.go +++ b/anthropic/convert_test.go @@ -484,6 +484,65 @@ func floatPtr(f float64) *float64 { return &f } +func TestConvertAnthropicToOpenAI_OutputConfigEffort(t *testing.T) { + // output_config.effort takes priority over thinking.budget_tokens and + // is forwarded verbatim to ReasoningEffort — no lossy bucketing. + budget := 4000 + req := AnthropicMessagesRequest{ + Model: "claude-sonnet-4.6", + MaxTokens: 1024, + Messages: []AnthropicMessage{ + {Role: "user", Content: AnthropicContent{Text: stringPtr("hi")}}, + }, + OutputConfig: &AnthropicOutputConfig{Effort: "xhigh"}, + Thinking: &AnthropicThinking{Type: "enabled", BudgetTokens: &budget}, + } + + openAIReq, err := ConvertAnthropicToOpenAI(req) + if err != nil { + t.Fatalf("Conversion failed: %v", err) + } + + if openAIReq.ReasoningEffort == nil { + t.Fatal("ReasoningEffort should not be nil when output_config.effort is set") + } + if *openAIReq.ReasoningEffort != "xhigh" { + t.Errorf("ReasoningEffort = %q, want xhigh", *openAIReq.ReasoningEffort) + } + if openAIReq.ThinkingBudget != nil { + t.Errorf("ThinkingBudget should not be set when effort wins, got %d", *openAIReq.ThinkingBudget) + } +} + +func TestConvertAnthropicToOpenAI_ThinkingBudgetFallback(t *testing.T) { + // When only legacy thinking.budget_tokens is present, it still flows + // through as ThinkingBudget — no effort string is invented. + budget := 8000 + req := AnthropicMessagesRequest{ + Model: "claude-sonnet-4.6", + MaxTokens: 1024, + Messages: []AnthropicMessage{ + {Role: "user", Content: AnthropicContent{Text: stringPtr("hi")}}, + }, + Thinking: &AnthropicThinking{Type: "enabled", BudgetTokens: &budget}, + } + + openAIReq, err := ConvertAnthropicToOpenAI(req) + if err != nil { + t.Fatalf("Conversion failed: %v", err) + } + + if openAIReq.ReasoningEffort != nil { + t.Errorf("ReasoningEffort should be nil when only budget is set, got %q", *openAIReq.ReasoningEffort) + } + if openAIReq.ThinkingBudget == nil { + t.Fatal("ThinkingBudget should pass through") + } + if *openAIReq.ThinkingBudget != 8000 { + t.Errorf("ThinkingBudget = %d, want 8000", *openAIReq.ThinkingBudget) + } +} + func TestConvertOpenAIToAnthropic_SplitChoices(t *testing.T) { // Copilot API returns split choices for Claude models: // text in choice 0, tool_calls in choice 1 diff --git a/anthropic/responses_convert.go b/anthropic/responses_convert.go index 7a91b83..04d9a8a 100644 --- a/anthropic/responses_convert.go +++ b/anthropic/responses_convert.go @@ -8,13 +8,12 @@ import ( // resolveReasoningEffort determines the reasoning effort level for the Responses API. // OutputConfig.Effort takes priority when set; otherwise falls back to thinking budget. +// Effort strings are forwarded verbatim — the upstream model's +// capabilities.supports.reasoning_effort enum is the source of truth for what's +// accepted (e.g. xhigh on GPT-5, max on Anthropic, minimal on Gemini Flash). func resolveReasoningEffort(thinking *AnthropicThinking, outputConfig *AnthropicOutputConfig) string { if outputConfig != nil && outputConfig.Effort != "" { - effort := outputConfig.Effort - if effort == "max" { - effort = "high" // Responses API doesn't support "max" - } - return effort + return outputConfig.Effort } return thinkingEffort(thinking) } diff --git a/anthropic/responses_convert_test.go b/anthropic/responses_convert_test.go index 7fa1af8..ae8592f 100644 --- a/anthropic/responses_convert_test.go +++ b/anthropic/responses_convert_test.go @@ -90,7 +90,8 @@ func TestResolveReasoningEffort(t *testing.T) { {"output_config low", nil, effort("low"), "low"}, {"output_config medium", nil, effort("medium"), "medium"}, {"output_config high", nil, effort("high"), "high"}, - {"output_config max maps to high", nil, effort("max"), "high"}, + {"output_config max forwarded verbatim", nil, effort("max"), "max"}, + {"output_config xhigh forwarded verbatim", nil, effort("xhigh"), "xhigh"}, {"output_config overrides thinking", budget(4000), effort("high"), "high"}, {"output_config empty falls back to thinking", budget(4000), &AnthropicOutputConfig{}, "low"}, } diff --git a/anthropic/types.go b/anthropic/types.go index 11a89c4..8098e7d 100644 --- a/anthropic/types.go +++ b/anthropic/types.go @@ -34,7 +34,7 @@ type AnthropicThinking struct { // AnthropicOutputConfig represents output configuration type AnthropicOutputConfig struct { - Effort string `json:"effort,omitempty"` // "low", "medium", "high", "max" + Effort string `json:"effort,omitempty"` // "low", "medium", "high", "xhigh", "max" } // AnthropicSystem can be string or []AnthropicTextBlock diff --git a/internal/types/openai.go b/internal/types/openai.go index 67b85f1..cdd292c 100644 --- a/internal/types/openai.go +++ b/internal/types/openai.go @@ -21,6 +21,11 @@ type OpenAIChatCompletionsRequest struct { User string `json:"user,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` ThinkingBudget *int `json:"thinking_budget,omitempty"` + // ReasoningEffort is forwarded verbatim. Per-model vocabulary varies + // (see /v1/models capabilities.supports.reasoning_effort): GPT-5 series + // accepts none|minimal|low|medium|high|xhigh; Gemini Flash adds minimal + // without xhigh. Pass any string through and let upstream validate. + ReasoningEffort *string `json:"reasoning_effort,omitempty"` ReasoningText *string `json:"reasoning_text,omitempty"` ReasoningOpaque *string `json:"reasoning_opaque,omitempty"` ResponseFormat *ResponseFormat `json:"response_format,omitempty"` diff --git a/proxy/convert.go b/proxy/convert.go index bb503d2..f405cf4 100644 --- a/proxy/convert.go +++ b/proxy/convert.go @@ -77,8 +77,15 @@ func ConvertChatToResponsesRequest(req types.OpenAIChatCompletionsRequest) types result.MaxOutputTokens = &v } - // Thinking budget → reasoning - if req.ThinkingBudget != nil { + // Reasoning effort: prefer the modern enum string when the client sends + // it directly. Fall back to bucketing thinking_budget for older clients + // that only know the numeric field. + if req.ReasoningEffort != nil && *req.ReasoningEffort != "" { + result.Reasoning = &types.ResponseReasoning{ + Effort: *req.ReasoningEffort, + Summary: "detailed", + } + } else if req.ThinkingBudget != nil { result.Reasoning = &types.ResponseReasoning{ Effort: thinkingBudgetToEffort(*req.ThinkingBudget), Summary: "detailed", @@ -499,10 +506,12 @@ func ConvertResponsesToChatRequest(req types.ResponsesRequest) types.OpenAIChatC result.MaxTokens = &v } - // Reasoning → thinking budget - if req.Reasoning != nil { - budget := effortToThinkingBudget(req.Reasoning.Effort) - result.ThinkingBudget = &budget + // Reasoning effort: forward the enum string directly. Chat Completions + // accepts reasoning_effort natively, so we don't bucket it back into the + // lossy thinking_budget representation. + if req.Reasoning != nil && req.Reasoning.Effort != "" { + effort := req.Reasoning.Effort + result.ReasoningEffort = &effort } // text.format → response_format diff --git a/proxy/convert_test.go b/proxy/convert_test.go index f902791..b517182 100644 --- a/proxy/convert_test.go +++ b/proxy/convert_test.go @@ -219,6 +219,53 @@ func TestConvertChatToResponsesRequest_ThinkingBudget(t *testing.T) { } } +func TestConvertChatToResponsesRequest_ReasoningEffort(t *testing.T) { + // reasoning_effort is forwarded verbatim, including modern values + // (xhigh, minimal, none) that the legacy budget bucketing can't produce. + efforts := []string{"low", "medium", "high", "xhigh", "minimal", "none", "max"} + + for _, effort := range efforts { + e := effort + req := types.OpenAIChatCompletionsRequest{ + Model: "gpt-5.5", + ReasoningEffort: &e, + } + + result := ConvertChatToResponsesRequest(req) + + if result.Reasoning == nil { + t.Fatalf("effort=%q: Reasoning should not be nil", effort) + } + if result.Reasoning.Effort != effort { + t.Errorf("effort=%q: Effort = %q, want %q", effort, result.Reasoning.Effort, effort) + } + if result.Reasoning.Summary != "detailed" { + t.Errorf("effort=%q: Summary = %q, want detailed", effort, result.Reasoning.Summary) + } + } +} + +func TestConvertChatToResponsesRequest_ReasoningEffortBeatsBudget(t *testing.T) { + // When both are set, the explicit effort string wins over the legacy + // thinking_budget so clients can opt into the new vocabulary cleanly. + effort := "xhigh" + budget := 4000 + req := types.OpenAIChatCompletionsRequest{ + Model: "gpt-5.5", + ReasoningEffort: &effort, + ThinkingBudget: &budget, + } + + result := ConvertChatToResponsesRequest(req) + + if result.Reasoning == nil { + t.Fatal("Reasoning should not be nil") + } + if result.Reasoning.Effort != "xhigh" { + t.Errorf("Effort = %q, want xhigh (bucketed budget would be 'low')", result.Reasoning.Effort) + } +} + func TestConvertChatToResponsesRequest_UserMultipartContent(t *testing.T) { req := types.OpenAIChatCompletionsRequest{ Model: "gpt-4", @@ -368,30 +415,28 @@ func TestConvertResponsesToChatRequest_TemperatureZero(t *testing.T) { } func TestConvertResponsesToChatRequest_Reasoning(t *testing.T) { - tests := []struct { - effort string - budget int - }{ - {"high", 32000}, - {"medium", 12000}, - {"low", 4000}, - } + // Effort is forwarded verbatim, including values the legacy budget + // bucketing couldn't represent (xhigh, max, minimal, none). + efforts := []string{"low", "medium", "high", "xhigh", "max", "minimal", "none"} - for _, tt := range tests { + for _, effort := range efforts { req := types.ResponsesRequest{ Model: "gpt-4", Reasoning: &types.ResponseReasoning{ - Effort: tt.effort, + Effort: effort, }, } result := ConvertResponsesToChatRequest(req) - if result.ThinkingBudget == nil { - t.Fatalf("effort=%q: ThinkingBudget should not be nil", tt.effort) + if result.ReasoningEffort == nil { + t.Fatalf("effort=%q: ReasoningEffort should not be nil", effort) + } + if *result.ReasoningEffort != effort { + t.Errorf("effort=%q: ReasoningEffort = %q, want %q", effort, *result.ReasoningEffort, effort) } - if *result.ThinkingBudget != tt.budget { - t.Errorf("effort=%q: ThinkingBudget = %d, want %d", tt.effort, *result.ThinkingBudget, tt.budget) + if result.ThinkingBudget != nil { + t.Errorf("effort=%q: ThinkingBudget should not be set when forwarding effort verbatim, got %d", effort, *result.ThinkingBudget) } } }