@marcusrbrown/infra 0.11.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcusrbrown/infra",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -21,6 +21,11 @@ function createFetchImplementation(handler: FetchReplacement): typeof fetch {
21
21
  )
22
22
  }
23
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
24
29
  const MIXED_MODELS_RESPONSE = {
25
30
  object: 'list',
26
31
  data: [
@@ -48,6 +53,24 @@ const NO_OWNED_BY_RESPONSE = {
48
53
  ],
49
54
  }
50
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
+
51
74
  describe('cliproxyModelsAction — plain output', () => {
52
75
  beforeEach(() => {
53
76
  globalThis.fetch = createFetchImplementation(async () => {
@@ -59,7 +82,7 @@ describe('cliproxyModelsAction — plain output', () => {
59
82
  globalThis.fetch = originalFetch
60
83
  })
61
84
 
62
- it('lists all model ids one per line when no provider filter', async () => {
85
+ it('emits provider/id format (not bare id) for each model', async () => {
63
86
  globalThis.fetch = createFetchImplementation(
64
87
  async () =>
65
88
  new Response(JSON.stringify(MIXED_MODELS_RESPONSE), {
@@ -72,13 +95,54 @@ describe('cliproxyModelsAction — plain output', () => {
72
95
  await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key'}, ctx)
73
96
 
74
97
  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')
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'])
79
143
  })
80
144
 
81
- it('filters to openai models only when provider=openai', async () => {
145
+ it('filters to openai models only when provider=openai, still emits provider/id', async () => {
82
146
  globalThis.fetch = createFetchImplementation(
83
147
  async () =>
84
148
  new Response(JSON.stringify(MIXED_MODELS_RESPONSE), {
@@ -91,12 +155,12 @@ describe('cliproxyModelsAction — plain output', () => {
91
155
  await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', provider: 'openai'}, ctx)
92
156
 
93
157
  const output = captured.stdout.join('\n')
94
- expect(output).toContain('gpt-4o')
95
- expect(output).toContain('gpt-4o-mini')
158
+ expect(output).toContain('openai/gpt-4o')
159
+ expect(output).toContain('openai/gpt-4o-mini')
96
160
  expect(output).not.toContain('claude')
97
161
  })
98
162
 
99
- it('matches provider via id prefix when owned_by is absent', async () => {
163
+ it('infers anthropic provider via id pattern when owned_by is absent', async () => {
100
164
  globalThis.fetch = createFetchImplementation(
101
165
  async () =>
102
166
  new Response(JSON.stringify(NO_OWNED_BY_RESPONSE), {
@@ -109,9 +173,66 @@ describe('cliproxyModelsAction — plain output', () => {
109
173
  await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', provider: 'anthropic'}, ctx)
110
174
 
111
175
  const output = captured.stdout.join('\n')
112
- expect(output).toContain('claude-3-5-sonnet-20241022')
176
+ expect(output).toContain('anthropic/claude-3-5-sonnet-20241022')
113
177
  expect(output).not.toContain('gpt-4o')
114
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
+ })
115
236
  })
116
237
 
117
238
  describe('cliproxyModelsAction — verbose output', () => {
@@ -119,7 +240,7 @@ describe('cliproxyModelsAction — verbose output', () => {
119
240
  globalThis.fetch = originalFetch
120
241
  })
121
242
 
122
- it('shows owned_by and formatted date in verbose mode', async () => {
243
+ it('emits a single valid JSON array parseable by JSON.parse', async () => {
123
244
  globalThis.fetch = createFetchImplementation(
124
245
  async () =>
125
246
  new Response(JSON.stringify(OPENAI_ONLY_RESPONSE), {
@@ -131,11 +252,126 @@ describe('cliproxyModelsAction — verbose output', () => {
131
252
  const {ctx, captured} = createCapturedCtx()
132
253
  await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', verbose: true}, ctx)
133
254
 
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}/)
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()
139
375
  })
140
376
  })
141
377
 
@@ -144,7 +380,7 @@ describe('cliproxyModelsAction — empty results', () => {
144
380
  globalThis.fetch = originalFetch
145
381
  })
146
382
 
147
- it('prints "No models." when data array is empty', async () => {
383
+ it('prints "No models." when data array is empty (non-verbose)', async () => {
148
384
  globalThis.fetch = createFetchImplementation(
149
385
  async () =>
150
386
  new Response(JSON.stringify({object: 'list', data: []}), {
@@ -161,7 +397,7 @@ describe('cliproxyModelsAction — empty results', () => {
161
397
  expect(captured.exit).toBeNull()
162
398
  })
163
399
 
164
- it('prints provider-specific "No models" message when filter yields zero matches', async () => {
400
+ it('prints provider-specific "No models" message when filter yields zero matches (non-verbose)', async () => {
165
401
  globalThis.fetch = createFetchImplementation(
166
402
  async () =>
167
403
  new Response(JSON.stringify(OPENAI_ONLY_RESPONSE), {
@@ -21,6 +21,7 @@ const modelEntrySchema = z
21
21
  .object({
22
22
  id: z.string(),
23
23
  owned_by: z.string().optional(),
24
+ created: z.number().optional(),
24
25
  })
25
26
  .passthrough()
26
27
 
@@ -46,21 +47,96 @@ function isValidProvider(value: string): value is ValidProvider {
46
47
  return (VALID_PROVIDERS as readonly string[]).includes(value)
47
48
  }
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
+
49
83
  /**
50
84
  * Decides whether a /v1/models entry belongs to a provider.
51
85
  * Prefers owned_by when present; falls back to id prefix / known bare-id
52
86
  * patterns when absent. Display/validation-only — never an auth or trust signal.
53
87
  */
54
88
  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
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,
61
139
  }
62
- const pattern = PROVIDER_ID_PATTERNS[provider]
63
- return pattern === undefined ? false : pattern.test(id)
64
140
  }
65
141
 
66
142
  export interface ModelsOptions {
@@ -110,6 +186,17 @@ export async function cliproxyModelsAction(options: ModelsOptions, ctx: ActionCt
110
186
  entries = entries.filter(e => entryMatchesProvider(e, options.provider as string))
111
187
  }
112
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
+
113
200
  if (entries.length === 0) {
114
201
  ctx.console.log(
115
202
  options.provider === undefined ? 'No models.' : `No models found for provider ${options.provider}.`,
@@ -117,21 +204,8 @@ export async function cliproxyModelsAction(options: ModelsOptions, ctx: ActionCt
117
204
  return
118
205
  }
119
206
 
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
- }
207
+ for (const entry of entries) {
208
+ ctx.console.log(formatPlainLine(entry))
135
209
  }
136
210
  } catch (error) {
137
211
  const message = error instanceof Error ? error.message : String(error)