diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 695adace..df768b0d 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -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'; diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index 27837e32..eeffe239 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -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'; diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts new file mode 100644 index 00000000..557cf828 --- /dev/null +++ b/src/cliproxy/model-catalog.ts @@ -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> = { + 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); +} diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts new file mode 100644 index 00000000..1ff67b97 --- /dev/null +++ b/src/cliproxy/model-config.ts @@ -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 { + // 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(''); +} diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js new file mode 100644 index 00000000..2a3c67d5 --- /dev/null +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -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}`); + } + }); + }); +}); diff --git a/tests/unit/cliproxy/model-config.test.js b/tests/unit/cliproxy/model-config.test.js new file mode 100644 index 00000000..24118bd7 --- /dev/null +++ b/tests/unit/cliproxy/model-config.test.js @@ -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})`, + }), +};