mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 20:17:03 +00:00
Merge pull request #962 from sgaluza/feat/cliproxy-catalog-json
feat(cliproxy): add --json flag to catalog command
This commit is contained in:
4 files changed
+200
No files matched your search
@@ -5,10 +5,12 @@ import {
|
|||||||
SYNCABLE_PROVIDERS,
|
SYNCABLE_PROVIDERS,
|
||||||
getResolvedCatalog,
|
getResolvedCatalog,
|
||||||
refreshCatalogFromProxy,
|
refreshCatalogFromProxy,
|
||||||
|
getAllResolvedCatalogs,
|
||||||
} from '../../cliproxy/catalog-cache';
|
} from '../../cliproxy/catalog-cache';
|
||||||
import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing';
|
import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing';
|
||||||
import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes';
|
import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes';
|
||||||
import { getProxyTarget } from '../../cliproxy/proxy-target-resolver';
|
import { getProxyTarget } from '../../cliproxy/proxy-target-resolver';
|
||||||
|
import type { ThinkingSupport } from '../../cliproxy/model-catalog';
|
||||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||||
import type { RemoteModelInfo } from '../../cliproxy/management-api-types';
|
import type { RemoteModelInfo } from '../../cliproxy/management-api-types';
|
||||||
import type { CliproxyProviderRoutingHints } from '../../shared/cliproxy-model-routing';
|
import type { CliproxyProviderRoutingHints } from '../../shared/cliproxy-model-routing';
|
||||||
@@ -173,6 +175,50 @@ export async function handleCatalogRefresh(verbose: boolean): Promise<void> {
|
|||||||
console.log('');
|
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;
|
||||||
|
issueUrl?: string;
|
||||||
|
thinking?: ThinkingSupport;
|
||||||
|
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]: CatalogJsonModel[] }
|
||||||
|
*/
|
||||||
|
export function handleCatalogJson(): void {
|
||||||
|
const catalogs = getAllResolvedCatalogs();
|
||||||
|
const result: Record<string, CatalogJsonModel[]> = {};
|
||||||
|
for (const [provider, catalog] of Object.entries(catalogs)) {
|
||||||
|
if (!catalog) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result[provider] = catalog.models.map((m) => {
|
||||||
|
const entry: CatalogJsonModel = { id: m.id, name: m.name };
|
||||||
|
if (m.tier !== undefined) entry.tier = m.tier;
|
||||||
|
if (m.description !== undefined) entry.description = m.description;
|
||||||
|
if (m.deprecated !== undefined) entry.deprecated = m.deprecated;
|
||||||
|
if (m.deprecationReason !== undefined) entry.deprecationReason = m.deprecationReason;
|
||||||
|
if (m.broken !== undefined) entry.broken = m.broken;
|
||||||
|
if (m.issueUrl !== undefined) entry.issueUrl = m.issueUrl;
|
||||||
|
if (m.thinking !== undefined) entry.thinking = m.thinking;
|
||||||
|
if (m.extendedContext !== undefined) entry.extendedContext = m.extendedContext;
|
||||||
|
if (m.nativeImageInput !== undefined) entry.nativeImageInput = m.nativeImageInput;
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify(result));
|
||||||
|
}
|
||||||
|
|
||||||
/** Reset catalog cache */
|
/** Reset catalog cache */
|
||||||
export async function handleCatalogReset(): Promise<void> {
|
export async function handleCatalogReset(): Promise<void> {
|
||||||
await initUI();
|
await initUI();
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export async function showHelp(): Promise<void> {
|
|||||||
['catalog', 'Show catalog status, routing hints, and pinned short prefixes'],
|
['catalog', 'Show catalog status, routing hints, and pinned short prefixes'],
|
||||||
['catalog refresh', 'Sync models from remote CLIProxy'],
|
['catalog refresh', 'Sync models from remote CLIProxy'],
|
||||||
['catalog reset', 'Clear cache, revert to static catalog'],
|
['catalog reset', 'Clear cache, revert to static catalog'],
|
||||||
|
['catalog --json', 'Output full model catalog as minified JSON'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
handleCatalogStatus,
|
handleCatalogStatus,
|
||||||
handleCatalogRefresh,
|
handleCatalogRefresh,
|
||||||
handleCatalogReset,
|
handleCatalogReset,
|
||||||
|
handleCatalogJson,
|
||||||
} from './catalog-subcommand';
|
} from './catalog-subcommand';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,6 +147,12 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
|
|||||||
|
|
||||||
// Catalog commands
|
// Catalog commands
|
||||||
if (command === 'catalog') {
|
if (command === 'catalog') {
|
||||||
|
// --json takes priority over subcommands (refresh/reset) — it always
|
||||||
|
// outputs the current resolved catalog regardless of other arguments.
|
||||||
|
if (hasAnyFlag(remainingArgs, ['--json'])) {
|
||||||
|
handleCatalogJson();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const subcommand = remainingArgs[1];
|
const subcommand = remainingArgs[1];
|
||||||
if (subcommand === 'refresh') {
|
if (subcommand === 'refresh') {
|
||||||
await handleCatalogRefresh(verbose);
|
await handleCatalogRefresh(verbose);
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
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[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalConsoleLog = console.log;
|
||||||
|
capturedOutput = [];
|
||||||
|
console.log = (...args: unknown[]) => {
|
||||||
|
capturedOutput.push(args.map(String).join(' '));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
console.log = originalConsoleLog;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cliproxy catalog --json output', () => {
|
||||||
|
it('outputs valid JSON mapping provider names to model arrays', () => {
|
||||||
|
handleCatalogJson();
|
||||||
|
|
||||||
|
expect(capturedOutput).toHaveLength(1);
|
||||||
|
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 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 as Array<Record<string, unknown>>) {
|
||||||
|
expect(typeof model.id).toBe('string');
|
||||||
|
expect(typeof model.name).toBe('string');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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('includes explicit false boolean values in output', () => {
|
||||||
|
handleCatalogJson();
|
||||||
|
|
||||||
|
const parsed = JSON.parse(capturedOutput[0]) as Record<
|
||||||
|
string,
|
||||||
|
Array<Record<string, unknown>>
|
||||||
|
>;
|
||||||
|
const allModels = Object.values(parsed).flat();
|
||||||
|
|
||||||
|
// Static catalog has models with extendedContext: false
|
||||||
|
const withExplicitFalse = allModels.filter((m) => m.extendedContext === false);
|
||||||
|
expect(withExplicitFalse.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes thinking configuration when present on models', () => {
|
||||||
|
handleCatalogJson();
|
||||||
|
|
||||||
|
const parsed = JSON.parse(capturedOutput[0]) as Record<
|
||||||
|
string,
|
||||||
|
Array<Record<string, unknown>>
|
||||||
|
>;
|
||||||
|
const allModels = Object.values(parsed).flat();
|
||||||
|
|
||||||
|
// Static catalog has thinking models (e.g. Claude Opus 4.6 Thinking)
|
||||||
|
const withThinking = allModels.filter((m) => m.thinking !== undefined);
|
||||||
|
expect(withThinking.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const model of withThinking) {
|
||||||
|
const thinking = model.thinking as Record<string, unknown>;
|
||||||
|
expect(['budget', 'levels', 'none']).toContain(thinking.type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('outputs minified JSON (single line, no whitespace formatting)', () => {
|
||||||
|
handleCatalogJson();
|
||||||
|
|
||||||
|
const output = capturedOutput[0];
|
||||||
|
expect(output.includes('\n')).toBe(false);
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user