/model asks GET /v1/models — paginated, authenticated the same way messages are — and opens a picker on the answer: display name, id, context window and list price, the model in force marked and under the cursor. Enter switches for the session; the conversation so far is replayed to the new model on the next turn. An endpoint that does not serve that route says so and closes, because an empty picker and "this endpoint cannot be asked" are different things to tell someone; /model <id> and -model still work there. Two things a switchable model breaks, fixed here rather than left to rot: Cost was the cumulative usage priced at the current model, so switching reprices the whole session retroactively. It is now accumulated as usage arrives, at the model in force then, with a record of what has already been priced so a resumed session's restored totals are charged once. Pricing was keyed on exact ids, and /v1/models hands out dated ones, so picking from the real list would have silently blanked the cost readout. Lookup now strips the date, the -latest, and the [1m]-style variant tag: same model, same rate. Client.Model becomes an accessor pair over a locked field. A mutex beside a field everyone reads directly would be decoration; making it unexported let the compiler find every reader. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing 16 changed files with 983 additions and 34 deletions.
| @@ -80,8 +80,8 @@ to six lines. | ||
| 80 | 80 | | `esc` | interrupt the turn | |
| 81 | 81 | | `ctrl+c` | interrupt the turn; again when idle to exit | |
| 82 | 82 | |
| 83 | - Commands: `/allowed`, `/clear`, `/effort`, `/exit`, `/help`, `/skills`, and one | |
| 84 | - per discovered skill. | |
| 83 | + Commands: `/allowed`, `/clear`, `/effort`, `/exit`, `/help`, `/model`, | |
| 84 | + `/skills`, and one per discovered skill. | |
| 85 | 85 | |
| 86 | 86 | A slash at the start of an empty prompt opens the command menu, which filters |
| 87 | 87 | as you type — a name that starts with what you typed before one that merely |
| @@ -102,6 +102,29 @@ level for `/effort` — and `esc` dismisses the menu without touching what you | ||
| 102 | 102 | typed. The menu offers exactly the commands the TUI answers, so nothing it |
| 103 | 103 | suggests can come back as "unknown command". |
| 104 | 104 | |
| 105 | + `/model` asks the endpoint what it serves — `GET /v1/models`, paginated — and | |
| 106 | + opens a picker on the answer: | |
| 107 | + | |
| 108 | + ``` | |
| 109 | + Select model | |
| 110 | + from anthropic · ↑↓ move · enter apply · esc cancel | |
| 111 | + 1. Claude Fable 5.1 claude-fable-5-1 · 1.0M in · $10 / $50 per Mtok | |
| 112 | + 2. Claude Opus 5 claude-opus-5 · 1.0M in · $5 / $25 per Mtok ✓ | |
| 113 | + ❯ 3. Claude Sonnet 5 claude-sonnet-5 · 1.0M in · $2 / $10 per Mtok | |
| 114 | + 4. Claude Haiku 4.5 claude-haiku-4-5-20251001 · 200.0k in · $1 / $5 per Mtok | |
| 115 | + ``` | |
| 116 | + | |
| 117 | + The cursor starts on the model in force, which is also the one marked. The id, | |
| 118 | + the context window and the list price come from the endpoint and from the | |
| 119 | + pricing table; a model neither knows about simply says less. The switch applies | |
| 120 | + from the next turn — the conversation so far is replayed to the new model — and | |
| 121 | + costs already incurred stay priced at the model that incurred them, because a | |
| 122 | + session that switched models was not all billed at the model it ended on. | |
| 123 | + | |
| 124 | + Not every endpoint serves that route. When the fetch fails the picker says so, | |
| 125 | + names the host and the reason, and closes; `/model <id>` and the `-model` flag | |
| 126 | + still switch without asking anyone's permission. | |
| 127 | + | |
| 105 | 128 | `/effort` opens a slider in place of the prompt, on the level in force — an |
| 106 | 129 | unset level is the API's own default, `high`, so the marker starts there rather |
| 107 | 130 | than at an end: |
| @@ -79,7 +79,7 @@ func run() error { | ||
| 79 | 79 | } |
| 80 | 80 | defer log.Close() |
| 81 | 81 | log.Log(metrics.Event{Type: "session_start", Extra: map[string]any{ |
| 82 | - "root": root, "model": client.Model, "base_url": client.BaseURL, | |
| 82 | + "root": root, "model": client.Model(), "base_url": client.BaseURL, | |
| 83 | 83 | }}) |
| 84 | 84 | |
| 85 | 85 | // The TUI takes over the screen, so anything worth saying before the loop |
| @@ -200,7 +200,7 @@ func run() error { | ||
| 200 | 200 | } |
| 201 | 201 | |
| 202 | 202 | fmt.Printf("harness · %s · %s\ntools: %s\ntype a prompt, ctrl-c to interrupt a turn, ctrl-d to exit\n\n", |
| 203 | - client.Model, root, strings.Join(toolNames(reg), ", ")) | |
| 203 | + client.Model(), root, strings.Join(toolNames(reg), ", ")) | |
| 204 | 204 | |
| 205 | 205 | for { |
| 206 | 206 | fmt.Print("> ") |
| @@ -239,9 +239,9 @@ func once(ctx context.Context, a *agent.Agent, render *ui.Renderer, sessionDir, | ||
| 239 | 239 | |
| 240 | 240 | render.Flush() |
| 241 | 241 | usage, turns := a.State().Usage() |
| 242 | - render.Usage(usage, turns, client.Model) | |
| 242 | + render.Usage(usage, turns, client.Model()) | |
| 243 | 243 | |
| 244 | - if err := a.Save(sessionDir, id, root, client.Model); err != nil { | |
| 244 | + if err := a.Save(sessionDir, id, root, client.Model()); err != nil { | |
| 245 | 245 | fmt.Fprintln(os.Stderr, "harness: saving session:", err) |
| 246 | 246 | } |
| 247 | 247 | if errors.Is(runErr, context.Canceled) { |
| @@ -97,7 +97,7 @@ func run() error { | ||
| 97 | 97 | if err := os.MkdirAll(runRoot, 0o755); err != nil { |
| 98 | 98 | return err |
| 99 | 99 | } |
| 100 | - fmt.Printf("%d tasks × %d runs · %s · transcripts in %s\n\n", len(tasks), *n, client.Model, runRoot) | |
| 100 | + fmt.Printf("%d tasks × %d runs · %s · transcripts in %s\n\n", len(tasks), *n, client.Model(), runRoot) | |
| 101 | 101 | |
| 102 | 102 | type job struct { |
| 103 | 103 | task Task |
| @@ -163,7 +163,7 @@ func run() error { | ||
| 163 | 163 | // The return value is named so the deferred timing assignment lands in the |
| 164 | 164 | // value actually returned rather than in a copy already made. |
| 165 | 165 | func runOne(task Task, runNo int, client *llm.Client, opts agent.Options, timeout time.Duration, keep bool) (res Result) { |
| 166 | - res = Result{Task: task.Name, Run: runNo, Model: client.Model} | |
| 166 | + res = Result{Task: task.Name, Run: runNo, Model: client.Model()} | |
| 167 | 167 | start := time.Now() |
| 168 | 168 | defer func() { res.WallMS = time.Since(start).Milliseconds() }() |
| 169 | 169 | |
| @@ -11,6 +11,7 @@ import ( | ||
| 11 | 11 | "os" |
| 12 | 12 | "strconv" |
| 13 | 13 | "strings" |
| 14 | + "sync" | |
| 14 | 15 | "time" |
| 15 | 16 | ) |
| 16 | 17 | |
| @@ -28,7 +29,6 @@ const ( | ||
| 28 | 29 | // Client talks to POST /v1/messages. |
| 29 | 30 | type Client struct { |
| 30 | 31 | BaseURL string |
| 31 | - Model string | |
| 32 | 32 | MaxTokens int |
| 33 | 33 | MaxRetries int |
| 34 | 34 | // RetryDelay is the base of the exponential backoff between retries. |
| @@ -37,6 +37,26 @@ type Client struct { | ||
| 37 | 37 | |
| 38 | 38 | apiKey string |
| 39 | 39 | authToken string |
| 40 | + | |
| 41 | + // model is behind a lock because a front end can switch it while the | |
| 42 | + // agent is between turns. It is the one field that moves after | |
| 43 | + // construction, so it is the one that is not public. | |
| 44 | + mu sync.Mutex | |
| 45 | + model string | |
| 46 | + } | |
| 47 | + | |
| 48 | + // Model reports the model requests default to. | |
| 49 | + func (c *Client) Model() string { | |
| 50 | + c.mu.Lock() | |
| 51 | + defer c.mu.Unlock() | |
| 52 | + return c.model | |
| 53 | + } | |
| 54 | + | |
| 55 | + // SetModel changes the model the requests that follow will ask for. | |
| 56 | + func (c *Client) SetModel(id string) { | |
| 57 | + c.mu.Lock() | |
| 58 | + defer c.mu.Unlock() | |
| 59 | + c.model = id | |
| 40 | 60 | } |
| 41 | 61 | |
| 42 | 62 | // Option configures a Client. |
| @@ -55,7 +75,7 @@ func WithBaseURL(u string) Option { | ||
| 55 | 75 | func WithModel(m string) Option { |
| 56 | 76 | return func(c *Client) { |
| 57 | 77 | if m != "" { |
| 58 | - c.Model = m | |
| 78 | + c.model = m | |
| 59 | 79 | } |
| 60 | 80 | } |
| 61 | 81 | } |
| @@ -89,7 +109,7 @@ func WithHTTPClient(h *http.Client) Option { return func(c *Client) { c.HTTP = h | ||
| 89 | 109 | func New(opts ...Option) *Client { |
| 90 | 110 | c := &Client{ |
| 91 | 111 | BaseURL: DefaultBaseURL, |
| 92 | - Model: DefaultModel, | |
| 112 | + model: DefaultModel, | |
| 93 | 113 | MaxTokens: DefaultMaxTokens, |
| 94 | 114 | MaxRetries: defaultMaxRetries, |
| 95 | 115 | RetryDelay: 500 * time.Millisecond, |
| @@ -154,7 +174,7 @@ func (c *Client) CreateMessage(ctx context.Context, req Request) (*Response, err | ||
| 154 | 174 | return nil, fmt.Errorf("llm: no credentials: set ANTHROPIC_API_KEY, or run `ant auth login`") |
| 155 | 175 | } |
| 156 | 176 | if req.Model == "" { |
| 157 | - req.Model = c.Model | |
| 177 | + req.Model = c.Model() | |
| 158 | 178 | } |
| 159 | 179 | if req.MaxTokens == 0 { |
| 160 | 180 | req.MaxTokens = c.MaxTokens |
| @@ -187,20 +207,35 @@ func (c *Client) CreateMessage(ctx context.Context, req Request) (*Response, err | ||
| 187 | 207 | return nil, lastErr |
| 188 | 208 | } |
| 189 | 209 | |
| 190 | - func (c *Client) attempt(ctx context.Context, body []byte) (*Response, error) { | |
| 191 | - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/v1/messages", bytes.NewReader(body)) | |
| 210 | + // newRequest builds a request to the endpoint with the version and the | |
| 211 | + // credential already on it. Every call the client makes goes through here, so | |
| 212 | + // a second route cannot end up authenticating differently from the first. | |
| 213 | + func (c *Client) newRequest(ctx context.Context, method, path string, body []byte) (*http.Request, error) { | |
| 214 | + var rdr io.Reader | |
| 215 | + if body != nil { | |
| 216 | + rdr = bytes.NewReader(body) | |
| 217 | + } | |
| 218 | + req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rdr) | |
| 192 | 219 | if err != nil { |
| 193 | 220 | return nil, err |
| 194 | 221 | } |
| 195 | - httpReq.Header.Set("content-type", "application/json") | |
| 196 | - httpReq.Header.Set("anthropic-version", apiVersion) | |
| 222 | + req.Header.Set("content-type", "application/json") | |
| 223 | + req.Header.Set("anthropic-version", apiVersion) | |
| 197 | 224 | switch { |
| 198 | 225 | case c.apiKey != "": |
| 199 | - httpReq.Header.Set("x-api-key", c.apiKey) | |
| 226 | + req.Header.Set("x-api-key", c.apiKey) | |
| 200 | 227 | default: |
| 201 | 228 | // OAuth tokens go on Authorization, not x-api-key, and need the beta flag. |
| 202 | - httpReq.Header.Set("authorization", "Bearer "+c.authToken) | |
| 203 | - httpReq.Header.Set("anthropic-beta", oauthBeta) | |
| 229 | + req.Header.Set("authorization", "Bearer "+c.authToken) | |
| 230 | + req.Header.Set("anthropic-beta", oauthBeta) | |
| 231 | + } | |
| 232 | + return req, nil | |
| 233 | + } | |
| 234 | + | |
| 235 | + func (c *Client) attempt(ctx context.Context, body []byte) (*Response, error) { | |
| 236 | + httpReq, err := c.newRequest(ctx, http.MethodPost, "/v1/messages", body) | |
| 237 | + if err != nil { | |
| 238 | + return nil, err | |
| 204 | 239 | } |
| 205 | 240 | |
| 206 | 241 | httpResp, err := c.HTTP.Do(httpReq) |
| @@ -3,6 +3,7 @@ package llm | ||
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | 5 | "encoding/json" |
| 6 | + "fmt" | |
| 6 | 7 | "io" |
| 7 | 8 | "net/http" |
| 8 | 9 | "net/http/httptest" |
| @@ -284,3 +285,34 @@ func TestCustomBaseURLIsActuallyUsed(t *testing.T) { | ||
| 284 | 285 | t.Errorf("path = %q, want /v1/messages", gotPath) |
| 285 | 286 | } |
| 286 | 287 | } |
| 288 | + | |
| 289 | + // The model is switchable mid-session, so what a request carries is whatever | |
| 290 | + // was last set rather than what the client was built with. | |
| 291 | + func TestSetModelChangesWhatTheNextRequestAsksFor(t *testing.T) { | |
| 292 | + var models []string | |
| 293 | + c := testClient(t, func(w http.ResponseWriter, r *http.Request) { | |
| 294 | + var body map[string]any | |
| 295 | + raw, _ := io.ReadAll(r.Body) | |
| 296 | + json.Unmarshal(raw, &body) | |
| 297 | + models = append(models, fmt.Sprint(body["model"])) | |
| 298 | + io.WriteString(w, okResponse) | |
| 299 | + }) | |
| 300 | + WithModel("claude-opus-5")(c) | |
| 301 | + | |
| 302 | + msg := Request{Messages: []Message{{Role: RoleUser, Content: []ContentBlock{TextBlock("hi")}}}} | |
| 303 | + if _, err := c.CreateMessage(context.Background(), msg); err != nil { | |
| 304 | + t.Fatalf("CreateMessage: %v", err) | |
| 305 | + } | |
| 306 | + | |
| 307 | + c.SetModel("claude-haiku-4-5") | |
| 308 | + if got := c.Model(); got != "claude-haiku-4-5" { | |
| 309 | + t.Errorf("Model() = %q", got) | |
| 310 | + } | |
| 311 | + if _, err := c.CreateMessage(context.Background(), msg); err != nil { | |
| 312 | + t.Fatalf("CreateMessage: %v", err) | |
| 313 | + } | |
| 314 | + | |
| 315 | + if want := "claude-opus-5 claude-haiku-4-5"; strings.Join(models, " ") != want { | |
| 316 | + t.Errorf("models sent = %v, want %q", models, want) | |
| 317 | + } | |
| 318 | + } | |
| @@ -0,0 +1,87 @@ | ||
| 1 | + package llm | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "fmt" | |
| 7 | + "io" | |
| 8 | + "net/http" | |
| 9 | + "net/url" | |
| 10 | + "time" | |
| 11 | + ) | |
| 12 | + | |
| 13 | + // modelPageSize is what one GET /v1/models asks for. The list is short enough | |
| 14 | + // that this is almost always the only request. | |
| 15 | + const modelPageSize = 100 | |
| 16 | + | |
| 17 | + // maxModelPages stops a paginating endpoint that never says it is done from | |
| 18 | + // looping forever behind a picker that is waiting on it. | |
| 19 | + const maxModelPages = 10 | |
| 20 | + | |
| 21 | + // Model is one entry from GET /v1/models. MaxInputTokens is the context | |
| 22 | + // window; it was added to the API in 2026 and is zero on an endpoint that | |
| 23 | + // does not report it, which the caller has to treat as "not said" rather than | |
| 24 | + // as a model with no context. | |
| 25 | + type Model struct { | |
| 26 | + ID string `json:"id"` | |
| 27 | + DisplayName string `json:"display_name"` | |
| 28 | + CreatedAt time.Time `json:"created_at"` | |
| 29 | + MaxInputTokens int `json:"max_input_tokens"` | |
| 30 | + MaxTokens int `json:"max_tokens"` | |
| 31 | + } | |
| 32 | + | |
| 33 | + type modelPage struct { | |
| 34 | + Data []Model `json:"data"` | |
| 35 | + HasMore bool `json:"has_more"` | |
| 36 | + LastID string `json:"last_id"` | |
| 37 | + } | |
| 38 | + | |
| 39 | + // ListModels returns the models the endpoint offers, in the order it gave | |
| 40 | + // them — which from Anthropic is newest first. | |
| 41 | + // | |
| 42 | + // Not every endpoint serves this route: a gateway that only proxies | |
| 43 | + // /v1/messages answers 404, and the error says so rather than being turned | |
| 44 | + // into an empty list, because "no models" and "this endpoint cannot be asked" | |
| 45 | + // are different things to tell someone. | |
| 46 | + func (c *Client) ListModels(ctx context.Context) ([]Model, error) { | |
| 47 | + if !c.HasCredentials() { | |
| 48 | + return nil, fmt.Errorf("llm: no credentials: set ANTHROPIC_API_KEY, or run `ant auth login`") | |
| 49 | + } | |
| 50 | + | |
| 51 | + var out []Model | |
| 52 | + after := "" | |
| 53 | + for page := 0; page < maxModelPages; page++ { | |
| 54 | + q := url.Values{"limit": {fmt.Sprint(modelPageSize)}} | |
| 55 | + if after != "" { | |
| 56 | + q.Set("after_id", after) | |
| 57 | + } | |
| 58 | + | |
| 59 | + req, err := c.newRequest(ctx, http.MethodGet, "/v1/models?"+q.Encode(), nil) | |
| 60 | + if err != nil { | |
| 61 | + return nil, err | |
| 62 | + } | |
| 63 | + resp, err := c.HTTP.Do(req) | |
| 64 | + if err != nil { | |
| 65 | + return nil, fmt.Errorf("llm: %w", err) | |
| 66 | + } | |
| 67 | + raw, err := io.ReadAll(resp.Body) | |
| 68 | + resp.Body.Close() | |
| 69 | + if err != nil { | |
| 70 | + return nil, fmt.Errorf("llm: reading models: %w", err) | |
| 71 | + } | |
| 72 | + if resp.StatusCode != http.StatusOK { | |
| 73 | + return nil, parseAPIError(resp.StatusCode, resp.Header.Get("retry-after"), raw) | |
| 74 | + } | |
| 75 | + | |
| 76 | + var p modelPage | |
| 77 | + if err := json.Unmarshal(raw, &p); err != nil { | |
| 78 | + return nil, fmt.Errorf("llm: decoding models: %w", err) | |
| 79 | + } | |
| 80 | + out = append(out, p.Data...) | |
| 81 | + if !p.HasMore || p.LastID == "" { | |
| 82 | + break | |
| 83 | + } | |
| 84 | + after = p.LastID | |
| 85 | + } | |
| 86 | + return out, nil | |
| 87 | + } | |
| @@ -0,0 +1,101 @@ | ||
| 1 | + package llm | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "net/http" | |
| 7 | + "strings" | |
| 8 | + "testing" | |
| 9 | + ) | |
| 10 | + | |
| 11 | + func TestListModelsReadsEveryPage(t *testing.T) { | |
| 12 | + var paths []string | |
| 13 | + var headers http.Header | |
| 14 | + c := testClient(t, func(w http.ResponseWriter, r *http.Request) { | |
| 15 | + headers = r.Header.Clone() | |
| 16 | + paths = append(paths, r.URL.RequestURI()) | |
| 17 | + if after := r.URL.Query().Get("after_id"); after == "" { | |
| 18 | + fmt.Fprint(w, `{"data":[ | |
| 19 | + {"type":"model","id":"claude-opus-5","display_name":"Claude Opus 5","created_at":"2026-02-01T00:00:00Z"}, | |
| 20 | + {"type":"model","id":"claude-sonnet-5","display_name":"Claude Sonnet 5","created_at":"2025-09-29T00:00:00Z"}], | |
| 21 | + "has_more":true,"last_id":"claude-sonnet-5"}`) | |
| 22 | + return | |
| 23 | + } | |
| 24 | + fmt.Fprint(w, `{"data":[ | |
| 25 | + {"type":"model","id":"claude-haiku-4-5-20251001","display_name":"Claude Haiku 4.5","created_at":"2025-10-01T00:00:00Z"}], | |
| 26 | + "has_more":false}`) | |
| 27 | + }) | |
| 28 | + | |
| 29 | + got, err := c.ListModels(context.Background()) | |
| 30 | + if err != nil { | |
| 31 | + t.Fatalf("ListModels: %v", err) | |
| 32 | + } | |
| 33 | + | |
| 34 | + var ids []string | |
| 35 | + for _, m := range got { | |
| 36 | + ids = append(ids, m.ID) | |
| 37 | + } | |
| 38 | + if want := "claude-opus-5 claude-sonnet-5 claude-haiku-4-5-20251001"; strings.Join(ids, " ") != want { | |
| 39 | + t.Errorf("ids = %v, want %s in the order the API gave them", ids, want) | |
| 40 | + } | |
| 41 | + if got[0].DisplayName != "Claude Opus 5" { | |
| 42 | + t.Errorf("display name = %q", got[0].DisplayName) | |
| 43 | + } | |
| 44 | + if len(paths) != 2 { | |
| 45 | + t.Fatalf("requests = %v, want a second one for the second page", paths) | |
| 46 | + } | |
| 47 | + if !strings.HasPrefix(paths[0], "/v1/models?") { | |
| 48 | + t.Errorf("path = %q, want /v1/models", paths[0]) | |
| 49 | + } | |
| 50 | + if !strings.Contains(paths[1], "after_id=claude-sonnet-5") { | |
| 51 | + t.Errorf("second request = %q, want it to carry the last id", paths[1]) | |
| 52 | + } | |
| 53 | + if headers.Get("x-api-key") != "test-key" || headers.Get("anthropic-version") == "" { | |
| 54 | + t.Errorf("credentials or version missing: %v", headers) | |
| 55 | + } | |
| 56 | + } | |
| 57 | + | |
| 58 | + // Not every endpoint serves this route. The error has to name what went wrong | |
| 59 | + // so the picker can say it out loud. | |
| 60 | + func TestListModelsReportsAnEndpointThatWillNotAnswer(t *testing.T) { | |
| 61 | + c := testClient(t, func(w http.ResponseWriter, r *http.Request) { | |
| 62 | + http.Error(w, `{"type":"error","error":{"type":"not_found_error","message":"unknown route"}}`, http.StatusNotFound) | |
| 63 | + }) | |
| 64 | + | |
| 65 | + _, err := c.ListModels(context.Background()) | |
| 66 | + if err == nil { | |
| 67 | + t.Fatal("a 404 produced no error") | |
| 68 | + } | |
| 69 | + if !strings.Contains(err.Error(), "404") && !strings.Contains(err.Error(), "not_found") { | |
| 70 | + t.Errorf("error = %q, want it to say what the endpoint answered", err) | |
| 71 | + } | |
| 72 | + } | |
| 73 | + | |
| 74 | + // A bearer credential is what OAuth tokens and most gateways use, and the | |
| 75 | + // listing has to authenticate the same way the messages call does. | |
| 76 | + func TestListModelsSendsABearerCredential(t *testing.T) { | |
| 77 | + var headers http.Header | |
| 78 | + c := testClient(t, func(w http.ResponseWriter, r *http.Request) { | |
| 79 | + headers = r.Header.Clone() | |
| 80 | + fmt.Fprint(w, `{"data":[],"has_more":false}`) | |
| 81 | + }) | |
| 82 | + WithAuthToken("oauth-token")(c) | |
| 83 | + | |
| 84 | + if _, err := c.ListModels(context.Background()); err != nil { | |
| 85 | + t.Fatalf("ListModels: %v", err) | |
| 86 | + } | |
| 87 | + if got := headers.Get("authorization"); got != "Bearer oauth-token" { | |
| 88 | + t.Errorf("authorization = %q", got) | |
| 89 | + } | |
| 90 | + if headers.Get("anthropic-beta") == "" { | |
| 91 | + t.Error("the oauth beta flag is missing") | |
| 92 | + } | |
| 93 | + } | |
| 94 | + | |
| 95 | + func TestListModelsRefusesWithoutCredentials(t *testing.T) { | |
| 96 | + c := New(WithBaseURL("http://127.0.0.1:1")) | |
| 97 | + c.apiKey, c.authToken = "", "" | |
| 98 | + if _, err := c.ListModels(context.Background()); err == nil { | |
| 99 | + t.Fatal("listing without credentials should not even be attempted") | |
| 100 | + } | |
| 101 | + } | |
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | package llm |
| 2 | 2 | |
| 3 | + import "strings" | |
| 4 | + | |
| 3 | 5 | // Price is per-million-token list pricing. |
| 4 | 6 | type Price struct { |
| 5 | 7 | InputPerMTok float64 |
| @@ -27,7 +29,7 @@ var prices = map[string]Price{ | ||
| 27 | 29 | // Cost prices one usage record. The boolean reports whether the model's |
| 28 | 30 | // pricing is known; a made-up number in METRICS.md is worse than a blank. |
| 29 | 31 | func (u Usage) Cost(model string) (float64, bool) { |
| 30 | - p, ok := prices[model] | |
| 32 | + p, ok := prices[basePrice(model)] | |
| 31 | 33 | if !ok { |
| 32 | 34 | return 0, false |
| 33 | 35 | } |
| @@ -36,3 +38,36 @@ func (u Usage) Cost(model string) (float64, bool) { | ||
| 36 | 38 | float64(u.CacheReadInputTokens)*p.InputPerMTok*cacheReadMultiplier + |
| 37 | 39 | float64(u.CacheCreationInputTokens)*p.InputPerMTok*cacheWriteMultiplier) / 1_000_000, true |
| 38 | 40 | } |
| 41 | + | |
| 42 | + // PriceOf reports the list pricing for a model, if this harness knows it. | |
| 43 | + // The picker shows what it knows and says nothing about what it does not. | |
| 44 | + func PriceOf(model string) (Price, bool) { | |
| 45 | + p, ok := prices[basePrice(model)] | |
| 46 | + return p, ok | |
| 47 | + } | |
| 48 | + | |
| 49 | + // basePrice strips the decoration the API puts on a model id — a bracketed | |
| 50 | + // variant tag, a -latest or -YYYYMMDD suffix — leaving the name the price is | |
| 51 | + // listed under. A dated id and a context-window variant are the same model | |
| 52 | + // billed at the same rate, and /v1/models hands out both. | |
| 53 | + func basePrice(model string) string { | |
| 54 | + if i := strings.IndexByte(model, '['); i >= 0 { | |
| 55 | + model = model[:i] | |
| 56 | + } | |
| 57 | + model = strings.TrimSuffix(model, "-latest") | |
| 58 | + if i := strings.LastIndexByte(model, '-'); i >= 0 { | |
| 59 | + if suffix := model[i+1:]; len(suffix) == 8 && allDigits(suffix) { | |
| 60 | + model = model[:i] | |
| 61 | + } | |
| 62 | + } | |
| 63 | + return model | |
| 64 | + } | |
| 65 | + | |
| 66 | + func allDigits(s string) bool { | |
| 67 | + for _, r := range s { | |
| 68 | + if r < '0' || r > '9' { | |
| 69 | + return false | |
| 70 | + } | |
| 71 | + } | |
| 72 | + return len(s) > 0 | |
| 73 | + } | |
| @@ -0,0 +1,36 @@ | ||
| 1 | + package llm | |
| 2 | + | |
| 3 | + import "testing" | |
| 4 | + | |
| 5 | + // The model picker offers whatever /v1/models returns, which is dated ids and | |
| 6 | + // context-window variants. Those are the same model at the same price, so | |
| 7 | + // pricing has to look past the decoration rather than report "unknown". | |
| 8 | + func TestCostPricesDecoratedModelIDs(t *testing.T) { | |
| 9 | + u := Usage{InputTokens: 1_000_000} | |
| 10 | + base, ok := u.Cost("claude-sonnet-5") | |
| 11 | + if !ok { | |
| 12 | + t.Fatal("the bare id is not priced") | |
| 13 | + } | |
| 14 | + for _, id := range []string{ | |
| 15 | + "claude-sonnet-5-20250930", | |
| 16 | + "claude-sonnet-5[1m]", | |
| 17 | + "claude-sonnet-5-latest", | |
| 18 | + } { | |
| 19 | + got, ok := u.Cost(id) | |
| 20 | + if !ok { | |
| 21 | + t.Errorf("Cost(%q) reported no price", id) | |
| 22 | + continue | |
| 23 | + } | |
| 24 | + if got != base { | |
| 25 | + t.Errorf("Cost(%q) = %v, want the same %v as the bare id", id, got, base) | |
| 26 | + } | |
| 27 | + } | |
| 28 | + | |
| 29 | + // A model nobody has priced still reports nothing rather than guessing. | |
| 30 | + if _, ok := u.Cost("some-gateway/mystery-model"); ok { | |
| 31 | + t.Error("an unknown model was given a price") | |
| 32 | + } | |
| 33 | + if _, ok := u.Cost("claude-something-9-20990101"); ok { | |
| 34 | + t.Error("an unknown Claude model was given a price") | |
| 35 | + } | |
| 36 | + } | |
| @@ -38,7 +38,7 @@ func (c *Client) StreamMessage(ctx context.Context, req Request, onEvent func(St | ||
| 38 | 38 | return nil, fmt.Errorf("llm: no credentials: set ANTHROPIC_API_KEY, or run `ant auth login`") |
| 39 | 39 | } |
| 40 | 40 | if req.Model == "" { |
| 41 | - req.Model = c.Model | |
| 41 | + req.Model = c.Model() | |
| 42 | 42 | } |
| 43 | 43 | if req.MaxTokens == 0 { |
| 44 | 44 | req.MaxTokens = c.MaxTokens |
| @@ -23,6 +23,7 @@ type command struct { | ||
| 23 | 23 | var builtins = []command{ |
| 24 | 24 | {name: "help", desc: "the commands and the keys"}, |
| 25 | 25 | {name: "skills", desc: "the instruction sets found at startup"}, |
| 26 | + {name: "model", args: "id", desc: "switch model — lists what the endpoint serves"}, | |
| 26 | 27 | {name: "effort", args: "level", desc: "how hard the model thinks — opens a slider"}, |
| 27 | 28 | {name: "clear", desc: "empty the transcript"}, |
| 28 | 29 | {name: "allowed", desc: "what is permanently allowed this session"}, |
| @@ -56,6 +56,9 @@ const ( | ||
| 56 | 56 | // phaseEffort is the effort slider: the prompt is gone until a level is |
| 57 | 57 | // picked or the slider is dismissed. |
| 58 | 58 | phaseEffort |
| 59 | + // phaseModel is the model picker, which starts on a wait: the list comes | |
| 60 | + // from the endpoint. | |
| 61 | + phaseModel | |
| 59 | 62 | ) |
| 60 | 63 | |
| 61 | 64 | // inputMaxLines caps how far the prompt box grows before it scrolls. Past |
| @@ -77,12 +80,18 @@ type model struct { | ||
| 77 | 80 | ready bool |
| 78 | 81 | |
| 79 | 82 | // Turn bookkeeping. |
| 80 | - turn int | |
| 81 | - started time.Time | |
| 82 | - usage llm.Usage | |
| 83 | - turns int | |
| 84 | - lastCost float64 | |
| 83 | + turn int | |
| 84 | + started time.Time | |
| 85 | + usage llm.Usage | |
| 86 | + turns int | |
| 87 | + // cost is the running bill, accumulated as usage is reported and priced | |
| 88 | + // at the model in force at that moment: a session that switched models | |
| 89 | + // was not all billed at the model it happens to end on. priced is what | |
| 90 | + // has already been counted, so a turn's totals — which on a resumed | |
| 91 | + // session include history this process never saw — are charged once. | |
| 92 | + cost float64 | |
| 85 | 93 | costKnown bool |
| 94 | + priced llm.Usage | |
| 86 | 95 | // ctxTokens is the last turn's input size — what the model actually read, |
| 87 | 96 | // which is the number the context gauge is about. It replaces itself each |
| 88 | 97 | // turn rather than accumulating, and compaction makes it drop. |
| @@ -97,6 +106,12 @@ type model struct { | ||
| 97 | 106 | // effortIdx is where the marker sits while the effort slider is up. |
| 98 | 107 | effortIdx int |
| 99 | 108 | |
| 109 | + // The model picker: what the endpoint offered, where the cursor is, and | |
| 110 | + // whether we are still waiting to be told. | |
| 111 | + models []llm.Model | |
| 112 | + modelIdx int | |
| 113 | + modelsLoading bool | |
| 114 | + | |
| 100 | 115 | // pending approvals. The agent blocks on one at a time, but a queue costs |
| 101 | 116 | // nothing and means a second request can never be dropped. |
| 102 | 117 | pending []approvalMsg |
| @@ -207,7 +222,7 @@ func (m *model) runTurn(prompt string) tea.Cmd { | ||
| 207 | 222 | _, err := a.Run(ctx, prompt) |
| 208 | 223 | cancel() |
| 209 | 224 | |
| 210 | - if saveErr := a.Save(m.opts.SessionDir, m.opts.SessionID, m.opts.Root, m.opts.Client.Model); saveErr != nil && err == nil { | |
| 225 | + if saveErr := a.Save(m.opts.SessionDir, m.opts.SessionID, m.opts.Root, m.opts.Client.Model()); saveErr != nil && err == nil { | |
| 211 | 226 | err = fmt.Errorf("saving session: %w", saveErr) |
| 212 | 227 | } |
| 213 | 228 | u, turns := a.State().Usage() |
| @@ -224,8 +239,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | ||
| 224 | 239 | case tea.KeyMsg: |
| 225 | 240 | return m.onKey(msg) |
| 226 | 241 | |
| 242 | + case modelsMsg: | |
| 243 | + return m, m.onModels(msg) | |
| 244 | + | |
| 227 | 245 | case spinner.TickMsg: |
| 228 | - if m.phase == phaseRunning { | |
| 246 | + if m.phase == phaseRunning || m.modelsLoading { | |
| 229 | 247 | var cmd tea.Cmd |
| 230 | 248 | m.spin, cmd = m.spin.Update(msg) |
| 231 | 249 | return m, cmd |
| @@ -256,6 +274,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | ||
| 256 | 274 | |
| 257 | 275 | case evUsage: |
| 258 | 276 | m.usage = addUsage(m.usage, msg.usage) |
| 277 | + m.addCost(msg.usage) | |
| 259 | 278 | m.ctxTokens = msg.usage.InputTokens + msg.usage.CacheReadInputTokens + msg.usage.CacheCreationInputTokens |
| 260 | 279 | return m, nil |
| 261 | 280 | |
| @@ -315,7 +334,7 @@ func (m *model) finishTurn(msg turnDone) tea.Cmd { | ||
| 315 | 334 | m.phase = phaseIdle |
| 316 | 335 | m.interrupting = false |
| 317 | 336 | m.usage, m.turns = msg.usage, msg.turns |
| 318 | - m.lastCost, m.costKnown = msg.usage.Cost(m.opts.Client.Model) | |
| 337 | + m.addCost(subUsage(msg.usage, m.priced)) | |
| 319 | 338 | m.tr.closeOpen() |
| 320 | 339 | |
| 321 | 340 | // Any approval still outstanding belongs to a turn that no longer exists. |
| @@ -342,6 +361,9 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | ||
| 342 | 361 | if m.phase == phaseEffort { |
| 343 | 362 | return m.onEffortKey(msg) |
| 344 | 363 | } |
| 364 | + if m.phase == phaseModel { | |
| 365 | + return m.onModelKey(msg) | |
| 366 | + } | |
| 345 | 367 | |
| 346 | 368 | // An open menu owns the keys that drive it — including enter and esc, |
| 347 | 369 | // which mean something else the rest of the time. |
| @@ -458,6 +480,9 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) { | ||
| 458 | 480 | m.tr.addNotice(m.helpText()) |
| 459 | 481 | return m, m.sync() |
| 460 | 482 | |
| 483 | + case line == "/model" || strings.HasPrefix(line, "/model "): | |
| 484 | + return m, m.setModel(strings.TrimSpace(strings.TrimPrefix(line, "/model"))) | |
| 485 | + | |
| 461 | 486 | case line == "/effort" || strings.HasPrefix(line, "/effort "): |
| 462 | 487 | return m, m.setEffort(strings.TrimSpace(strings.TrimPrefix(line, "/effort"))) |
| 463 | 488 | |
| @@ -500,6 +525,17 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) { | ||
| 500 | 525 | return m, tea.Batch(m.sync(), m.spin.Tick, m.runTurn(line)) |
| 501 | 526 | } |
| 502 | 527 | |
| 528 | + // setModel answers /model. Bare, it asks the endpoint what it serves and | |
| 529 | + // opens the picker on the answer; with an id, it switches straight away, | |
| 530 | + // which also works on an endpoint that does not list its models. | |
| 531 | + func (m *model) setModel(id string) tea.Cmd { | |
| 532 | + if id == "" { | |
| 533 | + return m.openModelPicker() | |
| 534 | + } | |
| 535 | + m.applyModel(id) | |
| 536 | + return m.sync() | |
| 537 | + } | |
| 538 | + | |
| 503 | 539 | // setEffort answers /effort. Bare, it opens the slider; with a level, it |
| 504 | 540 | // applies it directly, which is the fast path for anyone who knows what they |
| 505 | 541 | // want and can type it. |
| @@ -646,6 +682,8 @@ func (m *model) bottom() string { | ||
| 646 | 682 | b.WriteString(m.approvalView() + "\n") |
| 647 | 683 | case m.phase == phaseEffort: |
| 648 | 684 | b.WriteString(m.effortView() + "\n") |
| 685 | + case m.phase == phaseModel: | |
| 686 | + b.WriteString(m.modelView() + "\n") | |
| 649 | 687 | default: |
| 650 | 688 | b.WriteString(m.inputBox() + "\n") |
| 651 | 689 | if v := m.menuView(); v != "" { |
| @@ -707,7 +745,7 @@ func (m *model) inputBox() string { | ||
| 707 | 745 | func (m *model) status() string { |
| 708 | 746 | parts := []string{ |
| 709 | 747 | styName.Render(username()), |
| 710 | - styCoral.Render("✳ ") + styDim.Render(prettyModel(m.opts.Client.Model)), | |
| 748 | + styCoral.Render("✳ ") + styDim.Render(prettyModel(m.opts.Client.Model())), | |
| 711 | 749 | } |
| 712 | 750 | |
| 713 | 751 | if pill, ok := m.contextPill(); ok { |
| @@ -724,8 +762,8 @@ func (m *model) status() string { | ||
| 724 | 762 | } |
| 725 | 763 | io += fmt.Sprintf(" ↓ %s", human(u.OutputTokens)) |
| 726 | 764 | parts = append(parts, styDim.Render(io)) |
| 727 | - if cost, known := u.Cost(m.opts.Client.Model); known { | |
| 728 | - parts = append(parts, styDim.Render(fmt.Sprintf("$%.4f", cost))) | |
| 765 | + if m.costKnown { | |
| 766 | + parts = append(parts, styDim.Render(fmt.Sprintf("$%.4f", m.cost))) | |
| 729 | 767 | } |
| 730 | 768 | } |
| 731 | 769 | return truncateStyled(strings.Join(parts, " "), maxInt(1, m.width)) |
| @@ -819,6 +857,30 @@ func username() string { | ||
| 819 | 857 | return "you" |
| 820 | 858 | } |
| 821 | 859 | |
| 860 | + // addCost prices one piece of usage at the model in force and adds it to the | |
| 861 | + // running total, recording that those tokens have now been paid for. | |
| 862 | + func (m *model) addCost(u llm.Usage) { | |
| 863 | + if u == (llm.Usage{}) { | |
| 864 | + return | |
| 865 | + } | |
| 866 | + m.priced = addUsage(m.priced, u) | |
| 867 | + if c, known := u.Cost(m.opts.Client.Model()); known { | |
| 868 | + m.cost += c | |
| 869 | + m.costKnown = true | |
| 870 | + } | |
| 871 | + } | |
| 872 | + | |
| 873 | + // subUsage is a minus b, floored at zero per field: totals only ever grow, and | |
| 874 | + // a negative remainder would mean charging backwards. | |
| 875 | + func subUsage(a, b llm.Usage) llm.Usage { | |
| 876 | + return llm.Usage{ | |
| 877 | + InputTokens: maxInt(0, a.InputTokens-b.InputTokens), | |
| 878 | + OutputTokens: maxInt(0, a.OutputTokens-b.OutputTokens), | |
| 879 | + CacheReadInputTokens: maxInt(0, a.CacheReadInputTokens-b.CacheReadInputTokens), | |
| 880 | + CacheCreationInputTokens: maxInt(0, a.CacheCreationInputTokens-b.CacheCreationInputTokens), | |
| 881 | + } | |
| 882 | + } | |
| 883 | + | |
| 822 | 884 | func addUsage(a, b llm.Usage) llm.Usage { |
| 823 | 885 | a.InputTokens += b.InputTokens |
| 824 | 886 | a.OutputTokens += b.OutputTokens |
| @@ -0,0 +1,212 @@ | ||
| 1 | + package tui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "strings" | |
| 7 | + "time" | |
| 8 | + | |
| 9 | + "github.com/charmbracelet/bubbles/textarea" | |
| 10 | + tea "github.com/charmbracelet/bubbletea" | |
| 11 | + "github.com/charmbracelet/lipgloss" | |
| 12 | + | |
| 13 | + "harness/internal/llm" | |
| 14 | + ) | |
| 15 | + | |
| 16 | + // modelFetchTimeout bounds the wait. Someone is sitting in front of a picker | |
| 17 | + // that says it is asking; a minute of that is not a picker, it is a hang. | |
| 18 | + const modelFetchTimeout = 20 * time.Second | |
| 19 | + | |
| 20 | + // modelsMsg carries the answer to GET /v1/models back into the UI. | |
| 21 | + type modelsMsg struct { | |
| 22 | + models []llm.Model | |
| 23 | + err error | |
| 24 | + } | |
| 25 | + | |
| 26 | + // fetchModels asks the endpoint what it serves. It runs off the UI goroutine | |
| 27 | + // like any other command, and reports failure as a value rather than by | |
| 28 | + // leaving the picker waiting forever. | |
| 29 | + func fetchModels(c *llm.Client) tea.Cmd { | |
| 30 | + return func() tea.Msg { | |
| 31 | + ctx, cancel := context.WithTimeout(context.Background(), modelFetchTimeout) | |
| 32 | + defer cancel() | |
| 33 | + models, err := c.ListModels(ctx) | |
| 34 | + return modelsMsg{models: models, err: err} | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + // openModelPicker puts the picker up and starts asking. | |
| 39 | + func (m *model) openModelPicker() tea.Cmd { | |
| 40 | + m.phase = phaseModel | |
| 41 | + m.models, m.modelIdx, m.modelsLoading = nil, 0, true | |
| 42 | + m.closeMenu() | |
| 43 | + return tea.Batch(m.sync(), m.spin.Tick, fetchModels(m.opts.Client)) | |
| 44 | + } | |
| 45 | + | |
| 46 | + // onModels lands the answer. An answer nobody is waiting for any more is | |
| 47 | + // dropped: the picker was escaped, and reopening it over whatever the user | |
| 48 | + // went on to do would be the UI arguing with them. | |
| 49 | + func (m *model) onModels(msg modelsMsg) tea.Cmd { | |
| 50 | + if m.phase != phaseModel { | |
| 51 | + return nil | |
| 52 | + } | |
| 53 | + m.modelsLoading = false | |
| 54 | + m.phase = phaseIdle | |
| 55 | + | |
| 56 | + switch { | |
| 57 | + case msg.err != nil: | |
| 58 | + m.tr.addError("could not list models from " + endpointLabel(m.opts.Client) + ": " + | |
| 59 | + msg.err.Error() + " — this endpoint may not serve /v1/models; -model or /model <id> still works") | |
| 60 | + case len(msg.models) == 0: | |
| 61 | + m.tr.addError(endpointLabel(m.opts.Client) + " returned no models — -model or /model <id> still works") | |
| 62 | + default: | |
| 63 | + m.phase = phaseModel | |
| 64 | + m.models = msg.models | |
| 65 | + m.modelIdx = modelIndex(msg.models, m.opts.Client.Model()) | |
| 66 | + } | |
| 67 | + return tea.Batch(m.sync(), textarea.Blink) | |
| 68 | + } | |
| 69 | + | |
| 70 | + // modelIndex finds the model in force, so the picker opens where the session | |
| 71 | + // already is rather than at the top of a list. | |
| 72 | + func modelIndex(models []llm.Model, current string) int { | |
| 73 | + for i, mo := range models { | |
| 74 | + if mo.ID == current { | |
| 75 | + return i | |
| 76 | + } | |
| 77 | + } | |
| 78 | + return 0 | |
| 79 | + } | |
| 80 | + | |
| 81 | + // onModelKey drives the picker. While the fetch is in flight there is nothing | |
| 82 | + // to move through, so only the ways out are live. | |
| 83 | + func (m *model) onModelKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | |
| 84 | + key := msg.String() | |
| 85 | + if key == "esc" || key == "ctrl+c" { | |
| 86 | + m.phase = phaseIdle | |
| 87 | + m.models, m.modelsLoading = nil, false | |
| 88 | + return m, tea.Batch(m.sync(), textarea.Blink) | |
| 89 | + } | |
| 90 | + if m.modelsLoading || len(m.models) == 0 { | |
| 91 | + return m, nil | |
| 92 | + } | |
| 93 | + | |
| 94 | + last := len(m.models) - 1 | |
| 95 | + switch key { | |
| 96 | + case "up", "ctrl+p": | |
| 97 | + m.modelIdx = maxInt(0, m.modelIdx-1) | |
| 98 | + case "down", "ctrl+n": | |
| 99 | + m.modelIdx = minInt(last, m.modelIdx+1) | |
| 100 | + case "home": | |
| 101 | + m.modelIdx = 0 | |
| 102 | + case "end": | |
| 103 | + m.modelIdx = last | |
| 104 | + case "enter", "tab": | |
| 105 | + id := m.models[m.modelIdx].ID | |
| 106 | + m.phase = phaseIdle | |
| 107 | + m.models = nil | |
| 108 | + m.applyModel(id) | |
| 109 | + return m, tea.Batch(m.sync(), textarea.Blink) | |
| 110 | + default: | |
| 111 | + if len(key) == 1 && key[0] >= '1' && key[0] <= '9' { | |
| 112 | + if i := int(key[0] - '1'); i <= last { | |
| 113 | + m.modelIdx = i | |
| 114 | + } | |
| 115 | + } | |
| 116 | + } | |
| 117 | + return m, m.sync() | |
| 118 | + } | |
| 119 | + | |
| 120 | + // applyModel switches the client and says so. The session keeps its history: | |
| 121 | + // the conversation so far is replayed to the new model on the next turn, | |
| 122 | + // which is the point of switching mid-session. | |
| 123 | + func (m *model) applyModel(id string) { | |
| 124 | + m.opts.Client.SetModel(id) | |
| 125 | + m.tr.addNotice("model: " + prettyModel(id) + " (" + id + ") — from the next turn on") | |
| 126 | + } | |
| 127 | + | |
| 128 | + // modelView is the picker: a heading, where the list came from, and a row per | |
| 129 | + // model with what it is called, its id, and what it costs. | |
| 130 | + func (m *model) modelView() string { | |
| 131 | + width := maxInt(1, m.width) | |
| 132 | + head := " " + styHeading.Render("Select model") | |
| 133 | + from := styDim.Render("from " + endpointLabel(m.opts.Client)) | |
| 134 | + | |
| 135 | + if m.modelsLoading { | |
| 136 | + return head + "\n" + truncateStyled(" "+m.spin.View()+" "+from+styFaint.Render(" · esc to cancel"), width) | |
| 137 | + } | |
| 138 | + | |
| 139 | + rows := m.modelRows() | |
| 140 | + shown := rows | |
| 141 | + if len(m.models) > rows { | |
| 142 | + shown = maxInt(1, rows-1) | |
| 143 | + } | |
| 144 | + start := 0 | |
| 145 | + if m.modelIdx >= shown { | |
| 146 | + start = m.modelIdx - shown + 1 | |
| 147 | + } | |
| 148 | + end := minInt(len(m.models), start+shown) | |
| 149 | + | |
| 150 | + // One column for the names, so the ids line up under each other. | |
| 151 | + names := make([]string, len(m.models)) | |
| 152 | + col := 0 | |
| 153 | + for i, mo := range m.models { | |
| 154 | + names[i] = mo.DisplayName | |
| 155 | + if names[i] == "" { | |
| 156 | + names[i] = prettyModel(mo.ID) | |
| 157 | + } | |
| 158 | + if i >= start && i < end { | |
| 159 | + col = maxInt(col, lipgloss.Width(names[i])) | |
| 160 | + } | |
| 161 | + } | |
| 162 | + col = minInt(col, maxInt(10, width/3)) | |
| 163 | + | |
| 164 | + var b strings.Builder | |
| 165 | + b.WriteString(head + "\n") | |
| 166 | + b.WriteString(truncateStyled(" "+from+styFaint.Render(" · ↑↓ move · enter apply · esc cancel"), width) + "\n") | |
| 167 | + for i := start; i < end; i++ { | |
| 168 | + mo := m.models[i] | |
| 169 | + marker, name := " ", styDim.Render(pad(names[i], col)) | |
| 170 | + if i == m.modelIdx { | |
| 171 | + marker, name = styKey.Render("❯ "), styKey.Render(pad(names[i], col)) | |
| 172 | + } | |
| 173 | + // An endpoint that gave no display name leaves the id as the name; | |
| 174 | + // repeating it in the detail column says nothing twice. | |
| 175 | + detail := "" | |
| 176 | + if names[i] != mo.ID { | |
| 177 | + detail = mo.ID | |
| 178 | + } | |
| 179 | + if mo.MaxInputTokens > 0 { | |
| 180 | + if detail != "" { | |
| 181 | + detail += " · " | |
| 182 | + } | |
| 183 | + detail += human(mo.MaxInputTokens) + " in" | |
| 184 | + } | |
| 185 | + if p, ok := llm.PriceOf(mo.ID); ok { | |
| 186 | + if detail != "" { | |
| 187 | + detail += " · " | |
| 188 | + } | |
| 189 | + detail += fmt.Sprintf("$%g / $%g per Mtok", p.InputPerMTok, p.OutputPerMTok) | |
| 190 | + } | |
| 191 | + line := marker + fmt.Sprintf("%d. ", i+1) + name + " " + styFaint.Render(detail) | |
| 192 | + if mo.ID == m.opts.Client.Model() { | |
| 193 | + line += styOK.Render(" ✓") | |
| 194 | + } | |
| 195 | + b.WriteString(truncateStyled(" "+line, width) + "\n") | |
| 196 | + } | |
| 197 | + if n := len(m.models) - (end - start); n > 0 { | |
| 198 | + b.WriteString(truncateStyled(" "+styFaint.Render(fmt.Sprintf("… +%d more", n)), width) + "\n") | |
| 199 | + } | |
| 200 | + return strings.TrimRight(b.String(), "\n") | |
| 201 | + } | |
| 202 | + | |
| 203 | + // modelRows is how many model rows fit: the same arithmetic the completion | |
| 204 | + // menu does, minus the heading and the line saying where the list came from. | |
| 205 | + func (m *model) modelRows() int { | |
| 206 | + room := m.height - 6 | |
| 207 | + return maxInt(1, minInt(modelMaxRows, room)) | |
| 208 | + } | |
| 209 | + | |
| 210 | + // modelMaxRows caps the list. Anthropic serves a couple of dozen models and | |
| 211 | + // nobody scrolls a picker looking for the twenty-fourth. | |
| 212 | + const modelMaxRows = 10 | |
| @@ -0,0 +1,325 @@ | ||
| 1 | + package tui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "errors" | |
| 5 | + "fmt" | |
| 6 | + "net/http" | |
| 7 | + "net/http/httptest" | |
| 8 | + "strings" | |
| 9 | + "testing" | |
| 10 | + | |
| 11 | + tea "github.com/charmbracelet/bubbletea" | |
| 12 | + | |
| 13 | + "harness/internal/llm" | |
| 14 | + ) | |
| 15 | + | |
| 16 | + func someModels() []llm.Model { | |
| 17 | + return []llm.Model{ | |
| 18 | + {ID: "claude-opus-5", DisplayName: "Claude Opus 5"}, | |
| 19 | + {ID: "claude-sonnet-5", DisplayName: "Claude Sonnet 5"}, | |
| 20 | + {ID: "claude-haiku-4-5-20251001", DisplayName: "Claude Haiku 4.5"}, | |
| 21 | + } | |
| 22 | + } | |
| 23 | + | |
| 24 | + // /model opens on a wait, because the list comes from the endpoint rather | |
| 25 | + // than from a table compiled into the harness. | |
| 26 | + func TestModelPickerAsksTheEndpoint(t *testing.T) { | |
| 27 | + m := newTestModel(t) | |
| 28 | + m.submit("/model") | |
| 29 | + | |
| 30 | + if m.phase != phaseModel { | |
| 31 | + t.Fatalf("phase = %v, want the picker open", m.phase) | |
| 32 | + } | |
| 33 | + if !m.modelsLoading { | |
| 34 | + t.Error("the picker is not waiting on anything") | |
| 35 | + } | |
| 36 | + if !strings.Contains(m.modelView(), "anthropic") { | |
| 37 | + t.Errorf("the wait does not say where it is asking:\n%s", m.modelView()) | |
| 38 | + } | |
| 39 | + | |
| 40 | + m.Update(modelsMsg{models: someModels()}) | |
| 41 | + | |
| 42 | + if m.modelsLoading { | |
| 43 | + t.Error("still loading after the answer arrived") | |
| 44 | + } | |
| 45 | + view := m.modelView() | |
| 46 | + for _, want := range []string{"Claude Opus 5", "claude-sonnet-5", "Select model"} { | |
| 47 | + if !strings.Contains(view, want) { | |
| 48 | + t.Errorf("%q missing from the picker:\n%s", want, view) | |
| 49 | + } | |
| 50 | + } | |
| 51 | + } | |
| 52 | + | |
| 53 | + // The cursor starts on what is in force, and that row is marked, so the | |
| 54 | + // picker opens saying where you already are. | |
| 55 | + func TestModelPickerStartsOnTheModelInForce(t *testing.T) { | |
| 56 | + m := newTestModel(t) | |
| 57 | + m.opts.Client.SetModel("claude-sonnet-5") | |
| 58 | + m.submit("/model") | |
| 59 | + m.Update(modelsMsg{models: someModels()}) | |
| 60 | + | |
| 61 | + if got := m.models[m.modelIdx].ID; got != "claude-sonnet-5" { | |
| 62 | + t.Errorf("cursor on %q, want the model in force", got) | |
| 63 | + } | |
| 64 | + if !strings.Contains(m.modelView(), "✓") { | |
| 65 | + t.Errorf("the model in force is not marked:\n%s", m.modelView()) | |
| 66 | + } | |
| 67 | + } | |
| 68 | + | |
| 69 | + // Prices we know are shown beside the id, including for the dated ids the API | |
| 70 | + // hands out; a model we have no price for simply says less. | |
| 71 | + func TestModelRowsShowTheIDAndWhatItCosts(t *testing.T) { | |
| 72 | + m := newTestModel(t) | |
| 73 | + m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) | |
| 74 | + m.submit("/model") | |
| 75 | + m.Update(modelsMsg{models: append(someModels(), llm.Model{ID: "mystery-1", DisplayName: "Mystery"})}) | |
| 76 | + | |
| 77 | + view := m.modelView() | |
| 78 | + if !strings.Contains(view, "claude-haiku-4-5-20251001") { | |
| 79 | + t.Errorf("the id is missing:\n%s", view) | |
| 80 | + } | |
| 81 | + if !strings.Contains(view, "$1") || !strings.Contains(view, "$5") { | |
| 82 | + t.Errorf("the price of a known model is missing:\n%s", view) | |
| 83 | + } | |
| 84 | + for _, line := range strings.Split(view, "\n") { | |
| 85 | + if strings.Contains(line, "Mystery") && strings.Contains(line, "$") { | |
| 86 | + t.Errorf("a model with no known price was given one: %q", line) | |
| 87 | + } | |
| 88 | + } | |
| 89 | + | |
| 90 | + // A model the endpoint gave no name for is shown once, not twice: there | |
| 91 | + // is nothing to say beyond its id. | |
| 92 | + m2 := newTestModel(t) | |
| 93 | + m2.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) | |
| 94 | + m2.submit("/model") | |
| 95 | + m2.Update(modelsMsg{models: []llm.Model{{ID: "gateway/mystery"}}}) | |
| 96 | + for _, line := range strings.Split(m2.modelView(), "\n") { | |
| 97 | + if strings.Count(line, "gateway/mystery") > 1 { | |
| 98 | + t.Errorf("the id is printed twice: %q", line) | |
| 99 | + } | |
| 100 | + } | |
| 101 | + } | |
| 102 | + | |
| 103 | + func TestModelPickerAppliesOnEnter(t *testing.T) { | |
| 104 | + m := newTestModel(t) | |
| 105 | + m.submit("/model") | |
| 106 | + m.Update(modelsMsg{models: someModels()}) | |
| 107 | + | |
| 108 | + m.Update(key("down")) | |
| 109 | + if m.opts.Client.Model() != "claude-opus-5" { | |
| 110 | + t.Error("moving the cursor switched the model; it should only move") | |
| 111 | + } | |
| 112 | + | |
| 113 | + m.Update(key("enter")) | |
| 114 | + | |
| 115 | + if got := m.opts.Client.Model(); got != "claude-sonnet-5" { | |
| 116 | + t.Errorf("model = %q, want claude-sonnet-5", got) | |
| 117 | + } | |
| 118 | + if m.phase != phaseIdle { | |
| 119 | + t.Errorf("phase = %v after applying, want the prompt back", m.phase) | |
| 120 | + } | |
| 121 | + if out := m.tr.render(); !strings.Contains(out, "claude-sonnet-5") { | |
| 122 | + t.Errorf("the switch was not reported: %q", out) | |
| 123 | + } | |
| 124 | + if !strings.Contains(m.status(), "Sonnet 5") { | |
| 125 | + t.Errorf("the status bar still names the old model: %q", m.status()) | |
| 126 | + } | |
| 127 | + } | |
| 128 | + | |
| 129 | + func TestModelPickerNumbersJumpAndEscapeCancels(t *testing.T) { | |
| 130 | + m := newTestModel(t) | |
| 131 | + m.submit("/model") | |
| 132 | + m.Update(modelsMsg{models: someModels()}) | |
| 133 | + | |
| 134 | + m.Update(key("3")) | |
| 135 | + if got := m.models[m.modelIdx].ID; got != "claude-haiku-4-5-20251001" { | |
| 136 | + t.Errorf("3 selected %q", got) | |
| 137 | + } | |
| 138 | + m.Update(key("esc")) | |
| 139 | + | |
| 140 | + if got := m.opts.Client.Model(); got != "claude-opus-5" { | |
| 141 | + t.Errorf("model = %q, want it left alone", got) | |
| 142 | + } | |
| 143 | + if m.phase != phaseIdle { | |
| 144 | + t.Errorf("phase = %v, want the prompt back", m.phase) | |
| 145 | + } | |
| 146 | + } | |
| 147 | + | |
| 148 | + // An endpoint that cannot be asked says so, and says where to go instead, | |
| 149 | + // rather than leaving an empty picker on screen. | |
| 150 | + func TestModelPickerReportsAFailedFetch(t *testing.T) { | |
| 151 | + m := newTestModel(t) | |
| 152 | + m.submit("/model") | |
| 153 | + | |
| 154 | + m.Update(modelsMsg{err: errors.New("404 not_found_error: unknown route")}) | |
| 155 | + | |
| 156 | + if m.phase != phaseIdle { | |
| 157 | + t.Errorf("phase = %v, want the picker gone", m.phase) | |
| 158 | + } | |
| 159 | + out := m.tr.render() | |
| 160 | + for _, want := range []string{"404", "-model"} { | |
| 161 | + if !strings.Contains(out, want) { | |
| 162 | + t.Errorf("%q missing from the report: %q", want, out) | |
| 163 | + } | |
| 164 | + } | |
| 165 | + } | |
| 166 | + | |
| 167 | + func TestModelPickerReportsAnEmptyList(t *testing.T) { | |
| 168 | + m := newTestModel(t) | |
| 169 | + m.submit("/model") | |
| 170 | + | |
| 171 | + m.Update(modelsMsg{models: nil}) | |
| 172 | + | |
| 173 | + if m.phase != phaseIdle { | |
| 174 | + t.Errorf("phase = %v, want the picker gone", m.phase) | |
| 175 | + } | |
| 176 | + if !strings.Contains(m.tr.render(), "no models") { | |
| 177 | + t.Errorf("an empty list was not explained: %q", m.tr.render()) | |
| 178 | + } | |
| 179 | + } | |
| 180 | + | |
| 181 | + // Escaping the wait means the answer is no longer wanted: it must not reopen | |
| 182 | + // the picker over whatever the user did next. | |
| 183 | + func TestModelPickerIgnoresAnAnswerItNoLongerWants(t *testing.T) { | |
| 184 | + m := newTestModel(t) | |
| 185 | + m.submit("/model") | |
| 186 | + m.Update(key("esc")) | |
| 187 | + | |
| 188 | + m.Update(modelsMsg{models: someModels()}) | |
| 189 | + | |
| 190 | + if m.phase != phaseIdle { | |
| 191 | + t.Errorf("phase = %v, want the picker to have stayed closed", m.phase) | |
| 192 | + } | |
| 193 | + if len(m.models) != 0 { | |
| 194 | + t.Error("a discarded answer was kept") | |
| 195 | + } | |
| 196 | + } | |
| 197 | + | |
| 198 | + // Typing the id is still the fast path, and it does not need the endpoint to | |
| 199 | + // answer anything. | |
| 200 | + func TestTypedModelSkipsThePicker(t *testing.T) { | |
| 201 | + m := newTestModel(t) | |
| 202 | + m.submit("/model claude-haiku-4-5") | |
| 203 | + | |
| 204 | + if m.phase != phaseIdle { | |
| 205 | + t.Errorf("phase = %v, want no picker", m.phase) | |
| 206 | + } | |
| 207 | + if got := m.opts.Client.Model(); got != "claude-haiku-4-5" { | |
| 208 | + t.Errorf("model = %q", got) | |
| 209 | + } | |
| 210 | + } | |
| 211 | + | |
| 212 | + // A long list scrolls inside the room the terminal has rather than pushing | |
| 213 | + // the transcript off the screen. | |
| 214 | + func TestModelPickerFitsTheTerminal(t *testing.T) { | |
| 215 | + var many []llm.Model | |
| 216 | + for i := 0; i < 30; i++ { | |
| 217 | + many = append(many, llm.Model{ID: fmt.Sprintf("claude-model-%d", i), DisplayName: fmt.Sprintf("Model %d", i)}) | |
| 218 | + } | |
| 219 | + for _, h := range []int{12, 24, 40} { | |
| 220 | + m := newTestModel(t) | |
| 221 | + m.Update(tea.WindowSizeMsg{Width: 80, Height: h}) | |
| 222 | + m.submit("/model") | |
| 223 | + m.Update(modelsMsg{models: many}) | |
| 224 | + | |
| 225 | + if got := strings.Count(m.View(), "\n") + 1; got > h { | |
| 226 | + t.Errorf("height %d: the screen is %d lines", h, got) | |
| 227 | + } | |
| 228 | + if !strings.Contains(m.modelView(), "more") { | |
| 229 | + t.Errorf("height %d: a list of 30 showed no count of what did not fit", h) | |
| 230 | + } | |
| 231 | + | |
| 232 | + // The cursor stays visible as it walks down the list. | |
| 233 | + for i := 0; i < 29; i++ { | |
| 234 | + m.Update(key("down")) | |
| 235 | + } | |
| 236 | + if !strings.Contains(m.modelView(), "Model 29") { | |
| 237 | + t.Errorf("height %d: the list did not scroll to the cursor:\n%s", h, m.modelView()) | |
| 238 | + } | |
| 239 | + } | |
| 240 | + } | |
| 241 | + | |
| 242 | + // The fetch command is the part that talks to the endpoint; it turns both | |
| 243 | + // outcomes into a message the model can handle. | |
| 244 | + func TestFetchModelsCommandCarriesTheResult(t *testing.T) { | |
| 245 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 246 | + fmt.Fprint(w, `{"data":[{"type":"model","id":"claude-opus-5","display_name":"Claude Opus 5"}],"has_more":false}`) | |
| 247 | + })) | |
| 248 | + defer srv.Close() | |
| 249 | + | |
| 250 | + client := llm.New(llm.WithBaseURL(srv.URL), llm.WithAPIKey("k")) | |
| 251 | + msg, ok := fetchModels(client)().(modelsMsg) | |
| 252 | + if !ok { | |
| 253 | + t.Fatal("the command did not produce a modelsMsg") | |
| 254 | + } | |
| 255 | + if msg.err != nil { | |
| 256 | + t.Fatalf("err = %v", msg.err) | |
| 257 | + } | |
| 258 | + if len(msg.models) != 1 || msg.models[0].ID != "claude-opus-5" { | |
| 259 | + t.Errorf("models = %+v", msg.models) | |
| 260 | + } | |
| 261 | + } | |
| 262 | + | |
| 263 | + // Cost is accumulated at the model that earned it. Pricing the whole session | |
| 264 | + // at whatever it ended on would rewrite history every time you switch. | |
| 265 | + func TestCostIsPricedAtTheModelInForceWhenItWasSpent(t *testing.T) { | |
| 266 | + m := newTestModel(t) | |
| 267 | + m.opts.Client.SetModel("claude-opus-5") // $5 per Mtok in | |
| 268 | + m.Update(evUsage{usage: llm.Usage{InputTokens: 1_000_000}}) | |
| 269 | + | |
| 270 | + m.opts.Client.SetModel("claude-haiku-4-5") // $1 per Mtok in | |
| 271 | + m.Update(evUsage{usage: llm.Usage{InputTokens: 1_000_000}}) | |
| 272 | + | |
| 273 | + if got := m.cost; got != 6 { | |
| 274 | + t.Errorf("cost = %v, want 6: five dollars of Opus and one of Haiku", got) | |
| 275 | + } | |
| 276 | + if !strings.Contains(m.status(), "$6.0000") { | |
| 277 | + t.Errorf("the status bar disagrees: %q", m.status()) | |
| 278 | + } | |
| 279 | + } | |
| 280 | + | |
| 281 | + // A turn ends with the session's totals, which on a resumed session include | |
| 282 | + // usage this process never saw. That remainder is priced once. | |
| 283 | + func TestTurnTotalsPriceOnlyWhatWasNotPricedAlready(t *testing.T) { | |
| 284 | + m := newTestModel(t) | |
| 285 | + m.opts.Client.SetModel("claude-opus-5") | |
| 286 | + m.Update(evUsage{usage: llm.Usage{InputTokens: 1_000_000}}) | |
| 287 | + | |
| 288 | + total := llm.Usage{InputTokens: 3_000_000} | |
| 289 | + m.Update(turnDone{usage: total, turns: 1}) | |
| 290 | + if got := m.cost; got != 15 { | |
| 291 | + t.Errorf("cost = %v, want 15: the session's three million at five dollars each", got) | |
| 292 | + } | |
| 293 | + | |
| 294 | + // The same totals arriving again are the same tokens, not new ones. | |
| 295 | + m.Update(turnDone{usage: total, turns: 2}) | |
| 296 | + if got := m.cost; got != 15 { | |
| 297 | + t.Errorf("cost = %v after a second turn that spent nothing, want 15", got) | |
| 298 | + } | |
| 299 | + } | |
| 300 | + | |
| 301 | + // The context window is the other half of what a model is: /v1/models reports | |
| 302 | + // it, so the picker shows it rather than making you remember which model is | |
| 303 | + // the 200K one. | |
| 304 | + func TestModelRowsShowTheContextWindow(t *testing.T) { | |
| 305 | + m := newTestModel(t) | |
| 306 | + m.Update(tea.WindowSizeMsg{Width: 110, Height: 30}) | |
| 307 | + m.submit("/model") | |
| 308 | + m.Update(modelsMsg{models: []llm.Model{ | |
| 309 | + {ID: "claude-opus-5", DisplayName: "Claude Opus 5", MaxInputTokens: 1_000_000}, | |
| 310 | + {ID: "claude-haiku-4-5", DisplayName: "Claude Haiku 4.5", MaxInputTokens: 200_000}, | |
| 311 | + {ID: "mystery", DisplayName: "Mystery"}, | |
| 312 | + }}) | |
| 313 | + | |
| 314 | + view := m.modelView() | |
| 315 | + for _, want := range []string{"1.0M in", "200.0k in"} { | |
| 316 | + if !strings.Contains(view, want) { | |
| 317 | + t.Errorf("%q missing from the picker:\n%s", want, view) | |
| 318 | + } | |
| 319 | + } | |
| 320 | + for _, line := range strings.Split(view, "\n") { | |
| 321 | + if strings.Contains(line, "Mystery") && strings.Contains(line, " in") { | |
| 322 | + t.Errorf("a context window was invented for a model that reported none: %q", line) | |
| 323 | + } | |
| 324 | + } | |
| 325 | + } | |
| @@ -359,7 +359,7 @@ func TestStatusLineKeepsCostAtNormalWidth(t *testing.T) { | ||
| 359 | 359 | m.Update(evUsage{usage: llm.Usage{InputTokens: 2400, OutputTokens: 190, CacheReadInputTokens: 16000}}) |
| 360 | 360 | |
| 361 | 361 | status := m.status() |
| 362 | - if _, known := m.usage.Cost(m.opts.Client.Model); !known { | |
| 362 | + if _, known := m.usage.Cost(m.opts.Client.Model()); !known { | |
| 363 | 363 | t.Skip("no pricing for the test model") |
| 364 | 364 | } |
| 365 | 365 | if strings.HasSuffix(strings.TrimSpace(status), "…") { |
| @@ -30,7 +30,7 @@ func splash(o Options, width int) string { | ||
| 30 | 30 | } |
| 31 | 31 | lines := []string{ |
| 32 | 32 | name, |
| 33 | - styDim.Render(prettyModel(o.Client.Model) + " · " + endpointLabel(o.Client)), | |
| 33 | + styDim.Render(prettyModel(o.Client.Model()) + " · " + endpointLabel(o.Client)), | |
| 34 | 34 | styDim.Render(shortenHome(o.Root)), |
| 35 | 35 | } |
| 36 | 36 | |