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>
Showing 6 changed files with 266 additions and 0 deletions.
| @@ -61,6 +61,29 @@ func (c *Client) SetModel(id string) { | ||
| 61 | 61 | c.model = id |
| 62 | 62 | } |
| 63 | 63 | |
| 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 | + | |
| 64 | 87 | // errNoCredentials is what every route reports when it has nothing to |
| 65 | 88 | // authenticate with. It is one value rather than three copies of a string |
| 66 | 89 | // because ListModels, CreateMessage and StreamMessage all reach it, and a |
| @@ -434,3 +434,54 @@ func TestSetModelChangesWhatTheNextRequestAsksFor(t *testing.T) { | ||
| 434 | 434 | t.Errorf("models sent = %v, want %q", models, want) |
| 435 | 435 | } |
| 436 | 436 | } |
| 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 | + } | |
| @@ -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 | + } | |
| @@ -99,3 +99,95 @@ func TestListModelsRefusesWithoutCredentials(t *testing.T) { | ||
| 99 | 99 | t.Fatal("listing without credentials should not even be attempted") |
| 100 | 100 | } |
| 101 | 101 | } |
| 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 | + } | |
| @@ -54,6 +54,18 @@ func PriceOf(model string) (Price, bool) { | ||
| 54 | 54 | // listed under. A dated id and a context-window variant are the same model |
| 55 | 55 | // billed at the same rate, and /v1/models hands out both. |
| 56 | 56 | 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 | + } | |
| 57 | 69 | if i := strings.IndexByte(model, '['); i >= 0 { |
| 58 | 70 | model = model[:i] |
| 59 | 71 | } |
| @@ -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 | + } | |