@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
|
@@ -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('
|
|
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('
|
|
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('
|
|
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
|
|
135
|
-
|
|
136
|
-
expect(
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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)
|