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 (
231 openrouterDefaultBaseURL = "https://openrouter.ai/api/v1"
232 gondolaDefaultBaseURL = "https://api.gondola-ai.com/v1"
233)
234
235// DiscoverOpenRouter lists models from OpenRouter's public /models
236// endpoint (no auth). Per-token USD prices are converted to USD per 1M
237// tokens to match the rest of the catalog. baseURL defaults to the
238// public endpoint.
239func DiscoverOpenRouter(ctx context.Context, baseURL string) ([]Model, error) {
240 if baseURL == "" {
241 baseURL = openrouterDefaultBaseURL
242 }
243 baseURL = strings.TrimRight(baseURL, "/")
244 client := &http.Client{Timeout: 15 * time.Second}
245 url := baseURL + "/models"
246 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
247 if err != nil {
248 return nil, err
249 }
250 resp, err := client.Do(req)
251 if err != nil {
252 return nil, err
253 }
254 defer resp.Body.Close()
255 body, _ := io.ReadAll(resp.Body)
256 if resp.StatusCode != 200 {
257 return nil, fmt.Errorf("openrouter discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
258 }
259 var page struct {
260 Data []struct {
261 ID string `json:"id"`
262 Name string `json:"name"`
263 ContextLength int `json:"context_length"`
264 Pricing struct {
265 Prompt string `json:"prompt"`
266 Completion string `json:"completion"`
267 InputCacheRead string `json:"input_cache_read"`
268 InputCacheWrite string `json:"input_cache_write"`
269 } `json:"pricing"`
270 TopProvider struct {
271 ContextLength int `json:"context_length"`
272 MaxCompletionTokens *int `json:"max_completion_tokens"`
273 } `json:"top_provider"`
274 SupportedParameters []string `json:"supported_parameters"`
275 } `json:"data"`
276 }
277 if err := json.Unmarshal(body, &page); err != nil {
278 return nil, fmt.Errorf("openrouter discover parse: %w", err)
279 }
280 var out []Model
281 for _, d := range page.Data {
282 if d.ID == "" {
283 continue
284 }
285 display := d.Name
286 if display == "" {
287 display = d.ID
288 }
289 ctxWin := d.ContextLength
290 if d.TopProvider.ContextLength > 0 && (ctxWin == 0 || d.TopProvider.ContextLength < ctxWin) {
291 ctxWin = d.TopProvider.ContextLength
292 }
293 maxOut := 0
294 if d.TopProvider.MaxCompletionTokens != nil {
295 maxOut = *d.TopProvider.MaxCompletionTokens
296 }
297 out = append(out, Model{
298 Provider: "openrouter",
299 ID: d.ID,
300 DisplayName: display,
301 ContextWindow: ctxWin,
302 MaxOutput: maxOut,
303 Reasoning: openrouterSupportsReasoning(d.SupportedParameters),
304 PriceInput: perMillionTokens(d.Pricing.Prompt),
305 PriceOutput: perMillionTokens(d.Pricing.Completion),
306 PriceCacheRead: perMillionTokens(d.Pricing.InputCacheRead),
307 PriceCacheWrite: perMillionTokens(d.Pricing.InputCacheWrite),
308 BaseURL: baseURL,
309 Source: "live",
310 })
311 }
312 return out, nil
313}
314
315// openrouterSupportsReasoning reports whether OpenRouter's
316// supported_parameters list marks the model as reasoning-capable.
317func openrouterSupportsReasoning(params []string) bool {
318 for _, p := range params {
319 if p == "reasoning" || p == "include_reasoning" {
320 return true
321 }
322 }
323 return false
324}
325
326// DiscoverGondola lists text models from Gondola's public catalog. Gondola
327// reports prices directly in USD per 1M tokens, matching Model's units.
328func DiscoverGondola(ctx context.Context, baseURL string) ([]Model, error) {
329 if baseURL == "" {
330 baseURL = gondolaDefaultBaseURL
331 }
332 baseURL = strings.TrimRight(baseURL, "/")
333 client := &http.Client{Timeout: 15 * time.Second}
334 req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/models", nil)
335 if err != nil {
336 return nil, err
337 }
338 resp, err := client.Do(req)
339 if err != nil {
340 return nil, err
341 }
342 defer resp.Body.Close()
343 body, _ := io.ReadAll(resp.Body)
344 if resp.StatusCode != http.StatusOK {
345 return nil, fmt.Errorf("gondola discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
346 }
347 var page struct {
348 Data []struct {
349 ID string `json:"id"`
350 DisplayName string `json:"display_name"`
351 Modality string `json:"modality"`
352 Endpoint string `json:"endpoint"`
353 ContextLength int `json:"context_length"`
354 MaxCompletionTokens int `json:"max_completion_tokens"`
355 SupportsReasoning bool `json:"supports_reasoning"`
356 Pricing struct {
357 Input float64 `json:"input_usd_per_mtok"`
358 Output float64 `json:"output_usd_per_mtok"`
359 CacheRead float64 `json:"cached_input_usd_per_mtok"`
360 CacheWrite float64 `json:"cache_write_usd_per_mtok"`
361 } `json:"pricing"`
362 } `json:"data"`
363 }
364 if err := json.Unmarshal(body, &page); err != nil {
365 return nil, fmt.Errorf("gondola discover parse: %w", err)
366 }
367 out := make([]Model, 0, len(page.Data))
368 for _, d := range page.Data {
369 if d.ID == "" || d.Modality != "text" || d.Endpoint != "/v1/chat/completions" {
370 continue
371 }
372 display := d.DisplayName
373 if display == "" {
374 display = d.ID
375 }
376 model := Model{ID: d.ID}
377 out = append(out, Model{
378 Provider: "gondola",
379 ID: d.ID,
380 DisplayName: display,
381 ContextWindow: d.ContextLength,
382 MaxOutput: d.MaxCompletionTokens,
383 Reasoning: d.SupportsReasoning,
384 AdaptiveThinking: usesAdaptiveThinking(model),
385 PriceInput: d.Pricing.Input,
386 PriceOutput: d.Pricing.Output,
387 PriceCacheRead: d.Pricing.CacheRead,
388 PriceCacheWrite: d.Pricing.CacheWrite,
389 BaseURL: baseURL,
390 Source: "live",
391 })
392 }
393 return out, nil
394}
395
396// perMillionTokens converts a per-token USD price string to USD per 1M
397// tokens. Empty or unparseable values become 0.
398func perMillionTokens(s string) float64 {
399 if s == "" {
400 return 0
401 }
402 v, err := strconv.ParseFloat(s, 64)
403 if err != nil {
404 return 0
405 }
406 return v * 1_000_000
407}