mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-08-15 16:00:29 +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:
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user