mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
feat: add Claude Code model selection to setup
This commit is contained in:
parent
fe7ac389fc
commit
3d6e8e64f3
10 changed files with 203 additions and 19 deletions
|
|
@ -53,9 +53,10 @@ scripted project creation. They are not shown in `ktx setup --help`.
|
|||
|------|-------------|
|
||||
| `--llm-backend <backend>` | LLM backend: `anthropic`, `vertex`, or `claude-code` |
|
||||
| `--llm-backend claude-code` | Use the local Claude Code session for KTX LLM calls |
|
||||
| `--llm-model <model>` | LLM model ID or backend model alias to validate and save |
|
||||
| `--anthropic-api-key-env <name>` | Environment variable containing the Anthropic API key |
|
||||
| `--anthropic-api-key-file <path>` | File containing the Anthropic API key |
|
||||
| `--anthropic-model <model>` | Anthropic model ID to validate and save |
|
||||
| `--anthropic-model <model>` | Legacy alias for `--llm-model` |
|
||||
| `--vertex-project <project>` | Vertex AI project ID, `env:NAME`, or `file:/path` reference |
|
||||
| `--vertex-location <location>` | Vertex AI location, `env:NAME`, or `file:/path` reference |
|
||||
| `--skip-llm` | Leave LLM setup incomplete |
|
||||
|
|
@ -63,7 +64,8 @@ scripted project creation. They are not shown in `ktx setup --help`.
|
|||
Choose only one Anthropic credential source. Anthropic credential flags are only
|
||||
valid with the Anthropic backend; Vertex flags are only valid with the Vertex
|
||||
backend. The `claude-code` backend uses local Claude Code authentication instead
|
||||
of Anthropic API key or Vertex flags.
|
||||
of Anthropic API key or Vertex flags. For Claude Code, `--llm-model` accepts
|
||||
`sonnet`, `opus`, `haiku`, or a full Claude model ID.
|
||||
|
||||
### Embeddings
|
||||
|
||||
|
|
@ -144,6 +146,12 @@ ktx setup
|
|||
# Run setup for a specific project directory
|
||||
ktx setup --project-dir ./analytics
|
||||
|
||||
# Use Claude Code with Opus for KTX LLM calls
|
||||
ktx setup \
|
||||
--project-dir ./analytics \
|
||||
--llm-backend claude-code \
|
||||
--llm-model opus
|
||||
|
||||
# Script a Postgres connection that reads its URL from the environment
|
||||
ktx setup \
|
||||
--project-dir ./analytics \
|
||||
|
|
|
|||
|
|
@ -92,7 +92,9 @@ llm:
|
|||
|
||||
`claude-code` uses the Claude Code authentication already configured on your
|
||||
machine. It doesn't use `ANTHROPIC_API_KEY`, Vertex credentials, AI Gateway
|
||||
tokens, or Bedrock credentials.
|
||||
tokens, or Bedrock credentials. In non-interactive setup, pass
|
||||
`--llm-model opus`, `--llm-model sonnet`, `--llm-model haiku`, or a full Claude
|
||||
model ID to select the Claude Code model.
|
||||
|
||||
Setup checks the selected model before saving. Anthropic API setup fetches live
|
||||
Claude model choices when possible and falls back to bundled defaults if model
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ Set `llm.provider.backend` to one of these values:
|
|||
|
||||
## Claude Code
|
||||
|
||||
Use aliases or full Claude model ids in `llm.models`:
|
||||
Use aliases or full Claude model IDs in `llm.models`:
|
||||
|
||||
```yaml
|
||||
llm:
|
||||
|
|
@ -36,6 +36,16 @@ llm:
|
|||
repair: sonnet
|
||||
```
|
||||
|
||||
During setup, choose the Claude Code backend interactively or pass the model in
|
||||
automation:
|
||||
|
||||
```bash
|
||||
ktx setup --llm-backend claude-code --llm-model opus --no-input
|
||||
```
|
||||
|
||||
For Claude Code, `sonnet`, `opus`, and `haiku` map to the current KTX defaults.
|
||||
You can also pass a full Claude model ID, such as `claude-opus-4-7`.
|
||||
|
||||
`claude-code` keeps KTX tool boundaries intact. KTX exposes only the MCP tools
|
||||
needed for the current KTX agent loop, disables Claude Code built-in tools,
|
||||
keeps plugins empty, and denies every non-KTX tool request through
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -447,4 +447,18 @@ describe('ClaudeCodeKtxLlmRuntime', () => {
|
|||
env: expect.not.objectContaining({ ANTHROPIC_API_KEY: 'sk-ant-test' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('reports unsupported Claude Code models without framing them as auth failures', async () => {
|
||||
await expect(
|
||||
runClaudeCodeAuthProbe({
|
||||
projectDir: '/tmp/project',
|
||||
model: 'gpt-5',
|
||||
query: vi.fn(),
|
||||
env: {},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
message: 'Unsupported Claude Code model "gpt-5". Use sonnet, opus, haiku, or a claude-* model id.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -288,10 +288,20 @@ export async function runClaudeCodeAuthProbe(input: {
|
|||
query?: QueryFn;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<{ ok: true } | { ok: false; message: string }> {
|
||||
let model: string;
|
||||
try {
|
||||
model = resolveClaudeCodeModel(input.model);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const options = baseOptions({
|
||||
projectDir: input.projectDir,
|
||||
model: resolveClaudeCodeModel(input.model),
|
||||
model,
|
||||
env: input.env,
|
||||
maxTurns: 1,
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue