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.
@@ -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
  }