@marcusrbrown/infra 0.13.1 → 0.13.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +11965 -0
- package/package.json +7 -3
- package/src/__snapshots__/cli.test.ts.snap +0 -219
- package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
- package/src/__test__/mcp-ctx-fixture.ts +0 -104
- package/src/cli.test.ts +0 -66
- package/src/cli.ts +0 -43
- package/src/commands/cliproxy/config.test.ts +0 -475
- package/src/commands/cliproxy/config.ts +0 -238
- package/src/commands/cliproxy/deploy.test.ts +0 -181
- package/src/commands/cliproxy/deploy.ts +0 -157
- package/src/commands/cliproxy/host.test.ts +0 -73
- package/src/commands/cliproxy/host.ts +0 -31
- package/src/commands/cliproxy/index.ts +0 -21
- package/src/commands/cliproxy/keys.test.ts +0 -326
- package/src/commands/cliproxy/keys.ts +0 -201
- package/src/commands/cliproxy/login.test.ts +0 -415
- package/src/commands/cliproxy/login.ts +0 -131
- package/src/commands/cliproxy/models.test.ts +0 -592
- package/src/commands/cliproxy/models.ts +0 -237
- package/src/commands/cliproxy/open.test.ts +0 -132
- package/src/commands/cliproxy/open.ts +0 -78
- package/src/commands/cliproxy/setup/gh.test.ts +0 -218
- package/src/commands/cliproxy/setup/gh.ts +0 -250
- package/src/commands/cliproxy/setup/preview.test.ts +0 -159
- package/src/commands/cliproxy/setup/preview.ts +0 -41
- package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
- package/src/commands/cliproxy/setup/prompts.ts +0 -99
- package/src/commands/cliproxy/setup/providers.test.ts +0 -245
- package/src/commands/cliproxy/setup/providers.ts +0 -136
- package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
- package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
- package/src/commands/cliproxy/setup/templates.test.ts +0 -357
- package/src/commands/cliproxy/setup/templates.ts +0 -145
- package/src/commands/cliproxy/setup/validation.test.ts +0 -567
- package/src/commands/cliproxy/setup/validation.ts +0 -209
- package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
- package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
- package/src/commands/cliproxy/setup.test.ts +0 -2366
- package/src/commands/cliproxy/setup.ts +0 -809
- package/src/commands/cliproxy/shared.test.ts +0 -118
- package/src/commands/cliproxy/shared.ts +0 -84
- package/src/commands/cliproxy/status.test.ts +0 -1157
- package/src/commands/cliproxy/status.ts +0 -597
- package/src/commands/dashboard/deploy.test.ts +0 -215
- package/src/commands/dashboard/deploy.ts +0 -135
- package/src/commands/dashboard/host.test.ts +0 -79
- package/src/commands/dashboard/host.ts +0 -33
- package/src/commands/dashboard/index.ts +0 -13
- package/src/commands/dashboard/logs.test.ts +0 -317
- package/src/commands/dashboard/logs.ts +0 -180
- package/src/commands/dashboard/status.test.ts +0 -708
- package/src/commands/dashboard/status.ts +0 -293
- package/src/commands/gateway/backup.test.ts +0 -426
- package/src/commands/gateway/backup.ts +0 -205
- package/src/commands/gateway/deploy.test.ts +0 -491
- package/src/commands/gateway/deploy.ts +0 -173
- package/src/commands/gateway/host.test.ts +0 -89
- package/src/commands/gateway/host.ts +0 -33
- package/src/commands/gateway/index.ts +0 -17
- package/src/commands/gateway/logs.test.ts +0 -263
- package/src/commands/gateway/logs.ts +0 -160
- package/src/commands/gateway/restore.test.ts +0 -650
- package/src/commands/gateway/restore.ts +0 -308
- package/src/commands/gateway/status.test.ts +0 -755
- package/src/commands/gateway/status.ts +0 -255
- package/src/commands/keeweb/deploy.test.ts +0 -177
- package/src/commands/keeweb/deploy.ts +0 -191
- package/src/commands/keeweb/index.ts +0 -11
- package/src/commands/keeweb/open.test.ts +0 -54
- package/src/commands/keeweb/open.ts +0 -30
- package/src/commands/keeweb/status.test.ts +0 -509
- package/src/commands/keeweb/status.ts +0 -359
- package/src/commands/mcp.test.ts +0 -260
- package/src/commands/mcp.ts +0 -58
- package/src/commands/status.test.ts +0 -222
- package/src/commands/status.ts +0 -161
- package/src/commands/umami/deploy.test.ts +0 -202
- package/src/commands/umami/deploy.ts +0 -132
- package/src/commands/umami/host.test.ts +0 -79
- package/src/commands/umami/host.ts +0 -33
- package/src/commands/umami/index.ts +0 -13
- package/src/commands/umami/logs.test.ts +0 -191
- package/src/commands/umami/logs.ts +0 -163
- package/src/commands/umami/status.test.ts +0 -633
- package/src/commands/umami/status.ts +0 -285
- package/src/commands/vpn/client.test.ts +0 -949
- package/src/commands/vpn/client.ts +0 -467
- package/src/commands/vpn/deploy.test.ts +0 -208
- package/src/commands/vpn/deploy.ts +0 -143
- package/src/commands/vpn/host.test.ts +0 -89
- package/src/commands/vpn/host.ts +0 -36
- package/src/commands/vpn/index.ts +0 -15
- package/src/commands/vpn/logs.test.ts +0 -234
- package/src/commands/vpn/logs.ts +0 -136
- package/src/commands/vpn/peers.test.ts +0 -478
- package/src/commands/vpn/shared.test.ts +0 -454
- package/src/commands/vpn/shared.ts +0 -170
- package/src/commands/vpn/status.test.ts +0 -142
- package/src/commands/vpn/status.ts +0 -78
- package/src/conventions.test.ts +0 -1180
- package/src/lib/action-ctx.ts +0 -25
- package/src/lib/known-hosts.test.ts +0 -192
- package/src/lib/known-hosts.ts +0 -76
- package/src/lib/redact.ts +0 -21
- package/src/lib/ssh-identity.test.ts +0 -134
- package/src/lib/ssh-identity.ts +0 -76
- /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
|
@@ -1,567 +0,0 @@
|
|
|
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('regression: entries WITH owned_by openai still parse and are detected as OpenAI', async () => {
|
|
255
|
-
const fixture = {
|
|
256
|
-
data: [
|
|
257
|
-
{id: 'claude-sonnet-4-6', owned_by: 'anthropic'},
|
|
258
|
-
{id: 'gpt-5.4-mini', owned_by: 'openai'},
|
|
259
|
-
],
|
|
260
|
-
object: 'list',
|
|
261
|
-
}
|
|
262
|
-
globalThis.fetch = mock(async () => new Response(JSON.stringify(fixture))) as unknown as typeof fetch
|
|
263
|
-
|
|
264
|
-
await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')).resolves.toBeUndefined()
|
|
265
|
-
})
|
|
266
|
-
|
|
267
|
-
it('v7 compatibility: entries omitting owned_by parse without error and OpenAI is detected via id prefix', async () => {
|
|
268
|
-
// CLIProxyAPI v7 may return entries without owned_by — these must not fail Zod parse,
|
|
269
|
-
// and an entry like {id: 'openai/gpt-5.4-mini'} should be detected as an OpenAI model.
|
|
270
|
-
const v7Fixture = {
|
|
271
|
-
data: [
|
|
272
|
-
{id: 'openai/gpt-5.4-mini', object: 'model'},
|
|
273
|
-
{id: 'anthropic/claude-sonnet-4-6', object: 'model'},
|
|
274
|
-
],
|
|
275
|
-
object: 'list',
|
|
276
|
-
}
|
|
277
|
-
globalThis.fetch = mock(async () => new Response(JSON.stringify(v7Fixture))) as unknown as typeof fetch
|
|
278
|
-
|
|
279
|
-
// Requesting a model that doesn't exist so we can observe which check throws.
|
|
280
|
-
// The error must be "not found on proxy" — not a Zod parse error or "No OpenAI models on proxy".
|
|
281
|
-
await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/nonexistent-model')).rejects.toThrow(
|
|
282
|
-
'not found on proxy',
|
|
283
|
-
)
|
|
284
|
-
})
|
|
285
|
-
|
|
286
|
-
it('v7 compatibility: mixed entries (some with owned_by, some without) resolve correctly', async () => {
|
|
287
|
-
const mixedFixture = {
|
|
288
|
-
data: [
|
|
289
|
-
{id: 'claude-sonnet-4-6', owned_by: 'anthropic'},
|
|
290
|
-
// v7-style OpenAI entry — no owned_by, id has openai/ prefix
|
|
291
|
-
{id: 'openai/gpt-5.4-mini', object: 'model'},
|
|
292
|
-
],
|
|
293
|
-
object: 'list',
|
|
294
|
-
}
|
|
295
|
-
globalThis.fetch = mock(async () => new Response(JSON.stringify(mixedFixture))) as unknown as typeof fetch
|
|
296
|
-
|
|
297
|
-
// Should detect OpenAI via id inference and pass the OpenAI presence check.
|
|
298
|
-
// The error must be "not found on proxy" — not a Zod parse error or "No OpenAI models on proxy".
|
|
299
|
-
await expect(
|
|
300
|
-
verifyModelsAvailable(BASE_URL, KEY, ['anthropic', 'openai'], 'openai/nonexistent-model'),
|
|
301
|
-
).rejects.toThrow('not found on proxy')
|
|
302
|
-
})
|
|
303
|
-
|
|
304
|
-
it('v7 compatibility: entry with unrecognizable id and no owned_by does not crash (falls through as non-OpenAI)', async () => {
|
|
305
|
-
const unknownIdFixture = {
|
|
306
|
-
data: [
|
|
307
|
-
// No owned_by, id doesn't map to any known provider
|
|
308
|
-
{id: 'some-unknown-model', object: 'model'},
|
|
309
|
-
// A real OpenAI entry with owned_by — detection should succeed via this entry
|
|
310
|
-
{id: 'gpt-5.4-mini', owned_by: 'openai'},
|
|
311
|
-
],
|
|
312
|
-
object: 'list',
|
|
313
|
-
}
|
|
314
|
-
globalThis.fetch = mock(async () => new Response(JSON.stringify(unknownIdFixture))) as unknown as typeof fetch
|
|
315
|
-
|
|
316
|
-
// Should still pass — openai is detected via owned_by on the second entry,
|
|
317
|
-
// and the unknown entry does not cause a crash.
|
|
318
|
-
await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')).resolves.toBeUndefined()
|
|
319
|
-
})
|
|
320
|
-
|
|
321
|
-
it('error path: dual providers, no owned_by=openai entries — throws no-openai-models message', async () => {
|
|
322
|
-
const anthropicOnlyData = {
|
|
323
|
-
data: [
|
|
324
|
-
{id: 'claude-3-7-sonnet-20250219', owned_by: 'anthropic'},
|
|
325
|
-
{id: 'claude-sonnet-4-6', owned_by: 'anthropic'},
|
|
326
|
-
],
|
|
327
|
-
}
|
|
328
|
-
globalThis.fetch = mock(async () => new Response(JSON.stringify(anthropicOnlyData))) as unknown as typeof fetch
|
|
329
|
-
|
|
330
|
-
await expect(verifyModelsAvailable(BASE_URL, KEY, ['anthropic', 'openai'], 'openai/gpt-5.4-mini')).rejects.toThrow(
|
|
331
|
-
'No OpenAI models on proxy',
|
|
332
|
-
)
|
|
333
|
-
})
|
|
334
|
-
})
|
|
335
|
-
|
|
336
|
-
// ── validateSetupOptions — providers/model validation ─────────────────────────
|
|
337
|
-
|
|
338
|
-
describe('validateSetupOptions — providers/model validation', () => {
|
|
339
|
-
it('regression: no providers/model passes unchanged (anthropic-only default)', () => {
|
|
340
|
-
expect(() => validateSetupOptions({key: 'sk-test', repo: 'owner/repo', harness: 'opencode'}, false)).not.toThrow()
|
|
341
|
-
})
|
|
342
|
-
|
|
343
|
-
it('happy path: single provider anthropic, no model — passes', () => {
|
|
344
|
-
expect(() =>
|
|
345
|
-
validateSetupOptions({key: 'sk-test', repo: 'owner/repo', harness: 'opencode', providers: 'anthropic'}, false),
|
|
346
|
-
).not.toThrow()
|
|
347
|
-
})
|
|
348
|
-
|
|
349
|
-
it('happy path: openai + model with openai prefix — passes', () => {
|
|
350
|
-
expect(() =>
|
|
351
|
-
validateSetupOptions(
|
|
352
|
-
{key: 'sk-test', repo: 'owner/repo', harness: 'opencode', providers: 'openai', model: 'openai/gpt-5.4-mini'},
|
|
353
|
-
false,
|
|
354
|
-
),
|
|
355
|
-
).not.toThrow()
|
|
356
|
-
})
|
|
357
|
-
|
|
358
|
-
it('happy path: anthropic,openai + model with openai prefix — passes', () => {
|
|
359
|
-
expect(() =>
|
|
360
|
-
validateSetupOptions(
|
|
361
|
-
{
|
|
362
|
-
key: 'sk-test',
|
|
363
|
-
repo: 'owner/repo',
|
|
364
|
-
harness: 'opencode',
|
|
365
|
-
providers: 'anthropic,openai',
|
|
366
|
-
model: 'openai/gpt-5.4-mini',
|
|
367
|
-
},
|
|
368
|
-
false,
|
|
369
|
-
),
|
|
370
|
-
).not.toThrow()
|
|
371
|
-
})
|
|
372
|
-
|
|
373
|
-
it('error: multiple providers without --model throws "Pass --model" error', () => {
|
|
374
|
-
expect(() => validateSetupOptions({harness: 'opencode', providers: 'anthropic,openai'}, false)).toThrow(
|
|
375
|
-
'Pass --model <provider/model-id> when selecting multiple providers.',
|
|
376
|
-
)
|
|
377
|
-
})
|
|
378
|
-
|
|
379
|
-
it('error: model prefix does not match single provider (anthropic provider, openai model)', () => {
|
|
380
|
-
expect(() =>
|
|
381
|
-
validateSetupOptions({harness: 'opencode', providers: 'anthropic', model: 'openai/gpt-5.4-mini'}, false),
|
|
382
|
-
).toThrow(/Model prefix openai does not match selected providers/)
|
|
383
|
-
})
|
|
384
|
-
|
|
385
|
-
it('error: model prefix does not match single provider (openai provider, anthropic model)', () => {
|
|
386
|
-
expect(() =>
|
|
387
|
-
validateSetupOptions({harness: 'opencode', providers: 'openai', model: 'anthropic/claude-sonnet-4-6'}, false),
|
|
388
|
-
).toThrow(/Model prefix anthropic does not match selected providers/)
|
|
389
|
-
})
|
|
390
|
-
|
|
391
|
-
it('error: duplicate providers throws from parseProviders', () => {
|
|
392
|
-
expect(() => validateSetupOptions({harness: 'opencode', providers: 'anthropic,anthropic'}, false)).toThrow(
|
|
393
|
-
/duplicate/,
|
|
394
|
-
)
|
|
395
|
-
})
|
|
396
|
-
|
|
397
|
-
it('error: unknown provider throws from parseProviders', () => {
|
|
398
|
-
expect(() => validateSetupOptions({harness: 'opencode', providers: 'claude'}, false)).toThrow(/Unknown provider/)
|
|
399
|
-
})
|
|
400
|
-
|
|
401
|
-
it('interactive mode: providers/model checks are skipped even with invalid combo', () => {
|
|
402
|
-
// Multiple providers without model — would fail in non-interactive, but interactive skips all checks
|
|
403
|
-
expect(() => validateSetupOptions({providers: 'anthropic,openai'}, true)).not.toThrow()
|
|
404
|
-
})
|
|
405
|
-
})
|
|
406
|
-
|
|
407
|
-
// ── FIX 1: owned_by:'' falls back to id inference ────────────────────────────
|
|
408
|
-
|
|
409
|
-
describe('verifyModelsAvailable — owned_by empty string falls back to id inference', () => {
|
|
410
|
-
let originalFetch: typeof globalThis.fetch
|
|
411
|
-
afterEach(() => {
|
|
412
|
-
globalThis.fetch = originalFetch
|
|
413
|
-
})
|
|
414
|
-
originalFetch = globalThis.fetch
|
|
415
|
-
|
|
416
|
-
it('entry with owned_by:"" and id "openai/gpt-5.4-mini" is detected as OpenAI (does not throw no-openai-models)', async () => {
|
|
417
|
-
const fixture = {
|
|
418
|
-
data: [{id: 'openai/gpt-5.4-mini', owned_by: ''}],
|
|
419
|
-
}
|
|
420
|
-
globalThis.fetch = mock(async () => new Response(JSON.stringify(fixture))) as unknown as typeof fetch
|
|
421
|
-
|
|
422
|
-
// Must resolve — empty owned_by should fall back to id prefix inference
|
|
423
|
-
await expect(
|
|
424
|
-
verifyModelsAvailable('https://cliproxy.fro.bot', 'sk-test-key', ['openai'], 'openai/gpt-5.4-mini'),
|
|
425
|
-
).resolves.toBeUndefined()
|
|
426
|
-
})
|
|
427
|
-
})
|
|
428
|
-
|
|
429
|
-
// ── FIX 2: v7-prefixed model presence ────────────────────────────────────────
|
|
430
|
-
|
|
431
|
-
describe('verifyModelsAvailable — v7-prefixed model id matched without owned_by', () => {
|
|
432
|
-
let originalFetch: typeof globalThis.fetch
|
|
433
|
-
afterEach(() => {
|
|
434
|
-
globalThis.fetch = originalFetch
|
|
435
|
-
})
|
|
436
|
-
originalFetch = globalThis.fetch
|
|
437
|
-
|
|
438
|
-
it('entry with id "openai/gpt-5.4-mini" (no owned_by) resolves when requesting "openai/gpt-5.4-mini"', async () => {
|
|
439
|
-
const fixture = {
|
|
440
|
-
data: [{id: 'openai/gpt-5.4-mini'}],
|
|
441
|
-
}
|
|
442
|
-
globalThis.fetch = mock(async () => new Response(JSON.stringify(fixture))) as unknown as typeof fetch
|
|
443
|
-
|
|
444
|
-
await expect(
|
|
445
|
-
verifyModelsAvailable('https://cliproxy.fro.bot', 'sk-test-key', ['openai'], 'openai/gpt-5.4-mini'),
|
|
446
|
-
).resolves.toBeUndefined()
|
|
447
|
-
})
|
|
448
|
-
|
|
449
|
-
it('model not found error lists requested model and available openai id but not anthropic ids', async () => {
|
|
450
|
-
const fixture = {
|
|
451
|
-
data: [{id: 'openai/gpt-5.4-mini'}, {id: 'anthropic/claude-sonnet-4-6'}],
|
|
452
|
-
}
|
|
453
|
-
globalThis.fetch = mock(async () => new Response(JSON.stringify(fixture))) as unknown as typeof fetch
|
|
454
|
-
|
|
455
|
-
let errorMessage = ''
|
|
456
|
-
try {
|
|
457
|
-
await verifyModelsAvailable('https://cliproxy.fro.bot', 'sk-test-key', ['openai'], 'openai/nonexistent')
|
|
458
|
-
} catch (error) {
|
|
459
|
-
errorMessage = error instanceof Error ? error.message : String(error)
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
expect(errorMessage).toContain('nonexistent')
|
|
463
|
-
expect(errorMessage).toContain('openai/gpt-5.4-mini')
|
|
464
|
-
expect(errorMessage).not.toContain('anthropic')
|
|
465
|
-
expect(errorMessage).not.toContain('claude')
|
|
466
|
-
})
|
|
467
|
-
})
|
|
468
|
-
|
|
469
|
-
// ── FIX 3: exact key redacted in /v1/models error body ───────────────────────
|
|
470
|
-
|
|
471
|
-
describe('verifyModelsAvailable — exact key redacted in error body', () => {
|
|
472
|
-
let originalFetch: typeof globalThis.fetch
|
|
473
|
-
afterEach(() => {
|
|
474
|
-
globalThis.fetch = originalFetch
|
|
475
|
-
})
|
|
476
|
-
originalFetch = globalThis.fetch
|
|
477
|
-
|
|
478
|
-
it('500 response body containing the raw key does not expose the key in thrown message', async () => {
|
|
479
|
-
const rawKey = 'my-plain-key-no-bearer-prefix'
|
|
480
|
-
const body = `Internal error: key=${rawKey} was invalid`
|
|
481
|
-
globalThis.fetch = mock(async () => new Response(body, {status: 500})) as unknown as typeof fetch
|
|
482
|
-
|
|
483
|
-
let errorMessage = ''
|
|
484
|
-
try {
|
|
485
|
-
await verifyModelsAvailable('https://cliproxy.fro.bot', rawKey, ['openai'], 'openai/gpt-5.4-mini')
|
|
486
|
-
} catch (error) {
|
|
487
|
-
errorMessage = error instanceof Error ? error.message : String(error)
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
expect(errorMessage).toContain('500')
|
|
491
|
-
expect(errorMessage).not.toContain(rawKey)
|
|
492
|
-
})
|
|
493
|
-
})
|
|
494
|
-
|
|
495
|
-
// ── assertProxyReachable (new TDD tests) ──────────────────────────────────────
|
|
496
|
-
|
|
497
|
-
describe('assertProxyReachable', () => {
|
|
498
|
-
let originalFetch: typeof globalThis.fetch
|
|
499
|
-
afterEach(() => {
|
|
500
|
-
globalThis.fetch = originalFetch
|
|
501
|
-
})
|
|
502
|
-
originalFetch = globalThis.fetch
|
|
503
|
-
|
|
504
|
-
it('happy path: fetch returns HTTP 200 — resolves without throw', async () => {
|
|
505
|
-
globalThis.fetch = mock(async () => new Response('OK', {status: 200})) as unknown as typeof fetch
|
|
506
|
-
|
|
507
|
-
await expect(assertProxyReachable('https://good.example')).resolves.toBeUndefined()
|
|
508
|
-
})
|
|
509
|
-
|
|
510
|
-
it('error path: fetch throws AbortError — throws with "Unable to reach proxy" prefix', async () => {
|
|
511
|
-
const abortError = new DOMException('The operation was aborted.', 'AbortError')
|
|
512
|
-
globalThis.fetch = mock(async () => {
|
|
513
|
-
throw abortError
|
|
514
|
-
}) as unknown as typeof fetch
|
|
515
|
-
|
|
516
|
-
await expect(assertProxyReachable('https://bad.example')).rejects.toThrow(/Unable to reach proxy/)
|
|
517
|
-
})
|
|
518
|
-
|
|
519
|
-
it('error path: fetch returns non-ok status — throws with "Proxy check failed" prefix', async () => {
|
|
520
|
-
globalThis.fetch = mock(async () => new Response('Bad Gateway', {status: 502})) as unknown as typeof fetch
|
|
521
|
-
|
|
522
|
-
await expect(assertProxyReachable('https://bad.example')).rejects.toThrow(/Proxy check failed/)
|
|
523
|
-
})
|
|
524
|
-
|
|
525
|
-
it('probes /healthz: resolves when /healthz returns 200 and bare base returns 404', async () => {
|
|
526
|
-
const BASE = 'https://proxy.example'
|
|
527
|
-
let fetchedUrl: string | undefined
|
|
528
|
-
globalThis.fetch = mock(async (url: string) => {
|
|
529
|
-
fetchedUrl = url
|
|
530
|
-
if (url === `${BASE}/healthz`) return new Response('{"status":"ok"}', {status: 200})
|
|
531
|
-
return new Response('Not Found', {status: 404})
|
|
532
|
-
}) as unknown as typeof fetch
|
|
533
|
-
|
|
534
|
-
await expect(assertProxyReachable(BASE)).resolves.toBeUndefined()
|
|
535
|
-
expect(fetchedUrl).toBe(`${BASE}/healthz`)
|
|
536
|
-
})
|
|
537
|
-
})
|
|
538
|
-
|
|
539
|
-
// ── assertProxyKeyWorks (new TDD tests) ───────────────────────────────────────
|
|
540
|
-
|
|
541
|
-
describe('assertProxyKeyWorks', () => {
|
|
542
|
-
let originalFetch: typeof globalThis.fetch
|
|
543
|
-
afterEach(() => {
|
|
544
|
-
globalThis.fetch = originalFetch
|
|
545
|
-
})
|
|
546
|
-
originalFetch = globalThis.fetch
|
|
547
|
-
|
|
548
|
-
it('happy path: fetch returns HTTP 200 — resolves without throw', async () => {
|
|
549
|
-
globalThis.fetch = mock(async () => new Response('OK', {status: 200})) as unknown as typeof fetch
|
|
550
|
-
|
|
551
|
-
await expect(assertProxyKeyWorks('https://good.example', 'sk-good')).resolves.toBeUndefined()
|
|
552
|
-
})
|
|
553
|
-
|
|
554
|
-
it('error path: fetch returns HTTP 401 — throws with "Unable to verify proxy key" prefix', async () => {
|
|
555
|
-
globalThis.fetch = mock(async () => new Response('Unauthorized', {status: 401})) as unknown as typeof fetch
|
|
556
|
-
|
|
557
|
-
await expect(assertProxyKeyWorks('https://good.example', 'sk-bad')).rejects.toThrow(/Proxy key verification failed/)
|
|
558
|
-
})
|
|
559
|
-
|
|
560
|
-
it('error path: fetch throws network error — throws with "Unable to verify proxy key" prefix', async () => {
|
|
561
|
-
globalThis.fetch = mock(async () => {
|
|
562
|
-
throw new Error('network failure')
|
|
563
|
-
}) as unknown as typeof fetch
|
|
564
|
-
|
|
565
|
-
await expect(assertProxyKeyWorks('https://good.example', 'sk-good')).rejects.toThrow(/Unable to verify proxy key/)
|
|
566
|
-
})
|
|
567
|
-
})
|