From f15d7fcd499daaf9efdefe9e7b1f1d2af29584a1 Mon Sep 17 00:00:00 2001 From: Regan Bell Date: Tue, 4 Aug 2026 09:16:02 +0000 Subject: [PATCH 01/13] =?UTF-8?q?model:=20custom=20provider=20endpoints=20?= =?UTF-8?q?=E2=80=94=20base-URL=20overrides=20and=20admin-registered=20pro?= =?UTF-8?q?viders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two layers of custom model endpoints: 1. ANTHROPIC_BASE_URL / OPENAI_BASE_URL / OPENROUTER_BASE_URL are parsed and validated once in config (bad URLs fail at boot), resolved through one provider-endpoints module, and applied uniformly: the pi harness, the claude/codex child harness process envs, and admin key validation (a gateway-issued key validates against the configured endpoint). 2. An org admin can register additional providers that speak the OpenAI or Anthropic wire protocol — base URL, API key, and the model ids to expose — via PUT /v1/admin/custom-providers/:slug. Registered models resolve through the same choke point as built-ins, surface in the catalog and model pickers, and serve on the pi and opencode harnesses. Keys validate against the registered endpoint (skippable for gateways without a models listing), live in the same encrypted store as the built-in provider keys, and are write-only. Built-in model ids and provider slugs are reserved. The admin portal gets a Custom providers card. DeepSeek / Kimi / xAI / a corporate gateway become request bodies, not code. QA hardening: custom keys reach the model runtime for every provider, models.json materialization is cached per registry version, a corrupt custom key degrades only its provider, slashed custom ids win the opencode modelRef lookup, model/name input caps, double-delete 404s, and the picker refreshes when registrations change. Addresses the custom-endpoint asks in #110, #60, #116, #104. --- plugins/admin/public/index.html | 168 ++++++++++++ plugins/admin/src/index.ts | 2 + src/api/app-types.ts | 3 + src/api/deps.ts | 3 + src/api/routes/admin.ts | 4 + src/api/routes/admin/custom-providers.ts | 125 +++++++++ src/api/routes/admin/model-providers.ts | 46 ++-- src/config.ts | 8 +- src/harness/opencode-harness.ts | 47 +++- src/harness/pi-harness.ts | 37 ++- src/model/custom-provider-store.ts | 108 ++++++++ src/model/custom-providers.ts | 182 ++++++++++++ src/model/model-catalog.ts | 18 +- src/model/pi-models.ts | 14 +- src/model/provider-endpoints.ts | 65 +++++ src/wiring.ts | 66 ++++- test/custom-provider-e2e.test.ts | 334 +++++++++++++++++++++++ test/custom-provider-route.test.ts | 149 ++++++++++ test/custom-providers.test.ts | 192 +++++++++++++ test/opencode-harness.test.ts | 45 ++- test/provider-endpoints.test.ts | 81 ++++++ 21 files changed, 1659 insertions(+), 38 deletions(-) create mode 100644 src/api/routes/admin/custom-providers.ts create mode 100644 src/model/custom-provider-store.ts create mode 100644 src/model/custom-providers.ts create mode 100644 src/model/provider-endpoints.ts create mode 100644 test/custom-provider-e2e.test.ts create mode 100644 test/custom-provider-route.test.ts create mode 100644 test/custom-providers.test.ts create mode 100644 test/provider-endpoints.test.ts diff --git a/plugins/admin/public/index.html b/plugins/admin/public/index.html index f019ed3b..e7c0367b 100644 --- a/plugins/admin/public/index.html +++ b/plugins/admin/public/index.html @@ -3905,6 +3905,72 @@

Base model

> +
+
+

Custom providers

+

+ Point QM at any OpenAI- or Anthropic-compatible endpoint — a vendor like DeepSeek, or a gateway like + LiteLLM fronting many models. Keys are validated, stored write-only, and the models join the picker. +

+
+
+ + + + + + + + + + + + +
ProviderProtocolEndpointModelsKey
+ +
+
+ + + + + + + +
+
+ +
+
@@ -7044,6 +7110,7 @@

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.", @@ -7065,8 +7132,109 @@

Confirm governance change

