Skip to content

Commit 7311fc1

Browse files
authored
Merge pull request #2178 from dgageot/efforts
Fix issues and refactor thinking efforts
2 parents 31cb345 + 01179d6 commit 7311fc1

File tree

6 files changed

+403
-111
lines changed

6 files changed

+403
-111
lines changed

pkg/config/latest/types.go

Lines changed: 25 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/goccy/go-yaml"
1414

1515
"github.com/docker/docker-agent/pkg/config/types"
16+
"github.com/docker/docker-agent/pkg/effort"
1617
)
1718

1819
const Version = "7"
@@ -396,13 +397,10 @@ type ModelConfig struct {
396397
// ProviderOpts allows provider-specific options.
397398
ProviderOpts map[string]any `json:"provider_opts,omitempty"`
398399
TrackUsage *bool `json:"track_usage,omitempty"`
399-
// ThinkingBudget controls reasoning effort/budget:
400-
// - For OpenAI: accepts string levels "minimal", "low", "medium", "high", "xhigh"
401-
// - For Anthropic: accepts integer token budget (1024-32000), "adaptive",
402-
// or string levels "low", "medium", "high", "max" (uses adaptive thinking with effort)
403-
// - For Bedrock Claude: accepts integer token budget or string levels
404-
// "minimal", "low", "medium", "high" (mapped to token budgets via EffortTokens)
405-
// - For other providers: may be ignored
400+
// ThinkingBudget controls reasoning effort/budget.
401+
// Accepts an integer token count or a string effort level.
402+
// See [effort.ValidNames] for the full list of accepted strings.
403+
// Provider-specific mappings are in the effort package.
406404
ThinkingBudget *ThinkingBudget `json:"thinking_budget,omitempty"`
407405
// Routing defines rules for routing requests to different models.
408406
// When routing is configured, this model becomes a rule-based router:
@@ -672,42 +670,15 @@ func (d DeferConfig) MarshalYAML() (any, error) {
672670
}
673671

674672
// ThinkingBudget represents reasoning budget configuration.
675-
// It accepts either a string effort level or an integer token budget:
676-
// - String: "minimal", "low", "medium", "high", "xhigh" (for OpenAI)
677-
// - String: "adaptive" (Anthropic adaptive thinking with high effort by default)
678-
// - String: "adaptive/<effort>" where effort is low/medium/high/max (Anthropic adaptive with specified effort)
679-
// - Integer: token count (for Anthropic, range 1024-32768)
673+
// It accepts either a string effort level (see [effort.ValidNames]) or an
674+
// integer token budget.
680675
type ThinkingBudget struct {
681676
// Effort stores string-based reasoning effort levels
682677
Effort string `json:"effort,omitempty"`
683678
// Tokens stores integer-based token budgets
684679
Tokens int `json:"tokens,omitempty"`
685680
}
686681

687-
// validThinkingEfforts lists all accepted string values for thinking_budget.
688-
const validThinkingEfforts = "none, minimal, low, medium, high, xhigh, max, adaptive, adaptive/<effort>"
689-
690-
// validAdaptiveEfforts lists the accepted effort levels for adaptive thinking.
691-
var validAdaptiveEfforts = map[string]bool{
692-
"low": true, "medium": true, "high": true, "max": true,
693-
}
694-
695-
// isValidThinkingEffort reports whether s (case-insensitive, trimmed) is a
696-
// recognised thinking_budget effort level.
697-
func isValidThinkingEffort(s string) bool {
698-
norm := strings.ToLower(strings.TrimSpace(s))
699-
switch norm {
700-
case "none", "minimal", "low", "medium", "high", "xhigh", "max", "adaptive":
701-
return true
702-
default:
703-
// Support "adaptive/<effort>" format (e.g. "adaptive/high")
704-
if after, ok := strings.CutPrefix(norm, "adaptive/"); ok {
705-
return validAdaptiveEfforts[after]
706-
}
707-
return false
708-
}
709-
}
710-
711682
func (t *ThinkingBudget) UnmarshalYAML(unmarshal func(any) error) error {
712683
// Try integer tokens first
713684
var n int
@@ -719,8 +690,8 @@ func (t *ThinkingBudget) UnmarshalYAML(unmarshal func(any) error) error {
719690
// Try string level
720691
var s string
721692
if err := unmarshal(&s); err == nil {
722-
if !isValidThinkingEffort(s) {
723-
return fmt.Errorf("invalid thinking_budget effort %q: must be one of %s", s, validThinkingEfforts)
693+
if !effort.IsValid(s) {
694+
return fmt.Errorf("invalid thinking_budget effort %q: must be one of %s", s, effort.ValidNames())
724695
}
725696
*t = ThinkingBudget{Effort: s}
726697
return nil
@@ -772,6 +743,16 @@ func (t *ThinkingBudget) IsAdaptive() bool {
772743
return norm == "adaptive" || strings.HasPrefix(norm, "adaptive/")
773744
}
774745

746+
// EffortLevel parses the Effort field into an [effort.Level].
747+
// Returns ("", false) when the budget is nil, uses token counts, or has an
748+
// unrecognised effort string.
749+
func (t *ThinkingBudget) EffortLevel() (effort.Level, bool) {
750+
if t == nil {
751+
return "", false
752+
}
753+
return effort.Parse(t.Effort)
754+
}
755+
775756
// AdaptiveEffort returns the effort level for adaptive thinking.
776757
// For "adaptive" it returns the default ("high").
777758
// For "adaptive/<effort>" it returns the specified effort.
@@ -789,28 +770,16 @@ func (t *ThinkingBudget) AdaptiveEffort() (string, bool) {
789770

790771
// EffortTokens maps a string effort level to a token budget for providers
791772
// that only support token-based thinking (e.g. Bedrock Claude).
792-
//
793-
// The Anthropic direct API uses adaptive thinking + output_config.effort
794-
// for string levels instead; see anthropicEffort in the anthropic package.
773+
// Delegates to [effort.BedrockTokens].
795774
//
796775
// Returns (tokens, true) when a mapping exists, or (0, false) when
797776
// the budget uses an explicit token count or an unrecognised effort string.
798777
func (t *ThinkingBudget) EffortTokens() (int, bool) {
799-
if t == nil || t.Effort == "" {
800-
return 0, false
801-
}
802-
switch strings.ToLower(strings.TrimSpace(t.Effort)) {
803-
case "minimal":
804-
return 1024, true
805-
case "low":
806-
return 2048, true
807-
case "medium":
808-
return 8192, true
809-
case "high":
810-
return 16384, true
811-
default:
778+
l, ok := t.EffortLevel()
779+
if !ok {
812780
return 0, false
813781
}
782+
return effort.BedrockTokens(l)
814783
}
815784

816785
// MarshalJSON implements custom marshaling to output simple string or int format
@@ -838,8 +807,8 @@ func (t *ThinkingBudget) UnmarshalJSON(data []byte) error {
838807
// Try string level
839808
var s string
840809
if err := json.Unmarshal(data, &s); err == nil {
841-
if !isValidThinkingEffort(s) {
842-
return fmt.Errorf("invalid thinking_budget effort %q: must be one of %s", s, validThinkingEfforts)
810+
if !effort.IsValid(s) {
811+
return fmt.Errorf("invalid thinking_budget effort %q: must be one of %s", s, effort.ValidNames())
843812
}
844813
*t = ThinkingBudget{Effort: s}
845814
return nil

pkg/effort/effort.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Package effort defines the canonical set of thinking-effort levels and
2+
// provides per-provider mapping helpers. All provider packages should use
3+
// this package instead of hard-coding effort strings.
4+
package effort
5+
6+
import "strings"
7+
8+
// Level represents a thinking effort level.
9+
type Level string
10+
11+
// String returns the string representation of the Level.
12+
func (l Level) String() string {
13+
return string(l)
14+
}
15+
16+
const (
17+
None Level = "none"
18+
Minimal Level = "minimal"
19+
Low Level = "low"
20+
Medium Level = "medium"
21+
High Level = "high"
22+
XHigh Level = "xhigh"
23+
Max Level = "max"
24+
)
25+
26+
// allLevels lists every non-adaptive level in ascending order.
27+
var allLevels = []Level{None, Minimal, Low, Medium, High, XHigh, Max}
28+
29+
// adaptiveEfforts are the effort sub-levels valid after "adaptive/".
30+
var adaptiveEfforts = map[string]bool{
31+
string(Low): true, string(Medium): true, string(High): true, string(Max): true,
32+
}
33+
34+
// Parse normalises s (case-insensitive, trimmed) and returns the matching
35+
// Level. It returns ("", false) for unknown strings, adaptive values, and
36+
// empty input. Use [IsValid] for full validation including adaptive forms.
37+
func Parse(s string) (Level, bool) {
38+
norm := strings.ToLower(strings.TrimSpace(s))
39+
for _, l := range allLevels {
40+
if norm == string(l) {
41+
return l, true
42+
}
43+
}
44+
return "", false
45+
}
46+
47+
// IsValid reports whether s is a recognised thinking_budget effort value.
48+
// It accepts every [Level] constant, plain "adaptive", and the
49+
// "adaptive/<effort>" form.
50+
func IsValid(s string) bool {
51+
if _, ok := Parse(s); ok {
52+
return true
53+
}
54+
norm := strings.ToLower(strings.TrimSpace(s))
55+
if norm == "adaptive" {
56+
return true
57+
}
58+
if after, ok := strings.CutPrefix(norm, "adaptive/"); ok {
59+
return adaptiveEfforts[after]
60+
}
61+
return false
62+
}
63+
64+
// IsValidAdaptive reports whether sub is a valid effort for "adaptive/<sub>".
65+
func IsValidAdaptive(sub string) bool {
66+
return adaptiveEfforts[strings.ToLower(strings.TrimSpace(sub))]
67+
}
68+
69+
// ValidNames returns a human-readable list of accepted values, suitable for
70+
// error messages.
71+
func ValidNames() string {
72+
return "none, minimal, low, medium, high, xhigh, max, adaptive, adaptive/<effort>"
73+
}
74+
75+
// ---------------------------------------------------------------------------
76+
// Provider-specific mappings
77+
// ---------------------------------------------------------------------------
78+
79+
// ForOpenAI returns the OpenAI reasoning_effort string for l.
80+
// OpenAI accepts: minimal, low, medium, high, xhigh.
81+
func ForOpenAI(l Level) (string, bool) {
82+
switch l {
83+
case Minimal, Low, Medium, High, XHigh:
84+
return string(l), true
85+
default:
86+
return "", false
87+
}
88+
}
89+
90+
// ForAnthropic returns the Anthropic output_config effort string for l.
91+
// Anthropic accepts: low, medium, high, max.
92+
// Minimal is mapped to low as the closest equivalent.
93+
func ForAnthropic(l Level) (string, bool) {
94+
switch l {
95+
case Minimal:
96+
return string(Low), true
97+
case Low, Medium, High, Max:
98+
return string(l), true
99+
default:
100+
return "", false
101+
}
102+
}
103+
104+
// BedrockTokens maps l to a token budget for Bedrock Claude, which only
105+
// supports token-based thinking budgets.
106+
func BedrockTokens(l Level) (int, bool) {
107+
switch l {
108+
case Minimal:
109+
return 1024, true
110+
case Low:
111+
return 2048, true
112+
case Medium:
113+
return 8192, true
114+
case High:
115+
return 16384, true
116+
case XHigh, Max:
117+
return 32768, true
118+
default:
119+
return 0, false
120+
}
121+
}
122+
123+
// ForGemini3 returns the Gemini 3 thinking-level string for l.
124+
// Gemini 3 accepts: minimal, low, medium, high.
125+
func ForGemini3(l Level) (string, bool) {
126+
switch l {
127+
case Minimal, Low, Medium, High:
128+
return string(l), true
129+
default:
130+
return "", false
131+
}
132+
}

0 commit comments

Comments
 (0)