Files
ccs/src/commands/api-command/create-command.ts
T

550 lines
17 KiB
TypeScript

import {
apiProfileExists,
createCliproxyBridgeProfile,
createApiProfile,
getDefaultCliproxyBridgeName,
getPresetById,
getPresetIds,
getUrlWarning,
isOpenRouterUrl,
isUsingUnifiedConfig,
pickOpenRouterModel,
sanitizeBaseUrl,
suggestCliproxyBridgeName,
validateApiName,
validateUrl,
type ModelMapping,
type ProviderPreset,
} from '../../api/services';
import {
CLIPROXY_PROVIDER_IDS,
getProviderDisplayName,
isCLIProxyProvider,
} from '../../cliproxy/provider-capabilities';
import { syncToLocalConfig } from '../../cliproxy/sync/local-config-sync';
import type { TargetType } from '../../targets/target-adapter';
import { color, dim, fail, header, info, infoBox, initUI, warn } from '../../utils/ui';
import { InteractivePrompt } from '../../utils/prompt';
import {
applyClaudeExtendedContextPreference,
exitOnApiCommandErrors,
hasClaudeModelMapping,
hasExplicitClaudeExtendedContext,
parseApiCommandArgs,
} from './shared';
function resolvePresetOrExit(presetId?: string): ProviderPreset | null {
if (!presetId) {
return null;
}
const preset = getPresetById(presetId);
if (preset) {
return preset;
}
console.log(fail(`Unknown preset: ${presetId}`));
console.log('');
console.log('Available presets:');
getPresetIds().forEach((id) => console.log(` - ${id}`));
process.exit(1);
}
function showPresetDeprecationNotice(presetId?: string): void {
if ((presetId || '').trim().toLowerCase() !== 'glmt') {
return;
}
console.log(warn('Preset "glmt" is deprecated and now maps to the direct "glm" preset.'));
console.log(dim(' Z.AI models already expose thinking natively, so CCS no longer needs GLMT.'));
console.log(dim(' Update scripts/docs to: ccs api create --preset glm'));
console.log('');
}
async function resolveProfileName(
providedName: string | undefined,
preset: ProviderPreset | null
): Promise<string> {
const name = providedName || preset?.defaultProfileName;
if (!name) {
return InteractivePrompt.input('API name', {
validate: validateApiName,
});
}
const error = validateApiName(name);
if (error) {
console.log(fail(error));
process.exit(1);
}
return name;
}
async function resolveCliproxyProfileName(
provider: string,
providedName: string | undefined,
yes: boolean | undefined
): Promise<string | undefined> {
if (providedName) {
const error = validateApiName(providedName);
if (error) {
console.log(fail(error));
process.exit(1);
}
return providedName;
}
const suggestedName = isCLIProxyProvider(provider)
? suggestCliproxyBridgeName(provider)
: getDefaultCliproxyBridgeName('gemini');
if (yes) {
return suggestedName;
}
return InteractivePrompt.input('API name', {
default: suggestedName,
validate: validateApiName,
});
}
async function resolveBaseUrl(
providedBaseUrl: string | undefined,
preset: ProviderPreset | null
): Promise<string> {
let baseUrl = providedBaseUrl ?? preset?.baseUrl ?? '';
if (!baseUrl && !preset) {
baseUrl = await InteractivePrompt.input(
'API Base URL (e.g., https://api.example.com/v1 - without /chat/completions)',
{ validate: validateUrl }
);
} else if (!preset) {
const error = validateUrl(baseUrl);
if (error) {
console.log(fail(error));
process.exit(1);
}
}
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),
});
}
}
return baseUrl;
}
console.log(info(`Using preset: ${preset.name}`));
console.log(dim(` ${preset.description}`));
console.log(
dim(
preset.baseUrl
? ` Base URL: ${preset.baseUrl}`
: ' Auth: Native Anthropic API (x-api-key header)'
)
);
console.log('');
return baseUrl;
}
async function resolveApiKey(
providedApiKey: string | undefined,
preset: ProviderPreset | null
): Promise<string> {
if (preset?.requiresApiKey === false) {
if (providedApiKey) {
console.log(dim(`Note: Using provided API key for ${preset.name} (optional)`));
return providedApiKey;
}
console.log(info(`No API key required for ${preset.name}`));
return preset.apiKeyPlaceholder || preset.id;
}
if (providedApiKey) {
return providedApiKey;
}
const keyPrompt = preset?.apiKeyHint ? `API Key (${preset.apiKeyHint})` : 'API Key';
const apiKey = await InteractivePrompt.password(keyPrompt);
if (!apiKey) {
console.log(fail('API key is required'));
process.exit(1);
}
return apiKey;
}
async function resolveModelConfiguration(
baseUrl: string,
preset: ProviderPreset | null,
providedModel: string | undefined,
yes: boolean | undefined
): Promise<{ model: string; models: ModelMapping }> {
let openRouterModel: string | undefined;
let openRouterTierMapping: { opus?: string; sonnet?: string; haiku?: string } | undefined;
if (isOpenRouterUrl(baseUrl) && !providedModel) {
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.'));
}
const defaultModel = preset?.defaultModel || 'claude-sonnet-4-6';
let model = providedModel || openRouterModel || preset?.defaultModel;
if (!model && !yes && !preset) {
model = await InteractivePrompt.input('Default model (ANTHROPIC_MODEL)', {
default: defaultModel,
});
}
model = model || defaultModel;
let opusModel = openRouterTierMapping?.opus || model;
let sonnetModel = openRouterTierMapping?.sonnet || model;
let haikuModel = openRouterTierMapping?.haiku || model;
const shouldPromptForMapping = !yes && !openRouterTierMapping && !preset;
if (shouldPromptForMapping) {
let wantCustomMapping = model !== defaultModel;
if (!wantCustomMapping) {
console.log('');
console.log(dim('Some API proxies route different model types to different backends.'));
wantCustomMapping = await InteractivePrompt.confirm(
'Configure different models for Opus/Sonnet/Haiku?',
{ default: false }
);
}
if (wantCustomMapping) {
console.log('');
console.log(dim('Leave blank to use the default model for each tier.'));
opusModel =
(await InteractivePrompt.input('Opus model (ANTHROPIC_DEFAULT_OPUS_MODEL)', {
default: model,
})) || model;
sonnetModel =
(await InteractivePrompt.input('Sonnet model (ANTHROPIC_DEFAULT_SONNET_MODEL)', {
default: model,
})) || model;
haikuModel =
(await InteractivePrompt.input('Haiku model (ANTHROPIC_DEFAULT_HAIKU_MODEL)', {
default: model,
})) || model;
}
}
return {
model,
models: {
default: model,
opus: opusModel,
sonnet: sonnetModel,
haiku: haikuModel,
},
};
}
async function resolveDefaultTarget(
providedTarget: TargetType | undefined,
yes: boolean | undefined
): Promise<TargetType> {
if (providedTarget) {
return providedTarget;
}
if (yes) {
return 'claude';
}
const useDroidByDefault = await InteractivePrompt.confirm(
'Set default target to Factory Droid for this profile?',
{ default: false }
);
return useDroidByDefault ? 'droid' : 'claude';
}
async function resolveClaudeLongContextPreference(
models: ModelMapping,
explicitPreference: boolean | undefined,
yes: boolean | undefined
): Promise<boolean> {
if (explicitPreference !== undefined) {
return explicitPreference;
}
if (hasExplicitClaudeExtendedContext(models)) {
return true;
}
if (yes) {
return false;
}
console.log('');
console.log(info('Claude long context is explicit in CCS.'));
console.log(dim(' Plain Claude model IDs stay on standard context unless you opt into [1m].'));
console.log(
dim(' Some providers/accounts still require extra usage or PAYG for long-context requests.')
);
console.log(dim(' If that entitlement is missing, upstream requests can still return 429.'));
console.log('');
return InteractivePrompt.confirm('Enable [1m] for compatible Claude mappings?', {
default: false,
});
}
export async function handleApiCreateCommand(args: string[]): Promise<void> {
await initUI();
const parsedArgs = parseApiCommandArgs(args);
exitOnApiCommandErrors(parsedArgs.errors);
console.log(header('Create API Profile'));
console.log('');
if (parsedArgs.cliproxyProvider) {
const cliproxyProvider = parsedArgs.cliproxyProvider.trim().toLowerCase();
if (!isCLIProxyProvider(cliproxyProvider)) {
console.log(fail(`Unknown CLIProxy provider: ${cliproxyProvider}`));
console.log('');
console.log(`Available providers: ${CLIPROXY_PROVIDER_IDS.join(', ')}`);
process.exit(1);
}
const incompatibleFlags = [
parsedArgs.baseUrl && '--base-url',
parsedArgs.apiKey && '--api-key',
parsedArgs.model && '--model',
parsedArgs.preset && '--preset',
].filter(Boolean);
if (incompatibleFlags.length > 0) {
console.log(
fail(`--cliproxy-provider cannot be combined with ${incompatibleFlags.join(', ')}`)
);
process.exit(1);
}
const name = await resolveCliproxyProfileName(
cliproxyProvider,
parsedArgs.name,
parsedArgs.yes
);
const target = await resolveDefaultTarget(parsedArgs.target, parsedArgs.yes);
if (name && apiProfileExists(name) && !parsedArgs.force) {
console.log(fail(`API '${name}' already exists`));
console.log(` Use ${color('--force', 'command')} to overwrite`);
process.exit(1);
}
console.log(
info(
`Using CLIProxy provider: ${getProviderDisplayName(cliproxyProvider)} (${cliproxyProvider})`
)
);
console.log(
dim(' CCS will create a routed API profile. Provider credentials stay managed by CLIProxy.')
);
console.log('');
console.log(info('Creating API profile...'));
const result = createCliproxyBridgeProfile(cliproxyProvider, {
force: parsedArgs.force === true,
name,
target,
});
if (!result.success || !result.name || !result.cliproxyBridge) {
console.log(fail(`Failed to create CLIProxy bridge profile: ${result.error}`));
process.exit(1);
}
try {
syncToLocalConfig();
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.log(`[i] Auto-sync to CLIProxy config skipped: ${message}`);
}
const details =
`API: ${result.name}\n` +
`Config: ${isUsingUnifiedConfig() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'}\n` +
`Settings: ${result.settingsFile}\n` +
`Provider: ${result.cliproxyBridge.providerDisplayName}\n` +
`Route: ${result.cliproxyBridge.routePath}\n` +
`Proxy: ${result.cliproxyBridge.currentBaseUrl}\n` +
`Target: ${target}`;
console.log('');
console.log(infoBox(details, 'CLIProxy Bridge Created'));
console.log('');
console.log(header('Usage'));
if (target === 'droid') {
console.log(
` ${color(`ccs ${result.name} "your prompt"`, 'command')} ${dim('# uses droid by default')}`
);
console.log(
` ${color(`ccs-droid ${result.name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}`
);
console.log(
` ${color(`ccsd ${result.name} "your prompt"`, 'command')} ${dim('# legacy shortcut')}`
);
} else {
console.log(` ${color(`ccs ${result.name} "your prompt"`, 'command')}`);
console.log(
` ${color(`ccs-droid ${result.name} "your prompt"`, 'command')} ${dim('# explicit one-off droid alias')}`
);
console.log(
` ${color(`ccs ${result.name} --target droid "your prompt"`, 'command')} ${dim('# target flag alternative')}`
);
}
console.log('');
console.log(dim('Manage provider accounts, keys, and models in: ccs cliproxy'));
return;
}
showPresetDeprecationNotice(parsedArgs.preset);
const preset = resolvePresetOrExit(parsedArgs.preset);
const name = await resolveProfileName(parsedArgs.name, preset);
if (apiProfileExists(name) && !parsedArgs.force) {
console.log(fail(`API '${name}' already exists`));
console.log(` Use ${color('--force', 'command')} to overwrite`);
process.exit(1);
}
let baseUrl = await resolveBaseUrl(parsedArgs.baseUrl, preset);
if (baseUrl && baseUrl.includes('api.anthropic.com') && !preset) {
console.log('');
console.log(info('Anthropic Direct API detected. Base URL will be omitted for native auth.'));
baseUrl = '';
}
const apiKey = await resolveApiKey(parsedArgs.apiKey, preset);
const { models } = await resolveModelConfiguration(
baseUrl,
preset,
parsedArgs.model,
parsedArgs.yes
);
const hasClaudeMappings = hasClaudeModelMapping(models);
const shouldEnableClaudeLongContext = hasClaudeMappings
? await resolveClaudeLongContextPreference(models, parsedArgs.extendedContext, parsedArgs.yes)
: false;
const finalModels = hasClaudeMappings
? applyClaudeExtendedContextPreference(models, shouldEnableClaudeLongContext)
: models;
const target = await resolveDefaultTarget(parsedArgs.target, parsedArgs.yes);
if (parsedArgs.extendedContext !== undefined && !hasClaudeMappings) {
console.log('');
console.log(
dim('No compatible Claude mappings were detected, so --1m/--no-1m did not change models.')
);
}
console.log('');
console.log(info('Creating API profile...'));
const result = createApiProfile(name, baseUrl || '', apiKey, finalModels, target);
if (!result.success) {
console.log(fail(`Failed to create API profile: ${result.error}`));
process.exit(1);
}
try {
syncToLocalConfig();
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.log(`[i] Auto-sync to CLIProxy config skipped: ${message}`);
}
const hasCustomMapping =
finalModels.opus !== finalModels.default ||
finalModels.sonnet !== finalModels.default ||
finalModels.haiku !== finalModels.default;
let details =
`API: ${name}\n` +
`Config: ${isUsingUnifiedConfig() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'}\n` +
`Settings: ${result.settingsFile}\n` +
`Base URL: ${baseUrl}\n` +
`Model: ${finalModels.default}\n` +
`Target: ${target}`;
if (hasClaudeMappings) {
details += `\nLongCtx: ${
shouldEnableClaudeLongContext
? 'compatible Claude mappings use [1m]'
: 'standard Claude context'
}`;
}
if (hasCustomMapping) {
details +=
`\n\nModel Mapping:\n` +
` Opus: ${finalModels.opus}\n` +
` Sonnet: ${finalModels.sonnet}\n` +
` Haiku: ${finalModels.haiku}`;
}
console.log('');
console.log(infoBox(details, 'API Profile Created'));
if (hasClaudeMappings) {
console.log('');
console.log(
dim(
shouldEnableClaudeLongContext
? 'CCS saved [1m] on compatible Claude mappings. Provider-side extra-usage requirements can still reject long-context requests.'
: 'Claude mappings were saved with standard context. Use --1m later if you want CCS to write [1m] explicitly.'
)
);
}
console.log('');
console.log(header('Usage'));
if (target === 'droid') {
console.log(
` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}`
);
console.log(
` ${color(`ccs-droid ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}`
);
console.log(` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# legacy shortcut')}`);
console.log(
` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}`
);
} else {
console.log(
` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}`
);
console.log(
` ${color(`ccs-droid ${name} "your prompt"`, 'command')} ${dim('# explicit one-off droid alias')}`
);
console.log(
` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# target flag alternative')}`
);
}
console.log('');
console.log(header('Edit Settings'));
console.log(` ${dim('To modify env vars later:')}`);
console.log(` ${color(`nano ${result.settingsFile.replace('~', '$HOME')}`, 'command')}`);
console.log('');
}