@marcusrbrown/infra 0.10.4 → 0.11.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/README.md +3 -0
- package/package.json +1 -1
- package/src/__snapshots__/cli.test.ts.snap +7 -0
- package/src/commands/cliproxy/index.ts +2 -0
- package/src/commands/cliproxy/models.test.ts +356 -0
- package/src/commands/cliproxy/models.ts +163 -0
- package/src/commands/mcp.test.ts +104 -2
- package/src/commands/mcp.ts +1 -0
package/README.md
CHANGED
|
@@ -45,6 +45,7 @@ Manage the CLIProxyAPI proxy on `cliproxy.fro.bot`.
|
|
|
45
45
|
| Subcommand | Description |
|
|
46
46
|
| ----------------- | ------------------------------------------------------------------------ |
|
|
47
47
|
| `cliproxy status` | HTTP reachability, usage stats, version |
|
|
48
|
+
| `cliproxy models` | List models served at /v1/models; optional `[provider]` filter |
|
|
48
49
|
| `cliproxy deploy` | Trigger deployment (GitHub Actions by default; `--local` for direct SSH) |
|
|
49
50
|
| `cliproxy config` | Get or set proxy configuration via the management API |
|
|
50
51
|
| `cliproxy keys` | List, add, or remove API keys via the management API |
|
|
@@ -54,6 +55,8 @@ Manage the CLIProxyAPI proxy on `cliproxy.fro.bot`.
|
|
|
54
55
|
|
|
55
56
|
```bash
|
|
56
57
|
bunx @marcusrbrown/infra cliproxy status
|
|
58
|
+
bunx @marcusrbrown/infra cliproxy models
|
|
59
|
+
bunx @marcusrbrown/infra cliproxy models anthropic
|
|
57
60
|
bunx @marcusrbrown/infra cliproxy deploy
|
|
58
61
|
bunx @marcusrbrown/infra cliproxy config get
|
|
59
62
|
bunx @marcusrbrown/infra cliproxy config set
|
package/package.json
CHANGED
|
@@ -76,6 +76,13 @@ Commands:
|
|
|
76
76
|
--host [host] CLIProxyAPI droplet host for SSH execution. Falls back to CLIPROXY_DOMAIN or cliproxy.fro.bot.
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
cliproxy models [provider] List the models CLIProxyAPI serves.
|
|
80
|
+
|
|
81
|
+
--url [url] Base URL for CLIProxyAPI. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.
|
|
82
|
+
--key [key] API key (bearer). Falls back to CLIPROXY_API_KEY when omitted.
|
|
83
|
+
--verbose Show owned_by and created date for each model.
|
|
84
|
+
|
|
85
|
+
|
|
79
86
|
cliproxy open Launch CLIProxyAPI TUI dashboard via SSH.
|
|
80
87
|
|
|
81
88
|
--host [host] SSH host for CLIProxyAPI server. Falls back to CLIPROXY_DOMAIN or cliproxy.fro.bot.
|
|
@@ -4,6 +4,7 @@ import {registerCliproxyConfig} from './config'
|
|
|
4
4
|
import {registerCliproxyDeploy} from './deploy'
|
|
5
5
|
import {registerCliproxyKeys} from './keys'
|
|
6
6
|
import {registerCliproxyLogin} from './login'
|
|
7
|
+
import {registerCliproxyModels} from './models'
|
|
7
8
|
import {registerCliproxyOpen} from './open'
|
|
8
9
|
import {registerCliproxySetup} from './setup'
|
|
9
10
|
import {registerCliproxyStatus} from './status'
|
|
@@ -14,6 +15,7 @@ export function registerCliproxyCommands(cli: ReturnType<typeof goke>): void {
|
|
|
14
15
|
registerCliproxyConfig(cli)
|
|
15
16
|
registerCliproxyKeys(cli)
|
|
16
17
|
registerCliproxyLogin(cli)
|
|
18
|
+
registerCliproxyModels(cli)
|
|
17
19
|
registerCliproxyOpen(cli)
|
|
18
20
|
registerCliproxySetup(cli)
|
|
19
21
|
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
2
|
+
import {goke} from 'goke'
|
|
3
|
+
|
|
4
|
+
import {createCapturedCtx, MockProcessExit} from '../../__test__/mcp-ctx-fixture'
|
|
5
|
+
import {cliproxyModelsAction, registerCliproxyModels} from './models'
|
|
6
|
+
|
|
7
|
+
const originalFetch = globalThis.fetch
|
|
8
|
+
|
|
9
|
+
type FetchReplacement = (url: string, init?: RequestInit) => Promise<Response>
|
|
10
|
+
|
|
11
|
+
function createFetchImplementation(handler: FetchReplacement): typeof fetch {
|
|
12
|
+
return Object.assign(
|
|
13
|
+
(input: string | URL | Request, init?: RequestInit) => {
|
|
14
|
+
if (typeof input !== 'string') {
|
|
15
|
+
throw new TypeError(`Unexpected non-string fetch input: ${String(input)}`)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return handler(input, init)
|
|
19
|
+
},
|
|
20
|
+
{preconnect: originalFetch.preconnect},
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MIXED_MODELS_RESPONSE = {
|
|
25
|
+
object: 'list',
|
|
26
|
+
data: [
|
|
27
|
+
{id: 'claude-3-5-sonnet-20241022', object: 'model', owned_by: 'anthropic', created: 1_700_000_000},
|
|
28
|
+
{id: 'claude-opus-4', object: 'model', owned_by: 'anthropic', created: 1_710_000_000},
|
|
29
|
+
{id: 'gpt-4o', object: 'model', owned_by: 'openai', created: 1_705_000_000},
|
|
30
|
+
{id: 'gpt-4o-mini', object: 'model', owned_by: 'openai', created: 1_706_000_000},
|
|
31
|
+
],
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const OPENAI_ONLY_RESPONSE = {
|
|
35
|
+
object: 'list',
|
|
36
|
+
data: [
|
|
37
|
+
{id: 'gpt-4o', object: 'model', owned_by: 'openai', created: 1_705_000_000},
|
|
38
|
+
{id: 'gpt-4o-mini', object: 'model', owned_by: 'openai', created: 1_706_000_000},
|
|
39
|
+
],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Models without owned_by — provider inferred from id prefix
|
|
43
|
+
const NO_OWNED_BY_RESPONSE = {
|
|
44
|
+
object: 'list',
|
|
45
|
+
data: [
|
|
46
|
+
{id: 'claude-3-5-sonnet-20241022', object: 'model', created: 1_700_000_000},
|
|
47
|
+
{id: 'gpt-4o', object: 'model', created: 1_705_000_000},
|
|
48
|
+
],
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('cliproxyModelsAction — plain output', () => {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
globalThis.fetch = createFetchImplementation(async () => {
|
|
54
|
+
throw new Error('Unexpected fetch call')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
globalThis.fetch = originalFetch
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('lists all model ids one per line when no provider filter', async () => {
|
|
63
|
+
globalThis.fetch = createFetchImplementation(
|
|
64
|
+
async () =>
|
|
65
|
+
new Response(JSON.stringify(MIXED_MODELS_RESPONSE), {
|
|
66
|
+
status: 200,
|
|
67
|
+
headers: {'content-type': 'application/json'},
|
|
68
|
+
}),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const {ctx, captured} = createCapturedCtx()
|
|
72
|
+
await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key'}, ctx)
|
|
73
|
+
|
|
74
|
+
const output = captured.stdout.join('\n')
|
|
75
|
+
expect(output).toContain('claude-3-5-sonnet-20241022')
|
|
76
|
+
expect(output).toContain('claude-opus-4')
|
|
77
|
+
expect(output).toContain('gpt-4o')
|
|
78
|
+
expect(output).toContain('gpt-4o-mini')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('filters to openai models only when provider=openai', async () => {
|
|
82
|
+
globalThis.fetch = createFetchImplementation(
|
|
83
|
+
async () =>
|
|
84
|
+
new Response(JSON.stringify(MIXED_MODELS_RESPONSE), {
|
|
85
|
+
status: 200,
|
|
86
|
+
headers: {'content-type': 'application/json'},
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const {ctx, captured} = createCapturedCtx()
|
|
91
|
+
await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', provider: 'openai'}, ctx)
|
|
92
|
+
|
|
93
|
+
const output = captured.stdout.join('\n')
|
|
94
|
+
expect(output).toContain('gpt-4o')
|
|
95
|
+
expect(output).toContain('gpt-4o-mini')
|
|
96
|
+
expect(output).not.toContain('claude')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('matches provider via id prefix when owned_by is absent', async () => {
|
|
100
|
+
globalThis.fetch = createFetchImplementation(
|
|
101
|
+
async () =>
|
|
102
|
+
new Response(JSON.stringify(NO_OWNED_BY_RESPONSE), {
|
|
103
|
+
status: 200,
|
|
104
|
+
headers: {'content-type': 'application/json'},
|
|
105
|
+
}),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
const {ctx, captured} = createCapturedCtx()
|
|
109
|
+
await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', provider: 'anthropic'}, ctx)
|
|
110
|
+
|
|
111
|
+
const output = captured.stdout.join('\n')
|
|
112
|
+
expect(output).toContain('claude-3-5-sonnet-20241022')
|
|
113
|
+
expect(output).not.toContain('gpt-4o')
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('cliproxyModelsAction — verbose output', () => {
|
|
118
|
+
afterEach(() => {
|
|
119
|
+
globalThis.fetch = originalFetch
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('shows owned_by and formatted date in verbose mode', async () => {
|
|
123
|
+
globalThis.fetch = createFetchImplementation(
|
|
124
|
+
async () =>
|
|
125
|
+
new Response(JSON.stringify(OPENAI_ONLY_RESPONSE), {
|
|
126
|
+
status: 200,
|
|
127
|
+
headers: {'content-type': 'application/json'},
|
|
128
|
+
}),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
const {ctx, captured} = createCapturedCtx()
|
|
132
|
+
await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', verbose: true}, ctx)
|
|
133
|
+
|
|
134
|
+
const output = captured.stdout.join('\n')
|
|
135
|
+
expect(output).toContain('gpt-4o')
|
|
136
|
+
expect(output).toContain('openai')
|
|
137
|
+
// created=1705000000 → 2024-01-11 or similar ISO date prefix
|
|
138
|
+
expect(output).toMatch(/\d{4}-\d{2}-\d{2}/)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('cliproxyModelsAction — empty results', () => {
|
|
143
|
+
afterEach(() => {
|
|
144
|
+
globalThis.fetch = originalFetch
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('prints "No models." when data array is empty', async () => {
|
|
148
|
+
globalThis.fetch = createFetchImplementation(
|
|
149
|
+
async () =>
|
|
150
|
+
new Response(JSON.stringify({object: 'list', data: []}), {
|
|
151
|
+
status: 200,
|
|
152
|
+
headers: {'content-type': 'application/json'},
|
|
153
|
+
}),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
const {ctx, captured} = createCapturedCtx()
|
|
157
|
+
await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key'}, ctx)
|
|
158
|
+
|
|
159
|
+
const output = captured.stdout.join('\n')
|
|
160
|
+
expect(output).toContain('No models')
|
|
161
|
+
expect(captured.exit).toBeNull()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('prints provider-specific "No models" message when filter yields zero matches', async () => {
|
|
165
|
+
globalThis.fetch = createFetchImplementation(
|
|
166
|
+
async () =>
|
|
167
|
+
new Response(JSON.stringify(OPENAI_ONLY_RESPONSE), {
|
|
168
|
+
status: 200,
|
|
169
|
+
headers: {'content-type': 'application/json'},
|
|
170
|
+
}),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
const {ctx, captured} = createCapturedCtx()
|
|
174
|
+
await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', provider: 'anthropic'}, ctx)
|
|
175
|
+
|
|
176
|
+
const output = captured.stdout.join('\n')
|
|
177
|
+
expect(output).toContain('for provider anthropic')
|
|
178
|
+
expect(captured.exit).toBeNull()
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('cliproxyModelsAction — validation errors', () => {
|
|
183
|
+
afterEach(() => {
|
|
184
|
+
globalThis.fetch = originalFetch
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('exits with error for unknown provider without making a fetch call', async () => {
|
|
188
|
+
let fetchCalled = false
|
|
189
|
+
globalThis.fetch = createFetchImplementation(async () => {
|
|
190
|
+
fetchCalled = true
|
|
191
|
+
return new Response('{}', {status: 200})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
const {ctx, captured} = createCapturedCtx()
|
|
195
|
+
await expect(
|
|
196
|
+
cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', provider: 'gemini'}, ctx),
|
|
197
|
+
).rejects.toMatchObject({name: 'MockProcessExit', code: 1})
|
|
198
|
+
|
|
199
|
+
expect(fetchCalled).toBe(false)
|
|
200
|
+
expect(captured.stderr.join('')).toMatch(/gemini|provider/i)
|
|
201
|
+
expect(captured.exit).toEqual({code: 1})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('exits with error when no API key is provided', async () => {
|
|
205
|
+
const savedKey = process.env.CLIPROXY_API_KEY
|
|
206
|
+
delete process.env.CLIPROXY_API_KEY
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const {ctx, captured} = createCapturedCtx()
|
|
210
|
+
await expect(cliproxyModelsAction({url: 'https://cliproxy.example.com'}, ctx)).rejects.toMatchObject({
|
|
211
|
+
name: 'MockProcessExit',
|
|
212
|
+
code: 1,
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
expect(captured.stderr.join('')).toMatch(/key|CLIPROXY_API_KEY/i)
|
|
216
|
+
expect(captured.exit).toEqual({code: 1})
|
|
217
|
+
} finally {
|
|
218
|
+
if (savedKey !== undefined) process.env.CLIPROXY_API_KEY = savedKey
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
describe('cliproxyModelsAction — HTTP errors', () => {
|
|
224
|
+
afterEach(() => {
|
|
225
|
+
globalThis.fetch = originalFetch
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('exits with error on 401 and does not include the bearer key in output', async () => {
|
|
229
|
+
const secretKey = 'super-secret-api-key-12345'
|
|
230
|
+
|
|
231
|
+
globalThis.fetch = createFetchImplementation(async () => new Response('Unauthorized', {status: 401}))
|
|
232
|
+
|
|
233
|
+
const {ctx, captured} = createCapturedCtx()
|
|
234
|
+
await expect(
|
|
235
|
+
cliproxyModelsAction({url: 'https://cliproxy.example.com', key: secretKey}, ctx),
|
|
236
|
+
).rejects.toMatchObject({name: 'MockProcessExit', code: 1})
|
|
237
|
+
|
|
238
|
+
const allOutput = [...captured.stdout, ...captured.stderr].join('\n')
|
|
239
|
+
expect(allOutput).not.toContain(secretKey)
|
|
240
|
+
expect(captured.exit).toEqual({code: 1})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('exits with error on malformed JSON response', async () => {
|
|
244
|
+
globalThis.fetch = createFetchImplementation(
|
|
245
|
+
async () =>
|
|
246
|
+
new Response('not valid json {{{', {
|
|
247
|
+
status: 200,
|
|
248
|
+
headers: {'content-type': 'application/json'},
|
|
249
|
+
}),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
const {ctx, captured} = createCapturedCtx()
|
|
253
|
+
await expect(
|
|
254
|
+
cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key'}, ctx),
|
|
255
|
+
).rejects.toMatchObject({name: 'MockProcessExit', code: 1})
|
|
256
|
+
|
|
257
|
+
expect(captured.stderr.join('')).toMatch(/json|malformed|parse/i)
|
|
258
|
+
expect(captured.exit).toEqual({code: 1})
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
describe('cliproxyModelsAction — ambient key not forwarded to untrusted URL', () => {
|
|
263
|
+
let originalEnv: Record<string, string | undefined>
|
|
264
|
+
|
|
265
|
+
beforeEach(() => {
|
|
266
|
+
originalEnv = {
|
|
267
|
+
CLIPROXY_URL: process.env.CLIPROXY_URL,
|
|
268
|
+
CLIPROXY_API_KEY: process.env.CLIPROXY_API_KEY,
|
|
269
|
+
}
|
|
270
|
+
process.env.CLIPROXY_API_KEY = 'ambient-secret-key'
|
|
271
|
+
delete process.env.CLIPROXY_URL
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
afterEach(() => {
|
|
275
|
+
for (const [k, v] of Object.entries(originalEnv)) {
|
|
276
|
+
if (v === undefined) {
|
|
277
|
+
delete process.env[k]
|
|
278
|
+
} else {
|
|
279
|
+
process.env[k] = v
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
globalThis.fetch = originalFetch
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('does not forward ambient CLIPROXY_API_KEY to a non-trusted --url', async () => {
|
|
286
|
+
let capturedAuthHeader: string | null | undefined
|
|
287
|
+
let fetchCalled = false
|
|
288
|
+
|
|
289
|
+
globalThis.fetch = createFetchImplementation(async (_url, init) => {
|
|
290
|
+
fetchCalled = true
|
|
291
|
+
const hdrs = init?.headers
|
|
292
|
+
if (hdrs instanceof Headers) {
|
|
293
|
+
capturedAuthHeader = hdrs.get('Authorization')
|
|
294
|
+
} else if (hdrs !== null && hdrs !== undefined && typeof hdrs === 'object') {
|
|
295
|
+
const h = hdrs as Record<string, string>
|
|
296
|
+
capturedAuthHeader = h.Authorization ?? h.authorization ?? null
|
|
297
|
+
} else {
|
|
298
|
+
capturedAuthHeader = null
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return new Response(JSON.stringify({object: 'list', data: []}), {
|
|
302
|
+
status: 200,
|
|
303
|
+
headers: {'content-type': 'application/json'},
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
const {ctx, captured} = createCapturedCtx()
|
|
308
|
+
// Pass an explicit untrusted URL with no --key.
|
|
309
|
+
// The ambient CLIPROXY_API_KEY must NOT be forwarded to the untrusted host.
|
|
310
|
+
// Two valid outcomes: (a) no fetch is made at all (key withheld → "No API key" error),
|
|
311
|
+
// or (b) fetch is made but without the ambient key in the Authorization header.
|
|
312
|
+
try {
|
|
313
|
+
await cliproxyModelsAction({url: 'https://evil.example.com'}, ctx)
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (!(error instanceof MockProcessExit)) throw error
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (fetchCalled) {
|
|
319
|
+
// If a fetch was made, the ambient key must not appear in the Authorization header
|
|
320
|
+
if (capturedAuthHeader !== null && capturedAuthHeader !== undefined) {
|
|
321
|
+
expect(capturedAuthHeader).not.toContain('ambient-secret-key')
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
// No fetch made — key was withheld entirely (correct behavior)
|
|
325
|
+
// Verify the error output doesn't contain the ambient key either
|
|
326
|
+
const allOutput = [...captured.stdout, ...captured.stderr].join('\n')
|
|
327
|
+
expect(allOutput).not.toContain('ambient-secret-key')
|
|
328
|
+
}
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
describe('registerCliproxyModels — help output and command discovery', () => {
|
|
333
|
+
it('shows [provider] positional, --url, --key, and --verbose in help text', () => {
|
|
334
|
+
const cli = goke('infra')
|
|
335
|
+
registerCliproxyModels(cli)
|
|
336
|
+
cli.help()
|
|
337
|
+
|
|
338
|
+
const helpText = cli.helpText()
|
|
339
|
+
|
|
340
|
+
expect(helpText).toContain('cliproxy models')
|
|
341
|
+
expect(helpText).toContain('[provider]')
|
|
342
|
+
expect(helpText).toContain('--url [url]')
|
|
343
|
+
expect(helpText).toContain('--key [key]')
|
|
344
|
+
expect(helpText).toContain('--verbose')
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('command is discoverable in the registered cliproxy group', () => {
|
|
348
|
+
const cli = goke('infra')
|
|
349
|
+
registerCliproxyModels(cli)
|
|
350
|
+
cli.help()
|
|
351
|
+
|
|
352
|
+
const helpText = cli.helpText()
|
|
353
|
+
|
|
354
|
+
expect(helpText).toContain('List the models CLIProxyAPI serves.')
|
|
355
|
+
})
|
|
356
|
+
})
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type {goke} from 'goke'
|
|
2
|
+
import type {ActionCtx} from '../../lib/action-ctx'
|
|
3
|
+
|
|
4
|
+
import {z} from 'zod'
|
|
5
|
+
|
|
6
|
+
import {requestJson} from './shared'
|
|
7
|
+
|
|
8
|
+
declare const process: {
|
|
9
|
+
env: Record<string, string | undefined>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
|
|
13
|
+
|
|
14
|
+
function stripTrailingSlash(value: string): string {
|
|
15
|
+
return value.endsWith('/') ? value.slice(0, -1) : value
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Permissive schema: unknown fields preserved via passthrough().
|
|
19
|
+
// owned_by is optional — CLIProxyAPI v7 may omit it; provider is inferred from id instead.
|
|
20
|
+
const modelEntrySchema = z
|
|
21
|
+
.object({
|
|
22
|
+
id: z.string(),
|
|
23
|
+
owned_by: z.string().optional(),
|
|
24
|
+
})
|
|
25
|
+
.passthrough()
|
|
26
|
+
|
|
27
|
+
const modelsResponseSchema = z
|
|
28
|
+
.object({
|
|
29
|
+
data: z.array(modelEntrySchema),
|
|
30
|
+
})
|
|
31
|
+
.passthrough()
|
|
32
|
+
|
|
33
|
+
type ModelEntry = z.infer<typeof modelEntrySchema>
|
|
34
|
+
|
|
35
|
+
// Known bare-id patterns per provider, used only when owned_by is absent
|
|
36
|
+
// (e.g. CLIProxyAPI v7 omits owned_by from /v1/models entries).
|
|
37
|
+
const PROVIDER_ID_PATTERNS: Record<string, RegExp> = {
|
|
38
|
+
openai: /^(?:gpt-|o1-|o3-|codex)/i,
|
|
39
|
+
anthropic: /^claude-/i,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const VALID_PROVIDERS = ['anthropic', 'openai'] as const
|
|
43
|
+
type ValidProvider = (typeof VALID_PROVIDERS)[number]
|
|
44
|
+
|
|
45
|
+
function isValidProvider(value: string): value is ValidProvider {
|
|
46
|
+
return (VALID_PROVIDERS as readonly string[]).includes(value)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Decides whether a /v1/models entry belongs to a provider.
|
|
51
|
+
* Prefers owned_by when present; falls back to id prefix / known bare-id
|
|
52
|
+
* patterns when absent. Display/validation-only — never an auth or trust signal.
|
|
53
|
+
*/
|
|
54
|
+
function entryMatchesProvider(entry: ModelEntry, provider: string): boolean {
|
|
55
|
+
if (entry.owned_by !== undefined && entry.owned_by.trim() !== '') {
|
|
56
|
+
return entry.owned_by === provider
|
|
57
|
+
}
|
|
58
|
+
const id = entry.id ?? ''
|
|
59
|
+
if (id.startsWith(`${provider}/`)) {
|
|
60
|
+
return true
|
|
61
|
+
}
|
|
62
|
+
const pattern = PROVIDER_ID_PATTERNS[provider]
|
|
63
|
+
return pattern === undefined ? false : pattern.test(id)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ModelsOptions {
|
|
67
|
+
url?: string
|
|
68
|
+
key?: string
|
|
69
|
+
verbose?: boolean
|
|
70
|
+
provider?: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function cliproxyModelsAction(options: ModelsOptions, ctx: ActionCtx): Promise<void> {
|
|
74
|
+
try {
|
|
75
|
+
const baseUrl = stripTrailingSlash(options.url ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
|
|
76
|
+
|
|
77
|
+
// Trusted URL: the canonical configured/default host, trailing-slash-normalized.
|
|
78
|
+
// Ambient env keys must ONLY follow to this trusted destination to prevent
|
|
79
|
+
// secret exfiltration when an agent passes an attacker-controlled --url.
|
|
80
|
+
const trustedUrl = stripTrailingSlash(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
|
|
81
|
+
const urlIsExplicitlyOverridden = options.url !== undefined && baseUrl !== trustedUrl
|
|
82
|
+
|
|
83
|
+
// An explicit --key is always honored (operator knows what they're doing).
|
|
84
|
+
// An ambient env key is only forwarded when the resolved baseUrl is trusted.
|
|
85
|
+
const key = options.key ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_API_KEY)
|
|
86
|
+
|
|
87
|
+
if (!key) {
|
|
88
|
+
throw new Error('No API key. Provide --key or set CLIPROXY_API_KEY.')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Validate provider before any HTTP call
|
|
92
|
+
if (options.provider !== undefined && !isValidProvider(options.provider)) {
|
|
93
|
+
throw new Error(`Unknown provider "${options.provider}". Use anthropic or openai.`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const endpoint = `${baseUrl}/v1/models`
|
|
97
|
+
const raw = await requestJson(endpoint, {
|
|
98
|
+
headers: {Authorization: `Bearer ${key}`},
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const parseResult = modelsResponseSchema.safeParse(raw)
|
|
102
|
+
if (!parseResult.success) {
|
|
103
|
+
const message = parseResult.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; ')
|
|
104
|
+
throw new Error(`Malformed /v1/models response: ${message}`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let entries = parseResult.data.data
|
|
108
|
+
|
|
109
|
+
if (options.provider !== undefined) {
|
|
110
|
+
entries = entries.filter(e => entryMatchesProvider(e, options.provider as string))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (entries.length === 0) {
|
|
114
|
+
ctx.console.log(
|
|
115
|
+
options.provider === undefined ? 'No models.' : `No models found for provider ${options.provider}.`,
|
|
116
|
+
)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (options.verbose) {
|
|
121
|
+
// Aligned columns: id, owned_by (or '-' if absent), created as ISO date
|
|
122
|
+
const idWidth = Math.max(...entries.map(e => e.id.length), 2)
|
|
123
|
+
const ownerWidth = Math.max(...entries.map(e => (e.owned_by ?? '-').length), 8)
|
|
124
|
+
|
|
125
|
+
for (const entry of entries) {
|
|
126
|
+
const id = entry.id.padEnd(idWidth)
|
|
127
|
+
const owner = (entry.owned_by ?? '-').padEnd(ownerWidth)
|
|
128
|
+
const date = typeof entry.created === 'number' ? new Date(entry.created * 1000).toISOString().slice(0, 10) : '-'
|
|
129
|
+
ctx.console.log(`${id} ${owner} ${date}`)
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
ctx.console.log(entry.id)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} catch (error) {
|
|
137
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
138
|
+
ctx.console.error(message)
|
|
139
|
+
ctx.process.exit(1)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function registerCliproxyModels(cli: ReturnType<typeof goke>): void {
|
|
144
|
+
cli
|
|
145
|
+
.command('cliproxy models [provider]', 'List the models CLIProxyAPI serves.')
|
|
146
|
+
.option(
|
|
147
|
+
'--url [url]',
|
|
148
|
+
z.string().describe('Base URL for CLIProxyAPI. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.'),
|
|
149
|
+
)
|
|
150
|
+
.option('--key [key]', z.string().describe('API key (bearer). Falls back to CLIPROXY_API_KEY when omitted.'))
|
|
151
|
+
.option('--verbose', 'Show owned_by and created date for each model.')
|
|
152
|
+
.example('# List all models served by CLIProxyAPI')
|
|
153
|
+
.example('infra cliproxy models')
|
|
154
|
+
.example('# Filter to Anthropic models only')
|
|
155
|
+
.example('infra cliproxy models anthropic')
|
|
156
|
+
.example('# Show verbose output with owned_by and created date')
|
|
157
|
+
.example('infra cliproxy models --verbose')
|
|
158
|
+
.example('# Filter to OpenAI models with verbose output')
|
|
159
|
+
.example('infra cliproxy models openai --verbose')
|
|
160
|
+
.action((provider, options, ctx) =>
|
|
161
|
+
cliproxyModelsAction({...options, provider: provider as string | undefined}, ctx),
|
|
162
|
+
)
|
|
163
|
+
}
|
package/src/commands/mcp.test.ts
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import {createMcpAction} from '@goke/mcp'
|
|
27
27
|
import {Client} from '@modelcontextprotocol/sdk/client'
|
|
28
28
|
import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js'
|
|
29
|
-
import {afterAll, beforeAll, describe, expect, test} from 'bun:test'
|
|
29
|
+
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'bun:test'
|
|
30
30
|
import {goke} from 'goke'
|
|
31
31
|
|
|
32
32
|
import {registerCliproxyCommands} from './cliproxy'
|
|
@@ -42,9 +42,10 @@ import {registerVpnCommands} from './vpn'
|
|
|
42
42
|
/**
|
|
43
43
|
* The MCP tool surface, pinned as an INDEPENDENT literal (deliberately NOT
|
|
44
44
|
* derived from MCP_ALLOWLIST) so allowlist drift surfaces as a test failure —
|
|
45
|
-
* per the Tier-1 test bar in packages/cli/AGENTS.md.
|
|
45
|
+
* per the Tier-1 test bar in packages/cli/AGENTS.md. Seven read-only tools.
|
|
46
46
|
*/
|
|
47
47
|
const EXPECTED_TOOLS = [
|
|
48
|
+
'cliproxy_models',
|
|
48
49
|
'cliproxy_status',
|
|
49
50
|
'gateway_status',
|
|
50
51
|
'keeweb_status',
|
|
@@ -151,3 +152,104 @@ describe('mcp integration (Tier-1, in-process)', () => {
|
|
|
151
152
|
}
|
|
152
153
|
})
|
|
153
154
|
})
|
|
155
|
+
|
|
156
|
+
// ─── cliproxy_models end-to-end call (Tier-1, in-process) ────────────────────
|
|
157
|
+
|
|
158
|
+
const MODELS_RESPONSE = {
|
|
159
|
+
object: 'list',
|
|
160
|
+
data: [
|
|
161
|
+
{id: 'claude-opus-4', object: 'model', owned_by: 'anthropic', created: 1_710_000_000},
|
|
162
|
+
{id: 'claude-sonnet-4-5', object: 'model', owned_by: 'anthropic', created: 1_715_000_000},
|
|
163
|
+
{id: 'gpt-4o', object: 'model', owned_by: 'openai', created: 1_705_000_000},
|
|
164
|
+
],
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
type FetchReplacement = (url: string, init?: RequestInit) => Promise<Response>
|
|
168
|
+
|
|
169
|
+
function createFetchImplementation(handler: FetchReplacement): typeof fetch {
|
|
170
|
+
const originalFetch = globalThis.fetch
|
|
171
|
+
return Object.assign(
|
|
172
|
+
(input: string | URL | Request, init?: RequestInit) => {
|
|
173
|
+
if (typeof input !== 'string') {
|
|
174
|
+
throw new TypeError(`Unexpected non-string fetch input: ${String(input)}`)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return handler(input, init)
|
|
178
|
+
},
|
|
179
|
+
{preconnect: originalFetch.preconnect},
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
describe('cliproxy_models MCP tool (Tier-1, in-process call)', () => {
|
|
184
|
+
let callClient: Client
|
|
185
|
+
const originalFetch = globalThis.fetch
|
|
186
|
+
let savedApiKey: string | undefined
|
|
187
|
+
|
|
188
|
+
beforeAll(async () => {
|
|
189
|
+
const cli = buildTestCli()
|
|
190
|
+
cli.matchedCommandName = 'mcp'
|
|
191
|
+
|
|
192
|
+
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair()
|
|
193
|
+
|
|
194
|
+
const mcpAction = createMcpAction({
|
|
195
|
+
cli,
|
|
196
|
+
commandFilter: (name: string) => MCP_ALLOWLIST.has(name),
|
|
197
|
+
createTransport: () => serverTransport,
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
await mcpAction()
|
|
201
|
+
|
|
202
|
+
callClient = new Client({name: 'test-client-models', version: '1.0.0'}, {capabilities: {}})
|
|
203
|
+
await callClient.connect(clientTransport)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
afterAll(async () => {
|
|
207
|
+
await callClient?.close()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
beforeEach(() => {
|
|
211
|
+
savedApiKey = process.env.CLIPROXY_API_KEY
|
|
212
|
+
process.env.CLIPROXY_API_KEY = 'test-mcp-api-key'
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
afterEach(() => {
|
|
216
|
+
globalThis.fetch = originalFetch
|
|
217
|
+
if (savedApiKey === undefined) {
|
|
218
|
+
delete process.env.CLIPROXY_API_KEY
|
|
219
|
+
} else {
|
|
220
|
+
process.env.CLIPROXY_API_KEY = savedApiKey
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('listTools() includes cliproxy_models', async () => {
|
|
225
|
+
const result = await callClient.listTools()
|
|
226
|
+
const names = result.tools.map((t: {name: string}) => t.name)
|
|
227
|
+
expect(names).toContain('cliproxy_models')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('calling cliproxy_models returns captured model-list text with expected model ids', async () => {
|
|
231
|
+
globalThis.fetch = createFetchImplementation(async url => {
|
|
232
|
+
if (url.includes('/v1/models')) {
|
|
233
|
+
return new Response(JSON.stringify(MODELS_RESPONSE), {
|
|
234
|
+
status: 200,
|
|
235
|
+
headers: {'content-type': 'application/json'},
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
throw new Error(`Unexpected fetch: ${url}`)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
const result = await callClient.callTool({name: 'cliproxy_models', arguments: {}})
|
|
243
|
+
|
|
244
|
+
// MCP tool result is an array of content blocks; join text content for assertion
|
|
245
|
+
const content = result.content as {type: string; text?: string}[]
|
|
246
|
+
const text = content
|
|
247
|
+
.filter(c => c.type === 'text')
|
|
248
|
+
.map(c => c.text ?? '')
|
|
249
|
+
.join('\n')
|
|
250
|
+
|
|
251
|
+
expect(text).toContain('claude-opus-4')
|
|
252
|
+
expect(text).toContain('claude-sonnet-4-5')
|
|
253
|
+
expect(text).toContain('gpt-4o')
|
|
254
|
+
})
|
|
255
|
+
})
|