v0.50.1 a637010

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 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}

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