bingocode 1.0.20 → 1.0.22

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.
@@ -1,302 +1,289 @@
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 { anthropicStreamLabeler } from './streaming/anthropicStreamLabeler.js'
23
- import type { AnthropicRequest } from './transform/types.js'
24
- import type { SlotName } from '../types/provider.js'
25
-
26
- const providerService = new ProviderService()
27
-
28
- function buildUpstreamHeaders(apiKey: string): Record<string, string> {
29
- const headers: Record<string, string> = {
30
- 'Content-Type': 'application/json',
31
- Authorization: `Bearer ${apiKey}`,
32
- }
33
-
34
- if (process.env.X_API_KEY) {
35
- headers['x-api-key'] = process.env.X_API_KEY
36
- }
37
-
38
- if (process.env.APPLICATION_NAME) {
39
- headers['x-application-name'] = process.env.APPLICATION_NAME
40
- }
41
-
42
- return headers
43
- }
44
-
45
- /**
46
- * Identify which slot a model name belongs to.
47
- * Claude Code sends model names like "claude-3-5-haiku-20241022".
48
- */
49
- function identifySlot(modelName: string): SlotName {
50
- const m = modelName.toLowerCase()
51
- if (m.includes('haiku')) return 'haiku'
52
- if (m.includes('sonnet')) return 'sonnet'
53
- if (m.includes('opus')) return 'opus'
54
- return 'main'
55
- }
56
-
57
- export async function handleProxyRequest(req: Request, url: URL): Promise<Response> {
58
- // Only handle POST /proxy/v1/messages
59
- if (req.method !== 'POST' || url.pathname !== '/proxy/v1/messages') {
60
- return Response.json(
61
- { error: 'Not Found', message: 'Proxy only handles POST /proxy/v1/messages' },
62
- { status: 404 },
63
- )
64
- }
65
-
66
- // Parse request body
67
- let body: AnthropicRequest
68
- try {
69
- body = (await req.json()) as AnthropicRequest
70
- } catch {
71
- return Response.json(
72
- { type: 'error', error: { type: 'invalid_request_error', message: 'Invalid JSON in request body' } },
73
- { status: 400 },
74
- )
75
- }
76
-
77
- const isStream = body.stream === true
78
-
79
- // --- Slot-based routing ---
80
- const slot = identifySlot(body.model ?? '')
81
- const slotConfig = await providerService.getProviderForSlot(slot)
82
-
83
- if (slotConfig) {
84
- // Use the slot's configured modelId instead of the original Claude model name
85
- const proxiedBody: AnthropicRequest = { ...body, model: slotConfig.modelId }
86
- const baseUrl = slotConfig.baseUrl.replace(/\/+$/, '')
87
- const uiLabel = slotConfig.label || null
88
-
89
- try {
90
- if (slotConfig.apiFormat === 'anthropic') {
91
- return await handleAnthropicPassthrough(proxiedBody, baseUrl, slotConfig.apiKey, isStream, uiLabel)
92
- } else if (slotConfig.apiFormat === 'openai_chat') {
93
- return await handleOpenaiChat(proxiedBody, baseUrl, slotConfig.apiKey, isStream, uiLabel)
94
- } else {
95
- return await handleOpenaiResponses(proxiedBody, baseUrl, slotConfig.apiKey, isStream, uiLabel)
96
- }
97
- } catch (err) {
98
- console.error(`[Proxy] Slot "${slot}" upstream request failed:`, err)
99
- return Response.json(
100
- {
101
- type: 'error',
102
- error: {
103
- type: 'api_error',
104
- message: err instanceof Error ? err.message : String(err),
105
- },
106
- },
107
- { status: 502 },
108
- )
109
- }
110
- }
111
-
112
- // --- Fallback: legacy single-activeId routing ---
113
- const config = await providerService.getActiveProviderForProxy()
114
- if (!config) {
115
- return Response.json(
116
- {
117
- type: 'error',
118
- error: {
119
- type: 'invalid_request_error',
120
- message: `No provider configured for slot "${slot}". Please configure slots in the Provider panel.`,
121
- },
122
- },
123
- { status: 400 },
124
- )
125
- }
126
-
127
- if (config.apiFormat === 'anthropic') {
128
- return Response.json(
129
- { type: 'error', error: { type: 'invalid_request_error', message: 'Active provider uses anthropic format — proxy not needed' } },
130
- { status: 400 },
131
- )
132
- }
133
-
134
- const baseUrl = config.baseUrl.replace(/\/+$/, '')
135
-
136
- try {
137
- if (config.apiFormat === 'openai_chat') {
138
- return await handleOpenaiChat(body, baseUrl, config.apiKey, isStream)
139
- } else {
140
- return await handleOpenaiResponses(body, baseUrl, config.apiKey, isStream)
141
- }
142
- } catch (err) {
143
- console.error('[Proxy] Upstream request failed:', err)
144
- return Response.json(
145
- {
146
- type: 'error',
147
- error: {
148
- type: 'api_error',
149
- message: err instanceof Error ? err.message : String(err),
150
- },
151
- },
152
- { status: 502 },
153
- )
154
- }
155
- }
156
-
157
- /**
158
- * Pass through to an Anthropic-compatible upstream without format transformation.
159
- * Used when the slot provider uses apiFormat 'anthropic'.
160
- */
161
- async function handleAnthropicPassthrough(
162
- body: AnthropicRequest,
163
- baseUrl: string,
164
- apiKey: string,
165
- isStream: boolean,
166
- uiLabel: string | null = null,
167
- ): Promise<Response> {
168
- const url = `${baseUrl}/v1/messages`
169
- const upstream = await fetch(url, {
170
- method: 'POST',
171
- headers: {
172
- 'Content-Type': 'application/json',
173
- 'x-api-key': apiKey,
174
- 'anthropic-version': '2023-06-01',
175
- },
176
- body: JSON.stringify(body),
177
- signal: isStream ? AbortSignal.timeout(30_000) : AbortSignal.timeout(300_000),
178
- })
179
-
180
- // ... (existing error checks)
181
-
182
- if (isStream) {
183
- if (uiLabel) {
184
- const labeledStream = anthropicStreamLabeler(upstream.body!, uiLabel)
185
- return new Response(labeledStream, {
186
- status: upstream.status,
187
- headers: {
188
- 'Content-Type': 'text/event-stream',
189
- 'Cache-Control': 'no-cache',
190
- Connection: 'keep-alive',
191
- },
192
- })
193
- }
194
- return new Response(upstream.body, {
195
- status: upstream.status,
196
- headers: upstream.headers,
197
- })
198
- }
199
-
200
- const responseBody = await upstream.json()
201
- if (uiLabel) {
202
- (responseBody as any).model = uiLabel
203
- }
204
- return Response.json(responseBody)
205
- }
206
-
207
- async function handleOpenaiChat(
208
- body: AnthropicRequest,
209
- baseUrl: string,
210
- apiKey: string,
211
- isStream: boolean,
212
- uiLabel: string | null = null,
213
- ): Promise<Response> {
214
- const transformed = anthropicToOpenaiChat(body)
215
- const url = `${baseUrl}/v1/chat/completions`
216
-
217
- const upstream = await fetch(url, {
218
- method: 'POST',
219
- headers: buildUpstreamHeaders(apiKey),
220
- body: JSON.stringify(transformed),
221
- signal: isStream ? AbortSignal.timeout(30_000) : AbortSignal.timeout(300_000),
222
- })
223
-
224
- if (!upstream.ok) {
225
- // ... error handling
226
- }
227
-
228
- if (isStream) {
229
- if (!upstream.body) {
230
- return Response.json(/* ... */)
231
- }
232
- const anthropicStream = openaiChatStreamToAnthropic(upstream.body, uiLabel || body.model)
233
- return new Response(anthropicStream, {
234
- status: 200,
235
- headers: {
236
- 'Content-Type': 'text/event-stream',
237
- 'Cache-Control': 'no-cache',
238
- Connection: 'keep-alive',
239
- },
240
- })
241
- }
242
-
243
- // Non-streaming
244
- const responseBody = await upstream.json()
245
- const anthropicResponse = openaiChatToAnthropic(responseBody, uiLabel || body.model)
246
- return Response.json(anthropicResponse)
247
- }
248
-
249
- async function handleOpenaiResponses(
250
- body: AnthropicRequest,
251
- baseUrl: string,
252
- apiKey: string,
253
- isStream: boolean,
254
- uiLabel: string | null = null,
255
- ): Promise<Response> {
256
- const transformed = anthropicToOpenaiResponses(body)
257
- const url = `${baseUrl}/v1/responses`
258
-
259
- const upstream = await fetch(url, {
260
- method: 'POST',
261
- headers: buildUpstreamHeaders(apiKey),
262
- body: JSON.stringify(transformed),
263
- signal: isStream ? AbortSignal.timeout(30_000) : AbortSignal.timeout(300_000),
264
- })
265
-
266
- if (!upstream.ok) {
267
- const errText = await upstream.text().catch(() => '')
268
- return Response.json(
269
- {
270
- type: 'error',
271
- error: {
272
- type: 'api_error',
273
- message: `Upstream returned HTTP ${upstream.status}: ${errText.slice(0, 500)}`,
274
- },
275
- },
276
- { status: upstream.status },
277
- )
278
- }
279
-
280
- if (isStream) {
281
- if (!upstream.body) {
282
- return Response.json(
283
- { type: 'error', error: { type: 'api_error', message: 'Upstream returned no body for stream' } },
284
- { status: 502 },
285
- )
286
- }
287
- const anthropicStream = openaiResponsesStreamToAnthropic(upstream.body, uiLabel || body.model)
288
- return new Response(anthropicStream, {
289
- status: 200,
290
- headers: {
291
- 'Content-Type': 'text/event-stream',
292
- 'Cache-Control': 'no-cache',
293
- Connection: 'keep-alive',
294
- },
295
- })
296
- }
297
-
298
- // Non-streaming
299
- const responseBody = await upstream.json()
300
- const anthropicResponse = openaiResponsesToAnthropic(responseBody, uiLabel || body.model)
301
- return Response.json(anthropicResponse)
302
- }
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
+ const uiLabel = slotConfig.label || null
87
+
88
+ try {
89
+ if (slotConfig.apiFormat === 'anthropic') {
90
+ return await handleAnthropicPassthrough(proxiedBody, baseUrl, slotConfig.apiKey, isStream, uiLabel)
91
+ } else if (slotConfig.apiFormat === 'openai_chat') {
92
+ return await handleOpenaiChat(proxiedBody, baseUrl, slotConfig.apiKey, isStream, uiLabel)
93
+ } else {
94
+ return await handleOpenaiResponses(proxiedBody, baseUrl, slotConfig.apiKey, isStream, uiLabel)
95
+ }
96
+ } catch (err) {
97
+ console.error(`[Proxy] Slot "${slot}" upstream request failed:`, err)
98
+ return Response.json(
99
+ {
100
+ type: 'error',
101
+ error: {
102
+ type: 'api_error',
103
+ message: err instanceof Error ? err.message : String(err),
104
+ },
105
+ },
106
+ { status: 502 },
107
+ )
108
+ }
109
+ }
110
+
111
+ // --- Fallback: legacy single-activeId routing ---
112
+ const config = await providerService.getActiveProviderForProxy()
113
+ if (!config) {
114
+ return Response.json(
115
+ {
116
+ type: 'error',
117
+ error: {
118
+ type: 'invalid_request_error',
119
+ message: `No provider configured for slot "${slot}". Please configure slots in the Provider panel.`,
120
+ },
121
+ },
122
+ { status: 400 },
123
+ )
124
+ }
125
+
126
+ if (config.apiFormat === 'anthropic') {
127
+ return Response.json(
128
+ { type: 'error', error: { type: 'invalid_request_error', message: 'Active provider uses anthropic format — proxy not needed' } },
129
+ { status: 400 },
130
+ )
131
+ }
132
+
133
+ const baseUrl = config.baseUrl.replace(/\/+$/, '')
134
+
135
+ try {
136
+ if (config.apiFormat === 'openai_chat') {
137
+ return await handleOpenaiChat(body, baseUrl, config.apiKey, isStream)
138
+ } else {
139
+ return await handleOpenaiResponses(body, baseUrl, config.apiKey, isStream)
140
+ }
141
+ } catch (err) {
142
+ console.error('[Proxy] Upstream request failed:', err)
143
+ return Response.json(
144
+ {
145
+ type: 'error',
146
+ error: {
147
+ type: 'api_error',
148
+ message: err instanceof Error ? err.message : String(err),
149
+ },
150
+ },
151
+ { status: 502 },
152
+ )
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Pass through to an Anthropic-compatible upstream without format transformation.
158
+ * Used when the slot provider uses apiFormat 'anthropic'.
159
+ */
160
+ async function handleAnthropicPassthrough(
161
+ body: AnthropicRequest,
162
+ baseUrl: string,
163
+ apiKey: string,
164
+ isStream: boolean,
165
+ uiLabel: string | null = null,
166
+ ): Promise<Response> {
167
+ const url = `${baseUrl}/v1/messages`
168
+ const upstream = await fetch(url, {
169
+ method: 'POST',
170
+ headers: {
171
+ 'Content-Type': 'application/json',
172
+ 'x-api-key': apiKey,
173
+ 'anthropic-version': '2023-06-01',
174
+ },
175
+ body: JSON.stringify(body),
176
+ signal: isStream ? AbortSignal.timeout(30_000) : AbortSignal.timeout(300_000),
177
+ })
178
+
179
+ // ... (existing error checks)
180
+
181
+ if (isStream) {
182
+ // Anthropic pass-through doesn't easily support label injection without parsing SSE
183
+ // So for native anthropic format, we might just pass original stream
184
+ return new Response(upstream.body, { /* ... */ })
185
+ }
186
+
187
+ const responseBody = await upstream.json()
188
+ if (uiLabel) {
189
+ (responseBody as any).model = uiLabel
190
+ }
191
+ return Response.json(responseBody)
192
+ }
193
+
194
+ async function handleOpenaiChat(
195
+ body: AnthropicRequest,
196
+ baseUrl: string,
197
+ apiKey: string,
198
+ isStream: boolean,
199
+ uiLabel: string | null = null,
200
+ ): Promise<Response> {
201
+ const transformed = anthropicToOpenaiChat(body)
202
+ const url = `${baseUrl}/v1/chat/completions`
203
+
204
+ const upstream = await fetch(url, {
205
+ method: 'POST',
206
+ headers: buildUpstreamHeaders(apiKey),
207
+ body: JSON.stringify(transformed),
208
+ signal: isStream ? AbortSignal.timeout(30_000) : AbortSignal.timeout(300_000),
209
+ })
210
+
211
+ if (!upstream.ok) {
212
+ // ... error handling
213
+ }
214
+
215
+ if (isStream) {
216
+ if (!upstream.body) {
217
+ return Response.json(/* ... */)
218
+ }
219
+ const anthropicStream = openaiChatStreamToAnthropic(upstream.body, uiLabel || body.model)
220
+ return new Response(anthropicStream, {
221
+ status: 200,
222
+ headers: {
223
+ 'Content-Type': 'text/event-stream',
224
+ 'Cache-Control': 'no-cache',
225
+ Connection: 'keep-alive',
226
+ },
227
+ })
228
+ }
229
+
230
+ // Non-streaming
231
+ const responseBody = await upstream.json()
232
+ const anthropicResponse = openaiChatToAnthropic(responseBody, uiLabel || body.model)
233
+ return Response.json(anthropicResponse)
234
+ }
235
+
236
+ async function handleOpenaiResponses(
237
+ body: AnthropicRequest,
238
+ baseUrl: string,
239
+ apiKey: string,
240
+ isStream: boolean,
241
+ uiLabel: string | null = null,
242
+ ): Promise<Response> {
243
+ const transformed = anthropicToOpenaiResponses(body)
244
+ const url = `${baseUrl}/v1/responses`
245
+
246
+ const upstream = await fetch(url, {
247
+ method: 'POST',
248
+ headers: buildUpstreamHeaders(apiKey),
249
+ body: JSON.stringify(transformed),
250
+ signal: isStream ? AbortSignal.timeout(30_000) : AbortSignal.timeout(300_000),
251
+ })
252
+
253
+ if (!upstream.ok) {
254
+ const errText = await upstream.text().catch(() => '')
255
+ return Response.json(
256
+ {
257
+ type: 'error',
258
+ error: {
259
+ type: 'api_error',
260
+ message: `Upstream returned HTTP ${upstream.status}: ${errText.slice(0, 500)}`,
261
+ },
262
+ },
263
+ { status: upstream.status },
264
+ )
265
+ }
266
+
267
+ if (isStream) {
268
+ if (!upstream.body) {
269
+ return Response.json(
270
+ { type: 'error', error: { type: 'api_error', message: 'Upstream returned no body for stream' } },
271
+ { status: 502 },
272
+ )
273
+ }
274
+ const anthropicStream = openaiResponsesStreamToAnthropic(upstream.body, uiLabel || body.model)
275
+ return new Response(anthropicStream, {
276
+ status: 200,
277
+ headers: {
278
+ 'Content-Type': 'text/event-stream',
279
+ 'Cache-Control': 'no-cache',
280
+ Connection: 'keep-alive',
281
+ },
282
+ })
283
+ }
284
+
285
+ // Non-streaming
286
+ const responseBody = await upstream.json()
287
+ const anthropicResponse = openaiResponsesToAnthropic(responseBody, uiLabel || body.model)
288
+ return Response.json(anthropicResponse)
289
+ }