package llm import ( "context" "encoding/json" "fmt" "io" "net/http" "sort" "time" ) // Model is one entry in the catalogue at GET /v1/models. type Model struct { ID string `json:"id"` DisplayName string `json:"display_name"` Deprecated bool `json:"deprecated"` } // catalogueTimeout is deliberately short. This runs while a person is looking // at a wizard that cannot show them a list yet, and a gateway that black-holes // the request must not hold the setup open for the client's ten-minute // message timeout. const catalogueTimeout = 10 * time.Second // ListModels reads the model catalogue. // // It is best-effort by design: plenty of endpoints this harness can be pointed // at speak /v1/messages and nothing else, so every caller is expected to have // a fallback — KnownModels — rather than to treat an error here as fatal. func (c *Client) ListModels(ctx context.Context) ([]Model, error) { if !c.HasCredentials() { return nil, fmt.Errorf("llm: no credentials: set ANTHROPIC_API_KEY, or run `ant auth login`") } ctx, cancel := context.WithTimeout(ctx, catalogueTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/v1/models?limit=100", nil) if err != nil { return nil, err } c.setHeaders(req) resp, err := c.HTTP.Do(req) if err != nil { return nil, fmt.Errorf("llm: listing models: %w", err) } defer resp.Body.Close() // Capped: an endpoint that answers an unknown route with a whole HTML // page should not be read into memory in full to be thrown away. raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, fmt.Errorf("llm: listing models: %w", err) } if resp.StatusCode != http.StatusOK { return nil, parseAPIError(resp.StatusCode, resp.Header.Get("retry-after"), raw) } var out struct { Data []Model `json:"data"` } if err := json.Unmarshal(raw, &out); err != nil { return nil, fmt.Errorf("llm: %s did not return a model list: %w", c.BaseURL, err) } models := make([]Model, 0, len(out.Data)) for _, m := range out.Data { // A deprecated model still answers requests, which is exactly why it // should not be offered as a fresh default. if m.ID == "" || m.Deprecated { continue } if m.DisplayName == "" { m.DisplayName = m.ID } models = append(models, m) } if len(models) == 0 { return nil, fmt.Errorf("llm: %s returned no models", c.BaseURL) } return models, nil } // KnownModels is the fallback catalogue: what this build knows how to price. // // Pricing is the honest basis for the list. A model the harness cannot price // is one whose cost would silently read as blank in the status bar, so the set // it can price is exactly the set it can fully describe. func KnownModels() []Model { out := make([]Model, 0, len(prices)) for id := range prices { out = append(out, Model{ID: id, DisplayName: displayName(id)}) } // Map iteration is random; the wizard needs the same order every time. sort.Slice(out, func(i, j int) bool { ri, rj := familyRank(out[i].ID), familyRank(out[j].ID) if ri != rj { return ri < rj } return out[i].ID > out[j].ID // newer version first within a family }) return out } // familyRank orders the families the way someone choosing between them thinks // about it: the flagship first, the cheap one last. func familyRank(id string) int { for i, fam := range []string{"claude-opus", "claude-sonnet", "claude-haiku", "claude-fable"} { if len(id) >= len(fam) && id[:len(fam)] == fam { return i } } return 99 } // displayName stands in for the API's display_name on a model we only know // statically. It is derived rather than tabulated: a table would be one more // place to forget to update when a model is added to prices. func displayName(id string) string { return "Claude " + PrettyModel(id) }