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

@ -31,46 +31,32 @@ function createCache(initial: Record<string, string> = {}): KtxDescriptionCacheP
function createLlmProvider(text = 'generated description') {
vi.mocked(generateText).mockResolvedValue({ text } as never);
return {
getModel: vi.fn().mockReturnValue({ modelId: 'claude-sonnet-4-6', provider: 'anthropic' }),
getModelByName: vi.fn(),
cacheMarker: vi.fn(),
repairToolCallHandler: vi.fn(),
thinkingProviderOptions: vi.fn(),
telemetryConfig: vi.fn(),
promptCachingConfig: vi.fn(() => ({
enabled: false,
systemTtl: '1h',
toolsTtl: '1h',
historyTtl: '5m',
cacheSystem: true,
cacheTools: true,
cacheHistory: true,
vertexFallbackTo5m: false,
})),
activeBackend: vi.fn(() => 'anthropic'),
generateText: vi.fn(async (input) => {
const result = await generateText({
system: input.system ? { role: 'system', content: input.system } : undefined,
messages: [{ role: 'user', content: input.prompt }],
temperature: input.temperature,
} as never);
return result.text;
}),
generateObject: vi.fn(),
runAgentLoop: vi.fn(),
} as any;
}
function createFailingLlmProvider(message = 'timeout exceeded when trying to connect') {
vi.mocked(generateText).mockRejectedValue(new Error(message) as never);
return {
getModel: vi.fn().mockReturnValue({ modelId: 'claude-sonnet-4-6', provider: 'anthropic' }),
getModelByName: vi.fn(),
cacheMarker: vi.fn(),
repairToolCallHandler: vi.fn(),
thinkingProviderOptions: vi.fn(),
telemetryConfig: vi.fn(),
promptCachingConfig: vi.fn(() => ({
enabled: false,
systemTtl: '1h',
toolsTtl: '1h',
historyTtl: '5m',
cacheSystem: true,
cacheTools: true,
cacheHistory: true,
vertexFallbackTo5m: false,
})),
activeBackend: vi.fn(() => 'anthropic'),
generateText: vi.fn(async (input) => {
const result = await generateText({
system: input.system ? { role: 'system', content: input.system } : undefined,
messages: [{ role: 'user', content: input.prompt }],
temperature: input.temperature,
} as never);
return result.text;
}),
generateObject: vi.fn(),
runAgentLoop: vi.fn(),
} as any;
}
@ -158,10 +144,10 @@ describe('KTX description prompt builders', () => {
describe('KtxDescriptionGenerator', () => {
it('generates column descriptions with pre-fetched values, cache hits, and word-limit metadata', async () => {
const cache = createCache({ 'warehouse.public.orders.cached_status': 'Cached status description' });
const llmProvider = createLlmProvider('Payment state');
const llmRuntime = createLlmProvider('Payment state');
const connector = createConnector();
const generator = new KtxDescriptionGenerator({
llmProvider,
llmRuntime,
cache,
settings: {
columnMaxWords: 12,
@ -222,7 +208,7 @@ describe('KtxDescriptionGenerator', () => {
it('samples through the connector when column values are not pre-fetched', async () => {
const connector = createConnector();
const generator = new KtxDescriptionGenerator({
llmProvider: createLlmProvider('Current order state'),
llmRuntime: createLlmProvider('Current order state'),
settings: {
columnMaxWords: 12,
tableMaxWords: 18,
@ -271,7 +257,7 @@ describe('KtxDescriptionGenerator', () => {
})),
};
const generator = new KtxDescriptionGenerator({
llmProvider: createLlmProvider('Generated through sampler'),
llmRuntime: createLlmProvider('Generated through sampler'),
settings: {
columnMaxWords: 12,
tableMaxWords: 18,
@ -310,7 +296,7 @@ describe('KtxDescriptionGenerator', () => {
const cache = createCache();
const connector = createConnector();
const generator = new KtxDescriptionGenerator({
llmProvider: createFailingLlmProvider(),
llmRuntime: createFailingLlmProvider(),
cache,
settings: {
columnMaxWords: 12,
@ -355,7 +341,7 @@ describe('KtxDescriptionGenerator', () => {
const cache = createCache();
const connector = createConnector();
const generator = new KtxDescriptionGenerator({
llmProvider: createLlmProvider('Commerce orders'),
llmRuntime: createLlmProvider('Commerce orders'),
cache,
settings: {
columnMaxWords: 12,
@ -424,7 +410,7 @@ describe('KtxDescriptionGenerator resilience', () => {
const logger = createLogger();
const warnings: Array<{ code: string; table?: string }> = [];
const generator = new KtxDescriptionGenerator({
llmProvider: createLlmProvider('Commerce orders'),
llmRuntime: createLlmProvider('Commerce orders'),
logger,
onWarning: (warning) => warnings.push({ code: warning.code, ...(warning.table ? { table: warning.table } : {}) }),
settings: { columnMaxWords: 12, tableMaxWords: 18, dataSourceMaxWords: 24, concurrencyLimit: 2 },
@ -455,7 +441,7 @@ describe('KtxDescriptionGenerator resilience', () => {
const logger = createLogger();
const warnings: Array<{ code: string; table?: string; metadata?: Record<string, unknown> }> = [];
const generator = new KtxDescriptionGenerator({
llmProvider: createLlmProvider('Customer reference data'),
llmRuntime: createLlmProvider('Customer reference data'),
logger,
onWarning: (warning) =>
warnings.push({
@ -503,7 +489,7 @@ describe('KtxDescriptionGenerator resilience', () => {
};
const warnings: string[] = [];
const generator = new KtxDescriptionGenerator({
llmProvider: createFailingLlmProvider(),
llmRuntime: createFailingLlmProvider(),
onWarning: (warning) => warnings.push(warning.code),
settings: { columnMaxWords: 12, tableMaxWords: 18, dataSourceMaxWords: 24 },
});
@ -528,7 +514,7 @@ describe('KtxDescriptionGenerator resilience', () => {
};
const warnings: string[] = [];
const generator = new KtxDescriptionGenerator({
llmProvider: createLlmProvider('Orders mart'),
llmRuntime: createLlmProvider('Orders mart'),
onWarning: (warning) => warnings.push(warning.code),
settings: { columnMaxWords: 12, tableMaxWords: 18, dataSourceMaxWords: 24 },
});
@ -562,7 +548,7 @@ describe('KtxDescriptionGenerator resilience', () => {
};
const warnings: string[] = [];
const generator = new KtxDescriptionGenerator({
llmProvider: createLlmProvider('should not be called'),
llmRuntime: createLlmProvider('should not be called'),
onWarning: (warning) => warnings.push(warning.code),
settings: { columnMaxWords: 12, tableMaxWords: 18, dataSourceMaxWords: 24 },
});
@ -588,7 +574,7 @@ describe('KtxDescriptionGenerator resilience', () => {
};
const logger = createLogger();
const generator = new KtxDescriptionGenerator({
llmProvider: createLlmProvider('Payment lifecycle state'),
llmRuntime: createLlmProvider('Payment lifecycle state'),
logger,
settings: { columnMaxWords: 12, tableMaxWords: 18, dataSourceMaxWords: 24 },
});
@ -625,7 +611,7 @@ describe('KtxDescriptionGenerator resilience', () => {
sampleColumn,
};
const generator = new KtxDescriptionGenerator({
llmProvider: createLlmProvider('Customer reference identifier'),
llmRuntime: createLlmProvider('Customer reference identifier'),
settings: { columnMaxWords: 12, tableMaxWords: 18, dataSourceMaxWords: 24 },
});
@ -657,7 +643,7 @@ describe('KtxDescriptionGenerator resilience', () => {
};
vi.mocked(generateText).mockClear();
const generator = new KtxDescriptionGenerator({
llmProvider: createLlmProvider('should not be called'),
llmRuntime: createLlmProvider('should not be called'),
settings: { columnMaxWords: 12, tableMaxWords: 18, dataSourceMaxWords: 24 },
});

View file

@ -1,5 +1,4 @@
import type { KtxLlmProvider } from '@ktx/llm';
import { generateKtxText } from '../llm/index.js';
import type { KtxLlmRuntimePort } from '../llm/index.js';
import type {
KtxColumnSampleInput,
KtxColumnSampleResult,
@ -120,7 +119,7 @@ export interface KtxGenerateDataSourceDescriptionInput {
}
export interface KtxDescriptionGeneratorOptions {
llmProvider: KtxLlmProvider;
llmRuntime: KtxLlmRuntimePort;
cache?: KtxDescriptionCachePort;
logger?: KtxScanLoggerPort;
onWarning?: (warning: KtxScanWarning) => void;
@ -400,14 +399,14 @@ Data source type: ${input.dataSourceType}`;
}
export class KtxDescriptionGenerator {
private readonly llmProvider: KtxLlmProvider;
private readonly llmRuntime: KtxLlmRuntimePort;
private readonly cache?: KtxDescriptionCachePort;
private readonly logger?: KtxScanLoggerPort;
private readonly onWarning?: (warning: KtxScanWarning) => void;
private readonly settings: ResolvedKtxDescriptionGenerationSettings;
constructor(options: KtxDescriptionGeneratorOptions) {
this.llmProvider = options.llmProvider;
this.llmRuntime = options.llmRuntime;
this.cache = options.cache;
this.logger = options.logger;
this.onWarning = options.onWarning;
@ -779,8 +778,7 @@ export class KtxDescriptionGenerator {
private async generateAiDescription(prompt: KtxDescriptionPrompt, _operationName: string): Promise<string | null> {
try {
const text = await generateKtxText({
llmProvider: this.llmProvider,
const text = await this.llmRuntime.generateText({
role: 'candidateExtraction',
system: prompt.system,
prompt: prompt.user,

View file

@ -264,7 +264,6 @@ export type {
} from './relationship-graph-resolver.js';
export { resolveKtxRelationshipGraph } from './relationship-graph-resolver.js';
export type {
KtxRelationshipLlmProposalGenerateText,
KtxRelationshipLlmProposalResult,
KtxRelationshipLlmProposalSettings,
ProposeKtxRelationshipCandidatesWithLlmInput,

View file

@ -356,7 +356,7 @@ describe('local scan enrichment', () => {
it('honors scan relationship config when LLM proposals are disabled', async () => {
const providers = createDeterministicLocalScanEnrichmentProviders({ embeddingDimensions: 3 });
const getModel = vi.fn(() => ({ modelId: 'provider/language-model', provider: 'gateway' }));
const generateObject = vi.fn();
const result = await runLocalScanEnrichment({
connectionId: 'warehouse',
mode: 'relationships',
@ -365,9 +365,9 @@ describe('local scan enrichment', () => {
context: { runId: 'scan-run-llm-disabled' },
providers: {
...providers,
llm: {
...providers.llm,
getModel: getModel as never,
llmRuntime: {
...providers.llmRuntime,
generateObject: generateObject as never,
},
},
relationshipSettings: {
@ -378,7 +378,7 @@ describe('local scan enrichment', () => {
});
expect(result.summary.llmRelationshipValidation).toBe('skipped');
expect(getModel).not.toHaveBeenCalledWith('candidateExtraction');
expect(generateObject).not.toHaveBeenCalled();
});
it('skips relationship detection when scan relationships are disabled', async () => {
@ -628,7 +628,7 @@ describe('local scan enrichment', () => {
connector: scanConnector,
context: { runId: 'scan-run-batched-embeddings' },
providers: {
llm: deterministicProviders.llm,
llmRuntime: deterministicProviders.llmRuntime,
embedding: {
dimensions: 3,
maxBatchSize: 2,
@ -658,7 +658,7 @@ describe('local scan enrichment', () => {
providerIdentity: { provider: 'deterministic', embeddingDimensions: 6 },
});
const getModel = vi.spyOn(providers.llm, 'getModel');
const generateText = vi.spyOn(providers.llmRuntime, 'generateText');
const embedBatch = vi.spyOn(providers.embedding, 'embedBatch');
const second = await runLocalScanEnrichment({
connectionId: 'warehouse',
@ -676,7 +676,7 @@ describe('local scan enrichment', () => {
expect(first.state.resumedStages).toEqual([]);
expect(second.state.resumedStages).toEqual(['descriptions', 'embeddings', 'relationships']);
expect(second.state.completedStages).toEqual(['descriptions', 'embeddings', 'relationships']);
expect(getModel).not.toHaveBeenCalled();
expect(generateText).not.toHaveBeenCalled();
expect(embedBatch).not.toHaveBeenCalled();
expect(second.descriptionUpdates).toEqual(first.descriptionUpdates);
expect(second.embeddingUpdates).toEqual(first.embeddingUpdates);
@ -711,7 +711,7 @@ describe('local scan enrichment', () => {
tables: [{ ...firstTable, name: 'customers' }],
})),
};
const getModel = vi.spyOn(providers.llm, 'getModel');
const generateText = vi.spyOn(providers.llmRuntime, 'generateText');
const result = await runLocalScanEnrichment({
connectionId: 'warehouse',
@ -727,7 +727,7 @@ describe('local scan enrichment', () => {
expect(result.state.resumedStages).toEqual([]);
expect(result.state.completedStages).toEqual(['descriptions', 'embeddings', 'relationships']);
expect(getModel).toHaveBeenCalled();
expect(generateText).toHaveBeenCalled();
});
it('runs providerless enriched scans as relationship-only discovery enrichment', async () => {

View file

@ -1,5 +1,5 @@
import type { KtxLlmProvider } from '@ktx/llm';
import pLimit from 'p-limit';
import type { KtxLlmRuntimePort } from '../llm/index.js';
import { buildDefaultKtxProjectConfig, type KtxScanRelationshipConfig } from '../project/config.js';
import { type KtxDescriptionColumnTable, KtxDescriptionGenerator } from './description-generation.js';
import { buildKtxColumnEmbeddingText } from './embedding-text.js';
@ -49,7 +49,7 @@ export interface DeterministicLocalScanEnrichmentProviderOptions {
}
export interface KtxLocalScanEnrichmentProviders {
llm: KtxLlmProvider;
llmRuntime: KtxLlmRuntimePort;
embedding: KtxEmbeddingPort;
}
@ -190,7 +190,7 @@ export function createDeterministicLocalScanEnrichmentProviders(
const dimensions = options.embeddingDimensions ?? 8;
const maxBatchSize = options.maxBatchSize ?? 64;
return {
llm: deterministicLlmProvider(),
llmRuntime: deterministicLlmRuntime(),
embedding: {
dimensions,
maxBatchSize,
@ -201,41 +201,16 @@ export function createDeterministicLocalScanEnrichmentProviders(
};
}
function deterministicLlmProvider(): KtxLlmProvider {
const model = { modelId: 'deterministic-scan', provider: 'deterministic' };
function deterministicLlmRuntime(): KtxLlmRuntimePort {
return {
getModel() {
return model as ReturnType<KtxLlmProvider['getModel']>;
async generateText(input) {
return `Deterministic description for ${input.prompt.slice(0, 64).trim() || 'data source'}`;
},
getModelByName() {
return model as ReturnType<KtxLlmProvider['getModelByName']>;
async generateObject() {
return { pkCandidates: [], fkCandidates: [] } as never;
},
cacheMarker() {
return undefined;
},
repairToolCallHandler() {
throw new Error('deterministic scan provider does not support tool-call repair');
},
thinkingProviderOptions() {
return {};
},
telemetryConfig() {
return undefined;
},
promptCachingConfig() {
return {
enabled: false,
systemTtl: '1h',
toolsTtl: '1h',
historyTtl: '5m',
cacheSystem: true,
cacheTools: true,
cacheHistory: true,
vertexFallbackTo5m: false,
};
},
activeBackend() {
return 'gateway';
async runAgentLoop() {
return { stopReason: 'natural' };
},
};
}
@ -324,7 +299,7 @@ async function generateDescriptions(input: {
}): Promise<KtxLocalScanEnrichmentResult['descriptionUpdates']> {
const warningSink = input.warnings;
const generator = new KtxDescriptionGenerator({
llmProvider: input.providers.llm,
llmRuntime: input.providers.llmRuntime,
...(input.context.logger ? { logger: input.context.logger } : {}),
...(warningSink
? {
@ -643,7 +618,7 @@ export async function runLocalScanEnrichment(
schema,
context: input.context,
settings: relationshipSettings,
llmProvider: input.providers?.llm ?? null,
llmRuntime: input.providers?.llmRuntime ?? null,
});
await relationshipProgress?.update(

View file

@ -1,10 +1,10 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { KtxLlmProvider } from '@ktx/llm';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import YAML from 'yaml';
import type { SourceAdapter } from '../ingest/index.js';
import type { KtxLlmRuntimePort } from '../llm/index.js';
import { initKtxProject, type KtxLocalProject, loadKtxProject } from '../project/index.js';
import { filterSnapshotTables, getLocalScanReport, getLocalScanStatus, resolveEnabledTables, runLocalScan } from './local-scan.js';
import type { KtxQueryResult, KtxReadOnlyQueryInput, KtxSchemaSnapshot, KtxSchemaTable } from './types.js';
@ -79,25 +79,11 @@ function relationshipSqlResult(
throw new Error(`Unexpected relationship SQL: ${input.sql}`);
}
function deterministicLlmProvider(): KtxLlmProvider {
function deterministicLlmRuntime(): KtxLlmRuntimePort {
return {
getModel: () => ({ provider: 'deterministic', modelId: 'deterministic' }) as never,
getModelByName: () => ({ provider: 'deterministic', modelId: 'deterministic' }) as never,
cacheMarker: () => undefined,
repairToolCallHandler: (() => undefined) as never,
thinkingProviderOptions: () => ({}),
telemetryConfig: () => undefined,
promptCachingConfig: () => ({
enabled: false,
systemTtl: '1h',
toolsTtl: '1h',
historyTtl: '5m',
cacheSystem: true,
cacheTools: true,
cacheHistory: true,
vertexFallbackTo5m: false,
}),
activeBackend: () => 'gateway',
generateText: vi.fn(async (input) => `Deterministic description for ${input.prompt.slice(0, 64).trim() || 'data source'}`),
generateObject: vi.fn(async () => ({ pkCandidates: [], fkCandidates: [] }) as never),
runAgentLoop: vi.fn(),
};
}
@ -571,7 +557,7 @@ describe('local scan', () => {
llmProposals: false,
maxLlmTablesPerBatch: 7,
};
const getModel = vi.fn(() => ({ modelId: 'provider/language-model', provider: 'gateway' }));
const generateObject = vi.fn(async () => ({ pkCandidates: [], fkCandidates: [] }));
const connector = {
id: 'test:warehouse',
driver: 'postgres' as const,
@ -650,9 +636,9 @@ describe('local scan', () => {
detectRelationships: true,
connector,
enrichmentProviders: {
llm: {
...deterministicLlmProvider(),
getModel: getModel as never,
llmRuntime: {
...deterministicLlmRuntime(),
generateObject: generateObject as never,
},
embedding: {
dimensions: 8,
@ -668,7 +654,7 @@ describe('local scan', () => {
expect(result.report.relationships.accepted).toBe(1);
expect(result.report.enrichment.llmRelationshipValidation).toBe('skipped');
expect(getModel).not.toHaveBeenCalledWith('candidateExtraction');
expect(generateObject).not.toHaveBeenCalled();
});
it('accepts no-declared-constraint relationships and writes relationship artifacts', async () => {
@ -1206,7 +1192,7 @@ describe('local scan', () => {
mode: 'enriched',
connector,
enrichmentProviders: {
llm: deterministicLlmProvider(),
llmRuntime: deterministicLlmRuntime(),
embedding: {
dimensions: 8,
maxBatchSize: 64,
@ -1314,7 +1300,7 @@ describe('local scan', () => {
return { values: ['1'], nullCount: 0, distinctCount: 1 };
},
};
const llm = deterministicLlmProvider();
const llmRuntime = deterministicLlmRuntime();
const first = await runLocalScan({
project,
@ -1323,7 +1309,7 @@ describe('local scan', () => {
mode: 'enriched',
connector,
enrichmentProviders: {
llm,
llmRuntime,
embedding: {
dimensions: 8,
maxBatchSize: 64,
@ -1344,7 +1330,7 @@ describe('local scan', () => {
});
expect(first.report.enrichment.embeddings).toBe('failed');
const getModel = vi.spyOn(llm, 'getModel');
const generateObject = vi.spyOn(llmRuntime, 'generateObject');
const retry = await runLocalScan({
project,
adapters: [fetchOnlyAdapter()],
@ -1352,7 +1338,7 @@ describe('local scan', () => {
mode: 'enriched',
connector,
enrichmentProviders: {
llm,
llmRuntime,
embedding: {
dimensions: 8,
maxBatchSize: 64,
@ -1373,8 +1359,8 @@ describe('local scan', () => {
failedStages: [],
});
expect(retry.report.enrichment.embeddings).toBe('completed');
expect(getModel).toHaveBeenCalledTimes(1);
expect(getModel).toHaveBeenCalledWith('candidateExtraction');
expect(generateObject).toHaveBeenCalledTimes(1);
expect(generateObject).toHaveBeenCalledWith(expect.objectContaining({ role: 'candidateExtraction' }));
expect(embeddingAttempts).toBe(2);
const reportPath = retry.report.artifactPaths.reportPath;

View file

@ -8,7 +8,7 @@ import {
} from '../ingest/index.js';
import {
createLocalKtxEmbeddingProviderFromConfig,
createLocalKtxLlmProviderFromConfig,
createLocalKtxLlmRuntimeFromConfig,
KtxScanEmbeddingPortAdapter,
} from '../llm/index.js';
import type { KtxProjectLlmConfig, KtxScanEnrichmentConfig, KtxScanRelationshipConfig } from '../project/config.js';
@ -150,6 +150,7 @@ interface LocalScanEnrichmentProviderDeps {
createKtxLlmProvider?: typeof createKtxLlmProvider;
createKtxEmbeddingProvider?: typeof createKtxEmbeddingProvider;
env?: NodeJS.ProcessEnv;
projectDir?: string;
}
export function createLocalScanEnrichmentProvidersFromConfig(
@ -165,14 +166,17 @@ export function createLocalScanEnrichmentProvidersFromConfig(
return null;
}
const llm = createLocalKtxLlmProviderFromConfig(llmConfig, deps);
const llmRuntime = createLocalKtxLlmRuntimeFromConfig(llmConfig, {
...deps,
projectDir: deps.projectDir,
});
const embeddingProvider = createLocalKtxEmbeddingProviderFromConfig(config.embeddings, deps);
if (!llm || !embeddingProvider) {
if (!llmRuntime || !embeddingProvider) {
return null;
}
return {
llm,
llmRuntime,
embedding: new KtxScanEmbeddingPortAdapter(embeddingProvider),
};
}
@ -378,7 +382,9 @@ export async function runLocalScan(options: RunLocalScanOptions): Promise<LocalS
connector && (mode !== 'structural' || options.detectRelationships)
? options.enrichmentProviders !== undefined
? options.enrichmentProviders
: createLocalScanEnrichmentProvidersFromConfig(options.project.config.scan.enrichment, options.project.config.llm)
: createLocalScanEnrichmentProvidersFromConfig(options.project.config.scan.enrichment, options.project.config.llm, {
projectDir: options.project.projectDir,
})
: null;
await options.progress?.update(0.15, 'Inspecting database schema');

View file

@ -6,6 +6,7 @@ import { gunzipSync } from 'node:zlib';
import Database from 'better-sqlite3';
import YAML from 'yaml';
import { z } from 'zod';
import type { KtxLlmRuntimePort } from '../llm/index.js';
import type { KtxEnrichedRelationship, KtxEnrichedSchema, KtxRelationshipType } from './enrichment-types.js';
import { snapshotToKtxEnrichedSchema } from './local-enrichment.js';
import type { KtxRelationshipDiscoveryCandidate } from './relationship-candidates.js';
@ -13,7 +14,6 @@ import {
generateKtxRelationshipDiscoveryCandidates,
mergeKtxRelationshipDiscoveryCandidates,
} from './relationship-candidates.js';
import type { KtxLlmProvider } from '@ktx/llm';
import { proposeKtxRelationshipCandidatesWithLlm } from './relationship-llm-proposal.js';
import {
discoverKtxCompositeRelationships,
@ -527,7 +527,7 @@ export function isKtxRelationshipBenchmarkTuningEligible(input: {
}
export function ktxRelationshipBenchmarkDetectorWithLlm(
llmProvider: KtxLlmProvider,
llmRuntime: KtxLlmRuntimePort,
): KtxRelationshipBenchmarkDetector {
return {
async detect(input) {
@ -566,7 +566,7 @@ export function ktxRelationshipBenchmarkDetectorWithLlm(
connectionId: input.snapshot.connectionId,
schema: input.schema,
profile: profiles,
llmProvider,
llmRuntime,
});
const candidates = mergeKtxRelationshipDiscoveryCandidates([
...broadRelationshipCandidates,

View file

@ -1,6 +1,6 @@
import type { KtxLlmProvider } from '@ktx/llm';
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { KtxLlmRuntimePort } from '../llm/index.js';
import { buildDefaultKtxProjectConfig } from '../project/config.js';
import { snapshotToKtxEnrichedSchema } from './local-enrichment.js';
import {
@ -216,29 +216,11 @@ function connector(executor: InMemorySqliteExecutor | null): KtxScanConnector {
};
}
function llmProvider(): KtxLlmProvider {
const model = { modelId: 'claude-sonnet-4-6', provider: 'anthropic' };
function llmRuntime(output: unknown): KtxLlmRuntimePort {
return {
getModel: vi.fn(() => model as ReturnType<KtxLlmProvider['getModel']>),
getModelByName: vi.fn(() => model as ReturnType<KtxLlmProvider['getModelByName']>),
cacheMarker: vi.fn(),
repairToolCallHandler: vi.fn(),
thinkingProviderOptions: vi.fn(() => ({})),
telemetryConfig: vi.fn(() => undefined),
promptCachingConfig: vi.fn(
() =>
({
enabled: false,
systemTtl: '1h',
toolsTtl: '1h',
historyTtl: '5m',
cacheSystem: true,
cacheTools: true,
cacheHistory: true,
vertexFallbackTo5m: false,
}) as ReturnType<KtxLlmProvider['promptCachingConfig']>,
),
activeBackend: vi.fn(() => 'anthropic' as ReturnType<KtxLlmProvider['activeBackend']>),
generateText: vi.fn(),
generateObject: vi.fn(async () => output) as KtxLlmRuntimePort['generateObject'],
runAgentLoop: vi.fn(),
};
}
@ -505,21 +487,19 @@ describe('production relationship discovery', () => {
INSERT INTO customers (id) VALUES (1), (2);
INSERT INTO orders (id, buyer_ref) VALUES (10, 1), (11, 2);
`);
const generateText = vi.fn(async () => ({
output: {
pkCandidates: [{ table: 'customers', column: 'id', confidence: 0.91, rationale: 'Unique customer key.' }],
fkCandidates: [
{
fromTable: 'orders',
fromColumn: 'buyer_ref',
toTable: 'customers',
toColumn: 'id',
confidence: 0.89,
rationale: 'Buyer reference values align with customer identifiers.',
},
],
},
}));
const llmOutput = {
pkCandidates: [{ table: 'customers', column: 'id', confidence: 0.91, rationale: 'Unique customer key.' }],
fkCandidates: [
{
fromTable: 'orders',
fromColumn: 'buyer_ref',
toTable: 'customers',
toColumn: 'id',
confidence: 0.89,
rationale: 'Buyer reference values align with customer identifiers.',
},
],
};
const result = await discoverKtxRelationships({
connectionId: 'warehouse',
@ -528,8 +508,7 @@ describe('production relationship discovery', () => {
schema: snapshotToKtxEnrichedSchema(llmOnlyRelationshipSnapshot()),
context: { runId: 'llm-relationship-orchestrator' },
settings: relationshipSettings(),
llmProvider: llmProvider(),
generateText,
llmRuntime: llmRuntime(llmOutput),
});
expect(result.llmRelationshipValidation).toBe('completed');

View file

@ -1,4 +1,4 @@
import type { KtxLlmProvider } from '@ktx/llm';
import type { KtxLlmRuntimePort } from '../llm/index.js';
import type { KtxScanRelationshipConfig } from '../project/config.js';
import type { KtxEnrichedRelationship, KtxEnrichedSchema, KtxRelationshipUpdate } from './enrichment-types.js';
import {
@ -15,10 +15,7 @@ import {
type KtxResolvedRelationshipDiscoveryCandidate,
resolveKtxRelationshipGraph,
} from './relationship-graph-resolver.js';
import {
type KtxRelationshipLlmProposalGenerateText,
proposeKtxRelationshipCandidatesWithLlm,
} from './relationship-llm-proposal.js';
import { proposeKtxRelationshipCandidatesWithLlm } from './relationship-llm-proposal.js';
import {
createKtxRelationshipProfileCache,
type KtxRelationshipProfileArtifact,
@ -42,8 +39,7 @@ export interface DiscoverKtxRelationshipsInput {
schema: KtxEnrichedSchema;
context: KtxScanContext;
settings: KtxScanRelationshipConfig;
llmProvider?: KtxLlmProvider | null;
generateText?: KtxRelationshipLlmProposalGenerateText;
llmRuntime?: KtxLlmRuntimePort | null;
}
export interface DiscoverKtxRelationshipsResult {
@ -246,11 +242,10 @@ export async function discoverKtxRelationships(
connectionId: input.connectionId,
schema: input.schema,
profile,
llmProvider: input.llmProvider ?? null,
llmRuntime: input.llmRuntime ?? null,
settings: {
maxTablesPerBatch: input.settings.maxLlmTablesPerBatch,
},
generateText: input.generateText,
})
: { candidates: [], warnings: [], llmCalls: 0, summary: 'skipped' as const };
const candidates = mergeKtxRelationshipDiscoveryCandidates([

View file

@ -1,32 +1,14 @@
import type { KtxLlmProvider } from '@ktx/llm';
import { describe, expect, it, vi } from 'vitest';
import type { KtxLlmRuntimePort } from '../llm/index.js';
import type { KtxEnrichedColumn, KtxEnrichedSchema, KtxEnrichedTable } from './enrichment-types.js';
import type { KtxRelationshipProfileArtifact } from './relationship-profiling.js';
import { proposeKtxRelationshipCandidatesWithLlm } from './relationship-llm-proposal.js';
function llmProvider(provider = 'anthropic'): KtxLlmProvider {
const model = { modelId: 'claude-sonnet-4-6', provider };
function llmRuntime(output?: unknown): KtxLlmRuntimePort {
return {
getModel: vi.fn(() => model as ReturnType<KtxLlmProvider['getModel']>),
getModelByName: vi.fn(() => model as ReturnType<KtxLlmProvider['getModelByName']>),
cacheMarker: vi.fn(),
repairToolCallHandler: vi.fn(),
thinkingProviderOptions: vi.fn(() => ({})),
telemetryConfig: vi.fn(() => undefined),
promptCachingConfig: vi.fn(
() =>
({
enabled: false,
systemTtl: '1h',
toolsTtl: '1h',
historyTtl: '5m',
cacheSystem: true,
cacheTools: true,
cacheHistory: true,
vertexFallbackTo5m: false,
}) as ReturnType<KtxLlmProvider['promptCachingConfig']>,
),
activeBackend: vi.fn(() => provider as ReturnType<KtxLlmProvider['activeBackend']>),
generateText: vi.fn(),
generateObject: vi.fn(async () => output) as KtxLlmRuntimePort['generateObject'],
runAgentLoop: vi.fn(),
};
}
@ -125,28 +107,25 @@ function profile(): KtxRelationshipProfileArtifact {
describe('relationship LLM proposals', () => {
it('maps valid structured FK proposals into review candidates with rationale evidence', async () => {
const generateText = vi.fn(async () => ({
output: {
pkCandidates: [{ table: 'customers', column: 'id', confidence: 0.94, rationale: 'Unique customer identifier.' }],
fkCandidates: [
{
fromTable: 'orders',
fromColumn: 'buyer_ref',
toTable: 'customers',
toColumn: 'id',
confidence: 0.88,
rationale: 'Buyer reference values match customer identifiers.',
},
],
},
}));
const runtime = llmRuntime({
pkCandidates: [{ table: 'customers', column: 'id', confidence: 0.94, rationale: 'Unique customer identifier.' }],
fkCandidates: [
{
fromTable: 'orders',
fromColumn: 'buyer_ref',
toTable: 'customers',
toColumn: 'id',
confidence: 0.88,
rationale: 'Buyer reference values match customer identifiers.',
},
],
});
const result = await proposeKtxRelationshipCandidatesWithLlm({
connectionId: 'warehouse',
schema: schema(),
profile: profile(),
llmProvider: llmProvider(),
generateText,
llmRuntime: runtime,
});
expect(result.summary).toBe('completed');
@ -164,42 +143,27 @@ describe('relationship LLM proposals', () => {
reasons: ['llm_proposal', 'llm_pk_proposal'],
},
});
expect(generateText).toHaveBeenCalledWith(
expect(runtime.generateObject).toHaveBeenCalledWith(
expect.objectContaining({
system: expect.objectContaining({
role: 'system',
content: expect.stringContaining('You are helping KTX review possible SQL relationships'),
}),
messages: expect.arrayContaining([
expect.objectContaining({
role: 'user',
content: expect.stringContaining('"tables"'),
}),
]),
role: 'candidateExtraction',
system: expect.stringContaining('You are helping KTX review possible SQL relationships'),
prompt: expect.stringContaining('"tables"'),
}),
);
const call = (
generateText.mock.calls as unknown as Array<[{ messages: Array<{ role: string; content: string }> }]>
)[0]?.[0];
const userMessage = call?.messages.find((m) => m.role === 'user');
expect(userMessage?.content).not.toContain('You are helping KTX review possible SQL relationships');
expect(call?.messages.some((m) => m.role === 'system')).toBe(false);
const call = vi.mocked(runtime.generateObject).mock.calls[0]?.[0];
expect(call?.prompt).not.toContain('You are helping KTX review possible SQL relationships');
});
it('skips deterministic providers without calling generateText', async () => {
const generateText = vi.fn();
it('skips when no runtime is configured', async () => {
const result = await proposeKtxRelationshipCandidatesWithLlm({
connectionId: 'warehouse',
schema: schema(),
profile: profile(),
llmProvider: llmProvider('deterministic'),
generateText,
llmRuntime: null,
});
expect(result).toMatchObject({ candidates: [], llmCalls: 0, summary: 'skipped' });
expect(result.warnings).toEqual([]);
expect(generateText).not.toHaveBeenCalled();
});
it('returns recoverable warnings for invalid references and generation failures', async () => {
@ -207,22 +171,19 @@ describe('relationship LLM proposals', () => {
connectionId: 'warehouse',
schema: schema(),
profile: profile(),
llmProvider: llmProvider(),
generateText: vi.fn(async () => ({
output: {
pkCandidates: [],
fkCandidates: [
{
fromTable: 'orders',
fromColumn: 'missing_column',
toTable: 'customers',
toColumn: 'id',
confidence: 0.7,
rationale: 'Invalid source column.',
},
],
},
})),
llmRuntime: llmRuntime({
pkCandidates: [],
fkCandidates: [
{
fromTable: 'orders',
fromColumn: 'missing_column',
toTable: 'customers',
toColumn: 'id',
confidence: 0.7,
rationale: 'Invalid source column.',
},
],
}),
});
expect(invalidReference.candidates).toEqual([]);
expect(invalidReference.summary).toBe('completed');
@ -235,10 +196,13 @@ describe('relationship LLM proposals', () => {
connectionId: 'warehouse',
schema: schema(),
profile: profile(),
llmProvider: llmProvider(),
generateText: vi.fn(async () => {
throw new Error('model unavailable');
}),
llmRuntime: {
generateText: vi.fn(),
generateObject: vi.fn(async () => {
throw new Error('model unavailable');
}),
runAgentLoop: vi.fn(),
},
});
expect(failed).toMatchObject({ candidates: [], llmCalls: 1, summary: 'failed' });
expect(failed.warnings[0]).toMatchObject({

View file

@ -1,7 +1,5 @@
import type { KtxLlmProvider } from '@ktx/llm';
import type { generateText } from 'ai';
import { z } from 'zod';
import { generateKtxObject } from '../llm/index.js';
import { generateKtxObject, type KtxLlmRuntimePort } from '../llm/index.js';
import type { KtxEnrichedColumn, KtxEnrichedSchema, KtxEnrichedTable } from './enrichment-types.js';
import {
normalizeKtxRelationshipName,
@ -32,10 +30,6 @@ const relationshipLlmProposalSchema = z.object({
});
type KtxRelationshipLlmProposalOutput = z.infer<typeof relationshipLlmProposalSchema>;
type GenerateTextInput = Parameters<typeof generateText>[0];
export type KtxRelationshipLlmProposalGenerateText = (
input: GenerateTextInput,
) => Promise<{ text?: string; output?: unknown }>;
export interface KtxRelationshipLlmProposalSettings {
maxTablesPerBatch: number;
@ -48,9 +42,8 @@ export interface ProposeKtxRelationshipCandidatesWithLlmInput {
connectionId: string;
schema: KtxEnrichedSchema;
profile: KtxRelationshipProfileArtifact;
llmProvider: KtxLlmProvider | null;
llmRuntime: KtxLlmRuntimePort | null;
settings?: Partial<KtxRelationshipLlmProposalSettings>;
generateText?: KtxRelationshipLlmProposalGenerateText;
}
export interface KtxRelationshipLlmProposalResult {
@ -77,11 +70,6 @@ function clampConfidence(value: number): number {
return Number(Math.max(0, Math.min(1, value)).toFixed(3));
}
function modelIsDeterministic(llmProvider: KtxLlmProvider): boolean {
const model = llmProvider.getModel('candidateExtraction');
return (model as { provider?: string }).provider === 'deterministic';
}
function findTable(schema: KtxEnrichedSchema, name: string): KtxEnrichedTable | null {
const normalized = name.toLowerCase();
return schema.tables.find((table) => table.ref.name.toLowerCase() === normalized) ?? null;
@ -238,7 +226,7 @@ function generationFailureWarning(error: unknown): KtxScanWarning {
export async function proposeKtxRelationshipCandidatesWithLlm(
input: ProposeKtxRelationshipCandidatesWithLlmInput,
): Promise<KtxRelationshipLlmProposalResult> {
if (!input.llmProvider || modelIsDeterministic(input.llmProvider)) {
if (!input.llmRuntime) {
return { candidates: [], warnings: [], llmCalls: 0, summary: 'skipped' };
}
@ -256,12 +244,11 @@ export async function proposeKtxRelationshipCandidatesWithLlm(
KtxRelationshipLlmProposalOutput,
typeof relationshipLlmProposalSchema
>({
llmProvider: input.llmProvider,
runtime: input.llmRuntime,
role: 'candidateExtraction',
system,
prompt,
schema: relationshipLlmProposalSchema,
generateText: input.generateText,
});
const output = relationshipLlmProposalSchema.parse(generated);
const mapped = mapValidProposals(input.schema, output, settings);