Merge pull request #168 from kaitranntt/kai/feat/openrouter-model-catalog

feat(openrouter): add model catalog integration with searchable picker and tier mapping
This commit is contained in:
Kai (Tam Nhu) Tran
2025-12-20 23:58:50 -05:00
committed by GitHub
44 changed files with 2697 additions and 989 deletions
+11 -220
View File
@@ -68,12 +68,9 @@ function validateConfiguration() {
errors.push('~/.ccs/ directory not found');
}
// Check required files
// Check required files (GLM/GLMT/Kimi are now optional - created via presets)
const requiredFiles = [
{ path: path.join(ccsDir, 'config.json'), name: 'config.json' },
{ path: path.join(ccsDir, 'glm.settings.json'), name: 'glm.settings.json' },
{ path: path.join(ccsDir, 'glmt.settings.json'), name: 'glmt.settings.json' },
{ path: path.join(ccsDir, 'kimi.settings.json'), name: 'kimi.settings.json' }
{ path: path.join(ccsDir, 'config.json'), name: 'config.json' }
];
for (const file of requiredFiles) {
@@ -156,17 +153,15 @@ function createConfigFiles() {
// Create config.json if missing
// NOTE: gemini/codex profiles NOT included - they are added on-demand when user
// runs `ccs gemini` or `ccs codex` for first time (requires OAuth auth first)
// NOTE: GLM/GLMT/Kimi profiles are now created via UI/CLI presets, not auto-created
const configPath = path.join(ccsDir, 'config.json');
if (!fs.existsSync(configPath)) {
// NOTE: No 'default' entry - when no profile specified, CCS passes through
// to Claude's native auth without --settings flag. This prevents env var
// pollution from affecting the default profile.
// Profiles are empty by default - users create via `ccs api create --preset` or UI
const config = {
profiles: {
glm: '~/.ccs/glm.settings.json',
glmt: '~/.ccs/glmt.settings.json',
kimi: '~/.ccs/kimi.settings.json'
}
profiles: {}
};
// Atomic write: temp file → rename
@@ -213,216 +208,12 @@ function createConfigFiles() {
}
}
// Create glm.settings.json if missing
const glmSettingsPath = path.join(ccsDir, 'glm.settings.json');
if (!fs.existsSync(glmSettingsPath)) {
const glmSettings = {
env: {
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic',
ANTHROPIC_AUTH_TOKEN: 'YOUR_GLM_API_KEY_HERE',
ANTHROPIC_MODEL: 'glm-4.6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'glm-4.6',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'glm-4.6',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'glm-4.6'
}
};
// Atomic write
const tmpPath = `${glmSettingsPath}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify(glmSettings, null, 2) + '\n', 'utf8');
fs.renameSync(tmpPath, glmSettingsPath);
console.log('[OK] Created GLM profile: ~/.ccs/glm.settings.json');
console.log('');
console.log(' [!] Configure GLM API key:');
console.log(' 1. Get key from: https://api.z.ai');
console.log(' 2. Edit: ~/.ccs/glm.settings.json');
console.log(' 3. Replace: YOUR_GLM_API_KEY_HERE');
} else {
console.log('[OK] GLM profile exists: ~/.ccs/glm.settings.json (preserved)');
}
// Create glmt.settings.json if missing
const glmtSettingsPath = path.join(ccsDir, 'glmt.settings.json');
if (!fs.existsSync(glmtSettingsPath)) {
const glmtSettings = {
env: {
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/coding/paas/v4/chat/completions',
ANTHROPIC_AUTH_TOKEN: 'YOUR_GLM_API_KEY_HERE',
ANTHROPIC_MODEL: 'glm-4.6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'glm-4.6',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'glm-4.6',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'glm-4.6',
ANTHROPIC_TEMPERATURE: '0.2',
ANTHROPIC_MAX_TOKENS: '65536',
MAX_THINKING_TOKENS: '32768',
ENABLE_STREAMING: 'true',
ANTHROPIC_SAFE_MODE: 'false',
API_TIMEOUT_MS: '3000000'
},
alwaysThinkingEnabled: true
};
// Atomic write
const tmpPath = `${glmtSettingsPath}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify(glmtSettings, null, 2) + '\n', 'utf8');
fs.renameSync(tmpPath, glmtSettingsPath);
console.log('[OK] Created GLMT profile: ~/.ccs/glmt.settings.json');
console.log('');
console.log(' [!] Configure GLMT API key:');
console.log(' 1. Get key from: https://api.z.ai');
console.log(' 2. Edit: ~/.ccs/glmt.settings.json');
console.log(' 3. Replace: YOUR_GLM_API_KEY_HERE');
console.log(' Note: GLMT enables GLM thinking mode (reasoning)');
console.log(' Defaults: Temperature 0.2, thinking enabled, 50min timeout');
} else {
console.log('[OK] GLMT profile exists: ~/.ccs/glmt.settings.json (preserved)');
}
// Migrate existing GLMT configs to include new defaults (v3.3.0)
if (fs.existsSync(glmtSettingsPath)) {
try {
const existing = JSON.parse(fs.readFileSync(glmtSettingsPath, 'utf8'));
let updated = false;
// Ensure env object exists
if (!existing.env) {
existing.env = {};
updated = true;
}
// Add missing env vars (preserve existing values)
const envDefaults = {
ANTHROPIC_TEMPERATURE: '0.2',
ANTHROPIC_MAX_TOKENS: '65536',
MAX_THINKING_TOKENS: '32768',
ENABLE_STREAMING: 'true',
ANTHROPIC_SAFE_MODE: 'false',
API_TIMEOUT_MS: '3000000'
};
for (const [key, value] of Object.entries(envDefaults)) {
if (existing.env[key] === undefined) {
existing.env[key] = value;
updated = true;
}
}
// Add alwaysThinkingEnabled if missing
if (existing.alwaysThinkingEnabled === undefined) {
existing.alwaysThinkingEnabled = true;
updated = true;
}
// Write back if updated
if (updated) {
const tmpPath = `${glmtSettingsPath}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
fs.renameSync(tmpPath, glmtSettingsPath);
console.log('[OK] Migrated GLMT config with new defaults (v3.3.0)');
console.log(' Added: temperature, max_tokens, thinking settings, alwaysThinkingEnabled');
}
} catch (err) {
console.warn('[!] GLMT config migration failed:', err.message);
console.warn(' Existing config preserved, may be missing new defaults');
console.warn(' You can manually add fields or delete file to regenerate');
}
}
// Create kimi.settings.json if missing
const kimiSettingsPath = path.join(ccsDir, 'kimi.settings.json');
if (!fs.existsSync(kimiSettingsPath)) {
const kimiSettings = {
env: {
ANTHROPIC_BASE_URL: 'https://api.kimi.com/coding/',
ANTHROPIC_AUTH_TOKEN: 'YOUR_KIMI_API_KEY_HERE',
ANTHROPIC_MODEL: 'kimi-k2-thinking-turbo',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'kimi-k2-thinking-turbo',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'kimi-k2-thinking-turbo',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'kimi-k2-thinking-turbo'
},
alwaysThinkingEnabled: true
};
// Atomic write
const tmpPath = `${kimiSettingsPath}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify(kimiSettings, null, 2) + '\n', 'utf8');
fs.renameSync(tmpPath, kimiSettingsPath);
console.log('[OK] Created Kimi profile: ~/.ccs/kimi.settings.json');
console.log('');
console.log(' [!] Configure Kimi API key:');
console.log(' 1. Get key from: https://www.kimi.com/coding (membership page)');
console.log(' 2. Edit: ~/.ccs/kimi.settings.json');
console.log(' 3. Replace: YOUR_KIMI_API_KEY_HERE');
} else {
console.log('[OK] Kimi profile exists: ~/.ccs/kimi.settings.json (preserved)');
}
// NOTE: gemini.settings.json and codex.settings.json are NOT created during install
// They are created on-demand when user runs `ccs gemini` or `ccs codex` for the first time
// This prevents confusion - users need to run `--auth` first anyway
// Migrate existing Kimi configs to use kimi-k2-thinking-turbo model (v5.5.0)
// Kimi API now supports model specification with thinking models
if (fs.existsSync(kimiSettingsPath)) {
try {
const existing = JSON.parse(fs.readFileSync(kimiSettingsPath, 'utf8'));
let updated = false;
const defaultModel = 'kimi-k2-thinking-turbo';
// Ensure env object exists
if (!existing.env) {
existing.env = {};
updated = true;
}
// Add/update model fields to use kimi-k2-thinking-turbo
const modelFields = {
ANTHROPIC_MODEL: defaultModel,
ANTHROPIC_DEFAULT_OPUS_MODEL: defaultModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: defaultModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: defaultModel
};
for (const [field, value] of Object.entries(modelFields)) {
if (existing.env[field] !== value) {
existing.env[field] = value;
updated = true;
}
}
// Remove deprecated ANTHROPIC_SMALL_FAST_MODEL if present
if (existing.env.ANTHROPIC_SMALL_FAST_MODEL !== undefined) {
delete existing.env.ANTHROPIC_SMALL_FAST_MODEL;
updated = true;
}
// Ensure required fields exist
if (!existing.env.ANTHROPIC_BASE_URL) {
existing.env.ANTHROPIC_BASE_URL = 'https://api.kimi.com/coding/';
updated = true;
}
// Add alwaysThinkingEnabled if missing
if (existing.alwaysThinkingEnabled === undefined) {
existing.alwaysThinkingEnabled = true;
updated = true;
}
// Write back if updated
if (updated) {
const tmpPath = `${kimiSettingsPath}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
fs.renameSync(tmpPath, kimiSettingsPath);
console.log('[OK] Migrated Kimi config (v5.5.0): updated to kimi-k2-thinking-turbo model');
}
} catch (err) {
console.warn('[!] Kimi config migration failed:', err.message);
console.warn(' Existing config preserved');
}
}
// NOTE: GLM, GLMT, and Kimi profiles are NO LONGER auto-created during install
// Users can create these via:
// - UI: Profile Create Dialog → Provider Presets
// - CLI: ccs api create --preset glm|glmt|kimi
// This gives users control over which providers they want to use
// Existing profiles are preserved for backward compatibility
// Copy shell completion files to ~/.ccs/completions/
const completionsDir = path.join(ccsDir, 'completions');
+14
View File
@@ -28,3 +28,17 @@ export {
// Profile write operations
export { createApiProfile, removeApiProfile } from './profile-writer';
// OpenRouter catalog and picker
export { isOpenRouterUrl, fetchOpenRouterModels, type OpenRouterModel } from './openrouter-catalog';
export { pickOpenRouterModel, type OpenRouterSelection } from './openrouter-picker';
// Provider presets for CLI
export {
PROVIDER_PRESETS,
OPENROUTER_BASE_URL,
getPresetById,
getPresetIds,
isValidPresetId,
type ProviderPreset,
} from './provider-presets';
+119
View File
@@ -0,0 +1,119 @@
/**
* OpenRouter Model Catalog Fetcher
* Fetches model list from OpenRouter API for CLI use
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/models';
const CACHE_FILE = path.join(os.homedir(), '.ccs', 'openrouter-models-cache.json');
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
export interface OpenRouterModel {
id: string;
name: string;
description: string;
context_length: number;
pricing: {
prompt: string;
completion: string;
};
}
interface CacheData {
models: OpenRouterModel[];
fetchedAt: number;
}
/** Check if cached data is valid */
function getCachedModels(): OpenRouterModel[] | null {
try {
if (!fs.existsSync(CACHE_FILE)) return null;
const data = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')) as CacheData;
if (Date.now() - data.fetchedAt > CACHE_TTL_MS) return null;
return data.models;
} catch {
return null;
}
}
/** Save models to cache */
function setCachedModels(models: OpenRouterModel[]): void {
try {
const dir = path.dirname(CACHE_FILE);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
CACHE_FILE,
JSON.stringify({
models,
fetchedAt: Date.now(),
})
);
} catch {
// Ignore cache write errors
}
}
/** Fetch models from OpenRouter API */
export async function fetchOpenRouterModels(): Promise<OpenRouterModel[]> {
// Try cache first
const cached = getCachedModels();
if (cached) return cached;
// Fetch from API
const response = await fetch(OPENROUTER_API_URL);
if (!response.ok) {
throw new Error(`Failed to fetch OpenRouter models: ${response.status}`);
}
const data = (await response.json()) as { data: OpenRouterModel[] };
const models = data.data.map((m) => ({
id: m.id,
name: m.name,
description: m.description,
context_length: m.context_length,
pricing: m.pricing,
}));
// Cache for next time
setCachedModels(models);
return models;
}
/** Format price per token to per million */
export function formatPrice(perToken: string): string {
const value = parseFloat(perToken);
if (isNaN(value) || value === 0) return 'Free';
const perMillion = value * 1_000_000;
if (perMillion < 0.01) return '<$0.01';
if (perMillion < 1) return `$${perMillion.toFixed(2)}`;
return `$${perMillion.toFixed(perMillion < 10 ? 2 : 0)}`;
}
/** Format pricing pair */
export function formatPricingPair(pricing: { prompt: string; completion: string }): string {
return `${formatPrice(pricing.prompt)}/${formatPrice(pricing.completion)}`;
}
/** Format context length */
export function formatContext(length: number): string {
if (length >= 1_000_000) return `${(length / 1_000_000).toFixed(1)}M`;
return `${Math.round(length / 1_000)}K`;
}
/** Search models */
export function searchModels(models: OpenRouterModel[], query: string): OpenRouterModel[] {
if (!query.trim()) return models.slice(0, 20); // Show first 20 if no query
const q = query.toLowerCase();
return models
.filter((m) => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q))
.slice(0, 20); // Limit to 20 results
}
/** Check if URL is OpenRouter */
export function isOpenRouterUrl(url: string): boolean {
return url.toLowerCase().includes('openrouter.ai');
}
+153
View File
@@ -0,0 +1,153 @@
/**
* OpenRouter Interactive Model Picker
* CLI interface for browsing and selecting OpenRouter models
*/
import { InteractivePrompt } from '../../utils/prompt';
import { table, info, warn, color, dim, spinner } from '../../utils/ui';
import {
fetchOpenRouterModels,
searchModels,
formatPricingPair,
formatContext,
type OpenRouterModel,
} from './openrouter-catalog';
export interface OpenRouterSelection {
model: string;
tierMapping?: {
opus?: string;
sonnet?: string;
haiku?: string;
};
}
/** Interactive model picker */
export async function pickOpenRouterModel(): Promise<OpenRouterSelection | null> {
// Fetch models with spinner
const s = await spinner('Fetching OpenRouter models...');
let models: OpenRouterModel[];
try {
models = await fetchOpenRouterModels();
s.succeed(`Loaded ${models.length} models from OpenRouter`);
} catch (error) {
s.fail(`Failed to fetch models: ${(error as Error).message}`);
return null;
}
// Search loop
let selectedModel: OpenRouterModel | null = null;
while (!selectedModel) {
const query = await InteractivePrompt.input('Search models (or press Enter to see popular)', {
default: '',
});
const results = searchModels(models, query);
if (results.length === 0) {
console.log(warn('No models found. Try a different search term.'));
continue;
}
// Display results in table
console.log('');
const rows = results.map((m, i) => [
String(i + 1),
m.id.length > 35 ? m.id.slice(0, 32) + '...' : m.id,
formatPricingPair(m.pricing),
formatContext(m.context_length),
]);
console.log(
table(rows, {
head: ['#', 'Model ID', 'Price (prompt/completion)', 'Context'],
})
);
console.log('');
// Get selection
const selection = await InteractivePrompt.input(
`Select model [1-${results.length}] or search again`,
{ default: '1' }
);
const index = parseInt(selection, 10) - 1;
if (index >= 0 && index < results.length) {
selectedModel = results[index];
} else if (selection.trim()) {
// Treat as new search
const newResults = searchModels(models, selection);
if (newResults.length === 1) {
selectedModel = newResults[0];
}
}
}
console.log('');
console.log(info(`Selected: ${color(selectedModel.id, 'info')}`));
// Ask about tier mapping
const configureTiers = await InteractivePrompt.confirm(
'Configure model tier mapping (opus/sonnet/haiku)?',
{ default: false }
);
if (!configureTiers) {
return { model: selectedModel.id };
}
// Tier mapping
console.log('');
console.log(dim('Leave blank to skip a tier.'));
const tierMapping = {
opus: await InteractivePrompt.input('Opus tier model', {
default: suggestTier(selectedModel.id, 'opus', models),
}),
sonnet: await InteractivePrompt.input('Sonnet tier model', {
default: selectedModel.id,
}),
haiku: await InteractivePrompt.input('Haiku tier model', {
default: suggestTier(selectedModel.id, 'haiku', models),
}),
};
// Clean empty values
const cleanMapping = {
opus: tierMapping.opus || undefined,
sonnet: tierMapping.sonnet || undefined,
haiku: tierMapping.haiku || undefined,
};
return {
model: selectedModel.id,
tierMapping: cleanMapping,
};
}
/** Suggest tier model based on provider */
function suggestTier(
selectedId: string,
tier: 'opus' | 'haiku',
models: OpenRouterModel[]
): string {
const [provider] = selectedId.split('/');
const providerModels = models.filter((m) => m.id.startsWith(`${provider}/`));
if (providerModels.length < 2) return '';
// Sort by price
const sorted = [...providerModels].sort((a, b) => {
const priceA = parseFloat(a.pricing.prompt) || 0;
const priceB = parseFloat(b.pricing.prompt) || 0;
return priceB - priceA; // Descending
});
if (tier === 'opus') {
return sorted[0]?.id ?? '';
} else {
return sorted[sorted.length - 1]?.id ?? '';
}
}
+15 -9
View File
@@ -9,7 +9,6 @@ import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir, loadConfig } from '../../utils/config-manager';
import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
import { getProfileSecrets } from '../../config/secrets-manager';
import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types';
/**
@@ -33,14 +32,10 @@ export function apiProfileExists(name: string): boolean {
*/
export function isApiProfileConfigured(apiName: string): boolean {
try {
if (isUnifiedMode()) {
const secrets = getProfileSecrets(apiName);
const token = secrets?.ANTHROPIC_AUTH_TOKEN || '';
return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-');
}
// Legacy: check settings.json file
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${apiName}.settings.json`);
// Check settings.json file for API key
if (!fs.existsSync(settingsPath)) return false;
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
@@ -53,6 +48,9 @@ export function isApiProfileConfigured(apiName: string): boolean {
/**
* List all API profiles
*
* Note: The 'default' profile (pointing to ~/.claude/settings.json) is excluded
* as it represents the user's native Claude subscription, not an API profile.
*/
export function listApiProfiles(): ApiListResult {
const profiles: ApiProfileInfo[] = [];
@@ -60,10 +58,14 @@ export function listApiProfiles(): ApiListResult {
if (isUnifiedMode()) {
const unifiedConfig = loadOrCreateUnifiedConfig();
for (const name of Object.keys(unifiedConfig.profiles)) {
for (const [name, profile] of Object.entries(unifiedConfig.profiles)) {
// Skip 'default' profile - it's the user's native Claude settings
if (name === 'default' && profile.settings?.includes('.claude/settings.json')) {
continue;
}
profiles.push({
name,
settingsPath: 'config.yaml',
settingsPath: profile.settings || 'config.yaml',
isConfigured: isApiProfileConfigured(name),
configSource: 'unified',
});
@@ -79,6 +81,10 @@ export function listApiProfiles(): ApiListResult {
} else {
const config = loadConfig();
for (const [name, settingsPath] of Object.entries(config.profiles)) {
// Skip 'default' profile - it's the user's native Claude settings
if (name === 'default' && (settingsPath as string).includes('.claude/settings.json')) {
continue;
}
profiles.push({
name,
settingsPath: settingsPath as string,
+9 -4
View File
@@ -11,9 +11,13 @@ import {
saveUnifiedConfig,
isUnifiedMode,
} from '../../config/unified-config-loader';
import { deleteAllProfileSecrets } from '../../config/secrets-manager';
import type { ModelMapping, CreateApiProfileResult, RemoveApiProfileResult } from './profile-types';
/** Check if URL is an OpenRouter endpoint */
function isOpenRouterUrl(baseUrl: string): boolean {
return baseUrl.toLowerCase().includes('openrouter.ai');
}
/** Create settings.json file for API profile (legacy format) */
function createSettingsFile(
name: string,
@@ -32,6 +36,8 @@ function createSettingsFile(
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
// OpenRouter requires explicitly blanking the API key to prevent conflicts
...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }),
},
};
@@ -83,6 +89,8 @@ function createApiProfileUnified(
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
// OpenRouter requires explicitly blanking the API key to prevent conflicts
...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }),
},
};
@@ -152,9 +160,6 @@ function removeApiProfileUnified(name: string): void {
}
saveUnifiedConfig(config);
// Remove any legacy secrets
deleteAllProfileSecrets(name);
}
/** Remove API profile from legacy config */
+105
View File
@@ -0,0 +1,105 @@
/**
* Provider Presets for CLI
*
* Pre-configured templates for common API providers.
* Mirrors the UI presets in ui/src/lib/provider-presets.ts
*/
export type PresetCategory = 'recommended' | 'alternative';
export interface ProviderPreset {
id: string;
name: string;
description: string;
baseUrl: string;
defaultProfileName: string;
defaultModel: string;
apiKeyPlaceholder: string;
apiKeyHint: string;
category: PresetCategory;
/** Additional env vars for thinking mode, etc. */
extraEnv?: Record<string, string>;
/** Enable always thinking mode */
alwaysThinkingEnabled?: boolean;
}
export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api';
/**
* Provider presets available via CLI and UI
*
* NOTE: Keep in sync with ui/src/lib/provider-presets.ts
*/
export const PROVIDER_PRESETS: ProviderPreset[] = [
// Recommended
{
id: 'openrouter',
name: 'OpenRouter',
description: '349+ models from OpenAI, Anthropic, Google, Meta',
baseUrl: OPENROUTER_BASE_URL,
defaultProfileName: 'openrouter',
defaultModel: 'anthropic/claude-sonnet-4',
apiKeyPlaceholder: 'sk-or-...',
apiKeyHint: 'Get your API key at openrouter.ai/keys',
category: 'recommended',
},
// Alternative providers
{
id: 'glm',
name: 'GLM',
description: 'Claude via Z.AI (GitHub Copilot)',
baseUrl: 'https://api.z.ai/api/anthropic',
defaultProfileName: 'glm',
defaultModel: 'glm-4.6',
apiKeyPlaceholder: 'ghp_...',
apiKeyHint: 'Get your API key from Z.AI',
category: 'alternative',
},
{
id: 'glmt',
name: 'GLMT',
description: 'GLM with Thinking mode support',
baseUrl: 'https://api.z.ai/api/coding/paas/v4/chat/completions',
defaultProfileName: 'glmt',
defaultModel: 'glm-4.6',
apiKeyPlaceholder: 'ghp_...',
apiKeyHint: 'Same API key as GLM',
category: 'alternative',
extraEnv: {
ANTHROPIC_TEMPERATURE: '0.2',
ANTHROPIC_MAX_TOKENS: '65536',
MAX_THINKING_TOKENS: '32768',
ENABLE_STREAMING: 'true',
ANTHROPIC_SAFE_MODE: 'false',
API_TIMEOUT_MS: '3000000',
},
alwaysThinkingEnabled: true,
},
{
id: 'kimi',
name: 'Kimi',
description: 'Moonshot AI - Fast reasoning model',
baseUrl: 'https://api.kimi.com/coding/',
defaultProfileName: 'kimi',
defaultModel: 'kimi-k2-thinking-turbo',
apiKeyPlaceholder: 'sk-...',
apiKeyHint: 'Get your API key from Moonshot AI',
category: 'alternative',
alwaysThinkingEnabled: true,
},
];
/** Get preset by ID */
export function getPresetById(id: string): ProviderPreset | undefined {
return PROVIDER_PRESETS.find((p) => p.id === id.toLowerCase());
}
/** Get all preset IDs */
export function getPresetIds(): string[] {
return PROVIDER_PRESETS.map((p) => p.id);
}
/** Check if preset ID is valid */
export function isValidPresetId(id: string): boolean {
return getPresetById(id) !== undefined;
}
+1 -4
View File
@@ -16,7 +16,6 @@ import { findSimilarStrings } from '../utils/helpers';
import { Config, Settings, ProfileMetadata } from '../types';
import { UnifiedConfig, CopilotConfig } from '../config/unified-config-types';
import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loader';
import { getProfileSecrets } from '../config/secrets-manager';
export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default';
@@ -110,12 +109,10 @@ class ProfileDetector {
const profile = config.profiles[profileName];
// Load env from settings file
const settingsEnv = loadSettingsFromFile(profile.settings);
// Merge with secrets (for backward compat with any extracted secrets)
const secrets = getProfileSecrets(profileName);
return {
type: 'settings',
name: profileName,
env: { ...settingsEnv, ...secrets },
env: settingsEnv,
};
}
+97 -30
View File
@@ -33,6 +33,10 @@ import {
removeApiProfile,
getApiProfileNames,
isUsingUnifiedConfig,
isOpenRouterUrl,
pickOpenRouterModel,
getPresetById,
getPresetIds,
type ModelMapping,
} from '../api/services';
@@ -41,6 +45,7 @@ interface ApiCommandArgs {
baseUrl?: string;
apiKey?: string;
model?: string;
preset?: string;
force?: boolean;
yes?: boolean;
}
@@ -58,6 +63,8 @@ function parseArgs(args: string[]): ApiCommandArgs {
result.apiKey = args[++i];
} else if (arg === '--model' && args[i + 1]) {
result.model = args[++i];
} else if (arg === '--preset' && args[i + 1]) {
result.preset = args[++i];
} else if (arg === '--force') {
result.force = true;
} else if (arg === '--yes' || arg === '-y') {
@@ -78,8 +85,18 @@ async function handleCreate(args: string[]): Promise<void> {
console.log(header('Create API Profile'));
console.log('');
// Step 1: API name
let name = parsedArgs.name;
// Handle --preset option for quick provider setup
const preset = parsedArgs.preset ? getPresetById(parsedArgs.preset) : null;
if (parsedArgs.preset && !preset) {
console.log(fail(`Unknown preset: ${parsedArgs.preset}`));
console.log('');
console.log('Available presets:');
getPresetIds().forEach((id) => console.log(` - ${id}`));
process.exit(1);
}
// Step 1: API name (use preset default if --preset provided)
let name = parsedArgs.name || preset?.defaultProfileName;
if (!name) {
name = await InteractivePrompt.input('API name', {
validate: validateApiName,
@@ -99,14 +116,15 @@ async function handleCreate(args: string[]): Promise<void> {
process.exit(1);
}
// Step 2: Base URL
let baseUrl = parsedArgs.baseUrl;
// Step 2: Base URL (use preset if provided)
let baseUrl = parsedArgs.baseUrl || preset?.baseUrl;
if (!baseUrl) {
baseUrl = await InteractivePrompt.input(
'API Base URL (e.g., https://api.example.com/v1 - without /chat/completions)',
{ validate: validateUrl }
);
} else {
} else if (!preset) {
// Only validate custom URLs, not preset URLs
const error = validateUrl(baseUrl);
if (error) {
console.log(fail(error));
@@ -114,49 +132,85 @@ async function handleCreate(args: string[]): Promise<void> {
}
}
// Check for common URL mistakes and warn
const urlWarning = getUrlWarning(baseUrl);
if (urlWarning) {
console.log('');
console.log(warn(urlWarning));
const continueAnyway = await InteractivePrompt.confirm('Continue with this URL anyway?', {
default: false,
});
if (!continueAnyway) {
baseUrl = await InteractivePrompt.input('API Base URL', {
validate: validateUrl,
default: sanitizeBaseUrl(baseUrl),
// Check for common URL mistakes and warn (skip for presets)
if (!preset) {
const urlWarning = getUrlWarning(baseUrl);
if (urlWarning) {
console.log('');
console.log(warn(urlWarning));
const continueAnyway = await InteractivePrompt.confirm('Continue with this URL anyway?', {
default: false,
});
if (!continueAnyway) {
baseUrl = await InteractivePrompt.input('API Base URL', {
validate: validateUrl,
default: sanitizeBaseUrl(baseUrl),
});
}
}
} else {
// Show preset info
console.log(info(`Using preset: ${preset.name}`));
console.log(dim(` ${preset.description}`));
console.log(dim(` Base URL: ${preset.baseUrl}`));
console.log('');
}
// OpenRouter detection: offer interactive model picker
let openRouterModel: string | undefined;
let openRouterTierMapping: { opus?: string; sonnet?: string; haiku?: string } | undefined;
if (isOpenRouterUrl(baseUrl) && !parsedArgs.model) {
console.log('');
console.log(info('OpenRouter detected!'));
const useInteractive = await InteractivePrompt.confirm('Browse models interactively?', {
default: true,
});
if (useInteractive) {
const selection = await pickOpenRouterModel();
if (selection) {
openRouterModel = selection.model;
openRouterTierMapping = selection.tierMapping;
}
}
console.log('');
console.log(dim('Note: For OpenRouter, ANTHROPIC_API_KEY should be empty.'));
}
// Step 3: API Key
let apiKey = parsedArgs.apiKey;
if (!apiKey) {
apiKey = await InteractivePrompt.password('API Key');
const keyPrompt = preset?.apiKeyHint ? `API Key (${preset.apiKeyHint})` : 'API Key';
apiKey = await InteractivePrompt.password(keyPrompt);
if (!apiKey) {
console.log(fail('API key is required'));
process.exit(1);
}
}
// Step 4: Model configuration
const defaultModel = 'claude-sonnet-4-5-20250929';
let model = parsedArgs.model;
if (!model && !parsedArgs.yes) {
// Step 4: Model configuration (use preset default if available)
const defaultModel = preset?.defaultModel || 'claude-sonnet-4-5-20250929';
let model = parsedArgs.model || openRouterModel || preset?.defaultModel;
if (!model && !parsedArgs.yes && !preset) {
model = await InteractivePrompt.input('Default model (ANTHROPIC_MODEL)', {
default: defaultModel,
});
}
model = model || defaultModel;
// Step 5: Model mapping for Opus/Sonnet/Haiku
let opusModel = model;
let sonnetModel = model;
let haikuModel = model;
// Step 5: Model mapping for Opus/Sonnet/Haiku (skip prompt for presets with --yes)
let opusModel = openRouterTierMapping?.opus || model;
let sonnetModel = openRouterTierMapping?.sonnet || model;
let haikuModel = openRouterTierMapping?.haiku || model;
const isCustomModel = model !== defaultModel;
const hasOpenRouterTierMapping = openRouterTierMapping !== undefined;
const hasPreset = preset !== null;
if (!parsedArgs.yes) {
if (!parsedArgs.yes && !hasOpenRouterTierMapping && !hasPreset) {
let wantCustomMapping = isCustomModel;
if (!isCustomModel) {
@@ -330,11 +384,9 @@ async function handleRemove(args: string[]): Promise<void> {
// Confirm deletion
console.log('');
console.log(`API '${color(name, 'command')}' will be removed.`);
console.log(` Settings: ~/.ccs/${name}.settings.json`);
if (isUsingUnifiedConfig()) {
console.log(' Config: ~/.ccs/config.yaml');
console.log(' Secrets: ~/.ccs/secrets.yaml');
} else {
console.log(` Settings: ~/.ccs/${name}.settings.json`);
}
console.log('');
@@ -373,16 +425,31 @@ async function showHelp(): Promise<void> {
console.log(` ${color('remove <name>', 'command')} Remove an API profile`);
console.log('');
console.log(subheader('Options'));
console.log(
` ${color('--preset <id>', 'command')} Use provider preset (openrouter, glm, glmt, kimi)`
);
console.log(` ${color('--base-url <url>', 'command')} API base URL (create)`);
console.log(` ${color('--api-key <key>', 'command')} API key (create)`);
console.log(` ${color('--model <model>', 'command')} Default model (create)`);
console.log(` ${color('--force', 'command')} Overwrite existing (create)`);
console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts`);
console.log('');
console.log(subheader('Provider Presets'));
console.log(
` ${color('openrouter', 'command')} OpenRouter - 349+ models (Claude, GPT, Gemini, Llama)`
);
console.log(` ${color('glm', 'command')} GLM - Claude via Z.AI (GitHub Copilot)`);
console.log(` ${color('glmt', 'command')} GLMT - GLM with Thinking mode`);
console.log(` ${color('kimi', 'command')} Kimi - Moonshot AI reasoning model`);
console.log('');
console.log(subheader('Examples'));
console.log(` ${dim('# Interactive wizard')}`);
console.log(` ${color('ccs api create', 'command')}`);
console.log('');
console.log(` ${dim('# Quick setup with preset')}`);
console.log(` ${color('ccs api create --preset openrouter', 'command')}`);
console.log(` ${color('ccs api create --preset glm', 'command')}`);
console.log('');
console.log(` ${dim('# Create with name')}`);
console.log(` ${color('ccs api create myapi', 'command')}`);
console.log('');
-1
View File
@@ -15,7 +15,6 @@ export * from './reserved-names';
// Loaders
export * from './unified-config-loader';
export * from './secrets-manager';
// Migration
export * from './migration-manager';
-2
View File
@@ -224,11 +224,9 @@ export async function rollback(backupPath: string): Promise<boolean> {
try {
// Remove new config files
const configYaml = path.join(ccsDir, 'config.yaml');
const secretsYaml = path.join(ccsDir, 'secrets.yaml');
const cacheDir = path.join(ccsDir, 'cache');
if (fs.existsSync(configYaml)) fs.unlinkSync(configYaml);
if (fs.existsSync(secretsYaml)) fs.unlinkSync(secretsYaml);
// Restore cache files to original locations
if (fs.existsSync(cacheDir)) {
-187
View File
@@ -1,187 +0,0 @@
/**
* Secrets Manager
*
* Handles loading and saving secrets (API keys, tokens) in a separate file
* with restricted permissions (chmod 600).
*/
import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'js-yaml';
import { getCcsDir } from '../utils/config-manager';
import { SecretsConfig, isSecretsConfig, createEmptySecretsConfig } from './unified-config-types';
// Re-export from shared utility for backward compatibility
export { isSensitiveKey as isSecretKey } from '../utils/sensitive-keys';
const SECRETS_FILE = 'secrets.yaml';
const SECRETS_FILE_MODE = 0o600; // Owner read/write only
/**
* Get path to secrets.yaml
*/
export function getSecretsPath(): string {
return path.join(getCcsDir(), SECRETS_FILE);
}
/**
* Check if secrets.yaml exists
*/
export function hasSecrets(): boolean {
return fs.existsSync(getSecretsPath());
}
/**
* Load secrets from YAML file.
* Returns empty secrets config if file doesn't exist.
*/
export function loadSecrets(): SecretsConfig {
const secretsPath = getSecretsPath();
if (!fs.existsSync(secretsPath)) {
return createEmptySecretsConfig();
}
try {
const content = fs.readFileSync(secretsPath, 'utf8');
const parsed = yaml.load(content);
if (!isSecretsConfig(parsed)) {
console.error(`[!] Invalid secrets format in ${secretsPath}`);
return createEmptySecretsConfig();
}
return parsed;
} catch (err) {
const error = err instanceof Error ? err.message : 'Unknown error';
console.error(`[X] Failed to load secrets: ${error}`);
return createEmptySecretsConfig();
}
}
/**
* Save secrets to YAML file with restricted permissions.
* Uses atomic write (temp file + rename) to prevent corruption.
*/
export function saveSecrets(secrets: SecretsConfig): void {
const secretsPath = getSecretsPath();
const dir = path.dirname(secretsPath);
// Ensure directory exists
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
// Convert to YAML
const content = yaml.dump(secrets, {
indent: 2,
lineWidth: -1,
quotingType: '"',
noRefs: true,
});
// Atomic write: write to temp file, then rename
const tempPath = `${secretsPath}.tmp.${process.pid}`;
try {
fs.writeFileSync(tempPath, content, { mode: SECRETS_FILE_MODE });
fs.renameSync(tempPath, secretsPath);
// Ensure correct permissions after rename (some systems may not preserve)
fs.chmodSync(secretsPath, SECRETS_FILE_MODE);
} catch (err) {
// Clean up temp file on error
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// Ignore cleanup errors
}
}
throw err;
}
}
/**
* Get a secret value for a specific profile.
*/
export function getProfileSecret(profileName: string, key: string): string | undefined {
const secrets = loadSecrets();
return secrets.profiles[profileName]?.[key];
}
/**
* Set a secret value for a specific profile.
*/
export function setProfileSecret(profileName: string, key: string, value: string): void {
const secrets = loadSecrets();
if (!secrets.profiles[profileName]) {
secrets.profiles[profileName] = {};
}
secrets.profiles[profileName][key] = value;
saveSecrets(secrets);
}
/**
* Delete a secret value for a specific profile.
*/
export function deleteProfileSecret(profileName: string, key: string): boolean {
const secrets = loadSecrets();
if (!secrets.profiles[profileName]?.[key]) {
return false;
}
delete secrets.profiles[profileName][key];
// Clean up empty profile object
if (Object.keys(secrets.profiles[profileName]).length === 0) {
delete secrets.profiles[profileName];
}
saveSecrets(secrets);
return true;
}
/**
* Get all secrets for a profile.
*/
export function getProfileSecrets(profileName: string): Record<string, string> {
const secrets = loadSecrets();
return secrets.profiles[profileName] || {};
}
/**
* Set all secrets for a profile (replaces existing).
*/
export function setProfileSecrets(
profileName: string,
profileSecrets: Record<string, string>
): void {
const secrets = loadSecrets();
if (Object.keys(profileSecrets).length === 0) {
delete secrets.profiles[profileName];
} else {
secrets.profiles[profileName] = profileSecrets;
}
saveSecrets(secrets);
}
/**
* Delete all secrets for a profile.
*/
export function deleteAllProfileSecrets(profileName: string): boolean {
const secrets = loadSecrets();
if (!secrets.profiles[profileName]) {
return false;
}
delete secrets.profiles[profileName];
saveSecrets(secrets);
return true;
}
+1 -32
View File
@@ -6,7 +6,7 @@
* - profiles.json (account metadata)
* - *.settings.json (env vars)
*
* Into a single config.yaml + secrets.yaml structure.
* Into a single config.yaml structure.
*/
/**
@@ -321,18 +321,6 @@ export interface UnifiedConfig {
cliproxy_server?: CliproxyServerConfig;
}
/**
* Secrets configuration structure.
* Stored in ~/.ccs/secrets.yaml with chmod 600.
* Contains sensitive values like API keys.
*/
export interface SecretsConfig {
/** Secrets version */
version: number;
/** Profile secrets mapping: profile_name -> { key: value } */
profiles: Record<string, Record<string, string>>;
}
/**
* Default Copilot configuration.
* Strictly opt-in - disabled by default.
@@ -422,16 +410,6 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
};
}
/**
* Create an empty secrets config.
*/
export function createEmptySecretsConfig(): SecretsConfig {
return {
version: 1,
profiles: {},
};
}
/**
* Type guard for UnifiedConfig.
* Relaxed validation: accepts configs with version >= 1 and any subset of sections.
@@ -444,12 +422,3 @@ export function isUnifiedConfig(obj: unknown): obj is UnifiedConfig {
// Sections are optional - will be merged with defaults in loadOrCreateUnifiedConfig
return typeof config.version === 'number' && config.version >= 1;
}
/**
* Type guard for SecretsConfig.
*/
export function isSecretsConfig(obj: unknown): obj is SecretsConfig {
if (typeof obj !== 'object' || obj === null) return false;
const config = obj as Record<string, unknown>;
return typeof config.version === 'number' && typeof config.profiles === 'object';
}
+6 -11
View File
@@ -69,14 +69,9 @@ class RecoveryManager {
}
// Create default config (matches postinstall.js)
// NOTE: No 'default' entry - when no profile specified, CCS passes through
// to Claude's native auth without --settings flag
// NOTE: Empty profiles - users create profiles via `ccs api create` or UI
const defaultConfig = {
profiles: {
glm: '~/.ccs/glm.settings.json',
glmt: '~/.ccs/glmt.settings.json',
kimi: '~/.ccs/kimi.settings.json',
},
profiles: {},
};
const tmpPath = `${configPath}.tmp`;
@@ -274,6 +269,9 @@ class RecoveryManager {
/**
* Run all recovery operations (lazy initialization)
* Mirrors postinstall.js behavior
*
* NOTE: GLM/GLMT/Kimi profiles are NOT auto-created.
* Users should create them via `ccs api create --preset glm` or the UI.
*/
recoverAll(): boolean {
this.recovered = [];
@@ -283,11 +281,8 @@ class RecoveryManager {
this.ensureSharedDirectories();
this.ensureClaudeSettings();
// Config files
// Config files (core only - no GLM/GLMT/Kimi auto-creation)
this.ensureConfigJson();
this.ensureGlmSettings();
this.ensureGlmtSettings();
this.ensureKimiSettings();
// Shell completions
this.ensureShellCompletions();
+42 -5
View File
@@ -4,6 +4,7 @@ import * as os from 'os';
import { Config, isConfig, Settings, isSettings } from '../types';
import { expandPath, error } from './helpers';
import { info } from './ui';
import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
// TODO: Replace with proper imports after converting these files
// const { ErrorManager } = require('./error-manager');
@@ -82,16 +83,52 @@ export function readConfig(): Config {
}
/**
* Get settings path for profile
* Get settings path for profile.
* In unified mode (config.yaml exists), reads from config.yaml first,
* then falls back to config.json for backward compatibility.
*/
export function getSettingsPath(profile: string): string {
const config = readConfig();
let settingsPath: string | undefined;
let availableProfiles: string[] = [];
// Get settings path
const settingsPath = config.profiles[profile];
// Check unified config first (config.yaml)
if (isUnifiedMode()) {
const unifiedConfig = loadOrCreateUnifiedConfig();
// Check if profile exists in unified config
const profileConfig = unifiedConfig.profiles[profile];
if (profileConfig?.settings) {
settingsPath = profileConfig.settings;
}
// Collect available profiles from unified config
availableProfiles = Object.keys(unifiedConfig.profiles);
// If not found in unified config, try legacy config.json as fallback
if (!settingsPath) {
try {
const legacyConfig = loadConfig();
if (legacyConfig.profiles[profile]) {
settingsPath = legacyConfig.profiles[profile];
// Merge legacy profiles into available list (avoid duplicates)
for (const p of Object.keys(legacyConfig.profiles)) {
if (!availableProfiles.includes(p)) {
availableProfiles.push(p);
}
}
}
} catch {
// Legacy config doesn't exist or is invalid - that's OK in unified mode
}
}
} else {
// Legacy mode - read from config.json only
const config = readConfig();
settingsPath = config.profiles[profile];
availableProfiles = Object.keys(config.profiles);
}
if (!settingsPath) {
const availableProfiles = Object.keys(config.profiles);
const profileList = availableProfiles.map((p) => ` - ${p}`);
error(`Profile '${profile}' not found. Available profiles:\n${profileList.join('\n')}`);
}
-33
View File
@@ -17,7 +17,6 @@ import {
rollback,
getBackupDirectories,
} from '../../config/migration-manager';
import { getProfileSecrets, setProfileSecrets } from '../../config/secrets-manager';
import { isUnifiedConfig } from '../../config/unified-config-types';
const router = Router();
@@ -111,36 +110,4 @@ router.post('/rollback', async (req: Request, res: Response): Promise<void> => {
res.json({ success });
});
/**
* PUT /api/secrets/:profile - Update profile secrets (write-only)
*/
router.put('/secrets/:profile', (req: Request, res: Response): void => {
const { profile } = req.params;
const secrets = req.body;
if (!secrets || typeof secrets !== 'object') {
res.status(400).json({ error: 'Invalid secrets format' });
return;
}
try {
setProfileSecrets(profile, secrets as Record<string, string>);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
/**
* GET /api/secrets/:profile/exists - Check if secrets exist (no values returned)
*/
router.get('/secrets/:profile/exists', (req: Request, res: Response) => {
const { profile } = req.params;
const secrets = getProfileSecrets(profile);
res.json({
exists: Object.keys(secrets).length > 0,
keys: Object.keys(secrets), // Only key names, not values
});
});
export default router;
+1 -2
View File
@@ -31,9 +31,8 @@ apiRoutes.use('/settings', settingsRoutes);
apiRoutes.use('/accounts', profileRoutes);
// ==================== Unified Config ====================
// Config format, migration, secrets
// Config format, migration
apiRoutes.use('/config', configRoutes);
apiRoutes.use('/secrets', configRoutes);
// ==================== Health Checks ====================
apiRoutes.use('/health', healthRoutes);
+34 -45
View File
@@ -1,5 +1,7 @@
/**
* Profile Routes - CRUD operations for user profiles and accounts
*
* Uses unified config (config.yaml) when available, falls back to legacy (config.json).
*/
import { Router, Request, Response } from 'express';
@@ -7,13 +9,9 @@ import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager';
import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names';
import {
readConfigSafe,
writeConfig,
isConfigured,
createSettingsFile,
updateSettingsFile,
} from './route-helpers';
import { createApiProfile, removeApiProfile } from '../../api/services/profile-writer';
import { apiProfileExists, listApiProfiles } from '../../api/services/profile-reader';
import { updateSettingsFile } from './route-helpers';
const router = Router();
@@ -23,13 +21,13 @@ const router = Router();
* GET /api/profiles - List all profiles
*/
router.get('/', (_req: Request, res: Response) => {
const config = readConfigSafe();
const profiles = Object.entries(config.profiles).map(([name, settingsPath]) => ({
name,
settingsPath,
configured: isConfigured(name, config),
const result = listApiProfiles();
// Map isConfigured -> configured for UI compatibility
const profiles = result.profiles.map((p) => ({
name: p.name,
settingsPath: p.settingsPath,
configured: p.isConfigured,
}));
res.json({ profiles });
});
@@ -53,31 +51,26 @@ router.post('/', (req: Request, res: Response): void => {
return;
}
const config = readConfigSafe();
if (config.profiles[name]) {
// Check if profile already exists (uses unified config when available)
if (apiProfileExists(name)) {
res.status(409).json({ error: 'Profile already exists' });
return;
}
// Ensure .ccs directory exists
if (!fs.existsSync(getCcsDir())) {
fs.mkdirSync(getCcsDir(), { recursive: true });
}
// Create settings file with model mapping
const settingsPath = createSettingsFile(name, baseUrl, apiKey, {
model,
opusModel,
sonnetModel,
haikuModel,
// Create profile using unified-config-aware service
const result = createApiProfile(name, baseUrl, apiKey, {
default: model || '',
opus: opusModel || model || '',
sonnet: sonnetModel || model || '',
haiku: haikuModel || model || '',
});
// Update config
config.profiles[name] = settingsPath;
writeConfig(config);
if (!result.success) {
res.status(500).json({ error: result.error || 'Failed to create profile' });
return;
}
res.status(201).json({ name, settingsPath });
res.status(201).json({ name, settingsPath: result.settingsFile });
});
/**
@@ -87,9 +80,8 @@ router.put('/:name', (req: Request, res: Response): void => {
const { name } = req.params;
const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel } = req.body;
const config = readConfigSafe();
if (!config.profiles[name]) {
// Check if profile exists (uses unified config when available)
if (!apiProfileExists(name)) {
res.status(404).json({ error: 'Profile not found' });
return;
}
@@ -108,22 +100,19 @@ router.put('/:name', (req: Request, res: Response): void => {
router.delete('/:name', (req: Request, res: Response): void => {
const { name } = req.params;
const config = readConfigSafe();
if (!config.profiles[name]) {
// Check if profile exists (uses unified config when available)
if (!apiProfileExists(name)) {
res.status(404).json({ error: 'Profile not found' });
return;
}
// Delete settings file
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
if (fs.existsSync(settingsPath)) {
fs.unlinkSync(settingsPath);
}
// Remove profile using unified-config-aware service
const result = removeApiProfile(name);
// Remove from config
delete config.profiles[name];
writeConfig(config);
if (!result.success) {
res.status(500).json({ error: result.error || 'Failed to delete profile' });
return;
}
res.json({ name, deleted: true });
});
+16 -7
View File
@@ -77,12 +77,21 @@ describe('npm CLI', () => {
});
describe('Profile handling', () => {
it('loads glm profile', function() {
// Note: GLM/GLMT/Kimi profiles are no longer auto-created (v6.0)
// Users create these via UI presets or CLI: ccs api create --preset glm
it('shows helpful error for non-existent profile', function() {
try {
runCli('glm --help', { stdio: 'pipe' });
// If GLM profile exists from previous setup, this is fine too
} catch (e) {
const output = e.stderr?.toString() || '';
assert(!output.includes("Profile 'glm' not found"), 'GLM profile should exist');
const output = e.stderr?.toString() || e.stdout?.toString() || '';
// Either profile exists and works, or shows helpful "not found" message
// Both are valid behaviors depending on user's setup
const isValid = !output.includes("Profile 'glm' not found") ||
output.includes("not found") ||
output.includes("ccs api create");
assert(isValid, 'Should either find profile or show helpful message');
}
});
@@ -96,13 +105,13 @@ describe('npm CLI', () => {
}
});
it('handles profile with flags', function() {
it('handles profile with flags correctly', function() {
try {
runCli('glm -c', { stdio: 'pipe', timeout: 3000 });
// Use a known command instead of profile that may not exist
runCli('api --help', { stdio: 'pipe', timeout: 3000 });
} catch (e) {
const output = e.stderr?.toString() || '';
assert(!output.includes("Profile 'glm' not found"), 'GLM profile should exist');
assert(!output.includes("Profile '-c' not found"), 'Should not treat -c as profile');
assert(!output.includes("Profile '-c' not found"), 'Should not treat flags as profiles');
}
});
});
+10 -8
View File
@@ -30,20 +30,21 @@ describe('npm postinstall', () => {
const config = testEnv.readFile('config.json', true);
assert(config.profiles, 'config.json should have profiles');
assert(typeof config.profiles === 'object', 'profiles should be an object');
// Profiles are now empty by default - users create via presets
assert.deepStrictEqual(config.profiles, {}, 'profiles should be empty by default');
});
it('creates glm.settings.json', () => {
it('does NOT auto-create glm.settings.json (v6.0 - use presets instead)', () => {
execSync(`node "${postinstallScript}"`, {
stdio: 'ignore',
env: { ...process.env, CCS_HOME: testEnv.testHome }
});
assert(testEnv.fileExists('glm.settings.json'), 'glm.settings.json should be created');
const glmSettings = testEnv.readFile('glm.settings.json', true);
assert(glmSettings.env, 'glm.settings.json should have env section');
assert(glmSettings.env.ANTHROPIC_MODEL, 'should have ANTHROPIC_MODEL set');
assert.strictEqual(glmSettings.env.ANTHROPIC_MODEL, 'glm-4.6');
// GLM/GLMT/Kimi profiles are NO LONGER auto-created during install
// Users create these via UI presets or CLI: ccs api create --preset glm
assert(!testEnv.fileExists('glm.settings.json'), 'glm.settings.json should NOT be auto-created');
assert(!testEnv.fileExists('glmt.settings.json'), 'glmt.settings.json should NOT be auto-created');
assert(!testEnv.fileExists('kimi.settings.json'), 'kimi.settings.json should NOT be auto-created');
});
it('is idempotent', () => {
@@ -97,7 +98,8 @@ describe('npm postinstall', () => {
// Verify existing file still exists and new files are created
assert(testEnv.fileExists('existing.txt'), 'Existing files should be preserved');
assert(testEnv.fileExists('config.json'), 'config.json should be created');
assert(testEnv.fileExists('glm.settings.json'), 'glm.settings.json should be created');
// GLM/GLMT/Kimi are no longer auto-created
assert(!testEnv.fileExists('glm.settings.json'), 'glm.settings.json should NOT be auto-created');
});
it('does not create VERSION file', () => {
+2 -31
View File
@@ -11,14 +11,12 @@ import {
} from '../../src/config/reserved-names';
import {
createEmptyUnifiedConfig,
createEmptySecretsConfig,
isUnifiedConfig,
isSecretsConfig,
UNIFIED_CONFIG_VERSION,
} from '../../src/config/unified-config-types';
import { isUnifiedConfigEnabled } from '../../src/config/feature-flags';
// Inline helper to test secret key detection (copied from secrets-manager to avoid import chain)
// Inline helper to test secret key detection (utility kept for potential reuse)
function isSecretKey(key: string): boolean {
const upper = key.toUpperCase();
const secretPatterns = ['TOKEN', 'SECRET', 'API_KEY', 'APIKEY', 'PASSWORD', 'CREDENTIAL', 'AUTH', 'PRIVATE'];
@@ -102,18 +100,6 @@ describe('unified-config-types', () => {
});
});
describe('createEmptySecretsConfig', () => {
it('should create secrets with version 1', () => {
const secrets = createEmptySecretsConfig();
expect(secrets.version).toBe(1);
});
it('should have empty profiles', () => {
const secrets = createEmptySecretsConfig();
expect(Object.keys(secrets.profiles)).toHaveLength(0);
});
});
describe('isUnifiedConfig', () => {
it('should return true for valid config', () => {
const config = createEmptyUnifiedConfig();
@@ -143,24 +129,9 @@ describe('unified-config-types', () => {
expect(isUnifiedConfig({ version: -1 })).toBe(false);
});
});
describe('isSecretsConfig', () => {
it('should return true for valid secrets', () => {
const secrets = createEmptySecretsConfig();
expect(isSecretsConfig(secrets)).toBe(true);
});
it('should return false for null', () => {
expect(isSecretsConfig(null)).toBe(false);
});
it('should return false for missing fields', () => {
expect(isSecretsConfig({ version: 1 })).toBe(false);
});
});
});
describe('secrets-manager', () => {
describe('sensitive-keys', () => {
describe('isSecretKey', () => {
it('should identify token keys as secrets', () => {
expect(isSecretKey('ANTHROPIC_AUTH_TOKEN')).toBe(true);
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"></path></svg>

After

Width:  |  Height:  |  Size: 906 B

@@ -16,7 +16,9 @@ import type { Settings } from './types';
interface EnvEditorSectionProps {
currentSettings: Settings | undefined;
newEnvKey: string;
newEnvValue: string;
onNewEnvKeyChange: (value: string) => void;
onNewEnvValueChange: (value: string) => void;
onEnvValueChange: (key: string, value: string) => void;
onAddEnvVar: () => void;
}
@@ -24,7 +26,9 @@ interface EnvEditorSectionProps {
export function EnvEditorSection({
currentSettings,
newEnvKey,
newEnvValue,
onNewEnvKeyChange,
onNewEnvValueChange,
onEnvValueChange,
onAddEnvVar,
}: EnvEditorSectionProps) {
@@ -82,8 +86,15 @@ export function EnvEditorSection({
placeholder="VARIABLE_NAME"
value={newEnvKey}
onChange={(e) => onNewEnvKeyChange(e.target.value.toUpperCase())}
className="font-mono text-sm h-8"
onKeyDown={(e) => e.key === 'Enter' && onAddEnvVar()}
className="font-mono text-sm h-8 w-2/5"
onKeyDown={(e) => e.key === 'Enter' && newEnvKey.trim() && onAddEnvVar()}
/>
<Input
placeholder="value"
value={newEnvValue}
onChange={(e) => onNewEnvValueChange(e.target.value)}
className="font-mono text-sm h-8 flex-1"
onKeyDown={(e) => e.key === 'Enter' && newEnvKey.trim() && onAddEnvVar()}
/>
<Button
variant="outline"
@@ -1,11 +1,24 @@
/**
* Friendly UI Section
* Left column with environment variables and info tabs
* Enhanced with OpenRouter-specific streamlined UI when applicable
*/
import { useMemo, useState } from 'react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { EnvEditorSection } from './env-editor-section';
import { InfoSection } from './info-section';
import { OpenRouterModelPicker } from '@/components/profiles/openrouter-model-picker';
import { ModelTierMapping, type TierMapping } from '@/components/profiles/model-tier-mapping';
import { Label } from '@/components/ui/label';
import { MaskedInput } from '@/components/ui/masked-input';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { ChevronRight, Settings2, Plus } from 'lucide-react';
import { isOpenRouterProfile, extractTierMapping, applyTierMapping } from './utils';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { Settings, SettingsResponse } from './types';
interface FriendlyUISectionProps {
@@ -13,9 +26,12 @@ interface FriendlyUISectionProps {
data: SettingsResponse | undefined;
currentSettings: Settings | undefined;
newEnvKey: string;
newEnvValue: string;
onNewEnvKeyChange: (key: string) => void;
onNewEnvValueChange: (value: string) => void;
onEnvValueChange: (key: string, value: string) => void;
onAddEnvVar: () => void;
onEnvBulkChange?: (env: Record<string, string>) => void;
}
export function FriendlyUISection({
@@ -23,17 +39,89 @@ export function FriendlyUISection({
data,
currentSettings,
newEnvKey,
newEnvValue,
onNewEnvKeyChange,
onNewEnvValueChange,
onEnvValueChange,
onAddEnvVar,
onEnvBulkChange,
}: FriendlyUISectionProps) {
const isOpenRouter = isOpenRouterProfile(currentSettings);
const settingsEnv = currentSettings?.env;
// Derive tier mapping from env vars (no local state to sync)
const tierMapping = useMemo<TierMapping>(
() => extractTierMapping(settingsEnv ?? {}),
[settingsEnv]
);
// Memoize currentEnv for consistent reference
const currentEnv = settingsEnv ?? {};
// Handle model selection from OpenRouter picker - applies to ALL tiers
const handleModelChange = (modelId: string) => {
if (onEnvBulkChange) {
// Update all 4 model tiers at once
const newEnv = {
...currentEnv,
ANTHROPIC_MODEL: modelId,
ANTHROPIC_DEFAULT_OPUS_MODEL: modelId,
ANTHROPIC_DEFAULT_SONNET_MODEL: modelId,
ANTHROPIC_DEFAULT_HAIKU_MODEL: modelId,
};
onEnvBulkChange(newEnv);
} else {
// Fallback: update one by one
onEnvValueChange('ANTHROPIC_MODEL', modelId);
onEnvValueChange('ANTHROPIC_DEFAULT_OPUS_MODEL', modelId);
onEnvValueChange('ANTHROPIC_DEFAULT_SONNET_MODEL', modelId);
onEnvValueChange('ANTHROPIC_DEFAULT_HAIKU_MODEL', modelId);
}
// Show feedback toast
toast.success('Applied model to all tiers', { duration: 2000 });
};
// Handle tier mapping change
const handleTierMappingChange = (mapping: TierMapping) => {
// Apply tier mapping to env vars
if (onEnvBulkChange) {
const newEnv = applyTierMapping(currentEnv, mapping);
onEnvBulkChange(newEnv);
} else {
// Fallback: update one by one
if (mapping.opus !== undefined) {
onEnvValueChange('ANTHROPIC_DEFAULT_OPUS_MODEL', mapping.opus || '');
}
if (mapping.sonnet !== undefined) {
onEnvValueChange('ANTHROPIC_DEFAULT_SONNET_MODEL', mapping.sonnet || '');
}
if (mapping.haiku !== undefined) {
onEnvValueChange('ANTHROPIC_DEFAULT_HAIKU_MODEL', mapping.haiku || '');
}
}
};
// State for collapsible sections
const [showAllEnvVars, setShowAllEnvVars] = useState(false);
// For OpenRouter: only hide API key (has dedicated input above)
// Show all other env vars in "Additional Variables" section
const openRouterManagedKeys = new Set([
'ANTHROPIC_AUTH_TOKEN', // Managed by API Key section
]);
// Get non-managed env vars for display in "Additional Variables"
const unmanagedEnvVars = Object.entries(currentEnv).filter(
([key]) => !openRouterManagedKeys.has(key)
);
return (
<div className="h-full flex flex-col">
<Tabs defaultValue="env" className="h-full flex flex-col">
<div className="h-full w-full min-w-0 flex flex-col">
<Tabs defaultValue="env" className="h-full w-full min-w-0 flex flex-col">
<div className="px-4 pt-4 shrink-0">
<TabsList className="w-full">
<TabsTrigger value="env" className="flex-1">
Environment Variables
{isOpenRouter ? 'Configuration' : 'Environment Variables'}
</TabsTrigger>
<TabsTrigger value="info" className="flex-1">
Info & Usage
@@ -41,18 +129,134 @@ export function FriendlyUISection({
</TabsList>
</div>
<div className="flex-1 overflow-hidden flex flex-col">
<div className="flex-1 overflow-hidden flex flex-col min-w-0">
<TabsContent
value="env"
className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden"
className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden min-w-0"
>
<EnvEditorSection
currentSettings={currentSettings}
newEnvKey={newEnvKey}
onNewEnvKeyChange={onNewEnvKeyChange}
onEnvValueChange={onEnvValueChange}
onAddEnvVar={onAddEnvVar}
/>
{/* OpenRouter Streamlined View */}
{isOpenRouter ? (
<>
<div className="flex-1 overflow-hidden">
<div className="h-full overflow-y-auto overflow-x-hidden p-4 space-y-6">
{/* Model Selection - Primary Focus */}
<div className="space-y-3">
<Label className="text-sm font-medium">Model Selection</Label>
<OpenRouterModelPicker
value={currentEnv.ANTHROPIC_MODEL}
onChange={handleModelChange}
placeholder="Search OpenRouter models..."
/>
</div>
{/* Model Tier Mapping - Collapsible */}
<ModelTierMapping
selectedModel={currentEnv.ANTHROPIC_MODEL}
value={tierMapping}
onChange={handleTierMappingChange}
/>
{/* API Key */}
<div className="space-y-2">
<Label className="text-sm font-medium">API Key</Label>
<MaskedInput
value={currentEnv.ANTHROPIC_AUTH_TOKEN || ''}
onChange={(e) => onEnvValueChange('ANTHROPIC_AUTH_TOKEN', e.target.value)}
placeholder="sk-or-v1-..."
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Get your API key from{' '}
<a
href="https://openrouter.ai/keys"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
openrouter.ai/keys
</a>
</p>
</div>
{/* Additional Environment Variables (non-managed) */}
{unmanagedEnvVars.length > 0 && (
<Collapsible open={showAllEnvVars} onOpenChange={setShowAllEnvVars}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors group">
<ChevronRight
className={cn(
'h-4 w-4 transition-transform',
showAllEnvVars && 'rotate-90'
)}
/>
<Settings2 className="h-4 w-4" />
<span>Additional Variables</span>
<span className="text-xs font-normal opacity-70">
({unmanagedEnvVars.length})
</span>
</CollapsibleTrigger>
<CollapsibleContent className="pt-4">
<div className="space-y-3 border rounded-lg p-3 bg-muted/30">
{unmanagedEnvVars.map(([key, value]) => (
<div key={key} className="space-y-1">
<Label className="text-xs text-muted-foreground">{key}</Label>
<Input
value={value}
onChange={(e) => onEnvValueChange(key, e.target.value)}
className="font-mono text-xs h-8"
/>
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
)}
</div>
</div>
{/* Fixed Add Variable Input at Bottom */}
<div className="p-4 border-t bg-background shrink-0">
<Label className="text-xs font-medium text-muted-foreground">
Add Environment Variable
</Label>
<div className="flex gap-2 mt-2">
<Input
placeholder="VARIABLE_NAME"
value={newEnvKey}
onChange={(e) => onNewEnvKeyChange(e.target.value.toUpperCase())}
className="font-mono text-sm h-8 w-2/5"
onKeyDown={(e) => e.key === 'Enter' && newEnvKey.trim() && onAddEnvVar()}
/>
<Input
placeholder="value"
value={newEnvValue}
onChange={(e) => onNewEnvValueChange(e.target.value)}
className="font-mono text-sm h-8 flex-1"
onKeyDown={(e) => e.key === 'Enter' && newEnvKey.trim() && onAddEnvVar()}
/>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={onAddEnvVar}
disabled={!newEnvKey.trim()}
>
<Plus className="w-4 h-4" />
</Button>
</div>
</div>
</>
) : (
/* Standard Env Editor for non-OpenRouter profiles */
<EnvEditorSection
currentSettings={currentSettings}
newEnvKey={newEnvKey}
newEnvValue={newEnvValue}
onNewEnvKeyChange={onNewEnvKeyChange}
onNewEnvValueChange={onNewEnvValueChange}
onEnvValueChange={onEnvValueChange}
onAddEnvVar={onAddEnvVar}
/>
)}
</TabsContent>
<TabsContent
@@ -6,10 +6,14 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Save, Loader2, Trash2, RefreshCw } from 'lucide-react';
import { OpenRouterBadge } from '@/components/profiles/openrouter-badge';
import { isOpenRouterProfile } from './utils';
import type { Settings } from './types';
interface HeaderSectionProps {
profileName: string;
data: { path?: string; mtime: number } | undefined;
settings?: Settings;
isLoading: boolean;
isSaving: boolean;
hasChanges: boolean;
@@ -22,6 +26,7 @@ interface HeaderSectionProps {
export function HeaderSection({
profileName,
data,
settings,
isLoading,
isSaving,
hasChanges,
@@ -40,6 +45,7 @@ export function HeaderSection({
{data.path.replace(/^.*\//, '')}
</Badge>
)}
{isOpenRouterProfile(settings) && <OpenRouterBadge className="ml-1" />}
</div>
{data && (
<p className="text-xs text-muted-foreground mt-0.5">
+18 -4
View File
@@ -21,6 +21,7 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
const [conflictDialog, setConflictDialog] = useState(false);
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
const [newEnvKey, setNewEnvKey] = useState('');
const [newEnvValue, setNewEnvValue] = useState('');
const queryClient = useQueryClient();
// Fetch settings for selected profile
@@ -66,13 +67,22 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
setRawJsonEdits(JSON.stringify({ ...currentSettings, env: newEnv }, null, 2));
};
// Bulk update multiple env vars at once (avoids race conditions)
const updateEnvBulk = (env: Record<string, string>) => {
const newEnv = { ...(currentSettings?.env || {}), ...env };
setLocalEdits((prev) => ({ ...prev, ...env }));
setRawJsonEdits(JSON.stringify({ ...currentSettings, env: newEnv }, null, 2));
};
const addNewEnvVar = () => {
if (!newEnvKey.trim()) return;
const key = newEnvKey.trim();
const newEnv = { ...(currentSettings?.env || {}), [key]: '' };
setLocalEdits((prev) => ({ ...prev, [key]: '' }));
const value = newEnvValue;
const newEnv = { ...(currentSettings?.env || {}), [key]: value };
setLocalEdits((prev) => ({ ...prev, [key]: value }));
setRawJsonEdits(JSON.stringify({ ...currentSettings, env: newEnv }, null, 2));
setNewEnvKey('');
setNewEnvValue('');
};
// Computed validity and changes check
@@ -139,6 +149,7 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
<HeaderSection
profileName={profileName}
data={data}
settings={currentSettings}
isLoading={isLoading}
isSaving={saveMutation.isPending}
hasChanges={computedHasChanges}
@@ -165,14 +176,17 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
</div>
) : (
<div className="flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden">
<div className="flex flex-col overflow-hidden bg-muted/5">
<div className="flex flex-col overflow-hidden bg-muted/5 min-w-0">
<FriendlyUISection
profileName={profileName}
data={data}
currentSettings={currentSettings}
newEnvKey={newEnvKey}
newEnvValue={newEnvValue}
onNewEnvKeyChange={setNewEnvKey}
onNewEnvValueChange={setNewEnvValue}
onEnvValueChange={updateEnvValue}
onEnvBulkChange={updateEnvBulk}
onAddEnvVar={addNewEnvVar}
/>
</div>
@@ -214,5 +228,5 @@ export { RawEditorSection } from './raw-editor-section';
export { HeaderSection } from './header-section';
export { FriendlyUISection } from './friendly-ui-section';
export { useProfileEditor } from './use-profile-editor';
export { isSensitiveKey } from './utils';
export { isSensitiveKey, isOpenRouterProfile, extractTierMapping, applyTierMapping } from './utils';
export type { Settings, SettingsResponse, ProfileEditorProps } from './types';
@@ -2,6 +2,8 @@
* Utility functions for Profile Editor
*/
import type { Settings } from './types';
/** Check if a key is considered sensitive (API keys, tokens, etc.) */
export function isSensitiveKey(key: string): boolean {
const sensitivePatterns = [
@@ -15,3 +17,58 @@ export function isSensitiveKey(key: string): boolean {
];
return sensitivePatterns.some((pattern) => pattern.test(key));
}
/**
* Check if settings indicate an OpenRouter profile
*/
export function isOpenRouterProfile(settings: Settings | undefined): boolean {
if (!settings?.env) return false;
const baseUrl = settings.env.ANTHROPIC_BASE_URL || '';
return baseUrl.toLowerCase().includes('openrouter.ai');
}
/**
* Extract tier mapping from settings env vars
*/
export function extractTierMapping(env: Record<string, string>): {
opus?: string;
sonnet?: string;
haiku?: string;
} {
return {
opus: env.ANTHROPIC_DEFAULT_OPUS_MODEL || undefined,
sonnet: env.ANTHROPIC_DEFAULT_SONNET_MODEL || undefined,
haiku: env.ANTHROPIC_DEFAULT_HAIKU_MODEL || undefined,
};
}
/**
* Merge tier mapping into env vars
*/
export function applyTierMapping(
env: Record<string, string>,
mapping: { opus?: string; sonnet?: string; haiku?: string }
): Record<string, string> {
const result = { ...env };
// Set or remove tier overrides
if (mapping.opus) {
result.ANTHROPIC_DEFAULT_OPUS_MODEL = mapping.opus;
} else {
delete result.ANTHROPIC_DEFAULT_OPUS_MODEL;
}
if (mapping.sonnet) {
result.ANTHROPIC_DEFAULT_SONNET_MODEL = mapping.sonnet;
} else {
delete result.ANTHROPIC_DEFAULT_SONNET_MODEL;
}
if (mapping.haiku) {
result.ANTHROPIC_DEFAULT_HAIKU_MODEL = mapping.haiku;
} else {
delete result.ANTHROPIC_DEFAULT_HAIKU_MODEL;
}
return result;
}
+9
View File
@@ -12,3 +12,12 @@ export { ProfilesTable } from './profiles-table';
// Profile editor (from subdirectory)
export { ProfileEditor } from './editor';
export type { Settings, SettingsResponse, ProfileEditorProps } from './editor';
// OpenRouter components
export { OpenRouterBadge } from './openrouter-badge';
export { OpenRouterBanner } from './openrouter-banner';
export { OpenRouterModelPicker } from './openrouter-model-picker';
export { OpenRouterPromoCard } from './openrouter-promo-card';
export { OpenRouterQuickStart } from './openrouter-quick-start';
export { ModelTierMapping } from './model-tier-mapping';
export type { TierMapping } from './model-tier-mapping';
@@ -0,0 +1,114 @@
/**
* Model Tier Mapping Editor
* Configure opus/sonnet/haiku model overrides
*/
import { useMemo } from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Wand2, ChevronRight } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
import { suggestTierMappings } from '@/lib/openrouter-utils';
import { cn } from '@/lib/utils';
export interface TierMapping {
opus?: string;
sonnet?: string;
haiku?: string;
}
interface ModelTierMappingProps {
selectedModel?: string;
value: TierMapping;
onChange: (mapping: TierMapping) => void;
className?: string;
}
export function ModelTierMapping({
selectedModel,
value,
onChange,
className,
}: ModelTierMappingProps) {
const { models } = useOpenRouterCatalog();
const suggestions = useMemo(() => {
if (!selectedModel) return {};
return suggestTierMappings(selectedModel, models);
}, [selectedModel, models]);
const handleAutoSuggest = () => {
onChange(suggestions);
};
const updateTier = (tier: keyof TierMapping, modelId: string) => {
onChange({ ...value, [tier]: modelId || undefined });
};
const hasSuggestions = selectedModel && Object.keys(suggestions).length > 0;
return (
<Collapsible className={cn('group', className)}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium hover:underline">
<ChevronRight className="h-4 w-4 transition-transform group-data-[state=open]:rotate-90" />
Model Tier Mapping
<span className="text-muted-foreground font-normal">(Advanced)</span>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-3">
<p className="text-muted-foreground text-sm">
Configure different models for Claude Code&apos;s opus/sonnet/haiku tiers.
</p>
{hasSuggestions && (
<Button type="button" variant="outline" size="sm" onClick={handleAutoSuggest}>
<Wand2 className="mr-1 h-4 w-4" />
Auto-suggest based on {selectedModel?.split('/')[0]}
</Button>
)}
<div className="grid gap-3">
<div className="grid grid-cols-[80px_1fr] items-center gap-2">
<Label htmlFor="tier-opus" className="text-right">
Opus
</Label>
<Input
id="tier-opus"
value={value.opus ?? ''}
onChange={(e) => updateTier('opus', e.target.value)}
placeholder="e.g., anthropic/claude-opus-4"
/>
</div>
<div className="grid grid-cols-[80px_1fr] items-center gap-2">
<Label htmlFor="tier-sonnet" className="text-right">
Sonnet
</Label>
<Input
id="tier-sonnet"
value={value.sonnet ?? ''}
onChange={(e) => updateTier('sonnet', e.target.value)}
placeholder="e.g., anthropic/claude-sonnet-4"
/>
</div>
<div className="grid grid-cols-[80px_1fr] items-center gap-2">
<Label htmlFor="tier-haiku" className="text-right">
Haiku
</Label>
<Input
id="tier-haiku"
value={value.haiku ?? ''}
onChange={(e) => updateTier('haiku', e.target.value)}
placeholder="e.g., anthropic/claude-3.5-haiku"
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
These set ANTHROPIC_DEFAULT_OPUS_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL,
ANTHROPIC_DEFAULT_HAIKU_MODEL.
</p>
</CollapsibleContent>
</Collapsible>
);
}
@@ -0,0 +1,40 @@
/**
* OpenRouter Badge Component
* Visual indicator for OpenRouter-configured profiles
*/
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
interface OpenRouterBadgeProps {
className?: string;
showTooltip?: boolean;
}
export function OpenRouterBadge({ className, showTooltip = true }: OpenRouterBadgeProps) {
const badge = (
<Badge
variant="outline"
className={cn(
'bg-accent/10 border-accent/30 text-accent',
'dark:bg-accent/20 dark:border-accent/40 dark:text-accent-foreground',
className
)}
>
<img src="/icons/openrouter.svg" alt="OpenRouter" className="mr-1 h-3 w-3" />
OpenRouter
</Badge>
);
if (!showTooltip) return badge;
return (
<Tooltip>
<TooltipTrigger asChild>{badge}</TooltipTrigger>
<TooltipContent>
<p>Access 349+ models via OpenRouter</p>
</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,84 @@
/**
* OpenRouter Feature Banner
* Dismissible announcement banner for OpenRouter integration
*/
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useEffect } from 'react';
import { X, Sparkles, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useOpenRouterReady } from '@/hooks/use-openrouter-models';
const BANNER_DISMISSED_KEY = 'ccs:openrouter-banner-dismissed';
interface OpenRouterBannerProps {
onCreateClick?: () => void;
}
export function OpenRouterBanner({ onCreateClick }: OpenRouterBannerProps) {
const [dismissed, setDismissed] = useState(true); // Start hidden to avoid flash
const { modelCount, isLoading } = useOpenRouterReady();
// Check localStorage on mount
useEffect(() => {
const isDismissed = localStorage.getItem(BANNER_DISMISSED_KEY) === 'true';
setDismissed(isDismissed);
}, []);
const handleDismiss = () => {
localStorage.setItem(BANNER_DISMISSED_KEY, 'true');
setDismissed(true);
};
if (dismissed) return null;
return (
<div className="bg-gradient-to-r from-accent to-accent/90 text-white px-4 py-3 relative shrink-0">
<div className="flex items-center justify-between gap-4 max-w-screen-xl mx-auto">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="p-1.5 bg-white/20 rounded-md shrink-0">
<Sparkles className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm">NEW: OpenRouter Integration</p>
<p className="text-xs text-white/80 truncate">
Browse {isLoading ? '300+' : `${modelCount}+`} models from OpenAI, Anthropic, Google,
Meta and more.
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{onCreateClick && (
<Button
size="sm"
variant="secondary"
onClick={onCreateClick}
className="bg-white text-accent hover:bg-white/90 h-8"
>
Try it now
</Button>
)}
<a
href="https://openrouter.ai"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-white/80 hover:text-white hidden sm:flex items-center gap-1"
>
Learn more
<ExternalLink className="w-3 h-3" />
</a>
<Button
size="icon"
variant="ghost"
onClick={handleDismiss}
className="h-7 w-7 text-white/70 hover:text-white hover:bg-white/20"
>
<X className="w-4 h-4" />
<span className="sr-only">Dismiss</span>
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,306 @@
/**
* OpenRouter Model Picker Component
* Searchable model selector with categories and pricing
*/
import { useState, useMemo, useCallback } from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
import { Search, RefreshCw, Loader2, Sparkles } from 'lucide-react';
import { useOpenRouterCatalog, useRefreshOpenRouterModels } from '@/hooks/use-openrouter-models';
import {
searchModels,
sortModelsByPriority,
formatPricingPair,
formatContextLength,
formatModelAge,
getNewestModelsPerProvider,
CATEGORY_LABELS,
} from '@/lib/openrouter-utils';
import type { CategorizedModel, ModelCategory } from '@/lib/openrouter-types';
import { cn } from '@/lib/utils';
interface OpenRouterModelPickerProps {
value?: string;
onChange: (modelId: string) => void;
placeholder?: string;
className?: string;
}
export function OpenRouterModelPicker({
value,
onChange,
placeholder = 'Search models...',
className,
}: OpenRouterModelPickerProps) {
const [search, setSearch] = useState('');
const [selectedCategory, setSelectedCategory] = useState<ModelCategory | null>(null);
const { models, isLoading, isError, isFetching } = useOpenRouterCatalog();
const refreshModels = useRefreshOpenRouterModels();
// Filter and group models
const filteredModels = useMemo(() => {
return searchModels(models, search, {
category: selectedCategory ?? undefined,
});
}, [models, search, selectedCategory]);
// Get newest models for presets (shown when no search)
const newestModels = useMemo(() => {
return getNewestModelsPerProvider(models, 2);
}, [models]);
// Determine if we should show presets (no search query and no category filter)
const showPresets = !search.trim() && !selectedCategory;
// Group by category and sort each group by priority (Free > Exacto > Regular)
const groupedModels = useMemo(() => {
const groups: Record<ModelCategory, CategorizedModel[]> = {
anthropic: [],
openai: [],
google: [],
meta: [],
mistral: [],
opensource: [],
other: [],
};
filteredModels.forEach((model) => {
groups[model.category].push(model);
});
// Sort each category by priority
for (const category of Object.keys(groups) as ModelCategory[]) {
groups[category] = sortModelsByPriority(groups[category]);
}
return groups;
}, [filteredModels]);
const handleRefresh = useCallback(() => {
refreshModels();
}, [refreshModels]);
const selectedModel = models.find((m) => m.id === value);
if (isLoading && models.length === 0) {
return (
<div className={cn('space-y-2', className)}>
<Skeleton className="h-10 w-full" />
<Skeleton className="h-32 w-full" />
</div>
);
}
return (
<div className={cn('space-y-2 w-full min-w-0 overflow-hidden', className)}>
{/* Search Header */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={placeholder}
className="pl-9"
/>
</div>
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
disabled={isFetching}
title="Refresh models"
>
{isFetching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
</div>
{/* Category Filters */}
<div className="flex flex-wrap gap-1">
<Badge
variant={selectedCategory === null ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() => setSelectedCategory(null)}
>
All ({models.length})
</Badge>
{(Object.keys(CATEGORY_LABELS) as ModelCategory[]).map((cat) => {
const count = groupedModels[cat].length;
if (count === 0) return null;
return (
<Badge
key={cat}
variant={selectedCategory === cat ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() => setSelectedCategory(cat)}
>
{CATEGORY_LABELS[cat]} ({count})
</Badge>
);
})}
</div>
{/* Selected Model Display */}
{selectedModel && (
<div className="bg-muted rounded-md p-2 text-sm">
<span className="font-medium">{selectedModel.name}</span>
<span className="text-muted-foreground ml-2">
{formatPricingPair(selectedModel.pricing)} |{' '}
{formatContextLength(selectedModel.context_length)}
</span>
</div>
)}
{/* Model List */}
<ScrollArea className="h-72 w-full rounded-md border">
{isError ? (
<div className="text-destructive p-4 text-center">
Failed to load models.{' '}
<Button variant="link" onClick={handleRefresh}>
Retry
</Button>
</div>
) : filteredModels.length === 0 ? (
<div className="text-muted-foreground p-4 text-center">
No models found matching &quot;{search}&quot;
</div>
) : (
<div className="space-y-6 p-3">
{/* Newest Models Section (shown when no search) */}
{showPresets && newestModels.length > 0 && (
<div>
<div className="text-muted-foreground bg-background sticky top-0 mb-2 flex items-center gap-1.5 py-1.5 text-xs font-semibold border-b pb-2">
<Sparkles className="h-3 w-3 text-accent" />
<span>Newest Models</span>
</div>
<div className="space-y-1">
{newestModels.map((model) => (
<ModelItem
key={model.id}
model={model}
isSelected={model.id === value}
onClick={() => onChange(model.id)}
showAge
/>
))}
</div>
</div>
)}
{/* Category Groups */}
{(Object.keys(CATEGORY_LABELS) as ModelCategory[]).map((category) => {
const categoryModels = groupedModels[category];
if (categoryModels.length === 0) return null;
return (
<div key={category}>
<div className="text-muted-foreground bg-background sticky top-0 mb-2 py-1.5 text-xs font-semibold border-b pb-2">
{CATEGORY_LABELS[category]}
</div>
<div className="space-y-1">
{categoryModels.map((model) => (
<ModelItem
key={model.id}
model={model}
isSelected={model.id === value}
onClick={() => onChange(model.id)}
/>
))}
</div>
</div>
);
})}
</div>
)}
</ScrollArea>
</div>
);
}
function ModelItem({
model,
isSelected,
onClick,
showAge = false,
}: {
model: CategorizedModel;
isSelected: boolean;
onClick: () => void;
showAge?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'group flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors',
'hover:bg-accent hover:text-accent-foreground',
isSelected && 'bg-accent text-accent-foreground'
)}
>
<span className="flex-1 min-w-0 truncate font-medium">{model.name}</span>
<span
className={cn(
'flex shrink-0 items-center gap-1 text-xs whitespace-nowrap',
isSelected
? 'text-accent-foreground/80'
: 'text-muted-foreground group-hover:text-accent-foreground/80'
)}
>
{showAge && model.created && (
<Badge
variant="outline"
className={cn(
'text-[10px] px-1',
isSelected
? 'border-accent-foreground/30 text-accent-foreground/80'
: 'text-accent border-accent/30 group-hover:text-accent-foreground/80 group-hover:border-accent-foreground/30'
)}
>
{formatModelAge(model.created)}
</Badge>
)}
{model.isFree ? (
<Badge
variant="secondary"
className={cn(
'text-[10px] px-1',
isSelected
? 'bg-accent-foreground/20 text-accent-foreground'
: 'group-hover:bg-accent-foreground/20 group-hover:text-accent-foreground'
)}
>
Free
</Badge>
) : model.isExacto ? (
<>
<Badge
variant="outline"
className={cn(
'text-[10px] px-1 border-emerald-500/50 text-emerald-600',
isSelected
? 'border-accent-foreground/30 text-accent-foreground/80'
: 'group-hover:border-accent-foreground/30 group-hover:text-accent-foreground/80'
)}
>
Exacto
</Badge>
<span className="tabular-nums">{formatPricingPair(model.pricing)}</span>
</>
) : (
<span className="tabular-nums">{formatPricingPair(model.pricing)}</span>
)}
<span className="tabular-nums">{formatContextLength(model.context_length)}</span>
</span>
</button>
);
}
@@ -0,0 +1,41 @@
/**
* OpenRouter Promo Card
* Permanent promotional card for OpenRouter - always visible in sidebar footer
*/
import { Button } from '@/components/ui/button';
import { useOpenRouterReady } from '@/hooks/use-openrouter-models';
import { Zap } from 'lucide-react';
interface OpenRouterPromoCardProps {
onCreateClick: () => void;
}
export function OpenRouterPromoCard({ onCreateClick }: OpenRouterPromoCardProps) {
const { modelCount, isLoading } = useOpenRouterReady();
return (
<div className="p-3 border-t bg-gradient-to-r from-accent/5 to-accent/10 dark:from-accent/10 dark:to-accent/15">
<div className="flex items-center gap-2">
<div className="p-1.5 bg-accent/10 dark:bg-accent/20 rounded shrink-0">
<img src="/icons/openrouter.svg" alt="" className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-accent dark:text-accent-foreground">OpenRouter</p>
<p className="text-[10px] text-muted-foreground truncate">
{isLoading ? '300+' : `${modelCount}+`} models available
</p>
</div>
<Button
size="sm"
variant="ghost"
onClick={onCreateClick}
className="h-7 px-2 text-accent hover:text-accent hover:bg-accent/10 dark:hover:bg-accent/20"
>
<Zap className="w-3 h-3 mr-1" />
<span className="text-xs">Add</span>
</Button>
</div>
</div>
);
}
@@ -0,0 +1,98 @@
/**
* OpenRouter Quick Start Card
* Prominent CTA for new users to create OpenRouter profile
*/
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { useOpenRouterReady } from '@/hooks/use-openrouter-models';
import { Sparkles, ExternalLink, ArrowRight, Zap } from 'lucide-react';
interface OpenRouterQuickStartProps {
onOpenRouterClick: () => void;
onCustomClick: () => void;
}
export function OpenRouterQuickStart({
onOpenRouterClick,
onCustomClick,
}: OpenRouterQuickStartProps) {
const { modelCount, isLoading } = useOpenRouterReady();
return (
<div className="flex-1 flex items-center justify-center bg-muted/20 p-8">
<div className="max-w-lg w-full space-y-6">
{/* Main OpenRouter Card */}
<Card className="border-accent/30 dark:border-accent/40 bg-gradient-to-br from-accent/5 to-background dark:from-accent/10">
<CardHeader className="pb-3">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-accent/10 dark:bg-accent/20">
<img src="/icons/openrouter.svg" alt="OpenRouter" className="w-6 h-6" />
</div>
<Badge
variant="secondary"
className="bg-accent/10 text-accent dark:bg-accent/20 dark:text-accent-foreground"
>
Recommended
</Badge>
</div>
<CardTitle className="text-xl">Start with OpenRouter</CardTitle>
<CardDescription className="text-base">
Access {isLoading ? '300+' : `${modelCount}+`} models from OpenAI, Anthropic, Google,
Meta and more - all through one API.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Key Features */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Zap className="w-4 h-4 text-accent" />
<span>One API, all providers</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Sparkles className="w-4 h-4 text-accent" />
<span>Model tier mapping</span>
</div>
</div>
<Button
onClick={onOpenRouterClick}
className="w-full bg-accent hover:bg-accent/90 text-white"
size="lg"
>
Create OpenRouter Profile
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
<p className="text-xs text-center text-muted-foreground">
Get your API key at{' '}
<a
href="https://openrouter.ai/keys"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline inline-flex items-center gap-1"
>
openrouter.ai/keys
<ExternalLink className="w-3 h-3" />
</a>
</p>
</CardContent>
</Card>
{/* Divider */}
<div className="flex items-center gap-4">
<Separator className="flex-1" />
<span className="text-xs text-muted-foreground">or</span>
<Separator className="flex-1" />
</div>
{/* Custom Option */}
<Button variant="outline" onClick={onCustomClick} className="w-full">
Create Custom API Profile
</Button>
</div>
</div>
);
}
+16 -1
View File
@@ -1,7 +1,10 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { SettingsIcon, PlayIcon } from 'lucide-react';
import { isOpenRouterProfile } from './editor/utils';
import type { Settings } from './editor/types';
interface ProfileCardProps {
profile: {
@@ -12,18 +15,30 @@ interface ProfileCardProps {
lastUsed?: string;
model?: string;
};
/** Optional settings for OpenRouter detection */
settings?: Settings;
onSwitch?: () => void;
onConfig?: () => void;
onTest?: () => void;
}
export function ProfileCard({ profile, onSwitch, onConfig, onTest }: ProfileCardProps) {
export function ProfileCard({ profile, settings, onSwitch, onConfig, onTest }: ProfileCardProps) {
const showOpenRouterIcon = isOpenRouterProfile(settings);
return (
<Card className={profile.isActive ? 'border-primary' : ''}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{profile.name}</h3>
{showOpenRouterIcon && (
<Tooltip>
<TooltipTrigger asChild>
<img src="/icons/openrouter.svg" alt="OpenRouter" className="w-4 h-4" />
</TooltipTrigger>
<TooltipContent>OpenRouter profile</TooltipContent>
</Tooltip>
)}
{profile.isActive && (
<Badge variant="default" className="text-xs">
Active
@@ -1,17 +1,17 @@
/**
* Profile Create Dialog Component
* Modal dialog with tabbed interface for creating new API profiles
* Includes Quick Start templates and advanced model configuration
* Modal dialog with provider preset cards and model configuration
*/
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Dialog,
DialogContent,
@@ -23,11 +23,23 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { useCreateProfile } from '@/hooks/use-profiles';
import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff } from 'lucide-react';
import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff, Settings2, Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929';
import {
PROVIDER_PRESETS,
getPresetsByCategory,
type ProviderPreset,
} from '@/lib/provider-presets';
import {
searchModels,
formatPricingPair,
formatContextLength,
formatModelAge,
getNewestModelsPerProvider,
} from '@/lib/openrouter-utils';
import type { CategorizedModel } from '@/lib/openrouter-types';
const schema = z.object({
name: z
@@ -48,6 +60,7 @@ interface ProfileCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: (name: string) => void;
initialMode?: 'normal' | 'openrouter';
}
// Common URL mistakes to warn about
@@ -58,6 +71,11 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
const [activeTab, setActiveTab] = useState('basic');
const [urlWarning, setUrlWarning] = useState<string | null>(null);
const [showApiKey, setShowApiKey] = useState(false);
const [selectedPreset, setSelectedPreset] = useState<string | null>('openrouter');
const [modelSearch, setModelSearch] = useState('');
// OpenRouter models for model picker
const { models: openRouterModels } = useOpenRouterCatalog();
const {
register,
@@ -65,6 +83,7 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
formState: { errors },
control,
reset,
setValue,
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
@@ -80,21 +99,83 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
const baseUrlValue = useWatch({ control, name: 'baseUrl' });
// Reset form when dialog opens
// Get current preset config
const currentPreset = useMemo(() => {
if (!selectedPreset || selectedPreset === 'custom') return null;
return PROVIDER_PRESETS.find((p) => p.id === selectedPreset);
}, [selectedPreset]);
// Filter models for OpenRouter search (newest first)
const filteredModels = useMemo(() => {
if (!modelSearch.trim()) {
// Show newest models when no search
return getNewestModelsPerProvider(openRouterModels, 2);
}
// Search and sort by created date (newest first)
const results = searchModels(openRouterModels, modelSearch);
return [...results].sort((a, b) => (b.created ?? 0) - (a.created ?? 0)).slice(0, 20);
}, [openRouterModels, modelSearch]);
// Reset form when dialog opens
useEffect(() => {
if (open) {
reset();
setActiveTab('basic');
setUrlWarning(null);
setShowApiKey(false);
setSelectedPreset('openrouter');
setModelSearch('');
// Pre-fill with OpenRouter preset
const openrouterPreset = PROVIDER_PRESETS.find((p) => p.id === 'openrouter');
if (openrouterPreset) {
setTimeout(() => {
setValue('name', openrouterPreset.defaultProfileName);
setValue('baseUrl', openrouterPreset.baseUrl);
}, 0);
}
}
}, [open, reset]);
}, [open, reset, setValue]);
// Check for common URL mistakes
// Handle preset selection
const handlePresetSelect = (presetId: string) => {
setSelectedPreset(presetId);
const preset = PROVIDER_PRESETS.find((p) => p.id === presetId);
if (preset) {
setValue('name', preset.defaultProfileName);
setValue('baseUrl', preset.baseUrl);
if (preset.defaultModel) {
setValue('model', preset.defaultModel);
setValue('opusModel', preset.defaultModel);
setValue('sonnetModel', preset.defaultModel);
setValue('haikuModel', preset.defaultModel);
}
} else {
// Custom
setValue('name', '');
setValue('baseUrl', '');
setValue('model', '');
}
};
// Handle model selection from picker - applies to all 4 model tiers
const handleModelSelect = (model: CategorizedModel) => {
setValue('model', model.id);
setValue('opusModel', model.id);
setValue('sonnetModel', model.id);
setValue('haikuModel', model.id);
setModelSearch(model.name);
// Show feedback that model was applied to all tiers
toast.success(`Applied "${model.name}" to all model tiers`, {
duration: 2000,
});
};
// Check for common URL mistakes - only for truly custom URLs
// Presets (OpenRouter, GLM, GLMT, Kimi) have vetted URLs that may require full paths
useEffect(() => {
if (baseUrlValue) {
// Only warn for custom URLs, not preset-selected ones
const isCustomUrl = selectedPreset === 'custom';
if (baseUrlValue && isCustomUrl) {
const lowerUrl = baseUrlValue.toLowerCase();
for (const path of PROBLEMATIC_PATHS) {
if (lowerUrl.endsWith(path)) {
@@ -107,13 +188,17 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
}
}
setUrlWarning(null);
}, [baseUrlValue]);
}, [baseUrlValue, selectedPreset]);
const onSubmit = async (data: FormData) => {
// Use user-provided baseUrl (allows customization of preset URLs)
const finalData = {
...data,
};
try {
await createMutation.mutateAsync(data);
toast.success(`Profile "${data.name}" created`);
onSuccess(data.name);
await createMutation.mutateAsync(finalData);
toast.success(`Profile "${finalData.name}" created`);
onSuccess(finalData.name);
onOpenChange(false);
} catch (error) {
toast.error((error as Error).message || 'Failed to create profile');
@@ -124,19 +209,80 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
const hasModelErrors =
!!errors.model || !!errors.opusModel || !!errors.sonnetModel || !!errors.haikuModel;
const isOpenRouter = selectedPreset === 'openrouter';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] p-0 gap-0 overflow-hidden">
<DialogContent className="sm:max-w-[700px] p-0 gap-0 overflow-hidden max-h-[90vh]">
<DialogHeader className="p-6 pb-4 border-b">
<DialogTitle className="flex items-center gap-2">
<Plus className="w-5 h-5 text-primary" />
Create API Profile
</DialogTitle>
<DialogDescription>Configure a custom API endpoint for Claude Code.</DialogDescription>
<DialogDescription>
Choose a provider or configure a custom API endpoint.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col overflow-hidden">
{/* Provider Preset Cards - Compact horizontal layout */}
<div className="px-6 py-3 border-b bg-muted/30 space-y-2">
{/* Main Options: OpenRouter + Custom */}
<div>
<Label className="text-xs text-muted-foreground mb-1.5 block">Provider</Label>
<div className="flex gap-2">
{getPresetsByCategory('recommended').map((preset) => (
<CompactPresetCard
key={preset.id}
preset={preset}
isSelected={selectedPreset === preset.id}
onClick={() => handlePresetSelect(preset.id)}
/>
))}
{/* Custom option */}
<button
type="button"
onClick={() => handlePresetSelect('custom')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md border transition-all text-sm',
selectedPreset === 'custom' ||
getPresetsByCategory('alternative').some((p) => p.id === selectedPreset)
? 'border-primary bg-primary/5 font-medium'
: 'border-muted hover:border-muted-foreground/30'
)}
>
<Settings2 className="w-3.5 h-3.5 text-muted-foreground" />
<span>Custom</span>
</button>
</div>
</div>
{/* Show alternative presets when Custom is selected or an alternative is selected */}
{(selectedPreset === 'custom' ||
getPresetsByCategory('alternative').some((p) => p.id === selectedPreset)) && (
<div className="pt-2 border-t border-dashed">
<Label className="text-xs text-muted-foreground mb-1.5 block">
Quick Templates
</Label>
<div className="flex gap-2 flex-wrap">
{getPresetsByCategory('alternative').map((preset) => (
<CompactPresetCard
key={preset.id}
preset={preset}
isSelected={selectedPreset === preset.id}
onClick={() => handlePresetSelect(preset.id)}
/>
))}
</div>
</div>
)}
</div>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-col flex-1 overflow-hidden"
>
<div className="px-6 pt-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="basic" className="relative">
@@ -154,101 +300,149 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
</TabsList>
</div>
<div className="flex-1 overflow-y-auto max-h-[60vh]">
<TabsContent value="basic" className="p-6 space-y-6 mt-0">
<div className="space-y-4">
{/* Name */}
<div className="space-y-1.5">
<Label htmlFor="name">
Profile Name <span className="text-destructive">*</span>
</Label>
<Input
id="name"
{...register('name')}
placeholder="my-api"
className="font-mono"
/>
{errors.name ? (
<p className="text-xs text-destructive">{errors.name.message}</p>
) : (
<p className="text-xs text-muted-foreground">
Used in CLI:{' '}
<code className="bg-muted px-1 rounded text-[10px]">
ccs my-api "prompt"
</code>
</p>
)}
</div>
<ScrollArea className="flex-1">
<TabsContent value="basic" className="p-6 space-y-4 mt-0">
{/* Profile Name */}
<div className="space-y-1.5">
<Label htmlFor="name">
Profile Name <span className="text-destructive">*</span>
</Label>
<Input
id="name"
{...register('name')}
placeholder="my-api"
className="font-mono"
/>
{errors.name ? (
<p className="text-xs text-destructive">{errors.name.message}</p>
) : (
<p className="text-xs text-muted-foreground">
Used in CLI:{' '}
<code className="bg-muted px-1 rounded text-[10px]">ccs my-api "prompt"</code>
</p>
)}
</div>
{/* Base URL */}
<div className="space-y-1.5">
<Label htmlFor="baseUrl">
API Base URL <span className="text-destructive">*</span>
</Label>
<Input
id="baseUrl"
{...register('baseUrl')}
placeholder="https://api.example.com/v1"
/>
{errors.baseUrl ? (
<p className="text-xs text-destructive">{errors.baseUrl.message}</p>
) : urlWarning ? (
<div className="flex items-start gap-2 text-xs text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 p-2 rounded">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<span>{urlWarning}</span>
</div>
) : (
<p className="text-xs text-muted-foreground">
The endpoint that accepts OpenAI-compatible and Anthropic requests
</p>
)}
</div>
{/* API Key */}
<div className="space-y-1.5">
<Label htmlFor="apiKey">
API Key <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="apiKey"
type={showApiKey ? 'text' : 'password'}
{...register('apiKey')}
placeholder="sk-..."
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
onClick={() => setShowApiKey(!showApiKey)}
tabIndex={-1}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
<span className="sr-only">Toggle API key visibility</span>
</Button>
{/* Base URL - always editable, pre-filled from preset */}
<div className="space-y-1.5">
<Label htmlFor="baseUrl">
API Base URL <span className="text-destructive">*</span>
</Label>
<Input
id="baseUrl"
{...register('baseUrl')}
placeholder="https://api.example.com/v1"
/>
{errors.baseUrl ? (
<p className="text-xs text-destructive">{errors.baseUrl.message}</p>
) : urlWarning ? (
<div className="flex items-start gap-2 text-xs text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 p-2 rounded">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<span>{urlWarning}</span>
</div>
{errors.apiKey && (
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
)}
) : currentPreset ? (
<p className="text-xs text-muted-foreground">
Pre-filled from {currentPreset.name}. You can customize if needed.
</p>
) : (
<p className="text-xs text-muted-foreground">
The endpoint that accepts OpenAI-compatible and Anthropic requests
</p>
)}
</div>
{/* API Key */}
<div className="space-y-1.5">
<Label htmlFor="apiKey">
API Key <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="apiKey"
type={showApiKey ? 'text' : 'password'}
{...register('apiKey')}
placeholder={currentPreset?.apiKeyPlaceholder ?? 'sk-...'}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
onClick={() => setShowApiKey(!showApiKey)}
tabIndex={-1}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
{errors.apiKey ? (
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
) : (
currentPreset?.apiKeyHint && (
<p className="text-xs text-muted-foreground">{currentPreset.apiKeyHint}</p>
)
)}
</div>
</TabsContent>
<TabsContent value="models" className="p-6 mt-0 space-y-6">
<div className="flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-950/20 text-blue-800 dark:text-blue-300 rounded-md text-sm border border-blue-100 dark:border-blue-900/30">
<TabsContent value="models" className="p-6 mt-0 space-y-4">
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-950/20 text-blue-800 dark:text-blue-300 rounded-md text-sm border border-blue-100 dark:border-blue-900/30">
<Info className="w-5 h-5 shrink-0 mt-0.5" />
<div>
<p className="font-medium mb-1">Model Mapping</p>
<p className="text-xs opacity-90">
Claude Code requests specific model tiers (Opus/Sonnet/Haiku). Map these tiers
to the specific models supported by your API provider.
Map Claude Code tiers (Opus/Sonnet/Haiku) to models supported by your
provider.
</p>
</div>
</div>
<div className="space-y-5">
{/* OpenRouter Model Picker */}
{isOpenRouter && (
<div className="space-y-2">
<Label>Search Models</Label>
<Input
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
placeholder="Type to search (e.g., opus, sonnet, gpt-4o)..."
onKeyDown={(e) => {
if (e.key === 'Enter' && filteredModels.length > 0) {
e.preventDefault();
handleModelSelect(filteredModels[0]);
}
}}
/>
<div className="border rounded-md max-h-48 overflow-y-auto">
{filteredModels.length === 0 ? (
<p className="text-sm text-muted-foreground p-3 text-center">
{modelSearch
? `No models found for "${modelSearch}"`
: 'Loading models...'}
</p>
) : (
<div className="p-1">
{!modelSearch && (
<div className="flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground">
<Sparkles className="w-3 h-3 text-accent" />
<span>Newest Models</span>
</div>
)}
{filteredModels.map((model) => (
<ModelSearchItem
key={model.id}
model={model}
onClick={() => handleModelSelect(model)}
showAge={!modelSearch}
/>
))}
</div>
)}
</div>
</div>
)}
{/* Model Inputs */}
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="model">
Default Model
@@ -259,18 +453,15 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
<Input
id="model"
{...register('model')}
placeholder={DEFAULT_MODEL}
placeholder={currentPreset?.defaultModel ?? 'claude-sonnet-4'}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Fallback model if no specific tier is requested
</p>
</div>
<div className="grid gap-4 pt-2 border-t">
<div className="grid gap-3 pt-2 border-t">
<div className="space-y-1.5">
<Label htmlFor="sonnetModel" className="text-sm">
Sonnet Mapping (Primary)
Sonnet Mapping
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
DEFAULT_SONNET
</Badge>
@@ -278,14 +469,14 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
<Input
id="sonnetModel"
{...register('sonnetModel')}
placeholder="e.g. gpt-4o, claude-3-5-sonnet"
placeholder="e.g. gpt-4o, claude-sonnet-4"
className="font-mono text-sm h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="opusModel" className="text-sm">
Opus Mapping (Complex Tasks)
Opus Mapping
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
DEFAULT_OPUS
</Badge>
@@ -293,14 +484,14 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
<Input
id="opusModel"
{...register('opusModel')}
placeholder="e.g. o1-preview, claude-3-opus"
placeholder="e.g. o1, claude-opus-4.5"
className="font-mono text-sm h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="haikuModel" className="text-sm">
Haiku Mapping (Fast Tasks)
Haiku Mapping
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
DEFAULT_HAIKU
</Badge>
@@ -308,16 +499,16 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
<Input
id="haikuModel"
{...register('haikuModel')}
placeholder="e.g. gpt-4o-mini, claude-3-haiku"
placeholder="e.g. gpt-4o-mini, claude-3.5-haiku"
className="font-mono text-sm h-9"
/>
</div>
</div>
</div>
</TabsContent>
</div>
</ScrollArea>
<DialogFooter className="p-6 pt-2 border-t bg-muted/10">
<DialogFooter className="p-6 pt-4 border-t bg-muted/10">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
@@ -345,3 +536,85 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
</Dialog>
);
}
/** Compact preset card component - horizontal layout */
function CompactPresetCard({
preset,
isSelected,
onClick,
}: {
preset: ProviderPreset;
isSelected: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md border transition-all text-sm',
isSelected
? preset.featured
? 'border-accent bg-accent/10 dark:bg-accent/20 font-medium'
: 'border-primary bg-primary/5 font-medium'
: 'border-muted hover:border-muted-foreground/30'
)}
>
{preset.icon ? (
<img src={preset.icon} alt="" className="w-3.5 h-3.5" />
) : (
<div className="w-3.5 h-3.5 rounded-full bg-muted flex items-center justify-center text-[8px] font-bold">
{preset.name.charAt(0)}
</div>
)}
<span>{preset.name}</span>
{preset.badge && (
<Badge variant="secondary" className="text-[9px] px-1 py-0 ml-0.5">
{preset.badge}
</Badge>
)}
</button>
);
}
/** Model search result item */
function ModelSearchItem({
model,
onClick,
showAge,
}: {
model: CategorizedModel;
onClick: () => void;
showAge?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
className="group flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground"
>
<span className="flex-1 truncate">{model.name}</span>
<span className="text-muted-foreground group-hover:text-accent-foreground/80 ml-2 flex items-center gap-2 text-xs">
{showAge && model.created && (
<Badge
variant="outline"
className="text-[10px] text-accent group-hover:text-accent-foreground/80 group-hover:border-accent-foreground/30"
>
{formatModelAge(model.created)}
</Badge>
)}
{model.isFree ? (
<Badge
variant="secondary"
className="text-xs group-hover:bg-accent-foreground/20 group-hover:text-accent-foreground"
>
Free
</Badge>
) : (
<span>{formatPricingPair(model.pricing)}</span>
)}
<span>{formatContextLength(model.context_length)}</span>
</span>
</button>
);
}
+77
View File
@@ -0,0 +1,77 @@
/**
* OpenRouter Models Hook
* Fetches and caches OpenRouter model catalog
*/
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { OpenRouterModel, CategorizedModel } from '@/lib/openrouter-types';
import {
getCachedModels,
setCachedModels,
clearCachedModels,
enrichModel,
} from '@/lib/openrouter-utils';
const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models';
const QUERY_KEY = ['openrouter-models'];
const STALE_TIME = 24 * 60 * 60 * 1000; // 24 hours
async function fetchOpenRouterModels(): Promise<OpenRouterModel[]> {
const response = await fetch(OPENROUTER_MODELS_URL);
if (!response.ok) {
throw new Error(`Failed to fetch OpenRouter models: ${response.status}`);
}
const data = (await response.json()) as { data: OpenRouterModel[] };
const models = data.data;
// Cache for offline use
setCachedModels(models);
return models;
}
export function useOpenRouterModels() {
return useQuery({
queryKey: QUERY_KEY,
queryFn: fetchOpenRouterModels,
staleTime: STALE_TIME,
gcTime: STALE_TIME,
// Use cached data as initial data (instant display)
initialData: () => getCachedModels() ?? undefined,
// Don't refetch on window focus for this heavy payload
refetchOnWindowFocus: false,
});
}
/** Get enriched models with categories and pricing */
export function useOpenRouterCatalog() {
const query = useOpenRouterModels();
const enrichedModels: CategorizedModel[] = (query.data ?? []).map(enrichModel);
return {
...query,
models: enrichedModels,
};
}
/** Force refresh hook */
export function useRefreshOpenRouterModels() {
const queryClient = useQueryClient();
return () => {
clearCachedModels();
return queryClient.invalidateQueries({ queryKey: QUERY_KEY });
};
}
/** Check if OpenRouter catalog is loaded */
export function useOpenRouterReady() {
const { data, isLoading, isError } = useOpenRouterModels();
return {
isReady: !!data && data.length > 0,
isLoading,
isError,
modelCount: data?.length ?? 0,
};
}
-27
View File
@@ -98,30 +98,3 @@ export function useRollback() {
},
});
}
/**
* Update profile secrets
*/
export function useUpdateSecrets() {
return useMutation({
mutationFn: ({ profile, secrets }: { profile: string; secrets: Record<string, string> }) =>
api.secrets.update(profile, secrets),
onSuccess: () => {
toast.success('Secrets updated successfully');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
/**
* Check if profile has secrets (doesn't return values)
*/
export function useSecretsExists(profile: string) {
return useQuery({
queryKey: ['secrets-exists', profile],
queryFn: () => api.secrets.exists(profile),
enabled: !!profile,
});
}
+7
View File
@@ -312,3 +312,10 @@
.animate-border-glow {
animation: border-glow 2s ease-in-out infinite;
}
/* Fix Radix ScrollArea viewport overflow issue */
/* Radix uses inline styles with display: table which causes content to expand beyond container */
[data-radix-scroll-area-viewport] > div {
display: block !important;
min-width: 0 !important;
}
-13
View File
@@ -132,11 +132,6 @@ export interface MigrationResult {
warnings: string[];
}
export interface SecretsExists {
exists: boolean;
keys: string[];
}
/** Model preset for quick model switching */
export interface ModelPreset {
name: string;
@@ -369,14 +364,6 @@ export const api = {
body: JSON.stringify({ backupPath }),
}),
},
secrets: {
update: (profile: string, secrets: Record<string, string>) =>
request<{ success: boolean }>(`/secrets/${profile}`, {
method: 'PUT',
body: JSON.stringify(secrets),
}),
exists: (profile: string) => request<SecretsExists>(`/secrets/${profile}/exists`),
},
/** Model presets for quick model switching */
presets: {
list: (profile: string) => request<{ presets: ModelPreset[] }>(`/settings/${profile}/presets`),
+73
View File
@@ -0,0 +1,73 @@
/**
* OpenRouter Model Catalog Types
* Based on https://openrouter.ai/docs/api-reference/list-available-models
*/
export interface OpenRouterPricing {
prompt: string; // USD per token, e.g., "0.000003"
completion: string;
request: string;
image: string;
audio?: string;
web_search?: string;
internal_reasoning?: string;
input_cache_read?: string;
}
export interface OpenRouterArchitecture {
modality: string; // "text+image->text"
input_modalities: string[]; // ["text", "image"]
output_modalities: string[]; // ["text"]
tokenizer: string; // "GPT", "Claude", "Gemini"
instruct_type: string | null;
}
export interface OpenRouterTopProvider {
context_length: number;
max_completion_tokens: number | null;
is_moderated: boolean;
}
export interface OpenRouterModel {
id: string; // "anthropic/claude-sonnet-4"
name: string; // "Anthropic: Claude Sonnet 4"
canonical_slug: string;
hugging_face_id: string | null;
description: string;
context_length: number;
architecture: OpenRouterArchitecture;
pricing: OpenRouterPricing;
top_provider: OpenRouterTopProvider;
supported_parameters: string[];
per_request_limits: Record<string, string> | null;
created: number; // Unix timestamp when model was added to OpenRouter
}
export interface OpenRouterModelsResponse {
data: OpenRouterModel[];
}
export interface OpenRouterCatalogCache {
models: OpenRouterModel[];
fetchedAt: number;
version: string;
}
/** Model category for grouping */
export type ModelCategory =
| 'anthropic'
| 'openai'
| 'google'
| 'meta'
| 'mistral'
| 'opensource'
| 'other';
/** Categorized model for UI display */
export interface CategorizedModel extends OpenRouterModel {
category: ModelCategory;
pricePerMillionPrompt: number;
pricePerMillionCompletion: number;
isFree: boolean;
isExacto: boolean; // Models with :exacto suffix - optimized for tool use
}
+257
View File
@@ -0,0 +1,257 @@
/**
* OpenRouter Model Catalog Utilities
* Search, filter, pricing, and categorization
*/
import type { OpenRouterModel, CategorizedModel, ModelCategory } from './openrouter-types';
const CACHE_KEY = 'ccs:openrouter-models';
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const CACHE_VERSION = '1';
/** Convert per-token price to per-million */
export function pricePerMillion(perToken: string): number {
const value = parseFloat(perToken);
if (isNaN(value) || value === 0) return 0;
return value * 1_000_000;
}
/** Format price for display */
export function formatPrice(perToken: string): string {
const perMillion = pricePerMillion(perToken);
if (perMillion === 0) return 'Free';
if (perMillion < 0.01) return '<$0.01';
if (perMillion < 1) return `$${perMillion.toFixed(2)}`;
return `$${perMillion.toFixed(perMillion < 10 ? 2 : 0)}`;
}
/** Format pricing pair (prompt/completion) */
export function formatPricingPair(pricing: { prompt: string; completion: string }): string {
const promptPrice = formatPrice(pricing.prompt);
const completionPrice = formatPrice(pricing.completion);
if (promptPrice === 'Free' && completionPrice === 'Free') return 'Free';
return `${promptPrice}/${completionPrice}`;
}
/** Categorize model by provider */
export function categorizeModel(model: OpenRouterModel): ModelCategory {
const id = model.id.toLowerCase();
if (id.startsWith('anthropic/')) return 'anthropic';
if (id.startsWith('openai/')) return 'openai';
if (id.startsWith('google/')) return 'google';
if (id.startsWith('meta-llama/') || id.startsWith('meta/')) return 'meta';
if (id.startsWith('mistralai/')) return 'mistral';
// Open source indicators
if (id.includes(':free') || id.includes('qwen') || id.includes('deepseek')) return 'opensource';
return 'other';
}
/** Enrich model with computed fields */
export function enrichModel(model: OpenRouterModel): CategorizedModel {
return {
...model,
category: categorizeModel(model),
pricePerMillionPrompt: pricePerMillion(model.pricing.prompt),
pricePerMillionCompletion: pricePerMillion(model.pricing.completion),
isFree: model.pricing.prompt === '0' && model.pricing.completion === '0',
isExacto: model.id.includes(':exacto'), // Exacto variants - optimized for agentic/tool use
};
}
/** Search models by query */
export function searchModels(
models: CategorizedModel[],
query: string,
filters?: {
category?: ModelCategory;
freeOnly?: boolean;
minContext?: number;
}
): CategorizedModel[] {
const q = query.toLowerCase().trim();
return models.filter((model) => {
// Apply filters
if (filters?.category && model.category !== filters.category) return false;
if (filters?.freeOnly && !model.isFree) return false;
if (filters?.minContext && model.context_length < filters.minContext) return false;
// Search query
if (!q) return true;
return (
model.id.toLowerCase().includes(q) ||
model.name.toLowerCase().includes(q) ||
model.description?.toLowerCase().includes(q)
);
});
}
/**
* Sort models with priority: Free > Exacto > Regular
* Within each tier, sort by name alphabetically
*/
export function sortModelsByPriority(models: CategorizedModel[]): CategorizedModel[] {
return [...models].sort((a, b) => {
// Priority 1: Free models first
if (a.isFree && !b.isFree) return -1;
if (!a.isFree && b.isFree) return 1;
// Priority 2: Exacto models second (only if both not free)
if (!a.isFree && !b.isFree) {
if (a.isExacto && !b.isExacto) return -1;
if (!a.isExacto && b.isExacto) return 1;
}
// Same tier: sort by name
return a.name.localeCompare(b.name);
});
}
/** Get cached models from localStorage */
export function getCachedModels(): OpenRouterModel[] | null {
try {
const cached = localStorage.getItem(CACHE_KEY);
if (!cached) return null;
const data = JSON.parse(cached) as {
models: OpenRouterModel[];
fetchedAt: number;
version: string;
};
// Check version
if (data.version !== CACHE_VERSION) return null;
// Check TTL
if (Date.now() - data.fetchedAt > CACHE_TTL_MS) return null;
return data.models;
} catch {
return null;
}
}
/** Save models to localStorage cache */
export function setCachedModels(models: OpenRouterModel[]): void {
try {
localStorage.setItem(
CACHE_KEY,
JSON.stringify({
models,
fetchedAt: Date.now(),
version: CACHE_VERSION,
})
);
} catch {
// Storage full or unavailable, ignore
}
}
/** Clear cached models */
export function clearCachedModels(): void {
localStorage.removeItem(CACHE_KEY);
}
/** Suggest tier mappings based on selected model */
export function suggestTierMappings(
selectedModelId: string,
allModels: CategorizedModel[]
): { opus?: string; sonnet?: string; haiku?: string } {
// Extract provider prefix
const [provider] = selectedModelId.split('/');
if (!provider) return {};
const providerModels = allModels.filter((m) => m.id.startsWith(`${provider}/`));
if (providerModels.length === 0) return {};
// Sort by price (expensive = opus, mid = sonnet, cheap = haiku)
const sorted = [...providerModels].sort(
(a, b) => b.pricePerMillionPrompt - a.pricePerMillionPrompt
);
// Simple heuristic: top 1/3 = opus, middle = sonnet, bottom = haiku
const third = Math.ceil(sorted.length / 3);
return {
opus: sorted[0]?.id,
sonnet: sorted[Math.min(third, sorted.length - 1)]?.id,
haiku: sorted[sorted.length - 1]?.id,
};
}
/** Format context length for display */
export function formatContextLength(length: number): string {
if (length >= 1_000_000) return `${(length / 1_000_000).toFixed(1)}M`;
if (length >= 1_000) return `${Math.round(length / 1_000)}K`;
return String(length);
}
/** Category display names */
export const CATEGORY_LABELS: Record<ModelCategory, string> = {
anthropic: 'Anthropic (Claude)',
openai: 'OpenAI (GPT)',
google: 'Google (Gemini)',
meta: 'Meta (Llama)',
mistral: 'Mistral',
opensource: 'Open Source',
other: 'Other',
};
/** Provider prefixes for detecting newest models */
const PROVIDER_PREFIXES: Record<ModelCategory, string[]> = {
anthropic: ['anthropic/'],
openai: ['openai/'],
google: ['google/'],
meta: ['meta-llama/', 'meta/'],
mistral: ['mistralai/'],
opensource: ['deepseek/', 'qwen/', 'cohere/'],
other: [],
};
/** Get the newest models per provider (sorted by created timestamp) */
export function getNewestModelsPerProvider(
allModels: CategorizedModel[],
modelsPerProvider: number = 2
): CategorizedModel[] {
const result: CategorizedModel[] = [];
const categories: ModelCategory[] = [
'anthropic',
'openai',
'google',
'meta',
'mistral',
'opensource',
];
for (const category of categories) {
const prefixes = PROVIDER_PREFIXES[category];
if (prefixes.length === 0) continue;
// Get models for this provider
const providerModels = allModels.filter((m) =>
prefixes.some((prefix) => m.id.toLowerCase().startsWith(prefix))
);
// Sort by created timestamp (newest first)
const sorted = [...providerModels].sort((a, b) => (b.created ?? 0) - (a.created ?? 0));
// Take top N
result.push(...sorted.slice(0, modelsPerProvider));
}
// Sort final result by created (newest first)
return result.sort((a, b) => (b.created ?? 0) - (a.created ?? 0));
}
/** Format relative time for model creation date */
export function formatModelAge(created: number): string {
const now = Date.now() / 1000; // Convert to seconds
const diff = now - created;
if (diff < 86400) return 'Today';
if (diff < 172800) return 'Yesterday';
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
if (diff < 2592000) return `${Math.floor(diff / 604800)}w ago`;
if (diff < 31536000) return `${Math.floor(diff / 2592000)}mo ago`;
return `${Math.floor(diff / 31536000)}y ago`;
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Provider Presets Configuration
* Pre-configured templates for common API providers
*/
export type PresetCategory = 'recommended' | 'alternative';
export interface ProviderPreset {
id: string;
name: string;
description: string;
baseUrl: string;
defaultProfileName: string;
badge?: string;
featured?: boolean;
icon?: string;
defaultModel?: string;
requiresApiKey: boolean;
apiKeyPlaceholder: string;
apiKeyHint?: string;
category: PresetCategory;
}
export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api';
export const PROVIDER_PRESETS: ProviderPreset[] = [
// Recommended - OpenRouter
{
id: 'openrouter',
name: 'OpenRouter',
description: '349+ models from OpenAI, Anthropic, Google, Meta',
baseUrl: OPENROUTER_BASE_URL,
defaultProfileName: 'openrouter',
badge: '349+ models',
featured: true,
icon: '/icons/openrouter.svg',
defaultModel: 'anthropic/claude-sonnet-4',
requiresApiKey: true,
apiKeyPlaceholder: 'sk-or-...',
apiKeyHint: 'Get your API key at openrouter.ai/keys',
category: 'recommended',
},
// Alternative providers - GLM/GLMT/Kimi
{
id: 'glm',
name: 'GLM',
description: 'Claude via Z.AI (GitHub Copilot)',
baseUrl: 'https://api.z.ai/api/anthropic',
defaultProfileName: 'glm',
badge: 'Z.AI',
defaultModel: 'glm-4.6',
requiresApiKey: true,
apiKeyPlaceholder: 'ghp_...',
apiKeyHint: 'Get your API key from Z.AI',
category: 'alternative',
},
{
id: 'glmt',
name: 'GLMT',
description: 'GLM with Thinking mode support',
baseUrl: 'https://api.z.ai/api/coding/paas/v4/chat/completions',
defaultProfileName: 'glmt',
badge: 'Thinking',
defaultModel: 'glm-4.6',
requiresApiKey: true,
apiKeyPlaceholder: 'ghp_...',
apiKeyHint: 'Same API key as GLM',
category: 'alternative',
},
{
id: 'kimi',
name: 'Kimi',
description: 'Moonshot AI - Fast reasoning model',
baseUrl: 'https://api.kimi.com/coding/',
defaultProfileName: 'kimi',
badge: 'Reasoning',
defaultModel: 'kimi-k2-thinking-turbo',
requiresApiKey: true,
apiKeyPlaceholder: 'sk-...',
apiKeyHint: 'Get your API key from Moonshot AI',
category: 'alternative',
},
];
/** Get presets by category */
export function getPresetsByCategory(category: PresetCategory): ProviderPreset[] {
return PROVIDER_PRESETS.filter((p) => p.category === category);
}
/** Get preset by ID */
export function getPresetById(id: string): ProviderPreset | undefined {
return PROVIDER_PRESETS.find((p) => p.id === id);
}
/** Check if a URL matches a known preset */
export function detectPresetFromUrl(baseUrl: string): ProviderPreset | undefined {
const normalizedUrl = baseUrl.toLowerCase().trim();
return PROVIDER_PRESETS.find((p) => normalizedUrl.includes(p.baseUrl.toLowerCase()));
}
+144 -187
View File
@@ -7,23 +7,23 @@ import { useState, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
Plus,
Search,
Settings2,
Trash2,
CheckCircle2,
AlertCircle,
Server,
ExternalLink,
FileJson,
RefreshCw,
} from 'lucide-react';
import { ProfileEditor } from '@/components/profile-editor';
import { ProfileCreateDialog } from '@/components/profiles/profile-create-dialog';
import { OpenRouterBanner } from '@/components/profiles/openrouter-banner';
import { OpenRouterQuickStart } from '@/components/profiles/openrouter-quick-start';
import { OpenRouterPromoCard } from '@/components/profiles/openrouter-promo-card';
import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles';
import { useOpenRouterModels } from '@/hooks/use-openrouter-models';
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
import type { Profile } from '@/lib/api-client';
import { cn } from '@/lib/utils';
@@ -35,8 +35,12 @@ export function ApiPage() {
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
const [createMode, setCreateMode] = useState<'normal' | 'openrouter'>('normal');
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
// Prefetch OpenRouter models when page loads (lazy - won't block render)
useOpenRouterModels();
// Memoize profiles to maintain stable reference
const profiles = useMemo(() => data?.profiles || [], [data?.profiles]);
@@ -46,13 +50,11 @@ export function ApiPage() {
[profiles, searchQuery]
);
// Compute effective selected profile (auto-select first if none selected)
const effectiveSelectedProfile = useMemo(() => {
if (selectedProfile && profiles.some((p) => p.name === selectedProfile)) {
return selectedProfile;
}
return profiles.length > 0 ? profiles[0].name : null;
}, [selectedProfile, profiles]);
// selectedProfile is null by default - user must click to select
// This allows OpenRouterQuickStart to show as the default right panel
const selectedProfileData = selectedProfile
? profiles.find((p) => p.name === selectedProfile)
: null;
// Handle profile deletion
const handleDelete = (name: string) => {
@@ -72,137 +74,154 @@ export function ApiPage() {
setSelectedProfile(name);
};
const selectedProfileData = profiles.find((p) => p.name === effectiveSelectedProfile);
return (
<div className="h-[calc(100vh-100px)] flex">
{/* Left Panel - Profiles List */}
<div className="w-80 border-r flex flex-col bg-muted/30">
{/* Header */}
<div className="p-4 border-b bg-background">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Server className="w-5 h-5 text-primary" />
<h1 className="font-semibold">API Profiles</h1>
</div>
<Button
size="sm"
onClick={() => {
setCreateDialogOpen(true);
}}
>
<Plus className="w-4 h-4 mr-1" />
New
</Button>
</div>
<div className="h-[calc(100vh-100px)] flex flex-col">
{/* OpenRouter Announcement Banner */}
<OpenRouterBanner onCreateClick={() => setCreateDialogOpen(true)} />
{/* Search */}
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search profiles..."
className="pl-8 h-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
{/* Profile List */}
<ScrollArea className="flex-1">
{isLoading ? (
<div className="p-4 text-sm text-muted-foreground">Loading profiles...</div>
) : isError ? (
<div className="p-4 text-center">
<div className="space-y-3 py-8">
<AlertCircle className="w-12 h-12 mx-auto text-destructive/50" />
<div>
<p className="text-sm font-medium">Failed to load profiles</p>
<p className="text-xs text-muted-foreground mt-1">
Unable to fetch API profiles. Please try again.
</p>
</div>
<Button size="sm" variant="outline" onClick={() => refetch()}>
<RefreshCw className="w-4 h-4 mr-1" />
Retry
</Button>
{/* Main Content */}
<div className="flex-1 flex min-h-0">
{/* Left Panel - Profiles List */}
<div className="w-80 border-r flex flex-col bg-muted/30">
{/* Header */}
<div className="p-4 border-b bg-background">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Server className="w-5 h-5 text-primary" />
<h1 className="font-semibold">API Profiles</h1>
</div>
<Button
size="sm"
onClick={() => {
setCreateDialogOpen(true);
}}
>
<Plus className="w-4 h-4 mr-1" />
New
</Button>
</div>
) : filteredProfiles.length === 0 ? (
<div className="p-4 text-center">
{profiles.length === 0 ? (
{/* Search */}
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search profiles..."
className="pl-8 h-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
{/* Profile List */}
<ScrollArea className="flex-1">
{isLoading ? (
<div className="p-4 text-sm text-muted-foreground">Loading profiles...</div>
) : isError ? (
<div className="p-4 text-center">
<div className="space-y-3 py-8">
<FileJson className="w-12 h-12 mx-auto text-muted-foreground/50" />
<AlertCircle className="w-12 h-12 mx-auto text-destructive/50" />
<div>
<p className="text-sm font-medium">No API profiles yet</p>
<p className="text-sm font-medium">Failed to load profiles</p>
<p className="text-xs text-muted-foreground mt-1">
Create your first profile to connect to custom API endpoints
Unable to fetch API profiles. Please try again.
</p>
</div>
<Button
size="sm"
variant="outline"
onClick={() => {
setCreateDialogOpen(true);
}}
>
<Plus className="w-4 h-4 mr-1" />
Create Profile
<Button size="sm" variant="outline" onClick={() => refetch()}>
<RefreshCw className="w-4 h-4 mr-1" />
Retry
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground py-4">
No profiles match "{searchQuery}"
</p>
)}
</div>
) : (
<div className="p-2 space-y-1">
{filteredProfiles.map((profile) => (
<ProfileListItem
key={profile.name}
profile={profile}
isSelected={effectiveSelectedProfile === profile.name}
onSelect={() => {
setSelectedProfile(profile.name);
}}
onDelete={() => setDeleteConfirm(profile.name)}
/>
))}
</div>
) : filteredProfiles.length === 0 ? (
<div className="p-4 text-center">
{profiles.length === 0 ? (
<div className="space-y-3 py-8">
<FileJson className="w-12 h-12 mx-auto text-muted-foreground/50" />
<div>
<p className="text-sm font-medium">No API profiles yet</p>
<p className="text-xs text-muted-foreground mt-1">
Create your first profile to connect to custom API endpoints
</p>
</div>
<Button
size="sm"
variant="outline"
onClick={() => {
setCreateDialogOpen(true);
}}
>
<Plus className="w-4 h-4 mr-1" />
Create Profile
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground py-4">
No profiles match "{searchQuery}"
</p>
)}
</div>
) : (
<div className="p-2 space-y-1">
{filteredProfiles.map((profile) => (
<ProfileListItem
key={profile.name}
profile={profile}
isSelected={selectedProfile === profile.name}
onSelect={() => {
setSelectedProfile(profile.name);
}}
onDelete={() => setDeleteConfirm(profile.name)}
/>
))}
</div>
)}
</ScrollArea>
{/* Footer Stats */}
{profiles.length > 0 && (
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>
{profiles.length} profile{profiles.length !== 1 ? 's' : ''}
</span>
<span className="flex items-center gap-1">
<CheckCircle2 className="w-3 h-3 text-green-600" />
{profiles.filter((p) => p.configured).length} configured
</span>
</div>
</div>
)}
</ScrollArea>
{/* Footer Stats */}
{profiles.length > 0 && (
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>
{profiles.length} profile{profiles.length !== 1 ? 's' : ''}
</span>
<span className="flex items-center gap-1">
<CheckCircle2 className="w-3 h-3 text-green-600" />
{profiles.filter((p) => p.configured).length} configured
</span>
</div>
</div>
)}
</div>
{/* Right Panel - Editor */}
<div className="flex-1 flex flex-col min-w-0">
{selectedProfileData ? (
<ProfileEditor
profileName={selectedProfileData.name}
onDelete={() => setDeleteConfirm(selectedProfileData.name)}
/>
) : (
<EmptyState
{/* OpenRouter Promo - always visible */}
<OpenRouterPromoCard
onCreateClick={() => {
setCreateMode('openrouter');
setCreateDialogOpen(true);
}}
/>
)}
</div>
{/* Right Panel - Editor or QuickStart */}
<div className="flex-1 flex flex-col min-w-0">
{selectedProfileData ? (
<ProfileEditor
profileName={selectedProfileData.name}
onDelete={() => setDeleteConfirm(selectedProfileData.name)}
/>
) : (
<OpenRouterQuickStart
onOpenRouterClick={() => {
setCreateMode('openrouter');
setCreateDialogOpen(true);
}}
onCustomClick={() => {
setCreateMode('normal');
setCreateDialogOpen(true);
}}
/>
)}
</div>
</div>
{/* Create Dialog */}
@@ -210,6 +229,7 @@ export function ApiPage() {
open={isCreateDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={handleCreateSuccess}
initialMode={createMode}
/>
{/* Delete Confirmation */}
@@ -285,66 +305,3 @@ function ProfileListItem({
</div>
);
}
/** Empty state when no profile is selected */
function EmptyState({ onCreateClick }: { onCreateClick: () => void }) {
return (
<div className="flex-1 flex items-center justify-center bg-muted/20">
<div className="text-center max-w-md px-8">
<Settings2 className="w-16 h-16 mx-auto text-muted-foreground/30 mb-6" />
<h2 className="text-xl font-semibold mb-2">API Profile Manager</h2>
<p className="text-muted-foreground mb-6">
Configure custom API endpoints for Claude CLI. Connect to proxy services like copilot-api,
OpenRouter, or your own API backend.
</p>
<div className="space-y-3">
<Button onClick={onCreateClick} className="w-full">
<Plus className="w-4 h-4 mr-2" />
Create Your First Profile
</Button>
<Separator className="my-4" />
<div className="text-left space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
What you can configure:
</p>
<ul className="text-sm text-muted-foreground space-y-1.5">
<li className="flex items-start gap-2">
<Badge variant="outline" className="text-xs shrink-0 mt-0.5">
URL
</Badge>
<span>Custom API base URL endpoint</span>
</li>
<li className="flex items-start gap-2">
<Badge variant="outline" className="text-xs shrink-0 mt-0.5">
Auth
</Badge>
<span>API key or authentication token</span>
</li>
<li className="flex items-start gap-2">
<Badge variant="outline" className="text-xs shrink-0 mt-0.5">
Models
</Badge>
<span>Model mapping for Opus/Sonnet/Haiku</span>
</li>
</ul>
</div>
<div className="pt-4">
<a
href="https://github.com/kaitranntt/ccs#api-profiles"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-xs text-primary hover:underline"
>
Learn more about API profiles
<ExternalLink className="w-3 h-3 ml-1" />
</a>
</div>
</div>
</div>
</div>
);
}