feat(cliproxy): include model metadata in catalog --json output

- Expose tier, description, deprecated, broken, extendedContext,
  nativeImageInput fields (omitted when undefined)
- Simplify tests to use static imports instead of dynamic cache-busting
- Add integration tests verifying --json routing through handleCliproxyCommand
- Test --json priority over refresh/reset subcommands

Built [OnSteroids](https://onsteroids.ai)
This commit is contained in:
Sergey Galuza
2026-04-11 18:53:33 +02:00
parent c1d462ea41
commit 6fc22625ac
2 changed files with 97 additions and 25 deletions
+26 -3
View File
@@ -174,16 +174,39 @@ export async function handleCatalogRefresh(verbose: boolean): Promise<void> {
console.log('');
}
/** JSON-serialisable model entry emitted by `catalog --json`. */
interface CatalogJsonModel {
id: string;
name: string;
tier?: 'free' | 'pro' | 'ultra';
description?: string;
deprecated?: boolean;
deprecationReason?: string;
broken?: boolean;
extendedContext?: boolean;
nativeImageInput?: boolean;
}
/**
* Output catalog as JSON for programmatic consumption.
* Used by OnSteroids and other tools to get available models per provider.
* Format: { [providerName: string]: Array<{ id: string, name: string }> }
* Format: { [providerName: string]: CatalogJsonModel[] }
*/
export function handleCatalogJson(): void {
const catalogs = getAllResolvedCatalogs();
const result: Record<string, Array<{ id: string; name: string }>> = {};
const result: Record<string, CatalogJsonModel[]> = {};
for (const [provider, catalog] of Object.entries(catalogs)) {
result[provider] = catalog.models.map((m) => ({ id: m.id, name: m.name }));
result[provider] = catalog.models.map((m) => {
const entry: CatalogJsonModel = { id: m.id, name: m.name };
if (m.tier) entry.tier = m.tier;
if (m.description) entry.description = m.description;
if (m.deprecated) entry.deprecated = m.deprecated;
if (m.deprecationReason) entry.deprecationReason = m.deprecationReason;
if (m.broken) entry.broken = m.broken;
if (m.extendedContext) entry.extendedContext = m.extendedContext;
if (m.nativeImageInput) entry.nativeImageInput = m.nativeImageInput;
return entry;
});
}
console.log(JSON.stringify(result));
}
@@ -1,4 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { handleCatalogJson } from '../../../src/commands/cliproxy/catalog-subcommand';
import { handleCliproxyCommand } from '../../../src/commands/cliproxy/index';
let originalConsoleLog: typeof console.log;
let capturedOutput: string[];
@@ -16,49 +18,96 @@ afterEach(() => {
});
describe('cliproxy catalog --json output', () => {
it('outputs valid JSON mapping provider names to model arrays', async () => {
const { handleCatalogJson } = await import(
`../../../src/commands/cliproxy/catalog-subcommand?catalog-json-format=${Date.now()}`
);
it('outputs valid JSON mapping provider names to model arrays', () => {
handleCatalogJson();
expect(capturedOutput).toHaveLength(1);
const parsed = JSON.parse(capturedOutput[0]) as Record<
string,
Array<{ id: string; name: string }>
>;
const parsed = JSON.parse(capturedOutput[0]) as Record<string, unknown[]>;
// Must be a non-empty object (static catalog always has providers)
expect(typeof parsed).toBe('object');
expect(Object.keys(parsed).length).toBeGreaterThan(0);
// Every provider entry must be an array of { id, name } objects
for (const [provider, models] of Object.entries(parsed)) {
// Every provider entry must be an array of objects with at least id and name
for (const models of Object.values(parsed)) {
expect(Array.isArray(models)).toBe(true);
expect(models.length).toBeGreaterThan(0);
for (const model of models) {
for (const model of models as Array<Record<string, unknown>>) {
expect(typeof model.id).toBe('string');
expect(typeof model.name).toBe('string');
// Only id and name — no extra metadata leaks
expect(Object.keys(model).sort()).toEqual(['id', 'name']);
}
// Provider key should be a non-empty string
expect(provider.length).toBeGreaterThan(0);
}
});
it('outputs minified JSON (single line, no whitespace formatting)', async () => {
const { handleCatalogJson } = await import(
`../../../src/commands/cliproxy/catalog-subcommand?catalog-json-minified=${Date.now()}`
);
it('includes metadata fields when present on model entries', () => {
handleCatalogJson();
const parsed = JSON.parse(capturedOutput[0]) as Record<
string,
Array<Record<string, unknown>>
>;
const allModels = Object.values(parsed).flat();
// At least some models in the static catalog have tier set
const withTier = allModels.filter((m) => m.tier !== undefined);
expect(withTier.length).toBeGreaterThan(0);
// Tier values must be one of the allowed strings
for (const model of withTier) {
expect(['free', 'pro', 'ultra']).toContain(model.tier);
}
});
it('omits undefined optional fields instead of including nulls', () => {
handleCatalogJson();
const parsed = JSON.parse(capturedOutput[0]) as Record<
string,
Array<Record<string, unknown>>
>;
const allModels = Object.values(parsed).flat();
for (const model of allModels) {
for (const value of Object.values(model)) {
expect(value).not.toBeNull();
expect(value).not.toBeUndefined();
}
}
});
it('outputs minified JSON (single line, no whitespace formatting)', () => {
handleCatalogJson();
const output = capturedOutput[0];
// Minified JSON has no newlines
expect(output.includes('\n')).toBe(false);
// Valid JSON roundtrip
expect(JSON.stringify(JSON.parse(output))).toBe(output);
});
});
describe('cliproxy catalog --json routing', () => {
it('routes catalog --json through handleCliproxyCommand', async () => {
await handleCliproxyCommand(['catalog', '--json']);
expect(capturedOutput).toHaveLength(1);
const parsed = JSON.parse(capturedOutput[0]);
expect(typeof parsed).toBe('object');
expect(Object.keys(parsed).length).toBeGreaterThan(0);
});
it('--json takes priority over refresh subcommand', async () => {
await handleCliproxyCommand(['catalog', 'refresh', '--json']);
expect(capturedOutput).toHaveLength(1);
// Should output JSON, not refresh output
const parsed = JSON.parse(capturedOutput[0]);
expect(typeof parsed).toBe('object');
});
it('--json takes priority when placed before subcommand', async () => {
await handleCliproxyCommand(['catalog', '--json', 'reset']);
expect(capturedOutput).toHaveLength(1);
const parsed = JSON.parse(capturedOutput[0]);
expect(typeof parsed).toBe('object');
});
});