mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-22 08:38:08 +02:00
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:
parent
e6d578c03f
commit
b565e44a22
109 changed files with 10218 additions and 1093 deletions
29
packages/cli/src/claude-code-prompt-caching.ts
Normal file
29
packages/cli/src/claude-code-prompt-caching.ts
Normal 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.';
|
||||
}
|
||||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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]) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue