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`. + +![Admin reports the custom provider as ready](custom-provider-onboarding-ready.png) + +![DeepSeek completes text and tool turns in the Web UI](custom-provider-web-turn.png) 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" });