feat(cliproxy): add model catalog with configuration management

Introduces new model-catalog and model-config modules to manage LLM
provider configurations and aliases. Enables dynamic model selection
with validated configuration per provider.
This commit is contained in:
kaitranntt
2025-12-04 04:45:11 -05:00
parent c06e7ab871
commit 4654c15577
6 changed files with 656 additions and 4 deletions
+19 -4
View File
@@ -26,6 +26,8 @@ import {
} from './config-generator';
import { isAuthenticated } from './auth-handler';
import { CLIProxyProvider, ExecutorConfig } from './types';
import { configureProviderModel } from './model-config';
import { supportsModelConfig } from './model-catalog';
/** Default executor configuration */
const DEFAULT_CONFIG: ExecutorConfig = {
@@ -116,10 +118,17 @@ export async function execClaudeWithCLIProxy(
throw error;
}
// 2. Handle authentication flags
// 2. Handle special flags
const forceAuth = args.includes('--auth');
const forceHeadless = args.includes('--headless');
const forceLogout = args.includes('--logout');
const forceConfig = args.includes('--config');
// Handle --config: configure model selection and exit
if (forceConfig && supportsModelConfig(provider)) {
await configureProviderModel(provider, true);
process.exit(0);
}
// Handle --logout: clear auth and exit
if (forceLogout) {
@@ -155,10 +164,16 @@ export async function execClaudeWithCLIProxy(
}
}
// 4. Ensure user settings file exists (creates from defaults if not)
// 4. First-run model configuration (interactive)
// For supported providers, prompt user to select model on first run
if (supportsModelConfig(provider)) {
await configureProviderModel(provider, false); // false = only if not configured
}
// 5. Ensure user settings file exists (creates from defaults if not)
ensureProviderSettings(provider);
// 5. Generate config file
// 6. Generate config file
log(`Generating config for ${provider}`);
const configPath = generateConfig(provider, cfg.port);
log(`Config written: ${configPath}`);
@@ -226,7 +241,7 @@ export async function execClaudeWithCLIProxy(
log(`Claude env: ANTHROPIC_MODEL=${envVars.ANTHROPIC_MODEL}`);
// Filter out CCS-specific flags before passing to Claude CLI
const ccsFlags = ['--auth', '--headless', '--logout'];
const ccsFlags = ['--auth', '--headless', '--logout', '--config'];
const claudeArgs = args.filter((arg) => !ccsFlags.includes(arg));
const isWindows = process.platform === 'win32';
+10
View File
@@ -70,6 +70,16 @@ export {
clearConfigCache,
} from './base-config-loader';
// Model catalog and configuration
export type { ModelEntry, ProviderCatalog } from './model-catalog';
export { MODEL_CATALOG, supportsModelConfig, getProviderCatalog, findModel } from './model-catalog';
export {
hasUserSettings,
getCurrentModel,
configureProviderModel,
showCurrentConfig,
} from './model-config';
// Executor
export { execClaudeWithCLIProxy, isPortAvailable, findAvailablePort } from './cliproxy-executor';
+81
View File
@@ -0,0 +1,81 @@
/**
* Model Catalog - Available models for CLI Proxy providers
*
* Ships with CCS to provide users with interactive model selection.
* Models are mapped to their internal names used by the proxy backend.
*/
import { CLIProxyProvider } from './types';
/**
* Model entry definition
*/
export interface ModelEntry {
/** Literal model name to put in settings.json */
id: string;
/** Human-readable name for display */
name: string;
/** Access tier indicator */
tier?: 'free' | 'paid';
}
/**
* Provider catalog definition
*/
export interface ProviderCatalog {
provider: CLIProxyProvider;
displayName: string;
models: ModelEntry[];
defaultModel: string;
}
/**
* Model catalog for providers that support interactive configuration
*
* Models listed in order of recommendation (top = best)
*/
export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> = {
agy: {
provider: 'agy',
displayName: 'Antigravity',
defaultModel: 'gemini-3-pro-preview',
models: [
{ id: 'gemini-claude-opus-4-5-thinking', name: 'Claude Opus 4.5 Thinking' },
{ id: 'gemini-claude-sonnet-4-5-thinking', name: 'Claude Sonnet 4.5 Thinking' },
{ id: 'gemini-claude-sonnet-4-5', name: 'Claude Sonnet 4.5' },
{ id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', tier: 'paid' },
],
},
gemini: {
provider: 'gemini',
displayName: 'Gemini',
defaultModel: 'gemini-2.5-pro',
models: [
{ id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', tier: 'paid' },
{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro' },
],
},
};
/**
* Check if provider supports interactive model configuration
*/
export function supportsModelConfig(provider: CLIProxyProvider): boolean {
return provider in MODEL_CATALOG;
}
/**
* Get catalog for provider
*/
export function getProviderCatalog(provider: CLIProxyProvider): ProviderCatalog | undefined {
return MODEL_CATALOG[provider];
}
/**
* Find model entry by ID
*/
export function findModel(provider: CLIProxyProvider, modelId: string): ModelEntry | undefined {
const catalog = MODEL_CATALOG[provider];
if (!catalog) return undefined;
return catalog.models.find((m) => m.id === modelId);
}
+168
View File
@@ -0,0 +1,168 @@
/**
* Model Configuration - Interactive model selection for CLI Proxy providers
*
* Handles first-run configuration and explicit --config flag.
* Persists user selection to ~/.ccs/{provider}.settings.json
*/
import * as fs from 'fs';
import * as path from 'path';
import { InteractivePrompt } from '../utils/prompt';
import { getProviderCatalog, supportsModelConfig, ModelEntry } from './model-catalog';
import { getProviderSettingsPath, getClaudeEnvVars } from './config-generator';
import { CLIProxyProvider } from './types';
/** CCS directory */
const CCS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.ccs');
/**
* Check if provider has user settings configured
*/
export function hasUserSettings(provider: CLIProxyProvider): boolean {
const settingsPath = getProviderSettingsPath(provider);
return fs.existsSync(settingsPath);
}
/**
* Get current model from user settings
*/
export function getCurrentModel(provider: CLIProxyProvider): string | undefined {
const settingsPath = getProviderSettingsPath(provider);
if (!fs.existsSync(settingsPath)) return undefined;
try {
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
return settings.env?.ANTHROPIC_MODEL;
} catch {
return undefined;
}
}
/**
* Format model entry for display
*/
function formatModelOption(model: ModelEntry): string {
const tierLabel = model.tier === 'paid' ? ' [paid]' : '';
return `${model.name} (${model.id})${tierLabel}`;
}
/**
* Configure model for provider (interactive)
*
* @param provider CLIProxy provider (agy, gemini)
* @param force Force reconfiguration even if settings exist
* @returns true if configuration was performed, false if skipped
*/
export async function configureProviderModel(
provider: CLIProxyProvider,
force: boolean = false
): Promise<boolean> {
// Check if provider supports model configuration
if (!supportsModelConfig(provider)) {
return false;
}
const catalog = getProviderCatalog(provider);
if (!catalog) return false;
const settingsPath = getProviderSettingsPath(provider);
// Skip if already configured (unless --config flag)
if (!force && fs.existsSync(settingsPath)) {
return false;
}
// Build options list
const options = catalog.models.map((m) => ({
id: m.id,
label: formatModelOption(m),
}));
// Find default index
const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel);
const safeDefaultIdx = defaultIdx >= 0 ? defaultIdx : 0;
// Show header
console.error('');
console.error(`[i] Configure ${catalog.displayName} model`);
// Interactive selection
const selectedModel = await InteractivePrompt.selectFromList('Select model:', options, {
defaultIndex: safeDefaultIdx,
});
// Get base env vars to preserve haiku model and base URL
const baseEnv = getClaudeEnvVars(provider);
// Build settings with selected model
const settings = {
env: {
...baseEnv,
ANTHROPIC_MODEL: selectedModel,
ANTHROPIC_DEFAULT_OPUS_MODEL: selectedModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: selectedModel,
// Keep haiku as-is from base config (usually flash model)
},
};
// Ensure CCS directory exists
if (!fs.existsSync(CCS_DIR)) {
fs.mkdirSync(CCS_DIR, { recursive: true });
}
// Write settings file
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
// Find display name
const selectedEntry = catalog.models.find((m) => m.id === selectedModel);
const displayName = selectedEntry?.name || selectedModel;
console.error('');
console.error(`[OK] Model set to: ${displayName} (${selectedModel})`);
console.error(`[i] Saved to: ${settingsPath}`);
console.error('');
return true;
}
/**
* Show current model configuration
*/
export function showCurrentConfig(provider: CLIProxyProvider): void {
if (!supportsModelConfig(provider)) {
console.error(`[i] Provider ${provider} does not support model configuration`);
return;
}
const catalog = getProviderCatalog(provider);
if (!catalog) return;
const currentModel = getCurrentModel(provider);
const settingsPath = getProviderSettingsPath(provider);
console.error('');
console.error(`[i] ${catalog.displayName} Model Configuration`);
console.error('');
if (currentModel) {
const entry = catalog.models.find((m) => m.id === currentModel);
const displayName = entry?.name || 'Unknown';
console.error(` Current: ${displayName} (${currentModel})`);
console.error(` Config: ${settingsPath}`);
} else {
console.error(` Current: (using defaults)`);
console.error(` Default: ${catalog.defaultModel}`);
}
console.error('');
console.error('Available models:');
catalog.models.forEach((m) => {
const isCurrent = m.id === currentModel;
const marker = isCurrent ? '>' : ' ';
console.error(` ${marker} ${formatModelOption(m)}`);
});
console.error('');
console.error(`Run "ccs ${provider} --config" to change`);
console.error('');
}
+216
View File
@@ -0,0 +1,216 @@
/**
* Tests for CLIProxy Model Catalog
* Verifies model database structure and lookup functions
*/
const assert = require('assert');
describe('Model Catalog', () => {
const modelCatalog = require('../../../dist/cliproxy/model-catalog');
describe('MODEL_CATALOG structure', () => {
it('contains AGY provider catalog', () => {
const { MODEL_CATALOG } = modelCatalog;
assert(MODEL_CATALOG.agy, 'Should have agy provider');
assert.strictEqual(MODEL_CATALOG.agy.provider, 'agy');
assert.strictEqual(MODEL_CATALOG.agy.displayName, 'Antigravity');
});
it('contains Gemini provider catalog', () => {
const { MODEL_CATALOG } = modelCatalog;
assert(MODEL_CATALOG.gemini, 'Should have gemini provider');
assert.strictEqual(MODEL_CATALOG.gemini.provider, 'gemini');
assert.strictEqual(MODEL_CATALOG.gemini.displayName, 'Gemini');
});
it('does not contain codex or qwen (not configurable)', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.codex, undefined);
assert.strictEqual(MODEL_CATALOG.qwen, undefined);
});
});
describe('AGY models', () => {
it('has correct default model', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.agy.defaultModel, 'gemini-3-pro-preview');
});
it('includes Claude Opus 4.5 Thinking', () => {
const { MODEL_CATALOG } = modelCatalog;
const opus = MODEL_CATALOG.agy.models.find(
(m) => m.id === 'gemini-claude-opus-4-5-thinking'
);
assert(opus, 'Should include Claude Opus 4.5 Thinking');
assert.strictEqual(opus.name, 'Claude Opus 4.5 Thinking');
});
it('includes Claude Sonnet 4.5 Thinking', () => {
const { MODEL_CATALOG } = modelCatalog;
const sonnetThinking = MODEL_CATALOG.agy.models.find(
(m) => m.id === 'gemini-claude-sonnet-4-5-thinking'
);
assert(sonnetThinking, 'Should include Claude Sonnet 4.5 Thinking');
assert.strictEqual(sonnetThinking.name, 'Claude Sonnet 4.5 Thinking');
});
it('includes Claude Sonnet 4.5', () => {
const { MODEL_CATALOG } = modelCatalog;
const sonnet = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-claude-sonnet-4-5');
assert(sonnet, 'Should include Claude Sonnet 4.5');
assert.strictEqual(sonnet.name, 'Claude Sonnet 4.5');
});
it('includes Gemini 3 Pro with paid tier', () => {
const { MODEL_CATALOG } = modelCatalog;
const gem3 = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3-pro-preview');
assert(gem3, 'Should include Gemini 3 Pro');
assert.strictEqual(gem3.name, 'Gemini 3 Pro');
assert.strictEqual(gem3.tier, 'paid');
});
it('has 4 models total', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.agy.models.length, 4);
});
});
describe('Gemini models', () => {
it('has correct default model', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.gemini.defaultModel, 'gemini-2.5-pro');
});
it('includes Gemini 3 Pro with paid tier', () => {
const { MODEL_CATALOG } = modelCatalog;
const gem3 = MODEL_CATALOG.gemini.models.find((m) => m.id === 'gemini-3-pro-preview');
assert(gem3, 'Should include Gemini 3 Pro');
assert.strictEqual(gem3.name, 'Gemini 3 Pro');
assert.strictEqual(gem3.tier, 'paid');
});
it('includes Gemini 2.5 Pro without tier (free)', () => {
const { MODEL_CATALOG } = modelCatalog;
const gem25 = MODEL_CATALOG.gemini.models.find((m) => m.id === 'gemini-2.5-pro');
assert(gem25, 'Should include Gemini 2.5 Pro');
assert.strictEqual(gem25.name, 'Gemini 2.5 Pro');
assert.strictEqual(gem25.tier, undefined);
});
it('has 2 models total', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.gemini.models.length, 2);
});
});
describe('supportsModelConfig', () => {
it('returns true for agy', () => {
const { supportsModelConfig } = modelCatalog;
assert.strictEqual(supportsModelConfig('agy'), true);
});
it('returns true for gemini', () => {
const { supportsModelConfig } = modelCatalog;
assert.strictEqual(supportsModelConfig('gemini'), true);
});
it('returns false for codex', () => {
const { supportsModelConfig } = modelCatalog;
assert.strictEqual(supportsModelConfig('codex'), false);
});
it('returns false for qwen', () => {
const { supportsModelConfig } = modelCatalog;
assert.strictEqual(supportsModelConfig('qwen'), false);
});
});
describe('getProviderCatalog', () => {
it('returns catalog for agy', () => {
const { getProviderCatalog } = modelCatalog;
const catalog = getProviderCatalog('agy');
assert(catalog, 'Should return catalog');
assert.strictEqual(catalog.provider, 'agy');
assert(Array.isArray(catalog.models));
});
it('returns catalog for gemini', () => {
const { getProviderCatalog } = modelCatalog;
const catalog = getProviderCatalog('gemini');
assert(catalog, 'Should return catalog');
assert.strictEqual(catalog.provider, 'gemini');
});
it('returns undefined for codex', () => {
const { getProviderCatalog } = modelCatalog;
const catalog = getProviderCatalog('codex');
assert.strictEqual(catalog, undefined);
});
});
describe('findModel', () => {
it('finds Claude Opus 4.5 Thinking in agy', () => {
const { findModel } = modelCatalog;
const model = findModel('agy', 'gemini-claude-opus-4-5-thinking');
assert(model, 'Should find model');
assert.strictEqual(model.name, 'Claude Opus 4.5 Thinking');
});
it('finds Gemini 2.5 Pro in gemini', () => {
const { findModel } = modelCatalog;
const model = findModel('gemini', 'gemini-2.5-pro');
assert(model, 'Should find model');
assert.strictEqual(model.name, 'Gemini 2.5 Pro');
});
it('returns undefined for unknown model', () => {
const { findModel } = modelCatalog;
const model = findModel('agy', 'unknown-model');
assert.strictEqual(model, undefined);
});
it('returns undefined for unsupported provider', () => {
const { findModel } = modelCatalog;
const model = findModel('codex', 'any-model');
assert.strictEqual(model, undefined);
});
});
describe('Model entry structure', () => {
it('all models have required fields', () => {
const { MODEL_CATALOG } = modelCatalog;
for (const [provider, catalog] of Object.entries(MODEL_CATALOG)) {
for (const model of catalog.models) {
assert(model.id, `Model in ${provider} should have id`);
assert(typeof model.id === 'string', `Model id should be string`);
assert(model.name, `Model ${model.id} should have name`);
assert(typeof model.name === 'string', `Model name should be string`);
// tier is optional
if (model.tier !== undefined) {
assert(['free', 'paid'].includes(model.tier), `Invalid tier: ${model.tier}`);
}
}
}
});
it('all model IDs are unique within provider', () => {
const { MODEL_CATALOG } = modelCatalog;
for (const [provider, catalog] of Object.entries(MODEL_CATALOG)) {
const ids = catalog.models.map((m) => m.id);
const uniqueIds = new Set(ids);
assert.strictEqual(ids.length, uniqueIds.size, `Duplicate model IDs in ${provider}`);
}
});
it('default model exists in models array', () => {
const { MODEL_CATALOG } = modelCatalog;
for (const [provider, catalog] of Object.entries(MODEL_CATALOG)) {
const defaultExists = catalog.models.some((m) => m.id === catalog.defaultModel);
assert(defaultExists, `Default model ${catalog.defaultModel} not found in ${provider}`);
}
});
});
});
+162
View File
@@ -0,0 +1,162 @@
/**
* Tests for CLIProxy Model Configuration
* Verifies model configuration logic and settings management
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
describe('Model Config', () => {
const modelConfig = require('../../../dist/cliproxy/model-config');
const modelCatalog = require('../../../dist/cliproxy/model-catalog');
describe('hasUserSettings', () => {
it('returns false when settings file does not exist', () => {
// Ensure we're checking a non-existent path
const { hasUserSettings } = modelConfig;
// Since we can't easily mock getCcsDir, we test the function logic
// by checking it doesn't throw
const result = hasUserSettings('agy');
assert(typeof result === 'boolean', 'Should return boolean');
});
});
describe('getCurrentModel', () => {
it('returns undefined when settings file does not exist', () => {
const { getCurrentModel } = modelConfig;
// Test with a provider that likely has no settings in test env
const result = getCurrentModel('agy');
// Result depends on whether ~/.ccs/agy.settings.json exists
// Just verify it returns string or undefined
assert(
result === undefined || typeof result === 'string',
'Should return string or undefined'
);
});
});
describe('configureProviderModel', () => {
it('returns false for unsupported provider (codex)', async () => {
const { configureProviderModel } = modelConfig;
const result = await configureProviderModel('codex', true);
assert.strictEqual(result, false);
});
it('returns false for unsupported provider (qwen)', async () => {
const { configureProviderModel } = modelConfig;
const result = await configureProviderModel('qwen', true);
assert.strictEqual(result, false);
});
// Note: Full interactive tests require mocking stdin
// These are smoke tests to verify basic logic
});
describe('showCurrentConfig', () => {
it('does not throw for agy provider', () => {
const { showCurrentConfig } = modelConfig;
// Just verify it doesn't throw
assert.doesNotThrow(() => showCurrentConfig('agy'));
});
it('does not throw for gemini provider', () => {
const { showCurrentConfig } = modelConfig;
assert.doesNotThrow(() => showCurrentConfig('gemini'));
});
it('does not throw for unsupported provider', () => {
const { showCurrentConfig } = modelConfig;
assert.doesNotThrow(() => showCurrentConfig('codex'));
});
});
describe('Model catalog integration', () => {
it('configureProviderModel uses correct catalog for agy', async () => {
const { getProviderCatalog } = modelCatalog;
const catalog = getProviderCatalog('agy');
// Verify catalog structure is what configureProviderModel expects
assert(catalog.models, 'Should have models array');
assert(catalog.defaultModel, 'Should have defaultModel');
assert(catalog.displayName, 'Should have displayName');
});
it('configureProviderModel uses correct catalog for gemini', async () => {
const { getProviderCatalog } = modelCatalog;
const catalog = getProviderCatalog('gemini');
assert(catalog.models, 'Should have models array');
assert(catalog.defaultModel, 'Should have defaultModel');
assert(catalog.displayName, 'Should have displayName');
});
});
describe('Settings file format', () => {
it('should generate correct settings structure', () => {
// Test the expected settings structure
const expectedStructure = {
env: {
ANTHROPIC_BASE_URL: expect.any(String),
ANTHROPIC_AUTH_TOKEN: expect.any(String),
ANTHROPIC_MODEL: 'gemini-claude-opus-4-5-thinking',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-claude-opus-4-5-thinking',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-claude-opus-4-5-thinking',
ANTHROPIC_DEFAULT_HAIKU_MODEL: expect.any(String),
},
};
// Verify the structure is valid JSON
const testSettings = {
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'gemini-claude-opus-4-5-thinking',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-claude-opus-4-5-thinking',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-claude-opus-4-5-thinking',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-2.5-flash',
},
};
const json = JSON.stringify(testSettings, null, 2);
const parsed = JSON.parse(json);
assert(parsed.env, 'Should have env object');
assert(parsed.env.ANTHROPIC_MODEL, 'Should have ANTHROPIC_MODEL');
assert.strictEqual(
parsed.env.ANTHROPIC_MODEL,
'gemini-claude-opus-4-5-thinking'
);
});
it('all env values should be strings (PowerShell safety)', () => {
const testSettings = {
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'gemini-claude-opus-4-5-thinking',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-claude-opus-4-5-thinking',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-claude-opus-4-5-thinking',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-2.5-flash',
},
};
for (const [key, value] of Object.entries(testSettings.env)) {
assert.strictEqual(
typeof value,
'string',
`env.${key} should be string, got ${typeof value}`
);
}
});
});
});
// Helper for expect-like assertions
const expect = {
any: (type) => ({
_type: type,
toString: () => `expect.any(${type.name})`,
}),
};