1package provider
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "strconv"
10 "strings"
11 "time"
12)
13
14// DiscoverAnthropic lists model ids visible to key on api.anthropic.com.
15// The API returns a paginated list; we page through until has_more is false.
16func DiscoverAnthropic(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
17 if baseURL == "" {
18 baseURL = anthropicDefaultBaseURL
19 }
20 client := &http.Client{Timeout: 15 * time.Second}
21 var out []Model
22 after := ""
23 for {
24 url := strings.TrimRight(baseURL, "/") + "/v1/models?limit=1000"
25 if after != "" {
26 url += "&after_id=" + after
27 }
28 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
29 if err != nil {
30 return nil, err
31 }
32 req.Header.Set("x-api-key", apiKey)
33 req.Header.Set("anthropic-version", anthropicAPIVersion)
34 resp, err := client.Do(req)
35 if err != nil {
36 return nil, err
37 }
38 body, _ := io.ReadAll(resp.Body)
39 resp.Body.Close()
40 if resp.StatusCode != 200 {
41 return nil, fmt.Errorf("anthropic discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
42 }
43 var page struct {
44 Data []struct {
45 ID string `json:"id"`
46 DisplayName string `json:"display_name"`
47 } `json:"data"`
48 HasMore bool `json:"has_more"`
49 LastID string `json:"last_id"`
50 }
51 if err := json.Unmarshal(body, &page); err != nil {
52 return nil, fmt.Errorf("anthropic discover parse: %w", err)
53 }
54 for _, d := range page.Data {
55 out = append(out, Model{
56 Provider: "anthropic",
57 ID: d.ID,
58 DisplayName: d.DisplayName,
59 Source: "live",
60 })
61 }
62 if !page.HasMore || page.LastID == "" {
63 break
64 }
65 after = page.LastID
66 }
67 return out, nil
68}
69
70// DiscoverOpenAI lists model ids visible to key on api.openai.com.
71func DiscoverOpenAI(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
72 if baseURL == "" {
73 baseURL = openaiDefaultBaseURL
74 }
75 client := &http.Client{Timeout: 15 * time.Second}
76 url := strings.TrimRight(baseURL, "/") + "/v1/models"
77 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
78 if err != nil {
79 return nil, err
80 }
81 req.Header.Set("authorization", "Bearer "+apiKey)
82 resp, err := client.Do(req)
83 if err != nil {
84 return nil, err
85 }
86 defer resp.Body.Close()
87 body, _ := io.ReadAll(resp.Body)
88 if resp.StatusCode != 200 {
89 return nil, fmt.Errorf("openai discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
90 }
91 var page struct {
92 Data []struct {
93 ID string `json:"id"`
94 } `json:"data"`
95 }
96 if err := json.Unmarshal(body, &page); err != nil {
97 return nil, fmt.Errorf("openai discover parse: %w", err)
98 }
99 var out []Model
100 for _, d := range page.Data {
101 // Keep only chat-capable families. OpenAI's /v1/models returns
102 // everything including embeddings, TTS, DALL-E, etc.
103 if !looksLikeChatModel(d.ID) {
104 continue
105 }
106 out = append(out, Model{
107 Provider: "openai",
108 ID: d.ID,
109 DisplayName: d.ID,
110 Source: "live",
111 })
112 }
113 return out, nil
114}
115
116// DiscoverGoogle lists Gemini model ids visible to key on
117// generativelanguage.googleapis.com. The API paginates with
118// nextPageToken; we follow it until exhausted.
119func DiscoverGoogle(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
120 if baseURL == "" {
121 baseURL = geminiDefaultBaseURL
122 }
123 client := &http.Client{Timeout: 15 * time.Second}
124 var out []Model
125 pageToken := ""
126 for {
127 url := strings.TrimRight(baseURL, "/") + "/v1beta/models?pageSize=200"
128 if pageToken != "" {
129 url += "&pageToken=" + pageToken
130 }
131 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
132 if err != nil {
133 return nil, err
134 }
135 req.Header.Set("x-goog-api-key", apiKey)
136 resp, err := client.Do(req)
137 if err != nil {
138 return nil, err
139 }
140 body, _ := io.ReadAll(resp.Body)
141 resp.Body.Close()
142 if resp.StatusCode != 200 {
143 return nil, fmt.Errorf("google discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
144 }
145 var page struct {
146 Models []struct {
147 Name string `json:"name"` // "models/gemini-2.5-pro"
148 DisplayName string `json:"displayName"`
149 SupportedGenerationMethods []string `json:"supportedGenerationMethods"`
150 InputTokenLimit int `json:"inputTokenLimit"`
151 OutputTokenLimit int `json:"outputTokenLimit"`
152 } `json:"models"`
153 NextPageToken string `json:"nextPageToken"`
154 }
155 if err := json.Unmarshal(body, &page); err != nil {
156 return nil, fmt.Errorf("google discover parse: %w", err)
157 }
158 for _, m := range page.Models {
159 // Strip the "models/" prefix Gemini uses on resource names.
160 id := strings.TrimPrefix(m.Name, "models/")
161 if !looksLikeGeminiChatModel(id, m.SupportedGenerationMethods) {
162 continue
163 }
164 display := m.DisplayName
165 if display == "" {
166 display = id
167 }
168 out = append(out, Model{
169 Provider: "google",
170 ID: id,
171 DisplayName: display,
172 ContextWindow: m.InputTokenLimit,
173 MaxOutput: m.OutputTokenLimit,
174 Source: "live",
175 })
176 }
177 if page.NextPageToken == "" {
178 break
179 }
180 pageToken = page.NextPageToken
181 }
182 return out, nil
183}
184
185// looksLikeGeminiChatModel filters Gemini's /v1beta/models output
186// down to entries usable with streamGenerateContent. The API also
187// lists embedding models, AQA, and other non-chat artefacts.
188func looksLikeGeminiChatModel(id string, methods []string) bool {
189 if !strings.HasPrefix(id, "gemini-") && !strings.HasPrefix(id, "gemma-") {
190 return false
191 }
192 if strings.Contains(id, "embedding") || strings.Contains(id, "aqa") {
193 return false
194 }
195 if len(methods) > 0 {
196 ok := false
197 for _, m := range methods {
198 if m == "generateContent" || m == "streamGenerateContent" {
199 ok = true
200 break
201 }
202 }
203 if !ok {
204 return false
205 }
206 }
207 return true
208}
209
210// looksLikeChatModel returns true for OpenAI ids that can plausibly be
211// used with the chat/completions endpoint. Errs on the side of inclusion.
212func looksLikeChatModel(id string) bool {
213 switch {
214 case strings.HasPrefix(id, "gpt-"):
215 return true
216 case strings.HasPrefix(id, "o1"):
217 return true
218 case strings.HasPrefix(id, "o3"):
219 return true
220 case strings.HasPrefix(id, "o4"):
221 return true
222 case strings.HasPrefix(id, "o5"):
223 return true
224 case strings.HasPrefix(id, "chatgpt-"):
225 return true
226 }
227 return false
228}
229
230const openrouterDefaultBaseURL = "https://openrouter.ai/api/v1"
231
232// DiscoverOpenRouter lists models from OpenRouter's public /models
233// endpoint (no auth). Per-token USD prices are converted to USD per 1M
234// tokens to match the rest of the catalog. baseURL defaults to the
235// public endpoint.
236func DiscoverOpenRouter(ctx context.Context, baseURL string) ([]Model, error) {
237 if baseURL == "" {
238 baseURL = openrouterDefaultBaseURL
239 }
240 baseURL = strings.TrimRight(baseURL, "/")
241 client := &http.Client{Timeout: 15 * time.Second}
242 url := baseURL + "/models"
243 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
244 if err != nil {
245 return nil, err
246 }
247 resp, err := client.Do(req)
248 if err != nil {
249 return nil, err
250 }
251 defer resp.Body.Close()
252 body, _ := io.ReadAll(resp.Body)
253 if resp.StatusCode != 200 {
254 return nil, fmt.Errorf("openrouter discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
255 }
256 var page struct {
257 Data []struct {
258 ID string `json:"id"`
259 Name string `json:"name"`
260 ContextLength int `json:"context_length"`
261 Pricing struct {
262 Prompt string `json:"prompt"`
263 Completion string `json:"completion"`
264 InputCacheRead string `json:"input_cache_read"`
265 InputCacheWrite string `json:"input_cache_write"`
266 } `json:"pricing"`
267 TopProvider struct {
268 ContextLength int `json:"context_length"`
269 MaxCompletionTokens *int `json:"max_completion_tokens"`
270 } `json:"top_provider"`
271 SupportedParameters []string `json:"supported_parameters"`
272 } `json:"data"`
273 }
274 if err := json.Unmarshal(body, &page); err != nil {
275 return nil, fmt.Errorf("openrouter discover parse: %w", err)
276 }
277 var out []Model
278 for _, d := range page.Data {
279 if d.ID == "" {
280 continue
281 }
282 display := d.Name
283 if display == "" {
284 display = d.ID
285 }
286 ctxWin := d.ContextLength
287 if d.TopProvider.ContextLength > 0 && (ctxWin == 0 || d.TopProvider.ContextLength < ctxWin) {
288 ctxWin = d.TopProvider.ContextLength
289 }
290 maxOut := 0
291 if d.TopProvider.MaxCompletionTokens != nil {
292 maxOut = *d.TopProvider.MaxCompletionTokens
293 }
294 out = append(out, Model{
295 Provider: "openrouter",
296 ID: d.ID,
297 DisplayName: display,
298 ContextWindow: ctxWin,
299 MaxOutput: maxOut,
300 Reasoning: openrouterSupportsReasoning(d.SupportedParameters),
301 PriceInput: perMillionTokens(d.Pricing.Prompt),
302 PriceOutput: perMillionTokens(d.Pricing.Completion),
303 PriceCacheRead: perMillionTokens(d.Pricing.InputCacheRead),
304 PriceCacheWrite: perMillionTokens(d.Pricing.InputCacheWrite),
305 BaseURL: baseURL,
306 Source: "live",
307 })
308 }
309 return out, nil
310}
311
312// openrouterSupportsReasoning reports whether OpenRouter's
313// supported_parameters list marks the model as reasoning-capable.
314func openrouterSupportsReasoning(params []string) bool {
315 for _, p := range params {
316 if p == "reasoning" || p == "include_reasoning" {
317 return true
318 }
319 }
320 return false
321}
322
323// perMillionTokens converts a per-token USD price string to USD per 1M
324// tokens. Empty or unparseable values become 0.
325func perMillionTokens(s string) float64 {
326 if s == "" {
327 return 0
328 }
329 v, err := strconv.ParseFloat(s, 64)
330 if err != nil {
331 return 0
332 }
333 return v * 1_000_000
334}