mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
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:
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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})`,
|
||||
}),
|
||||
};
|
||||
Reference in New Issue
Block a user