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:
ezl-keygraph
2026-08-07 00:28:23 +05:30
committed by GitHub
parent 86effd5240
commit a1675f8390
11 changed files with 257 additions and 123 deletions
+70 -19
View File
@@ -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);
+26 -5
View File
@@ -9,7 +9,13 @@ 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';
import {
type CuratedProviderId,
DEFAULT_MODEL_SPEC,
GENERIC_API_KEY_ENV,
isCuratedProvider,
parseModelSpec,
} from '../model-spec.js';
// === TOML ↔ Env Mapping ===
@@ -42,16 +48,22 @@ const CONFIG_MAP: readonly ConfigMapping[] = [
// Bedrock
{ env: 'AWS_REGION', toml: 'bedrock.region', type: 'string' },
{ env: 'AWS_BEARER_TOKEN_BEDROCK', toml: 'bedrock.token', type: 'string' },
// Generic — credential for any provider Shannon does not curate
{ env: GENERIC_API_KEY_ENV, toml: 'provider.api_key', type: 'string' },
] as const;
/** TOML section holding each provider's credentials, keyed by provider id. */
const PROVIDER_SECTIONS: Readonly<Record<ProviderId, string>> = {
/** TOML section holding each curated provider's credentials, keyed by provider id. */
const PROVIDER_SECTIONS: Readonly<Record<CuratedProviderId, string>> = {
anthropic: 'anthropic',
openai: 'openai',
xai: 'xai',
'amazon-bedrock': 'bedrock',
};
/** TOML section holding the generic credential for uncurated providers. */
const GENERIC_PROVIDER_SECTION = 'provider';
// === TOML Parsing ===
type TOMLValue = string | number | boolean;
@@ -128,9 +140,18 @@ function buildSchema(): Map<string, Map<string, TOMLType>> {
/**
* 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.
* other providers' sections are ignored and never forwarded. An uncurated
* provider draws its credential from the generic [provider] section.
*/
function validateProviderFields(config: TOMLConfig, providerId: ProviderId, errors: string[]): void {
function validateProviderFields(config: TOMLConfig, providerId: string, errors: string[]): void {
if (!isCuratedProvider(providerId)) {
const section = config[GENERIC_PROVIDER_SECTION] as Record<string, unknown> | undefined;
if (!section || !Object.keys(section).includes('api_key')) {
errors.push(`[${GENERIC_PROVIDER_SECTION}] requires api_key for provider "${providerId}"`);
}
return;
}
const sectionName = PROVIDER_SECTIONS[providerId];
const section = config[sectionName] as Record<string, unknown> | undefined;
const keys = section ? Object.keys(section) : [];
+2
View File
@@ -13,6 +13,8 @@ export interface ShannonConfig {
openai?: { api_key?: string; format?: string };
xai?: { api_key?: string };
bedrock?: { region?: string; token?: string };
/** Generic credential for any provider Shannon does not curate. Maps to SHANNON_AI_API_KEY. */
provider?: { api_key?: string };
}
// === File Operations ===
+33 -19
View File
@@ -9,25 +9,35 @@ import dotenv from 'dotenv';
import { resolveConfig } from './config/resolver.js';
import { getMode } from './mode.js';
import {
CURATED_PROVIDERS,
type CuratedProviderId,
GENERIC_API_KEY_ENV,
isCuratedProvider,
PROVIDER_API_KEY_ENV,
PROVIDER_CREDENTIAL_HINT,
PROVIDER_EXTRA_ENV,
type ProviderId,
resolveModelSpec,
SUPPORTED_PROVIDERS,
} from './model-spec.js';
/**
* Variables forwarded to every worker container regardless of provider. Each is
* forwarded only when set, so an unused one never appears in the container.
* SHANNON_AI_API_KEY rides along because it is provider-neutral.
*/
const COMMON_FORWARD_VARS = ['SHANNON_AI_MODEL', 'SHANNON_AI_BASE_URL', 'SHANNON_AI_OPENAI_FORMAT'] as const;
const COMMON_FORWARD_VARS = [
'SHANNON_AI_MODEL',
'SHANNON_AI_BASE_URL',
'SHANNON_AI_OPENAI_FORMAT',
GENERIC_API_KEY_ENV,
] 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.
* forwarded, so a key for an unused provider never enters the scan container. An
* uncurated provider has none — it relies on the common SHANNON_AI_API_KEY.
*/
function providerForwardVars(providerId: ProviderId): readonly string[] {
function providerForwardVars(providerId: string): readonly string[] {
if (!isCuratedProvider(providerId)) return [];
return [...PROVIDER_API_KEY_ENV[providerId], ...PROVIDER_EXTRA_ENV[providerId]];
}
@@ -70,22 +80,23 @@ interface CredentialValidation {
error?: string;
}
/**
* 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 {
/** Whether a curated provider has its own named credential set (API key plus any extra var). */
function hasNamedCredential(providerId: CuratedProviderId): boolean {
const apiKeys = PROVIDER_API_KEY_ENV[providerId];
if (apiKeys.length > 0 && !apiKeys.some((name) => Boolean(process.env[name]))) {
return false;
}
if (!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));
/** Whether the selected provider has a credential. Bedrock needs its AWS_ vars; the generic key never stands in for it. */
function hasCredential(providerId: string): boolean {
if (providerId === 'amazon-bedrock') return hasNamedCredential('amazon-bedrock');
if (isCuratedProvider(providerId) && hasNamedCredential(providerId)) return true;
return Boolean(process.env[GENERIC_API_KEY_ENV]);
}
/** Curated providers with a named credential. The generic key is neutral, so it never counts toward ambiguity. */
function configuredProviders(): CuratedProviderId[] {
return CURATED_PROVIDERS.filter((providerId) => hasNamedCredential(providerId));
}
/**
@@ -93,7 +104,7 @@ function configuredProviders(): ProviderId[] {
* Runs before any Docker work so mistakes fail immediately.
*/
export function validateCredentials(): CredentialValidation {
// 1. Model selection must parse and name a supported provider
// 1. Model selection must parse into a provider and model id
const spec = resolveModelSpec();
if (typeof spec === 'string') {
return { valid: false, error: spec };
@@ -101,9 +112,12 @@ export function validateCredentials(): CredentialValidation {
// 2. The selected provider must have a credential
if (!hasCredential(spec.providerId)) {
const requirement = isCuratedProvider(spec.providerId)
? PROVIDER_CREDENTIAL_HINT[spec.providerId]
: GENERIC_API_KEY_ENV;
const hint =
getMode() === 'local'
? `Set ${PROVIDER_CREDENTIAL_HINT[spec.providerId]} in .env or export it.`
? `Set ${requirement} in .env or export it.`
: `Export the variables or run 'npx @keygraph/shannon setup'.`;
return {
valid: false,
+23 -18
View File
@@ -6,24 +6,35 @@
* 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;
/**
* Providers Shannon curates with their own credential variables, config sections,
* and setup flows. Any other pi provider is reachable via the generic credential
* path. Mirrors CURATED_PROVIDERS in apps/worker/src/ai/models.ts.
*/
export const CURATED_PROVIDERS = ['anthropic', 'openai', 'xai', 'amazon-bedrock'] as const;
export type ProviderId = (typeof SUPPORTED_PROVIDERS)[number];
export type CuratedProviderId = (typeof CURATED_PROVIDERS)[number];
export function isCuratedProvider(value: string): value is CuratedProviderId {
return (CURATED_PROVIDERS as readonly string[]).includes(value);
}
/** Generic API key, honored for any provider Shannon does not curate. Mirrors the worker. */
export const GENERIC_API_KEY_ENV = 'SHANNON_AI_API_KEY';
/**
* 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.
* Env vars carrying each curated 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[]>> = {
export const PROVIDER_API_KEY_ENV: Readonly<Record<CuratedProviderId, 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[]>> = {
/** Additional env vars a curated provider requires beyond its API key. All must be set. */
export const PROVIDER_EXTRA_ENV: Readonly<Record<CuratedProviderId, readonly string[]>> = {
anthropic: [],
openai: [],
xai: [],
@@ -31,7 +42,7 @@ export const PROVIDER_EXTRA_ENV: Readonly<Record<ProviderId, readonly string[]>>
};
/** Human-readable credential requirement, used in "nothing configured" errors. */
export const PROVIDER_CREDENTIAL_HINT: Readonly<Record<ProviderId, string>> = {
export const PROVIDER_CREDENTIAL_HINT: Readonly<Record<CuratedProviderId, string>> = {
anthropic: 'ANTHROPIC_API_KEY (or CLAUDE_CODE_OAUTH_TOKEN)',
openai: 'OPENAI_API_KEY',
xai: 'XAI_API_KEY',
@@ -51,18 +62,15 @@ export const OPENAI_FORMATS = ['chat-completions', 'responses'] as const;
export type OpenAiFormat = (typeof OPENAI_FORMATS)[number];
export interface ModelSpec {
providerId: ProviderId;
providerId: string;
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.
* The provider id is passed through as given — the worker's preflight validates it
* against pi. Returns an error string rather than throwing, for the CLI's flow.
*/
export function parseModelSpec(spec: string): ModelSpec | string {
const trimmed = spec.trim();
@@ -74,9 +82,6 @@ export function parseModelSpec(spec: string): ModelSpec | string {
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 };
}
+44 -29
View File
@@ -24,18 +24,29 @@
import type { Api, Credential, CredentialInfo, CredentialStore, Model } from '@earendil-works/pi-ai';
import { ModelRuntime } from '@earendil-works/pi-coding-agent';
/** Providers Shannon can currently reach. Each is a pi-ai provider id. */
export const SUPPORTED_PROVIDERS = ['anthropic', 'openai', 'xai', 'amazon-bedrock'] as const;
/**
* Providers Shannon curates with their own credential variables, config sections,
* and setup flows. Each is a pi-ai provider id; any other pi provider is still
* reachable through the generic credential path below.
*/
export const CURATED_PROVIDERS = ['anthropic', 'openai', 'xai', 'amazon-bedrock'] as const;
export type ProviderId = (typeof SUPPORTED_PROVIDERS)[number];
export type CuratedProviderId = (typeof CURATED_PROVIDERS)[number];
function isCuratedProvider(value: string): value is CuratedProviderId {
return (CURATED_PROVIDERS as readonly string[]).includes(value);
}
/** Generic API key, honored for any provider Shannon does not curate. */
export const GENERIC_API_KEY_ENV = 'SHANNON_AI_API_KEY';
/**
* Env vars carrying each provider's API key, in precedence order. Shannon does not
* invent credential names — these are the variables each provider's own tooling
* uses. Bedrock pairs its bearer token with AWS_REGION, which is provider config
* rather than a credential.
* Env vars carrying each curated provider's API key, in precedence order. Shannon
* does not invent credential names — these are the variables each provider's own
* tooling uses. Bedrock pairs its bearer token with AWS_REGION, which is provider
* config rather than a credential.
*/
export const PROVIDER_API_KEY_ENV: Readonly<Record<ProviderId, readonly string[]>> = {
export const PROVIDER_API_KEY_ENV: Readonly<Record<CuratedProviderId, readonly string[]>> = {
anthropic: ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'],
openai: ['OPENAI_API_KEY'],
xai: ['XAI_API_KEY'],
@@ -45,6 +56,9 @@ export const PROVIDER_API_KEY_ENV: Readonly<Record<ProviderId, readonly string[]
/** Model used when SHANNON_AI_MODEL is unset. */
export const DEFAULT_MODEL_SPEC = 'anthropic:claude-sonnet-4-6';
/** Browsable pi model catalogue — the source of valid `<provider>:<model-id>` ids. */
export const PI_CATALOG_URL = 'https://pi.dev/models';
/**
* Wire formats an OpenAI-compatible gateway may serve, named by
* SHANNON_AI_OPENAI_FORMAT. Only `openai` offers a choice: every other supported
@@ -82,18 +96,14 @@ export function resolveOpenAiFormat(): OpenAiFormat | undefined {
}
export interface ModelSpec {
providerId: ProviderId;
providerId: string;
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. Throws with the supported provider list on
* a malformed or unknown provider.
* Parse a `<provider>:<model-id>` spec. Splits on the first colon only, so colons
* inside a model ID survive. The provider id is passed through as given — pi's
* registry validates it later — so this throws only on a malformed spec.
*/
export function parseModelSpec(spec: string): ModelSpec {
const trimmed = spec.trim();
@@ -112,11 +122,6 @@ export function parseModelSpec(spec: string): ModelSpec {
`SHANNON_AI_MODEL must be "<provider>:<model-id>", got "${trimmed}". Example: ${DEFAULT_MODEL_SPEC}`,
);
}
if (!isSupportedProvider(providerId)) {
throw new Error(
`Unsupported provider "${providerId}" in SHANNON_AI_MODEL. Supported providers: ${SUPPORTED_PROVIDERS.join(', ')}`,
);
}
return { providerId, modelId };
}
@@ -133,17 +138,25 @@ export interface ProviderCredentials {
apiKey?: string;
}
/** Collect the API key and optional endpoint override for a provider. */
export function resolveProviderCredentials(providerId: ProviderId): ProviderCredentials {
/**
* Collect the API key and optional endpoint override for a provider. A curated
* provider's own variables win, then the generic SHANNON_AI_API_KEY. Bedrock is
* excluded — it authenticates through its AWS_ variables, which pi reads directly.
*/
export function resolveProviderCredentials(providerId: string): ProviderCredentials {
const credentials: ProviderCredentials = {};
for (const name of PROVIDER_API_KEY_ENV[providerId]) {
const namedVars = isCuratedProvider(providerId) ? PROVIDER_API_KEY_ENV[providerId] : [];
for (const name of namedVars) {
const value = process.env[name];
if (value) {
credentials.apiKey = value;
break;
}
}
if (!credentials.apiKey && providerId !== 'amazon-bedrock' && process.env[GENERIC_API_KEY_ENV]) {
credentials.apiKey = process.env[GENERIC_API_KEY_ENV];
}
if (process.env.SHANNON_AI_BASE_URL) credentials.baseUrl = process.env.SHANNON_AI_BASE_URL;
return credentials;
@@ -203,7 +216,7 @@ export interface ModelSelection {
model: Model<Api>;
modelRuntime: ModelRuntime;
modelId: string;
providerId: ProviderId;
providerId: string;
}
/**
@@ -218,7 +231,7 @@ export interface ModelSelection {
* then describes the format in use. Every other provider has one API and only
* changes address.
*/
function pointAtGateway(model: Model<Api>, providerId: ProviderId, baseUrl: string, format: OpenAiFormat): Model<Api> {
function pointAtGateway(model: Model<Api>, providerId: string, baseUrl: string, format: OpenAiFormat): Model<Api> {
if (providerId !== 'openai') return { ...model, baseUrl };
if (format === 'responses') return { ...model, baseUrl, api: OPENAI_FORMATS.responses };
@@ -240,7 +253,7 @@ function pointAtGateway(model: Model<Api>, providerId: ProviderId, baseUrl: stri
*/
export function resolveModel(
modelRuntime: ModelRuntime,
providerId: ProviderId,
providerId: string,
modelId: string,
baseUrl: string | undefined,
format: OpenAiFormat = DEFAULT_OPENAI_FORMAT,
@@ -265,7 +278,7 @@ export function resolveModel(
* are configured, so it is rejected outside that combination rather than
* silently ignored.
*/
export function resolveGatewayFormat(providerId: ProviderId, baseUrl: string | undefined): OpenAiFormat {
export function resolveGatewayFormat(providerId: string, baseUrl: string | undefined): OpenAiFormat {
const configured = resolveOpenAiFormat();
if (!configured) return DEFAULT_OPENAI_FORMAT;
@@ -296,7 +309,9 @@ export async function resolveModelSelection(): Promise<ModelSelection> {
const model = resolveModel(modelRuntime, providerId, modelId, credentials.baseUrl, format);
if (!model) {
throw new Error(`Model not found in pi registry: provider="${providerId}" model="${modelId}"`);
throw new Error(
`Model not found in pi registry: provider="${providerId}" model="${modelId}". Browse valid providers and models at ${PI_CATALOG_URL}.`,
);
}
return {
+14 -6
View File
@@ -36,10 +36,12 @@ import {
} from '@earendil-works/pi-coding-agent';
import { glob } from 'zx';
import {
type CuratedProviderId,
createModelRuntime,
GENERIC_API_KEY_ENV,
type ModelSpec,
type OpenAiFormat,
type ProviderId,
PI_CATALOG_URL,
resolveGatewayFormat,
resolveModel,
resolveModelSpec,
@@ -277,16 +279,22 @@ async function probeCredentialsWithPi(
return ok(undefined);
}
/** Credential env var a provider reads, for "credential missing" messages. */
const PROVIDER_CREDENTIAL_HINT: Readonly<Record<ProviderId, string>> = {
/** Credential env var a curated provider reads, for "credential missing" messages. */
const PROVIDER_CREDENTIAL_HINT: Readonly<Record<CuratedProviderId, string>> = {
anthropic: 'ANTHROPIC_API_KEY (or CLAUDE_CODE_OAUTH_TOKEN)',
openai: 'OPENAI_API_KEY',
xai: 'XAI_API_KEY',
'amazon-bedrock': 'AWS_BEARER_TOKEN_BEDROCK and AWS_REGION',
};
/** Which variable to set when a provider's credential is missing. */
function credentialHint(providerId: string): string {
const curated = (PROVIDER_CREDENTIAL_HINT as Record<string, string | undefined>)[providerId];
return curated ?? GENERIC_API_KEY_ENV;
}
/** Human-readable label for which credential path a run is using. */
function describeAuth(providerId: ProviderId, baseUrl: string | undefined): string {
function describeAuth(providerId: string, baseUrl: string | undefined): string {
if (baseUrl) return `custom endpoint (${baseUrl})`;
if (providerId === 'amazon-bedrock') return 'Bedrock bearer token';
return `${providerId} API key`;
@@ -338,7 +346,7 @@ async function validateCredentials(logger: ActivityLogger): Promise<Result<void,
if (missing.length > 0 || (!isBedrock && !credentials.apiKey)) {
return err(
new PentestError(
`No credentials found for provider "${spec.providerId}". Set ${PROVIDER_CREDENTIAL_HINT[spec.providerId]} in .env.`,
`No credentials found for provider "${spec.providerId}". Set ${credentialHint(spec.providerId)} in .env.`,
'config',
false,
{ providerId: spec.providerId, ...(missing.length > 0 && { missing }) },
@@ -356,7 +364,7 @@ async function validateCredentials(logger: ActivityLogger): Promise<Result<void,
if (!baseModel) {
return err(
new PentestError(
`Model not found in pi registry: provider="${spec.providerId}" model="${spec.modelId}". Check SHANNON_AI_MODEL.`,
`Model not found in pi registry: provider="${spec.providerId}" model="${spec.modelId}". Check SHANNON_AI_MODEL — browse valid providers and models at ${PI_CATALOG_URL}.`,
'config',
false,
{ providerId: spec.providerId, modelId: spec.modelId },