diff --git a/docs/pr-evidence/custom-provider-onboarding-ready.png b/docs/pr-evidence/custom-provider-onboarding-ready.png
new file mode 100644
index 00000000..9700bedb
Binary files /dev/null and b/docs/pr-evidence/custom-provider-onboarding-ready.png differ
diff --git a/docs/pr-evidence/custom-provider-web-flow.md b/docs/pr-evidence/custom-provider-web-flow.md
new file mode 100644
index 00000000..d97adf2b
--- /dev/null
+++ b/docs/pr-evidence/custom-provider-web-flow.md
@@ -0,0 +1,34 @@
+# Custom Provider Web Flow
+
+## Reproduced failures
+
+A production-style local instance exposed four separate breaks in the same flow:
+
+1. `PUT /v1/admin/custom-providers/:id` returned `404` because the production entrypoint did not pass the custom-provider dependencies to the server.
+2. A configured custom-provider key did not satisfy Portal or Admin readiness checks.
+3. A custom model was rejected on Web turns unless the administrator explicitly saved a Web UI model allowlist.
+4. The runtime config advertised the custom model, but the Web picker silently discarded it because the browser lacked its protocol metadata.
+
+## Automated verification
+
+- The production entrypoint is covered by a regression assertion for both required dependencies.
+- Core route tests cover custom-provider readiness, status hygiene, runtime catalog metadata, and a Web turn without an explicit picker allowlist.
+- Web UI tests cover generic OpenAI-compatible custom-model construction and selection.
+- Admin tests cover custom-provider readiness, initial table loading, and readiness refresh after save/remove mutations.
+- The full root suite passed once at 3,818 tests with 0 failures and 135 skipped. A later post-review run hit one unrelated OpenCode startup-output timing failure; that test passed immediately on an isolated rerun (10/10 OpenCode harness tests).
+- Prettier, TypeScript, ESLint, and Oxlint checks pass.
+
+## Live DeepSeek verification
+
+The current branch was loaded through the repository's production-style dev supervisor and tested through Chrome against the saved `deepseek` provider. No credential is present in these artifacts.
+
+- Portal `/` returned the Web application instead of redirecting to onboarding.
+- Admin reported `Ready`, identified `deepseek-v4-flash · custom provider key`, and listed the provider with a write-only key status.
+- The Web picker displayed `DeepSeek V4 Flash` under the Pi harness without requiring an explicit model allowlist.
+- Text turn: `DS_PR_TEXT_OK_20260810`.
+- Tool turn: the model called `execute` with `pwd`; Admin recorded exit code `0`, the next model request contained the tool result `/root/workspace`, and the model returned `DS_PR_TOOL_OK`.
+- Session: `4de03b0b-6ec4-426d-aa72-8fd066cf9246`.
+
+
+
+
diff --git a/docs/pr-evidence/custom-provider-web-turn.png b/docs/pr-evidence/custom-provider-web-turn.png
new file mode 100644
index 00000000..c6c18ba1
Binary files /dev/null and b/docs/pr-evidence/custom-provider-web-turn.png differ
diff --git a/plugins/admin/public/index.html b/plugins/admin/public/index.html
index e7c0367b..714d9e45 100644
--- a/plugins/admin/public/index.html
+++ b/plugins/admin/public/index.html
@@ -6977,6 +6977,7 @@
Confirm governance change
let onboardingModels = {};
let onboardingModelStatuses = [];
+ let onboardingCustomProviderStatuses = [];
function onboardingBadge(id, text, ready, optional = false) {
const el = $(id);
el.textContent = text;
@@ -6998,6 +6999,15 @@ Confirm governance change
""
);
}
+ function onboardingModelStatus(modelId) {
+ const provider = onboardingProviderForModel(modelId);
+ const managed = onboardingModelStatuses.find((item) => item.provider === provider);
+ if (managed?.configured) return managed;
+ const custom = onboardingCustomProviderStatuses.find(
+ (item) => item.id === provider && !item.disabled && item.hasKey,
+ );
+ return custom ? { provider, configured: true, source: "custom" } : managed;
+ }
function renderOnboardingProviderOptions(preferred) {
const select = $("onboarding-model-provider");
select.textContent = "";
@@ -7043,13 +7053,14 @@ Confirm governance change
return;
}
onboardingModelStatuses = models.data.providers || [];
+ onboardingCustomProviderStatuses = models.data.customProviders || [];
onboardingModels = {};
(models.data.models || []).forEach((model) => {
(onboardingModels[model.provider] ||= []).push(model);
});
const baseModel = config.data.baseModel || config.data.baseModelDefault || "";
const baseProvider = onboardingProviderForModel(baseModel);
- const baseStatus = onboardingModelStatuses.find((item) => item.provider === baseProvider);
+ const baseStatus = onboardingModelStatus(baseModel);
onboardingBadge(
"onboarding-model-badge",
baseStatus?.configured ? "Ready" : "Needs a key",
@@ -7058,7 +7069,13 @@ Confirm governance change
$("onboarding-model-summary").textContent = !baseModel
? "No base model is configured yet — pick a provider and model below."
: baseStatus?.configured
- ? baseModel + " · " + (baseStatus.source === "admin" ? "admin-managed key" : "deployment key")
+ ? baseModel +
+ " · " +
+ (baseStatus.source === "admin"
+ ? "admin-managed key"
+ : baseStatus.source === "custom"
+ ? "custom provider key"
+ : "deployment key")
: baseModel +
" cannot run until its " +
(MODEL_PROVIDER_LABELS[baseProvider] || connectorName(baseProvider)) +
@@ -7087,6 +7104,7 @@ Confirm governance change
$("onboarding-oauth-summary").textContent = configured.length
? configured.map((item) => connectorName(item.provider)).join(", ") + " available in the web UI."
: "Add Google, GitHub, Notion, or other OAuth clients to make those connectors available.";
+ await loadCustomProviders();
viewLoadedAt.onboarding = Date.now();
}
$("onboarding-model-provider").onchange = () => renderOnboardingModelOptions();
@@ -7110,7 +7128,6 @@ Confirm governance change
$("onboarding-model-save").disabled = false;
$("onboarding-model-key").value = "";
await loadOnboarding();
- await loadCustomProviders();
setStatus(
"st-onboarding-model",
selected.ok ? "Key and base model saved." : "Key saved, but the base model could not be changed.",
@@ -7132,7 +7149,6 @@ Confirm governance change
return;
}
await loadOnboarding();
- await loadCustomProviders();
setStatus("st-onboarding-model", "Provider disabled.", "ok");
};
let customProvidersLoaded = [];
@@ -7196,7 +7212,7 @@ Confirm governance change
setStatus("st-custom-provider", removed.data?.message || "Could not remove this provider.", "err");
return;
}
- await loadCustomProviders();
+ await loadOnboarding();
setStatus("st-custom-provider", "Provider removed.", "ok");
};
actions.appendChild(edit);
@@ -7232,7 +7248,7 @@ Confirm governance change
return;
}
$("custom-provider-key").value = "";
- await loadCustomProviders();
+ await loadOnboarding();
setStatus("st-custom-provider", "Provider saved. Its models are now in the picker.", "ok");
};
function openOnboardingTarget(target) {
diff --git a/plugins/admin/test/onboarding-view.test.ts b/plugins/admin/test/onboarding-view.test.ts
index c7fe9b4f..5dc77b6b 100644
--- a/plugins/admin/test/onboarding-view.test.ts
+++ b/plugins/admin/test/onboarding-view.test.ts
@@ -43,3 +43,32 @@ test("?view=onboarding resolves to the onboarding view", () => {
test("unknown views still fall back to the default view", () => {
assert.equal(resolveView("/admin/no-such-view", ""), "history");
});
+
+test("a keyed custom provider makes its base model ready", () => {
+ const src = [
+ slice("function onboardingProviderForModel(modelId) {", "function renderOnboardingProviderOptions"),
+ "onboardingModelStatus('acme-large');",
+ ].join("\n");
+ const context = vm.createContext({
+ onboardingModels: { "acme-gateway": [{ id: "acme-large", name: "Acme Large" }] },
+ onboardingModelStatuses: [],
+ onboardingCustomProviderStatuses: [{ id: "acme-gateway", disabled: false, hasKey: true }],
+ });
+ assert.deepEqual(JSON.parse(JSON.stringify(vm.runInContext(src, context))), {
+ provider: "acme-gateway",
+ configured: true,
+ source: "custom",
+ });
+});
+
+test("onboarding loads the custom provider table on entry", () => {
+ assert.match(
+ slice("async function loadOnboarding() {", '$("onboarding-model-provider").onchange'),
+ /await loadCustomProviders\(\)/,
+ );
+});
+
+test("custom provider mutations refresh onboarding readiness", () => {
+ const handlers = slice("async function loadCustomProviders() {", "function openOnboardingTarget");
+ assert.equal(handlers.match(/await loadOnboarding\(\)/g)?.length, 2);
+});
diff --git a/plugins/web-ui/src/core-bridge.ts b/plugins/web-ui/src/core-bridge.ts
index a7cef34d..ec149677 100644
--- a/plugins/web-ui/src/core-bridge.ts
+++ b/plugins/web-ui/src/core-bridge.ts
@@ -473,7 +473,10 @@ export interface RuntimeConfig {
scopeId: string;
approvedHarnesses: string[];
modelsByHarness: Record;
- modelCatalog: Record;
+ modelCatalog: Record<
+ string,
+ { name: string; provider: string; api?: Api; contextWindow?: number; maxTokens?: number }
+ >;
orgDefault: { harnessId: string; modelId: string; effortLevel?: string; fastMode?: boolean; revision: number };
scopeOverride: {
harnessId: string;
diff --git a/plugins/web-ui/src/model-options.ts b/plugins/web-ui/src/model-options.ts
index 5ee2c799..60d28de4 100644
--- a/plugins/web-ui/src/model-options.ts
+++ b/plugins/web-ui/src/model-options.ts
@@ -16,6 +16,14 @@ interface ModelMeta {
buttonLabel: string;
}
+interface CatalogModel {
+ name: string;
+ provider: string;
+ api?: Api;
+ contextWindow?: number;
+ maxTokens?: number;
+}
+
const MODEL_CATALOG: Record = {
"claude-opus-5": {
label: "Opus 5",
@@ -78,7 +86,7 @@ function buildOption(
id: string,
harnessId = "pi",
qualified = false,
- catalog: Readonly> = {},
+ catalog: Readonly> = {},
): ModelOption | null {
try {
const dynamic = catalog[id];
@@ -101,7 +109,7 @@ function buildOptions(
ids: readonly string[],
harnessId = "pi",
qualified = false,
- catalog: Readonly> = {},
+ catalog: Readonly> = {},
): ModelOption[] {
const seen = new Set();
const out: ModelOption[] = [];
@@ -154,7 +162,7 @@ export function applyPickerModelIds(ids: readonly string[] | null | undefined, b
export function runtimeModelOptions(
approvedHarnesses: readonly string[],
modelsByHarness: Readonly>,
- catalog: Readonly> = {},
+ catalog: Readonly> = {},
): ModelOption[] {
const options = approvedHarnesses.flatMap((harnessId) => {
const configured = buildOptions(modelsByHarness[harnessId] ?? [], harnessId, true, catalog);
@@ -170,7 +178,7 @@ export function applyRuntimeOptions(
approvedHarnesses: readonly string[],
modelsByHarness: Readonly>,
effective: { harnessId: string; modelId: string },
- catalog: Readonly> = {},
+ catalog: Readonly> = {},
): void {
const options = runtimeModelOptions(approvedHarnesses, modelsByHarness, catalog);
const applied = { options, defaultValue: `${effective.harnessId}:${effective.modelId}` };
diff --git a/plugins/web-ui/src/pi-models.ts b/plugins/web-ui/src/pi-models.ts
index ca2128a3..8328206e 100644
--- a/plugins/web-ui/src/pi-models.ts
+++ b/plugins/web-ui/src/pi-models.ts
@@ -13,6 +13,13 @@ const CLONE_TEMPLATES: Readonly;
+type DynamicModel = {
+ name: string;
+ provider: string;
+ api?: Api;
+ contextWindow?: number;
+ maxTokens?: number;
+};
function builtinModel(id: string): PiModel | undefined {
for (const provider of KNOWN_PROVIDERS) {
@@ -22,7 +29,7 @@ function builtinModel(id: string): PiModel | undefined {
return undefined;
}
-export function getBaseModel(id: string, fallback?: { name: string; provider: string }): PiModel {
+export function getBaseModel(id: string, fallback?: DynamicModel): PiModel {
const builtin = builtinModel(id);
if (builtin) return builtin;
const clone = CLONE_TEMPLATES[id];
@@ -34,6 +41,18 @@ export function getBaseModel(id: string, fallback?: { name: string; provider: st
const template = getModel("openrouter", "openrouter/auto" as Parameters[1]) as PiModel | undefined;
if (template) return cloneModel(template, id, fallback.name);
}
+ if (fallback?.api) {
+ const templateId = fallback.api === "anthropic-messages" ? "claude-opus-4-8" : "gpt-5.5";
+ const template = builtinModel(templateId);
+ if (template)
+ return {
+ ...cloneModel(template, id, fallback.name),
+ provider: fallback.provider,
+ api: fallback.api,
+ ...(fallback.contextWindow ? { contextWindow: fallback.contextWindow } : {}),
+ ...(fallback.maxTokens ? { maxTokens: fallback.maxTokens } : {}),
+ };
+ }
throw new Error(`Unsupported model: ${id}`);
}
diff --git a/plugins/web-ui/test/model-options.test.ts b/plugins/web-ui/test/model-options.test.ts
index 329b6453..02fa2580 100644
--- a/plugins/web-ui/test/model-options.test.ts
+++ b/plugins/web-ui/test/model-options.test.ts
@@ -96,6 +96,28 @@ test("runtime options preserve a fetched OpenRouter model as the selected web tu
assert.equal(defaultModelValue(), "pi:anthropic/claude-sonnet-4.5");
});
+test("runtime options render and select a custom provider model", () => {
+ applyRuntimeOptions(
+ null,
+ ["pi"],
+ { pi: ["acme-large"] },
+ { harnessId: "pi", modelId: "acme-large" },
+ {
+ "acme-large": {
+ name: "Acme Large",
+ provider: "acme-gateway",
+ api: "openai-completions",
+ contextWindow: 128000,
+ maxTokens: 8192,
+ },
+ },
+ );
+ const option = getModelOptions()[0]!;
+ assert.equal(option.value, "pi:acme-large");
+ assert.equal(option.model.provider, "acme-gateway");
+ assert.equal(defaultModelValue(), "pi:acme-large");
+});
+
test("runtime options hide retired persisted model ids", () => {
applyRuntimeOptions(
null,
diff --git a/plugins/web-ui/test/pi-models.test.ts b/plugins/web-ui/test/pi-models.test.ts
index 8f586d46..b66891f5 100644
--- a/plugins/web-ui/test/pi-models.test.ts
+++ b/plugins/web-ui/test/pi-models.test.ts
@@ -30,6 +30,22 @@ test("models this pi-ai build lacks are cloned from a template of their own prov
assert.equal(sol.provider, "openai", "an OpenAI model never resolves through an Anthropic template");
});
+test("custom provider models are materialized from protocol metadata", () => {
+ const model = getBaseModel("acme-large", {
+ name: "Acme Large",
+ provider: "acme-gateway",
+ api: "openai-completions",
+ contextWindow: 128000,
+ maxTokens: 8192,
+ });
+ assert.equal(model.id, "acme-large");
+ assert.equal(model.name, "Acme Large");
+ assert.equal(model.provider, "acme-gateway");
+ assert.equal(model.api, "openai-completions");
+ assert.equal(model.contextWindow, 128000);
+ assert.equal(model.maxTokens, 8192);
+});
+
test("fast-mode support is fed from core's runtime config, not a hardcoded client copy", () => {
setFastModeModelIds(null, []);
assert.equal(modelSupportsFastMode(null, "claude-opus-4-8"), false);
diff --git a/src/api/app-turn.ts b/src/api/app-turn.ts
index 17d835f0..093a3ad6 100644
--- a/src/api/app-turn.ts
+++ b/src/api/app-turn.ts
@@ -13,7 +13,7 @@ import {
modelProviderAvailabilityFor,
modelServiceable,
} from "../model/pi-models.ts";
-import { selectableCatalogForHarness, selectableModelCatalog } from "../model/model-catalog.ts";
+import { builtInModelCatalog, selectableCatalogForHarness, selectableModelCatalog } from "../model/model-catalog.ts";
import { resolveRuntimeChoiceDurable } from "../harness/harness-router.ts";
import { errMessage } from "../util/errors.ts";
@@ -170,16 +170,16 @@ export function createTurnMethods(
};
}
const configuredWebuiModels = await deps.config.getWebuiModelsDurable(org);
- let enabledWebuiModels: string[] | null = null;
+ let enabledWebuiModels: string[];
if (configuredWebuiModels?.length) {
enabledWebuiModels = [...new Set([...configuredWebuiModels, orgRuntime.modelId])];
- } else if (providers?.openrouter) {
+ } else {
+ const catalog = providers?.openrouter
+ ? await selectableModelCatalog(deps.modelCredentialFetch)
+ : builtInModelCatalog();
enabledWebuiModels = [
...new Set([
- ...selectableCatalogForHarness(
- await selectableModelCatalog(deps.modelCredentialFetch),
- runtime.harnessId,
- ).map((model) => model.id),
+ ...selectableCatalogForHarness(catalog, runtime.harnessId).map((model) => model.id),
...(orgRuntime.harnessId === runtime.harnessId ? [orgRuntime.modelId] : []),
]),
];
diff --git a/src/api/routes/admin/model-providers.ts b/src/api/routes/admin/model-providers.ts
index a4380655..414597b7 100644
--- a/src/api/routes/admin/model-providers.ts
+++ b/src/api/routes/admin/model-providers.ts
@@ -58,9 +58,15 @@ export async function getModelProviders(ctx: ApiCtx): Promise {
resource: "model-providers",
scopeLabel: orgScope(ctx.deps),
});
+ const [providers, models, customProviders] = await Promise.all([
+ ctx.deps.modelCredentials.statuses(),
+ selectableModelCatalog(ctx.deps.modelCredentialFetch),
+ ctx.deps.customProviders?.statuses() ?? [],
+ ]);
return sendJson(ctx.res, 200, {
- providers: await ctx.deps.modelCredentials.statuses(),
- models: await selectableModelCatalog(ctx.deps.modelCredentialFetch),
+ providers,
+ models,
+ ...(ctx.deps.customProviders ? { customProviders } : {}),
});
}
diff --git a/src/api/routes/surface.ts b/src/api/routes/surface.ts
index 3fbe270a..e88a9b1d 100644
--- a/src/api/routes/surface.ts
+++ b/src/api/routes/surface.ts
@@ -997,14 +997,16 @@ export async function shareArtifact(ctx: ApiCtx): Promise {
async function getSurfaceConfig(ctx: ApiCtx): Promise {
const { res, deps } = ctx;
if (!deps.config) return sendJson(res, 404, { error: "not_found" });
- const [webuiModels, baseModel, externalSlackParticipants, branding] = await Promise.all([
- deps.config.getWebuiModelsDurable(orgScope(deps)),
- deps.config.getBaseModelDurable(orgScope(deps)),
- deps.config.getExternalSlackParticipantsDurable(orgScope(deps)),
- deps.config.getBrandingDurable(orgScope(deps)),
- ]);
+ const [webuiModels, baseModel, externalSlackParticipants, branding, managedKeys, customProviderStatuses] =
+ await Promise.all([
+ deps.config.getWebuiModelsDurable(orgScope(deps)),
+ deps.config.getBaseModelDurable(orgScope(deps)),
+ deps.config.getExternalSlackParticipantsDurable(orgScope(deps)),
+ deps.config.getBrandingDurable(orgScope(deps)),
+ deps.modelCredentials?.availability() ?? null,
+ deps.customProviders?.statuses() ?? [],
+ ]);
const harnessId = deps.harnessId ?? "pi";
- const managedKeys = deps.modelCredentials ? await deps.modelCredentials.availability() : null;
const catalog = managedKeys?.openrouter
? await selectableModelCatalog(deps.modelCredentialFetch)
: builtInModelCatalog();
@@ -1038,7 +1040,13 @@ async function getSurfaceConfig(ctx: ApiCtx): Promise {
webuiModels: configuredPicker.length ? configuredPicker : allowed,
baseModel: resolvedBase,
harnessId,
- ...(managedKeys ? { modelProviderConfigured: Object.values(managedKeys).some(Boolean) } : {}),
+ ...(managedKeys
+ ? {
+ modelProviderConfigured:
+ Object.values(managedKeys).some(Boolean) ||
+ customProviderStatuses.some((provider) => !provider.disabled && provider.hasKey),
+ }
+ : {}),
externalSlackParticipants,
...(Object.keys(resolvedBranding).length ? { branding: resolvedBranding } : {}),
});
@@ -1165,7 +1173,19 @@ async function runtimeConfigBody(ctx: ApiCtx, scope: ScopeId): Promise {
const model = catalog.find((candidate) => candidate.id === id);
- if (model) return [[id, { name: model.name, provider: model.provider }]];
+ if (model)
+ return [
+ [
+ id,
+ {
+ name: model.name,
+ provider: model.provider,
+ ...(model.api ? { api: model.api } : {}),
+ ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}),
+ ...(model.maxTokens ? { maxTokens: model.maxTokens } : {}),
+ },
+ ],
+ ];
const resolved = resolveModel(id);
return resolved ? [[id, { name: resolved.name, provider: resolved.provider }]] : [];
}),
diff --git a/src/index.ts b/src/index.ts
index fbd390c3..c3188894 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -33,6 +33,8 @@ const server = createServer(built.app, {
modelProviders: modelProviderAvailabilityFor(config.harness, providerKeysPresent(config)),
providerKeys: providerKeysPresent(config),
modelCredentials: built.modelCredentials,
+ customProviders: built.customProviders,
+ refreshCustomProviders: built.refreshCustomProviders,
...(config.brandingDefault ? { brandingDefault: config.brandingDefault } : {}),
harnessId: config.harness,
connectorTokens: built.connectorTokens,
diff --git a/src/model/custom-providers.ts b/src/model/custom-providers.ts
index cb2a92c2..f98682fe 100644
--- a/src/model/custom-providers.ts
+++ b/src/model/custom-providers.ts
@@ -147,8 +147,22 @@ export function isCustomModelId(id: string): boolean {
return registry.has(id);
}
-export function customModelCatalog(): Array<{ id: string; name: string; provider: string }> {
- return [...registry.values()].map((m) => ({ id: m.id, name: m.name, provider: m.provider }));
+export function customModelCatalog(): Array<{
+ id: string;
+ name: string;
+ provider: string;
+ api: CustomRuntimeModel["api"];
+ contextWindow: number;
+ maxTokens: number;
+}> {
+ return [...registry.values()].map((m) => ({
+ id: m.id,
+ name: m.name,
+ provider: m.provider,
+ api: m.api,
+ contextWindow: m.contextWindow,
+ maxTokens: m.maxTokens,
+ }));
}
/**
diff --git a/src/model/model-catalog.ts b/src/model/model-catalog.ts
index 77a26440..9927c069 100644
--- a/src/model/model-catalog.ts
+++ b/src/model/model-catalog.ts
@@ -6,6 +6,9 @@ export interface ModelCatalogEntry {
name: string;
/** A built-in provider or the slug of an admin-registered custom provider. */
provider: string;
+ api?: string;
+ contextWindow?: number;
+ maxTokens?: number;
}
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models?supported_parameters=tools&sort=most-popular";
diff --git a/test/custom-provider-route.test.ts b/test/custom-provider-route.test.ts
index 910bdd9e..0971b903 100644
--- a/test/custom-provider-route.test.ts
+++ b/test/custom-provider-route.test.ts
@@ -2,7 +2,7 @@ import "./support/auto-fake-sprites.ts";
import assert from "node:assert/strict";
import type { AddressInfo } from "node:net";
-import { mkdtempSync } from "node:fs";
+import { mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test, afterEach } from "node:test";
@@ -71,6 +71,35 @@ test("custom provider lifecycle: register, list, resolve, delete — admin only,
assert.equal(putBody.status.hasKey, true);
assert.equal(JSON.stringify(putBody).includes("sk-acme-secret"), false);
+ const surface = await fetch(`${srv.base}/v1/surface-config`);
+ assert.equal(surface.status, 200);
+ assert.equal(((await surface.json()) as { modelProviderConfigured?: boolean }).modelProviderConfigured, true);
+
+ const providers = await fetch(`${srv.base}/v1/admin/model-providers`, { headers: ADMIN });
+ assert.equal(providers.status, 200);
+ const providersBody = (await providers.json()) as {
+ customProviders: Array<{ id: string; disabled: boolean; hasKey: boolean }>;
+ };
+ assert.deepEqual(
+ providersBody.customProviders.map(({ id, disabled, hasKey }) => ({ id, disabled, hasKey })),
+ [{ id: "acme-gateway", disabled: false, hasKey: true }],
+ );
+
+ const runtime = await fetch(`${srv.base}/v1/runtime-config?principalId=alice&scopeId=personal%3Aalice`);
+ assert.equal(runtime.status, 200);
+ const runtimeBody = (await runtime.json()) as {
+ modelsByHarness: Record;
+ modelCatalog: Record;
+ };
+ assert.ok(runtimeBody.modelsByHarness.pi?.includes("acme-large"));
+ assert.deepEqual(runtimeBody.modelCatalog["acme-large"], {
+ name: "Acme Large",
+ provider: "acme-gateway",
+ api: "openai-completions",
+ contextWindow: 128000,
+ maxTokens: 8192,
+ });
+
// The runtime registry serves the model immediately.
assert.equal(String(resolveModel("acme-large")?.provider), "acme-gateway");
@@ -97,6 +126,12 @@ test("custom provider lifecycle: register, list, resolve, delete — admin only,
}
});
+test("production entrypoint wires custom provider routes into the core server", () => {
+ const entrypoint = readFileSync(new URL("../src/index.ts", import.meta.url), "utf8");
+ assert.match(entrypoint, /customProviders:\s*built\.customProviders/);
+ assert.match(entrypoint, /refreshCustomProviders:\s*built\.refreshCustomProviders/);
+});
+
test("a rejected key blocks registration unless validate:false", async () => {
const srv = start(async () => new Response(null, { status: 401 }));
try {
diff --git a/test/custom-providers.test.ts b/test/custom-providers.test.ts
index 6621b64d..0dc1c415 100644
--- a/test/custom-providers.test.ts
+++ b/test/custom-providers.test.ts
@@ -74,7 +74,16 @@ test("a registered custom model is serviceable regardless of built-in key availa
test("catalog lists custom models; clearing the registry removes them", () => {
setCustomProviders([GATEWAY]);
- assert.deepEqual(customModelCatalog(), [{ id: "acme-large", name: "Acme Large", provider: "acme-gateway" }]);
+ assert.deepEqual(customModelCatalog(), [
+ {
+ id: "acme-large",
+ name: "Acme Large",
+ provider: "acme-gateway",
+ api: "openai-completions",
+ contextWindow: 200_000,
+ maxTokens: 16_000,
+ },
+ ]);
setCustomProviders([]);
assert.equal(isCustomModelId("acme-large"), false);
assert.equal(resolveModel("acme-large"), undefined);
diff --git a/test/model-credential-route.test.ts b/test/model-credential-route.test.ts
index e127025a..d05f3122 100644
--- a/test/model-credential-route.test.ts
+++ b/test/model-credential-route.test.ts
@@ -401,6 +401,39 @@ test("surface-config reports whether any model provider is configured", async ()
}
});
+test("web turns allow a configured custom model without an explicit picker allowlist", async () => {
+ const srv = start({ harness: "pi" });
+ try {
+ await srv.built.customProviders.upsert(
+ {
+ id: "acme-gateway",
+ name: "Acme Gateway",
+ protocol: "openai",
+ baseUrl: "https://llm.acme.internal/v1",
+ models: [{ id: "acme-large", name: "Acme Large" }],
+ },
+ "sk-acme-secret",
+ "admin-alice@default-org",
+ );
+ await srv.built.refreshCustomProviders();
+ srv.built.config.setRuntimeSelection("org:default-org", { harnessId: "pi", modelId: "acme-large" });
+ await srv.built.config.flushScope("org:default-org");
+
+ const result = await srv.built.app.turn({
+ surface: "web",
+ actor: { externalId: "alice" },
+ conversation: { kind: "dm", threadRef: "web:alice:custom-provider" },
+ text: "hello",
+ harness: "pi",
+ model: "acme-large",
+ async: true,
+ });
+ assert.equal(result.status, "queued");
+ } finally {
+ await srv.close();
+ }
+});
+
test("admin model credentials survive a second app instance on the same durable store", async () => {
const backing = createMemoryMap();
const first = createModelCredentialStore({ backing, keyMaterial: "shared-model-key" });