v0.33.0 653c635

discover.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close