Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 28 additions & 19 deletions src/api/routes/admin/model-providers.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,44 @@
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<ModelProvider, { url: string; headers: (apiKey: string) => Record<string, string> }> =
{
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<string, string> }
> = {
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);
return authorizeAdmin(ctx, scope);
}

async function validate(ctx: ApiCtx, provider: ModelProvider, apiKey: string): Promise<boolean> {
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;
Expand Down
17 changes: 17 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export interface Config {
anthropicApiKey?: string;
openaiApiKey?: string;
openrouterApiKey?: string;
providerBaseUrls: Partial<Record<ModelProvider, string>>;
modelProvider?: ModelProvider;
piCaptureRequests: boolean;
piSystemCacheSplit: boolean;
Expand Down Expand Up @@ -375,6 +376,21 @@ export function orgId(): string {
return process.env.ORG_ID ?? DEFAULT_ORG_ID;
}

const PROVIDER_BASE_URL_ENV: Record<ModelProvider, string> = {
anthropic: "ANTHROPIC_BASE_URL",
openai: "OPENAI_BASE_URL",
openrouter: "OPENROUTER_BASE_URL",
};

export function providerBaseUrlsFromEnv(env: NodeJS.ProcessEnv): Partial<Record<ModelProvider, string>> {
const urls: Partial<Record<ModelProvider, string>> = {};
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()}`;
}
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 16 additions & 1 deletion src/model/pi-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<ModelProvider, string>> = {};

export function setProviderBaseUrls(urls: Partial<Record<ModelProvider, string>>): void {
configuredBaseUrls = { ...urls };
}

export function providerBaseUrl(provider: ModelProvider): string | undefined {
return configuredBaseUrls[provider];
}

type PiModel = Model<Api>;

interface ModelEntry {
Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions src/wiring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
}
Expand Down
12 changes: 12 additions & 0 deletions test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {});
});
29 changes: 29 additions & 0 deletions test/pi-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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({});
}
});