From 5ded24789190072370cd93427bb33569e54145db Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:53:04 +0000 Subject: [PATCH] gateway: add inference provider abstraction with MiniMax path Introduce a provider registry so the managed-agent client can route to an Anthropic-compatible endpoint. Adds MiniMax (MiniMax-M3, global_en/cn_zh regional base URLs) alongside the existing default provider, selected via INFERENCE_PROVIDER/PROVIDER_REGION. Default behaviour is unchanged. --- gateway/.env.example | 10 +++++ gateway/src/cma.ts | 16 +++++++- gateway/src/config.ts | 24 +++++++++++- gateway/src/providers.ts | 85 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 gateway/src/providers.ts diff --git a/gateway/.env.example b/gateway/.env.example index 3eb5de4c..8b61d448 100644 --- a/gateway/.env.example +++ b/gateway/.env.example @@ -6,6 +6,16 @@ AGENT_EFFORT=medium QUICK_ANSWER_TIMEOUT_MS=30000 STORE_PATH=./data/gateway-store.json +# Inference provider. Defaults to "anthropic" (native API, ANTHROPIC_API_KEY). +# Set to "minimax" to route the managed-agent client at MiniMax's +# Anthropic-compatible endpoint instead. When unset, AGENT_MODEL falls back to +# the selected provider's default model (e.g. MiniMax-M3 for minimax). +INFERENCE_PROVIDER=anthropic +# Region for providers that publish regional endpoints (minimax: global_en | cn_zh). +PROVIDER_REGION=global_en +# API key for the MiniMax provider (used when INFERENCE_PROVIDER=minimax). +MINIMAX_API_KEY= + # App connections (OAuth -> per-user vault credential) STATE_SECRET=generate-a-random-string PUBLIC_BASE_URL=https://your-gateway.example.com diff --git a/gateway/src/cma.ts b/gateway/src/cma.ts index d1fedb03..ddbfaef4 100644 --- a/gateway/src/cma.ts +++ b/gateway/src/cma.ts @@ -1,4 +1,16 @@ import Anthropic from "@anthropic-ai/sdk"; +import { config } from "./config.js"; -/** One client for the whole gateway. Reads ANTHROPIC_API_KEY from the environment. */ -export const anthropic = new Anthropic(); +/** + * One client for the whole gateway. + * + * Defaults to the native Anthropic API, reading ANTHROPIC_API_KEY from the + * environment. When an Anthropic-compatible provider is selected (see + * providers.ts), its base URL and API key are passed through so the same SDK + * reaches that provider's endpoint. + */ +const providerApiKey = process.env[config.providerApiKeyEnv]; +export const anthropic = new Anthropic({ + ...(config.providerBaseURL ? { baseURL: config.providerBaseURL } : {}), + ...(providerApiKey ? { apiKey: providerApiKey } : {}), +}); diff --git a/gateway/src/config.ts b/gateway/src/config.ts index 8e581cb7..834b2965 100644 --- a/gateway/src/config.ts +++ b/gateway/src/config.ts @@ -1,10 +1,20 @@ import "dotenv/config"; +import { InferenceProvider, resolveBaseURL, resolveProvider } from "./providers.js"; export interface GatewayConfig { port: number; storePath: string; /** token -> userId. Parsed from GATEWAY_TOKENS="tokenA:alice,tokenB:bob". */ tokens: Map; + /** Selected inference provider (see providers.ts). */ + provider: InferenceProvider; + /** + * Anthropic-compatible base URL for the provider/region, or undefined to use + * the SDK default (native Anthropic API). + */ + providerBaseURL?: string; + /** Environment variable that carries the selected provider's API key. */ + providerApiKeyEnv: string; agentModel: string; agentEffort: "low" | "medium" | "high"; /** How long a /v1/chat/completions call waits before converting to a background task. */ @@ -29,11 +39,16 @@ function parseTokens(raw: string | undefined): Map { return map; } +const provider = resolveProvider(process.env.INFERENCE_PROVIDER); + export const config: GatewayConfig = { port: Number(process.env.PORT ?? 8788), storePath: process.env.STORE_PATH ?? "./data/gateway-store.json", tokens: parseTokens(process.env.GATEWAY_TOKENS), - agentModel: process.env.AGENT_MODEL ?? "claude-opus-5", + provider, + providerBaseURL: resolveBaseURL(provider, process.env.PROVIDER_REGION), + providerApiKeyEnv: provider.apiKeyEnv, + agentModel: process.env.AGENT_MODEL ?? provider.defaultModel, agentEffort: (process.env.AGENT_EFFORT as GatewayConfig["agentEffort"]) ?? "medium", quickAnswerTimeoutMs: Number(process.env.QUICK_ANSWER_TIMEOUT_MS ?? 30_000), spawnMode: process.env.SPAWN_MODE !== "false", @@ -45,3 +60,10 @@ if (config.tokens.size === 0) { 'Set GATEWAY_TOKENS="sometoken:someUserId" in .env', ); } + +if (!process.env[config.providerApiKeyEnv]) { + console.warn( + `[config] ${config.providerApiKeyEnv} is empty - the ${config.provider.name} ` + + "inference provider will reject requests. Set it in .env", + ); +} diff --git a/gateway/src/providers.ts b/gateway/src/providers.ts new file mode 100644 index 00000000..b53195ff --- /dev/null +++ b/gateway/src/providers.ts @@ -0,0 +1,85 @@ +/** + * Inference provider registry. + * + * The gateway drives its managed-agent sessions through the single + * `@anthropic-ai/sdk` client in cma.ts. Any provider that exposes an + * Anthropic-compatible endpoint can be reached through that same client by + * pointing its `baseURL` at the provider's gateway, so adding one here is a + * table entry rather than a second SDK. Selection is env-driven (see config.ts) + * and defaults to the native Anthropic API, which keeps the existing behaviour. + */ + +export interface ProviderRegion { + /** Region id used in PROVIDER_REGION. */ + region: string; + /** Anthropic-compatible base URL for this region. */ + anthropicBaseURL: string; +} + +export interface InferenceProvider { + /** Stable id used in INFERENCE_PROVIDER. */ + id: string; + /** Human-readable name. */ + name: string; + /** Model id used when AGENT_MODEL is unset. */ + defaultModel: string; + /** Environment variable that carries this provider's API key. */ + apiKeyEnv: string; + /** + * Anthropic-compatible base URLs by region. The native Anthropic API needs no + * override, so its list is empty and the SDK default is used. + */ + regions: ProviderRegion[]; + /** Region chosen when PROVIDER_REGION is unset. */ + defaultRegion?: string; +} + +export const PROVIDERS: Record = { + anthropic: { + id: "anthropic", + name: "Anthropic", + defaultModel: "claude-opus-5", + apiKeyEnv: "ANTHROPIC_API_KEY", + regions: [], + }, + minimax: { + id: "minimax", + name: "MiniMax", + defaultModel: "MiniMax-M3", + apiKeyEnv: "MINIMAX_API_KEY", + regions: [ + { region: "global_en", anthropicBaseURL: "https://api.minimax.io/anthropic" }, + { region: "cn_zh", anthropicBaseURL: "https://api.minimaxi.com/anthropic" }, + ], + defaultRegion: "global_en", + }, +}; + +/** Resolve a provider by id, falling back to Anthropic for unknown ids. */ +export function resolveProvider(id: string | undefined): InferenceProvider { + const key = (id ?? "anthropic").toLowerCase(); + const provider = PROVIDERS[key]; + if (!provider) { + console.warn(`[providers] unknown INFERENCE_PROVIDER "${id}", falling back to anthropic`); + return PROVIDERS.anthropic; + } + return provider; +} + +/** + * Anthropic-compatible base URL for the provider/region, or undefined when the + * SDK default should be used (native Anthropic API). + */ +export function resolveBaseURL(provider: InferenceProvider, region: string | undefined): string | undefined { + if (provider.regions.length === 0) return undefined; + const wanted = region ?? provider.defaultRegion; + const match = provider.regions.find((r) => r.region === wanted); + if (!match) { + console.warn( + `[providers] unknown PROVIDER_REGION "${region}" for ${provider.name}, ` + + `using "${provider.regions[0].region}"`, + ); + return provider.regions[0].anthropicBaseURL; + } + return match.anthropicBaseURL; +}