bingocode 1.1.200-beta.12 → 1.1.200-beta.13
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/package.json +1 -1
- package/src/constants/prompts.ts +21 -15
- package/src/server/services/providerService.ts +1131 -1131
- package/src/utils/attachments.ts +1 -1
|
@@ -1,1132 +1,1132 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Provider Service — preset-based provider configuration
|
|
3
|
-
*
|
|
4
|
-
* Storage: ~/.claude/bingo/providers.json (lightweight index)
|
|
5
|
-
* Active provider env vars written to ~/.claude/bingo/settings.json
|
|
6
|
-
* (isolated from the original Claude Code's ~/.claude/settings.json)
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import * as fs from 'fs/promises'
|
|
10
|
-
import * as path from 'path'
|
|
11
|
-
import * as os from 'os'
|
|
12
|
-
import { getDirectFetchOptions } from '../../utils/proxy.ts'
|
|
13
|
-
import { ApiError } from '../middleware/errorHandler.js'
|
|
14
|
-
import { anthropicToOpenaiChat } from '../proxy/transform/anthropicToOpenaiChat.js'
|
|
15
|
-
import { anthropicToOpenaiResponses } from '../proxy/transform/anthropicToOpenaiResponses.js'
|
|
16
|
-
import { openaiChatToAnthropic } from '../proxy/transform/openaiChatToAnthropic.js'
|
|
17
|
-
import { openaiResponsesToAnthropic } from '../proxy/transform/openaiResponsesToAnthropic.js'
|
|
18
|
-
import type { AnthropicRequest, AnthropicResponse } from '../proxy/transform/types.js'
|
|
19
|
-
import { PROVIDER_PRESETS } from '../config/providerPresets.js'
|
|
20
|
-
import type {
|
|
21
|
-
SavedProvider,
|
|
22
|
-
ProvidersIndex,
|
|
23
|
-
CreateProviderInput,
|
|
24
|
-
UpdateProviderInput,
|
|
25
|
-
TestProviderInput,
|
|
26
|
-
ProviderTestResult,
|
|
27
|
-
ProviderTestStepResult,
|
|
28
|
-
ApiFormat,
|
|
29
|
-
SlotName,
|
|
30
|
-
SlotEntry,
|
|
31
|
-
SlotTable,
|
|
32
|
-
} from '../types/provider.ts'
|
|
33
|
-
import { SlotTableSchema } from '../types/provider.ts'
|
|
34
|
-
|
|
35
|
-
// NOTE: When the proxy is active, ANTHROPIC_AUTH_TOKEN is written as "proxy-managed"
|
|
36
|
-
// (a sentinel placeholder). This tells the proxy to resolve the real API key from the
|
|
37
|
-
// active provider config at request time. It is NOT a real credential and is expected
|
|
38
|
-
// to be overwritten every time a provider is activated or VS Code is linked.
|
|
39
|
-
const MANAGED_ENV_KEYS = [
|
|
40
|
-
'ANTHROPIC_BASE_URL',
|
|
41
|
-
'ANTHROPIC_AUTH_TOKEN',
|
|
42
|
-
'ANTHROPIC_MODEL',
|
|
43
|
-
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
|
44
|
-
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
|
45
|
-
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
|
46
|
-
] as const
|
|
47
|
-
|
|
48
|
-
const CLAUDE_DEFAULT_LABELS: Record<SlotName, string> = {
|
|
49
|
-
main: 'claude-sonnet-4-5',
|
|
50
|
-
haiku: 'claude-haiku-4-5',
|
|
51
|
-
sonnet: 'claude-sonnet-4-5',
|
|
52
|
-
opus: 'claude-opus-4-7',
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const VSCODE_EDITOR_NAMES = ['Code', 'Code - Insiders', 'Cursor'] as const
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Return candidate VS Code settings.json paths for the current platform.
|
|
59
|
-
* These are checked for existence (editor directory must exist) before writing.
|
|
60
|
-
*/
|
|
61
|
-
function getVscodeSettingsPaths(): string[] {
|
|
62
|
-
const home = os.homedir()
|
|
63
|
-
const paths: string[] = []
|
|
64
|
-
|
|
65
|
-
if (process.platform === 'win32') {
|
|
66
|
-
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming')
|
|
67
|
-
for (const name of VSCODE_EDITOR_NAMES) {
|
|
68
|
-
paths.push(path.join(appData, name, 'User', 'settings.json'))
|
|
69
|
-
}
|
|
70
|
-
} else if (process.platform === 'darwin') {
|
|
71
|
-
const lib = path.join(home, 'Library', 'Application Support')
|
|
72
|
-
for (const name of VSCODE_EDITOR_NAMES) {
|
|
73
|
-
paths.push(path.join(lib, name, 'User', 'settings.json'))
|
|
74
|
-
}
|
|
75
|
-
} else {
|
|
76
|
-
// Linux / other Unix
|
|
77
|
-
const config = path.join(home, '.config')
|
|
78
|
-
for (const name of VSCODE_EDITOR_NAMES) {
|
|
79
|
-
paths.push(path.join(config, name, 'User', 'settings.json'))
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return paths
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const DEFAULT_INDEX: ProvidersIndex = { activeId: null, providers: [] }
|
|
87
|
-
|
|
88
|
-
export class ProviderService {
|
|
89
|
-
private static serverPort = 3456
|
|
90
|
-
|
|
91
|
-
static setServerPort(port: number): void {
|
|
92
|
-
ProviderService.serverPort = port
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
static getServerPort(): number {
|
|
96
|
-
return ProviderService.serverPort
|
|
97
|
-
}
|
|
98
|
-
private getConfigDir(): string {
|
|
99
|
-
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
private getCcHahaDir(): string {
|
|
103
|
-
return path.join(this.getConfigDir(), 'bingo')
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
private getIndexPath(): string {
|
|
107
|
-
return path.join(this.getCcHahaDir(), 'providers.json')
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
private getSettingsPath(): string {
|
|
111
|
-
return path.join(this.getCcHahaDir(), 'settings.json')
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
private getSlotsPath(): string {
|
|
115
|
-
return path.join(this.getCcHahaDir(), 'slots.json')
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
private async readIndex(): Promise<ProvidersIndex> {
|
|
119
|
-
try {
|
|
120
|
-
const raw = await fs.readFile(this.getIndexPath(), 'utf-8')
|
|
121
|
-
return JSON.parse(raw) as ProvidersIndex
|
|
122
|
-
} catch (err: unknown) {
|
|
123
|
-
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
124
|
-
return { ...DEFAULT_INDEX, providers: [] }
|
|
125
|
-
}
|
|
126
|
-
throw ApiError.internal(`Failed to read providers index: ${err}`)
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
private async writeIndex(index: ProvidersIndex): Promise<void> {
|
|
131
|
-
const filePath = this.getIndexPath()
|
|
132
|
-
const dir = path.dirname(filePath)
|
|
133
|
-
await fs.mkdir(dir, { recursive: true })
|
|
134
|
-
|
|
135
|
-
const tmpFile = `${filePath}.tmp.${Date.now()}`
|
|
136
|
-
try {
|
|
137
|
-
await fs.writeFile(tmpFile, JSON.stringify(index, null, 2) + '\n', 'utf-8')
|
|
138
|
-
await fs.rename(tmpFile, filePath)
|
|
139
|
-
} catch (err) {
|
|
140
|
-
await fs.unlink(tmpFile).catch(() => {})
|
|
141
|
-
throw ApiError.internal(`Failed to write providers index: ${err}`)
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
private async readSettings(): Promise<Record<string, unknown>> {
|
|
146
|
-
try {
|
|
147
|
-
const raw = await fs.readFile(this.getSettingsPath(), 'utf-8')
|
|
148
|
-
return JSON.parse(raw) as Record<string, unknown>
|
|
149
|
-
} catch (err: unknown) {
|
|
150
|
-
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}
|
|
151
|
-
throw ApiError.internal(`Failed to read settings.json: ${err}`)
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
private async writeSettings(settings: Record<string, unknown>): Promise<void> {
|
|
156
|
-
const filePath = this.getSettingsPath()
|
|
157
|
-
const dir = path.dirname(filePath)
|
|
158
|
-
await fs.mkdir(dir, { recursive: true })
|
|
159
|
-
|
|
160
|
-
const tmpFile = `${filePath}.tmp.${Date.now()}`
|
|
161
|
-
try {
|
|
162
|
-
await fs.writeFile(tmpFile, JSON.stringify(settings, null, 2) + '\n', 'utf-8')
|
|
163
|
-
await fs.rename(tmpFile, filePath)
|
|
164
|
-
} catch (err) {
|
|
165
|
-
await fs.unlink(tmpFile).catch(() => {})
|
|
166
|
-
throw ApiError.internal(`Failed to write settings.json: ${err}`)
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// --- CRUD ---
|
|
171
|
-
|
|
172
|
-
async listProviders(): Promise<{ providers: SavedProvider[]; activeId: string | null }> {
|
|
173
|
-
const index = await this.readIndex()
|
|
174
|
-
return { providers: index.providers, activeId: index.activeId }
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
async getProvider(id: string): Promise<SavedProvider> {
|
|
178
|
-
const index = await this.readIndex()
|
|
179
|
-
const provider = index.providers.find((p) => p.id === id)
|
|
180
|
-
if (!provider) throw ApiError.notFound(`Provider not found: ${id}`)
|
|
181
|
-
return provider
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
async addProvider(input: CreateProviderInput): Promise<SavedProvider> {
|
|
185
|
-
const index = await this.readIndex()
|
|
186
|
-
|
|
187
|
-
const provider: SavedProvider = {
|
|
188
|
-
id: crypto.randomUUID(),
|
|
189
|
-
presetId: input.presetId,
|
|
190
|
-
name: input.name,
|
|
191
|
-
apiKey: input.apiKey,
|
|
192
|
-
baseUrl: input.baseUrl,
|
|
193
|
-
apiFormat: input.apiFormat ?? 'anthropic',
|
|
194
|
-
models: input.models,
|
|
195
|
-
...(input.notes !== undefined && { notes: input.notes }),
|
|
196
|
-
...(input.extra !== undefined && { extra: input.extra }),
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
index.providers.push(provider)
|
|
200
|
-
await this.writeIndex(index)
|
|
201
|
-
return provider
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
async updateProvider(id: string, input: UpdateProviderInput): Promise<SavedProvider> {
|
|
205
|
-
const index = await this.readIndex()
|
|
206
|
-
const idx = index.providers.findIndex((p) => p.id === id)
|
|
207
|
-
if (idx === -1) throw ApiError.notFound(`Provider not found: ${id}`)
|
|
208
|
-
|
|
209
|
-
const existing = index.providers[idx]
|
|
210
|
-
const updated: SavedProvider = {
|
|
211
|
-
...existing,
|
|
212
|
-
...(input.name !== undefined && { name: input.name }),
|
|
213
|
-
...(input.apiKey !== undefined && { apiKey: input.apiKey }),
|
|
214
|
-
...(input.baseUrl !== undefined && { baseUrl: input.baseUrl }),
|
|
215
|
-
...(input.apiFormat !== undefined && { apiFormat: input.apiFormat }),
|
|
216
|
-
...(input.models !== undefined && { models: input.models }),
|
|
217
|
-
...(input.notes !== undefined && { notes: input.notes }),
|
|
218
|
-
...(input.extra !== undefined && { extra: input.extra }),
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
index.providers[idx] = updated
|
|
222
|
-
await this.writeIndex(index)
|
|
223
|
-
|
|
224
|
-
if (index.activeId === id) {
|
|
225
|
-
await this.syncToSettings(updated)
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
return updated
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
async deleteProvider(id: string): Promise<void> {
|
|
232
|
-
const index = await this.readIndex()
|
|
233
|
-
const idx = index.providers.findIndex((p) => p.id === id)
|
|
234
|
-
if (idx === -1) throw ApiError.notFound(`Provider not found: ${id}`)
|
|
235
|
-
|
|
236
|
-
if (index.activeId === id) {
|
|
237
|
-
throw ApiError.conflict('Cannot delete the active provider. Switch to another provider first.')
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
index.providers.splice(idx, 1)
|
|
241
|
-
await this.writeIndex(index)
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// --- Activation ---
|
|
245
|
-
|
|
246
|
-
async activateProvider(id: string): Promise<void> {
|
|
247
|
-
const index = await this.readIndex()
|
|
248
|
-
const provider = index.providers.find((p) => p.id === id)
|
|
249
|
-
if (!provider) throw ApiError.notFound(`Provider not found: ${id}`)
|
|
250
|
-
|
|
251
|
-
index.activeId = id
|
|
252
|
-
await this.writeIndex(index)
|
|
253
|
-
|
|
254
|
-
if (provider.presetId === 'official') {
|
|
255
|
-
await this.clearProviderFromSettings()
|
|
256
|
-
} else {
|
|
257
|
-
await this.syncToSettings(provider)
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
async activateOfficial(): Promise<void> {
|
|
262
|
-
const index = await this.readIndex()
|
|
263
|
-
index.activeId = null
|
|
264
|
-
await this.writeIndex(index)
|
|
265
|
-
await this.clearProviderFromSettings()
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// --- Settings sync ---
|
|
269
|
-
|
|
270
|
-
private async syncToSettings(provider: SavedProvider): Promise<void> {
|
|
271
|
-
const settings = await this.readSettings()
|
|
272
|
-
const existingEnv = (settings.env as Record<string, string>) || {}
|
|
273
|
-
|
|
274
|
-
const needsProxy = provider.apiFormat != null && provider.apiFormat !== 'anthropic'
|
|
275
|
-
const baseUrl = needsProxy
|
|
276
|
-
? `http://127.0.0.1:${ProviderService.serverPort}/proxy`
|
|
277
|
-
: provider.baseUrl
|
|
278
|
-
|
|
279
|
-
settings.env = {
|
|
280
|
-
...existingEnv,
|
|
281
|
-
ANTHROPIC_BASE_URL: baseUrl,
|
|
282
|
-
ANTHROPIC_AUTH_TOKEN: needsProxy ? 'proxy-managed' : provider.apiKey,
|
|
283
|
-
ANTHROPIC_MODEL: provider.models.main,
|
|
284
|
-
ANTHROPIC_DEFAULT_HAIKU_MODEL: provider.models.haiku,
|
|
285
|
-
ANTHROPIC_DEFAULT_SONNET_MODEL: provider.models.sonnet,
|
|
286
|
-
ANTHROPIC_DEFAULT_OPUS_MODEL: provider.models.opus,
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
await this.writeSettings(settings)
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
private async clearProviderFromSettings(): Promise<void> {
|
|
293
|
-
const settings = await this.readSettings()
|
|
294
|
-
const env = (settings.env as Record<string, string>) || {}
|
|
295
|
-
|
|
296
|
-
for (const key of MANAGED_ENV_KEYS) {
|
|
297
|
-
delete env[key]
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
settings.env = env
|
|
301
|
-
if (Object.keys(env).length === 0) {
|
|
302
|
-
delete settings.env
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
await this.writeSettings(settings)
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* Sync settings.json based on the current slot table.
|
|
310
|
-
*
|
|
311
|
-
* Examines all configured slots to determine whether the CLI should connect
|
|
312
|
-
* through the local proxy or directly. If ANY slot uses a non-anthropic format
|
|
313
|
-
* (openai_chat, openai_responses), the proxy is required and settings.json is
|
|
314
|
-
* written with:
|
|
315
|
-
* - ANTHROPIC_BASE_URL → http://127.0.0.1:{port}/proxy
|
|
316
|
-
* - ANTHROPIC_AUTH_TOKEN → "proxy-managed"
|
|
317
|
-
*
|
|
318
|
-
* Model env vars (ANTHROPIC_MODEL, ANTHROPIC_DEFAULT_*_MODEL) are populated
|
|
319
|
-
* from the slot table so the CLI requests the correct model names, which the
|
|
320
|
-
* proxy's identifySlot() then routes to the right provider.
|
|
321
|
-
*
|
|
322
|
-
* If ALL configured slots use native anthropic format, we write the main
|
|
323
|
-
* slot's provider info directly (no proxy needed).
|
|
324
|
-
*
|
|
325
|
-
* If no slots are configured at all, settings.json is left unchanged.
|
|
326
|
-
*/
|
|
327
|
-
private async syncSettingsForSlots(slots: SlotTable): Promise<void> {
|
|
328
|
-
const index = await this.readIndex()
|
|
329
|
-
|
|
330
|
-
// Collect resolved slot info
|
|
331
|
-
type ResolvedSlot = { slot: SlotName; provider: SavedProvider; modelId: string; label?: string | null }
|
|
332
|
-
const resolved: ResolvedSlot[] = []
|
|
333
|
-
for (const slotName of ['main', 'haiku', 'sonnet', 'opus'] as const) {
|
|
334
|
-
const entry = slots[slotName]
|
|
335
|
-
if (!entry) continue
|
|
336
|
-
const provider = index.providers.find((p) => p.id === entry.providerId)
|
|
337
|
-
if (!provider) continue
|
|
338
|
-
resolved.push({ slot: slotName, provider, modelId: entry.modelId, label: entry.label })
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// No slots configured at all — don't touch settings.json
|
|
342
|
-
if (resolved.length === 0) return
|
|
343
|
-
|
|
344
|
-
const needsProxy = resolved.some(
|
|
345
|
-
(r) => r.provider.apiFormat != null && r.provider.apiFormat !== 'anthropic',
|
|
346
|
-
)
|
|
347
|
-
|
|
348
|
-
const settings = await this.readSettings()
|
|
349
|
-
const existingEnv = (settings.env as Record<string, string>) || {}
|
|
350
|
-
|
|
351
|
-
// Build model env vars from slot table.
|
|
352
|
-
//
|
|
353
|
-
// The proxy's identifySlot() routes requests by looking for keywords
|
|
354
|
-
// (haiku/sonnet/opus) in the model name the CLI sends. If the actual
|
|
355
|
-
// model ID (e.g., "deepseek-chat") doesn't contain the keyword, the
|
|
356
|
-
// proxy would misroute to "main". To fix this, we ensure the env var
|
|
357
|
-
// value always contains the slot keyword so identifySlot() can match.
|
|
358
|
-
const modelEnv: Record<string, string> = {}
|
|
359
|
-
for (const r of resolved) {
|
|
360
|
-
const rawName = r.label || r.modelId
|
|
361
|
-
switch (r.slot) {
|
|
362
|
-
case 'main':
|
|
363
|
-
modelEnv.ANTHROPIC_MODEL = rawName
|
|
364
|
-
break
|
|
365
|
-
case 'haiku': {
|
|
366
|
-
// Ensure the value contains "haiku" for identifySlot() routing
|
|
367
|
-
const name = /haiku/i.test(rawName) ? rawName : `${rawName}-haiku`
|
|
368
|
-
modelEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL = name
|
|
369
|
-
break
|
|
370
|
-
}
|
|
371
|
-
case 'sonnet': {
|
|
372
|
-
const name = /sonnet/i.test(rawName) ? rawName : `${rawName}-sonnet`
|
|
373
|
-
modelEnv.ANTHROPIC_DEFAULT_SONNET_MODEL = name
|
|
374
|
-
break
|
|
375
|
-
}
|
|
376
|
-
case 'opus': {
|
|
377
|
-
const name = /opus/i.test(rawName) ? rawName : `${rawName}-opus`
|
|
378
|
-
modelEnv.ANTHROPIC_DEFAULT_OPUS_MODEL = name
|
|
379
|
-
break
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
if (needsProxy) {
|
|
385
|
-
settings.env = {
|
|
386
|
-
...existingEnv,
|
|
387
|
-
ANTHROPIC_BASE_URL: `http://127.0.0.1:${ProviderService.serverPort}/proxy`,
|
|
388
|
-
ANTHROPIC_AUTH_TOKEN: 'proxy-managed',
|
|
389
|
-
...modelEnv,
|
|
390
|
-
}
|
|
391
|
-
} else {
|
|
392
|
-
// All slots are native anthropic — use main slot's provider directly
|
|
393
|
-
const mainSlot = resolved.find((r) => r.slot === 'main') || resolved[0]
|
|
394
|
-
settings.env = {
|
|
395
|
-
...existingEnv,
|
|
396
|
-
ANTHROPIC_BASE_URL: mainSlot.provider.baseUrl,
|
|
397
|
-
ANTHROPIC_AUTH_TOKEN: mainSlot.provider.apiKey,
|
|
398
|
-
...modelEnv,
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
await this.writeSettings(settings)
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
// --- Auth status ---
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* Check whether any usable auth exists:
|
|
409
|
-
* 1. A bingo provider is active → has auth
|
|
410
|
-
* 2. Original ~/.claude/settings.json has ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY → has auth
|
|
411
|
-
* 3. process.env already has ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN → has auth
|
|
412
|
-
* 4. None of the above → needs setup
|
|
413
|
-
*/
|
|
414
|
-
async checkAuthStatus(): Promise<{
|
|
415
|
-
hasAuth: boolean
|
|
416
|
-
source: 'bingo-provider' | 'original-settings' | 'env' | 'none'
|
|
417
|
-
activeProvider?: string
|
|
418
|
-
}> {
|
|
419
|
-
// 1. Check bingo active provider
|
|
420
|
-
const index = await this.readIndex()
|
|
421
|
-
if (index.activeId) {
|
|
422
|
-
const provider = index.providers.find(p => p.id === index.activeId)
|
|
423
|
-
if (provider?.apiKey) {
|
|
424
|
-
return { hasAuth: true, source: 'bingo-provider', activeProvider: provider.name }
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// 2. Check process.env (covers .env file + inherited env)
|
|
429
|
-
if (process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN) {
|
|
430
|
-
return { hasAuth: true, source: 'env' }
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// 3. Check original ~/.claude/settings.json
|
|
434
|
-
try {
|
|
435
|
-
const originalPath = path.join(this.getConfigDir(), 'settings.json')
|
|
436
|
-
const raw = await fs.readFile(originalPath, 'utf-8')
|
|
437
|
-
const settings = JSON.parse(raw) as { env?: Record<string, string> }
|
|
438
|
-
const env = settings.env ?? {}
|
|
439
|
-
if (env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY) {
|
|
440
|
-
return { hasAuth: true, source: 'original-settings' }
|
|
441
|
-
}
|
|
442
|
-
} catch {
|
|
443
|
-
// File doesn't exist or invalid
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
return { hasAuth: false, source: 'none' }
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
// --- Proxy support ---
|
|
450
|
-
|
|
451
|
-
async getActiveProviderForProxy(): Promise<{
|
|
452
|
-
baseUrl: string
|
|
453
|
-
apiKey: string
|
|
454
|
-
apiFormat: ApiFormat
|
|
455
|
-
} | null> {
|
|
456
|
-
const index = await this.readIndex()
|
|
457
|
-
if (!index.activeId) return null
|
|
458
|
-
const provider = index.providers.find((p) => p.id === index.activeId)
|
|
459
|
-
if (!provider) return null
|
|
460
|
-
return {
|
|
461
|
-
baseUrl: provider.baseUrl,
|
|
462
|
-
apiKey: provider.apiKey,
|
|
463
|
-
apiFormat: provider.apiFormat ?? 'anthropic',
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// --- Slot routing ---
|
|
468
|
-
|
|
469
|
-
async readSlots(): Promise<SlotTable> {
|
|
470
|
-
try {
|
|
471
|
-
const raw = await fs.readFile(this.getSlotsPath(), 'utf-8')
|
|
472
|
-
return SlotTableSchema.parse(JSON.parse(raw))
|
|
473
|
-
} catch (err: unknown) {
|
|
474
|
-
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
475
|
-
return SlotTableSchema.parse({})
|
|
476
|
-
}
|
|
477
|
-
throw ApiError.internal(`Failed to read slots: ${err}`)
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
async writeSlots(slots: SlotTable): Promise<void> {
|
|
482
|
-
const filePath = this.getSlotsPath()
|
|
483
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
|
484
|
-
const tmp = `${filePath}.tmp.${Date.now()}`
|
|
485
|
-
try {
|
|
486
|
-
await fs.writeFile(tmp, JSON.stringify(slots, null, 2) + '\n', 'utf-8')
|
|
487
|
-
await fs.rename(tmp, filePath)
|
|
488
|
-
} catch (err) {
|
|
489
|
-
await fs.unlink(tmp).catch(() => {})
|
|
490
|
-
throw ApiError.internal(`Failed to write slots: ${err}`)
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
async setSlot(slot: SlotName, entry: SlotEntry): Promise<SlotTable> {
|
|
495
|
-
const slots = await this.readSlots()
|
|
496
|
-
slots[slot] = entry
|
|
497
|
-
await this.writeSlots(slots)
|
|
498
|
-
|
|
499
|
-
// Auto-sync settings.json so the CLI knows where to connect.
|
|
500
|
-
// When any slot uses a non-anthropic provider, the CLI must go through
|
|
501
|
-
// the local proxy; when all slots are anthropic (or empty), we can
|
|
502
|
-
// write direct provider info instead.
|
|
503
|
-
await this.syncSettingsForSlots(slots)
|
|
504
|
-
|
|
505
|
-
return slots
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
async getProviderForSlot(slotName: SlotName): Promise<{
|
|
509
|
-
baseUrl: string
|
|
510
|
-
apiKey: string
|
|
511
|
-
apiFormat: ApiFormat
|
|
512
|
-
modelId: string
|
|
513
|
-
label?: string | null
|
|
514
|
-
} | null> {
|
|
515
|
-
const slots = await this.readSlots()
|
|
516
|
-
const entry = slots[slotName]
|
|
517
|
-
if (!entry) return null
|
|
518
|
-
const index = await this.readIndex()
|
|
519
|
-
const provider = index.providers.find((p) => p.id === entry.providerId)
|
|
520
|
-
if (!provider) return null
|
|
521
|
-
|
|
522
|
-
// Auto-fill Claude model names as label when user hasn't set one.
|
|
523
|
-
// This lets closed-source clients (e.g. VS Code extension) see Claude
|
|
524
|
-
// model names while the CLI continues to show the real upstream modelId
|
|
525
|
-
// (syncSettingsForSlots reads raw entry.label, which stays null).
|
|
526
|
-
return {
|
|
527
|
-
baseUrl: provider.baseUrl,
|
|
528
|
-
apiKey: provider.apiKey,
|
|
529
|
-
apiFormat: provider.apiFormat ?? 'anthropic',
|
|
530
|
-
modelId: entry.modelId,
|
|
531
|
-
label: entry.label || CLAUDE_DEFAULT_LABELS[slotName],
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
// --- VS Code linking ---
|
|
536
|
-
|
|
537
|
-
private buildBingoEnv(envVars: Array<{ name: string; value: string }>): Record<string, string> {
|
|
538
|
-
const env: Record<string, string> = {}
|
|
539
|
-
for (const v of envVars) {
|
|
540
|
-
env[v.name] = v.value
|
|
541
|
-
}
|
|
542
|
-
return env
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
/**
|
|
546
|
-
* Build the environmentVariables array for VS Code claudeCode settings
|
|
547
|
-
* based on current slot configuration. Uses user-set labels or falls back
|
|
548
|
-
* to Claude default model names so the VS Code extension shows the right labels.
|
|
549
|
-
*/
|
|
550
|
-
private async buildVscodeEnvVars(): Promise<Array<{ name: string; value: string }>> {
|
|
551
|
-
const slots = await this.readSlots()
|
|
552
|
-
const index = await this.readIndex()
|
|
553
|
-
|
|
554
|
-
function getSlotLabel(slotName: SlotName): string | null {
|
|
555
|
-
const entry = slots[slotName]
|
|
556
|
-
if (!entry) return null
|
|
557
|
-
const provider = index.providers.find((p) => p.id === entry.providerId)
|
|
558
|
-
if (!provider) return null
|
|
559
|
-
return entry.label || CLAUDE_DEFAULT_LABELS[slotName]
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
const envVars: Array<{ name: string; value: string }> = [
|
|
563
|
-
{ name: 'ANTHROPIC_BASE_URL', value: `http://127.0.0.1:${ProviderService.serverPort}/proxy` },
|
|
564
|
-
{ name: 'ANTHROPIC_AUTH_TOKEN', value: 'proxy-managed' },
|
|
565
|
-
]
|
|
566
|
-
|
|
567
|
-
const mainLabel = getSlotLabel("main")
|
|
568
|
-
if (mainLabel) {
|
|
569
|
-
envVars.push({ name: "ANTHROPIC_MODEL", value: mainLabel })
|
|
570
|
-
}
|
|
571
|
-
const haikuLabel = getSlotLabel("haiku")
|
|
572
|
-
if (haikuLabel) {
|
|
573
|
-
envVars.push({ name: "ANTHROPIC_DEFAULT_HAIKU_MODEL", value: haikuLabel })
|
|
574
|
-
}
|
|
575
|
-
const sonnetLabel = getSlotLabel("sonnet")
|
|
576
|
-
if (sonnetLabel) {
|
|
577
|
-
envVars.push({ name: "ANTHROPIC_DEFAULT_SONNET_MODEL", value: sonnetLabel })
|
|
578
|
-
}
|
|
579
|
-
const opusLabel = getSlotLabel("opus")
|
|
580
|
-
if (opusLabel) {
|
|
581
|
-
envVars.push({ name: "ANTHROPIC_DEFAULT_OPUS_MODEL", value: opusLabel })
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
return envVars
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
/**
|
|
588
|
-
* Write claudeCode settings into ALL detected VS Code editor instances
|
|
589
|
-
* (stable, Insiders, Cursor). Persists linked state in bingo settings so
|
|
590
|
-
* it survives restarts.
|
|
591
|
-
*
|
|
592
|
-
* Fixes applied:
|
|
593
|
-
* #8 - Saves deep copy of original settings.claudeCode as vscodePreLinkClaudeCode
|
|
594
|
-
* so unlink can restore exactly what was there before.
|
|
595
|
-
* #9 - Collects failed paths; on partial failure rollback successful writes.
|
|
596
|
-
* #11 - Creates .bak backup before mutating VS Code settings.json; restores from
|
|
597
|
-
* .bak or in-memory copy on write failure.
|
|
598
|
-
*/
|
|
599
|
-
async linkVscode(): Promise<{ paths: string[]; linked: boolean; failedPaths?: string[] }> {
|
|
600
|
-
const envVars = await this.buildVscodeEnvVars()
|
|
601
|
-
const candidatePaths = getVscodeSettingsPaths()
|
|
602
|
-
const writtenPaths: string[] = []
|
|
603
|
-
const failedPaths: string[] = []
|
|
604
|
-
|
|
605
|
-
// Save pre-link bingo env so we can restore it on disconnect.
|
|
606
|
-
const bingoSettings = await this.readSettings()
|
|
607
|
-
const preLinkEnv = bingoSettings.env ?? null
|
|
608
|
-
bingoSettings.vscodePreLinkEnv = preLinkEnv
|
|
609
|
-
await this.writeSettings(bingoSettings)
|
|
610
|
-
|
|
611
|
-
// Fix #8: Snapshot original VS Code claudeCode per path for unlink restore.
|
|
612
|
-
const vscodePreLinkClaudeCode: Record<string, unknown> = {}
|
|
613
|
-
// Fix #9: Backup original contents for rollback on partial failure.
|
|
614
|
-
const originalContents = new Map<string, string | null>()
|
|
615
|
-
|
|
616
|
-
for (const settingsPath of candidatePaths) {
|
|
617
|
-
// Only write if the editor config directory exists (editor is installed)
|
|
618
|
-
const editorDir = path.dirname(path.dirname(settingsPath))
|
|
619
|
-
try {
|
|
620
|
-
await fs.access(editorDir)
|
|
621
|
-
} catch {
|
|
622
|
-
continue
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
let settings: Record<string, unknown> = {}
|
|
626
|
-
let originalContent: string | null = null
|
|
627
|
-
try {
|
|
628
|
-
originalContent = await fs.readFile(settingsPath, "utf-8")
|
|
629
|
-
settings = JSON.parse(originalContent)
|
|
630
|
-
} catch {
|
|
631
|
-
// File does not exist yet — start fresh
|
|
632
|
-
}
|
|
633
|
-
originalContents.set(settingsPath, originalContent)
|
|
634
|
-
|
|
635
|
-
// Fix #8: Save deep copy of original claudeCode before overwriting
|
|
636
|
-
vscodePreLinkClaudeCode[settingsPath] = settings.claudeCode !== undefined
|
|
637
|
-
? JSON.parse(JSON.stringify(settings.claudeCode))
|
|
638
|
-
: undefined
|
|
639
|
-
|
|
640
|
-
// Fix #11: Write backup before mutating
|
|
641
|
-
if (originalContent !== null) {
|
|
642
|
-
const bakPath = `${settingsPath}.bak`
|
|
643
|
-
try {
|
|
644
|
-
await fs.writeFile(bakPath, originalContent, "utf-8")
|
|
645
|
-
} catch {
|
|
646
|
-
// best-effort backup — proceed without it
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
settings.claudeCode = {
|
|
651
|
-
...(settings.claudeCode as Record<string, unknown> || {}),
|
|
652
|
-
preferredLocation: "panel",
|
|
653
|
-
disableLoginPrompt: true,
|
|
654
|
-
environmentVariables: envVars,
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
try {
|
|
658
|
-
await fs.mkdir(path.dirname(settingsPath), { recursive: true })
|
|
659
|
-
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 4) + '\n')
|
|
660
|
-
writtenPaths.push(settingsPath)
|
|
661
|
-
} catch (err) {
|
|
662
|
-
failedPaths.push(settingsPath)
|
|
663
|
-
console.error(
|
|
664
|
-
`[ProviderService] Failed to write VS Code settings to ${settingsPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
665
|
-
)
|
|
666
|
-
|
|
667
|
-
// Fix #11: Attempt restore from backup on write failure
|
|
668
|
-
if (originalContent !== null) {
|
|
669
|
-
try {
|
|
670
|
-
await fs.writeFile(settingsPath, originalContent, "utf-8")
|
|
671
|
-
console.error(`[ProviderService] Restored ${settingsPath} from in-memory copy after write failure`)
|
|
672
|
-
} catch {
|
|
673
|
-
// In-memory restore failed, try .bak file
|
|
674
|
-
try {
|
|
675
|
-
await fs.copyFile(`${settingsPath}.bak`, settingsPath)
|
|
676
|
-
console.error(`[ProviderService] Restored ${settingsPath} from .bak after write failure`)
|
|
677
|
-
} catch (restoreErr) {
|
|
678
|
-
console.error(
|
|
679
|
-
`[ProviderService] Failed to restore ${settingsPath} after write failure: ${restoreErr instanceof Error ? restoreErr.message : String(restoreErr)}`,
|
|
680
|
-
)
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
// Clean up .bak files for successfully written paths
|
|
688
|
-
for (const p of writtenPaths) {
|
|
689
|
-
await fs.unlink(`${p}.bak`).catch(() => {})
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
// Fix #9: Rollback successful writes on partial failure for atomicity.
|
|
693
|
-
// If any writes failed AND some succeeded, attempt to restore originals.
|
|
694
|
-
if (failedPaths.length > 0 && writtenPaths.length > 0) {
|
|
695
|
-
console.error(
|
|
696
|
-
`[ProviderService] Partial VS Code link failure: ${writtenPaths.length} succeeded, ${failedPaths.length} failed. Rolling back successful writes.`,
|
|
697
|
-
)
|
|
698
|
-
let allRolledBack = true
|
|
699
|
-
for (const p of writtenPaths) {
|
|
700
|
-
const original = originalContents.get(p)
|
|
701
|
-
try {
|
|
702
|
-
if (original !== null) {
|
|
703
|
-
await fs.writeFile(p, original, "utf-8")
|
|
704
|
-
} else {
|
|
705
|
-
await fs.unlink(p).catch(() => {})
|
|
706
|
-
}
|
|
707
|
-
} catch (rollbackErr) {
|
|
708
|
-
allRolledBack = false
|
|
709
|
-
console.error(
|
|
710
|
-
`[ProviderService] Rollback failed for ${p}: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
|
|
711
|
-
)
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
if (allRolledBack) {
|
|
715
|
-
writtenPaths.length = 0
|
|
716
|
-
}
|
|
717
|
-
// If rollback itself failed we have a mess — leave writtenPaths as-is so caller sees what happened.
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
if (writtenPaths.length === 0) {
|
|
721
|
-
throw ApiError.badRequest(
|
|
722
|
-
'No VS Code installation detected. Please install VS Code (stable, Insiders, or Cursor) first.',
|
|
723
|
-
)
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
// Persist so re-sync on restart and unlink work correctly
|
|
727
|
-
bingoSettings.vscodeLinked = true
|
|
728
|
-
bingoSettings.vscodeLinkedPaths = writtenPaths
|
|
729
|
-
bingoSettings.vscodePreLinkClaudeCode = vscodePreLinkClaudeCode
|
|
730
|
-
bingoSettings.env = this.buildBingoEnv(envVars)
|
|
731
|
-
await this.writeSettings(bingoSettings)
|
|
732
|
-
|
|
733
|
-
const result: { paths: string[]; linked: boolean; failedPaths?: string[] } = {
|
|
734
|
-
paths: writtenPaths,
|
|
735
|
-
linked: true,
|
|
736
|
-
}
|
|
737
|
-
if (failedPaths.length > 0) {
|
|
738
|
-
result.failedPaths = failedPaths
|
|
739
|
-
}
|
|
740
|
-
return result
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
/**
|
|
744
|
-
* Restore pre-link bingo env so the CLI no longer routes through the proxy,
|
|
745
|
-
* and reset VS Code settings to their pre-link state (the original claudeCode
|
|
746
|
-
* that was saved in vscodePreLinkClaudeCode). Falls back to a minimal stub
|
|
747
|
-
* if no snapshot exists.
|
|
748
|
-
*
|
|
749
|
-
* Without this, the only way to stop VS Code from using bingo would be to
|
|
750
|
-
* delete ~/.claude/bingo/settings.json entirely — which would also kill CLI
|
|
751
|
-
* routing. Saving/restoring the pre-link env keeps CLI and VS Code independent.
|
|
752
|
-
*/
|
|
753
|
-
async unlinkVscode(): Promise<{ paths: string[]; linked: boolean }> {
|
|
754
|
-
const bingoSettings = await this.readSettings()
|
|
755
|
-
const linkedPaths = (bingoSettings.vscodeLinkedPaths as string[]) || []
|
|
756
|
-
|
|
757
|
-
// Restore bingo env to pre-link state
|
|
758
|
-
const preLinkEnv = bingoSettings.vscodePreLinkEnv
|
|
759
|
-
if (preLinkEnv !== undefined) {
|
|
760
|
-
if (preLinkEnv === null || Object.keys(preLinkEnv as Record<string, unknown>).length === 0) {
|
|
761
|
-
delete bingoSettings.env
|
|
762
|
-
} else {
|
|
763
|
-
bingoSettings.env = preLinkEnv
|
|
764
|
-
}
|
|
765
|
-
delete bingoSettings.vscodePreLinkEnv
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
// Fix #8: Restore original claudeCode snapshot per-path instead of a hardcoded stub.
|
|
769
|
-
// Fall back to { preferredLocation: "panel" } when no snapshot exists for a path.
|
|
770
|
-
const preLinkClaudeCode =
|
|
771
|
-
(bingoSettings.vscodePreLinkClaudeCode as Record<string, unknown> | undefined) || {}
|
|
772
|
-
|
|
773
|
-
for (const settingsPath of linkedPaths) {
|
|
774
|
-
try {
|
|
775
|
-
const raw = await fs.readFile(settingsPath, "utf-8")
|
|
776
|
-
const settings = JSON.parse(raw)
|
|
777
|
-
settings.claudeCode = preLinkClaudeCode[settingsPath] ?? { preferredLocation: "panel" }
|
|
778
|
-
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 4) + '\n')
|
|
779
|
-
} catch {
|
|
780
|
-
// File may have been moved or deleted since linking — harmless
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
bingoSettings.vscodeLinked = false
|
|
785
|
-
delete bingoSettings.vscodeLinkedPaths
|
|
786
|
-
delete bingoSettings.vscodePreLinkClaudeCode
|
|
787
|
-
await this.writeSettings(bingoSettings)
|
|
788
|
-
|
|
789
|
-
return { paths: linkedPaths, linked: false }
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
/**
|
|
793
|
-
* Return current VS Code link status from persisted bingo settings.
|
|
794
|
-
*/
|
|
795
|
-
async getVscodeStatus(): Promise<{ linked: boolean; paths: string[] }> {
|
|
796
|
-
const bingoSettings = await this.readSettings()
|
|
797
|
-
return {
|
|
798
|
-
linked: Boolean(bingoSettings.vscodeLinked),
|
|
799
|
-
paths: (bingoSettings.vscodeLinkedPaths as string[]) || [],
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
/**
|
|
804
|
-
* Initialise the service. If vscodeLinked is true in bingo settings,
|
|
805
|
-
* re-sync VS Code settings so they stay current with the slot config.
|
|
806
|
-
* Should be called once after server startup.
|
|
807
|
-
*/
|
|
808
|
-
async init(): Promise<void> {
|
|
809
|
-
const status = await this.getVscodeStatus()
|
|
810
|
-
if (status.linked) {
|
|
811
|
-
try {
|
|
812
|
-
await this.linkVscode()
|
|
813
|
-
} catch (err: unknown) {
|
|
814
|
-
console.error(
|
|
815
|
-
`[ProviderService] init: failed to re-sync VS Code link: ${err instanceof Error ? err.message : String(err)}`,
|
|
816
|
-
)
|
|
817
|
-
|
|
818
|
-
// Only clear linked state for "VS Code not installed" type errors.
|
|
819
|
-
// Disk failures, corrupt JSON, etc. are transient and should not
|
|
820
|
-
// silently destroy the user's linked state.
|
|
821
|
-
const isNotFound =
|
|
822
|
-
err instanceof Error &&
|
|
823
|
-
(err.message.includes('No VS Code installation detected') ||
|
|
824
|
-
((err as { statusCode?: number }).statusCode === 400))
|
|
825
|
-
|
|
826
|
-
if (isNotFound) {
|
|
827
|
-
const bingoSettings = await this.readSettings()
|
|
828
|
-
bingoSettings.vscodeLinked = false
|
|
829
|
-
delete bingoSettings.vscodeLinkedPaths
|
|
830
|
-
await this.writeSettings(bingoSettings)
|
|
831
|
-
}
|
|
832
|
-
// For other errors, keep existing state — the link may be recoverable.
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
async fetchProviderModels(id: string): Promise<string[]> {
|
|
838
|
-
const provider = await this.getProvider(id)
|
|
839
|
-
const preset = PROVIDER_PRESETS.find(p => p.id === provider.presetId)
|
|
840
|
-
|
|
841
|
-
const base = provider.baseUrl.replace(/\/+$/, '')
|
|
842
|
-
if (!base && provider.presetId !== 'official') return []
|
|
843
|
-
|
|
844
|
-
// Special case for Official
|
|
845
|
-
if (provider.presetId === 'official') {
|
|
846
|
-
return ['claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229', 'claude-3-sonnet-20240229', 'claude-3-haiku-20240307']
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
const modelsUrl = preset?.modelsUrl || '/v1/models'
|
|
850
|
-
// modelsUrl 为绝对 URL 时直接使用(如 DeepSeek: baseUrl 是 anthropic 端,模型列表需走 OpenAI 端)
|
|
851
|
-
const url = modelsUrl.startsWith('http') ? modelsUrl : `${base}${modelsUrl}`
|
|
852
|
-
|
|
853
|
-
const headers: Record<string, string> = {
|
|
854
|
-
'Content-Type': 'application/json',
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
const authStyle = preset?.modelsAuthStyle || (provider.apiFormat === 'anthropic' ? 'x-api-key' : 'bearer')
|
|
858
|
-
if (authStyle === 'x-api-key') {
|
|
859
|
-
headers['x-api-key'] = provider.apiKey
|
|
860
|
-
headers['anthropic-version'] = '2023-06-01'
|
|
861
|
-
} else {
|
|
862
|
-
headers['Authorization'] = `Bearer ${provider.apiKey}`
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
try {
|
|
866
|
-
const directOpts = getDirectFetchOptions()
|
|
867
|
-
const res = await fetch(url, { headers, signal: AbortSignal.timeout(10000), ...directOpts })
|
|
868
|
-
if (!res.ok) {
|
|
869
|
-
const errText = await res.text().catch(() => '')
|
|
870
|
-
console.error(`[ProviderService] Failed to fetch models from ${url}: ${res.status} ${errText}`)
|
|
871
|
-
throw new Error(`HTTP ${res.status}: ${errText.slice(0, 200)}`)
|
|
872
|
-
}
|
|
873
|
-
const data = await res.json() as any
|
|
874
|
-
const dataPath = preset?.modelsDataPath || 'data'
|
|
875
|
-
const list = data[dataPath] ?? data.data ?? data.models ?? []
|
|
876
|
-
if (!Array.isArray(list)) return []
|
|
877
|
-
return list.map((m: any) => (typeof m === 'string' ? m : m.id)).filter(Boolean)
|
|
878
|
-
} catch (err) {
|
|
879
|
-
console.error(`[ProviderService] Error fetching models from ${url}:`, err)
|
|
880
|
-
throw err
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
// --- Test ---
|
|
885
|
-
|
|
886
|
-
async testProvider(
|
|
887
|
-
id: string,
|
|
888
|
-
overrides?: { baseUrl?: string; modelId?: string; apiFormat?: ApiFormat },
|
|
889
|
-
): Promise<ProviderTestResult> {
|
|
890
|
-
const provider = await this.getProvider(id)
|
|
891
|
-
const baseUrl = overrides?.baseUrl || provider.baseUrl
|
|
892
|
-
const apiFormat = overrides?.apiFormat ?? provider.apiFormat ?? 'anthropic'
|
|
893
|
-
|
|
894
|
-
// If no modelId provided, try to fetch from provider or use preset default
|
|
895
|
-
let modelId = overrides?.modelId || provider.models.main
|
|
896
|
-
const needsAutoDetect =
|
|
897
|
-
!modelId ||
|
|
898
|
-
modelId === 'auto' ||
|
|
899
|
-
(apiFormat !== 'anthropic' && modelId.startsWith('claude-'))
|
|
900
|
-
if (needsAutoDetect) {
|
|
901
|
-
const fetched = await this.fetchProviderModels(id).catch(() => [])
|
|
902
|
-
if (fetched.length > 0) {
|
|
903
|
-
modelId = fetched[0] // Use first available model for testing
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
// 兜底:动态拉取失败时,按 apiFormat 使用通用 fallback 模型做连通性测试
|
|
908
|
-
if (!modelId) {
|
|
909
|
-
if (apiFormat === 'anthropic') {
|
|
910
|
-
modelId = 'claude-3-5-haiku-20241022'
|
|
911
|
-
} else {
|
|
912
|
-
// openai_chat / openai_responses: 无法确定模型,返回有意义的错误
|
|
913
|
-
return {
|
|
914
|
-
connectivity: {
|
|
915
|
-
success: false,
|
|
916
|
-
latencyMs: 0,
|
|
917
|
-
error: '无法确定测试用模型:models.main 为空且自动拉取模型列表失败。请先在槽位配置中选择模型,或检查 API Key 和网络连接。',
|
|
918
|
-
},
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
if (!baseUrl || !provider.apiKey) {
|
|
924
|
-
return { connectivity: { success: false, latencyMs: 0, error: 'Missing baseUrl or apiKey' } }
|
|
925
|
-
}
|
|
926
|
-
return this.testProviderConfig({
|
|
927
|
-
baseUrl,
|
|
928
|
-
apiKey: provider.apiKey,
|
|
929
|
-
modelId,
|
|
930
|
-
apiFormat,
|
|
931
|
-
})
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
async testProviderConfig(input: TestProviderInput): Promise<ProviderTestResult> {
|
|
935
|
-
const format: ApiFormat = input.apiFormat ?? 'anthropic'
|
|
936
|
-
const base = input.baseUrl.replace(/\/+$/, '')
|
|
937
|
-
|
|
938
|
-
// ── Step 1: Basic connectivity ───────────────────────────
|
|
939
|
-
// Directly call the upstream API to verify URL, key, and model.
|
|
940
|
-
const step1 = await this.testConnectivity(base, input.apiKey, input.modelId, format)
|
|
941
|
-
|
|
942
|
-
// If connectivity failed, no point running step 2
|
|
943
|
-
if (!step1.success) {
|
|
944
|
-
return { connectivity: step1 }
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
// For native Anthropic format, no proxy pipeline to test
|
|
948
|
-
if (format === 'anthropic') {
|
|
949
|
-
return { connectivity: step1 }
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
// ── Step 2: Full proxy pipeline ──────────────────────────
|
|
953
|
-
// Anthropic request → transform → upstream → transform back → validate
|
|
954
|
-
const step2 = await this.testProxyPipeline(base, input.apiKey, input.modelId, format)
|
|
955
|
-
|
|
956
|
-
return { connectivity: step1, proxy: step2 }
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
/** Step 1: Direct upstream call to verify connectivity, auth, and model. */
|
|
960
|
-
private async testConnectivity(
|
|
961
|
-
base: string,
|
|
962
|
-
apiKey: string,
|
|
963
|
-
modelId: string,
|
|
964
|
-
format: ApiFormat,
|
|
965
|
-
): Promise<ProviderTestStepResult> {
|
|
966
|
-
const start = Date.now()
|
|
967
|
-
try {
|
|
968
|
-
const { url, headers, body } = buildDirectTestRequest(base, apiKey, modelId, format)
|
|
969
|
-
const directOpts = getDirectFetchOptions()
|
|
970
|
-
const response = await fetch(url, {
|
|
971
|
-
method: 'POST',
|
|
972
|
-
headers,
|
|
973
|
-
body: JSON.stringify(body),
|
|
974
|
-
signal: AbortSignal.timeout(30000),
|
|
975
|
-
...directOpts,
|
|
976
|
-
})
|
|
977
|
-
|
|
978
|
-
const latencyMs = Date.now() - start
|
|
979
|
-
const resBody = await response.json().catch(() => null) as Record<string, unknown> | null
|
|
980
|
-
|
|
981
|
-
if (!response.ok) {
|
|
982
|
-
let error = `HTTP ${response.status}`
|
|
983
|
-
if (resBody?.error && typeof resBody.error === 'object') {
|
|
984
|
-
error = ((resBody.error as Record<string, unknown>).message as string) || error
|
|
985
|
-
}
|
|
986
|
-
return { success: false, latencyMs, error, modelUsed: modelId, httpStatus: response.status }
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
// Validate response structure
|
|
990
|
-
const valid = validateResponseBody(resBody, format)
|
|
991
|
-
if (!valid.ok) {
|
|
992
|
-
return { success: false, latencyMs, error: valid.error, modelUsed: modelId, httpStatus: response.status }
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
return { success: true, latencyMs, modelUsed: valid.model || modelId, httpStatus: response.status }
|
|
996
|
-
} catch (err: unknown) {
|
|
997
|
-
const latencyMs = Date.now() - start
|
|
998
|
-
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
|
999
|
-
return { success: false, latencyMs, error: 'Request timed out (30s)', modelUsed: modelId }
|
|
1000
|
-
}
|
|
1001
|
-
return { success: false, latencyMs, error: err instanceof Error ? err.message : String(err), modelUsed: modelId }
|
|
1002
|
-
}
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
/** Step 2: Full proxy pipeline — Anthropic → transform → upstream → transform back → validate. */
|
|
1006
|
-
private async testProxyPipeline(
|
|
1007
|
-
base: string,
|
|
1008
|
-
apiKey: string,
|
|
1009
|
-
modelId: string,
|
|
1010
|
-
format: 'openai_chat' | 'openai_responses',
|
|
1011
|
-
): Promise<ProviderTestStepResult> {
|
|
1012
|
-
const start = Date.now()
|
|
1013
|
-
try {
|
|
1014
|
-
// Build an Anthropic Messages API request (same shape as what CLI sends)
|
|
1015
|
-
const anthropicReq: AnthropicRequest = {
|
|
1016
|
-
model: modelId,
|
|
1017
|
-
max_tokens: 64,
|
|
1018
|
-
messages: [{ role: 'user', content: 'Say "ok" and nothing else.' }],
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
// Transform to OpenAI format
|
|
1022
|
-
let upstreamUrl: string
|
|
1023
|
-
let transformedBody: unknown
|
|
1024
|
-
if (format === 'openai_chat') {
|
|
1025
|
-
transformedBody = anthropicToOpenaiChat(anthropicReq)
|
|
1026
|
-
upstreamUrl = `${base}/v1/chat/completions`
|
|
1027
|
-
} else {
|
|
1028
|
-
transformedBody = anthropicToOpenaiResponses(anthropicReq)
|
|
1029
|
-
upstreamUrl = `${base}/v1/responses`
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
// Call upstream with transformed request
|
|
1033
|
-
const directOpts = getDirectFetchOptions()
|
|
1034
|
-
const response = await fetch(upstreamUrl, {
|
|
1035
|
-
method: 'POST',
|
|
1036
|
-
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
1037
|
-
body: JSON.stringify(transformedBody),
|
|
1038
|
-
signal: AbortSignal.timeout(30000),
|
|
1039
|
-
...directOpts,
|
|
1040
|
-
})
|
|
1041
|
-
|
|
1042
|
-
if (!response.ok) {
|
|
1043
|
-
const latencyMs = Date.now() - start
|
|
1044
|
-
const errText = await response.text().catch(() => '')
|
|
1045
|
-
return { success: false, latencyMs, modelUsed: modelId, httpStatus: response.status,
|
|
1046
|
-
error: `Upstream HTTP ${response.status}: ${errText.slice(0, 200)}` }
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
// Transform response back to Anthropic format
|
|
1050
|
-
const responseBody = await response.json()
|
|
1051
|
-
const anthropicRes = format === 'openai_chat'
|
|
1052
|
-
? openaiChatToAnthropic(responseBody, modelId)
|
|
1053
|
-
: openaiResponsesToAnthropic(responseBody, modelId)
|
|
1054
|
-
|
|
1055
|
-
const latencyMs = Date.now() - start
|
|
1056
|
-
|
|
1057
|
-
// Validate the final Anthropic response
|
|
1058
|
-
if (anthropicRes.type !== 'message' || !Array.isArray(anthropicRes.content)) {
|
|
1059
|
-
return { success: false, latencyMs, modelUsed: modelId,
|
|
1060
|
-
error: 'Proxy transform produced invalid Anthropic response' }
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
return { success: true, latencyMs, modelUsed: anthropicRes.model || modelId, httpStatus: response.status }
|
|
1064
|
-
} catch (err: unknown) {
|
|
1065
|
-
const latencyMs = Date.now() - start
|
|
1066
|
-
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
|
1067
|
-
return { success: false, latencyMs, error: 'Proxy pipeline timed out (30s)', modelUsed: modelId }
|
|
1068
|
-
}
|
|
1069
|
-
return { success: false, latencyMs, error: err instanceof Error ? err.message : String(err), modelUsed: modelId }
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
// ─── Helpers ───────────────────────────────────────────────
|
|
1075
|
-
|
|
1076
|
-
function buildDirectTestRequest(
|
|
1077
|
-
base: string,
|
|
1078
|
-
apiKey: string,
|
|
1079
|
-
modelId: string,
|
|
1080
|
-
format: ApiFormat,
|
|
1081
|
-
): { url: string; headers: Record<string, string>; body: Record<string, unknown> } {
|
|
1082
|
-
const prompt = 'Say "ok" and nothing else.'
|
|
1083
|
-
|
|
1084
|
-
if (format === 'openai_chat') {
|
|
1085
|
-
return {
|
|
1086
|
-
url: `${base}/v1/chat/completions`,
|
|
1087
|
-
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
1088
|
-
body: { model: modelId, max_tokens: 16, messages: [{ role: 'user', content: prompt }] },
|
|
1089
|
-
}
|
|
1090
|
-
}
|
|
1091
|
-
if (format === 'openai_responses') {
|
|
1092
|
-
return {
|
|
1093
|
-
url: `${base}/v1/responses`,
|
|
1094
|
-
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
1095
|
-
body: { model: modelId, max_output_tokens: 16, input: [{ type: 'message', role: 'user', content: prompt }] },
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
1098
|
-
// anthropic
|
|
1099
|
-
return {
|
|
1100
|
-
url: `${base}/v1/messages`,
|
|
1101
|
-
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
|
|
1102
|
-
body: { model: modelId, max_tokens: 16, messages: [{ role: 'user', content: prompt }] },
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
function validateResponseBody(
|
|
1107
|
-
body: Record<string, unknown> | null,
|
|
1108
|
-
format: ApiFormat,
|
|
1109
|
-
): { ok: true; model?: string } | { ok: false; error: string } {
|
|
1110
|
-
if (!body) return { ok: false, error: 'Empty response — not a valid API endpoint' }
|
|
1111
|
-
if (body.error && typeof body.error === 'object') {
|
|
1112
|
-
return { ok: false, error: ((body.error as Record<string, unknown>).message as string) || 'Error in response body' }
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
if (format === 'openai_chat') {
|
|
1116
|
-
if (!Array.isArray(body.choices) || body.choices.length === 0) {
|
|
1117
|
-
return { ok: false, error: 'Response missing choices — not a valid Chat Completions endpoint' }
|
|
1118
|
-
}
|
|
1119
|
-
return { ok: true, model: (body.model as string) || undefined }
|
|
1120
|
-
}
|
|
1121
|
-
if (format === 'openai_responses') {
|
|
1122
|
-
if (!Array.isArray(body.output)) {
|
|
1123
|
-
return { ok: false, error: 'Response missing output — not a valid Responses API endpoint' }
|
|
1124
|
-
}
|
|
1125
|
-
return { ok: true, model: (body.model as string) || undefined }
|
|
1126
|
-
}
|
|
1127
|
-
// anthropic
|
|
1128
|
-
if (body.type !== 'message' || !Array.isArray(body.content)) {
|
|
1129
|
-
return { ok: false, error: 'Not a valid Anthropic Messages endpoint' }
|
|
1130
|
-
}
|
|
1131
|
-
return { ok: true, model: (body.model as string) || undefined }
|
|
1
|
+
/**
|
|
2
|
+
* Provider Service — preset-based provider configuration
|
|
3
|
+
*
|
|
4
|
+
* Storage: ~/.claude/bingo/providers.json (lightweight index)
|
|
5
|
+
* Active provider env vars written to ~/.claude/bingo/settings.json
|
|
6
|
+
* (isolated from the original Claude Code's ~/.claude/settings.json)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from 'fs/promises'
|
|
10
|
+
import * as path from 'path'
|
|
11
|
+
import * as os from 'os'
|
|
12
|
+
import { getDirectFetchOptions } from '../../utils/proxy.ts'
|
|
13
|
+
import { ApiError } from '../middleware/errorHandler.js'
|
|
14
|
+
import { anthropicToOpenaiChat } from '../proxy/transform/anthropicToOpenaiChat.js'
|
|
15
|
+
import { anthropicToOpenaiResponses } from '../proxy/transform/anthropicToOpenaiResponses.js'
|
|
16
|
+
import { openaiChatToAnthropic } from '../proxy/transform/openaiChatToAnthropic.js'
|
|
17
|
+
import { openaiResponsesToAnthropic } from '../proxy/transform/openaiResponsesToAnthropic.js'
|
|
18
|
+
import type { AnthropicRequest, AnthropicResponse } from '../proxy/transform/types.js'
|
|
19
|
+
import { PROVIDER_PRESETS } from '../config/providerPresets.js'
|
|
20
|
+
import type {
|
|
21
|
+
SavedProvider,
|
|
22
|
+
ProvidersIndex,
|
|
23
|
+
CreateProviderInput,
|
|
24
|
+
UpdateProviderInput,
|
|
25
|
+
TestProviderInput,
|
|
26
|
+
ProviderTestResult,
|
|
27
|
+
ProviderTestStepResult,
|
|
28
|
+
ApiFormat,
|
|
29
|
+
SlotName,
|
|
30
|
+
SlotEntry,
|
|
31
|
+
SlotTable,
|
|
32
|
+
} from '../types/provider.ts'
|
|
33
|
+
import { SlotTableSchema } from '../types/provider.ts'
|
|
34
|
+
|
|
35
|
+
// NOTE: When the proxy is active, ANTHROPIC_AUTH_TOKEN is written as "proxy-managed"
|
|
36
|
+
// (a sentinel placeholder). This tells the proxy to resolve the real API key from the
|
|
37
|
+
// active provider config at request time. It is NOT a real credential and is expected
|
|
38
|
+
// to be overwritten every time a provider is activated or VS Code is linked.
|
|
39
|
+
const MANAGED_ENV_KEYS = [
|
|
40
|
+
'ANTHROPIC_BASE_URL',
|
|
41
|
+
'ANTHROPIC_AUTH_TOKEN',
|
|
42
|
+
'ANTHROPIC_MODEL',
|
|
43
|
+
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
|
44
|
+
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
|
45
|
+
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
|
46
|
+
] as const
|
|
47
|
+
|
|
48
|
+
const CLAUDE_DEFAULT_LABELS: Record<SlotName, string> = {
|
|
49
|
+
main: 'claude-sonnet-4-5',
|
|
50
|
+
haiku: 'claude-haiku-4-5',
|
|
51
|
+
sonnet: 'claude-sonnet-4-5',
|
|
52
|
+
opus: 'claude-opus-4-7',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const VSCODE_EDITOR_NAMES = ['Code', 'Code - Insiders', 'Cursor'] as const
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Return candidate VS Code settings.json paths for the current platform.
|
|
59
|
+
* These are checked for existence (editor directory must exist) before writing.
|
|
60
|
+
*/
|
|
61
|
+
function getVscodeSettingsPaths(): string[] {
|
|
62
|
+
const home = os.homedir()
|
|
63
|
+
const paths: string[] = []
|
|
64
|
+
|
|
65
|
+
if (process.platform === 'win32') {
|
|
66
|
+
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming')
|
|
67
|
+
for (const name of VSCODE_EDITOR_NAMES) {
|
|
68
|
+
paths.push(path.join(appData, name, 'User', 'settings.json'))
|
|
69
|
+
}
|
|
70
|
+
} else if (process.platform === 'darwin') {
|
|
71
|
+
const lib = path.join(home, 'Library', 'Application Support')
|
|
72
|
+
for (const name of VSCODE_EDITOR_NAMES) {
|
|
73
|
+
paths.push(path.join(lib, name, 'User', 'settings.json'))
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
// Linux / other Unix
|
|
77
|
+
const config = path.join(home, '.config')
|
|
78
|
+
for (const name of VSCODE_EDITOR_NAMES) {
|
|
79
|
+
paths.push(path.join(config, name, 'User', 'settings.json'))
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return paths
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const DEFAULT_INDEX: ProvidersIndex = { activeId: null, providers: [] }
|
|
87
|
+
|
|
88
|
+
export class ProviderService {
|
|
89
|
+
private static serverPort = 3456
|
|
90
|
+
|
|
91
|
+
static setServerPort(port: number): void {
|
|
92
|
+
ProviderService.serverPort = port
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
static getServerPort(): number {
|
|
96
|
+
return ProviderService.serverPort
|
|
97
|
+
}
|
|
98
|
+
private getConfigDir(): string {
|
|
99
|
+
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private getCcHahaDir(): string {
|
|
103
|
+
return path.join(this.getConfigDir(), 'bingo')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private getIndexPath(): string {
|
|
107
|
+
return path.join(this.getCcHahaDir(), 'providers.json')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private getSettingsPath(): string {
|
|
111
|
+
return path.join(this.getCcHahaDir(), 'settings.json')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private getSlotsPath(): string {
|
|
115
|
+
return path.join(this.getCcHahaDir(), 'slots.json')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async readIndex(): Promise<ProvidersIndex> {
|
|
119
|
+
try {
|
|
120
|
+
const raw = await fs.readFile(this.getIndexPath(), 'utf-8')
|
|
121
|
+
return JSON.parse(raw) as ProvidersIndex
|
|
122
|
+
} catch (err: unknown) {
|
|
123
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
124
|
+
return { ...DEFAULT_INDEX, providers: [] }
|
|
125
|
+
}
|
|
126
|
+
throw ApiError.internal(`Failed to read providers index: ${err}`)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private async writeIndex(index: ProvidersIndex): Promise<void> {
|
|
131
|
+
const filePath = this.getIndexPath()
|
|
132
|
+
const dir = path.dirname(filePath)
|
|
133
|
+
await fs.mkdir(dir, { recursive: true })
|
|
134
|
+
|
|
135
|
+
const tmpFile = `${filePath}.tmp.${Date.now()}`
|
|
136
|
+
try {
|
|
137
|
+
await fs.writeFile(tmpFile, JSON.stringify(index, null, 2) + '\n', 'utf-8')
|
|
138
|
+
await fs.rename(tmpFile, filePath)
|
|
139
|
+
} catch (err) {
|
|
140
|
+
await fs.unlink(tmpFile).catch(() => {})
|
|
141
|
+
throw ApiError.internal(`Failed to write providers index: ${err}`)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private async readSettings(): Promise<Record<string, unknown>> {
|
|
146
|
+
try {
|
|
147
|
+
const raw = await fs.readFile(this.getSettingsPath(), 'utf-8')
|
|
148
|
+
return JSON.parse(raw) as Record<string, unknown>
|
|
149
|
+
} catch (err: unknown) {
|
|
150
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}
|
|
151
|
+
throw ApiError.internal(`Failed to read settings.json: ${err}`)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async writeSettings(settings: Record<string, unknown>): Promise<void> {
|
|
156
|
+
const filePath = this.getSettingsPath()
|
|
157
|
+
const dir = path.dirname(filePath)
|
|
158
|
+
await fs.mkdir(dir, { recursive: true })
|
|
159
|
+
|
|
160
|
+
const tmpFile = `${filePath}.tmp.${Date.now()}`
|
|
161
|
+
try {
|
|
162
|
+
await fs.writeFile(tmpFile, JSON.stringify(settings, null, 2) + '\n', 'utf-8')
|
|
163
|
+
await fs.rename(tmpFile, filePath)
|
|
164
|
+
} catch (err) {
|
|
165
|
+
await fs.unlink(tmpFile).catch(() => {})
|
|
166
|
+
throw ApiError.internal(`Failed to write settings.json: ${err}`)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- CRUD ---
|
|
171
|
+
|
|
172
|
+
async listProviders(): Promise<{ providers: SavedProvider[]; activeId: string | null }> {
|
|
173
|
+
const index = await this.readIndex()
|
|
174
|
+
return { providers: index.providers, activeId: index.activeId }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async getProvider(id: string): Promise<SavedProvider> {
|
|
178
|
+
const index = await this.readIndex()
|
|
179
|
+
const provider = index.providers.find((p) => p.id === id)
|
|
180
|
+
if (!provider) throw ApiError.notFound(`Provider not found: ${id}`)
|
|
181
|
+
return provider
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async addProvider(input: CreateProviderInput): Promise<SavedProvider> {
|
|
185
|
+
const index = await this.readIndex()
|
|
186
|
+
|
|
187
|
+
const provider: SavedProvider = {
|
|
188
|
+
id: crypto.randomUUID(),
|
|
189
|
+
presetId: input.presetId,
|
|
190
|
+
name: input.name,
|
|
191
|
+
apiKey: input.apiKey,
|
|
192
|
+
baseUrl: input.baseUrl,
|
|
193
|
+
apiFormat: input.apiFormat ?? 'anthropic',
|
|
194
|
+
models: input.models,
|
|
195
|
+
...(input.notes !== undefined && { notes: input.notes }),
|
|
196
|
+
...(input.extra !== undefined && { extra: input.extra }),
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
index.providers.push(provider)
|
|
200
|
+
await this.writeIndex(index)
|
|
201
|
+
return provider
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async updateProvider(id: string, input: UpdateProviderInput): Promise<SavedProvider> {
|
|
205
|
+
const index = await this.readIndex()
|
|
206
|
+
const idx = index.providers.findIndex((p) => p.id === id)
|
|
207
|
+
if (idx === -1) throw ApiError.notFound(`Provider not found: ${id}`)
|
|
208
|
+
|
|
209
|
+
const existing = index.providers[idx]
|
|
210
|
+
const updated: SavedProvider = {
|
|
211
|
+
...existing,
|
|
212
|
+
...(input.name !== undefined && { name: input.name }),
|
|
213
|
+
...(input.apiKey !== undefined && { apiKey: input.apiKey }),
|
|
214
|
+
...(input.baseUrl !== undefined && { baseUrl: input.baseUrl }),
|
|
215
|
+
...(input.apiFormat !== undefined && { apiFormat: input.apiFormat }),
|
|
216
|
+
...(input.models !== undefined && { models: input.models }),
|
|
217
|
+
...(input.notes !== undefined && { notes: input.notes }),
|
|
218
|
+
...(input.extra !== undefined && { extra: input.extra }),
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
index.providers[idx] = updated
|
|
222
|
+
await this.writeIndex(index)
|
|
223
|
+
|
|
224
|
+
if (index.activeId === id) {
|
|
225
|
+
await this.syncToSettings(updated)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return updated
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async deleteProvider(id: string): Promise<void> {
|
|
232
|
+
const index = await this.readIndex()
|
|
233
|
+
const idx = index.providers.findIndex((p) => p.id === id)
|
|
234
|
+
if (idx === -1) throw ApiError.notFound(`Provider not found: ${id}`)
|
|
235
|
+
|
|
236
|
+
if (index.activeId === id) {
|
|
237
|
+
throw ApiError.conflict('Cannot delete the active provider. Switch to another provider first.')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
index.providers.splice(idx, 1)
|
|
241
|
+
await this.writeIndex(index)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// --- Activation ---
|
|
245
|
+
|
|
246
|
+
async activateProvider(id: string): Promise<void> {
|
|
247
|
+
const index = await this.readIndex()
|
|
248
|
+
const provider = index.providers.find((p) => p.id === id)
|
|
249
|
+
if (!provider) throw ApiError.notFound(`Provider not found: ${id}`)
|
|
250
|
+
|
|
251
|
+
index.activeId = id
|
|
252
|
+
await this.writeIndex(index)
|
|
253
|
+
|
|
254
|
+
if (provider.presetId === 'official') {
|
|
255
|
+
await this.clearProviderFromSettings()
|
|
256
|
+
} else {
|
|
257
|
+
await this.syncToSettings(provider)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async activateOfficial(): Promise<void> {
|
|
262
|
+
const index = await this.readIndex()
|
|
263
|
+
index.activeId = null
|
|
264
|
+
await this.writeIndex(index)
|
|
265
|
+
await this.clearProviderFromSettings()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// --- Settings sync ---
|
|
269
|
+
|
|
270
|
+
private async syncToSettings(provider: SavedProvider): Promise<void> {
|
|
271
|
+
const settings = await this.readSettings()
|
|
272
|
+
const existingEnv = (settings.env as Record<string, string>) || {}
|
|
273
|
+
|
|
274
|
+
const needsProxy = provider.apiFormat != null && provider.apiFormat !== 'anthropic'
|
|
275
|
+
const baseUrl = needsProxy
|
|
276
|
+
? `http://127.0.0.1:${ProviderService.serverPort}/proxy`
|
|
277
|
+
: provider.baseUrl
|
|
278
|
+
|
|
279
|
+
settings.env = {
|
|
280
|
+
...existingEnv,
|
|
281
|
+
ANTHROPIC_BASE_URL: baseUrl,
|
|
282
|
+
ANTHROPIC_AUTH_TOKEN: needsProxy ? 'proxy-managed' : provider.apiKey,
|
|
283
|
+
ANTHROPIC_MODEL: provider.models.main,
|
|
284
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: provider.models.haiku,
|
|
285
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: provider.models.sonnet,
|
|
286
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL: provider.models.opus,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
await this.writeSettings(settings)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private async clearProviderFromSettings(): Promise<void> {
|
|
293
|
+
const settings = await this.readSettings()
|
|
294
|
+
const env = (settings.env as Record<string, string>) || {}
|
|
295
|
+
|
|
296
|
+
for (const key of MANAGED_ENV_KEYS) {
|
|
297
|
+
delete env[key]
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
settings.env = env
|
|
301
|
+
if (Object.keys(env).length === 0) {
|
|
302
|
+
delete settings.env
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
await this.writeSettings(settings)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Sync settings.json based on the current slot table.
|
|
310
|
+
*
|
|
311
|
+
* Examines all configured slots to determine whether the CLI should connect
|
|
312
|
+
* through the local proxy or directly. If ANY slot uses a non-anthropic format
|
|
313
|
+
* (openai_chat, openai_responses), the proxy is required and settings.json is
|
|
314
|
+
* written with:
|
|
315
|
+
* - ANTHROPIC_BASE_URL → http://127.0.0.1:{port}/proxy
|
|
316
|
+
* - ANTHROPIC_AUTH_TOKEN → "proxy-managed"
|
|
317
|
+
*
|
|
318
|
+
* Model env vars (ANTHROPIC_MODEL, ANTHROPIC_DEFAULT_*_MODEL) are populated
|
|
319
|
+
* from the slot table so the CLI requests the correct model names, which the
|
|
320
|
+
* proxy's identifySlot() then routes to the right provider.
|
|
321
|
+
*
|
|
322
|
+
* If ALL configured slots use native anthropic format, we write the main
|
|
323
|
+
* slot's provider info directly (no proxy needed).
|
|
324
|
+
*
|
|
325
|
+
* If no slots are configured at all, settings.json is left unchanged.
|
|
326
|
+
*/
|
|
327
|
+
private async syncSettingsForSlots(slots: SlotTable): Promise<void> {
|
|
328
|
+
const index = await this.readIndex()
|
|
329
|
+
|
|
330
|
+
// Collect resolved slot info
|
|
331
|
+
type ResolvedSlot = { slot: SlotName; provider: SavedProvider; modelId: string; label?: string | null }
|
|
332
|
+
const resolved: ResolvedSlot[] = []
|
|
333
|
+
for (const slotName of ['main', 'haiku', 'sonnet', 'opus'] as const) {
|
|
334
|
+
const entry = slots[slotName]
|
|
335
|
+
if (!entry) continue
|
|
336
|
+
const provider = index.providers.find((p) => p.id === entry.providerId)
|
|
337
|
+
if (!provider) continue
|
|
338
|
+
resolved.push({ slot: slotName, provider, modelId: entry.modelId, label: entry.label })
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// No slots configured at all — don't touch settings.json
|
|
342
|
+
if (resolved.length === 0) return
|
|
343
|
+
|
|
344
|
+
const needsProxy = resolved.some(
|
|
345
|
+
(r) => r.provider.apiFormat != null && r.provider.apiFormat !== 'anthropic',
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
const settings = await this.readSettings()
|
|
349
|
+
const existingEnv = (settings.env as Record<string, string>) || {}
|
|
350
|
+
|
|
351
|
+
// Build model env vars from slot table.
|
|
352
|
+
//
|
|
353
|
+
// The proxy's identifySlot() routes requests by looking for keywords
|
|
354
|
+
// (haiku/sonnet/opus) in the model name the CLI sends. If the actual
|
|
355
|
+
// model ID (e.g., "deepseek-chat") doesn't contain the keyword, the
|
|
356
|
+
// proxy would misroute to "main". To fix this, we ensure the env var
|
|
357
|
+
// value always contains the slot keyword so identifySlot() can match.
|
|
358
|
+
const modelEnv: Record<string, string> = {}
|
|
359
|
+
for (const r of resolved) {
|
|
360
|
+
const rawName = r.label || r.modelId
|
|
361
|
+
switch (r.slot) {
|
|
362
|
+
case 'main':
|
|
363
|
+
modelEnv.ANTHROPIC_MODEL = rawName
|
|
364
|
+
break
|
|
365
|
+
case 'haiku': {
|
|
366
|
+
// Ensure the value contains "haiku" for identifySlot() routing
|
|
367
|
+
const name = /haiku/i.test(rawName) ? rawName : `${rawName}-haiku`
|
|
368
|
+
modelEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL = name
|
|
369
|
+
break
|
|
370
|
+
}
|
|
371
|
+
case 'sonnet': {
|
|
372
|
+
const name = /sonnet/i.test(rawName) ? rawName : `${rawName}-sonnet`
|
|
373
|
+
modelEnv.ANTHROPIC_DEFAULT_SONNET_MODEL = name
|
|
374
|
+
break
|
|
375
|
+
}
|
|
376
|
+
case 'opus': {
|
|
377
|
+
const name = /opus/i.test(rawName) ? rawName : `${rawName}-opus`
|
|
378
|
+
modelEnv.ANTHROPIC_DEFAULT_OPUS_MODEL = name
|
|
379
|
+
break
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (needsProxy) {
|
|
385
|
+
settings.env = {
|
|
386
|
+
...existingEnv,
|
|
387
|
+
ANTHROPIC_BASE_URL: `http://127.0.0.1:${ProviderService.serverPort}/proxy`,
|
|
388
|
+
ANTHROPIC_AUTH_TOKEN: 'proxy-managed',
|
|
389
|
+
...modelEnv,
|
|
390
|
+
}
|
|
391
|
+
} else {
|
|
392
|
+
// All slots are native anthropic — use main slot's provider directly
|
|
393
|
+
const mainSlot = resolved.find((r) => r.slot === 'main') || resolved[0]
|
|
394
|
+
settings.env = {
|
|
395
|
+
...existingEnv,
|
|
396
|
+
ANTHROPIC_BASE_URL: mainSlot.provider.baseUrl,
|
|
397
|
+
ANTHROPIC_AUTH_TOKEN: mainSlot.provider.apiKey,
|
|
398
|
+
...modelEnv,
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
await this.writeSettings(settings)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// --- Auth status ---
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Check whether any usable auth exists:
|
|
409
|
+
* 1. A bingo provider is active → has auth
|
|
410
|
+
* 2. Original ~/.claude/settings.json has ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY → has auth
|
|
411
|
+
* 3. process.env already has ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN → has auth
|
|
412
|
+
* 4. None of the above → needs setup
|
|
413
|
+
*/
|
|
414
|
+
async checkAuthStatus(): Promise<{
|
|
415
|
+
hasAuth: boolean
|
|
416
|
+
source: 'bingo-provider' | 'original-settings' | 'env' | 'none'
|
|
417
|
+
activeProvider?: string
|
|
418
|
+
}> {
|
|
419
|
+
// 1. Check bingo active provider
|
|
420
|
+
const index = await this.readIndex()
|
|
421
|
+
if (index.activeId) {
|
|
422
|
+
const provider = index.providers.find(p => p.id === index.activeId)
|
|
423
|
+
if (provider?.apiKey) {
|
|
424
|
+
return { hasAuth: true, source: 'bingo-provider', activeProvider: provider.name }
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// 2. Check process.env (covers .env file + inherited env)
|
|
429
|
+
if (process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN) {
|
|
430
|
+
return { hasAuth: true, source: 'env' }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// 3. Check original ~/.claude/settings.json
|
|
434
|
+
try {
|
|
435
|
+
const originalPath = path.join(this.getConfigDir(), 'settings.json')
|
|
436
|
+
const raw = await fs.readFile(originalPath, 'utf-8')
|
|
437
|
+
const settings = JSON.parse(raw) as { env?: Record<string, string> }
|
|
438
|
+
const env = settings.env ?? {}
|
|
439
|
+
if (env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY) {
|
|
440
|
+
return { hasAuth: true, source: 'original-settings' }
|
|
441
|
+
}
|
|
442
|
+
} catch {
|
|
443
|
+
// File doesn't exist or invalid
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return { hasAuth: false, source: 'none' }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// --- Proxy support ---
|
|
450
|
+
|
|
451
|
+
async getActiveProviderForProxy(): Promise<{
|
|
452
|
+
baseUrl: string
|
|
453
|
+
apiKey: string
|
|
454
|
+
apiFormat: ApiFormat
|
|
455
|
+
} | null> {
|
|
456
|
+
const index = await this.readIndex()
|
|
457
|
+
if (!index.activeId) return null
|
|
458
|
+
const provider = index.providers.find((p) => p.id === index.activeId)
|
|
459
|
+
if (!provider) return null
|
|
460
|
+
return {
|
|
461
|
+
baseUrl: provider.baseUrl,
|
|
462
|
+
apiKey: provider.apiKey,
|
|
463
|
+
apiFormat: provider.apiFormat ?? 'anthropic',
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// --- Slot routing ---
|
|
468
|
+
|
|
469
|
+
async readSlots(): Promise<SlotTable> {
|
|
470
|
+
try {
|
|
471
|
+
const raw = await fs.readFile(this.getSlotsPath(), 'utf-8')
|
|
472
|
+
return SlotTableSchema.parse(JSON.parse(raw))
|
|
473
|
+
} catch (err: unknown) {
|
|
474
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
475
|
+
return SlotTableSchema.parse({})
|
|
476
|
+
}
|
|
477
|
+
throw ApiError.internal(`Failed to read slots: ${err}`)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async writeSlots(slots: SlotTable): Promise<void> {
|
|
482
|
+
const filePath = this.getSlotsPath()
|
|
483
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
|
484
|
+
const tmp = `${filePath}.tmp.${Date.now()}`
|
|
485
|
+
try {
|
|
486
|
+
await fs.writeFile(tmp, JSON.stringify(slots, null, 2) + '\n', 'utf-8')
|
|
487
|
+
await fs.rename(tmp, filePath)
|
|
488
|
+
} catch (err) {
|
|
489
|
+
await fs.unlink(tmp).catch(() => {})
|
|
490
|
+
throw ApiError.internal(`Failed to write slots: ${err}`)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async setSlot(slot: SlotName, entry: SlotEntry): Promise<SlotTable> {
|
|
495
|
+
const slots = await this.readSlots()
|
|
496
|
+
slots[slot] = entry
|
|
497
|
+
await this.writeSlots(slots)
|
|
498
|
+
|
|
499
|
+
// Auto-sync settings.json so the CLI knows where to connect.
|
|
500
|
+
// When any slot uses a non-anthropic provider, the CLI must go through
|
|
501
|
+
// the local proxy; when all slots are anthropic (or empty), we can
|
|
502
|
+
// write direct provider info instead.
|
|
503
|
+
await this.syncSettingsForSlots(slots)
|
|
504
|
+
|
|
505
|
+
return slots
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async getProviderForSlot(slotName: SlotName): Promise<{
|
|
509
|
+
baseUrl: string
|
|
510
|
+
apiKey: string
|
|
511
|
+
apiFormat: ApiFormat
|
|
512
|
+
modelId: string
|
|
513
|
+
label?: string | null
|
|
514
|
+
} | null> {
|
|
515
|
+
const slots = await this.readSlots()
|
|
516
|
+
const entry = slots[slotName]
|
|
517
|
+
if (!entry) return null
|
|
518
|
+
const index = await this.readIndex()
|
|
519
|
+
const provider = index.providers.find((p) => p.id === entry.providerId)
|
|
520
|
+
if (!provider) return null
|
|
521
|
+
|
|
522
|
+
// Auto-fill Claude model names as label when user hasn't set one.
|
|
523
|
+
// This lets closed-source clients (e.g. VS Code extension) see Claude
|
|
524
|
+
// model names while the CLI continues to show the real upstream modelId
|
|
525
|
+
// (syncSettingsForSlots reads raw entry.label, which stays null).
|
|
526
|
+
return {
|
|
527
|
+
baseUrl: provider.baseUrl,
|
|
528
|
+
apiKey: provider.apiKey,
|
|
529
|
+
apiFormat: provider.apiFormat ?? 'anthropic',
|
|
530
|
+
modelId: entry.modelId,
|
|
531
|
+
label: entry.label || CLAUDE_DEFAULT_LABELS[slotName],
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// --- VS Code linking ---
|
|
536
|
+
|
|
537
|
+
private buildBingoEnv(envVars: Array<{ name: string; value: string }>): Record<string, string> {
|
|
538
|
+
const env: Record<string, string> = {}
|
|
539
|
+
for (const v of envVars) {
|
|
540
|
+
env[v.name] = v.value
|
|
541
|
+
}
|
|
542
|
+
return env
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Build the environmentVariables array for VS Code claudeCode settings
|
|
547
|
+
* based on current slot configuration. Uses user-set labels or falls back
|
|
548
|
+
* to Claude default model names so the VS Code extension shows the right labels.
|
|
549
|
+
*/
|
|
550
|
+
private async buildVscodeEnvVars(): Promise<Array<{ name: string; value: string }>> {
|
|
551
|
+
const slots = await this.readSlots()
|
|
552
|
+
const index = await this.readIndex()
|
|
553
|
+
|
|
554
|
+
function getSlotLabel(slotName: SlotName): string | null {
|
|
555
|
+
const entry = slots[slotName]
|
|
556
|
+
if (!entry) return null
|
|
557
|
+
const provider = index.providers.find((p) => p.id === entry.providerId)
|
|
558
|
+
if (!provider) return null
|
|
559
|
+
return entry.label || CLAUDE_DEFAULT_LABELS[slotName]
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const envVars: Array<{ name: string; value: string }> = [
|
|
563
|
+
{ name: 'ANTHROPIC_BASE_URL', value: `http://127.0.0.1:${ProviderService.serverPort}/proxy` },
|
|
564
|
+
{ name: 'ANTHROPIC_AUTH_TOKEN', value: 'proxy-managed' },
|
|
565
|
+
]
|
|
566
|
+
|
|
567
|
+
const mainLabel = getSlotLabel("main")
|
|
568
|
+
if (mainLabel) {
|
|
569
|
+
envVars.push({ name: "ANTHROPIC_MODEL", value: mainLabel })
|
|
570
|
+
}
|
|
571
|
+
const haikuLabel = getSlotLabel("haiku")
|
|
572
|
+
if (haikuLabel) {
|
|
573
|
+
envVars.push({ name: "ANTHROPIC_DEFAULT_HAIKU_MODEL", value: haikuLabel })
|
|
574
|
+
}
|
|
575
|
+
const sonnetLabel = getSlotLabel("sonnet")
|
|
576
|
+
if (sonnetLabel) {
|
|
577
|
+
envVars.push({ name: "ANTHROPIC_DEFAULT_SONNET_MODEL", value: sonnetLabel })
|
|
578
|
+
}
|
|
579
|
+
const opusLabel = getSlotLabel("opus")
|
|
580
|
+
if (opusLabel) {
|
|
581
|
+
envVars.push({ name: "ANTHROPIC_DEFAULT_OPUS_MODEL", value: opusLabel })
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return envVars
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Write claudeCode settings into ALL detected VS Code editor instances
|
|
589
|
+
* (stable, Insiders, Cursor). Persists linked state in bingo settings so
|
|
590
|
+
* it survives restarts.
|
|
591
|
+
*
|
|
592
|
+
* Fixes applied:
|
|
593
|
+
* #8 - Saves deep copy of original settings.claudeCode as vscodePreLinkClaudeCode
|
|
594
|
+
* so unlink can restore exactly what was there before.
|
|
595
|
+
* #9 - Collects failed paths; on partial failure rollback successful writes.
|
|
596
|
+
* #11 - Creates .bak backup before mutating VS Code settings.json; restores from
|
|
597
|
+
* .bak or in-memory copy on write failure.
|
|
598
|
+
*/
|
|
599
|
+
async linkVscode(): Promise<{ paths: string[]; linked: boolean; failedPaths?: string[] }> {
|
|
600
|
+
const envVars = await this.buildVscodeEnvVars()
|
|
601
|
+
const candidatePaths = getVscodeSettingsPaths()
|
|
602
|
+
const writtenPaths: string[] = []
|
|
603
|
+
const failedPaths: string[] = []
|
|
604
|
+
|
|
605
|
+
// Save pre-link bingo env so we can restore it on disconnect.
|
|
606
|
+
const bingoSettings = await this.readSettings()
|
|
607
|
+
const preLinkEnv = bingoSettings.env ?? null
|
|
608
|
+
bingoSettings.vscodePreLinkEnv = preLinkEnv
|
|
609
|
+
await this.writeSettings(bingoSettings)
|
|
610
|
+
|
|
611
|
+
// Fix #8: Snapshot original VS Code claudeCode per path for unlink restore.
|
|
612
|
+
const vscodePreLinkClaudeCode: Record<string, unknown> = {}
|
|
613
|
+
// Fix #9: Backup original contents for rollback on partial failure.
|
|
614
|
+
const originalContents = new Map<string, string | null>()
|
|
615
|
+
|
|
616
|
+
for (const settingsPath of candidatePaths) {
|
|
617
|
+
// Only write if the editor config directory exists (editor is installed)
|
|
618
|
+
const editorDir = path.dirname(path.dirname(settingsPath))
|
|
619
|
+
try {
|
|
620
|
+
await fs.access(editorDir)
|
|
621
|
+
} catch {
|
|
622
|
+
continue
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
let settings: Record<string, unknown> = {}
|
|
626
|
+
let originalContent: string | null = null
|
|
627
|
+
try {
|
|
628
|
+
originalContent = await fs.readFile(settingsPath, "utf-8")
|
|
629
|
+
settings = JSON.parse(originalContent)
|
|
630
|
+
} catch {
|
|
631
|
+
// File does not exist yet — start fresh
|
|
632
|
+
}
|
|
633
|
+
originalContents.set(settingsPath, originalContent)
|
|
634
|
+
|
|
635
|
+
// Fix #8: Save deep copy of original claudeCode before overwriting
|
|
636
|
+
vscodePreLinkClaudeCode[settingsPath] = settings.claudeCode !== undefined
|
|
637
|
+
? JSON.parse(JSON.stringify(settings.claudeCode))
|
|
638
|
+
: undefined
|
|
639
|
+
|
|
640
|
+
// Fix #11: Write backup before mutating
|
|
641
|
+
if (originalContent !== null) {
|
|
642
|
+
const bakPath = `${settingsPath}.bak`
|
|
643
|
+
try {
|
|
644
|
+
await fs.writeFile(bakPath, originalContent, "utf-8")
|
|
645
|
+
} catch {
|
|
646
|
+
// best-effort backup — proceed without it
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
settings.claudeCode = {
|
|
651
|
+
...(settings.claudeCode as Record<string, unknown> || {}),
|
|
652
|
+
preferredLocation: "panel",
|
|
653
|
+
disableLoginPrompt: true,
|
|
654
|
+
environmentVariables: envVars,
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
try {
|
|
658
|
+
await fs.mkdir(path.dirname(settingsPath), { recursive: true })
|
|
659
|
+
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 4) + '\n')
|
|
660
|
+
writtenPaths.push(settingsPath)
|
|
661
|
+
} catch (err) {
|
|
662
|
+
failedPaths.push(settingsPath)
|
|
663
|
+
console.error(
|
|
664
|
+
`[ProviderService] Failed to write VS Code settings to ${settingsPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
// Fix #11: Attempt restore from backup on write failure
|
|
668
|
+
if (originalContent !== null) {
|
|
669
|
+
try {
|
|
670
|
+
await fs.writeFile(settingsPath, originalContent, "utf-8")
|
|
671
|
+
console.error(`[ProviderService] Restored ${settingsPath} from in-memory copy after write failure`)
|
|
672
|
+
} catch {
|
|
673
|
+
// In-memory restore failed, try .bak file
|
|
674
|
+
try {
|
|
675
|
+
await fs.copyFile(`${settingsPath}.bak`, settingsPath)
|
|
676
|
+
console.error(`[ProviderService] Restored ${settingsPath} from .bak after write failure`)
|
|
677
|
+
} catch (restoreErr) {
|
|
678
|
+
console.error(
|
|
679
|
+
`[ProviderService] Failed to restore ${settingsPath} after write failure: ${restoreErr instanceof Error ? restoreErr.message : String(restoreErr)}`,
|
|
680
|
+
)
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Clean up .bak files for successfully written paths
|
|
688
|
+
for (const p of writtenPaths) {
|
|
689
|
+
await fs.unlink(`${p}.bak`).catch(() => {})
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Fix #9: Rollback successful writes on partial failure for atomicity.
|
|
693
|
+
// If any writes failed AND some succeeded, attempt to restore originals.
|
|
694
|
+
if (failedPaths.length > 0 && writtenPaths.length > 0) {
|
|
695
|
+
console.error(
|
|
696
|
+
`[ProviderService] Partial VS Code link failure: ${writtenPaths.length} succeeded, ${failedPaths.length} failed. Rolling back successful writes.`,
|
|
697
|
+
)
|
|
698
|
+
let allRolledBack = true
|
|
699
|
+
for (const p of writtenPaths) {
|
|
700
|
+
const original = originalContents.get(p)
|
|
701
|
+
try {
|
|
702
|
+
if (original !== null) {
|
|
703
|
+
await fs.writeFile(p, original, "utf-8")
|
|
704
|
+
} else {
|
|
705
|
+
await fs.unlink(p).catch(() => {})
|
|
706
|
+
}
|
|
707
|
+
} catch (rollbackErr) {
|
|
708
|
+
allRolledBack = false
|
|
709
|
+
console.error(
|
|
710
|
+
`[ProviderService] Rollback failed for ${p}: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
|
|
711
|
+
)
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
if (allRolledBack) {
|
|
715
|
+
writtenPaths.length = 0
|
|
716
|
+
}
|
|
717
|
+
// If rollback itself failed we have a mess — leave writtenPaths as-is so caller sees what happened.
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (writtenPaths.length === 0) {
|
|
721
|
+
throw ApiError.badRequest(
|
|
722
|
+
'No VS Code installation detected. Please install VS Code (stable, Insiders, or Cursor) first.',
|
|
723
|
+
)
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Persist so re-sync on restart and unlink work correctly
|
|
727
|
+
bingoSettings.vscodeLinked = true
|
|
728
|
+
bingoSettings.vscodeLinkedPaths = writtenPaths
|
|
729
|
+
bingoSettings.vscodePreLinkClaudeCode = vscodePreLinkClaudeCode
|
|
730
|
+
bingoSettings.env = this.buildBingoEnv(envVars)
|
|
731
|
+
await this.writeSettings(bingoSettings)
|
|
732
|
+
|
|
733
|
+
const result: { paths: string[]; linked: boolean; failedPaths?: string[] } = {
|
|
734
|
+
paths: writtenPaths,
|
|
735
|
+
linked: true,
|
|
736
|
+
}
|
|
737
|
+
if (failedPaths.length > 0) {
|
|
738
|
+
result.failedPaths = failedPaths
|
|
739
|
+
}
|
|
740
|
+
return result
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Restore pre-link bingo env so the CLI no longer routes through the proxy,
|
|
745
|
+
* and reset VS Code settings to their pre-link state (the original claudeCode
|
|
746
|
+
* that was saved in vscodePreLinkClaudeCode). Falls back to a minimal stub
|
|
747
|
+
* if no snapshot exists.
|
|
748
|
+
*
|
|
749
|
+
* Without this, the only way to stop VS Code from using bingo would be to
|
|
750
|
+
* delete ~/.claude/bingo/settings.json entirely — which would also kill CLI
|
|
751
|
+
* routing. Saving/restoring the pre-link env keeps CLI and VS Code independent.
|
|
752
|
+
*/
|
|
753
|
+
async unlinkVscode(): Promise<{ paths: string[]; linked: boolean }> {
|
|
754
|
+
const bingoSettings = await this.readSettings()
|
|
755
|
+
const linkedPaths = (bingoSettings.vscodeLinkedPaths as string[]) || []
|
|
756
|
+
|
|
757
|
+
// Restore bingo env to pre-link state
|
|
758
|
+
const preLinkEnv = bingoSettings.vscodePreLinkEnv
|
|
759
|
+
if (preLinkEnv !== undefined) {
|
|
760
|
+
if (preLinkEnv === null || Object.keys(preLinkEnv as Record<string, unknown>).length === 0) {
|
|
761
|
+
delete bingoSettings.env
|
|
762
|
+
} else {
|
|
763
|
+
bingoSettings.env = preLinkEnv
|
|
764
|
+
}
|
|
765
|
+
delete bingoSettings.vscodePreLinkEnv
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Fix #8: Restore original claudeCode snapshot per-path instead of a hardcoded stub.
|
|
769
|
+
// Fall back to { preferredLocation: "panel" } when no snapshot exists for a path.
|
|
770
|
+
const preLinkClaudeCode =
|
|
771
|
+
(bingoSettings.vscodePreLinkClaudeCode as Record<string, unknown> | undefined) || {}
|
|
772
|
+
|
|
773
|
+
for (const settingsPath of linkedPaths) {
|
|
774
|
+
try {
|
|
775
|
+
const raw = await fs.readFile(settingsPath, "utf-8")
|
|
776
|
+
const settings = JSON.parse(raw)
|
|
777
|
+
settings.claudeCode = preLinkClaudeCode[settingsPath] ?? { preferredLocation: "panel" }
|
|
778
|
+
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 4) + '\n')
|
|
779
|
+
} catch {
|
|
780
|
+
// File may have been moved or deleted since linking — harmless
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
bingoSettings.vscodeLinked = false
|
|
785
|
+
delete bingoSettings.vscodeLinkedPaths
|
|
786
|
+
delete bingoSettings.vscodePreLinkClaudeCode
|
|
787
|
+
await this.writeSettings(bingoSettings)
|
|
788
|
+
|
|
789
|
+
return { paths: linkedPaths, linked: false }
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Return current VS Code link status from persisted bingo settings.
|
|
794
|
+
*/
|
|
795
|
+
async getVscodeStatus(): Promise<{ linked: boolean; paths: string[] }> {
|
|
796
|
+
const bingoSettings = await this.readSettings()
|
|
797
|
+
return {
|
|
798
|
+
linked: Boolean(bingoSettings.vscodeLinked),
|
|
799
|
+
paths: (bingoSettings.vscodeLinkedPaths as string[]) || [],
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Initialise the service. If vscodeLinked is true in bingo settings,
|
|
805
|
+
* re-sync VS Code settings so they stay current with the slot config.
|
|
806
|
+
* Should be called once after server startup.
|
|
807
|
+
*/
|
|
808
|
+
async init(): Promise<void> {
|
|
809
|
+
const status = await this.getVscodeStatus()
|
|
810
|
+
if (status.linked) {
|
|
811
|
+
try {
|
|
812
|
+
await this.linkVscode()
|
|
813
|
+
} catch (err: unknown) {
|
|
814
|
+
console.error(
|
|
815
|
+
`[ProviderService] init: failed to re-sync VS Code link: ${err instanceof Error ? err.message : String(err)}`,
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
// Only clear linked state for "VS Code not installed" type errors.
|
|
819
|
+
// Disk failures, corrupt JSON, etc. are transient and should not
|
|
820
|
+
// silently destroy the user's linked state.
|
|
821
|
+
const isNotFound =
|
|
822
|
+
err instanceof Error &&
|
|
823
|
+
(err.message.includes('No VS Code installation detected') ||
|
|
824
|
+
((err as { statusCode?: number }).statusCode === 400))
|
|
825
|
+
|
|
826
|
+
if (isNotFound) {
|
|
827
|
+
const bingoSettings = await this.readSettings()
|
|
828
|
+
bingoSettings.vscodeLinked = false
|
|
829
|
+
delete bingoSettings.vscodeLinkedPaths
|
|
830
|
+
await this.writeSettings(bingoSettings)
|
|
831
|
+
}
|
|
832
|
+
// For other errors, keep existing state — the link may be recoverable.
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
async fetchProviderModels(id: string): Promise<string[]> {
|
|
838
|
+
const provider = await this.getProvider(id)
|
|
839
|
+
const preset = PROVIDER_PRESETS.find(p => p.id === provider.presetId)
|
|
840
|
+
|
|
841
|
+
const base = provider.baseUrl.replace(/\/+$/, '')
|
|
842
|
+
if (!base && provider.presetId !== 'official') return []
|
|
843
|
+
|
|
844
|
+
// Special case for Official
|
|
845
|
+
if (provider.presetId === 'official') {
|
|
846
|
+
return ['claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229', 'claude-3-sonnet-20240229', 'claude-3-haiku-20240307']
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const modelsUrl = preset?.modelsUrl || '/v1/models'
|
|
850
|
+
// modelsUrl 为绝对 URL 时直接使用(如 DeepSeek: baseUrl 是 anthropic 端,模型列表需走 OpenAI 端)
|
|
851
|
+
const url = modelsUrl.startsWith('http') ? modelsUrl : `${base}${modelsUrl}`
|
|
852
|
+
|
|
853
|
+
const headers: Record<string, string> = {
|
|
854
|
+
'Content-Type': 'application/json',
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const authStyle = preset?.modelsAuthStyle || (provider.apiFormat === 'anthropic' ? 'x-api-key' : 'bearer')
|
|
858
|
+
if (authStyle === 'x-api-key') {
|
|
859
|
+
headers['x-api-key'] = provider.apiKey
|
|
860
|
+
headers['anthropic-version'] = '2023-06-01'
|
|
861
|
+
} else {
|
|
862
|
+
headers['Authorization'] = `Bearer ${provider.apiKey}`
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
try {
|
|
866
|
+
const directOpts = getDirectFetchOptions()
|
|
867
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(10000), ...directOpts })
|
|
868
|
+
if (!res.ok) {
|
|
869
|
+
const errText = await res.text().catch(() => '')
|
|
870
|
+
console.error(`[ProviderService] Failed to fetch models from ${url}: ${res.status} ${errText}`)
|
|
871
|
+
throw new Error(`HTTP ${res.status}: ${errText.slice(0, 200)}`)
|
|
872
|
+
}
|
|
873
|
+
const data = await res.json() as any
|
|
874
|
+
const dataPath = preset?.modelsDataPath || 'data'
|
|
875
|
+
const list = data[dataPath] ?? data.data ?? data.models ?? []
|
|
876
|
+
if (!Array.isArray(list)) return []
|
|
877
|
+
return list.map((m: any) => (typeof m === 'string' ? m : m.id)).filter(Boolean)
|
|
878
|
+
} catch (err) {
|
|
879
|
+
console.error(`[ProviderService] Error fetching models from ${url}:`, err)
|
|
880
|
+
throw err
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// --- Test ---
|
|
885
|
+
|
|
886
|
+
async testProvider(
|
|
887
|
+
id: string,
|
|
888
|
+
overrides?: { baseUrl?: string; modelId?: string; apiFormat?: ApiFormat },
|
|
889
|
+
): Promise<ProviderTestResult> {
|
|
890
|
+
const provider = await this.getProvider(id)
|
|
891
|
+
const baseUrl = overrides?.baseUrl || provider.baseUrl
|
|
892
|
+
const apiFormat = overrides?.apiFormat ?? provider.apiFormat ?? 'anthropic'
|
|
893
|
+
|
|
894
|
+
// If no modelId provided, try to fetch from provider or use preset default
|
|
895
|
+
let modelId = overrides?.modelId || provider.models.main
|
|
896
|
+
const needsAutoDetect =
|
|
897
|
+
!modelId ||
|
|
898
|
+
modelId === 'auto' ||
|
|
899
|
+
(apiFormat !== 'anthropic' && modelId.startsWith('claude-'))
|
|
900
|
+
if (needsAutoDetect) {
|
|
901
|
+
const fetched = await this.fetchProviderModels(id).catch(() => [])
|
|
902
|
+
if (fetched.length > 0) {
|
|
903
|
+
modelId = fetched[0] // Use first available model for testing
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// 兜底:动态拉取失败时,按 apiFormat 使用通用 fallback 模型做连通性测试
|
|
908
|
+
if (!modelId) {
|
|
909
|
+
if (apiFormat === 'anthropic') {
|
|
910
|
+
modelId = 'claude-3-5-haiku-20241022'
|
|
911
|
+
} else {
|
|
912
|
+
// openai_chat / openai_responses: 无法确定模型,返回有意义的错误
|
|
913
|
+
return {
|
|
914
|
+
connectivity: {
|
|
915
|
+
success: false,
|
|
916
|
+
latencyMs: 0,
|
|
917
|
+
error: '无法确定测试用模型:models.main 为空且自动拉取模型列表失败。请先在槽位配置中选择模型,或检查 API Key 和网络连接。',
|
|
918
|
+
},
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (!baseUrl || !provider.apiKey) {
|
|
924
|
+
return { connectivity: { success: false, latencyMs: 0, error: 'Missing baseUrl or apiKey' } }
|
|
925
|
+
}
|
|
926
|
+
return this.testProviderConfig({
|
|
927
|
+
baseUrl,
|
|
928
|
+
apiKey: provider.apiKey,
|
|
929
|
+
modelId,
|
|
930
|
+
apiFormat,
|
|
931
|
+
})
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
async testProviderConfig(input: TestProviderInput): Promise<ProviderTestResult> {
|
|
935
|
+
const format: ApiFormat = input.apiFormat ?? 'anthropic'
|
|
936
|
+
const base = input.baseUrl.replace(/\/+$/, '')
|
|
937
|
+
|
|
938
|
+
// ── Step 1: Basic connectivity ───────────────────────────
|
|
939
|
+
// Directly call the upstream API to verify URL, key, and model.
|
|
940
|
+
const step1 = await this.testConnectivity(base, input.apiKey, input.modelId, format)
|
|
941
|
+
|
|
942
|
+
// If connectivity failed, no point running step 2
|
|
943
|
+
if (!step1.success) {
|
|
944
|
+
return { connectivity: step1 }
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// For native Anthropic format, no proxy pipeline to test
|
|
948
|
+
if (format === 'anthropic') {
|
|
949
|
+
return { connectivity: step1 }
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// ── Step 2: Full proxy pipeline ──────────────────────────
|
|
953
|
+
// Anthropic request → transform → upstream → transform back → validate
|
|
954
|
+
const step2 = await this.testProxyPipeline(base, input.apiKey, input.modelId, format)
|
|
955
|
+
|
|
956
|
+
return { connectivity: step1, proxy: step2 }
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** Step 1: Direct upstream call to verify connectivity, auth, and model. */
|
|
960
|
+
private async testConnectivity(
|
|
961
|
+
base: string,
|
|
962
|
+
apiKey: string,
|
|
963
|
+
modelId: string,
|
|
964
|
+
format: ApiFormat,
|
|
965
|
+
): Promise<ProviderTestStepResult> {
|
|
966
|
+
const start = Date.now()
|
|
967
|
+
try {
|
|
968
|
+
const { url, headers, body } = buildDirectTestRequest(base, apiKey, modelId, format)
|
|
969
|
+
const directOpts = getDirectFetchOptions()
|
|
970
|
+
const response = await fetch(url, {
|
|
971
|
+
method: 'POST',
|
|
972
|
+
headers,
|
|
973
|
+
body: JSON.stringify(body),
|
|
974
|
+
signal: AbortSignal.timeout(30000),
|
|
975
|
+
...directOpts,
|
|
976
|
+
})
|
|
977
|
+
|
|
978
|
+
const latencyMs = Date.now() - start
|
|
979
|
+
const resBody = await response.json().catch(() => null) as Record<string, unknown> | null
|
|
980
|
+
|
|
981
|
+
if (!response.ok) {
|
|
982
|
+
let error = `HTTP ${response.status}`
|
|
983
|
+
if (resBody?.error && typeof resBody.error === 'object') {
|
|
984
|
+
error = ((resBody.error as Record<string, unknown>).message as string) || error
|
|
985
|
+
}
|
|
986
|
+
return { success: false, latencyMs, error, modelUsed: modelId, httpStatus: response.status }
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// Validate response structure
|
|
990
|
+
const valid = validateResponseBody(resBody, format)
|
|
991
|
+
if (!valid.ok) {
|
|
992
|
+
return { success: false, latencyMs, error: valid.error, modelUsed: modelId, httpStatus: response.status }
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
return { success: true, latencyMs, modelUsed: valid.model || modelId, httpStatus: response.status }
|
|
996
|
+
} catch (err: unknown) {
|
|
997
|
+
const latencyMs = Date.now() - start
|
|
998
|
+
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
|
999
|
+
return { success: false, latencyMs, error: 'Request timed out (30s)', modelUsed: modelId }
|
|
1000
|
+
}
|
|
1001
|
+
return { success: false, latencyMs, error: err instanceof Error ? err.message : String(err), modelUsed: modelId }
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** Step 2: Full proxy pipeline — Anthropic → transform → upstream → transform back → validate. */
|
|
1006
|
+
private async testProxyPipeline(
|
|
1007
|
+
base: string,
|
|
1008
|
+
apiKey: string,
|
|
1009
|
+
modelId: string,
|
|
1010
|
+
format: 'openai_chat' | 'openai_responses',
|
|
1011
|
+
): Promise<ProviderTestStepResult> {
|
|
1012
|
+
const start = Date.now()
|
|
1013
|
+
try {
|
|
1014
|
+
// Build an Anthropic Messages API request (same shape as what CLI sends)
|
|
1015
|
+
const anthropicReq: AnthropicRequest = {
|
|
1016
|
+
model: modelId,
|
|
1017
|
+
max_tokens: 64,
|
|
1018
|
+
messages: [{ role: 'user', content: 'Say "ok" and nothing else.' }],
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Transform to OpenAI format
|
|
1022
|
+
let upstreamUrl: string
|
|
1023
|
+
let transformedBody: unknown
|
|
1024
|
+
if (format === 'openai_chat') {
|
|
1025
|
+
transformedBody = anthropicToOpenaiChat(anthropicReq)
|
|
1026
|
+
upstreamUrl = `${base}/v1/chat/completions`
|
|
1027
|
+
} else {
|
|
1028
|
+
transformedBody = anthropicToOpenaiResponses(anthropicReq)
|
|
1029
|
+
upstreamUrl = `${base}/v1/responses`
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Call upstream with transformed request
|
|
1033
|
+
const directOpts = getDirectFetchOptions()
|
|
1034
|
+
const response = await fetch(upstreamUrl, {
|
|
1035
|
+
method: 'POST',
|
|
1036
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
1037
|
+
body: JSON.stringify(transformedBody),
|
|
1038
|
+
signal: AbortSignal.timeout(30000),
|
|
1039
|
+
...directOpts,
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
if (!response.ok) {
|
|
1043
|
+
const latencyMs = Date.now() - start
|
|
1044
|
+
const errText = await response.text().catch(() => '')
|
|
1045
|
+
return { success: false, latencyMs, modelUsed: modelId, httpStatus: response.status,
|
|
1046
|
+
error: `Upstream HTTP ${response.status}: ${errText.slice(0, 200)}` }
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Transform response back to Anthropic format
|
|
1050
|
+
const responseBody = await response.json()
|
|
1051
|
+
const anthropicRes = format === 'openai_chat'
|
|
1052
|
+
? openaiChatToAnthropic(responseBody, modelId)
|
|
1053
|
+
: openaiResponsesToAnthropic(responseBody, modelId)
|
|
1054
|
+
|
|
1055
|
+
const latencyMs = Date.now() - start
|
|
1056
|
+
|
|
1057
|
+
// Validate the final Anthropic response
|
|
1058
|
+
if (anthropicRes.type !== 'message' || !Array.isArray(anthropicRes.content)) {
|
|
1059
|
+
return { success: false, latencyMs, modelUsed: modelId,
|
|
1060
|
+
error: 'Proxy transform produced invalid Anthropic response' }
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
return { success: true, latencyMs, modelUsed: anthropicRes.model || modelId, httpStatus: response.status }
|
|
1064
|
+
} catch (err: unknown) {
|
|
1065
|
+
const latencyMs = Date.now() - start
|
|
1066
|
+
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
|
1067
|
+
return { success: false, latencyMs, error: 'Proxy pipeline timed out (30s)', modelUsed: modelId }
|
|
1068
|
+
}
|
|
1069
|
+
return { success: false, latencyMs, error: err instanceof Error ? err.message : String(err), modelUsed: modelId }
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// ─── Helpers ───────────────────────────────────────────────
|
|
1075
|
+
|
|
1076
|
+
function buildDirectTestRequest(
|
|
1077
|
+
base: string,
|
|
1078
|
+
apiKey: string,
|
|
1079
|
+
modelId: string,
|
|
1080
|
+
format: ApiFormat,
|
|
1081
|
+
): { url: string; headers: Record<string, string>; body: Record<string, unknown> } {
|
|
1082
|
+
const prompt = 'Say "ok" and nothing else.'
|
|
1083
|
+
|
|
1084
|
+
if (format === 'openai_chat') {
|
|
1085
|
+
return {
|
|
1086
|
+
url: `${base}/v1/chat/completions`,
|
|
1087
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
1088
|
+
body: { model: modelId, max_tokens: 16, messages: [{ role: 'user', content: prompt }] },
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
if (format === 'openai_responses') {
|
|
1092
|
+
return {
|
|
1093
|
+
url: `${base}/v1/responses`,
|
|
1094
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
1095
|
+
body: { model: modelId, max_output_tokens: 16, input: [{ type: 'message', role: 'user', content: prompt }] },
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
// anthropic
|
|
1099
|
+
return {
|
|
1100
|
+
url: `${base}/v1/messages`,
|
|
1101
|
+
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
|
|
1102
|
+
body: { model: modelId, max_tokens: 16, messages: [{ role: 'user', content: prompt }] },
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function validateResponseBody(
|
|
1107
|
+
body: Record<string, unknown> | null,
|
|
1108
|
+
format: ApiFormat,
|
|
1109
|
+
): { ok: true; model?: string } | { ok: false; error: string } {
|
|
1110
|
+
if (!body) return { ok: false, error: 'Empty response — not a valid API endpoint' }
|
|
1111
|
+
if (body.error && typeof body.error === 'object') {
|
|
1112
|
+
return { ok: false, error: ((body.error as Record<string, unknown>).message as string) || 'Error in response body' }
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
if (format === 'openai_chat') {
|
|
1116
|
+
if (!Array.isArray(body.choices) || body.choices.length === 0) {
|
|
1117
|
+
return { ok: false, error: 'Response missing choices — not a valid Chat Completions endpoint' }
|
|
1118
|
+
}
|
|
1119
|
+
return { ok: true, model: (body.model as string) || undefined }
|
|
1120
|
+
}
|
|
1121
|
+
if (format === 'openai_responses') {
|
|
1122
|
+
if (!Array.isArray(body.output)) {
|
|
1123
|
+
return { ok: false, error: 'Response missing output — not a valid Responses API endpoint' }
|
|
1124
|
+
}
|
|
1125
|
+
return { ok: true, model: (body.model as string) || undefined }
|
|
1126
|
+
}
|
|
1127
|
+
// anthropic
|
|
1128
|
+
if (body.type !== 'message' || !Array.isArray(body.content)) {
|
|
1129
|
+
return { ok: false, error: 'Not a valid Anthropic Messages endpoint' }
|
|
1130
|
+
}
|
|
1131
|
+
return { ok: true, model: (body.model as string) || undefined }
|
|
1132
1132
|
}
|