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
+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 },