Files
ccs/src/cliproxy/model-config.ts
T
kaitranntt aeb9abc998 feat(cliproxy): add granular account tier prioritization (ultra/pro/free)
- change AccountTier from binary (free/paid) to granular (ultra/pro/free/unknown)

- update mapTierString() to parse API tier strings correctly

- remove faulty inferTierFromModels() fallback - tier comes only from API

- update default tier_priority config to ['ultra', 'pro', 'free']

- update model catalog tier types and display logic

Closes #387
2026-01-28 15:11:00 -05:00

264 lines
8.8 KiB
TypeScript

/**
* 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 os from 'os';
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';
import { initUI, color, bold, dim, ok, info, header } from '../utils/ui';
/** CCS directory */
const CCS_DIR = path.join(os.homedir(), '.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
* @param provider CLIProxy provider
* @param customSettingsPath Optional custom settings path for CLIProxy variants
*/
export function getCurrentModel(
provider: CLIProxyProvider,
customSettingsPath?: string
): string | undefined {
const settingsPath = customSettingsPath
? customSettingsPath.replace(/^~/, os.homedir())
: 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 in selection list
*/
function formatModelOption(model: ModelEntry): string {
// Tier badge: ultra/pro indicate paid tiers
const tierBadge =
model.tier === 'ultra'
? color(' [Ultra]', 'warning')
: model.tier === 'pro'
? color(' [Pro]', 'warning')
: '';
const brokenBadge = model.broken ? color(' [BROKEN]', 'error') : '';
const deprecatedBadge = model.deprecated ? color(' [DEPRECATED]', 'warning') : '';
return `${model.name}${tierBadge}${brokenBadge}${deprecatedBadge}`;
}
/**
* Format model entry for detailed display (with description)
*/
function formatModelDetailed(model: ModelEntry, isCurrent: boolean): string {
const marker = isCurrent ? color('>', 'success') : ' ';
const name = isCurrent ? bold(model.name) : model.name;
const tierBadge =
model.tier === 'ultra'
? color(' [Ultra]', 'warning')
: model.tier === 'pro'
? color(' [Pro]', 'warning')
: '';
const brokenBadge = model.broken ? color(' [BROKEN]', 'error') : '';
const deprecatedBadge = model.deprecated ? color(' [DEPRECATED]', 'warning') : '';
const desc = model.description ? dim(` - ${model.description}`) : '';
return ` ${marker} ${name}${tierBadge}${brokenBadge}${deprecatedBadge}${desc}`;
}
/**
* Configure model for provider (interactive)
*
* @param provider CLIProxy provider (agy, gemini)
* @param force Force reconfiguration even if settings exist
* @param customSettingsPath Optional custom settings path for CLIProxy variants
* @returns true if configuration was performed, false if skipped
*/
export async function configureProviderModel(
provider: CLIProxyProvider,
force: boolean = false,
customSettingsPath?: string
): Promise<boolean> {
// Check if provider supports model configuration
if (!supportsModelConfig(provider)) {
return false;
}
const catalog = getProviderCatalog(provider);
if (!catalog) return false;
// Use custom settings path for CLIProxy variants, otherwise use default provider path
const settingsPath = customSettingsPath
? customSettingsPath.replace(/^~/, os.homedir())
: getProviderSettingsPath(provider);
// Skip if already configured (unless --config flag)
if (!force && fs.existsSync(settingsPath)) {
return false;
}
// Initialize UI for colors/gradient
await initUI();
// Build options list
const options = catalog.models.map((m) => ({
id: m.id,
label: formatModelOption(m),
}));
// Find default index - use current model if configured, otherwise catalog default
const currentModel = getCurrentModel(provider, customSettingsPath);
const targetModel = currentModel || catalog.defaultModel;
const defaultIdx = catalog.models.findIndex((m) => m.id === targetModel);
const safeDefaultIdx = defaultIdx >= 0 ? defaultIdx : 0;
// Show header with context (gradient like ccs doctor)
console.error('');
console.error(header(`Configure ${catalog.displayName} Model`));
console.error('');
console.error(dim(' Select which model to use for this provider.'));
console.error(
dim(' Models marked [Paid Tier] require a paid Google account (not free tier).')
);
console.error(dim(' Models marked [DEPRECATED] are not recommended for use.'));
console.error('');
// Interactive selection
const selectedModel = await InteractivePrompt.selectFromList('Select model:', options, {
defaultIndex: safeDefaultIdx,
});
// Get base env vars for defaults
const baseEnv = getClaudeEnvVars(provider);
// Read existing settings to preserve user customizations
let existingSettings: Record<string, unknown> = {};
let existingEnv: Record<string, string> = {};
if (fs.existsSync(settingsPath)) {
try {
existingSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
existingEnv = (existingSettings.env as Record<string, string>) || {};
} catch {
// Invalid JSON - start fresh
}
}
// Build settings with selective merge:
// - Preserve ALL user settings (top-level and env vars)
// - Only update CCS-controlled fields (model selection)
// CCS-controlled env vars (always override with our values)
const ccsControlledEnv: Record<string, string> = {
ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '',
ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '',
ANTHROPIC_MODEL: selectedModel,
ANTHROPIC_DEFAULT_OPUS_MODEL: selectedModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: selectedModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || '',
};
// Merge: user env vars (preserved) + CCS controlled (override)
const mergedEnv = {
...existingEnv,
...ccsControlledEnv,
};
// Build final settings: preserve user top-level settings + update env
const settings: Record<string, unknown> = {
...existingSettings,
env: mergedEnv,
};
// 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: ${bold(displayName)}`));
console.error(dim(` Config saved: ${settingsPath}`));
// Show deprecation warning if model is deprecated
if (selectedEntry?.deprecated) {
console.error('');
console.error(color('[!] DEPRECATION WARNING', 'warning'));
const reason = selectedEntry.deprecationReason || 'This model is deprecated';
console.error(dim(` ${reason}`));
console.error(dim(' Consider using a non-deprecated model for better compatibility.'));
}
console.error('');
return true;
}
/**
* Show current model configuration
*/
export async function showCurrentConfig(provider: CLIProxyProvider): Promise<void> {
if (!supportsModelConfig(provider)) {
console.error(info(`Provider ${provider} does not support model configuration`));
return;
}
const catalog = getProviderCatalog(provider);
if (!catalog) return;
// Initialize UI for colors/gradient
await initUI();
const currentModel = getCurrentModel(provider);
const settingsPath = getProviderSettingsPath(provider);
console.error('');
console.error(header(`${catalog.displayName} Model Configuration`));
console.error('');
if (currentModel) {
const entry = catalog.models.find((m) => m.id === currentModel);
const displayName = entry?.name || 'Unknown';
console.error(
` ${bold('Current:')} ${color(displayName, 'success')} ${dim(`(${currentModel})`)}`
);
console.error(` ${bold('Config:')} ${dim(settingsPath)}`);
} else {
console.error(` ${bold('Current:')} ${dim('(using defaults)')}`);
console.error(` ${bold('Default:')} ${catalog.defaultModel}`);
}
console.error('');
console.error(bold('Available models:'));
console.error(dim(' [Paid Tier] = Requires paid Google account (not free tier)'));
console.error(dim(' [DEPRECATED] = Not recommended for use'));
console.error('');
catalog.models.forEach((m) => {
const isCurrent = m.id === currentModel;
console.error(formatModelDetailed(m, isCurrent));
});
console.error('');
console.error(dim(`Run "ccs ${provider} --config" to change`));
console.error('');
}