Vectrum
Sign In

llm: per-model capabilities, and a client a subagent can borrow

Three things the API treats as per-model, which this build treated as
universal. Adaptive thinking and output_config.effort are both rejected
outright by models that lack them — a 400 that ends a run before any work
happens — so each gets a table of ids confirmed to accept it, and an id this
build has never heard of is assumed to accept neither. The two mistakes are
not symmetric: omitting a parameter costs the setting and says so, where
sending one that is not supported costs the run.

basePrice, which both tables and the pricing table normalise through, now sees
past gateway decoration as well: a routing prefix, a Bedrock namespace, a
Vertex version suffix all name the same model, and a proxy should not silently
lose pricing and capability answers for models this build knows perfectly well.

Clone is for the subagents that follow: a client that authenticates exactly as
the session's does and differs only in the model it asks for. The credential is
shared rather than copied, so a borrowed OAuth token refreshes once for every
agent using it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jjonicommitted 1 parent 4f35549commite40c80c

Showing 6 changed files with 266 additions and 0 deletions.

+23−0internal/llm/client.goView File
@@ -61,6 +61,29 @@ func (c *Client) SetModel(id string) {
6161 c.model = id
6262 }
6363
64+ // Clone returns a copy that authenticates exactly as this client does and
65+ // differs only in the model it asks for. A subagent runs on its own model, and
66+ // must keep running on it even if the session switches the parent's — so the
67+ // model is copied, not shared, and so is the lock that guards it.
68+ func (c *Client) Clone(model string) *Client {
69+ c.mu.Lock()
70+ defer c.mu.Unlock()
71+ if model == "" {
72+ model = c.model
73+ }
74+ return &Client{
75+ BaseURL: c.BaseURL,
76+ MaxTokens: c.MaxTokens,
77+ MaxRetries: c.MaxRetries,
78+ RetryDelay: c.RetryDelay,
79+ HTTP: c.HTTP,
80+ apiKey: c.apiKey,
81+ authToken: c.authToken,
82+ tokenSource: c.tokenSource,
83+ model: model,
84+ }
85+ }
86+
6487 // errNoCredentials is what every route reports when it has nothing to
6588 // authenticate with. It is one value rather than three copies of a string
6689 // because ListModels, CreateMessage and StreamMessage all reach it, and a
+51−0internal/llm/client_test.goView File
@@ -434,3 +434,54 @@ func TestSetModelChangesWhatTheNextRequestAsksFor(t *testing.T) {
434434 t.Errorf("models sent = %v, want %q", models, want)
435435 }
436436 }
437+
438+ // A subagent runs on its own model without disturbing the session's. Clone is
439+ // how it gets a client that authenticates exactly as the parent does and
440+ // differs in nothing else.
441+ func TestCloneKeepsCredentialsAndOwnsItsModel(t *testing.T) {
442+ var models []string
443+ var keys []string
444+ parent := testClient(t, func(w http.ResponseWriter, r *http.Request) {
445+ var body map[string]any
446+ raw, _ := io.ReadAll(r.Body)
447+ json.Unmarshal(raw, &body)
448+ models = append(models, fmt.Sprint(body["model"]))
449+ keys = append(keys, r.Header.Get("x-api-key"))
450+ io.WriteString(w, okResponse)
451+ })
452+ parent.SetModel("claude-opus-5")
453+
454+ child := parent.Clone("claude-haiku-4-5")
455+ msg := Request{Messages: []Message{{Role: RoleUser, Content: []ContentBlock{TextBlock("hi")}}}}
456+ if _, err := child.CreateMessage(context.Background(), msg); err != nil {
457+ t.Fatalf("child CreateMessage: %v", err)
458+ }
459+ if _, err := parent.CreateMessage(context.Background(), msg); err != nil {
460+ t.Fatalf("parent CreateMessage: %v", err)
461+ }
462+
463+ if want := "claude-haiku-4-5 claude-opus-5"; strings.Join(models, " ") != want {
464+ t.Errorf("models sent = %v, want %q", models, want)
465+ }
466+ if keys[0] != "test-key" {
467+ t.Errorf("clone sent x-api-key %q, want the parent's", keys[0])
468+ }
469+ if child.BaseURL != parent.BaseURL {
470+ t.Errorf("clone BaseURL = %q, want %q", child.BaseURL, parent.BaseURL)
471+ }
472+
473+ // Switching the session's model mid-run must not retarget a child that is
474+ // already working.
475+ parent.SetModel("claude-sonnet-5")
476+ if got := child.Model(); got != "claude-haiku-4-5" {
477+ t.Errorf("after parent switched, child Model() = %q", got)
478+ }
479+ }
480+
481+ // An empty model means "whatever the parent is on", resolved at clone time.
482+ func TestCloneWithNoModelInheritsTheParents(t *testing.T) {
483+ parent := New(WithAPIKey("k"), WithModel("claude-sonnet-5"))
484+ if got := parent.Clone("").Model(); got != "claude-sonnet-5" {
485+ t.Errorf("Clone(\"\").Model() = %q, want the parent's", got)
486+ }
487+ }
+50−0internal/llm/effort.goaddedView File
@@ -0,0 +1,50 @@
1+ package llm
2+
3+ // effortLevels records which output_config.effort levels each model accepts.
4+ // It is a per-model table for the same reason prices and adaptiveThinking are:
5+ // the answer differs by model and a wrong one is not a rounding error.
6+ // claude-haiku-4-5 answers a request carrying any effort level with
7+ //
8+ // 400 invalid_request_error: This model does not support the effort parameter
9+ //
10+ // which kills the agent before its first turn — which is what a Haiku subagent
11+ // spawned from a session set to high effort did on every spawn.
12+ //
13+ // xhigh is not universal either: it arrived with Opus 4.7, so the 4.6 family
14+ // takes the other four and Opus 4.5 takes only the first three.
15+ //
16+ // Only ids confirmed to accept a level are listed, and an unlisted id — a
17+ // gateway serving something this build has never heard of, a family released
18+ // after it — is treated as taking none. The two mistakes are not symmetric:
19+ // sending effort to a model that lacks it is a hard 400 that ends the run
20+ // before any work happens, where omitting it costs the level and says so in
21+ // the status line. The recoverable failure is the one to default to, which is
22+ // the same reading SupportsAdaptiveThinking makes.
23+ var effortLevels = map[string][]string{
24+ "claude-fable-5-1": EffortLevels,
25+ "claude-fable-5": EffortLevels,
26+ "claude-mythos-5-1": EffortLevels,
27+ "claude-mythos-5": EffortLevels,
28+ "claude-opus-5": EffortLevels,
29+ "claude-opus-4-8": EffortLevels,
30+ "claude-opus-4-7": EffortLevels,
31+ "claude-sonnet-5": EffortLevels,
32+ "claude-opus-4-6": {"low", "medium", "high", "max"},
33+ "claude-sonnet-4-6": {"low", "medium", "high", "max"},
34+ "claude-opus-4-5": {"low", "medium", "high"},
35+ }
36+
37+ // SupportsEffort reports whether model accepts this effort level. An empty
38+ // level is the API's own default and is always safe: it means the request
39+ // carries no output_config at all.
40+ func SupportsEffort(model, level string) bool {
41+ if level == "" {
42+ return true
43+ }
44+ for _, l := range effortLevels[basePrice(model)] {
45+ if l == level {
46+ return true
47+ }
48+ }
49+ return false
50+ }
+92−0internal/llm/models_test.goView File
@@ -99,3 +99,95 @@ func TestListModelsRefusesWithoutCredentials(t *testing.T) {
9999 t.Fatal("listing without credentials should not even be attempted")
100100 }
101101 }
102+
103+ // output_config.effort is not a universal parameter: Haiku 4.5 rejects the
104+ // request outright, and xhigh only exists from Opus 4.7 on. Sending it anyway
105+ // is a 400 that reads like a bug in the harness, because it is one.
106+ func TestSupportsEffortKnowsWhichModelsTakeIt(t *testing.T) {
107+ cases := []struct {
108+ model, level string
109+ want bool
110+ }{
111+ {"claude-opus-5", "high", true},
112+ {"claude-opus-5", "max", true},
113+ {"claude-sonnet-5", "xhigh", true},
114+ {"claude-fable-5-1", "low", true},
115+ {"claude-opus-4-8", "xhigh", true},
116+
117+ // The one that bit us: a subagent on Haiku with the session's effort.
118+ {"claude-haiku-4-5", "high", false},
119+ {"claude-haiku-4-5", "low", false},
120+ {"claude-haiku-4-5-20251001", "high", false},
121+ {"claude-sonnet-4-5", "high", false},
122+
123+ // xhigh arrived with Opus 4.7; 4.6 takes the other four.
124+ {"claude-opus-4-6", "high", true},
125+ {"claude-opus-4-6", "max", true},
126+ {"claude-opus-4-6", "xhigh", false},
127+ {"claude-sonnet-4-6", "xhigh", false},
128+
129+ // A bracketed context variant is the same model.
130+ {"claude-opus-5[1m]", "max", true},
131+
132+ // An empty level means "the API's own default", which is always fine.
133+ // A model this build has never heard of is treated as taking none —
134+ // see TestAnUnknownModelIsNotSentAnEffortLevel for why that way round.
135+ {"claude-haiku-4-5", "", true},
136+ {"some-gateway/llama-4", "high", false},
137+ }
138+ for _, c := range cases {
139+ if got := SupportsEffort(c.model, c.level); got != c.want {
140+ t.Errorf("SupportsEffort(%q, %q) = %v, want %v", c.model, c.level, got, c.want)
141+ }
142+ }
143+ }
144+
145+ // A gateway that serves an id this build has never seen is the case the
146+ // default has to be chosen for. The two mistakes are not symmetric: sending
147+ // effort to something that does not take it is a 400 that ends the run, where
148+ // omitting it costs the level and says so in the status line. The recoverable
149+ // failure is the one to default to — the same reading adaptive thinking makes.
150+ func TestAnUnknownModelIsNotSentAnEffortLevel(t *testing.T) {
151+ for _, model := range []string{"some-gateway/llama-4", "gpt-4o", "mistral-large", ""} {
152+ if SupportsEffort(model, "high") {
153+ t.Errorf("SupportsEffort(%q, high) = true; a 400 from an unknown model ends the run", model)
154+ }
155+ }
156+ // And the level nobody set is never a problem.
157+ if !SupportsEffort("some-gateway/llama-4", "") {
158+ t.Error("an unset level was treated as unsupported")
159+ }
160+ }
161+
162+ // Gateways decorate ids: a vendor prefix, a Bedrock namespace, a Vertex
163+ // version suffix. They name the same model, and a capability lookup that does
164+ // not see past the decoration silently downgrades every request through a
165+ // proxy.
166+ func TestModelIdsAreNormalisedPastGatewayDecoration(t *testing.T) {
167+ cases := []string{
168+ "claude-opus-5",
169+ "anthropic/claude-opus-5",
170+ "anthropic.claude-opus-5",
171+ "claude-opus-5@20260401",
172+ "openrouter/anthropic/claude-opus-5",
173+ "claude-opus-5-20260401",
174+ "claude-opus-5[1m]",
175+ }
176+ for _, id := range cases {
177+ if !SupportsEffort(id, "max") {
178+ t.Errorf("SupportsEffort(%q, max) = false, want the same answer as the bare id", id)
179+ }
180+ if !SupportsAdaptiveThinking(id) {
181+ t.Errorf("SupportsAdaptiveThinking(%q) = false, want the same answer as the bare id", id)
182+ }
183+ if _, known := PriceOf(id); !known {
184+ t.Errorf("PriceOf(%q) does not recognise the model", id)
185+ }
186+ }
187+ // Haiku stays itself through the same decoration.
188+ for _, id := range []string{"anthropic/claude-haiku-4-5", "anthropic.claude-haiku-4-5"} {
189+ if SupportsEffort(id, "high") {
190+ t.Errorf("SupportsEffort(%q, high) = true", id)
191+ }
192+ }
193+ }
+12−0internal/llm/pricing.goView File
@@ -54,6 +54,18 @@ func PriceOf(model string) (Price, bool) {
5454 // listed under. A dated id and a context-window variant are the same model
5555 // billed at the same rate, and /v1/models hands out both.
5656 func basePrice(model string) string {
57+ // Gateways decorate the id: a routing prefix (openrouter/anthropic/...),
58+ // a Bedrock namespace (anthropic.claude-opus-5), a Vertex version suffix
59+ // (claude-opus-5@20260401). They all name the same model, and a lookup
60+ // that cannot see past them prices nothing and, worse, answers a
61+ // capability question wrongly for every request through a proxy.
62+ if i := strings.LastIndexByte(model, '/'); i >= 0 {
63+ model = model[i+1:]
64+ }
65+ model = strings.TrimPrefix(model, "anthropic.")
66+ if i := strings.IndexByte(model, '@'); i >= 0 {
67+ model = model[:i]
68+ }
5769 if i := strings.IndexByte(model, '['); i >= 0 {
5870 model = model[:i]
5971 }
+38−0internal/llm/thinking.goaddedView File
@@ -0,0 +1,38 @@
1+ package llm
2+
3+ // adaptiveThinking records which models accept thinking: {"type":"adaptive"}.
4+ // It is a per-model table for the same reason prices is: the answer differs by
5+ // model and a wrong one is not a rounding error. claude-haiku-4-5 answers a
6+ // request carrying adaptive thinking with
7+ //
8+ // 400 invalid_request_error: adaptive thinking is not supported on this model
9+ //
10+ // which kills the agent before its first turn — which is exactly what the
11+ // explore subagent preset, pinned to haiku, did on every spawn.
12+ //
13+ // Only ids confirmed to accept adaptive thinking are listed. Absence is not a
14+ // claim that the model rejects it, only that this build has not confirmed it.
15+ var adaptiveThinking = map[string]bool{
16+ "claude-opus-5": true,
17+ "claude-opus-4-8": true,
18+ "claude-opus-4-7": true,
19+ "claude-sonnet-5": true,
20+ }
21+
22+ // SupportsAdaptiveThinking reports whether model accepts an adaptive thinking
23+ // parameter. Callers omit the field entirely when it does not.
24+ //
25+ // An unlisted id — a gateway serving a model this build has never heard of, a
26+ // family released after it — is treated as unsupported. The two mistakes are
27+ // not symmetric: sending adaptive to a model that lacks it is a hard 400 that
28+ // ends the run before any work happens, where omitting it on a model that would
29+ // have taken it only costs the thinking. The recoverable failure is the one to
30+ // default to. Flagships stay on the list, so the main session is unaffected.
31+ //
32+ // basePrice is the pricing table's normaliser, and the decoration it strips —
33+ // a -YYYYMMDD or -latest suffix, a bracketed context-window variant — is the
34+ // same decoration a capability lookup has to see past. /v1/models hands out
35+ // claude-opus-5-20251101, and that is the same model as claude-opus-5.
36+ func SupportsAdaptiveThinking(model string) bool {
37+ return adaptiveThinking[basePrice(model)]
38+ }