@marcusrbrown/infra 0.10.5 → 0.11.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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcusrbrown/infra",
3
- "version": "0.10.5",
3
+ "version": "0.11.1",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -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,592 @@
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
+ // Fixture: mixed providers with known created timestamps for ordering assertions.
25
+ // anthropic group: claude-3-5-sonnet (created=1700000000) < claude-opus-4 (created=1710000000)
26
+ // openai group: gpt-4o (created=1705000000) < gpt-4o-mini (created=1706000000)
27
+ // Provider groups sorted ascending: anthropic < openai
28
+ // Expected plain order: anthropic/claude-3-5-sonnet-20241022, anthropic/claude-opus-4, openai/gpt-4o, openai/gpt-4o-mini
29
+ const MIXED_MODELS_RESPONSE = {
30
+ object: 'list',
31
+ data: [
32
+ {id: 'claude-3-5-sonnet-20241022', object: 'model', owned_by: 'anthropic', created: 1_700_000_000},
33
+ {id: 'claude-opus-4', object: 'model', owned_by: 'anthropic', created: 1_710_000_000},
34
+ {id: 'gpt-4o', object: 'model', owned_by: 'openai', created: 1_705_000_000},
35
+ {id: 'gpt-4o-mini', object: 'model', owned_by: 'openai', created: 1_706_000_000},
36
+ ],
37
+ }
38
+
39
+ const OPENAI_ONLY_RESPONSE = {
40
+ object: 'list',
41
+ data: [
42
+ {id: 'gpt-4o', object: 'model', owned_by: 'openai', created: 1_705_000_000},
43
+ {id: 'gpt-4o-mini', object: 'model', owned_by: 'openai', created: 1_706_000_000},
44
+ ],
45
+ }
46
+
47
+ // Models without owned_by — provider inferred from id prefix
48
+ const NO_OWNED_BY_RESPONSE = {
49
+ object: 'list',
50
+ data: [
51
+ {id: 'claude-3-5-sonnet-20241022', object: 'model', created: 1_700_000_000},
52
+ {id: 'gpt-4o', object: 'model', created: 1_705_000_000},
53
+ ],
54
+ }
55
+
56
+ // Model with no owned_by and no matching pattern → unknown provider
57
+ const UNKNOWN_PROVIDER_RESPONSE = {
58
+ object: 'list',
59
+ data: [
60
+ {id: 'claude-3-5-sonnet-20241022', object: 'model', owned_by: 'anthropic', created: 1_700_000_000},
61
+ {id: 'some-mystery-model-v1', object: 'model', created: 1_715_000_000},
62
+ ],
63
+ }
64
+
65
+ // Fixture with deliberately reversed order to test date-asc sorting within a group
66
+ const REVERSED_ORDER_RESPONSE = {
67
+ object: 'list',
68
+ data: [
69
+ {id: 'claude-opus-4', object: 'model', owned_by: 'anthropic', created: 1_710_000_000},
70
+ {id: 'claude-3-5-sonnet-20241022', object: 'model', owned_by: 'anthropic', created: 1_700_000_000},
71
+ ],
72
+ }
73
+
74
+ describe('cliproxyModelsAction — plain output', () => {
75
+ beforeEach(() => {
76
+ globalThis.fetch = createFetchImplementation(async () => {
77
+ throw new Error('Unexpected fetch call')
78
+ })
79
+ })
80
+
81
+ afterEach(() => {
82
+ globalThis.fetch = originalFetch
83
+ })
84
+
85
+ it('emits provider/id format (not bare id) for each model', async () => {
86
+ globalThis.fetch = createFetchImplementation(
87
+ async () =>
88
+ new Response(JSON.stringify(MIXED_MODELS_RESPONSE), {
89
+ status: 200,
90
+ headers: {'content-type': 'application/json'},
91
+ }),
92
+ )
93
+
94
+ const {ctx, captured} = createCapturedCtx()
95
+ await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key'}, ctx)
96
+
97
+ const output = captured.stdout.join('\n')
98
+ expect(output).toContain('anthropic/claude-3-5-sonnet-20241022')
99
+ expect(output).toContain('anthropic/claude-opus-4')
100
+ expect(output).toContain('openai/gpt-4o')
101
+ expect(output).toContain('openai/gpt-4o-mini')
102
+ // Must NOT emit bare ids without prefix
103
+ expect(output).not.toMatch(/^claude-/m)
104
+ expect(output).not.toMatch(/^gpt-/m)
105
+ })
106
+
107
+ it('groups by provider (anthropic before openai) and sorts date-asc within each group', async () => {
108
+ globalThis.fetch = createFetchImplementation(
109
+ async () =>
110
+ new Response(JSON.stringify(MIXED_MODELS_RESPONSE), {
111
+ status: 200,
112
+ headers: {'content-type': 'application/json'},
113
+ }),
114
+ )
115
+
116
+ const {ctx, captured} = createCapturedCtx()
117
+ await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key'}, ctx)
118
+
119
+ const lines = captured.stdout.join('\n').split('\n').filter(Boolean)
120
+ expect(lines).toEqual([
121
+ 'anthropic/claude-3-5-sonnet-20241022',
122
+ 'anthropic/claude-opus-4',
123
+ 'openai/gpt-4o',
124
+ 'openai/gpt-4o-mini',
125
+ ])
126
+ })
127
+
128
+ it('sorts date-asc within a provider group even when API returns them in reverse order', async () => {
129
+ globalThis.fetch = createFetchImplementation(
130
+ async () =>
131
+ new Response(JSON.stringify(REVERSED_ORDER_RESPONSE), {
132
+ status: 200,
133
+ headers: {'content-type': 'application/json'},
134
+ }),
135
+ )
136
+
137
+ const {ctx, captured} = createCapturedCtx()
138
+ await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key'}, ctx)
139
+
140
+ const lines = captured.stdout.join('\n').split('\n').filter(Boolean)
141
+ // claude-3-5-sonnet (created=1700000000) must come before claude-opus-4 (created=1710000000)
142
+ expect(lines).toEqual(['anthropic/claude-3-5-sonnet-20241022', 'anthropic/claude-opus-4'])
143
+ })
144
+
145
+ it('filters to openai models only when provider=openai, still emits provider/id', async () => {
146
+ globalThis.fetch = createFetchImplementation(
147
+ async () =>
148
+ new Response(JSON.stringify(MIXED_MODELS_RESPONSE), {
149
+ status: 200,
150
+ headers: {'content-type': 'application/json'},
151
+ }),
152
+ )
153
+
154
+ const {ctx, captured} = createCapturedCtx()
155
+ await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', provider: 'openai'}, ctx)
156
+
157
+ const output = captured.stdout.join('\n')
158
+ expect(output).toContain('openai/gpt-4o')
159
+ expect(output).toContain('openai/gpt-4o-mini')
160
+ expect(output).not.toContain('claude')
161
+ })
162
+
163
+ it('infers anthropic provider via id pattern when owned_by is absent', async () => {
164
+ globalThis.fetch = createFetchImplementation(
165
+ async () =>
166
+ new Response(JSON.stringify(NO_OWNED_BY_RESPONSE), {
167
+ status: 200,
168
+ headers: {'content-type': 'application/json'},
169
+ }),
170
+ )
171
+
172
+ const {ctx, captured} = createCapturedCtx()
173
+ await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', provider: 'anthropic'}, ctx)
174
+
175
+ const output = captured.stdout.join('\n')
176
+ expect(output).toContain('anthropic/claude-3-5-sonnet-20241022')
177
+ expect(output).not.toContain('gpt-4o')
178
+ })
179
+
180
+ it('infers openai provider via id pattern when owned_by is absent', async () => {
181
+ globalThis.fetch = createFetchImplementation(
182
+ async () =>
183
+ new Response(JSON.stringify(NO_OWNED_BY_RESPONSE), {
184
+ status: 200,
185
+ headers: {'content-type': 'application/json'},
186
+ }),
187
+ )
188
+
189
+ const {ctx, captured} = createCapturedCtx()
190
+ await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', provider: 'openai'}, ctx)
191
+
192
+ const output = captured.stdout.join('\n')
193
+ expect(output).toContain('openai/gpt-4o')
194
+ expect(output).not.toContain('claude')
195
+ })
196
+
197
+ it('prefixes unknown/<id> for entries with no owned_by and no matching pattern, sorted last', async () => {
198
+ globalThis.fetch = createFetchImplementation(
199
+ async () =>
200
+ new Response(JSON.stringify(UNKNOWN_PROVIDER_RESPONSE), {
201
+ status: 200,
202
+ headers: {'content-type': 'application/json'},
203
+ }),
204
+ )
205
+
206
+ const {ctx, captured} = createCapturedCtx()
207
+ await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key'}, ctx)
208
+
209
+ const lines = captured.stdout.join('\n').split('\n').filter(Boolean)
210
+ // anthropic group first, unknown group last
211
+ expect(lines[0]).toBe('anthropic/claude-3-5-sonnet-20241022')
212
+ expect(lines.at(-1)).toBe('unknown/some-mystery-model-v1')
213
+ })
214
+
215
+ it('does not double-prefix an id that already has a provider/ prefix', async () => {
216
+ const alreadyPrefixedResponse = {
217
+ object: 'list',
218
+ data: [{id: 'anthropic/claude-opus-4', object: 'model', owned_by: 'anthropic', created: 1_710_000_000}],
219
+ }
220
+
221
+ globalThis.fetch = createFetchImplementation(
222
+ async () =>
223
+ new Response(JSON.stringify(alreadyPrefixedResponse), {
224
+ status: 200,
225
+ headers: {'content-type': 'application/json'},
226
+ }),
227
+ )
228
+
229
+ const {ctx, captured} = createCapturedCtx()
230
+ await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key'}, ctx)
231
+
232
+ const output = captured.stdout.join('\n')
233
+ expect(output).toContain('anthropic/claude-opus-4')
234
+ expect(output).not.toContain('anthropic/anthropic/')
235
+ })
236
+ })
237
+
238
+ describe('cliproxyModelsAction — verbose output', () => {
239
+ afterEach(() => {
240
+ globalThis.fetch = originalFetch
241
+ })
242
+
243
+ it('emits a single valid JSON array parseable by JSON.parse', async () => {
244
+ globalThis.fetch = createFetchImplementation(
245
+ async () =>
246
+ new Response(JSON.stringify(OPENAI_ONLY_RESPONSE), {
247
+ status: 200,
248
+ headers: {'content-type': 'application/json'},
249
+ }),
250
+ )
251
+
252
+ const {ctx, captured} = createCapturedCtx()
253
+ await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', verbose: true}, ctx)
254
+
255
+ const raw = captured.stdout.join('')
256
+ const parsed = JSON.parse(raw)
257
+ expect(Array.isArray(parsed)).toBe(true)
258
+ })
259
+
260
+ it('verbose entries have {id, provider, raw_id, created} shape with prefixed id', async () => {
261
+ globalThis.fetch = createFetchImplementation(
262
+ async () =>
263
+ new Response(JSON.stringify(OPENAI_ONLY_RESPONSE), {
264
+ status: 200,
265
+ headers: {'content-type': 'application/json'},
266
+ }),
267
+ )
268
+
269
+ const {ctx, captured} = createCapturedCtx()
270
+ await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', verbose: true}, ctx)
271
+
272
+ const parsed: {id: string; provider: string; raw_id: string; created: string | null}[] = JSON.parse(
273
+ captured.stdout.join(''),
274
+ )
275
+ expect(parsed.length).toBe(2)
276
+
277
+ // parsed.length === 2 asserted above; non-null access is safe
278
+ const first = parsed[0]!
279
+ expect(first).toHaveProperty('id')
280
+ expect(first).toHaveProperty('provider')
281
+ expect(first).toHaveProperty('raw_id')
282
+ expect(first).toHaveProperty('created')
283
+ // id must be prefixed
284
+ expect(first.id).toBe('openai/gpt-4o')
285
+ expect(first.provider).toBe('openai')
286
+ expect(first.raw_id).toBe('gpt-4o')
287
+ // created is ISO 8601 string
288
+ expect(typeof first.created).toBe('string')
289
+ expect(first.created).toMatch(/^\d{4}-\d{2}-\d{2}T/)
290
+ })
291
+
292
+ it('verbose output is date-asc sorted and provider-grouped (anthropic before openai)', async () => {
293
+ globalThis.fetch = createFetchImplementation(
294
+ async () =>
295
+ new Response(JSON.stringify(MIXED_MODELS_RESPONSE), {
296
+ status: 200,
297
+ headers: {'content-type': 'application/json'},
298
+ }),
299
+ )
300
+
301
+ const {ctx, captured} = createCapturedCtx()
302
+ await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', verbose: true}, ctx)
303
+
304
+ const parsed: {id: string; provider: string; raw_id: string; created: string | null}[] = JSON.parse(
305
+ captured.stdout.join(''),
306
+ )
307
+ expect(parsed.map(e => e.id)).toEqual([
308
+ 'anthropic/claude-3-5-sonnet-20241022',
309
+ 'anthropic/claude-opus-4',
310
+ 'openai/gpt-4o',
311
+ 'openai/gpt-4o-mini',
312
+ ])
313
+ })
314
+
315
+ it('verbose with empty data emits [] (not a text message)', async () => {
316
+ globalThis.fetch = createFetchImplementation(
317
+ async () =>
318
+ new Response(JSON.stringify({object: 'list', data: []}), {
319
+ status: 200,
320
+ headers: {'content-type': 'application/json'},
321
+ }),
322
+ )
323
+
324
+ const {ctx, captured} = createCapturedCtx()
325
+ await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', verbose: true}, ctx)
326
+
327
+ const raw = captured.stdout.join('')
328
+ const parsed = JSON.parse(raw)
329
+ expect(parsed).toEqual([])
330
+ })
331
+
332
+ it('verbose with zero-match provider filter emits [] (not a text message)', async () => {
333
+ globalThis.fetch = createFetchImplementation(
334
+ async () =>
335
+ new Response(JSON.stringify(OPENAI_ONLY_RESPONSE), {
336
+ status: 200,
337
+ headers: {'content-type': 'application/json'},
338
+ }),
339
+ )
340
+
341
+ const {ctx, captured} = createCapturedCtx()
342
+ await cliproxyModelsAction(
343
+ {url: 'https://cliproxy.example.com', key: 'test-api-key', verbose: true, provider: 'anthropic'},
344
+ ctx,
345
+ )
346
+
347
+ const raw = captured.stdout.join('')
348
+ const parsed = JSON.parse(raw)
349
+ expect(parsed).toEqual([])
350
+ })
351
+
352
+ it('verbose: created is null when entry has no created timestamp', async () => {
353
+ const noCreatedResponse = {
354
+ object: 'list',
355
+ data: [{id: 'gpt-4o', object: 'model', owned_by: 'openai'}],
356
+ }
357
+
358
+ globalThis.fetch = createFetchImplementation(
359
+ async () =>
360
+ new Response(JSON.stringify(noCreatedResponse), {
361
+ status: 200,
362
+ headers: {'content-type': 'application/json'},
363
+ }),
364
+ )
365
+
366
+ const {ctx, captured} = createCapturedCtx()
367
+ await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', verbose: true}, ctx)
368
+
369
+ const parsed: {id: string; provider: string; raw_id: string; created: string | null}[] = JSON.parse(
370
+ captured.stdout.join(''),
371
+ )
372
+ expect(parsed.length).toBe(1)
373
+ // parsed.length === 1 asserted above; non-null access is safe
374
+ expect(parsed[0]!.created).toBeNull()
375
+ })
376
+ })
377
+
378
+ describe('cliproxyModelsAction — empty results', () => {
379
+ afterEach(() => {
380
+ globalThis.fetch = originalFetch
381
+ })
382
+
383
+ it('prints "No models." when data array is empty (non-verbose)', async () => {
384
+ globalThis.fetch = createFetchImplementation(
385
+ async () =>
386
+ new Response(JSON.stringify({object: 'list', data: []}), {
387
+ status: 200,
388
+ headers: {'content-type': 'application/json'},
389
+ }),
390
+ )
391
+
392
+ const {ctx, captured} = createCapturedCtx()
393
+ await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key'}, ctx)
394
+
395
+ const output = captured.stdout.join('\n')
396
+ expect(output).toContain('No models')
397
+ expect(captured.exit).toBeNull()
398
+ })
399
+
400
+ it('prints provider-specific "No models" message when filter yields zero matches (non-verbose)', async () => {
401
+ globalThis.fetch = createFetchImplementation(
402
+ async () =>
403
+ new Response(JSON.stringify(OPENAI_ONLY_RESPONSE), {
404
+ status: 200,
405
+ headers: {'content-type': 'application/json'},
406
+ }),
407
+ )
408
+
409
+ const {ctx, captured} = createCapturedCtx()
410
+ await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', provider: 'anthropic'}, ctx)
411
+
412
+ const output = captured.stdout.join('\n')
413
+ expect(output).toContain('for provider anthropic')
414
+ expect(captured.exit).toBeNull()
415
+ })
416
+ })
417
+
418
+ describe('cliproxyModelsAction — validation errors', () => {
419
+ afterEach(() => {
420
+ globalThis.fetch = originalFetch
421
+ })
422
+
423
+ it('exits with error for unknown provider without making a fetch call', async () => {
424
+ let fetchCalled = false
425
+ globalThis.fetch = createFetchImplementation(async () => {
426
+ fetchCalled = true
427
+ return new Response('{}', {status: 200})
428
+ })
429
+
430
+ const {ctx, captured} = createCapturedCtx()
431
+ await expect(
432
+ cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', provider: 'gemini'}, ctx),
433
+ ).rejects.toMatchObject({name: 'MockProcessExit', code: 1})
434
+
435
+ expect(fetchCalled).toBe(false)
436
+ expect(captured.stderr.join('')).toMatch(/gemini|provider/i)
437
+ expect(captured.exit).toEqual({code: 1})
438
+ })
439
+
440
+ it('exits with error when no API key is provided', async () => {
441
+ const savedKey = process.env.CLIPROXY_API_KEY
442
+ delete process.env.CLIPROXY_API_KEY
443
+
444
+ try {
445
+ const {ctx, captured} = createCapturedCtx()
446
+ await expect(cliproxyModelsAction({url: 'https://cliproxy.example.com'}, ctx)).rejects.toMatchObject({
447
+ name: 'MockProcessExit',
448
+ code: 1,
449
+ })
450
+
451
+ expect(captured.stderr.join('')).toMatch(/key|CLIPROXY_API_KEY/i)
452
+ expect(captured.exit).toEqual({code: 1})
453
+ } finally {
454
+ if (savedKey !== undefined) process.env.CLIPROXY_API_KEY = savedKey
455
+ }
456
+ })
457
+ })
458
+
459
+ describe('cliproxyModelsAction — HTTP errors', () => {
460
+ afterEach(() => {
461
+ globalThis.fetch = originalFetch
462
+ })
463
+
464
+ it('exits with error on 401 and does not include the bearer key in output', async () => {
465
+ const secretKey = 'super-secret-api-key-12345'
466
+
467
+ globalThis.fetch = createFetchImplementation(async () => new Response('Unauthorized', {status: 401}))
468
+
469
+ const {ctx, captured} = createCapturedCtx()
470
+ await expect(
471
+ cliproxyModelsAction({url: 'https://cliproxy.example.com', key: secretKey}, ctx),
472
+ ).rejects.toMatchObject({name: 'MockProcessExit', code: 1})
473
+
474
+ const allOutput = [...captured.stdout, ...captured.stderr].join('\n')
475
+ expect(allOutput).not.toContain(secretKey)
476
+ expect(captured.exit).toEqual({code: 1})
477
+ })
478
+
479
+ it('exits with error on malformed JSON response', async () => {
480
+ globalThis.fetch = createFetchImplementation(
481
+ async () =>
482
+ new Response('not valid json {{{', {
483
+ status: 200,
484
+ headers: {'content-type': 'application/json'},
485
+ }),
486
+ )
487
+
488
+ const {ctx, captured} = createCapturedCtx()
489
+ await expect(
490
+ cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key'}, ctx),
491
+ ).rejects.toMatchObject({name: 'MockProcessExit', code: 1})
492
+
493
+ expect(captured.stderr.join('')).toMatch(/json|malformed|parse/i)
494
+ expect(captured.exit).toEqual({code: 1})
495
+ })
496
+ })
497
+
498
+ describe('cliproxyModelsAction — ambient key not forwarded to untrusted URL', () => {
499
+ let originalEnv: Record<string, string | undefined>
500
+
501
+ beforeEach(() => {
502
+ originalEnv = {
503
+ CLIPROXY_URL: process.env.CLIPROXY_URL,
504
+ CLIPROXY_API_KEY: process.env.CLIPROXY_API_KEY,
505
+ }
506
+ process.env.CLIPROXY_API_KEY = 'ambient-secret-key'
507
+ delete process.env.CLIPROXY_URL
508
+ })
509
+
510
+ afterEach(() => {
511
+ for (const [k, v] of Object.entries(originalEnv)) {
512
+ if (v === undefined) {
513
+ delete process.env[k]
514
+ } else {
515
+ process.env[k] = v
516
+ }
517
+ }
518
+ globalThis.fetch = originalFetch
519
+ })
520
+
521
+ it('does not forward ambient CLIPROXY_API_KEY to a non-trusted --url', async () => {
522
+ let capturedAuthHeader: string | null | undefined
523
+ let fetchCalled = false
524
+
525
+ globalThis.fetch = createFetchImplementation(async (_url, init) => {
526
+ fetchCalled = true
527
+ const hdrs = init?.headers
528
+ if (hdrs instanceof Headers) {
529
+ capturedAuthHeader = hdrs.get('Authorization')
530
+ } else if (hdrs !== null && hdrs !== undefined && typeof hdrs === 'object') {
531
+ const h = hdrs as Record<string, string>
532
+ capturedAuthHeader = h.Authorization ?? h.authorization ?? null
533
+ } else {
534
+ capturedAuthHeader = null
535
+ }
536
+
537
+ return new Response(JSON.stringify({object: 'list', data: []}), {
538
+ status: 200,
539
+ headers: {'content-type': 'application/json'},
540
+ })
541
+ })
542
+
543
+ const {ctx, captured} = createCapturedCtx()
544
+ // Pass an explicit untrusted URL with no --key.
545
+ // The ambient CLIPROXY_API_KEY must NOT be forwarded to the untrusted host.
546
+ // Two valid outcomes: (a) no fetch is made at all (key withheld → "No API key" error),
547
+ // or (b) fetch is made but without the ambient key in the Authorization header.
548
+ try {
549
+ await cliproxyModelsAction({url: 'https://evil.example.com'}, ctx)
550
+ } catch (error) {
551
+ if (!(error instanceof MockProcessExit)) throw error
552
+ }
553
+
554
+ if (fetchCalled) {
555
+ // If a fetch was made, the ambient key must not appear in the Authorization header
556
+ if (capturedAuthHeader !== null && capturedAuthHeader !== undefined) {
557
+ expect(capturedAuthHeader).not.toContain('ambient-secret-key')
558
+ }
559
+ } else {
560
+ // No fetch made — key was withheld entirely (correct behavior)
561
+ // Verify the error output doesn't contain the ambient key either
562
+ const allOutput = [...captured.stdout, ...captured.stderr].join('\n')
563
+ expect(allOutput).not.toContain('ambient-secret-key')
564
+ }
565
+ })
566
+ })
567
+
568
+ describe('registerCliproxyModels — help output and command discovery', () => {
569
+ it('shows [provider] positional, --url, --key, and --verbose in help text', () => {
570
+ const cli = goke('infra')
571
+ registerCliproxyModels(cli)
572
+ cli.help()
573
+
574
+ const helpText = cli.helpText()
575
+
576
+ expect(helpText).toContain('cliproxy models')
577
+ expect(helpText).toContain('[provider]')
578
+ expect(helpText).toContain('--url [url]')
579
+ expect(helpText).toContain('--key [key]')
580
+ expect(helpText).toContain('--verbose')
581
+ })
582
+
583
+ it('command is discoverable in the registered cliproxy group', () => {
584
+ const cli = goke('infra')
585
+ registerCliproxyModels(cli)
586
+ cli.help()
587
+
588
+ const helpText = cli.helpText()
589
+
590
+ expect(helpText).toContain('List the models CLIProxyAPI serves.')
591
+ })
592
+ })
@@ -0,0 +1,237 @@
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
+ created: z.number().optional(),
25
+ })
26
+ .passthrough()
27
+
28
+ const modelsResponseSchema = z
29
+ .object({
30
+ data: z.array(modelEntrySchema),
31
+ })
32
+ .passthrough()
33
+
34
+ type ModelEntry = z.infer<typeof modelEntrySchema>
35
+
36
+ // Known bare-id patterns per provider, used only when owned_by is absent
37
+ // (e.g. CLIProxyAPI v7 omits owned_by from /v1/models entries).
38
+ const PROVIDER_ID_PATTERNS: Record<string, RegExp> = {
39
+ openai: /^(?:gpt-|o1-|o3-|codex)/i,
40
+ anthropic: /^claude-/i,
41
+ }
42
+
43
+ const VALID_PROVIDERS = ['anthropic', 'openai'] as const
44
+ type ValidProvider = (typeof VALID_PROVIDERS)[number]
45
+
46
+ function isValidProvider(value: string): value is ValidProvider {
47
+ return (VALID_PROVIDERS as readonly string[]).includes(value)
48
+ }
49
+
50
+ /**
51
+ * Derives the provider string for a model entry.
52
+ * Prefers owned_by when present and non-empty; falls back to PROVIDER_ID_PATTERNS
53
+ * matching against the bare id; falls back to 'unknown'.
54
+ * Display/validation-only — never an auth or trust signal.
55
+ */
56
+ function deriveProvider(entry: ModelEntry): string {
57
+ if (entry.owned_by !== undefined && entry.owned_by.trim() !== '') {
58
+ return entry.owned_by
59
+ }
60
+ // Strip any existing provider/ prefix before pattern matching
61
+ const bareId = stripProviderPrefix(entry.id)
62
+ for (const [provider, pattern] of Object.entries(PROVIDER_ID_PATTERNS)) {
63
+ if (pattern.test(bareId)) return provider
64
+ }
65
+ return 'unknown'
66
+ }
67
+
68
+ /**
69
+ * Strips an existing `provider/` prefix from a raw id, if present.
70
+ * Defensive: handles entries that already have a prefix so we don't double-prefix.
71
+ */
72
+ function stripProviderPrefix(id: string): string {
73
+ const slashIdx = id.indexOf('/')
74
+ if (slashIdx === -1) return id
75
+ // Only strip if the prefix looks like a known provider or 'unknown'
76
+ const prefix = id.slice(0, slashIdx)
77
+ if (prefix === 'unknown' || isValidProvider(prefix)) {
78
+ return id.slice(slashIdx + 1)
79
+ }
80
+ return id
81
+ }
82
+
83
+ /**
84
+ * Decides whether a /v1/models entry belongs to a provider.
85
+ * Prefers owned_by when present; falls back to id prefix / known bare-id
86
+ * patterns when absent. Display/validation-only — never an auth or trust signal.
87
+ */
88
+ function entryMatchesProvider(entry: ModelEntry, provider: string): boolean {
89
+ return deriveProvider(entry) === provider
90
+ }
91
+
92
+ /**
93
+ * Sort entries: grouped by provider (ascending), within each group by created asc.
94
+ * Entries with missing/non-number created sort last within their group.
95
+ */
96
+ function sortEntries(entries: ModelEntry[]): ModelEntry[] {
97
+ // Annotate with derived provider for stable grouping
98
+ const annotated = entries.map(e => ({entry: e, provider: deriveProvider(e)}))
99
+
100
+ // Sort: provider asc, then created asc (missing created → Infinity → sorts last)
101
+ annotated.sort((a, b) => {
102
+ const providerCmp = a.provider.localeCompare(b.provider)
103
+ if (providerCmp !== 0) return providerCmp
104
+ const aCreated = typeof a.entry.created === 'number' ? a.entry.created : Infinity
105
+ const bCreated = typeof b.entry.created === 'number' ? b.entry.created : Infinity
106
+ return aCreated - bCreated
107
+ })
108
+
109
+ return annotated.map(a => a.entry)
110
+ }
111
+
112
+ /**
113
+ * Formats a model entry for plain output: `provider/raw_id`.
114
+ */
115
+ function formatPlainLine(entry: ModelEntry): string {
116
+ const provider = deriveProvider(entry)
117
+ const rawId = stripProviderPrefix(entry.id)
118
+ return `${provider}/${rawId}`
119
+ }
120
+
121
+ interface VerboseEntry {
122
+ id: string
123
+ provider: string
124
+ raw_id: string
125
+ created: string | null
126
+ }
127
+
128
+ /**
129
+ * Formats a model entry for verbose JSON output.
130
+ */
131
+ function formatVerboseEntry(entry: ModelEntry): VerboseEntry {
132
+ const provider = deriveProvider(entry)
133
+ const rawId = stripProviderPrefix(entry.id)
134
+ return {
135
+ id: `${provider}/${rawId}`,
136
+ provider,
137
+ raw_id: rawId,
138
+ created: typeof entry.created === 'number' ? new Date(entry.created * 1000).toISOString() : null,
139
+ }
140
+ }
141
+
142
+ export interface ModelsOptions {
143
+ url?: string
144
+ key?: string
145
+ verbose?: boolean
146
+ provider?: string
147
+ }
148
+
149
+ export async function cliproxyModelsAction(options: ModelsOptions, ctx: ActionCtx): Promise<void> {
150
+ try {
151
+ const baseUrl = stripTrailingSlash(options.url ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
152
+
153
+ // Trusted URL: the canonical configured/default host, trailing-slash-normalized.
154
+ // Ambient env keys must ONLY follow to this trusted destination to prevent
155
+ // secret exfiltration when an agent passes an attacker-controlled --url.
156
+ const trustedUrl = stripTrailingSlash(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
157
+ const urlIsExplicitlyOverridden = options.url !== undefined && baseUrl !== trustedUrl
158
+
159
+ // An explicit --key is always honored (operator knows what they're doing).
160
+ // An ambient env key is only forwarded when the resolved baseUrl is trusted.
161
+ const key = options.key ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_API_KEY)
162
+
163
+ if (!key) {
164
+ throw new Error('No API key. Provide --key or set CLIPROXY_API_KEY.')
165
+ }
166
+
167
+ // Validate provider before any HTTP call
168
+ if (options.provider !== undefined && !isValidProvider(options.provider)) {
169
+ throw new Error(`Unknown provider "${options.provider}". Use anthropic or openai.`)
170
+ }
171
+
172
+ const endpoint = `${baseUrl}/v1/models`
173
+ const raw = await requestJson(endpoint, {
174
+ headers: {Authorization: `Bearer ${key}`},
175
+ })
176
+
177
+ const parseResult = modelsResponseSchema.safeParse(raw)
178
+ if (!parseResult.success) {
179
+ const message = parseResult.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; ')
180
+ throw new Error(`Malformed /v1/models response: ${message}`)
181
+ }
182
+
183
+ let entries = parseResult.data.data
184
+
185
+ if (options.provider !== undefined) {
186
+ entries = entries.filter(e => entryMatchesProvider(e, options.provider as string))
187
+ }
188
+
189
+ // Sort: provider-grouped (asc), date-asc within each group
190
+ entries = sortEntries(entries)
191
+
192
+ if (options.verbose) {
193
+ // --verbose: emit a single JSON array (pretty-printed) of the fields we have.
194
+ // Always valid JSON — empty/zero-match → [].
195
+ const verboseEntries = entries.map(formatVerboseEntry)
196
+ ctx.console.log(JSON.stringify(verboseEntries, null, 2))
197
+ return
198
+ }
199
+
200
+ if (entries.length === 0) {
201
+ ctx.console.log(
202
+ options.provider === undefined ? 'No models.' : `No models found for provider ${options.provider}.`,
203
+ )
204
+ return
205
+ }
206
+
207
+ for (const entry of entries) {
208
+ ctx.console.log(formatPlainLine(entry))
209
+ }
210
+ } catch (error) {
211
+ const message = error instanceof Error ? error.message : String(error)
212
+ ctx.console.error(message)
213
+ ctx.process.exit(1)
214
+ }
215
+ }
216
+
217
+ export function registerCliproxyModels(cli: ReturnType<typeof goke>): void {
218
+ cli
219
+ .command('cliproxy models [provider]', 'List the models CLIProxyAPI serves.')
220
+ .option(
221
+ '--url [url]',
222
+ z.string().describe('Base URL for CLIProxyAPI. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.'),
223
+ )
224
+ .option('--key [key]', z.string().describe('API key (bearer). Falls back to CLIPROXY_API_KEY when omitted.'))
225
+ .option('--verbose', 'Show owned_by and created date for each model.')
226
+ .example('# List all models served by CLIProxyAPI')
227
+ .example('infra cliproxy models')
228
+ .example('# Filter to Anthropic models only')
229
+ .example('infra cliproxy models anthropic')
230
+ .example('# Show verbose output with owned_by and created date')
231
+ .example('infra cliproxy models --verbose')
232
+ .example('# Filter to OpenAI models with verbose output')
233
+ .example('infra cliproxy models openai --verbose')
234
+ .action((provider, options, ctx) =>
235
+ cliproxyModelsAction({...options, provider: provider as string | undefined}, ctx),
236
+ )
237
+ }
@@ -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. Six read-only status tools.
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
+ })
@@ -38,6 +38,7 @@ import {createMcpAction} from '@goke/mcp'
38
38
  export const MCP_ALLOWLIST: ReadonlySet<string> = new Set([
39
39
  'gateway status',
40
40
  'cliproxy status',
41
+ 'cliproxy models',
41
42
  'keeweb status',
42
43
  'umami status',
43
44
  'vpn status',