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 openrouterDefaultBaseURL = "https://openrouter.ai/api/v1"
232 gondolaDefaultBaseURL = "https://api.gondola-ai.com/v1"
233)
234
235// DiscoverOpenRouter lists models from OpenRouter's public /models
236// endpoint (no auth). Per-token USD prices are converted to USD per 1M
237// tokens to match the rest of the catalog. baseURL defaults to the
238// public endpoint.
239func DiscoverOpenRouter(ctx context.Context, baseURL string) ([]Model, error) {
240 if baseURL == "" {
241 baseURL = openrouterDefaultBaseURL
242 }
243 baseURL = strings.TrimRight(baseURL, "/")
244 client := &http.Client{Timeout: 15 * time.Second}
245 url := baseURL + "/models"
246 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
247 if err != nil {
248 return nil, err
249 }
250 resp, err := client.Do(req)
251 if err != nil {
252 return nil, err
253 }
254 defer resp.Body.Close()
255 body, _ := io.ReadAll(resp.Body)
256 if resp.StatusCode != 200 {
257 return nil, fmt.Errorf("openrouter discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
258 }
259 var page struct {
260 Data []struct {
261 ID string `json:"id"`
262 Name string `json:"name"`
263 ContextLength int `json:"context_length"`
264 Pricing struct {
265 Prompt string `json:"prompt"`
266 Completion string `json:"completion"`
267 InputCacheRead string `json:"input_cache_read"`
268 InputCacheWrite string `json:"input_cache_write"`
269 } `json:"pricing"`
270 TopProvider struct {
271 ContextLength int `json:"context_length"`
272 MaxCompletionTokens *int `json:"max_completion_tokens"`
273 } `json:"top_provider"`
274 SupportedParameters []string `json:"supported_parameters"`
275 } `json:"data"`
276 }
277 if err := json.Unmarshal(body, &page); err != nil {
278 return nil, fmt.Errorf("openrouter discover parse: %w", err)
279 }
280 var out []Model
281 for _, d := range page.Data {
282 if d.ID == "" {
283 continue
284 }
285 display := d.Name
286 if display == "" {
287 display = d.ID
288 }
289 ctxWin := d.ContextLength
290 if d.TopProvider.ContextLength > 0 && (ctxWin == 0 || d.TopProvider.ContextLength < ctxWin) {
291 ctxWin = d.TopProvider.ContextLength
292 }
293 maxOut := 0
294 if d.TopProvider.MaxCompletionTokens != nil {
295 maxOut = *d.TopProvider.MaxCompletionTokens
296 }
297 out = append(out, Model{
298 Provider: "openrouter",
299 ID: d.ID,
300 DisplayName: display,
301 ContextWindow: ctxWin,
302 MaxOutput: maxOut,
303 Reasoning: openrouterSupportsReasoning(d.SupportedParameters),
304 PriceInput: perMillionTokens(d.Pricing.Prompt),
305 PriceOutput: perMillionTokens(d.Pricing.Completion),
306 PriceCacheRead: perMillionTokens(d.Pricing.InputCacheRead),
307 PriceCacheWrite: perMillionTokens(d.Pricing.InputCacheWrite),
308 BaseURL: baseURL,
309 Source: "live",
310 })
311 }
312 return out, nil
313}
314
315// DiscoverOpenRouterPresets lists the authenticated user's OpenRouter
316// presets and returns them as selectable models with ids of the form
317// "@preset/{slug}". Requires an API key; /presets is not public.
318func DiscoverOpenRouterPresets(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
319 if baseURL == "" {
320 baseURL = openrouterDefaultBaseURL
321 }
322 baseURL = strings.TrimRight(baseURL, "/")
323 client := &http.Client{Timeout: 15 * time.Second}
324 var out []Model
325 offset := 0
326 for {
327 url := fmt.Sprintf("%s/presets?limit=100&offset=%d", baseURL, offset)
328 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
329 if err != nil {
330 return nil, err
331 }
332 if apiKey != "" {
333 req.Header.Set("authorization", "Bearer "+apiKey)
334 }
335 resp, err := client.Do(req)
336 if err != nil {
337 return nil, err
338 }
339 body, _ := io.ReadAll(resp.Body)
340 resp.Body.Close()
341 if resp.StatusCode != 200 {
342 return nil, fmt.Errorf("openrouter presets http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
343 }
344 var page struct {
345 Data []struct {
346 Name string `json:"name"`
347 Slug string `json:"slug"`
348 Description string `json:"description"`
349 Status string `json:"status"`
350 } `json:"data"`
351 TotalCount int `json:"total_count"`
352 }
353 if err := json.Unmarshal(body, &page); err != nil {
354 return nil, fmt.Errorf("openrouter presets parse: %w", err)
355 }
356 for _, d := range page.Data {
357 if d.Slug == "" || d.Status == "archived" || d.Status == "disabled" {
358 continue
359 }
360 display := d.Name
361 if display == "" {
362 display = d.Slug
363 }
364 out = append(out, Model{
365 Provider: "openrouter",
366 ID: "@preset/" + d.Slug,
367 DisplayName: display + " (preset)",
368 BaseURL: baseURL,
369 Source: "live",
370 })
371 }
372 if len(page.Data) == 0 || offset+len(page.Data) >= page.TotalCount {
373 break
374 }
375 offset += len(page.Data)
376 }
377 return out, nil
378}
379
380// openrouterSupportsReasoning reports whether OpenRouter's
381// supported_parameters list marks the model as reasoning-capable.
382func openrouterSupportsReasoning(params []string) bool {
383 for _, p := range params {
384 if p == "reasoning" || p == "include_reasoning" {
385 return true
386 }
387 }
388 return false
389}
390
391// DiscoverGondola lists text models from Gondola's public catalog. Gondola
392// reports prices directly in USD per 1M tokens, matching Model's units.
393func DiscoverGondola(ctx context.Context, baseURL string) ([]Model, error) {
394 if baseURL == "" {
395 baseURL = gondolaDefaultBaseURL
396 }
397 baseURL = strings.TrimRight(baseURL, "/")
398 client := &http.Client{Timeout: 15 * time.Second}
399 req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/models", nil)
400 if err != nil {
401 return nil, err
402 }
403 resp, err := client.Do(req)
404 if err != nil {
405 return nil, err
406 }
407 defer resp.Body.Close()
408 body, _ := io.ReadAll(resp.Body)
409 if resp.StatusCode != http.StatusOK {
410 return nil, fmt.Errorf("gondola discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
411 }
412 var page struct {
413 Data []struct {
414 ID string `json:"id"`
415 DisplayName string `json:"display_name"`
416 Modality string `json:"modality"`
417 Endpoint string `json:"endpoint"`
418 ContextLength int `json:"context_length"`
419 MaxCompletionTokens int `json:"max_completion_tokens"`
420 SupportsReasoning bool `json:"supports_reasoning"`
421 Pricing struct {
422 Input float64 `json:"input_usd_per_mtok"`
423 Output float64 `json:"output_usd_per_mtok"`
424 CacheRead float64 `json:"cached_input_usd_per_mtok"`
425 CacheWrite float64 `json:"cache_write_usd_per_mtok"`
426 } `json:"pricing"`
427 } `json:"data"`
428 }
429 if err := json.Unmarshal(body, &page); err != nil {
430 return nil, fmt.Errorf("gondola discover parse: %w", err)
431 }
432 out := make([]Model, 0, len(page.Data))
433 for _, d := range page.Data {
434 if d.ID == "" || d.Modality != "text" || d.Endpoint != "/v1/chat/completions" {
435 continue
436 }
437 display := d.DisplayName
438 if display == "" {
439 display = d.ID
440 }
441 model := Model{ID: d.ID}
442 out = append(out, Model{
443 Provider: "gondola",
444 ID: d.ID,
445 DisplayName: display,
446 ContextWindow: d.ContextLength,
447 MaxOutput: d.MaxCompletionTokens,
448 Reasoning: d.SupportsReasoning,
449 AdaptiveThinking: usesAdaptiveThinking(model),
450 PriceInput: d.Pricing.Input,
451 PriceOutput: d.Pricing.Output,
452 PriceCacheRead: d.Pricing.CacheRead,
453 PriceCacheWrite: d.Pricing.CacheWrite,
454 BaseURL: baseURL,
455 Source: "live",
456 })
457 }
458 return out, nil
459}
460
461// perMillionTokens converts a per-token USD price string to USD per 1M
462// tokens. Empty or unparseable values become 0.
463func perMillionTokens(s string) float64 {
464 if s == "" {
465 return 0
466 }
467 v, err := strconv.ParseFloat(s, 64)
468 if err != nil {
469 return 0
470 }
471 return v * 1_000_000
472}