@marcusrbrown/infra 0.7.0 → 0.8.1

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.
@@ -0,0 +1,158 @@
1
+ /// <reference types="bun" />
2
+
3
+ import type {GenericSecretNames} from './prompts'
4
+ import type {ProviderId} from './providers'
5
+ import {z} from 'zod'
6
+ import {PROVIDER_DEFAULTS} from './providers'
7
+
8
+ export const harnessSchema = z.enum(['opencode', 'claude-code', 'generic'])
9
+ export type Harness = z.infer<typeof harnessSchema>
10
+
11
+ export interface SecretAssignment {
12
+ name: string
13
+ value: string
14
+ }
15
+
16
+ export interface VariableAssignment {
17
+ name: string
18
+ value: string
19
+ }
20
+
21
+ export interface HarnessTemplate {
22
+ secrets: SecretAssignment[]
23
+ variables: VariableAssignment[]
24
+ }
25
+
26
+ const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
27
+
28
+ // OMO_PROVIDERS token map — template/harness concern, not a provider concern.
29
+ // Keyed by ProviderId but lives here because it drives harness template construction.
30
+ const OMO_TOKEN: Record<ProviderId, string> = {
31
+ anthropic: 'claude-max20',
32
+ openai: 'openai',
33
+ }
34
+
35
+ export function stripTrailingSlash(value: string): string {
36
+ return value.endsWith('/') ? value.slice(0, -1) : value
37
+ }
38
+
39
+ export function getHarnessTemplate(
40
+ harness: Harness,
41
+ values: {
42
+ keyValue?: string
43
+ baseUrl?: string
44
+ genericSecretNames?: GenericSecretNames
45
+ providers?: ProviderId[]
46
+ model?: string
47
+ } = {},
48
+ ): HarnessTemplate {
49
+ const keyValue = values.keyValue ?? 'sk-placeholder'
50
+ const baseUrl = stripTrailingSlash(values.baseUrl ?? DEFAULT_CLIPROXY_URL)
51
+
52
+ if (harness === 'opencode') {
53
+ // Normalize provider list: default to anthropic-only, always sort anthropic first
54
+ const rawProviders = values.providers ?? ['anthropic']
55
+ // Stable ordering: anthropic always before openai regardless of input order
56
+ const PROVIDER_ORDER: ProviderId[] = ['anthropic', 'openai']
57
+ const providers = PROVIDER_ORDER.filter(p => rawProviders.includes(p))
58
+
59
+ // Resolve model
60
+ let model: string
61
+ if (values.model) {
62
+ model = values.model
63
+ } else if (providers.length === 1) {
64
+ model = PROVIDER_DEFAULTS[providers[0] as ProviderId]
65
+ } else {
66
+ throw new Error('model required when multiple providers selected')
67
+ }
68
+
69
+ // Build auth JSON object (anthropic-first insertion order)
70
+ const authObj: Record<string, {type: string; key: string}> = {}
71
+ for (const p of providers) {
72
+ authObj[p] = {type: 'api', key: keyValue}
73
+ }
74
+
75
+ // Build config JSON object (anthropic-first insertion order)
76
+ const providerConfig: Record<string, {options: {baseURL: string}}> = {}
77
+ for (const p of providers) {
78
+ providerConfig[p] = {options: {baseURL: `${baseUrl}/v1`}}
79
+ }
80
+
81
+ const omoProviders = providers.map(p => OMO_TOKEN[p]).join(',')
82
+
83
+ return {
84
+ secrets: [
85
+ {
86
+ name: 'OPENCODE_AUTH_JSON',
87
+ value: JSON.stringify(authObj),
88
+ },
89
+ {
90
+ name: 'OPENCODE_CONFIG',
91
+ value: JSON.stringify({provider: providerConfig}),
92
+ },
93
+ {
94
+ name: 'OMO_PROVIDERS',
95
+ value: omoProviders,
96
+ },
97
+ ],
98
+ variables: [
99
+ {
100
+ name: 'FRO_BOT_MODEL',
101
+ value: model,
102
+ },
103
+ ],
104
+ }
105
+ }
106
+
107
+ if (harness === 'claude-code') {
108
+ return {
109
+ secrets: [
110
+ {
111
+ name: 'ANTHROPIC_API_KEY',
112
+ value: keyValue,
113
+ },
114
+ ],
115
+ variables: [],
116
+ }
117
+ }
118
+
119
+ if (!values.genericSecretNames) {
120
+ throw new Error('Generic harness requires custom secret names.')
121
+ }
122
+
123
+ return {
124
+ secrets: [
125
+ {name: values.genericSecretNames.apiKeySecretName, value: keyValue},
126
+ {name: values.genericSecretNames.baseUrlSecretName, value: `${baseUrl}/v1`},
127
+ ],
128
+ variables: [],
129
+ }
130
+ }
131
+
132
+ export function formatTemplateSummary(template: HarnessTemplate): string {
133
+ const secretLines = template.secrets.map(secret => `- secret ${secret.name}`)
134
+ const variableLines = template.variables.map(variable => `- variable ${variable.name}`)
135
+ return [...secretLines, ...variableLines].join('\n')
136
+ }
137
+
138
+ export function collectCollisions(
139
+ template: HarnessTemplate,
140
+ existingSecrets: string[],
141
+ existingVariables: string[],
142
+ ): string[] {
143
+ const collisions: string[] = []
144
+
145
+ for (const secret of template.secrets) {
146
+ if (existingSecrets.includes(secret.name)) {
147
+ collisions.push(`secret ${secret.name}`)
148
+ }
149
+ }
150
+
151
+ for (const variable of template.variables) {
152
+ if (existingVariables.includes(variable.name)) {
153
+ collisions.push(`variable ${variable.name}`)
154
+ }
155
+ }
156
+
157
+ return collisions
158
+ }
@@ -0,0 +1,399 @@
1
+ /// <reference types="bun" />
2
+
3
+ import {afterEach, describe, expect, it, mock} from 'bun:test'
4
+
5
+ import {
6
+ assertProxyKeyWorks,
7
+ assertProxyReachable,
8
+ MODEL_ID_RE,
9
+ validateSetupOptions,
10
+ verifyModelsAvailable,
11
+ } from './validation'
12
+
13
+ // ── validateSetupOptions ──────────────────────────────────────────────────────
14
+
15
+ describe('validateSetupOptions', () => {
16
+ it('requires --key in non-interactive mode', () => {
17
+ expect(() => validateSetupOptions({repo: 'owner/repo', harness: 'opencode'}, false)).toThrow(
18
+ '--key is required when stdin is not a TTY',
19
+ )
20
+ })
21
+
22
+ it('requires --repo in non-interactive mode', () => {
23
+ expect(() => validateSetupOptions({key: 'sk-test', harness: 'opencode'}, false)).toThrow(
24
+ '--repo is required when stdin is not a TTY',
25
+ )
26
+ })
27
+
28
+ it('requires --harness in non-interactive mode', () => {
29
+ expect(() => validateSetupOptions({key: 'sk-test', repo: 'owner/repo'}, false)).toThrow(
30
+ '--harness is required when stdin is not a TTY',
31
+ )
32
+ })
33
+ })
34
+
35
+ // ── model flag validation (MODEL_ID_RE) ───────────────────────────────────────
36
+
37
+ describe('model flag validation', () => {
38
+ it('accepts "openai/gpt-5.4-mini"', () => {
39
+ expect(MODEL_ID_RE.test('openai/gpt-5.4-mini')).toBe(true)
40
+ })
41
+
42
+ it('rejects "gpt-5.4-mini" (no provider prefix)', () => {
43
+ expect(MODEL_ID_RE.test('gpt-5.4-mini')).toBe(false)
44
+ })
45
+
46
+ it('rejects "openai/GPT-5.4-mini" (uppercase)', () => {
47
+ expect(MODEL_ID_RE.test('openai/GPT-5.4-mini')).toBe(false)
48
+ })
49
+
50
+ it('rejects "openai/gpt-5.4-mini; rm -rf /" (injection attempt)', () => {
51
+ expect(MODEL_ID_RE.test('openai/gpt-5.4-mini; rm -rf /')).toBe(false)
52
+ })
53
+
54
+ it('rejects "openai/gpt-4o." (trailing dot)', () => {
55
+ expect(MODEL_ID_RE.test('openai/gpt-4o.')).toBe(false)
56
+ })
57
+
58
+ it('rejects "openai/gpt-4o-" (trailing hyphen)', () => {
59
+ expect(MODEL_ID_RE.test('openai/gpt-4o-')).toBe(false)
60
+ })
61
+
62
+ it('accepts "openai/gpt-4o" (regression — still works)', () => {
63
+ expect(MODEL_ID_RE.test('openai/gpt-4o')).toBe(true)
64
+ })
65
+
66
+ it('accepts "anthropic/claude-sonnet-4-6" (regression)', () => {
67
+ expect(MODEL_ID_RE.test('anthropic/claude-sonnet-4-6')).toBe(true)
68
+ })
69
+
70
+ it('accepts "openai/a" (single-char tail)', () => {
71
+ expect(MODEL_ID_RE.test('openai/a')).toBe(true)
72
+ })
73
+
74
+ it('rejects "openai/" (empty tail)', () => {
75
+ expect(MODEL_ID_RE.test('openai/')).toBe(false)
76
+ })
77
+ })
78
+
79
+ // ── verifyModelsAvailable ─────────────────────────────────────────────────────
80
+
81
+ describe('verifyModelsAvailable', () => {
82
+ // Realistic fixture matching the plan spec
83
+ const MODELS_FIXTURE = {
84
+ data: [
85
+ {id: 'claude-3-7-sonnet-20250219', owned_by: 'anthropic'},
86
+ {id: 'claude-sonnet-4-6', owned_by: 'anthropic'},
87
+ {id: 'gpt-5.4-mini', owned_by: 'openai'},
88
+ {id: 'gpt-5.5', owned_by: 'openai'},
89
+ ],
90
+ }
91
+
92
+ const BASE_URL = 'https://cliproxy.fro.bot'
93
+ const KEY = 'sk-test-key'
94
+
95
+ // Save and restore globalThis.fetch around each test
96
+ let originalFetch: typeof globalThis.fetch
97
+ afterEach(() => {
98
+ globalThis.fetch = originalFetch
99
+ })
100
+ // Capture original before any test runs
101
+ originalFetch = globalThis.fetch
102
+
103
+ it('anthropic-only short-circuit: returns immediately without calling fetch', async () => {
104
+ const fetchSpy = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE)))
105
+ globalThis.fetch = fetchSpy as unknown as typeof fetch
106
+
107
+ await verifyModelsAvailable(BASE_URL, KEY, ['anthropic'], 'anthropic/claude-sonnet-4-6')
108
+
109
+ expect(fetchSpy.mock.calls.length).toBe(0)
110
+ })
111
+
112
+ it('happy path: openai-only, model present, owned_by openai — passes without throw', async () => {
113
+ globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
114
+
115
+ await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')).resolves.toBeUndefined()
116
+ })
117
+
118
+ it('happy path: dual providers, anthropic model present, openai entries exist — passes', async () => {
119
+ globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
120
+
121
+ await expect(
122
+ verifyModelsAvailable(BASE_URL, KEY, ['anthropic', 'openai'], 'anthropic/claude-sonnet-4-6'),
123
+ ).resolves.toBeUndefined()
124
+ })
125
+
126
+ it('error path: 401 throws "Proxy key rejected" message', async () => {
127
+ globalThis.fetch = mock(async () => new Response('Unauthorized', {status: 401})) as unknown as typeof fetch
128
+
129
+ await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')).rejects.toThrow(
130
+ 'Proxy key rejected',
131
+ )
132
+ })
133
+
134
+ it('error path: 401 error message does NOT contain the Authorization header value', async () => {
135
+ globalThis.fetch = mock(async () => new Response('Unauthorized', {status: 401})) as unknown as typeof fetch
136
+
137
+ let errorMessage = ''
138
+ try {
139
+ await verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')
140
+ } catch (error) {
141
+ errorMessage = error instanceof Error ? error.message : String(error)
142
+ }
143
+
144
+ expect(errorMessage).not.toContain(KEY)
145
+ expect(errorMessage).not.toContain('Bearer')
146
+ })
147
+
148
+ it('error path: 403 throws "Proxy key rejected" message', async () => {
149
+ globalThis.fetch = mock(async () => new Response('Forbidden', {status: 403})) as unknown as typeof fetch
150
+
151
+ await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')).rejects.toThrow(
152
+ 'Proxy key rejected',
153
+ )
154
+ })
155
+
156
+ it('error path: 500 throws with status and truncated body; no Authorization header in message', async () => {
157
+ const body = 'Internal Server Error — something went wrong on the proxy'
158
+ globalThis.fetch = mock(async () => new Response(body, {status: 500})) as unknown as typeof fetch
159
+
160
+ let errorMessage = ''
161
+ try {
162
+ await verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')
163
+ } catch (error) {
164
+ errorMessage = error instanceof Error ? error.message : String(error)
165
+ }
166
+
167
+ expect(errorMessage).toContain('500')
168
+ expect(errorMessage).not.toContain(KEY)
169
+ expect(errorMessage).not.toContain('Bearer')
170
+ })
171
+
172
+ it('error path: 200 with data:[] and openai in providers throws no-openai-models message', async () => {
173
+ globalThis.fetch = mock(async () => new Response(JSON.stringify({data: []}))) as unknown as typeof fetch
174
+
175
+ await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')).rejects.toThrow(
176
+ 'No OpenAI models on proxy',
177
+ )
178
+ })
179
+
180
+ it('error path: model not present in data — throws and lists available openai ids', async () => {
181
+ globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
182
+
183
+ let errorMessage = ''
184
+ try {
185
+ await verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-99-unknown')
186
+ } catch (error) {
187
+ errorMessage = error instanceof Error ? error.message : String(error)
188
+ }
189
+
190
+ expect(errorMessage).toContain('gpt-99-unknown')
191
+ // Should list available openai models
192
+ expect(errorMessage).toContain('gpt-5.4-mini')
193
+ expect(errorMessage).toContain('gpt-5.5')
194
+ // Should NOT list anthropic models
195
+ expect(errorMessage).not.toContain('claude')
196
+ })
197
+
198
+ it('error path: model not present and provider is anthropic — lists available anthropic ids', async () => {
199
+ globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
200
+
201
+ let errorMessage = ''
202
+ try {
203
+ await verifyModelsAvailable(BASE_URL, KEY, ['anthropic', 'openai'], 'anthropic/claude-unknown-model')
204
+ } catch (error) {
205
+ errorMessage = error instanceof Error ? error.message : String(error)
206
+ }
207
+
208
+ expect(errorMessage).toContain('claude-unknown-model')
209
+ // Should list available anthropic models
210
+ expect(errorMessage).toContain('claude-3-7-sonnet-20250219')
211
+ expect(errorMessage).toContain('claude-sonnet-4-6')
212
+ // Should NOT list openai models
213
+ expect(errorMessage).not.toContain('gpt-')
214
+ })
215
+
216
+ it('error path: data is a string (not array) — throws Zod-derived error mentioning "data" and array/Expected', async () => {
217
+ globalThis.fetch = mock(async () => new Response(JSON.stringify({data: 'not-an-array'}))) as unknown as typeof fetch
218
+
219
+ await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')).rejects.toThrow(
220
+ /data.*Expected|Expected.*data|data.*array/i,
221
+ )
222
+ })
223
+
224
+ it('error path: data is missing (response is {}) — throws Zod-derived error indicating data is required', async () => {
225
+ globalThis.fetch = mock(async () => new Response(JSON.stringify({}))) as unknown as typeof fetch
226
+
227
+ await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')).rejects.toThrow(/data/i)
228
+ })
229
+
230
+ it('happy path (passthrough): extra top-level field ignored — passes', async () => {
231
+ const fixtureWithExtra = {
232
+ data: [
233
+ {id: 'claude-sonnet-4-6', owned_by: 'anthropic'},
234
+ {id: 'gpt-5.4-mini', owned_by: 'openai'},
235
+ ],
236
+ extraField: 'ignored',
237
+ }
238
+ globalThis.fetch = mock(async () => new Response(JSON.stringify(fixtureWithExtra))) as unknown as typeof fetch
239
+
240
+ await expect(
241
+ verifyModelsAvailable(BASE_URL, KEY, ['anthropic', 'openai'], 'openai/gpt-5.4-mini'),
242
+ ).resolves.toBeUndefined()
243
+ })
244
+
245
+ it('happy path (passthrough on entries): extra entry field ignored — passes', async () => {
246
+ const fixtureWithEntryExtra = {
247
+ data: [{id: 'gpt-5.4-mini', owned_by: 'openai', extraEntryField: 'ignored'}],
248
+ }
249
+ globalThis.fetch = mock(async () => new Response(JSON.stringify(fixtureWithEntryExtra))) as unknown as typeof fetch
250
+
251
+ await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')).resolves.toBeUndefined()
252
+ })
253
+
254
+ it('error path: dual providers, no owned_by=openai entries — throws no-openai-models message', async () => {
255
+ const anthropicOnlyData = {
256
+ data: [
257
+ {id: 'claude-3-7-sonnet-20250219', owned_by: 'anthropic'},
258
+ {id: 'claude-sonnet-4-6', owned_by: 'anthropic'},
259
+ ],
260
+ }
261
+ globalThis.fetch = mock(async () => new Response(JSON.stringify(anthropicOnlyData))) as unknown as typeof fetch
262
+
263
+ await expect(verifyModelsAvailable(BASE_URL, KEY, ['anthropic', 'openai'], 'openai/gpt-5.4-mini')).rejects.toThrow(
264
+ 'No OpenAI models on proxy',
265
+ )
266
+ })
267
+ })
268
+
269
+ // ── validateSetupOptions — providers/model validation ─────────────────────────
270
+
271
+ describe('validateSetupOptions — providers/model validation', () => {
272
+ it('regression: no providers/model passes unchanged (anthropic-only default)', () => {
273
+ expect(() => validateSetupOptions({key: 'sk-test', repo: 'owner/repo', harness: 'opencode'}, false)).not.toThrow()
274
+ })
275
+
276
+ it('happy path: single provider anthropic, no model — passes', () => {
277
+ expect(() =>
278
+ validateSetupOptions({key: 'sk-test', repo: 'owner/repo', harness: 'opencode', providers: 'anthropic'}, false),
279
+ ).not.toThrow()
280
+ })
281
+
282
+ it('happy path: openai + model with openai prefix — passes', () => {
283
+ expect(() =>
284
+ validateSetupOptions(
285
+ {key: 'sk-test', repo: 'owner/repo', harness: 'opencode', providers: 'openai', model: 'openai/gpt-5.4-mini'},
286
+ false,
287
+ ),
288
+ ).not.toThrow()
289
+ })
290
+
291
+ it('happy path: anthropic,openai + model with openai prefix — passes', () => {
292
+ expect(() =>
293
+ validateSetupOptions(
294
+ {
295
+ key: 'sk-test',
296
+ repo: 'owner/repo',
297
+ harness: 'opencode',
298
+ providers: 'anthropic,openai',
299
+ model: 'openai/gpt-5.4-mini',
300
+ },
301
+ false,
302
+ ),
303
+ ).not.toThrow()
304
+ })
305
+
306
+ it('error: multiple providers without --model throws "Pass --model" error', () => {
307
+ expect(() => validateSetupOptions({harness: 'opencode', providers: 'anthropic,openai'}, false)).toThrow(
308
+ 'Pass --model <provider/model-id> when selecting multiple providers.',
309
+ )
310
+ })
311
+
312
+ it('error: model prefix does not match single provider (anthropic provider, openai model)', () => {
313
+ expect(() =>
314
+ validateSetupOptions({harness: 'opencode', providers: 'anthropic', model: 'openai/gpt-5.4-mini'}, false),
315
+ ).toThrow(/Model prefix openai does not match selected providers/)
316
+ })
317
+
318
+ it('error: model prefix does not match single provider (openai provider, anthropic model)', () => {
319
+ expect(() =>
320
+ validateSetupOptions({harness: 'opencode', providers: 'openai', model: 'anthropic/claude-sonnet-4-6'}, false),
321
+ ).toThrow(/Model prefix anthropic does not match selected providers/)
322
+ })
323
+
324
+ it('error: duplicate providers throws from parseProviders', () => {
325
+ expect(() => validateSetupOptions({harness: 'opencode', providers: 'anthropic,anthropic'}, false)).toThrow(
326
+ /duplicate/,
327
+ )
328
+ })
329
+
330
+ it('error: unknown provider throws from parseProviders', () => {
331
+ expect(() => validateSetupOptions({harness: 'opencode', providers: 'claude'}, false)).toThrow(/Unknown provider/)
332
+ })
333
+
334
+ it('interactive mode: providers/model checks are skipped even with invalid combo', () => {
335
+ // Multiple providers without model — would fail in non-interactive, but interactive skips all checks
336
+ expect(() => validateSetupOptions({providers: 'anthropic,openai'}, true)).not.toThrow()
337
+ })
338
+ })
339
+
340
+ // ── assertProxyReachable (new TDD tests) ──────────────────────────────────────
341
+
342
+ describe('assertProxyReachable', () => {
343
+ let originalFetch: typeof globalThis.fetch
344
+ afterEach(() => {
345
+ globalThis.fetch = originalFetch
346
+ })
347
+ originalFetch = globalThis.fetch
348
+
349
+ it('happy path: fetch returns HTTP 200 — resolves without throw', async () => {
350
+ globalThis.fetch = mock(async () => new Response('OK', {status: 200})) as unknown as typeof fetch
351
+
352
+ await expect(assertProxyReachable('https://good.example')).resolves.toBeUndefined()
353
+ })
354
+
355
+ it('error path: fetch throws AbortError — throws with "Unable to reach proxy" prefix', async () => {
356
+ const abortError = new DOMException('The operation was aborted.', 'AbortError')
357
+ globalThis.fetch = mock(async () => {
358
+ throw abortError
359
+ }) as unknown as typeof fetch
360
+
361
+ await expect(assertProxyReachable('https://bad.example')).rejects.toThrow(/Unable to reach proxy/)
362
+ })
363
+
364
+ it('error path: fetch returns non-ok status — throws with "Proxy check failed" prefix', async () => {
365
+ globalThis.fetch = mock(async () => new Response('Bad Gateway', {status: 502})) as unknown as typeof fetch
366
+
367
+ await expect(assertProxyReachable('https://bad.example')).rejects.toThrow(/Proxy check failed/)
368
+ })
369
+ })
370
+
371
+ // ── assertProxyKeyWorks (new TDD tests) ───────────────────────────────────────
372
+
373
+ describe('assertProxyKeyWorks', () => {
374
+ let originalFetch: typeof globalThis.fetch
375
+ afterEach(() => {
376
+ globalThis.fetch = originalFetch
377
+ })
378
+ originalFetch = globalThis.fetch
379
+
380
+ it('happy path: fetch returns HTTP 200 — resolves without throw', async () => {
381
+ globalThis.fetch = mock(async () => new Response('OK', {status: 200})) as unknown as typeof fetch
382
+
383
+ await expect(assertProxyKeyWorks('https://good.example', 'sk-good')).resolves.toBeUndefined()
384
+ })
385
+
386
+ it('error path: fetch returns HTTP 401 — throws with "Unable to verify proxy key" prefix', async () => {
387
+ globalThis.fetch = mock(async () => new Response('Unauthorized', {status: 401})) as unknown as typeof fetch
388
+
389
+ await expect(assertProxyKeyWorks('https://good.example', 'sk-bad')).rejects.toThrow(/Proxy key verification failed/)
390
+ })
391
+
392
+ it('error path: fetch throws network error — throws with "Unable to verify proxy key" prefix', async () => {
393
+ globalThis.fetch = mock(async () => {
394
+ throw new Error('network failure')
395
+ }) as unknown as typeof fetch
396
+
397
+ await expect(assertProxyKeyWorks('https://good.example', 'sk-good')).rejects.toThrow(/Unable to verify proxy key/)
398
+ })
399
+ })