@marcusrbrown/infra 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__snapshots__/cli.test.ts.snap +6 -0
- package/src/commands/cliproxy/config.ts +2 -26
- package/src/commands/cliproxy/keys.ts +8 -43
- package/src/commands/cliproxy/setup/gh.test.ts +218 -0
- package/src/commands/cliproxy/setup/gh.ts +250 -0
- package/src/commands/cliproxy/setup/preview.test.ts +159 -0
- package/src/commands/cliproxy/setup/preview.ts +41 -0
- package/src/commands/cliproxy/setup/prompts.test.ts +58 -0
- package/src/commands/cliproxy/setup/prompts.ts +99 -0
- package/src/commands/cliproxy/setup/providers.test.ts +228 -0
- package/src/commands/cliproxy/setup/providers.ts +136 -0
- package/src/commands/cliproxy/setup/smoke-test.test.ts +643 -0
- package/src/commands/cliproxy/setup/smoke-test.ts +205 -0
- package/src/commands/cliproxy/setup/templates.test.ts +358 -0
- package/src/commands/cliproxy/setup/templates.ts +158 -0
- package/src/commands/cliproxy/setup/validation.test.ts +399 -0
- package/src/commands/cliproxy/setup/validation.ts +182 -0
- package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +341 -0
- package/src/commands/cliproxy/setup/workflow-analyzer.ts +137 -0
- package/src/commands/cliproxy/setup.test.ts +1867 -247
- package/src/commands/cliproxy/setup.ts +544 -831
- package/src/commands/cliproxy/shared.test.ts +118 -0
- package/src/commands/cliproxy/shared.ts +84 -0
- package/src/commands/cliproxy/status.ts +2 -7
|
@@ -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
|
+
})
|