1package provider
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "strings"
10 "time"
11)
12
13// DiscoverAnthropic lists model ids visible to key on api.anthropic.com.
14// The API returns a paginated list; we page through until has_more is false.
15func DiscoverAnthropic(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
16 if baseURL == "" {
17 baseURL = anthropicDefaultBaseURL
18 }
19 client := &http.Client{Timeout: 15 * time.Second}
20 var out []Model
21 after := ""
22 for {
23 url := strings.TrimRight(baseURL, "/") + "/v1/models?limit=1000"
24 if after != "" {
25 url += "&after_id=" + after
26 }
27 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
28 if err != nil {
29 return nil, err
30 }
31 req.Header.Set("x-api-key", apiKey)
32 req.Header.Set("anthropic-version", anthropicAPIVersion)
33 resp, err := client.Do(req)
34 if err != nil {
35 return nil, err
36 }
37 body, _ := io.ReadAll(resp.Body)
38 resp.Body.Close()
39 if resp.StatusCode != 200 {
40 return nil, fmt.Errorf("anthropic discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
41 }
42 var page struct {
43 Data []struct {
44 ID string `json:"id"`
45 DisplayName string `json:"display_name"`
46 } `json:"data"`
47 HasMore bool `json:"has_more"`
48 LastID string `json:"last_id"`
49 }
50 if err := json.Unmarshal(body, &page); err != nil {
51 return nil, fmt.Errorf("anthropic discover parse: %w", err)
52 }
53 for _, d := range page.Data {
54 out = append(out, Model{
55 Provider: "anthropic",
56 ID: d.ID,
57 DisplayName: d.DisplayName,
58 Source: "live",
59 })
60 }
61 if !page.HasMore || page.LastID == "" {
62 break
63 }
64 after = page.LastID
65 }
66 return out, nil
67}
68
69// DiscoverOpenAI lists model ids visible to key on api.openai.com.
70func DiscoverOpenAI(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
71 if baseURL == "" {
72 baseURL = openaiDefaultBaseURL
73 }
74 client := &http.Client{Timeout: 15 * time.Second}
75 url := strings.TrimRight(baseURL, "/") + "/v1/models"
76 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
77 if err != nil {
78 return nil, err
79 }
80 req.Header.Set("authorization", "Bearer "+apiKey)
81 resp, err := client.Do(req)
82 if err != nil {
83 return nil, err
84 }
85 defer resp.Body.Close()
86 body, _ := io.ReadAll(resp.Body)
87 if resp.StatusCode != 200 {
88 return nil, fmt.Errorf("openai discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
89 }
90 var page struct {
91 Data []struct {
92 ID string `json:"id"`
93 } `json:"data"`
94 }
95 if err := json.Unmarshal(body, &page); err != nil {
96 return nil, fmt.Errorf("openai discover parse: %w", err)
97 }
98 var out []Model
99 for _, d := range page.Data {
100 // Keep only chat-capable families. OpenAI's /v1/models returns
101 // everything including embeddings, TTS, DALL-E, etc.
102 if !looksLikeChatModel(d.ID) {
103 continue
104 }
105 out = append(out, Model{
106 Provider: "openai",
107 ID: d.ID,
108 DisplayName: d.ID,
109 Source: "live",
110 })
111 }
112 return out, nil
113}
114
115// DiscoverGoogle lists Gemini model ids visible to key on
116// generativelanguage.googleapis.com. The API paginates with
117// nextPageToken; we follow it until exhausted.
118func DiscoverGoogle(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
119 if baseURL == "" {
120 baseURL = geminiDefaultBaseURL
121 }
122 client := &http.Client{Timeout: 15 * time.Second}
123 var out []Model
124 pageToken := ""
125 for {
126 url := strings.TrimRight(baseURL, "/") + "/v1beta/models?pageSize=200"
127 if pageToken != "" {
128 url += "&pageToken=" + pageToken
129 }
130 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
131 if err != nil {
132 return nil, err
133 }
134 req.Header.Set("x-goog-api-key", apiKey)
135 resp, err := client.Do(req)
136 if err != nil {
137 return nil, err
138 }
139 body, _ := io.ReadAll(resp.Body)
140 resp.Body.Close()
141 if resp.StatusCode != 200 {
142 return nil, fmt.Errorf("google discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
143 }
144 var page struct {
145 Models []struct {
146 Name string `json:"name"` // "models/gemini-2.5-pro"
147 DisplayName string `json:"displayName"`
148 SupportedGenerationMethods []string `json:"supportedGenerationMethods"`
149 InputTokenLimit int `json:"inputTokenLimit"`
150 OutputTokenLimit int `json:"outputTokenLimit"`
151 } `json:"models"`
152 NextPageToken string `json:"nextPageToken"`
153 }
154 if err := json.Unmarshal(body, &page); err != nil {
155 return nil, fmt.Errorf("google discover parse: %w", err)
156 }
157 for _, m := range page.Models {
158 // Strip the "models/" prefix Gemini uses on resource names.
159 id := strings.TrimPrefix(m.Name, "models/")
160 if !looksLikeGeminiChatModel(id, m.SupportedGenerationMethods) {
161 continue
162 }
163 display := m.DisplayName
164 if display == "" {
165 display = id
166 }
167 out = append(out, Model{
168 Provider: "google",
169 ID: id,
170 DisplayName: display,
171 ContextWindow: m.InputTokenLimit,
172 MaxOutput: m.OutputTokenLimit,
173 Source: "live",
174 })
175 }
176 if page.NextPageToken == "" {
177 break
178 }
179 pageToken = page.NextPageToken
180 }
181 return out, nil
182}
183
184// looksLikeGeminiChatModel filters Gemini's /v1beta/models output
185// down to entries usable with streamGenerateContent. The API also
186// lists embedding models, AQA, and other non-chat artefacts.
187func looksLikeGeminiChatModel(id string, methods []string) bool {
188 if !strings.HasPrefix(id, "gemini-") && !strings.HasPrefix(id, "gemma-") {
189 return false
190 }
191 if strings.Contains(id, "embedding") || strings.Contains(id, "aqa") {
192 return false
193 }
194 if len(methods) > 0 {
195 ok := false
196 for _, m := range methods {
197 if m == "generateContent" || m == "streamGenerateContent" {
198 ok = true
199 break
200 }
201 }
202 if !ok {
203 return false
204 }
205 }
206 return true
207}
208
209// looksLikeChatModel returns true for OpenAI ids that can plausibly be
210// used with the chat/completions endpoint. Errs on the side of inclusion.
211func looksLikeChatModel(id string) bool {
212 switch {
213 case strings.HasPrefix(id, "gpt-"):
214 return true
215 case strings.HasPrefix(id, "o1"):
216 return true
217 case strings.HasPrefix(id, "o3"):
218 return true
219 case strings.HasPrefix(id, "o4"):
220 return true
221 case strings.HasPrefix(id, "o5"):
222 return true
223 case strings.HasPrefix(id, "chatgpt-"):
224 return true
225 }
226 return false
227}