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 yoloAutoDefaultBaseURL = "https://yolo-auto.com/v1"
234
235 // Yolo-Auto's documented conservative client limits. The provider is
236 // flat-rate with plan-bounded windows, so the window is a safe floor
237 // rather than a ceiling; /v1/models reports the per-key window and
238 // DiscoverYoloAuto prefers it.
239 yoloAutoContextWindow = 131072
240 yoloAutoMaxOutput = 32768
241)
242
243// DiscoverOpenRouter lists models from OpenRouter's public /models
244// endpoint (no auth). Per-token USD prices are converted to USD per 1M
245// tokens to match the rest of the catalog. baseURL defaults to the
246// public endpoint.
247func DiscoverOpenRouter(ctx context.Context, baseURL string) ([]Model, error) {
248 if baseURL == "" {
249 baseURL = openrouterDefaultBaseURL
250 }
251 baseURL = strings.TrimRight(baseURL, "/")
252 client := &http.Client{Timeout: 15 * time.Second}
253 url := baseURL + "/models"
254 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
255 if err != nil {
256 return nil, err
257 }
258 resp, err := client.Do(req)
259 if err != nil {
260 return nil, err
261 }
262 defer resp.Body.Close()
263 body, _ := io.ReadAll(resp.Body)
264 if resp.StatusCode != 200 {
265 return nil, fmt.Errorf("openrouter discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
266 }
267 var page struct {
268 Data []struct {
269 ID string `json:"id"`
270 Name string `json:"name"`
271 ContextLength int `json:"context_length"`
272 Pricing struct {
273 Prompt string `json:"prompt"`
274 Completion string `json:"completion"`
275 InputCacheRead string `json:"input_cache_read"`
276 InputCacheWrite string `json:"input_cache_write"`
277 } `json:"pricing"`
278 TopProvider struct {
279 ContextLength int `json:"context_length"`
280 MaxCompletionTokens *int `json:"max_completion_tokens"`
281 } `json:"top_provider"`
282 SupportedParameters []string `json:"supported_parameters"`
283 } `json:"data"`
284 }
285 if err := json.Unmarshal(body, &page); err != nil {
286 return nil, fmt.Errorf("openrouter discover parse: %w", err)
287 }
288 var out []Model
289 for _, d := range page.Data {
290 if d.ID == "" {
291 continue
292 }
293 display := d.Name
294 if display == "" {
295 display = d.ID
296 }
297 ctxWin := d.ContextLength
298 if d.TopProvider.ContextLength > 0 && (ctxWin == 0 || d.TopProvider.ContextLength < ctxWin) {
299 ctxWin = d.TopProvider.ContextLength
300 }
301 maxOut := 0
302 if d.TopProvider.MaxCompletionTokens != nil {
303 maxOut = *d.TopProvider.MaxCompletionTokens
304 }
305 out = append(out, Model{
306 Provider: "openrouter",
307 ID: d.ID,
308 DisplayName: display,
309 ContextWindow: ctxWin,
310 MaxOutput: maxOut,
311 Reasoning: openrouterSupportsReasoning(d.SupportedParameters),
312 PriceInput: perMillionTokens(d.Pricing.Prompt),
313 PriceOutput: perMillionTokens(d.Pricing.Completion),
314 PriceCacheRead: perMillionTokens(d.Pricing.InputCacheRead),
315 PriceCacheWrite: perMillionTokens(d.Pricing.InputCacheWrite),
316 BaseURL: baseURL,
317 Source: "live",
318 })
319 }
320 return out, nil
321}
322
323// DiscoverOpenRouterPresets lists the authenticated user's OpenRouter
324// presets and returns them as selectable models with ids of the form
325// "@preset/{slug}". Requires an API key; /presets is not public.
326func DiscoverOpenRouterPresets(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
327 if baseURL == "" {
328 baseURL = openrouterDefaultBaseURL
329 }
330 baseURL = strings.TrimRight(baseURL, "/")
331 client := &http.Client{Timeout: 15 * time.Second}
332 var out []Model
333 offset := 0
334 for {
335 url := fmt.Sprintf("%s/presets?limit=100&offset=%d", baseURL, offset)
336 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
337 if err != nil {
338 return nil, err
339 }
340 if apiKey != "" {
341 req.Header.Set("authorization", "Bearer "+apiKey)
342 }
343 resp, err := client.Do(req)
344 if err != nil {
345 return nil, err
346 }
347 body, _ := io.ReadAll(resp.Body)
348 resp.Body.Close()
349 if resp.StatusCode != 200 {
350 return nil, fmt.Errorf("openrouter presets http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
351 }
352 var page struct {
353 Data []struct {
354 Name string `json:"name"`
355 Slug string `json:"slug"`
356 Description string `json:"description"`
357 Status string `json:"status"`
358 } `json:"data"`
359 TotalCount int `json:"total_count"`
360 }
361 if err := json.Unmarshal(body, &page); err != nil {
362 return nil, fmt.Errorf("openrouter presets parse: %w", err)
363 }
364 for _, d := range page.Data {
365 if d.Slug == "" || d.Status == "archived" || d.Status == "disabled" {
366 continue
367 }
368 display := d.Name
369 if display == "" {
370 display = d.Slug
371 }
372 out = append(out, Model{
373 Provider: "openrouter",
374 ID: "@preset/" + d.Slug,
375 DisplayName: display + " (preset)",
376 BaseURL: baseURL,
377 Source: "live",
378 })
379 }
380 if len(page.Data) == 0 || offset+len(page.Data) >= page.TotalCount {
381 break
382 }
383 offset += len(page.Data)
384 }
385 return out, nil
386}
387
388// openrouterSupportsReasoning reports whether OpenRouter's
389// supported_parameters list marks the model as reasoning-capable.
390func openrouterSupportsReasoning(params []string) bool {
391 for _, p := range params {
392 if p == "reasoning" || p == "include_reasoning" {
393 return true
394 }
395 }
396 return false
397}
398
399// DiscoverGondola lists text models from Gondola's public catalog. Gondola
400// reports prices directly in USD per 1M tokens, matching Model's units.
401func DiscoverGondola(ctx context.Context, baseURL string) ([]Model, error) {
402 if baseURL == "" {
403 baseURL = gondolaDefaultBaseURL
404 }
405 baseURL = strings.TrimRight(baseURL, "/")
406 client := &http.Client{Timeout: 15 * time.Second}
407 req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/models", nil)
408 if err != nil {
409 return nil, err
410 }
411 resp, err := client.Do(req)
412 if err != nil {
413 return nil, err
414 }
415 defer resp.Body.Close()
416 body, _ := io.ReadAll(resp.Body)
417 if resp.StatusCode != http.StatusOK {
418 return nil, fmt.Errorf("gondola discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
419 }
420 var page struct {
421 Data []struct {
422 ID string `json:"id"`
423 DisplayName string `json:"display_name"`
424 Modality string `json:"modality"`
425 Endpoint string `json:"endpoint"`
426 ContextLength int `json:"context_length"`
427 MaxCompletionTokens int `json:"max_completion_tokens"`
428 SupportsReasoning bool `json:"supports_reasoning"`
429 Pricing struct {
430 Input float64 `json:"input_usd_per_mtok"`
431 Output float64 `json:"output_usd_per_mtok"`
432 CacheRead float64 `json:"cached_input_usd_per_mtok"`
433 CacheWrite float64 `json:"cache_write_usd_per_mtok"`
434 } `json:"pricing"`
435 } `json:"data"`
436 }
437 if err := json.Unmarshal(body, &page); err != nil {
438 return nil, fmt.Errorf("gondola discover parse: %w", err)
439 }
440 out := make([]Model, 0, len(page.Data))
441 for _, d := range page.Data {
442 if d.ID == "" || d.Modality != "text" || d.Endpoint != "/v1/chat/completions" {
443 continue
444 }
445 display := d.DisplayName
446 if display == "" {
447 display = d.ID
448 }
449 model := Model{ID: d.ID}
450 out = append(out, Model{
451 Provider: "gondola",
452 ID: d.ID,
453 DisplayName: display,
454 ContextWindow: d.ContextLength,
455 MaxOutput: d.MaxCompletionTokens,
456 Reasoning: d.SupportsReasoning,
457 AdaptiveThinking: usesAdaptiveThinking(model),
458 PriceInput: d.Pricing.Input,
459 PriceOutput: d.Pricing.Output,
460 PriceCacheRead: d.Pricing.CacheRead,
461 PriceCacheWrite: d.Pricing.CacheWrite,
462 BaseURL: baseURL,
463 Source: "live",
464 })
465 }
466 return out, nil
467}
468
469// DiscoverYoloAuto lists the models a Yolo-Auto key can reach from the
470// provider's OpenAI-compatible /v1/models endpoint. Each entry reports the
471// plan-bounded context window and, when the alias supports them, the
472// thinking levels it accepts.
473func DiscoverYoloAuto(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
474 if baseURL == "" {
475 baseURL = yoloAutoDefaultBaseURL
476 }
477 baseURL = strings.TrimRight(baseURL, "/")
478 client := &http.Client{Timeout: 15 * time.Second}
479 req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/models", nil)
480 if err != nil {
481 return nil, err
482 }
483 if apiKey != "" {
484 req.Header.Set("authorization", "Bearer "+apiKey)
485 }
486 resp, err := client.Do(req)
487 if err != nil {
488 return nil, err
489 }
490 defer resp.Body.Close()
491 body, _ := io.ReadAll(resp.Body)
492 if resp.StatusCode != http.StatusOK {
493 return nil, fmt.Errorf("yolo-auto discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
494 }
495 var page struct {
496 Data []struct {
497 ID string `json:"id"`
498 ContextLength int `json:"context_length"`
499 MaxModelLen int `json:"max_model_len"`
500 Thinking []string `json:"thinking"`
501 } `json:"data"`
502 }
503 if err := json.Unmarshal(body, &page); err != nil {
504 return nil, fmt.Errorf("yolo-auto discover parse: %w", err)
505 }
506 out := make([]Model, 0, len(page.Data))
507 for _, d := range page.Data {
508 id := strings.TrimSpace(d.ID)
509 if id == "" || strings.Contains(strings.ToLower(id), "embed") {
510 continue
511 }
512 contextWindow := d.ContextLength
513 if contextWindow == 0 {
514 contextWindow = d.MaxModelLen
515 }
516 if contextWindow == 0 {
517 contextWindow = yoloAutoContextWindow
518 }
519 out = append(out, Model{
520 Provider: "yolo-auto",
521 ID: id,
522 DisplayName: id,
523 ContextWindow: contextWindow,
524 MaxOutput: yoloAutoMaxOutput,
525 Reasoning: len(d.Thinking) > 0,
526 ReasoningLevelMap: yoloAutoReasoningLevels(d.Thinking),
527 BaseURL: baseURL,
528 Source: "live",
529 })
530 }
531 return out, nil
532}
533
534// yoloAutoReasoningLevels restricts the generic chat-completions defaults to
535// the endpoint's advertised efforts, including minimal and xhigh when offered.
536func yoloAutoReasoningLevels(thinking []string) map[string]string {
537 levels := make(map[string]string)
538 for _, level := range reasoningLevelOrder[1:] {
539 levels[level] = ""
540 }
541 for _, effort := range thinking {
542 level := NormalizeReasoning(effort)
543 if _, known := levels[level]; known {
544 levels[level] = level
545 }
546 }
547 return levels
548}
549
550// perMillionTokens converts a per-token USD price string to USD per 1M
551// tokens. Empty or unparseable values become 0.
552func perMillionTokens(s string) float64 {
553 if s == "" {
554 return 0
555 }
556 v, err := strconv.ParseFloat(s, 64)
557 if err != nil {
558 return 0
559 }
560 return v * 1_000_000
561}