Merge pull request #19 from Kaelio/andreybavt/dbt-vertex-no-anthropic

fix(cli): honor configured LLM backends in setup
This commit is contained in:
Andrey Avtomonov 2026-05-12 10:25:24 +02:00 committed by GitHub
commit bb8868f238
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 185 additions and 17 deletions

View file

@ -676,4 +676,53 @@ describe('setup Anthropic model step', () => {
).resolves.toMatchObject({ status: 'ready' });
expect(healthCheck).not.toHaveBeenCalled();
});
it.each([
{
backend: 'vertex',
providerLines: [' backend: vertex', ' vertex:', ' project: kaelio-dev', ' location: us-east5'],
model: 'claude-sonnet-4-6',
},
{
backend: 'gateway',
providerLines: [' backend: gateway', ' gateway:', ' api_key: env:AI_GATEWAY_API_KEY'],
model: 'anthropic/claude-sonnet-4-6',
},
])('preserves already configured $backend llm setup without asking for Anthropic credentials', async (fixture) => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'setup:',
' database_connection_ids: []',
' completed_steps:',
' - project',
' - llm',
'connections: {}',
'llm:',
' provider:',
...fixture.providerLines,
' models:',
` default: ${fixture.model}`,
'ingest:',
' embeddings:',
' backend: deterministic',
' model: deterministic',
' dimensions: 8',
].join('\n'),
'utf-8',
);
const healthCheck = vi.fn(async () => ({ ok: true as const }));
const io = makeIo();
await expect(
runKtxSetupAnthropicModelStep({ projectDir: tempDir, inputMode: 'disabled', skipLlm: false }, io.io, {
healthCheck,
}),
).resolves.toMatchObject({ status: 'ready' });
expect(healthCheck).not.toHaveBeenCalled();
expect(io.stdout()).toContain(`LLM ready: yes (${fixture.model})`);
expect(io.stderr()).not.toContain('Anthropic');
});
});

View file

