1package provider
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "strconv"
10 "strings"
11 "time"
12)
13
14// DiscoverAnthropic lists model ids visible to key on api.anthropic.com.
15// The API returns a paginated list; we page through until has_more is false.
16func DiscoverAnthropic(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
17 if baseURL == "" {
18 baseURL = anthropicDefaultBaseURL
19 }
20 client := &http.Client{Timeout: 15 * time.Second}
21 var out []Model
22 after := ""
23 for {
24 url := strings.TrimRight(baseURL, "/") + "/v1/models?limit=1000"
25 if after != "" {
26 url += "&after_id=" + after
27 }
28 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
29 if err != nil {
30 return nil, err
31 }
32 req.Header.Set("x-api-key", apiKey)
33 req.Header.Set("anthropic-version", anthropicAPIVersion)
34 resp, err := client.Do(req)
35 if err != nil {
36 return nil, err
37 }
38 body, _ := io.ReadAll(resp.Body)
39 resp.Body.Close()
40 if resp.StatusCode != 200 {
41 return nil, fmt.Errorf("anthropic discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
42 }
43 var page struct {
44 Data []struct {
45 ID string `json:"id"`
46 DisplayName string `json:"display_name"`
47 } `json:"data"`
48 HasMore bool `json:"has_more"`
49 LastID string `json:"last_id"`
50 }
51 if err := json.Unmarshal(body, &page); err != nil {
52 return nil, fmt.Errorf("anthropic discover parse: %w", err)
53 }
54 for _, d := range page.Data {
55 out = append(out, Model{
56 Provider: "anthropic",
57 ID: d.ID,
58 DisplayName: d.DisplayName,
59 Source: "live",
60 })
61 }
62 if !page.HasMore || page.LastID == "" {
63 break
64 }
65 after = page.LastID
66 }
67 return out, nil
68}
69
70// DiscoverOpenAI lists model ids visible to key on api.openai.com.
71func DiscoverOpenAI(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
72 if baseURL == "" {
73 baseURL = openaiDefaultBaseURL
74 }
75 client := &http.Client{Timeout: 15 * time.Second}
76 url := strings.TrimRight(baseURL, "/") + "/v1/models"
77 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
78 if err != nil {
79 return nil, err
80 }
81 req.Header.Set("authorization", "Bearer "+apiKey)
82 resp, err := client.Do(req)
83 if err != nil {
84 return nil, err
85 }
86 defer resp.Body.Close()
87 body, _ := io.ReadAll(resp.Body)
88 if resp.StatusCode != 200 {
89 return nil, fmt.Errorf("openai discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
90 }
91 var page struct {
92 Data []struct {
93 ID string `json:"id"`
94 } `json:"data"`
95 }
96 if err := json.Unmarshal(body, &page); err != nil {
97 return nil, fmt.Errorf("openai discover parse: %w", err)
98 }
99 var out []Model
100 for _, d := range page.Data {
101 // Keep only chat-capable families. OpenAI's /v1/models returns
102 // everything including embeddings, TTS, DALL-E, etc.
103 if !looksLikeChatModel(d.ID) {
104 continue
105 }
106 out = append(out, Model{
107 Provider: "openai",
108 ID: d.ID,
109 DisplayName: d.ID,
110 Source: "live",
111 })
112 }
113 return out, nil
114}
115
116// DiscoverGoogle lists Gemini model ids visible to key on
117// generativelanguage.googleapis.com. The API paginates with
118// nextPageToken; we follow it until exhausted.
119func DiscoverGoogle(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
120 if baseURL == "" {
121 baseURL = geminiDefaultBaseURL
122 }
123 client := &http.Client{Timeout: 15 * time.Second}
124 var out []Model
125 pageToken := ""
126 for {
127 url := strings.TrimRight(baseURL, "/") + "/v1beta/models?pageSize=200"
128 if pageToken != "" {
129 url += "&pageToken=" + pageToken
130 }
131 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
132 if err != nil {
133 return nil, err
134 }
135 req.Header.Set("x-goog-api-key", apiKey)
136 resp, err := client.Do(req)
137 if err != nil {
138 return nil, err
139 }
140 body, _ := io.ReadAll(resp.Body)
141 resp.Body.Close()
142 if resp.StatusCode != 200 {
143 return nil, fmt.Errorf("google discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
144 }
145 var page struct {
146 Models []struct {
147 Name string `json:"name"` // "models/gemini-2.5-pro"
148 DisplayName string `json:"displayName"`
149 SupportedGenerationMethods []string `json:"supportedGenerationMethods"`
150 InputTokenLimit int `json:"inputTokenLimit"`
151 OutputTokenLimit int `json:"outputTokenLimit"`
152 } `json:"models"`
153 NextPageToken string `json:"nextPageToken"`
154 }
155 if err := json.Unmarshal(body, &page); err != nil {
156 return nil, fmt.Errorf("google discover parse: %w", err)
157 }
158 for _, m := range page.Models {
159 // Strip the "models/" prefix Gemini uses on resource names.
160 id := strings.TrimPrefix(m.Name, "models/")
161 if !looksLikeGeminiChatModel(id, m.SupportedGenerationMethods) {
162 continue
163 }
164 display := m.DisplayName
165 if display == "" {
166 display = id
167 }
168 out = append(out, Model{
169 Provider: "google",
170 ID: id,
171 DisplayName: display,
172 ContextWindow: m.InputTokenLimit,
173 MaxOutput: m.OutputTokenLimit,
174 Source: "live",
175 })
176 }
177 if page.NextPageToken == "" {
178 break
179 }
180 pageToken = page.NextPageToken
181 }
182 return out, nil
183}
184
185// looksLikeGeminiChatModel filters Gemini's /v1beta/models output
186// down to entries usable with streamGenerateContent. The API also
187// lists embedding models, AQA, and other non-chat artefacts.
188func looksLikeGeminiChatModel(id string, methods []string) bool {
189 if !strings.HasPrefix(id, "gemini-") && !strings.HasPrefix(id, "gemma-") {
190 return false
191 }
192 if strings.Contains(id, "embedding") || strings.Contains(id, "aqa") {
193 return false
194 }
195 if len(methods) > 0 {
196 ok := false
197 for _, m := range methods {
198 if m == "generateContent" || m == "streamGenerateContent" {
199 ok = true
200 break
201 }
202 }
203 if !ok {
204 return false
205 }
206 }
207 return true
208}
209
210// looksLikeChatModel returns true for OpenAI ids that can plausibly be
211// used with the chat/completions endpoint. Errs on the side of inclusion.
212func looksLikeChatModel(id string) bool {
213 switch {
214 case strings.HasPrefix(id, "gpt-"):
215 return true
216 case strings.HasPrefix(id, "o1"):
217 return true
218 case strings.HasPrefix(id, "o3"):
219 return true
220 case strings.HasPrefix(id, "o4"):
221 return true
222 case strings.HasPrefix(id, "o5"):
223 return true
224 case strings.HasPrefix(id, "chatgpt-"):
225 return true
226 }
227 return false
228}
229
230const (
231 openaiCodexModelsBaseURL = "https://chatgpt.com/backend-api/codex"
232 openaiCodexClientVersion = "0.144.0"
233 openrouterDefaultBaseURL = "https://openrouter.ai/api/v1"
234 maxDiscoveryResponseBytes = 10 << 20
235)
236
237// DiscoverOpenAICodex lists the models available to a ChatGPT/Codex OAuth
238// account. This catalog is separate from api.openai.com/v1/models and is the
239// authoritative source for models usable through the Codex subscription route.
240func DiscoverOpenAICodex(ctx context.Context, token, accountID, baseURL string) ([]Model, error) {
241 if baseURL == "" {
242 baseURL = openaiCodexModelsBaseURL
243 }
244 url := strings.TrimRight(baseURL, "/") + "/models?client_version=" + openaiCodexClientVersion
245 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
246 if err != nil {
247 return nil, err
248 }
249 req.Header.Set("authorization", "Bearer "+token)
250 if accountID != "" {
251 req.Header.Set("chatgpt-account-id", accountID)
252 }
253 // The Codex backend expects the same stable client identity used for
254 // requests to its Responses endpoint.
255 req.Header.Set("originator", "codex_cli_rs")
256 req.Header.Set("user-agent", "codex_cli_rs/0.0.0")
257
258 client := &http.Client{Timeout: 15 * time.Second}
259 resp, err := client.Do(req)
260 if err != nil {
261 return nil, err
262 }
263 defer resp.Body.Close()
264 body, err := io.ReadAll(io.LimitReader(resp.Body, maxDiscoveryResponseBytes+1))
265 if err != nil {
266 return nil, fmt.Errorf("openai-codex discover read response: %w", err)
267 }
268 if len(body) > maxDiscoveryResponseBytes {
269 return nil, fmt.Errorf("openai-codex discover response exceeds %d bytes", maxDiscoveryResponseBytes)
270 }
271 if resp.StatusCode != http.StatusOK {
272 return nil, fmt.Errorf("openai-codex discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
273 }
274
275 var page struct {
276 Models []struct {
277 Slug string `json:"slug"`
278 DisplayName string `json:"display_name"`
279 SupportedInAPI bool `json:"supported_in_api"`
280 Visibility string `json:"visibility"`
281 SupportedReasoningLevels []string `json:"supported_reasoning_levels"`
282 ContextWindow *int `json:"context_window"`
283 MaxContext *int `json:"max_context_window"`
284 } `json:"models"`
285 }
286 if err := json.Unmarshal(body, &page); err != nil {
287 return nil, fmt.Errorf("openai-codex discover parse: %w", err)
288 }
289 out := make([]Model, 0, len(page.Models))
290 for _, m := range page.Models {
291 if m.Slug == "" || !m.SupportedInAPI || m.Visibility == "hide" {
292 continue
293 }
294 contextWindow := 0
295 if m.ContextWindow != nil {
296 contextWindow = *m.ContextWindow
297 }
298 if contextWindow == 0 && m.MaxContext != nil {
299 contextWindow = *m.MaxContext
300 }
301 displayName := m.DisplayName
302 if displayName == "" {
303 displayName = m.Slug
304 }
305 out = append(out, Model{
306 Provider: "openai-codex",
307 ID: m.Slug,
308 DisplayName: displayName,
309 ContextWindow: contextWindow,
310 Reasoning: len(m.SupportedReasoningLevels) > 0,
311 Source: "live",
312 })
313 }
314 return out, nil
315}
316
317// DiscoverOpenRouter lists models from OpenRouter's public /models
318// endpoint (no auth). Per-token USD prices are converted to USD per 1M
319// tokens to match the rest of the catalog. baseURL defaults to the
320// public endpoint.
321func DiscoverOpenRouter(ctx context.Context, baseURL string) ([]Model, error) {
322 if baseURL == "" {
323 baseURL = openrouterDefaultBaseURL
324 }
325 baseURL = strings.TrimRight(baseURL, "/")
326 client := &http.Client{Timeout: 15 * time.Second}
327 url := baseURL + "/models"
328 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
329 if err != nil {
330 return nil, err
331 }
332 resp, err := client.Do(req)
333 if err != nil {
334 return nil, err
335 }
336 defer resp.Body.Close()
337 body, _ := io.ReadAll(resp.Body)
338 if resp.StatusCode != 200 {
339 return nil, fmt.Errorf("openrouter discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
340 }
341 var page struct {
342 Data []struct {
343 ID string `json:"id"`
344 Name string `json:"name"`
345 ContextLength int `json:"context_length"`
346 Pricing struct {
347 Prompt string `json:"prompt"`
348 Completion string `json:"completion"`
349 InputCacheRead string `json:"input_cache_read"`
350 InputCacheWrite string `json:"input_cache_write"`
351 } `json:"pricing"`
352 TopProvider struct {
353 ContextLength int `json:"context_length"`
354 MaxCompletionTokens *int `json:"max_completion_tokens"`
355 } `json:"top_provider"`
356 SupportedParameters []string `json:"supported_parameters"`
357 } `json:"data"`
358 }
359 if err := json.Unmarshal(body, &page); err != nil {
360 return nil, fmt.Errorf("openrouter discover parse: %w", err)
361 }
362 var out []Model
363 for _, d := range page.Data {
364 if d.ID == "" {
365 continue
366 }
367 display := d.Name
368 if display == "" {
369 display = d.ID
370 }
371 ctxWin := d.ContextLength
372 if d.TopProvider.ContextLength > 0 && (ctxWin == 0 || d.TopProvider.ContextLength < ctxWin) {
373 ctxWin = d.TopProvider.ContextLength
374 }
375 maxOut := 0
376 if d.TopProvider.MaxCompletionTokens != nil {
377 maxOut = *d.TopProvider.MaxCompletionTokens
378 }
379 out = append(out, Model{
380 Provider: "openrouter",
381 ID: d.ID,
382 DisplayName: display,
383 ContextWindow: ctxWin,
384 MaxOutput: maxOut,
385 Reasoning: openrouterSupportsReasoning(d.SupportedParameters),
386 PriceInput: perMillionTokens(d.Pricing.Prompt),
387 PriceOutput: perMillionTokens(d.Pricing.Completion),
388 PriceCacheRead: perMillionTokens(d.Pricing.InputCacheRead),
389 PriceCacheWrite: perMillionTokens(d.Pricing.InputCacheWrite),
390 BaseURL: baseURL,
391 Source: "live",
392 })
393 }
394 return out, nil
395}
396
397// openrouterSupportsReasoning reports whether OpenRouter's
398// supported_parameters list marks the model as reasoning-capable.
399func openrouterSupportsReasoning(params []string) bool {
400 for _, p := range params {
401 if p == "reasoning" || p == "include_reasoning" {
402 return true
403 }
404 }
405 return false
406}
407
408// perMillionTokens converts a per-token USD price string to USD per 1M
409// tokens. Empty or unparseable values become 0.
410func perMillionTokens(s string) float64 {
411 if s == "" {
412 return 0
413 }
414 v, err := strconv.ParseFloat(s, 64)
415 if err != nil {
416 return 0
417 }
418 return v * 1_000_000
419}