mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-19 08:28:06 +02:00
feat(setup): apply per-role LLM model presets, remove --llm-model (#268)
* feat(setup): write per-role llm model presets * feat(setup): remove llm model setup flag * chore(setup): update llm preset guidance * docs(setup): document llm model presets * chore(release): sync uv.lock to 0.9.0 * fix(cli): make sl query --execute work on secret-backed connections sl query --execute used a parallel SQL executor (createDefaultLocalQueryExecutor) that passed connection.url verbatim into pg, so file:/env: secret references failed with "SASL: SCRAM-SERVER-FIRST-MESSAGE: client password must be a string". Collapse onto the connector-based executor already used by MCP and ingest (createKtxCliIngestQueryExecutor), which resolves secret references and supports every driver. Delete the now-dead local/postgres/sqlite query executors, their tests, and the orphaned hasLocalQueryExecutor driver flag. * docs(agents): require one implementation per capability Add a design-reasoning default and a matching self-check question telling agents to route callers through a single shared implementation of a capability rather than forking a parallel one, and to fix the shared layer rather than patch one branch. Encodes the lesson from a divergent SQL-execution-path bug, stated generally. CLAUDE.md is a symlink to AGENTS.md, so both agent-instruction files are covered.
This commit is contained in:
parent
2896f9fb91
commit
2c18a62de4
25 changed files with 404 additions and 1384 deletions
|
|
@ -95,7 +95,6 @@ function shouldShowSetupEntryMenu(
|
|||
llmBackend?: KtxSetupLlmBackend;
|
||||
anthropicApiKeyEnv?: string;
|
||||
anthropicApiKeyFile?: string;
|
||||
llmModel?: string;
|
||||
vertexProject?: string;
|
||||
vertexLocation?: string;
|
||||
skipLlm?: boolean;
|
||||
|
|
@ -166,7 +165,6 @@ function shouldShowSetupEntryMenu(
|
|||
'llmBackend',
|
||||
'anthropicApiKeyEnv',
|
||||
'anthropicApiKeyFile',
|
||||
'llmModel',
|
||||
'vertexProject',
|
||||
'vertexLocation',
|
||||
'skipLlm',
|
||||
|
|
@ -229,7 +227,6 @@ 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('--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())
|
||||
.addOption(new Option('--skip-llm', 'Leave LLM setup incomplete for now').hideHelp().default(false))
|
||||
|
|
@ -423,7 +420,6 @@ 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.vertexProject ? { vertexProject: options.vertexProject } : {}),
|
||||
...(options.vertexLocation ? { vertexLocation: options.vertexLocation } : {}),
|
||||
skipLlm: options.skipLlm === true,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ export interface KtxDriverRegistration {
|
|||
readonly driver: KtxConnectionDriver;
|
||||
readonly scopeConfigKey: KtxScopeConfigKey | null;
|
||||
readonly hasHistoricSqlReader: boolean;
|
||||
readonly hasLocalQueryExecutor: boolean;
|
||||
load(): Promise<KtxDriverConnectorModule>;
|
||||
}
|
||||
|
||||
|
|
@ -31,7 +30,6 @@ export const driverRegistrations: Record<KtxConnectionDriver, KtxDriverRegistrat
|
|||
driver: 'bigquery',
|
||||
scopeConfigKey: 'dataset_ids',
|
||||
hasHistoricSqlReader: true,
|
||||
hasLocalQueryExecutor: false,
|
||||
load: async () => {
|
||||
const m = await import('../../connectors/bigquery/connector.js');
|
||||
return {
|
||||
|
|
@ -53,7 +51,6 @@ export const driverRegistrations: Record<KtxConnectionDriver, KtxDriverRegistrat
|
|||
driver: 'clickhouse',
|
||||
scopeConfigKey: 'databases',
|
||||
hasHistoricSqlReader: false,
|
||||
hasLocalQueryExecutor: false,
|
||||
load: async () => {
|
||||
const m = await import('../../connectors/clickhouse/connector.js');
|
||||
return {
|
||||
|
|
@ -75,7 +72,6 @@ export const driverRegistrations: Record<KtxConnectionDriver, KtxDriverRegistrat
|
|||
driver: 'mysql',
|
||||
scopeConfigKey: 'schemas',
|
||||
hasHistoricSqlReader: false,
|
||||
hasLocalQueryExecutor: false,
|
||||
load: async () => {
|
||||
const m = await import('../../connectors/mysql/connector.js');
|
||||
return {
|
||||
|
|
@ -97,7 +93,6 @@ export const driverRegistrations: Record<KtxConnectionDriver, KtxDriverRegistrat
|
|||
driver: 'postgres',
|
||||
scopeConfigKey: 'schemas',
|
||||
hasHistoricSqlReader: true,
|
||||
hasLocalQueryExecutor: true,
|
||||
load: async () => {
|
||||
const m = await import('../../connectors/postgres/connector.js');
|
||||
return {
|
||||
|
|
@ -119,7 +114,6 @@ export const driverRegistrations: Record<KtxConnectionDriver, KtxDriverRegistrat
|
|||
driver: 'sqlite',
|
||||
scopeConfigKey: null,
|
||||
hasHistoricSqlReader: false,
|
||||
hasLocalQueryExecutor: true,
|
||||
load: async () => {
|
||||
const m = await import('../../connectors/sqlite/connector.js');
|
||||
return {
|
||||
|
|
@ -141,7 +135,6 @@ export const driverRegistrations: Record<KtxConnectionDriver, KtxDriverRegistrat
|
|||
driver: 'snowflake',
|
||||
scopeConfigKey: 'schema_names',
|
||||
hasHistoricSqlReader: true,
|
||||
hasLocalQueryExecutor: false,
|
||||
load: async () => {
|
||||
const m = await import('../../connectors/snowflake/connector.js');
|
||||
return {
|
||||
|
|
@ -163,7 +156,6 @@ export const driverRegistrations: Record<KtxConnectionDriver, KtxDriverRegistrat
|
|||
driver: 'sqlserver',
|
||||
scopeConfigKey: 'schemas',
|
||||
hasHistoricSqlReader: false,
|
||||
hasLocalQueryExecutor: false,
|
||||
load: async () => {
|
||||
const m = await import('../../connectors/sqlserver/connector.js');
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
import { driverRegistrations, getDriverRegistration } from './drivers.js';
|
||||
import { createPostgresQueryExecutor } from './postgres-query-executor.js';
|
||||
import type {
|
||||
KtxSqlQueryExecutionInput,
|
||||
KtxSqlQueryExecutionResult,
|
||||
KtxSqlQueryExecutorPort,
|
||||
} from './query-executor.js';
|
||||
import { createSqliteQueryExecutor } from './sqlite-query-executor.js';
|
||||
import type { KtxConnectionDriver } from '../scan/types.js';
|
||||
|
||||
export interface DefaultLocalQueryExecutorOptions {
|
||||
postgres?: KtxSqlQueryExecutorPort;
|
||||
sqlite?: KtxSqlQueryExecutorPort;
|
||||
}
|
||||
|
||||
function driverFor(input: KtxSqlQueryExecutionInput): string {
|
||||
return String(input.connection?.driver ?? '').toLowerCase();
|
||||
}
|
||||
|
||||
function localExecutorMap(
|
||||
options: DefaultLocalQueryExecutorOptions,
|
||||
): Partial<Record<KtxConnectionDriver, KtxSqlQueryExecutorPort>> {
|
||||
const wiredExecutors: Partial<Record<KtxConnectionDriver, KtxSqlQueryExecutorPort>> = {
|
||||
postgres: options.postgres ?? createPostgresQueryExecutor(),
|
||||
sqlite: options.sqlite ?? createSqliteQueryExecutor(),
|
||||
};
|
||||
|
||||
const executors: Partial<Record<KtxConnectionDriver, KtxSqlQueryExecutorPort>> = {};
|
||||
for (const registration of Object.values(driverRegistrations)) {
|
||||
if (!registration.hasLocalQueryExecutor) continue;
|
||||
const executor = wiredExecutors[registration.driver];
|
||||
if (executor) {
|
||||
executors[registration.driver] = executor;
|
||||
}
|
||||
}
|
||||
return executors;
|
||||
}
|
||||
|
||||
export function createDefaultLocalQueryExecutor(options: DefaultLocalQueryExecutorOptions = {}): KtxSqlQueryExecutorPort {
|
||||
const executors = localExecutorMap(options);
|
||||
|
||||
return {
|
||||
async execute(input: KtxSqlQueryExecutionInput): Promise<KtxSqlQueryExecutionResult> {
|
||||
const driver = driverFor(input);
|
||||
const registration = getDriverRegistration(driver);
|
||||
if (!registration?.hasLocalQueryExecutor) {
|
||||
throw new Error(`No local query executor is configured for driver "${input.connection?.driver ?? 'unknown'}".`);
|
||||
}
|
||||
|
||||
const executor = executors[registration.driver];
|
||||
if (!executor) {
|
||||
throw new Error(
|
||||
`Local query executor flag is enabled for driver "${registration.driver}", but no executor factory is wired.`,
|
||||
);
|
||||
}
|
||||
return executor.execute(input);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import { Client, type ClientConfig } from 'pg';
|
||||
import type {
|
||||
KtxSqlQueryExecutionInput,
|
||||
KtxSqlQueryExecutionResult,
|
||||
KtxSqlQueryExecutorPort,
|
||||
} from './query-executor.js';
|
||||
import { limitSqlForExecution } from './read-only-sql.js';
|
||||
|
||||
interface PgClientLike {
|
||||
connect(): Promise<unknown>;
|
||||
query(input: string | { text: string; rowMode: 'array' }): Promise<{
|
||||
fields: Array<{ name: string }>;
|
||||
rows: unknown[][];
|
||||
command: string;
|
||||
rowCount: number | null;
|
||||
}>;
|
||||
end(): Promise<void>;
|
||||
}
|
||||
|
||||
interface PostgresQueryExecutorOptions {
|
||||
statementTimeoutMs?: number;
|
||||
queryTimeoutMs?: number;
|
||||
connectionTimeoutMs?: number;
|
||||
clientFactory?: (config: ClientConfig) => PgClientLike;
|
||||
}
|
||||
|
||||
function connectionDriver(input: KtxSqlQueryExecutionInput): string {
|
||||
return String(input.connection?.driver ?? '').toLowerCase();
|
||||
}
|
||||
|
||||
function createDefaultClient(config: ClientConfig): PgClientLike {
|
||||
return new Client(config);
|
||||
}
|
||||
|
||||
export function createPostgresQueryExecutor(options: PostgresQueryExecutorOptions = {}): KtxSqlQueryExecutorPort {
|
||||
const clientFactory = options.clientFactory ?? createDefaultClient;
|
||||
return {
|
||||
async execute(input: KtxSqlQueryExecutionInput): Promise<KtxSqlQueryExecutionResult> {
|
||||
const driver = connectionDriver(input);
|
||||
const connection = input.connection;
|
||||
if (driver !== 'postgres') {
|
||||
throw new Error(`Local Postgres execution cannot run driver "${connection?.driver ?? 'unknown'}".`);
|
||||
}
|
||||
if (typeof connection?.url !== 'string' || connection.url.trim().length === 0) {
|
||||
throw new Error(`Local Postgres execution requires connections.${input.connectionId}.url.`);
|
||||
}
|
||||
|
||||
const client = clientFactory({
|
||||
connectionString: connection.url,
|
||||
statement_timeout: options.statementTimeoutMs ?? 30_000,
|
||||
query_timeout: options.queryTimeoutMs ?? 35_000,
|
||||
connectionTimeoutMillis: options.connectionTimeoutMs ?? 5_000,
|
||||
application_name: 'ktx-local-query',
|
||||
});
|
||||
await client.connect();
|
||||
try {
|
||||
await client.query('BEGIN READ ONLY');
|
||||
const result = await client.query({
|
||||
text: limitSqlForExecution(input.sql, input.maxRows),
|
||||
rowMode: 'array',
|
||||
});
|
||||
await client.query('COMMIT');
|
||||
return {
|
||||
headers: result.fields.map((field) => field.name),
|
||||
rows: result.rows,
|
||||
totalRows: result.rows.length,
|
||||
command: result.command,
|
||||
rowCount: result.rowCount,
|
||||
};
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK').catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ export interface KtxSqlQueryExecutionInput {
|
|||
maxRows?: number;
|
||||
}
|
||||
|
||||
export interface KtxSqlQueryExecutionResult {
|
||||
interface KtxSqlQueryExecutionResult {
|
||||
headers: string[];
|
||||
rows: unknown[][];
|
||||
totalRows: number;
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
import { isAbsolute, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import Database from 'better-sqlite3';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import type {
|
||||
KtxSqlQueryExecutionInput,
|
||||
KtxSqlQueryExecutionResult,
|
||||
KtxSqlQueryExecutorPort,
|
||||
} from './query-executor.js';
|
||||
import { normalizeQueryRows } from './query-executor.js';
|
||||
import { limitSqlForExecution } from './read-only-sql.js';
|
||||
|
||||
type SqliteConnectionConfig = Record<string, unknown> | undefined;
|
||||
|
||||
function connectionDriver(input: KtxSqlQueryExecutionInput): string {
|
||||
return String(input.connection?.driver ?? '').toLowerCase();
|
||||
}
|
||||
|
||||
function stringConfigValue(connection: SqliteConnectionConfig, key: string): string | undefined {
|
||||
const value = connection?.[key];
|
||||
return typeof value === 'string' && value.trim().length > 0 ? resolveStringReference(key, value.trim()) : undefined;
|
||||
}
|
||||
|
||||
function resolveStringReference(key: string, value: string): string {
|
||||
if (value.startsWith('env:')) {
|
||||
return process.env[value.slice('env:'.length)] ?? '';
|
||||
}
|
||||
if (key !== 'url' && value.startsWith('file:')) {
|
||||
const rawPath = value.slice('file:'.length);
|
||||
const path = rawPath.startsWith('~') ? resolve(homedir(), rawPath.slice(1)) : rawPath;
|
||||
return readFileSync(path, 'utf-8').trim();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sqlitePathFromUrl(url: string): string {
|
||||
if (url.startsWith('file:')) {
|
||||
return fileURLToPath(url);
|
||||
}
|
||||
|
||||
if (url.startsWith('sqlite:')) {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.pathname.length > 0) {
|
||||
return decodeURIComponent(parsed.pathname);
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function sqliteDatabasePathFromConnection(input: KtxSqlQueryExecutionInput): string {
|
||||
const driver = connectionDriver(input);
|
||||
if (driver !== 'sqlite') {
|
||||
throw new Error(`Local SQLite execution cannot run driver "${input.connection?.driver ?? 'unknown'}".`);
|
||||
}
|
||||
|
||||
const pathValue = stringConfigValue(input.connection, 'path');
|
||||
const urlValue = stringConfigValue(input.connection, 'url');
|
||||
if (!pathValue && !urlValue) {
|
||||
throw new Error(
|
||||
`Local SQLite execution requires connections.${input.connectionId}.path or connections.${input.connectionId}.url.`,
|
||||
);
|
||||
}
|
||||
|
||||
const candidate = pathValue ?? sqlitePathFromUrl(urlValue as string);
|
||||
return isAbsolute(candidate) ? candidate : resolve(input.projectDir ?? process.cwd(), candidate);
|
||||
}
|
||||
|
||||
export function createSqliteQueryExecutor(): KtxSqlQueryExecutorPort {
|
||||
return {
|
||||
async execute(input: KtxSqlQueryExecutionInput): Promise<KtxSqlQueryExecutionResult> {
|
||||
const sql = limitSqlForExecution(input.sql, input.maxRows);
|
||||
const dbPath = sqliteDatabasePathFromConnection(input);
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const statement = db.prepare(sql);
|
||||
const rows = statement.all() as unknown[];
|
||||
return {
|
||||
headers: statement.columns().map((column) => column.name),
|
||||
rows: normalizeQueryRows(rows),
|
||||
totalRows: rows.length,
|
||||
command: 'SELECT',
|
||||
rowCount: rows.length,
|
||||
};
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -615,8 +615,8 @@ function localIngestLlmProviderGuardMessage(projectDir: string): string {
|
|||
'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, claude-code, or codex, or an injected agentRunner.',
|
||||
'Configure a local Claude Code/Codex session or API-backed LLM, then rerun ingest:',
|
||||
` ktx setup --project-dir ${projectDir} --llm-backend claude-code --no-input`,
|
||||
` ktx setup --project-dir ${projectDir} --llm-backend codex --llm-model gpt-5.5 --no-input`,
|
||||
` ktx setup --project-dir ${projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --llm-model claude-sonnet-4-6 --no-input`,
|
||||
` ktx setup --project-dir ${projectDir} --llm-backend codex --no-input`,
|
||||
` ktx setup --project-dir ${projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --no-input`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { resolveKtxConfigReference } from './context/core/config-reference.js';
|
|||
import { type KtxProjectConfig, type KtxProjectLlmConfig, serializeKtxProjectConfig } from './context/project/config.js';
|
||||
import { loadKtxProject } from './context/project/project.js';
|
||||
import { markKtxSetupStateStepComplete } from './context/project/setup-config.js';
|
||||
import type { KtxLlmConfig } from './llm/types.js';
|
||||
import { type KtxModelRole, KTX_MODEL_ROLES, type KtxLlmConfig } from './llm/types.js';
|
||||
import { type KtxLlmHealthCheckResult, runKtxLlmHealthCheck } from './llm/model-health.js';
|
||||
import {
|
||||
formatClaudeCodePromptCachingWarning,
|
||||
|
|
@ -37,7 +37,6 @@ export interface KtxSetupModelArgs {
|
|||
llmBackend?: KtxSetupLlmBackend;
|
||||
anthropicApiKeyEnv?: string;
|
||||
anthropicApiKeyFile?: string;
|
||||
llmModel?: string;
|
||||
vertexProject?: string;
|
||||
vertexLocation?: string;
|
||||
forcePrompt?: boolean;
|
||||
|
|
@ -52,13 +51,6 @@ export type KtxSetupModelResult =
|
|||
| { status: 'missing-input'; projectDir: string }
|
||||
| { status: 'failed'; projectDir: string };
|
||||
|
||||
/** @internal */
|
||||
export interface AnthropicModelChoice {
|
||||
id: string;
|
||||
label: string;
|
||||
recommended: boolean;
|
||||
}
|
||||
|
||||
export type KtxSetupLlmBackend = 'anthropic' | 'vertex' | 'claude-code' | 'codex';
|
||||
|
||||
/** @internal */
|
||||
|
|
@ -76,9 +68,7 @@ export interface KtxSetupModelPromptAdapter {
|
|||
|
||||
export interface KtxSetupModelDeps {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
fetch?: typeof fetch;
|
||||
prompts?: KtxSetupModelPromptAdapter;
|
||||
listModels?: (apiKey: string) => Promise<AnthropicModelChoice[]>;
|
||||
healthCheck?: (config: KtxLlmConfig) => Promise<KtxLlmHealthCheckResult>;
|
||||
claudeCodeAuthProbe?: (input: {
|
||||
projectDir: string;
|
||||
|
|
@ -91,91 +81,58 @@ export interface KtxSetupModelDeps {
|
|||
spinner?: () => KtxCliSpinner;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export const BUNDLED_ANTHROPIC_MODELS: AnthropicModelChoice[] = [
|
||||
{ id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', recommended: true },
|
||||
{ id: 'claude-opus-4-6', label: 'Claude Opus 4.6', recommended: false },
|
||||
{ id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', recommended: false },
|
||||
];
|
||||
|
||||
const VERTEX_ANTHROPIC_MODELS: AnthropicModelChoice[] = [
|
||||
{ id: 'claude-opus-4-7', label: 'Claude Opus 4.7', recommended: false },
|
||||
{ id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', recommended: false },
|
||||
{ id: 'claude-opus-4-6', label: 'Claude Opus 4.6', recommended: false },
|
||||
{ id: 'claude-opus-4-5', label: 'Claude Opus 4.5', recommended: false },
|
||||
{ id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', recommended: false },
|
||||
{ id: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5', recommended: false },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
// Curated Codex models from OpenAI's current lineup that work under both
|
||||
// ChatGPT-account (subscription) and API-key auth. Intentionally omitted:
|
||||
// the `*-codex` ids (e.g. gpt-5.3-codex, gpt-5.2-codex) are API-key-only and
|
||||
// fail on ChatGPT-account auth, and gpt-5.3-codex-spark is a ChatGPT-Pro-only
|
||||
// research preview. Codex resolves real availability per account at runtime
|
||||
// (its binary remote-fetches the model list), so this is a convenience
|
||||
// shortlist only — the manual-entry option accepts any id your account's
|
||||
// `codex` picker exposes, and the auth probe reports an unsupported choice.
|
||||
const CODEX_MODELS: AnthropicModelChoice[] = [
|
||||
{ id: 'gpt-5.5', label: 'GPT-5.5', recommended: true },
|
||||
{ id: 'gpt-5.4', label: 'GPT-5.4', recommended: false },
|
||||
{ id: 'gpt-5.4-mini', label: 'GPT-5.4 mini', recommended: false },
|
||||
];
|
||||
|
||||
const HIDDEN_ANTHROPIC_MODEL_PATTERNS = [
|
||||
/^claude-sonnet-4$/i,
|
||||
/^claude-opus-4$/i,
|
||||
/^Claude Sonnet 4$/i,
|
||||
/^Claude Opus 4$/i,
|
||||
];
|
||||
|
||||
const ANTHROPIC_CREDENTIAL_PROMPT_CONTEXT =
|
||||
'KTX uses the key to verify Anthropic model access now and to run ingest agents that turn schemas, SQL, ' +
|
||||
'BI metadata, and docs into semantic-layer sources and wiki context. ktx.yaml stores an env: or file: ' +
|
||||
'reference, not the raw key.';
|
||||
|
||||
const ANTHROPIC_MODEL_PROMPT_CONTEXT =
|
||||
'KTX uses this as the default model for ingest agents that turn schemas, SQL, BI metadata, and docs ' +
|
||||
'into semantic-layer sources and wiki context.';
|
||||
|
||||
const VERTEX_PROJECT_PROMPT_CONTEXT =
|
||||
'KTX stores the selected Google Cloud project ID in ktx.yaml and uses Application Default Credentials for ' +
|
||||
'access. Project visibility depends on the signed-in Google account and organization permissions.';
|
||||
const DEFAULT_VERTEX_LOCATION = 'us-east5';
|
||||
|
||||
type KtxSetupModelPreset = Record<KtxModelRole, string>;
|
||||
|
||||
const ANTHROPIC_PRESET = {
|
||||
default: 'claude-sonnet-4-6',
|
||||
triage: 'claude-haiku-4-5',
|
||||
candidateExtraction: 'claude-sonnet-4-6',
|
||||
curator: 'claude-opus-4-7',
|
||||
reconcile: 'claude-opus-4-7',
|
||||
repair: 'claude-haiku-4-5',
|
||||
} satisfies KtxSetupModelPreset;
|
||||
|
||||
const CLAUDE_CODE_PRESET = {
|
||||
default: 'sonnet',
|
||||
triage: 'haiku',
|
||||
candidateExtraction: 'sonnet',
|
||||
curator: 'opus',
|
||||
reconcile: 'opus',
|
||||
repair: 'haiku',
|
||||
} satisfies KtxSetupModelPreset;
|
||||
|
||||
const CODEX_PRESET = {
|
||||
default: DEFAULT_CODEX_MODEL,
|
||||
triage: DEFAULT_CODEX_MODEL,
|
||||
candidateExtraction: DEFAULT_CODEX_MODEL,
|
||||
curator: DEFAULT_CODEX_MODEL,
|
||||
reconcile: DEFAULT_CODEX_MODEL,
|
||||
repair: DEFAULT_CODEX_MODEL,
|
||||
} satisfies KtxSetupModelPreset;
|
||||
|
||||
const MODEL_PRESETS = {
|
||||
anthropic: ANTHROPIC_PRESET,
|
||||
vertex: ANTHROPIC_PRESET,
|
||||
'claude-code': CLAUDE_CODE_PRESET,
|
||||
codex: CODEX_PRESET,
|
||||
} satisfies Record<KtxSetupLlmBackend, KtxSetupModelPreset>;
|
||||
|
||||
function presetForBackend(backend: KtxSetupLlmBackend): KtxSetupModelPreset {
|
||||
return MODEL_PRESETS[backend];
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
type AnthropicModelDiscoveryErrorReason = 'authentication' | 'http' | 'empty-response';
|
||||
|
||||
class AnthropicModelDiscoveryError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly reason: AnthropicModelDiscoveryErrorReason,
|
||||
public readonly status?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AnthropicModelDiscoveryError';
|
||||
}
|
||||
}
|
||||
|
||||
function isAnthropicModelAuthenticationError(error: unknown): error is AnthropicModelDiscoveryError {
|
||||
return error instanceof AnthropicModelDiscoveryError && error.reason === 'authentication';
|
||||
}
|
||||
|
||||
function isSelectableAnthropicModel(model: AnthropicModelChoice): boolean {
|
||||
return !HIDDEN_ANTHROPIC_MODEL_PATTERNS.some((pattern) => pattern.test(model.id) || pattern.test(model.label));
|
||||
}
|
||||
|
||||
type ChooseModelResult =
|
||||
| { status: 'ready'; model: string }
|
||||
| { status: 'back' | 'missing-input' | 'invalid-credential' };
|
||||
|
||||
type ChooseBackendResult =
|
||||
| { status: 'ready'; backend: KtxSetupLlmBackend; prompted: boolean }
|
||||
| { status: 'back' };
|
||||
|
|
@ -234,47 +191,6 @@ async function defaultListGcloudProjects(): Promise<GcloudProjectChoice[]> {
|
|||
.filter((project): project is GcloudProjectChoice => Boolean(project));
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export async function fetchAnthropicModels(
|
||||
apiKey: string,
|
||||
fetchFn: typeof fetch = fetch,
|
||||
): Promise<AnthropicModelChoice[]> {
|
||||
const response = await fetchFn('https://api.anthropic.com/v1/models?limit=1000', {
|
||||
headers: {
|
||||
'anthropic-version': '2023-06-01',
|
||||
'x-api-key': apiKey,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new AnthropicModelDiscoveryError(
|
||||
`Anthropic model discovery failed with HTTP ${response.status}`,
|
||||
'authentication',
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
throw new AnthropicModelDiscoveryError(
|
||||
`Anthropic model discovery failed with HTTP ${response.status}`,
|
||||
'http',
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
const body = (await response.json()) as { data?: Array<{ id?: unknown; display_name?: unknown; type?: unknown }> };
|
||||
const models = (body.data ?? [])
|
||||
.map((item) => ({
|
||||
id: typeof item.id === 'string' ? item.id : '',
|
||||
label: typeof item.display_name === 'string' ? item.display_name : typeof item.id === 'string' ? item.id : '',
|
||||
recommended: false,
|
||||
}))
|
||||
.filter((item) => item.id.startsWith('claude-'))
|
||||
.filter(isSelectableAnthropicModel);
|
||||
if (models.length === 0) {
|
||||
throw new AnthropicModelDiscoveryError('Anthropic model discovery returned no Claude models', 'empty-response');
|
||||
}
|
||||
const recommendedIndex = models.findIndex((item) => item.id.includes('sonnet'));
|
||||
return models.map((item, index) => ({ ...item, recommended: index === Math.max(recommendedIndex, 0) }));
|
||||
}
|
||||
|
||||
export function isKtxSetupLlmConfigReady(config: KtxProjectLlmConfig): boolean {
|
||||
let resolved: KtxLlmConfig | null;
|
||||
try {
|
||||
|
|
@ -309,12 +225,12 @@ function buildProjectLlmConfig(
|
|||
| { backend: 'vertex'; vertex: { project?: string; location: string } }
|
||||
| { backend: 'claude-code' }
|
||||
| { backend: 'codex' },
|
||||
model: string,
|
||||
models: KtxSetupModelPreset,
|
||||
): KtxProjectLlmConfig {
|
||||
if (provider.backend === 'claude-code') {
|
||||
return {
|
||||
provider: { backend: 'claude-code' },
|
||||
models: { ...existing.models, default: model },
|
||||
models,
|
||||
promptCaching: existing.promptCaching,
|
||||
};
|
||||
}
|
||||
|
|
@ -322,7 +238,7 @@ function buildProjectLlmConfig(
|
|||
if (provider.backend === 'codex') {
|
||||
return {
|
||||
provider: { backend: 'codex' },
|
||||
models: { ...existing.models, default: model },
|
||||
models,
|
||||
promptCaching: existing.promptCaching,
|
||||
};
|
||||
}
|
||||
|
|
@ -333,7 +249,7 @@ function buildProjectLlmConfig(
|
|||
backend: 'vertex',
|
||||
vertex: provider.vertex,
|
||||
},
|
||||
models: { ...existing.models, default: model },
|
||||
models,
|
||||
promptCaching: { ...(existing.promptCaching ?? {}), enabled: true, vertexFallbackTo5m: true },
|
||||
};
|
||||
}
|
||||
|
|
@ -343,7 +259,7 @@ function buildProjectLlmConfig(
|
|||
backend: 'anthropic',
|
||||
anthropic: { api_key: provider.credentialRef },
|
||||
},
|
||||
models: { ...existing.models, default: model },
|
||||
models,
|
||||
promptCaching: { ...(existing.promptCaching ?? {}), enabled: true },
|
||||
};
|
||||
}
|
||||
|
|
@ -514,16 +430,12 @@ function requestedBackend(args: KtxSetupModelArgs): KtxSetupLlmBackend | undefin
|
|||
if (args.vertexProject || args.vertexLocation) {
|
||||
return 'vertex';
|
||||
}
|
||||
if (args.anthropicApiKeyEnv || args.anthropicApiKeyFile || args.llmModel) {
|
||||
if (args.anthropicApiKeyEnv || args.anthropicApiKeyFile) {
|
||||
return 'anthropic';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function requestedModel(args: KtxSetupModelArgs): string | undefined {
|
||||
return args.llmModel;
|
||||
}
|
||||
|
||||
async function chooseBackend(
|
||||
args: KtxSetupModelArgs,
|
||||
io: KtxCliIo,
|
||||
|
|
@ -774,187 +686,6 @@ async function chooseVertexConfig(
|
|||
};
|
||||
}
|
||||
|
||||
async function chooseModel(
|
||||
args: KtxSetupModelArgs,
|
||||
credentialValue: string,
|
||||
io: KtxCliIo,
|
||||
deps: KtxSetupModelDeps,
|
||||
): Promise<ChooseModelResult> {
|
||||
const providedModel = requestedModel(args);
|
||||
if (providedModel) {
|
||||
return { status: 'ready', model: providedModel };
|
||||
}
|
||||
if (args.inputMode === 'disabled') {
|
||||
io.stderr.write('Missing LLM model: pass --llm-model.\n');
|
||||
return { status: 'missing-input' };
|
||||
}
|
||||
|
||||
let models: AnthropicModelChoice[];
|
||||
try {
|
||||
models = deps.listModels
|
||||
? await deps.listModels(credentialValue)
|
||||
: await fetchAnthropicModels(credentialValue, deps.fetch);
|
||||
} catch (error) {
|
||||
if (isAnthropicModelAuthenticationError(error)) {
|
||||
const statusSuffix = error.status ? ` (HTTP ${error.status})` : '';
|
||||
io.stderr.write(`Anthropic API key is invalid or unauthorized${statusSuffix}. Check the key and try again.\n`);
|
||||
return { status: 'invalid-credential' };
|
||||
}
|
||||
io.stderr.write(
|
||||
'Could not fetch live Anthropic models. Showing bundled defaults. Setup will still test the selected model before saving it.\n',
|
||||
);
|
||||
models = BUNDLED_ANTHROPIC_MODELS;
|
||||
}
|
||||
|
||||
const selectableModels = models.filter(isSelectableAnthropicModel);
|
||||
const prompts = deps.prompts ?? createPromptAdapter();
|
||||
const modelOptions = [
|
||||
...selectableModels.map((model) => ({
|
||||
value: model.id,
|
||||
label: model.label || model.id,
|
||||
...(model.recommended ? { hint: 'recommended' } : {}),
|
||||
})),
|
||||
{ value: 'manual', label: 'Enter a model ID manually' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
];
|
||||
const choice = await prompts.autocomplete({
|
||||
message: `Which Anthropic model should KTX use?\n\n${ANTHROPIC_MODEL_PROMPT_CONTEXT}`,
|
||||
placeholder: 'Type to search models',
|
||||
options: modelOptions,
|
||||
});
|
||||
if (choice === 'back') {
|
||||
return { status: 'back' };
|
||||
}
|
||||
if (choice === 'manual') {
|
||||
const manual = await prompts.text({
|
||||
message: withTextInputNavigation('Anthropic model ID'),
|
||||
placeholder: selectableModels.find((model) => model.recommended)?.id ?? selectableModels[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 chooseVertexModel(args: KtxSetupModelArgs, io: KtxCliIo, deps: KtxSetupModelDeps): Promise<ChooseModelResult> {
|
||||
const providedModel = requestedModel(args);
|
||||
if (providedModel) {
|
||||
return { status: 'ready', model: providedModel };
|
||||
}
|
||||
if (args.inputMode === 'disabled') {
|
||||
io.stderr.write('Missing LLM model: pass --llm-model.\n');
|
||||
return { status: 'missing-input' };
|
||||
}
|
||||
|
||||
const selectableModels = VERTEX_ANTHROPIC_MODELS.filter(isSelectableAnthropicModel);
|
||||
const prompts = deps.prompts ?? createPromptAdapter();
|
||||
const choice = await prompts.autocomplete({
|
||||
message: `Which Anthropic model should KTX use?\n\n${ANTHROPIC_MODEL_PROMPT_CONTEXT}`,
|
||||
placeholder: 'Type to search models',
|
||||
options: [
|
||||
...selectableModels.map((model) => ({
|
||||
value: model.id,
|
||||
label: model.label || model.id,
|
||||
...(model.recommended ? { hint: 'recommended' } : {}),
|
||||
})),
|
||||
{ value: 'manual', label: 'Enter a model ID manually' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
if (choice === 'back') {
|
||||
return { status: 'back' };
|
||||
}
|
||||
if (choice === 'manual') {
|
||||
const manual = await prompts.text({
|
||||
message: withTextInputNavigation('Anthropic model ID'),
|
||||
placeholder: selectableModels.find((model) => model.recommended)?.id ?? selectableModels[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 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 chooseCodexModel(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: DEFAULT_CODEX_MODEL };
|
||||
}
|
||||
|
||||
const prompts = deps.prompts ?? createPromptAdapter();
|
||||
const choice = await prompts.select({
|
||||
message: `Which Codex model should KTX use?\n\n${ANTHROPIC_MODEL_PROMPT_CONTEXT}`,
|
||||
options: [
|
||||
...CODEX_MODELS.map((model) => ({
|
||||
value: model.id,
|
||||
label: model.label,
|
||||
...(model.recommended ? { hint: 'recommended' } : {}),
|
||||
})),
|
||||
{ value: 'manual', label: 'Enter a Codex model ID manually' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
if (choice === 'back') {
|
||||
return { status: 'back' };
|
||||
}
|
||||
if (choice === 'manual') {
|
||||
const manual = await prompts.text({
|
||||
message: withTextInputNavigation('Codex model ID'),
|
||||
placeholder: CODEX_MODELS.find((model) => model.recommended)?.id ?? CODEX_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:
|
||||
|
|
@ -962,12 +693,12 @@ async function persistLlmConfig(
|
|||
| { backend: 'vertex'; vertex: { project?: string; location: string } }
|
||||
| { backend: 'claude-code' }
|
||||
| { backend: 'codex' },
|
||||
model: string,
|
||||
models: KtxSetupModelPreset,
|
||||
): Promise<void> {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
const config = {
|
||||
...project.config,
|
||||
llm: buildProjectLlmConfig(project.config.llm, provider, model),
|
||||
llm: buildProjectLlmConfig(project.config.llm, provider, models),
|
||||
scan: {
|
||||
...project.config.scan,
|
||||
enrichment: {
|
||||
|
|
@ -990,6 +721,61 @@ function buildInteractiveRetryArgs(args: KtxSetupModelArgs, backend?: KtxSetupLl
|
|||
};
|
||||
}
|
||||
|
||||
type PresetModelValidationResult = { ok: true } | { ok: false; message: string };
|
||||
|
||||
function distinctPresetModels(preset: KtxSetupModelPreset): string[] {
|
||||
const models: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const role of KTX_MODEL_ROLES) {
|
||||
const model = preset[role];
|
||||
if (!seen.has(model)) {
|
||||
seen.add(model);
|
||||
models.push(model);
|
||||
}
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
function rolesUsingModel(preset: KtxSetupModelPreset, model: string): KtxModelRole[] {
|
||||
return KTX_MODEL_ROLES.filter((role) => preset[role] === model);
|
||||
}
|
||||
|
||||
function formatPresetFallbackWarning(roles: KtxModelRole[], unavailableModel: string, anchorModel: string): string {
|
||||
return `LLM model ${unavailableModel} is unavailable for ${roles.join(', ')}; using ${anchorModel} for those roles.`;
|
||||
}
|
||||
|
||||
async function validatePresetModels(
|
||||
preset: KtxSetupModelPreset,
|
||||
validateModel: (model: string) => Promise<PresetModelValidationResult>,
|
||||
io: KtxCliIo,
|
||||
): Promise<{ status: 'ready'; models: KtxSetupModelPreset } | { status: 'failed'; message: string }> {
|
||||
const anchorModel = preset.default;
|
||||
const degraded = { ...preset };
|
||||
const models = distinctPresetModels(preset);
|
||||
|
||||
const anchorResult = await validateModel(anchorModel);
|
||||
if (!anchorResult.ok) {
|
||||
return { status: 'failed', message: anchorResult.message };
|
||||
}
|
||||
|
||||
for (const model of models) {
|
||||
if (model === anchorModel) {
|
||||
continue;
|
||||
}
|
||||
const result = await validateModel(model);
|
||||
if (result.ok) {
|
||||
continue;
|
||||
}
|
||||
const affectedRoles = rolesUsingModel(degraded, model);
|
||||
for (const role of affectedRoles) {
|
||||
degraded[role] = anchorModel;
|
||||
}
|
||||
io.stderr.write(`${formatPresetFallbackWarning(affectedRoles, model, anchorModel)}\n`);
|
||||
}
|
||||
|
||||
return { status: 'ready', models: degraded };
|
||||
}
|
||||
|
||||
export async function runKtxSetupAnthropicModelStep(
|
||||
args: KtxSetupModelArgs,
|
||||
io: KtxCliIo,
|
||||
|
|
@ -1007,7 +793,6 @@ export async function runKtxSetupAnthropicModelStep(
|
|||
!args.llmBackend &&
|
||||
!args.anthropicApiKeyEnv &&
|
||||
!args.anthropicApiKeyFile &&
|
||||
!args.llmModel &&
|
||||
!args.vertexProject &&
|
||||
!args.vertexLocation
|
||||
) {
|
||||
|
|
@ -1038,94 +823,74 @@ export async function runKtxSetupAnthropicModelStep(
|
|||
return { status: vertex.status, projectDir: args.projectDir };
|
||||
}
|
||||
|
||||
const model = await chooseVertexModel(backendArgs, io, deps);
|
||||
if (model.status === 'back' && !backendArgs.vertexLocation) {
|
||||
const preset = presetForBackend('vertex');
|
||||
const validation = await validatePresetModels(
|
||||
preset,
|
||||
async (model) =>
|
||||
runLlmHealthCheckWithProgress(
|
||||
buildVertexHealthConfig(vertex.values, model),
|
||||
'Vertex AI',
|
||||
model,
|
||||
healthCheck,
|
||||
deps,
|
||||
),
|
||||
io,
|
||||
);
|
||||
if (validation.status !== 'ready') {
|
||||
io.stderr.write(
|
||||
`Vertex AI Anthropic model health check failed: ${formatVertexHealthFailure(validation.message, vertex.values)}\n`,
|
||||
);
|
||||
if (args.inputMode === 'disabled') {
|
||||
return { status: 'failed', projectDir: args.projectDir };
|
||||
}
|
||||
io.stderr.write('Choose a different Vertex AI project or location, or Back.\n');
|
||||
attemptArgs = buildInteractiveRetryArgs(args, backendChoice.backend);
|
||||
continue;
|
||||
}
|
||||
if (model.status === 'invalid-credential') {
|
||||
return { status: 'failed', projectDir: args.projectDir };
|
||||
}
|
||||
if (model.status !== 'ready') {
|
||||
return { status: model.status, projectDir: args.projectDir };
|
||||
}
|
||||
|
||||
const health = await runLlmHealthCheckWithProgress(
|
||||
buildVertexHealthConfig(vertex.values, model.model),
|
||||
'Vertex AI',
|
||||
model.model,
|
||||
healthCheck,
|
||||
deps,
|
||||
);
|
||||
if (health.ok) {
|
||||
await persistLlmConfig(args.projectDir, { backend: 'vertex', vertex: vertex.refs }, model.model);
|
||||
io.stdout.write(`│ LLM ready: yes (${model.model})\n`);
|
||||
return { status: 'ready', projectDir: args.projectDir };
|
||||
}
|
||||
|
||||
io.stderr.write(`Vertex AI Anthropic model health check failed: ${formatVertexHealthFailure(health.message, vertex.values)}\n`);
|
||||
if (args.inputMode === 'disabled') {
|
||||
return { status: 'failed', projectDir: args.projectDir };
|
||||
}
|
||||
io.stderr.write('Choose a different Vertex AI project, location, or model, or Back.\n');
|
||||
attemptArgs = buildInteractiveRetryArgs(args, backendChoice.backend);
|
||||
continue;
|
||||
await persistLlmConfig(args.projectDir, { backend: 'vertex', vertex: vertex.refs }, validation.models);
|
||||
io.stdout.write(`│ LLM ready: yes (${validation.models.default})\n`);
|
||||
return { status: 'ready', projectDir: args.projectDir };
|
||||
}
|
||||
|
||||
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 preset = presetForBackend('claude-code');
|
||||
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`);
|
||||
const validation = await validatePresetModels(
|
||||
preset,
|
||||
async (model) => probe({ projectDir: args.projectDir, model, env: deps.env ?? process.env }),
|
||||
io,
|
||||
);
|
||||
if (validation.status !== 'ready') {
|
||||
io.stderr.write(`${validation.message}\n`);
|
||||
return { status: 'failed', projectDir: args.projectDir };
|
||||
}
|
||||
const warning = formatClaudeCodePromptCachingWarning(
|
||||
ignoredClaudeCodePromptCachingFields(
|
||||
buildProjectLlmConfig(project.config.llm, { backend: 'claude-code' }, model.model),
|
||||
buildProjectLlmConfig(project.config.llm, { backend: 'claude-code' }, validation.models),
|
||||
),
|
||||
);
|
||||
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`);
|
||||
await persistLlmConfig(args.projectDir, { backend: 'claude-code' }, validation.models);
|
||||
io.stdout.write(`│ LLM ready: yes (${validation.models.default})\n`);
|
||||
return { status: 'ready', projectDir: args.projectDir };
|
||||
}
|
||||
|
||||
if (backendChoice.backend === 'codex') {
|
||||
const model = await chooseCodexModel(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 preset = presetForBackend('codex');
|
||||
const probe = deps.codexAuthProbe ?? runCodexAuthProbe;
|
||||
const health = await probe({ projectDir: args.projectDir, model: model.model });
|
||||
if (!health.ok) {
|
||||
io.stderr.write(`${health.message}\n`);
|
||||
const validation = await validatePresetModels(preset, async (model) => probe({ projectDir: args.projectDir, model }), io);
|
||||
if (validation.status !== 'ready') {
|
||||
io.stderr.write(`${validation.message}\n`);
|
||||
return { status: 'failed', projectDir: args.projectDir };
|
||||
}
|
||||
// Prefix the clack gutter so the warning sits inside the setup frame
|
||||
// instead of breaking out of it; kept on stderr for scripted runs.
|
||||
io.stderr.write(`│ ${formatCodexIsolationWarning()}\n`);
|
||||
await persistLlmConfig(args.projectDir, { backend: 'codex' }, model.model);
|
||||
io.stdout.write(`│ LLM ready: yes (codex, ${model.model})\n`);
|
||||
await persistLlmConfig(args.projectDir, { backend: 'codex' }, validation.models);
|
||||
io.stdout.write(`│ LLM ready: yes (codex, ${validation.models.default})\n`);
|
||||
return { status: 'ready', projectDir: args.projectDir };
|
||||
}
|
||||
|
||||
|
|
@ -1138,8 +903,21 @@ export async function runKtxSetupAnthropicModelStep(
|
|||
return { status: credential.status, projectDir: args.projectDir };
|
||||
}
|
||||
|
||||
const model = await chooseModel(backendArgs, credential.value, io, deps);
|
||||
if (model.status === 'invalid-credential') {
|
||||
const preset = presetForBackend('anthropic');
|
||||
const validation = await validatePresetModels(
|
||||
preset,
|
||||
async (model) =>
|
||||
runLlmHealthCheckWithProgress(
|
||||
buildAnthropicHealthConfig(credential.value, model),
|
||||
'Anthropic API',
|
||||
model,
|
||||
healthCheck,
|
||||
deps,
|
||||
),
|
||||
io,
|
||||
);
|
||||
if (validation.status !== 'ready') {
|
||||
io.stderr.write(`Anthropic model health check failed: ${validation.message}\n`);
|
||||
if (args.inputMode === 'disabled') {
|
||||
return { status: 'failed', projectDir: args.projectDir };
|
||||
}
|
||||
|
|
@ -1147,32 +925,9 @@ export async function runKtxSetupAnthropicModelStep(
|
|||
attemptArgs = buildInteractiveRetryArgs(args, backendChoice.backend);
|
||||
continue;
|
||||
}
|
||||
if (model.status === 'back' && !backendArgs.anthropicApiKeyEnv && !backendArgs.anthropicApiKeyFile) {
|
||||
attemptArgs = buildInteractiveRetryArgs(args, backendChoice.backend);
|
||||
continue;
|
||||
}
|
||||
if (model.status !== 'ready') {
|
||||
return { status: model.status, projectDir: args.projectDir };
|
||||
}
|
||||
|
||||
const health = await runLlmHealthCheckWithProgress(
|
||||
buildAnthropicHealthConfig(credential.value, model.model),
|
||||
'Anthropic API',
|
||||
model.model,
|
||||
healthCheck,
|
||||
deps,
|
||||
);
|
||||
if (health.ok) {
|
||||
await persistLlmConfig(args.projectDir, { backend: 'anthropic', credentialRef: credential.ref }, model.model);
|
||||
io.stdout.write(`│ LLM ready: yes (${model.model})\n`);
|
||||
return { status: 'ready', projectDir: args.projectDir };
|
||||
}
|
||||
|
||||
io.stderr.write(`Anthropic model health check failed: ${health.message}\n`);
|
||||
if (args.inputMode === 'disabled') {
|
||||
return { status: 'failed', projectDir: args.projectDir };
|
||||
}
|
||||
io.stderr.write('Choose a different credential source or model, or Back.\n');
|
||||
attemptArgs = buildInteractiveRetryArgs(args, backendChoice.backend);
|
||||
await persistLlmConfig(args.projectDir, { backend: 'anthropic', credentialRef: credential.ref }, validation.models);
|
||||
io.stdout.write(`│ LLM ready: yes (${validation.models.default})\n`);
|
||||
return { status: 'ready', projectDir: args.projectDir };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,6 @@ export type KtxSetupArgs =
|
|||
llmBackend?: KtxSetupLlmBackend;
|
||||
anthropicApiKeyEnv?: string;
|
||||
anthropicApiKeyFile?: string;
|
||||
llmModel?: string;
|
||||
vertexProject?: string;
|
||||
vertexLocation?: string;
|
||||
skipLlm: boolean;
|
||||
|
|
@ -700,7 +699,6 @@ 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.vertexProject ? { vertexProject: args.vertexProject } : {}),
|
||||
...(args.vertexLocation ? { vertexLocation: args.vertexLocation } : {}),
|
||||
forcePrompt: forcePromptSteps.has('models') || runOnly === 'models',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { readFile } from 'node:fs/promises';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { createDefaultLocalQueryExecutor } from './context/connections/local-query-executor.js';
|
||||
import type { KtxSqlQueryExecutorPort } from './context/connections/query-executor.js';
|
||||
import { KtxIngestEmbeddingPortAdapter } from './context/llm/embedding-port.js';
|
||||
import type { KtxEmbeddingPort } from './context/core/embedding.js';
|
||||
|
|
@ -20,6 +19,7 @@ import {
|
|||
resolveProjectEmbeddingProvider,
|
||||
type EmbeddingProviderResolution,
|
||||
} from './embedding-resolution.js';
|
||||
import { createKtxCliIngestQueryExecutor } from './ingest-query-executor.js';
|
||||
import type { PrintListColumn } from './io/print-list.js';
|
||||
import {
|
||||
createManagedPythonSemanticLayerComputePort,
|
||||
|
|
@ -81,7 +81,7 @@ interface KtxSlDeps {
|
|||
io: KtxSlIo;
|
||||
projectDir?: string;
|
||||
}) => Promise<KtxSemanticLayerComputePort>;
|
||||
createQueryExecutor?: () => KtxSqlQueryExecutorPort;
|
||||
createQueryExecutor?: (project: KtxLocalProject) => KtxSqlQueryExecutorPort;
|
||||
}
|
||||
|
||||
function resolutionToEmbeddingPort(resolution: EmbeddingProviderResolution): KtxEmbeddingPort | null {
|
||||
|
|
@ -321,7 +321,7 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
|
|||
io,
|
||||
projectDir: args.projectDir,
|
||||
});
|
||||
const queryExecutor = args.execute ? (deps.createQueryExecutor ?? createDefaultLocalQueryExecutor)() : undefined;
|
||||
const queryExecutor = args.execute ? (deps.createQueryExecutor ?? createKtxCliIngestQueryExecutor)(project) : undefined;
|
||||
const result = await compileLocalSlQuery(project, {
|
||||
connectionId: args.connectionId,
|
||||
query,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue