-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathkey-resolver.js
More file actions
416 lines (379 loc) · 18.4 KB
/
Copy pathkey-resolver.js
File metadata and controls
416 lines (379 loc) · 18.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
// Simple provider + key resolver for Anthropic/OpenAI style backends
// Language: Node.js ESM
const PROVIDERS = {
openrouter: 'openrouter',
openai: 'openai',
together: 'together',
deepseek: 'deepseek',
glm: 'glm',
anthropic: 'anthropic',
grok: 'grok',
kimi: 'kimi',
minimax: 'minimax',
custom: 'custom',
}
export const ENDPOINT_KIND = {
OPENAI_COMPATIBLE: 'openai-compatible',
ANTHROPIC_NATIVE: 'anthropic-native',
UNKNOWN: 'unknown', // Comment 1: Unknown state until negotiation completes
}
// Comment 1: In-memory cache for endpoint kind probe results
const endpointKindCache = new Map() // key: normalized baseUrl, value: { kind, timestamp, lastProbedAt }
const CACHE_TTL_MS = 3600000 // 1 hour
const PROVIDER_KEY_SOURCES = {
[PROVIDERS.custom]: ['CUSTOM_API_KEY', 'API_KEY'],
[PROVIDERS.openrouter]: ['OPENROUTER_API_KEY'],
[PROVIDERS.openai]: ['OPENAI_API_KEY'],
[PROVIDERS.together]: ['TOGETHER_API_KEY'],
[PROVIDERS.deepseek]: ['DEEPSEEK_API_KEY'],
[PROVIDERS.glm]: ['GLM_API_KEY', 'ZAI_API_KEY'], // Comment 2: Support both names for backward compatibility
[PROVIDERS.anthropic]: ['ANTHROPIC_API_KEY'],
[PROVIDERS.grok]: ['GROK_API_KEY', 'XAI_API_KEY'],
[PROVIDERS.kimi]: ['KIMI_API_KEY'],
[PROVIDERS.minimax]: ['MINIMAX_API_KEY', 'ANTHROPIC_AUTH_TOKEN'],
}
// Comment 4: Data-driven known-host registry for Anthropic-like endpoints
// Broaden heuristics with a static array of known Anthropic-like host substrings or regex patterns
const ANTHROPIC_LIKE_PATTERNS = [
{ host: 'anthropic.com' },
{ host: 'anthropic.ai' },
{ host: 'deepseek.com', path: 'anthropic' },
{ host: 'z.ai', path: 'anthropic' },
{ host: 'moonshot.cn', path: 'anthropic' }, // Comment 4: Known Anthropic-like provider
{ host: 'minimax.chat', path: 'anthropic' }, // Comment 4: Known Anthropic-like provider
{ host: 'kimi.com' }, // Comment 4: Kimi Code uses /coding/ path, match on host alone
{ path: '/anthropic' }, // Generic path pattern
]
/**
* Determine whether a base URL matches any known Anthropic-like host or path pattern.
*
* The function parses the provided URL and checks it against a data-driven list of
* Anthropic-like host and path patterns. If the URL cannot be parsed, the function
* returns `false`.
*
* @param {string} baseUrl - The base URL to test (e.g., "https://api.anthropic.com" or "https://example.com/anthropic").
* @returns {boolean} `true` if the URL matches any Anthropic-like host or path pattern, `false` otherwise.
*/
function isAnthropicLikeUrl(baseUrl) {
try {
const url = new URL(baseUrl)
const host = url.host.toLowerCase()
const path = url.pathname.toLowerCase()
// Comment 2: Check against data-driven patterns
for (const pattern of ANTHROPIC_LIKE_PATTERNS) {
if (pattern.host && !host.includes(pattern.host)) continue
if (pattern.path && !path.includes(pattern.path)) continue
if (!pattern.host && !pattern.path) continue
return true
}
return false
} catch {
return false
}
}
/**
* Determine which known provider best matches the given base URL.
*
* @param {string} baseUrl - The base URL or host to inspect.
* @returns {string} The matching value from `PROVIDERS` (for example `PROVIDERS.openrouter`, `PROVIDERS.openai`, `PROVIDERS.together`, `PROVIDERS.deepseek`, `PROVIDERS.glm`, `PROVIDERS.anthropic`, `PROVIDERS.grok`). Returns `PROVIDERS.custom` if no known provider is detected or if the URL cannot be parsed.
*/
export function detectProvider(baseUrl, env = process.env) {
const forced = (env.FORCE_PROVIDER || '').toLowerCase()
if (forced && Object.values(PROVIDERS).includes(forced)) {
return forced
}
try {
const url = new URL(baseUrl)
const host = url.host.toLowerCase()
const path = url.pathname.toLowerCase()
if (host.includes('openrouter.ai')) return PROVIDERS.openrouter
if (host.includes('api.openai.com')) return PROVIDERS.openai
if (host.includes('together.ai') || host.includes('together.xyz')) return PROVIDERS.together
if (host.includes('deepseek.com')) return PROVIDERS.deepseek
if (host.includes('z.ai')) return PROVIDERS.glm
if (host.includes('anthropic.com') || host.includes('anthropic.ai') || host.endsWith('.anthropic.app')) return PROVIDERS.anthropic
if (host.includes('x.ai') || host.includes('grok')) return PROVIDERS.grok
// Use endsWith/equality to avoid false positives from substring matching
// e.g., "evil-kimi.com.example.com" should NOT match kimi.com
if (host === 'kimi.com' || host === 'api.kimi.com' || host.endsWith('.kimi.com')) return PROVIDERS.kimi
if (host === 'minimax.io' || host === 'api.minimax.io' || host.endsWith('.minimax.io') || host === 'minimax.chat' || host.endsWith('.minimax.chat')) return PROVIDERS.minimax
if (/\/anthropic/.test(path)) return PROVIDERS.anthropic
return PROVIDERS.custom
} catch {
return PROVIDERS.custom
}
}
/**
* Determine whether a base URL exposes an Anthropic-native or OpenAI-compatible API and cache the result.
*
* Probes the service at the provided base URL (using the optional API key) with a short timeout and, if probing is inconclusive or fails, falls back to a heuristic. The determined endpoint kind is cached for a short period to avoid repeated probing.
*
* @param {string} baseUrl - The base URL of the endpoint to probe (may include or omit trailing slash).
* @param {string|null} key - Optional API key used during the probe; may be null for anonymous probes.
* @returns {{ kind: symbol, lastProbedAt: number }} An object containing `kind` (one of `ENDPOINT_KIND` values) and `lastProbedAt` (epoch milliseconds when the probe completed or when the cached value was set).
*/
export async function negotiateEndpointKind(baseUrl, key) {
const normalizedUrl = baseUrl.replace(/\/+$/, '')
const now = Date.now()
// Check cache first
const cached = endpointKindCache.get(normalizedUrl)
if (cached && now - cached.timestamp < CACHE_TTL_MS) {
return { kind: cached.kind, lastProbedAt: cached.lastProbedAt }
}
const probeUrl = `${normalizedUrl}/v1/models`
// Comment 1: Try Anthropic-native first with short timeout (1-2s), no retries
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 1500) // 1.5 second timeout
const resp = await fetch(probeUrl, {
method: 'GET',
headers: {
'x-api-key': key || 'test-key',
'anthropic-version': '2023-06-01',
},
signal: controller.signal,
})
clearTimeout(timeoutId)
// Comment 1: Only treat 200 OK as definitive OpenAI-compatible
// Other responses (400/404/401/403) are inconclusive - inspect response body
if (resp.ok) {
// 200 OK with Anthropic headers confirms Anthropic-native
const kind = ENDPOINT_KIND.ANTHROPIC_NATIVE
endpointKindCache.set(normalizedUrl, { kind, timestamp: now, lastProbedAt: now })
return { kind, lastProbedAt: now }
}
// Non-OK responses are inconclusive - try OpenAI probe with response body inspection
if (resp.status === 400 || resp.status === 404 || resp.status === 401 || resp.status === 403) {
try {
const openaiController = new AbortController()
const openaiTimeoutId = setTimeout(() => openaiController.abort(), 1500)
const openaiResp = await fetch(probeUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${key || 'test-key'}`,
},
signal: openaiController.signal,
})
clearTimeout(openaiTimeoutId)
// Only treat 200 OK as definitive OpenAI-compatible
if (openaiResp.ok) {
const kind = ENDPOINT_KIND.OPENAI_COMPATIBLE
endpointKindCache.set(normalizedUrl, { kind, timestamp: now, lastProbedAt: now })
return { kind, lastProbedAt: now }
}
// 401/403 are inconclusive - inspect response body for provider signatures
if (openaiResp.status === 401 || openaiResp.status === 403) {
try {
const body = await openaiResp.text()
// OpenAI error shape: { "error": { "message": ..., "type": ..., "code": ... } }
// Anthropic error shape: { "error": { "type": "error", "message": ... } } or { "type": "error", "error": { ... } }
if (body.includes('"data"') || body.includes('"type":"invalid_request_error"') || body.includes('"code":')) {
// OpenAI signature detected
const kind = ENDPOINT_KIND.OPENAI_COMPATIBLE
endpointKindCache.set(normalizedUrl, { kind, timestamp: now, lastProbedAt: now })
return { kind, lastProbedAt: now }
}
if (body.includes('"type":"error"') || body.includes('anthropic')) {
// Anthropic signature detected
const kind = ENDPOINT_KIND.ANTHROPIC_NATIVE
endpointKindCache.set(normalizedUrl, { kind, timestamp: now, lastProbedAt: now })
return { kind, lastProbedAt: now }
}
} catch (bodyErr) {
console.warn(`[negotiateEndpointKind] Could not parse response body:`, bodyErr.message)
}
}
// Inconclusive - fall back to existing heuristics or cached value
const cached = endpointKindCache.get(normalizedUrl)
if (cached && cached.kind) {
console.warn(`[negotiateEndpointKind] Probe inconclusive, using cached value: ${cached.kind}`)
return { kind: cached.kind, lastProbedAt: now }
}
// Default fallback: assume Anthropic-native (safer for the proxy's primary use case)
console.warn(`[negotiateEndpointKind] Probe inconclusive, defaulting to Anthropic-native`)
const kind = ENDPOINT_KIND.ANTHROPIC_NATIVE
endpointKindCache.set(normalizedUrl, { kind, timestamp: now, lastProbedAt: now })
return { kind, lastProbedAt: now }
} catch (openaiErr) {
// OpenAI probe failed (network/timeout) - mark as inconclusive and use fallback
console.warn(`[negotiateEndpointKind] OpenAI probe failed:`, openaiErr.message)
const cached = endpointKindCache.get(normalizedUrl)
if (cached && cached.kind) {
console.warn(`[negotiateEndpointKind] Using cached value after probe failure: ${cached.kind}`)
return { kind: cached.kind, lastProbedAt: now }
}
// Default fallback
const kind = ENDPOINT_KIND.ANTHROPIC_NATIVE
endpointKindCache.set(normalizedUrl, { kind, timestamp: now, lastProbedAt: now })
return { kind, lastProbedAt: now }
}
} else if (resp.ok) {
// This block shouldn't be reached since we handle resp.ok above, but keep for safety
const kind = ENDPOINT_KIND.ANTHROPIC_NATIVE
endpointKindCache.set(normalizedUrl, { kind, timestamp: now, lastProbedAt: now })
return { kind, lastProbedAt: now }
}
} catch (err) {
// Network error or timeout - fall back to heuristic
console.warn(`[negotiateEndpointKind] Probe failed for ${normalizedUrl}:`, err.message)
}
// Fallback to heuristic detection
const kind = isAnthropicLikeUrl(baseUrl)
? ENDPOINT_KIND.ANTHROPIC_NATIVE
: ENDPOINT_KIND.OPENAI_COMPATIBLE
endpointKindCache.set(normalizedUrl, { kind, timestamp: now, lastProbedAt: now })
return { kind, lastProbedAt: now }
}
/**
* Infer the endpoint kind for a provider and base URL using explicit overrides, heuristics, or an endpoint probe.
*
* @param {string} provider - Provider identifier (one of PROVIDERS).
* @param {string} baseUrl - The provider base URL to inspect.
* @param {Object<string,string>} [overrides={}] - Optional map of normalized base URLs to override values ('anthropic' | 'anthropic-native' | 'openai' | 'openai-compatible').
* @param {string|null} [key=null] - Optional API key used to probe custom endpoints; if omitted, probing is skipped.
* @returns {{ kind: number, source: 'override'|'heuristic'|'probe', lastProbedAt?: number }}
* An object describing the inferred endpoint kind:
* - `kind`: one of ENDPOINT_KIND (ANThROPIC_NATIVE, OPENAI_COMPATIBLE, or UNKNOWN).
* - `source`: indicates whether the result came from an 'override', a 'heuristic', or a 'probe'.
* - `lastProbedAt`: provided when the result originates from a successful probe.
* When a probe for a custom provider fails, returns `kind: ENDPOINT_KIND.UNKNOWN` with `source: 'probe'`.
*/
export async function inferEndpointKind(provider, baseUrl, overrides = {}, key = null) {
// Check for explicit override first (from env JSON or extension settings)
const normalizedUrl = baseUrl.replace(/\/+$/, '')
if (overrides[normalizedUrl]) {
const override = overrides[normalizedUrl].toLowerCase()
if (override === 'anthropic' || override === 'anthropic-native') {
return { kind: ENDPOINT_KIND.ANTHROPIC_NATIVE, source: 'override' }
}
if (override === 'openai' || override === 'openai-compatible') {
return { kind: ENDPOINT_KIND.OPENAI_COMPATIBLE, source: 'override' }
}
}
// Fall back to automatic detection
if (provider === PROVIDERS.deepseek || provider === PROVIDERS.glm || provider === PROVIDERS.anthropic || provider === PROVIDERS.kimi || provider === PROVIDERS.minimax) {
return { kind: ENDPOINT_KIND.ANTHROPIC_NATIVE, source: 'heuristic' }
}
if (isAnthropicLikeUrl(baseUrl)) {
return { kind: ENDPOINT_KIND.ANTHROPIC_NATIVE, source: 'heuristic' }
}
// Comment 1: For custom providers without override, probe the endpoint
if (provider === PROVIDERS.custom && key) {
try {
const result = await negotiateEndpointKind(baseUrl, key)
return { kind: result.kind, source: 'probe', lastProbedAt: result.lastProbedAt }
} catch (err) {
console.warn(`[inferEndpointKind] Probe failed, using heuristic:`, err.message)
// Return unknown so request is gated until negotiation completes
return { kind: ENDPOINT_KIND.UNKNOWN, source: 'probe' }
}
}
return { kind: ENDPOINT_KIND.OPENAI_COMPATIBLE, source: 'heuristic' }
}
/**
* Infer the endpoint kind synchronously from provider, base URL, and explicit overrides without performing network probes.
*
* Checks an exact URL override first; if none applies it uses provider hints and Anthropic-like URL heuristics to decide.
* @param {string} provider - Provider identifier (one of the values in `PROVIDERS`).
* @param {string} baseUrl - Base URL of the endpoint being classified.
* @param {Object.<string,string>} [overrides={}] - Optional mapping of normalized base URLs to override kinds (e.g., `"anthropic"`, `"openai"`).
* @returns {number} `ENDPOINT_KIND.ANTHROPIC_NATIVE` when the override, provider, or URL heuristics indicate Anthropic-native; otherwise `ENDPOINT_KIND.OPENAI_COMPATIBLE`.
*/
export function inferEndpointKindSync(provider, baseUrl, overrides = {}) {
const normalizedUrl = baseUrl.replace(/\/+$/, '')
if (overrides[normalizedUrl]) {
const override = overrides[normalizedUrl].toLowerCase()
if (override === 'anthropic' || override === 'anthropic-native') {
return ENDPOINT_KIND.ANTHROPIC_NATIVE
}
if (override === 'openai' || override === 'openai-compatible') {
return ENDPOINT_KIND.OPENAI_COMPATIBLE
}
}
if (provider === PROVIDERS.deepseek || provider === PROVIDERS.glm || provider === PROVIDERS.anthropic || provider === PROVIDERS.kimi || provider === PROVIDERS.minimax) {
return ENDPOINT_KIND.ANTHROPIC_NATIVE
}
if (isAnthropicLikeUrl(baseUrl)) {
return ENDPOINT_KIND.ANTHROPIC_NATIVE
}
return ENDPOINT_KIND.OPENAI_COMPATIBLE
}
/**
* Determine the API key for a provider by checking configured environment variable names in priority order.
*
* @param {string} provider - Provider identifier (one of the values from PROVIDERS).
* @param {Object} [env=process.env] - Environment-like object to read variables from.
* @returns {{key: string|null, source: string|null}} The first found key and the environment variable name it came from, or `{ key: null, source: null }` if none was found.
*/
export function resolveApiKey(provider, env = process.env) {
const sources = PROVIDER_KEY_SOURCES[provider] || []
for (const name of sources) {
if (env[name]) {
return { key: env[name], source: name }
}
}
return { key: null, source: null }
}
export function providerSpecificHeaders(provider, env = process.env) {
const headers = {}
if (provider === PROVIDERS.openrouter) {
// OpenRouter appreciates HTTP-Referer and X-Title
const referer = env.OPENROUTER_SITE_URL || env.HTTP_REFERER || env.APP_URL
const title = env.OPENROUTER_APP_TITLE || env.APP_NAME || 'anthropic-proxy'
if (referer) headers['HTTP-Referer'] = referer
if (title) headers['X-Title'] = title
}
return headers
}
/**
* Resolve the API key for an explicit provider ID.
* Unlike resolveApiKey(), this accepts the key directly (for mixed-provider mode
* where keys are passed via MIXED_PROVIDERS_CONFIG, not env vars).
*
* @param {string} providerId - Provider identifier
* @param {string|null} explicitKey - Key passed directly (takes priority)
* @param {Object} [env=process.env] - Fallback environment
* @returns {{key: string|null, source: string|null}}
*/
export function resolveApiKeyForProvider(providerId, explicitKey = null, env = process.env) {
if (explicitKey) {
return { key: explicitKey, source: 'mixed-providers-config' }
}
return resolveApiKey(providerId, env)
}
/**
* Build upstream headers from a ProviderContext-like object.
* Accepts an explicit context instead of reading from global state.
*
* @param {object} ctx - Provider context with { providerId, endpointKind, key }
* @param {string} ctx.providerId - Provider identifier
* @param {string} ctx.endpointKind - ENDPOINT_KIND value
* @param {string|null} ctx.key - API key
* @param {Object} [env=process.env] - Environment for provider-specific headers
* @returns {Object<string,string>} Headers object
*/
export function buildUpstreamHeadersForContext(ctx, env = process.env) {
const headers = {
'Content-Type': 'application/json',
...providerSpecificHeaders(ctx.providerId, env),
}
if (!ctx.key) {
return headers
}
const kind = String(ctx.endpointKind || '').toLowerCase()
const isAnthropicNative =
kind === 'anthropic' ||
kind === 'anthropic-native' ||
kind === ENDPOINT_KIND.ANTHROPIC_NATIVE
if (isAnthropicNative) {
headers['x-api-key'] = ctx.key
headers['anthropic-version'] = env.ANTHROPIC_VERSION || '2023-06-01'
if (env.ANTHROPIC_BETA) {
headers['anthropic-beta'] = env.ANTHROPIC_BETA
}
} else {
headers['Authorization'] = `Bearer ${ctx.key}`
}
return headers
}
export const PROVIDER = PROVIDERS