1package provider
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "sort"
10 "strconv"
11 "strings"
12 "time"
13)
14
15// DiscoverAnthropic lists model ids visible to key on api.anthropic.com.
16// The API returns a paginated list; we page through until has_more is false.
17func DiscoverAnthropic(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
18 if baseURL == "" {
19 baseURL = anthropicDefaultBaseURL
20 }
21 client := &http.Client{Timeout: 15 * time.Second}
22 var out []Model
23 after := ""
24 for {
25 url := strings.TrimRight(baseURL, "/") + "/v1/models?limit=1000"
26 if after != "" {
27 url += "&after_id=" + after
28 }
29 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
30 if err != nil {
31 return nil, err
32 }
33 req.Header.Set("x-api-key", apiKey)
34 req.Header.Set("anthropic-version", anthropicAPIVersion)
35 resp, err := client.Do(req)
36 if err != nil {
37 return nil, err
38 }
39 body, _ := io.ReadAll(resp.Body)
40 resp.Body.Close()
41 if resp.StatusCode != 200 {
42 return nil, fmt.Errorf("anthropic discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
43 }
44 var page struct {
45 Data []struct {
46 ID string `json:"id"`
47 DisplayName string `json:"display_name"`
48 } `json:"data"`
49 HasMore bool `json:"has_more"`
50 LastID string `json:"last_id"`
51 }
52 if err := json.Unmarshal(body, &page); err != nil {
53 return nil, fmt.Errorf("anthropic discover parse: %w", err)
54 }
55 for _, d := range page.Data {
56 out = append(out, Model{
57 Provider: "anthropic",
58 ID: d.ID,
59 DisplayName: d.DisplayName,
60 Source: "live",
61 })
62 }
63 if !page.HasMore || page.LastID == "" {
64 break
65 }
66 after = page.LastID
67 }
68 return out, nil
69}
70
71// DiscoverOpenAI lists model ids visible to key on api.openai.com.
72func DiscoverOpenAI(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
73 if baseURL == "" {
74 baseURL = openaiDefaultBaseURL
75 }
76 client := &http.Client{Timeout: 15 * time.Second}
77 url := strings.TrimRight(baseURL, "/") + "/v1/models"
78 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
79 if err != nil {
80 return nil, err
81 }
82 req.Header.Set("authorization", "Bearer "+apiKey)
83 resp, err := client.Do(req)
84 if err != nil {
85 return nil, err
86 }
87 defer resp.Body.Close()
88 body, _ := io.ReadAll(resp.Body)
89 if resp.StatusCode != 200 {
90 return nil, fmt.Errorf("openai discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
91 }
92 var page struct {
93 Data []struct {
94 ID string `json:"id"`
95 } `json:"data"`
96 }
97 if err := json.Unmarshal(body, &page); err != nil {
98 return nil, fmt.Errorf("openai discover parse: %w", err)
99 }
100 var out []Model
101 for _, d := range page.Data {
102 // Keep only chat-capable families. OpenAI's /v1/models returns
103 // everything including embeddings, TTS, DALL-E, etc.
104 if !looksLikeChatModel(d.ID) {
105 continue
106 }
107 out = append(out, Model{
108 Provider: "openai",
109 ID: d.ID,
110 DisplayName: d.ID,
111 Source: "live",
112 })
113 }
114 return out, nil
115}
116
117const OllamaCloudBaseURL = "https://ollama.com/v1"
118
119// DiscoverOllama lists the public Ollama Cloud catalog. Ollama model IDs do
120// not follow OpenAI's gpt-/o* naming convention, so discovery keeps every ID.
121func DiscoverOllama(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
122 if baseURL == "" {
123 baseURL = OllamaCloudBaseURL
124 }
125 client := &http.Client{Timeout: 15 * time.Second}
126 baseURL = strings.TrimRight(baseURL, "/")
127 url := baseURL + "/v1/models"
128 if versionSegmentSuffix.MatchString(baseURL) {
129 url = baseURL + "/models"
130 }
131 body, err := fetchDiscoveryJSON(ctx, client, url, "Bearer "+apiKey)
132 if err != nil {
133 return nil, fmt.Errorf("ollama discover: %w", err)
134 }
135 var page struct {
136 Data []struct {
137 ID string `json:"id"`
138 } `json:"data"`
139 }
140 if err := json.Unmarshal(body, &page); err != nil {
141 return nil, fmt.Errorf("ollama discover parse: %w", err)
142 }
143 out := make([]Model, 0, len(page.Data))
144 for _, item := range page.Data {
145 if item.ID == "" {
146 continue
147 }
148 out = append(out, Model{
149 Provider: "ollama",
150 ID: item.ID,
151 DisplayName: item.ID,
152 Source: "live",
153 })
154 }
155 return out, nil
156}
157
158const (
159 modelsDevAPIURL = "https://models.dev/api.json"
160 OpenCodeGoDefaultBaseURL = "https://opencode.ai/zen/go/v1"
161)
162
163// DiscoverOpenCodeGo joins the model ids published by OpenCode Go with
164// metadata from models.dev. The /models endpoint is authoritative for the
165// provider's published catalog, but it does not prove account entitlement;
166// consent, region, quota, and other access checks still happen at inference.
167// models.dev supplies display names, limits, reasoning capabilities, pricing,
168// and the adapter that selects the wire protocol.
169func DiscoverOpenCodeGo(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
170 return discoverOpenCodeGo(ctx, apiKey, baseURL, modelsDevAPIURL)
171}
172
173// discoverOpenCodeGo is split out so tests can use a local models.dev fixture.
174func discoverOpenCodeGo(ctx context.Context, apiKey, baseURL, metadataURL string) ([]Model, error) {
175 if baseURL == "" {
176 baseURL = OpenCodeGoDefaultBaseURL
177 }
178 if metadataURL == "" {
179 metadataURL = modelsDevAPIURL
180 }
181
182 client := &http.Client{Timeout: 15 * time.Second}
183 // Models.dev is enrichment only. Keep serving IDs even when its public
184 // catalog is temporarily unavailable or has not published this provider.
185 var metadata modelsDevProvider
186 if metadataBody, err := fetchDiscoveryJSON(ctx, client, metadataURL, ""); err == nil {
187 var providers map[string]modelsDevProvider
188 if err := json.Unmarshal(metadataBody, &providers); err == nil {
189 metadata = providers[ProviderOpenCodeGo]
190 }
191 }
192
193 modelsBody, err := fetchDiscoveryJSON(ctx, client, strings.TrimRight(baseURL, "/")+"/models", "Bearer "+apiKey)
194 if err != nil {
195 return nil, fmt.Errorf("%s models: %w", ProviderOpenCodeGo, err)
196 }
197 var page struct {
198 Data []struct {
199 ID string `json:"id"`
200 } `json:"data"`
201 }
202 if err := json.Unmarshal(modelsBody, &page); err != nil {
203 return nil, fmt.Errorf("%s models parse: %w", ProviderOpenCodeGo, err)
204 }
205
206 out := make([]Model, 0, len(page.Data))
207 for _, live := range page.Data {
208 id := strings.TrimSpace(live.ID)
209 if id == "" {
210 continue
211 }
212 model := Model{
213 Provider: ProviderOpenCodeGo,
214 ID: id,
215 DisplayName: id,
216 BaseURL: strings.TrimRight(baseURL, "/"),
217 Source: "live",
218 API: openCodeGoAPIForModel(id),
219 }
220 if details, ok := metadata.Models[id]; ok {
221 model.API = modelsDevAPIForModel(metadata, details, id)
222 if details.Name != "" {
223 model.DisplayName = details.Name
224 }
225 model.ContextWindow = details.Limit.Context
226 model.MaxOutput = details.Limit.Output
227 model.Reasoning = details.Reasoning
228 if details.ReasoningOptions != nil {
229 model.ReasoningLevelMap, model.ReasoningEffortMap = modelsDevReasoningMaps(details.Reasoning, *details.ReasoningOptions)
230 }
231 model.PriceInput = details.Cost.Input
232 model.PriceOutput = details.Cost.Output
233 model.PriceCacheRead = details.Cost.CacheRead
234 model.PriceCacheWrite = details.Cost.CacheWrite
235 model.PriceTiers = modelsDevPriceTiers(details.Cost.Tiers)
236 if len(model.PriceTiers) > 0 {
237 // Keep the legacy fields pointed at the first threshold for
238 // callers that do not yet understand PriceTiers.
239 first := model.PriceTiers[0]
240 model.PriceTierInputTokens = first.InputTokens
241 model.PriceInputAbove = first.PriceInput
242 model.PriceOutputAbove = first.PriceOutput
243 model.PriceCacheReadAbove = first.PriceCacheRead
244 model.PriceCacheWriteAbove = first.PriceCacheWrite
245 }
246 }
247 out = append(out, model)
248 }
249 return out, nil
250}
251
252// modelsDevProvider and its nested types intentionally cover only the stable
253// subset needed by provider.Model. models.dev adds fields over time, and the
254// JSON decoder safely ignores those additions.
255type modelsDevProvider struct {
256 NPM string `json:"npm"`
257 Models map[string]modelsDevModel `json:"models"`
258}
259
260type modelsDevModel struct {
261 Name string `json:"name"`
262 Reasoning bool `json:"reasoning"`
263 ReasoningOptions *[]modelsDevReasoningOption `json:"reasoning_options"`
264 Provider *modelsDevModelProviderOverride `json:"provider"`
265 Limit modelsDevLimit `json:"limit"`
266 Cost modelsDevCost `json:"cost"`
267}
268
269type modelsDevModelProviderOverride struct {
270 NPM string `json:"npm"`
271}
272
273// modelsDevAPIForModel resolves the models.dev adapter without trusting its
274// endpoint URL. A model-level adapter overrides the provider default; when
275// neither is published, retain the narrow model-name bootstrap heuristic.
276// Unknown explicit adapters are returned unchanged so the router can fail
277// with an actionable unsupported-protocol error instead of silently selecting
278// Chat Completions.
279func modelsDevAPIForModel(provider modelsDevProvider, model modelsDevModel, id string) string {
280 npm := ""
281 if model.Provider != nil {
282 npm = strings.TrimSpace(model.Provider.NPM)
283 }
284 if npm == "" {
285 npm = strings.TrimSpace(provider.NPM)
286 }
287 if npm == "" {
288 return openCodeGoAPIForModel(id)
289 }
290 switch strings.ToLower(npm) {
291 case "@ai-sdk/openai":
292 return APIResponses
293 case "@ai-sdk/openai-compatible":
294 return APICompletions
295 case "@ai-sdk/anthropic":
296 return APIAnthropicMessages
297 default:
298 return npm
299 }
300}
301
302type modelsDevReasoningOption struct {
303 Type string `json:"type"`
304 Values []string `json:"values"`
305}
306
307type modelsDevLimit struct {
308 Context int `json:"context"`
309 Output int `json:"output"`
310}
311
312type modelsDevCost struct {
313 Input float64 `json:"input"`
314 Output float64 `json:"output"`
315 CacheRead float64 `json:"cache_read"`
316 CacheWrite float64 `json:"cache_write"`
317 Tiers []modelsDevCostTier `json:"tiers"`
318}
319
320type modelsDevCostTier struct {
321 Input float64 `json:"input"`
322 Output float64 `json:"output"`
323 CacheRead float64 `json:"cache_read"`
324 CacheWrite float64 `json:"cache_write"`
325 Tier modelsDevCostLimit `json:"tier"`
326}
327
328type modelsDevCostLimit struct {
329 Type string `json:"type"`
330 Size int `json:"size"`
331}
332
333func modelsDevPriceTiers(tiers []modelsDevCostTier) []ModelPriceTier {
334 out := make([]ModelPriceTier, 0, len(tiers))
335 for _, tier := range tiers {
336 if tier.Tier.Type != "context" || tier.Tier.Size <= 0 {
337 continue
338 }
339 out = append(out, ModelPriceTier{
340 InputTokens: tier.Tier.Size,
341 PriceInput: tier.Input,
342 PriceOutput: tier.Output,
343 PriceCacheRead: tier.CacheRead,
344 PriceCacheWrite: tier.CacheWrite,
345 })
346 }
347 sort.SliceStable(out, func(i, j int) bool {
348 return out[i].InputTokens < out[j].InputTokens
349 })
350 return out
351}
352
353// modelsDevReasoningMaps translates the effort values used by models.dev
354// into zut's reasoning levels and exact provider wire values. The explicit
355// metadata list is authoritative: remove every unsupported default and add
356// supported minimum/xhigh/max levels.
357func modelsDevReasoningMaps(reasoning bool, options []modelsDevReasoningOption) (map[string]string, map[string]string) {
358 if !reasoning {
359 return nil, nil
360 }
361 supported := make(map[string]string)
362 for _, option := range options {
363 if option.Type != "effort" {
364 continue
365 }
366 for _, value := range option.Values {
367 level := NormalizeReasoning(value)
368 if reasoningLevelRank(level) > 0 {
369 supported[level] = strings.ToLower(strings.TrimSpace(value))
370 }
371 }
372 }
373 levels := []string{"minimum", "low", "medium", "high", "xhigh", "max"}
374 if len(supported) == 0 {
375 levelMap := make(map[string]string, len(levels))
376 for _, level := range levels {
377 levelMap[level] = ""
378 }
379 return levelMap, nil
380 }
381
382 levelMap := make(map[string]string, len(levels))
383 effortMap := make(map[string]string, len(supported))
384 for _, level := range levels {
385 if wire, ok := supported[level]; ok {
386 levelMap[level] = level
387 effortMap[level] = wire
388 continue
389 }
390 // An explicit models.dev effort list is authoritative for every
391 // protocol default, including Responses-only xhigh and max.
392 levelMap[level] = ""
393 }
394 return levelMap, effortMap
395}
396
397// DynamicOpenCodeGoModel returns bootstrap metadata for an OpenCode Go model
398// that is not present in a discovered catalog yet.
399func DynamicOpenCodeGoModel(providerName, modelID, baseURL string) Model {
400 return Model{
401 Provider: providerName,
402 ID: modelID,
403 DisplayName: modelID,
404 ContextWindow: 128000,
405 MaxOutput: 16384,
406 Reasoning: true,
407 API: OpenCodeGoAPIForModel(modelID),
408 BaseURL: baseURL,
409 Source: "dynamic",
410 }
411}
412
413// OpenCodeGoAPIForModel reports the narrow bootstrap wire API for an
414// OpenCode Go model when adapter metadata is unavailable. Discovery metadata
415// is authoritative whenever it is present; this heuristic only recognizes the
416// GPT-5.6 Responses family and leaves other models on the Chat Completions
417// fallback.
418func OpenCodeGoAPIForModel(id string) string {
419 return openCodeGoAPIForModel(id)
420}
421
422// Keep the family rule private so discovery and client fallbacks share one
423// implementation without another model-id list.
424func openCodeGoAPIForModel(id string) string {
425 id = strings.ToLower(strings.TrimSpace(id))
426 if strings.HasPrefix(id, "gpt-5.6-") {
427 return APIResponses
428 }
429 return ""
430}
431
432func fetchDiscoveryJSON(ctx context.Context, client *http.Client, url, authorization string) ([]byte, error) {
433 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
434 if err != nil {
435 return nil, err
436 }
437 if authorization != "" {
438 req.Header.Set("authorization", authorization)
439 }
440 resp, err := client.Do(req)
441 if err != nil {
442 return nil, err
443 }
444 defer resp.Body.Close()
445 body, err := io.ReadAll(io.LimitReader(resp.Body, maxDiscoveryResponseBytes+1))
446 if err != nil {
447 return nil, err
448 }
449 if len(body) > maxDiscoveryResponseBytes {
450 return nil, fmt.Errorf("response exceeds %d bytes", maxDiscoveryResponseBytes)
451 }
452 if resp.StatusCode != http.StatusOK {
453 return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
454 }
455 return body, nil
456}
457
458// DiscoverGoogle lists Gemini model ids visible to key on
459// generativelanguage.googleapis.com. The API paginates with
460// nextPageToken; we follow it until exhausted.
461func DiscoverGoogle(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
462 if baseURL == "" {
463 baseURL = geminiDefaultBaseURL
464 }
465 client := &http.Client{Timeout: 15 * time.Second}
466 var out []Model
467 pageToken := ""
468 for {
469 url := strings.TrimRight(baseURL, "/") + "/v1beta/models?pageSize=200"
470 if pageToken != "" {
471 url += "&pageToken=" + pageToken
472 }
473 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
474 if err != nil {
475 return nil, err
476 }
477 req.Header.Set("x-goog-api-key", apiKey)
478 resp, err := client.Do(req)
479 if err != nil {
480 return nil, err
481 }
482 body, _ := io.ReadAll(resp.Body)
483 resp.Body.Close()
484 if resp.StatusCode != 200 {
485 return nil, fmt.Errorf("google discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
486 }
487 var page struct {
488 Models []struct {
489 Name string `json:"name"` // "models/gemini-2.5-pro"
490 DisplayName string `json:"displayName"`
491 SupportedGenerationMethods []string `json:"supportedGenerationMethods"`
492 InputTokenLimit int `json:"inputTokenLimit"`
493 OutputTokenLimit int `json:"outputTokenLimit"`
494 } `json:"models"`
495 NextPageToken string `json:"nextPageToken"`
496 }
497 if err := json.Unmarshal(body, &page); err != nil {
498 return nil, fmt.Errorf("google discover parse: %w", err)
499 }
500 for _, m := range page.Models {
501 // Strip the "models/" prefix Gemini uses on resource names.
502 id := strings.TrimPrefix(m.Name, "models/")
503 if !looksLikeGeminiChatModel(id, m.SupportedGenerationMethods) {
504 continue
505 }
506 display := m.DisplayName
507 if display == "" {
508 display = id
509 }
510 out = append(out, Model{
511 Provider: "google",
512 ID: id,
513 DisplayName: display,
514 ContextWindow: m.InputTokenLimit,
515 MaxOutput: m.OutputTokenLimit,
516 Source: "live",
517 })
518 }
519 if page.NextPageToken == "" {
520 break
521 }
522 pageToken = page.NextPageToken
523 }
524 return out, nil
525}
526
527// looksLikeGeminiChatModel filters Gemini's /v1beta/models output
528// down to entries usable with streamGenerateContent. The API also
529// lists embedding models, AQA, and other non-chat artefacts.
530func looksLikeGeminiChatModel(id string, methods []string) bool {
531 if !strings.HasPrefix(id, "gemini-") && !strings.HasPrefix(id, "gemma-") {
532 return false
533 }
534 if strings.Contains(id, "embedding") || strings.Contains(id, "aqa") {
535 return false
536 }
537 if len(methods) > 0 {
538 ok := false
539 for _, m := range methods {
540 if m == "generateContent" || m == "streamGenerateContent" {
541 ok = true
542 break
543 }
544 }
545 if !ok {
546 return false
547 }
548 }
549 return true
550}
551
552// looksLikeChatModel returns true for OpenAI ids that can plausibly be
553// used with the chat/completions endpoint. Errs on the side of inclusion.
554func looksLikeChatModel(id string) bool {
555 switch {
556 case strings.HasPrefix(id, "gpt-"):
557 return true
558 case strings.HasPrefix(id, "o1"):
559 return true
560 case strings.HasPrefix(id, "o3"):
561 return true
562 case strings.HasPrefix(id, "o4"):
563 return true
564 case strings.HasPrefix(id, "o5"):
565 return true
566 case strings.HasPrefix(id, "chatgpt-"):
567 return true
568 }
569 return false
570}
571
572const (
573 openaiCodexModelsBaseURL = "https://chatgpt.com/backend-api/codex"
574 openaiCodexClientVersion = "0.144.0"
575 openrouterDefaultBaseURL = "https://openrouter.ai/api/v1"
576 maxDiscoveryResponseBytes = 10 << 20
577)
578
579// DiscoverOpenAICodex lists the models available to a ChatGPT/Codex OAuth
580// account. This catalog is separate from api.openai.com/v1/models and is the
581// authoritative source for models usable through the Codex subscription route.
582func DiscoverOpenAICodex(ctx context.Context, token, accountID, baseURL string) ([]Model, error) {
583 if baseURL == "" {
584 baseURL = openaiCodexModelsBaseURL
585 }
586 url := strings.TrimRight(baseURL, "/") + "/models?client_version=" + openaiCodexClientVersion
587 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
588 if err != nil {
589 return nil, err
590 }
591 req.Header.Set("authorization", "Bearer "+token)
592 if accountID != "" {
593 req.Header.Set("chatgpt-account-id", accountID)
594 }
595 // The Codex backend expects the same stable client identity used for
596 // requests to its Responses endpoint.
597 req.Header.Set("originator", "codex_cli_rs")
598 req.Header.Set("user-agent", "codex_cli_rs/0.0.0")
599
600 client := &http.Client{Timeout: 15 * time.Second}
601 resp, err := client.Do(req)
602 if err != nil {
603 return nil, err
604 }
605 defer resp.Body.Close()
606 body, err := io.ReadAll(io.LimitReader(resp.Body, maxDiscoveryResponseBytes+1))
607 if err != nil {
608 return nil, fmt.Errorf("openai-codex discover read response: %w", err)
609 }
610 if len(body) > maxDiscoveryResponseBytes {
611 return nil, fmt.Errorf("openai-codex discover response exceeds %d bytes", maxDiscoveryResponseBytes)
612 }
613 if resp.StatusCode != http.StatusOK {
614 return nil, fmt.Errorf("openai-codex discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
615 }
616
617 var page struct {
618 Models []struct {
619 Slug string `json:"slug"`
620 DisplayName string `json:"display_name"`
621 SupportedInAPI bool `json:"supported_in_api"`
622 Visibility string `json:"visibility"`
623 SupportedReasoningLevels []string `json:"supported_reasoning_levels"`
624 ContextWindow *int `json:"context_window"`
625 MaxContext *int `json:"max_context_window"`
626 } `json:"models"`
627 }
628 if err := json.Unmarshal(body, &page); err != nil {
629 return nil, fmt.Errorf("openai-codex discover parse: %w", err)
630 }
631 out := make([]Model, 0, len(page.Models))
632 for _, m := range page.Models {
633 if m.Slug == "" || !m.SupportedInAPI || m.Visibility == "hide" {
634 continue
635 }
636 contextWindow := 0
637 if m.ContextWindow != nil {
638 contextWindow = *m.ContextWindow
639 }
640 if m.MaxContext != nil {
641 switch {
642 case contextWindow == 0:
643 contextWindow = *m.MaxContext
644 case *m.MaxContext >= openAIContextWindowTarget && contextWindow < openAIContextWindowTarget:
645 contextWindow = openAIContextWindowTarget
646 }
647 }
648 displayName := m.DisplayName
649 if displayName == "" {
650 displayName = m.Slug
651 }
652 out = append(out, Model{
653 Provider: "openai-codex",
654 ID: m.Slug,
655 DisplayName: displayName,
656 ContextWindow: contextWindow,
657 Reasoning: len(m.SupportedReasoningLevels) > 0,
658 Source: "live",
659 })
660 }
661 return out, nil
662}
663
664// DiscoverOpenRouter lists models from OpenRouter's public /models
665// endpoint (no auth). Per-token USD prices are converted to USD per 1M
666// tokens to match the rest of the catalog. baseURL defaults to the
667// public endpoint.
668func DiscoverOpenRouter(ctx context.Context, baseURL string) ([]Model, error) {
669 if baseURL == "" {
670 baseURL = openrouterDefaultBaseURL
671 }
672 baseURL = strings.TrimRight(baseURL, "/")
673 client := &http.Client{Timeout: 15 * time.Second}
674 url := baseURL + "/models"
675 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
676 if err != nil {
677 return nil, err
678 }
679 resp, err := client.Do(req)
680 if err != nil {
681 return nil, err
682 }
683 defer resp.Body.Close()
684 body, _ := io.ReadAll(resp.Body)
685 if resp.StatusCode != 200 {
686 return nil, fmt.Errorf("openrouter discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
687 }
688 var page struct {
689 Data []struct {
690 ID string `json:"id"`
691 Name string `json:"name"`
692 ContextLength int `json:"context_length"`
693 Pricing struct {
694 Prompt string `json:"prompt"`
695 Completion string `json:"completion"`
696 InputCacheRead string `json:"input_cache_read"`
697 InputCacheWrite string `json:"input_cache_write"`
698 } `json:"pricing"`
699 TopProvider struct {
700 ContextLength int `json:"context_length"`
701 MaxCompletionTokens *int `json:"max_completion_tokens"`
702 } `json:"top_provider"`
703 SupportedParameters []string `json:"supported_parameters"`
704 } `json:"data"`
705 }
706 if err := json.Unmarshal(body, &page); err != nil {
707 return nil, fmt.Errorf("openrouter discover parse: %w", err)
708 }
709 var out []Model
710 for _, d := range page.Data {
711 if d.ID == "" {
712 continue
713 }
714 display := d.Name
715 if display == "" {
716 display = d.ID
717 }
718 ctxWin := d.ContextLength
719 if d.TopProvider.ContextLength > 0 && (ctxWin == 0 || d.TopProvider.ContextLength < ctxWin) {
720 ctxWin = d.TopProvider.ContextLength
721 }
722 maxOut := 0
723 if d.TopProvider.MaxCompletionTokens != nil {
724 maxOut = *d.TopProvider.MaxCompletionTokens
725 }
726 out = append(out, Model{
727 Provider: "openrouter",
728 ID: d.ID,
729 DisplayName: display,
730 ContextWindow: ctxWin,
731 MaxOutput: maxOut,
732 Reasoning: openrouterSupportsReasoning(d.SupportedParameters),
733 PriceInput: perMillionTokens(d.Pricing.Prompt),
734 PriceOutput: perMillionTokens(d.Pricing.Completion),
735 PriceCacheRead: perMillionTokens(d.Pricing.InputCacheRead),
736 PriceCacheWrite: perMillionTokens(d.Pricing.InputCacheWrite),
737 BaseURL: baseURL,
738 Source: "live",
739 })
740 }
741 return out, nil
742}
743
744// openrouterSupportsReasoning reports whether OpenRouter's
745// supported_parameters list marks the model as reasoning-capable.
746func openrouterSupportsReasoning(params []string) bool {
747 for _, p := range params {
748 if p == "reasoning" || p == "include_reasoning" {
749 return true
750 }
751 }
752 return false
753}
754
755// perMillionTokens converts a per-token USD price string to USD per 1M
756// tokens. Empty or unparseable values become 0.
757func perMillionTokens(s string) float64 {
758 if s == "" {
759 return 0
760 }
761 v, err := strconv.ParseFloat(s, 64)
762 if err != nil {
763 return 0
764 }
765 return v * 1_000_000
766}