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:
ezl-keygraph
2026-09-03 20:01:32 +05:30
committed by GitHub
parent e92ee61c05
commit 4b8131fdd5
15 changed files with 112 additions and 227 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ It analyzes your source code, identifies attack paths, and executes real exploit
- **Docker**: required for the worker container.
- **Node.js 18+**: required for the recommended `npx` workflow.
- **AI provider credentials**: Shannon runs on Anthropic, OpenAI, xAI, AWS Bedrock, any other provider in the harness catalogue, and any endpoint that speaks the Anthropic Messages API or the OpenAI Chat Completions or Responses API through a custom base URL. You bring your own key, and Keygraph never proxies your model traffic. Shannon is provider-agnostic.
- **AI provider credentials**: Shannon runs on Anthropic, OpenAI, xAI, AWS Bedrock, and any other provider in the harness catalogue — each of which you can point at a proxy or LLM gateway through a custom base URL. You bring your own key, and Keygraph never proxies your model traffic. Shannon is provider-agnostic.
- **Cyber safeguards cleared with your provider**: Anthropic and OpenAI apply real-time safeguards to cyber-security workloads, which can interrupt a scan mid-run. Complete their guidance for legitimate security testers before your first run.
### Run Shannon
+54 -27
View File
@@ -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,
+1 -2
View File
@@ -40,9 +40,8 @@ const CONFIG_MAP: readonly ConfigMapping[] = [
{ env: 'ANTHROPIC_API_KEY', toml: 'anthropic.api_key', type: 'string' },
{ env: 'CLAUDE_CODE_OAUTH_TOKEN', toml: 'anthropic.oauth_token', type: 'string' },
// OpenAI — format picks the wire API a gateway serves
// OpenAI
{ env: 'OPENAI_API_KEY', toml: 'openai.api_key', type: 'string' },
{ env: 'SHANNON_AI_OPENAI_FORMAT', toml: 'openai.format', type: 'string' },
// xAI
{ env: 'XAI_API_KEY', toml: 'xai.api_key', type: 'string' },
+1 -1
View File
@@ -10,7 +10,7 @@ import { getConfigFile } from '../home.js';
export interface ShannonConfig {
core?: { model?: string; base_url?: string };
anthropic?: { api_key?: string; oauth_token?: string };
openai?: { api_key?: string; format?: string };
openai?: { api_key?: 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. */
-1
View File
@@ -30,7 +30,6 @@ import {
const COMMON_FORWARD_VARS = [
'SHANNON_AI_MODEL',
'SHANNON_AI_BASE_URL',
'SHANNON_AI_OPENAI_FORMAT',
// Opt-in debug flag: when set, the worker persists a bounded, sanitized snippet of a failed
// provider turn's raw error message to error.log. Off by default; provider prose stays out of
// durable state unless an operator deliberately enables it for a diagnosis.
-9
View File
@@ -52,15 +52,6 @@ export const PROVIDER_CREDENTIAL_HINT: Readonly<Record<CuratedProviderId, string
/** Model used when SHANNON_AI_MODEL is unset. */
export const DEFAULT_MODEL_SPEC = 'anthropic:claude-sonnet-4-6';
/**
* Values SHANNON_AI_OPENAI_FORMAT accepts, selecting the wire format an
* OpenAI-compatible gateway serves. Mirrors OPENAI_FORMATS in
* apps/worker/src/ai/models.ts; the worker validates and applies it.
*/
export const OPENAI_FORMATS = ['chat-completions', 'responses'] as const;
export type OpenAiFormat = (typeof OPENAI_FORMATS)[number];
export interface ModelSpec {
providerId: string;
modelId: string;
+3 -87
View File
@@ -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}.`,
+5 -24
View File
@@ -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.