return; } await loadOnboarding(); + await loadCustomProviders(); setStatus("st-onboarding-model", "Provider disabled.", "ok"); }; + let customProvidersLoaded = []; + function parseCustomModels(text) { + return text + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [id, name, contextWindow, maxTokens] = line.split("|").map((part) => part.trim()); + const model = { id }; + if (name) model.name = name; + if (contextWindow) model.contextWindow = Number(contextWindow); + if (maxTokens) model.maxTokens = Number(maxTokens); + return model; + }); + } + async function loadCustomProviders() { + const res = await api("GET", "/api/custom-providers"); + if (!res.ok) return; + customProvidersLoaded = (res.data?.providers || []).filter((provider) => !provider.disabled); + const rows = $("custom-provider-rows"); + rows.textContent = ""; + $("custom-provider-empty").hidden = customProvidersLoaded.length > 0; + customProvidersLoaded.forEach((provider) => { + const tr = document.createElement("tr"); + const cells = [ + provider.name + " (" + provider.id + ")", + provider.protocol === "anthropic" ? "Anthropic" : "OpenAI", + provider.baseUrl, + provider.models.map((model) => model.id).join(", "), + provider.hasKey ? "set (write-only)" : "none", + ]; + cells.forEach((textContent) => { + const td = document.createElement("td"); + td.textContent = textContent; + tr.appendChild(td); + }); + const actions = document.createElement("td"); + const edit = document.createElement("button"); + edit.textContent = "Edit"; + edit.onclick = () => { + $("custom-provider-id").value = provider.id; + $("custom-provider-name").value = provider.name; + $("custom-provider-protocol").value = provider.protocol; + $("custom-provider-url").value = provider.baseUrl; + $("custom-provider-key").value = ""; + $("custom-provider-models").value = provider.models + .map((model) => + [model.id, model.name, model.contextWindow, model.maxTokens].filter((part) => part != null).join(" | "), + ) + .join("\n"); + }; + const remove = document.createElement("button"); + remove.className = "danger"; + remove.textContent = "Remove"; + remove.onclick = async () => { + if (!confirm("Remove " + provider.name + "? Its models leave every model picker.")) return; + const removed = await api("DELETE", "/api/custom-providers/" + encodeURIComponent(provider.id)); + if (!removed.ok) { + setStatus("st-custom-provider", removed.data?.message || "Could not remove this provider.", "err"); + return; + } + await loadCustomProviders(); + setStatus("st-custom-provider", "Provider removed.", "ok"); + }; + actions.appendChild(edit); + actions.appendChild(remove); + tr.appendChild(actions); + rows.appendChild(tr); + }); + } + $("custom-provider-save").onclick = async () => { + const id = $("custom-provider-id").value.trim(); + const name = $("custom-provider-name").value.trim(); + const baseUrl = $("custom-provider-url").value.trim(); + const models = parseCustomModels($("custom-provider-models").value); + if (!id || !name || !baseUrl || models.length === 0) { + setStatus("st-custom-provider", "Provider id, name, base URL, and at least one model are required.", "err"); + return; + } + const apiKey = $("custom-provider-key").value.trim(); + const body = { + name, + protocol: $("custom-provider-protocol").value, + baseUrl, + models, + ...(apiKey ? { apiKey } : {}), + ...($("custom-provider-validate").checked ? {} : { validate: false }), + }; + $("custom-provider-save").disabled = true; + setStatus("st-custom-provider", "Saving…", "saving", true); + const saved = await api("PUT", "/api/custom-providers/" + encodeURIComponent(id), body); + $("custom-provider-save").disabled = false; + if (!saved.ok) { + setStatus("st-custom-provider", saved.data?.message || "Could not save this provider.", "err", true); + return; + } + $("custom-provider-key").value = ""; + await loadCustomProviders(); + setStatus("st-custom-provider", "Provider saved. Its models are now in the picker.", "ok"); + }; function openOnboardingTarget(target) { setView("connectors"); setTimeout( diff --git a/plugins/admin/src/index.ts b/plugins/admin/src/index.ts index f4902bd3..25106783 100644 --- a/plugins/admin/src/index.ts +++ b/plugins/admin/src/index.ts @@ -267,6 +267,7 @@ const WRITES = new Map([ ["users", ["PUT", "POST"]], ["slack-installation", ["PUT", "DELETE"]], ["model-providers", ["PUT", "DELETE"]], + ["custom-providers", ["PUT", "DELETE"]], ]); const READS = [ @@ -291,6 +292,7 @@ const READS = [ "ack-emoji-picks", "slack-installation", "model-providers", + "custom-providers", ]; const server = createServer((req, res) => { diff --git a/src/api/app-types.ts b/src/api/app-types.ts index cdb80154..3da29178 100644 --- a/src/api/app-types.ts +++ b/src/api/app-types.ts @@ -27,6 +27,7 @@ import type { RunSignal, RunSignalStore } from "../runs/run-signal-store.ts"; import type { TaskStore, TaskStatus } from "../tasks/task-store.ts"; import type { ModelGateway } from "../model/model-gateway.ts"; import type { ModelCredentialStore } from "../model/model-credential-store.ts"; +import type { CustomProviderStore } from "../model/custom-provider-store.ts"; import type { AclStore } from "../acl/acl-store.ts"; import type { SkillStore, Skill, SkillResolution } from "../skills/skill-store.ts"; import type { SkillPack, NewSkillPack, SkillPackStore } from "../skills/skill-pack-store.ts"; @@ -467,6 +468,8 @@ export interface AppDeps { modelGateway: ModelGateway; modelCredentials?: ModelCredentialStore; modelCredentialFetch?: typeof fetch; + customProviders?: CustomProviderStore; + refreshCustomProviders?: () => Promise; acl: AclStore; admin?: AdminService; skills: SkillStore; diff --git a/src/api/deps.ts b/src/api/deps.ts index b31743f0..bf77b25d 100644 --- a/src/api/deps.ts +++ b/src/api/deps.ts @@ -1,5 +1,6 @@ import type { ModelProviderAvailability } from "../model/pi-models.ts"; import type { ModelCredentialStore } from "../model/model-credential-store.ts"; +import type { CustomProviderStore } from "../model/custom-provider-store.ts"; import type { ReplayDedupe } from "../auth/replay-dedupe.ts"; import type { FetchLike, OAuthClientResolver } from "../connectors/oauth.ts"; import type { ConsentLinkStore } from "../connectors/consent-link.ts"; @@ -81,6 +82,8 @@ export interface ServerDeps { providerKeys?: ModelProviderAvailability; modelCredentials?: ModelCredentialStore; modelCredentialFetch?: typeof fetch; + customProviders?: CustomProviderStore; + refreshCustomProviders?: () => Promise; brandingDefault?: { accent?: string; mark?: string; selfLabel?: string }; harnessId?: string; admin?: AdminService; diff --git a/src/api/routes/admin.ts b/src/api/routes/admin.ts index 0813b471..4bf8c7d2 100644 --- a/src/api/routes/admin.ts +++ b/src/api/routes/admin.ts @@ -33,6 +33,7 @@ import { } from "./admin/slack-mirror.ts"; import { deleteSlackInstallation, getSlackInstallation, putSlackInstallation } from "./admin/slack-installation.ts"; import { deleteModelProvider, getModelProviders, putModelProvider } from "./admin/model-providers.ts"; +import { deleteCustomProvider, getCustomProviders, putCustomProvider } from "./admin/custom-providers.ts"; const timed = (handle: (ctx: ApiCtx) => void | Promise) => @@ -58,6 +59,9 @@ const routes: ReadonlyArray> = [ { method: "GET", path: "/v1/admin/model-providers", auth: "either", handle: getModelProviders }, { method: "PUT", path: "/v1/admin/model-providers/:provider", auth: "either", handle: putModelProvider }, { method: "DELETE", path: "/v1/admin/model-providers/:provider", auth: "either", handle: deleteModelProvider }, + { method: "GET", path: "/v1/admin/custom-providers", auth: "either", handle: getCustomProviders }, + { method: "PUT", path: "/v1/admin/custom-providers/:provider", auth: "either", handle: putCustomProvider }, + { method: "DELETE", path: "/v1/admin/custom-providers/:provider", auth: "either", handle: deleteCustomProvider }, { method: "PUT", path: "/v1/admin/scopes/:scope/:resource", auth: "either", handle: putScopeConfig }, { method: "GET", path: "/v1/admin/whoami", auth: "either", handle: whoami }, { method: "GET", path: "/v1/admin/scopes", auth: "either", handle: listAdminScopes }, diff --git a/src/api/routes/admin/custom-providers.ts b/src/api/routes/admin/custom-providers.ts new file mode 100644 index 00000000..8af109d8 --- /dev/null +++ b/src/api/routes/admin/custom-providers.ts @@ -0,0 +1,125 @@ +import { + CUSTOM_PROVIDER_PROTOCOLS, + type CustomProviderSpec, + type CustomProviderProtocol, +} from "../../../model/custom-providers.ts"; +import { sendJson } from "../../http.ts"; +import type { ApiCtx } from "../route.ts"; +import { audit, authorizeAdmin, orgScope } from "../shared.ts"; + +async function actor(ctx: ApiCtx) { + const scope = orgScope(ctx.deps); + return authorizeAdmin(ctx, scope); +} + +/** + * Key validation against the registered endpoint, protocol-appropriate. + * A gateway may not implement a models listing, so callers can skip + * with {"validate": false} — the registration is admin-only either way. + */ +async function validateKey( + ctx: ApiCtx, + protocol: CustomProviderProtocol, + baseUrl: string, + apiKey: string, +): Promise { + const url = protocol === "anthropic" ? `${baseUrl}/v1/models` : `${baseUrl}/models`; + const headers: Record = + protocol === "anthropic" + ? { "x-api-key": apiKey, "anthropic-version": "2023-06-01" } + : { authorization: `Bearer ${apiKey}` }; + try { + const response = await (ctx.deps.modelCredentialFetch ?? fetch)(url, { + headers, + signal: AbortSignal.timeout(5_000), + }); + return response.ok; + } catch { + return false; + } +} + +export async function getCustomProviders(ctx: ApiCtx): Promise { + const authorized = await actor(ctx); + if (!authorized) return; + if (!ctx.deps.customProviders) return sendJson(ctx.res, 404, { error: "not_found" }); + audit(ctx.deps, { + principalId: authorized.id, + action: "custom-providers.read", + resource: "custom-providers", + scopeLabel: orgScope(ctx.deps), + }); + return sendJson(ctx.res, 200, { providers: await ctx.deps.customProviders.statuses() }); +} + +export async function putCustomProvider(ctx: ApiCtx): Promise { + const authorized = await actor(ctx); + if (!authorized) return; + if (!ctx.deps.customProviders) return sendJson(ctx.res, 404, { error: "not_found" }); + const id = ctx.params.provider; + if (!id) return sendJson(ctx.res, 404, { error: "not_found" }); + const body = ctx.body as { + name?: unknown; + protocol?: unknown; + baseUrl?: unknown; + models?: unknown; + apiKey?: unknown; + validate?: unknown; + }; + if (typeof body.name !== "string" || typeof body.protocol !== "string" || typeof body.baseUrl !== "string") { + return sendJson(ctx.res, 400, { error: "bad_request", message: "name, protocol, and baseUrl are required" }); + } + if (!(CUSTOM_PROVIDER_PROTOCOLS as readonly string[]).includes(body.protocol)) { + return sendJson(ctx.res, 400, { + error: "bad_request", + message: `protocol must be one of ${CUSTOM_PROVIDER_PROTOCOLS.join(", ")}`, + }); + } + const spec: CustomProviderSpec = { + id, + name: body.name, + protocol: body.protocol as CustomProviderProtocol, + baseUrl: body.baseUrl.trim().replace(/\/+$/, ""), + models: Array.isArray(body.models) ? (body.models as CustomProviderSpec["models"]) : [], + }; + const apiKey = typeof body.apiKey === "string" && body.apiKey.trim() ? body.apiKey.trim() : undefined; + const shouldValidate = body.validate !== false && apiKey !== undefined; + if (shouldValidate && !(await validateKey(ctx, spec.protocol, spec.baseUrl, apiKey!))) { + return sendJson(ctx.res, 400, { + error: "invalid_api_key", + message: `${spec.baseUrl} rejected this API key (pass "validate": false to skip for endpoints without a models listing)`, + }); + } + try { + await ctx.deps.customProviders.upsert(spec, apiKey, authorized.id); + } catch (e) { + return sendJson(ctx.res, 400, { error: "bad_request", message: (e as Error).message }); + } + await ctx.deps.refreshCustomProviders?.(); + audit(ctx.deps, { + principalId: authorized.id, + action: "custom-providers.update", + resource: id, + scopeLabel: orgScope(ctx.deps), + }); + const status = (await ctx.deps.customProviders.statuses()).find((item) => item.id === id); + return sendJson(ctx.res, 200, { ok: true, status }); +} + +export async function deleteCustomProvider(ctx: ApiCtx): Promise { + const authorized = await actor(ctx); + if (!authorized) return; + if (!ctx.deps.customProviders) return sendJson(ctx.res, 404, { error: "not_found" }); + const id = ctx.params.provider; + if (!id) return sendJson(ctx.res, 404, { error: "not_found" }); + const removed = await ctx.deps.customProviders.delete(id, authorized.id); + if (!removed) return sendJson(ctx.res, 404, { error: "not_found" }); + await ctx.deps.refreshCustomProviders?.(); + audit(ctx.deps, { + principalId: authorized.id, + action: "custom-providers.delete", + resource: id, + scopeLabel: orgScope(ctx.deps), + }); + return sendJson(ctx.res, 200, { ok: true }); +} diff --git a/src/api/routes/admin/model-providers.ts b/src/api/routes/admin/model-providers.ts index 66a2e098..a4380655 100644 --- a/src/api/routes/admin/model-providers.ts +++ b/src/api/routes/admin/model-providers.ts @@ -1,24 +1,35 @@ import { isModelProvider, type ModelProvider } from "../../../model/pi-models.ts"; +import { providerBaseUrl } from "../../../model/provider-endpoints.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 +37,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 534134b0..16e85a7b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,5 @@ import { existsSync, readdirSync } from "node:fs"; +import { providerBaseUrlsFromEnv, type ProviderBaseUrls } from "./model/provider-endpoints.ts"; import { join, resolve } from "node:path"; import { parseMemoryCaptureMode, @@ -50,6 +51,7 @@ export interface Config { openaiApiKey?: string; openrouterApiKey?: string; modelProvider?: ModelProvider; + providerBaseUrls: ProviderBaseUrls; piCaptureRequests: boolean; piSystemCacheSplit: boolean; sessionTapeMode: "shadow" | "serve"; @@ -636,6 +638,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { const deployProvider: "aws" | "docker" = env.DEPLOY_PROVIDER === "aws" ? "aws" : "docker"; let runStore: "memory" | "postgres" = env.SESSION_STORE === "postgres" ? "postgres" : "memory"; if (env.RUN_STORE === "memory" || env.RUN_STORE === "postgres") runStore = env.RUN_STORE; + const providerBaseUrls = providerBaseUrlsFromEnv(env); const codexProcessEnv = Object.fromEntries( [ "PATH", @@ -650,7 +653,6 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { "NO_PROXY", "ALL_PROXY", "OPENAI_API_KEY", - "OPENAI_BASE_URL", "CODEX_ACCESS_TOKEN", "HOME", "CODEX_HOME", @@ -671,10 +673,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { "ALL_PROXY", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", - "ANTHROPIC_BASE_URL", "CLAUDE_CODE_OAUTH_TOKEN", ].flatMap((name) => (env[name] === undefined ? [] : [[name, env[name]]])), ) as NodeJS.ProcessEnv; + if (providerBaseUrls.openai) codexProcessEnv.OPENAI_BASE_URL = providerBaseUrls.openai; + if (providerBaseUrls.anthropic) claudeProcessEnv.ANTHROPIC_BASE_URL = providerBaseUrls.anthropic; const turnWallClockMs = (numEnvStrict("TURN_WALL_CLOCK_SEC", env.TURN_WALL_CLOCK_SEC) ?? CONFIG_DEFAULTS.turnWallClockSec) * 1000; const runMaxAgeMs = @@ -728,6 +731,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { ...(env.OPENAI_API_KEY ? { openaiApiKey: env.OPENAI_API_KEY } : {}), ...(env.OPENROUTER_API_KEY ? { openrouterApiKey: env.OPENROUTER_API_KEY } : {}), ...(modelProvider ? { modelProvider } : {}), + providerBaseUrls, ...(env.ADMIN_GRANTS ? { adminGrants: env.ADMIN_GRANTS } : {}), piCaptureRequests: boolEnvStrict("PI_CAPTURE_REQUESTS", env.PI_CAPTURE_REQUESTS) ?? true, piSystemCacheSplit: boolEnvStrict("PI_SYSTEM_CACHE_SPLIT", env.PI_SYSTEM_CACHE_SPLIT) ?? false, diff --git a/src/harness/opencode-harness.ts b/src/harness/opencode-harness.ts index 6abfe3b0..df75a8e8 100644 --- a/src/harness/opencode-harness.ts +++ b/src/harness/opencode-harness.ts @@ -8,6 +8,8 @@ import { pathToFileURL } from "node:url"; import { spawn, type ChildProcess } from "node:child_process"; import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk"; import { CONFIG_DEFAULTS, type Config } from "../config.ts"; +import { isCustomModelId } from "../model/custom-providers.ts"; +import type { CustomProviderSpec } from "../model/custom-providers.ts"; import { DEFAULT_AGENT_MODEL_ID, resolveModel } from "../model/pi-models.ts"; import { startSignalPoll, type RunSignalStore } from "../runs/run-signal-store.ts"; import type { LlmCallUsage } from "../sessions/session-store.ts"; @@ -44,6 +46,12 @@ export interface OpenCodeHarnessOptions { binaryPath?: string; startupTimeoutMs?: number; tasks?: TaskStore; + /** + * Admin-registered custom providers, resolved (with keys) when the + * opencode server starts. Registrations made while a server is already + * running apply to the next server start. + */ + resolveCustomProviders?: () => Promise>; } export function openCodeHarnessConfigOptions(config: Config): OpenCodeHarnessOptions { @@ -156,7 +164,14 @@ function sessionToken(secret: string, sessionId: string): string { return createHmac("sha256", secret).update(sessionId).digest("base64url"); } -function modelRef(id: string): { providerID: string; modelID: string } { +export function modelRef(id: string): { providerID: string; modelID: string } { + // A registered custom model wins before slash-splitting: gateway model ids + // routinely contain slashes (e.g. "bedrock/claude-x" behind LiteLLM), and + // those must route to the registered provider, not a phantom "bedrock". + if (isCustomModelId(id)) { + const resolved = resolveModel(id); + if (resolved?.provider) return { providerID: String(resolved.provider), modelID: id }; + } const slash = id.indexOf("/"); if (slash > 0) return { providerID: id.slice(0, slash), modelID: id.slice(slash + 1) }; const resolved = resolveModel(id); @@ -628,6 +643,33 @@ export function createOpenCodeHarness(opts: OpenCodeHarnessOptions = {}): Harnes const bridgeUrl = `http://127.0.0.1:${address.port}`; const pluginUrl = pathToFileURL(join(import.meta.dirname, "opencode-plugin.ts")).href; const enabledTools = Object.fromEntries(definitions.map((item) => [item.name, true])); + const custom = (await opts.resolveCustomProviders?.()) ?? []; + const customProviderConfig = Object.fromEntries( + custom.map(({ spec, apiKey }) => [ + spec.id, + { + npm: spec.protocol === "anthropic" ? "@ai-sdk/anthropic" : "@ai-sdk/openai-compatible", + name: spec.name, + options: { baseURL: spec.baseUrl, ...(apiKey ? { apiKey } : {}) }, + models: Object.fromEntries( + spec.models.map((m) => [ + m.id, + { + name: m.name ?? m.id, + ...(m.contextWindow || m.maxTokens + ? { + limit: { + context: m.contextWindow ?? 128_000, + output: m.maxTokens ?? 8_192, + }, + } + : {}), + }, + ]), + ), + }, + ]), + ); const config = { plugin: [pluginUrl], autoupdate: false, @@ -636,10 +678,11 @@ export function createOpenCodeHarness(opts: OpenCodeHarnessOptions = {}): Harnes lsp: false, formatter: false, instructions: [], - enabled_providers: ["anthropic", "openai"], + enabled_providers: ["anthropic", "openai", ...custom.map(({ spec }) => spec.id)], provider: { anthropic: { options: { apiKey: opts.apiKey ?? "" } }, openai: { options: { apiKey: opts.openaiApiKey ?? "" } }, + ...customProviderConfig, }, tools: { ...enabledTools, diff --git a/src/harness/pi-harness.ts b/src/harness/pi-harness.ts index 133429df..4b272c0c 100644 --- a/src/harness/pi-harness.ts +++ b/src/harness/pi-harness.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -48,6 +48,7 @@ import { modelSupportsFastMode, contextTokenBudgetForModel, } from "../model/pi-models.ts"; +import { customModelsJson, customProvidersVersion } from "../model/custom-providers.ts"; import { defineHarness, type Harness, @@ -975,17 +976,40 @@ export interface ProviderKeys { anthropic?: string; openai?: string; openrouter?: string; + /** Admin-registered custom providers, keyed by provider slug. */ + [provider: string]: string | undefined; +} + +// buildModelRuntime runs per turn; the models.json only changes when the +// custom-provider registry does, so cache the materialized file per registry +// version instead of leaking a temp dir per turn. +let cachedCustomModels: { version: number; path: string | null } | null = null; +function customModelsPath(): string | null { + const version = customProvidersVersion(); + if (cachedCustomModels?.version === version) return cachedCustomModels.path; + const custom = customModelsJson(); + let path: string | null = null; + if (custom) { + path = join(mkdtempSync(join(tmpdir(), "pi-custom-models-")), "models.json"); + writeFileSync(path, JSON.stringify(custom)); + } + cachedCustomModels = { version, path }; + return path; } async function buildModelRuntime(keys: ProviderKeys | string): Promise { const k: ProviderKeys = typeof keys === "string" ? { anthropic: keys } : keys; + // Custom providers must exist in the runtime's own registry — a runtime + // API key alone is invisible to its availability checks. models.json is + // the sanctioned vocabulary, so materialize one when any are registered. + const modelsPath = customModelsPath(); const runtime = await ModelRuntime.create({ credentials: new InMemoryCredentialStore(), - modelsPath: null, + modelsPath, }); - if (k.anthropic) await runtime.setRuntimeApiKey("anthropic", k.anthropic, { allowNetwork: false }); - if (k.openai) await runtime.setRuntimeApiKey("openai", k.openai, { allowNetwork: false }); - if (k.openrouter) await runtime.setRuntimeApiKey("openrouter", k.openrouter, { allowNetwork: false }); + for (const [provider, apiKey] of Object.entries(k)) { + if (apiKey) await runtime.setRuntimeApiKey(provider, apiKey, { allowNetwork: false }); + } return runtime; } @@ -1208,8 +1232,7 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { ...configuredProviderKeys, ...(await opts?.resolveProviderKeys?.()), }); - const keyForModel = (keys: ProviderKeys, model: Model): string | undefined => - keys[model.provider as keyof ProviderKeys]; + const keyForModel = (keys: ProviderKeys, model: Model): string | undefined => keys[String(model.provider)]; const captureRequests = opts?.captureRequests ?? true; const systemCacheSplit = opts?.systemCacheSplit ?? false; const scratchExec = opts?.scratchExec ?? false; diff --git a/src/model/custom-provider-store.ts b/src/model/custom-provider-store.ts new file mode 100644 index 00000000..28528727 --- /dev/null +++ b/src/model/custom-provider-store.ts @@ -0,0 +1,108 @@ +/** + * Durable, encrypted storage for custom model providers. + * + * Mirrors model-credential-store: specs live in a DurableMap, API keys + * are encrypted at rest with a key derived from the connector secret, + * and the store never hands the plaintext key to anything but the + * per-call resolver. + */ + +import { decryptSecret, deriveConnectorKey, encryptSecret } from "../connectors/connector-client-store.ts"; +import type { DurableMap } from "../persistence/durable-map.ts"; +import { validateCustomProviderSpec, type CustomProviderSpec } from "./custom-providers.ts"; + +export interface StoredCustomProvider extends CustomProviderSpec { + apiKeyEnc?: string; + disabled?: boolean; + updatedAt: number; + updatedBy: string; +} + +interface CustomProviderStatus extends CustomProviderSpec { + disabled: boolean; + hasKey: boolean; + updatedAt: number; + updatedBy: string; +} + +export interface CustomProviderStore { + /** Enabled specs only — what the runtime registry should serve. */ + enabled(): Promise; + /** Everything, for the admin surface (no secrets). */ + statuses(): Promise; + /** Plaintext key for one provider, or null when absent/disabled. */ + resolveKey(id: string): Promise; + upsert(spec: CustomProviderSpec, apiKey: string | undefined, updatedBy: string): Promise; + delete(id: string, updatedBy: string): Promise; +} + +function strip(saved: StoredCustomProvider): CustomProviderSpec { + return { + id: saved.id, + name: saved.name, + protocol: saved.protocol, + baseUrl: saved.baseUrl, + models: saved.models, + }; +} + +export function createCustomProviderStore(input: { + backing: DurableMap; + keyMaterial: string | Buffer; +}): CustomProviderStore { + const key = deriveConnectorKey(input.keyMaterial, "custom-model-providers"); + + return { + async enabled() { + const all = await input.backing.all(); + return all.filter((p) => !p.disabled).map(strip); + }, + + async statuses() { + const all = await input.backing.all(); + return all + .map((p) => ({ + ...strip(p), + disabled: p.disabled ?? false, + hasKey: Boolean(p.apiKeyEnc), + updatedAt: p.updatedAt, + updatedBy: p.updatedBy, + })) + .sort((a, b) => a.id.localeCompare(b.id)); + }, + + async resolveKey(id) { + const saved = await input.backing.get(id); + if (!saved || saved.disabled || !saved.apiKeyEnc) return null; + return decryptSecret(saved.apiKeyEnc, key); + }, + + async upsert(spec, apiKey, updatedBy) { + validateCustomProviderSpec(spec); + const actor = updatedBy.trim(); + if (!actor) throw new Error("updatedBy is required"); + const existing = await input.backing.get(spec.id); + const trimmedKey = apiKey?.trim(); + const apiKeyEnc = trimmedKey ? encryptSecret(trimmedKey, key) : existing?.apiKeyEnc; + await input.backing.put(spec.id, { + ...spec, + ...(apiKeyEnc ? { apiKeyEnc } : {}), + disabled: false, + updatedAt: Date.now(), + updatedBy: actor, + }); + }, + + async delete(id, updatedBy) { + const existing = await input.backing.get(id); + if (!existing || existing.disabled) return false; + await input.backing.put(id, { + ...existing, + disabled: true, + updatedAt: Date.now(), + updatedBy, + }); + return true; + }, + }; +} diff --git a/src/model/custom-providers.ts b/src/model/custom-providers.ts new file mode 100644 index 00000000..cb2a92c2 --- /dev/null +++ b/src/model/custom-providers.ts @@ -0,0 +1,182 @@ +/** + * Custom model providers. + * + * An org admin can register additional model providers that speak one of + * the two wire protocols we already run — OpenAI-compatible or + * Anthropic-compatible — by giving a base URL, an API key, and the model + * ids to expose. Registered models resolve like built-ins (the pi + * harness reaches them through the same request path), surface in the + * catalog, and are gated to harnesses that route through pi-ai. + * + * Secrets never live here: this module holds the runtime registry + * (everything except the key). Keys stay in the encrypted store and are + * resolved per-call by wiring alongside the built-in provider keys. + */ + +import { parseProviderBaseUrl, PROVIDER_IDS } from "./provider-endpoints.ts"; + +export const CUSTOM_PROVIDER_PROTOCOLS = ["openai", "anthropic"] as const; +export type CustomProviderProtocol = (typeof CUSTOM_PROVIDER_PROTOCOLS)[number]; + +interface CustomModelSpec { + id: string; + name?: string; + contextWindow?: number; + maxTokens?: number; + /** USD per million input tokens. Defaults to 0 (unknown / not metered). */ + input?: number; + /** USD per million output tokens. Defaults to 0. */ + output?: number; +} + +export interface CustomProviderSpec { + /** Slug: lowercase, digits, hyphens; also the model's `provider` value. */ + id: string; + name: string; + protocol: CustomProviderProtocol; + baseUrl: string; + models: CustomModelSpec[]; +} + +const SLUG_RE = /^[a-z][a-z0-9-]{1,31}$/; +const RESERVED = new Set([...PROVIDER_IDS, "mock"]); + +export function validateCustomProviderSpec(spec: CustomProviderSpec): void { + if (!SLUG_RE.test(spec.id)) { + throw new Error(`provider id must match ${SLUG_RE} (lowercase slug), got "${spec.id}"`); + } + if (RESERVED.has(spec.id)) throw new Error(`provider id "${spec.id}" is reserved`); + if (!spec.name.trim()) throw new Error("provider name is required"); + if (spec.name.length > 100) throw new Error("provider name must be 100 chars or fewer"); + if (!CUSTOM_PROVIDER_PROTOCOLS.includes(spec.protocol)) { + throw new Error(`protocol must be one of ${CUSTOM_PROVIDER_PROTOCOLS.join(", ")}`); + } + parseProviderBaseUrl(`custom provider ${spec.id} baseUrl`, spec.baseUrl); + if (!Array.isArray(spec.models) || spec.models.length === 0) { + throw new Error("at least one model is required"); + } + if (spec.models.length > 200) throw new Error("at most 200 models per provider"); + const seen = new Set(); + for (const m of spec.models) { + if (!m.id?.trim() || m.id.length > 200) throw new Error("every model needs an id (<=200 chars)"); + if (m.name !== undefined && (typeof m.name !== "string" || m.name.length > 200)) + throw new Error(`model "${m.id}": name must be a string of 200 chars or fewer`); + if (seen.has(m.id)) throw new Error(`duplicate model id "${m.id}"`); + seen.add(m.id); + for (const [field, v] of [ + ["contextWindow", m.contextWindow], + ["maxTokens", m.maxTokens], + ["input", m.input], + ["output", m.output], + ] as const) { + if (v !== undefined && (typeof v !== "number" || !Number.isFinite(v) || v < 0)) { + throw new Error(`model "${m.id}": ${field} must be a non-negative number`); + } + } + } +} + +/** + * The wire-level shape pi-ai expects. We construct these without + * importing pi-ai so this module stays dependency-free; pi-models casts + * at its boundary, the same way it treats getBuiltinModel. + */ +export interface CustomRuntimeModel { + id: string; + name: string; + provider: string; + api: "openai-completions" | "anthropic-messages"; + baseUrl: string; + reasoning: boolean; + input: ("text" | "image")[]; + cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; + contextWindow: number; + maxTokens: number; +} + +const DEFAULT_CONTEXT_WINDOW = 128_000; +const DEFAULT_MAX_TOKENS = 8_192; + +function toRuntimeModel(provider: CustomProviderSpec, m: CustomModelSpec): CustomRuntimeModel { + return { + id: m.id, + name: m.name?.trim() || m.id, + provider: provider.id, + api: provider.protocol === "anthropic" ? "anthropic-messages" : "openai-completions", + baseUrl: provider.baseUrl, + reasoning: false, + input: ["text"], + cost: { input: m.input ?? 0, output: m.output ?? 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: m.contextWindow ?? DEFAULT_CONTEXT_WINDOW, + maxTokens: m.maxTokens ?? DEFAULT_MAX_TOKENS, + }; +} + +let registry = new Map(); +let providers: CustomProviderSpec[] = []; +let version = 0; + +/** + * Called by wiring at boot and again after every admin write, with the + * full current set of enabled providers. Last write wins; built-in model + * ids shadow custom ones at resolution, so a collision can't hijack a + * built-in. + */ +export function setCustomProviders(specs: CustomProviderSpec[]): void { + const next = new Map(); + for (const spec of specs) { + for (const m of spec.models) { + next.set(m.id, toRuntimeModel(spec, m)); + } + } + registry = next; + providers = specs.map((s) => ({ ...s, models: [...s.models] })); + version += 1; +} + +/** Bumps on every registry change — lets callers cache derived artifacts. */ +export function customProvidersVersion(): number { + return version; +} + +export function resolveCustomModel(id: string): CustomRuntimeModel | undefined { + return registry.get(id); +} + +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 })); +} + +/** + * The models.json fragment pi-coding-agent understands. Materialized to a + * temp file whenever the pi harness builds a model runtime, so the + * runtime's own provider registry knows each custom provider natively — + * a runtime API key alone is not enough (availability checks only cover + * providers the ModelsStore knows). + */ +export function customModelsJson(): { providers: Record } | undefined { + if (providers.length === 0) return undefined; + return { + providers: Object.fromEntries( + providers.map((spec) => [ + spec.id, + { + name: spec.name, + baseUrl: spec.baseUrl, + api: spec.protocol === "anthropic" ? "anthropic-messages" : "openai-completions", + models: spec.models.map((m) => ({ + id: m.id, + name: m.name ?? m.id, + contextWindow: m.contextWindow ?? 128_000, + maxTokens: m.maxTokens ?? 8_192, + cost: { input: m.input ?? 0, output: m.output ?? 0, cacheRead: 0, cacheWrite: 0 }, + })), + }, + ]), + ), + }; +} diff --git a/src/model/model-catalog.ts b/src/model/model-catalog.ts index 812d69c9..77a26440 100644 --- a/src/model/model-catalog.ts +++ b/src/model/model-catalog.ts @@ -1,9 +1,11 @@ import { modelSupportedByHarness, resolveModel, SELECTABLE_BASE_MODELS } from "./pi-models.ts"; +import { customModelCatalog, customProvidersVersion } from "./custom-providers.ts"; export interface ModelCatalogEntry { id: string; name: string; - provider: "anthropic" | "openai" | "openrouter"; + /** A built-in provider or the slug of an admin-registered custom provider. */ + provider: string; } const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models?supported_parameters=tools&sort=most-popular"; @@ -14,6 +16,7 @@ const CACHE_TTL_MS = 5 * 60_000; const FAILURE_TTL_MS = 30_000; interface CacheEntry { + customVersion?: number; expiresAt: number; models: ModelCatalogEntry[]; inFlight?: Promise; @@ -22,12 +25,14 @@ interface CacheEntry { const cache = new WeakMap(); export function builtInModelCatalog(): ModelCatalogEntry[] { - return SELECTABLE_BASE_MODELS.flatMap((model) => { + const builtIns = SELECTABLE_BASE_MODELS.flatMap((model) => { const provider = resolveModel(model.id)?.provider; return provider === "anthropic" || provider === "openai" || provider === "openrouter" - ? [{ ...model, provider }] + ? [{ ...model, provider: provider as string }] : []; }); + const known = new Set(builtIns.map((model) => model.id)); + return [...builtIns, ...customModelCatalog().filter((model) => !known.has(model.id))]; } async function boundedJson(response: Response): Promise { @@ -77,7 +82,10 @@ async function fetchOpenRouterModels(fetcher: typeof fetch): Promise { const now = Date.now(); const existing = cache.get(fetcher); - if (existing && existing.expiresAt > now) return existing.models; + // A registry change (admin registered/removed a custom provider) must be + // visible in the next picker load, not after the TTL runs out. + if (existing && existing.expiresAt > now && existing.customVersion === customProvidersVersion()) + return existing.models; if (existing?.inFlight) return existing.inFlight; const entry = existing ?? { expiresAt: 0, models: [] }; entry.inFlight = fetchOpenRouterModels(fetcher) @@ -86,11 +94,13 @@ export async function selectableModelCatalog(fetcher: typeof fetch = fetch): Pro const known = new Set(models.map((model) => model.id)); entry.models = [...models, ...dynamic.filter((model) => !known.has(model.id))]; entry.expiresAt = Date.now() + CACHE_TTL_MS; + entry.customVersion = customProvidersVersion(); return entry.models; }) .catch(() => { entry.models = entry.models.length ? entry.models : builtInModelCatalog(); entry.expiresAt = Date.now() + FAILURE_TTL_MS; + entry.customVersion = customProvidersVersion(); return entry.models; }) .finally(() => { diff --git a/src/model/pi-models.ts b/src/model/pi-models.ts index a57462ef..7d193eec 100644 --- a/src/model/pi-models.ts +++ b/src/model/pi-models.ts @@ -1,5 +1,7 @@ import { getBuiltinModel } from "@earendil-works/pi-ai/providers/all"; import type { Api, Model } from "@earendil-works/pi-ai"; +import { providerBaseUrl } from "./provider-endpoints.ts"; +import { isCustomModelId, resolveCustomModel } from "./custom-providers.ts"; const getModel = getBuiltinModel as unknown as (provider: string, id: string) => Model | undefined; @@ -106,7 +108,12 @@ export const SELECTABLE_BASE_MODELS: ReadonlyArray<{ id: string; name: string }> function builtinModel(id: string): PiModel | undefined { for (const provider of MODEL_PROVIDERS) { const m = getModel(provider, id); - if (m) return m; + if (!m) continue; + // Endpoint overrides apply here, at the single choke point every + // resolution passes through — including clones, whose template is + // spread by cloneModel, so an overridden template covers its clones. + const override = providerBaseUrl(String(m.provider ?? provider)); + return override ? { ...m, baseUrl: override } : m; } return undefined; } @@ -142,7 +149,7 @@ export function resolveModel(id: string): PiModel | undefined { }) : undefined; } - return builtinModel(id); + return builtinModel(id) ?? (resolveCustomModel(id) as unknown as PiModel | undefined); } export function auxiliaryModelForProvider(provider: string): string | undefined { @@ -168,6 +175,8 @@ export function contextTokenBudgetForModel(id: string): number | undefined { export function modelSupportedByHarness(id: string | undefined, harness: string): boolean { if (!id) return false; + if (isCustomModelId(id) && !REGISTRY_BY_ID.has(id)) + return harness === "pi" || harness === "opencode" || harness === "mock"; if (harness === "pi" || harness === "opencode" || harness === "mock") return Boolean(resolveModel(id)); const provider = resolveModel(id)?.provider; if (harness === "claude") return provider === "anthropic" || /^claude-/i.test(id); @@ -198,6 +207,7 @@ export interface ModelProviderAvailability { export function modelServiceable(id: string, providers: ModelProviderAvailability): boolean { const provider = resolveModel(id)?.provider; if (!provider) return false; + if (isCustomModelId(id) && !REGISTRY_BY_ID.has(id)) return true; if (provider === "openai") return providers.openai; if (provider === "anthropic") return providers.anthropic; if (provider === "openrouter") return providers.openrouter; diff --git a/src/model/provider-endpoints.ts b/src/model/provider-endpoints.ts new file mode 100644 index 00000000..d5389cd6 --- /dev/null +++ b/src/model/provider-endpoints.ts @@ -0,0 +1,65 @@ +/** + * Provider endpoint overrides. + * + * One place decides which base URL each model provider is reached at. + * Config parses and validates the `*_BASE_URL` environment variables, + * wiring injects them here, and everything that issues a request — the + * in-process pi harness, the child-process harness environments, and + * admin API-key validation — resolves through this module. No harness + * reads its own override. + */ + +export const PROVIDER_IDS = ["anthropic", "openai", "openrouter"] as const; +type ProviderId = (typeof PROVIDER_IDS)[number]; + +const PROVIDER_BASE_URL_ENV: Record = { + anthropic: "ANTHROPIC_BASE_URL", + openai: "OPENAI_BASE_URL", + openrouter: "OPENROUTER_BASE_URL", +}; + +export type ProviderBaseUrls = Partial>; + +/** + * Validate and normalize a provider base URL. Returns the normalized + * origin+path with trailing slashes removed. Throws on anything that + * would silently misroute requests: non-HTTP(S) schemes, embedded + * credentials, query strings, and fragments. + */ +export function parseProviderBaseUrl(envName: string, value: string): string { + const trimmed = value.trim().replace(/\/+$/, ""); + let url: URL; + try { + url = new URL(trimmed); + } catch { + throw new Error(`${envName} is not a valid URL: ${value}`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") + throw new Error(`${envName} must be an http(s) URL, got ${url.protocol}//`); + if (url.username || url.password) throw new Error(`${envName} must not contain credentials`); + if (url.search) throw new Error(`${envName} must not contain a query string`); + if (url.hash) throw new Error(`${envName} must not contain a fragment`); + return trimmed; +} + +export function providerBaseUrlsFromEnv(env: NodeJS.ProcessEnv): ProviderBaseUrls { + const urls: ProviderBaseUrls = {}; + for (const provider of PROVIDER_IDS) { + const envName = PROVIDER_BASE_URL_ENV[provider]; + const raw = env[envName]; + if (raw?.trim()) urls[provider] = parseProviderBaseUrl(envName, raw); + } + return urls; +} + +let configured: ProviderBaseUrls = {}; + +/** Called once by wiring with the config-parsed overrides. */ +export function setProviderBaseUrls(urls: ProviderBaseUrls): void { + configured = { ...urls }; +} + +/** The override for a provider, if one is configured. */ +export function providerBaseUrl(provider: string): string | undefined { + return (PROVIDER_IDS as readonly string[]).includes(provider) ? configured[provider as ProviderId] : undefined; +} diff --git a/src/wiring.ts b/src/wiring.ts index f2a93ade..83540e3d 100644 --- a/src/wiring.ts +++ b/src/wiring.ts @@ -158,6 +158,9 @@ import { createPostgresEgressAuditSink } from "./admin/postgres-egress-audit-sin import { createConsentLinkStore, type ConsentLinkStore, type ConsentLinkRecord } from "./connectors/consent-link.ts"; import { createModelGateway, type ModelGateway } from "./model/model-gateway.ts"; import { createModelCredentialStore, type ModelCredentialStore } from "./model/model-credential-store.ts"; +import { setProviderBaseUrls } from "./model/provider-endpoints.ts"; +import { setCustomProviders } from "./model/custom-providers.ts"; +import { createCustomProviderStore, type CustomProviderStore } from "./model/custom-provider-store.ts"; import { createMemorySessionStore } from "./sessions/memory-session-store.ts"; import { createPostgresSessionStore } from "./sessions/postgres-session-store.ts"; import type { SessionStore } from "./sessions/session-store.ts"; @@ -318,6 +321,8 @@ export interface BuiltApp { secretDrops: SecretDropStore; modelGateway: ModelGateway; modelCredentials: ModelCredentialStore; + customProviders: CustomProviderStore; + refreshCustomProviders: () => Promise; acl: AclStore; skills: SkillStore; skillBundles: SkillBundleStore; @@ -394,6 +399,7 @@ export function buildApp( const pgArtifactMap = config.databaseUrl ? createPostgresMapFactory(config.databaseUrl) : null; const artifactMap = (table: string): DurableMap => pgArtifactMap ? pgArtifactMap.map(table) : createMemoryMap(); + setProviderBaseUrls(config.providerBaseUrls); const modelCredentials = createModelCredentialStore({ backing: artifactMap("model_credentials"), keyMaterial: config.connectorSecretKey ?? randomBytes(32), @@ -681,16 +687,44 @@ export function buildApp( ? createPostgresRunSignalStore(requireDbUrl("RUN_STORE")) : createMemoryRunSignalStore(); const tasks = config.databaseUrl ? createPostgresTaskStore(config.databaseUrl) : createMemoryTaskStore(); + const customProviders = createCustomProviderStore({ + backing: artifactMap("custom_model_providers"), + keyMaterial: config.connectorSecretKey ?? randomBytes(32), + }); + const refreshCustomProviders = async () => { + setCustomProviders(await customProviders.enabled()); + }; + void refreshCustomProviders().catch((e) => + console.error("[wiring] custom provider hydration failed:", errMessage(e)), + ); const resolveModelProviderKeys = async () => { - const [anthropic, openai, openrouter] = await Promise.all([ + const [anthropic, openai, openrouter, enabledCustom] = await Promise.all([ modelCredentials.resolve("anthropic"), modelCredentials.resolve("openai"), modelCredentials.resolve("openrouter"), + customProviders.enabled(), ]); + const customKeys = Object.fromEntries( + ( + await Promise.all( + enabledCustom.map(async (p) => { + try { + return [p.id, await customProviders.resolveKey(p.id)] as const; + } catch (e) { + // A corrupt/undecryptable custom key must degrade that one + // provider, never the whole turn (built-ins included). + console.error(`[model] custom provider ${p.id}: key unreadable: ${errMessage(e)}`); + return [p.id, null] as const; + } + }), + ) + ).filter(([, key]) => key), + ); return { ...(anthropic ? { anthropic } : {}), ...(openai ? { openai } : {}), ...(openrouter ? { openrouter } : {}), + ...customKeys, }; }; const runtimeOrgScope = scopeId("org", config.orgId); @@ -706,7 +740,31 @@ export function buildApp( signals: runSignals, }), ], - ["opencode", createOpenCodeHarness({ ...openCodeHarnessConfigOptions(config), signals: runSignals, tasks })], + [ + "opencode", + createOpenCodeHarness({ + ...openCodeHarnessConfigOptions(config), + signals: runSignals, + tasks, + resolveCustomProviders: async () => { + const enabled = await customProviders.enabled(); + return Promise.all( + enabled.map(async (spec) => { + try { + const apiKey = await customProviders.resolveKey(spec.id); + return { spec, ...(apiKey ? { apiKey } : {}) }; + } catch (e) { + // An unreadable key must not prevent the opencode server from + // starting; the provider is configured keyless and its models + // fail individually instead. + console.error(`[model] custom provider ${spec.id}: key unreadable: ${errMessage(e)}`); + return { spec }; + } + }), + ); + }, + }), + ], ["codex", createCodexHarness({ ...codexHarnessConfigOptions(config), signals: runSignals, tasks })], ["claude", createClaudeHarness({ ...claudeHarnessConfigOptions(config), signals: runSignals, tasks })], ["mock", createMockHarness()], @@ -1049,6 +1107,8 @@ export function buildApp( tasks, modelGateway, modelCredentials, + customProviders, + refreshCustomProviders, ...(overrides.modelCredentialFetch ? { modelCredentialFetch: overrides.modelCredentialFetch } : {}), acl, admin, @@ -1383,6 +1443,8 @@ export function buildApp( secretDrops, modelGateway, modelCredentials, + customProviders, + refreshCustomProviders, acl, skills, skillBundles, diff --git a/test/custom-provider-e2e.test.ts b/test/custom-provider-e2e.test.ts new file mode 100644 index 00000000..cc51db83 --- /dev/null +++ b/test/custom-provider-e2e.test.ts @@ -0,0 +1,334 @@ +// QA: end-to-end custom-provider lifecycle against a REAL fake upstream. +// Boots the app, registers a provider pointing at a local OpenAI-compatible +// server, and proves: validation, catalog surfacing, key hygiene, a real +// model call leaving QM and hitting the endpoint, edit-without-key, delete. +import "./support/auto-fake-sprites.ts"; +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { createInsecureTestServer } from "../src/api/server.ts"; +import { buildApp } from "../src/wiring.ts"; +import { testConfig } from "./support/test-config.ts"; +import { oneShot } from "../src/harness/pi-harness.ts"; +import { resolveModel, modelSupportedByHarness, modelServiceable } from "../src/model/pi-models.ts"; +import { setCustomProviders } from "../src/model/custom-providers.ts"; +import { createCustomProviderStore } from "../src/model/custom-provider-store.ts"; +import { createMemoryMap } from "../src/persistence/durable-map.ts"; +import type { Api, Model } from "@earendil-works/pi-ai"; + +const ADMIN = { "content-type": "application/json", "x-admin-actor": "admin-alice@default-org" }; + +test("QA: full custom-provider lifecycle against a live fake upstream", async () => { + // --- fake OpenAI-compatible upstream --- + const seen: Array<{ path: string; auth: string | undefined; model?: string }> = []; + const upstream = createServer((req, res) => { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + const record = { path: req.url ?? "", auth: req.headers.authorization as string | undefined } as (typeof seen)[0]; + if (req.url?.endsWith("/models")) { + seen.push(record); + if (record.auth !== "Bearer sk-qa-good") { + res.writeHead(401, { "content-type": "application/json" }); + return res.end(JSON.stringify({ error: { message: "bad key" } })); + } + res.writeHead(200, { "content-type": "application/json" }); + return res.end(JSON.stringify({ data: [{ id: "qa-chat" }] })); + } + if (req.url?.endsWith("/chat/completions")) { + record.model = (JSON.parse(body) as { model?: string }).model; + seen.push(record); + res.writeHead(200, { "content-type": "text/event-stream" }); + const chunk = (delta: object, finish: string | null) => + `data: ${JSON.stringify({ id: "cmpl-qa", object: "chat.completion.chunk", model: "qa-chat", choices: [{ index: 0, delta, finish_reason: finish }], usage: finish ? { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 } : undefined })}\n\n`; + res.write(chunk({ role: "assistant", content: "QA UPSTREAM REPLY" }, null)); + res.write(chunk({}, "stop")); + res.write("data: [DONE]\n\n"); + return res.end(); + } + seen.push(record); + res.writeHead(404); + res.end(); + }); + }); + await new Promise((r) => upstream.listen(0, "127.0.0.1", r)); + const upstreamUrl = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}/v1`; + + const built = buildApp(testConfig({ dataDir: mkdtempSync(join(tmpdir(), "qa-custom-")) })); + const server = createInsecureTestServer(built.app, { + config: built.config, + modelCredentials: built.modelCredentials, + customProviders: built.customProviders, + refreshCustomProviders: built.refreshCustomProviders, + admin: built.admin, + auditLog: built.auditLog, + harnessId: "pi", + }); + server.listen(0); + const base = `http://localhost:${(server.address() as AddressInfo).port}`; + const api = (path: string, init?: RequestInit) => fetch(`${base}${path}`, { headers: ADMIN, ...init }); + + try { + // 1. empty list + let r = await api("/v1/admin/custom-providers"); + assert.equal(r.status, 200); + assert.deepEqual(((await r.json()) as { providers: unknown[] }).providers, []); + + // 2. guardrails + r = await api("/v1/admin/custom-providers/openai", { + method: "PUT", + body: JSON.stringify({ + name: "X", + protocol: "openai", + baseUrl: upstreamUrl, + apiKey: "sk-qa-good", + models: [{ id: "m" }], + }), + }); + assert.equal(r.status, 400, "reserved slug refused"); + r = await api("/v1/admin/custom-providers/qa", { + method: "PUT", + body: JSON.stringify({ + name: "QA", + protocol: "openai", + baseUrl: "ftp://nope", + apiKey: "sk-qa-good", + models: [{ id: "m" }], + }), + }); + assert.equal(r.status, 400, "non-http url refused"); + r = await api("/v1/admin/custom-providers/qa", { + method: "PUT", + body: JSON.stringify({ name: "QA", protocol: "openai", baseUrl: upstreamUrl, apiKey: "sk-qa-good", models: [] }), + }); + assert.equal(r.status, 400, "no models refused"); + r = await api("/v1/admin/custom-providers/qa", { + method: "PUT", + body: JSON.stringify({ + name: "QA", + protocol: "openai", + baseUrl: upstreamUrl, + apiKey: "sk-wrong", + models: [{ id: "qa-chat" }], + }), + }); + assert.equal(r.status, 400, "bad key rejected by REAL upstream 401"); + assert.equal(((await r.json()) as { error: string }).error, "invalid_api_key"); + + // 3. register for real — validation hits the live upstream + r = await api("/v1/admin/custom-providers/qa", { + method: "PUT", + body: JSON.stringify({ + name: "QA Provider", + protocol: "openai", + baseUrl: upstreamUrl, + apiKey: "sk-qa-good", + models: [{ id: "qa-chat", name: "QA Chat", contextWindow: 64000, maxTokens: 4096 }], + }), + }); + assert.equal(r.status, 200); + assert.ok( + seen.some((s) => s.path.endsWith("/models") && s.auth === "Bearer sk-qa-good"), + "validation actually reached the upstream", + ); + + // 4. list: keyConfigured true, key NEVER present anywhere in the payload + r = await api("/v1/admin/custom-providers"); + const listing = JSON.stringify(await r.json()); + assert.ok(listing.includes('"hasKey":true')); + assert.ok(!listing.includes("sk-qa-good"), "key never readable"); + + // 5. model resolves like a built-in and is catalog-visible + const model = resolveModel("qa-chat"); + assert.ok(model, "custom model resolves"); + assert.equal(model!.provider, "qa"); + assert.equal((model as { baseUrl?: string }).baseUrl, upstreamUrl); + assert.equal(modelSupportedByHarness("qa-chat", "pi"), true); + assert.equal(modelSupportedByHarness("qa-chat", "opencode"), true); + assert.equal(modelSupportedByHarness("qa-chat", "codex"), false); + assert.equal(modelServiceable("qa-chat", { anthropic: false, openai: false, openrouter: false }), true); + + // 6. REAL model call through QM's pi path → fake upstream answers + const reply = await oneShot( + "qa", + model as unknown as Model, + { qa: "sk-qa-good" }, + "you are terse", + "say anything", + ); + assert.equal(reply, "QA UPSTREAM REPLY"); + const call = seen.find((s) => s.path.endsWith("/chat/completions")); + assert.ok(call, "completion request reached the upstream"); + assert.equal(call!.model, "qa-chat"); + assert.equal(call!.auth, "Bearer sk-qa-good", "stored key was sent to the custom endpoint"); + + // 7. edit WITHOUT key keeps the stored key + r = await api("/v1/admin/custom-providers/qa", { + method: "PUT", + body: JSON.stringify({ + name: "QA Provider v2", + protocol: "openai", + baseUrl: upstreamUrl, + models: [{ id: "qa-chat" }], + }), + }); + assert.equal(r.status, 200); + r = await api("/v1/admin/custom-providers"); + assert.ok(JSON.stringify(await r.json()).includes('"hasKey":true'), "key survives keyless edit"); + + // 8. delete: models leave the registry + r = await api("/v1/admin/custom-providers/qa", { method: "DELETE" }); + assert.equal(r.status, 200); + assert.equal(resolveModel("qa-chat"), undefined, "model gone after delete"); + r = await api("/v1/admin/custom-providers/qa", { method: "DELETE" }); + assert.equal(r.status, 404, "second delete 404s"); + + // 9. non-admin cannot touch any of it + r = await fetch(`${base}/v1/admin/custom-providers`, { headers: { "content-type": "application/json" } }); + assert.notEqual(r.status, 200, "unauthenticated read refused"); + } finally { + server.close(); + upstream.close(); + } +}); + +test("QA: anthropic-protocol custom provider serves a real turn (correct wire shape + headers)", async () => { + const seen: Array<{ path: string; apiKeyHeader?: string; version?: string; model?: string }> = []; + const upstream = createServer((req, res) => { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + const record = { + path: req.url ?? "", + apiKeyHeader: req.headers["x-api-key"] as string | undefined, + version: req.headers["anthropic-version"] as string | undefined, + } as (typeof seen)[0]; + if (req.url?.endsWith("/v1/models")) { + seen.push(record); + res.writeHead(record.apiKeyHeader === "sk-ant-qa" ? 200 : 401, { "content-type": "application/json" }); + return res.end(JSON.stringify({ data: [] })); + } + if (req.url?.endsWith("/v1/messages")) { + record.model = (JSON.parse(body) as { model?: string }).model; + seen.push(record); + res.writeHead(200, { "content-type": "text/event-stream" }); + res.write( + `event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { id: "msg_qa", type: "message", role: "assistant", content: [], model: "claude-compat", stop_reason: null, usage: { input_tokens: 5, output_tokens: 0 } } })}\n\n`, + ); + res.write( + `event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })}\n\n`, + ); + res.write( + `event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "ANTHROPIC QA REPLY" } })}\n\n`, + ); + res.write(`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: 0 })}\n\n`); + res.write( + `event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 3 } })}\n\n`, + ); + res.write(`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`); + return res.end(); + } + seen.push(record); + res.writeHead(404); + res.end(); + }); + }); + await new Promise((r) => upstream.listen(0, "127.0.0.1", r)); + const upstreamUrl = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`; + + const built = buildApp(testConfig({ dataDir: mkdtempSync(join(tmpdir(), "qa-ant-")) })); + const server = createInsecureTestServer(built.app, { + config: built.config, + modelCredentials: built.modelCredentials, + customProviders: built.customProviders, + refreshCustomProviders: built.refreshCustomProviders, + admin: built.admin, + auditLog: built.auditLog, + harnessId: "pi", + }); + server.listen(0); + const base = `http://localhost:${(server.address() as AddressInfo).port}`; + try { + const r = await fetch(`${base}/v1/admin/custom-providers/antcompat`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ + name: "Ant Compat", + protocol: "anthropic", + baseUrl: upstreamUrl, + apiKey: "sk-ant-qa", + models: [{ id: "claude-compat", name: "Claude Compat" }], + }), + }); + assert.equal(r.status, 200, "anthropic-protocol registration validates against /v1/models with x-api-key"); + const model = resolveModel("claude-compat"); + assert.ok(model); + assert.equal((model as { api?: string }).api, "anthropic-messages"); + const reply = await oneShot("qa-ant", model as unknown as Model, { antcompat: "sk-ant-qa" }, "terse", "go"); + assert.equal(reply, "ANTHROPIC QA REPLY"); + const call = seen.find((s) => s.path.endsWith("/v1/messages")); + assert.ok(call, "messages request reached the anthropic-compatible upstream"); + assert.equal(call!.model, "claude-compat"); + assert.equal(call!.apiKeyHeader, "sk-ant-qa", "anthropic wire auth uses x-api-key"); + } finally { + server.close(); + upstream.close(); + } +}); + +test("QA: registrations survive a restart (shared durable backing + same secret)", async () => { + // In production the backing map is the Postgres artifact store (same as + // model credentials); a restart is a new store instance over the same + // rows with the same CONNECTOR_SECRET_KEY. Simulate exactly that. + const backing = createMemoryMap() as Parameters[0]["backing"]; + const secret = "restart-secret-restart-secret-restart-secret"; + const first = createCustomProviderStore({ backing, keyMaterial: secret }); + await first.upsert( + { + id: "survivor", + name: "Survivor", + protocol: "openai", + baseUrl: "https://gw.example.com/v1", + models: [{ id: "survivor-model" }], + }, + "sk-live-key", + "admin-alice@default-org", + ); + // "restart": brand-new store instance over the same backing + const second = createCustomProviderStore({ backing, keyMaterial: secret }); + const enabled = await second.enabled(); + assert.equal(enabled[0]?.id, "survivor", "spec survives the restart"); + assert.equal(await second.resolveKey("survivor"), "sk-live-key", "key decrypts after restart with the same secret"); + // and the hydration path wires it into the runtime registry + setCustomProviders(enabled); + assert.ok(resolveModel("survivor-model"), "hydrated model resolves"); + setCustomProviders([]); +}); + +test("QA: a corrupt stored key degrades that provider only — admin surface stays intact", async () => { + const backing = createMemoryMap() as Parameters[0]["backing"]; + const writer = createCustomProviderStore({ backing, keyMaterial: "first-secret-first-secret-first-secret-1" }); + await writer.upsert( + { + id: "corrupted", + name: "Corrupted", + protocol: "openai", + baseUrl: "https://gw.example.com/v1", + models: [{ id: "corrupted-model" }], + }, + "sk-will-be-unreadable", + "admin-alice@default-org", + ); + // reboot with a DIFFERENT secret: the stored key is undecryptable + const reader = createCustomProviderStore({ backing, keyMaterial: "other-secret-other-secret-other-secret-2" }); + await assert.rejects(reader.resolveKey("corrupted"), "decryption fails with the wrong secret"); + const statuses = await reader.statuses(); + assert.equal(statuses[0]?.id, "corrupted"); + assert.equal(statuses[0]?.hasKey, true, "admin surface (no secrets) unaffected"); + const enabled = await reader.enabled(); + assert.equal(enabled[0]?.id, "corrupted", "spec listing unaffected — only the key is lost"); +}); diff --git a/test/custom-provider-route.test.ts b/test/custom-provider-route.test.ts new file mode 100644 index 00000000..910bdd9e --- /dev/null +++ b/test/custom-provider-route.test.ts @@ -0,0 +1,149 @@ +import "./support/auto-fake-sprites.ts"; + +import assert from "node:assert/strict"; +import type { AddressInfo } from "node:net"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test, afterEach } from "node:test"; +import { createInsecureTestServer } from "../src/api/server.ts"; +import { buildApp, type BuiltApp } from "../src/wiring.ts"; +import { testConfig } from "./support/test-config.ts"; +import { resolveModel } from "../src/model/pi-models.ts"; +import { setCustomProviders } from "../src/model/custom-providers.ts"; + +const ADMIN = { "content-type": "application/json", "x-admin-actor": "admin-alice@default-org" }; +const USER = { "content-type": "application/json", "x-admin-actor": "bob@default-org" }; + +afterEach(() => setCustomProviders([])); + +function start(modelCredentialFetch: typeof fetch = async () => new Response(null, { status: 200 })): { + base: string; + built: BuiltApp; + close: () => Promise; +} { + const built = buildApp(testConfig({ dataDir: mkdtempSync(join(tmpdir(), "custom-provider-route-")) }), { + modelCredentialFetch, + }); + const server = createInsecureTestServer(built.app, { + config: built.config, + modelCredentials: built.modelCredentials, + customProviders: built.customProviders, + refreshCustomProviders: built.refreshCustomProviders, + modelCredentialFetch, + harnessId: "pi", + providerKeys: { anthropic: true, openai: false, openrouter: false }, + admin: built.admin, + auditLog: built.auditLog, + }); + server.listen(0); + return { + base: `http://localhost:${(server.address() as AddressInfo).port}`, + built, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +const BODY = { + name: "Acme Gateway", + protocol: "openai", + baseUrl: "https://llm.acme.internal/v1", + models: [{ id: "acme-large", name: "Acme Large" }], + apiKey: "sk-acme-secret", +}; + +test("custom provider lifecycle: register, list, resolve, delete — admin only, no key leakage", async () => { + const validated: string[] = []; + const srv = start(async (input) => { + validated.push(String(input)); + return new Response(null, { status: 200 }); + }); + try { + // Register (validates against the endpoint's /models). + const put = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify(BODY), + }); + assert.equal(put.status, 200); + assert.ok(validated.some((u) => u === "https://llm.acme.internal/v1/models")); + const putBody = (await put.json()) as { status: { hasKey: boolean } }; + assert.equal(putBody.status.hasKey, true); + assert.equal(JSON.stringify(putBody).includes("sk-acme-secret"), false); + + // The runtime registry serves the model immediately. + assert.equal(String(resolveModel("acme-large")?.provider), "acme-gateway"); + + // List never leaks the key. + const list = await fetch(`${srv.base}/v1/admin/custom-providers`, { headers: ADMIN }); + assert.equal(list.status, 200); + const listBody = await list.text(); + assert.equal(listBody.includes("sk-acme-secret"), false); + assert.ok(listBody.includes("acme-gateway")); + + // Non-admin gets refused. + const denied = await fetch(`${srv.base}/v1/admin/custom-providers`, { headers: USER }); + assert.notEqual(denied.status, 200); + + // Delete disables and clears the registry. + const del = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "DELETE", + headers: ADMIN, + }); + assert.equal(del.status, 200); + assert.equal(resolveModel("acme-large"), undefined); + } finally { + await srv.close(); + } +}); + +test("a rejected key blocks registration unless validate:false", async () => { + const srv = start(async () => new Response(null, { status: 401 })); + try { + const put = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify(BODY), + }); + assert.equal(put.status, 400); + assert.equal(((await put.json()) as { error: string }).error, "invalid_api_key"); + + const skip = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ ...BODY, validate: false }), + }); + assert.equal(skip.status, 200); + } finally { + await srv.close(); + } +}); + +test("bad specs are refused with a reason", async () => { + const srv = start(); + try { + for (const [patch, reason] of [ + [{ models: [] }, /at least one model/], + [{ protocol: "grpc" }, /protocol/], + [{ baseUrl: "https://x?y=1" }, /query/], + ] as const) { + const res = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ ...BODY, ...patch, validate: false }), + }); + assert.equal(res.status, 400); + assert.match(((await res.json()) as { message: string }).message, reason); + } + // Reserved slug via the path. + const reserved = await fetch(`${srv.base}/v1/admin/custom-providers/openai`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ ...BODY, validate: false }), + }); + assert.equal(reserved.status, 400); + assert.match(((await reserved.json()) as { message: string }).message, /reserved/); + } finally { + await srv.close(); + } +}); diff --git a/test/custom-providers.test.ts b/test/custom-providers.test.ts new file mode 100644 index 00000000..6621b64d --- /dev/null +++ b/test/custom-providers.test.ts @@ -0,0 +1,192 @@ +import { test, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { + setCustomProviders, + resolveCustomModel, + isCustomModelId, + customModelCatalog, + validateCustomProviderSpec, +} from "../src/model/custom-providers.ts"; +import { builtInModelCatalog } from "../src/model/model-catalog.ts"; +import { createCustomProviderStore } from "../src/model/custom-provider-store.ts"; +import { modelSupportedByHarness, modelServiceable, resolveModel } from "../src/model/pi-models.ts"; +import { createMemoryMap } from "../src/persistence/durable-map.ts"; +import type { StoredCustomProvider } from "../src/model/custom-provider-store.ts"; + +afterEach(() => setCustomProviders([])); + +const GATEWAY = { + id: "acme-gateway", + name: "Acme Gateway", + protocol: "openai" as const, + baseUrl: "https://llm.acme.internal/v1", + models: [{ id: "acme-large", name: "Acme Large", contextWindow: 200_000, maxTokens: 16_000, input: 2, output: 8 }], +}; + +test("a registered custom model resolves with the provider's protocol and base URL", () => { + setCustomProviders([GATEWAY]); + const model = resolveCustomModel("acme-large"); + assert.ok(model); + assert.equal(model.provider, "acme-gateway"); + assert.equal(model.api, "openai-completions"); + assert.equal(model.baseUrl, "https://llm.acme.internal/v1"); + assert.equal(model.contextWindow, 200_000); + assert.equal(model.cost.input, 2); +}); + +test("anthropic-protocol providers produce anthropic-messages models with defaults", () => { + setCustomProviders([ + { + id: "eu-anthropic", + name: "EU Anthropic-compatible", + protocol: "anthropic", + baseUrl: "https://eu.example.com", + models: [{ id: "eu-claude" }], + }, + ]); + const model = resolveCustomModel("eu-claude"); + assert.ok(model); + assert.equal(model.api, "anthropic-messages"); + assert.equal(model.contextWindow, 128_000); + assert.equal(model.cost.input, 0); +}); + +test("resolveModel falls back to custom models; built-ins shadow custom ids", () => { + setCustomProviders([{ ...GATEWAY, models: [{ id: "acme-large" }, { id: "claude-opus-5", name: "impostor" }] }]); + assert.equal(resolveModel("acme-large")?.provider, "acme-gateway"); + // The built-in claude-opus-5 must win over a custom model claiming its id. + assert.equal(String(resolveModel("claude-opus-5")?.provider), "anthropic"); +}); + +test("custom models are gated to pi and mock harnesses", () => { + setCustomProviders([GATEWAY]); + assert.equal(modelSupportedByHarness("acme-large", "pi"), true); + assert.equal(modelSupportedByHarness("acme-large", "mock"), true); + assert.equal(modelSupportedByHarness("acme-large", "claude"), false); + assert.equal(modelSupportedByHarness("acme-large", "codex"), false); + assert.equal(modelSupportedByHarness("acme-large", "opencode"), true); +}); + +test("a registered custom model is serviceable regardless of built-in key availability", () => { + setCustomProviders([GATEWAY]); + assert.equal(modelServiceable("acme-large", { anthropic: false, openai: false, openrouter: false }), true); +}); + +test("catalog lists custom models; clearing the registry removes them", () => { + setCustomProviders([GATEWAY]); + assert.deepEqual(customModelCatalog(), [{ id: "acme-large", name: "Acme Large", provider: "acme-gateway" }]); + setCustomProviders([]); + assert.equal(isCustomModelId("acme-large"), false); + assert.equal(resolveModel("acme-large"), undefined); +}); + +test("spec validation rejects reserved ids, bad slugs, bad URLs, and empty model lists", () => { + assert.throws(() => validateCustomProviderSpec({ ...GATEWAY, id: "openai" }), /reserved/); + assert.throws(() => validateCustomProviderSpec({ ...GATEWAY, id: "Not A Slug" }), /slug/); + assert.throws(() => validateCustomProviderSpec({ ...GATEWAY, baseUrl: "ftp://x" }), /http/); + assert.throws(() => validateCustomProviderSpec({ ...GATEWAY, baseUrl: "https://x?y=1" }), /query/); + assert.throws(() => validateCustomProviderSpec({ ...GATEWAY, models: [] }), /at least one model/); + assert.throws(() => validateCustomProviderSpec({ ...GATEWAY, models: [{ id: "a" }, { id: "a" }] }), /duplicate/); +}); + +test("store round-trip: upsert encrypts the key, statuses never leak it, delete disables", async () => { + const backing = createMemoryMap(); + const store = createCustomProviderStore({ backing, keyMaterial: "test-key-material" }); + + await store.upsert(GATEWAY, "sk-secret-123", "admin@example.com"); + const statuses = await store.statuses(); + assert.equal(statuses.length, 1); + assert.equal(statuses[0]!.hasKey, true); + assert.equal(JSON.stringify(statuses).includes("sk-secret-123"), false); + + const raw = await backing.get("acme-gateway"); + assert.ok(raw?.apiKeyEnc); + assert.equal(raw!.apiKeyEnc!.includes("sk-secret-123"), false); + + assert.equal(await store.resolveKey("acme-gateway"), "sk-secret-123"); + assert.deepEqual(await store.enabled(), [GATEWAY]); + + // Upsert without a key keeps the existing one. + await store.upsert({ ...GATEWAY, name: "Renamed" }, undefined, "admin@example.com"); + assert.equal(await store.resolveKey("acme-gateway"), "sk-secret-123"); + + assert.equal(await store.delete("acme-gateway", "admin@example.com"), true); + assert.equal(await store.resolveKey("acme-gateway"), null); + assert.deepEqual(await store.enabled(), []); + assert.equal((await store.statuses())[0]!.disabled, true); + assert.equal(await store.delete("never-existed", "admin@example.com"), false); +}); + +test("store validates specs on upsert", async () => { + const store = createCustomProviderStore({ + backing: createMemoryMap(), + keyMaterial: "k", + }); + await assert.rejects(store.upsert({ ...GATEWAY, id: "anthropic" }, "k", "a@b.c"), /reserved/); +}); + +test("registered models surface in the catalog and vanish on unregister", () => { + setCustomProviders([ + { + id: "deepseek", + name: "DeepSeek", + protocol: "openai", + baseUrl: "https://api.deepseek.com/v1", + models: [{ id: "deepseek-chat", name: "DeepSeek Chat" }], + }, + ]); + const catalog = builtInModelCatalog(); + const entry = catalog.find((m) => m.id === "deepseek-chat"); + assert.ok(entry, "custom model appears in the catalog"); + assert.equal(entry!.provider, "deepseek"); + setCustomProviders([]); + assert.ok(!builtInModelCatalog().some((m) => m.id === "deepseek-chat")); +}); + +test("opencode modelRef routes slashed custom model ids to the registered provider, not a phantom slash-prefix", async () => { + const { modelRef } = await import("../src/harness/opencode-harness.ts"); + setCustomProviders([ + { + id: "litellm", + name: "LiteLLM", + protocol: "openai", + baseUrl: "https://litellm.example.com/v1", + models: [{ id: "bedrock/claude-opus-5" }], + }, + ]); + try { + assert.deepEqual(modelRef("bedrock/claude-opus-5"), { providerID: "litellm", modelID: "bedrock/claude-opus-5" }); + // built-in slash convention untouched + assert.deepEqual(modelRef("openrouter/auto"), { providerID: "openrouter", modelID: "auto" }); + } finally { + setCustomProviders([]); + } +}); + +test("catalog cache invalidates immediately when the custom registry changes", async () => { + const { selectableModelCatalog } = await import("../src/model/model-catalog.ts"); + const fetcher: typeof fetch = async () => new Response(JSON.stringify({ data: [] }), { status: 200 }); + setCustomProviders([]); + const before = await selectableModelCatalog(fetcher); + assert.ok(!before.some((m) => m.id === "fresh-model")); + setCustomProviders([ + { + id: "freshco", + name: "FreshCo", + protocol: "openai", + baseUrl: "https://fresh.example.com/v1", + models: [{ id: "fresh-model" }], + }, + ]); + try { + const after = await selectableModelCatalog(fetcher); + assert.ok( + after.some((m) => m.id === "fresh-model"), + "new registration visible without waiting out the TTL", + ); + } finally { + setCustomProviders([]); + } + const cleared = await selectableModelCatalog(fetcher); + assert.ok(!cleared.some((m) => m.id === "fresh-model"), "removal visible immediately too"); +}); diff --git a/test/opencode-harness.test.ts b/test/opencode-harness.test.ts index fb815d18..3072fa5a 100644 --- a/test/opencode-harness.test.ts +++ b/test/opencode-harness.test.ts @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { assistantFailure, createOpenCodeHarness, latestAssistantParts } from "../src/harness/opencode-harness.ts"; @@ -294,3 +294,46 @@ test("assistantFailure classifies provider errors and exempts aborts and output- assert.equal(assistantFailure({ role: "assistant" }), null); assert.equal(assistantFailure(undefined), null); }); + +test("custom providers materialize into the opencode config (enabled + provider map, key included)", async () => { + const dir = mkdtempSync(join(tmpdir(), "opencode-custom-")); + const dump = join(dir, "config.json"); + const bin = fakeSidecar(dir, "custom", promptHandlers(okAssistant)); + // wrap the binary so it writes the config it received before exec + const wrapped = join(dir, "custom-wrapped"); + writeFileSync( + wrapped, + `#!/bin/sh\nprintf '%s' "$OPENCODE_CONFIG_CONTENT" > ${JSON.stringify(dump)}\nexec ${JSON.stringify(bin)} "$@"\n`, + ); + chmodSync(wrapped, 0o755); + const harness = createOpenCodeHarness({ + binaryPath: wrapped, + resolveCustomProviders: async () => [ + { + spec: { + id: "litellm", + name: "LiteLLM", + protocol: "openai" as const, + baseUrl: "http://litellm.internal:4000/v1", + models: [{ id: "deepseek-chat", name: "DeepSeek", contextWindow: 128000, maxTokens: 8192 }], + }, + apiKey: "sk-lite", + }, + ], + }); + const entries: SessionEntry[] = []; + const llmRows: HarnessLlmRequestRecord[] = []; + try { + await harness.turns.runTurn(turnInput(entries, llmRows)); + const config = JSON.parse(readFileSync(dump, "utf8")); + assert.ok(config.enabled_providers.includes("litellm")); + const litellm = config.provider.litellm; + assert.equal(litellm.npm, "@ai-sdk/openai-compatible"); + assert.equal(litellm.options.baseURL, "http://litellm.internal:4000/v1"); + assert.equal(litellm.options.apiKey, "sk-lite"); + assert.deepEqual(litellm.models["deepseek-chat"], { name: "DeepSeek", limit: { context: 128000, output: 8192 } }); + } finally { + await harness.turns.close?.(); + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/provider-endpoints.test.ts b/test/provider-endpoints.test.ts new file mode 100644 index 00000000..8cbe2bf8 --- /dev/null +++ b/test/provider-endpoints.test.ts @@ -0,0 +1,81 @@ +import { test, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { + parseProviderBaseUrl, + providerBaseUrl, + providerBaseUrlsFromEnv, + setProviderBaseUrls, +} from "../src/model/provider-endpoints.ts"; +import { resolveModel } from "../src/model/pi-models.ts"; +import { loadConfig } from "../src/config.ts"; + +const BASE_ENV = { HARNESS: "mock" } as NodeJS.ProcessEnv; + +afterEach(() => setProviderBaseUrls({})); + +test("parseProviderBaseUrl normalizes trailing slashes and whitespace", () => { + assert.equal(parseProviderBaseUrl("X", " https://gw.example.com/v1// "), "https://gw.example.com/v1"); +}); + +test("parseProviderBaseUrl rejects bad values", () => { + assert.throws(() => parseProviderBaseUrl("X", "not a url")); + assert.throws(() => parseProviderBaseUrl("X", "ftp://gw.example.com")); + assert.throws(() => parseProviderBaseUrl("X", "https://user:pw@gw.example.com")); + assert.throws(() => parseProviderBaseUrl("X", "https://gw.example.com/?a=b")); + assert.throws(() => parseProviderBaseUrl("X", "https://gw.example.com/#frag")); +}); + +test("providerBaseUrlsFromEnv reads the three provider variables", () => { + const urls = providerBaseUrlsFromEnv({ + ANTHROPIC_BASE_URL: "https://a.example.com", + OPENAI_BASE_URL: "https://o.example.com/v1/", + OPENROUTER_BASE_URL: " ", + } as NodeJS.ProcessEnv); + assert.deepEqual(urls, { anthropic: "https://a.example.com", openai: "https://o.example.com/v1" }); +}); + +test("providerBaseUrl answers only for configured, known providers", () => { + setProviderBaseUrls({ anthropic: "https://a.example.com" }); + assert.equal(providerBaseUrl("anthropic"), "https://a.example.com"); + assert.equal(providerBaseUrl("openai"), undefined); + assert.equal(providerBaseUrl("weird"), undefined); +}); + +test("resolveModel routes a built-in model through the override", () => { + assert.equal(resolveModel("claude-opus-4-8")?.baseUrl, "https://api.anthropic.com"); + setProviderBaseUrls({ anthropic: "https://gw.example.com" }); + assert.equal(resolveModel("claude-opus-4-8")?.baseUrl, "https://gw.example.com"); + assert.equal(resolveModel("gpt-5.6-sol")?.baseUrl, "https://api.openai.com/v1"); +}); + +test("a cloned model follows its template's override", () => { + setProviderBaseUrls({ anthropic: "https://gw.example.com" }); + const clone = resolveModel("claude-opus-5"); + assert.equal(clone?.baseUrl, "https://gw.example.com"); + assert.equal(clone?.id, "claude-opus-5"); +}); + +test("loadConfig parses provider base URLs and feeds the child harness envs", () => { + const config = loadConfig({ + ...BASE_ENV, + ANTHROPIC_BASE_URL: "https://a.example.com/", + OPENAI_BASE_URL: "https://o.example.com/v1", + }); + assert.deepEqual(config.providerBaseUrls, { + anthropic: "https://a.example.com", + openai: "https://o.example.com/v1", + }); + assert.equal(config.claudeProcessEnv.ANTHROPIC_BASE_URL, "https://a.example.com"); + assert.equal(config.codexProcessEnv.OPENAI_BASE_URL, "https://o.example.com/v1"); +}); + +test("loadConfig rejects an invalid provider base URL", () => { + assert.throws(() => loadConfig({ ...BASE_ENV, OPENAI_BASE_URL: "nope" } as NodeJS.ProcessEnv)); +}); + +test("loadConfig leaves child envs untouched when no override is set", () => { + const config = loadConfig(BASE_ENV); + assert.deepEqual(config.providerBaseUrls, {}); + assert.equal(config.claudeProcessEnv.ANTHROPIC_BASE_URL, undefined); + assert.equal(config.codexProcessEnv.OPENAI_BASE_URL, undefined); +}); From 346388d3fe7c6396adf0650d087c6343c3b254aa Mon Sep 17 00:00:00 2001 From: Regan Bell Date: Tue, 4 Aug 2026 09:17:33 +0000 Subject: [PATCH 02/13] =?UTF-8?q?deploy:=20owner=20app=20shell=20=E2=80=94?= =?UTF-8?q?=20artifact-style=20top=20bar=20with=20a=20docked=20iterate=20c?= =?UTF-8?q?hat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the injected edit bubble with a wrapper shell served to a signed-in owner on top-level document loads: a slim top bar (app name, version chip, update-reload pill) over the app in a same-origin iframe, with a resizable docked chat column for iterating on the app. The frame's own load carries sec-fetch-dest: iframe, so the app itself proxies through byte-identical — no more HTML injection, no CSS/z-index fights, and reloading a new version keeps the chat thread. Clients without fetch metadata get the raw app (never a nested shell). The shell wears the web-ui design system (neutral oklch grays, system sans, dark mode via prefers-color-scheme) and picks up the org's configured branding accent for the Chat toggle and update pill, falling back to the web-ui default. Accent values are allowlist-sanitized before landing in a style attribute. With the chat panel open, the app frame auto-reloads the moment a new version lands (the chat thread survives); with it closed, the polite 'Updated ↻ Reload' pill stays instead of yanking the page. --- src/api/routes/deployments.ts | 66 ++---- src/deploy/app-shell.ts | 201 ++++++++++++++++++ src/deploy/edit-widget.ts | 107 ---------- ...idget.test.ts => deploy-app-shell.test.ts} | 120 +++++++---- 4 files changed, 297 insertions(+), 197 deletions(-) create mode 100644 src/deploy/app-shell.ts delete mode 100644 src/deploy/edit-widget.ts rename test/{deploy-edit-widget.test.ts => deploy-app-shell.test.ts} (74%) diff --git a/src/api/routes/deployments.ts b/src/api/routes/deployments.ts index 7af8045e..bff8821e 100644 --- a/src/api/routes/deployments.ts +++ b/src/api/routes/deployments.ts @@ -19,7 +19,7 @@ import type { ApiCtx, BaseCtx, Route } from "./route.ts"; import { CONFIG_DEFAULTS } from "../../config.ts"; import { resolveShareTarget as resolveShareTargetGrammar } from "../artifact-share.ts"; import { mintDeployOwnerToken, verifyDeployGitAccess, verifyDeployOwnerToken } from "../../deploy/access-token.ts"; -import { EDIT_WIDGET_JS, EDIT_WIDGET_PATH_PREFIX, editWidgetTag } from "../../deploy/edit-widget.ts"; +import { APP_SHELL_PATH_PREFIX, appShellHtml } from "../../deploy/app-shell.ts"; import { principalDestination } from "../../reach/reach.ts"; import { portalSessionSub } from "../../deploy/viewer-session.ts"; import { proxyHeaders } from "../../util/http-proxy.ts"; @@ -245,28 +245,12 @@ function checkDeploymentHttp2Session(connection: DeploymentHttp2Connection): voi } } -function htmlInjection( - inject: string | undefined, - method: string, - statusCode: number, - headers: Record, -): string | null { - if (!inject || method !== "GET") return null; - if (statusCode !== 200) return null; - const contentType = headers["content-type"]; - if (typeof contentType !== "string" || !/^text\/html\b/i.test(contentType)) return null; - if (headers["content-encoding"]) return null; - delete headers["content-length"]; - return inject; -} - function proxyReachHttp2( ctx: BaseCtx, endpoint: Awaited> & { status: "ok" }, subPath: string, headers: Record, bufferedBody: Buffer | null, - inject?: string, ): void { const { req, res, deps, url, method } = ctx; const { host, port, tls } = endpoint.endpoint; @@ -311,7 +295,6 @@ function proxyReachHttp2( current = up; connection.activeStreams++; let responseStarted = false; - let pendingInject: string | null = null; let failureHandled = false; const fail = (error?: unknown): void => { if (failureHandled) return; @@ -345,7 +328,7 @@ function proxyReachHttp2( up.once("close", () => { releaseDeploymentHttp2Stream(origin, connection); if (!responseStarted || !up.readableEnded || up.rstCode !== http2Constants.NGHTTP2_NO_ERROR) fail(); - else if (!failureHandled && !res.destroyed && !res.writableEnded) res.end(pendingInject ?? undefined); + else if (!failureHandled && !res.destroyed && !res.writableEnded) res.end(); }); up.setTimeout(deps.deployDialTimeoutMs ?? CONFIG_DEFAULTS.deployDialTimeoutMs, () => { if (failureHandled) return; @@ -365,7 +348,6 @@ function proxyReachHttp2( armThrottleShield(`${host}:${port}`, Number(responseHeaders[":status"] ?? 0), up); const status = Number(responseHeaders[":status"] ?? 502); const safeHeaders = gatewaySafeResponseHeaders(responseHeaders); - pendingInject = htmlInjection(inject, method, status, safeHeaders); res.writeHead(status, safeHeaders); up.pipe(res, { end: false }); }); @@ -381,7 +363,6 @@ async function proxyReach( ctx: BaseCtx, reach: Awaited>, subPath: string, - inject?: string, ): Promise { const { req, res, deps, url, method } = ctx; if (res.destroyed) return; @@ -408,7 +389,6 @@ async function proxyReach( host: hostHeader, ...reach.endpoint.proxyHeaders, }; - if (inject) headers["accept-encoding"] = "identity"; if (/(?:^|,)\s*chunked\s*$/i.test(String(req.headers["transfer-encoding"] ?? ""))) { const chunks: Buffer[] = []; let size = 0; @@ -428,7 +408,7 @@ async function proxyReach( } if (res.destroyed) return; if (reach.endpoint.httpVersion === "2") { - proxyReachHttp2(ctx, reach, subPath, headers, bufferedBody, inject); + proxyReachHttp2(ctx, reach, subPath, headers, bufferedBody); return; } const up = requestFn({ hostname: host, port, path: subPath + url.search, method, headers }, (upRes) => { @@ -436,16 +416,8 @@ async function proxyReach( upRes.on("error", () => res.destroy()); armThrottleShield(upstreamKey, upRes.statusCode ?? 0, upRes); const headers = gatewaySafeResponseHeaders(upRes.headers); - const pendingInject = htmlInjection(inject, method, upRes.statusCode ?? 502, headers); res.writeHead(upRes.statusCode ?? 502, headers); - if (pendingInject === null) { - upRes.pipe(res); - } else { - upRes.pipe(res, { end: false }); - upRes.on("end", () => { - if (!res.destroyed && !res.writableEnded) res.end(pendingInject); - }); - } + upRes.pipe(res); }); up.setTimeout(deps.deployDialTimeoutMs ?? CONFIG_DEFAULTS.deployDialTimeoutMs, () => { if (!res.headersSent) sendJson(res, 504, { error: "gateway_timeout", message: "deployment did not respond" }); @@ -690,12 +662,7 @@ export async function proxyDeploymentSubdomain(ctx: BaseCtx): Promise { const session = await verifyDeployOwnerToken(gateSecret, ownerCookieValue, slug); if (session && (await app.canManageDeployment(slug, session.sub))) ownerSub = session.sub; } - if (ownerSub && ctx.method === "GET" && pathname.startsWith(EDIT_WIDGET_PATH_PREFIX)) { - if (pathname === "/__claw__/widget.js") { - res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" }); - res.end(EDIT_WIDGET_JS); - return true; - } + if (ownerSub && ctx.method === "GET" && pathname.startsWith(APP_SHELL_PATH_PREFIX)) { if (pathname === "/__claw__/version") { const d = await app.getDeployment(slug); if (!d) sendJson(res, 404, { error: "not_found" }); @@ -705,17 +672,30 @@ export async function proxyDeploymentSubdomain(ctx: BaseCtx): Promise { sendJson(res, 404, { error: "not_found" }); return true; } - const fetchDest = String(req.headers["sec-fetch-dest"] ?? ""); - const isDocumentLoad = fetchDest === "" || fetchDest === "document"; - const inject = - ownerSub && isDocumentLoad && deps.deployAppsLoginUrl ? editWidgetTag(deps.deployAppsLoginUrl, slug) : undefined; if (ownerSub) { if (signInAttempted && ctx.method === "GET") { cleanUrlRedirect(); return true; } + // A top-level document load gets the owner shell: a slim top bar over the app + // (framed same-origin) with a slide-out chat column for iterating on it. The + // frame's own load carries sec-fetch-dest: iframe, so it proxies straight through. + const isTopDocument = String(req.headers["sec-fetch-dest"] ?? "") === "document"; + if (ctx.method === "GET" && isTopDocument && deps.deployAppsLoginUrl) { + const accent = deps.config ? (await deps.config.getBrandingDurable(orgScope(deps)))?.accent : undefined; + res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); + res.end( + appShellHtml({ + slug, + portalUrl: deps.deployAppsLoginUrl, + path: safePathname + url.search, + ...(accent ? { accent } : {}), + }), + ); + return true; + } const reach = await app.reachDeployment(slug, "", { bypassAcl: true }); - await proxyReach(ctx, reach, pathname, inject); + await proxyReach(ctx, reach, pathname); return true; } const sessionSecret = deps.deployAppsSessionSecret; diff --git a/src/deploy/app-shell.ts b/src/deploy/app-shell.ts new file mode 100644 index 00000000..3efc6ed6 --- /dev/null +++ b/src/deploy/app-shell.ts @@ -0,0 +1,201 @@ +export const APP_SHELL_PATH_PREFIX = "/__claw__/"; + +function escAttr(value: string): string { + return value.replace(/&/g, "&").replace(/"/g, """).replace(/ + + + + + +${slug} + + + +
+ ${slug} + + + + + +
+
+ + +
+ + + + +`; +} diff --git a/src/deploy/edit-widget.ts b/src/deploy/edit-widget.ts deleted file mode 100644 index df5adaf1..00000000 --- a/src/deploy/edit-widget.ts +++ /dev/null @@ -1,107 +0,0 @@ -export const EDIT_WIDGET_PATH_PREFIX = "/__claw__/"; - -export function editWidgetTag(portalUrl: string, slug: string): string { - const esc = (value: string): string => value.replace(/&/g, "&").replace(/"/g, """).replace(/`; -} - -export const EDIT_WIDGET_JS = `(() => { - const script = document.currentScript; - if (!script || window.__clawEditWidget) return; - window.__clawEditWidget = true; - const portal = script.dataset.portal || ""; - const slug = script.dataset.slug || ""; - if (!portal || !slug) return; - - const host = document.createElement("div"); - const root = host.attachShadow({ mode: "closed" }); - const style = document.createElement("style"); - style.textContent = \` - .bubble { position: fixed; right: 20px; bottom: 20px; z-index: 2147483646; width: 48px; height: 48px; - border-radius: 50%; border: none; cursor: pointer; background: #111; color: #fff; - box-shadow: 0 4px 16px rgba(0,0,0,.28); display: grid; place-items: center; font-size: 21px; - transition: transform .15s ease; } - .bubble:hover { transform: scale(1.08); } - .panel { position: fixed; top: 0; right: 0; bottom: 0; width: min(420px, 92vw); z-index: 2147483647; - background: #fff; box-shadow: -8px 0 28px rgba(0,0,0,.22); display: none; flex-direction: column; } - .panel.open { display: flex; } - .bar { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; - background: #111; color: #fff; font: 13px/1.4 system-ui, sans-serif; } - .bar button { background: none; border: none; color: #fff; font-size: 16px; cursor: pointer; } - iframe { border: 0; flex: 1; width: 100%; } - .reload { position: fixed; right: 84px; bottom: 28px; z-index: 2147483646; display: none; - background: #111; color: #fff; border: none; border-radius: 8px; padding: 8px 12px; - font: 13px system-ui, sans-serif; cursor: pointer; } - .reload.show { display: block; } - \`; - const bubble = document.createElement("button"); - bubble.className = "bubble"; - bubble.type = "button"; - bubble.title = "Edit this app"; - bubble.setAttribute("aria-label", "Edit this app"); - bubble.textContent = "\\u270E"; - const panel = document.createElement("div"); - panel.className = "panel"; - const bar = document.createElement("div"); - bar.className = "bar"; - const title = document.createElement("span"); - title.textContent = "Editing " + slug; - const close = document.createElement("button"); - close.type = "button"; - close.setAttribute("aria-label", "Close editor"); - close.textContent = "\\u2715"; - bar.append(title, close); - panel.append(bar); - const reload = document.createElement("button"); - reload.className = "reload"; - reload.type = "button"; - reload.textContent = "App updated \\u21BB reload"; - root.append(style, bubble, panel, reload); - - let frame = null; - let baseVersion = null; - let timer = null; - - const fetchVersion = async () => { - try { - const r = await fetch("/__claw__/version", { cache: "no-store" }); - if (!r.ok) return null; - const d = await r.json(); - return typeof d.version === "number" ? d.version : null; - } catch { - return null; - } - }; - - const poll = async () => { - const v = await fetchVersion(); - if (v === null) return; - if (baseVersion === null) baseVersion = v; - else if (v !== baseVersion) reload.classList.add("show"); - }; - - const open = () => { - if (!frame) { - frame = document.createElement("iframe"); - frame.src = portal.replace(/\\/$/, "") + "/app-edit?slug=" + encodeURIComponent(slug) + "&embed=1"; - panel.append(frame); - } - panel.classList.add("open"); - bubble.style.display = "none"; - void poll(); - if (!timer) timer = setInterval(poll, 4000); - }; - const shut = () => { - panel.classList.remove("open"); - bubble.style.display = ""; - if (timer) { clearInterval(timer); timer = null; } - }; - bubble.addEventListener("click", open); - close.addEventListener("click", shut); - reload.addEventListener("click", () => location.reload()); - - const mount = () => document.body && document.body.append(host); - if (document.body) mount(); - else document.addEventListener("DOMContentLoaded", mount); -})(); -`; diff --git a/test/deploy-edit-widget.test.ts b/test/deploy-app-shell.test.ts similarity index 74% rename from test/deploy-edit-widget.test.ts rename to test/deploy-app-shell.test.ts index 6ebbd35a..dd4ef6b4 100644 --- a/test/deploy-edit-widget.test.ts +++ b/test/deploy-app-shell.test.ts @@ -33,7 +33,7 @@ function appServingUpstream(upstreamPort: number) { }, auditLog, acl, - deployDir: mkdtempSync(join(tmpdir(), "edit-widget-")), + deployDir: mkdtempSync(join(tmpdir(), "app-shell-")), }); return createApp({ deploy, @@ -109,7 +109,7 @@ const viewerCookie = () => `portal_session=${mintPortalSession("U-viewer")}`; const ownerToken = (sub: string, expInMs = 60_000) => mintDeployOwnerToken(GATE_SECRET, { slug: "mysite", sub, exp: Date.now() + expInMs }); -test("edit widget: a valid owner link becomes a host-only cookie and turns on HTML injection", async () => { +test("app shell: a valid owner link becomes a host-only cookie and turns on the shell", async () => { const f = await widgetFixture(); try { const token = await ownerToken("U1"); @@ -120,40 +120,80 @@ test("edit widget: a valid owner link becomes a host-only cookie and turns on HT assert.match(setCookie, /HttpOnly/, "the owner cookie is HttpOnly"); assert.equal(swallow.headers.location, "/", "the redirect drops the token from the URL"); - const page = await httpGet(f.port, "/", { Host: HOST, Cookie: `dpl_owner=${token}` }); + const page = await httpGet(f.port, "/", { + Host: HOST, + Cookie: `dpl_owner=${token}`, + "Sec-Fetch-Dest": "document", + }); assert.equal(page.status, 200); - assert.match(page.body, /APP<\/body><\/html>/, "the app's own HTML is intact"); - assert.match(page.body, /