|
|
|
@@ -10,7 +10,7 @@ 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 { CURATED_PROVIDERS, type CuratedProviderId, isCuratedProvider, type OpenAiFormat } from '../model-spec.js';
|
|
|
|
|
import { CURATED_PROVIDERS, type CuratedProviderId, isCuratedProvider } from '../model-spec.js';
|
|
|
|
|
import { displaySplash } from '../splash.js';
|
|
|
|
|
import { requireInteractive } from '../tty.js';
|
|
|
|
|
import { getVersion } from '../version.js';
|
|
|
|
@@ -22,24 +22,16 @@ const CUSTOM_BASE_URL = '__custom_base_url__';
|
|
|
|
|
const OTHER_PROVIDER = '__other_provider__';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
* API dialects reachable through the gateway route. The dialect picks the provider
|
|
|
|
|
* that supplies the credential and names the wire protocol the endpoint must speak.
|
|
|
|
|
*/
|
|
|
|
|
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' },
|
|
|
|
|
{ value: 'openai', label: 'OpenAI Responses', provider: 'openai' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/** Suggested models per curated provider, best-first. Free-text entry accepts any model in the provider's catalogue. */
|
|
|
|
@@ -78,7 +70,11 @@ export async function setup(): Promise<void> {
|
|
|
|
|
{ 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' },
|
|
|
|
|
{
|
|
|
|
|
value: CUSTOM_BASE_URL as typeof CUSTOM_BASE_URL,
|
|
|
|
|
label: 'Custom Base URL',
|
|
|
|
|
hint: 'route through a proxy or LLM gateway',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
value: OTHER_PROVIDER as typeof OTHER_PROVIDER,
|
|
|
|
|
label: 'Other provider',
|
|
|
|
@@ -88,20 +84,21 @@ export async function setup(): Promise<void> {
|
|
|
|
|
});
|
|
|
|
|
if (p.isCancel(selected)) return cancelAndExit();
|
|
|
|
|
|
|
|
|
|
// 2. Credentials — and, on the gateway route, the endpoint and its dialect.
|
|
|
|
|
const { provider, config, gateway } = await setupSelection(selected);
|
|
|
|
|
// 2. Credentials, and any endpoint override. A base URL overrides the endpoint
|
|
|
|
|
// for whichever provider is chosen — the curated gateway route names it via
|
|
|
|
|
// the dialect, the "Other provider" route asks for it directly.
|
|
|
|
|
const { provider, config, baseUrl } = await setupSelection(selected);
|
|
|
|
|
|
|
|
|
|
// 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 };
|
|
|
|
|
if (baseUrl) config.core = { ...config.core, base_url: baseUrl };
|
|
|
|
|
|
|
|
|
|
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}`);
|
|
|
|
|
if (baseUrl) summary.push(`Endpoint ${baseUrl}`);
|
|
|
|
|
|
|
|
|
|
p.log.success(`Configuration saved to ${configPath}`);
|
|
|
|
|
p.log.info(summary.join('\n'));
|
|
|
|
@@ -111,7 +108,7 @@ export async function setup(): Promise<void> {
|
|
|
|
|
interface Selection {
|
|
|
|
|
provider: string;
|
|
|
|
|
config: ShannonConfig;
|
|
|
|
|
gateway?: GatewaySetup;
|
|
|
|
|
baseUrl?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Resolve the provider selection into a provider id and its credential config. */
|
|
|
|
@@ -120,7 +117,7 @@ async function setupSelection(
|
|
|
|
|
): Promise<Selection> {
|
|
|
|
|
if (selected === CUSTOM_BASE_URL) {
|
|
|
|
|
const gateway = await setupGateway();
|
|
|
|
|
return { provider: gateway.provider, config: gateway.config, gateway };
|
|
|
|
|
return { provider: gateway.provider, config: gateway.config, baseUrl: gateway.baseUrl };
|
|
|
|
|
}
|
|
|
|
|
if (selected === OTHER_PROVIDER) {
|
|
|
|
|
return setupOtherProvider();
|
|
|
|
@@ -144,6 +141,8 @@ async function setupProvider(provider: CuratedProviderId): 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.
|
|
|
|
|
* An optional base URL points that provider at a proxy or LLM gateway; left blank, the
|
|
|
|
|
* provider's own endpoint is used.
|
|
|
|
|
*/
|
|
|
|
|
async function setupOtherProvider(): Promise<Selection> {
|
|
|
|
|
p.log.info('Browse supported providers and models at https://pi.dev/models');
|
|
|
|
@@ -159,7 +158,13 @@ async function setupOtherProvider(): Promise<Selection> {
|
|
|
|
|
if (p.isCancel(provider)) return cancelAndExit();
|
|
|
|
|
|
|
|
|
|
const apiKey = await promptSecret('Enter the API key');
|
|
|
|
|
return { provider: provider.trim(), config: { provider: { api_key: apiKey } } };
|
|
|
|
|
const baseUrl = await promptOptionalBaseUrl();
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
provider: provider.trim(),
|
|
|
|
|
config: { provider: { api_key: apiKey } },
|
|
|
|
|
...(baseUrl && { baseUrl }),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === Provider Setup Flows ===
|
|
|
|
@@ -200,11 +205,10 @@ interface GatewaySetup {
|
|
|
|
|
provider: CuratedProviderId;
|
|
|
|
|
config: ShannonConfig;
|
|
|
|
|
baseUrl: string;
|
|
|
|
|
format?: OpenAiFormat;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Gateway route: the endpoint decides where requests go, but the format still
|
|
|
|
|
* Gateway route: the endpoint decides where requests go, but the dialect still
|
|
|
|
|
* picks a real provider, because that is what supplies the credential and the
|
|
|
|
|
* wire protocol.
|
|
|
|
|
*/
|
|
|
|
@@ -236,11 +240,9 @@ async function setupGateway(): Promise<GatewaySetup> {
|
|
|
|
|
|
|
|
|
|
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 }) } };
|
|
|
|
|
provider === 'anthropic' ? { anthropic: { api_key: authToken } } : { openai: { api_key: authToken } };
|
|
|
|
|
|
|
|
|
|
return { provider, config, baseUrl, ...(dialect.format && { format: dialect.format }) };
|
|
|
|
|
return { provider, config, baseUrl };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === Model Selection ===
|
|
|
|
@@ -308,6 +310,31 @@ async function promptModelId(provider: string, placeholder?: string): Promise<st
|
|
|
|
|
|
|
|
|
|
// === Helpers ===
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Optional endpoint override. Empty input means the provider's default endpoint;
|
|
|
|
|
* any value must be a valid URL.
|
|
|
|
|
*/
|
|
|
|
|
async function promptOptionalBaseUrl(): Promise<string | undefined> {
|
|
|
|
|
const baseUrl = await p.text({
|
|
|
|
|
message: 'Custom base URL (optional, leave blank for the provider default)',
|
|
|
|
|
placeholder: 'https://llm-gateway.example.com',
|
|
|
|
|
validate: (value) => {
|
|
|
|
|
const trimmed = value?.trim();
|
|
|
|
|
if (!trimmed) return undefined;
|
|
|
|
|
try {
|
|
|
|
|
new URL(trimmed);
|
|
|
|
|
} catch {
|
|
|
|
|
return 'Must be a valid URL';
|
|
|
|
|
}
|
|
|
|
|
return undefined;
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
if (p.isCancel(baseUrl)) return cancelAndExit();
|
|
|
|
|
|
|
|
|
|
const trimmed = baseUrl?.trim();
|
|
|
|
|
return trimmed ? trimmed : undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function promptSecret(message: string): Promise<string> {
|
|
|
|
|
const value = await p.password({
|
|
|
|
|
message,
|
|
|
|
|