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 authored and GitHub committed 2026-09-03 20:01:32 +05:30
1 parent e92ee61c05
commit 4b8131fdd5
15 files changed
+112 -227

No files matched your search

+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;