@ -1,5 +1,6 @@
import { writeFile } from 'node:fs/promises';
import { cancel, isCancel, password, select, text } from '@clack/prompts';
import { resolveLocalKtxLlmConfig } from '@ktx/context';
import { resolveKtxConfigReference } from '@ktx/context/core';
import {
type KtxProjectConfig,
@ -170,13 +171,26 @@ export async function fetchAnthropicModels(
return models.map((item, index) => ({ ...item, recommended: index === Math.max(recommendedIndex, 0) }));
}
function hasCompletedLlm(config: KtxProjectConfig): boolean {
return (
config.setup?.completed_steps.includes('llm') === true &&
config.llm.provider.backend === 'anthropic' &&
typeof config.llm.models.default === 'string' &&
config.llm.models.default.length > 0
);
export function isKtxSetupLlmConfigReady(config: KtxProjectLlmConfig): boolean {
let resolved: KtxLlmConfig | null;
try {
resolved = resolveLocalKtxLlmConfig(config, process.env);
} catch {
return false;
}
if (!resolved) {
return false;
}
if (resolved.backend === 'vertex') {
return typeof resolved.vertex?.location === 'string' && resolved.vertex.location.trim().length > 0;
}
return resolved.backend === 'anthropic' || resolved.backend === 'gateway';
}
function hasUsableConfiguredLlm(config: KtxProjectConfig): boolean {
return isKtxSetupLlmConfigReady(config.llm);
}
function buildProjectLlmConfig(
@ -386,7 +400,7 @@ export async function runKtxSetupAnthropicModelStep(
const project = await loadKtxProject({ projectDir: args.projectDir });
if (
args.forcePrompt !== true &&
hasCompletedLlm(project.config) &&
hasUsableConfiguredLlm(project.config) &&
!args.anthropicApiKeyEnv &&
!args.anthropicApiKeyFile &&
!args.anthropicModel

View file

@ -87,6 +87,38 @@ describe('setup status', () => {
});
});
it.each([
{
backend: 'vertex',
providerLines: [' backend: vertex', ' vertex:', ' project: kaelio-dev', ' location: us-east5'],
model: 'claude-sonnet-4-6',
},
{
backend: 'gateway',
providerLines: [' backend: gateway', ' gateway:', ' api_key: env:AI_GATEWAY_API_KEY'],
model: 'anthropic/claude-sonnet-4-6',
},
])('reports configured $backend llm backends as setup-ready', async (fixture) => {
await mkdir(tempDir, { recursive: true });
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'llm:',
' provider:',
...fixture.providerLines,
' models:',
` default: ${fixture.model}`,
'connections: {}',
].join('\n'),
'utf-8',
);
await expect(readKtxSetupStatus(tempDir)).resolves.toMatchObject({
llm: { backend: fixture.backend, ready: true, model: fixture.model },
});
});
it('uses setup database connection ids when present', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
@ -1174,6 +1206,77 @@ describe('setup status', () => {
expect(calls).toEqual(['model', 'embeddings', 'databases', 'sources']);
});
it.each([
{
backend: 'vertex',
providerLines: [' backend: vertex', ' vertex:', ' project: kaelio-dev', ' location: us-east5'],
model: 'claude-sonnet-4-6',
},
{
backend: 'gateway',
providerLines: [' backend: gateway', ' gateway:', ' api_key: env:AI_GATEWAY_API_KEY'],
model: 'anthropic/claude-sonnet-4-6',
},
])('adds a dbt source in non-interactive setup with existing $backend llm config', async (fixture) => {
const io = makeIo();
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'setup:',
' database_connection_ids:',
' - warehouse',
' completed_steps:',
' - project',
' - databases',
'connections:',
' warehouse:',
' driver: postgres',
' url: env:WAREHOUSE_URL',
'llm:',
' provider:',
...fixture.providerLines,
' models:',
` default: ${fixture.model}`,
].join('\n'),
'utf-8',
);
await expect(
runKtxSetup(
{
command: 'run',
projectDir: tempDir,
mode: 'existing',
agents: false,
skipAgents: true,
inputMode: 'disabled',
yes: true,
cliVersion: '0.2.0',
skipLlm: false,
skipEmbeddings: true,
skipDatabases: true,
source: 'dbt',
sourceConnectionId: 'dbt-main',
sourceGitUrl: 'https://github.com/Kaelio/klo-dbt-demo',
sourceBranch: 'main',
sourceProjectName: 'orbit_analytics',
sourceWarehouseConnectionId: 'warehouse',
skipSources: false,
databaseSchemas: [],
},
io.io,
{
sourcesDeps: { validateDbt: vi.fn(async () => ({ ok: true as const, detail: 'dbt project valid' })) },
context: vi.fn(async () => ({ status: 'ready' as const, projectDir: tempDir, runId: 'setup-context-test' })),
},
),
).resolves.toBe(0);
expect(io.stderr()).not.toContain('Anthropic');
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).toContain('dbt-main:');
});
it('does not fail context build when prerequisites were explicitly skipped and agents are skipped', async () => {
const calls: string[] = [];
const io = makeIo();

View file

@ -20,7 +20,7 @@ import {
runKtxSetupDatabasesStep,
} from './setup-databases.js';
import { type KtxSetupEmbeddingsDeps, runKtxSetupEmbeddingsStep } from './setup-embeddings.js';
import { type KtxSetupModelDeps, runKtxSetupAnthropicModelStep } from './setup-models.js';
import { type KtxSetupModelDeps, isKtxSetupLlmConfigReady, runKtxSetupAnthropicModelStep } from './setup-models.js';
import { type KtxSetupProjectDeps, runKtxSetupProjectStep } from './setup-project.js';
import {
isKtxPreAgentSetupReady,
@ -226,10 +226,6 @@ async function runKtxSetupDemoFromEntryMenu(
);
}
function llmReady(status: KtxSetupStatus['llm']): boolean {
return status.backend === 'anthropic' && typeof status.model === 'string' && status.model.length > 0;
}
function embeddingsReady(status: KtxSetupStatus['embeddings']): boolean {
return (
status.backend !== undefined &&
@ -269,10 +265,9 @@ export async function readKtxSetupStatus(projectDir: string): Promise<KtxSetupSt
const project = await loadKtxProject({ projectDir: resolvedProjectDir });
const llm = {
backend: project.config.llm.provider.backend,
ready: false,
ready: isKtxSetupLlmConfigReady(project.config.llm),
model: project.config.llm.models.default,
};
llm.ready = llmReady(llm);
const embeddings = {
backend: project.config.ingest.embeddings.backend,
@ -376,7 +371,7 @@ function setupStatusReady(status: KtxSetupStatus): boolean {
return true;
}
return (
llmReady(status.llm) &&
status.llm.ready &&
embeddingsReady(status.embeddings) &&
status.databases.every((database) => database.ready) &&
status.sources.every((source) => source.ready)

View file

@ -95,7 +95,7 @@ function scansForContextProductionLlmBoundaries(relativePath) {
}
function scansForForbiddenIdentifiers(relativePath) {
return isCodeSource(relativePath) || isRuntimeAsset(relativePath);
return (isCodeSource(relativePath) && !isTestSource(relativePath)) || isRuntimeAsset(relativePath);
}
function skipsIdentifierScan(relativePath) {

View file

@ -65,6 +65,13 @@ describe('scanFileContent', () => {
assert.equal(scanFileContent('python/ktx-sl/openspec/specs/semantic-layer/spec.md', name).length, 0);
});
it('allows product identifiers in test fixtures', () => {
const name = lowerProductName();
assert.equal(scanFileContent('packages/cli/src/setup.test.ts', `project: ${name}-dev`).length, 0);
assert.equal(scanFileContent('packages/context/src/ingest/importer.test.ts', `email: system@${name}.dev`).length, 0);
});
it('allows public package identifiers in release packaging and managed runtime source', () => {
const name = lowerProductName();