feat: add Claude Code model selection to setup

This commit is contained in:
Andrey Avtomonov 2026-05-16 11:40:16 +02:00
parent fe7ac389fc
commit 3d6e8e64f3
10 changed files with 203 additions and 19 deletions

View file

@ -97,6 +97,7 @@ function shouldShowSetupEntryMenu(
llmBackend?: KtxSetupLlmBackend;
anthropicApiKeyEnv?: string;
anthropicApiKeyFile?: string;
llmModel?: string;
anthropicModel?: string;
vertexProject?: string;
vertexLocation?: string;
@ -171,6 +172,7 @@ function shouldShowSetupEntryMenu(
'llmBackend',
'anthropicApiKeyEnv',
'anthropicApiKeyFile',
'llmModel',
'anthropicModel',
'vertexProject',
'vertexLocation',
@ -236,6 +238,7 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
.addOption(
new Option('--anthropic-api-key-file <path>', 'File containing the Anthropic API key').hideHelp(),
)
.addOption(new Option('--llm-model <model>', 'LLM model ID or backend model alias').hideHelp())
.addOption(new Option('--anthropic-model <model>', 'Anthropic model ID to validate and save').hideHelp())
.addOption(new Option('--vertex-project <project>', 'Google Vertex AI project ID, env:NAME, or file:/path').hideHelp())
.addOption(new Option('--vertex-location <location>', 'Google Vertex AI location, env:NAME, or file:/path').hideHelp())
@ -361,6 +364,11 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
context.setExitCode(1);
return;
}
if (options.llmModel && options.anthropicModel) {
context.io.stderr.write('Choose only one LLM model flag: --llm-model or --anthropic-model.\n');
context.setExitCode(1);
return;
}
if (
options.llmBackend &&
options.llmBackend !== 'anthropic' &&
@ -426,6 +434,7 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
...(options.llmBackend ? { llmBackend: options.llmBackend } : {}),
...(options.anthropicApiKeyEnv ? { anthropicApiKeyEnv: options.anthropicApiKeyEnv } : {}),
...(options.anthropicApiKeyFile ? { anthropicApiKeyFile: options.anthropicApiKeyFile } : {}),
...(options.llmModel ? { llmModel: options.llmModel } : {}),
...(options.anthropicModel ? { anthropicModel: options.anthropicModel } : {}),
...(options.vertexProject ? { vertexProject: options.vertexProject } : {}),
...(options.vertexLocation ? { vertexLocation: options.vertexLocation } : {}),

View file

@ -1074,6 +1074,41 @@ describe('runKtxCli', () => {
);
});
it('dispatches the provider-neutral LLM model setup flag to the setup runner', async () => {
const setup = vi.fn(async () => 0);
const setupIo = makeIo();
await expect(
runKtxCli(
[
'--project-dir',
tempDir,
'setup',
'--no-input',
'--llm-backend',
'claude-code',
'--llm-model',
'opus',
],
setupIo.io,
{ setup },
),
).resolves.toBe(0);
expect(setup).toHaveBeenCalledWith(
expect.objectContaining({
command: 'run',
projectDir: tempDir,
inputMode: 'disabled',
cliVersion: '0.0.0-private',
llmBackend: 'claude-code',
llmModel: 'opus',
skipLlm: false,
}),
setupIo.io,
);
});
it('rejects conflicting Anthropic credential setup flags', async () => {
const setup = vi.fn(async () => 0);
const setupIo = makeIo();

View file

@ -209,6 +209,38 @@ describe('setup Anthropic model step', () => {
expect(authProbe).toHaveBeenCalledWith(expect.objectContaining({ projectDir: tempDir, model: 'sonnet' }));
});
it('prompts for the Claude Code model during interactive setup', async () => {
const io = makeIo();
const prompts = makePromptAdapter({ selectValues: ['claude-code', 'opus'] });
const authProbe = vi.fn(async () => ({ ok: true as const }));
const result = await runKtxSetupAnthropicModelStep(
{ projectDir: tempDir, inputMode: 'auto', skipLlm: false },
io.io,
{ prompts, claudeCodeAuthProbe: authProbe },
);
expect(result.status).toBe('ready');
expect(prompts.select).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('Which Claude Code model should KTX use?'),
options: [
{ value: 'sonnet', label: 'Claude Sonnet', hint: 'recommended' },
{ value: 'opus', label: 'Claude Opus' },
{ value: 'haiku', label: 'Claude Haiku' },
{ value: 'manual', label: 'Enter a Claude Code model ID manually' },
{ value: 'back', label: 'Back' },
],
}),
);
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
expect(config.llm).toMatchObject({
provider: { backend: 'claude-code' },
models: { default: 'opus' },
});
expect(authProbe).toHaveBeenCalledWith(expect.objectContaining({ projectDir: tempDir, model: 'opus' }));
});
it('warns during Claude Code setup when existing prompt-caching fields will be ignored', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
@ -716,7 +748,7 @@ describe('setup Anthropic model step', () => {
expect(io.stderr()).not.toContain('--skip-llm');
});
it('does not recommend skipping when non-interactive setup is missing an Anthropic model', async () => {
it('does not recommend skipping when non-interactive setup is missing an LLM model', async () => {
const io = makeIo();
const healthCheck = vi.fn(async () => ({ ok: true as const }));
@ -733,7 +765,7 @@ describe('setup Anthropic model step', () => {
expect(result.status).toBe('missing-input');
expect(healthCheck).not.toHaveBeenCalled();
expect(io.stderr()).toContain('Missing Anthropic model: pass --anthropic-model.');
expect(io.stderr()).toContain('Missing LLM model: pass --llm-model.');
expect(io.stderr()).not.toContain('--skip-llm');
});

View file

@ -36,6 +36,7 @@ export interface KtxSetupModelArgs {
llmBackend?: KtxSetupLlmBackend;
anthropicApiKeyEnv?: string;
anthropicApiKeyFile?: string;
llmModel?: string;
anthropicModel?: string;
vertexProject?: string;
vertexLocation?: string;
@ -100,6 +101,12 @@ const VERTEX_ANTHROPIC_MODELS: AnthropicModelChoice[] = [
{ id: 'claude-opus-4-1', label: 'Claude Opus 4.1', recommended: false },
];
const CLAUDE_CODE_MODELS: AnthropicModelChoice[] = [
{ id: 'sonnet', label: 'Claude Sonnet', recommended: true },
{ id: 'opus', label: 'Claude Opus', recommended: false },
{ id: 'haiku', label: 'Claude Haiku', recommended: false },
];
const HIDDEN_ANTHROPIC_MODEL_PATTERNS = [
/^claude-sonnet-4$/i,
/^claude-opus-4$/i,
@ -471,12 +478,16 @@ function requestedBackend(args: KtxSetupModelArgs): KtxSetupLlmBackend | undefin
if (args.vertexProject || args.vertexLocation) {
return 'vertex';
}
if (args.anthropicApiKeyEnv || args.anthropicApiKeyFile || args.anthropicModel) {
if (args.anthropicApiKeyEnv || args.anthropicApiKeyFile || args.llmModel || args.anthropicModel) {
return 'anthropic';
}
return undefined;
}
function requestedModel(args: KtxSetupModelArgs): string | undefined {
return args.llmModel ?? args.anthropicModel;
}
async function chooseBackend(
args: KtxSetupModelArgs,
io: KtxCliIo,
@ -731,11 +742,12 @@ async function chooseModel(
io: KtxCliIo,
deps: KtxSetupModelDeps,
): Promise<ChooseModelResult> {
if (args.anthropicModel) {
return { status: 'ready', model: args.anthropicModel };
const providedModel = requestedModel(args);
if (providedModel) {
return { status: 'ready', model: providedModel };
}
if (args.inputMode === 'disabled') {
io.stderr.write('Missing Anthropic model: pass --anthropic-model.\n');
io.stderr.write('Missing LLM model: pass --llm-model.\n');
return { status: 'missing-input' };
}
@ -788,11 +800,12 @@ async function chooseModel(
}
async function chooseVertexModel(args: KtxSetupModelArgs, io: KtxCliIo, deps: KtxSetupModelDeps): Promise<ChooseModelResult> {
if (args.anthropicModel) {
return { status: 'ready', model: args.anthropicModel };
const providedModel = requestedModel(args);
if (providedModel) {
return { status: 'ready', model: providedModel };
}
if (args.inputMode === 'disabled') {
io.stderr.write('Missing Anthropic model: pass --anthropic-model.\n');
io.stderr.write('Missing LLM model: pass --llm-model.\n');
return { status: 'missing-input' };
}
@ -826,6 +839,44 @@ async function chooseVertexModel(args: KtxSetupModelArgs, io: KtxCliIo, deps: Kt
return { status: 'ready', model: choice };
}
async function chooseClaudeCodeModel(args: KtxSetupModelArgs, deps: KtxSetupModelDeps): Promise<ChooseModelResult> {
const providedModel = requestedModel(args);
if (providedModel) {
return { status: 'ready', model: providedModel };
}
if (args.inputMode === 'disabled') {
return { status: 'ready', model: 'sonnet' };
}
const prompts = deps.prompts ?? createPromptAdapter();
const choice = await prompts.select({
message: `Which Claude Code model should KTX use?\n\n${ANTHROPIC_MODEL_PROMPT_CONTEXT}`,
options: [
...CLAUDE_CODE_MODELS.map((model) => ({
value: model.id,
label: model.label,
...(model.recommended ? { hint: 'recommended' } : {}),
})),
{ value: 'manual', label: 'Enter a Claude Code model ID manually' },
{ value: 'back', label: 'Back' },
],
});
if (choice === 'back') {
return { status: 'back' };
}
if (choice === 'manual') {
const manual = await prompts.text({
message: withTextInputNavigation('Claude Code model ID'),
placeholder: CLAUDE_CODE_MODELS.find((model) => model.recommended)?.id ?? CLAUDE_CODE_MODELS[0]?.id,
});
if (manual === undefined) {
return { status: 'back' };
}
return manual.trim() ? { status: 'ready', model: manual.trim() } : { status: 'missing-input' };
}
return { status: 'ready', model: choice };
}
async function persistLlmConfig(
projectDir: string,
provider:
@ -877,6 +928,7 @@ export async function runKtxSetupAnthropicModelStep(
!args.llmBackend &&
!args.anthropicApiKeyEnv &&
!args.anthropicApiKeyFile &&
!args.llmModel &&
!args.anthropicModel &&
!args.vertexProject &&
!args.vertexLocation
@ -943,23 +995,33 @@ export async function runKtxSetupAnthropicModelStep(
}
if (backendChoice.backend === 'claude-code') {
const model = backendArgs.anthropicModel ?? 'sonnet';
const model = await chooseClaudeCodeModel(backendArgs, deps);
if (model.status === 'back' && backendChoice.prompted) {
attemptArgs = buildInteractiveRetryArgs(args);
continue;
}
if (model.status === 'invalid-credential') {
return { status: 'failed', projectDir: args.projectDir };
}
if (model.status !== 'ready') {
return { status: model.status, projectDir: args.projectDir };
}
const probe = deps.claudeCodeAuthProbe ?? runClaudeCodeAuthProbe;
const health = await probe({ projectDir: args.projectDir, model, env: deps.env ?? process.env });
const health = await probe({ projectDir: args.projectDir, model: model.model, env: deps.env ?? process.env });
if (!health.ok) {
io.stderr.write(`${health.message}\n`);
return { status: 'failed', projectDir: args.projectDir };
}
const warning = formatClaudeCodePromptCachingWarning(
ignoredClaudeCodePromptCachingFields(
buildProjectLlmConfig(project.config.llm, { backend: 'claude-code' }, model),
buildProjectLlmConfig(project.config.llm, { backend: 'claude-code' }, model.model),
),
);
if (warning) {
io.stderr.write(`${warning}\n`);
}
await persistLlmConfig(args.projectDir, { backend: 'claude-code' }, model);
io.stdout.write(`│ LLM ready: yes (${model})\n`);
await persistLlmConfig(args.projectDir, { backend: 'claude-code' }, model.model);
io.stdout.write(`│ LLM ready: yes (${model.model})\n`);
return { status: 'ready', projectDir: args.projectDir };
}

View file

@ -77,6 +77,7 @@ export type KtxSetupArgs =
llmBackend?: KtxSetupLlmBackend;
anthropicApiKeyEnv?: string;
anthropicApiKeyFile?: string;
llmModel?: string;
anthropicModel?: string;
vertexProject?: string;
vertexLocation?: string;
@ -547,6 +548,7 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
...(args.llmBackend ? { llmBackend: args.llmBackend } : {}),
...(args.anthropicApiKeyEnv ? { anthropicApiKeyEnv: args.anthropicApiKeyEnv } : {}),
...(args.anthropicApiKeyFile ? { anthropicApiKeyFile: args.anthropicApiKeyFile } : {}),
...(args.llmModel ? { llmModel: args.llmModel } : {}),
...(args.anthropicModel ? { anthropicModel: args.anthropicModel } : {}),
...(args.vertexProject ? { vertexProject: args.vertexProject } : {}),
...(args.vertexLocation ? { vertexLocation: args.vertexLocation } : {}),