From c9d96b7762782e625428e0ba022d0ae416df7526 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Thu, 19 Mar 2026 18:20:41 +0100 Subject: [PATCH 1/2] Fix EffortTokens() missing xhigh/max mappings for Bedrock thinking budget When a user sets thinking_budget to "xhigh" or "max" on a Bedrock model, EffortTokens() returned (0, false) causing thinking to be silently disabled. Add mappings for both levels to 32768 tokens. Assisted-By: docker-agent --- pkg/config/latest/types.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/config/latest/types.go b/pkg/config/latest/types.go index 20b431bbe..ffb828957 100644 --- a/pkg/config/latest/types.go +++ b/pkg/config/latest/types.go @@ -401,7 +401,7 @@ type ModelConfig struct { // - For Anthropic: accepts integer token budget (1024-32000), "adaptive", // or string levels "low", "medium", "high", "max" (uses adaptive thinking with effort) // - For Bedrock Claude: accepts integer token budget or string levels - // "minimal", "low", "medium", "high" (mapped to token budgets via EffortTokens) + // "minimal", "low", "medium", "high", "xhigh", "max" (mapped to token budgets via EffortTokens) // - For other providers: may be ignored ThinkingBudget *ThinkingBudget `json:"thinking_budget,omitempty"` // Routing defines rules for routing requests to different models. @@ -808,6 +808,10 @@ func (t *ThinkingBudget) EffortTokens() (int, bool) { return 8192, true case "high": return 16384, true + case "xhigh": + return 32768, true + case "max": + return 32768, true default: return 0, false } From 01179d6b38c9f7e43559f98f00c51fc79334091e Mon Sep 17 00:00:00 2001 From: David Gageot Date: Thu, 19 Mar 2026 18:32:37 +0100 Subject: [PATCH 2/2] Centralize effort levels into pkg/effort package Extract all thinking-effort level definitions, validation, and per-provider mappings into a dedicated pkg/effort package. This eliminates duplicated effort strings across types.go, openai, anthropic, gemini, and bedrock providers. Adding a new effort level now requires changing one file. Assisted-By: docker-agent --- pkg/config/latest/types.go | 85 +++------- pkg/effort/effort.go | 132 ++++++++++++++++ pkg/effort/effort_test.go | 210 +++++++++++++++++++++++++ pkg/model/provider/anthropic/client.go | 42 ++--- pkg/model/provider/gemini/client.go | 20 ++- pkg/model/provider/openai/client.go | 29 ++-- 6 files changed, 403 insertions(+), 115 deletions(-) create mode 100644 pkg/effort/effort.go create mode 100644 pkg/effort/effort_test.go diff --git a/pkg/config/latest/types.go b/pkg/config/latest/types.go index ffb828957..fc9315759 100644 --- a/pkg/config/latest/types.go +++ b/pkg/config/latest/types.go @@ -13,6 +13,7 @@ import ( "github.com/goccy/go-yaml" "github.com/docker/docker-agent/pkg/config/types" + "github.com/docker/docker-agent/pkg/effort" ) const Version = "7" @@ -396,13 +397,10 @@ type ModelConfig struct { // ProviderOpts allows provider-specific options. ProviderOpts map[string]any `json:"provider_opts,omitempty"` TrackUsage *bool `json:"track_usage,omitempty"` - // ThinkingBudget controls reasoning effort/budget: - // - For OpenAI: accepts string levels "minimal", "low", "medium", "high", "xhigh" - // - For Anthropic: accepts integer token budget (1024-32000), "adaptive", - // or string levels "low", "medium", "high", "max" (uses adaptive thinking with effort) - // - For Bedrock Claude: accepts integer token budget or string levels - // "minimal", "low", "medium", "high", "xhigh", "max" (mapped to token budgets via EffortTokens) - // - For other providers: may be ignored + // ThinkingBudget controls reasoning effort/budget. + // Accepts an integer token count or a string effort level. + // See [effort.ValidNames] for the full list of accepted strings. + // Provider-specific mappings are in the effort package. ThinkingBudget *ThinkingBudget `json:"thinking_budget,omitempty"` // Routing defines rules for routing requests to different models. // When routing is configured, this model becomes a rule-based router: @@ -672,11 +670,8 @@ func (d DeferConfig) MarshalYAML() (any, error) { } // ThinkingBudget represents reasoning budget configuration. -// It accepts either a string effort level or an integer token budget: -// - String: "minimal", "low", "medium", "high", "xhigh" (for OpenAI) -// - String: "adaptive" (Anthropic adaptive thinking with high effort by default) -// - String: "adaptive/" where effort is low/medium/high/max (Anthropic adaptive with specified effort) -// - Integer: token count (for Anthropic, range 1024-32768) +// It accepts either a string effort level (see [effort.ValidNames]) or an +// integer token budget. type ThinkingBudget struct { // Effort stores string-based reasoning effort levels Effort string `json:"effort,omitempty"` @@ -684,30 +679,6 @@ type ThinkingBudget struct { Tokens int `json:"tokens,omitempty"` } -// validThinkingEfforts lists all accepted string values for thinking_budget. -const validThinkingEfforts = "none, minimal, low, medium, high, xhigh, max, adaptive, adaptive/" - -// validAdaptiveEfforts lists the accepted effort levels for adaptive thinking. -var validAdaptiveEfforts = map[string]bool{ - "low": true, "medium": true, "high": true, "max": true, -} - -// isValidThinkingEffort reports whether s (case-insensitive, trimmed) is a -// recognised thinking_budget effort level. -func isValidThinkingEffort(s string) bool { - norm := strings.ToLower(strings.TrimSpace(s)) - switch norm { - case "none", "minimal", "low", "medium", "high", "xhigh", "max", "adaptive": - return true - default: - // Support "adaptive/" format (e.g. "adaptive/high") - if after, ok := strings.CutPrefix(norm, "adaptive/"); ok { - return validAdaptiveEfforts[after] - } - return false - } -} - func (t *ThinkingBudget) UnmarshalYAML(unmarshal func(any) error) error { // Try integer tokens first var n int @@ -719,8 +690,8 @@ func (t *ThinkingBudget) UnmarshalYAML(unmarshal func(any) error) error { // Try string level var s string if err := unmarshal(&s); err == nil { - if !isValidThinkingEffort(s) { - return fmt.Errorf("invalid thinking_budget effort %q: must be one of %s", s, validThinkingEfforts) + if !effort.IsValid(s) { + return fmt.Errorf("invalid thinking_budget effort %q: must be one of %s", s, effort.ValidNames()) } *t = ThinkingBudget{Effort: s} return nil @@ -772,6 +743,16 @@ func (t *ThinkingBudget) IsAdaptive() bool { return norm == "adaptive" || strings.HasPrefix(norm, "adaptive/") } +// EffortLevel parses the Effort field into an [effort.Level]. +// Returns ("", false) when the budget is nil, uses token counts, or has an +// unrecognised effort string. +func (t *ThinkingBudget) EffortLevel() (effort.Level, bool) { + if t == nil { + return "", false + } + return effort.Parse(t.Effort) +} + // AdaptiveEffort returns the effort level for adaptive thinking. // For "adaptive" it returns the default ("high"). // For "adaptive/" it returns the specified effort. @@ -789,32 +770,16 @@ func (t *ThinkingBudget) AdaptiveEffort() (string, bool) { // EffortTokens maps a string effort level to a token budget for providers // that only support token-based thinking (e.g. Bedrock Claude). -// -// The Anthropic direct API uses adaptive thinking + output_config.effort -// for string levels instead; see anthropicEffort in the anthropic package. +// Delegates to [effort.BedrockTokens]. // // Returns (tokens, true) when a mapping exists, or (0, false) when // the budget uses an explicit token count or an unrecognised effort string. func (t *ThinkingBudget) EffortTokens() (int, bool) { - if t == nil || t.Effort == "" { - return 0, false - } - switch strings.ToLower(strings.TrimSpace(t.Effort)) { - case "minimal": - return 1024, true - case "low": - return 2048, true - case "medium": - return 8192, true - case "high": - return 16384, true - case "xhigh": - return 32768, true - case "max": - return 32768, true - default: + l, ok := t.EffortLevel() + if !ok { return 0, false } + return effort.BedrockTokens(l) } // MarshalJSON implements custom marshaling to output simple string or int format @@ -842,8 +807,8 @@ func (t *ThinkingBudget) UnmarshalJSON(data []byte) error { // Try string level var s string if err := json.Unmarshal(data, &s); err == nil { - if !isValidThinkingEffort(s) { - return fmt.Errorf("invalid thinking_budget effort %q: must be one of %s", s, validThinkingEfforts) + if !effort.IsValid(s) { + return fmt.Errorf("invalid thinking_budget effort %q: must be one of %s", s, effort.ValidNames()) } *t = ThinkingBudget{Effort: s} return nil diff --git a/pkg/effort/effort.go b/pkg/effort/effort.go new file mode 100644 index 000000000..d4340572b --- /dev/null +++ b/pkg/effort/effort.go @@ -0,0 +1,132 @@ +// Package effort defines the canonical set of thinking-effort levels and +// provides per-provider mapping helpers. All provider packages should use +// this package instead of hard-coding effort strings. +package effort + +import "strings" + +// Level represents a thinking effort level. +type Level string + +// String returns the string representation of the Level. +func (l Level) String() string { + return string(l) +} + +const ( + None Level = "none" + Minimal Level = "minimal" + Low Level = "low" + Medium Level = "medium" + High Level = "high" + XHigh Level = "xhigh" + Max Level = "max" +) + +// allLevels lists every non-adaptive level in ascending order. +var allLevels = []Level{None, Minimal, Low, Medium, High, XHigh, Max} + +// adaptiveEfforts are the effort sub-levels valid after "adaptive/". +var adaptiveEfforts = map[string]bool{ + string(Low): true, string(Medium): true, string(High): true, string(Max): true, +} + +// Parse normalises s (case-insensitive, trimmed) and returns the matching +// Level. It returns ("", false) for unknown strings, adaptive values, and +// empty input. Use [IsValid] for full validation including adaptive forms. +func Parse(s string) (Level, bool) { + norm := strings.ToLower(strings.TrimSpace(s)) + for _, l := range allLevels { + if norm == string(l) { + return l, true + } + } + return "", false +} + +// IsValid reports whether s is a recognised thinking_budget effort value. +// It accepts every [Level] constant, plain "adaptive", and the +// "adaptive/" form. +func IsValid(s string) bool { + if _, ok := Parse(s); ok { + return true + } + norm := strings.ToLower(strings.TrimSpace(s)) + if norm == "adaptive" { + return true + } + if after, ok := strings.CutPrefix(norm, "adaptive/"); ok { + return adaptiveEfforts[after] + } + return false +} + +// IsValidAdaptive reports whether sub is a valid effort for "adaptive/". +func IsValidAdaptive(sub string) bool { + return adaptiveEfforts[strings.ToLower(strings.TrimSpace(sub))] +} + +// ValidNames returns a human-readable list of accepted values, suitable for +// error messages. +func ValidNames() string { + return "none, minimal, low, medium, high, xhigh, max, adaptive, adaptive/" +} + +// --------------------------------------------------------------------------- +// Provider-specific mappings +// --------------------------------------------------------------------------- + +// ForOpenAI returns the OpenAI reasoning_effort string for l. +// OpenAI accepts: minimal, low, medium, high, xhigh. +func ForOpenAI(l Level) (string, bool) { + switch l { + case Minimal, Low, Medium, High, XHigh: + return string(l), true + default: + return "", false + } +} + +// ForAnthropic returns the Anthropic output_config effort string for l. +// Anthropic accepts: low, medium, high, max. +// Minimal is mapped to low as the closest equivalent. +func ForAnthropic(l Level) (string, bool) { + switch l { + case Minimal: + return string(Low), true + case Low, Medium, High, Max: + return string(l), true + default: + return "", false + } +} + +// BedrockTokens maps l to a token budget for Bedrock Claude, which only +// supports token-based thinking budgets. +func BedrockTokens(l Level) (int, bool) { + switch l { + case Minimal: + return 1024, true + case Low: + return 2048, true + case Medium: + return 8192, true + case High: + return 16384, true + case XHigh, Max: + return 32768, true + default: + return 0, false + } +} + +// ForGemini3 returns the Gemini 3 thinking-level string for l. +// Gemini 3 accepts: minimal, low, medium, high. +func ForGemini3(l Level) (string, bool) { + switch l { + case Minimal, Low, Medium, High: + return string(l), true + default: + return "", false + } +} diff --git a/pkg/effort/effort_test.go b/pkg/effort/effort_test.go new file mode 100644 index 000000000..e275f5830 --- /dev/null +++ b/pkg/effort/effort_test.go @@ -0,0 +1,210 @@ +package effort + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + input string + want Level + ok bool + }{ + {"none", None, true}, + {"minimal", Minimal, true}, + {"low", Low, true}, + {"medium", Medium, true}, + {"high", High, true}, + {"xhigh", XHigh, true}, + {"max", Max, true}, + {"HIGH", High, true}, + {" Medium ", Medium, true}, + {"adaptive", "", false}, + {"adaptive/high", "", false}, + {"unknown", "", false}, + {"", "", false}, + } { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + got, ok := Parse(tt.input) + assert.Equal(t, tt.ok, ok) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestIsValid(t *testing.T) { + t.Parallel() + + valid := []string{ + "none", "minimal", "low", "medium", "high", "xhigh", "max", + "adaptive", "adaptive/low", "adaptive/medium", "adaptive/high", "adaptive/max", + "ADAPTIVE/HIGH", " adaptive ", + } + for _, s := range valid { + t.Run("valid_"+s, func(t *testing.T) { + t.Parallel() + assert.True(t, IsValid(s), "expected %q to be valid", s) + }) + } + + invalid := []string{ + "", "unknown", "adaptive/none", "adaptive/minimal", "adaptive/xhigh", + "adaptive/", "adaptive/foo", + } + for _, s := range invalid { + t.Run("invalid_"+s, func(t *testing.T) { + t.Parallel() + assert.False(t, IsValid(s), "expected %q to be invalid", s) + }) + } +} + +func TestForOpenAI(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + level Level + want string + ok bool + }{ + {Minimal, "minimal", true}, + {Low, "low", true}, + {Medium, "medium", true}, + {High, "high", true}, + {XHigh, "xhigh", true}, + {Max, "", false}, + {None, "", false}, + } { + t.Run(string(tt.level), func(t *testing.T) { + t.Parallel() + got, ok := ForOpenAI(tt.level) + require.Equal(t, tt.ok, ok) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestForAnthropic(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + level Level + want string + ok bool + }{ + {Minimal, "low", true}, // minimal maps to low + {Low, "low", true}, + {Medium, "medium", true}, + {High, "high", true}, + {Max, "max", true}, + {XHigh, "", false}, + {None, "", false}, + } { + t.Run(string(tt.level), func(t *testing.T) { + t.Parallel() + got, ok := ForAnthropic(tt.level) + require.Equal(t, tt.ok, ok) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestBedrockTokens(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + level Level + want int + ok bool + }{ + {Minimal, 1024, true}, + {Low, 2048, true}, + {Medium, 8192, true}, + {High, 16384, true}, + {XHigh, 32768, true}, + {Max, 32768, true}, + {None, 0, false}, + } { + t.Run(string(tt.level), func(t *testing.T) { + t.Parallel() + got, ok := BedrockTokens(tt.level) + require.Equal(t, tt.ok, ok) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestForGemini3(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + level Level + want string + ok bool + }{ + {Minimal, "minimal", true}, + {Low, "low", true}, + {Medium, "medium", true}, + {High, "high", true}, + {XHigh, "", false}, + {Max, "", false}, + {None, "", false}, + } { + t.Run(string(tt.level), func(t *testing.T) { + t.Parallel() + got, ok := ForGemini3(tt.level) + require.Equal(t, tt.ok, ok) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestIsValidAdaptive(t *testing.T) { + t.Parallel() + + valid := []string{"low", "medium", "high", "max", "HIGH", " Medium "} + for _, s := range valid { + t.Run("valid_"+s, func(t *testing.T) { + t.Parallel() + assert.True(t, IsValidAdaptive(s), "expected %q to be valid", s) + }) + } + + invalid := []string{"", "none", "minimal", "xhigh", "unknown", "adaptive", "adaptive/high"} + for _, s := range invalid { + t.Run("invalid_"+s, func(t *testing.T) { + t.Parallel() + assert.False(t, IsValidAdaptive(s), "expected %q to be invalid", s) + }) + } +} + +func TestString(t *testing.T) { + t.Parallel() + + tests := []struct { + level Level + want string + }{ + {None, "none"}, + {Minimal, "minimal"}, + {Low, "low"}, + {Medium, "medium"}, + {High, "high"}, + {XHigh, "xhigh"}, + {Max, "max"}, + } + + for _, tt := range tests { + t.Run(string(tt.level), func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, tt.level.String()) + }) + } +} diff --git a/pkg/model/provider/anthropic/client.go b/pkg/model/provider/anthropic/client.go index 05554a5cb..c9f66fbd4 100644 --- a/pkg/model/provider/anthropic/client.go +++ b/pkg/model/provider/anthropic/client.go @@ -19,6 +19,7 @@ import ( "github.com/docker/docker-agent/pkg/chat" "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/effort" "github.com/docker/docker-agent/pkg/environment" "github.com/docker/docker-agent/pkg/httpclient" "github.com/docker/docker-agent/pkg/model/provider/base" @@ -309,12 +310,12 @@ func (c *Client) CreateChatCompletionStream( // Apply thinking budget first, as it affects whether we can set temperature thinkingEnabled := false if budget := c.ModelConfig.ThinkingBudget; budget != nil { - if effort, ok := anthropicThinkingEffort(budget); ok { + if effortStr, ok := anthropicThinkingEffort(budget); ok { adaptive := anthropic.NewThinkingConfigAdaptiveParam() params.Thinking = anthropic.ThinkingConfigParamUnion{OfAdaptive: &adaptive} - params.OutputConfig.Effort = anthropic.OutputConfigEffort(effort) + params.OutputConfig.Effort = anthropic.OutputConfigEffort(effortStr) thinkingEnabled = true - slog.Debug("Anthropic API using adaptive thinking", "effort", effort) + slog.Debug("Anthropic API using adaptive thinking", "effort", effortStr) } else if tokens, ok := validThinkingTokens(int64(budget.Tokens), maxTokens); ok { params.Thinking = anthropic.ThinkingConfigParamOfEnabled(tokens) thinkingEnabled = true @@ -922,41 +923,20 @@ func validThinkingTokens(tokens, maxTokens int64) (int64, bool) { } // anthropicThinkingEffort returns the Anthropic API effort level for the given -// ThinkingBudget. It covers both explicit adaptive mode ("adaptive", -// "adaptive/") and string effort levels ("low", "medium", "high", "max") -// that Anthropic maps to adaptive thinking. Returns ("", false) when the -// budget uses token counts or is nil. +// ThinkingBudget. It covers both explicit adaptive mode and string effort +// levels. Returns ("", false) when the budget uses token counts or is nil. func anthropicThinkingEffort(b *latest.ThinkingBudget) (string, bool) { if b == nil { return "", false } - if effort, ok := b.AdaptiveEffort(); ok { - return effort, true + if e, ok := b.AdaptiveEffort(); ok { + return e, true } - return anthropicEffort(b) -} - -// anthropicEffort maps a ThinkingBudget effort string to an Anthropic API -// effort level ("low", "medium", "high", "max"). Returns ("", false) when -// the budget uses token counts, adaptive mode, or an unrecognised string. -func anthropicEffort(b *latest.ThinkingBudget) (string, bool) { - if b == nil { - return "", false - } - switch strings.ToLower(strings.TrimSpace(b.Effort)) { - case "low": - return "low", true - case "minimal": // "minimal" is not in the Anthropic API; map to closest - return "low", true - case "medium": - return "medium", true - case "high": - return "high", true - case "max": - return "max", true - default: + l, ok := b.EffortLevel() + if !ok { return "", false } + return effort.ForAnthropic(l) } // anthropicContextLimit returns a reasonable default context window for Anthropic models. diff --git a/pkg/model/provider/gemini/client.go b/pkg/model/provider/gemini/client.go index 1cf2f2713..1f36f8b39 100644 --- a/pkg/model/provider/gemini/client.go +++ b/pkg/model/provider/gemini/client.go @@ -16,6 +16,7 @@ import ( "github.com/docker/docker-agent/pkg/chat" "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/effort" "github.com/docker/docker-agent/pkg/environment" "github.com/docker/docker-agent/pkg/httpclient" "github.com/docker/docker-agent/pkg/model/provider/base" @@ -414,19 +415,16 @@ func (c *Client) applyGemini3ThinkingLevel(config *genai.GenerateContentConfig) } // gemini3ThinkingLevel maps an effort string to a Gemini 3 ThinkingLevel. -func gemini3ThinkingLevel(effort string) (genai.ThinkingLevel, bool) { - switch strings.ToLower(strings.TrimSpace(effort)) { - case "minimal": - return genai.ThinkingLevelMinimal, true - case "low": - return genai.ThinkingLevelLow, true - case "medium": - return genai.ThinkingLevelMedium, true - case "high": - return genai.ThinkingLevelHigh, true - default: +func gemini3ThinkingLevel(effortStr string) (genai.ThinkingLevel, bool) { + l, ok := effort.Parse(effortStr) + if !ok { + return "", false + } + s, ok := effort.ForGemini3(l) + if !ok { return "", false } + return genai.ThinkingLevel(strings.ToUpper(s)), true } // applyGemini25ThinkingBudget applies token-based thinking for Gemini 2.5 and other models. diff --git a/pkg/model/provider/openai/client.go b/pkg/model/provider/openai/client.go index 3537441fd..d3ad07eff 100644 --- a/pkg/model/provider/openai/client.go +++ b/pkg/model/provider/openai/client.go @@ -18,6 +18,7 @@ import ( "github.com/docker/docker-agent/pkg/chat" "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/effort" "github.com/docker/docker-agent/pkg/environment" "github.com/docker/docker-agent/pkg/httpclient" "github.com/docker/docker-agent/pkg/model/provider/base" @@ -251,13 +252,13 @@ func (c *Client) CreateChatCompletionStream( // Apply thinking budget: set reasoning_effort for reasoning models (o-series, gpt-5) if c.ModelConfig.ThinkingBudget != nil && isOpenAIReasoningModel(c.ModelConfig.Model) { - effort, err := openAIReasoningEffort(c.ModelConfig.ThinkingBudget) + effortStr, err := openAIReasoningEffort(c.ModelConfig.ThinkingBudget) if err != nil { slog.Error("OpenAI request using thinking_budget failed", "error", err) return nil, err } - params.ReasoningEffort = shared.ReasoningEffort(effort) - slog.Debug("OpenAI request using thinking_budget", "reasoning_effort", effort) + params.ReasoningEffort = shared.ReasoningEffort(effortStr) + slog.Debug("OpenAI request using thinking_budget", "reasoning_effort", effortStr) } // Apply structured output configuration @@ -367,13 +368,13 @@ func (c *Client) CreateResponseStream( Summary: shared.ReasoningSummaryDetailed, } if c.ModelConfig.ThinkingBudget != nil { - effort, err := openAIReasoningEffort(c.ModelConfig.ThinkingBudget) + effortStr, err := openAIReasoningEffort(c.ModelConfig.ThinkingBudget) if err != nil { slog.Error("OpenAI responses request using thinking_budget failed", "error", err) return nil, err } - params.Reasoning.Effort = shared.ReasoningEffort(effort) - slog.Debug("OpenAI responses request using thinking_budget", "reasoning_effort", effort) + params.Reasoning.Effort = shared.ReasoningEffort(effortStr) + slog.Debug("OpenAI responses request using thinking_budget", "reasoning_effort", effortStr) } } @@ -902,15 +903,17 @@ func isOpenAIReasoningModel(model string) bool { } // openAIReasoningEffort validates a ThinkingBudget effort string for the -// OpenAI API. Returns the effort (minimal|low|medium|high|xhigh) or an error. +// OpenAI API. Returns the effort string or an error. func openAIReasoningEffort(b *latest.ThinkingBudget) (string, error) { - effort := strings.TrimSpace(strings.ToLower(b.Effort)) - switch effort { - case "minimal", "low", "medium", "high", "xhigh": - return effort, nil - default: - return "", fmt.Errorf("OpenAI requests only support 'minimal', 'low', 'medium', 'high', 'xhigh' as values for thinking_budget effort, got effort: '%s', tokens: '%d'", effort, b.Tokens) + l, ok := b.EffortLevel() + if !ok { + return "", fmt.Errorf("OpenAI reasoning models require a string thinking_budget (%s), got effort: '%s', tokens: '%d'", effort.ValidNames(), b.Effort, b.Tokens) + } + s, ok := effort.ForOpenAI(l) + if !ok { + return "", fmt.Errorf("OpenAI reasoning models require a string thinking_budget (%s), got effort: '%s', tokens: '%d'", effort.ValidNames(), b.Effort, b.Tokens) } + return s, nil } // jsonSchema is a helper type that implements json.Marshaler for map[string]any