mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-10-04 15:26:55 +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:
1 parent
e92ee61c05
commit
4b8131fdd5
15 files changed
+112
-227
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user