mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-08-26 05:02:46 +02:00
feat(cli): support any Pi provider via generic SHANNON_AI_API_KEY (#415)
* feat(cli): support any Pi provider via generic SHANNON_AI_API_KEY * docs(cli): point users to pi.dev/models for provider and model ids * docs: document generic provider path and pi.dev catalogue
This commit is contained in:
@@ -10,13 +10,14 @@ 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 { CURATED_PROVIDERS, type CuratedProviderId, isCuratedProvider, type OpenAiFormat } from '../model-spec.js';
|
||||
import { requireInteractive } from '../tty.js';
|
||||
|
||||
const SHANNON_HOME = path.join(os.homedir(), '.shannon');
|
||||
|
||||
const CUSTOM_MODEL = '__custom__';
|
||||
const CUSTOM_BASE_URL = '__custom_base_url__';
|
||||
const OTHER_PROVIDER = '__other_provider__';
|
||||
|
||||
/**
|
||||
* Wire formats reachable through the gateway route. The format picks the provider
|
||||
@@ -39,28 +40,34 @@ const GATEWAY_DIALECTS: readonly {
|
||||
{ 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[]>> = {
|
||||
/** Suggested models per curated provider, best-first. Free-text entry accepts any model in the provider's catalogue. */
|
||||
const MODEL_SUGGESTIONS: Readonly<Record<CuratedProviderId, 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>> = {
|
||||
/** Placeholder shown in the free-text model ID prompt, per curated provider. */
|
||||
const MODEL_ID_PLACEHOLDER: Readonly<Record<CuratedProviderId, string>> = {
|
||||
anthropic: 'claude-sonnet-4-6',
|
||||
openai: 'gpt-5.6-sol',
|
||||
xai: 'grok-4.5',
|
||||
'amazon-bedrock': 'us.anthropic.claude-opus-4-8',
|
||||
};
|
||||
|
||||
/** Model ID placeholder for a provider, absent when the provider is not curated. */
|
||||
function modelIdPlaceholder(provider: string): string | undefined {
|
||||
return isCuratedProvider(provider) ? MODEL_ID_PLACEHOLDER[provider] : undefined;
|
||||
}
|
||||
|
||||
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. "Custom Base URL" is a route, not a provider — it asks
|
||||
// which API dialect the gateway speaks and configures that provider.
|
||||
// which API dialect the gateway speaks and configures that provider. "Other
|
||||
// provider" reaches any pi-supported provider Shannon does not curate.
|
||||
const selected = await p.select({
|
||||
message: 'Select your AI provider',
|
||||
options: [
|
||||
@@ -69,14 +76,17 @@ export async function setup(): Promise<void> {
|
||||
{ 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' },
|
||||
{
|
||||
value: OTHER_PROVIDER as typeof OTHER_PROVIDER,
|
||||
label: 'Other provider',
|
||||
hint: 'any other Pi-supported provider',
|
||||
},
|
||||
],
|
||||
});
|
||||
if (p.isCancel(selected)) return cancelAndExit();
|
||||
|
||||
// 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));
|
||||
const { provider, config, gateway } = await setupSelection(selected);
|
||||
|
||||
// 3. The model that runs every phase.
|
||||
const modelId = await promptModel(provider);
|
||||
@@ -95,7 +105,27 @@ export async function setup(): Promise<void> {
|
||||
p.outro('Run `npx @keygraph/shannon start` to begin a scan.');
|
||||
}
|
||||
|
||||
async function setupProvider(provider: ProviderId): Promise<ShannonConfig> {
|
||||
interface Selection {
|
||||
provider: string;
|
||||
config: ShannonConfig;
|
||||
gateway?: GatewaySetup;
|
||||
}
|
||||
|
||||
/** Resolve the provider selection into a provider id and its credential config. */
|
||||
async function setupSelection(
|
||||
selected: CuratedProviderId | typeof CUSTOM_BASE_URL | typeof OTHER_PROVIDER,
|
||||
): Promise<Selection> {
|
||||
if (selected === CUSTOM_BASE_URL) {
|
||||
const gateway = await setupGateway();
|
||||
return { provider: gateway.provider, config: gateway.config, gateway };
|
||||
}
|
||||
if (selected === OTHER_PROVIDER) {
|
||||
return setupOtherProvider();
|
||||
}
|
||||
return { provider: selected, config: await setupProvider(selected) };
|
||||
}
|
||||
|
||||
async function setupProvider(provider: CuratedProviderId): Promise<ShannonConfig> {
|
||||
switch (provider) {
|
||||
case 'amazon-bedrock':
|
||||
return setupBedrock();
|
||||
@@ -108,6 +138,27 @@ async function setupProvider(provider: ProviderId): Promise<ShannonConfig> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Any pi provider Shannon does not curate. The id is free text — the worker's
|
||||
* preflight validates it — and the key is stored generically as SHANNON_AI_API_KEY.
|
||||
*/
|
||||
async function setupOtherProvider(): Promise<Selection> {
|
||||
p.log.info('Browse supported providers and models at https://pi.dev/models');
|
||||
const provider = await p.text({
|
||||
message: 'Provider ID',
|
||||
validate: (value) => {
|
||||
const id = value?.trim();
|
||||
if (!id) return 'Provider ID is required';
|
||||
if (isCuratedProvider(id)) return `${id} has its own option.`;
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
if (p.isCancel(provider)) return cancelAndExit();
|
||||
|
||||
const apiKey = await promptSecret('Enter the API key');
|
||||
return { provider: provider.trim(), config: { provider: { api_key: apiKey } } };
|
||||
}
|
||||
|
||||
// === Provider Setup Flows ===
|
||||
|
||||
async function setupAnthropic(): Promise<ShannonConfig> {
|
||||
@@ -143,7 +194,7 @@ async function setupBedrock(): Promise<ShannonConfig> {
|
||||
}
|
||||
|
||||
interface GatewaySetup {
|
||||
provider: ProviderId;
|
||||
provider: CuratedProviderId;
|
||||
config: ShannonConfig;
|
||||
baseUrl: string;
|
||||
format?: OpenAiFormat;
|
||||
@@ -195,11 +246,11 @@ async function setupGateway(): Promise<GatewaySetup> {
|
||||
* 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];
|
||||
async function promptModel(provider: string): Promise<string> {
|
||||
const suggestions = isCuratedProvider(provider) ? MODEL_SUGGESTIONS[provider] : [];
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
return promptModelId(provider, MODEL_ID_PLACEHOLDER[provider]);
|
||||
return promptModelId(provider, modelIdPlaceholder(provider));
|
||||
}
|
||||
|
||||
const choice = await p.select({
|
||||
@@ -212,7 +263,7 @@ async function promptModel(provider: ProviderId): Promise<string> {
|
||||
if (p.isCancel(choice)) return cancelAndExit();
|
||||
|
||||
if (choice === CUSTOM_MODEL) {
|
||||
return promptModelId(provider, MODEL_ID_PLACEHOLDER[provider]);
|
||||
return promptModelId(provider, modelIdPlaceholder(provider));
|
||||
}
|
||||
return choice as string;
|
||||
}
|
||||
@@ -222,13 +273,13 @@ async function promptModel(provider: ProviderId): Promise<string> {
|
||||
* 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 {
|
||||
function conflictingProviderPrefix(provider: string, 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;
|
||||
return (CURATED_PROVIDERS as readonly string[]).includes(head) ? head : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,10 +287,10 @@ function conflictingProviderPrefix(provider: ProviderId, value: string): string
|
||||
* 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> {
|
||||
async function promptModelId(provider: string, placeholder?: string): Promise<string> {
|
||||
const modelId = await p.text({
|
||||
message: 'Model ID',
|
||||
placeholder,
|
||||
...(placeholder && { placeholder }),
|
||||
validate: (value) => {
|
||||
if (!value) return 'Model ID is required';
|
||||
const conflicting = conflictingProviderPrefix(provider, value);
|
||||
|
||||
Reference in New Issue
Block a user