mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-23 10:10:51 +02:00
feat: per-provider custom base URL, OpenAI Responses only (#445)
* feat: drop OpenAI chat-completions gateway format, keep Responses only * docs: reframe custom base URL as a universal endpoint override * docs: show optional base URL in the any-other-provider example
This commit is contained in:
@@ -78,42 +78,6 @@ 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
|
||||
* provider has exactly one API in pi's registry.
|
||||
*/
|
||||
export const OPENAI_FORMATS = {
|
||||
'chat-completions': 'openai-completions',
|
||||
responses: 'openai-responses',
|
||||
} as const;
|
||||
|
||||
export type OpenAiFormat = keyof typeof OPENAI_FORMATS;
|
||||
|
||||
/** Format assumed when a gateway is configured but no format is named. */
|
||||
export const DEFAULT_OPENAI_FORMAT: OpenAiFormat = 'chat-completions';
|
||||
|
||||
function isOpenAiFormat(value: string): value is OpenAiFormat {
|
||||
return value in OPENAI_FORMATS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read SHANNON_AI_OPENAI_FORMAT. Unset returns undefined, which lets the caller
|
||||
* distinguish "not configured" from an explicit choice and reject the variable
|
||||
* where it has no effect.
|
||||
*/
|
||||
export function resolveOpenAiFormat(): OpenAiFormat | undefined {
|
||||
const raw = process.env.SHANNON_AI_OPENAI_FORMAT?.trim();
|
||||
if (!raw) return undefined;
|
||||
|
||||
if (!isOpenAiFormat(raw)) {
|
||||
throw new Error(
|
||||
`SHANNON_AI_OPENAI_FORMAT must be one of: ${Object.keys(OPENAI_FORMATS).join(', ')}. Got "${raw}".`,
|
||||
);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export interface ModelSpec {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
@@ -256,26 +220,6 @@ export interface ModelSelection {
|
||||
readonly credentialSource: 'api-key' | 'pi-auth' | 'ambient';
|
||||
}
|
||||
|
||||
/**
|
||||
* Point a model descriptor at a gateway.
|
||||
*
|
||||
* An OpenAI gateway may serve either wire format, named by
|
||||
* SHANNON_AI_OPENAI_FORMAT and defaulting to chat completions, which is what
|
||||
* most gateway software exposes. Switching to completions also drops the stored
|
||||
* `compat` block: the catalogue's block describes Responses, and an explicit
|
||||
* entry outranks pi's `detectCompat`, so leaving it would apply Responses
|
||||
* settings to a completions request. Staying on Responses keeps it, since it
|
||||
* then describes the format in use. Every other provider has one API and only
|
||||
* changes address.
|
||||
*/
|
||||
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 };
|
||||
|
||||
const { compat: _responsesCompat, ...withoutCompat } = model;
|
||||
return { ...withoutCompat, baseUrl, api: OPENAI_FORMATS['chat-completions'] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a model against a runtime.
|
||||
*
|
||||
@@ -293,44 +237,17 @@ export function resolveModel(
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
baseUrl: string | undefined,
|
||||
format: OpenAiFormat = DEFAULT_OPENAI_FORMAT,
|
||||
): Model<Api> | undefined {
|
||||
const found = modelRuntime.getModel(providerId, modelId);
|
||||
if (found) {
|
||||
return baseUrl ? pointAtGateway(found, providerId, baseUrl, format) : found;
|
||||
return baseUrl ? { ...found, baseUrl } : found;
|
||||
}
|
||||
if (!baseUrl) return undefined;
|
||||
|
||||
const reference = modelRuntime.getModels(providerId)[0];
|
||||
if (!reference) return undefined;
|
||||
|
||||
return pointAtGateway({ ...reference, id: modelId, name: modelId }, providerId, baseUrl, format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate SHANNON_AI_OPENAI_FORMAT against the rest of the configuration and
|
||||
* return the format a gateway run should use.
|
||||
*
|
||||
* The variable only reaches a request when both an OpenAI model and a gateway
|
||||
* are configured, so it is rejected outside that combination rather than
|
||||
* silently ignored.
|
||||
*/
|
||||
export function resolveGatewayFormat(providerId: string, baseUrl: string | undefined): OpenAiFormat {
|
||||
const configured = resolveOpenAiFormat();
|
||||
if (!configured) return DEFAULT_OPENAI_FORMAT;
|
||||
|
||||
if (providerId !== 'openai') {
|
||||
throw new Error(
|
||||
`SHANNON_AI_OPENAI_FORMAT applies to openai models only, but SHANNON_AI_MODEL selects "${providerId}". ` +
|
||||
`${providerId} serves a single API, so there is no format to choose.`,
|
||||
);
|
||||
}
|
||||
if (!baseUrl) {
|
||||
throw new Error(
|
||||
'SHANNON_AI_OPENAI_FORMAT applies to gateway runs only. Set SHANNON_AI_BASE_URL, or unset the format to call OpenAI directly.',
|
||||
);
|
||||
}
|
||||
return configured;
|
||||
return { ...reference, id: modelId, name: modelId, baseUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -340,12 +257,11 @@ export function resolveGatewayFormat(providerId: string, baseUrl: string | undef
|
||||
export async function resolveModelSelection(): Promise<ModelSelection> {
|
||||
const { providerId, modelId } = resolveModelSpec();
|
||||
const credentials = resolveProviderCredentials(providerId);
|
||||
const format = resolveGatewayFormat(providerId, credentials.baseUrl);
|
||||
|
||||
const mountedPiAuth = piAuthPresent();
|
||||
const modelRuntime = await createModelRuntime(providerId, credentials.apiKey);
|
||||
|
||||
const model = resolveModel(modelRuntime, providerId, modelId, credentials.baseUrl, format);
|
||||
const model = resolveModel(modelRuntime, providerId, modelId, credentials.baseUrl);
|
||||
if (!model) {
|
||||
throw new Error(
|
||||
`Model not found in pi registry: provider="${providerId}" model="${modelId}". Browse valid providers and models at ${PI_CATALOG_URL}.`,
|
||||
|
||||
@@ -40,10 +40,8 @@ import {
|
||||
createModelRuntime,
|
||||
GENERIC_API_KEY_ENV,
|
||||
type ModelSpec,
|
||||
type OpenAiFormat,
|
||||
PI_CATALOG_URL,
|
||||
piAuthPresent,
|
||||
resolveGatewayFormat,
|
||||
resolveModel,
|
||||
resolveModelSpec,
|
||||
resolveProviderCredentials,
|
||||
@@ -329,24 +327,7 @@ async function validateCredentials(logger: ActivityLogger): Promise<Result<void,
|
||||
// needs one API key.
|
||||
const credentials = resolveProviderCredentials(spec.providerId);
|
||||
|
||||
// 3. Wire format for an OpenAI gateway. Rejects a format named where it cannot
|
||||
// take effect, rather than letting the run proceed on the wrong API.
|
||||
let format: OpenAiFormat;
|
||||
try {
|
||||
format = resolveGatewayFormat(spec.providerId, credentials.baseUrl);
|
||||
} catch (error) {
|
||||
return err(
|
||||
new PentestError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
'config',
|
||||
false,
|
||||
{ providerId: spec.providerId },
|
||||
ErrorCode.AUTH_FAILED,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// With a mounted pi auth.json the env-var checks don't apply — step 5's probe validates it.
|
||||
// With a mounted pi auth.json the env-var checks don't apply — step 4's probe validates it.
|
||||
const isBedrock = spec.providerId === 'amazon-bedrock';
|
||||
const missing =
|
||||
isBedrock && !piAuthPresent() ? ['AWS_REGION', 'AWS_BEARER_TOKEN_BEDROCK'].filter((n) => !process.env[n]) : [];
|
||||
@@ -362,12 +343,12 @@ async function validateCredentials(logger: ActivityLogger): Promise<Result<void,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Model must exist in the registry, for every provider — Bedrock IDs are the
|
||||
// 3. Model must exist in the registry, for every provider — Bedrock IDs are the
|
||||
// easiest to get wrong, since region prefixes and version suffixes differ per
|
||||
// model (`us.anthropic.claude-opus-5` exists, bare `anthropic.` does not).
|
||||
// A custom endpoint is exempt: it may serve models under its own names.
|
||||
const modelRuntime = await createModelRuntime(spec.providerId, credentials.apiKey);
|
||||
const baseModel = resolveModel(modelRuntime, spec.providerId, spec.modelId, credentials.baseUrl, format);
|
||||
const baseModel = resolveModel(modelRuntime, spec.providerId, spec.modelId, credentials.baseUrl);
|
||||
if (!baseModel) {
|
||||
return err(
|
||||
new PentestError(
|
||||
@@ -385,10 +366,10 @@ async function validateCredentials(logger: ActivityLogger): Promise<Result<void,
|
||||
);
|
||||
}
|
||||
if (credentials.baseUrl && spec.providerId === 'openai') {
|
||||
logger.info(`Gateway API: ${format} (${baseModel.api})`);
|
||||
logger.info(`Gateway API: ${baseModel.api}`);
|
||||
}
|
||||
|
||||
// 5. One real request, so a credential the account cannot use fails here
|
||||
// 4. One real request, so a credential the account cannot use fails here
|
||||
// rather than partway through the run. Bedrock included: pi resolves the
|
||||
// bearer token from the primed credential and the region from AWS_REGION,
|
||||
// so the probe exercises the same auth path the scan will.
|
||||
|
||||
Reference in New Issue
Block a user