feat: add claude-code llm backend with runtime port (#115)

* docs: revise claude-code ingest backend spec

* docs: keep claude-code spec focused on ingest

* docs: expand claude-code spec to full llm parity

* Refine claude-code backend spec after adversarial review iteration 1

* Refine claude-code backend spec after adversarial review iteration 2

* Refine claude-code backend spec after adversarial review iteration 3

* feat: recognize claude-code llm backend

* feat: add ktx llm runtime port

* feat: add claude-code llm runtime

* feat: route non-agent llm calls through runtime

* feat: run ingest agents through llm runtime

* feat: support claude-code setup and status

* test: verify claude-code backend runtime

* docs: add claude-code backend v1 runtime plan

* fix: close claude-code runtime isolation checks

* fix: warn on claude-code prompt caching during setup

* chore: verify claude-code v1 closure

* docs: add claude-code backend v1 isolation closure plan

* fix: update claude-code ingest setup guidance

* docs: add claude-code backend v1 ingest guidance closure plan

* docs: align claude-code isolation spec with sdk metadata

* test: cover claude-code host discovery metadata

* fix: tolerate claude-code host discovery metadata

* docs: clarify claude-code host discovery metadata

* docs: add claude-code auth-probe isolation fix plan

* chore: prepare kaelio ktx rc1 release

* chore: add semantic release workflow

* fix: unblock ci checks

* chore(release): 0.1.0-rc.1

* feat: add Claude Code model selection to setup

* fix: keep git maintenance attached in local repos
This commit is contained in:
Andrey Avtomonov 2026-05-16 12:06:34 +02:00 committed by GitHub
parent e6d578c03f
commit b565e44a22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
109 changed files with 10218 additions and 1093 deletions

View file

@ -0,0 +1,29 @@
import type { KtxProjectLlmConfig } from '@ktx/context/project';
const CLAUDE_CODE_IGNORED_PROMPT_CACHING_FIELDS = [
'systemTtl',
'toolsTtl',
'historyTtl',
'vertexFallbackTo5m',
] as const;
export function ignoredClaudeCodePromptCachingFields(config: KtxProjectLlmConfig): string[] {
if (config.provider.backend !== 'claude-code' || !config.promptCaching) {
return [];
}
const promptCaching = config.promptCaching;
return CLAUDE_CODE_IGNORED_PROMPT_CACHING_FIELDS.filter((key) => key in promptCaching).map(
(key) => `llm.promptCaching.${key}`,
);
}
export function formatClaudeCodePromptCachingWarning(fields: string[]): string | null {
if (fields.length === 0) {
return null;
}
return `claude-code ignores ${fields.join(', ')} because the Claude Agent SDK does not expose KTX prompt-cache TTL, tool, or history markers.`;
}
export function formatClaudeCodePromptCachingFix(): string {
return 'Remove those promptCaching fields or use anthropic, vertex, or gateway when those cache knobs are required.';
}

View file

@ -29,7 +29,7 @@ function embeddingBackend(value: string): 'openai' | 'sentence-transformers' {
}
function llmBackend(value: string): KtxSetupLlmBackend {
if (value === 'anthropic' || value === 'vertex') {
if (value === 'anthropic' || value === 'vertex' || value === 'claude-code') {
return value;
}
throw new InvalidArgumentError(`invalid choice '${value}'`);
@ -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',
@ -237,6 +239,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())
@ -362,12 +365,21 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
context.setExitCode(1);
return;
}
if (options.llmBackend === 'vertex' && (options.anthropicApiKeyEnv || options.anthropicApiKeyFile)) {
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' &&
(options.anthropicApiKeyEnv || options.anthropicApiKeyFile)
) {
context.io.stderr.write('Anthropic API key flags are only valid with --llm-backend anthropic.\n');
context.setExitCode(1);
return;
}
if (options.llmBackend === 'anthropic' && (options.vertexProject || options.vertexLocation)) {
if (options.llmBackend && options.llmBackend !== 'vertex' && (options.vertexProject || options.vertexLocation)) {
context.io.stderr.write('Vertex AI flags are only valid with --llm-backend vertex.\n');
context.setExitCode(1);
return;
@ -423,6 +435,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

@ -464,6 +464,44 @@ describe('runKtxDoctor', () => {
delete process.env.OPENAI_API_KEY;
});
it('reports Claude Code auth failures and ignored prompt-caching fields in project doctor output', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'llm:',
' provider:',
' backend: claude-code',
' models:',
' default: sonnet',
' promptCaching:',
' enabled: true',
' systemTtl: 1h',
' toolsTtl: 1h',
' historyTtl: 5m',
'',
].join('\n'),
'utf-8',
);
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{
claudeCodeAuthProbe: async () => ({
ok: false as const,
message: 'Authenticate Claude Code locally.',
}),
},
),
).resolves.toBe(1);
expect(testIo.stdout()).toContain('claude-code');
expect(testIo.stdout()).toContain('Authenticate Claude Code locally');
expect(testIo.stdout()).toContain('claude-code ignores llm.promptCaching');
});
it('includes Postgres query-history readiness in project doctor output', async () => {
process.env.ANTHROPIC_API_KEY = 'test-key'; // pragma: allowlist secret
process.env.OPENAI_API_KEY = 'test-key'; // pragma: allowlist secret

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

@ -1,7 +1,7 @@
import { EventEmitter } from 'node:events';
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { AgentRunnerService, type RunLoopParams } from '@ktx/context/agent';
import type { AgentRunnerPort, RunLoopParams } from '@ktx/context';
import {
KtxYamlMetabaseSourceStateReader,
LocalMetabaseDiscoveryCache,
@ -255,8 +255,8 @@ export function failedLocalBundleRun(input: RunLocalIngestOptions, jobId: string
};
}
export class CliLookerSlWritingAgentRunner extends AgentRunnerService {
override runLoop = vi.fn(async (params: RunLoopParams) => {
export class CliLookerSlWritingAgentRunner implements AgentRunnerPort {
runLoop = vi.fn(async (params: RunLoopParams) => {
if (
params.telemetryTags?.operationName === 'ingest-bundle-wu' &&
params.telemetryTags?.unitKey === 'looker-explore-ecommerce-orders'
@ -265,53 +265,39 @@ export class CliLookerSlWritingAgentRunner extends AgentRunnerService {
if (!ledger?.execute) {
throw new Error('record_verification_ledger tool was not available to the Looker WorkUnit');
}
await ledger.execute(
{
summary: 'Test fixture verified Looker explore target identifiers before writing SL.',
verifiedIdentifiers: ['prod-warehouse', 'public.orders'],
unverifiedIdentifiers: [],
},
{ toolCallId: 'cli-looker-verification-ledger', messages: [] },
);
await ledger.execute({
summary: 'Test fixture verified Looker explore target identifiers before writing SL.',
verifiedIdentifiers: ['prod-warehouse', 'public.orders'],
unverifiedIdentifiers: [],
});
const slWrite = params.toolSet.sl_write_source;
if (!slWrite?.execute) {
throw new Error('sl_write_source tool was not available to the Looker WorkUnit');
}
const result = await slWrite.execute(
{
connectionId: 'prod-warehouse',
sourceName: 'looker__ecommerce__orders',
source: {
name: 'looker__ecommerce__orders',
table: 'public.orders',
grain: ['id'],
columns: [
{ name: 'id', type: 'number' },
{ name: 'revenue', type: 'number' },
],
measures: [{ name: 'total_revenue', expr: 'sum(revenue)' }],
},
const result = await slWrite.execute({
connectionId: 'prod-warehouse',
sourceName: 'looker__ecommerce__orders',
source: {
name: 'looker__ecommerce__orders',
table: 'public.orders',
grain: ['id'],
columns: [
{ name: 'id', type: 'number' },
{ name: 'revenue', type: 'number' },
],
measures: [{ name: 'total_revenue', expr: 'sum(revenue)' }],
},
{ toolCallId: 'cli-looker-sl-write', messages: [] },
);
if (!result.structured.success) {
});
if (!(result.structured as { success?: boolean } | undefined)?.success) {
throw new Error(result.markdown);
}
}
return { stopReason: 'natural' as const };
});
constructor() {
super({ llmProvider: { getModel: () => ({}) as never } as never });
}
}
export class CliMetabaseAgentRunner extends AgentRunnerService {
override runLoop = vi.fn(async () => ({ stopReason: 'natural' as const }));
constructor() {
super({ llmProvider: { getModel: () => ({}) as never } as never });
}
export class CliMetabaseAgentRunner implements AgentRunnerPort {
runLoop = vi.fn(async () => ({ stopReason: 'natural' as const }));
}
export class CliMetabaseSourceAdapter implements SourceAdapter {

View file

@ -311,10 +311,12 @@ describe('runKtxIngest', () => {
expect(runIo.stdout()).toBe('');
expect(runIo.stderr()).toContain(
'ktx ingest requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner.',
'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, or claude-code, or an injected agentRunner.',
);
expect(runIo.stderr()).toContain('Configure a local Claude Code session or API-backed LLM, then rerun ingest:');
expect(runIo.stderr()).toContain(`ktx setup --project-dir ${projectDir} --llm-backend claude-code --no-input`);
expect(runIo.stderr()).toContain(
`ktx setup --project-dir ${projectDir} --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`,
`ktx setup --project-dir ${projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`,
);
});

View file

@ -86,11 +86,11 @@ export interface KtxIngestDeps {
renderStoredMemoryFlow?: typeof renderMemoryFlowTui;
startLiveMemoryFlow?: typeof startLiveMemoryFlowTui;
env?: NodeJS.ProcessEnv;
localIngestOptions?: Pick<
RunLocalIngestOptions,
| 'agentRunner'
| 'llmProvider'
| 'memoryModel'
localIngestOptions?: Pick<
RunLocalIngestOptions,
| 'agentRunner'
| 'llmRuntime'
| 'memoryModel'
| 'semanticLayerCompute'
| 'queryExecutor'
| 'logger'

View file

@ -61,7 +61,12 @@ function makePromptAdapter(options: {
if (message.includes('LLM provider')) {
providerPromptCount += 1;
const nextProviderChoice = selectValues[0];
if (nextProviderChoice === 'anthropic' || nextProviderChoice === 'vertex' || nextProviderChoice === 'back') {
if (
nextProviderChoice === 'anthropic' ||
nextProviderChoice === 'vertex' ||
nextProviderChoice === 'claude-code' ||
nextProviderChoice === 'back'
) {
return selectValues.shift() ?? nextProviderChoice;
}
if (options.credentialChoice === 'back' && providerPromptCount > 1) {
@ -180,6 +185,100 @@ describe('setup Anthropic model step', () => {
);
});
it('configures Claude Code backend and validates local auth', async () => {
const io = makeIo();
const authProbe = vi.fn(async () => ({ ok: true as const }));
const result = await runKtxSetupAnthropicModelStep(
{
projectDir: tempDir,
inputMode: 'disabled',
llmBackend: 'claude-code',
skipLlm: false,
},
io.io,
{ claudeCodeAuthProbe: authProbe },
);
expect(result.status).toBe('ready');
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
expect(config.llm).toMatchObject({
provider: { backend: 'claude-code' },
models: { default: 'sonnet' },
});
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'),
[
'llm:',
' provider:',
' backend: anthropic',
' models:',
' default: claude-sonnet-4-6',
' promptCaching:',
' enabled: true',
' systemTtl: 1h',
' toolsTtl: 1h',
' historyTtl: 5m',
'',
].join('\n'),
'utf-8',
);
const io = makeIo();
const result = await runKtxSetupAnthropicModelStep(
{
projectDir: tempDir,
inputMode: 'disabled',
llmBackend: 'claude-code',
skipLlm: false,
},
io.io,
{
claudeCodeAuthProbe: async () => ({ ok: true as const }),
},
);
expect(result.status).toBe('ready');
expect(io.stderr()).toContain('claude-code ignores llm.promptCaching.systemTtl');
expect(io.stderr()).toContain('Claude Agent SDK does not expose KTX prompt-cache TTL, tool, or history markers');
});
it('returns from Anthropic credential Back to provider selection', async () => {
const prompts = makePromptAdapter({ selectValues: ['anthropic', 'back', 'back'] });
@ -649,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 }));
@ -666,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

@ -1,7 +1,7 @@
import { execFile } from 'node:child_process';
import { writeFile } from 'node:fs/promises';
import { promisify } from 'node:util';
import { resolveLocalKtxLlmConfig } from '@ktx/context';
import { resolveLocalKtxLlmConfig, runClaudeCodeAuthProbe } from '@ktx/context';
import { resolveKtxConfigReference } from '@ktx/context/core';
import {
type KtxProjectConfig,
@ -11,6 +11,10 @@ import {
serializeKtxProjectConfig,
} from '@ktx/context/project';
import { type KtxLlmConfig, type KtxLlmHealthCheckResult, runKtxLlmHealthCheck } from '@ktx/llm';
import {
formatClaudeCodePromptCachingWarning,
ignoredClaudeCodePromptCachingFields,
} from './claude-code-prompt-caching.js';
import { createClackSpinner, type KtxCliSpinner } from './clack.js';
import type { KtxCliIo } from './cli-runtime.js';
import { withTextInputNavigation } from './prompt-navigation.js';
@ -32,6 +36,7 @@ export interface KtxSetupModelArgs {
llmBackend?: KtxSetupLlmBackend;
anthropicApiKeyEnv?: string;
anthropicApiKeyFile?: string;
llmModel?: string;
anthropicModel?: string;
vertexProject?: string;
vertexLocation?: string;
@ -53,7 +58,7 @@ export interface AnthropicModelChoice {
recommended: boolean;
}
export type KtxSetupLlmBackend = 'anthropic' | 'vertex';
export type KtxSetupLlmBackend = 'anthropic' | 'vertex' | 'claude-code';
export interface KtxSetupModelPromptAdapter {
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
@ -68,6 +73,11 @@ export interface KtxSetupModelDeps {
prompts?: KtxSetupModelPromptAdapter;
listModels?: (apiKey: string) => Promise<AnthropicModelChoice[]>;
healthCheck?: (config: KtxLlmConfig) => Promise<KtxLlmHealthCheckResult>;
claudeCodeAuthProbe?: (input: {
projectDir: string;
model: string;
env?: NodeJS.ProcessEnv;
}) => Promise<{ ok: true } | { ok: false; message: string }>;
readGcloudProject?: () => Promise<string | undefined>;
listGcloudProjects?: () => Promise<GcloudProjectChoice[]>;
spinner?: () => KtxCliSpinner;
@ -91,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,
@ -252,7 +268,7 @@ export function isKtxSetupLlmConfigReady(config: KtxProjectLlmConfig): boolean {
return typeof resolved.vertex?.location === 'string' && resolved.vertex.location.trim().length > 0;
}
return resolved.backend === 'anthropic' || resolved.backend === 'gateway';
return resolved.backend === 'anthropic' || resolved.backend === 'gateway' || resolved.backend === 'claude-code';
}
function hasUsableConfiguredLlm(config: KtxProjectConfig): boolean {
@ -263,9 +279,18 @@ function buildProjectLlmConfig(
existing: KtxProjectLlmConfig,
provider:
| { backend: 'anthropic'; credentialRef: string }
| { backend: 'vertex'; vertex: { project?: string; location: string } },
| { backend: 'vertex'; vertex: { project?: string; location: string } }
| { backend: 'claude-code' },
model: string,
): KtxProjectLlmConfig {
if (provider.backend === 'claude-code') {
return {
provider: { backend: 'claude-code' },
models: { ...existing.models, default: model },
promptCaching: existing.promptCaching,
};
}
if (provider.backend === 'vertex') {
return {
provider: {
@ -453,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,
@ -480,16 +509,21 @@ async function chooseBackend(
}
const choice = await prompts.select({
message: 'Which LLM provider should KTX use?',
options: [
{ value: 'anthropic', label: 'Anthropic API' },
{ value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' },
{ value: 'back', label: 'Back' },
],
options: [
{ value: 'anthropic', label: 'Anthropic API' },
{ value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' },
{ value: 'claude-code', label: 'Local Claude Code session' },
{ value: 'back', label: 'Back' },
],
});
if (choice === 'back') {
return { status: 'back' };
}
return { status: 'ready', backend: choice === 'vertex' ? 'vertex' : 'anthropic', prompted: true };
return {
status: 'ready',
backend: choice === 'vertex' || choice === 'claude-code' ? choice : 'anthropic',
prompted: true,
};
}
function resolveProvidedVertexRef(
@ -708,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' };
}
@ -765,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' };
}
@ -803,11 +839,50 @@ 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:
| { backend: 'anthropic'; credentialRef: string }
| { backend: 'vertex'; vertex: { project?: string; location: string } },
| { backend: 'vertex'; vertex: { project?: string; location: string } }
| { backend: 'claude-code' },
model: string,
): Promise<void> {
const project = await loadKtxProject({ projectDir });
@ -853,6 +928,7 @@ export async function runKtxSetupAnthropicModelStep(
!args.llmBackend &&
!args.anthropicApiKeyEnv &&
!args.anthropicApiKeyFile &&
!args.llmModel &&
!args.anthropicModel &&
!args.vertexProject &&
!args.vertexLocation
@ -918,6 +994,37 @@ export async function runKtxSetupAnthropicModelStep(
continue;
}
if (backendChoice.backend === 'claude-code') {
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: 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.model),
),
);
if (warning) {
io.stderr.write(`${warning}\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 };
}
const credential = await chooseCredentialRef(backendArgs, io, deps);
if (credential.status === 'back' && backendChoice.prompted) {
attemptArgs = buildInteractiveRetryArgs(args);

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 } : {}),

View file

@ -1,4 +1,5 @@
import { basename } from 'node:path';
import { runClaudeCodeAuthProbe } from '@ktx/context';
import type {
KtxConfigIssue,
KtxLocalProject,
@ -8,6 +9,11 @@ import type {
KtxProjectLlmConfig,
} from '@ktx/context/project';
import type { PostgresPgssProbeResult } from '@ktx/context/ingest';
import {
formatClaudeCodePromptCachingFix,
formatClaudeCodePromptCachingWarning,
ignoredClaudeCodePromptCachingFields,
} from './claude-code-prompt-caching.js';
import type { DoctorCheck } from './doctor.js';
import {
bold as _bold,
@ -77,6 +83,12 @@ interface WarningItem {
fix?: string;
}
type ClaudeCodeAuthProbe = (input: {
projectDir: string;
model: string;
env?: NodeJS.ProcessEnv;
}) => Promise<{ ok: true } | { ok: false; message: string }>;
const PROJECT_READY_COMMANDS = KTX_NEXT_STEP_DIRECT_COMMANDS.map((step) => step.command);
function isRecord(value: unknown): value is Record<string, unknown> {
@ -134,7 +146,15 @@ function envHint(value: unknown): string | undefined {
return undefined;
}
function buildLlmStatus(config: KtxProjectLlmConfig, env: NodeJS.ProcessEnv): LlmStatus {
async function buildLlmStatus(
config: KtxProjectLlmConfig,
options: {
projectDir: string;
env: NodeJS.ProcessEnv;
claudeCodeAuthProbe?: ClaudeCodeAuthProbe;
},
): Promise<LlmStatus> {
const env = options.env;
const backend = config.provider.backend;
const model = config.models?.default;
if (backend === 'none') {
@ -186,6 +206,26 @@ function buildLlmStatus(config: KtxProjectLlmConfig, env: NodeJS.ProcessEnv): Ll
fix: hint ? `Set ${hint}` : 'Set the gateway api_key or rerun `ktx setup`',
};
}
if (backend === 'claude-code') {
const modelName = model ?? 'sonnet';
const probe = options.claudeCodeAuthProbe ?? runClaudeCodeAuthProbe;
const auth = await probe({ projectDir: options.projectDir, model: modelName, env });
if (auth.ok) {
return {
backend,
model: modelName,
status: 'ok',
detail: 'local Claude Code session authenticated',
};
}
return {
backend,
model: modelName,
status: 'fail',
detail: auth.message,
fix: 'Authenticate Claude Code locally with the Claude Code CLI, then rerun `ktx status`.',
};
}
return { backend, model, status: 'warn', detail: 'unknown LLM backend' };
}
@ -568,6 +608,14 @@ function buildWarnings(
});
}
const warning = formatClaudeCodePromptCachingWarning(ignoredClaudeCodePromptCachingFields(config.llm));
if (warning) {
warnings.push({
message: warning,
fix: formatClaudeCodePromptCachingFix(),
});
}
return warnings;
}
@ -629,6 +677,7 @@ function buildVerdict(
export interface BuildProjectStatusOptions {
env?: NodeJS.ProcessEnv;
postgresQueryHistoryProbe?: PostgresQueryHistoryProbe;
claudeCodeAuthProbe?: ClaudeCodeAuthProbe;
configIssues?: KtxConfigIssue[];
}
@ -649,7 +698,11 @@ export async function buildProjectStatus(project: KtxLocalProject, options: Buil
const config = project.config;
const configStatus = buildConfigStatus(options.configIssues);
const llm = buildLlmStatus(config.llm, env);
const llm = await buildLlmStatus(config.llm, {
projectDir: project.projectDir,
env,
claudeCodeAuthProbe: options.claudeCodeAuthProbe,
});
const embeddings = buildEmbeddingsStatus(config.ingest.embeddings, env);
const storage = buildStorageStatus(config);
const connections = Object.entries(config.connections).map(([name, conn]) =>