mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-08-10 21:40:22 +02:00
feat: multi-provider model support, SARIF output, and exploit-mode fixes (#402)
* feat(worker): record token, cache, and turn usage per agent * feat: replace model tiers with a single SHANNON_AI_MODEL across five providers * feat(cli): rebuild the setup wizard for provider and model selection * docs: document single-model selection and supported providers * feat(worker): use chat completions for OpenAI behind a custom base URL * feat: add SHANNON_AI_OPENAI_FORMAT to pick the wire API for OpenAI gateways * refactor(cli): drop endpoint path hints from the gateway format picker * feat(worker): enable pi in-session provider retry with retry-after backoff * refactor(worker): hand provider error classification to pi and drop the Anthropic ladders * refactor: remove the subscription retry preset and pipeline config section * fix(worker): validate Bedrock credentials with the same live probe as other providers * feat(worker): render the report from structured findings instead of agent-written markdown * fix(worker): dispose the credential probe session on every path * fix(worker): refuse to replace the assembled report with an empty one * refactor(worker): catch post-processing throws across the whole finalization block * revert(worker): drop the report zero-findings guard * docs(worker): correct the retry split and Bedrock credential claims * docs: regenerate llms-full.txt from current sources * feat(cli): build and run the npx flow from a clone * refactor(cli): flatten the setup summary output * feat(cli): reject runs with more than one provider configured * fix(worker): say a rejected bash call never ran * chore(cli): drop grok-4.3 and gpt-5.6-luna from the setup suggestions * feat(worker): capture structured finding locations for SARIF output * fix(worker): enumerate queue confidence so the report inherits it verbatim * feat(worker): give the reporting phase a mode-specific output schema * feat(worker): emit a SARIF 2.1.0 log for exploitative runs * fix(worker): correct SARIF locations and defer fingerprinting to the upload action * fix(worker): drop the confidence suffix from the analysis-mode summary list * feat(worker): give exploit findings a dedicated code location field * feat(worker): carry structured code locations from the vuln queue to the report * fix(worker): join code locations from the vuln queue instead of re-asking agents * fix(worker): spell out the finding_id to category mapping in the tool schema * feat: drop Google/Gemini as a supported AI provider * fix(worker): stop asking the report agent for code locations * docs: correct the provider list and drop the removed rate-limit settings * docs: add provider cyber safeguards and suggested models per provider * docs: document the SARIF output and the report rating thresholds
This commit is contained in:
@@ -1,19 +1,16 @@
|
||||
/**
|
||||
* `shannon build` command — build the worker Docker image locally.
|
||||
* Only available in local mode (running from cloned repository).
|
||||
* `shannon build` command — build the worker Docker image from the repository.
|
||||
* Requires a clone (Dockerfile in the working directory).
|
||||
*/
|
||||
|
||||
import { buildImage } from '../docker.js';
|
||||
import { isLocal } from '../mode.js';
|
||||
import { buildImage, canBuildImage } from '../docker.js';
|
||||
|
||||
export function build(noCache: boolean): void {
|
||||
if (!isLocal()) {
|
||||
export function build(noCache: boolean, version: string): void {
|
||||
if (!canBuildImage()) {
|
||||
console.error('ERROR: Build is only available when running from the Shannon repository');
|
||||
console.error(' (Dockerfile not found in current directory)');
|
||||
console.error('');
|
||||
console.error('For npx usage, run: shannon update');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
buildImage(noCache);
|
||||
buildImage(noCache, version);
|
||||
}
|
||||
|
||||
+182
-155
@@ -1,56 +1,110 @@
|
||||
/**
|
||||
* `npx @keygraph/shannon setup` — interactive TUI wizard for one-time credential configuration.
|
||||
*
|
||||
* Walks the user through selecting a provider and entering credentials,
|
||||
* then persists everything to ~/.shannon/config.toml with 0o600 permissions.
|
||||
* Walks the user through selecting a provider, entering credentials, and naming
|
||||
* the model that runs the whole scan, then persists everything to
|
||||
* ~/.shannon/config.toml with 0o600 permissions.
|
||||
*/
|
||||
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import * as p from '@clack/prompts';
|
||||
import { type ShannonConfig, saveConfig } from '../config/writer.js';
|
||||
import { type OpenAiFormat, type ProviderId, SUPPORTED_PROVIDERS } from '../model-spec.js';
|
||||
import { requireInteractive } from '../tty.js';
|
||||
|
||||
const SHANNON_HOME = path.join(os.homedir(), '.shannon');
|
||||
|
||||
type Provider = 'anthropic' | 'custom_base_url' | 'bedrock';
|
||||
const CUSTOM_MODEL = '__custom__';
|
||||
const CUSTOM_BASE_URL = '__custom_base_url__';
|
||||
|
||||
/**
|
||||
* Wire formats reachable through the gateway route. The format picks the provider
|
||||
* that supplies the credential, and for OpenAI it also picks which of the two
|
||||
* OpenAI APIs Shannon calls.
|
||||
*/
|
||||
const GATEWAY_DIALECTS: readonly {
|
||||
value: string;
|
||||
label: string;
|
||||
provider: 'anthropic' | 'openai';
|
||||
format?: OpenAiFormat;
|
||||
}[] = [
|
||||
{ value: 'anthropic', label: 'Anthropic Messages', provider: 'anthropic' },
|
||||
{
|
||||
value: 'openai-chat-completions',
|
||||
label: 'OpenAI Chat Completions',
|
||||
provider: 'openai',
|
||||
format: 'chat-completions',
|
||||
},
|
||||
{ value: 'openai-responses', label: 'OpenAI Responses', provider: 'openai', format: 'responses' },
|
||||
];
|
||||
|
||||
/** Suggested models per provider, best-first. Free-text entry accepts any model in the provider's catalogue. */
|
||||
const MODEL_SUGGESTIONS: Readonly<Record<ProviderId, readonly string[]>> = {
|
||||
anthropic: ['claude-sonnet-4-6', 'claude-opus-4-8', 'claude-opus-4-7', 'claude-haiku-4-5-20251001'],
|
||||
openai: ['gpt-5.6-sol', 'gpt-5.5', 'gpt-5.4'],
|
||||
xai: ['grok-4.5'],
|
||||
'amazon-bedrock': ['us.anthropic.claude-sonnet-4-6', 'us.anthropic.claude-opus-4-8', 'us.anthropic.claude-opus-4-7'],
|
||||
};
|
||||
|
||||
/** Placeholder shown in the free-text model ID prompt. */
|
||||
const MODEL_ID_PLACEHOLDER: Readonly<Record<ProviderId, string>> = {
|
||||
anthropic: 'claude-sonnet-4-6',
|
||||
openai: 'gpt-5.6-sol',
|
||||
xai: 'grok-4.5',
|
||||
'amazon-bedrock': 'us.anthropic.claude-opus-4-8',
|
||||
};
|
||||
|
||||
export async function setup(): Promise<void> {
|
||||
requireInteractive('setup', 'For non-interactive use, export credentials as env vars (e.g. ANTHROPIC_API_KEY).');
|
||||
p.intro('Shannon Setup');
|
||||
|
||||
// 1. Select provider
|
||||
const provider = await p.select({
|
||||
// 1. Select provider. "Custom Base URL" is a route, not a provider — it asks
|
||||
// which API dialect the gateway speaks and configures that provider.
|
||||
const selected = await p.select({
|
||||
message: 'Select your AI provider',
|
||||
options: [
|
||||
{ value: 'anthropic' as const, label: 'Claude Direct', hint: 'recommended' },
|
||||
{ value: 'custom_base_url' as const, label: 'Custom Base URL', hint: 'proxies, gateways' },
|
||||
{ value: 'bedrock' as const, label: 'Claude via AWS Bedrock' },
|
||||
{ value: 'anthropic' as const, label: 'Anthropic', hint: 'Claude models - recommended' },
|
||||
{ value: 'openai' as const, label: 'OpenAI', hint: 'GPT models' },
|
||||
{ value: 'xai' as const, label: 'xAI', hint: 'Grok models' },
|
||||
{ value: 'amazon-bedrock' as const, label: 'AWS Bedrock', hint: 'Claude models via AWS' },
|
||||
{ value: CUSTOM_BASE_URL as typeof CUSTOM_BASE_URL, label: 'Custom Base URL', hint: 'your own proxy or gateway' },
|
||||
],
|
||||
});
|
||||
if (p.isCancel(provider)) return cancelAndExit();
|
||||
if (p.isCancel(selected)) return cancelAndExit();
|
||||
|
||||
const config = await setupProvider(provider as Provider);
|
||||
// 2. Credentials — and, on the gateway route, the endpoint and its dialect.
|
||||
const gateway = selected === CUSTOM_BASE_URL ? await setupGateway() : undefined;
|
||||
const provider = gateway?.provider ?? (selected as ProviderId);
|
||||
const config = gateway?.config ?? (await setupProvider(provider));
|
||||
|
||||
// 2. Adaptive thinking
|
||||
await maybePromptAdaptiveThinking(config);
|
||||
// 3. The model that runs every phase.
|
||||
const modelId = await promptModel(provider);
|
||||
config.core = { ...config.core, model: `${provider}:${modelId}` };
|
||||
if (gateway) config.core = { ...config.core, base_url: gateway.baseUrl };
|
||||
|
||||
// 3. Save config
|
||||
saveConfig(config);
|
||||
|
||||
const configPath = path.join(SHANNON_HOME, 'config.toml');
|
||||
const summary = [`Provider ${provider}`, `Model ${modelId}`];
|
||||
if (gateway) summary.push(`Endpoint ${gateway.baseUrl}`);
|
||||
if (gateway?.format) summary.push(`API ${gateway.format}`);
|
||||
|
||||
p.log.success(`Configuration saved to ${configPath}`);
|
||||
p.log.info(summary.join('\n'));
|
||||
p.outro('Run `npx @keygraph/shannon start` to begin a scan.');
|
||||
}
|
||||
|
||||
async function setupProvider(provider: Provider): Promise<ShannonConfig> {
|
||||
async function setupProvider(provider: ProviderId): Promise<ShannonConfig> {
|
||||
switch (provider) {
|
||||
case 'amazon-bedrock':
|
||||
return setupBedrock();
|
||||
case 'anthropic':
|
||||
return setupAnthropic();
|
||||
case 'custom_base_url':
|
||||
return setupCustomBaseUrl();
|
||||
case 'bedrock':
|
||||
return setupBedrock();
|
||||
case 'openai':
|
||||
return { openai: { api_key: await promptSecret('Enter your OpenAI API key') } };
|
||||
case 'xai':
|
||||
return { xai: { api_key: await promptSecret('Enter your xAI API key') } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,112 +120,13 @@ async function setupAnthropic(): Promise<ShannonConfig> {
|
||||
});
|
||||
if (p.isCancel(authMethod)) return cancelAndExit();
|
||||
|
||||
const config: ShannonConfig = {};
|
||||
|
||||
if (authMethod === 'oauth') {
|
||||
const token = await promptSecret('Enter your OAuth token');
|
||||
config.anthropic = { oauth_token: token };
|
||||
} else {
|
||||
const apiKey = await promptSecret('Enter your Anthropic API key');
|
||||
config.anthropic = { api_key: apiKey };
|
||||
return { anthropic: { oauth_token: token } };
|
||||
}
|
||||
|
||||
const customizeModels = await p.confirm({
|
||||
message:
|
||||
'Do you want to change the default models?\n' +
|
||||
' Small - claude-haiku-4-5-20251001\n' +
|
||||
' Medium - claude-sonnet-4-6\n' +
|
||||
' Large - claude-opus-4-8',
|
||||
initialValue: false,
|
||||
});
|
||||
if (p.isCancel(customizeModels)) return cancelAndExit();
|
||||
|
||||
if (customizeModels) {
|
||||
const small = await p.text({
|
||||
message: 'Small model ID',
|
||||
initialValue: 'claude-haiku-4-5-20251001',
|
||||
validate: required('Small model ID is required'),
|
||||
});
|
||||
if (p.isCancel(small)) return cancelAndExit();
|
||||
|
||||
const medium = await p.text({
|
||||
message: 'Medium model ID',
|
||||
initialValue: 'claude-sonnet-4-6',
|
||||
validate: required('Medium model ID is required'),
|
||||
});
|
||||
if (p.isCancel(medium)) return cancelAndExit();
|
||||
|
||||
const large = await p.text({
|
||||
message: 'Large model ID',
|
||||
initialValue: 'claude-opus-4-8',
|
||||
validate: required('Large model ID is required'),
|
||||
});
|
||||
if (p.isCancel(large)) return cancelAndExit();
|
||||
|
||||
config.models = { small, medium, large };
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async function setupCustomBaseUrl(): Promise<ShannonConfig> {
|
||||
const baseUrl = await p.text({
|
||||
message: 'Endpoint URL',
|
||||
placeholder: 'https://your-proxy.example.com',
|
||||
validate: (value) => {
|
||||
if (!value) return 'Endpoint URL is required';
|
||||
try {
|
||||
new URL(value);
|
||||
} catch {
|
||||
return 'Must be a valid URL';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
if (p.isCancel(baseUrl)) return cancelAndExit();
|
||||
|
||||
const authToken = await promptSecret('Enter the auth token for the custom endpoint');
|
||||
|
||||
const config: ShannonConfig = {
|
||||
custom_base_url: { base_url: baseUrl, auth_token: authToken },
|
||||
};
|
||||
|
||||
const customizeModels = await p.confirm({
|
||||
message:
|
||||
'Do you want to change the default models?\n' +
|
||||
' Small - claude-haiku-4-5-20251001\n' +
|
||||
' Medium - claude-sonnet-4-6\n' +
|
||||
' Large - claude-opus-4-8',
|
||||
initialValue: false,
|
||||
});
|
||||
if (p.isCancel(customizeModels)) return cancelAndExit();
|
||||
|
||||
if (customizeModels) {
|
||||
const small = await p.text({
|
||||
message: 'Small model ID',
|
||||
initialValue: 'claude-haiku-4-5-20251001',
|
||||
validate: required('Small model ID is required'),
|
||||
});
|
||||
if (p.isCancel(small)) return cancelAndExit();
|
||||
|
||||
const medium = await p.text({
|
||||
message: 'Medium model ID',
|
||||
initialValue: 'claude-sonnet-4-6',
|
||||
validate: required('Medium model ID is required'),
|
||||
});
|
||||
if (p.isCancel(medium)) return cancelAndExit();
|
||||
|
||||
const large = await p.text({
|
||||
message: 'Large model ID',
|
||||
initialValue: 'claude-opus-4-8',
|
||||
validate: required('Large model ID is required'),
|
||||
});
|
||||
if (p.isCancel(large)) return cancelAndExit();
|
||||
|
||||
config.models = { small, medium, large };
|
||||
}
|
||||
|
||||
return config;
|
||||
const apiKey = await promptSecret('Enter your Anthropic API key');
|
||||
return { anthropic: { api_key: apiKey } };
|
||||
}
|
||||
|
||||
async function setupBedrock(): Promise<ShannonConfig> {
|
||||
@@ -184,49 +139,121 @@ async function setupBedrock(): Promise<ShannonConfig> {
|
||||
|
||||
const token = await promptSecret('Enter your AWS Bearer Token');
|
||||
|
||||
const small = await p.text({
|
||||
message: 'Small model ID',
|
||||
placeholder: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||
validate: required('Small model ID is required'),
|
||||
});
|
||||
if (p.isCancel(small)) return cancelAndExit();
|
||||
return { bedrock: { region, token } };
|
||||
}
|
||||
|
||||
const medium = await p.text({
|
||||
message: 'Medium model ID',
|
||||
placeholder: 'us.anthropic.claude-sonnet-4-6',
|
||||
validate: required('Medium model ID is required'),
|
||||
});
|
||||
if (p.isCancel(medium)) return cancelAndExit();
|
||||
interface GatewaySetup {
|
||||
provider: ProviderId;
|
||||
config: ShannonConfig;
|
||||
baseUrl: string;
|
||||
format?: OpenAiFormat;
|
||||
}
|
||||
|
||||
const large = await p.text({
|
||||
message: 'Large model ID',
|
||||
placeholder: 'us.anthropic.claude-opus-4-8',
|
||||
validate: required('Large model ID is required'),
|
||||
/**
|
||||
* Gateway route: the endpoint decides where requests go, but the format still
|
||||
* picks a real provider, because that is what supplies the credential and the
|
||||
* wire protocol.
|
||||
*/
|
||||
async function setupGateway(): Promise<GatewaySetup> {
|
||||
const choice = await p.select({
|
||||
message: 'API format',
|
||||
options: GATEWAY_DIALECTS.map(({ value, label }) => ({ value, label })),
|
||||
});
|
||||
if (p.isCancel(large)) return cancelAndExit();
|
||||
if (p.isCancel(choice)) return cancelAndExit();
|
||||
|
||||
return {
|
||||
bedrock: { use: true, region, token },
|
||||
models: { small, medium, large },
|
||||
};
|
||||
const dialect = GATEWAY_DIALECTS.find((entry) => entry.value === choice);
|
||||
if (!dialect) return cancelAndExit();
|
||||
const provider = dialect.provider;
|
||||
|
||||
const baseUrl = await p.text({
|
||||
message: 'Endpoint URL',
|
||||
placeholder: 'https://llm-gateway.example.com',
|
||||
validate: (value) => {
|
||||
if (!value) return 'Endpoint URL is required';
|
||||
try {
|
||||
new URL(value);
|
||||
} catch {
|
||||
return 'Must be a valid URL';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
if (p.isCancel(baseUrl)) return cancelAndExit();
|
||||
|
||||
const authToken = await promptSecret('Enter the auth token for the endpoint');
|
||||
const config: ShannonConfig =
|
||||
provider === 'anthropic'
|
||||
? { anthropic: { api_key: authToken } }
|
||||
: { openai: { api_key: authToken, ...(dialect.format && { format: dialect.format }) } };
|
||||
|
||||
return { provider, config, baseUrl, ...(dialect.format && { format: dialect.format }) };
|
||||
}
|
||||
|
||||
// === Model Selection ===
|
||||
|
||||
/**
|
||||
* Ask for the one model that runs every phase. Providers with suggestions offer a
|
||||
* pick list with a free-text escape hatch; the rest go straight to free text.
|
||||
*/
|
||||
async function promptModel(provider: ProviderId): Promise<string> {
|
||||
const suggestions = MODEL_SUGGESTIONS[provider];
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
return promptModelId(provider, MODEL_ID_PLACEHOLDER[provider]);
|
||||
}
|
||||
|
||||
const choice = await p.select({
|
||||
message: 'Model',
|
||||
options: [
|
||||
...suggestions.map((model) => ({ value: model, label: model })),
|
||||
{ value: CUSTOM_MODEL, label: 'Enter a model ID…' },
|
||||
],
|
||||
});
|
||||
if (p.isCancel(choice)) return cancelAndExit();
|
||||
|
||||
if (choice === CUSTOM_MODEL) {
|
||||
return promptModelId(provider, MODEL_ID_PLACEHOLDER[provider]);
|
||||
}
|
||||
return choice as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A leading `<provider>:` naming a supported provider other than the selected
|
||||
* one. Bedrock model IDs carry their own colons (`…-v1:0`), so only a genuine
|
||||
* provider id counts as a prefix.
|
||||
*/
|
||||
function conflictingProviderPrefix(provider: ProviderId, value: string): string | undefined {
|
||||
const separator = value.indexOf(':');
|
||||
if (separator === -1) return undefined;
|
||||
|
||||
const head = value.slice(0, separator);
|
||||
if (head === provider) return undefined;
|
||||
return (SUPPORTED_PROVIDERS as readonly string[]).includes(head) ? head : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for a model ID. The provider is already chosen, so this takes the bare ID
|
||||
* and the caller pairs it with the provider — pasting a full `<provider>:<model>`
|
||||
* spec just has its redundant prefix dropped.
|
||||
*/
|
||||
async function promptModelId(provider: ProviderId, placeholder: string): Promise<string> {
|
||||
const modelId = await p.text({
|
||||
message: 'Model ID',
|
||||
placeholder,
|
||||
validate: (value) => {
|
||||
if (!value) return 'Model ID is required';
|
||||
const conflicting = conflictingProviderPrefix(provider, value);
|
||||
if (conflicting) return `That model ID is for ${conflicting}, but you selected ${provider}.`;
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
if (p.isCancel(modelId)) return cancelAndExit();
|
||||
|
||||
return modelId.startsWith(`${provider}:`) ? modelId.slice(provider.length + 1) : modelId;
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
async function maybePromptAdaptiveThinking(config: ShannonConfig): Promise<void> {
|
||||
const m = config.models;
|
||||
const hasAdaptiveModel = !m || [m.small, m.medium, m.large].some((v) => v && /opus-4-[678]/.test(v));
|
||||
if (!hasAdaptiveModel) return;
|
||||
|
||||
const enable = await p.confirm({
|
||||
message: 'Enable adaptive thinking on Opus 4.6/4.7/4.8? Claude decides when and how deeply to reason.',
|
||||
initialValue: true,
|
||||
});
|
||||
if (p.isCancel(enable)) return cancelAndExit();
|
||||
|
||||
config.core = { ...config.core, adaptive_thinking: enable };
|
||||
}
|
||||
|
||||
async function promptSecret(message: string): Promise<string> {
|
||||
const value = await p.password({
|
||||
message,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ensureImage, ensureInfra, randomSuffix, spawnWorker } from '../docker.j
|
||||
import { buildEnvFlags, loadEnv, validateCredentials } from '../env.js';
|
||||
import { getWorkspacesDir, initHome } from '../home.js';
|
||||
import { isLocal } from '../mode.js';
|
||||
import { resolveModelSpec } from '../model-spec.js';
|
||||
import { FINAL_REPORT_FILENAME, INTERNAL_DIR, resolveConfig, resolveRepo, resolveRunFile } from '../paths.js';
|
||||
import { displaySplash } from '../splash.js';
|
||||
import { stdoutIsTerminal } from '../tty.js';
|
||||
@@ -261,19 +262,9 @@ function printInfo(
|
||||
console.log(' Mode: Pipeline Testing');
|
||||
}
|
||||
|
||||
// Surface Fable usage: its safety classifiers route cybersecurity tasks to
|
||||
// Opus 4.8, so those phases run on Opus 4.8 regardless of the tier setting.
|
||||
const fableTiers = (
|
||||
[
|
||||
['small', process.env.ANTHROPIC_SMALL_MODEL],
|
||||
['medium', process.env.ANTHROPIC_MEDIUM_MODEL],
|
||||
['large', process.env.ANTHROPIC_LARGE_MODEL],
|
||||
] as const
|
||||
).filter(([, model]) => model && /fable/i.test(model));
|
||||
if (fableTiers.length > 0) {
|
||||
const tierList = fableTiers.map(([tier, model]) => `${tier} (${model})`).join(', ');
|
||||
console.log(` Note: ${tierList} set to a Fable model. Fable's safety classifiers`);
|
||||
console.log(' route cybersecurity tasks to Opus 4.8, so those phases run on Opus 4.8.');
|
||||
const spec = resolveModelSpec();
|
||||
if (typeof spec !== 'string') {
|
||||
console.log(` Model: ${spec.providerId}:${spec.modelId}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
@@ -9,6 +9,7 @@ import fs from 'node:fs';
|
||||
import { parse as parseTOML } from 'smol-toml';
|
||||
import { getConfigFile } from '../home.js';
|
||||
import { getMode } from '../mode.js';
|
||||
import { DEFAULT_MODEL_SPEC, type ProviderId, parseModelSpec } from '../model-spec.js';
|
||||
|
||||
// === TOML ↔ Env Mapping ===
|
||||
|
||||
@@ -23,28 +24,34 @@ interface ConfigMapping {
|
||||
|
||||
/** Maps every supported env var to its TOML path (section.key) and expected type. */
|
||||
const CONFIG_MAP: readonly ConfigMapping[] = [
|
||||
// Core
|
||||
{ env: 'CLAUDE_ADAPTIVE_THINKING', toml: 'core.adaptive_thinking', type: 'boolean', boolFormat: 'literal' },
|
||||
// Core — base_url points any provider at a proxy or gateway
|
||||
{ env: 'SHANNON_AI_MODEL', toml: 'core.model', type: 'string' },
|
||||
{ env: 'SHANNON_AI_BASE_URL', toml: 'core.base_url', type: 'string' },
|
||||
|
||||
// Anthropic
|
||||
{ env: 'ANTHROPIC_API_KEY', toml: 'anthropic.api_key', type: 'string' },
|
||||
{ env: 'CLAUDE_CODE_OAUTH_TOKEN', toml: 'anthropic.oauth_token', type: 'string' },
|
||||
|
||||
// OpenAI — format picks the wire API a gateway serves
|
||||
{ env: 'OPENAI_API_KEY', toml: 'openai.api_key', type: 'string' },
|
||||
{ env: 'SHANNON_AI_OPENAI_FORMAT', toml: 'openai.format', type: 'string' },
|
||||
|
||||
// xAI
|
||||
{ env: 'XAI_API_KEY', toml: 'xai.api_key', type: 'string' },
|
||||
|
||||
// Bedrock
|
||||
{ env: 'CLAUDE_CODE_USE_BEDROCK', toml: 'bedrock.use', type: 'boolean' },
|
||||
{ env: 'AWS_REGION', toml: 'bedrock.region', type: 'string' },
|
||||
{ env: 'AWS_BEARER_TOKEN_BEDROCK', toml: 'bedrock.token', type: 'string' },
|
||||
|
||||
// Custom Base URL
|
||||
{ env: 'ANTHROPIC_BASE_URL', toml: 'custom_base_url.base_url', type: 'string' },
|
||||
{ env: 'ANTHROPIC_AUTH_TOKEN', toml: 'custom_base_url.auth_token', type: 'string' },
|
||||
|
||||
// Model tiers
|
||||
{ env: 'ANTHROPIC_SMALL_MODEL', toml: 'models.small', type: 'string' },
|
||||
{ env: 'ANTHROPIC_MEDIUM_MODEL', toml: 'models.medium', type: 'string' },
|
||||
{ env: 'ANTHROPIC_LARGE_MODEL', toml: 'models.large', type: 'string' },
|
||||
] as const;
|
||||
|
||||
/** TOML section holding each provider's credentials, keyed by provider id. */
|
||||
const PROVIDER_SECTIONS: Readonly<Record<ProviderId, string>> = {
|
||||
anthropic: 'anthropic',
|
||||
openai: 'openai',
|
||||
xai: 'xai',
|
||||
'amazon-bedrock': 'bedrock',
|
||||
};
|
||||
|
||||
// === TOML Parsing ===
|
||||
|
||||
type TOMLValue = string | number | boolean;
|
||||
@@ -118,52 +125,33 @@ function buildSchema(): Map<string, Map<string, TOMLType>> {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/** Check that a provider section has all required fields and dependencies. */
|
||||
function validateProviderFields(config: TOMLConfig, provider: string, errors: string[]): void {
|
||||
const section = config[provider] as Record<string, unknown> | undefined;
|
||||
if (!section) return;
|
||||
const keys = Object.keys(section);
|
||||
/**
|
||||
* Check that the section backing the selected provider carries a usable
|
||||
* credential. `core.model` names the provider, so only that section is required;
|
||||
* other providers' sections are ignored and never forwarded.
|
||||
*/
|
||||
function validateProviderFields(config: TOMLConfig, providerId: ProviderId, errors: string[]): void {
|
||||
const sectionName = PROVIDER_SECTIONS[providerId];
|
||||
const section = config[sectionName] as Record<string, unknown> | undefined;
|
||||
const keys = section ? Object.keys(section) : [];
|
||||
|
||||
switch (provider) {
|
||||
case 'anthropic':
|
||||
if (!keys.includes('api_key') && !keys.includes('oauth_token')) {
|
||||
errors.push('[anthropic] requires either api_key or oauth_token');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'custom_base_url': {
|
||||
const required = ['base_url', 'auth_token'];
|
||||
const missing = required.filter((k) => !keys.includes(k));
|
||||
if (missing.length > 0) {
|
||||
errors.push(`[custom_base_url] missing required keys: ${missing.join(', ')}`);
|
||||
}
|
||||
break;
|
||||
if (providerId === 'amazon-bedrock') {
|
||||
const missing = ['region', 'token'].filter((k) => !keys.includes(k));
|
||||
if (missing.length > 0) {
|
||||
errors.push(`[bedrock] missing required keys: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
case 'bedrock': {
|
||||
const required = ['use', 'region', 'token'];
|
||||
const missing = required.filter((k) => !keys.includes(k));
|
||||
if (missing.length > 0) {
|
||||
errors.push(`[bedrock] missing required keys: ${missing.join(', ')}`);
|
||||
}
|
||||
validateModelTiers(config, 'bedrock', errors);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Bedrock requires a [models] section with all three tiers. */
|
||||
function validateModelTiers(config: TOMLConfig, provider: string, errors: string[]): void {
|
||||
const models = config.models as Record<string, unknown> | undefined;
|
||||
if (!models || typeof models !== 'object') {
|
||||
errors.push(`[${provider}] requires a [models] section with small, medium, and large`);
|
||||
return;
|
||||
}
|
||||
|
||||
const required = ['small', 'medium', 'large'];
|
||||
const missing = required.filter((k) => !Object.keys(models).includes(k));
|
||||
if (missing.length > 0) {
|
||||
errors.push(`[models] missing required keys for ${provider}: ${missing.join(', ')}`);
|
||||
if (providerId === 'anthropic') {
|
||||
if (!keys.includes('api_key') && !keys.includes('oauth_token')) {
|
||||
errors.push('[anthropic] requires either api_key or oauth_token');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!keys.includes('api_key')) {
|
||||
errors.push(`[${sectionName}] requires api_key`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,23 +199,19 @@ function validateConfig(config: TOMLConfig): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Only one provider section allowed (ignore empty sections)
|
||||
const PROVIDER_SECTIONS = ['anthropic', 'custom_base_url', 'bedrock'] as const;
|
||||
const present = PROVIDER_SECTIONS.filter((s) => {
|
||||
const section = config[s];
|
||||
return section && typeof section === 'object' && Object.keys(section).length > 0;
|
||||
});
|
||||
if (present.length > 1) {
|
||||
errors.push(
|
||||
`Multiple providers configured: [${present.join('], [')}]. Only one provider section is allowed at a time`,
|
||||
);
|
||||
// 4. core.model must parse and name a supported provider
|
||||
const modelValue = config.core?.model;
|
||||
if (modelValue !== undefined && typeof modelValue !== 'string') {
|
||||
return errors;
|
||||
}
|
||||
const spec = parseModelSpec(modelValue || DEFAULT_MODEL_SPEC);
|
||||
if (typeof spec === 'string') {
|
||||
errors.push(`[core].model — ${spec}`);
|
||||
return errors;
|
||||
}
|
||||
|
||||
// 5. Required fields per provider
|
||||
const singleProvider = present.length === 1 ? present[0] : undefined;
|
||||
if (singleProvider) {
|
||||
validateProviderFields(config, singleProvider, errors);
|
||||
}
|
||||
// 5. The selected provider's section must carry a credential
|
||||
validateProviderFields(config, spec.providerId, errors);
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ import { getConfigFile } from '../home.js';
|
||||
// === Types ===
|
||||
|
||||
export interface ShannonConfig {
|
||||
core?: { adaptive_thinking?: boolean };
|
||||
core?: { model?: string; base_url?: string };
|
||||
anthropic?: { api_key?: string; oauth_token?: string };
|
||||
custom_base_url?: { base_url?: string; auth_token?: string };
|
||||
bedrock?: { use?: boolean; region?: string; token?: string };
|
||||
models?: { small?: string; medium?: string; large?: string };
|
||||
openai?: { api_key?: string; format?: string };
|
||||
xai?: { api_key?: string };
|
||||
bedrock?: { region?: string; token?: string };
|
||||
}
|
||||
|
||||
// === File Operations ===
|
||||
|
||||
+22
-9
@@ -12,7 +12,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getMode } from './mode.js';
|
||||
import { getMode, isDevMode } from './mode.js';
|
||||
import { INTERNAL_DIR } from './paths.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -24,6 +24,17 @@ export function getWorkerImage(version: string): string {
|
||||
return getMode() === 'local' ? DEV_IMAGE : `${NPX_IMAGE_REPO}:${version}`;
|
||||
}
|
||||
|
||||
/** True when the working directory supplies a Dockerfile and build context. */
|
||||
export function canBuildImage(): boolean {
|
||||
if (getMode() === 'local') return true;
|
||||
if (!isDevMode()) return false;
|
||||
|
||||
const hasDockerfile = fs.existsSync(path.resolve('Dockerfile'));
|
||||
const hasCompose = fs.existsSync(path.resolve('docker-compose.yml'));
|
||||
|
||||
return hasDockerfile && hasCompose;
|
||||
}
|
||||
|
||||
function getComposeFile(): string {
|
||||
return getMode() === 'local'
|
||||
? path.resolve('docker-compose.yml')
|
||||
@@ -96,29 +107,31 @@ export async function ensureInfra(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the worker image locally (local mode only).
|
||||
* Build the worker image from the repository, tagged with the name this mode
|
||||
* resolves at run time.
|
||||
*/
|
||||
export function buildImage(noCache: boolean): void {
|
||||
console.log(`Building ${DEV_IMAGE}...`);
|
||||
export function buildImage(noCache: boolean, version: string): void {
|
||||
const image = getWorkerImage(version);
|
||||
console.log(`Building ${image}...`);
|
||||
const args = ['build'];
|
||||
if (noCache) args.push('--no-cache');
|
||||
args.push('-t', DEV_IMAGE, '.');
|
||||
args.push('-t', image, '.');
|
||||
execFileSync('docker', args, { stdio: 'inherit' });
|
||||
console.log(`Build complete: ${DEV_IMAGE}`);
|
||||
console.log(`Build complete: ${image}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the worker image is available.
|
||||
* Local mode: auto-builds if missing. NPX mode: pulls from Docker Hub.
|
||||
* Buildable checkout: auto-builds if missing. Otherwise: pulls from Docker Hub.
|
||||
*/
|
||||
export function ensureImage(version: string): void {
|
||||
const image = getWorkerImage(version);
|
||||
const exists = runQuiet('docker', ['image', 'inspect', image]);
|
||||
if (exists) return;
|
||||
|
||||
if (getMode() === 'local') {
|
||||
if (canBuildImage()) {
|
||||
console.log('Shannon image not found, building...');
|
||||
buildImage(false);
|
||||
buildImage(false, version);
|
||||
} else {
|
||||
console.log(`Pulling ${image}...`);
|
||||
try {
|
||||
|
||||
+66
-71
@@ -8,21 +8,28 @@
|
||||
import dotenv from 'dotenv';
|
||||
import { resolveConfig } from './config/resolver.js';
|
||||
import { getMode } from './mode.js';
|
||||
import {
|
||||
PROVIDER_API_KEY_ENV,
|
||||
PROVIDER_CREDENTIAL_HINT,
|
||||
PROVIDER_EXTRA_ENV,
|
||||
type ProviderId,
|
||||
resolveModelSpec,
|
||||
SUPPORTED_PROVIDERS,
|
||||
} from './model-spec.js';
|
||||
|
||||
/** Environment variables forwarded to worker containers. */
|
||||
const FORWARD_VARS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'CLAUDE_CODE_USE_BEDROCK',
|
||||
'AWS_REGION',
|
||||
'AWS_BEARER_TOKEN_BEDROCK',
|
||||
'ANTHROPIC_SMALL_MODEL',
|
||||
'ANTHROPIC_MEDIUM_MODEL',
|
||||
'ANTHROPIC_LARGE_MODEL',
|
||||
'CLAUDE_ADAPTIVE_THINKING',
|
||||
] as const;
|
||||
/**
|
||||
* Variables forwarded to every worker container regardless of provider. Each is
|
||||
* forwarded only when set, so an unused one never appears in the container.
|
||||
*/
|
||||
const COMMON_FORWARD_VARS = ['SHANNON_AI_MODEL', 'SHANNON_AI_BASE_URL', 'SHANNON_AI_OPENAI_FORMAT'] as const;
|
||||
|
||||
/**
|
||||
* Credential variables for one provider. Only the selected provider's entries are
|
||||
* forwarded, so a key for an unused provider never enters the scan container.
|
||||
*/
|
||||
function providerForwardVars(providerId: ProviderId): readonly string[] {
|
||||
return [...PROVIDER_API_KEY_ENV[providerId], ...PROVIDER_EXTRA_ENV[providerId]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load credentials into process.env.
|
||||
@@ -39,12 +46,16 @@ export function loadEnv(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build `-e KEY=VALUE` flags for docker run, only for set variables.
|
||||
* Build `-e KEY=VALUE` flags for docker run. Forwards the common vars plus only
|
||||
* the selected provider's credentials.
|
||||
*/
|
||||
export function buildEnvFlags(): string[] {
|
||||
const flags: string[] = ['-e', 'TEMPORAL_ADDRESS=shannon-temporal:7233'];
|
||||
|
||||
for (const key of FORWARD_VARS) {
|
||||
const spec = resolveModelSpec();
|
||||
const providerVars = typeof spec === 'string' ? [] : providerForwardVars(spec.providerId);
|
||||
|
||||
for (const key of [...COMMON_FORWARD_VARS, ...providerVars]) {
|
||||
const value = process.env[key];
|
||||
if (value) {
|
||||
flags.push('-e', `${key}=${value}`);
|
||||
@@ -57,71 +68,55 @@ export function buildEnvFlags(): string[] {
|
||||
interface CredentialValidation {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
mode: 'api-key' | 'oauth' | 'custom-base-url' | 'bedrock';
|
||||
}
|
||||
|
||||
/** Check if a custom Anthropic-compatible base URL is configured. */
|
||||
function isCustomBaseUrlConfigured(): boolean {
|
||||
return !!(process.env.ANTHROPIC_BASE_URL && process.env.ANTHROPIC_AUTH_TOKEN);
|
||||
}
|
||||
|
||||
/** Detect which providers are configured via environment variables. */
|
||||
function detectProviders(): string[] {
|
||||
const providers: string[] = [];
|
||||
if (process.env.ANTHROPIC_API_KEY) providers.push('Anthropic API key');
|
||||
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) providers.push('Anthropic OAuth');
|
||||
if (isCustomBaseUrlConfigured()) providers.push('Custom Base URL');
|
||||
if (process.env.CLAUDE_CODE_USE_BEDROCK === '1') providers.push('AWS Bedrock');
|
||||
return providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that exactly one authentication method is configured.
|
||||
* Whether the selected provider has a usable credential in the environment. Any
|
||||
* one API key satisfies a key-based provider; Bedrock instead needs every one of
|
||||
* its AWS_ vars.
|
||||
*/
|
||||
function hasCredential(providerId: ProviderId): boolean {
|
||||
const apiKeys = PROVIDER_API_KEY_ENV[providerId];
|
||||
if (apiKeys.length > 0 && !apiKeys.some((name) => Boolean(process.env[name]))) {
|
||||
return false;
|
||||
}
|
||||
return PROVIDER_EXTRA_ENV[providerId].every((name) => Boolean(process.env[name]));
|
||||
}
|
||||
|
||||
/** Every provider that currently has a complete credential in the environment. */
|
||||
function configuredProviders(): ProviderId[] {
|
||||
return SUPPORTED_PROVIDERS.filter((providerId) => hasCredential(providerId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the model selection parses and its provider has a credential.
|
||||
* Runs before any Docker work so mistakes fail immediately.
|
||||
*/
|
||||
export function validateCredentials(): CredentialValidation {
|
||||
// Reject multiple providers
|
||||
const providers = detectProviders();
|
||||
if (providers.length > 1) {
|
||||
// 1. Model selection must parse and name a supported provider
|
||||
const spec = resolveModelSpec();
|
||||
if (typeof spec === 'string') {
|
||||
return { valid: false, error: spec };
|
||||
}
|
||||
|
||||
// 2. The selected provider must have a credential
|
||||
if (!hasCredential(spec.providerId)) {
|
||||
const hint =
|
||||
getMode() === 'local'
|
||||
? `Set ${PROVIDER_CREDENTIAL_HINT[spec.providerId]} in .env or export it.`
|
||||
: `Export the variables or run 'npx @keygraph/shannon setup'.`;
|
||||
return {
|
||||
valid: false,
|
||||
mode: 'api-key',
|
||||
error: `Multiple providers detected: ${providers.join(', ')}. Only one provider can be active at a time.`,
|
||||
error: `No credentials found for provider "${spec.providerId}". ${hint}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env.ANTHROPIC_API_KEY) {
|
||||
return { valid: true, mode: 'api-key' };
|
||||
}
|
||||
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||
return { valid: true, mode: 'oauth' };
|
||||
}
|
||||
if (isCustomBaseUrlConfigured()) {
|
||||
return { valid: true, mode: 'custom-base-url' };
|
||||
}
|
||||
if (process.env.CLAUDE_CODE_USE_BEDROCK === '1') {
|
||||
const missing: string[] = [];
|
||||
if (!process.env.AWS_REGION) missing.push('AWS_REGION');
|
||||
if (!process.env.AWS_BEARER_TOKEN_BEDROCK) missing.push('AWS_BEARER_TOKEN_BEDROCK');
|
||||
if (!process.env.ANTHROPIC_SMALL_MODEL) missing.push('ANTHROPIC_SMALL_MODEL');
|
||||
if (!process.env.ANTHROPIC_MEDIUM_MODEL) missing.push('ANTHROPIC_MEDIUM_MODEL');
|
||||
if (!process.env.ANTHROPIC_LARGE_MODEL) missing.push('ANTHROPIC_LARGE_MODEL');
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
valid: false,
|
||||
mode: 'bedrock',
|
||||
error: `Bedrock mode requires: ${missing.join(', ')}`,
|
||||
};
|
||||
}
|
||||
return { valid: true, mode: 'bedrock' };
|
||||
// 3. Exactly one provider may be configured. Several complete credentials make
|
||||
// the scan's provider depend on SHANNON_AI_MODEL alone, which is too easy to
|
||||
// misread as "both are in play" and too easy to redirect by editing one line.
|
||||
if (configuredProviders().length > 1) {
|
||||
return { valid: false, error: 'Credentials for more than one provider are set.' };
|
||||
}
|
||||
|
||||
const hint =
|
||||
getMode() === 'local'
|
||||
? `No credentials found. Set ANTHROPIC_API_KEY in .env or export it.`
|
||||
: `Authentication not configured. Export variables or run 'npx @keygraph/shannon setup'.`;
|
||||
return {
|
||||
valid: false,
|
||||
mode: 'api-key',
|
||||
error: hint,
|
||||
};
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ switch (command) {
|
||||
setup();
|
||||
break;
|
||||
case 'build':
|
||||
build(args.includes('--no-cache'));
|
||||
build(args.includes('--no-cache'), getVersion());
|
||||
break;
|
||||
case 'uninstall':
|
||||
if (getMode() === 'local') {
|
||||
|
||||
@@ -23,3 +23,7 @@ export function setMode(mode: Mode): void {
|
||||
export function isLocal(): boolean {
|
||||
return getMode() === 'local';
|
||||
}
|
||||
|
||||
export function isDevMode(): boolean {
|
||||
return process.env.SHANNON_DEV === '1';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Parsing for the single model setting, `SHANNON_AI_MODEL=<provider>:<model-id>`.
|
||||
*
|
||||
* Mirrors apps/worker/src/ai/models.ts. The CLI cannot import from the worker
|
||||
* package (it ships as a standalone bundle), so the provider list and the parse
|
||||
* rule are duplicated here deliberately and must stay in sync.
|
||||
*/
|
||||
|
||||
/** Providers Shannon can currently reach. Each is a pi-ai provider id. */
|
||||
export const SUPPORTED_PROVIDERS = ['anthropic', 'openai', 'xai', 'amazon-bedrock'] as const;
|
||||
|
||||
export type ProviderId = (typeof SUPPORTED_PROVIDERS)[number];
|
||||
|
||||
/**
|
||||
* Env vars carrying each provider's API key, in precedence order. Any one of them
|
||||
* satisfies the provider. Mirrors PROVIDER_API_KEY_ENV in apps/worker/src/ai/models.ts.
|
||||
*/
|
||||
export const PROVIDER_API_KEY_ENV: Readonly<Record<ProviderId, readonly string[]>> = {
|
||||
anthropic: ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'],
|
||||
openai: ['OPENAI_API_KEY'],
|
||||
xai: ['XAI_API_KEY'],
|
||||
'amazon-bedrock': ['AWS_BEARER_TOKEN_BEDROCK'],
|
||||
};
|
||||
|
||||
/** Additional env vars a provider requires beyond its API key. All must be set. */
|
||||
export const PROVIDER_EXTRA_ENV: Readonly<Record<ProviderId, readonly string[]>> = {
|
||||
anthropic: [],
|
||||
openai: [],
|
||||
xai: [],
|
||||
'amazon-bedrock': ['AWS_REGION'],
|
||||
};
|
||||
|
||||
/** Human-readable credential requirement, used in "nothing configured" errors. */
|
||||
export const PROVIDER_CREDENTIAL_HINT: Readonly<Record<ProviderId, string>> = {
|
||||
anthropic: 'ANTHROPIC_API_KEY (or CLAUDE_CODE_OAUTH_TOKEN)',
|
||||
openai: 'OPENAI_API_KEY',
|
||||
xai: 'XAI_API_KEY',
|
||||
'amazon-bedrock': 'AWS_REGION and AWS_BEARER_TOKEN_BEDROCK',
|
||||
};
|
||||
|
||||
/** Model used when SHANNON_AI_MODEL is unset. */
|
||||
export const DEFAULT_MODEL_SPEC = 'anthropic:claude-sonnet-4-6';
|
||||
|
||||
/**
|
||||
* Values SHANNON_AI_OPENAI_FORMAT accepts, selecting the wire format an
|
||||
* OpenAI-compatible gateway serves. Mirrors OPENAI_FORMATS in
|
||||
* apps/worker/src/ai/models.ts; the worker validates and applies it.
|
||||
*/
|
||||
export const OPENAI_FORMATS = ['chat-completions', 'responses'] as const;
|
||||
|
||||
export type OpenAiFormat = (typeof OPENAI_FORMATS)[number];
|
||||
|
||||
export interface ModelSpec {
|
||||
providerId: ProviderId;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
function isSupportedProvider(value: string): value is ProviderId {
|
||||
return (SUPPORTED_PROVIDERS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `<provider>:<model-id>` spec. Splits on the first colon only, so colons
|
||||
* inside a model ID survive (`amazon-bedrock:us.anthropic.claude-opus-4-5-20251101-v1:0`).
|
||||
* Returns an error string rather than throwing, for the CLI's validation flow.
|
||||
*/
|
||||
export function parseModelSpec(spec: string): ModelSpec | string {
|
||||
const trimmed = spec.trim();
|
||||
const separator = trimmed.indexOf(':');
|
||||
const malformed = `SHANNON_AI_MODEL must be "<provider>:<model-id>", got "${trimmed}". Example: ${DEFAULT_MODEL_SPEC}`;
|
||||
if (separator === -1) return malformed;
|
||||
|
||||
const providerId = trimmed.slice(0, separator).trim();
|
||||
const modelId = trimmed.slice(separator + 1).trim();
|
||||
if (!providerId || !modelId) return malformed;
|
||||
|
||||
if (!isSupportedProvider(providerId)) {
|
||||
return `Unsupported provider "${providerId}" in SHANNON_AI_MODEL. Supported providers: ${SUPPORTED_PROVIDERS.join(', ')}`;
|
||||
}
|
||||
return { providerId, modelId };
|
||||
}
|
||||
|
||||
/** Resolve the run's model spec from the environment, or an error string. */
|
||||
export function resolveModelSpec(): ModelSpec | string {
|
||||
return parseModelSpec(process.env.SHANNON_AI_MODEL || DEFAULT_MODEL_SPEC);
|
||||
}
|
||||
Reference in New Issue
Block a user