diff --git a/src/api/routes/admin/model-providers.ts b/src/api/routes/admin/model-providers.ts index 66a2e0987..7ae287e95 100644 --- a/src/api/routes/admin/model-providers.ts +++ b/src/api/routes/admin/model-providers.ts @@ -1,24 +1,34 @@ -import { isModelProvider, type ModelProvider } from "../../../model/pi-models.ts"; +import { isModelProvider, providerBaseUrl, type ModelProvider } from "../../../model/pi-models.ts"; import { selectableModelCatalog } from "../../../model/model-catalog.ts"; import { sendJson } from "../../http.ts"; import type { ApiCtx } from "../route.ts"; import { audit, authorizeAdmin, orgScope } from "../shared.ts"; -const VALIDATION_REQUESTS: Record Record }> = - { - anthropic: { - url: "https://api.anthropic.com/v1/models", - headers: (apiKey) => ({ "x-api-key": apiKey, "anthropic-version": "2023-06-01" }), - }, - openai: { - url: "https://api.openai.com/v1/models", - headers: (apiKey) => ({ authorization: `Bearer ${apiKey}` }), - }, - openrouter: { - url: "https://openrouter.ai/api/v1/key", - headers: (apiKey) => ({ authorization: `Bearer ${apiKey}` }), - }, - }; +const VALIDATION_REQUESTS: Record< + ModelProvider, + { baseUrl: string; path: string; headers: (apiKey: string) => Record } +> = { + anthropic: { + baseUrl: "https://api.anthropic.com", + path: "/v1/models", + headers: (apiKey) => ({ "x-api-key": apiKey, "anthropic-version": "2023-06-01" }), + }, + openai: { + baseUrl: "https://api.openai.com/v1", + path: "/models", + headers: (apiKey) => ({ authorization: `Bearer ${apiKey}` }), + }, + openrouter: { + baseUrl: "https://openrouter.ai/api/v1", + path: "/key", + headers: (apiKey) => ({ authorization: `Bearer ${apiKey}` }), + }, +}; + +function validationUrl(provider: ModelProvider): string { + const request = VALIDATION_REQUESTS[provider]; + return `${providerBaseUrl(provider) ?? request.baseUrl}${request.path}`; +} async function actor(ctx: ApiCtx) { const scope = orgScope(ctx.deps); @@ -26,10 +36,9 @@ async function actor(ctx: ApiCtx) { } async function validate(ctx: ApiCtx, provider: ModelProvider, apiKey: string): Promise { - const request = VALIDATION_REQUESTS[provider]; try { - const response = await (ctx.deps.modelCredentialFetch ?? fetch)(request.url, { - headers: request.headers(apiKey), + const response = await (ctx.deps.modelCredentialFetch ?? fetch)(validationUrl(provider), { + headers: VALIDATION_REQUESTS[provider].headers(apiKey), signal: AbortSignal.timeout(5_000), }); return response.ok; diff --git a/src/config.ts b/src/config.ts index 534134b09..6d7eacba3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -49,6 +49,7 @@ export interface Config { anthropicApiKey?: string; openaiApiKey?: string; openrouterApiKey?: string; + providerBaseUrls: Partial>; modelProvider?: ModelProvider; piCaptureRequests: boolean; piSystemCacheSplit: boolean; @@ -375,6 +376,21 @@ export function orgId(): string { return process.env.ORG_ID ?? DEFAULT_ORG_ID; } +const PROVIDER_BASE_URL_ENV: Record = { + anthropic: "ANTHROPIC_BASE_URL", + openai: "OPENAI_BASE_URL", + openrouter: "OPENROUTER_BASE_URL", +}; + +export function providerBaseUrlsFromEnv(env: NodeJS.ProcessEnv): Partial> { + const urls: Partial> = {}; + for (const provider of MODEL_PROVIDERS) { + const configured = env[PROVIDER_BASE_URL_ENV[provider]]?.trim().replace(/\/+$/, ""); + if (configured) urls[provider] = configured; + } + return urls; +} + export function orgScope(): string { return `org:${orgId()}`; } @@ -727,6 +743,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { ...(env.ANTHROPIC_API_KEY ? { anthropicApiKey: env.ANTHROPIC_API_KEY } : {}), ...(env.OPENAI_API_KEY ? { openaiApiKey: env.OPENAI_API_KEY } : {}), ...(env.OPENROUTER_API_KEY ? { openrouterApiKey: env.OPENROUTER_API_KEY } : {}), + providerBaseUrls: providerBaseUrlsFromEnv(env), ...(modelProvider ? { modelProvider } : {}), ...(env.ADMIN_GRANTS ? { adminGrants: env.ADMIN_GRANTS } : {}), piCaptureRequests: boolEnvStrict("PI_CAPTURE_REQUESTS", env.PI_CAPTURE_REQUESTS) ?? true, diff --git a/src/model/pi-models.ts b/src/model/pi-models.ts index a57462ef8..c14ac34e8 100644 --- a/src/model/pi-models.ts +++ b/src/model/pi-models.ts @@ -20,6 +20,16 @@ export function isHarnessId(value: unknown): value is HarnessId { return typeof value === "string" && (HARNESS_IDS as readonly string[]).includes(value); } +let configuredBaseUrls: Partial> = {}; + +export function setProviderBaseUrls(urls: Partial>): void { + configuredBaseUrls = { ...urls }; +} + +export function providerBaseUrl(provider: ModelProvider): string | undefined { + return configuredBaseUrls[provider]; +} + type PiModel = Model; interface ModelEntry { @@ -103,10 +113,15 @@ export const SELECTABLE_BASE_MODELS: ReadonlyArray<{ id: string; name: string }> (m) => m.base, ).map((m) => ({ id: m.id, name: m.name })); +function atConfiguredBaseUrl(model: PiModel): PiModel { + const baseUrl = configuredBaseUrls[model.provider as ModelProvider]; + return baseUrl && baseUrl !== model.baseUrl ? { ...model, baseUrl } : model; +} + function builtinModel(id: string): PiModel | undefined { for (const provider of MODEL_PROVIDERS) { const m = getModel(provider, id); - if (m) return m; + if (m) return atConfiguredBaseUrl(m); } return undefined; } diff --git a/src/wiring.ts b/src/wiring.ts index 684365420..1936458fb 100644 --- a/src/wiring.ts +++ b/src/wiring.ts @@ -238,6 +238,7 @@ import { defaultModelForHarness, modelProviderAvailabilityFor, type HarnessId, + setProviderBaseUrls, } from "./model/pi-models.ts"; import { createAdminService, bootAdminGrantSeed, type AdminService } from "./admin/admin-service.ts"; import { createAdminGrantStore, createMapAdminGrantPersistence, type AdminGrant } from "./admin/admin-grant-store.ts"; @@ -369,6 +370,7 @@ export function buildApp( modelCredentialFetch?: typeof fetch; } = {}, ): BuiltApp { + setProviderBaseUrls(config.providerBaseUrls); if (config.databaseUrl && !config.connectorSecretKey) { throw new Error("CONNECTOR_SECRET_KEY is required with durable storage"); } diff --git a/test/config.test.ts b/test/config.test.ts index ee5ba307f..5712fbf07 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -409,3 +409,15 @@ test("baseModelProviders constrains the base model only when a provider is decla "with no declaration the shipped default stands, so upgrading never moves a deployment's model or its billing", ); }); + +test("provider base urls are read from env and normalized", () => { + assert.deepEqual(loadConfig({}).providerBaseUrls, {}); + assert.deepEqual( + loadConfig({ + ANTHROPIC_BASE_URL: "https://gateway.example.com/", + OPENAI_BASE_URL: " https://oai.example.com/v1// ", + }).providerBaseUrls, + { anthropic: "https://gateway.example.com", openai: "https://oai.example.com/v1" }, + ); + assert.deepEqual(loadConfig({ OPENROUTER_BASE_URL: " " }).providerBaseUrls, {}); +}); diff --git a/test/pi-models.test.ts b/test/pi-models.test.ts index 5b03e44bc..6492604d1 100644 --- a/test/pi-models.test.ts +++ b/test/pi-models.test.ts @@ -13,6 +13,7 @@ import { MODEL_PROVIDERS, SELECTABLE_BASE_MODELS, contextTokenBudgetForModel, + setProviderBaseUrls, } from "../src/model/pi-models.ts"; test("every selectable base model resolves against the pi-ai registry", () => { @@ -175,3 +176,31 @@ test("context token budget is half of each model's real input room", () => { assert.ok(budget !== undefined && budget >= 60_000, `${m.id} budget ${budget} suspiciously small`); } }); + +test("a configured provider base url replaces the vendor endpoint, including for cloned models", () => { + const gateway = "https://gateway.example.com"; + try { + assert.equal(getRequiredModel("claude-opus-4-8").baseUrl, "https://api.anthropic.com"); + assert.equal(getRequiredModel("claude-opus-5").baseUrl, "https://api.anthropic.com"); + + setProviderBaseUrls({ anthropic: gateway }); + + assert.equal(getRequiredModel("claude-opus-4-8").baseUrl, gateway, "direct builtin follows the gateway"); + assert.equal(getRequiredModel("claude-opus-5").baseUrl, gateway, "cloned model inherits the gateway"); + assert.equal(getRequiredModel("claude-opus-5").id, "claude-opus-5", "the clone keeps its own id"); + assert.ok(getRequiredModel("gpt-5.6-sol").baseUrl?.startsWith("https://api.openai.com"), "openai untouched"); + } finally { + setProviderBaseUrls({}); + } + assert.equal(getRequiredModel("claude-opus-5").baseUrl, "https://api.anthropic.com", "clearing restores the vendor"); +}); + +test("each provider is redirected independently", () => { + try { + setProviderBaseUrls({ openai: "https://oai.example.com/v1" }); + assert.equal(getRequiredModel("gpt-5.6-sol").baseUrl, "https://oai.example.com/v1"); + assert.equal(getRequiredModel("claude-opus-5").baseUrl, "https://api.anthropic.com"); + } finally { + setProviderBaseUrls({}); + } +});