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