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;