bingocode 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/bingo-win.cjs +34 -3
- package/package.json +2 -4
- package/src/cli/ProviderPanel.tsx +725 -418
- package/src/manager/CliMenuManager.tsx +30 -25
- package/src/manager/CliMenuUi.tsx +43 -15
- package/src/server/api/providers.ts +26 -0
- package/src/server/config/providerPresets.ts +93 -86
- package/src/server/config/providers.yaml +145 -41
- package/src/server/proxy/handler.ts +319 -206
- package/src/server/services/providerService.ts +94 -0
- package/src/server/types/provider.ts +19 -0
|
@@ -1,206 +1,319 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Proxy Handler — protocol-translating reverse proxy for OpenAI-compatible APIs.
|
|
3
|
-
*
|
|
4
|
-
* Receives Anthropic Messages API requests from the CLI, transforms them to
|
|
5
|
-
* OpenAI Chat Completions or Responses API format, forwards to the upstream
|
|
6
|
-
* provider, and transforms the response back to Anthropic format.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
if (process.env.
|
|
34
|
-
headers['x-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
return Response.json(
|
|
60
|
-
{
|
|
61
|
-
{ status:
|
|
62
|
-
)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Parse request body
|
|
66
|
-
let body: AnthropicRequest
|
|
67
|
-
try {
|
|
68
|
-
body = (await req.json()) as AnthropicRequest
|
|
69
|
-
} catch {
|
|
70
|
-
return Response.json(
|
|
71
|
-
{ type: 'error', error: { type: 'invalid_request_error', message: 'Invalid JSON in request body' } },
|
|
72
|
-
{ status: 400 },
|
|
73
|
-
)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const isStream = body.stream === true
|
|
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
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Proxy Handler — protocol-translating reverse proxy for OpenAI-compatible APIs.
|
|
3
|
+
*
|
|
4
|
+
* Receives Anthropic Messages API requests from the CLI, transforms them to
|
|
5
|
+
* OpenAI Chat Completions or Responses API format, forwards to the upstream
|
|
6
|
+
* provider, and transforms the response back to Anthropic format.
|
|
7
|
+
*
|
|
8
|
+
* Supports slot-based routing: each request's model field is mapped to a slot
|
|
9
|
+
* (main/haiku/sonnet/opus), and the corresponding configured provider is used.
|
|
10
|
+
*
|
|
11
|
+
* Derived from cc-switch (https://github.com/farion1231/cc-switch)
|
|
12
|
+
* Original work by Jason Young, MIT License
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { ProviderService } from '../services/providerService.js'
|
|
16
|
+
import { anthropicToOpenaiChat } from './transform/anthropicToOpenaiChat.js'
|
|
17
|
+
import { anthropicToOpenaiResponses } from './transform/anthropicToOpenaiResponses.js'
|
|
18
|
+
import { openaiChatToAnthropic } from './transform/openaiChatToAnthropic.js'
|
|
19
|
+
import { openaiResponsesToAnthropic } from './transform/openaiResponsesToAnthropic.js'
|
|
20
|
+
import { openaiChatStreamToAnthropic } from './streaming/openaiChatStreamToAnthropic.js'
|
|
21
|
+
import { openaiResponsesStreamToAnthropic } from './streaming/openaiResponsesStreamToAnthropic.js'
|
|
22
|
+
import type { AnthropicRequest } from './transform/types.js'
|
|
23
|
+
import type { SlotName } from '../types/provider.js'
|
|
24
|
+
|
|
25
|
+
const providerService = new ProviderService()
|
|
26
|
+
|
|
27
|
+
function buildUpstreamHeaders(apiKey: string): Record<string, string> {
|
|
28
|
+
const headers: Record<string, string> = {
|
|
29
|
+
'Content-Type': 'application/json',
|
|
30
|
+
Authorization: `Bearer ${apiKey}`,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (process.env.X_API_KEY) {
|
|
34
|
+
headers['x-api-key'] = process.env.X_API_KEY
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (process.env.APPLICATION_NAME) {
|
|
38
|
+
headers['x-application-name'] = process.env.APPLICATION_NAME
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return headers
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Identify which slot a model name belongs to.
|
|
46
|
+
* Claude Code sends model names like "claude-3-5-haiku-20241022".
|
|
47
|
+
*/
|
|
48
|
+
function identifySlot(modelName: string): SlotName {
|
|
49
|
+
const m = modelName.toLowerCase()
|
|
50
|
+
if (m.includes('haiku')) return 'haiku'
|
|
51
|
+
if (m.includes('sonnet')) return 'sonnet'
|
|
52
|
+
if (m.includes('opus')) return 'opus'
|
|
53
|
+
return 'main'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function handleProxyRequest(req: Request, url: URL): Promise<Response> {
|
|
57
|
+
// Only handle POST /proxy/v1/messages
|
|
58
|
+
if (req.method !== 'POST' || url.pathname !== '/proxy/v1/messages') {
|
|
59
|
+
return Response.json(
|
|
60
|
+
{ error: 'Not Found', message: 'Proxy only handles POST /proxy/v1/messages' },
|
|
61
|
+
{ status: 404 },
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Parse request body
|
|
66
|
+
let body: AnthropicRequest
|
|
67
|
+
try {
|
|
68
|
+
body = (await req.json()) as AnthropicRequest
|
|
69
|
+
} catch {
|
|
70
|
+
return Response.json(
|
|
71
|
+
{ type: 'error', error: { type: 'invalid_request_error', message: 'Invalid JSON in request body' } },
|
|
72
|
+
{ status: 400 },
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const isStream = body.stream === true
|
|
77
|
+
|
|
78
|
+
// --- Slot-based routing ---
|
|
79
|
+
const slot = identifySlot(body.model ?? '')
|
|
80
|
+
const slotConfig = await providerService.getProviderForSlot(slot)
|
|
81
|
+
|
|
82
|
+
if (slotConfig) {
|
|
83
|
+
// Use the slot's configured modelId instead of the original Claude model name
|
|
84
|
+
const proxiedBody: AnthropicRequest = { ...body, model: slotConfig.modelId }
|
|
85
|
+
const baseUrl = slotConfig.baseUrl.replace(/\/+$/, '')
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
if (slotConfig.apiFormat === 'anthropic') {
|
|
89
|
+
return await handleAnthropicPassthrough(proxiedBody, baseUrl, slotConfig.apiKey, isStream)
|
|
90
|
+
} else if (slotConfig.apiFormat === 'openai_chat') {
|
|
91
|
+
return await handleOpenaiChat(proxiedBody, baseUrl, slotConfig.apiKey, isStream)
|
|
92
|
+
} else {
|
|
93
|
+
return await handleOpenaiResponses(proxiedBody, baseUrl, slotConfig.apiKey, isStream)
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error(`[Proxy] Slot "${slot}" upstream request failed:`, err)
|
|
97
|
+
return Response.json(
|
|
98
|
+
{
|
|
99
|
+
type: 'error',
|
|
100
|
+
error: {
|
|
101
|
+
type: 'api_error',
|
|
102
|
+
message: err instanceof Error ? err.message : String(err),
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{ status: 502 },
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- Fallback: legacy single-activeId routing ---
|
|
111
|
+
const config = await providerService.getActiveProviderForProxy()
|
|
112
|
+
if (!config) {
|
|
113
|
+
return Response.json(
|
|
114
|
+
{
|
|
115
|
+
type: 'error',
|
|
116
|
+
error: {
|
|
117
|
+
type: 'invalid_request_error',
|
|
118
|
+
message: `No provider configured for slot "${slot}". Please configure slots in the Provider panel.`,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
{ status: 400 },
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (config.apiFormat === 'anthropic') {
|
|
126
|
+
return Response.json(
|
|
127
|
+
{ type: 'error', error: { type: 'invalid_request_error', message: 'Active provider uses anthropic format — proxy not needed' } },
|
|
128
|
+
{ status: 400 },
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const baseUrl = config.baseUrl.replace(/\/+$/, '')
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
if (config.apiFormat === 'openai_chat') {
|
|
136
|
+
return await handleOpenaiChat(body, baseUrl, config.apiKey, isStream)
|
|
137
|
+
} else {
|
|
138
|
+
return await handleOpenaiResponses(body, baseUrl, config.apiKey, isStream)
|
|
139
|
+
}
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.error('[Proxy] Upstream request failed:', err)
|
|
142
|
+
return Response.json(
|
|
143
|
+
{
|
|
144
|
+
type: 'error',
|
|
145
|
+
error: {
|
|
146
|
+
type: 'api_error',
|
|
147
|
+
message: err instanceof Error ? err.message : String(err),
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{ status: 502 },
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Pass through to an Anthropic-compatible upstream without format transformation.
|
|
157
|
+
* Used when the slot provider uses apiFormat 'anthropic'.
|
|
158
|
+
*/
|
|
159
|
+
async function handleAnthropicPassthrough(
|
|
160
|
+
body: AnthropicRequest,
|
|
161
|
+
baseUrl: string,
|
|
162
|
+
apiKey: string,
|
|
163
|
+
isStream: boolean,
|
|
164
|
+
): Promise<Response> {
|
|
165
|
+
const url = `${baseUrl}/v1/messages`
|
|
166
|
+
const upstream = await fetch(url, {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: {
|
|
169
|
+
'Content-Type': 'application/json',
|
|
170
|
+
'x-api-key': apiKey,
|
|
171
|
+
'anthropic-version': '2023-06-01',
|
|
172
|
+
},
|
|
173
|
+
body: JSON.stringify(body),
|
|
174
|
+
signal: isStream ? AbortSignal.timeout(30_000) : AbortSignal.timeout(300_000),
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
if (!upstream.ok) {
|
|
178
|
+
const errText = await upstream.text().catch(() => '')
|
|
179
|
+
return Response.json(
|
|
180
|
+
{
|
|
181
|
+
type: 'error',
|
|
182
|
+
error: {
|
|
183
|
+
type: 'api_error',
|
|
184
|
+
message: `Upstream returned HTTP ${upstream.status}: ${errText.slice(0, 500)}`,
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
{ status: upstream.status },
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (isStream) {
|
|
192
|
+
if (!upstream.body) {
|
|
193
|
+
return Response.json(
|
|
194
|
+
{ type: 'error', error: { type: 'api_error', message: 'Upstream returned no body for stream' } },
|
|
195
|
+
{ status: 502 },
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
// Pass through Anthropic SSE stream directly
|
|
199
|
+
return new Response(upstream.body, {
|
|
200
|
+
status: 200,
|
|
201
|
+
headers: {
|
|
202
|
+
'Content-Type': 'text/event-stream',
|
|
203
|
+
'Cache-Control': 'no-cache',
|
|
204
|
+
Connection: 'keep-alive',
|
|
205
|
+
},
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const responseBody = await upstream.json()
|
|
210
|
+
return Response.json(responseBody)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function handleOpenaiChat(
|
|
214
|
+
body: AnthropicRequest,
|
|
215
|
+
baseUrl: string,
|
|
216
|
+
apiKey: string,
|
|
217
|
+
isStream: boolean,
|
|
218
|
+
): Promise<Response> {
|
|
219
|
+
const transformed = anthropicToOpenaiChat(body)
|
|
220
|
+
const url = `${baseUrl}/v1/chat/completions`
|
|
221
|
+
|
|
222
|
+
const upstream = await fetch(url, {
|
|
223
|
+
method: 'POST',
|
|
224
|
+
headers: buildUpstreamHeaders(apiKey),
|
|
225
|
+
body: JSON.stringify(transformed),
|
|
226
|
+
signal: isStream ? AbortSignal.timeout(30_000) : AbortSignal.timeout(300_000),
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
if (!upstream.ok) {
|
|
230
|
+
const errText = await upstream.text().catch(() => '')
|
|
231
|
+
return Response.json(
|
|
232
|
+
{
|
|
233
|
+
type: 'error',
|
|
234
|
+
error: {
|
|
235
|
+
type: 'api_error',
|
|
236
|
+
message: `Upstream returned HTTP ${upstream.status}: ${errText.slice(0, 500)}`,
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
{ status: upstream.status },
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (isStream) {
|
|
244
|
+
if (!upstream.body) {
|
|
245
|
+
return Response.json(
|
|
246
|
+
{ type: 'error', error: { type: 'api_error', message: 'Upstream returned no body for stream' } },
|
|
247
|
+
{ status: 502 },
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
const anthropicStream = openaiChatStreamToAnthropic(upstream.body, body.model)
|
|
251
|
+
return new Response(anthropicStream, {
|
|
252
|
+
status: 200,
|
|
253
|
+
headers: {
|
|
254
|
+
'Content-Type': 'text/event-stream',
|
|
255
|
+
'Cache-Control': 'no-cache',
|
|
256
|
+
Connection: 'keep-alive',
|
|
257
|
+
},
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Non-streaming
|
|
262
|
+
const responseBody = await upstream.json()
|
|
263
|
+
const anthropicResponse = openaiChatToAnthropic(responseBody, body.model)
|
|
264
|
+
return Response.json(anthropicResponse)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function handleOpenaiResponses(
|
|
268
|
+
body: AnthropicRequest,
|
|
269
|
+
baseUrl: string,
|
|
270
|
+
apiKey: string,
|
|
271
|
+
isStream: boolean,
|
|
272
|
+
): Promise<Response> {
|
|
273
|
+
const transformed = anthropicToOpenaiResponses(body)
|
|
274
|
+
const url = `${baseUrl}/v1/responses`
|
|
275
|
+
|
|
276
|
+
const upstream = await fetch(url, {
|
|
277
|
+
method: 'POST',
|
|
278
|
+
headers: buildUpstreamHeaders(apiKey),
|
|
279
|
+
body: JSON.stringify(transformed),
|
|
280
|
+
signal: isStream ? AbortSignal.timeout(30_000) : AbortSignal.timeout(300_000),
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
if (!upstream.ok) {
|
|
284
|
+
const errText = await upstream.text().catch(() => '')
|
|
285
|
+
return Response.json(
|
|
286
|
+
{
|
|
287
|
+
type: 'error',
|
|
288
|
+
error: {
|
|
289
|
+
type: 'api_error',
|
|
290
|
+
message: `Upstream returned HTTP ${upstream.status}: ${errText.slice(0, 500)}`,
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
{ status: upstream.status },
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (isStream) {
|
|
298
|
+
if (!upstream.body) {
|
|
299
|
+
return Response.json(
|
|
300
|
+
{ type: 'error', error: { type: 'api_error', message: 'Upstream returned no body for stream' } },
|
|
301
|
+
{ status: 502 },
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
const anthropicStream = openaiResponsesStreamToAnthropic(upstream.body, body.model)
|
|
305
|
+
return new Response(anthropicStream, {
|
|
306
|
+
status: 200,
|
|
307
|
+
headers: {
|
|
308
|
+
'Content-Type': 'text/event-stream',
|
|
309
|
+
'Cache-Control': 'no-cache',
|
|
310
|
+
Connection: 'keep-alive',
|
|
311
|
+
},
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Non-streaming
|
|
316
|
+
const responseBody = await upstream.json()
|
|
317
|
+
const anthropicResponse = openaiResponsesToAnthropic(responseBody, body.model)
|
|
318
|
+
return Response.json(anthropicResponse)
|
|
319
|
+
}
|
|
@@ -24,7 +24,11 @@ import type {
|
|
|
24
24
|
ProviderTestResult,
|
|
25
25
|
ProviderTestStepResult,
|
|
26
26
|
ApiFormat,
|
|
27
|
+
SlotName,
|
|
28
|
+
SlotEntry,
|
|
29
|
+
SlotTable,
|
|
27
30
|
} from '../types/provider.ts'
|
|
31
|
+
import { SlotTableSchema } from '../types/provider.ts'
|
|
28
32
|
|
|
29
33
|
const MANAGED_ENV_KEYS = [
|
|
30
34
|
'ANTHROPIC_BASE_URL',
|
|
@@ -63,6 +67,10 @@ export class ProviderService {
|
|
|
63
67
|
return path.join(this.getCcHahaDir(), 'settings.json')
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
private getSlotsPath(): string {
|
|
71
|
+
return path.join(this.getCcHahaDir(), 'slots.json')
|
|
72
|
+
}
|
|
73
|
+
|
|
66
74
|
private async readIndex(): Promise<ProvidersIndex> {
|
|
67
75
|
try {
|
|
68
76
|
const raw = await fs.readFile(this.getIndexPath(), 'utf-8')
|
|
@@ -141,6 +149,7 @@ export class ProviderService {
|
|
|
141
149
|
apiFormat: input.apiFormat ?? 'anthropic',
|
|
142
150
|
models: input.models,
|
|
143
151
|
...(input.notes !== undefined && { notes: input.notes }),
|
|
152
|
+
...(input.extra !== undefined && { extra: input.extra }),
|
|
144
153
|
}
|
|
145
154
|
|
|
146
155
|
index.providers.push(provider)
|
|
@@ -162,6 +171,7 @@ export class ProviderService {
|
|
|
162
171
|
...(input.apiFormat !== undefined && { apiFormat: input.apiFormat }),
|
|
163
172
|
...(input.models !== undefined && { models: input.models }),
|
|
164
173
|
...(input.notes !== undefined && { notes: input.notes }),
|
|
174
|
+
...(input.extra !== undefined && { extra: input.extra }),
|
|
165
175
|
}
|
|
166
176
|
|
|
167
177
|
index.providers[idx] = updated
|
|
@@ -313,6 +323,90 @@ export class ProviderService {
|
|
|
313
323
|
}
|
|
314
324
|
}
|
|
315
325
|
|
|
326
|
+
// --- Slot routing ---
|
|
327
|
+
|
|
328
|
+
async readSlots(): Promise<SlotTable> {
|
|
329
|
+
try {
|
|
330
|
+
const raw = await fs.readFile(this.getSlotsPath(), 'utf-8')
|
|
331
|
+
return SlotTableSchema.parse(JSON.parse(raw))
|
|
332
|
+
} catch (err: unknown) {
|
|
333
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
334
|
+
return SlotTableSchema.parse({})
|
|
335
|
+
}
|
|
336
|
+
throw ApiError.internal(`Failed to read slots: ${err}`)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async writeSlots(slots: SlotTable): Promise<void> {
|
|
341
|
+
const filePath = this.getSlotsPath()
|
|
342
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
|
343
|
+
const tmp = `${filePath}.tmp.${Date.now()}`
|
|
344
|
+
try {
|
|
345
|
+
await fs.writeFile(tmp, JSON.stringify(slots, null, 2) + '\n', 'utf-8')
|
|
346
|
+
await fs.rename(tmp, filePath)
|
|
347
|
+
} catch (err) {
|
|
348
|
+
await fs.unlink(tmp).catch(() => {})
|
|
349
|
+
throw ApiError.internal(`Failed to write slots: ${err}`)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async setSlot(slot: SlotName, entry: SlotEntry): Promise<SlotTable> {
|
|
354
|
+
const slots = await this.readSlots()
|
|
355
|
+
slots[slot] = entry
|
|
356
|
+
await this.writeSlots(slots)
|
|
357
|
+
return slots
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async getProviderForSlot(slotName: SlotName): Promise<{
|
|
361
|
+
baseUrl: string
|
|
362
|
+
apiKey: string
|
|
363
|
+
apiFormat: ApiFormat
|
|
364
|
+
modelId: string
|
|
365
|
+
} | null> {
|
|
366
|
+
const slots = await this.readSlots()
|
|
367
|
+
const entry = slots[slotName]
|
|
368
|
+
if (!entry) return null
|
|
369
|
+
const index = await this.readIndex()
|
|
370
|
+
const provider = index.providers.find((p) => p.id === entry.providerId)
|
|
371
|
+
if (!provider) return null
|
|
372
|
+
return {
|
|
373
|
+
baseUrl: provider.baseUrl,
|
|
374
|
+
apiKey: provider.apiKey,
|
|
375
|
+
apiFormat: provider.apiFormat ?? 'anthropic',
|
|
376
|
+
modelId: entry.modelId,
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async fetchProviderModels(id: string): Promise<string[]> {
|
|
381
|
+
const provider = await this.getProvider(id)
|
|
382
|
+
const base = provider.baseUrl.replace(/\/+$/, '')
|
|
383
|
+
if (!base) return []
|
|
384
|
+
|
|
385
|
+
const url = `${base}/v1/models`
|
|
386
|
+
const headers: Record<string, string> = {
|
|
387
|
+
'Content-Type': 'application/json',
|
|
388
|
+
Authorization: `Bearer ${provider.apiKey}`,
|
|
389
|
+
}
|
|
390
|
+
// anthropic format uses x-api-key header style
|
|
391
|
+
if (provider.apiFormat === 'anthropic') {
|
|
392
|
+
headers['x-api-key'] = provider.apiKey
|
|
393
|
+
delete headers['Authorization']
|
|
394
|
+
headers['anthropic-version'] = '2023-06-01'
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(10000) })
|
|
399
|
+
if (!res.ok) {
|
|
400
|
+
return []
|
|
401
|
+
}
|
|
402
|
+
const data = await res.json() as { data?: { id: string }[]; models?: { id: string }[] }
|
|
403
|
+
const list = data.data ?? data.models ?? []
|
|
404
|
+
return list.map((m: { id: string }) => m.id).filter(Boolean)
|
|
405
|
+
} catch {
|
|
406
|
+
return []
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
316
410
|
// --- Test ---
|
|
317
411
|
|
|
318
412
|
async testProvider(
|