1package auth
2
3import (
4 "context"
5 "fmt"
6 "net/http"
7 "os"
8 "strings"
9 "time"
10)
11
12// ProbeAPIKey verifies that key is valid for provider by making a
13// lightweight authenticated request. Returns nil on success.
14func ProbeAPIKey(ctx context.Context, provider, key string) error {
15 if key == "" {
16 return fmt.Errorf("empty key")
17 }
18 c := &http.Client{Timeout: 15 * time.Second}
19 var req *http.Request
20 var err error
21
22 switch provider {
23 case "anthropic":
24 req, err = http.NewRequestWithContext(ctx, "GET", "https://api.anthropic.com/v1/models", nil)
25 if err != nil {
26 return err
27 }
28 req.Header.Set("x-api-key", key)
29 req.Header.Set("anthropic-version", "2023-06-01")
30 case "openai":
31 req, err = http.NewRequestWithContext(ctx, "GET", "https://api.openai.com/v1/models", nil)
32 if err != nil {
33 return err
34 }
35 req.Header.Set("authorization", "Bearer "+key)
36 case "kimi":
37 req, err = http.NewRequestWithContext(ctx, "GET", "https://api.kimi.com/coding/v1/models", nil)
38 if err != nil {
39 return err
40 }
41 req.Header.Set("authorization", "Bearer "+key)
42 case "deepseek":
43 req, err = http.NewRequestWithContext(ctx, "GET", "https://api.deepseek.com/v1/models", nil)
44 if err != nil {
45 return err
46 }
47 req.Header.Set("authorization", "Bearer "+key)
48 case "ollama":
49 // GET /v1/models is public on ollama.com (200 without a key),
50 // so it cannot validate one. POST /v1/chat/completions is
51 // auth-gated: 401/403 means the key was rejected. The empty
52 // body is enough — auth runs before request validation.
53 req, err = http.NewRequestWithContext(ctx, "POST", "https://ollama.com/v1/chat/completions", strings.NewReader("{}"))
54 if err != nil {
55 return err
56 }
57 req.Header.Set("authorization", "Bearer "+key)
58 req.Header.Set("content-type", "application/json")
59 case "google":
60 // Google Generative Language: list models with the API key.
61 // Accepts the key via x-goog-api-key header (preferred over
62 // the ?key= query param so it doesn't show up in proxy logs).
63 req, err = http.NewRequestWithContext(ctx, "GET", "https://generativelanguage.googleapis.com/v1beta/models", nil)
64 if err != nil {
65 return err
66 }
67 req.Header.Set("x-goog-api-key", key)
68 // OpenAI-compatible third parties: a GET /v1/models with bearer auth
69 // is enough to validate the key. Branches kept explicit (rather than a
70 // generic default) so the URL list is searchable and reviewable.
71 case "moonshotai":
72 req, err = http.NewRequestWithContext(ctx, "GET", "https://api.moonshot.ai/v1/models", nil)
73 if err != nil {
74 return err
75 }
76 req.Header.Set("authorization", "Bearer "+key)
77 case "moonshotai-cn":
78 req, err = http.NewRequestWithContext(ctx, "GET", "https://api.moonshot.cn/v1/models", nil)
79 if err != nil {
80 return err
81 }
82 req.Header.Set("authorization", "Bearer "+key)
83 case "groq":
84 req, err = http.NewRequestWithContext(ctx, "GET", "https://api.groq.com/openai/v1/models", nil)
85 if err != nil {
86 return err
87 }
88 req.Header.Set("authorization", "Bearer "+key)
89 case "cerebras":
90 req, err = http.NewRequestWithContext(ctx, "GET", "https://api.cerebras.ai/v1/models", nil)
91 if err != nil {
92 return err
93 }
94 req.Header.Set("authorization", "Bearer "+key)
95 case "xai":
96 req, err = http.NewRequestWithContext(ctx, "GET", "https://api.x.ai/v1/models", nil)
97 if err != nil {
98 return err
99 }
100 req.Header.Set("authorization", "Bearer "+key)
101 case "together":
102 req, err = http.NewRequestWithContext(ctx, "GET", "https://api.together.ai/v1/models", nil)
103 if err != nil {
104 return err
105 }
106 req.Header.Set("authorization", "Bearer "+key)
107 case "openrouter":
108 req, err = http.NewRequestWithContext(ctx, "GET", "https://openrouter.ai/api/v1/models", nil)
109 if err != nil {
110 return err
111 }
112 req.Header.Set("authorization", "Bearer "+key)
113 case "huggingface":
114 req, err = http.NewRequestWithContext(ctx, "GET", "https://router.huggingface.co/v1/models", nil)
115 if err != nil {
116 return err
117 }
118 req.Header.Set("authorization", "Bearer "+key)
119 case "zai":
120 req, err = http.NewRequestWithContext(ctx, "GET", "https://api.z.ai/api/coding/paas/v4/models", nil)
121 if err != nil {
122 return err
123 }
124 req.Header.Set("authorization", "Bearer "+key)
125 case "mistral":
126 req, err = http.NewRequestWithContext(ctx, "GET", "https://api.mistral.ai/v1/models", nil)
127 if err != nil {
128 return err
129 }
130 req.Header.Set("authorization", "Bearer "+key)
131 case "xiaomi":
132 req, err = http.NewRequestWithContext(ctx, "GET", "https://api.xiaomimimo.com/v1/models", nil)
133 if err != nil {
134 return err
135 }
136 req.Header.Set("authorization", "Bearer "+key)
137 case "xiaomi-token-plan-ams":
138 req, err = http.NewRequestWithContext(ctx, "GET", "https://token-plan-ams.xiaomimimo.com/v1/models", nil)
139 if err != nil {
140 return err
141 }
142 req.Header.Set("authorization", "Bearer "+key)
143 case "xiaomi-token-plan-cn":
144 req, err = http.NewRequestWithContext(ctx, "GET", "https://token-plan-cn.xiaomimimo.com/v1/models", nil)
145 if err != nil {
146 return err
147 }
148 req.Header.Set("authorization", "Bearer "+key)
149 case "xiaomi-token-plan-sgp":
150 req, err = http.NewRequestWithContext(ctx, "GET", "https://token-plan-sgp.xiaomimimo.com/v1/models", nil)
151 if err != nil {
152 return err
153 }
154 req.Header.Set("authorization", "Bearer "+key)
155 case "minimax":
156 req, err = http.NewRequestWithContext(ctx, "GET", "https://api.minimax.io/v1/models", nil)
157 if err != nil {
158 return err
159 }
160 req.Header.Set("authorization", "Bearer "+key)
161 case "minimax-cn":
162 req, err = http.NewRequestWithContext(ctx, "GET", "https://api.minimaxi.com/v1/models", nil)
163 if err != nil {
164 return err
165 }
166 req.Header.Set("authorization", "Bearer "+key)
167 case "fireworks":
168 req, err = http.NewRequestWithContext(ctx, "GET", "https://api.fireworks.ai/inference/v1/models", nil)
169 if err != nil {
170 return err
171 }
172 req.Header.Set("authorization", "Bearer "+key)
173 case "vercel-ai-gateway":
174 req, err = http.NewRequestWithContext(ctx, "GET", "https://ai-gateway.vercel.sh/v1/models", nil)
175 if err != nil {
176 return err
177 }
178 req.Header.Set("authorization", "Bearer "+key)
179 case "opencode":
180 req, err = http.NewRequestWithContext(ctx, "GET", "https://opencode.ai/zen/v1/models", nil)
181 if err != nil {
182 return err
183 }
184 req.Header.Set("authorization", "Bearer "+key)
185 case "opencode-go":
186 req, err = http.NewRequestWithContext(ctx, "GET", "https://opencode.ai/zen/go/v1/models", nil)
187 if err != nil {
188 return err
189 }
190 req.Header.Set("authorization", "Bearer "+key)
191 case "azure-openai-responses":
192 return nil
193 case "amazon-bedrock":
194 return nil
195 case "google-vertex":
196 return nil
197 case "cloudflare-workers-ai", "cloudflare-ai-gateway":
198 return nil
199 case "github-copilot":
200 return nil
201 default:
202 // Custom providers are registered with the auth package at startup
203 // from models.json. Some self-hosted or enterprise gateways do not
204 // expose a model-list endpoint, so accept and store the key without
205 // probing just like Bedrock/Azure.
206 if isExtraAPIKeyProvider(provider) {
207 return nil
208 }
209 return fmt.Errorf("unknown provider %q", provider)
210 }
211
212 if strings.Contains(req.URL.String(), "{CLOUDFLARE_ACCOUNT_ID}") {
213 if acct := os.Getenv("CLOUDFLARE_ACCOUNT_ID"); acct != "" {
214 u := strings.ReplaceAll(req.URL.String(), "{CLOUDFLARE_ACCOUNT_ID}", acct)
215 req.URL, _ = req.URL.Parse(u)
216 }
217 }
218 resp, err := c.Do(req)
219 if err != nil {
220 return fmt.Errorf("probe %s: %w", provider, err)
221 }
222 defer resp.Body.Close()
223 if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
224 return fmt.Errorf("%s rejected the key (http %d)", provider, resp.StatusCode)
225 }
226 if provider == "ollama" && resp.StatusCode == http.StatusBadRequest {
227 // Chat-completions probe with an empty body: auth passed,
228 // request validation failed. Key is valid.
229 return nil
230 }
231 if resp.StatusCode >= 400 {
232 return fmt.Errorf("%s http %d", provider, resp.StatusCode)
233 }
234 return nil
235}