Merge pull request #802 from kaitranntt/kai/fix/789-opus-long-context

fix: preserve Claude long-context intent across CLI and dashboard
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-03-26 17:09:32 -04:00
commit d08b4a2293
20 files changed
+1126 -571

No files matched your search

+15 -51
View File
@@ -13,9 +13,10 @@ import type { CLIProxyProvider } from '../types';
import { supportsExtendedContext } from '../model-catalog';
import { warn } from '../../utils/ui';
import {
applyExtendedContextPreferenceToAnthropicModels,
applyExtendedContextSuffix as applyExtendedContextSuffixShared,
isNativeGeminiModel,
stripExtendedContextSuffix,
stripModelConfigurationSuffixes,
} from '../../shared/extended-context-utils';
// Backward-compatible export retained for tests/importers that reference this module.
@@ -72,57 +73,20 @@ export function applyExtendedContextConfig(
provider: CLIProxyProvider,
extendedContextOverride?: boolean
): void {
// Get base model to check support (strip any existing suffixes for lookup)
const baseModel = envVars.ANTHROPIC_MODEL || '';
const cleanModelId = stripModelSuffixes(baseModel);
// Tier model env vars to apply/strip extended context suffix
const tierModels = [
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
] as const;
if (!shouldApplyExtendedContext(provider, cleanModelId, extendedContextOverride)) {
// Strip [1m] suffix from models that no longer support extended context
// (e.g., user had it enabled before backend dropped support)
if (envVars.ANTHROPIC_MODEL?.toLowerCase().endsWith('[1m]')) {
envVars.ANTHROPIC_MODEL = envVars.ANTHROPIC_MODEL.replace(/\[1m\]$/i, '');
}
for (const tierVar of tierModels) {
const model = envVars[tierVar];
if (model?.toLowerCase().endsWith('[1m]')) {
envVars[tierVar] = model.replace(/\[1m\]$/i, '');
}
}
if (extendedContextOverride === false) {
Object.assign(envVars, applyExtendedContextPreferenceToAnthropicModels(envVars, false));
return;
}
// Apply suffix to main model
if (envVars.ANTHROPIC_MODEL) {
envVars.ANTHROPIC_MODEL = applyExtendedContextSuffixShared(envVars.ANTHROPIC_MODEL);
}
// Apply to tier models if they support extended context
for (const tierVar of tierModels) {
const model = envVars[tierVar];
if (model) {
const tierCleanId = stripModelSuffixes(model);
if (shouldApplyExtendedContext(provider, tierCleanId, extendedContextOverride)) {
envVars[tierVar] = applyExtendedContextSuffixShared(model);
}
}
}
}
/**
* Strip thinking and extended context suffixes from model ID for catalog lookup.
* Examples:
* "gemini-2.5-pro(high)[1m]" -> "gemini-2.5-pro"
* "gemini-2.5-pro(8192)" -> "gemini-2.5-pro"
* "gemini-2.5-pro" -> "gemini-2.5-pro"
*/
function stripModelSuffixes(modelId: string): string {
return stripExtendedContextSuffix(modelId.trim()).replace(/\([^)]+\)$/, '');
Object.assign(
envVars,
applyExtendedContextPreferenceToAnthropicModels(envVars, true, {
supportsExtendedContext: (modelId) =>
shouldApplyExtendedContext(
provider,
stripModelConfigurationSuffixes(modelId),
extendedContextOverride
),
})
);
}
+2 -6
View File
@@ -6,14 +6,10 @@
*/
import type { CLIProxyProvider } from './types';
import { ANTHROPIC_MODEL_ENV_KEYS } from '../shared/extended-context-utils';
/** Env vars that carry model identifiers. */
export const MODEL_ENV_VAR_KEYS = [
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
] as const;
export const MODEL_ENV_VAR_KEYS = ANTHROPIC_MODEL_ENV_KEYS;
type ProviderLike = CLIProxyProvider | string | null | undefined;
+79 -8
View File
@@ -25,7 +25,13 @@ 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 { exitOnApiCommandErrors, parseApiCommandArgs } from './shared';
import {
applyClaudeExtendedContextPreference,
exitOnApiCommandErrors,
hasClaudeModelMapping,
hasExplicitClaudeExtendedContext,
parseApiCommandArgs,
} from './shared';
function resolvePresetOrExit(presetId?: string): ProviderPreset | null {
if (!presetId) {
@@ -276,6 +282,37 @@ async function resolveDefaultTarget(
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);
@@ -403,17 +440,31 @@ export async function handleApiCreateCommand(args: string[]): Promise<void> {
}
const apiKey = await resolveApiKey(parsedArgs.apiKey, preset);
const { model, models } = await resolveModelConfiguration(
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, models, target);
const result = createApiProfile(name, baseUrl || '', apiKey, finalModels, target);
if (!result.success) {
console.log(fail(`Failed to create API profile: ${result.error}`));
process.exit(1);
@@ -427,25 +478,45 @@ export async function handleApiCreateCommand(args: string[]): Promise<void> {
}
const hasCustomMapping =
models.opus !== model || models.sonnet !== model || models.haiku !== model;
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: ${model}\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: ${models.opus}\n` +
` Sonnet: ${models.sonnet}\n` +
` Haiku: ${models.haiku}`;
` 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') {
+87 -74
View File
@@ -8,6 +8,8 @@ import {
import { color, dim, fail, header, initUI, subheader } from '../../utils/ui';
import { sanitizeHelpText } from './shared';
type HelpWriter = (line: string) => void;
function renderPresetHelpLine(preset: ProviderPreset, idWidth: number): string {
const presetId = sanitizeHelpText(preset.id) || 'unknown';
const paddedId = presetId.padEnd(idWidth);
@@ -16,7 +18,7 @@ function renderPresetHelpLine(preset: ProviderPreset, idWidth: number): string {
return ` ${color(paddedId, 'command')} ${presetName} - ${presetDescription}`;
}
export async function showApiCommandHelp(): Promise<void> {
export async function showApiCommandHelp(writeLine: HelpWriter = console.log): Promise<void> {
await initUI();
const presetIds = getPresetIds()
.map((id) => sanitizeHelpText(id))
@@ -25,97 +27,108 @@ export async function showApiCommandHelp(): Promise<void> {
const presetAliases = getPresetAliases();
const presetIdWidth = Math.max(0, ...presetIds.map((id) => id.length)) + 2;
console.log(header('CCS API Management'));
console.log('');
console.log(subheader('Usage'));
console.log(` ${color('ccs api', 'command')} <command> [options]`);
console.log('');
console.log(subheader('Commands'));
console.log(` ${color('create [name]', 'command')} Create new API profile (interactive)`);
console.log(` ${color('list', 'command')} List all API profiles`);
console.log(
writeLine(header('CCS API Management'));
writeLine('');
writeLine(subheader('Usage'));
writeLine(` ${color('ccs api', 'command')} <command> [options]`);
writeLine('');
writeLine(subheader('Commands'));
writeLine(` ${color('create [name]', 'command')} Create new API profile (interactive)`);
writeLine(` ${color('list', 'command')} List all API profiles`);
writeLine(
` ${color('discover', 'command')} Discover orphan *.settings.json and register`
);
console.log(` ${color('copy <src> <dest>', 'command')} Duplicate API profile settings + config`);
console.log(
writeLine(` ${color('copy <src> <dest>', 'command')} Duplicate API profile settings + config`);
writeLine(
` ${color('export <name>', 'command')} Export profile bundle for cross-device transfer`
);
console.log(
` ${color('import <file>', 'command')} Import profile bundle and register profile`
);
console.log(` ${color('remove <name>', 'command')} Remove an API profile`);
console.log('');
console.log(subheader('Options'));
console.log(
writeLine(` ${color('import <file>', 'command')} Import profile bundle and register profile`);
writeLine(` ${color('remove <name>', 'command')} Remove an API profile`);
writeLine('');
writeLine(subheader('Options'));
writeLine(
` ${color('--preset <id>', 'command')} Use provider preset (${presetIds.join(', ')})`
);
console.log(
writeLine(
` ${color('--cliproxy-provider <id>', 'command')} Use routed CLIProxy provider (${cliproxyProviderIds.join(', ')})`
);
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(
writeLine(` ${color('--base-url <url>', 'command')} API base URL (create)`);
writeLine(` ${color('--api-key <key>', 'command')} API key (create)`);
writeLine(` ${color('--model <model>', 'command')} Default model (create)`);
writeLine(
` ${color('--1m / --no-1m', 'command')} Write or clear [1m] on compatible Claude mappings`
);
writeLine(
` ${color('--target <cli>', 'command')} Default target: claude or droid (create)`
);
console.log(` ${color('--register', 'command')} Register discovered orphan settings`);
console.log(` ${color('--json', 'command')} JSON output for discover command`);
console.log(` ${color('--out <file>', 'command')} Export bundle output path`);
console.log(` ${color('--include-secrets', 'command')} Include token in export bundle`);
console.log(` ${color('--name <name>', 'command')} Override profile name during import`);
console.log(
writeLine(` ${color('--register', 'command')} Register discovered orphan settings`);
writeLine(` ${color('--json', 'command')} JSON output for discover command`);
writeLine(` ${color('--out <file>', 'command')} Export bundle output path`);
writeLine(` ${color('--include-secrets', 'command')} Include token in export bundle`);
writeLine(` ${color('--name <name>', 'command')} Override profile name during import`);
writeLine(
` ${color('--force', 'command')} Overwrite existing or bypass validation (create/discover/copy/import)`
);
console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts`);
console.log('');
console.log(subheader('Provider Presets'));
PROVIDER_PRESETS.forEach((preset) => console.log(renderPresetHelpLine(preset, presetIdWidth)));
writeLine(` ${color('--yes, -y', 'command')} Skip confirmation prompts`);
writeLine('');
writeLine(subheader('Provider Presets'));
PROVIDER_PRESETS.forEach((preset) => writeLine(renderPresetHelpLine(preset, presetIdWidth)));
Object.entries(presetAliases).forEach(([alias, canonical]) => {
const safeAlias = sanitizeHelpText(alias);
const safeCanonical = sanitizeHelpText(canonical);
console.log(
` ${dim(`Legacy alias: --preset ${safeAlias} (auto-mapped to ${safeCanonical})`)}`
);
writeLine(` ${dim(`Legacy alias: --preset ${safeAlias} (auto-mapped to ${safeCanonical})`)}`);
});
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 anthropic', 'command')}`);
console.log(` ${color('ccs api create --preset openrouter', 'command')}`);
console.log(` ${color('ccs api create --preset alibaba-coding-plan', 'command')}`);
console.log(` ${color('ccs api create --preset alibaba', 'command')} ${dim('# alias')}`);
console.log(` ${color('ccs api create --preset glm', 'command')}`);
console.log('');
console.log(` ${dim('# Create routed profile from existing CLIProxy provider config')}`);
console.log(` ${color('ccs api create --cliproxy-provider gemini', 'command')}`);
console.log(
writeLine('');
writeLine(subheader('Examples'));
writeLine(` ${dim('# Interactive wizard')}`);
writeLine(` ${color('ccs api create', 'command')}`);
writeLine('');
writeLine(` ${dim('# Quick setup with preset')}`);
writeLine(` ${color('ccs api create --preset anthropic', 'command')}`);
writeLine(
` ${color('ccs api create --preset anthropic --1m', 'command')} ${dim('# explicit Claude [1m] opt-in')}`
);
writeLine(` ${color('ccs api create --preset openrouter', 'command')}`);
writeLine(` ${color('ccs api create --preset alibaba-coding-plan', 'command')}`);
writeLine(` ${color('ccs api create --preset alibaba', 'command')} ${dim('# alias')}`);
writeLine(` ${color('ccs api create --preset glm', 'command')}`);
writeLine('');
writeLine(subheader('Claude Long Context'));
writeLine(` ${dim('Plain Claude model IDs stay on standard context by default.')}`);
writeLine(
` ${dim('Use --1m during create to append [1m] to compatible Claude mappings, or --no-1m to force plain IDs.')}`
);
writeLine(
` ${dim('CCS controls only the saved [1m] suffix. Provider pricing/entitlement stay upstream, and some accounts can still return 429 for long-context requests.')}`
);
writeLine('');
writeLine(` ${dim('# Create routed profile from existing CLIProxy provider config')}`);
writeLine(` ${color('ccs api create --cliproxy-provider gemini', 'command')}`);
writeLine(
` ${color('ccs api create gemini-droid --cliproxy-provider gemini --target droid', 'command')}`
);
console.log('');
console.log(` ${dim('# Create with name')}`);
console.log(` ${color('ccs api create myapi', 'command')}`);
console.log(` ${color('ccs api create mydroid --preset glm --target droid', 'command')}`);
console.log('');
console.log(` ${dim('# Remove API profile')}`);
console.log(` ${color('ccs api remove myapi', 'command')}`);
console.log('');
console.log(` ${dim('# Discover and register orphan settings files')}`);
console.log(` ${color('ccs api discover', 'command')}`);
console.log(` ${color('ccs api discover --register', 'command')}`);
console.log('');
console.log(` ${dim('# Duplicate an existing API profile')}`);
console.log(` ${color('ccs api copy glm glm-backup', 'command')}`);
console.log('');
console.log(` ${dim('# Export and import across devices')}`);
console.log(` ${color('ccs api export glm --out ./glm.ccs-profile.json', 'command')}`);
console.log(` ${color('ccs api import ./glm.ccs-profile.json', 'command')}`);
console.log('');
console.log(` ${dim('# Show all API profiles')}`);
console.log(` ${color('ccs api list', 'command')}`);
console.log('');
writeLine('');
writeLine(` ${dim('# Create with name')}`);
writeLine(` ${color('ccs api create myapi', 'command')}`);
writeLine(` ${color('ccs api create mydroid --preset glm --target droid', 'command')}`);
writeLine('');
writeLine(` ${dim('# Remove API profile')}`);
writeLine(` ${color('ccs api remove myapi', 'command')}`);
writeLine('');
writeLine(` ${dim('# Discover and register orphan settings files')}`);
writeLine(` ${color('ccs api discover', 'command')}`);
writeLine(` ${color('ccs api discover --register', 'command')}`);
writeLine('');
writeLine(` ${dim('# Duplicate an existing API profile')}`);
writeLine(` ${color('ccs api copy glm glm-backup', 'command')}`);
writeLine('');
writeLine(` ${dim('# Export and import across devices')}`);
writeLine(` ${color('ccs api export glm --out ./glm.ccs-profile.json', 'command')}`);
writeLine(` ${color('ccs api import ./glm.ccs-profile.json', 'command')}`);
writeLine('');
writeLine(` ${dim('# Show all API profiles')}`);
writeLine(` ${color('ccs api list', 'command')}`);
writeLine('');
}
export async function showUnknownApiCommand(command: string): Promise<void> {
+62 -16
View File
@@ -10,22 +10,68 @@ import { handleApiRemoveCommand } from './remove-command';
export { parseApiCommandArgs } from './shared';
const API_COMMAND_ROUTES: readonly NamedCommandRoute[] = [
{ name: 'create', handle: handleApiCreateCommand },
{ name: 'list', handle: handleApiListCommand },
{ name: 'discover', handle: handleApiDiscoverCommand },
{ name: 'copy', handle: handleApiCopyCommand },
{ name: 'export', handle: handleApiExportCommand },
{ name: 'import', handle: handleApiImportCommand },
{ name: 'remove', aliases: ['delete', 'rm'], handle: handleApiRemoveCommand },
];
type ApiCommandHandler = (args: string[]) => Promise<void>;
type ApiCommandHelpHandler = () => Promise<void>;
type ApiCommandUnknownHandler = (command: string) => Promise<void>;
export interface ApiCommandDependencies {
help: ApiCommandHelpHandler;
unknown: ApiCommandUnknownHandler;
create: ApiCommandHandler;
list: ApiCommandHandler;
discover: ApiCommandHandler;
copy: ApiCommandHandler;
export: ApiCommandHandler;
import: ApiCommandHandler;
remove: ApiCommandHandler;
}
const DEFAULT_API_COMMAND_DEPENDENCIES: ApiCommandDependencies = {
help: showApiCommandHelp,
unknown: showUnknownApiCommand,
create: handleApiCreateCommand,
list: handleApiListCommand,
discover: handleApiDiscoverCommand,
copy: handleApiCopyCommand,
export: handleApiExportCommand,
import: handleApiImportCommand,
remove: handleApiRemoveCommand,
};
function createApiCommandRoutes(
dependencies: ApiCommandDependencies
): readonly NamedCommandRoute[] {
return [
{ name: 'create', handle: dependencies.create },
{ name: 'list', handle: dependencies.list },
{ name: 'discover', handle: dependencies.discover },
{ name: 'copy', handle: dependencies.copy },
{ name: 'export', handle: dependencies.export },
{ name: 'import', handle: dependencies.import },
{ name: 'remove', aliases: ['delete', 'rm'], handle: dependencies.remove },
];
}
export function createApiCommandHandler(
overrides: Partial<ApiCommandDependencies> = {}
): (args: string[]) => Promise<void> {
const dependencies: ApiCommandDependencies = {
...DEFAULT_API_COMMAND_DEPENDENCIES,
...overrides,
};
const routes = createApiCommandRoutes(dependencies);
return async (args: string[]) => {
await dispatchNamedCommand({
args,
routes,
onHelp: dependencies.help,
onUnknown: dependencies.unknown,
allowEmptyHelp: true,
});
};
}
export async function handleApiCommand(args: string[]): Promise<void> {
await dispatchNamedCommand({
args,
routes: API_COMMAND_ROUTES,
onHelp: showApiCommandHelp,
onUnknown: showUnknownApiCommand,
allowEmptyHelp: true,
});
await createApiCommandHandler()(args);
}
+53 -1
View File
@@ -1,4 +1,12 @@
import type { ModelMapping } from '../../api/services';
import type { TargetType } from '../../targets/target-adapter';
import {
applyExtendedContextSuffix,
hasExtendedContextSuffix,
isClaudeModelId,
likelySupportsClaudeExtendedContext,
stripExtendedContextSuffix,
} from '../../shared/extended-context-utils';
import { fail } from '../../utils/ui';
import { extractOption, hasAnyFlag, scanCommandArgs } from '../arg-extractor';
@@ -11,12 +19,15 @@ export interface ApiCommandArgs {
preset?: string;
cliproxyProvider?: string;
target?: TargetType;
extendedContext?: boolean;
force?: boolean;
yes?: boolean;
errors: string[];
}
export const API_BOOLEAN_FLAGS = ['--force', '--yes', '-y'] as const;
const MODEL_MAPPING_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const;
export const API_BOOLEAN_FLAGS = ['--force', '--yes', '-y', '--1m', '--no-1m'] as const;
export const API_VALUE_FLAGS = [
'--base-url',
'--api-key',
@@ -155,6 +166,8 @@ export function parseApiCommandArgs(
args: string[],
options: ParseApiCommandArgsOptions = {}
): ApiCommandArgs {
const enableExtendedContext = hasAnyFlag(args, ['--1m']);
const disableExtendedContext = hasAnyFlag(args, ['--no-1m']);
const result: ApiCommandArgs = {
positionals: [],
force: hasAnyFlag(args, ['--force']),
@@ -162,6 +175,14 @@ export function parseApiCommandArgs(
errors: [],
};
if (enableExtendedContext && disableExtendedContext) {
result.errors.push('Cannot combine --1m and --no-1m');
} else if (enableExtendedContext) {
result.extendedContext = true;
} else if (disableExtendedContext) {
result.extendedContext = false;
}
let remaining = [...args];
remaining = applyRepeatedOption(
@@ -251,6 +272,37 @@ export function parseApiCommandArgs(
return result;
}
export function hasClaudeModelMapping(models: ModelMapping): boolean {
return MODEL_MAPPING_KEYS.some((key) => isClaudeModelId(models[key]));
}
export function hasExplicitClaudeExtendedContext(models: ModelMapping): boolean {
return MODEL_MAPPING_KEYS.some(
(key) => isClaudeModelId(models[key]) && hasExtendedContextSuffix(models[key])
);
}
export function applyClaudeExtendedContextPreference(
models: ModelMapping,
enabled: boolean
): ModelMapping {
const nextModels = { ...models };
for (const key of MODEL_MAPPING_KEYS) {
const value = nextModels[key];
if (!isClaudeModelId(value)) {
continue;
}
nextModels[key] =
enabled && likelySupportsClaudeExtendedContext(value)
? applyExtendedContextSuffix(value)
: stripExtendedContextSuffix(value);
}
return nextModels;
}
export function exitOnApiCommandErrors(errors: string[]): void {
if (errors.length === 0) {
return;
+339 -248
View File
@@ -5,6 +5,8 @@ import { isUnifiedMode } from '../config/unified-config-loader';
import { getCcsDir, getCcsDirSource } from '../utils/config-manager';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
type HelpWriter = (line: string) => void;
// Get version from package.json (same as version-command.ts)
const VERSION = JSON.parse(
fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8')
@@ -19,28 +21,33 @@ const VERSION = JSON.parse(
*
* command Description
*/
function printMajorSection(title: string, subtitles: string[], items: [string, string][]): void {
function printMajorSection(
title: string,
subtitles: string[],
items: [string, string][],
writeLine: HelpWriter = console.log
): void {
// Section header with ═══ borders
console.log(sectionHeader(title));
writeLine(sectionHeader(title));
// Subtitles on separate lines (dim)
for (const subtitle of subtitles) {
console.log(` ${dim(subtitle)}`);
writeLine(` ${dim(subtitle)}`);
}
// Empty line before items
console.log('');
writeLine('');
// Calculate max command length for alignment
const maxCmdLen = Math.max(...items.map(([cmd]) => cmd.length));
for (const [cmd, desc] of items) {
const paddedCmd = cmd.padEnd(maxCmdLen + 2);
console.log(` ${color(paddedCmd, 'command')} ${desc}`);
writeLine(` ${color(paddedCmd, 'command')} ${desc}`);
}
// Extra spacing after section
console.log('');
writeLine('');
}
/**
@@ -49,20 +56,24 @@ function printMajorSection(title: string, subtitles: string[], items: [string, s
* Title (context):
* command Description
*/
function printSubSection(title: string, items: [string, string][]): void {
function printSubSection(
title: string,
items: [string, string][],
writeLine: HelpWriter = console.log
): void {
// Sub-section header (colored, no borders)
console.log(subheader(`${title}:`));
writeLine(subheader(`${title}:`));
// Calculate max command length for alignment
const maxCmdLen = Math.max(...items.map(([cmd]) => cmd.length));
for (const [cmd, desc] of items) {
const paddedCmd = cmd.padEnd(maxCmdLen + 2);
console.log(` ${color(paddedCmd, 'command')} ${desc}`);
writeLine(` ${color(paddedCmd, 'command')} ${desc}`);
}
// Spacing after section
console.log('');
writeLine('');
}
/**
@@ -71,24 +82,28 @@ function printSubSection(title: string, items: [string, string][]): void {
* Title:
* Label: path
*/
function printConfigSection(title: string, items: [string, string][]): void {
console.log(subheader(`${title}:`));
function printConfigSection(
title: string,
items: [string, string][],
writeLine: HelpWriter = console.log
): void {
writeLine(subheader(`${title}:`));
// Calculate max label length for alignment
const maxLabelLen = Math.max(...items.map(([label]) => label.length));
for (const [label, path] of items) {
const paddedLabel = label.padEnd(maxLabelLen);
console.log(` ${paddedLabel} ${color(path, 'path')}`);
writeLine(` ${paddedLabel} ${color(path, 'path')}`);
}
console.log('');
writeLine('');
}
/**
* Display comprehensive help information for CCS (Claude Code Switch)
*/
export async function handleHelpCommand(): Promise<void> {
export async function handleHelpCommand(writeLine: HelpWriter = console.log): Promise<void> {
// Initialize UI (if not already)
await initUI();
@@ -103,24 +118,24 @@ Claude Code Profile & Model Switcher
Run ${color('ccs config', 'command')} for web dashboard`.trim();
console.log(
writeLine(
box(logo, {
padding: 1,
borderStyle: 'round',
titleAlignment: 'center',
})
);
console.log('');
writeLine('');
// Resolve display path for dynamic sections
const [dirSource] = getCcsDirSource();
const dirDisplay = dirSource === 'default' ? '~/.ccs' : getCcsDir();
// Usage section
console.log(subheader('Usage:'));
console.log(` ${color('ccs', 'command')} [profile] [claude-args...]`);
console.log(` ${color('ccs', 'command')} [flags]`);
console.log('');
writeLine(subheader('Usage:'));
writeLine(` ${color('ccs', 'command')} [profile] [claude-args...]`);
writeLine(` ${color('ccs', 'command')} [flags]`);
writeLine('');
// ═══════════════════════════════════════════════════════════════════════════
// MAJOR SECTION 1: API Key Profiles
@@ -152,7 +167,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['ccs api import <file>', 'Import profile bundle'],
['ccs api remove', 'Remove an API profile'],
['ccs api list', 'List all API profiles'],
]
],
writeLine
);
// ═══════════════════════════════════════════════════════════════════════════
@@ -176,7 +192,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['ccs auth default <name>', 'Set default profile'],
['ccs auth reset-default', 'Restore original CCS default'],
['ccs cliproxy auth claude', 'Alternative: authenticate Claude account pool via CLIProxy'],
]
],
writeLine
);
// ═══════════════════════════════════════════════════════════════════════════
@@ -220,8 +237,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
'Set thinking budget (low/medium/high/xhigh/auto/off or number)',
],
['ccs codex --effort <level>', 'Set codex reasoning effort (medium/high/xhigh)'],
['ccs <provider> --1m', 'Enable 1M token context window'],
['ccs <provider> --no-1m', 'Disable 1M context (use 200K default)'],
['ccs <provider> --1m', 'Request explicit 1M context when the selected model supports [1m]'],
['ccs <provider> --no-1m', 'Force standard context / clear [1m]'],
['ccs <provider> --logout', 'Clear authentication'],
['ccs <provider> --headless', 'Headless auth (for SSH)'],
['ccs <provider> --port-forward', 'Force port-forwarding mode (skip prompt)'],
@@ -232,7 +249,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['ccs kiro --import', 'Import token from Kiro IDE'],
['ccs kiro --incognito', 'Use incognito browser (default: normal)'],
['ccs codex "explain code"', 'Use with prompt'],
]
],
writeLine
);
// ═══════════════════════════════════════════════════════════════════════════
@@ -255,7 +273,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['ccs copilot stop', 'Stop copilot-api daemon'],
['ccs copilot enable', 'Enable integration'],
['ccs copilot disable', 'Disable integration'],
]
],
writeLine
);
// ═══════════════════════════════════════════════════════════════════════════
@@ -277,7 +296,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['ccs cursor stop', 'Stop proxy daemon'],
['ccs cursor enable', 'Enable cursor integration'],
['ccs cursor disable', 'Disable cursor integration'],
]
],
writeLine
);
// ═══════════════════════════════════════════════════════════════════════════
@@ -285,280 +305,351 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
// ═══════════════════════════════════════════════════════════════════════════
// Delegation
printSubSection('Delegation (inside Claude Code CLI)', [
['/ccs "task"', 'Delegate task (auto-selects profile)'],
['/ccs --glm "task"', 'Force GLM-5 for simple tasks'],
['/ccs --kimi "task"', 'Force Kimi OAuth for long context'],
['/ccs --km "task"', 'Force Kimi API key for long context'],
['/ccs:continue "follow-up"', 'Continue last delegation session'],
]);
printSubSection(
'Delegation (inside Claude Code CLI)',
[
['/ccs "task"', 'Delegate task (auto-selects profile)'],
['/ccs --glm "task"', 'Force GLM-5 for simple tasks'],
['/ccs --kimi "task"', 'Force Kimi OAuth for long context'],
['/ccs --km "task"', 'Force Kimi API key for long context'],
['/ccs:continue "follow-up"', 'Continue last delegation session'],
],
writeLine
);
// Delegation CLI Flags (Claude Code passthrough)
printSubSection('Delegation Flags (Claude Code passthrough)', [
['--max-turns <n>', 'Limit agentic turns (prevents loops)'],
['--fallback-model <model>', 'Auto-fallback on overload (sonnet)'],
['--agents <json>', 'Inject dynamic subagents'],
['--betas <features>', 'Enable experimental features'],
['--allowedTools <list>', 'Restrict available tools'],
['--disallowedTools <list>', 'Block specific tools'],
]);
printSubSection(
'Delegation Flags (Claude Code passthrough)',
[
['--max-turns <n>', 'Limit agentic turns (prevents loops)'],
['--fallback-model <model>', 'Auto-fallback on overload (sonnet)'],
['--agents <json>', 'Inject dynamic subagents'],
['--betas <features>', 'Enable experimental features'],
['--allowedTools <list>', 'Restrict available tools'],
['--disallowedTools <list>', 'Block specific tools'],
],
writeLine
);
// Diagnostics
printSubSection('Diagnostics', [
['ccs setup', 'First-time setup wizard'],
['ccs doctor', 'Run health check and diagnostics'],
['ccs cleanup', 'Remove old CLIProxy logs'],
['ccs config', 'Open web dashboard (includes Claude IDE Extension setup page)'],
['ccs config auth setup', 'Configure dashboard login'],
['ccs config auth show', 'Show dashboard auth status'],
['ccs config channels', 'Show Official Channels status'],
printSubSection(
'Diagnostics',
[
'ccs config channels --set telegram,discord',
'Auto-add Telegram + Discord on supported native Claude runs',
['ccs setup', 'First-time setup wizard'],
['ccs doctor', 'Run health check and diagnostics'],
['ccs cleanup', 'Remove old CLIProxy logs'],
['ccs config', 'Open web dashboard (includes Claude IDE Extension setup page)'],
['ccs config auth setup', 'Configure dashboard login'],
['ccs config auth show', 'Show dashboard auth status'],
['ccs config channels', 'Show Official Channels status'],
[
'ccs config channels --set telegram,discord',
'Auto-add Telegram + Discord on supported native Claude runs',
],
['ccs config channels --set-token telegram=<token>', 'Save TELEGRAM_BOT_TOKEN'],
['ccs config image-analysis', 'Show image analysis settings'],
['ccs config image-analysis --enable', 'Enable image analysis'],
['ccs config thinking', 'Show thinking/reasoning settings'],
['ccs config thinking --mode auto', 'Set thinking mode'],
['ccs config thinking --clear-provider-override codex', 'Clear provider overrides'],
['ccs config --port 3000', 'Use specific port'],
['ccs config --host 0.0.0.0', 'Force all-interface binding for remote devices'],
['ccs persist <profile>', 'Write profile setup to ~/.claude/settings.json'],
['ccs persist --list-backups', 'List available settings.json backups'],
['ccs persist --restore', 'Restore settings.json from latest backup'],
['ccs sync', 'Sync delegation commands and skills'],
['ccs update', 'Update CCS to latest version'],
['ccs update --force', 'Force reinstall current version'],
['ccs update --beta', 'Install from dev channel (unstable)'],
],
['ccs config channels --set-token telegram=<token>', 'Save TELEGRAM_BOT_TOKEN'],
['ccs config image-analysis', 'Show image analysis settings'],
['ccs config image-analysis --enable', 'Enable image analysis'],
['ccs config thinking', 'Show thinking/reasoning settings'],
['ccs config thinking --mode auto', 'Set thinking mode'],
['ccs config thinking --clear-provider-override codex', 'Clear provider overrides'],
['ccs config --port 3000', 'Use specific port'],
['ccs config --host 0.0.0.0', 'Force all-interface binding for remote devices'],
['ccs persist <profile>', 'Write profile setup to ~/.claude/settings.json'],
['ccs persist --list-backups', 'List available settings.json backups'],
['ccs persist --restore', 'Restore settings.json from latest backup'],
['ccs sync', 'Sync delegation commands and skills'],
['ccs update', 'Update CCS to latest version'],
['ccs update --force', 'Force reinstall current version'],
['ccs update --beta', 'Install from dev channel (unstable)'],
]);
writeLine
);
// Environment export
printSubSection('Environment Export', [
['ccs env <profile>', 'Export env vars for third-party tools'],
['ccs env <profile> --format openai', 'OpenAI-compatible vars (OpenCode/Cursor)'],
['ccs env <profile> --format anthropic', 'Anthropic vars (default)'],
['ccs env <profile> --format raw', 'All effective env vars'],
printSubSection(
'Environment Export',
[
'ccs env <profile> --format claude-extension --ide vscode',
'VS Code/Cursor Claude extension settings JSON',
['ccs env <profile>', 'Export env vars for third-party tools'],
['ccs env <profile> --format openai', 'OpenAI-compatible vars (OpenCode/Cursor)'],
['ccs env <profile> --format anthropic', 'Anthropic vars (default)'],
['ccs env <profile> --format raw', 'All effective env vars'],
[
'ccs env <profile> --format claude-extension --ide vscode',
'VS Code/Cursor Claude extension settings JSON',
],
[
'ccs env <profile> --format claude-extension --ide windsurf',
'Windsurf Claude extension settings JSON',
],
['ccs env <profile> --shell fish', 'Fish shell syntax'],
],
[
'ccs env <profile> --format claude-extension --ide windsurf',
'Windsurf Claude extension settings JSON',
],
['ccs env <profile> --shell fish', 'Fish shell syntax'],
]);
writeLine
);
// Flags
printSubSection('Flags', [
['--config-dir <path>', 'Use custom CCS config directory'],
['--target <cli>', 'Target CLI: claude (default), droid'],
['-h, --help', 'Show this help message'],
['-v, --version', 'Show version and installation info'],
['-sc, --shell-completion', 'Install shell auto-completion'],
]);
printSubSection(
'Flags',
[
['--config-dir <path>', 'Use custom CCS config directory'],
['--target <cli>', 'Target CLI: claude (default), droid'],
['-h, --help', 'Show this help message'],
['-v, --version', 'Show version and installation info'],
['-sc, --shell-completion', 'Install shell auto-completion'],
],
writeLine
);
// Aliases
printSubSection('Aliases', [
['ccs-droid <profile> [args]', 'Explicit Droid runtime alias'],
['ccsd <profile> [args]', 'Legacy shortcut for: ccs-droid <profile> [args]'],
]);
printSubSection(
'Aliases',
[
['ccs-droid <profile> [args]', 'Explicit Droid runtime alias'],
['ccsd <profile> [args]', 'Legacy shortcut for: ccs-droid <profile> [args]'],
],
writeLine
);
// Multi-target examples
printSubSection('Multi-Target', [
['ccs glm --target droid', 'Run GLM profile on Droid CLI'],
['ccs-droid glm', 'Same as above (explicit alias)'],
['ccsd glm', 'Legacy shortcut for ccs-droid'],
['ccs-droid codex', 'Run built-in CLIProxy Codex profile on Droid'],
['ccs-droid agy', 'Run built-in CLIProxy Antigravity profile on Droid'],
printSubSection(
'Multi-Target',
[
'ccs-droid codex exec --skip-permissions-unsafe "fix failing tests"',
'Pass through Droid exec mode',
['ccs glm --target droid', 'Run GLM profile on Droid CLI'],
['ccs-droid glm', 'Same as above (explicit alias)'],
['ccsd glm', 'Legacy shortcut for ccs-droid'],
['ccs-droid codex', 'Run built-in CLIProxy Codex profile on Droid'],
['ccs-droid agy', 'Run built-in CLIProxy Antigravity profile on Droid'],
[
'ccs-droid codex exec --skip-permissions-unsafe "fix failing tests"',
'Pass through Droid exec mode',
],
[
'ccs-droid codex -m custom:gpt-5.3-codex "fix failing tests"',
'Auto-routes short exec flags',
],
[
'ccs-droid codex --skip-permissions-unsafe "fix failing tests"',
'Auto-routes to Droid exec when exec-only flags are detected',
],
[
'ccs cliproxy create my-codex --provider codex --target droid',
'Create CLIProxy variant with Droid as default target',
],
['ccs glm', 'Run GLM profile on Claude Code (default)'],
],
['ccs-droid codex -m custom:gpt-5.3-codex "fix failing tests"', 'Auto-routes short exec flags'],
[
'ccs-droid codex --skip-permissions-unsafe "fix failing tests"',
'Auto-routes to Droid exec when exec-only flags are detected',
],
[
'ccs cliproxy create my-codex --provider codex --target droid',
'Create CLIProxy variant with Droid as default target',
],
['ccs glm', 'Run GLM profile on Claude Code (default)'],
]);
writeLine
);
// Configuration
printConfigSection('Configuration', [
['Config File:', isUnifiedMode() ? `${dirDisplay}/config.yaml` : `${dirDisplay}/config.json`],
['Profiles:', `${dirDisplay}/profiles.json`],
['Instances:', `${dirDisplay}/instances/`],
['Settings:', `${dirDisplay}/*.settings.json`],
]);
printConfigSection(
'Configuration',
[
['Config File:', isUnifiedMode() ? `${dirDisplay}/config.yaml` : `${dirDisplay}/config.json`],
['Profiles:', `${dirDisplay}/profiles.json`],
['Instances:', `${dirDisplay}/instances/`],
['Settings:', `${dirDisplay}/*.settings.json`],
],
writeLine
);
// CLI Proxy management
printSubSection('CLI Proxy Plus Management', [
['ccs cliproxy', 'Show CLIProxy Plus status and version'],
['ccs cliproxy --help', 'Full CLIProxy Plus management help'],
['ccs cliproxy doctor', 'Quota diagnostics (Antigravity)'],
['ccs cliproxy --install <ver>', 'Install specific version (e.g., 6.6.6)'],
['ccs cliproxy --latest', 'Update to latest version'],
['', ''], // Spacer
['ccs cliproxy pause <p> <a>', 'Pause account from rotation'],
['ccs cliproxy resume <p> <a>', 'Resume paused account'],
['ccs cliproxy status', 'Show CLIProxy process status'],
['ccs cliproxy quota', 'Show quota/tier/pause status for all providers'],
['ccs cliproxy quota --provider <name>', 'Show quota/tier/pause status for one provider'],
]);
printSubSection(
'CLI Proxy Plus Management',
[
['ccs cliproxy', 'Show CLIProxy Plus status and version'],
['ccs cliproxy --help', 'Full CLIProxy Plus management help'],
['ccs cliproxy doctor', 'Quota diagnostics (Antigravity)'],
['ccs cliproxy --install <ver>', 'Install specific version (e.g., 6.6.6)'],
['ccs cliproxy --latest', 'Update to latest version'],
['', ''], // Spacer
['ccs cliproxy pause <p> <a>', 'Pause account from rotation'],
['ccs cliproxy resume <p> <a>', 'Resume paused account'],
['ccs cliproxy status', 'Show CLIProxy process status'],
['ccs cliproxy quota', 'Show quota/tier/pause status for all providers'],
['ccs cliproxy quota --provider <name>', 'Show quota/tier/pause status for one provider'],
],
writeLine
);
// CLI Proxy configuration flags (new)
printSubSection('CLI Proxy Configuration', [
['--proxy-host <host>', 'Remote proxy hostname/IP'],
['--proxy-port <port>', `Proxy port (default: ${CLIPROXY_DEFAULT_PORT})`],
['--proxy-protocol <proto>', 'Protocol: http or https (default: http)'],
['--proxy-auth-token <token>', 'Auth token for remote proxy'],
['--proxy-timeout <ms>', 'Connection timeout in ms (default: 2000)'],
['--local-proxy', 'Force local mode, ignore remote config'],
['--remote-only', 'Fail if remote unreachable (no fallback)'],
['--allow-self-signed', 'Allow self-signed certs (for dev proxies)'],
]);
printSubSection(
'CLI Proxy Configuration',
[
['--proxy-host <host>', 'Remote proxy hostname/IP'],
['--proxy-port <port>', `Proxy port (default: ${CLIPROXY_DEFAULT_PORT})`],
['--proxy-protocol <proto>', 'Protocol: http or https (default: http)'],
['--proxy-auth-token <token>', 'Auth token for remote proxy'],
['--proxy-timeout <ms>', 'Connection timeout in ms (default: 2000)'],
['--local-proxy', 'Force local mode, ignore remote config'],
['--remote-only', 'Fail if remote unreachable (no fallback)'],
['--allow-self-signed', 'Allow self-signed certs (for dev proxies)'],
],
writeLine
);
// W3: Thinking Budget explanation
printSubSection('Extended Thinking / Reasoning', [
['--thinking off', 'Disable extended thinking'],
['--thinking auto', 'Let model decide dynamically'],
['--thinking low', '1K tokens - Quick responses'],
['--thinking medium', '8K tokens - Standard analysis'],
['--thinking high', '24K tokens - Deep reasoning'],
['--thinking xhigh', '32K tokens - Maximum depth'],
['--thinking <number>', 'Custom token budget (512-100000)'],
['', ''],
['--effort <level>', 'Codex alias for reasoning effort (medium/high/xhigh)'],
['--effort xhigh', 'Pin Codex effort to xhigh for this run'],
['', ''],
['Droid exec:', 'Use native Droid flag: --reasoning-effort <level>'],
['', 'CCS auto-maps --thinking/--effort to --reasoning-effort in droid exec mode.'],
['', 'For interactive droid sessions, CCS applies reasoning via Droid BYOK model config.'],
['', 'When multiple reasoning flags are provided, the first flag wins.'],
['', ''],
['Note:', 'Extended thinking allocates compute for step-by-step reasoning'],
['', 'before responding.'],
['', 'Providers: agy/gemini use --thinking, codex uses --effort (or --thinking alias).'],
['', 'Codex model suffixes also pin effort: -medium / -high / -xhigh.'],
]);
printSubSection(
'Extended Thinking / Reasoning',
[
['--thinking off', 'Disable extended thinking'],
['--thinking auto', 'Let model decide dynamically'],
['--thinking low', '1K tokens - Quick responses'],
['--thinking medium', '8K tokens - Standard analysis'],
['--thinking high', '24K tokens - Deep reasoning'],
['--thinking xhigh', '32K tokens - Maximum depth'],
['--thinking <number>', 'Custom token budget (512-100000)'],
['', ''],
['--effort <level>', 'Codex alias for reasoning effort (medium/high/xhigh)'],
['--effort xhigh', 'Pin Codex effort to xhigh for this run'],
['', ''],
['Droid exec:', 'Use native Droid flag: --reasoning-effort <level>'],
['', 'CCS auto-maps --thinking/--effort to --reasoning-effort in droid exec mode.'],
['', 'For interactive droid sessions, CCS applies reasoning via Droid BYOK model config.'],
['', 'When multiple reasoning flags are provided, the first flag wins.'],
['', ''],
['Note:', 'Extended thinking allocates compute for step-by-step reasoning'],
['', 'before responding.'],
['', 'Providers: agy/gemini use --thinking, codex uses --effort (or --thinking alias).'],
['', 'Codex model suffixes also pin effort: -medium / -high / -xhigh.'],
],
writeLine
);
// Extended Context (1M)
printSubSection('Extended Context (--1m)', [
['--1m', 'Enable 1M token context window'],
['--no-1m', 'Disable 1M context (use 200K default)'],
['', ''],
['Auto behavior:', 'Gemini models: auto-enabled by default'],
['', 'Claude models: opt-in with --1m flag'],
['', ''],
['Note:', 'Extended context enables 1M token window via [1m] suffix.'],
['', 'Premium pricing: 2x input for >200K tokens.'],
]);
printSubSection(
'Extended Context (--1m)',
[
['--1m', 'Request 1M token context when the selected model supports [1m]'],
['--no-1m', 'Force standard context (Claude default stays plain)'],
['', ''],
['Auto behavior:', 'Gemini models: CCS auto-adds [1m] when supported'],
['', 'Claude models: plain by default, opt-in with --1m or saved [1m]'],
['', ''],
['Note:', 'CCS only controls the saved [1m] suffix.'],
['', 'Provider pricing and entitlement stay upstream.'],
[
'',
'Some accounts/providers can still return 429 extra-usage errors for long-context requests.',
],
],
writeLine
);
// Image Analysis
printSubSection('Image Analysis (CLIProxy vision)', [
['ccs config image-analysis', 'Show current settings'],
['ccs config image-analysis --enable', 'Enable for CLIProxy providers'],
['ccs config image-analysis --disable', 'Disable (use native Read)'],
['ccs config image-analysis --timeout 120', 'Set analysis timeout'],
['ccs config image-analysis --set-model <p> <m>', 'Set provider model'],
['', ''],
['Note:', 'When enabled, images/PDFs are analyzed via vision models'],
['', 'instead of passing raw data to Claude. Works with CLIProxy'],
['', 'providers (agy, gemini, codex, kiro, ghcp).'],
]);
printSubSection('Official Channels (official Claude plugins)', [
['ccs config', 'Dashboard -> Settings -> Channels (fastest path)'],
['ccs config channels', 'Show current status'],
printSubSection(
'Image Analysis (CLIProxy vision)',
[
'ccs config channels --set telegram,discord',
'Auto-add selected channels on native Claude default/account sessions',
['ccs config image-analysis', 'Show current settings'],
['ccs config image-analysis --enable', 'Enable for CLIProxy providers'],
['ccs config image-analysis --disable', 'Disable (use native Read)'],
['ccs config image-analysis --timeout 120', 'Set analysis timeout'],
['ccs config image-analysis --set-model <p> <m>', 'Set provider model'],
['', ''],
['Note:', 'When enabled, images/PDFs are analyzed via vision models'],
['', 'instead of passing raw data to Claude. Works with CLIProxy'],
['', 'providers (agy, gemini, codex, kiro, ghcp).'],
],
['ccs config channels --set all', 'Enable Telegram, Discord, and iMessage'],
['ccs config channels --unattended', 'Also add --dangerously-skip-permissions'],
['ccs config channels --set-token telegram=<token>', 'Save TELEGRAM_BOT_TOKEN'],
['ccs config channels --set-token discord=<token>', 'Save DISCORD_BOT_TOKEN'],
['ccs config channels --clear-token [channel]', 'Remove one or all saved channel tokens'],
['', ''],
['', 'Fastest path: turn on the channel, save the token if needed, then run ccs.'],
['Note:', 'Runtime-only. Applies to native Claude default/account sessions only.'],
['', 'Not supported for ccs glm, other API/OAuth profiles, or Droid targets.'],
['', 'Telegram/Discord tokens live in ~/.claude/channels/<channel>/.env.'],
['', 'Current-process TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN also work for that launch.'],
['', 'iMessage is macOS-only and requires local OS permissions instead of a bot token.'],
]);
writeLine
);
printSubSection(
'Official Channels (official Claude plugins)',
[
['ccs config', 'Dashboard -> Settings -> Channels (fastest path)'],
['ccs config channels', 'Show current status'],
[
'ccs config channels --set telegram,discord',
'Auto-add selected channels on native Claude default/account sessions',
],
['ccs config channels --set all', 'Enable Telegram, Discord, and iMessage'],
['ccs config channels --unattended', 'Also add --dangerously-skip-permissions'],
['ccs config channels --set-token telegram=<token>', 'Save TELEGRAM_BOT_TOKEN'],
['ccs config channels --set-token discord=<token>', 'Save DISCORD_BOT_TOKEN'],
['ccs config channels --clear-token [channel]', 'Remove one or all saved channel tokens'],
['', ''],
['', 'Fastest path: turn on the channel, save the token if needed, then run ccs.'],
['Note:', 'Runtime-only. Applies to native Claude default/account sessions only.'],
['', 'Not supported for ccs glm, other API/OAuth profiles, or Droid targets.'],
['', 'Telegram/Discord tokens live in ~/.claude/channels/<channel>/.env.'],
['', 'Current-process TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN also work for that launch.'],
['', 'iMessage is macOS-only and requires local OS permissions instead of a bot token.'],
],
writeLine
);
// CCS Environment Variables
printSubSection('Environment Variables', [
['CCS_DIR', 'Override CCS config directory (default: ~/.ccs)'],
['CCS_HOME', 'Override home directory (legacy, appends .ccs)'],
['CCS_DEBUG', 'Enable debug logging'],
['CCS_THINKING', 'Override thinking level (flag > env > config)'],
]);
printSubSection(
'Environment Variables',
[
['CCS_DIR', 'Override CCS config directory (default: ~/.ccs)'],
['CCS_HOME', 'Override home directory (legacy, appends .ccs)'],
['CCS_DEBUG', 'Enable debug logging'],
['CCS_THINKING', 'Override thinking level (flag > env > config)'],
],
writeLine
);
// CLI Proxy env vars
printSubSection('CLI Proxy Environment Variables', [
['CCS_PROXY_HOST', 'Remote proxy hostname'],
['CCS_PROXY_PORT', 'Proxy port'],
['CCS_PROXY_PROTOCOL', 'Protocol (http/https)'],
['CCS_PROXY_AUTH_TOKEN', 'Auth token'],
['CCS_PROXY_TIMEOUT', 'Connection timeout in ms'],
['CCS_PROXY_FALLBACK_ENABLED', 'Enable local fallback (1/0)'],
['CCS_ALLOW_SELF_SIGNED', 'Allow self-signed certs (1/0)'],
]);
printSubSection(
'CLI Proxy Environment Variables',
[
['CCS_PROXY_HOST', 'Remote proxy hostname'],
['CCS_PROXY_PORT', 'Proxy port'],
['CCS_PROXY_PROTOCOL', 'Protocol (http/https)'],
['CCS_PROXY_AUTH_TOKEN', 'Auth token'],
['CCS_PROXY_TIMEOUT', 'Connection timeout in ms'],
['CCS_PROXY_FALLBACK_ENABLED', 'Enable local fallback (1/0)'],
['CCS_ALLOW_SELF_SIGNED', 'Allow self-signed certs (1/0)'],
],
writeLine
);
// CLI Proxy paths
console.log(subheader('CLI Proxy:'));
console.log(` Binary: ${color(`${dirDisplay}/cliproxy/bin/cli-proxy-api-plus`, 'path')}`);
console.log(` Config: ${color(`${dirDisplay}/cliproxy/config.yaml`, 'path')}`);
console.log(` Auth: ${color(`${dirDisplay}/cliproxy/auth/`, 'path')}`);
console.log(` ${dim(`Port: ${CLIPROXY_DEFAULT_PORT} (default)`)}`);
console.log('');
writeLine(subheader('CLI Proxy:'));
writeLine(` Binary: ${color(`${dirDisplay}/cliproxy/bin/cli-proxy-api-plus`, 'path')}`);
writeLine(` Config: ${color(`${dirDisplay}/cliproxy/config.yaml`, 'path')}`);
writeLine(` Auth: ${color(`${dirDisplay}/cliproxy/auth/`, 'path')}`);
writeLine(` ${dim(`Port: ${CLIPROXY_DEFAULT_PORT} (default)`)}`);
writeLine('');
// Shared Data
console.log(subheader('Shared Data:'));
console.log(` Commands: ${color(`${dirDisplay}/shared/commands/`, 'path')}`);
console.log(` Skills: ${color(`${dirDisplay}/shared/skills/`, 'path')}`);
console.log(` Agents: ${color(`${dirDisplay}/shared/agents/`, 'path')}`);
console.log(` ${dim('Note: Symlinked across all profiles')}`);
console.log('');
writeLine(subheader('Shared Data:'));
writeLine(` Commands: ${color(`${dirDisplay}/shared/commands/`, 'path')}`);
writeLine(` Skills: ${color(`${dirDisplay}/shared/skills/`, 'path')}`);
writeLine(` Agents: ${color(`${dirDisplay}/shared/agents/`, 'path')}`);
writeLine(` ${dim('Note: Symlinked across all profiles')}`);
writeLine('');
// Examples (aligned with consistent spacing)
console.log(subheader('Examples:'));
console.log(` $ ${color('ccs', 'command')} ${dim('# Use default account')}`);
console.log(
writeLine(subheader('Examples:'));
writeLine(` $ ${color('ccs', 'command')} ${dim('# Use default account')}`);
writeLine(
` $ ${color('ccs gemini', 'command')} ${dim('# OAuth (browser opens first time)')}`
);
console.log(` $ ${color('ccs glm "implement API"', 'command')} ${dim('# API key model')}`);
console.log(` $ ${color('ccs config', 'command')} ${dim('# Open web dashboard')}`);
console.log('');
writeLine(` $ ${color('ccs glm "implement API"', 'command')} ${dim('# API key model')}`);
writeLine(` $ ${color('ccs config', 'command')} ${dim('# Open web dashboard')}`);
writeLine('');
// Update examples
console.log(subheader('Update:'));
console.log(
writeLine(subheader('Update:'));
writeLine(
` $ ${color('ccs update', 'command')} ${dim('# Update to latest stable')}`
);
console.log(
writeLine(
` $ ${color('ccs update --force', 'command')} ${dim('# Force reinstall current')}`
);
console.log(` $ ${color('ccs update --beta', 'command')} ${dim('# Install dev channel')}`);
console.log('');
writeLine(` $ ${color('ccs update --beta', 'command')} ${dim('# Install dev channel')}`);
writeLine('');
// Docs link
console.log(` ${dim('Docs: https://github.com/kaitranntt/ccs')}`);
console.log('');
writeLine(` ${dim('Docs: https://github.com/kaitranntt/ccs')}`);
writeLine('');
// Uninstall
console.log(subheader('Uninstall:'));
console.log(` ${color('npm uninstall -g @kaitranntt/ccs', 'command')}`);
console.log('');
writeLine(subheader('Uninstall:'));
writeLine(` ${color('npm uninstall -g @kaitranntt/ccs', 'command')}`);
writeLine('');
// License
console.log(dim('License: MIT'));
console.log('');
writeLine(dim('License: MIT'));
writeLine('');
}
+71
View File
@@ -4,6 +4,16 @@
/** Extended context suffix recognized by Claude Code. */
export const EXTENDED_CONTEXT_SUFFIX = '[1m]';
export const ANTHROPIC_MODEL_ENV_KEYS = [
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
] as const;
export type AnthropicModelEnvKey = (typeof ANTHROPIC_MODEL_ENV_KEYS)[number];
const ANTHROPIC_MODEL_ENV_KEY_SET = new Set<string>(ANTHROPIC_MODEL_ENV_KEYS);
/** Check if model is a native Gemini model (auto-enabled behavior). */
export function isNativeGeminiModel(modelId: string): boolean {
@@ -27,3 +37,64 @@ export function stripExtendedContextSuffix(model: string): string {
if (!model) return model;
return hasExtendedContextSuffix(model) ? model.slice(0, -EXTENDED_CONTEXT_SUFFIX.length) : model;
}
/** True when key belongs to Anthropic model mapping state. */
export function isAnthropicModelEnvKey(key: string): key is AnthropicModelEnvKey {
return ANTHROPIC_MODEL_ENV_KEY_SET.has(key);
}
/** Strip transient config suffixes so model IDs can be checked against catalogs. */
export function stripModelConfigurationSuffixes(modelId: string): string {
return stripExtendedContextSuffix(modelId.trim()).replace(/\([^)]+\)$/, '');
}
/** Whether any saved Anthropic model mapping explicitly requests [1m]. */
export function hasAnthropicExtendedContextEnabled(
env: Partial<Record<string, string | undefined>>
): boolean {
return ANTHROPIC_MODEL_ENV_KEYS.some((key) => {
const value = env[key];
return typeof value === 'string' && hasExtendedContextSuffix(value);
});
}
/** Apply or strip [1m] across Anthropic model mappings while honoring compatibility. */
export function applyExtendedContextPreferenceToAnthropicModels<
T extends Record<string, string | undefined>,
>(
env: T,
enabled: boolean,
options: {
supportsExtendedContext?: (modelId: string, key: AnthropicModelEnvKey) => boolean;
} = {}
): T {
const nextEnv: Record<string, string | undefined> = { ...env };
for (const key of ANTHROPIC_MODEL_ENV_KEYS) {
const value = nextEnv[key];
if (typeof value !== 'string' || value.trim().length === 0) {
continue;
}
const modelId = stripModelConfigurationSuffixes(value);
const supported = options.supportsExtendedContext?.(modelId, key) ?? true;
nextEnv[key] =
enabled && supported ? applyExtendedContextSuffix(value) : stripExtendedContextSuffix(value);
}
return nextEnv as T;
}
/** Detect Claude model identifiers, regardless of transient [1m]/(thinking) suffixes. */
export function isClaudeModelId(modelId: string): boolean {
return stripModelConfigurationSuffixes(modelId).toLowerCase().startsWith('claude-');
}
/**
* Conservative Claude long-context support heuristic for generic API profile flows.
* Haiku stays plain; Opus/Sonnet default to opt-in [1m].
*/
export function likelySupportsClaudeExtendedContext(modelId: string): boolean {
const baseModel = stripModelConfigurationSuffixes(modelId).toLowerCase();
return baseModel.startsWith('claude-') && !baseModel.startsWith('claude-haiku-');
}
@@ -136,6 +136,22 @@ describe('applyExtendedContextConfig', () => {
expect(env.ANTHROPIC_MODEL).toBe('claude-opus-4-5-20251101[1m]');
});
it('applies explicit Claude [1m] per tier and leaves unsupported Haiku plain', () => {
const env: NodeJS.ProcessEnv = {
ANTHROPIC_MODEL: 'claude-haiku-4-5-20251001',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-20250929',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
};
applyExtendedContextConfig(env, 'claude', true);
expect(env.ANTHROPIC_MODEL).toBe('claude-haiku-4-5-20251001');
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-5-20251101[1m]');
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-20250929[1m]');
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001');
});
it('strips existing suffixes before catalog lookup', () => {
const env: NodeJS.ProcessEnv = {
ANTHROPIC_MODEL: 'gemini-2.5-pro(high)',
@@ -169,6 +185,20 @@ describe('applyExtendedContextConfig', () => {
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
});
it('strips stale Claude [1m] from unsupported mappings while preserving compatible ones', () => {
const env: NodeJS.ProcessEnv = {
ANTHROPIC_MODEL: 'claude-haiku-4-5-20251001[1m]',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101[1m]',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-20250929[1m]',
};
applyExtendedContextConfig(env, 'claude', true);
expect(env.ANTHROPIC_MODEL).toBe('claude-haiku-4-5-20251001');
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-5-20251101[1m]');
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-20250929[1m]');
});
it('strips [1m] suffix when --no-1m is explicit even if model has it', () => {
const env: NodeJS.ProcessEnv = {
ANTHROPIC_MODEL: 'gemini-2.5-pro[1m]',
@@ -1,7 +1,10 @@
import { describe, expect, test } from 'bun:test';
import {
applyClaudeExtendedContextPreference,
collectUnexpectedApiArgs,
hasClaudeModelMapping,
hasExplicitClaudeExtendedContext,
parseApiCommandArgs,
} from '../../../src/commands/api-command/shared';
@@ -132,6 +135,27 @@ describe('api-command arg parser', () => {
expect(parsed.model).toBe('-preview');
expect(parsed.errors).toEqual([]);
});
test('parses --1m for explicit Claude long context', () => {
const parsed = parseApiCommandArgs(['my-api', '--1m']);
expect(parsed.extendedContext).toBe(true);
expect(parsed.errors).toEqual([]);
});
test('parses --no-1m for explicit standard-context preference', () => {
const parsed = parseApiCommandArgs(['my-api', '--no-1m']);
expect(parsed.extendedContext).toBe(false);
expect(parsed.errors).toEqual([]);
});
test('rejects conflicting --1m and --no-1m flags', () => {
const parsed = parseApiCommandArgs(['my-api', '--1m', '--no-1m']);
expect(parsed.extendedContext).toBeUndefined();
expect(parsed.errors).toEqual(['Cannot combine --1m and --no-1m']);
});
});
describe('collectUnexpectedApiArgs', () => {
@@ -155,3 +179,55 @@ describe('collectUnexpectedApiArgs', () => {
expect(parsed.errors).toEqual(['Unknown option: --bogus', 'Unexpected arguments: value']);
});
});
describe('Claude long-context mapping helpers', () => {
test('detects Claude mappings and explicit [1m] state across all tiers', () => {
const models = {
default: 'gpt-5.4',
opus: 'claude-opus-4-6[1m]',
sonnet: 'claude-sonnet-4-6',
haiku: 'claude-haiku-4-5-20251001',
};
expect(hasClaudeModelMapping(models)).toBe(true);
expect(hasExplicitClaudeExtendedContext(models)).toBe(true);
});
test('applies [1m] only to compatible Claude mappings and leaves Haiku plain', () => {
const models = applyClaudeExtendedContextPreference(
{
default: 'claude-sonnet-4-6',
opus: 'claude-opus-4-6',
sonnet: 'claude-sonnet-4-6',
haiku: 'claude-haiku-4-5-20251001',
},
true
);
expect(models).toEqual({
default: 'claude-sonnet-4-6[1m]',
opus: 'claude-opus-4-6[1m]',
sonnet: 'claude-sonnet-4-6[1m]',
haiku: 'claude-haiku-4-5-20251001',
});
});
test('strips [1m] from Claude mappings when standard context is requested', () => {
const models = applyClaudeExtendedContextPreference(
{
default: 'claude-sonnet-4-6[1m]',
opus: 'claude-opus-4-6[1m]',
sonnet: 'claude-sonnet-4-6[1m]',
haiku: 'claude-haiku-4-5-20251001',
},
false
);
expect(models).toEqual({
default: 'claude-sonnet-4-6',
opus: 'claude-opus-4-6',
sonnet: 'claude-sonnet-4-6',
haiku: 'claude-haiku-4-5-20251001',
});
});
});
+23 -49
View File
@@ -1,74 +1,48 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import { beforeEach, describe, expect, it } from 'bun:test';
import { createApiCommandHandler } from '../../../src/commands/api-command';
let calls: string[] = [];
beforeEach(() => {
calls = [];
});
mock.module('../../../src/commands/api-command/help', () => ({
showApiCommandHelp: async () => {
function buildHandleApiCommand() {
return createApiCommandHandler({
help: async () => {
calls.push('help');
},
showUnknownApiCommand: async (command: string) => {
unknown: async (command: string) => {
calls.push(`unknown:${command}`);
},
}));
mock.module('../../../src/commands/api-command/create-command', () => ({
handleApiCreateCommand: async (args: string[]) => {
create: async (args: string[]) => {
calls.push(`create:${args.join(' ')}`);
},
}));
mock.module('../../../src/commands/api-command/list-command', () => ({
handleApiListCommand: async (args: string[]) => {
list: async (args: string[]) => {
calls.push(`list:${args.join(' ')}`);
},
}));
mock.module('../../../src/commands/api-command/remove-command', () => ({
handleApiRemoveCommand: async (args: string[]) => {
calls.push(`remove:${args.join(' ')}`);
},
}));
mock.module('../../../src/commands/api-command/discover-command', () => ({
handleApiDiscoverCommand: async (args: string[]) => {
discover: async (args: string[]) => {
calls.push(`discover:${args.join(' ')}`);
},
}));
mock.module('../../../src/commands/api-command/copy-command', () => ({
handleApiCopyCommand: async (args: string[]) => {
copy: async (args: string[]) => {
calls.push(`copy:${args.join(' ')}`);
},
}));
mock.module('../../../src/commands/api-command/export-command', () => ({
handleApiExportCommand: async (args: string[]) => {
export: async (args: string[]) => {
calls.push(`export:${args.join(' ')}`);
},
}));
mock.module('../../../src/commands/api-command/import-command', () => ({
handleApiImportCommand: async (args: string[]) => {
import: async (args: string[]) => {
calls.push(`import:${args.join(' ')}`);
},
}));
});
afterEach(() => {
mock.restore();
});
async function loadHandleApiCommand() {
const mod = await import(`../../../src/commands/api-command?test=${Date.now()}-${Math.random()}`);
return mod.handleApiCommand;
remove: async (args: string[]) => {
calls.push(`remove:${args.join(' ')}`);
},
});
}
describe('api-command router', () => {
it('defaults to help when no subcommand is provided', async () => {
const handleApiCommand = await loadHandleApiCommand();
const handleApiCommand = buildHandleApiCommand();
await handleApiCommand([]);
@@ -76,7 +50,7 @@ describe('api-command router', () => {
});
it('routes remove aliases through the named command dispatcher', async () => {
const handleApiCommand = await loadHandleApiCommand();
const handleApiCommand = buildHandleApiCommand();
await handleApiCommand(['rm', 'profile-a']);
@@ -84,7 +58,7 @@ describe('api-command router', () => {
});
it('forwards list arguments to the handler for validation', async () => {
const handleApiCommand = await loadHandleApiCommand();
const handleApiCommand = buildHandleApiCommand();
await handleApiCommand(['list', 'unexpected']);
@@ -92,7 +66,7 @@ describe('api-command router', () => {
});
it('routes hardened subcommands through their handlers', async () => {
const handleApiCommand = await loadHandleApiCommand();
const handleApiCommand = buildHandleApiCommand();
await handleApiCommand(['discover', '--json']);
await handleApiCommand(['copy', 'source', 'dest']);
@@ -108,7 +82,7 @@ describe('api-command router', () => {
});
it('delegates unknown commands to the shared unknown handler', async () => {
const handleApiCommand = await loadHandleApiCommand();
const handleApiCommand = buildHandleApiCommand();
await handleApiCommand(['bogus']);
@@ -15,20 +15,6 @@ beforeEach(() => {
logLines.push(args.map(String).join(' '));
};
const uiModule = {
initUI: async () => {},
header: (message: string) => message,
subheader: (message: string) => message,
color: (message: string) => message,
dim: (message: string) => message,
ok: (message: string) => message,
info: (message: string) => message,
warn: (message: string) => message,
fail: (message: string) => message,
};
mock.module('../../../src/utils/ui', () => uiModule);
mock.module('../../../src/utils/ui.ts', () => uiModule);
mock.module('../../../src/commands/config-auth/setup-command', () => ({
handleSetup: async () => {
calls.push('setup');
@@ -55,9 +41,7 @@ afterEach(() => {
});
async function loadHandleConfigAuthCommand() {
const mod = await import(
`../../../src/commands/config-auth?test=${Date.now()}-${Math.random()}`
);
const mod = await import(`../../../src/commands/config-auth?test=${Date.now()}-${Math.random()}`);
return mod.handleConfigAuthCommand;
}
+39 -39
View File
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { describe, expect, test } from 'bun:test';
import { showApiCommandHelp } from '../../../src/commands/api-command/help';
import { handleHelpCommand } from '../../../src/commands/help-command';
function stripAnsi(input: string): string {
@@ -7,19 +8,9 @@ function stripAnsi(input: string): string {
}
describe('help command parity', () => {
const originalLog = console.log;
afterEach(() => {
console.log = originalLog;
});
test('root help documents cliproxy provider filter under quota command', async () => {
const lines: string[] = [];
console.log = (...args: unknown[]) => {
lines.push(args.map((arg) => String(arg)).join(' '));
};
await handleHelpCommand();
await handleHelpCommand((line) => lines.push(line));
const rendered = stripAnsi(lines.join('\n'));
expect(rendered.includes('ccs cliproxy status [provider]')).toBe(false);
@@ -30,11 +21,7 @@ describe('help command parity', () => {
test('root help documents llama.cpp as a local API profile', async () => {
const lines: string[] = [];
console.log = (...args: unknown[]) => {
lines.push(args.map((arg) => String(arg)).join(' '));
};
await handleHelpCommand();
await handleHelpCommand((line) => lines.push(line));
const rendered = stripAnsi(lines.join('\n'));
expect(rendered.includes('ccs llamacpp')).toBe(true);
@@ -43,11 +30,7 @@ describe('help command parity', () => {
test('root help no longer markets glmt as a supported profile', async () => {
const lines: string[] = [];
console.log = (...args: unknown[]) => {
lines.push(args.map((arg) => String(arg)).join(' '));
};
await handleHelpCommand();
await handleHelpCommand((line) => lines.push(line));
const rendered = stripAnsi(lines.join('\n'));
expect(rendered.includes('ccs glmt')).toBe(false);
@@ -56,11 +39,7 @@ describe('help command parity', () => {
test('root help documents Claude IDE extension setup surfaces', async () => {
const lines: string[] = [];
console.log = (...args: unknown[]) => {
lines.push(args.map((arg) => String(arg)).join(' '));
};
await handleHelpCommand();
await handleHelpCommand((line) => lines.push(line));
const rendered = stripAnsi(lines.join('\n'));
expect(rendered.includes('Claude IDE Extension setup page')).toBe(true);
@@ -74,11 +53,7 @@ describe('help command parity', () => {
test('root help documents dashboard host binding example', async () => {
const lines: string[] = [];
console.log = (...args: unknown[]) => {
lines.push(args.map((arg) => String(arg)).join(' '));
};
await handleHelpCommand();
await handleHelpCommand((line) => lines.push(line));
const rendered = stripAnsi(lines.join('\n'));
expect(rendered.includes('ccs config --host 0.0.0.0')).toBe(true);
@@ -87,19 +62,44 @@ describe('help command parity', () => {
test('root help documents official channels native-only scope and process-env tokens', async () => {
const lines: string[] = [];
console.log = (...args: unknown[]) => {
lines.push(args.map((arg) => String(arg)).join(' '));
};
await handleHelpCommand();
await handleHelpCommand((line) => lines.push(line));
const rendered = stripAnsi(lines.join('\n'));
expect(rendered.includes('Dashboard -> Settings -> Channels (fastest path)')).toBe(true);
expect(
rendered.includes('Fastest path: turn on the channel, save the token if needed, then run ccs.')
rendered.includes(
'Fastest path: turn on the channel, save the token if needed, then run ccs.'
)
).toBe(true);
expect(rendered.includes('Not supported for ccs glm')).toBe(true);
expect(rendered.includes('Current-process TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN also work')).toBe(
expect(
rendered.includes('Current-process TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN also work')
).toBe(true);
});
test('root help explains Claude [1m] as an explicit CCS suffix with upstream limits', async () => {
const lines: string[] = [];
await handleHelpCommand((line) => lines.push(line));
const rendered = stripAnsi(lines.join('\n'));
expect(
rendered.includes('Claude models: plain by default, opt-in with --1m or saved [1m]')
).toBe(true);
expect(rendered.includes('CCS only controls the saved [1m] suffix.')).toBe(true);
expect(rendered.includes('return 429 extra-usage errors for long-context requests')).toBe(true);
});
test('api help documents create-time Claude [1m] flags and entitlement warning', async () => {
const lines: string[] = [];
await showApiCommandHelp((line) => lines.push(line));
const rendered = stripAnsi(lines.join('\n'));
expect(rendered.includes('--1m / --no-1m')).toBe(true);
expect(rendered.includes('ccs api create --preset anthropic --1m')).toBe(true);
expect(rendered.includes('Plain Claude model IDs stay on standard context by default.')).toBe(
true
);
expect(rendered.includes('some accounts can still return 429 for long-context requests')).toBe(
true
);
});
@@ -1,7 +1,6 @@
/**
* Extended Context Toggle Component
* Shows toggle for models that support 1M token context window.
* Only visible when selected model has extendedContext: true.
* Shows toggle when any selected mapping supports 1M token context.
*/
import { Zap, Info } from 'lucide-react';
@@ -12,8 +11,8 @@ import { isNativeGeminiModel } from '@/lib/extended-context-utils';
import type { ModelEntry } from './provider-model-selector';
interface ExtendedContextToggleProps {
/** Currently selected model */
model: ModelEntry | undefined;
/** Compatible selected models */
models: ModelEntry[];
/** Provider name for display */
provider: string;
/** Whether extended context is enabled */
@@ -27,19 +26,28 @@ interface ExtendedContextToggleProps {
}
export function ExtendedContextToggle({
model,
provider,
models,
provider: _provider,
enabled,
onToggle,
disabled,
className,
}: ExtendedContextToggleProps) {
// Only show if model supports extended context
if (!model?.extendedContext) {
if (models.length === 0) {
return null;
}
const isAutoEnabled = isNativeGeminiModel(model.id);
const hasGeminiModels = models.some((model) => isNativeGeminiModel(model.id));
const hasClaudeModels = models.some((model) => !isNativeGeminiModel(model.id));
let behaviorHint = 'Compatible mappings stay on standard context unless you turn this on.';
if (hasGeminiModels && hasClaudeModels) {
behaviorHint =
'Gemini-compatible mappings can use 1M automatically. Claude mappings stay plain unless you turn this on.';
} else if (hasGeminiModels) {
behaviorHint =
'Gemini-compatible mappings can use 1M by default. Turn this off to keep standard context.';
}
return (
<div
@@ -65,19 +73,12 @@ export function ExtendedContextToggle({
<div className="flex items-start gap-2 text-xs text-muted-foreground">
<Info className="w-3.5 h-3.5 mt-0.5 shrink-0" />
<div className="space-y-1">
<p>Enables 1M token context window instead of default 200K.</p>
<p className="text-[10px]">
{isAutoEnabled ? (
<span className="text-primary">Auto-enabled for {provider} Gemini models</span>
) : (
<span>Opt-in for {provider} Claude models via --1m flag</span>
)}
<p>Applies the explicit <code>[1m]</code> long-context suffix to compatible saved mappings.</p>
<p className="text-[10px]">{behaviorHint}</p>
<p className="text-amber-600 dark:text-amber-500">
CCS only saves <code>[1m]</code>. Provider pricing and entitlement are separate, and
some accounts can still return 429 extra-usage errors for long-context requests.
</p>
{enabled && (
<p className="text-amber-600 dark:text-amber-500">
Note: 2x input pricing applies for tokens beyond 200K
</p>
)}
</div>
</div>
</div>
@@ -11,6 +11,7 @@ import { Sparkles, Zap, Star, X, Plus } from 'lucide-react';
import { FlexibleModelSelector } from '../provider-model-selector';
import { ExtendedContextToggle } from '../extended-context-toggle';
import { stripExtendedContextSuffix } from '@/lib/extended-context-utils';
import { findCatalogModel } from '@/lib/model-catalogs';
import type { ModelConfigSectionProps } from './types';
type CatalogPresetModel = NonNullable<ModelConfigSectionProps['catalog']>['models'][number];
@@ -48,13 +49,18 @@ export function ModelConfigSection({
onDeletePreset,
isDeletePending,
}: ModelConfigSectionProps) {
// Find current model entry to check for extended context support
// Strip [1m] suffix when looking up in catalog since catalog IDs don't have suffix
const currentModelEntry = useMemo(() => {
if (!catalog || !currentModel) return undefined;
const baseModelId = stripExtendedContextSuffix(currentModel);
return catalog.models.find((m) => m.id === baseModelId);
}, [catalog, currentModel]);
const extendedContextModels = useMemo(() => {
if (!catalog) return [];
const selectedModels = [currentModel, opusModel, sonnetModel, haikuModel]
.filter((modelId): modelId is string => Boolean(modelId))
.map((modelId) => stripExtendedContextSuffix(modelId));
const uniqueIds = [...new Set(selectedModels)];
return uniqueIds
.map((modelId) => findCatalogModel(catalog.provider, modelId))
.filter((model): model is NonNullable<typeof model> => Boolean(model?.extendedContext));
}, [catalog, currentModel, opusModel, sonnetModel, haikuModel]);
const presetGroups = useMemo(() => {
const presetModels = (catalog?.models ?? []).filter((model) => model.presetMapping);
@@ -199,10 +205,10 @@ export function ModelConfigSection({
catalog={catalog}
allModels={providerModels}
/>
{/* Extended Context Toggle - only shows for models that support it */}
{currentModelEntry?.extendedContext && onExtendedContextToggle && (
{/* Extended Context Toggle - shows when any saved mapping supports it */}
{extendedContextModels.length > 0 && onExtendedContextToggle && (
<ExtendedContextToggle
model={currentModelEntry}
models={extendedContextModels}
provider={provider}
enabled={extendedContextEnabled ?? false}
onToggle={onExtendedContextToggle}
@@ -8,13 +8,11 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import type { SettingsResponse, UseProviderEditorReturn } from './types';
import {
applyExtendedContextSuffix,
stripExtendedContextSuffix,
hasExtendedContextSuffix,
applyExtendedContextPreferenceToAnthropicModels,
hasAnthropicExtendedContextEnabled,
isAnthropicModelEnvKey,
} from '@/lib/extended-context-utils';
/** Model env keys that should have [1m] suffix applied */
const MODEL_ENV_KEYS = ['ANTHROPIC_MODEL'] as const;
import { supportsExtendedContext } from '@/lib/model-catalogs';
/** Required env vars for CLIProxy providers (informational only - runtime fills defaults) */
const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const;
@@ -78,56 +76,59 @@ export function useProviderEditor(provider: string): UseProviderEditorReturn {
// Extended context is enabled if any model has [1m] suffix
const extendedContextEnabled = useMemo(() => {
const env = currentSettings?.env || {};
return MODEL_ENV_KEYS.some((key) => {
const value = env[key];
return value && hasExtendedContextSuffix(value);
});
return hasAnthropicExtendedContextEnabled(currentSettings?.env || {});
}, [currentSettings]);
const applySavedLongContextIntent = useCallback(
(env: Record<string, string>, enabled: boolean) =>
applyExtendedContextPreferenceToAnthropicModels(env, enabled, {
supportsExtendedContext: (modelId) => supportsExtendedContext(provider, modelId),
}),
[provider]
);
// Update a single setting value
const updateEnvValue = useCallback(
(key: string, value: string) => {
const newEnv = { ...(currentSettings?.env || {}), [key]: value };
const newSettings = { ...currentSettings, env: newEnv };
const envWithIntent = isAnthropicModelEnvKey(key)
? applySavedLongContextIntent(newEnv, extendedContextEnabled)
: newEnv;
delete envWithIntent['CCS_EXTENDED_CONTEXT'];
const newSettings = { ...currentSettings, env: envWithIntent };
setRawJsonEdits(JSON.stringify(newSettings, null, 2));
},
[currentSettings]
[applySavedLongContextIntent, currentSettings, extendedContextEnabled]
);
// Toggle extended context - applies/strips [1m] suffix to all model env vars
const toggleExtendedContext = useCallback(
(enabled: boolean) => {
const env = currentSettings?.env || {};
const updates: Record<string, string> = {};
for (const key of MODEL_ENV_KEYS) {
const value = env[key];
if (value) {
updates[key] = enabled
? applyExtendedContextSuffix(value)
: stripExtendedContextSuffix(value);
}
}
// Remove the legacy flag if present
const newEnv = { ...env, ...updates };
const newEnv = applySavedLongContextIntent(env, enabled);
delete newEnv['CCS_EXTENDED_CONTEXT'];
const newSettings = { ...currentSettings, env: newEnv };
setRawJsonEdits(JSON.stringify(newSettings, null, 2));
},
[currentSettings]
[applySavedLongContextIntent, currentSettings]
);
// Batch update multiple env values at once
const updateEnvValues = useCallback(
(updates: Record<string, string>) => {
const newEnv = { ...(currentSettings?.env || {}), ...updates };
const newSettings = { ...currentSettings, env: newEnv };
const touchesAnthropicModel = Object.keys(updates).some(isAnthropicModelEnvKey);
const envWithIntent = touchesAnthropicModel
? applySavedLongContextIntent(newEnv, extendedContextEnabled)
: newEnv;
delete envWithIntent['CCS_EXTENDED_CONTEXT'];
const newSettings = { ...currentSettings, env: envWithIntent };
setRawJsonEdits(JSON.stringify(newSettings, null, 2));
},
[currentSettings]
[applySavedLongContextIntent, currentSettings, extendedContextEnabled]
);
// Check if JSON is valid
+7
View File
@@ -3,9 +3,16 @@
*/
export {
ANTHROPIC_MODEL_ENV_KEYS,
EXTENDED_CONTEXT_SUFFIX,
applyExtendedContextPreferenceToAnthropicModels,
isNativeGeminiModel,
isAnthropicModelEnvKey,
hasAnthropicExtendedContextEnabled,
hasExtendedContextSuffix,
applyExtendedContextSuffix,
isClaudeModelId,
likelySupportsClaudeExtendedContext,
stripModelConfigurationSuffixes,
stripExtendedContextSuffix,
} from '../../../src/shared/extended-context-utils';
+13
View File
@@ -4,6 +4,7 @@
*/
import type { ProviderCatalog } from '@/components/cliproxy/provider-model-selector';
import { stripModelConfigurationSuffixes } from '@/lib/extended-context-utils';
/** Model catalog data - mirrors src/cliproxy/model-catalog.ts */
export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
@@ -553,3 +554,15 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
],
},
};
export function findCatalogModel(provider: string, modelId: string) {
const catalog = MODEL_CATALOGS[provider.toLowerCase()];
if (!catalog) return undefined;
const normalizedModelId = stripModelConfigurationSuffixes(modelId);
return catalog.models.find((model) => model.id === normalizedModelId);
}
export function supportsExtendedContext(provider: string, modelId: string): boolean {
return findCatalogModel(provider, modelId)?.extendedContext === true;
}
@@ -26,6 +26,7 @@ describe('ModelConfigSection presets', () => {
haikuModel="gpt-5-codex-mini"
providerModels={[]}
provider="codex"
onExtendedContextToggle={vi.fn()}
onApplyPreset={onApplyPreset}
onUpdateEnvValue={vi.fn()}
onOpenCustomPreset={vi.fn()}
@@ -55,10 +56,11 @@ describe('ModelConfigSection presets', () => {
savedPresets={[]}
currentModel="claude-opus-4-6-thinking"
opusModel="claude-opus-4-6-thinking"
sonnetModel="claude-sonnet-4-6"
haikuModel="claude-sonnet-4-6"
sonnetModel="gemini-3-pro-preview"
haikuModel="gemini-3-flash-preview"
providerModels={[]}
provider="agy"
onExtendedContextToggle={vi.fn()}
onApplyPreset={vi.fn()}
onUpdateEnvValue={vi.fn()}
onOpenCustomPreset={vi.fn()}
@@ -69,5 +71,6 @@ describe('ModelConfigSection presets', () => {
expect(screen.queryByText('Free Tier')).not.toBeInTheDocument();
expect(screen.queryByText('Paid Tier')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Claude Opus 4.6 Thinking' })).toBeInTheDocument();
expect(screen.getByTestId('extended-context-toggle')).toBeInTheDocument();
});
});
@@ -0,0 +1,156 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AllProviders } from '../../../../setup/test-utils';
import { useProviderEditor } from '@/components/cliproxy/provider-editor/use-provider-editor';
function createJsonResponse(body: Record<string, unknown>, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
const wrapper = ({ children }: { children: ReactNode }) => (
<AllProviders>{children}</AllProviders>
);
describe('useProviderEditor', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('derives extended-context state from all Anthropic model env keys', async () => {
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/api/settings/claude/raw')) {
return Promise.resolve(
createJsonResponse({
profile: 'claude',
settings: {
env: {
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6[1m]',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
},
},
mtime: 1,
path: '~/.ccs/profiles/claude/settings.json',
})
);
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
})
);
const { result } = renderHook(() => useProviderEditor('claude'), { wrapper });
await waitFor(() => expect(result.current.currentModel).toBe('claude-sonnet-4-6'));
expect(result.current.extendedContextEnabled).toBe(true);
});
it('applies [1m] across compatible Claude mappings and leaves Haiku plain', async () => {
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/api/settings/claude/raw')) {
return Promise.resolve(
createJsonResponse({
profile: 'claude',
settings: {
env: {
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
},
},
mtime: 1,
path: '~/.ccs/profiles/claude/settings.json',
})
);
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
})
);
const { result } = renderHook(() => useProviderEditor('claude'), { wrapper });
await waitFor(() => expect(result.current.currentModel).toBe('claude-sonnet-4-6'));
act(() => {
result.current.toggleExtendedContext(true);
});
const nextSettings = JSON.parse(result.current.rawJsonContent);
expect(nextSettings.env).toMatchObject({
ANTHROPIC_MODEL: 'claude-sonnet-4-6[1m]',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6[1m]',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6[1m]',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
});
});
it('preserves explicit long-context intent when preset-style updates replace mappings', async () => {
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/api/settings/claude/raw')) {
return Promise.resolve(
createJsonResponse({
profile: 'claude',
settings: {
env: {
ANTHROPIC_MODEL: 'claude-sonnet-4-6[1m]',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6[1m]',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6[1m]',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
},
},
mtime: 1,
path: '~/.ccs/profiles/claude/settings.json',
})
);
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
})
);
const { result } = renderHook(() => useProviderEditor('claude'), { wrapper });
await waitFor(() => expect(result.current.extendedContextEnabled).toBe(true));
act(() => {
result.current.updateEnvValues({
ANTHROPIC_MODEL: 'claude-opus-4-6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
});
});
const nextSettings = JSON.parse(result.current.rawJsonContent);
expect(nextSettings.env).toMatchObject({
ANTHROPIC_MODEL: 'claude-opus-4-6[1m]',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6[1m]',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6[1m]',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
});
});
});