diff --git a/.github/workflows/release-package.yml b/.github/workflows/release-package.yml index c5c33ec44..5b79e4686 100644 --- a/.github/workflows/release-package.yml +++ b/.github/workflows/release-package.yml @@ -19,6 +19,7 @@ jobs: include: - name: core dockerfile: deploy/core/Dockerfile + build-args: GIT_SHA=${{ github.sha }} - name: web-ui dockerfile: deploy/web-ui/Dockerfile - name: admin diff --git a/cli/src/backends/aws.ts b/cli/src/backends/aws.ts index 2f1e9f717..f901e079d 100644 --- a/cli/src/backends/aws.ts +++ b/cli/src/backends/aws.ts @@ -47,6 +47,8 @@ import { resolveBuildRepoRoot, runInherit, sleep, + sourceBuildArgs, + sourceBuildInfo, streamLabeled, } from "../util.ts"; import { doctorCommon } from "./doctor.ts"; @@ -471,22 +473,6 @@ function workloadImageProvenance( return { kind: "configured", source }; } -const sourceBuildInfoByRoot = new Map(); - -function sourceBuildInfo(root: string): { gitCommit?: string; dirty?: boolean } { - const cached = sourceBuildInfoByRoot.get(root); - if (cached) return cached; - const info: { gitCommit?: string; dirty?: boolean } = {}; - try { - info.gitCommit = capture("git", ["-C", root, "rev-parse", "HEAD"]).trim(); - info.dirty = capture("git", ["-C", root, "status", "--porcelain"]).trim().length > 0; - } catch { - void 0; - } - sourceBuildInfoByRoot.set(root, info); - return info; -} - function sourceImageDigest(source: string): string { const pinned = source.match(/@(?sha256:[0-9a-f]{64})$/)?.groups?.digest; if (pinned) return pinned; @@ -556,10 +542,12 @@ function publishWorkloadImage( "-t", tagged, ]; - const info = sourceBuildInfo(root); - if (info.gitCommit) args.push("--build-arg", `GIT_SHA=${info.gitCommit}${info.dirty ? "-dirty" : ""}`); - for (const [name, value] of Object.entries(workloadBuildArgs(config, workload))) - args.push("--build-arg", `${name}=${value}`); + const configuredBuildArgs = workloadBuildArgs(config, workload); + if (workload === "core" && Object.hasOwn(configuredBuildArgs, "GIT_SHA")) { + throw new CliError("aws.services.core.buildArgs.GIT_SHA is reserved for source-build provenance"); + } + for (const [name, value] of Object.entries(configuredBuildArgs)) args.push("--build-arg", `${name}=${value}`); + args.push(...sourceBuildArgs(root, workload)); args.push(root); runInherit("docker", args); } else { diff --git a/cli/src/backends/docker.ts b/cli/src/backends/docker.ts index 81f1147b5..1452fb0d4 100644 --- a/cli/src/backends/docker.ts +++ b/cli/src/backends/docker.ts @@ -14,6 +14,7 @@ import { resolveBuildRepoRoot, runInherit, sleep, + sourceBuildArgs, streamLabeled, tailString, which, @@ -144,7 +145,7 @@ function resolveImage(ctx: DockerCtx, service: ServiceName): string { const dockerfile = join(root, "deploy", service, "Dockerfile"); if (!existsSync(dockerfile)) throw new CliError(`no Dockerfile at ${dockerfile}`); const tag = `qm-${service}:local`; - const buildArgs: string[] = []; + const buildArgs = sourceBuildArgs(root, service); step(`building ${service} from ${dockerfile}`); dockerInherit(["build", "-f", dockerfile, "-t", tag, ...buildArgs, root]); return tag; diff --git a/cli/src/backends/fly.ts b/cli/src/backends/fly.ts index 8507d95e0..fdc32b442 100644 --- a/cli/src/backends/fly.ts +++ b/cli/src/backends/fly.ts @@ -12,6 +12,7 @@ import { promptHidden, readEnvFile, settleAll, + sourceBuildArgs, streamLabeled, which, } from "../util.ts"; @@ -791,7 +792,13 @@ async function deployService( } else if (imageSource?.kind === "manifest") { args.push("--image", ctx.config.imageOverrides[service] ?? manifestRef(service)); } else { - args.push("--remote-only", "--dockerfile", join(ctx.sourceRoot!, "deploy", service, "Dockerfile"), ctx.sourceRoot!); + args.push( + "--remote-only", + "--dockerfile", + join(ctx.sourceRoot!, "deploy", service, "Dockerfile"), + ...sourceBuildArgs(ctx.sourceRoot!, service), + ctx.sourceRoot!, + ); } step(`fly ${args.join(" ")}`); @@ -832,6 +839,7 @@ async function buildServiceImage( "--remote-only", "--dockerfile", join(ctx.sourceRoot!, "deploy", service, "Dockerfile"), + ...sourceBuildArgs(ctx.sourceRoot!, service), ctx.sourceRoot!, ]; step(`fly ${args.join(" ")}`); diff --git a/cli/src/util.ts b/cli/src/util.ts index c3a738c3a..556800102 100644 --- a/cli/src/util.ts +++ b/cli/src/util.ts @@ -49,6 +49,29 @@ export function capture( } } +const sourceBuildInfoByRoot = new Map(); + +export function sourceBuildInfo(root: string): { gitCommit?: string; dirty?: boolean } { + const cached = sourceBuildInfoByRoot.get(root); + if (cached) return cached; + const info: { gitCommit?: string; dirty?: boolean } = {}; + try { + info.gitCommit = capture("git", ["-C", root, "rev-parse", "HEAD"]).trim(); + info.dirty = capture("git", ["-C", root, "status", "--porcelain"]).trim().length > 0; + } catch { + void 0; + } + sourceBuildInfoByRoot.set(root, info); + return info; +} + +export function sourceBuildArgs(root: string, service: string): string[] { + if (service !== "core") return []; + const info = sourceBuildInfo(root); + if (!info.gitCommit) throw new CliError("building core from source requires a Git checkout with a commit"); + return ["--build-arg", `GIT_SHA=${info.gitCommit}${info.dirty ? "-dirty" : ""}`]; +} + export function captureBoth(cmd: string, args: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}): string { const r = spawnSync(cmd, args, { encoding: "utf8", diff --git a/cli/test/aws.test.ts b/cli/test/aws.test.ts index c9086f2cd..70280c28c 100644 --- a/cli/test/aws.test.ts +++ b/cli/test/aws.test.ts @@ -1830,11 +1830,27 @@ test("AWS source builds honor a per-service dockerfile override and stamp GIT_SH `core build uses the override: ${coreBuild}`, ); assert.ok(coreBuild?.includes(`--build-arg GIT_SHA=${head}`), `core build stamps GIT_SHA: ${coreBuild}`); + assert.equal(coreBuild?.match(/--build-arg GIT_SHA=/g)?.length, 1); const webUiBuild = builds.find((line) => line.includes("qm-web-ui")); assert.ok( webUiBuild?.includes(`-f ${join(sourceDir, "deploy", "web-ui", "Dockerfile")}`), `web-ui build keeps the default: ${webUiBuild}`, ); + assert.doesNotMatch(webUiBuild ?? "", /--build-arg GIT_SHA=/); + const reservedConfig: QmConfig = { + ...layeredConfig, + aws: { + ...layeredConfig.aws!, + services: { + ...layeredConfig.aws!.services, + core: { ...layeredConfig.aws!.services.core!, buildArgs: { GIT_SHA: "spoofed" } }, + }, + }, + }; + await assert.rejects( + () => awsUp(reservedConfig, dir, { yes: true, buildFrom: true, buildFromPath: sourceDir }), + /aws\.services\.core\.buildArgs\.GIT_SHA is reserved/, + ); const dirtySource = join(dir, "dirty-source"); for (const service of ["core", "web-ui", "portal"]) { mkdirSync(join(dirtySource, "deploy", service), { recursive: true }); diff --git a/cli/test/docker-secrets.test.ts b/cli/test/docker-secrets.test.ts index 98a34a9aa..f698df25d 100644 --- a/cli/test/docker-secrets.test.ts +++ b/cli/test/docker-secrets.test.ts @@ -2,10 +2,13 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { CONFIG_FILENAME, loadConfigAt } from "../src/config.ts"; import { dockerUp } from "../src/backends/docker.ts"; +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); + const SECRETS = { ANTHROPIC_API_KEY: "anthropic-supersecret", CAPABILITY_SECRET: "capability-supersecret", @@ -137,9 +140,19 @@ test("docker up delivers secrets via a 0600 env-file, never on the docker argv", console.log = (...parts: unknown[]): void => void lines.push(parts.join(" ")); console.warn = console.log; const { config } = loadConfigAt(join(dir, CONFIG_FILENAME)); - await dockerUp(config, dir, {}); + await dockerUp(config, dir, { buildFrom: true, buildFromPath: repoRoot }); const argv = readFileSync(fake.argvLog, "utf8"); + const build = argv + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as string[]) + .find((args) => args[0] === "build" && args.includes("qm-core:local")); + assert.ok(build, "the core image is built from source"); + const stampIndexes = build.flatMap((value, index) => (value === "--build-arg" ? [index] : [])); + const stamps = stampIndexes.map((index) => build[index + 1]!).filter((value) => value.startsWith("GIT_SHA=")); + assert.equal(stamps.length, 1); + assert.match(stamps[0]!, /^GIT_SHA=[0-9a-f]{40}(?:-dirty)?$/); for (const value of Object.values(SECRETS)) { assert.ok(!argv.includes(value), `secret value must not reach the docker argv: ${value}`); } diff --git a/cli/test/e2e/harness.ts b/cli/test/e2e/harness.ts index 7cb88619f..6ef205de7 100644 --- a/cli/test/e2e/harness.ts +++ b/cli/test/e2e/harness.ts @@ -103,6 +103,13 @@ export function standInCheckout(services: readonly string[]): string { mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, "Dockerfile"), STANDIN_DOCKERFILE); } + execFileSync("git", ["init", "-q"], { cwd: root }); + execFileSync("git", ["add", "-A"], { cwd: root }); + execFileSync( + "git", + ["-c", "user.email=test@qm.invalid", "-c", "user.name=QM Test", "commit", "-q", "-m", "stand-in"], + { cwd: root }, + ); return root; } diff --git a/cli/test/fly-timing.test.ts b/cli/test/fly-timing.test.ts index 24888ffc3..b2feb645b 100644 --- a/cli/test/fly-timing.test.ts +++ b/cli/test/fly-timing.test.ts @@ -375,16 +375,17 @@ test("fly up build-only pushes a tagged image without checking runtime deploy se true, "new apps receive only the ownership marker", ); - assert.equal( - commands.some( - (args) => - args[0] === "deploy" && - args.includes("--build-only") && - args.includes("--push") && - args.includes("--image-label"), - ), - true, + const build = commands.find( + (args) => + args[0] === "deploy" && + args.includes("--build-only") && + args.includes("--push") && + args.includes("--image-label"), ); + assert.ok(build); + const stamps = build.filter((value) => value.startsWith("GIT_SHA=")); + assert.equal(stamps.length, 1); + assert.match(stamps[0]!, /^GIT_SHA=[0-9a-f]{40}(?:-dirty)?$/); }); test("fly up build-only dry-run plans without pushing an image", () => { diff --git a/cli/test/fly-up.test.ts b/cli/test/fly-up.test.ts index 135e771e0..d21b4283d 100644 --- a/cli/test/fly-up.test.ts +++ b/cli/test/fly-up.test.ts @@ -130,6 +130,12 @@ else console.log("ok"); assert.match(calls, /secrets unset --stage -a acme-core FLY_DEPLOY_API_TOKEN/); assert.ok(calls.indexOf("secrets unset") < calls.indexOf("deploy")); assert.ok(calls.indexOf("storage create") < calls.indexOf("deploy")); + const deploy = calls + .split("\n") + .find((line) => line.startsWith("deploy ") && line.includes("deploy/core/Dockerfile")); + assert.ok(deploy); + assert.equal(deploy.match(/--build-arg GIT_SHA=/g)?.length, 1); + assert.match(deploy, /--build-arg GIT_SHA=[0-9a-f]{40}(?:-dirty)?(?:\s|$)/); assert.equal( JSON.parse(readFileSync(configPath, "utf8")).imageOverrides.core, `registry.fly.io/acme-core@sha256:${"b".repeat(64)}`, diff --git a/cli/test/util.test.ts b/cli/test/util.test.ts index 617b071fd..736d84b54 100644 --- a/cli/test/util.test.ts +++ b/cli/test/util.test.ts @@ -1,9 +1,40 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { canonicalJson, flyBin, isInvalidSecret, readEnvFile, writeEnvValue } from "../src/util.ts"; +import { canonicalJson, flyBin, isInvalidSecret, readEnvFile, sourceBuildArgs, writeEnvValue } from "../src/util.ts"; + +test("source builds stamp only core from Git provenance and fail closed without a commit", (t) => { + const root = mkdtempSync(join(tmpdir(), "qm-source-build-")); + const dirtyRoot = mkdtempSync(join(tmpdir(), "qm-source-build-dirty-")); + const missing = mkdtempSync(join(tmpdir(), "qm-source-build-missing-")); + t.after(() => { + rmSync(root, { recursive: true, force: true }); + rmSync(dirtyRoot, { recursive: true, force: true }); + rmSync(missing, { recursive: true, force: true }); + }); + const initialize = (directory: string): string => { + mkdirSync(join(directory, "deploy", "core"), { recursive: true }); + writeFileSync(join(directory, "deploy", "core", "Dockerfile"), "FROM scratch\n"); + execFileSync("git", ["init", "-q"], { cwd: directory }); + execFileSync("git", ["add", "-A"], { cwd: directory }); + execFileSync( + "git", + ["-c", "user.email=test@qm.invalid", "-c", "user.name=QM Test", "commit", "-q", "-m", "initial"], + { cwd: directory }, + ); + return execFileSync("git", ["rev-parse", "HEAD"], { cwd: directory, encoding: "utf8" }).trim(); + }; + const head = initialize(root); + assert.deepEqual(sourceBuildArgs(root, "core"), ["--build-arg", `GIT_SHA=${head}`]); + assert.deepEqual(sourceBuildArgs(root, "web-ui"), []); + const dirtyHead = initialize(dirtyRoot); + writeFileSync(join(dirtyRoot, "dirty.txt"), "dirty\n"); + assert.deepEqual(sourceBuildArgs(dirtyRoot, "core"), ["--build-arg", `GIT_SHA=${dirtyHead}-dirty`]); + assert.throws(() => sourceBuildArgs(missing, "core"), /requires a Git checkout with a commit/); +}); test("managed credential encryption keys require strong material", () => { assert.equal(isInvalidSecret("CONNECTOR_SECRET_KEY", "short"), true); diff --git a/plugins/admin/public/index.html b/plugins/admin/public/index.html index becbb6f75..a492603ab 100644 --- a/plugins/admin/public/index.html +++ b/plugins/admin/public/index.html @@ -1265,6 +1265,30 @@ .status.muted { color: var(--muted); } + .custom-provider-test-foot { + flex-wrap: wrap; + } + #st-custom-provider-test:not(:empty) { + flex: 1 0 100%; + padding: 11px 13px; + border: 1px solid var(--border); + border-left: 3px solid currentColor; + border-radius: var(--radius); + background: var(--subtle); + color: var(--text); + line-height: 1.55; + white-space: pre-line; + } + #st-custom-provider-test.ok { + border-left-color: var(--ok); + } + #st-custom-provider-test.err { + border-left-color: var(--danger); + } + #st-custom-provider-test.muted, + #st-custom-provider-test.saving { + border-left-color: var(--muted); + } .center { min-height: 60vh; display: grid; @@ -3923,7 +3947,7 @@

Base model

-
+
@@ -3962,7 +3986,8 @@

Custom providers

@@ -3979,12 +4004,17 @@

Custom providers

placeholder="Write-only; blank on edit keeps the stored key" />
+ > +
+
+ + +

+ Each run sends at most one real billable model request through the selected Harness and caps every + Harness at 128 output tokens. Automatic provider retries are disabled. The result verifies the + response model and shows the endpoint alias, first-token and total latency, streaming status, and + token usage. Each click starts a new test. If the response is lost, retrying from this page reuses the + same durable request receipt for five minutes without another model charge. +

+
+
+
@@ -6136,7 +6197,7 @@

Confirm governance change

window.addEventListener("focus", () => { if ($("app-view").classList.contains("hidden")) return; if (FOCUS_REFRESH_EXEMPT.has(view)) return; - if (Date.now() - (viewLoadedAt[view] || 0) < FOCUS_STALE_MS) return; + if (view !== "onboarding" && Date.now() - (viewLoadedAt[view] || 0) < FOCUS_STALE_MS) return; if (view === "memory" && !(orgWideView() && urlToState().mem !== "edit")) return; if (view === "history" && urlToState().session) return; if (view === "onboarding") { @@ -7234,12 +7295,23 @@

Confirm governance change

: "Write-only API key"; } async function loadOnboarding() { - const [models, slack, catalog, config] = await Promise.all([ - api("GET", "/api/model-providers"), - api("GET", "/api/slack-installation"), - api("GET", "/api/connector-catalog"), - api("GET", "/api/scopes/" + encodeURIComponent(orgScope())), - ]); + const customProviders = loadCustomProviders(); + let setup; + try { + setup = await Promise.all([ + api("GET", "/api/model-providers"), + api("GET", "/api/slack-installation"), + api("GET", "/api/connector-catalog"), + api("GET", "/api/scopes/" + encodeURIComponent(orgScope())), + ]); + } catch { + await customProviders; + setStatus("st-onboarding-model", "Setup status could not be loaded. Try again.", "err", true); + renderOnboardingProviderOptions(); + return; + } + await customProviders; + const [models, slack, catalog, config] = setup; if (!models.ok || !slack.ok || !catalog.ok || !config.ok) { setStatus("st-onboarding-model", "Setup status could not be loaded. Try again.", "err", true); renderOnboardingProviderOptions(); @@ -7313,7 +7385,6 @@

Confirm governance change

$("onboarding-model-save").disabled = false; $("onboarding-model-key").value = ""; await loadOnboarding(); - await loadCustomProviders(); setStatus( "st-onboarding-model", selected.ok ? "Key and base model saved." : "Key saved, but the base model could not be changed.", @@ -7335,28 +7406,305 @@

Confirm governance change

return; } await loadOnboarding(); - await loadCustomProviders(); setStatus("st-onboarding-model", "Provider disabled.", "ok"); }; let customProvidersLoaded = []; + let customProviderTestTargets = []; + let customProviderTestFingerprint = null; + let customProviderTestRun = 0; + let customProviderTestActiveRun = null; + let customProviderTestRetry = null; + let customProviderTestBlockedRetry = null; + let customProviderTestRetryTimer = null; + let customProviderTestStorageAvailable = true; + let customProviderTestStatusOwner = null; + const customProviderTestStorageStatusOwner = {}; + const CUSTOM_PROVIDER_TEST_RETRY_TTL_MS = 5 * 60_000; + const CUSTOM_PROVIDER_TEST_KNOWN_ERRORS = new Set([ + "bad_request", + "not_found", + "missing_api_key", + "provider_not_ready", + "harness_not_supported", + "harness_test_unavailable", + "harness_test_guard_unavailable", + "harness_test_request_conflict", + "harness_test_rollout_incomplete", + "provider_changed_during_test", + "provider_test_failed", + "signed_out", + ]); + let customProvidersReady = false; + let customProvidersLoad = 0; + let customProviderLoadFailed = false; + function showCustomProviderTestStatus(message, kind, owner = null) { + customProviderTestStatusOwner = owner; + setStatus("st-custom-provider-test", message, kind, true); + } + function clearCustomProviderTestResult() { + showCustomProviderTestStatus("", ""); + } + function customProviderTestRetryKey(target, harness) { + return ["qm-custom-provider-test-retry", orgScope(), target.providerId, target.modelId, harness] + .map(encodeURIComponent) + .join(":"); + } + function readCustomProviderTestRetry(identity, target, harness) { + const storageKey = customProviderTestRetryKey(target, harness); + try { + const saved = JSON.parse(localStorage.getItem(storageKey) || "null"); + customProviderTestStorageAvailable = true; + if (saved?.knownResult === true) { + localStorage.removeItem(storageKey); + const remaining = JSON.parse(localStorage.getItem(storageKey) || "null"); + if (remaining?.requestId === saved.requestId) throw new Error("request receipt was not removed"); + customProviderTestStorageAvailable = true; + return null; + } + const expiresAt = Number.isFinite(saved?.expiresAt) + ? saved.expiresAt + : saved?.createdAt + CUSTOM_PROVIDER_TEST_RETRY_TTL_MS; + if ( + saved?.identity === identity && + typeof saved.requestId === "string" && + Number.isFinite(saved.createdAt) && + saved.createdAt <= Date.now() && + Number.isFinite(expiresAt) && + expiresAt > Date.now() + ) { + return { + ...saved, + expiresAt, + retryAt: Number.isFinite(saved.retryAt) ? saved.retryAt : saved.createdAt, + storageKey, + }; + } + localStorage.removeItem(storageKey); + const remaining = JSON.parse(localStorage.getItem(storageKey) || "null"); + if (saved && remaining?.requestId === saved.requestId) throw new Error("request receipt was not removed"); + customProviderTestStorageAvailable = true; + } catch { + customProviderTestStorageAvailable = false; + } + return null; + } + function persistCustomProviderTestRetry(retry) { + const { storageKey, ...saved } = retry; + try { + localStorage.setItem(storageKey, JSON.stringify(saved)); + const verified = JSON.parse(localStorage.getItem(storageKey) || "null"); + if ( + verified?.identity !== saved.identity || + verified?.requestId !== saved.requestId || + verified?.createdAt !== saved.createdAt || + verified?.expiresAt !== saved.expiresAt || + verified?.retryAt !== saved.retryAt + ) { + throw new Error("request receipt did not round-trip"); + } + customProviderTestStorageAvailable = true; + return true; + } catch { + customProviderTestStorageAvailable = false; + return false; + } + } + function loadCustomProviderTestRetry(identity, target, harness) { + const stored = readCustomProviderTestRetry(identity, target, harness); + if (stored) return stored; + const retry = { + identity, + requestId: crypto.randomUUID(), + createdAt: Date.now(), + expiresAt: Date.now() + CUSTOM_PROVIDER_TEST_RETRY_TTL_MS, + retryAt: Date.now(), + storageKey: customProviderTestRetryKey(target, harness), + }; + return persistCustomProviderTestRetry(retry) ? retry : null; + } + function clearCustomProviderTestRetry(knownResult = false) { + const retry = customProviderTestRetry; + if (!retry) return true; + try { + const saved = JSON.parse(localStorage.getItem(retry.storageKey) || "null"); + if (saved?.requestId === retry.requestId) { + if (knownResult) { + localStorage.setItem(retry.storageKey, JSON.stringify({ ...saved, knownResult: true })); + const marked = JSON.parse(localStorage.getItem(retry.storageKey) || "null"); + if (marked?.requestId !== retry.requestId || marked?.knownResult !== true) { + throw new Error("known result marker did not round-trip"); + } + } + localStorage.removeItem(retry.storageKey); + const remaining = JSON.parse(localStorage.getItem(retry.storageKey) || "null"); + if (remaining?.requestId === retry.requestId) throw new Error("request receipt was not removed"); + } + customProviderTestRetry = null; + customProviderTestStorageAvailable = true; + return true; + } catch { + customProviderTestStorageAvailable = false; + return false; + } + } + function customProviderTestIdentity(target, harness) { + return JSON.stringify([target.providerId, target.modelId, harness, target.protocol, target.providerUpdatedAt]); + } + function customProviderTestRetryBlocked() { + const target = customProviderTestTargets[Number($("custom-provider-test-model").value)]; + if (!target || !customProviderTestBlockedRetry) return false; + return ( + customProviderTestBlockedRetry.identity === + customProviderTestIdentity(target, $("custom-provider-test-harness").value) && + customProviderTestBlockedRetry.until > Date.now() + ); + } + function blockCustomProviderTestRetry(identity, retryAt) { + const blocked = { identity, until: retryAt }; + customProviderTestBlockedRetry = blocked; + if (customProviderTestRetryTimer !== null) clearTimeout(customProviderTestRetryTimer); + customProviderTestRetryTimer = setTimeout( + () => { + if (customProviderTestBlockedRetry === blocked) customProviderTestBlockedRetry = null; + if (customProviderTestRetry?.identity === identity && customProviderTestRetry.expiresAt <= blocked.until) { + if (!clearCustomProviderTestRetry()) { + showCustomProviderTestStatus( + "Paid Harness tests are disabled because the expired request receipt could not be removed safely.", + "err", + customProviderTestStorageStatusOwner, + ); + } + } + if (customProviderTestStatusOwner === blocked) clearCustomProviderTestResult(); + customProviderTestRetryTimer = null; + syncCustomProviderTestControls(); + }, + Math.max(1, retryAt - Date.now()), + ); + syncCustomProviderTestControls(); + return blocked.until; + } + function restoreCustomProviderTestRetry() { + if (customProviderTestActiveRun !== null) return; + const target = customProviderTestTargets[Number($("custom-provider-test-model").value)]; + if (!target) return; + const harness = $("custom-provider-test-harness").value; + const identity = customProviderTestIdentity(target, harness); + customProviderTestRetry = readCustomProviderTestRetry(identity, target, harness); + if (!customProviderTestStorageAvailable) { + showCustomProviderTestStatus( + "Paid Harness tests are disabled because this browser cannot safely store request receipts.", + "err", + customProviderTestStorageStatusOwner, + ); + return; + } + if (customProviderTestStatusOwner === customProviderTestStorageStatusOwner) clearCustomProviderTestResult(); + if (!customProviderTestRetry || customProviderTestRetry.retryAt <= Date.now()) { + const blocked = customProviderTestBlockedRetry; + if (blocked && blocked.until <= Date.now()) { + customProviderTestBlockedRetry = null; + if (customProviderTestStatusOwner === blocked) clearCustomProviderTestResult(); + } + return; + } + const retryAt = blockCustomProviderTestRetry(identity, customProviderTestRetry.retryAt); + showCustomProviderTestStatus( + "A prior paid test is still protected. Retry available after " + new Date(retryAt).toLocaleTimeString() + ".", + "saving", + customProviderTestBlockedRetry, + ); + } + function syncCustomProviderTestControls() { + const unavailable = + !customProvidersReady || customProviderTestTargets.length === 0 || !customProviderTestStorageAvailable; + const running = customProviderTestActiveRun !== null; + $("custom-provider-test").disabled = unavailable || running || customProviderTestRetryBlocked(); + $("custom-provider-test-model").disabled = unavailable || running; + $("custom-provider-test-harness").disabled = unavailable || running; + } 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 [id, name, contextWindow, maxTokens, upstreamId, inputModalities] = 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); + if (upstreamId) model.upstreamId = upstreamId; + if (inputModalities) model.inputModalities = inputModalities.split(",").map((part) => part.trim()); 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 load = ++customProvidersLoad; + customProvidersReady = false; + syncCustomProviderTestControls(); + let res; + try { + res = await api("GET", "/api/custom-providers"); + } catch { + if (load !== customProvidersLoad) return false; + clearCustomProviderTestResult(); + customProviderLoadFailed = true; + setStatus("st-custom-provider", "Custom model providers could not be loaded. Try again.", "err", true); + return false; + } + if (load !== customProvidersLoad) return false; + if (!res.ok) { + clearCustomProviderTestResult(); + customProviderLoadFailed = true; + setStatus( + "st-custom-provider", + res.data?.message || "Custom model providers could not be loaded. Try again.", + "err", + true, + ); + return false; + } + if (customProviderLoadFailed) { + setStatus("st-custom-provider", "", "", true); + customProviderLoadFailed = false; + } + const nextProviders = (res.data?.providers || []).filter((provider) => !provider.disabled); + const nextFingerprint = JSON.stringify(nextProviders); + if (customProviderTestFingerprint !== null && customProviderTestFingerprint !== nextFingerprint) { + clearCustomProviderTestResult(); + } + customProviderTestFingerprint = nextFingerprint; + const selectedTarget = customProviderTestTargets[Number($("custom-provider-test-model").value)]; + customProvidersLoaded = nextProviders; + customProviderTestTargets = customProvidersLoaded.flatMap((provider) => + provider.models.map((model) => ({ + providerId: provider.id, + providerName: provider.name, + protocol: provider.protocol, + providerUpdatedAt: provider.updatedAt, + modelId: model.id, + upstreamModelId: model.upstreamId || model.id, + })), + ); + const testSelect = $("custom-provider-test-model"); + testSelect.textContent = ""; + customProviderTestTargets.forEach((target, index) => { + const option = document.createElement("option"); + option.value = String(index); + option.textContent = target.providerName + " · " + target.modelId; + testSelect.appendChild(option); + }); + const selectedIndex = customProviderTestTargets.findIndex( + (target) => target.providerId === selectedTarget?.providerId && target.modelId === selectedTarget?.modelId, + ); + if (selectedIndex >= 0) testSelect.value = String(selectedIndex); + syncCustomProviderTestHarnesses(); + customProvidersReady = true; + restoreCustomProviderTestRetry(); + syncCustomProviderTestControls(); const rows = $("custom-provider-rows"); rows.textContent = ""; $("custom-provider-empty").hidden = customProvidersLoaded.length > 0; @@ -7364,9 +7712,15 @@

Confirm governance change

const tr = document.createElement("tr"); const cells = [ provider.name + " (" + provider.id + ")", - provider.protocol === "anthropic" ? "Anthropic" : "OpenAI", + { + anthropic: "Anthropic", + openai: "OpenAI Chat Completions", + "openai-responses": "OpenAI Responses", + }[provider.protocol] || provider.protocol, provider.baseUrl, - provider.models.map((model) => model.id).join(", "), + provider.models + .map((model) => (model.upstreamId ? model.id + " → " + model.upstreamId : model.id)) + .join(", "), provider.hasKey ? "set (write-only)" : "none", ]; cells.forEach((textContent) => { @@ -7385,7 +7739,17 @@

Confirm governance change

$("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(" | "), + [ + model.id, + model.name, + model.contextWindow, + model.maxTokens, + model.upstreamId, + model.inputModalities?.join(","), + ] + .map((part) => part ?? "") + .join(" | ") + .replace(/(?: \| )+$/, ""), ) .join("\n"); }; @@ -7399,15 +7763,138 @@

Confirm governance change

setStatus("st-custom-provider", removed.data?.message || "Could not remove this provider.", "err"); return; } - await loadCustomProviders(); - setStatus("st-custom-provider", "Provider removed.", "ok"); + clearCustomProviderTestResult(); + if (await loadCustomProviders()) { + setStatus("st-custom-provider", "Provider removed.", "ok"); + } }; actions.appendChild(edit); actions.appendChild(remove); tr.appendChild(actions); rows.appendChild(tr); }); + return true; + } + function customProviderTestEvidenceText(data) { + const testedRevision = Number.isSafeInteger(data?.providerRevision) + ? "saved revision " + data.providerRevision + " · " + : ""; + const testedAt = Number.isFinite(data?.testedAt) + ? "tested " + new Date(data.testedAt).toLocaleString() + " · " + : ""; + return testedRevision + testedAt; + } + function customProviderTestResultText(data, target) { + const testedModelId = String(data?.modelId || target.modelId); + const usage = data.usage; + return ( + (data.cached ? "Recent saved result · no new model charge · " : "") + + customProviderTestEvidenceText(data) + + data.endpointAlias + + " endpoint · " + + data.harness + + " · " + + testedModelId + + " · requested " + + data.requestedModel + + " · response " + + data.responseModel + + "\nFirst token " + + data.firstTokenMs + + " ms · provider " + + data.providerTotalMs + + " ms · total " + + data.latencyMs + + " ms · " + + (data.streamed ? "stream verified" : "not streamed") + + (data.noDefaultEgress ? " · custom endpoint verified" : "") + + "\nUsage " + + usage.inputTokens + + " input / " + + usage.outputTokens + + " output / " + + usage.totalTokens + + " total · cache read " + + usage.cachedInputTokens + + " / cache write " + + usage.cacheCreationInputTokens + + " · output cap " + + data.maxOutputTokens + + " · generation verified" + ); + } + function customProviderTestUsageKnown(usage, maxOutputTokens) { + return ( + usage && + Number.isSafeInteger(usage.inputTokens) && + usage.inputTokens > 0 && + Number.isSafeInteger(usage.outputTokens) && + usage.outputTokens > 0 && + usage.outputTokens <= maxOutputTokens && + Number.isSafeInteger(usage.totalTokens) && + usage.totalTokens >= usage.inputTokens + usage.outputTokens && + Number.isSafeInteger(usage.cachedInputTokens) && + usage.cachedInputTokens >= 0 && + Number.isSafeInteger(usage.cacheCreationInputTokens) && + usage.cacheCreationInputTokens >= 0 && + usage.cachedInputTokens + usage.cacheCreationInputTokens <= usage.inputTokens + ); + } + function customProviderTestResponseKnown(tested, requestId, target, harness) { + const data = tested.data; + if (!tested.ok && tested.status === 401 && data?.error === "signed_out") return true; + if (!data || data.requestId !== requestId) return false; + if (!tested.ok) { + return ( + CUSTOM_PROVIDER_TEST_KNOWN_ERRORS.has(data.error) || + (data.error === "harness_test_in_progress" && data.replayExpected === false) + ); + } + return ( + data.ok === true && + data.providerId === target.providerId && + data.modelId === target.modelId && + data.harness === harness && + typeof data.upstreamModelId === "string" && + data.requestedModel === data.upstreamModelId && + data.responseModel === data.upstreamModelId && + data.endpointAlias === target.providerName && + typeof data.reply === "string" && + Number.isFinite(data.latencyMs) && + Number.isSafeInteger(data.firstTokenMs) && + data.firstTokenMs >= 1 && + Number.isSafeInteger(data.providerTotalMs) && + data.providerTotalMs >= data.firstTokenMs && + data.streamed === true && + data.upstreamRequests === 1 && + data.noDefaultEgress === true && + Number.isSafeInteger(data.maxOutputTokens) && + data.maxOutputTokens > 0 && + data.maxOutputTokens <= 128 && + customProviderTestUsageKnown(data.usage, data.maxOutputTokens) && + Number.isSafeInteger(data.providerRevision) && + Number.isFinite(data.testedAt) + ); + } + function syncCustomProviderTestHarnesses() { + const target = customProviderTestTargets[Number($("custom-provider-test-model").value)]; + const select = $("custom-provider-test-harness"); + Array.from(select.options).forEach((option) => { + option.disabled = option.value === "codex" && target?.protocol !== "openai-responses"; + }); + if (select.selectedOptions[0]?.disabled) select.value = "pi"; } + $("custom-provider-test-model").onchange = () => { + syncCustomProviderTestHarnesses(); + clearCustomProviderTestResult(); + restoreCustomProviderTestRetry(); + syncCustomProviderTestControls(); + }; + $("custom-provider-test-harness").onchange = () => { + clearCustomProviderTestResult(); + restoreCustomProviderTestRetry(); + syncCustomProviderTestControls(); + }; $("custom-provider-save").onclick = async () => { const id = $("custom-provider-id").value.trim(); const name = $("custom-provider-name").value.trim(); @@ -7435,8 +7922,153 @@

Confirm governance change

return; } $("custom-provider-key").value = ""; - await loadCustomProviders(); - setStatus("st-custom-provider", "Provider saved. Its models are now in the picker.", "ok"); + clearCustomProviderTestResult(); + if (await loadCustomProviders()) { + setStatus("st-custom-provider", "Provider saved. Its models are now in the picker.", "ok"); + } + }; + $("custom-provider-test").onclick = async () => { + if ( + customProviderTestActiveRun !== null || + customProviderTestRetryBlocked() || + !customProviderTestStorageAvailable + ) + return; + const target = customProviderTestTargets[Number($("custom-provider-test-model").value)]; + if (!target) { + showCustomProviderTestStatus("Save a provider and model first.", "err"); + return; + } + const harness = $("custom-provider-test-harness").value; + const retryIdentity = customProviderTestIdentity(target, harness); + if (customProviderTestRetry?.identity !== retryIdentity || customProviderTestRetry.expiresAt <= Date.now()) { + customProviderTestRetry = loadCustomProviderTestRetry(retryIdentity, target, harness); + } + if (!customProviderTestRetry || !persistCustomProviderTestRetry(customProviderTestRetry)) { + syncCustomProviderTestControls(); + showCustomProviderTestStatus( + "No model request was sent because this browser could not safely store its paid-test receipt.", + "err", + customProviderTestStorageStatusOwner, + ); + return; + } + const requestId = customProviderTestRetry.requestId; + const run = ++customProviderTestRun; + customProviderTestActiveRun = run; + syncCustomProviderTestControls(); + showCustomProviderTestStatus("Running a real paid " + harness + " test turn…", "saving"); + let tested; + try { + tested = await api( + "POST", + "/api/custom-providers/" + encodeURIComponent(target.providerId) + "/harness-test", + { + modelId: target.modelId, + harness, + requestId, + }, + ); + } catch { + customProviderTestRetry.retryAt = Math.min(customProviderTestRetry.expiresAt, Date.now() + 2_000); + if (!persistCustomProviderTestRetry(customProviderTestRetry)) { + syncCustomProviderTestControls(); + showCustomProviderTestStatus( + "The paid outcome is unknown and its receipt could not be saved. Harness tests are disabled in this browser.", + "err", + customProviderTestStorageStatusOwner, + ); + return; + } + const retryAt = blockCustomProviderTestRetry(retryIdentity, customProviderTestRetry.retryAt); + showCustomProviderTestStatus( + "The generation test could not reach QM, so its paid outcome is unknown. Do not retry before " + + new Date(retryAt).toLocaleTimeString() + + ".", + "err", + customProviderTestBlockedRetry, + ); + return; + } finally { + if (customProviderTestActiveRun === run) customProviderTestActiveRun = null; + syncCustomProviderTestControls(); + } + const explicitlyUnresolved = + (tested.data?.error === "harness_test_in_progress" && tested.data?.replayExpected !== false) || + tested.data?.error === "harness_test_result_unresolved" || + tested.data?.error === "harness_test_result_not_durable" || + tested.data?.error === "core_unreachable"; + const outcomeUnknown = + explicitlyUnresolved || !customProviderTestResponseKnown(tested, requestId, target, harness); + const retryAfterMs = Number(tested.data?.retryAfterMs); + let retryAvailableAt = null; + if (outcomeUnknown) { + const requestExpiresInMs = Number(tested.data?.requestExpiresInMs); + customProviderTestRetry.expiresAt = + Number.isFinite(requestExpiresInMs) && requestExpiresInMs > 0 + ? Date.now() + requestExpiresInMs + : customProviderTestRetry.expiresAt; + customProviderTestRetry.retryAt = + Number.isFinite(retryAfterMs) && retryAfterMs > 0 + ? Math.min(customProviderTestRetry.expiresAt, Date.now() + retryAfterMs) + : Math.min(customProviderTestRetry.expiresAt, Date.now() + 2_000); + if (!persistCustomProviderTestRetry(customProviderTestRetry)) { + syncCustomProviderTestControls(); + showCustomProviderTestStatus( + "The paid outcome is unknown and its receipt could not be saved. Harness tests are disabled in this browser.", + "err", + customProviderTestStorageStatusOwner, + ); + return; + } + retryAvailableAt = blockCustomProviderTestRetry(retryIdentity, customProviderTestRetry.retryAt); + } else if (!tested.ok && Number.isFinite(retryAfterMs) && retryAfterMs > 0) { + retryAvailableAt = blockCustomProviderTestRetry(retryIdentity, Date.now() + retryAfterMs); + } + if (outcomeUnknown) { + showCustomProviderTestStatus( + (tested.data?.message || + "QM returned an unverified response, so this paid test's outcome is still unknown.") + + (retryAvailableAt + ? " Retry the same saved request after " + new Date(retryAvailableAt).toLocaleTimeString() + "." + : ""), + "err", + customProviderTestBlockedRetry, + ); + return; + } + const receiptCleared = clearCustomProviderTestRetry(true); + if (!receiptCleared) syncCustomProviderTestControls(); + const evidence = customProviderTestEvidenceText(tested.data); + if (!tested.ok) { + showCustomProviderTestStatus( + (tested.data?.cached ? "Recent saved result · no new model charge · " : "") + + evidence + + (tested.data?.message || + (tested.data?.error === "signed_out" + ? "You are signed out. No model request was sent." + : "The generation test failed.")) + + (retryAvailableAt + ? " Retry available after " + new Date(retryAvailableAt).toLocaleTimeString() + "." + : "") + + (receiptCleared + ? "" + : " The result is known, but receipt cleanup could not be verified; further paid tests are disabled until browser storage recovers."), + "err", + receiptCleared ? customProviderTestBlockedRetry : customProviderTestStorageStatusOwner, + ); + return; + } + if (!receiptCleared) { + showCustomProviderTestStatus( + customProviderTestResultText(tested.data, target) + + " Receipt cleanup could not be verified; further paid tests are disabled until browser storage recovers.", + "err", + customProviderTestStorageStatusOwner, + ); + return; + } + showCustomProviderTestStatus(customProviderTestResultText(tested.data, target), "ok"); }; function openOnboardingTarget(target) { setView("connectors"); diff --git a/plugins/admin/src/index.ts b/plugins/admin/src/index.ts index 8bd13f704..fad106435 100644 --- a/plugins/admin/src/index.ts +++ b/plugins/admin/src/index.ts @@ -127,8 +127,11 @@ async function forward( }, ...(body ? { body } : {}), }); + const responseHeaders: Record = { "content-type": "application/json" }; + const retryAfter = r.headers.get("retry-after"); + if (retryAfter) responseHeaders["retry-after"] = retryAfter; if (r.body && acceptsGzip(req)) { - res.writeHead(r.status, { "content-type": "application/json", "content-encoding": "gzip" }); + res.writeHead(r.status, { ...responseHeaders, "content-encoding": "gzip" }); const src = Readable.fromWeb(r.body as Parameters[0]); const gz = createGzip(); src.on("error", () => res.destroy()); @@ -142,7 +145,7 @@ async function forward( src.pipe(gz).pipe(res); return; } - res.writeHead(r.status, { "content-type": "application/json" }); + res.writeHead(r.status, responseHeaders); pipeBody(res, r.body); } catch (err) { console.error("[admin] core request failed:", String(err)); @@ -289,7 +292,7 @@ const WRITES = new Map([ ["users", ["PUT", "POST"]], ["slack-installation", ["PUT", "DELETE"]], ["model-providers", ["PUT", "DELETE"]], - ["custom-providers", ["PUT", "DELETE"]], + ["custom-providers", ["PUT", "POST", "DELETE"]], ]); const READS = [ diff --git a/plugins/admin/test/custom-providers.test.ts b/plugins/admin/test/custom-providers.test.ts new file mode 100644 index 000000000..f54790a9f --- /dev/null +++ b/plugins/admin/test/custom-providers.test.ts @@ -0,0 +1,81 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createServer, type IncomingMessage } from "node:http"; +import type { AddressInfo } from "node:net"; + +const calls: { method: string; url: string; actor: string | null; signed: boolean; body: string }[] = []; +const core = createServer((req: IncomingMessage, res) => { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { + calls.push({ + method: req.method ?? "", + url: req.url ?? "", + actor: (req.headers["x-admin-actor"] as string) ?? null, + signed: Boolean(req.headers["x-timestamp"] && req.headers["x-signature"]), + body, + }); + if ((JSON.parse(body || "{}") as { requestId?: string }).requestId === "request-busy") { + res.writeHead(409, { "content-type": "application/json", "retry-after": "12" }); + res.end(JSON.stringify({ error: "harness_test_in_progress", retryAfterMs: 12_000 })); + return; + } + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true, modelId: "gpt-5.6-luna", reply: "ready", latencyMs: 1 })); + }); +}); +await new Promise((resolve) => core.listen(0, resolve)); +const corePort = (core.address() as AddressInfo).port; + +process.env.CORE_API_URL = `http://localhost:${corePort}`; +process.env.CORE_SIGNING_SECRET = "admin-custom-provider-proxy-secret"; +process.env.NODE_ENV = "test"; +process.env.ALLOW_UNSIGNED_TEST_IDENTITY = "1"; + +const { server } = await import("../src/index.ts"); +await new Promise((resolve) => server.listen(0, resolve)); +const base = `http://localhost:${(server.address() as AddressInfo).port}`; +test.after(() => { + server.close(); + if (core.listening) core.close(); +}); + +test("POST /api/custom-providers/:id/harness-test forwards the paid model test with admin attribution", async () => { + const response = await fetch(`${base}/api/custom-providers/gateway/harness-test`, { + method: "POST", + headers: { cookie: "admin=U-admin", "content-type": "application/json" }, + body: JSON.stringify({ modelId: "gpt-5.6-luna", harness: "codex", requestId: "request-paid" }), + }); + assert.equal(response.status, 200); + const call = calls.at(-1)!; + assert.equal(call.method, "POST"); + assert.equal(call.url, "/v1/admin/custom-providers/gateway/harness-test"); + assert.equal(call.actor, "U-admin@acme"); + assert.equal(call.signed, true); + assert.deepEqual(JSON.parse(call.body), { + modelId: "gpt-5.6-luna", + harness: "codex", + requestId: "request-paid", + }); +}); + +test("the paid model test forwards the core retry window", async () => { + const response = await fetch(`${base}/api/custom-providers/gateway/harness-test`, { + method: "POST", + headers: { cookie: "admin=U-admin", "content-type": "application/json" }, + body: JSON.stringify({ modelId: "gpt-5.6-luna", harness: "pi", requestId: "request-busy" }), + }); + assert.equal(response.status, 409); + assert.equal(response.headers.get("retry-after"), "12"); +}); + +test("the paid model test rejects signed-out callers before reaching core", async () => { + const before = calls.length; + const response = await fetch(`${base}/api/custom-providers/gateway/harness-test`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ modelId: "gpt-5.6-luna" }), + }); + assert.equal(response.status, 401); + assert.equal(calls.length, before); +}); diff --git a/plugins/admin/test/onboarding-view.test.ts b/plugins/admin/test/onboarding-view.test.ts index c7fe9b4fc..f885afc03 100644 --- a/plugins/admin/test/onboarding-view.test.ts +++ b/plugins/admin/test/onboarding-view.test.ts @@ -28,6 +28,261 @@ function resolveView(pathname: string, search: string): string { return vm.runInContext(src, context); } +function renderCustomProviderTestResult(data: object, target: object): string { + const src = slice("function customProviderTestEvidenceText(data) {", "function syncCustomProviderTestHarnesses()"); + const context = vm.createContext({ data, target }); + return vm.runInContext(`${src}\ncustomProviderTestResultText(data, target);`, context); +} + +type ApiResponse = { ok: boolean; status?: number; data?: Record }; +type ApiHandler = (method: string, path: string, body?: unknown) => Promise; + +const TEST_MODEL_EVIDENCE = { + requestedModel: "gpt-5.6-luna", + responseModel: "gpt-5.6-luna", + endpointAlias: "Gateway", + firstTokenMs: 12, + providerTotalMs: 40, + usage: { + inputTokens: 5, + outputTokens: 3, + totalTokens: 8, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + }, + streamed: true, + upstreamRequests: 1, + noDefaultEgress: true, + maxOutputTokens: 128, +}; + +function successfulHarnessResponse(requestId: string, overrides: Record = {}): ApiResponse { + return { + ok: true, + status: 200, + data: { + ok: true, + requestId, + providerId: "gateway", + modelId: "luna", + upstreamModelId: "gpt-5.6-luna", + harness: "pi", + reply: "ready", + latencyMs: 42, + ...TEST_MODEL_EVIDENCE, + providerRevision: 1, + testedAt: Date.UTC(2026, 7, 24), + ...overrides, + }, + }; +} + +type FakeElement = { + tagName: string; + value: string; + textContent: string; + className: string; + disabled: boolean; + hidden: boolean; + checked: boolean; + placeholder: string; + options: FakeElement[]; + children: FakeElement[]; + selectedOptions: FakeElement[]; + onclick?: () => unknown; + onchange?: () => unknown; + appendChild(child: FakeElement): FakeElement; + append(...children: FakeElement[]): void; +}; + +function fakeElement(tagName = "div"): FakeElement { + let textContent = ""; + const element = { + tagName, + value: "", + textContent: "", + className: "", + disabled: false, + hidden: false, + checked: false, + placeholder: "", + options: [], + children: [], + selectedOptions: [], + appendChild(child: FakeElement) { + this.children.push(child); + if (this.tagName === "select") { + this.options.push(child); + if (this.options.length === 1) this.value = child.value; + } + return child; + }, + append(...children: FakeElement[]) { + children.forEach((child) => this.appendChild(child)); + }, + } as FakeElement; + Object.defineProperty(element, "textContent", { + get: () => textContent, + set: (value: string) => { + textContent = value; + if (tagName === "select" && value === "") { + element.options.length = 0; + element.children.length = 0; + element.value = ""; + } + }, + }); + Object.defineProperty(element, "selectedOptions", { + get: () => element.options.filter((option) => option.value === element.value).slice(0, 1), + }); + return element; +} + +function customProvider(updatedAt: number, models = ["luna"]): Record { + return { + id: "gateway", + name: "Gateway", + protocol: "openai-responses", + baseUrl: "https://models.example/v1", + hasKey: true, + disabled: false, + updatedAt, + updatedBy: "admin", + models: models.map((id) => ({ id, upstreamId: "gpt-5.6-" + id })), + }; +} + +let nextFakeRequestId = 0; + +function createCustomProviderUi( + initialApi: ApiHandler, + retryStorage = new Map(), + storageFailure: { get?: boolean; remove?: boolean; set?: boolean } = {}, +) { + const elements = new Map(); + const element = (id: string, tagName = "div") => { + if (!elements.has(id)) elements.set(id, fakeElement(tagName)); + return elements.get(id)!; + }; + const modelSelect = element("custom-provider-test-model", "select"); + const harnessSelect = element("custom-provider-test-harness", "select"); + modelSelect.disabled = true; + harnessSelect.disabled = true; + ["pi", "opencode", "codex"].forEach((value) => { + const option = fakeElement("option"); + option.value = value; + harnessSelect.appendChild(option); + }); + harnessSelect.value = "pi"; + element("custom-provider-test", "button").disabled = true; + element("custom-provider-rows", "tbody"); + element("custom-provider-empty"); + element("custom-provider-save", "button"); + element("custom-provider-id", "input"); + element("custom-provider-name", "input"); + element("custom-provider-protocol", "select").value = "openai-responses"; + element("custom-provider-url", "input"); + element("custom-provider-key", "input"); + element("custom-provider-models", "textarea"); + element("custom-provider-validate", "input").checked = true; + element("st-custom-provider"); + element("st-custom-provider-test"); + const statuses: Array<{ id: string; message: string; kind: string; sticky: boolean }> = []; + const timers = new Map void>(); + let nextTimer = 0; + let apiHandler = initialApi; + const source = slice("let customProvidersLoaded = [];", "function openOnboardingTarget(target) {"); + const context = vm.createContext({ + $: (id: string) => element(id), + api: (method: string, path: string, body?: unknown) => apiHandler(method, path, body), + crypto: { randomUUID: () => `request-${++nextFakeRequestId}` }, + confirm: () => true, + document: { createElement: (tagName: string) => fakeElement(tagName) }, + localStorage: { + getItem: (key: string) => { + if (storageFailure.get) throw new Error("storage unavailable"); + return retryStorage.get(key) ?? null; + }, + removeItem: (key: string) => { + if (storageFailure.remove) throw new Error("storage unavailable"); + return retryStorage.delete(key); + }, + setItem: (key: string, value: string) => { + if (storageFailure.set) throw new Error("storage unavailable"); + retryStorage.set(key, value); + }, + }, + orgScope: () => "org:acme", + clearTimeout: (id: number) => timers.delete(id), + setTimeout: (callback: () => void) => { + const id = ++nextTimer; + timers.set(id, callback); + return id; + }, + setStatus: (id: string, message: string, kind: string, sticky = false) => { + const target = element(id); + target.textContent = message; + target.className = "status " + (kind || ""); + statuses.push({ id, message, kind, sticky }); + }, + }); + const ui = vm.runInContext( + `${source}\n({ + loadCustomProviders, + runTest: () => $("custom-provider-test").onclick(), + saveProvider: () => $("custom-provider-save").onclick(), + });`, + context, + ) as { + loadCustomProviders(): Promise; + runTest(): Promise; + saveProvider(): Promise; + }; + return { + ui, + modelSelect, + harnessSelect, + testButton: element("custom-provider-test"), + providerStatus: element("st-custom-provider"), + testStatus: element("st-custom-provider-test"), + providerRows: element("custom-provider-rows"), + retryStorage, + statuses, + fireTimers() { + const callbacks = [...timers.values()]; + timers.clear(); + callbacks.forEach((callback) => callback()); + }, + setProviderForm() { + element("custom-provider-id").value = "gateway"; + element("custom-provider-name").value = "Gateway"; + element("custom-provider-url").value = "https://models.example/v1"; + element("custom-provider-models").value = "luna | Luna | 1000 | 128 | gpt-5.6-luna | text,image"; + }, + setApi(handler: ApiHandler) { + apiHandler = handler; + }, + }; +} + +async function onboardingCustomProviderLoadsWhenSetupFails(apiHandler: ApiHandler): Promise { + let customProviderLoads = 0; + const source = slice("async function loadOnboarding() {", '$("onboarding-model-provider").onchange'); + const context = vm.createContext({ + api: apiHandler, + encodeURIComponent, + loadCustomProviders: async () => { + customProviderLoads += 1; + return true; + }, + orgScope: () => "org:acme", + renderOnboardingProviderOptions: () => undefined, + setStatus: () => undefined, + }); + await (vm.runInContext(`${source}\nloadOnboarding();`, context) as Promise); + return customProviderLoads; +} + test("onboarding is a navigable view", () => { assert.match(html, /\{ label: "Admin", views: \["onboarding",/); }); @@ -43,3 +298,668 @@ test("?view=onboarding resolves to the onboarding view", () => { test("unknown views still fall back to the default view", () => { assert.equal(resolveView("/admin/no-such-view", ""), "history"); }); + +test("custom provider setup exposes an explicit paid generation test", () => { + assert.match(html, /id="custom-provider-test-model"/); + assert.match(html, /id="custom-provider-test-harness"/); + assert.match(html, /id="custom-provider-test-model" disabled/); + assert.match(html, /id="custom-provider-test-harness" disabled/); + assert.match(html, /id="custom-provider-test" disabled>Run paid Harness test/); + assert.match(html, /value="openai">OpenAI Chat Completions/); + assert.match(html, /value="openai-responses">OpenAI Responses \(Codex-compatible\)/); + assert.match(html, /at most one real billable model request through the selected Harness/); + assert.match(html, /caps every\s+Harness at 128 output tokens/s); + assert.match(html, /first-token and total latency, streaming status, and\s+token usage/s); + assert.match(html, /Automatic provider retries are\s+disabled/); + assert.match(html, /Each click starts a new test/); + assert.match(html, /same durable request receipt for five minutes without another model charge/); + assert.match(html, /modelId: target\.modelId,\s*harness,\s*requestId,/); + assert.match(html, /id="st-custom-provider"\s+role="status"\s+aria-live="polite"\s+aria-atomic="true"/); + assert.match(html, /id="st-custom-provider-test" role="status" aria-live="polite"/); + assert.match(html, /showCustomProviderTestStatus\(customProviderTestResultText\(tested\.data, target\), "ok"\);/); +}); + +test("custom providers load once when an unrelated onboarding request fails", async () => { + const failedResponseLoads = await onboardingCustomProviderLoadsWhenSetupFails(async (_method, path) => ({ + ok: path !== "/api/slack-installation", + data: {}, + })); + assert.equal(failedResponseLoads, 1); + + const networkFailureLoads = await onboardingCustomProviderLoadsWhenSetupFails(async (_method, path) => { + if (path === "/api/connector-catalog") throw new Error("offline"); + return { ok: true, data: {} }; + }); + assert.equal(networkFailureLoads, 1); +}); + +test("custom provider paid test stays locked across refresh and rejects a duplicate run", async () => { + let postCalls = 0; + let postRequestId = ""; + let resolvePost!: (response: ApiResponse) => void; + const delayedPost = new Promise((resolve) => { + resolvePost = resolve; + }); + const listing = { ok: true, data: { providers: [customProvider(1)] } }; + const harness = createCustomProviderUi(async (method, _path, body) => { + if (method === "GET") return listing; + postCalls += 1; + postRequestId = (body as { requestId: string }).requestId; + return delayedPost; + }); + + await harness.ui.loadCustomProviders(); + assert.equal(harness.testButton.disabled, false); + const running = harness.ui.runTest(); + assert.equal(harness.testButton.disabled, true); + assert.equal(harness.modelSelect.disabled, true); + assert.equal(harness.harnessSelect.disabled, true); + await harness.ui.loadCustomProviders(); + assert.equal(harness.testButton.disabled, true); + assert.equal(harness.modelSelect.disabled, true); + assert.equal(harness.harnessSelect.disabled, true); + await harness.ui.runTest(); + assert.equal(postCalls, 1); + + resolvePost(successfulHarnessResponse(postRequestId)); + await running; + assert.equal(harness.testButton.disabled, false); + assert.equal(harness.modelSelect.disabled, false); + assert.equal(harness.harnessSelect.disabled, false); + assert.match( + harness.testStatus.textContent, + /Gateway endpoint · pi · luna · requested gpt-5\.6-luna · response gpt-5\.6-luna\nFirst token 12 ms · provider 40 ms · total 42 ms · stream verified · custom endpoint verified\nUsage 5 input \/ 3 output \/ 8 total · cache read 0 \/ cache write 0 · output cap 128 · generation verified$/, + ); + assert.equal(harness.statuses.at(-1)?.sticky, true); +}); + +test("custom provider load errors clear after a successful refresh", async () => { + let fails = true; + const harness = createCustomProviderUi(async () => + fails + ? { ok: false, data: { message: "temporary failure" } } + : { ok: true, data: { providers: [customProvider(1)] } }, + ); + + assert.equal(await harness.ui.loadCustomProviders(), false); + assert.equal(harness.providerStatus.textContent, "temporary failure"); + fails = false; + assert.equal(await harness.ui.loadCustomProviders(), true); + assert.equal(harness.providerStatus.textContent, ""); + assert.equal(harness.testButton.disabled, false); +}); + +test("custom provider save does not overwrite a failed refresh with a success", async () => { + let savedBody: unknown; + const harness = createCustomProviderUi(async (method, _path, body) => { + if (method === "PUT") { + savedBody = body; + return { ok: true, data: {} }; + } + return { ok: false, data: { message: "refresh failed" } }; + }); + harness.setProviderForm(); + + await harness.ui.saveProvider(); + + assert.deepEqual(JSON.parse(JSON.stringify((savedBody as { models: unknown }).models)), [ + { + id: "luna", + name: "Luna", + contextWindow: 1000, + maxTokens: 128, + upstreamId: "gpt-5.6-luna", + inputModalities: ["text", "image"], + }, + ]); + assert.equal(harness.providerStatus.textContent, "refresh failed"); + assert.equal(harness.providerStatus.className, "status err"); +}); + +test("custom provider removal does not overwrite a failed refresh with a success", async () => { + let listingCalls = 0; + const harness = createCustomProviderUi(async (method) => { + if (method === "DELETE") return { ok: true, data: {} }; + listingCalls += 1; + return listingCalls === 1 + ? { ok: true, data: { providers: [customProvider(1)] } } + : { ok: false, data: { message: "refresh failed" } }; + }); + await harness.ui.loadCustomProviders(); + const row = harness.providerRows.children[0]; + const actions = row?.children.at(-1); + const remove = actions?.children.at(-1); + assert.ok(remove?.onclick); + + await remove.onclick(); + + assert.equal(harness.providerStatus.textContent, "refresh failed"); + assert.equal(harness.providerStatus.className, "status err"); +}); + +test("custom provider network retry reuses its request receipt, then a new click gets a new receipt", async () => { + const requestIds: string[] = []; + let posts = 0; + const harness = createCustomProviderUi(async (method, _path, body) => { + if (method === "GET") return { ok: true, data: { providers: [customProvider(1)] } }; + posts += 1; + requestIds.push((body as { requestId: string }).requestId); + if (posts === 1) throw new Error("response lost"); + return successfulHarnessResponse(requestIds.at(-1)!); + }); + + await harness.ui.loadCustomProviders(); + await harness.ui.runTest(); + harness.fireTimers(); + await harness.ui.runTest(); + await harness.ui.runTest(); + assert.equal(requestIds[0], requestIds[1]); + assert.notEqual(requestIds[1], requestIds[2]); +}); + +test("custom provider response-loss receipt survives a page reload", async () => { + const retryStorage = new Map(); + const requestIds: string[] = []; + const first = createCustomProviderUi(async (method, _path, body) => { + if (method === "GET") return { ok: true, data: { providers: [customProvider(1)] } }; + requestIds.push((body as { requestId: string }).requestId); + throw new Error("response lost"); + }, retryStorage); + await first.ui.loadCustomProviders(); + await first.ui.runTest(); + assert.doesNotMatch([...retryStorage.values()].join(""), /models\.example/); + + const reloaded = createCustomProviderUi(async (method, _path, body) => { + if (method === "GET") return { ok: true, data: { providers: [customProvider(1)] } }; + requestIds.push((body as { requestId: string }).requestId); + return successfulHarnessResponse(requestIds.at(-1)!, { cached: true }); + }, retryStorage); + await reloaded.ui.loadCustomProviders(); + assert.equal(reloaded.testButton.disabled, true); + reloaded.fireTimers(); + await reloaded.ui.runTest(); + assert.equal(requestIds[0], requestIds[1]); + assert.equal(retryStorage.size, 0); +}); + +test("custom provider proxy failure keeps the request receipt because the paid outcome is unknown", async () => { + const requestIds: string[] = []; + let posts = 0; + const harness = createCustomProviderUi(async (method, _path, body) => { + if (method === "GET") return { ok: true, data: { providers: [customProvider(1)] } }; + posts += 1; + requestIds.push((body as { requestId: string }).requestId); + return posts === 1 + ? { ok: false, data: { error: "core_unreachable", message: "core unavailable" } } + : successfulHarnessResponse(requestIds.at(-1)!, { cached: true }); + }); + + await harness.ui.loadCustomProviders(); + await harness.ui.runTest(); + harness.fireTimers(); + await harness.ui.runTest(); + assert.equal(requestIds[0], requestIds[1]); + assert.match(harness.testStatus.textContent, /^Recent saved result · no new model charge/); +}); + +test("custom provider structured server failure keeps the request receipt because the paid outcome is unknown", async () => { + const requestIds: string[] = []; + let posts = 0; + const harness = createCustomProviderUi(async (method, _path, body) => { + if (method === "GET") return { ok: true, data: { providers: [customProvider(1)] } }; + posts += 1; + requestIds.push((body as { requestId: string }).requestId); + return posts === 1 + ? { ok: false, status: 500, data: { error: "internal_error", message: "internal server error" } } + : successfulHarnessResponse(requestIds.at(-1)!, { cached: true }); + }); + + await harness.ui.loadCustomProviders(); + await harness.ui.runTest(); + harness.fireTimers(); + await harness.ui.runTest(); + assert.equal(requestIds[0], requestIds[1]); + assert.match(harness.testStatus.textContent, /^Recent saved result · no new model charge/); +}); + +for (const scenario of [ + { name: "empty success", response: { ok: true, status: 200 } }, + { name: "empty client error", response: { ok: false, status: 409 } }, + { name: "wrong request id", response: successfulHarnessResponse("request-from-another-test") }, + { + name: "unrecognized client error", + response: { ok: false, status: 409, data: { error: "future_guard_state", message: "unknown state" } }, + }, +] satisfies Array<{ name: string; response: ApiResponse }>) { + test(`custom provider ${scenario.name} keeps the same paid request receipt`, async () => { + const requestIds: string[] = []; + let posts = 0; + const harness = createCustomProviderUi(async (method, _path, body) => { + if (method === "GET") return { ok: true, data: { providers: [customProvider(1)] } }; + posts += 1; + requestIds.push((body as { requestId: string }).requestId); + return posts === 1 ? scenario.response : successfulHarnessResponse(requestIds.at(-1)!, { cached: true }); + }); + + await harness.ui.loadCustomProviders(); + await harness.ui.runTest(); + assert.equal(harness.testButton.disabled, true); + assert.equal(harness.retryStorage.size, 1); + harness.fireTimers(); + await harness.ui.runTest(); + assert.equal(requestIds[0], requestIds[1]); + assert.match(harness.testStatus.textContent, /^Recent saved result · no new model charge/); + }); +} + +test("custom provider non-streamed success keeps the same paid request receipt", async () => { + const requestIds: string[] = []; + let posts = 0; + const harness = createCustomProviderUi(async (method, _path, body) => { + if (method === "GET") return { ok: true, data: { providers: [customProvider(1)] } }; + posts += 1; + requestIds.push((body as { requestId: string }).requestId); + return posts === 1 + ? successfulHarnessResponse(requestIds.at(-1)!, { streamed: false }) + : successfulHarnessResponse(requestIds.at(-1)!, { cached: true }); + }); + + await harness.ui.loadCustomProviders(); + await harness.ui.runTest(); + assert.equal(harness.testButton.disabled, true); + assert.equal(harness.retryStorage.size, 1); + harness.fireTimers(); + await harness.ui.runTest(); + assert.equal(requestIds[0], requestIds[1]); + assert.match(harness.testStatus.textContent, /^Recent saved result · no new model charge/); +}); + +test("custom provider over-cap success keeps the same paid request receipt", async () => { + const requestIds: string[] = []; + let posts = 0; + const harness = createCustomProviderUi(async (method, _path, body) => { + if (method === "GET") return { ok: true, data: { providers: [customProvider(1)] } }; + posts += 1; + requestIds.push((body as { requestId: string }).requestId); + return posts === 1 + ? successfulHarnessResponse(requestIds.at(-1)!, { + maxOutputTokens: 64, + usage: { + inputTokens: 5, + outputTokens: 65, + totalTokens: 70, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + }, + }) + : successfulHarnessResponse(requestIds.at(-1)!, { cached: true }); + }); + + await harness.ui.loadCustomProviders(); + await harness.ui.runTest(); + assert.equal(harness.testButton.disabled, true); + assert.equal(harness.retryStorage.size, 1); + harness.fireTimers(); + await harness.ui.runTest(); + assert.equal(requestIds[0], requestIds[1]); + assert.match(harness.testStatus.textContent, /^Recent saved result · no new model charge/); +}); + +test("custom provider accepts and displays a lower safe model output cap", async () => { + const harness = createCustomProviderUi(async (method, _path, body) => + method === "GET" + ? { ok: true, data: { providers: [customProvider(1)] } } + : successfulHarnessResponse((body as { requestId: string }).requestId, { maxOutputTokens: 64 }), + ); + + await harness.ui.loadCustomProviders(); + await harness.ui.runTest(); + assert.equal(harness.retryStorage.size, 0); + assert.equal(harness.testButton.disabled, false); + assert.match(harness.testStatus.textContent, /output cap 64 · generation verified$/); +}); + +test("custom provider signed-out response clears the unspent request receipt", async () => { + const requestIds: string[] = []; + const harness = createCustomProviderUi(async (method, _path, body) => { + if (method === "GET") return { ok: true, data: { providers: [customProvider(1)] } }; + requestIds.push((body as { requestId: string }).requestId); + return { ok: false, status: 401, data: { error: "signed_out" } }; + }); + + await harness.ui.loadCustomProviders(); + await harness.ui.runTest(); + assert.equal(harness.retryStorage.size, 0); + assert.equal(harness.testButton.disabled, false); + assert.equal(harness.testStatus.textContent, "You are signed out. No model request was sent."); + await harness.ui.runTest(); + assert.notEqual(requestIds[0], requestIds[1]); +}); + +test("custom provider ambiguous result persistence blocks a second paid request", async () => { + let posts = 0; + const harness = createCustomProviderUi(async (method, _path, body) => { + if (method === "GET") return { ok: true, data: { providers: [customProvider(1)] } }; + posts += 1; + assert.equal(typeof (body as { requestId: string }).requestId, "string"); + return { + ok: false, + data: { + error: "harness_test_result_not_durable", + message: "retry safely", + retryAfterMs: 300_000, + requestExpiresInMs: 300_000, + }, + }; + }); + + await harness.ui.loadCustomProviders(); + await harness.ui.runTest(); + await harness.ui.runTest(); + assert.equal(posts, 1); + assert.equal(harness.testButton.disabled, true); + assert.equal(harness.retryStorage.size, 1); +}); + +test("custom provider retry window disables only the blocked paid-test target", async () => { + let posts = 0; + const harness = createCustomProviderUi(async (method, _path, body) => { + if (method === "GET") return { ok: true, data: { providers: [customProvider(1, ["luna", "terra"])] } }; + posts += 1; + return posts === 1 + ? { + ok: false, + data: { + error: "harness_test_in_progress", + message: "the same paid test is already running", + retryAfterMs: 30_000, + requestExpiresInMs: 300_000, + replayExpected: true, + }, + } + : successfulHarnessResponse((body as { requestId: string }).requestId); + }); + await harness.ui.loadCustomProviders(); + + await harness.ui.runTest(); + assert.equal(harness.testButton.disabled, true); + assert.equal(harness.modelSelect.disabled, false); + assert.equal(harness.harnessSelect.disabled, false); + assert.match(harness.testStatus.textContent, /Retry the same saved request after/); + await harness.ui.runTest(); + assert.equal(posts, 1); + + harness.modelSelect.value = "1"; + harness.modelSelect.onchange?.(); + assert.equal(harness.testButton.disabled, false); + harness.modelSelect.value = "0"; + harness.modelSelect.onchange?.(); + assert.equal(harness.testButton.disabled, true); + + harness.fireTimers(); + assert.equal(harness.testButton.disabled, false); + assert.equal(harness.testStatus.textContent, ""); + await harness.ui.runTest(); + assert.equal(posts, 2); +}); + +test("a near-expiry waiter keeps one request id across reload until the server safety window ends", async () => { + const retryStorage = new Map(); + const requestIds: string[] = []; + const first = createCustomProviderUi(async (method, _path, body) => { + if (method === "GET") return { ok: true, data: { providers: [customProvider(1)] } }; + requestIds.push((body as { requestId: string }).requestId); + return { + ok: false, + data: { + error: "harness_test_in_progress", + message: "running", + retryAfterMs: 1_000, + requestExpiresInMs: 5_000, + replayExpected: true, + }, + }; + }, retryStorage); + await first.ui.loadCustomProviders(); + await first.ui.runTest(); + + let posts = 0; + const reloaded = createCustomProviderUi(async (method, _path, body) => { + if (method === "GET") return { ok: true, data: { providers: [customProvider(1)] } }; + posts += 1; + requestIds.push((body as { requestId: string }).requestId); + return posts === 1 + ? { + ok: false, + data: { + error: "harness_test_result_unresolved", + message: "wait for the safety window", + retryAfterMs: 4_000, + requestExpiresInMs: 4_000, + }, + } + : successfulHarnessResponse(requestIds.at(-1)!); + }, retryStorage); + await reloaded.ui.loadCustomProviders(); + assert.equal(reloaded.testButton.disabled, true); + reloaded.fireTimers(); + await reloaded.ui.runTest(); + assert.equal(requestIds[0], requestIds[1]); + assert.equal(reloaded.testButton.disabled, true); + + reloaded.fireTimers(); + assert.equal(reloaded.retryStorage.size, 0); + assert.equal(reloaded.testButton.disabled, false); + await reloaded.ui.runTest(); + assert.notEqual(requestIds[1], requestIds[2]); +}); + +test("custom provider paid tests fail closed when request receipt storage cannot be written", async () => { + let posts = 0; + const harness = createCustomProviderUi( + async (method) => { + if (method === "GET") return { ok: true, data: { providers: [customProvider(1)] } }; + posts += 1; + return { ok: true, data: {} }; + }, + new Map(), + { set: true }, + ); + await harness.ui.loadCustomProviders(); + await harness.ui.runTest(); + + assert.equal(posts, 0); + assert.equal(harness.testButton.disabled, true); + assert.match(harness.testStatus.textContent, /No model request was sent/); +}); + +test("custom provider paid tests stay disabled after reload when request receipt storage cannot be read", async () => { + let posts = 0; + const storageFailure = { get: true }; + const harness = createCustomProviderUi( + async (method) => { + if (method === "GET") return { ok: true, data: { providers: [customProvider(1)] } }; + posts += 1; + return { ok: true, data: {} }; + }, + new Map(), + storageFailure, + ); + await harness.ui.loadCustomProviders(); + assert.equal(harness.testButton.disabled, true); + assert.match(harness.testStatus.textContent, /cannot safely store request receipts/); + await harness.ui.runTest(); + + assert.equal(posts, 0); + assert.equal(harness.testButton.disabled, true); + assert.match(harness.testStatus.textContent, /cannot safely store request receipts/); + + storageFailure.get = false; + await harness.ui.loadCustomProviders(); + assert.equal(harness.testButton.disabled, false); + assert.equal(harness.testStatus.textContent, ""); +}); + +test("custom provider known result fails closed until its stored receipt is verifiably removed", async () => { + const requestIds: string[] = []; + const storageFailure = { remove: false }; + const harness = createCustomProviderUi( + async (method, _path, body) => { + if (method === "GET") return { ok: true, data: { providers: [customProvider(1)] } }; + requestIds.push((body as { requestId: string }).requestId); + return successfulHarnessResponse(requestIds.at(-1)!, { providerRevision: 7 }); + }, + new Map(), + storageFailure, + ); + + await harness.ui.loadCustomProviders(); + storageFailure.remove = true; + await harness.ui.runTest(); + assert.equal(requestIds.length, 1); + assert.equal(harness.testButton.disabled, true); + assert.match(harness.testStatus.textContent, /Receipt cleanup could not be verified/); + assert.match([...harness.retryStorage.values()].join(""), /"knownResult":true/); + await harness.ui.runTest(); + assert.equal(requestIds.length, 1); + + storageFailure.remove = false; + await harness.ui.loadCustomProviders(); + assert.equal(harness.retryStorage.size, 0); + assert.equal(harness.testButton.disabled, false); + assert.equal(harness.testStatus.textContent, ""); + await harness.ui.runTest(); + assert.equal(requestIds.length, 2); + assert.notEqual(requestIds[0], requestIds[1]); +}); + +test("custom provider failure displays the saved revision and test time", async () => { + const harness = createCustomProviderUi(async (method, _path, body) => + method === "GET" + ? { ok: true, data: { providers: [customProvider(1)] } } + : { + ok: false, + status: 502, + data: { + error: "provider_test_failed", + message: "pi could not complete the saved model request", + requestId: (body as { requestId: string }).requestId, + providerRevision: 7, + testedAt: Date.UTC(2026, 7, 24), + }, + }, + ); + + await harness.ui.loadCustomProviders(); + await harness.ui.runTest(); + assert.match(harness.testStatus.textContent, /saved revision 7/); + assert.match(harness.testStatus.textContent, /tested/); + assert.match(harness.testStatus.textContent, /pi could not complete/); +}); + +test("custom provider test success clears when its configuration or selection changes", async () => { + let providers = [customProvider(1, ["luna", "terra"])]; + const harness = createCustomProviderUi(async (method, _path, body) => + method === "GET" + ? { ok: true, data: { providers } } + : successfulHarnessResponse((body as { requestId: string }).requestId), + ); + + await harness.ui.loadCustomProviders(); + await harness.ui.runTest(); + assert.notEqual(harness.testStatus.textContent, ""); + harness.harnessSelect.value = "opencode"; + harness.harnessSelect.onchange?.(); + assert.equal(harness.testStatus.textContent, ""); + + harness.harnessSelect.value = "pi"; + await harness.ui.runTest(); + harness.modelSelect.value = "1"; + harness.modelSelect.onchange?.(); + assert.equal(harness.testStatus.textContent, ""); + + harness.modelSelect.value = "0"; + await harness.ui.runTest(); + providers = [customProvider(2, ["luna", "terra"])]; + await harness.ui.loadCustomProviders(); + assert.equal(harness.testStatus.textContent, ""); +}); + +test("custom provider test success attributes the server-confirmed upstream model", () => { + const rendered = renderCustomProviderTestResult( + { + harness: "codex", + modelId: "luna", + upstreamModelId: "wire-new", + latencyMs: 42, + ...TEST_MODEL_EVIDENCE, + requestedModel: "wire-new", + responseModel: "wire-new", + reply: "ready", + }, + { modelId: "luna", upstreamModelId: "wire-old", providerName: "Gateway" }, + ); + assert.equal( + rendered, + "Gateway endpoint · codex · luna · requested wire-new · response wire-new\nFirst token 12 ms · provider 40 ms · total 42 ms · stream verified · custom endpoint verified\nUsage 5 input / 3 output / 8 total · cache read 0 / cache write 0 · output cap 128 · generation verified", + ); + assert.doesNotMatch(rendered, /wire-old/); +}); + +test("custom provider test labels a replay as not newly charged", () => { + const rendered = renderCustomProviderTestResult( + { + cached: true, + harness: "pi", + modelId: "luna", + upstreamModelId: "gpt-5.6-luna", + latencyMs: 42, + ...TEST_MODEL_EVIDENCE, + reply: "ready", + }, + { modelId: "luna", providerName: "Gateway" }, + ); + assert.match(rendered, /^Recent saved result · no new model charge · Gateway endpoint · pi/); +}); + +test("custom provider test binds its visible result to a saved revision and time", () => { + const rendered = renderCustomProviderTestResult( + { + harness: "codex", + modelId: "luna", + providerRevision: 7, + testedAt: Date.UTC(2026, 7, 24, 8, 0, 0), + latencyMs: 42, + upstreamModelId: "gpt-5.6-luna", + ...TEST_MODEL_EVIDENCE, + reply: "ready", + }, + { modelId: "luna", providerName: "Gateway" }, + ); + assert.match(rendered, /^saved revision 7 · tested .+ · Gateway endpoint · codex · luna/); +}); + +test("custom provider test labels a replayed failure as not newly charged", async () => { + const harness = createCustomProviderUi(async (method, _path, body) => + method === "GET" + ? { ok: true, data: { providers: [customProvider(1)] } } + : { + ok: false, + status: 502, + data: { + cached: true, + error: "provider_test_failed", + message: "the prior request failed", + requestId: (body as { requestId: string }).requestId, + providerRevision: 1, + testedAt: Date.UTC(2026, 7, 24), + }, + }, + ); + await harness.ui.loadCustomProviders(); + await harness.ui.runTest(); + assert.match( + harness.testStatus.textContent, + /^Recent saved result · no new model charge · saved revision 1 · tested .+ · the prior request failed$/, + ); +}); diff --git a/src/api/app-turn.ts b/src/api/app-turn.ts index 889a7a1f2..455995d7e 100644 --- a/src/api/app-turn.ts +++ b/src/api/app-turn.ts @@ -61,6 +61,7 @@ export function createTurnMethods( if (!deps.identity.isInternal(actor)) { return { status: "refused", reason: "internal-only: non-internal principals cannot interact" }; } + await deps.refreshCustomProviders?.(); let projectAudience: Principal[] | undefined; let projectName: string | undefined; let projectVersion: string | undefined; diff --git a/src/api/deps.ts b/src/api/deps.ts index 7e0b60d0c..9c2b4945c 100644 --- a/src/api/deps.ts +++ b/src/api/deps.ts @@ -1,6 +1,7 @@ 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 { CustomProviderTestRunStore } from "../model/custom-provider-test-runs.ts"; import type { McpServerStore } from "../mcp/mcp-server-store.ts"; import type { McpToolService } from "../mcp/mcp-tool-service.ts"; import type { ReplayDedupe } from "../auth/replay-dedupe.ts"; @@ -52,9 +53,31 @@ import type { UiStateStore } from "../surfaces/ui-state.ts"; import type { RateLimiter } from "../ratelimit/rate-limiter.ts"; import type { AdvisoryLock } from "../persistence/advisory-lock.ts"; import type { SlackInstallationStore, SlackSocketAppIdReader } from "../surfaces/slack-installation.ts"; +import type { ModelTestProxyEvidence } from "../harness/model-test-proxy.ts"; + +export type CustomProviderTestHarness = "pi" | "opencode" | "codex"; + +export class CustomProviderTestConfigurationChangedError extends Error {} +export class CustomProviderHarnessTestRolloutIncompleteError extends Error {} + +export type CustomProviderHarnessTestRunner = (input: { + providerId: string; + modelId: string; + harnessId: CustomProviderTestHarness; + expectedRevision: number; + rolloutFence: string; + signal: AbortSignal; +}) => Promise<{ + reply?: string; + maxOutputTokens?: number; + evidence?: ModelTestProxyEvidence; + providerRevision: number; + upstreamModelId: string; +}>; export interface ServerDeps { production?: boolean; + readyForTraffic?: () => boolean; allowUnauthenticatedCore?: boolean; signingSecret?: string; capabilitySecret?: string; @@ -92,6 +115,9 @@ export interface ServerDeps { modelCredentialFetch?: typeof fetch; customProviders?: CustomProviderStore; refreshCustomProviders?: () => Promise; + customProviderHarnessTest?: CustomProviderHarnessTestRunner; + customProviderHarnessTestFence?: () => Promise; + customProviderTestRuns?: CustomProviderTestRunStore; brandingDefault?: OrgBranding; harnessId?: string; admin?: AdminService; diff --git a/src/api/routes/admin.ts b/src/api/routes/admin.ts index 74bb807d0..a019cee97 100644 --- a/src/api/routes/admin.ts +++ b/src/api/routes/admin.ts @@ -38,7 +38,12 @@ import { putSlackInstallation, } from "./admin/slack-installation.ts"; import { deleteModelProvider, getModelProviders, putModelProvider } from "./admin/model-providers.ts"; -import { deleteCustomProvider, getCustomProviders, putCustomProvider } from "./admin/custom-providers.ts"; +import { + deleteCustomProvider, + getCustomProviders, + putCustomProvider, + testCustomProvider, +} from "./admin/custom-providers.ts"; import { deleteMcpServer, getMcpServers, putMcpServer } from "./admin/mcp-servers.ts"; import { listSecurityFlags, releaseSecurityTaint } from "./admin/security.ts"; @@ -72,6 +77,12 @@ const routes: ReadonlyArray> = [ { 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: "POST", + path: "/v1/admin/custom-providers/:provider/harness-test", + auth: "either", + handle: testCustomProvider, + }, { 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 }, diff --git a/src/api/routes/admin/custom-providers.ts b/src/api/routes/admin/custom-providers.ts index 8af109d8c..b2231d40c 100644 --- a/src/api/routes/admin/custom-providers.ts +++ b/src/api/routes/admin/custom-providers.ts @@ -1,11 +1,33 @@ import { CUSTOM_PROVIDER_PROTOCOLS, + runtimeModelForCustomProvider, + validateCustomProviderSpec, type CustomProviderSpec, type CustomProviderProtocol, } from "../../../model/custom-providers.ts"; +import { + CustomProviderRuntimeNotReadyError, + requiredCustomProviderRuntimeSchema, +} from "../../../model/custom-provider-store.ts"; +import { modelSupportedByHarness, resolveModel, resolveStaticModel } from "../../../model/pi-models.ts"; import { sendJson } from "../../http.ts"; import type { ApiCtx } from "../route.ts"; import { audit, authorizeAdmin, orgScope } from "../shared.ts"; +import { + CustomProviderHarnessTestRolloutIncompleteError, + CustomProviderTestConfigurationChangedError, + type CustomProviderTestHarness, +} from "../../deps.ts"; +import { + customProviderTestReceiptId, + customProviderTestRequestFingerprint, + type CustomProviderTestRunClaim, + type CustomProviderTestRunResponse, +} from "../../../model/custom-provider-test-runs.ts"; +import { isValidModelTestProxyEvidence, MODEL_TEST_MAX_OUTPUT_TOKENS } from "../../../harness/model-test-proxy.ts"; + +const TEST_HARNESSES = new Set(["pi", "opencode", "codex"]); +const TEST_REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; async function actor(ctx: ApiCtx) { const scope = orgScope(ctx.deps); @@ -58,6 +80,9 @@ export async function putCustomProvider(ctx: ApiCtx): Promise { 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" }); + if (!ctx.body || typeof ctx.body !== "object" || Array.isArray(ctx.body)) { + return sendJson(ctx.res, 400, { error: "bad_request", message: "a JSON object is required" }); + } const body = ctx.body as { name?: unknown; protocol?: unknown; @@ -75,13 +100,33 @@ export async function putCustomProvider(ctx: ApiCtx): Promise { message: `protocol must be one of ${CUSTOM_PROVIDER_PROTOCOLS.join(", ")}`, }); } + const submittedModels = Array.isArray(body.models) ? (body.models as CustomProviderSpec["models"]) : []; 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"]) : [], + models: submittedModels.map((model) => + typeof model?.id === "string" && resolveStaticModel(model.id) + ? { ...model, id: `${id}/${model.id}`, upstreamId: model.upstreamId ?? model.id } + : model, + ), }; + try { + validateCustomProviderSpec(spec); + } catch (e) { + return sendJson(ctx.res, 400, { error: "bad_request", message: (e as Error).message }); + } + const requiredRuntimeSchema = requiredCustomProviderRuntimeSchema(spec); + if ( + requiredRuntimeSchema !== undefined && + !(await ctx.deps.customProviders.runtimeSchemaWritable(requiredRuntimeSchema)) + ) { + return sendJson(ctx.res, 409, { + error: "runtime_rollout_incomplete", + message: "custom model configuration is unavailable until the compatibility rollout is complete", + }); + } 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!))) { @@ -93,7 +138,11 @@ export async function putCustomProvider(ctx: ApiCtx): Promise { 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 }); + const rollout = e instanceof CustomProviderRuntimeNotReadyError; + return sendJson(ctx.res, rollout ? 409 : 400, { + error: rollout ? "runtime_rollout_incomplete" : "bad_request", + message: (e as Error).message, + }); } await ctx.deps.refreshCustomProviders?.(); audit(ctx.deps, { @@ -106,6 +155,332 @@ export async function putCustomProvider(ctx: ApiCtx): Promise { return sendJson(ctx.res, 200, { ok: true, status }); } +export async function testCustomProvider(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" }); + if (!ctx.body || typeof ctx.body !== "object" || Array.isArray(ctx.body)) { + return sendJson(ctx.res, 400, { error: "bad_request", message: "a JSON object is required" }); + } + const body = ctx.body as { modelId?: unknown; harness?: unknown; requestId?: unknown }; + if (typeof body.modelId !== "string" || !body.modelId.trim()) { + return sendJson(ctx.res, 400, { error: "bad_request", message: "modelId is required" }); + } + const modelId = body.modelId.trim(); + const harnessId = body.harness === undefined ? "pi" : body.harness; + if (typeof harnessId !== "string" || !TEST_HARNESSES.has(harnessId as CustomProviderTestHarness)) { + return sendJson(ctx.res, 400, { + error: "bad_request", + message: "harness must be one of pi, opencode, or codex", + }); + } + if (typeof body.requestId !== "string" || !TEST_REQUEST_ID.test(body.requestId)) { + return sendJson(ctx.res, 400, { + error: "bad_request", + message: "requestId is required and must be 1-128 letters, numbers, dots, colons, underscores, or hyphens", + }); + } + const requestId = body.requestId; + const sendTestJson = (status: number, response: Record) => + sendJson(ctx.res, status, { ...response, requestId }); + const readRolloutFence = ctx.deps.customProviderHarnessTestFence ?? (async () => null); + const initialState = await ctx.deps.customProviders.harnessTestState(id, readRolloutFence).catch(() => null); + if (!initialState) { + return sendTestJson(502, { error: "provider_test_failed", message: "the saved test state could not be read" }); + } + const rolloutFence = initialState.rolloutFence; + if (!rolloutFence) { + return sendTestJson(409, { + error: "harness_test_rollout_incomplete", + message: "model testing is unavailable until every live QM runtime supports this test version", + }); + } + const active = initialState.active; + if (!active) return sendTestJson(404, { error: "not_found" }); + const { provider, apiKey, revision } = active; + if (!provider.models.some((model) => model.id === modelId)) { + return sendTestJson(400, { + error: "bad_request", + message: `model "${modelId}" is not registered to ${id}`, + }); + } + if (!apiKey) { + return sendTestJson(400, { error: "missing_api_key", message: "this provider has no active API key" }); + } + const model = runtimeModelForCustomProvider(provider, modelId); + if (!model) return sendTestJson(409, { error: "provider_not_ready", message: "the saved model is not active" }); + await ctx.deps.refreshCustomProviders?.(); + const resolved = resolveModel(modelId); + if ( + !resolved || + resolved.provider !== model.provider || + resolved.api !== model.api || + resolved.baseUrl !== model.baseUrl + ) { + return sendTestJson(409, { + error: "provider_not_ready", + message: "the saved model is shadowed or the runtime has not activated this provider version", + }); + } + if (!modelSupportedByHarness(modelId, harnessId)) { + return sendTestJson(400, { + error: "harness_not_supported", + message: `${harnessId} does not support this provider protocol`, + }); + } + if (!ctx.deps.customProviderHarnessTest) { + return sendTestJson(503, { + error: "harness_test_unavailable", + message: "model testing is unavailable on this QM runtime", + }); + } + if (!ctx.deps.customProviderTestRuns || (ctx.deps.production && !ctx.deps.customProviderTestRuns.durable)) { + return sendTestJson(503, { + error: "harness_test_guard_unavailable", + message: "model testing is unavailable until its durable billing guard is ready", + }); + } + const testHarness = harnessId as CustomProviderTestHarness; + const upstreamModelId = model.id; + const testIdentity = { + scopeId: orgScope(ctx.deps), + providerId: id, + modelId, + harnessId: testHarness, + providerRevision: revision, + rolloutFence, + }; + const requestIdHash = customProviderTestReceiptId(requestId); + const requestFingerprint = customProviderTestRequestFingerprint(testIdentity); + const auditCorrelation = `requestIdHash=${requestIdHash} requestFingerprint=${requestFingerprint}`; + let claim: CustomProviderTestRunClaim; + try { + claim = await ctx.deps.customProviderTestRuns.claim(testIdentity, requestId); + } catch { + return sendTestJson(503, { + error: "harness_test_guard_unavailable", + message: "the paid test was not started because its durable billing guard could not be reached", + }); + } + if (claim.kind === "conflict") { + audit(ctx.deps, { + principalId: authorized.id, + action: "custom-providers.test", + resource: `${id}/${modelId}/${testHarness}`, + scopeLabel: orgScope(ctx.deps), + status: "conflict", + detail: `harness=${testHarness} upstreamModelId=${upstreamModelId} providerRevision=${revision} ${auditCorrelation}`, + }); + return sendTestJson(409, { + error: "harness_test_request_conflict", + message: "requestId was already used for a different saved model test", + }); + } + if (claim.kind === "unresolved") { + ctx.res.setHeader("retry-after", String(Math.max(1, Math.ceil(claim.retryAfterMs / 1000)))); + audit(ctx.deps, { + principalId: authorized.id, + action: "custom-providers.test", + resource: `${id}/${modelId}/${testHarness}`, + scopeLabel: orgScope(ctx.deps), + status: "unresolved", + detail: `harness=${testHarness} upstreamModelId=${upstreamModelId} providerRevision=${revision} retryAfterMs=${claim.retryAfterMs} ${auditCorrelation}`, + }); + return sendTestJson(409, { + error: "harness_test_result_unresolved", + message: + "the prior paid test no longer has a running owner or saved result; wait for its safety window before starting a new paid test", + retryAfterMs: claim.retryAfterMs, + requestExpiresInMs: Math.max(1, claim.requestExpiresAt - Date.now()), + }); + } + if (claim.kind === "running") { + const retryAfterMs = Math.max(1, Math.min(2_000, claim.retryAfterMs)); + ctx.res.setHeader("retry-after", String(Math.max(1, Math.ceil(retryAfterMs / 1000)))); + audit(ctx.deps, { + principalId: authorized.id, + action: "custom-providers.test", + resource: `${id}/${modelId}/${testHarness}`, + scopeLabel: orgScope(ctx.deps), + status: "busy", + detail: `harness=${testHarness} upstreamModelId=${upstreamModelId} providerRevision=${revision} retryAfterMs=${retryAfterMs} requestExpiresAt=${claim.requestExpiresAt ?? "none"} ${auditCorrelation}`, + }); + return sendTestJson(409, { + error: "harness_test_in_progress", + message: claim.replayExpected + ? "the same paid test is already running; retry this requestId after it finishes to read the shared saved result" + : "an older saved configuration is still being tested; retry later to start a new paid test for the current configuration", + retryAfterMs, + replayExpected: claim.replayExpected, + ...(claim.requestExpiresAt === undefined + ? {} + : { requestExpiresInMs: Math.max(1, claim.requestExpiresAt - Date.now()) }), + }); + } + if (claim.kind === "replay") { + audit(ctx.deps, { + principalId: authorized.id, + action: "custom-providers.test", + resource: `${id}/${modelId}/${testHarness}`, + scopeLabel: orgScope(ctx.deps), + status: "replayed", + detail: `harness=${testHarness} upstreamModelId=${upstreamModelId} providerRevision=${revision} completedAt=${claim.completedAt} ${auditCorrelation}`, + }); + return sendTestJson(claim.response.status, { + ...claim.response.body, + cached: true, + cachedAt: claim.completedAt, + cachedUntil: claim.expiresAt, + }); + } + audit(ctx.deps, { + principalId: authorized.id, + action: "custom-providers.test", + resource: `${id}/${modelId}/${testHarness}`, + scopeLabel: orgScope(ctx.deps), + status: "attempted", + detail: `harness=${testHarness} upstreamModelId=${upstreamModelId} providerRevision=${revision} ${auditCorrelation}`, + }); + const startedAt = Date.now(); + const recordResult = ( + status: "succeeded" | "failed", + identity: { upstreamModelId: string; providerRevision: number } = { upstreamModelId, providerRevision: revision }, + metrics = "", + ) => + audit(ctx.deps, { + principalId: authorized.id, + action: "custom-providers.test", + resource: `${id}/${modelId}/${testHarness}`, + scopeLabel: orgScope(ctx.deps), + status, + detail: `harness=${testHarness} upstreamModelId=${identity.upstreamModelId} providerRevision=${identity.providerRevision} latencyMs=${Date.now() - startedAt}${metrics} ${auditCorrelation}`, + }); + let response: CustomProviderTestRunResponse; + try { + const result = await ctx.deps.customProviderHarnessTest({ + providerId: id, + modelId, + harnessId: testHarness, + expectedRevision: revision, + rolloutFence, + signal: AbortSignal.timeout(60_000), + }); + const finalState = await ctx.deps.customProviders.harnessTestState(id, readRolloutFence); + if (finalState.rolloutFence !== rolloutFence) { + throw new CustomProviderHarnessTestRolloutIncompleteError( + "custom provider harness testing became unavailable during the request", + ); + } + if (!finalState.active || finalState.active.revision !== revision || result.providerRevision !== revision) { + throw new CustomProviderTestConfigurationChangedError("custom provider changed during the test"); + } + const identity = { + upstreamModelId: result.upstreamModelId, + providerRevision: result.providerRevision, + }; + const reply = result.reply; + const evidence = result.evidence; + const maxOutputTokens = result.maxOutputTokens; + if ( + !reply?.trim() || + !Number.isSafeInteger(maxOutputTokens) || + maxOutputTokens! <= 0 || + maxOutputTokens! > MODEL_TEST_MAX_OUTPUT_TOKENS || + !isValidModelTestProxyEvidence(evidence, result.upstreamModelId, maxOutputTokens!) + ) { + recordResult("failed", identity); + response = { + status: 502, + body: { error: "provider_test_failed", message: "the model response could not be verified" }, + }; + } else { + recordResult( + "succeeded", + identity, + ` responseModel=${evidence.responseModel} firstTokenMs=${evidence.firstTokenMs} providerTotalMs=${evidence.totalMs} inputTokens=${evidence.usage.inputTokens} outputTokens=${evidence.usage.outputTokens} totalTokens=${evidence.usage.totalTokens} cachedInputTokens=${evidence.usage.cachedInputTokens} cacheCreationInputTokens=${evidence.usage.cacheCreationInputTokens} maxOutputTokens=${maxOutputTokens} streamed=${evidence.streamed} upstreamRequests=${evidence.upstreamRequests}`, + ); + response = { + status: 200, + body: { + ok: true, + providerId: id, + modelId, + upstreamModelId: result.upstreamModelId, + requestedModel: evidence.requestedModel, + endpointAlias: provider.name, + harness: testHarness, + reply: reply.trim(), + latencyMs: Date.now() - startedAt, + responseModel: evidence.responseModel, + firstTokenMs: evidence.firstTokenMs, + providerTotalMs: evidence.totalMs, + usage: evidence.usage, + streamed: evidence.streamed, + upstreamRequests: evidence.upstreamRequests, + noDefaultEgress: true, + maxOutputTokens, + }, + }; + } + } catch (error) { + recordResult("failed"); + if (error instanceof CustomProviderHarnessTestRolloutIncompleteError) { + response = { + status: 409, + body: { + error: "harness_test_rollout_incomplete", + message: "model testing became unavailable during a mixed-version rollout; retry after rollout completes", + }, + }; + } else if (error instanceof CustomProviderTestConfigurationChangedError) { + response = { + status: 409, + body: { + error: "provider_changed_during_test", + message: "the provider configuration changed during the test; retry to verify the current version", + }, + }; + } else { + response = { + status: 502, + body: { + error: "provider_test_failed", + message: `${testHarness} could not complete the saved model request`, + }, + }; + } + } + response = { + status: response.status, + body: { ...response.body, requestId, providerRevision: revision, testedAt: Date.now() }, + }; + try { + const stored = await ctx.deps.customProviderTestRuns.complete(claim, response); + if (!stored) throw new Error("paid test lease changed before its result was persisted"); + } catch { + const retryAfterMs = Math.max(1, claim.requestExpiresAt - Date.now()); + ctx.res.setHeader("retry-after", String(Math.max(1, Math.ceil(retryAfterMs / 1000)))); + audit(ctx.deps, { + principalId: authorized.id, + action: "custom-providers.test", + resource: `${id}/${modelId}/${testHarness}`, + scopeLabel: orgScope(ctx.deps), + status: "result_unpersisted", + detail: `harness=${testHarness} upstreamModelId=${upstreamModelId} providerRevision=${revision} retryAfterMs=${retryAfterMs} ${auditCorrelation}`, + }); + return sendTestJson(503, { + error: "harness_test_result_not_durable", + message: + "the paid test finished but its result could not be stored; do not retry until the safety window expires", + retryAfterMs, + requestExpiresInMs: retryAfterMs, + }); + } + return sendTestJson(response.status, response.body); +} + export async function deleteCustomProvider(ctx: ApiCtx): Promise { const authorized = await actor(ctx); if (!authorized) return; diff --git a/src/api/routes/index.ts b/src/api/routes/index.ts index c6b54f744..befa35ba2 100644 --- a/src/api/routes/index.ts +++ b/src/api/routes/index.ts @@ -27,7 +27,15 @@ import { egressAuditRoutes } from "./egress-audit.ts"; import { authBrokerRoutes } from "./auth-broker.ts"; export const rawRoutes: ReadonlyArray> = [ - { method: "GET", path: "/healthz", auth: "public", handle: ({ res }) => sendJson(res, 200, { ok: true }) }, + { + method: "GET", + path: "/healthz", + auth: "public", + handle: ({ res, deps }) => { + const ok = deps.readyForTraffic?.() ?? true; + sendJson(res, ok ? 200 : 503, { ok }); + }, + }, { match: (m, p) => (m === "GET" || m === "POST") && p.startsWith(GIT_HTTP_BROKER_PREFIX), auth: { aud: "credential-broker" }, diff --git a/src/api/routes/surface.ts b/src/api/routes/surface.ts index 39690ed4a..349fe3a01 100644 --- a/src/api/routes/surface.ts +++ b/src/api/routes/surface.ts @@ -1054,6 +1054,7 @@ export async function shareArtifact(ctx: ApiCtx): Promise { async function getSurfaceConfig(ctx: ApiCtx): Promise { const { res, deps } = ctx; if (!deps.config) return sendJson(res, 404, { error: "not_found" }); + await deps.refreshCustomProviders?.(); const [webuiModels, baseModel, externalSlackParticipants, branding] = await Promise.all([ deps.config.getWebuiModelsDurable(orgScope(deps)), deps.config.getBaseModelDurable(orgScope(deps)), @@ -1109,6 +1110,7 @@ async function runtimeTarget(ctx: ApiCtx): Promise<{ actorId: string; scope: Sco } async function runtimeConfigBody(ctx: ApiCtx, scope: ScopeId): Promise> { + await ctx.deps.refreshCustomProviders?.(); const config = ctx.deps.config!; const fallback = runtimeFallback(ctx); const org = orgScope(ctx.deps); @@ -1253,6 +1255,7 @@ async function putRuntimeConfig(ctx: ApiCtx): Promise { return sendJson(ctx.res, 403, { error: "live_actor_required" }); const target = await runtimeTarget(ctx); if (!target) return sendJson(ctx.res, 403, { error: "forbidden" }); + await ctx.deps.refreshCustomProviders?.(); const config = ctx.deps.config; if (ctx.body.inherit === true) await config.setRuntimeSelectionLatest(target.scope, null); else if (ctx.body.keep === true) { diff --git a/src/harness/codex-harness.ts b/src/harness/codex-harness.ts index 46d79a12b..f69d889da 100644 --- a/src/harness/codex-harness.ts +++ b/src/harness/codex-harness.ts @@ -14,10 +14,18 @@ import { swallow } from "../util/errors.ts"; import { countTokens } from "../util/tokens.ts"; import { parseSecurityScreenVerdict, SECURITY_SCREEN_SYSTEM_PROMPT } from "../security/security-posture.ts"; import { CodexAppServer, CodexRpcError } from "./codex-app-server.ts"; -import { defineHarness, type Harness, type HarnessTurnInput, type HarnessTurnResult } from "./harness.ts"; +import { + defineHarness, + type Harness, + type HarnessModelTestInput, + type HarnessTurnInput, + type HarnessTurnResult, +} from "./harness.ts"; import { coreToolOptions, createPiTools, type PiToolsOptions, type ToolContextRef } from "./pi-tools.ts"; import type { McpToolDescriptor } from "../mcp/mcp-tool-service.ts"; import { reconstructMessagesFromHistory, seedPriorTurns, type PiReplayMessage } from "./replay.ts"; +import { createModelTestProxy } from "./model-test-proxy.ts"; +import { createCodexProviderProxy } from "./codex-provider-proxy.ts"; export interface CodexHarnessOptions { modelId?: string | ((scope?: ScopeId) => string | undefined); @@ -38,6 +46,15 @@ export interface CodexHarnessOptions { appServerStartTimeoutMs?: number; signals?: RunSignalStore; tasks?: TaskStore; + resolveCustomProvider?: (modelId: string) => Promise; +} + +export interface CodexCustomProviderBinding { + id: string; + name: string; + baseUrl: string; + apiKey: string; + modelId?: string; } export function codexHarnessConfigOptions(config: Config): CodexHarnessOptions { @@ -100,9 +117,18 @@ type ActiveTurn = { tapeWriteFailed: boolean; interrupt?: () => Promise; stopped: boolean; + runtime: Runtime; }; -type Runtime = { server: CodexAppServer; jail: string }; +type Runtime = { + server: CodexAppServer; + providerProxy?: Awaited>; + jail: string; + key: string; + family: string; + retired: boolean; + closing: boolean; +}; const CODEX_START_TIMEOUT_MS = 30_000; const CODEX_NON_RETRYABLE_PATTERN = @@ -182,9 +208,11 @@ const CODEX_ENV_PASSTHROUGH = [ "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY", + "no_proxy", "OPENAI_API_KEY", "OPENAI_BASE_URL", "CODEX_ACCESS_TOKEN", + "QM_CODEX_PROVIDER_KEY", ] as const; export function codexChildEnv(source: NodeJS.ProcessEnv, jail: string): NodeJS.ProcessEnv { @@ -211,6 +239,55 @@ export function prepareCodexHome(source: NodeJS.ProcessEnv, jail: string): strin return target; } +export function codexCustomRuntimeSpec( + source: NodeJS.ProcessEnv, + binding: CodexCustomProviderBinding | null, + disableRetries = false, +): { key: string; family: string; env: NodeJS.ProcessEnv; config: Record } { + if (!binding) return { key: "default", family: "default", env: source, config: {} }; + const noProxy = [ + ...new Set([ + ...(source.NO_PROXY ?? "").split(","), + ...(source.no_proxy ?? "").split(","), + "127.0.0.1", + "localhost", + "::1", + ]), + ] + .map((value) => value.trim()) + .filter(Boolean) + .join(","); + const env: NodeJS.ProcessEnv = { + ...source, + QM_CODEX_PROVIDER_KEY: binding.apiKey, + NO_PROXY: noProxy, + no_proxy: noProxy, + }; + delete env.OPENAI_API_KEY; + delete env.OPENAI_BASE_URL; + delete env.CODEX_ACCESS_TOKEN; + const key = createHash("sha256") + .update(JSON.stringify([binding.id, binding.baseUrl, binding.apiKey])) + .digest("hex"); + return { + key: `custom:${binding.id}:${key}`, + family: `custom:${binding.id}`, + env, + config: { + model_provider: binding.id, + model_providers: { + [binding.id]: { + name: binding.name, + base_url: binding.baseUrl, + env_key: "QM_CODEX_PROVIDER_KEY", + wire_api: "responses", + ...(disableRetries ? { request_max_retries: 0, stream_max_retries: 0 } : {}), + }, + }, + }, + }; +} + async function transitionTask( store: TaskStore | undefined, id: string, @@ -356,9 +433,32 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { DEFAULT_CODEX_MODEL_ID, ].find((id): id is string => modelSupportedByHarness(id, "codex"))!; const defaultTurnWallClockMs = opts.turnWallClockMs ?? CONFIG_DEFAULTS.turnWallClockSec * 1000; - let runtime: Runtime | null = null; - let starting: Promise | null = null; - let startingServer: CodexAppServer | null = null; + const runtimes = new Map(); + const starting = new Map>(); + const startingServers = new Set(); + const reservations = new Map(); + const desiredRuntimeByFamily = new Map(); + + const closeRuntime = async (runtime: Runtime): Promise => { + if (runtime.closing) return; + runtime.closing = true; + if (runtimes.get(runtime.key) === runtime) runtimes.delete(runtime.key); + try { + await runtime.server.close(); + } finally { + await runtime.providerProxy?.close(); + rmSync(runtime.jail, { recursive: true, force: true }); + } + }; + const runtimeInUse = (runtime: Runtime) => + Boolean(reservations.get(runtime.key)) || [...active.values()].some((state) => state.runtime === runtime); + const releaseReservation = async (key: string) => { + const remaining = (reservations.get(key) ?? 1) - 1; + if (remaining > 0) reservations.set(key, remaining); + else reservations.delete(key); + const runtime = runtimes.get(key); + if (runtime?.retired && !runtimeInUse(runtime)) await closeRuntime(runtime).catch(() => undefined); + }; const processCollabItem = async (state: ActiveTurn, item: CodexItem): Promise => { if (item.type !== "collabAgentToolCall") return; @@ -421,14 +521,28 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { } }; - const ensureRuntime = async (): Promise => { - if (runtime && runtime.server.process.exitCode === null) return runtime; - if (starting) return await starting; - starting = (async () => { + const ensureRuntime = async (spec: { + key: string; + family: string; + env: NodeJS.ProcessEnv; + providerBaseUrl?: string; + }): Promise => { + desiredRuntimeByFamily.set(spec.family, spec.key); + const current = runtimes.get(spec.key); + if (current && !current.closing && current.server.process.exitCode === null) return current; + const pending = starting.get(spec.key); + if (pending) { + const joined = await pending; + if (!joined.closing && joined.server.process.exitCode === null) return joined; + if (starting.get(spec.key) === pending) starting.delete(spec.key); + return ensureRuntime(spec); + } + const operation = (async () => { const jail = mkdtempSync(join(tmpdir(), "qm-codex-")); - const sourceEnv = opts.env ?? {}; + const sourceEnv = spec.env; prepareCodexHome(sourceEnv, jail); const binaryPath = opts.binaryPath ?? resolve("node_modules/.bin/codex"); + const providerProxy = spec.providerBaseUrl ? await createCodexProviderProxy(spec.providerBaseUrl) : undefined; const server = new CodexAppServer({ binaryPath, cwd: jail, @@ -519,7 +633,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { } }, }); - startingServer = server; + startingServers.add(server); let startTimer: NodeJS.Timeout | undefined; try { await Promise.race([ @@ -533,32 +647,63 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { ]); } catch (error) { await server.close().catch(() => undefined); + await providerProxy?.close().catch(() => undefined); rmSync(jail, { recursive: true, force: true }); throw error; } finally { if (startTimer) clearTimeout(startTimer); - if (startingServer === server) startingServer = null; + startingServers.delete(server); } - runtime = { server, jail }; + const runtime = { + server, + ...(providerProxy ? { providerProxy } : {}), + jail, + key: spec.key, + family: spec.family, + retired: spec.family.startsWith("custom:") || desiredRuntimeByFamily.get(spec.family) !== spec.key, + closing: false, + }; + runtimes.set(spec.key, runtime); server.process.once("close", () => { - if (runtime?.server !== server) return; + if (runtimes.get(spec.key)?.server !== server) return; for (const state of active.values()) - state.reject(server.error() ?? new Error("Codex app-server exited during a turn")); - active.clear(); - runtime = null; + if (state.runtime.server === server) + state.reject(server.error() ?? new Error("Codex app-server exited during a turn")); + for (const [threadId, state] of active) if (state.runtime.server === server) active.delete(threadId); + runtimes.delete(spec.key); + void providerProxy?.close(); rmSync(jail, { recursive: true, force: true }); }); + const idlePriorVersions: Runtime[] = []; + if (!runtime.retired) { + for (const [key, prior] of runtimes) { + if (key === spec.key || prior.family !== spec.family) continue; + prior.retired = true; + if (!runtimeInUse(prior)) idlePriorVersions.push(prior); + } + } + if (runtime.retired && !runtimeInUse(runtime)) idlePriorVersions.push(runtime); + await Promise.all(idlePriorVersions.map((prior) => closeRuntime(prior).catch(() => undefined))); return runtime; })(); + starting.set(spec.key, operation); try { - return await starting; + return await operation; } finally { - starting = null; + if (starting.get(spec.key) === operation) starting.delete(spec.key); } }; - const runPrompt = async (turn: HarnessTurnInput, toolsEnabled = true): Promise => { + const runPrompt = async ( + turn: HarnessTurnInput, + toolsEnabled = true, + disableProviderRetries = false, + customProvider?: HarnessModelTestInput["customProvider"], + ): Promise => { if (turn.cancel?.aborted) return { reply: "", stopped: true }; + if (turn.model && !customProvider && !modelSupportedByHarness(turn.model, "codex")) { + throw new NonRetryableTurnError(`Codex does not support requested model ${turn.model}`); + } const wallMs = turn.turnWallClockMs ?? defaultTurnWallClockMs; const deadline = wallMs > 0 ? Date.now() + wallMs : 0; const setupCancelled = new Error("Codex setup cancelled"); @@ -577,10 +722,48 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { turn.cancel?.addEventListener("abort", onSetupCancel, { once: true }); const setupTimer = wallMs > 0 ? setTimeout(() => stopSetup(setupTimedOut), wallMs) : undefined; const awaitSetup = (operation: Promise): Promise => Promise.race([operation, setupStop]); + const model = turn.model ?? resolveModelId(turn.scopeLabel); + let runtimeModel: string; let rt: Runtime; + let runtimeConfig: Record; + let reservedRuntimeKey: string | null = null; try { - rt = await awaitSetup(ensureRuntime()); + const customModel = customProvider?.spec.models.find((candidate) => candidate.id === model); + if (customProvider?.spec.protocol !== undefined && customProvider.spec.protocol !== "openai-responses") + throw new NonRetryableTurnError(`Codex requires an OpenAI Responses custom provider`); + if (customProvider && !customModel) + throw new NonRetryableTurnError(`Codex custom provider snapshot does not contain model ${model}`); + let binding: CodexCustomProviderBinding | null = null; + if (customProvider) { + binding = { + id: customProvider.spec.id, + name: customProvider.spec.name, + baseUrl: customProvider.spec.baseUrl, + apiKey: customProvider.apiKey, + modelId: customModel!.upstreamId?.trim() || customModel!.id, + }; + } else if (opts.resolveCustomProvider) { + binding = await awaitSetup(opts.resolveCustomProvider(model)); + } + runtimeModel = binding?.modelId ?? model; + const spec = codexCustomRuntimeSpec(opts.env ?? {}, binding, disableProviderRetries); + reservedRuntimeKey = spec.key; + reservations.set(spec.key, (reservations.get(spec.key) ?? 0) + 1); + rt = await awaitSetup(ensureRuntime({ ...spec, ...(binding ? { providerBaseUrl: binding.baseUrl } : {}) })); + runtimeConfig = binding + ? { + ...spec.config, + model_providers: { + [binding.id]: { + ...(spec.config.model_providers as Record>)[binding.id], + base_url: rt.providerProxy?.baseUrl ?? binding.baseUrl, + }, + }, + } + : spec.config; + if (spec.family.startsWith("custom:")) rt.retired = true; } catch (error) { + if (reservedRuntimeKey) await releaseReservation(reservedRuntimeKey); setupSettled = true; if (setupTimer) clearTimeout(setupTimer); turn.cancel?.removeEventListener("abort", onSetupCancel); @@ -590,16 +773,24 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { const ref = codexToolContext(turn); const toolAbort = new AbortController(); ref.abortSignal = toolAbort.signal; - const tools = toolsEnabled ? asTools(ref, toolOptions(opts, turn)) : []; + let tools: ReturnType; + try { + tools = toolsEnabled ? asTools(ref, toolOptions(opts, turn)) : []; + } catch (error) { + if (reservedRuntimeKey) await releaseReservation(reservedRuntimeKey); + setupSettled = true; + if (setupTimer) clearTimeout(setupTimer); + turn.cancel?.removeEventListener("abort", onSetupCancel); + throw error; + } const dynamicTools = tools.map((tool) => ({ type: "function", name: tool.name, description: tool.description, inputSchema: tool.parameters, })); - const model = modelSupportedByHarness(turn.model, "codex") ? turn.model! : resolveModelId(turn.scopeLabel); const threadStartRequest = { - ...(model ? { model } : {}), + ...(runtimeModel ? { model: runtimeModel } : {}), cwd: rt.jail, approvalPolicy: "never", sandbox: "read-only", @@ -611,6 +802,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { experimentalRawEvents: true, environments: [], config: { + ...runtimeConfig, web_search: "disabled", ...(codexReasoningEffort(turn.thinkingLevel) ? { model_reasoning_effort: codexReasoningEffort(turn.thinkingLevel) } @@ -636,6 +828,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { try { started = await awaitSetup(rt.server.request("thread/start", threadStartRequest)); } catch (error) { + if (reservedRuntimeKey) await releaseReservation(reservedRuntimeKey); setupSettled = true; if (setupTimer) clearTimeout(setupTimer); turn.cancel?.removeEventListener("abort", onSetupCancel); @@ -659,6 +852,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { }), ); } catch (error) { + if (reservedRuntimeKey) await releaseReservation(reservedRuntimeKey); setupSettled = true; if (setupTimer) clearTimeout(setupTimer); turn.cancel?.removeEventListener("abort", onSetupCancel); @@ -702,8 +896,10 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { fallbackInputTokens: countTokens(JSON.stringify({ replay, input })), tapeWriteFailed: false, stopped: false, + runtime: rt, }; active.set(threadId, state); + if (reservedRuntimeKey) await releaseReservation(reservedRuntimeKey); const promptEnvelope = { threadStart: { ...threadStartRequest, @@ -720,7 +916,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { model: selectedModel, promptEnvelope, truncated: Boolean(turn.images?.length), - transport: { modelId: selectedModel }, + transport: { modelId: runtimeModel }, ttftMs: state.firstOutputAt ? state.firstOutputAt - startedAt : null, durationMs: Date.now() - startedAt, usage: sumUsage(state.usageByThread), @@ -794,7 +990,11 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { let timer: NodeJS.Timeout | undefined; try { const response = await rt.server - .request<{ turn: CodexTurn }>("turn/start", { threadId, input, ...(model ? { model } : {}) }) + .request<{ turn: CodexTurn }>("turn/start", { + threadId, + input, + ...(runtimeModel ? { model: runtimeModel } : {}), + }) .catch((error: unknown) => { throw error instanceof CodexRpcError ? codexProviderFailure(error.message) : error; }); @@ -854,6 +1054,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { for (const [activeThreadId, activeState] of active) { if (activeState === state) active.delete(activeThreadId); } + if (rt.retired && !runtimeInUse(rt)) await closeRuntime(rt).catch(() => undefined); } }; @@ -863,6 +1064,8 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { signal?: AbortSignal, observe?: Pick, modelOverride?: string, + disableProviderRetries = false, + customProvider?: HarnessModelTestInput["customProvider"], ): Promise => { const session = { id: `oneshot-${randomBytes(8).toString("hex")}` } as HarnessTurnInput["session"]; const scope = { kind: "org", id: "oneshot" } as unknown as ScopeId; @@ -893,6 +1096,8 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { ...(observe?.recordLlmRequest ? { recordLlmRequest: observe.recordLlmRequest } : {}), }, false, + disableProviderRetries, + customProvider, ); return result.reply || undefined; }; @@ -908,19 +1113,48 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { { runTurn: runPrompt, close: async () => { - await startingServer?.close().catch(() => undefined); - await starting?.catch(() => undefined); - const current = runtime; - if (current) { - for (const state of active.values()) state.reject(new Error("Codex harness closed during a turn")); - active.clear(); - await current.server.close(); - rmSync(current.jail, { recursive: true, force: true }); - if (runtime === current) runtime = null; - } + await Promise.all([...startingServers].map((server) => server.close().catch(() => undefined))); + await Promise.all([...starting.values()].map((operation) => operation.catch(() => undefined))); + for (const state of active.values()) state.reject(new Error("Codex harness closed during a turn")); + active.clear(); + const current = [...runtimes.values()]; + runtimes.clear(); + await Promise.all(current.map((runtime) => closeRuntime(runtime))); + for (const runtime of current) rmSync(runtime.jail, { recursive: true, force: true }); }, resetSession: () => {}, oneShot: (system, prompt) => single(system, prompt), + testModel: async (input) => { + if (!input.customProvider) + throw new NonRetryableTurnError("Codex model test requires a custom provider snapshot"); + const proxy = await createModelTestProxy(input.customProvider.spec.baseUrl, { + ...(input.signal ? { signal: input.signal } : {}), + expectedModel: input.expectedUpstreamModel, + maxOutputTokens: input.maxOutputTokens, + }); + try { + const customProvider = { + ...input.customProvider, + spec: { ...input.customProvider.spec, baseUrl: proxy.baseUrl }, + }; + const reply = await single( + input.systemPrompt, + input.prompt, + input.signal, + undefined, + input.model, + true, + customProvider, + ); + return { + ...(reply ? { reply } : {}), + maxOutputTokens: input.maxOutputTokens, + evidence: proxy.evidence(), + }; + } finally { + await proxy.close(); + } + }, judge: (system, prompt) => single(system, prompt, undefined, undefined, judgeModelId), screenSecurity: async ({ payload, signal, recordModelCall, recordLlmRequest }) => parseSecurityScreenVerdict( diff --git a/src/harness/codex-provider-proxy.ts b/src/harness/codex-provider-proxy.ts new file mode 100644 index 000000000..4eff47cb3 --- /dev/null +++ b/src/harness/codex-provider-proxy.ts @@ -0,0 +1,101 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { Readable } from "node:stream"; + +const UNFORWARDED_HEADERS = new Set([ + "connection", + "content-length", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "x-openai-internal-codex-responses-lite", +]); + +function headersFromRequest(req: IncomingMessage): Headers { + const headers = new Headers(); + for (const [name, value] of Object.entries(req.headers)) { + if (UNFORWARDED_HEADERS.has(name) || value === undefined) continue; + for (const item of Array.isArray(value) ? value : [value]) headers.append(name, item); + } + return headers; +} + +function targetUrl(baseUrl: string, requestUrl: string | undefined): URL { + const target = new URL(baseUrl); + const request = new URL(requestUrl ?? "/", "http://127.0.0.1"); + const basePath = target.pathname.replace(/\/+$/, ""); + target.pathname = `${basePath}${request.pathname.startsWith("/") ? request.pathname : `/${request.pathname}`}`; + target.search = request.search; + return target; +} + +async function writeResponse(upstream: Response, res: ServerResponse): Promise { + const headers: Record = {}; + upstream.headers.forEach((value, name) => { + if (!UNFORWARDED_HEADERS.has(name) && name !== "content-encoding" && name !== "location") headers[name] = value; + }); + res.writeHead(upstream.status, headers); + if (!upstream.body) { + res.end(); + return; + } + for await (const chunk of Readable.fromWeb(upstream.body)) { + if (!res.write(chunk)) await new Promise((resolve) => res.once("drain", resolve)); + } + res.end(); +} + +function closeServer(server: ReturnType): Promise { + if (!server.listening) return Promise.resolve(); + return new Promise((resolve) => server.close(() => resolve())); +} + +export async function createCodexProviderProxy(baseUrl: string): Promise<{ baseUrl: string; close(): Promise }> { + const server = createServer(async (req, res) => { + const abort = new AbortController(); + const abortUpstream = () => abort.abort(); + const abortIncompleteResponse = () => { + if (!res.writableFinished) abort.abort(); + }; + req.once("aborted", abortUpstream); + req.once("error", abortUpstream); + res.once("close", abortIncompleteResponse); + try { + const method = req.method ?? "GET"; + const upstream = await fetch(targetUrl(baseUrl, req.url), { + method, + headers: headersFromRequest(req), + redirect: "manual", + ...(method === "GET" || method === "HEAD" ? {} : { body: Readable.toWeb(req), duplex: "half" as const }), + signal: abort.signal, + } as RequestInit & { duplex?: "half" }); + await writeResponse(upstream, res); + } catch { + if (!res.headersSent && !res.destroyed) { + res.writeHead(502); + res.end(); + } else if (!res.destroyed) res.destroy(); + } finally { + req.off("aborted", abortUpstream); + req.off("error", abortUpstream); + res.off("close", abortIncompleteResponse); + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + await closeServer(server); + throw new Error("failed to bind Codex provider proxy"); + } + return { + baseUrl: `http://127.0.0.1:${address.port}`, + close: () => closeServer(server), + }; +} diff --git a/src/harness/harness-router.ts b/src/harness/harness-router.ts index 280896272..d6cf020b2 100644 --- a/src/harness/harness-router.ts +++ b/src/harness/harness-router.ts @@ -28,6 +28,15 @@ export function resolveRuntimeChoice( approved.includes(fallback.harnessId) && modelSupportedByHarness(fallback.modelId, fallback.harnessId) ? fallback : { harnessId: firstApproved, modelId: defaultModelForHarness(firstApproved, fallback.modelId) }; + if ( + (orgStored || orgLegacy) && + (!approved.includes(configuredOrg.harnessId) || + !modelSupportedByHarness(configuredOrg.modelId, configuredOrg.harnessId)) + ) { + throw new NonRetryableTurnError( + `configured runtime ${configuredOrg.harnessId}/${configuredOrg.modelId} is unavailable`, + ); + } const org = approved.includes(configuredOrg.harnessId) && modelSupportedByHarness(configuredOrg.modelId, configuredOrg.harnessId) @@ -41,6 +50,12 @@ export function resolveRuntimeChoice( } else if (scopedLegacy) { inherited = { harnessId: fallback.harnessId, modelId: scopedLegacy }; } + if ( + (scopedStored || scopedLegacy) && + (!approved.includes(inherited.harnessId) || !modelSupportedByHarness(inherited.modelId, inherited.harnessId)) + ) { + throw new NonRetryableTurnError(`configured runtime ${inherited.harnessId}/${inherited.modelId} is unavailable`); + } const choice = requested?.harnessId || requested?.modelId ? { harnessId: requested.harnessId ?? inherited.harnessId, modelId: requested.modelId ?? inherited.modelId } diff --git a/src/harness/harness.ts b/src/harness/harness.ts index 77940e80a..2275fddd9 100644 --- a/src/harness/harness.ts +++ b/src/harness/harness.ts @@ -12,6 +12,8 @@ export type { GapWork } from "../sessions/session-store.ts"; import type { OverheardEntryPayload } from "./replay.ts"; import type { ToolContext } from "../tools/primitives.ts"; import type { SecurityScreenVerdict } from "../security/security-posture.ts"; +import type { CustomProviderSpec } from "../model/custom-providers.ts"; +import type { ModelTestProxyEvidence } from "./model-test-proxy.ts"; interface HarnessImage { mimeType: string; @@ -136,6 +138,25 @@ export interface HarnessCompactInput { recordModelCall(rec: { model: string; inputTokens: number; entryCount: number }): void; } +export interface HarnessModelTestInput { + model: string; + expectedUpstreamModel: string; + maxOutputTokens: number; + systemPrompt: string; + prompt: string; + signal?: AbortSignal; + customProvider?: { + spec: CustomProviderSpec; + apiKey: string; + }; +} + +export interface HarnessModelTestResult { + reply?: string; + maxOutputTokens?: number; + evidence?: ModelTestProxyEvidence; +} + interface HarnessTurnController { runTurn(input: HarnessTurnInput): Promise; close?(): Promise | void; @@ -147,6 +168,7 @@ export interface HarnessModelUtilities { compactHistory?(input: HarnessCompactInput): Promise; contextTokenBudget?(scopeLabel?: string, model?: string): number | undefined; oneShot?(systemPrompt: string, prompt: string): Promise; + testModel?(input: HarnessModelTestInput): Promise; judge?(systemPrompt: string, prompt: string): Promise; screenSecurity?(input: HarnessSecurityScreenInput): Promise; pickAckEmoji?(text: string, candidates: readonly string[]): Promise; @@ -196,6 +218,7 @@ export function defineHarness( ? { contextTokenBudget: implementation.contextTokenBudget.bind(implementation) } : {}), ...(implementation.oneShot ? { oneShot: implementation.oneShot.bind(implementation) } : {}), + ...(implementation.testModel ? { testModel: implementation.testModel.bind(implementation) } : {}), ...(implementation.judge ? { judge: implementation.judge.bind(implementation) } : {}), ...(implementation.screenSecurity ? { screenSecurity: implementation.screenSecurity.bind(implementation) } : {}), ...(implementation.pickAckEmoji ? { pickAckEmoji: implementation.pickAckEmoji.bind(implementation) } : {}), diff --git a/src/harness/model-test-proxy.ts b/src/harness/model-test-proxy.ts new file mode 100644 index 000000000..4506b0721 --- /dev/null +++ b/src/harness/model-test-proxy.ts @@ -0,0 +1,555 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; + +export interface ModelTestUsage { + inputTokens: number; + outputTokens: number; + totalTokens: number; + cachedInputTokens: number; + cacheCreationInputTokens: number; +} + +export interface ModelTestProxyEvidence { + requestedModel: string; + responseModel: string; + firstTokenMs: number; + totalMs: number; + usage: ModelTestUsage; + streamed: boolean; + upstreamRequests: number; +} + +export const MODEL_TEST_MAX_OUTPUT_TOKENS = 128; +const MODEL_TEST_MAX_REQUEST_BYTES = 512 * 1024; + +const UNFORWARDED_HEADERS = new Set([ + "connection", + "content-length", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +function json(res: ServerResponse, status: number, value: unknown): void { + const text = JSON.stringify(value); + res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(text) }); + res.end(text); +} + +function requestBody(req: IncomingMessage, max = MODEL_TEST_MAX_REQUEST_BYTES): Promise { + return new Promise((resolveBody, reject) => { + const chunks: Buffer[] = []; + let size = 0; + req.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > max) { + reject(new Error("request body too large")); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => resolveBody(Buffer.concat(chunks))); + req.on("error", reject); + req.resume(); + }); +} + +function forwardedHeaders(req: IncomingMessage): Headers { + const headers = new Headers(); + for (const [name, value] of Object.entries(req.headers)) { + if (UNFORWARDED_HEADERS.has(name) || value === undefined) continue; + for (const item of Array.isArray(value) ? value : [value]) headers.append(name, item); + } + return headers; +} + +function upstreamUrl(baseUrl: string, requestUrl: string | undefined): URL { + const target = new URL(baseUrl); + const request = new URL(requestUrl ?? "/", "http://127.0.0.1"); + const basePath = target.pathname.replace(/\/+$/, ""); + target.pathname = `${basePath}${request.pathname.startsWith("/") ? request.pathname : `/${request.pathname}`}`; + target.search = request.search; + return target; +} + +function outputLimit(value: unknown, maximum: number): number { + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? Math.min(parsed, maximum) : maximum; +} + +function preparedBody( + body: Buffer, + requestUrl: string | undefined, + expectedModel: string, + maxOutputTokens: number, +): { body: Buffer; requestedModel: string | null } { + try { + const payload = JSON.parse(body.toString("utf8")) as Record; + delete payload.tools; + delete payload.tool_choice; + delete payload.parallel_tool_calls; + const requestedModel = typeof payload.model === "string" ? payload.model : null; + const pathname = new URL(requestUrl ?? "/", "http://127.0.0.1").pathname; + if (pathname.endsWith("/responses")) { + payload.max_output_tokens = outputLimit(payload.max_output_tokens, maxOutputTokens); + } else if (pathname.endsWith("/chat/completions")) { + if ("max_completion_tokens" in payload) { + payload.max_completion_tokens = outputLimit(payload.max_completion_tokens, maxOutputTokens); + } + if ("max_tokens" in payload || !("max_completion_tokens" in payload)) { + payload.max_tokens = outputLimit(payload.max_tokens, maxOutputTokens); + } + if (payload.stream === true) { + const streamOptions = + payload.stream_options && typeof payload.stream_options === "object" + ? (payload.stream_options as Record) + : {}; + payload.stream_options = { ...streamOptions, include_usage: true }; + } + } else if (pathname.endsWith("/messages")) { + payload.max_tokens = outputLimit(payload.max_tokens, maxOutputTokens); + } + return { + body: Buffer.from(JSON.stringify(payload)), + requestedModel: requestedModel === expectedModel ? requestedModel : null, + }; + } catch { + return { body, requestedModel: null }; + } +} + +type PartialModelTestUsage = Partial; + +interface UsageAccumulator extends PartialModelTestUsage { + invalid: boolean; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function hasOwn(value: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function integerFromFields(fields: Array<[Record, string]>): { + present: boolean; + valid: boolean; + value?: number; +} { + const values: number[] = []; + for (const [record, key] of fields) { + if (!hasOwn(record, key)) continue; + const value = record[key]; + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + return { present: true, valid: false }; + } + values.push(value); + } + if (!values.length) return { present: false, valid: true }; + if (values.some((value) => value !== values[0])) return { present: true, valid: false }; + return { present: true, valid: true, value: values[0] }; +} + +function partialUsage(value: Record): { valid: boolean; usage?: PartialModelTestUsage } { + const input = integerFromFields([ + [value, "input_tokens"], + [value, "prompt_tokens"], + [value, "inputTokens"], + ]); + const output = integerFromFields([ + [value, "output_tokens"], + [value, "completion_tokens"], + [value, "outputTokens"], + ]); + const total = integerFromFields([ + [value, "total_tokens"], + [value, "totalTokens"], + ]); + const cachedFields: Array<[Record, string]> = [ + [value, "cached_input_tokens"], + [value, "cache_read_input_tokens"], + ]; + for (const key of ["input_tokens_details", "prompt_tokens_details"]) { + if (!hasOwn(value, key)) continue; + const details = value[key]; + if (!isRecord(details)) return { valid: false }; + cachedFields.push([details, "cached_tokens"]); + } + const cached = integerFromFields(cachedFields); + const cacheCreation = integerFromFields([[value, "cache_creation_input_tokens"]]); + const fields = [input, output, total, cached, cacheCreation]; + if (fields.some((field) => !field.valid) || fields.every((field) => !field.present)) { + return { valid: false }; + } + return { + valid: true, + usage: { + ...(input.present ? { inputTokens: input.value } : {}), + ...(output.present ? { outputTokens: output.value } : {}), + ...(total.present ? { totalTokens: total.value } : {}), + ...(cached.present ? { cachedInputTokens: cached.value } : {}), + ...(cacheCreation.present ? { cacheCreationInputTokens: cacheCreation.value } : {}), + }, + }; +} + +function payloadRoots(payload: Record): Record[] { + const roots = [payload]; + if (isRecord(payload.response)) roots.push(payload.response); + if (isRecord(payload.message)) roots.push(payload.message); + if (isRecord(payload.response) && isRecord(payload.response.message)) roots.push(payload.response.message); + return [...new Set(roots)]; +} + +function usageFromPayload(payload: Record): { invalid: boolean; values: PartialModelTestUsage[] } { + const values: PartialModelTestUsage[] = []; + const seen = new Set>(); + let invalid = false; + for (const root of payloadRoots(payload)) { + if (!hasOwn(root, "usage")) continue; + const candidate = root.usage; + if (candidate === null || candidate === undefined) continue; + if (!isRecord(candidate)) { + invalid = true; + continue; + } + if (seen.has(candidate)) continue; + seen.add(candidate); + const parsed = partialUsage(candidate); + if (!parsed.valid || !parsed.usage) invalid = true; + else values.push(parsed.usage); + } + return { invalid, values }; +} + +function modelsFromPayload(payload: Record): { invalid: boolean; values: string[] } { + const values: string[] = []; + let invalid = false; + for (const root of payloadRoots(payload)) { + if (!hasOwn(root, "model")) continue; + const model = root.model; + if (typeof model !== "string" || !model || model.trim() !== model) invalid = true; + else values.push(model); + } + return { invalid, values }; +} + +function mergeUsage(accumulator: UsageAccumulator, usage: PartialModelTestUsage, maxOutputTokens: number): void { + for (const key of [ + "inputTokens", + "outputTokens", + "totalTokens", + "cachedInputTokens", + "cacheCreationInputTokens", + ] as const) { + const next = usage[key]; + if (next === undefined) continue; + const current = accumulator[key]; + if (current !== undefined && next < current) accumulator.invalid = true; + else accumulator[key] = next; + } + if (accumulator.outputTokens !== undefined && accumulator.outputTokens > maxOutputTokens) { + accumulator.invalid = true; + } +} + +function completeUsage(accumulator: UsageAccumulator, anthropic: boolean): ModelTestUsage | null { + if (accumulator.invalid || accumulator.inputTokens === undefined || accumulator.outputTokens === undefined) { + return null; + } + const cachedInputTokens = accumulator.cachedInputTokens ?? 0; + const cacheCreationInputTokens = accumulator.cacheCreationInputTokens ?? 0; + if (!anthropic && cacheCreationInputTokens !== 0) return null; + const inputTokens = anthropic + ? accumulator.inputTokens + cachedInputTokens + cacheCreationInputTokens + : accumulator.inputTokens; + if (!Number.isSafeInteger(inputTokens)) return null; + const totalTokens = accumulator.totalTokens ?? inputTokens + accumulator.outputTokens; + const usage = { + inputTokens, + outputTokens: accumulator.outputTokens, + totalTokens, + cachedInputTokens, + cacheCreationInputTokens, + }; + return validUsage(usage, Number.MAX_SAFE_INTEGER) ? usage : null; +} + +function validUsage(usage: unknown, maxOutputTokens: number): usage is ModelTestUsage { + if (!isRecord(usage)) return false; + return ( + Number.isSafeInteger(usage.inputTokens) && + (usage.inputTokens as number) > 0 && + Number.isSafeInteger(usage.outputTokens) && + (usage.outputTokens as number) > 0 && + (usage.outputTokens as number) <= maxOutputTokens && + Number.isSafeInteger(usage.totalTokens) && + (usage.totalTokens as number) >= (usage.inputTokens as number) + (usage.outputTokens as number) && + Number.isSafeInteger(usage.cachedInputTokens) && + (usage.cachedInputTokens as number) >= 0 && + Number.isSafeInteger(usage.cacheCreationInputTokens) && + (usage.cacheCreationInputTokens as number) >= 0 && + (usage.cachedInputTokens as number) + (usage.cacheCreationInputTokens as number) <= (usage.inputTokens as number) + ); +} + +export function isValidModelTestProxyEvidence( + evidence: unknown, + expectedModel: string, + maxOutputTokens: number, +): evidence is ModelTestProxyEvidence { + if (!isRecord(evidence)) return false; + return ( + evidence.requestedModel === expectedModel && + evidence.responseModel === expectedModel && + Number.isSafeInteger(evidence.firstTokenMs) && + (evidence.firstTokenMs as number) > 0 && + Number.isSafeInteger(evidence.totalMs) && + (evidence.totalMs as number) >= (evidence.firstTokenMs as number) && + validUsage(evidence.usage, maxOutputTokens) && + evidence.streamed === true && + evidence.upstreamRequests === 1 + ); +} + +function textDelta(payload: Record): string { + if (payload.type === "response.output_text.delta" && typeof payload.delta === "string") return payload.delta; + const choices = Array.isArray(payload.choices) ? payload.choices : []; + for (const choice of choices) { + if (!choice || typeof choice !== "object") continue; + const delta = (choice as Record).delta; + if (!delta || typeof delta !== "object") continue; + const content = (delta as Record).content; + if (typeof content === "string" && content) return content; + } + const delta = payload.delta; + if (delta && typeof delta === "object" && typeof (delta as Record).text === "string") { + return (delta as Record).text as string; + } + return ""; +} + +function writeChunk(res: ServerResponse, chunk: Buffer): Promise { + if (res.write(chunk)) return Promise.resolve(); + return new Promise((resolveWrite, reject) => { + const cleanup = () => { + res.off("drain", onDrain); + res.off("error", onError); + res.off("close", onClose); + }; + const onDrain = () => { + cleanup(); + resolveWrite(); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const onClose = () => { + cleanup(); + reject(new Error("model test client disconnected")); + }; + res.once("drain", onDrain); + res.once("error", onError); + res.once("close", onClose); + }); +} + +function createResponseObserver(startedAt: number, streamed: boolean, maxOutputTokens: number, anthropic: boolean) { + const decoder = new TextDecoder(); + const chunks: Buffer[] = []; + const responseModels = new Set(); + const usage: UsageAccumulator = { invalid: false }; + let pending = ""; + let size = 0; + let firstTokenAt: number | null = null; + let invalidPayload = false; + let invalidModel = false; + const observePayload = (payload: Record, at: number) => { + const observedModels = modelsFromPayload(payload); + invalidModel ||= observedModels.invalid; + for (const model of observedModels.values) responseModels.add(model); + const observedUsage = usageFromPayload(payload); + usage.invalid ||= observedUsage.invalid; + for (const value of observedUsage.values) mergeUsage(usage, value, maxOutputTokens); + if (!firstTokenAt && textDelta(payload)) firstTokenAt = at; + }; + const drainEvents = (at: number, final: boolean) => { + while (pending) { + const delimiter = /\r?\n\r?\n/.exec(pending); + if (!delimiter && !final) return; + const end = delimiter?.index ?? pending.length; + const block = pending.slice(0, end); + pending = delimiter ? pending.slice(end + delimiter[0].length) : ""; + const data = block + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trimStart()) + .join("\n"); + if (!data || data === "[DONE]") continue; + try { + const payload = JSON.parse(data) as unknown; + if (!isRecord(payload)) invalidPayload = true; + else observePayload(payload, at); + } catch { + invalidPayload = true; + } + } + }; + return { + async observe(chunk: Buffer, at: number, max: number): Promise { + size += chunk.length; + if (size > max) throw new Error("upstream response body too large"); + chunks.push(chunk); + if (!streamed) { + firstTokenAt ??= at; + return; + } + pending += decoder.decode(chunk, { stream: true }); + drainEvents(at, false); + }, + finish(at: number): Omit | null { + if (streamed) { + pending += decoder.decode(); + drainEvents(at, true); + } else { + try { + const payload = JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown; + if (!isRecord(payload)) return null; + observePayload(payload, at); + } catch { + return null; + } + } + const observedUsage = completeUsage(usage, anthropic); + const responseModel = responseModels.size === 1 ? responseModels.values().next().value : undefined; + if (invalidPayload || invalidModel || !responseModel || !observedUsage || !firstTokenAt) return null; + return { + responseModel, + firstTokenMs: Math.max(1, firstTokenAt - startedAt), + totalMs: Math.max(1, at - startedAt), + usage: observedUsage, + streamed, + }; + }, + }; +} + +async function forwardResponseBody( + response: Response, + res: ServerResponse, + startedAt: number, + streamed: boolean, + maxOutputTokens: number, + anthropic: boolean, + max = 16 * 1024 * 1024, +): Promise | null> { + if (!response.body) return null; + const observer = createResponseObserver(startedAt, streamed, maxOutputTokens, anthropic); + const reader = response.body.getReader(); + while (true) { + const next = await reader.read(); + if (next.done) { + return observer.finish(Date.now()); + } + const chunk = Buffer.from(next.value); + try { + await observer.observe(chunk, Date.now(), max); + } catch (error) { + await reader.cancel(); + throw error; + } + await writeChunk(res, chunk); + } +} + +function closeServer(server: ReturnType): Promise { + if (!server.listening) return Promise.resolve(); + return new Promise((resolveClose) => server.close(() => resolveClose())); +} + +export async function createModelTestProxy( + baseUrl: string, + options: { signal?: AbortSignal; expectedModel: string; maxOutputTokens: number }, +): Promise<{ baseUrl: string; evidence(): ModelTestProxyEvidence; close(): Promise }> { + let attempted = false; + let upstreamRequests = 0; + let evidence: ModelTestProxyEvidence | null = null; + const server = createServer(async (req, res) => { + if (attempted) return json(res, 400, { error: { message: "Model connection test permits one upstream request" } }); + attempted = true; + try { + const method = req.method ?? "GET"; + const rawBody = method === "GET" || method === "HEAD" ? undefined : await requestBody(req); + const prepared = rawBody + ? preparedBody(rawBody, req.url, options.expectedModel, options.maxOutputTokens) + : { body: undefined, requestedModel: null }; + if (prepared.requestedModel !== options.expectedModel) { + return json(res, 400, { error: { message: "Model connection test refused an unexpected model id" } }); + } + const startedAt = Date.now(); + upstreamRequests += 1; + const upstream = await fetch(upstreamUrl(baseUrl, req.url), { + method, + headers: forwardedHeaders(req), + redirect: "manual", + ...(prepared.body ? { body: prepared.body } : {}), + ...(options.signal ? { signal: options.signal } : {}), + }); + if (upstream.status >= 300 && upstream.status < 400) { + await upstream.body?.cancel(); + return json(res, 400, { error: { message: `Upstream model test rejected HTTP ${upstream.status} redirect` } }); + } + if (upstream.status === 408 || upstream.status === 409 || upstream.status === 429 || upstream.status >= 500) { + await upstream.body?.cancel(); + return json(res, 400, { error: { message: `Upstream model test failed with HTTP ${upstream.status}` } }); + } + const headers: Record = {}; + upstream.headers.forEach((value, name) => { + if (!UNFORWARDED_HEADERS.has(name) && name !== "content-encoding" && name !== "location") { + headers[name] = value; + } + }); + res.writeHead(upstream.status, headers); + const observed = await forwardResponseBody( + upstream, + res, + startedAt, + upstream.headers.get("content-type")?.toLowerCase().includes("text/event-stream") ?? false, + options.maxOutputTokens, + new URL(req.url ?? "/", "http://127.0.0.1").pathname.endsWith("/messages"), + ); + evidence = observed ? { ...observed, requestedModel: options.expectedModel, upstreamRequests } : null; + res.end(); + } catch { + if (!res.headersSent && !res.destroyed) + json(res, 400, { error: { message: "Upstream model test request failed" } }); + else res.destroy(); + } + }); + await new Promise((resolveListen, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolveListen); + }); + const address = server.address(); + if (!address || typeof address === "string") { + await closeServer(server); + throw new Error("failed to bind model test proxy"); + } + return { + baseUrl: `http://127.0.0.1:${address.port}`, + evidence: () => { + if (!isValidModelTestProxyEvidence(evidence, options.expectedModel, options.maxOutputTokens)) { + throw new Error("model test response evidence did not match the requested model"); + } + return evidence; + }, + close: () => closeServer(server), + }; +} diff --git a/src/harness/opencode-harness.ts b/src/harness/opencode-harness.ts index 71fa163bd..ddeebafb7 100644 --- a/src/harness/opencode-harness.ts +++ b/src/harness/opencode-harness.ts @@ -1,4 +1,4 @@ -import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { sanitizeTitle, TITLE_GENERATION_PROMPT, titleUserPrompt } from "./pi-harness.ts"; import { mkdtempSync, rmSync } from "node:fs"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; @@ -10,7 +10,7 @@ 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 { DEFAULT_AGENT_MODEL_ID, modelSupportedByHarness, resolveModel } from "../model/pi-models.ts"; import { startSignalPoll, type RunSignalStore } from "../runs/run-signal-store.ts"; import type { LlmCallUsage } from "../sessions/session-store.ts"; import type { ScopeId, SessionEntry } from "../types.ts"; @@ -22,6 +22,7 @@ import { defineHarness, envelopeWithoutMessages, type Harness, + type HarnessModelTestInput, type HarnessTurnInput, type HarnessTurnResult, } from "./harness.ts"; @@ -30,6 +31,7 @@ import type { McpToolDescriptor } from "../mcp/mcp-tool-service.ts"; import { reconstructMessagesFromHistory } from "./replay.ts"; import { parseSecurityScreenVerdict, SECURITY_SCREEN_SYSTEM_PROMPT } from "../security/security-posture.ts"; import { countTokens } from "../util/tokens.ts"; +import { createModelTestProxy } from "./model-test-proxy.ts"; const OPENCODE_VERSION = "1.17.18"; const OPENCODE_IDLE_WAIT_MS = 30 * 60_000; @@ -54,11 +56,6 @@ 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>; } @@ -95,9 +92,13 @@ type ActiveTurn = { eventTail: Promise; stopped: boolean; child: boolean; + runtime: Runtime; }; type Runtime = { + key: string; + retired: boolean; + closing: boolean; client: OpencodeClient; process: ChildProcess; bridge: ReturnType; @@ -184,7 +185,10 @@ export function modelRef(id: string): { providerID: string; modelID: string } { // 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 }; + if (resolved?.provider) { + const custom = resolved as typeof resolved & { wireId?: string }; + return { providerID: String(resolved.provider), modelID: custom.wireId ?? id }; + } } const slash = id.indexOf("/"); if (slash > 0) return { providerID: id.slice(0, slash), modelID: id.slice(slash + 1) }; @@ -475,8 +479,38 @@ export function createOpenCodeHarness(opts: OpenCodeHarnessOptions = {}): Harnes opts.defaultModelId ?? DEFAULT_AGENT_MODEL_ID; const defaultTurnWallClockMs = opts.turnWallClockMs ?? CONFIG_DEFAULTS.turnWallClockSec * 1000; - let runtime: Runtime | null = null; - let starting: Promise | null = null; + const runtimes = new Map(); + const starting = new Map>(); + const reservations = new Map(); + let desiredRuntimeKey = ""; + + const closeRuntime = async (runtime: Runtime): Promise => { + if (runtime.closing) return; + runtime.closing = true; + if (runtimes.get(runtime.key) === runtime) runtimes.delete(runtime.key); + await runtime.close(); + }; + + const runtimeInUse = (candidate: Runtime): boolean => + (reservations.get(candidate.key) ?? 0) > 0 || [...active.values()].some((state) => state.runtime === candidate); + + const releaseReservation = async (key: string): Promise => { + const remaining = (reservations.get(key) ?? 1) - 1; + if (remaining > 0) reservations.set(key, remaining); + else reservations.delete(key); + const candidate = runtimes.get(key); + if (candidate?.retired && !runtimeInUse(candidate)) await closeRuntime(candidate).catch(() => undefined); + }; + + const resolveRuntimeSpec = async (customProvider?: HarnessModelTestInput["customProvider"], toolsEnabled = true) => { + const exclusive = Boolean(customProvider); + const custom = customProvider + ? [{ spec: customProvider.spec, apiKey: customProvider.apiKey }] + : ((await opts.resolveCustomProviders?.()) ?? []); + custom.sort((left, right) => left.spec.id.localeCompare(right.spec.id)); + const key = createHash("sha256").update(JSON.stringify({ custom, exclusive, toolsEnabled })).digest("hex"); + return { key, custom, exclusive, toolsEnabled }; + }; const childState = (parent: ActiveTurn): ActiveTurn => ({ ...parent, @@ -568,11 +602,20 @@ export function createOpenCodeHarness(opts: OpenCodeHarnessOptions = {}): Harnes await operation; }; - const ensureRuntime = async (): Promise => { - if (runtime && runtime.process.exitCode === null) return runtime; - if (starting) return await starting; - starting = (async () => { + const ensureRuntime = async (spec: Awaited>): Promise => { + desiredRuntimeKey = spec.key; + const existing = runtimes.get(spec.key); + if (existing && !existing.closing && existing.process.exitCode === null) return existing; + const pending = starting.get(spec.key); + if (pending) { + const joined = await pending; + if (!joined.closing && joined.process.exitCode === null) return joined; + if (starting.get(spec.key) === pending) starting.delete(spec.key); + return ensureRuntime(spec); + } + const operation = (async () => { const jail = mkdtempSync(join(tmpdir(), "qm-opencode-")); + let createdRuntime: Runtime | null = null; const bridge = createServer(async (req, res) => { try { const url = new URL(req.url ?? "/", "http://127.0.0.1"); @@ -585,8 +628,10 @@ export function createOpenCodeHarness(opts: OpenCodeHarnessOptions = {}): Harnes if (!equalSecret(bearer(req), bridgeSecret)) return json(res, 401, { error: "unauthorized" }); const requestedSessionId = decodeURIComponent(sessionMatch[1]!); let state = active.get(requestedSessionId); - if (!state && runtime) { - const session = await runtime.client.session.get({ path: { id: requestedSessionId } }).catch(() => null); + if (!state && createdRuntime) { + const session = await createdRuntime.client.session + .get({ path: { id: requestedSessionId } }) + .catch(() => null); const parentId = session?.data?.parentID; const parent = parentId ? active.get(parentId) : undefined; if (parent) { @@ -656,33 +701,39 @@ export function createOpenCodeHarness(opts: OpenCodeHarnessOptions = {}): Harnes if (!address || typeof address === "string") throw new Error("failed to bind OpenCode bridge"); 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 enabledTools = Object.fromEntries(definitions.map((item) => [item.name, spec.toolsEnabled])); + const custom = spec.custom; 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, - }, - } - : {}), - }, - ]), - ), - }, - ]), + custom.map(({ spec, apiKey }) => { + let npm = "@ai-sdk/openai-compatible"; + if (spec.protocol === "anthropic") npm = "@ai-sdk/anthropic"; + if (spec.protocol === "openai-responses") npm = "@ai-sdk/openai"; + return [ + spec.id, + { + npm, + name: spec.name, + options: { baseURL: spec.baseUrl, ...(apiKey ? { apiKey } : {}) }, + models: Object.fromEntries( + spec.models.map((m) => [ + m.upstreamId?.trim() || m.id, + { + name: m.name ?? m.id, + attachment: m.inputModalities?.includes("image") ?? false, + modalities: { + input: m.inputModalities ?? ["text"], + output: ["text"], + }, + limit: { + context: m.contextWindow ?? 128_000, + output: m.maxTokens ?? 8_192, + }, + }, + ]), + ), + }, + ]; + }), ); const config = { plugin: [pluginUrl], @@ -692,15 +743,19 @@ export function createOpenCodeHarness(opts: OpenCodeHarnessOptions = {}): Harnes lsp: false, formatter: false, instructions: [], - enabled_providers: ["anthropic", "openai", ...custom.map(({ spec }) => spec.id)], + enabled_providers: [...(spec.exclusive ? [] : ["anthropic", "openai"]), ...custom.map(({ spec }) => spec.id)], provider: { - anthropic: { options: { apiKey: opts.apiKey ?? "" } }, - openai: { options: { apiKey: opts.openaiApiKey ?? "" } }, + ...(spec.exclusive + ? {} + : { + anthropic: { options: { apiKey: opts.apiKey ?? "" } }, + openai: { options: { apiKey: opts.openaiApiKey ?? "" } }, + }), ...customProviderConfig, }, tools: { ...enabledTools, - task: true, + task: spec.toolsEnabled, read: false, write: false, bash: false, @@ -731,7 +786,7 @@ export function createOpenCodeHarness(opts: OpenCodeHarnessOptions = {}): Harnes doom_loop: "deny", }, agent: { - qm: { mode: "primary", prompt: "", tools: { ...enabledTools, task: true } }, + qm: { mode: "primary", prompt: "", tools: { ...enabledTools, task: spec.toolsEnabled } }, research: { mode: "subagent", description: "Research a bounded question and report evidence.", @@ -796,6 +851,9 @@ export function createOpenCodeHarness(opts: OpenCodeHarnessOptions = {}): Harnes } })(); const created: Runtime = { + key: spec.key, + retired: spec.custom.length > 0 || desiredRuntimeKey !== spec.key, + closing: false, client, process: proc, bridge, @@ -806,16 +864,29 @@ export function createOpenCodeHarness(opts: OpenCodeHarnessOptions = {}): Harnes await terminateProcess(proc!); await closeServer(bridge); rmSync(jail, { recursive: true, force: true }); + if (runtimes.get(spec.key) === created) runtimes.delete(spec.key); }, }; + createdRuntime = created; proc.once("exit", () => { abortEvents.abort(); - if (runtime !== created) return; - runtime = null; + if (runtimes.get(spec.key) === created) runtimes.delete(spec.key); bridge.close(); rmSync(jail, { recursive: true, force: true }); }); - runtime = created; + runtimes.set(spec.key, created); + const priorVersions = created.retired + ? [created] + : [...runtimes.values()].filter((candidate) => candidate !== created); + if (!created.retired) + priorVersions.forEach((candidate) => { + candidate.retired = true; + }); + await Promise.all( + priorVersions + .filter((candidate) => !runtimeInUse(candidate)) + .map((candidate) => closeRuntime(candidate).catch(() => undefined)), + ); return created; } catch (error) { if (proc) await terminateProcess(proc); @@ -824,68 +895,128 @@ export function createOpenCodeHarness(opts: OpenCodeHarnessOptions = {}): Harnes throw error; } })(); + starting.set(spec.key, operation); try { - return await starting; + return await operation; } finally { - starting = null; + if (starting.get(spec.key) === operation) starting.delete(spec.key); } }; - const runPrompt = async (turn: HarnessTurnInput): Promise => { - if (turn.cancel?.aborted) return { reply: "", stopped: true }; - const rt = await ensureRuntime(); + const runPrompt = async ( + turn: HarnessTurnInput, + customProvider?: HarnessModelTestInput["customProvider"], + toolsEnabled = true, + ): Promise => { if (turn.cancel?.aborted) return { reply: "", stopped: true }; + if (turn.model && !customProvider && !modelSupportedByHarness(turn.model, "opencode")) { + throw new NonRetryableTurnError(`OpenCode does not support requested model ${turn.model}`); + } const selectedModel = turn.model ?? resolveModelId(turn.scopeLabel); - const model = modelRef(selectedModel); - const created = await rt.client.session.create({ body: { title: `qm:${turn.session.id}` } }); + const selectedCustomModel = customProvider?.spec.models.find((candidate) => candidate.id === selectedModel); + if (customProvider && !selectedCustomModel) + throw new NonRetryableTurnError(`OpenCode custom provider snapshot does not contain model ${selectedModel}`); + const runtimeSpec = await resolveRuntimeSpec(customProvider, toolsEnabled); + reservations.set(runtimeSpec.key, (reservations.get(runtimeSpec.key) ?? 0) + 1); + let rt: Runtime; + try { + rt = await ensureRuntime(runtimeSpec); + } catch (error) { + await releaseReservation(runtimeSpec.key); + throw error; + } + if (turn.cancel?.aborted) { + await releaseReservation(runtimeSpec.key); + return { reply: "", stopped: true }; + } + const model = customProvider + ? { + providerID: customProvider.spec.id, + modelID: selectedCustomModel!.upstreamId?.trim() || selectedCustomModel!.id, + } + : modelRef(selectedModel); + let created; + try { + created = await rt.client.session.create({ body: { title: `qm:${turn.session.id}` } }); + } catch (error) { + await releaseReservation(runtimeSpec.key); + throw error; + } const session = created.data; - if (!session) throw new Error(`OpenCode session creation failed: ${JSON.stringify(created.error)}`); + if (!session) { + await releaseReservation(runtimeSpec.key); + throw new Error(`OpenCode session creation failed: ${JSON.stringify(created.error)}`); + } const sessionId = session.id; if (turn.cancel?.aborted) { await rt.client.session.abort({ path: { id: sessionId } }).catch(() => undefined); await rt.client.session.delete({ path: { id: sessionId } }).catch(() => undefined); + await releaseReservation(runtimeSpec.key); return { reply: "", stopped: true }; } - const ref: ToolContextRef = { - current: turn.tools, - pendingApprovals: [], - pausedOnApproval: false, - silentRequested: false, - pollFire: Boolean(turn.pollFire), - emit: turn.emit, - scopeLabel: turn.scopeLabel, - orgScopeId: turn.orgScopeId, - screenExternalContent: turn.screenExternalContent, - toolApprovalGate: turn.toolApprovalGate, - }; - const controller = new AbortController(); - ref.abortSignal = controller.signal; - const tools = asTools(ref, toolOptions(opts, turn)); - const userEntry = await turn.emit({ - type: "user", - payload: { - text: turn.input, - ...((turn.triggerTs ?? turn.entryTs) ? { ts: turn.triggerTs ?? turn.entryTs } : {}), - ...(turn.attachments?.length ? { attachments: turn.attachments } : {}), - }, - scopeLabel: turn.scopeLabel, - }); - const state: ActiveTurn = { - turn, - ref, - tools: new Map(tools.map((tool) => [bridgeToolName(tool.name), tool])), - system: `${turn.systemPrompt}\n\nOpenCode tool aliases: workspace_execute is foreground \`execute\`; workspace_read reads workspace files; workspace_write writes workspace files.`, - history: replayMessages(reconstructMessagesFromHistory(turn.history), sessionId, model), - userSeq: userEntry.seq, - captures: [], - model: selectedModel, - seenText: new Map(), - seenTasks: new Map(), - eventTail: Promise.resolve(), - stopped: false, - child: false, - }; + let prepared: { ref: ToolContextRef; controller: AbortController; state: ActiveTurn }; + try { + const ref: ToolContextRef = { + current: turn.tools, + pendingApprovals: [], + pausedOnApproval: false, + silentRequested: false, + pollFire: Boolean(turn.pollFire), + emit: turn.emit, + scopeLabel: turn.scopeLabel, + orgScopeId: turn.orgScopeId, + screenExternalContent: turn.screenExternalContent, + toolApprovalGate: turn.toolApprovalGate, + }; + const controller = new AbortController(); + ref.abortSignal = controller.signal; + const tools = toolsEnabled ? asTools(ref, toolOptions(opts, turn)) : []; + prepared = { + ref, + controller, + state: { + turn, + ref, + tools: new Map(tools.map((tool) => [bridgeToolName(tool.name), tool])), + system: `${turn.systemPrompt}\n\nOpenCode tool aliases: workspace_execute is foreground \`execute\`; workspace_read reads workspace files; workspace_write writes workspace files.`, + history: replayMessages(reconstructMessagesFromHistory(turn.history), sessionId, model), + userSeq: null, + captures: [], + model: selectedModel, + seenText: new Map(), + seenTasks: new Map(), + eventTail: Promise.resolve(), + stopped: false, + child: false, + runtime: rt, + }, + }; + } catch (error) { + await releaseReservation(runtimeSpec.key); + await rt.client.session.delete({ path: { id: sessionId } }).catch(() => undefined); + throw error; + } + const { ref, controller, state } = prepared; active.set(sessionId, state); + await releaseReservation(runtimeSpec.key); + let userEntry: SessionEntry; + try { + userEntry = await turn.emit({ + type: "user", + payload: { + text: turn.input, + ...((turn.triggerTs ?? turn.entryTs) ? { ts: turn.triggerTs ?? turn.entryTs } : {}), + ...(turn.attachments?.length ? { attachments: turn.attachments } : {}), + }, + scopeLabel: turn.scopeLabel, + }); + } catch (error) { + active.delete(sessionId); + await rt.client.session.delete({ path: { id: sessionId } }).catch(() => undefined); + if (rt.retired && !runtimeInUse(rt)) await closeRuntime(rt).catch(() => undefined); + throw error; + } + state.userSeq = userEntry.seq; const abort = async (stopped: boolean) => { state.stopped ||= stopped; controller.abort(); @@ -903,6 +1034,7 @@ export function createOpenCodeHarness(opts: OpenCodeHarnessOptions = {}): Harnes active.delete(sessionId); turn.cancel.removeEventListener("abort", onCancel); await rt.client.session.delete({ path: { id: sessionId } }).catch(() => undefined); + if (rt.retired && !runtimeInUse(rt)) await closeRuntime(rt).catch(() => undefined); return { reply: "", stopped: true }; } const wallMs = turn.turnWallClockMs ?? defaultTurnWallClockMs; @@ -997,8 +1129,8 @@ export function createOpenCodeHarness(opts: OpenCodeHarnessOptions = {}): Harnes })), ]; const enabled = Object.fromEntries(definitions.map((tool) => [tool.name, false])); - for (const tool of tools) enabled[bridgeToolName(tool.name)] = true; - enabled.task = !turn.readOnly; + for (const name of state.tools.keys()) enabled[name] = true; + enabled.task = toolsEnabled && !turn.readOnly; let timer: NodeJS.Timeout | undefined; let signalsStopped = false; try { @@ -1100,6 +1232,7 @@ export function createOpenCodeHarness(opts: OpenCodeHarnessOptions = {}): Harnes await flushLlmRequests(); for (const [id, candidate] of active) if (candidate.turn === turn) active.delete(id); await rt.client.session.delete({ path: { id: sessionId } }).catch(() => undefined); + if (rt.retired && !runtimeInUse(rt)) await closeRuntime(rt).catch(() => undefined); } }; @@ -1108,33 +1241,41 @@ export function createOpenCodeHarness(opts: OpenCodeHarnessOptions = {}): Harnes prompt: string, instrumentation?: Pick, signal?: AbortSignal, + modelOverride?: string, + customProvider?: HarnessModelTestInput["customProvider"], + toolsEnabled = true, ): Promise => { const session = { id: `oneshot-${randomBytes(8).toString("hex")}` } as HarnessTurnInput["session"]; const scope = { kind: "org", id: "oneshot" } as unknown as ScopeId; const emitted: SessionEntry[] = []; - const result = await runPrompt({ - session, - input: prompt, - systemPrompt: system, - history: [], - tools: {} as HarnessTurnInput["tools"], - scopeLabel: scope, - orgScopeId: scope, - ...(signal ? { cancel: signal } : {}), - emit: async (entry) => { - const saved = { - ...entry, - sessionId: session.id, - seq: emitted.length + 1, - createdAt: Date.now(), - } as SessionEntry; - emitted.push(saved); - return saved; + const result = await runPrompt( + { + session, + input: prompt, + systemPrompt: system, + history: [], + tools: {} as HarnessTurnInput["tools"], + scopeLabel: scope, + orgScopeId: scope, + ...(signal ? { cancel: signal } : {}), + ...(modelOverride ? { model: modelOverride } : {}), + emit: async (entry) => { + const saved = { + ...entry, + sessionId: session.id, + seq: emitted.length + 1, + createdAt: Date.now(), + } as SessionEntry; + emitted.push(saved); + return saved; + }, + recordModelCall: instrumentation?.recordModelCall ?? (() => {}), + ...(instrumentation?.recordLlmRequest ? { recordLlmRequest: instrumentation.recordLlmRequest } : {}), + readOnly: true, }, - recordModelCall: instrumentation?.recordModelCall ?? (() => {}), - ...(instrumentation?.recordLlmRequest ? { recordLlmRequest: instrumentation.recordLlmRequest } : {}), - readOnly: true, - }); + customProvider, + toolsEnabled, + ); return result.reply || undefined; }; @@ -1149,12 +1290,42 @@ export function createOpenCodeHarness(opts: OpenCodeHarnessOptions = {}): Harnes { runTurn: runPrompt, close: async () => { - await starting?.catch(() => undefined); - await runtime?.close(); - runtime = null; + await Promise.allSettled(starting.values()); + await Promise.all([...runtimes.values()].map((runtime) => closeRuntime(runtime).catch(() => undefined))); }, resetSession: () => {}, oneShot: single, + testModel: async (input) => { + if (!input.customProvider) + throw new NonRetryableTurnError("OpenCode model test requires a custom provider snapshot"); + const proxy = await createModelTestProxy(input.customProvider.spec.baseUrl, { + ...(input.signal ? { signal: input.signal } : {}), + expectedModel: input.expectedUpstreamModel, + maxOutputTokens: input.maxOutputTokens, + }); + try { + const customProvider = { + ...input.customProvider, + spec: { ...input.customProvider.spec, baseUrl: proxy.baseUrl }, + }; + const reply = await single( + input.systemPrompt, + input.prompt, + undefined, + input.signal, + input.model, + customProvider, + false, + ); + return { + ...(reply ? { reply } : {}), + maxOutputTokens: input.maxOutputTokens, + evidence: proxy.evidence(), + }; + } finally { + await proxy.close(); + } + }, judge: single, screenSecurity: async ({ payload, signal, recordModelCall, recordLlmRequest }) => parseSecurityScreenVerdict( diff --git a/src/harness/pi-harness.ts b/src/harness/pi-harness.ts index 73ff2ba74..929a0f228 100644 --- a/src/harness/pi-harness.ts +++ b/src/harness/pi-harness.ts @@ -1,11 +1,12 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, + SettingsManager, type AgentSession, } from "@earendil-works/pi-coding-agent"; import { InMemoryCredentialStore, type Api, type Model } from "@earendil-works/pi-ai"; @@ -45,11 +46,18 @@ import { defaultInteractiveThinkingLevel, modelDisplayName, resolveModel, + resolveStaticModel, getRequiredModel, modelSupportsFastMode, contextTokenBudgetForModel, } from "../model/pi-models.ts"; -import { customModelsJson, customProvidersVersion } from "../model/custom-providers.ts"; +import { + customModelsJson, + customModelsJsonForProviders, + customProvidersVersion, + runtimeModelForCustomProvider, + type CustomProviderSpec, +} from "../model/custom-providers.ts"; import { defineHarness, envelopeWithoutMessages, @@ -81,6 +89,7 @@ import { parseSecurityScreenVerdict, SECURITY_SCREEN_SYSTEM_PROMPT } from "../se import { errMessage } from "../util/errors.ts"; import { createGrindMeter, meterGrindCall } from "./grind.ts"; import { enforceGoal, goalSteeringNote, meterGoalCall, type GoalRecord } from "./goal.ts"; +import { createModelTestProxy } from "./model-test-proxy.ts"; export interface PiHarnessOptions { modelId?: string | ((scope?: ScopeId) => string | undefined); @@ -93,6 +102,7 @@ export interface PiHarnessOptions { openaiApiKey?: string; openrouterApiKey?: string; resolveProviderKeys?: () => Promise; + resolveProviderRuntime?: () => Promise; tempDirPrefix?: string; captureRequests?: boolean; systemCacheSplit?: boolean; @@ -390,6 +400,7 @@ interface TurnSession { composedPromptTokens: number; cwd: string; agentDir: string; + providerRuntime: ProviderRuntime; } interface PerCallStat { @@ -1008,11 +1019,23 @@ export interface ProviderKeys { [provider: string]: string | undefined; } +export interface ProviderRuntime { + keys: ProviderKeys; + customProviders?: CustomProviderSpec[]; +} + // 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 { +function customModelsPath(specs?: CustomProviderSpec[]): string | null { + if (specs) { + const custom = customModelsJsonForProviders(specs); + if (!custom) return null; + const path = join(mkdtempSync(join(tmpdir(), "pi-custom-models-")), "models.json"); + writeFileSync(path, JSON.stringify(custom)); + return path; + } const version = customProvidersVersion(); if (cachedCustomModels?.version === version) return cachedCustomModels.path; const custom = customModelsJson(); @@ -1025,20 +1048,28 @@ function customModelsPath(): string | null { return path; } -async function buildModelRuntime(keys: ProviderKeys | string): Promise { +async function buildModelRuntime( + keys: ProviderKeys | string, + customProviders?: CustomProviderSpec[], +): 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, - }); - for (const [provider, apiKey] of Object.entries(k)) { - if (apiKey) await runtime.setRuntimeApiKey(provider, apiKey, { allowNetwork: false }); + const modelsPath = customModelsPath(customProviders); + let runtime: ModelRuntime; + try { + runtime = await ModelRuntime.create({ + credentials: new InMemoryCredentialStore(), + modelsPath, + }); + for (const [provider, apiKey] of Object.entries(k)) { + if (apiKey) await runtime.setRuntimeApiKey(provider, apiKey, { allowNetwork: false }); + } + return runtime; + } finally { + if (customProviders && modelsPath) rmSync(dirname(modelsPath), { recursive: true, force: true }); } - return runtime; } export async function oneShot( @@ -1047,9 +1078,9 @@ export async function oneShot( keys: ProviderKeys | string, systemPrompt: string, prompt: string, - opts?: { signal?: AbortSignal }, + opts?: { signal?: AbortSignal; disableRetries?: boolean; customProviders?: CustomProviderSpec[] }, ): Promise { - const modelRuntime = await buildModelRuntime(keys); + const modelRuntime = await buildModelRuntime(keys, opts?.customProviders); const { resourceLoader, cwd, agentDir } = await createIsolatedResources(prefix, systemPrompt); try { const { session } = await createAgentSession({ @@ -1059,6 +1090,13 @@ export async function oneShot( customTools: [], noTools: "builtin", sessionManager: SessionManager.inMemory(), + ...(opts?.disableRetries + ? { + settingsManager: SettingsManager.inMemory({ + retry: { enabled: false, maxRetries: 0, provider: { maxRetries: 0 } }, + }), + } + : {}), cwd, agentDir, }); @@ -1249,17 +1287,33 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { const titleModelId = (): string => opts?.titleModelId ?? auxiliaryModelId(); const judgeModelId = (): string => opts?.judgeModelId ?? auxiliaryModelId(); const tempDirPrefix = opts?.tempDirPrefix ?? "pi"; - const configuredProviderKeys: ProviderKeys = opts?.resolveProviderKeys - ? {} - : { - ...(opts?.apiKey ? { anthropic: opts.apiKey } : {}), - ...(opts?.openaiApiKey ? { openai: opts.openaiApiKey } : {}), - ...(opts?.openrouterApiKey ? { openrouter: opts.openrouterApiKey } : {}), - }; - const resolveProviderKeys = async (): Promise => ({ - ...configuredProviderKeys, - ...(await opts?.resolveProviderKeys?.()), - }); + const configuredProviderKeys: ProviderKeys = + opts?.resolveProviderKeys || opts?.resolveProviderRuntime + ? {} + : { + ...(opts?.apiKey ? { anthropic: opts.apiKey } : {}), + ...(opts?.openaiApiKey ? { openai: opts.openaiApiKey } : {}), + ...(opts?.openrouterApiKey ? { openrouter: opts.openrouterApiKey } : {}), + }; + const resolveProviderRuntime = async (): Promise => { + if (opts?.resolveProviderRuntime) return opts.resolveProviderRuntime(); + return { + keys: { + ...configuredProviderKeys, + ...(await opts?.resolveProviderKeys?.()), + }, + }; + }; + const resolveProviderKeys = async (): Promise => (await resolveProviderRuntime()).keys; + const modelForRuntime = (modelId: string, runtime: ProviderRuntime): Model => { + const fixed = resolveStaticModel(modelId); + if (fixed) return fixed; + for (const provider of runtime.customProviders ?? []) { + const custom = runtimeModelForCustomProvider(provider, modelId); + if (custom) return custom as unknown as Model; + } + return getRequiredModel(modelId); + }; const keyForModel = (keys: ProviderKeys, model: Model): string | undefined => keys[String(model.provider)]; const captureRequests = opts?.captureRequests ?? true; const systemCacheSplit = opts?.systemCacheSplit ?? false; @@ -1280,6 +1334,7 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { surfaceTools?: boolean, surfaceName?: string, turnScope?: ScopeId, + requestedModelId?: string, credentialExecServices?: readonly { service: string; binary: string }[], tapeRows?: TapeRecord[], tapeMode?: "shadow" | "serve", @@ -1323,8 +1378,9 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { const seedPlan = planColdStartSeed(seedSource, !!priorTurns?.length); const composedPrompt = systemPrompt + (seedPlan === "preamble" ? replayPreamble(history) : ""); - const model = getRequiredModel(resolveModelId(turnScope)); - const modelRuntime = await buildModelRuntime(await resolveProviderKeys()); + const providerRuntime = await resolveProviderRuntime(); + const model = modelForRuntime(requestedModelId ?? resolveModelId(turnScope), providerRuntime); + const modelRuntime = await buildModelRuntime(providerRuntime.keys, providerRuntime.customProviders); const ref: ToolContextRef = { current: null }; const { resourceLoader, cwd, agentDir } = await createIsolatedResources(tempDirPrefix, composedPrompt); const compileMs = Date.now() - compileStart; @@ -1470,6 +1526,7 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { composedPromptTokens: countTokens(composedPrompt), cwd, agentDir, + providerRuntime, }; return { entry, compileMs, tapeWriteFailed: bootstrapTapeWriteFailed }; } @@ -1498,6 +1555,7 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { turn.surfaceTools, turn.surfaceName, turn.scopeLabel, + turn.model, turn.credentialExecServices, turn.tapeRows, turn.tapeMode, @@ -1524,7 +1582,7 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { const currentFast = Boolean(current?.headers?.["anthropic-beta"]?.includes(FAST_MODE_BETA)); if (current?.id !== desiredModelId || currentFast !== wantFast) { try { - const base = resolveModel(desiredModelId); + const base = modelForRuntime(desiredModelId, entry.providerRuntime); if (base) await entry.agentSession.setModel(wantFast ? withFastModeHeaders(base) : base); } catch (e) { swallow("pi: model switch", e); @@ -1532,7 +1590,6 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { } const activeModel = entry.agentSession.model as { id?: string; headers?: Record } | undefined; entry.ref.fast = Boolean(activeModel?.headers?.["anthropic-beta"]?.includes(FAST_MODE_BETA)); - const effectiveModel = activeModel?.id ?? desiredModelId; const defaultThinkingLevel = entry.agentSession.model ? defaultInteractiveThinkingLevel(entry.agentSession.model) : "auto"; @@ -1587,7 +1644,7 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { entry.ref.pendingPrepareNextTurn = undefined; entry.ref.pendingTransformContext = undefined; turn.recordModelCall({ - model: effectiveModel, + model: desiredModelId, inputTokens: entry.composedPromptTokens + estimateHistoryTokens(turn.history) + countTokens(modelPrompt), entryCount: turn.history.length, }); @@ -1653,11 +1710,7 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { } else if (event.type === "message_end" && (event.message as { role?: string }).role === "assistant") { const end = Date.now(); const u = (event.message as { usage?: PiUsageShape }).usage; - meterGrindCall( - grindMeter, - piUsageToCallUsage(u), - (entry.agentSession.model as { id?: string } | undefined)?.id ?? effectiveModel, - ); + meterGrindCall(grindMeter, piUsageToCallUsage(u), desiredModelId); if (entry.ref.goal?.status === "active") meterGoalCall(entry.ref.goal, piUsageToCallUsage(u)); callStats.push({ ttftMs: curStart !== undefined && curFirst !== undefined ? curFirst - curStart : null, @@ -1719,7 +1772,7 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { await turn.recordLlmRequest({ turnSeq: userEntry.seq, step, - model: captured[step]!.transport?.modelId ?? effectiveModel, + model: desiredModelId, promptEnvelope: captured[step]!.envelope, truncated: captured[step]!.truncated, transport: captured[step]!.transport ?? null, @@ -2032,9 +2085,9 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { async shouldRespond(detect: HarnessDetectInput): Promise { try { const modelId = detectModelId(); - const model = getRequiredModel(modelId); - const providerKeys = await resolveProviderKeys(); - if (!keyForModel(providerKeys, model)) return { respond: true }; + const providerRuntime = await resolveProviderRuntime(); + const model = modelForRuntime(modelId, providerRuntime); + if (!keyForModel(providerRuntime.keys, model)) return { respond: true }; const detectSystemPrompt = buildDetectionPrompt(detect.reactionGuidance); const prompt = renderDetectPrompt(detect); detect.recordModelCall({ @@ -2042,7 +2095,11 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { inputTokens: countTokens(detectSystemPrompt) + countTokens(prompt), entryCount: detect.history.length, }); - const out = ((await oneShot("pi-detect", model, providerKeys, detectSystemPrompt, prompt)) ?? "").trim(); + const out = ( + (await oneShot("pi-detect", model, providerRuntime.keys, detectSystemPrompt, prompt, { + customProviders: providerRuntime.customProviders, + })) ?? "" + ).trim(); return parseDetectVerdict(out, Boolean(detect.reactionGuidance?.trim())); } catch { return { respond: false }; @@ -2058,10 +2115,12 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { inputTokens: countTokens(CONTEXT_COMPACTION_PROMPT) + countTokens(transcript), entryCount: input.history.length, }); - const model = getRequiredModel(compactModelId); - const providerKeys = await resolveProviderKeys(); - if (!keyForModel(providerKeys, model)) return deterministicCompactSummary(input.history); - const out = await oneShot("pi-compact", model, providerKeys, CONTEXT_COMPACTION_PROMPT, transcript); + const providerRuntime = await resolveProviderRuntime(); + const model = modelForRuntime(compactModelId, providerRuntime); + if (!keyForModel(providerRuntime.keys, model)) return deterministicCompactSummary(input.history); + const out = await oneShot("pi-compact", model, providerRuntime.keys, CONTEXT_COMPACTION_PROMPT, transcript, { + customProviders: providerRuntime.customProviders, + }); return out ?? deterministicCompactSummary(input.history); } catch { return deterministicCompactSummary(input.history); @@ -2074,25 +2133,69 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { }, async oneShot(systemPrompt: string, prompt: string): Promise { - const model = getRequiredModel(resolveModelId()); - const providerKeys = await resolveProviderKeys(); - if (!keyForModel(providerKeys, model)) return undefined; - return oneShot("pi-oneshot", model, providerKeys, systemPrompt, prompt); + const providerRuntime = await resolveProviderRuntime(); + const model = modelForRuntime(resolveModelId(), providerRuntime); + if (!keyForModel(providerRuntime.keys, model)) return undefined; + return oneShot("pi-oneshot", model, providerRuntime.keys, systemPrompt, prompt, { + customProviders: providerRuntime.customProviders, + }); + }, + + async testModel(input) { + const proxy = input.customProvider + ? await createModelTestProxy(input.customProvider.spec.baseUrl, { + ...(input.signal ? { signal: input.signal } : {}), + expectedModel: input.expectedUpstreamModel, + maxOutputTokens: input.maxOutputTokens, + }) + : null; + try { + const customProvider = + input.customProvider && proxy + ? { + ...input.customProvider, + spec: { ...input.customProvider.spec, baseUrl: proxy.baseUrl }, + } + : input.customProvider; + const providerRuntime = customProvider + ? { + keys: { [customProvider.spec.id]: customProvider.apiKey }, + customProviders: [customProvider.spec], + } + : await resolveProviderRuntime(); + const resolved = modelForRuntime(input.model, providerRuntime); + if (!keyForModel(providerRuntime.keys, resolved)) return {}; + const model = { ...resolved, maxTokens: Math.min(resolved.maxTokens, input.maxOutputTokens) }; + const reply = await oneShot("pi-model-test", model, providerRuntime.keys, input.systemPrompt, input.prompt, { + ...(input.signal ? { signal: input.signal } : {}), + disableRetries: true, + customProviders: providerRuntime.customProviders, + }); + return { + ...(reply ? { reply } : {}), + maxOutputTokens: model.maxTokens, + ...(proxy ? { evidence: proxy.evidence() } : {}), + }; + } finally { + await proxy?.close(); + } }, async judge(systemPrompt: string, prompt: string): Promise { - const model = getRequiredModel(judgeModelId()); - const providerKeys = await resolveProviderKeys(); - if (!keyForModel(providerKeys, model)) return undefined; - return oneShot("pi-judge", model, providerKeys, systemPrompt, prompt); + const providerRuntime = await resolveProviderRuntime(); + const model = modelForRuntime(judgeModelId(), providerRuntime); + if (!keyForModel(providerRuntime.keys, model)) return undefined; + return oneShot("pi-judge", model, providerRuntime.keys, systemPrompt, prompt, { + customProviders: providerRuntime.customProviders, + }); }, async screenSecurity({ payload, signal, recordModelCall, recordLlmRequest }) { try { const modelId = detectModelId(); - const model = getRequiredModel(modelId); - const providerKeys = await resolveProviderKeys(); - if (!keyForModel(providerKeys, model)) return undefined; + const providerRuntime = await resolveProviderRuntime(); + const model = modelForRuntime(modelId, providerRuntime); + if (!keyForModel(providerRuntime.keys, model)) return undefined; recordModelCall({ model: modelId, inputTokens: countTokens(SECURITY_SCREEN_SYSTEM_PROMPT) + countTokens(payload), @@ -2106,8 +2209,9 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { truncated: false, }); return parseSecurityScreenVerdict( - await oneShot("pi-security-screen", model, providerKeys, SECURITY_SCREEN_SYSTEM_PROMPT, payload, { + await oneShot("pi-security-screen", model, providerRuntime.keys, SECURITY_SCREEN_SYSTEM_PROMPT, payload, { signal, + customProviders: providerRuntime.customProviders, }), ); } catch (e) { @@ -2137,26 +2241,27 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { async generateTitle(transcript: string): Promise { if (!transcript.trim()) return undefined; - const model = getRequiredModel(titleModelId()); - const providerKeys = await resolveProviderKeys(); - if (!keyForModel(providerKeys, model)) { + const providerRuntime = await resolveProviderRuntime(); + const model = modelForRuntime(titleModelId(), providerRuntime); + if (!keyForModel(providerRuntime.keys, model)) { throw new Error(`Missing ${model.provider} credential for title model ${model.id}`); } const out = await oneShot( "pi-title", model, - providerKeys, + providerRuntime.keys, TITLE_GENERATION_PROMPT, titleUserPrompt(transcript), + { customProviders: providerRuntime.customProviders }, ); return sanitizeTitle(out); }, async summarizeApproval(command: string, reason: string, purpose?: string): Promise { if (!command.trim()) return undefined; - const model = getRequiredModel(titleModelId()); - const providerKeys = await resolveProviderKeys(); - if (!keyForModel(providerKeys, model)) return undefined; + const providerRuntime = await resolveProviderRuntime(); + const model = modelForRuntime(titleModelId(), providerRuntime); + if (!keyForModel(providerRuntime.keys, model)) return undefined; const prompt = [ `Policy flagged this as: ${reason}`, purpose ? `Agent's stated purpose: ${purpose}` : "", @@ -2167,7 +2272,9 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { .filter((l) => l !== undefined) .join("\n"); const out = ( - await oneShot("pi-approval-summary", model, providerKeys, APPROVAL_SUMMARY_PROMPT, prompt) + await oneShot("pi-approval-summary", model, providerRuntime.keys, APPROVAL_SUMMARY_PROMPT, prompt, { + customProviders: providerRuntime.customProviders, + }) )?.trim(); if (!out || out === "NONE") return undefined; return out.replace(/^["']|["']$/g, "").slice(0, 300); diff --git a/src/index.ts b/src/index.ts index 635753204..12e9b2ead 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { createServer } from "./api/server.ts"; import { errMessage } from "./util/errors.ts"; import { slackPluginConfigFromEnv, startSlackPlugin } from "./slack/index.ts"; import { createSlackRuntimeReconciler } from "./surfaces/slack-runtime.ts"; +import { startRuntime } from "./runtime-start.ts"; const config = loadConfig(); @@ -21,14 +22,14 @@ await built.refreshCustomProviders(); await built.identity.hydrate(); await built.deploymentLayerReady; built.deploymentLayerRefresh.start(); -built.runtime.start(); - -server.listen(config.port, () => { - console.log( - `[qm] listening on :${config.port} (org=${config.orgId}, store=${config.sessionStore}, ` + - `runStore=${config.runStore}, workers=${config.workers}, backgroundWork=${config.backgroundWorkEnabled})`, - ); -}); +await startRuntime(built.runtime, () => + server.listen(config.port, () => { + console.log( + `[qm] listening on :${config.port} (org=${config.orgId}, store=${config.sessionStore}, ` + + `runStore=${config.runStore}, workers=${config.workers}, backgroundWork=${config.backgroundWorkEnabled})`, + ); + }), +); if (config.backgroundWorkEnabled) { built.scheduler.start(1000); diff --git a/src/model/custom-provider-store.ts b/src/model/custom-provider-store.ts index 285287271..f7c72b63d 100644 --- a/src/model/custom-provider-store.ts +++ b/src/model/custom-provider-store.ts @@ -9,11 +9,25 @@ import { decryptSecret, deriveConnectorKey, encryptSecret } from "../connectors/connector-client-store.ts"; import type { DurableMap } from "../persistence/durable-map.ts"; +import type { AdvisoryLock } from "../persistence/advisory-lock.ts"; +import { errMessage } from "../util/errors.ts"; import { validateCustomProviderSpec, type CustomProviderSpec } from "./custom-providers.ts"; +export const CUSTOM_PROVIDER_WIRE_ID_SCHEMA = 1; +export const CUSTOM_PROVIDER_WIRE_ID_CAPABILITY = "custom-provider-wire-id-v1"; +export const CUSTOM_PROVIDER_INPUT_MODALITIES_SCHEMA = 2; +export const CUSTOM_PROVIDER_INPUT_MODALITIES_CAPABILITY = "custom-provider-input-modalities-v2"; +export const CUSTOM_PROVIDER_HARNESS_TEST_CAPABILITY = "custom-provider-harness-test-v2"; + +export class CustomProviderRuntimeNotReadyError extends Error {} + export interface StoredCustomProvider extends CustomProviderSpec { apiKeyEnc?: string; disabled?: boolean; + compatibilityDisabled?: boolean; + runtimeSchema?: number; + modelHistory?: string[]; + revision?: number; updatedAt: number; updatedBy: string; } @@ -25,6 +39,17 @@ interface CustomProviderStatus extends CustomProviderSpec { updatedBy: string; } +export interface ActiveCustomProvider { + provider: CustomProviderSpec; + apiKey: string | null; + revision: number; +} + +export interface CustomProviderHarnessTestState { + active: ActiveCustomProvider | null; + rolloutFence: string | null; +} + export interface CustomProviderStore { /** Enabled specs only — what the runtime registry should serve. */ enabled(): Promise; @@ -32,6 +57,13 @@ export interface CustomProviderStore { statuses(): Promise; /** Plaintext key for one provider, or null when absent/disabled. */ resolveKey(id: string): Promise; + resolveActive(id: string): Promise; + harnessTestState(id: string, readRolloutFence: () => Promise): Promise; + active(): Promise>; + knowsModel(id: string): Promise; + knownModelIds(): Promise; + runtimeSchemaReady(schema: number): Promise; + runtimeSchemaWritable(schema: number): Promise; upsert(spec: CustomProviderSpec, apiKey: string | undefined, updatedBy: string): Promise; delete(id: string, updatedBy: string): Promise; } @@ -42,28 +74,84 @@ function strip(saved: StoredCustomProvider): CustomProviderSpec { name: saved.name, protocol: saved.protocol, baseUrl: saved.baseUrl, - models: saved.models, + models: saved.models.map((model) => { + if (saved.runtimeSchema === CUSTOM_PROVIDER_INPUT_MODALITIES_SCHEMA) return model; + const legacyModel = { ...model }; + delete legacyModel.inputModalities; + return legacyModel; + }), }; } +export function requiredCustomProviderRuntimeSchema(spec: CustomProviderSpec): number | undefined { + if (spec.models.some((model) => model.inputModalities !== undefined)) { + return CUSTOM_PROVIDER_INPUT_MODALITIES_SCHEMA; + } + if (spec.models.some((model) => model.upstreamId !== undefined)) return CUSTOM_PROVIDER_WIRE_ID_SCHEMA; + return undefined; +} + export function createCustomProviderStore(input: { backing: DurableMap; keyMaterial: string | Buffer; + advisoryLock?: AdvisoryLock; + runtimeSchemaReady?: (schema: number) => Promise; + runtimeSchemaWritable?: (schema: number) => Promise; }): CustomProviderStore { const key = deriveConnectorKey(input.keyMaterial, "custom-model-providers"); + const withRegistryLock = (operation: () => Promise): Promise => + input.advisoryLock ? input.advisoryLock.withLock("custom-model-providers", operation) : operation(); + const knownRuntimeSchema = (schema: number): boolean => + schema === CUSTOM_PROVIDER_WIRE_ID_SCHEMA || schema === CUSTOM_PROVIDER_INPUT_MODALITIES_SCHEMA; + const runtimeSchemaReady = (schema: number): Promise => + knownRuntimeSchema(schema) ? (input.runtimeSchemaReady?.(schema) ?? Promise.resolve(true)) : Promise.resolve(false); + const runtimeSchemaWritable = (schema: number): Promise => + knownRuntimeSchema(schema) + ? (input.runtimeSchemaWritable?.(schema) ?? runtimeSchemaReady(schema)) + : Promise.resolve(false); + const compatibilityEncoded = (saved: StoredCustomProvider): boolean => + saved.runtimeSchema !== undefined && + knownRuntimeSchema(saved.runtimeSchema) && + saved.compatibilityDisabled === true && + saved.disabled === true; + const explicitlyDisabled = (saved: StoredCustomProvider): boolean => + saved.disabled === true && !compatibilityEncoded(saved); + const runtimeEnabled = (saved: StoredCustomProvider, readySchemas: ReadonlySet): boolean => { + if (explicitlyDisabled(saved)) return false; + if (saved.runtimeSchema === undefined) return true; + return compatibilityEncoded(saved) && readySchemas.has(saved.runtimeSchema); + }; + const readySchemas = async (saved: StoredCustomProvider[]): Promise> => { + const schemas = [...new Set(saved.flatMap((provider) => provider.runtimeSchema ?? []))]; + const readiness = await Promise.all( + schemas.map(async (schema) => [schema, await runtimeSchemaReady(schema)] as const), + ); + return new Set(readiness.filter(([, ready]) => ready).map(([schema]) => schema)); + }; + const resolveActive = async (id: string): Promise => { + const saved = await input.backing.get(id); + if (!saved || !runtimeEnabled(saved, await readySchemas([saved]))) return null; + return { + provider: strip(saved), + apiKey: saved.apiKeyEnc ? decryptSecret(saved.apiKeyEnc, key) : null, + revision: saved.revision ?? 0, + }; + }; return { async enabled() { const all = await input.backing.all(); - return all.filter((p) => !p.disabled).map(strip); + const ready = await readySchemas(all); + return all.filter((saved) => runtimeEnabled(saved, ready)).map(strip); }, async statuses() { const all = await input.backing.all(); + const ready = await readySchemas(all); return all .map((p) => ({ ...strip(p), - disabled: p.disabled ?? false, + disabled: !runtimeEnabled(p, ready), hasKey: Boolean(p.apiKeyEnc), updatedAt: p.updatedAt, updatedBy: p.updatedBy, @@ -73,36 +161,119 @@ export function createCustomProviderStore(input: { async resolveKey(id) { const saved = await input.backing.get(id); - if (!saved || saved.disabled || !saved.apiKeyEnc) return null; + if (!saved || !runtimeEnabled(saved, await readySchemas([saved])) || !saved.apiKeyEnc) return null; return decryptSecret(saved.apiKeyEnc, key); }, + resolveActive, + + async harnessTestState(id, readRolloutFence) { + const before = await readRolloutFence(); + return withRegistryLock(async () => { + const active = await resolveActive(id); + const after = await readRolloutFence(); + return { + active, + rolloutFence: before !== null && before === after ? before : null, + }; + }); + }, + + async active() { + const all = await input.backing.all(); + const ready = await readySchemas(all); + return all + .filter((saved) => runtimeEnabled(saved, ready)) + .map((saved) => ({ + provider: strip(saved), + apiKey: (() => { + if (!saved.apiKeyEnc) return null; + try { + return decryptSecret(saved.apiKeyEnc, key); + } catch (error) { + console.error(`[model] custom provider ${saved.id}: key unreadable: ${errMessage(error)}`); + return null; + } + })(), + })); + }, + + async knowsModel(id) { + const all = await input.backing.all(); + return all.some((saved) => saved.models.some((model) => model.id === id) || saved.modelHistory?.includes(id)); + }, + + async knownModelIds() { + const all = await input.backing.all(); + return [ + ...new Set(all.flatMap((saved) => [...saved.models.map((model) => model.id), ...(saved.modelHistory ?? [])])), + ]; + }, + + runtimeSchemaReady, + + runtimeSchemaWritable, + 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, + await withRegistryLock(async () => { + validateCustomProviderSpec(spec); + const existing = await input.backing.get(spec.id); + const requiredSchema = requiredCustomProviderRuntimeSchema(spec); + const protectedSchema = + existing?.runtimeSchema ?? + (existing?.compatibilityDisabled === true ? CUSTOM_PROVIDER_WIRE_ID_SCHEMA : undefined); + const runtimeSchema = Math.max(protectedSchema ?? 0, requiredSchema ?? 0) || undefined; + if (runtimeSchema !== undefined && !(await runtimeSchemaWritable(runtimeSchema))) { + throw new CustomProviderRuntimeNotReadyError( + "custom model configuration is unavailable until the compatibility rollout is complete", + ); + } + const conflict = (await input.backing.all()).find( + (saved) => + !explicitlyDisabled(saved) && + saved.id !== spec.id && + saved.models.some((model) => spec.models.some((candidate) => candidate.id === model.id)), + ); + if (conflict) throw new Error(`custom model id is already registered by provider "${conflict.id}"`); + const actor = updatedBy.trim(); + if (!actor) throw new Error("updatedBy is required"); + const trimmedKey = apiKey?.trim(); + const apiKeyEnc = trimmedKey ? encryptSecret(trimmedKey, key) : existing?.apiKeyEnc; + const modelHistory = [ + ...new Set([ + ...(existing?.modelHistory ?? []), + ...(existing?.models ?? []).map((model) => model.id), + ...spec.models.map((model) => model.id), + ]), + ]; + await input.backing.put(spec.id, { + ...spec, + ...(apiKeyEnc ? { apiKeyEnc } : {}), + modelHistory, + ...(runtimeSchema !== undefined + ? { runtimeSchema, compatibilityDisabled: true, disabled: true } + : { compatibilityDisabled: false, disabled: false }), + revision: (existing?.revision ?? 0) + 1, + 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 withRegistryLock(async () => { + const existing = await input.backing.get(id); + if (!existing || explicitlyDisabled(existing)) return false; + await input.backing.put(id, { + ...existing, + disabled: true, + compatibilityDisabled: false, + revision: (existing.revision ?? 0) + 1, + updatedAt: Date.now(), + updatedBy, + }); + return true; }); - return true; }, }; } diff --git a/src/model/custom-provider-test-runs.ts b/src/model/custom-provider-test-runs.ts new file mode 100644 index 000000000..f9c33fab7 --- /dev/null +++ b/src/model/custom-provider-test-runs.ts @@ -0,0 +1,382 @@ +import { createHash, randomUUID } from "node:crypto"; +import type { DurableMap } from "../persistence/durable-map.ts"; +import type { AdvisoryLock } from "../persistence/advisory-lock.ts"; + +export interface CustomProviderTestRunIdentity { + scopeId: string; + providerId: string; + modelId: string; + harnessId: string; + providerRevision: number; + rolloutFence: string; +} + +export interface CustomProviderTestRunResponse { + status: number; + body: Record; +} + +interface StoredCustomProviderTestLease { + owner: string; + requestId: string; + requestFingerprint: string; + startedAt: number; + expiresAt: number; +} + +type StoredCustomProviderTestReceipt = + | { + state: "pending"; + owner: string; + requestId: string; + requestFingerprint: string; + activeId: string; + startedAt: number; + expiresAt: number; + } + | { + state: "completed"; + requestId: string; + requestFingerprint: string; + response: CustomProviderTestRunResponse; + completedAt: number; + expiresAt: number; + }; + +export interface StoredCustomProviderTestRun { + active: Record; + receipts: Record; +} + +export type ClaimedCustomProviderTestRun = { + kind: "claimed"; + key: string; + activeId: string; + receiptId: string; + owner: string; + requestId: string; + requestFingerprint: string; + expiresAt: number; + requestExpiresAt: number; +}; + +export type CustomProviderTestRunClaim = + | ClaimedCustomProviderTestRun + | { kind: "running"; retryAfterMs: number; replayExpected: boolean; requestExpiresAt?: number } + | { kind: "unresolved"; retryAfterMs: number; requestExpiresAt: number } + | { kind: "conflict" } + | { + kind: "replay"; + response: CustomProviderTestRunResponse; + completedAt: number; + expiresAt: number; + }; + +export interface CustomProviderTestRunStore { + readonly durable: boolean; + claim(identity: CustomProviderTestRunIdentity, requestId: string): Promise; + complete(claim: ClaimedCustomProviderTestRun, response: CustomProviderTestRunResponse): Promise; + sweep(): Promise; +} + +export const CUSTOM_PROVIDER_TEST_RUNNING_TTL_MS = 5 * 60_000; +export const CUSTOM_PROVIDER_TEST_RESULT_TTL_MS = 5 * 60_000; + +function digest(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +export function customProviderTestRunKey(identity: CustomProviderTestRunIdentity): string { + return `provider:${digest([identity.scopeId, identity.providerId])}`; +} + +export function customProviderTestActiveId(identity: CustomProviderTestRunIdentity): string { + return digest([identity.modelId, identity.harnessId]); +} + +export function customProviderTestRequestFingerprint(identity: CustomProviderTestRunIdentity): string { + return digest([ + identity.scopeId, + identity.providerId, + identity.modelId, + identity.harnessId, + identity.providerRevision, + identity.rolloutFence, + ]); +} + +export function customProviderTestReceiptId(requestId: string): string { + return digest(requestId); +} + +function pruneExpired( + bucket: StoredCustomProviderTestRun, + expiredAt: number, +): { + bucket: StoredCustomProviderTestRun; + removed: number; +} { + const active = { ...bucket.active }; + const receipts = { ...bucket.receipts }; + let removed = 0; + for (const [id, lease] of Object.entries(active)) { + if (lease.expiresAt > expiredAt) continue; + delete active[id]; + removed += 1; + } + for (const [id, receipt] of Object.entries(receipts)) { + if (receipt.expiresAt > expiredAt) continue; + delete receipts[id]; + removed += 1; + } + return { bucket: { active, receipts }, removed }; +} + +function sameLease(left: StoredCustomProviderTestLease | undefined, right: StoredCustomProviderTestLease): boolean { + return ( + left?.owner === right.owner && + left.requestId === right.requestId && + left.requestFingerprint === right.requestFingerprint && + left.startedAt === right.startedAt && + left.expiresAt === right.expiresAt + ); +} + +function samePendingReceipt( + left: StoredCustomProviderTestReceipt | undefined, + right: Extract, +): boolean { + return ( + left?.state === "pending" && + left.owner === right.owner && + left.requestId === right.requestId && + left.requestFingerprint === right.requestFingerprint && + left.activeId === right.activeId && + left.startedAt === right.startedAt && + left.expiresAt === right.expiresAt + ); +} + +async function putWithCommitRecovery( + backing: DurableMap, + key: string, + bucket: StoredCustomProviderTestRun, + committed: (stored: StoredCustomProviderTestRun) => boolean, +): Promise { + try { + await backing.put(key, bucket); + } catch (error) { + const stored: StoredCustomProviderTestRun | null = await backing.get(key).catch(() => null); + if (stored && committed(stored)) return; + throw error; + } +} + +export function createCustomProviderTestRunStore(input: { + backing: DurableMap; + advisoryLock: AdvisoryLock; + now?: () => number; + ownerId?: () => string; + runningTtlMs?: number; + resultTtlMs?: number; + durable?: boolean; +}): CustomProviderTestRunStore { + const now = input.now ?? Date.now; + const ownerId = input.ownerId ?? randomUUID; + const runningTtlMs = input.runningTtlMs ?? CUSTOM_PROVIDER_TEST_RUNNING_TTL_MS; + const resultTtlMs = input.resultTtlMs ?? CUSTOM_PROVIDER_TEST_RESULT_TTL_MS; + const withRunLock = (key: string, operation: () => Promise): Promise => + input.advisoryLock.withLock(`custom-provider-test:${key}`, operation); + + return { + durable: input.durable ?? false, + + async claim(identity, requestId) { + const key = customProviderTestRunKey(identity); + const activeId = customProviderTestActiveId(identity); + const receiptId = customProviderTestReceiptId(requestId); + const requestFingerprint = customProviderTestRequestFingerprint(identity); + return withRunLock(key, async () => { + const claimedAt = now(); + const stored = (await input.backing.get(key)) ?? { active: {}, receipts: {} }; + const pruned = pruneExpired(stored, claimedAt).bucket; + const receipt = pruned.receipts[receiptId]; + if (receipt) { + if (receipt.requestId !== requestId || receipt.requestFingerprint !== requestFingerprint) { + return { kind: "conflict" }; + } + if (receipt.state === "completed") { + return { + kind: "replay", + response: receipt.response, + completedAt: receipt.completedAt, + expiresAt: receipt.expiresAt, + }; + } + const active = pruned.active[receipt.activeId]; + if (!active || active.owner !== receipt.owner || active.requestFingerprint !== receipt.requestFingerprint) { + return { + kind: "unresolved", + retryAfterMs: receipt.expiresAt - claimedAt, + requestExpiresAt: receipt.expiresAt, + }; + } + return { + kind: "running", + retryAfterMs: active.expiresAt - claimedAt, + replayExpected: true, + requestExpiresAt: receipt.expiresAt, + }; + } + const existing = pruned.active[activeId]; + if (existing) { + const replayExpected = existing.requestFingerprint === requestFingerprint; + let requestExpiresAt: number | undefined; + if (replayExpected) { + requestExpiresAt = Math.max(existing.expiresAt, claimedAt + resultTtlMs); + const pending: Extract = { + state: "pending", + owner: existing.owner, + requestId, + requestFingerprint, + activeId, + startedAt: existing.startedAt, + expiresAt: requestExpiresAt, + }; + await putWithCommitRecovery( + input.backing, + key, + { + active: pruned.active, + receipts: { + ...pruned.receipts, + [receiptId]: pending, + }, + }, + (persisted) => + sameLease(persisted.active[activeId], existing) && + samePendingReceipt(persisted.receipts[receiptId], pending), + ); + } + return { + kind: "running", + retryAfterMs: existing.expiresAt - claimedAt, + replayExpected, + ...(requestExpiresAt === undefined ? {} : { requestExpiresAt }), + }; + } + const unresolvedUntil = Object.values(pruned.receipts).reduce( + (latest, pending) => + pending.state === "pending" && pending.activeId === activeId ? Math.max(latest, pending.expiresAt) : latest, + 0, + ); + if (unresolvedUntil > claimedAt) { + return { + kind: "unresolved", + retryAfterMs: unresolvedUntil - claimedAt, + requestExpiresAt: unresolvedUntil, + }; + } + const owner = ownerId(); + const expiresAt = claimedAt + runningTtlMs; + const requestExpiresAt = Math.max(expiresAt, claimedAt + resultTtlMs); + const lease = { owner, requestId, requestFingerprint, startedAt: claimedAt, expiresAt }; + const pending: Extract = { + state: "pending", + ...lease, + activeId, + expiresAt: requestExpiresAt, + }; + await putWithCommitRecovery( + input.backing, + key, + { + active: { ...pruned.active, [activeId]: lease }, + receipts: { + ...pruned.receipts, + [receiptId]: pending, + }, + }, + (persisted) => + sameLease(persisted.active[activeId], lease) && samePendingReceipt(persisted.receipts[receiptId], pending), + ); + return { + kind: "claimed", + key, + activeId, + receiptId, + owner, + requestId, + requestFingerprint, + expiresAt, + requestExpiresAt, + }; + }); + }, + + async complete(claim, response) { + return withRunLock(claim.key, async () => { + const stored = await input.backing.get(claim.key); + const lease = stored?.active[claim.activeId]; + const receipt = stored?.receipts[claim.receiptId]; + if ( + !stored || + !lease || + !receipt || + receipt.state !== "pending" || + lease.owner !== claim.owner || + receipt.owner !== claim.owner || + lease.requestId !== claim.requestId || + receipt.requestId !== claim.requestId || + lease.requestFingerprint !== claim.requestFingerprint || + receipt.requestFingerprint !== claim.requestFingerprint || + receipt.activeId !== claim.activeId + ) { + return false; + } + const completedAt = now(); + const active = { ...stored.active }; + delete active[claim.activeId]; + const receipts = { ...stored.receipts }; + for (const [id, pending] of Object.entries(receipts)) { + if ( + pending.state !== "pending" || + pending.owner !== claim.owner || + pending.activeId !== claim.activeId || + pending.requestFingerprint !== claim.requestFingerprint + ) { + continue; + } + receipts[id] = { + state: "completed", + requestId: pending.requestId, + requestFingerprint: pending.requestFingerprint, + response, + completedAt, + expiresAt: completedAt + resultTtlMs, + }; + } + await input.backing.put(claim.key, { + active, + receipts, + }); + return true; + }); + }, + + async sweep() { + const expiredAt = now(); + let removed = 0; + for (const [key] of await input.backing.entries()) { + removed += await withRunLock(key, async () => { + const stored = await input.backing.get(key); + if (!stored) return 0; + const pruned = pruneExpired(stored, expiredAt); + if (pruned.removed > 0) await input.backing.put(key, pruned.bucket); + return pruned.removed; + }); + } + return removed; + }, + }; +} diff --git a/src/model/custom-providers.ts b/src/model/custom-providers.ts index cb2a92c20..00866015f 100644 --- a/src/model/custom-providers.ts +++ b/src/model/custom-providers.ts @@ -1,28 +1,15 @@ -/** - * 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 const CUSTOM_PROVIDER_PROTOCOLS = ["openai", "openai-responses", "anthropic"] as const; export type CustomProviderProtocol = (typeof CUSTOM_PROVIDER_PROTOCOLS)[number]; -interface CustomModelSpec { +export interface CustomModelSpec { id: string; + upstreamId?: string; name?: string; contextWindow?: number; maxTokens?: number; + inputModalities?: ("text" | "image")[]; /** USD per million input tokens. Defaults to 0 (unknown / not metered). */ input?: number; /** USD per million output tokens. Defaults to 0. */ @@ -57,15 +44,46 @@ export function validateCustomProviderSpec(spec: CustomProviderSpec): void { } if (spec.models.length > 200) throw new Error("at most 200 models per provider"); const seen = new Set(); + const upstreamSeen = 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 || typeof m !== "object" || typeof m.id !== "string" || !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); + if ( + m.upstreamId !== undefined && + (typeof m.upstreamId !== "string" || + !m.upstreamId.trim() || + m.upstreamId.length > 200 || + m.upstreamId !== m.upstreamId.trim()) + ) { + throw new Error(`model "${m.id}": upstreamId must be a non-empty string of 200 chars or fewer`); + } + const upstreamId = m.upstreamId?.trim() || m.id; + if (upstreamSeen.has(upstreamId)) throw new Error(`duplicate upstream model id "${upstreamId}"`); + upstreamSeen.add(upstreamId); for (const [field, v] of [ ["contextWindow", m.contextWindow], ["maxTokens", m.maxTokens], + ] as const) { + if (v !== undefined && (!Number.isInteger(v) || v <= 0)) { + throw new Error(`model "${m.id}": ${field} must be a positive integer`); + } + } + if ( + m.inputModalities !== undefined && + (!Array.isArray(m.inputModalities) || + m.inputModalities.length === 0 || + !m.inputModalities.includes("text") || + m.inputModalities.some((modality) => modality !== "text" && modality !== "image") || + new Set(m.inputModalities).size !== m.inputModalities.length) + ) { + throw new Error(`model "${m.id}": inputModalities must be text or text,image`); + } + for (const [field, v] of [ ["input", m.input], ["output", m.output], ] as const) { @@ -83,9 +101,10 @@ export function validateCustomProviderSpec(spec: CustomProviderSpec): void { */ export interface CustomRuntimeModel { id: string; + wireId: string; name: string; provider: string; - api: "openai-completions" | "anthropic-messages"; + api: "openai-completions" | "openai-responses" | "anthropic-messages"; baseUrl: string; reasoning: boolean; input: ("text" | "image")[]; @@ -97,23 +116,41 @@ export interface CustomRuntimeModel { const DEFAULT_CONTEXT_WINDOW = 128_000; const DEFAULT_MAX_TOKENS = 8_192; +function runtimeApi(protocol: CustomProviderProtocol): CustomRuntimeModel["api"] { + if (protocol === "anthropic") return "anthropic-messages"; + if (protocol === "openai-responses") return "openai-responses"; + return "openai-completions"; +} + function toRuntimeModel(provider: CustomProviderSpec, m: CustomModelSpec): CustomRuntimeModel { return { id: m.id, + wireId: m.upstreamId?.trim() || m.id, name: m.name?.trim() || m.id, provider: provider.id, - api: provider.protocol === "anthropic" ? "anthropic-messages" : "openai-completions", + api: runtimeApi(provider.protocol), baseUrl: provider.baseUrl, reasoning: false, - input: ["text"], + input: m.inputModalities ? [...m.inputModalities] : ["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, }; } +export function runtimeModelForCustomProvider( + provider: CustomProviderSpec, + modelId: string, +): CustomRuntimeModel | undefined { + const model = provider.models.find((candidate) => candidate.id === modelId); + if (!model) return undefined; + const runtime = toRuntimeModel(provider, model); + return { ...runtime, id: runtime.wireId }; +} + let registry = new Map(); let providers: CustomProviderSpec[] = []; +let knownModelIds = new Set(); let version = 0; /** @@ -122,15 +159,27 @@ let version = 0; * ids shadow custom ones at resolution, so a collision can't hijack a * built-in. */ -export function setCustomProviders(specs: CustomProviderSpec[]): void { +export function setCustomProviders(specs: CustomProviderSpec[], knownIds?: readonly string[]): void { + const snapshot = specs.map((spec) => ({ ...spec, models: [...spec.models] })); const next = new Map(); - for (const spec of specs) { + for (const spec of snapshot) { for (const m of spec.models) { + if (next.has(m.id)) throw new Error(`custom model id "${m.id}" is registered by more than one provider`); next.set(m.id, toRuntimeModel(spec, m)); } } + const nextKnown = new Set((knownIds ?? [...knownModelIds]).filter((id) => typeof id === "string" && id.length > 0)); + for (const id of next.keys()) nextKnown.add(id); + if ( + JSON.stringify(snapshot) === JSON.stringify(providers) && + nextKnown.size === knownModelIds.size && + [...nextKnown].every((id) => knownModelIds.has(id)) + ) { + return; + } registry = next; - providers = specs.map((s) => ({ ...s, models: [...s.models] })); + providers = snapshot; + knownModelIds = nextKnown; version += 1; } @@ -147,6 +196,10 @@ export function isCustomModelId(id: string): boolean { return registry.has(id); } +export function isKnownCustomModelId(id: string): boolean { + return knownModelIds.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 })); } @@ -158,21 +211,24 @@ export function customModelCatalog(): Array<{ id: string; name: string; provider * 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; +export function customModelsJsonForProviders( + specs: CustomProviderSpec[], +): { providers: Record } | undefined { + if (specs.length === 0) return undefined; return { providers: Object.fromEntries( - providers.map((spec) => [ + specs.map((spec) => [ spec.id, { name: spec.name, baseUrl: spec.baseUrl, - api: spec.protocol === "anthropic" ? "anthropic-messages" : "openai-completions", + api: runtimeApi(spec.protocol), models: spec.models.map((m) => ({ - id: m.id, + id: m.upstreamId?.trim() || m.id, name: m.name ?? m.id, contextWindow: m.contextWindow ?? 128_000, maxTokens: m.maxTokens ?? 8_192, + input: m.inputModalities ? [...m.inputModalities] : ["text"], cost: { input: m.input ?? 0, output: m.output ?? 0, cacheRead: 0, cacheWrite: 0 }, })), }, @@ -180,3 +236,7 @@ export function customModelsJson(): { providers: Record } | und ), }; } + +export function customModelsJson(): { providers: Record } | undefined { + return customModelsJsonForProviders(providers); +} diff --git a/src/model/pi-models.ts b/src/model/pi-models.ts index b23d86c01..1d7730b6b 100644 --- a/src/model/pi-models.ts +++ b/src/model/pi-models.ts @@ -1,7 +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"; +import { isCustomModelId, isKnownCustomModelId, resolveCustomModel } from "./custom-providers.ts"; const getModel = getBuiltinModel as unknown as (provider: string, id: string) => Model | undefined; @@ -97,7 +97,12 @@ const REGISTRY_BY_ID = new Map(MODEL_REGISTRY.map((m) => [m.id, m])); const OPENROUTER_CATALOG_MODELS = new Map(); export function modelDisplayName(id: string): string { - return REGISTRY_BY_ID.get(id)?.name ?? OPENROUTER_CATALOG_MODELS.get(id)?.name ?? id; + return ( + REGISTRY_BY_ID.get(id)?.name ?? + resolveCustomModel(id)?.name ?? + (isKnownCustomModelId(id) ? id : OPENROUTER_CATALOG_MODELS.get(id)?.name) ?? + id + ); } export const DEFAULT_WEBUI_MODEL_IDS: readonly string[] = MODEL_REGISTRY.filter((m) => m.webui).map((m) => m.id); @@ -162,7 +167,7 @@ export function registerOpenRouterCatalogModel(definition: OpenRouterCatalogMode return model; } -export function resolveModel(id: string): PiModel | undefined { +export function resolveStaticModel(id: string): PiModel | undefined { const entry = REGISTRY_BY_ID.get(id); if (entry?.clone) { const template = builtinModel(entry.clone.template); @@ -179,9 +184,15 @@ export function resolveModel(id: string): PiModel | undefined { }) : undefined; } - return ( - builtinModel(id) ?? (resolveCustomModel(id) as unknown as PiModel | undefined) ?? OPENROUTER_CATALOG_MODELS.get(id) - ); + return builtinModel(id); +} + +export function resolveModel(id: string): PiModel | undefined { + const stable = resolveStaticModel(id); + if (stable) return stable; + const custom = resolveCustomModel(id) as unknown as PiModel | undefined; + if (custom) return custom; + return isKnownCustomModelId(id) ? undefined : OPENROUTER_CATALOG_MODELS.get(id); } export function auxiliaryModelForProvider(provider: string): string | undefined { @@ -207,8 +218,10 @@ 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)) + if (isCustomModelId(id) && !REGISTRY_BY_ID.has(id)) { + if (harness === "codex") return resolveCustomModel(id)?.api === "openai-responses"; 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); diff --git a/src/persistence/advisory-lock.ts b/src/persistence/advisory-lock.ts index 8558863c2..405943c09 100644 --- a/src/persistence/advisory-lock.ts +++ b/src/persistence/advisory-lock.ts @@ -1,4 +1,4 @@ -import type { PgPool } from "./pg-pool.ts"; +import type { PgPool, Pool, PoolClient } from "./pg-pool.ts"; import { createKeyedQueue, sleep } from "../util/async.ts"; export interface AdvisoryLock { @@ -9,6 +9,105 @@ export interface AdvisoryLock { const DEFAULT_ADVISORY_LOCK_TIMEOUT_MS = 5 * 60_000; const DEFAULT_ADVISORY_LOCK_POLL_MS = 300; +type SlotRelease = () => void; +type PoolSlotWaiter = { resolve: (release: SlotRelease | null) => void; timer: ReturnType }; +type PoolSlots = { active: number; max: number; waiters: Set }; +type AdvisoryClient = { client: PoolClient; release: SlotRelease }; + +const poolSlots = new WeakMap(); + +function slotsFor(pool: Pool): PoolSlots { + let slots = poolSlots.get(pool); + if (!slots) { + slots = { active: 0, max: pool.options.max, waiters: new Set() }; + poolSlots.set(pool, slots); + } + return slots; +} + +function releaseSlot(slots: PoolSlots): void { + slots.active -= 1; + const next = slots.waiters.values().next().value as PoolSlotWaiter | undefined; + if (!next) return; + slots.waiters.delete(next); + clearTimeout(next.timer); + slots.active += 1; + let released = false; + next.resolve(() => { + if (released) return; + released = true; + releaseSlot(slots); + }); +} + +function reserveSlotBefore(pool: Pool, deadline: number): Promise { + const slots = slotsFor(pool); + if (slots.active < slots.max) { + slots.active += 1; + let released = false; + return Promise.resolve(() => { + if (released) return; + released = true; + releaseSlot(slots); + }); + } + const remaining = deadline - Date.now(); + if (remaining <= 0) return Promise.resolve(null); + return new Promise((resolve) => { + const waiter: PoolSlotWaiter = { + resolve, + timer: setTimeout(() => { + slots.waiters.delete(waiter); + resolve(null); + }, remaining), + }; + waiter.timer.unref(); + slots.waiters.add(waiter); + }); +} + +async function connectBefore(pool: Pool, deadline: number): Promise { + const slot = await reserveSlotBefore(pool, deadline); + if (!slot) return null; + const remaining = deadline - Date.now(); + if (remaining <= 0) { + slot(); + return null; + } + let expired = false; + let timer: ReturnType | undefined; + const connected = pool.connect().then( + (client) => { + if (expired || Date.now() >= deadline) { + client.release(); + slot(); + return null; + } + if (timer) clearTimeout(timer); + return { + client, + release() { + client.release(); + slot(); + }, + }; + }, + (error) => { + if (timer) clearTimeout(timer); + slot(); + throw error; + }, + ); + const timedOut = new Promise((resolve) => { + timer = setTimeout(() => { + expired = true; + resolve(null); + }, remaining); + timer.unref(); + }); + return Promise.race([connected, timedOut]); +} + export function createNoopAdvisoryLock(): AdvisoryLock { return { async withLock(_key: string, fn: () => Promise): Promise { @@ -53,7 +152,9 @@ export function createPostgresAdvisoryLock( const deadline = Date.now() + timeoutMs; const pool = await pg.pool(); for (;;) { - const client = await pool.connect(); + const connection = await connectBefore(pool, deadline); + if (!connection) throw new Error(`timeout acquiring advisory lock for ${key}`); + const { client } = connection; try { const res = await client.query<{ locked: boolean }>( "SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS locked", @@ -68,16 +169,18 @@ export function createPostgresAdvisoryLock( } } } finally { - client.release(); + connection.release(); } if (Date.now() >= deadline) throw new Error(`timeout acquiring advisory lock for ${key}`); - await sleep(pollMs); + await sleep(Math.min(pollMs, Math.max(1, deadline - Date.now()))); } }, async tryWithLock(key: string, fn: () => Promise): Promise { const pool = await pg.pool(); - const client = await pool.connect(); + const connection = await connectBefore(pool, Date.now() + Math.max(1, Math.min(timeoutMs, pollMs))); + if (!connection) return null; + const { client } = connection; try { const res = await client.query<{ locked: boolean }>( "SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS locked", @@ -90,7 +193,7 @@ export function createPostgresAdvisoryLock( await client.query("SELECT pg_advisory_unlock(hashtextextended($1, 0))", [key]); } } finally { - client.release(); + connection.release(); } }, }; diff --git a/src/persistence/durable-map.ts b/src/persistence/durable-map.ts index da3d0a917..f6c667352 100644 --- a/src/persistence/durable-map.ts +++ b/src/persistence/durable-map.ts @@ -254,9 +254,14 @@ export function createPostgresMap(pg: PgPool, table: string): DurableMap { export interface PostgresArtifactMaps { map(table: string): DurableMap; pool: PgPool; + advisoryPool: PgPool; } export function createPostgresMapFactory(connectionString: string): PostgresArtifactMaps { const pg = createPgPool(connectionString, []); - return { map: (table: string): DurableMap => createPostgresMap(pg, table), pool: pg }; + return { + map: (table: string): DurableMap => createPostgresMap(pg, table), + pool: pg, + advisoryPool: createPgPool(connectionString, []), + }; } diff --git a/src/persistence/pg-pool.ts b/src/persistence/pg-pool.ts index 8c5ef21b1..296007356 100644 --- a/src/persistence/pg-pool.ts +++ b/src/persistence/pg-pool.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import type { Pool, PoolClient } from "pg"; import { swallowAs } from "../util/errors.ts"; import { errMessage } from "../util/errors.ts"; +import { sleep } from "../util/async.ts"; export type { Pool, PoolClient }; @@ -45,26 +46,78 @@ export function concurrentIndexName(stmt: string): string | undefined { return /^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+CONCURRENTLY\s+IF\s+NOT\s+EXISTS\s+([a-z_][a-z0-9_$]*)\b/i.exec(stmt)?.[1]; } +function retryableDdlConflict(error: unknown): boolean { + const code = (error as { code?: unknown } | null)?.code; + return code === "40P01" || code === "55P03"; +} + async function applyDdl(pool: Pool, statements: string[]): Promise { - const ddl = await pool.connect(); - try { - await ddl.query("SELECT pg_advisory_lock(hashtext('agent-platform:schema-init'))"); - for (const stmt of statements) { - const indexName = concurrentIndexName(stmt); - if (indexName) { - const existing = await ddl.query( - "SELECT NOT indisvalid OR NOT indisready AS invalid FROM pg_index WHERE indexrelid = to_regclass($1)", - [indexName], - ); - if (existing.rows[0]?.invalid) await ddl.query(`DROP INDEX CONCURRENTLY ${indexName}`); + if (statements.length === 0) { + const ddl = await pool.connect(); + ddl.release(); + return; + } + const deadline = Date.now() + 5 * 60_000; + let lastConflict: unknown; + for (;;) { + const ddl = await pool.connect(); + let locked = false; + try { + const acquired = await ddl.query<{ locked: boolean }>( + "SELECT pg_try_advisory_lock(hashtext('agent-platform:schema-init')) AS locked", + ); + locked = acquired.rows[0]?.locked === true; + if (locked) { + try { + for (const stmt of statements) { + const indexName = concurrentIndexName(stmt); + if (indexName) { + const previousLockTimeout = await ddl.query<{ value: string }>( + "SELECT current_setting('lock_timeout') AS value", + ); + const timeout = await ddl.query<{ ms: number }>( + `SELECT GREATEST( + 1, + LEAST( + 250, + FLOOR(EXTRACT(EPOCH FROM current_setting('deadlock_timeout')::interval) * 400) + ) + )::int AS ms`, + ); + await ddl.query("SELECT set_config('lock_timeout', $1, false)", [`${timeout.rows[0]?.ms ?? 250}ms`]); + try { + const existing = await ddl.query( + "SELECT NOT indisvalid OR NOT indisready AS invalid FROM pg_index WHERE indexrelid = to_regclass($1)", + [indexName], + ); + if (existing.rows[0]?.invalid) await ddl.query(`DROP INDEX CONCURRENTLY ${indexName}`); + await ddl.query(stmt); + } finally { + await ddl + .query("SELECT set_config('lock_timeout', $1, false)", [previousLockTimeout.rows[0]?.value ?? "0"]) + .catch(swallowAs("pg-pool: reset schema lock timeout", undefined)); + } + } else { + await ddl.query(stmt); + } + } + return; + } catch (error) { + if (!retryableDdlConflict(error)) throw error; + lastConflict = error; + } + } + } finally { + if (locked) { + await ddl + .query("SELECT pg_advisory_unlock(hashtext('agent-platform:schema-init'))") + .catch(swallowAs("pg-pool: schema-init unlock", undefined)); } - await ddl.query(stmt); + ddl.release(); } - } finally { - await ddl - .query("SELECT pg_advisory_unlock(hashtext('agent-platform:schema-init'))") - .catch(swallowAs("pg-pool: schema-init unlock", undefined)); - ddl.release(); + if (Date.now() >= deadline) + throw new Error("timeout applying schema under the schema-init advisory lock", { cause: lastConflict }); + await sleep(300); } } diff --git a/src/runs/drain.ts b/src/runs/drain.ts index 7ac4a3b80..9082121fa 100644 --- a/src/runs/drain.ts +++ b/src/runs/drain.ts @@ -3,54 +3,83 @@ import type { TaskProtection } from "./task-protection.ts"; import { createSweeper, type Sweeper } from "../util/sweeper.ts"; export interface DrainController { + ready(): Promise; start(): void; stop(): void; canClaim(): boolean; + readyForTraffic(): boolean; noteBusy(): void; } const DRAIN_SWEEP_MS = 10_000; +const REGISTRY_FRESHNESS_MS = 25_000; export function createDrainController(opts: { registry: InstanceRegistry; protection: TaskProtection | null; busy: () => boolean; sweepMs?: number; + freshnessMs?: number; }): DrainController { let superseded = false; let protectionOn = false; - const sweeper: Sweeper = createSweeper( - async () => { + let registryHealthy = false; + let lastRegistryBeatAt = 0; + let readyP: Promise | null = null; + const registryBeat = async () => { + try { const wasSuperseded = superseded; superseded = await opts.registry.beat(); + registryHealthy = true; + lastRegistryBeatAt = Date.now(); if (superseded !== wasSuperseded) { console.error( `[drain] ${superseded ? "newer build is live — draining: no new run claims, finishing in-flight turns" : "newer build gone — resuming run claims"}`, ); } - if (!opts.protection) return; - const busy = opts.busy(); - if (busy) { - await opts.protection.set(true); - protectionOn = true; - } else if (protectionOn) { - await opts.protection.set(false); - protectionOn = false; + } catch (error) { + registryHealthy = false; + throw error; + } + }; + const beat = async () => { + await registryBeat(); + if (!opts.protection) return; + const busy = opts.busy(); + if (busy) { + await opts.protection.set(true); + protectionOn = true; + } else if (protectionOn) { + await opts.protection.set(false); + protectionOn = false; + } + }; + const sweeper: Sweeper = createSweeper(beat, opts.sweepMs ?? DRAIN_SWEEP_MS, { + label: "deploy-drain", + immediate: true, + }); + return { + ready: () => { + if (!readyP) { + readyP = registryBeat().catch((error) => { + readyP = null; + throw error; + }); } + return readyP; }, - opts.sweepMs ?? DRAIN_SWEEP_MS, - { label: "deploy-drain", immediate: true }, - ); - return { start: () => sweeper.start(), stop: () => { sweeper.stop(); + registryHealthy = false; if (protectionOn && opts.protection) { protectionOn = false; void opts.protection.set(false); } }, canClaim: () => !superseded, + readyForTraffic: () => + registryHealthy && Date.now() - lastRegistryBeatAt < (opts.freshnessMs ?? REGISTRY_FRESHNESS_MS), noteBusy: () => { if (!opts.protection || protectionOn) return; protectionOn = true; diff --git a/src/runs/instance-registry.ts b/src/runs/instance-registry.ts index cf96237a6..cef1b530b 100644 --- a/src/runs/instance-registry.ts +++ b/src/runs/instance-registry.ts @@ -2,9 +2,12 @@ import type { PgPool } from "../persistence/pg-pool.ts"; export interface InstanceRegistry { beat(): Promise; + allLiveSupport?(capability: string): Promise; + capabilitySnapshot?(capability: string): Promise<{ ready: boolean; epoch: string }>; } const INSTANCE_LIVENESS_MS = 30_000; +const INCOMPATIBLE_GRACE_MS = 120_000; export function createNoopInstanceRegistry(): InstanceRegistry { return { beat: async () => false }; @@ -12,9 +15,18 @@ export function createNoopInstanceRegistry(): InstanceRegistry { export function createPostgresInstanceRegistry( pg: PgPool, - opts: { instanceId: string; buildSha: string; startedAt: number; livenessMs?: number }, + opts: { + instanceId: string; + buildSha: string; + startedAt: number; + livenessMs?: number; + incompatibleGraceMs?: number; + capabilities?: readonly string[]; + }, ): InstanceRegistry { const livenessMs = opts.livenessMs ?? INSTANCE_LIVENESS_MS; + const incompatibleGraceMs = opts.incompatibleGraceMs ?? INCOMPATIBLE_GRACE_MS; + const capabilities = [...(opts.capabilities ?? [])]; let readyP: Promise | null = null; function ready(): Promise { if (!readyP) { @@ -24,9 +36,30 @@ export function createPostgresInstanceRegistry( instance_id TEXT PRIMARY KEY, build_sha TEXT NOT NULL, started_at BIGINT NOT NULL, - beat_at TIMESTAMPTZ NOT NULL + beat_at TIMESTAMPTZ NOT NULL, + capabilities TEXT[] NOT NULL DEFAULT '{}' )`, ) + .then(() => + pg.query( + `ALTER TABLE instance_heartbeats ADD COLUMN IF NOT EXISTS capabilities TEXT[] NOT NULL DEFAULT '{}'`, + ), + ) + .then(() => + pg.query( + `CREATE TABLE IF NOT EXISTS instance_registry_state( + singleton BOOLEAN PRIMARY KEY DEFAULT true CHECK (singleton), + capability_epoch BIGINT NOT NULL DEFAULT 0 + )`, + ), + ) + .then(() => + pg.query( + `INSERT INTO instance_registry_state(singleton, capability_epoch) + VALUES (true, 0) + ON CONFLICT (singleton) DO NOTHING`, + ), + ) .then(() => undefined) .catch((e) => { readyP = null; @@ -36,14 +69,56 @@ export function createPostgresInstanceRegistry( return readyP; } + async function capabilitySnapshot(capability: string): Promise<{ ready: boolean; epoch: string }> { + await ready(); + const { rows } = await pg.query( + `SELECT + (SELECT capability_epoch::text FROM instance_registry_state WHERE singleton = true) AS epoch, + EXISTS( + SELECT 1 FROM instance_heartbeats + WHERE instance_id = $1 + AND beat_at > now() - ($3 || ' milliseconds')::interval + AND $2::text = ANY(capabilities) + ) AS current_live, + NOT EXISTS( + SELECT 1 FROM instance_heartbeats + WHERE beat_at > now() - ($4 || ' milliseconds')::interval + AND NOT ($2::text = ANY(capabilities)) + ) AS all_live_support`, + [opts.instanceId, capability, String(livenessMs), String(incompatibleGraceMs)], + ); + return { + ready: rows[0]?.current_live === true && rows[0]?.all_live_support === true, + epoch: String(rows[0]?.epoch ?? "0"), + }; + } + return { async beat(): Promise { await ready(); await pg.query( - `INSERT INTO instance_heartbeats(instance_id, build_sha, started_at, beat_at) - VALUES ($1, $2, $3, now()) - ON CONFLICT (instance_id) DO UPDATE SET beat_at = now()`, - [opts.instanceId, opts.buildSha, String(opts.startedAt)], + `WITH prior AS MATERIALIZED ( + SELECT capabilities, + beat_at > now() - ($5 || ' milliseconds')::interval AS was_live + FROM instance_heartbeats + WHERE instance_id = $1 + ), upserted AS ( + INSERT INTO instance_heartbeats(instance_id, build_sha, started_at, beat_at, capabilities) + VALUES ($1, $2, $3, now(), $4) + ON CONFLICT (instance_id) DO UPDATE + SET beat_at = now(), capabilities = EXCLUDED.capabilities + RETURNING 1 + ) + UPDATE instance_registry_state + SET capability_epoch = capability_epoch + 1 + WHERE singleton = true + AND EXISTS (SELECT 1 FROM upserted) + AND ( + NOT EXISTS (SELECT 1 FROM prior) + OR NOT COALESCE((SELECT was_live FROM prior), false) + OR (SELECT capabilities FROM prior) IS DISTINCT FROM $4::text[] + )`, + [opts.instanceId, opts.buildSha, String(opts.startedAt), capabilities, String(livenessMs)], ); await pg.query(`DELETE FROM instance_heartbeats WHERE beat_at < now() - interval '1 hour'`, []); const { rowCount } = await pg.query( @@ -55,5 +130,12 @@ export function createPostgresInstanceRegistry( ); return rowCount > 0; }, + + capabilitySnapshot, + + async allLiveSupport(capability): Promise { + const snapshot = await capabilitySnapshot(capability); + return snapshot.ready; + }, }; } diff --git a/src/runs/worker-main.ts b/src/runs/worker-main.ts index 7f3ab29ac..20f57a225 100644 --- a/src/runs/worker-main.ts +++ b/src/runs/worker-main.ts @@ -1,12 +1,13 @@ import { loadConfig } from "../config.ts"; import { buildApp, stopWithBackstop } from "../wiring.ts"; +import { startRuntime } from "../runtime-start.ts"; const config = loadConfig(); const built = buildApp(config); await built.config.hydrate?.(); await built.identity.hydrate(); const { runtime } = built; -runtime.start(); +await startRuntime(runtime); console.log(`[qm:worker] draining runs (org=${config.orgId}, runStore=${config.runStore}, workers=${config.workers})`); let shuttingDown = false; diff --git a/src/runtime-start.ts b/src/runtime-start.ts new file mode 100644 index 000000000..2e6ce0808 --- /dev/null +++ b/src/runtime-start.ts @@ -0,0 +1,7 @@ +import type { Runtime } from "./wiring.ts"; + +export async function startRuntime(runtime: Runtime, afterReady?: () => void): Promise { + await runtime.ready(); + runtime.start(); + afterReady?.(); +} diff --git a/src/wiring.ts b/src/wiring.ts index 3499722b3..a9782fa57 100644 --- a/src/wiring.ts +++ b/src/wiring.ts @@ -2,7 +2,12 @@ import { mkdirSync } from "node:fs"; import { randomBytes, randomUUID } from "node:crypto"; import { join, resolve } from "node:path"; import { baseModelProviders, configuredModelForHarness, providerKeysPresent, type Config } from "./config.ts"; -import type { ServerDeps } from "./api/deps.ts"; +import { + CustomProviderHarnessTestRolloutIncompleteError, + CustomProviderTestConfigurationChangedError, + type CustomProviderHarnessTestRunner, + type ServerDeps, +} from "./api/deps.ts"; import { createIdentityService, type DeactivationRecord, type IdentityService } from "./identity/identity-service.ts"; import { createMemoryConfigStore, @@ -171,8 +176,21 @@ import { createConsentLinkStore, type ConsentLinkStore, type ConsentLinkRecord } 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 { resolveCustomModel, runtimeModelForCustomProvider, setCustomProviders } from "./model/custom-providers.ts"; +import { + createCustomProviderStore, + CUSTOM_PROVIDER_HARNESS_TEST_CAPABILITY, + CUSTOM_PROVIDER_INPUT_MODALITIES_CAPABILITY, + CUSTOM_PROVIDER_INPUT_MODALITIES_SCHEMA, + CUSTOM_PROVIDER_WIRE_ID_CAPABILITY, + CUSTOM_PROVIDER_WIRE_ID_SCHEMA, + type CustomProviderStore, +} from "./model/custom-provider-store.ts"; +import { + createCustomProviderTestRunStore, + type CustomProviderTestRunStore, + type StoredCustomProviderTestRun, +} from "./model/custom-provider-test-runs.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"; @@ -181,6 +199,7 @@ import { createOpenCodeHarness, openCodeHarnessConfigOptions } from "./harness/o import { createCodexHarness, codexHarnessConfigOptions } from "./harness/codex-harness.ts"; import { createClaudeHarness, claudeHarnessConfigOptions } from "./harness/claude-harness.ts"; import { createPiHarness, piHarnessConfigOptions } from "./harness/pi-harness.ts"; +import { MODEL_TEST_MAX_OUTPUT_TOKENS } from "./harness/model-test-proxy.ts"; import { createHarnessRouter, resolveRuntimeChoiceDurable } from "./harness/harness-router.ts"; import type { Harness } from "./harness/harness.ts"; import { createSecurityScreenProxy, type SecurityScreener } from "./security/security-screener.ts"; @@ -252,8 +271,11 @@ import { auxiliaryModelForProvider, defaultModelForHarness, modelProviderAvailabilityFor, + resolveModel, + resolveStaticModel, type HarnessId, } from "./model/pi-models.ts"; +import { NonRetryableTurnError } from "./core/turn-error.ts"; import { createAdminService, bootAdminGrantSeed, type AdminService } from "./admin/admin-service.ts"; import { createAdminGrantStore, createMapAdminGrantPersistence, type AdminGrant } from "./admin/admin-grant-store.ts"; import { createPostgresAdminGrantStore } from "./admin/postgres-admin-grant-store.ts"; @@ -282,6 +304,8 @@ import { sleep } from "./util/async.ts"; import { createSlackInstallationStore, type SlackInstallationStore } from "./surfaces/slack-installation.ts"; export interface Runtime { + ready(): Promise; + readyForTraffic(): boolean; start(): void; stop(): Promise; releaseInFlightRuns(): Promise; @@ -334,7 +358,10 @@ export interface BuiltApp { modelGateway: ModelGateway; modelCredentials: ModelCredentialStore; customProviders: CustomProviderStore; + customProviderTestRuns: CustomProviderTestRunStore; refreshCustomProviders: () => Promise; + customProviderHarnessTest: CustomProviderHarnessTestRunner; + customProviderHarnessTestFence: () => Promise; mcpServers: McpServerStore; mcpToolService: McpToolService; acl: AclStore; @@ -388,8 +415,12 @@ export function buildApp( securityScreener?: SecurityScreener; credentialBrokers?: Record; modelCredentialFetch?: typeof fetch; + instanceRegistry?: InstanceRegistry; } = {}, ): BuiltApp { + if (config.production && config.databaseUrl && !overrides.instanceRegistry && !config.buildSha) { + throw new Error("GIT_SHA is required for production instances with durable storage"); + } if (config.databaseUrl && !config.connectorSecretKey) { throw new Error("CONNECTOR_SECRET_KEY is required with durable storage"); } @@ -435,7 +466,7 @@ export function buildApp( ? createPostgresLeaderLease(pgArtifactMap.pool) : createNoopLeaderLease(); const advisoryLock: AdvisoryLock = pgArtifactMap - ? createPostgresAdvisoryLock(pgArtifactMap.pool) + ? createPostgresAdvisoryLock(pgArtifactMap.advisoryPool) : createMemoryAdvisoryLock(); const configStore = createMemoryConfigStore(config.orgId, { connectorClients: artifactMap("connector_clients"), @@ -724,44 +755,110 @@ export function buildApp( ? createPostgresRunSignalStore(requireDbUrl("RUN_STORE")) : createMemoryRunSignalStore(); const tasks = config.databaseUrl ? createPostgresTaskStore(config.databaseUrl) : createMemoryTaskStore(); + const instanceRegistry: InstanceRegistry = + overrides.instanceRegistry ?? + (config.buildSha && pgArtifactMap + ? createPostgresInstanceRegistry(pgArtifactMap.pool, { + instanceId: randomUUID(), + buildSha: config.buildSha, + startedAt: Date.now(), + capabilities: [ + CUSTOM_PROVIDER_WIRE_ID_CAPABILITY, + CUSTOM_PROVIDER_INPUT_MODALITIES_CAPABILITY, + CUSTOM_PROVIDER_HARNESS_TEST_CAPABILITY, + ], + }) + : createNoopInstanceRegistry()); + const customProviderRuntimeSchemaReady = async (schema: number) => { + if (!config.production) return true; + const capabilitiesBySchema = new Map([ + [CUSTOM_PROVIDER_WIRE_ID_SCHEMA, CUSTOM_PROVIDER_WIRE_ID_CAPABILITY], + [CUSTOM_PROVIDER_INPUT_MODALITIES_SCHEMA, CUSTOM_PROVIDER_INPUT_MODALITIES_CAPABILITY], + ]); + const capability = capabilitiesBySchema.get(schema); + if (!capability) return false; + return (await instanceRegistry.allLiveSupport?.(capability)) ?? false; + }; + const customProviderHarnessTestFence = async (): Promise => { + if (!config.production) return "non-production"; + const snapshot = await instanceRegistry.capabilitySnapshot?.(CUSTOM_PROVIDER_HARNESS_TEST_CAPABILITY); + return snapshot?.ready ? snapshot.epoch : null; + }; const customProviders = createCustomProviderStore({ backing: artifactMap("custom_model_providers"), keyMaterial: config.connectorSecretKey ?? randomBytes(32), + advisoryLock, + runtimeSchemaReady: customProviderRuntimeSchemaReady, + runtimeSchemaWritable: customProviderRuntimeSchemaReady, + }); + const customProviderTestRuns = createCustomProviderTestRunStore({ + backing: artifactMap("custom_provider_test_runs"), + advisoryLock, + durable: pgArtifactMap !== null, }); const refreshCustomProviders = async () => { - setCustomProviders(await customProviders.enabled()); + const [enabled, knownIds] = await Promise.all([customProviders.enabled(), customProviders.knownModelIds()]); + setCustomProviders(enabled, knownIds); }; void refreshCustomProviders().catch((e) => console.error("[wiring] custom provider hydration failed:", errMessage(e)), ); - const resolveModelProviderKeys = async () => { - const [anthropic, openai, openrouter, enabledCustom] = await Promise.all([ + const resolveModelProviderRuntime = async () => { + const [anthropic, openai, openrouter, activeCustom] = await Promise.all([ modelCredentials.resolve("anthropic"), modelCredentials.resolve("openai"), modelCredentials.resolve("openrouter"), - customProviders.enabled(), + customProviders.active(), ]); + const customProvidersSnapshot = activeCustom.map(({ provider }) => provider); 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), + activeCustom.filter(({ apiKey }) => apiKey).map(({ provider, apiKey }) => [provider.id, apiKey]), ); return { - ...(anthropic ? { anthropic } : {}), - ...(openai ? { openai } : {}), - ...(openrouter ? { openrouter } : {}), - ...customKeys, + keys: { + ...(anthropic ? { anthropic } : {}), + ...(openai ? { openai } : {}), + ...(openrouter ? { openrouter } : {}), + ...customKeys, + }, + customProviders: customProvidersSnapshot, + }; + }; + const resolveCodexCustomProvider = async (modelId: string) => { + if (resolveStaticModel(modelId)) return null; + const knownCustom = await customProviders.knowsModel(modelId); + const custom = resolveCustomModel(modelId); + const model = resolveModel(modelId); + if ( + !custom || + !model || + model.provider !== custom.provider || + model.api !== custom.api || + model.baseUrl !== custom.baseUrl + ) { + if (knownCustom) throw new NonRetryableTurnError(`custom model ${modelId} is not active`); + return null; + } + if (model.api !== "openai-responses") { + throw new NonRetryableTurnError(`custom model ${modelId} does not support the Codex Responses transport`); + } + const providerId = String(model.provider); + const active = await customProviders.resolveActive(providerId); + if ( + !active || + !active.apiKey || + active.provider.protocol !== "openai-responses" || + active.provider.baseUrl !== model.baseUrl || + !active.provider.models.some((candidate) => candidate.id === modelId) + ) { + throw new NonRetryableTurnError(`custom model ${modelId} is not active with a usable Codex credential`); + } + return { + id: active.provider.id, + name: active.provider.name, + baseUrl: active.provider.baseUrl, + apiKey: active.apiKey, + modelId: active.provider.models.find((candidate) => candidate.id === modelId)?.upstreamId?.trim() || modelId, }; }; const runtimeOrgScope = scopeId("org", config.orgId); @@ -773,7 +870,7 @@ export function buildApp( createPiHarness({ ...piHarnessConfigOptions(config), resolveBaseModelId: orgBaseModelId, - resolveProviderKeys: resolveModelProviderKeys, + resolveProviderRuntime: resolveModelProviderRuntime, signals: runSignals, mcpTools, }), @@ -786,25 +883,21 @@ export function buildApp( tasks, mcpTools, 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 }; - } - }), - ); + const active = await customProviders.active(); + return active.map(({ provider: spec, apiKey }) => ({ spec, ...(apiKey ? { apiKey } : {}) })); }, }), ], - ["codex", createCodexHarness({ ...codexHarnessConfigOptions(config), signals: runSignals, tasks, mcpTools })], + [ + "codex", + createCodexHarness({ + ...codexHarnessConfigOptions(config), + signals: runSignals, + tasks, + mcpTools, + resolveCustomProvider: resolveCodexCustomProvider, + }), + ], ["claude", createClaudeHarness({ ...claudeHarnessConfigOptions(config), signals: runSignals, tasks, mcpTools })], ["mock", createMockHarness()], ]); @@ -820,12 +913,63 @@ export function buildApp( }, }; const judgeModelId = (): string => config.judgeModelId ?? auxiliaryModelFor(orgBaseModelId() ?? fallback.modelId); - const harness = createHarnessRouter(adapters, adapters.get(fallbackHarness)!, (input) => - resolveRuntimeChoiceDurable(configStore, runtimeOrgScope, input.scopeLabel, fallback, { + const harness = createHarnessRouter(adapters, adapters.get(fallbackHarness)!, async (input) => { + await refreshCustomProviders(); + return resolveRuntimeChoiceDurable(configStore, runtimeOrgScope, input.scopeLabel, fallback, { ...(input.harness ? { harnessId: input.harness as HarnessId } : {}), ...(input.model ? { modelId: input.model } : {}), - }), - ); + }); + }); + const customProviderHarnessTest: CustomProviderHarnessTestRunner = async (input) => { + await refreshCustomProviders(); + const initialState = await customProviders.harnessTestState(input.providerId, customProviderHarnessTestFence); + if (initialState.rolloutFence !== input.rolloutFence) { + throw new CustomProviderHarnessTestRolloutIncompleteError( + "custom provider harness testing is unavailable during a mixed-version rollout", + ); + } + const active = initialState.active; + if ( + !active?.apiKey || + active.revision !== input.expectedRevision || + !active.provider.models.some((model) => model.id === input.modelId) + ) { + if (active?.revision !== input.expectedRevision) { + throw new CustomProviderTestConfigurationChangedError("custom provider changed before the test started"); + } + throw new Error("custom provider model is not active"); + } + const runtimeModel = runtimeModelForCustomProvider(active.provider, input.modelId); + if (!runtimeModel) throw new Error("custom provider model is not active"); + if (input.harnessId === "codex" && active.provider.protocol !== "openai-responses") { + throw new Error(`model ${input.modelId} is unavailable to ${input.harnessId}`); + } + const testModel = adapters.get(input.harnessId)?.models.testModel; + if (!testModel) throw new Error(`harness ${input.harnessId} cannot test a model`); + const result = await testModel({ + model: input.modelId, + expectedUpstreamModel: runtimeModel.id, + maxOutputTokens: MODEL_TEST_MAX_OUTPUT_TOKENS, + systemPrompt: "You are testing a model connection for an organization administrator. Do not use tools.", + prompt: "Reply with a short confirmation that the model connection works.", + signal: input.signal, + customProvider: { spec: active.provider, apiKey: active.apiKey }, + }); + const finalState = await customProviders.harnessTestState(input.providerId, customProviderHarnessTestFence); + if (finalState.rolloutFence !== input.rolloutFence) { + throw new CustomProviderHarnessTestRolloutIncompleteError( + "custom provider harness testing became unavailable during the request", + ); + } + if (finalState.active?.revision !== active.revision) { + throw new CustomProviderTestConfigurationChangedError("custom provider changed during the test"); + } + return { + ...result, + providerRevision: active.revision, + upstreamModelId: runtimeModel.id, + }; + }; const leaseTtlMs = config.leaseTtlMs; const maxAttempts = config.maxAttempts; @@ -1382,14 +1526,6 @@ export function buildApp( }, }) : undefined; - const instanceRegistry: InstanceRegistry = - config.buildSha && pgArtifactMap - ? createPostgresInstanceRegistry(pgArtifactMap.pool, { - instanceId: randomUUID(), - buildSha: config.buildSha, - startedAt: Date.now(), - }) - : createNoopInstanceRegistry(); const taskProtection: TaskProtection | null = config.ecsTaskProtection && config.ecsAgentUri ? createEcsTaskProtection(config.ecsAgentUri) : null; const drain: DrainController = createDrainController({ @@ -1419,6 +1555,9 @@ export function buildApp( const deployIdleTtlMs = deployProvider.profile.managedScaleToZero ? undefined : config.deployIdleTtlMs; const BLOB_TTL_MS = 6 * 60 * 60_000; const blobSweeper = createSweeper(() => blobTransfer.sweep(BLOB_TTL_MS), 30 * 60_000); + const customProviderTestRunSweeper = createSweeper(() => customProviderTestRuns.sweep(), 5 * 60_000, { + label: "custom-provider-test-runs", + }); const BLOB_TRANSFER_EXPIRY_DAYS = 1; void blobTransfer .ensureExpiry?.(BLOB_TRANSFER_EXPIRY_DAYS) @@ -1446,7 +1585,10 @@ export function buildApp( ) : null; const runtime: Runtime = { + ready: () => drain.ready(), + readyForTraffic: () => drain.readyForTraffic(), start() { + drain.start(); if (!config.backgroundWorkEnabled) return; for (const w of workers) w.start(); reaper.start(); @@ -1454,12 +1596,12 @@ export function buildApp( monitorPoller?.start(config.monitorPollMs); if (config.skillSyncPollMs > 0) skillSyncEngine.start(config.skillSyncPollMs); blobSweeper.start(); + customProviderTestRunSweeper.start(); idleSweeper?.start(); deepIdleSweeper?.start(); reachDeniedNotifier?.start(config.insightsIntervalMs); wakeSweep.start(); orphanedSignalSweeper.start(); - drain.start(); }, async releaseInFlightRuns() { await Promise.all(workers.map((w) => w.releaseInFlight())); @@ -1473,6 +1615,7 @@ export function buildApp( deepIdleSweeper?.stop(); reachDeniedNotifier?.stop(); blobSweeper.stop(); + customProviderTestRunSweeper.stop(); wakeSweep.stop(); orphanedSignalSweeper.stop(); await Promise.all(workers.map((w) => w.stop(config.shutdownDrainMs))).catch( @@ -1511,7 +1654,10 @@ export function buildApp( modelGateway, modelCredentials, customProviders, + customProviderTestRuns, refreshCustomProviders, + customProviderHarnessTest, + customProviderHarnessTestFence, mcpServers, mcpToolService, acl, @@ -1569,6 +1715,7 @@ export function serverDeps( const configuredModel = configuredModelForHarness(config, config.harness); return { production: config.production, + ...(config.production ? { readyForTraffic: built.runtime.readyForTraffic } : {}), allowUnauthenticatedCore: config.allowUnauthenticatedCore, ...(config.signingSecret ? { signingSecret: config.signingSecret } : {}), ...(config.capabilitySecret ? { capabilitySecret: config.capabilitySecret } : {}), @@ -1581,7 +1728,10 @@ export function serverDeps( providerKeys: providerKeysPresent(config), modelCredentials: built.modelCredentials, customProviders: built.customProviders, + customProviderTestRuns: built.customProviderTestRuns, refreshCustomProviders: built.refreshCustomProviders, + customProviderHarnessTest: built.customProviderHarnessTest, + customProviderHarnessTestFence: built.customProviderHarnessTestFence, mcpServers: built.mcpServers, mcpToolService: built.mcpToolService, ...(config.brandingDefault ? { brandingDefault: config.brandingDefault } : {}), diff --git a/test/advisory-lock.test.ts b/test/advisory-lock.test.ts index 42bc0ecf6..271c50881 100644 --- a/test/advisory-lock.test.ts +++ b/test/advisory-lock.test.ts @@ -121,3 +121,49 @@ test("pg mutex: waiting beyond timeoutMs throws a clear error", { skip }, async await pgWaiter.close(); } }); + +test("pg mutex: pool saturation still honors withLock and tryWithLock deadlines", { skip }, async () => { + const pg = createPgPool(URL!, []); + try { + const lock = createPostgresAdvisoryLock(pg, { pollMs: 50, timeoutMs: 150 }); + let entered = 0; + let ready!: () => void; + let release!: () => void; + const allEntered = new Promise((resolve) => { + ready = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + const holders = Array.from({ length: 10 }, (_, index) => + lock.withLock(`deploy:saturated:${index}`, async () => { + entered += 1; + if (entered === 10) ready(); + await held; + }), + ); + await allEntered; + + const started = Date.now(); + await assert.rejects( + lock.withLock("deploy:saturated:waiter", async () => "never"), + /timeout acquiring advisory lock for deploy:saturated:waiter/, + ); + assert.ok(Date.now() - started < 1_000); + const tries = await Promise.all( + Array.from({ length: 500 }, (_, index) => + lock.tryWithLock!(`deploy:saturated:try:${index}`, async () => "never"), + ), + ); + assert.ok(tries.every((result) => result === null)); + const pool = await pg.pool(); + assert.equal(pool.waitingCount, 0); + + release(); + await Promise.all(holders); + await sleep(50); + assert.equal(pool.waitingCount, 0); + } finally { + await pg.close(); + } +}); diff --git a/test/codex-harness.test.ts b/test/codex-harness.test.ts index 9b9cf957e..d91f899ea 100644 --- a/test/codex-harness.test.ts +++ b/test/codex-harness.test.ts @@ -4,8 +4,11 @@ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { homedir, tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { createRequire } from "node:module"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; import { codexChildEnv, + codexCustomRuntimeSpec, codexNonRetryable, codexProviderFailure, codexUsageTotals, @@ -25,6 +28,7 @@ import type { ScopeId, Session, SessionEntry } from "../src/types.ts"; import { createMemoryTaskStore } from "../src/tasks/memory-task-store.ts"; import { CodexAppServer } from "../src/harness/codex-app-server.ts"; import { DEFAULT_CODEX_MODEL_ID } from "../src/model/pi-models.ts"; +import { setCustomProviders } from "../src/model/custom-providers.ts"; const replaySmokeItems = [ { type: "message", role: "user", content: [{ type: "input_text", text: "earlier question" }] }, @@ -41,6 +45,40 @@ test("Codex replay keeps paired tool ids within the provider's 64-character limi assert.equal(codexReplayCallId("short-id"), "short-id"); }); +test("Codex fails closed when an explicitly requested model is unavailable", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-unavailable-")); + let resolutions = 0; + const harness = createCodexHarness({ + binaryPath: fakeCodexBinary(dir), + resolveCustomProvider: async () => { + resolutions += 1; + return null; + }, + }); + t.after(async () => { + await harness.turns.close?.(); + rmSync(dir, { recursive: true, force: true }); + }); + const scope = { kind: "org", id: "test" } as unknown as ScopeId; + await assert.rejects( + harness.turns.runTurn({ + session: { id: "unavailable" } as Session, + input: "hi", + model: "removed-custom-model", + systemPrompt: "be concise", + history: [], + tools: {} as HarnessTurnInput["tools"], + scopeLabel: scope, + orgScopeId: scope, + emit: async (entry) => ({ ...entry, sessionId: "unavailable", seq: 1, createdAt: Date.now() }) as SessionEntry, + recordModelCall: () => {}, + }), + /does not support requested model/, + ); + assert.equal(resolutions, 0); + assert.equal(existsSync(join(dir, "starts")), false); +}); + function fakeCodexBinary(dir: string): string { const path = join(dir, "fake-codex"); writeFileSync( @@ -84,6 +122,75 @@ rl.on("line", (line) => { return path; } +function customProviderCodexBinary(dir: string): string { + const path = join(dir, "custom-provider-codex"); + writeFileSync( + path, + `#!/usr/bin/env node +const readline = require("node:readline"); +const rl = readline.createInterface({ input: process.stdin }); +const send = (value) => process.stdout.write(JSON.stringify(value) + "\\n"); +rl.on("line", (line) => { + const msg = JSON.parse(line); + if (msg.method === "initialize") return send({ id: msg.id, result: {} }); + if (msg.method === "initialized") return; + if (msg.method === "thread/start") { + const provider = msg.params.config?.model_providers?.gateway; + if (msg.params.model !== "gpt-5.6-luna" || msg.params.config?.model_provider !== "gateway" || + !String(provider?.base_url).startsWith("http://127.0.0.1:") || provider?.wire_api !== "responses" || + provider?.env_key !== "QM_CODEX_PROVIDER_KEY" || process.env.QM_CODEX_PROVIDER_KEY !== "sk-custom" || + process.env.OPENAI_API_KEY || process.env.OPENAI_BASE_URL || process.env.CODEX_ACCESS_TOKEN || + line.includes("sk-custom")) { + return send({ id: msg.id, error: { code: -1, message: "bad custom provider binding" } }); + } + return send({ id: msg.id, result: { thread: { id: "thread-custom" }, model: "gpt-5.6-luna" } }); + } + if (msg.method === "turn/start") { + if (msg.params.model !== "gpt-5.6-luna") { + return send({ id: msg.id, error: { code: -1, message: "bad custom turn model" } }); + } + send({ id: msg.id, result: { turn: { id: "turn-custom", status: "inProgress", items: [] } } }); + return send({ method: "turn/completed", params: { threadId: "thread-custom", turn: { id: "turn-custom", status: "completed", items: [{ type: "agentMessage", text: "CUSTOM-OK", phase: "final_answer" }] } } }); + } +}); +`, + ); + chmodSync(path, 0o755); + return path; +} + +function rotatingProviderCodexBinary(dir: string): string { + const path = join(dir, "rotating-provider-codex"); + const log = join(dir, "runtime-log"); + writeFileSync( + path, + `#!/usr/bin/env node +const fs = require("node:fs"); +const readline = require("node:readline"); +const key = process.env.QM_CODEX_PROVIDER_KEY; +fs.appendFileSync(${JSON.stringify(log)}, "start:" + key + "\\n"); +process.on("SIGTERM", () => { + fs.appendFileSync(${JSON.stringify(log)}, "close:" + key + "\\n"); + process.exit(0); +}); +const rl = readline.createInterface({ input: process.stdin }); +const send = (value) => process.stdout.write(JSON.stringify(value) + "\\n"); +rl.on("line", (line) => { + const msg = JSON.parse(line); + if (msg.method === "initialize") return send({ id: msg.id, result: {} }); + if (msg.method === "initialized") return; + if (msg.method === "thread/start") return send({ id: msg.id, result: { thread: { id: "thread-" + key } } }); + if (msg.method === "turn/start") { + send({ id: msg.id, result: { turn: { id: "turn-" + key, status: "inProgress", items: [] } } }); + return send({ method: "turn/completed", params: { threadId: "thread-" + key, turn: { id: "turn-" + key, status: "completed", items: [{ type: "agentMessage", text: key, phase: "final_answer" }] } } }); + } +}); +`, + ); + chmodSync(path, 0o755); + return path; +} + function terminatingCodexBinary(dir: string): string { const path = join(dir, "terminating-codex"); writeFileSync( @@ -164,6 +271,28 @@ process.stdin.resume(); return path; } +function delayedCustomCodexBinary(dir: string): string { + const path = join(dir, "delayed-custom-codex"); + writeFileSync( + path, + `#!/usr/bin/env node +const fs = require("node:fs"); +const readline = require("node:readline"); +const log = ${JSON.stringify(join(dir, "delayed-log"))}; +fs.appendFileSync(log, "start\\n"); +fs.writeFileSync(${JSON.stringify(join(dir, "delayed-jail"))}, process.env.HOME); +process.on("SIGTERM", () => { fs.appendFileSync(log, "close\\n"); process.exit(0); }); +const rl = readline.createInterface({ input: process.stdin }); +rl.on("line", (line) => { + const msg = JSON.parse(line); + if (msg.method === "initialize") setTimeout(() => process.stdout.write(JSON.stringify({ id: msg.id, result: {} }) + "\\n"), 300); +}); +`, + ); + chmodSync(path, 0o755); + return path; +} + test("Codex forwards external-content screening into its native tool bridge", () => { const screenExternalContent: NonNullable = async () => ({ decision: "auto", @@ -215,6 +344,169 @@ test("Codex harness drives app-server JSON-RPC with a read-only jail", async (t) ); }); +test("Codex binds a Responses custom provider without exposing its key in RPC", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-custom-")); + setCustomProviders([ + { + id: "gateway", + name: "Gateway", + protocol: "openai-responses", + baseUrl: "https://gateway.example.com/v1", + models: [{ id: "gateway/gpt-luna", upstreamId: "gpt-5.6-luna" }], + }, + ]); + const harness = createCodexHarness({ + binaryPath: customProviderCodexBinary(dir), + env: { + ...process.env, + OPENAI_API_KEY: "sk-official", + OPENAI_BASE_URL: "https://official.example.com/v1", + CODEX_ACCESS_TOKEN: "official-token", + }, + resolveCustomProvider: async (modelId) => { + assert.equal(modelId, "gateway/gpt-luna"); + return { + id: "gateway", + name: "Gateway", + baseUrl: "https://gateway.example.com/v1", + apiKey: "sk-custom", + modelId: "gpt-5.6-luna", + }; + }, + }); + t.after(async () => { + await harness.turns.close?.(); + setCustomProviders([], []); + rmSync(dir, { recursive: true, force: true }); + }); + const scope = { kind: "org", id: "test" } as unknown as ScopeId; + const session = { id: "session-custom" } as Session; + const modelCalls: Array<{ model: string }> = []; + const llmRows: HarnessLlmRequestRecord[] = []; + const result = await harness.turns.runTurn({ + session, + input: "hi", + model: "gateway/gpt-luna", + systemPrompt: "be concise", + history: [], + tools: {} as HarnessTurnInput["tools"], + scopeLabel: scope, + orgScopeId: scope, + emit: async (entry) => ({ ...entry, sessionId: session.id, seq: 1, createdAt: Date.now() }) as SessionEntry, + recordModelCall: (record) => modelCalls.push(record), + recordLlmRequest: async (record) => { + llmRows.push(record); + }, + }); + assert.equal(result.reply, "CUSTOM-OK"); + assert.equal(modelCalls[0]?.model, "gateway/gpt-luna"); + assert.equal(llmRows[0]?.model, "gateway/gpt-luna"); + assert.equal(llmRows[0]?.transport?.modelId, "gpt-5.6-luna"); +}); + +test("custom Codex runtime identity rotates with endpoint or key and strips built-in credentials", () => { + const source = { + OPENAI_API_KEY: "official-key", + OPENAI_BASE_URL: "https://official.example.com/v1", + CODEX_ACCESS_TOKEN: "official-token", + PATH: "/bin", + NO_PROXY: "corp.internal", + no_proxy: "service.local", + }; + const first = codexCustomRuntimeSpec(source, { + id: "gateway", + name: "Gateway", + baseUrl: "https://gateway.example.com/v1", + apiKey: "sk-one", + }); + const second = codexCustomRuntimeSpec(source, { + id: "gateway", + name: "Gateway", + baseUrl: "https://gateway.example.com/v2", + apiKey: "sk-two", + }); + assert.notEqual(first.key, second.key); + assert.equal(first.env.QM_CODEX_PROVIDER_KEY, "sk-one"); + assert.equal(first.env.OPENAI_API_KEY, undefined); + assert.equal(first.env.OPENAI_BASE_URL, undefined); + assert.equal(first.env.CODEX_ACCESS_TOKEN, undefined); + assert.match(first.env.NO_PROXY!, /(?:^|,)127\.0\.0\.1(?:,|$)/); + assert.match(first.env.no_proxy!, /(?:^|,)localhost(?:,|$)/); + assert.match(first.env.NO_PROXY!, /(?:^|,)corp\.internal(?:,|$)/); + assert.match(first.env.no_proxy!, /(?:^|,)service\.local(?:,|$)/); + assert.equal(first.env.PATH, "/bin"); + assert.equal(JSON.stringify(first.config).includes("sk-one"), false); + assert.equal(first.key.includes("sk-one"), false); + const singleAttempt = codexCustomRuntimeSpec( + source, + { + id: "gateway", + name: "Gateway", + baseUrl: "https://gateway.example.com/v1", + apiKey: "sk-one", + }, + true, + ); + assert.deepEqual((singleAttempt.config.model_providers as Record>).gateway, { + name: "Gateway", + base_url: "https://gateway.example.com/v1", + env_key: "QM_CODEX_PROVIDER_KEY", + wire_api: "responses", + request_max_retries: 0, + stream_max_retries: 0, + }); +}); + +test("Codex retires an idle provider process when its saved key rotates", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-rotate-")); + let key = "sk-one"; + setCustomProviders([ + { + id: "gateway", + name: "Gateway", + protocol: "openai-responses", + baseUrl: "https://gateway.example.com/v1", + models: [{ id: "responses-model" }], + }, + ]); + const harness = createCodexHarness({ + binaryPath: rotatingProviderCodexBinary(dir), + env: { PATH: process.env.PATH }, + resolveCustomProvider: async () => ({ + id: "gateway", + name: "Gateway", + baseUrl: "https://gateway.example.com/v1", + apiKey: key, + }), + }); + t.after(async () => { + await harness.turns.close?.(); + setCustomProviders([], []); + rmSync(dir, { recursive: true, force: true }); + }); + const scope = { kind: "org", id: "test" } as unknown as ScopeId; + const run = (id: string) => + harness.turns.runTurn({ + session: { id } as Session, + input: "hi", + model: "responses-model", + systemPrompt: "be concise", + history: [], + tools: {} as HarnessTurnInput["tools"], + scopeLabel: scope, + orgScopeId: scope, + emit: async (entry) => ({ ...entry, sessionId: id, seq: 1, createdAt: Date.now() }) as SessionEntry, + recordModelCall: () => {}, + }); + assert.equal((await run("rotation-one")).reply, "sk-one"); + key = "sk-two"; + assert.equal((await run("rotation-two")).reply, "sk-two"); + assert.equal( + readFileSync(join(dir, "runtime-log"), "utf8"), + "start:sk-one\nclose:sk-one\nstart:sk-two\nclose:sk-two\n", + ); +}); + test("Codex task titles stay concise when the provider includes the parent request", () => { assert.equal( codexTaskTitle("The user asked for two workers. You are the WEST subagent. Return a useful summary."), @@ -409,6 +701,56 @@ test("Codex discards a nonresponsive startup so a later turn can retry", async ( assert.equal(readFileSync(join(dir, "starts"), "utf8"), "start\nstart\n"); }); +test("a timed-out custom Codex startup closes after its background initialization finishes", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-codex-abandoned-custom-")); + setCustomProviders([ + { + id: "gateway", + name: "Gateway", + protocol: "openai-responses", + baseUrl: "https://gateway.example.com/v1", + models: [{ id: "responses-model" }], + }, + ]); + const harness = createCodexHarness({ + binaryPath: delayedCustomCodexBinary(dir), + env: { PATH: process.env.PATH }, + turnWallClockMs: 50, + appServerStartTimeoutMs: 2_000, + resolveCustomProvider: async () => ({ + id: "gateway", + name: "Gateway", + baseUrl: "https://gateway.example.com/v1", + apiKey: "sk-abandoned", + }), + }); + t.after(async () => { + await harness.turns.close?.(); + setCustomProviders([], []); + rmSync(dir, { recursive: true, force: true }); + }); + const scope = { kind: "org", id: "test" } as unknown as ScopeId; + await assert.rejects( + harness.turns.runTurn({ + session: { id: "abandoned-custom" } as Session, + input: "hi", + model: "responses-model", + systemPrompt: "be concise", + history: [], + tools: {} as HarnessTurnInput["tools"], + scopeLabel: scope, + orgScopeId: scope, + emit: async (entry) => + ({ ...entry, sessionId: "abandoned-custom", seq: 1, createdAt: Date.now() }) as SessionEntry, + recordModelCall: () => {}, + }), + /exceeded/, + ); + await new Promise((resolveWait) => setTimeout(resolveWait, 2_500)); + assert.equal(readFileSync(join(dir, "delayed-log"), "utf8"), "start\nclose\n"); + assert.equal(existsSync(readFileSync(join(dir, "delayed-jail"), "utf8")), false); +}); + test("cancelling one Codex setup does not kill another active turn", async (t) => { const dir = mkdtempSync(join(tmpdir(), "qm-codex-concurrent-test-")); const harness = createCodexHarness({ @@ -623,7 +965,7 @@ test( const server = new CodexAppServer({ binaryPath: realCodexBinary!, cwd: jail, - env: codexChildEnv({ PATH: process.env.PATH }, jail), + env: codexChildEnv({ PATH: process.env.PATH, QM_CODEX_PROVIDER_KEY: "sk-fake" }, jail), onNotification: () => {}, onRequest: async (method) => { requests.push(method); @@ -678,6 +1020,184 @@ test( threadId: started.thread.id, items: replaySmokeItems, }); + const custom = await server.request<{ thread: { id: string } }>("thread/start", { + model: "responses-model", + cwd: jail, + approvalPolicy: "never", + sandbox: "read-only", + ephemeral: true, + dynamicTools: [], + environments: [], + config: { + model_provider: "gateway", + model_providers: { + gateway: { + name: "Gateway", + base_url: "https://gateway.example.com/v1", + env_key: "QM_CODEX_PROVIDER_KEY", + wire_api: "responses", + }, + }, + web_search: "disabled", + }, + }); + assert.ok(custom.thread.id); assert.deepEqual(requests, []); }, ); + +test( + "the installed Codex app-server completes a turn through a saved Responses provider binding", + { skip: realCodexBinary && existsSync(realCodexBinary) ? false : "@openai/codex is not resolvable" }, + async (t) => { + const requests: Array<{ path: string; auth?: string; model?: string; responsesLite?: string }> = []; + let proxyRequests = 0; + const hostileProxy = createServer((req, res) => { + proxyRequests += 1; + req.resume(); + res.writeHead(502); + res.end(); + }); + await new Promise((resolve) => hostileProxy.listen(0, "127.0.0.1", resolve)); + const hostileProxyUrl = `http://127.0.0.1:${(hostileProxy.address() as AddressInfo).port}`; + const upstream = createServer((req, res) => { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { + const payload = body ? (JSON.parse(body) as { model?: string }) : {}; + requests.push({ + path: req.url ?? "", + auth: req.headers.authorization, + model: payload.model, + responsesLite: req.headers["x-openai-internal-codex-responses-lite"] as string | undefined, + }); + if (!req.url?.endsWith("/responses")) { + res.writeHead(404); + return res.end(); + } + const item = { + id: "msg_codex_qa", + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "CODEX RESPONSES OK", annotations: [] }], + }; + const response = { + id: "resp_codex_qa", + object: "response", + created_at: Math.floor(Date.now() / 1000), + status: "completed", + model: "gpt-5.6-luna", + output: [item], + usage: { + input_tokens: 5, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 4, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 9, + }, + }; + const events = [ + { type: "response.created", response: { ...response, status: "in_progress", output: [] } }, + { + type: "response.output_item.added", + output_index: 0, + item: { ...item, status: "in_progress", content: [] }, + }, + { + type: "response.content_part.added", + output_index: 0, + item_id: item.id, + content_index: 0, + part: { type: "output_text", text: "", annotations: [] }, + }, + { + type: "response.output_text.delta", + output_index: 0, + item_id: item.id, + content_index: 0, + delta: "CODEX RESPONSES OK", + }, + { + type: "response.output_text.done", + output_index: 0, + item_id: item.id, + content_index: 0, + text: "CODEX RESPONSES OK", + }, + { + type: "response.content_part.done", + output_index: 0, + item_id: item.id, + content_index: 0, + part: item.content[0], + }, + { type: "response.output_item.done", output_index: 0, item }, + { type: "response.completed", response }, + ]; + res.writeHead(200, { "content-type": "text/event-stream" }); + events.forEach((event, sequence_number) => + res.write(`event: ${event.type}\ndata: ${JSON.stringify({ ...event, sequence_number })}\n\n`), + ); + res.end(); + }); + }); + await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve)); + const baseUrl = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}/v1`; + setCustomProviders([ + { + id: "gateway", + name: "Gateway", + protocol: "openai-responses", + baseUrl, + models: [{ id: "gateway/gpt-luna", upstreamId: "gpt-5.6-luna" }], + }, + ]); + const harness = createCodexHarness({ + binaryPath: realCodexBinary!, + env: { + PATH: process.env.PATH, + HTTP_PROXY: hostileProxyUrl, + HTTPS_PROXY: hostileProxyUrl, + ALL_PROXY: hostileProxyUrl, + NO_PROXY: "", + }, + turnWallClockMs: 10_000, + resolveCustomProvider: async (modelId) => ({ + id: "gateway", + name: "Gateway", + baseUrl, + apiKey: "sk-codex-qa", + modelId: modelId === "gateway/gpt-luna" ? "gpt-5.6-luna" : modelId, + }), + }); + t.after(async () => { + await harness.turns.close?.(); + hostileProxy.close(); + upstream.close(); + setCustomProviders([], []); + }); + const scope = { kind: "org", id: "test" } as unknown as ScopeId; + const result = await harness.turns.runTurn({ + session: { id: "real-custom-provider" } as Session, + input: "reply briefly", + model: "gateway/gpt-luna", + systemPrompt: "be concise", + history: [], + tools: {} as HarnessTurnInput["tools"], + scopeLabel: scope, + orgScopeId: scope, + readOnly: true, + emit: async (entry) => + ({ ...entry, sessionId: "real-custom-provider", seq: 1, createdAt: Date.now() }) as SessionEntry, + recordModelCall: () => {}, + }); + assert.equal(result.reply, "CODEX RESPONSES OK"); + assert.equal(requests.length, 1); + assert.equal(requests[0]?.path, "/v1/responses"); + assert.equal(requests[0]?.auth, "Bearer sk-codex-qa"); + assert.equal(requests[0]?.model, "gpt-5.6-luna"); + assert.equal(requests[0]?.responsesLite, undefined); + assert.equal(proxyRequests, 0); + }, +); diff --git a/test/codex-provider-proxy.test.ts b/test/codex-provider-proxy.test.ts new file mode 100644 index 000000000..5fdd1eff8 --- /dev/null +++ b/test/codex-provider-proxy.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import { createServer, request } from "node:http"; +import type { AddressInfo } from "node:net"; +import test from "node:test"; +import { createCodexProviderProxy } from "../src/harness/codex-provider-proxy.ts"; + +test("Codex custom provider proxy removes the Responses Lite header", async (t) => { + const requests: Array<{ url?: string; lite?: string; retained?: string; body: string }> = []; + const upstream = createServer((req, res) => { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { + requests.push({ + url: req.url, + lite: req.headers["x-openai-internal-codex-responses-lite"] as string | undefined, + retained: req.headers["x-client-request-id"] as string | undefined, + body, + }); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + }); + }); + await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve)); + const upstreamBase = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}/v1`; + const proxy = await createCodexProviderProxy(upstreamBase); + t.after(async () => { + await proxy.close(); + upstream.close(); + }); + + const response = await fetch(`${proxy.baseUrl}/responses?mode=test`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-client-request-id": "request-1", + "x-openai-internal-codex-responses-lite": "true", + }, + body: JSON.stringify({ model: "gpt-5.6-luna" }), + }); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ok: true }); + assert.deepEqual(requests, [ + { + url: "/v1/responses?mode=test", + lite: undefined, + retained: "request-1", + body: JSON.stringify({ model: "gpt-5.6-luna" }), + }, + ]); +}); + +test("Codex custom provider proxy aborts upstream when the downstream stream closes", async () => { + let upstreamClosed!: () => void; + const closed = new Promise((resolve) => (upstreamClosed = resolve)); + const upstream = createServer((req, res) => { + res.once("close", upstreamClosed); + res.writeHead(200, { "content-type": "text/event-stream" }); + res.write('data: {"type":"response.output_text.delta","delta":"first"}\n\n'); + }); + await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve)); + const upstreamBase = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}/v1`; + const proxy = await createCodexProviderProxy(upstreamBase); + + const downstreamClosed = new Promise((resolve, reject) => { + const client = request(`${proxy.baseUrl}/responses`, { method: "POST" }); + client.once("error", (error) => { + if ((error as NodeJS.ErrnoException).code === "ECONNRESET") resolve(); + else reject(error); + }); + client.once("response", (response) => { + response.once("data", () => { + response.destroy(); + resolve(); + }); + }); + client.end(JSON.stringify({ model: "gpt-5.6-luna" })); + }); + + await downstreamClosed; + await Promise.race([ + closed, + new Promise((_, reject) => setTimeout(() => reject(new Error("upstream request stayed open")), 1_000)), + ]); + await Promise.race([ + proxy.close(), + new Promise((_, reject) => setTimeout(() => reject(new Error("proxy close stayed blocked")), 1_000)), + ]); + upstream.close(); +}); diff --git a/test/custom-provider-boot-wiring.test.ts b/test/custom-provider-boot-wiring.test.ts index 3c7730c40..76d86fd0a 100644 --- a/test/custom-provider-boot-wiring.test.ts +++ b/test/custom-provider-boot-wiring.test.ts @@ -11,10 +11,29 @@ import { buildApp, serverDeps } from "../src/wiring.ts"; import { testConfig } from "./support/test-config.ts"; import { defaultModelForHarness } from "../src/model/pi-models.ts"; import { setCustomProviders } from "../src/model/custom-providers.ts"; +import { + CUSTOM_PROVIDER_HARNESS_TEST_CAPABILITY, + CUSTOM_PROVIDER_INPUT_MODALITIES_CAPABILITY, +} from "../src/model/custom-provider-store.ts"; const ADMIN = { "content-type": "application/json", "x-admin-actor": "admin-alice@default-org" }; -afterEach(() => setCustomProviders([])); +afterEach(() => setCustomProviders([], [])); + +test("durable production instances require a build id for capability registration", () => { + assert.throws( + () => + buildApp( + testConfig({ + dataDir: mkdtempSync(join(tmpdir(), "custom-provider-build-id-")), + production: true, + databaseUrl: "postgres://unused.invalid/qm", + buildSha: undefined, + }), + ), + /GIT_SHA is required/, + ); +}); test("serverDeps wires the custom-provider store and resolves a custom boot default lazily", async () => { const config = testConfig({ @@ -25,7 +44,9 @@ test("serverDeps wires the custom-provider store and resolves a custom boot defa const built = buildApp(config, { modelCredentialFetch: async () => new Response(null, { status: 200 }) }); const deps = serverDeps(config, built); assert.equal(deps.customProviders, built.customProviders); + assert.equal(deps.customProviderTestRuns, built.customProviderTestRuns); assert.equal(deps.refreshCustomProviders, built.refreshCustomProviders); + assert.equal(deps.customProviderHarnessTestFence, built.customProviderHarnessTestFence); assert.equal(deps.baseModelDefault, "acme-large"); const server = createInsecureTestServer(built.app, deps); @@ -53,6 +74,25 @@ test("serverDeps wires the custom-provider store and resolves a custom boot defa assert.equal(defaultModelForHarness("pi", deps.baseModelDefault), "acme-large"); + setCustomProviders([], []); + const surface = await fetch(`${base}/v1/surface-config`, { headers: ADMIN }); + assert.equal(surface.status, 200); + assert.ok(((await surface.json()) as { webuiModels: string[] }).webuiModels.includes("acme-large")); + + setCustomProviders([], []); + const selected = await fetch(`${base}/v1/runtime-config`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ + principalId: "admin-alice@default-org", + scopeId: "personal:admin-alice@default-org", + harnessId: "pi", + modelId: "acme-large", + }), + }); + assert.equal(selected.status, 200); + + setCustomProviders([], []); const runtime = await fetch( `${base}/v1/runtime-config?principalId=admin-alice@default-org&scopeId=personal:admin-alice@default-org`, { headers: ADMIN }, @@ -70,3 +110,89 @@ test("serverDeps wires the custom-provider store and resolves a custom boot defa await new Promise((resolve) => server.close(() => resolve())); } }); + +test("production harness testing returns a stable fence only when every live runtime advertises the B protocol", async () => { + let allCapable = false; + const checked: string[] = []; + const built = buildApp( + testConfig({ + dataDir: mkdtempSync(join(tmpdir(), "custom-provider-capability-")), + production: true, + }), + { + instanceRegistry: { + beat: async () => false, + capabilitySnapshot: async (capability) => { + checked.push(capability); + return { ready: allCapable, epoch: "epoch-7" }; + }, + }, + }, + ); + + assert.equal(await built.customProviderHarnessTestFence(), null); + allCapable = true; + assert.equal(await built.customProviderHarnessTestFence(), "epoch-7"); + assert.equal(checked.filter((capability) => capability === CUSTOM_PROVIDER_HARNESS_TEST_CAPABILITY).length, 2); +}); + +test("production image provider writes use the input-modalities capability fence", async () => { + const checked: string[] = []; + const built = buildApp( + testConfig({ + dataDir: mkdtempSync(join(tmpdir(), "custom-provider-image-capability-")), + production: true, + }), + { + instanceRegistry: { + beat: async () => false, + allLiveSupport: async (capability) => { + checked.push(capability); + return capability === CUSTOM_PROVIDER_INPUT_MODALITIES_CAPABILITY; + }, + }, + }, + ); + + await built.customProviders.upsert( + { + id: "image-gateway", + name: "Image Gateway", + protocol: "openai-responses", + baseUrl: "https://llm.example.com/v1", + models: [{ id: "image-model", inputModalities: ["text", "image"] }], + }, + "sk-image", + "admin-alice@default-org", + ); + + assert.deepEqual(checked, [CUSTOM_PROVIDER_INPUT_MODALITIES_CAPABILITY]); + assert.equal((await built.customProviders.statuses())[0]?.disabled, false); +}); + +test("web turns refresh durable custom providers before runtime validation", async () => { + const built = buildApp(testConfig({ dataDir: mkdtempSync(join(tmpdir(), "custom-provider-turn-refresh-")) })); + await built.customProviders.upsert( + { + id: "fresh-gateway", + name: "Fresh Gateway", + protocol: "openai", + baseUrl: "https://llm.example.com/v1", + models: [{ id: "fresh-model" }], + }, + "sk-fresh", + "admin-alice@default-org", + ); + setCustomProviders([], []); + + const turn = await built.app.turn({ + surface: "web", + actor: { externalId: "alice" }, + conversation: { kind: "dm", threadRef: "web:alice:custom-provider-refresh" }, + text: "hello", + model: "fresh-model", + async: true, + }); + + assert.equal(turn.status, "queued"); +}); diff --git a/test/custom-provider-e2e.test.ts b/test/custom-provider-e2e.test.ts index cc51db83f..810807205 100644 --- a/test/custom-provider-e2e.test.ts +++ b/test/custom-provider-e2e.test.ts @@ -4,7 +4,7 @@ // 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 { createServer, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -13,18 +13,85 @@ 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" }; +function responsesReply(res: ServerResponse, model: string, text: string): void { + const item = { + id: "msg_responses_qa", + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text, annotations: [] }], + }; + const response = { + id: "resp_qa", + object: "response", + created_at: Math.floor(Date.now() / 1000), + status: "completed", + model, + output: [item], + usage: { + input_tokens: 5, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 3, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 8, + }, + }; + const events = [ + { type: "response.created", response: { ...response, status: "in_progress", output: [] } }, + { + type: "response.output_item.added", + output_index: 0, + item: { ...item, status: "in_progress", content: [] }, + }, + { + type: "response.content_part.added", + output_index: 0, + item_id: item.id, + content_index: 0, + part: { type: "output_text", text: "", annotations: [] }, + }, + { + type: "response.output_text.delta", + output_index: 0, + item_id: item.id, + content_index: 0, + delta: text, + }, + { + type: "response.output_text.done", + output_index: 0, + item_id: item.id, + content_index: 0, + text, + }, + { + type: "response.content_part.done", + output_index: 0, + item_id: item.id, + content_index: 0, + part: item.content[0], + }, + { type: "response.output_item.done", output_index: 0, item }, + { type: "response.completed", response }, + ]; + res.writeHead(200, { "content-type": "text/event-stream" }); + events.forEach((event, sequence_number) => + res.write(`event: ${event.type}\ndata: ${JSON.stringify({ ...event, sequence_number })}\n\n`), + ); + res.end(); +} + 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 seen: Array<{ path: string; auth: string | undefined; model?: string; maxTokens?: number }> = []; + let failCompletions = false; const upstream = createServer((req, res) => { let body = ""; req.on("data", (c) => (body += c)); @@ -40,11 +107,17 @@ test("QA: full custom-provider lifecycle against a live fake upstream", async () return res.end(JSON.stringify({ data: [{ id: "qa-chat" }] })); } if (req.url?.endsWith("/chat/completions")) { - record.model = (JSON.parse(body) as { model?: string }).model; + const payload = JSON.parse(body) as { model?: string; max_tokens?: number; max_completion_tokens?: number }; + record.model = payload.model; + record.maxTokens = payload.max_completion_tokens ?? payload.max_tokens; seen.push(record); + if (failCompletions) { + res.writeHead(500, { "content-type": "application/json" }); + return res.end(JSON.stringify({ error: { message: "retryable failure" } })); + } 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`; + `data: ${JSON.stringify({ id: "cmpl-qa", object: "chat.completion.chunk", model: payload.model, 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"); @@ -57,13 +130,24 @@ test("QA: full custom-provider lifecycle against a live fake upstream", async () }); 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 staleSeen: string[] = []; + const staleUpstream = createServer((req, res) => { + staleSeen.push(req.url ?? ""); + res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: { message: "stale endpoint must not be called" } })); + }); + await new Promise((r) => staleUpstream.listen(0, "127.0.0.1", r)); + const staleUpstreamUrl = `http://127.0.0.1:${(staleUpstream.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, + customProviderTestRuns: built.customProviderTestRuns, refreshCustomProviders: built.refreshCustomProviders, + customProviderHarnessTest: built.customProviderHarnessTest, + customProviderHarnessTestFence: built.customProviderHarnessTestFence, admin: built.admin, auditLog: built.auditLog, harnessId: "pi", @@ -153,18 +237,149 @@ test("QA: full custom-provider lifecycle against a live fake upstream", async () 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"); + setCustomProviders([ + { + id: "qa", + name: "Stale QA Provider", + protocol: "openai", + baseUrl: staleUpstreamUrl, + models: [{ id: "qa-chat" }], + }, + ]); + r = await api("/v1/admin/custom-providers/qa/harness-test", { + method: "POST", + body: JSON.stringify({ modelId: "not-registered", requestId: "request-not-registered" }), + }); + assert.equal(r.status, 400); + r = await api("/v1/admin/custom-providers/qa/harness-test", { + method: "POST", + body: JSON.stringify({ modelId: "qa-chat", requestId: "request-qa-success" }), + }); + assert.equal(r.status, 200); + const testResult = (await r.json()) as { + ok: boolean; + providerId: string; + modelId: string; + upstreamModelId: string; + harness: string; + reply: string; + latencyMs: number; + maxOutputTokens: number; + requestedModel: string; + endpointAlias: string; + responseModel: string; + firstTokenMs: number; + providerTotalMs: number; + usage: { + inputTokens: number; + outputTokens: number; + totalTokens: number; + cachedInputTokens: number; + cacheCreationInputTokens: number; + }; + streamed: boolean; + upstreamRequests: number; + noDefaultEgress: boolean; + }; + assert.equal(testResult.ok, true); + assert.equal(testResult.providerId, "qa"); + assert.equal(testResult.modelId, "qa-chat"); + assert.equal(testResult.upstreamModelId, "qa-chat"); + assert.equal(testResult.harness, "pi"); + assert.equal(testResult.reply, "QA UPSTREAM REPLY"); + assert.ok(testResult.latencyMs >= 0); + assert.equal(testResult.maxOutputTokens, 128); + assert.equal(testResult.requestedModel, "qa-chat"); + assert.equal(testResult.endpointAlias, "QA Provider"); + assert.equal(testResult.responseModel, "qa-chat"); + assert.ok(testResult.firstTokenMs >= 1); + assert.ok(testResult.providerTotalMs >= testResult.firstTokenMs); + assert.deepEqual(testResult.usage, { + inputTokens: 5, + outputTokens: 3, + totalTokens: 8, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + }); + assert.equal(testResult.streamed, true); + assert.equal(testResult.upstreamRequests, 1); + assert.equal(testResult.noDefaultEgress, true); + assert.ok(!JSON.stringify(testResult).includes("sk-qa-good")); + assert.deepEqual(staleSeen, []); 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"); + assert.equal(call!.maxTokens, 128, "the outbound generation request is capped at 128 output tokens"); + + const callsBeforeFailure = seen.filter((s) => s.path.endsWith("/chat/completions")).length; + failCompletions = true; + r = await api("/v1/admin/custom-providers/qa/harness-test", { + method: "POST", + body: JSON.stringify({ modelId: "qa-chat", requestId: "request-qa-failure" }), + }); + assert.equal(r.status, 502); + assert.equal(seen.filter((s) => s.path.endsWith("/chat/completions")).length, callsBeforeFailure + 1); + const testAudits = (await built.auditLog.events()).filter((event) => event.action === "custom-providers.test"); + assert.deepEqual( + testAudits.map((event) => event.status), + ["attempted", "succeeded", "attempted", "failed"], + ); + assert.ok(testAudits.every((event) => !JSON.stringify(event).includes("sk-qa-good"))); + const succeededAudit = testAudits.find((event) => event.status === "succeeded"); + assert.match( + succeededAudit?.detail ?? "", + /^harness=pi upstreamModelId=qa-chat providerRevision=\d+ latencyMs=\d+ responseModel=qa-chat firstTokenMs=\d+ providerTotalMs=\d+ inputTokens=5 outputTokens=3 totalTokens=8 cachedInputTokens=0 cacheCreationInputTokens=0 maxOutputTokens=128 streamed=true upstreamRequests=1 requestIdHash=/, + ); + const failedAudit = testAudits.find((event) => event.status === "failed"); + assert.match( + failedAudit?.detail ?? "", + /^harness=pi upstreamModelId=qa-chat providerRevision=\d+ latencyMs=\d+ requestIdHash=/, + ); + failCompletions = false; + + r = await api("/v1/admin/custom-providers/collision", { + method: "PUT", + body: JSON.stringify({ + name: "Built-in Collision", + protocol: "openai", + baseUrl: upstreamUrl, + apiKey: "sk-qa-good", + models: [{ id: "gpt-5.6-luna", upstreamId: "gpt-5.6-luna" }], + validate: false, + }), + }); + assert.equal(r.status, 200); + const collisionStatus = (await r.json()) as { + status: { models: Array<{ id: string; upstreamId?: string }> }; + }; + assert.deepEqual(collisionStatus.status.models, [{ id: "collision/gpt-5.6-luna", upstreamId: "gpt-5.6-luna" }]); + const callsBeforeBuiltInCollision = seen.filter((s) => s.path.endsWith("/chat/completions")).length; + r = await api("/v1/admin/custom-providers/collision/harness-test", { + method: "POST", + body: JSON.stringify({ modelId: "collision/gpt-5.6-luna", requestId: "request-collision" }), + }); + assert.equal(r.status, 200); + const builtInCollisionCalls = seen.filter((s) => s.path.endsWith("/chat/completions")); + assert.equal(builtInCollisionCalls.length, callsBeforeBuiltInCollision + 1); + assert.equal(builtInCollisionCalls.at(-1)?.model, "gpt-5.6-luna"); + assert.equal((await api("/v1/admin/custom-providers/collision", { method: "DELETE" })).status, 200); + + const callsBeforeCustomCollision = seen.filter((s) => s.path.endsWith("/chat/completions")).length; + r = await api("/v1/admin/custom-providers/z-shadow", { + method: "PUT", + body: JSON.stringify({ + name: "Custom Collision", + protocol: "openai", + baseUrl: upstreamUrl, + apiKey: "sk-qa-good", + models: [{ id: "qa-chat" }], + validate: false, + }), + }); + assert.equal(r.status, 400); + assert.equal(seen.filter((s) => s.path.endsWith("/chat/completions")).length, callsBeforeCustomCollision); + assert.equal((await api("/v1/admin/custom-providers/z-shadow", { method: "DELETE" })).status, 404); // 7. edit WITHOUT key keeps the stored key r = await api("/v1/admin/custom-providers/qa", { @@ -193,11 +408,461 @@ test("QA: full custom-provider lifecycle against a live fake upstream", async () } finally { server.close(); upstream.close(); + staleUpstream.close(); + } +}); + +test("QA: OpenAI Responses custom provider serves the Admin self-test path", async () => { + const seen: Array<{ path: string; auth?: string; model?: string; maxTokens?: number }> = []; + let failResponses = false; + const upstream = createServer((req, res) => { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { + const record = { path: req.url ?? "", auth: req.headers.authorization }; + if (req.url?.endsWith("/models")) { + seen.push(record); + res.writeHead(200, { "content-type": "application/json" }); + return res.end(JSON.stringify({ data: [{ id: "gpt-5.6-luna" }] })); + } + if (req.url?.endsWith("/responses")) { + const payload = JSON.parse(body) as { model?: string; max_output_tokens?: number }; + seen.push({ ...record, model: payload.model, maxTokens: payload.max_output_tokens }); + if (failResponses) { + res.writeHead(429, { "content-type": "application/json", "retry-after": "0" }); + return res.end(JSON.stringify({ error: { message: "retryable failure" } })); + } + const item = { + id: "msg_responses_qa", + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "RESPONSES QA REPLY", annotations: [] }], + }; + const response = { + id: "resp_qa", + object: "response", + created_at: Math.floor(Date.now() / 1000), + status: "completed", + model: "gpt-5.6-luna", + output: [item], + usage: { + input_tokens: 5, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 3, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 8, + }, + }; + res.writeHead(200, { "content-type": "text/event-stream" }); + const events = [ + { type: "response.created", response: { ...response, status: "in_progress", output: [] } }, + { + type: "response.output_item.added", + output_index: 0, + item: { ...item, status: "in_progress", content: [] }, + }, + { + type: "response.content_part.added", + output_index: 0, + item_id: item.id, + content_index: 0, + part: { type: "output_text", text: "", annotations: [] }, + }, + { + type: "response.output_text.delta", + output_index: 0, + item_id: item.id, + content_index: 0, + delta: "RESPONSES QA REPLY", + }, + { + type: "response.output_text.done", + output_index: 0, + item_id: item.id, + content_index: 0, + text: "RESPONSES QA REPLY", + }, + { + type: "response.content_part.done", + output_index: 0, + item_id: item.id, + content_index: 0, + part: item.content[0], + }, + { type: "response.output_item.done", output_index: 0, item }, + { type: "response.completed", response }, + ]; + events.forEach((event, sequence_number) => + res.write(`event: ${event.type}\ndata: ${JSON.stringify({ ...event, sequence_number })}\n\n`), + ); + return res.end(); + } + seen.push(record); + res.writeHead(404); + res.end(); + }); + }); + await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve)); + const upstreamUrl = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}/v1`; + const built = buildApp( + testConfig({ + dataDir: mkdtempSync(join(tmpdir(), "qa-responses-")), + codexProcessEnv: { PATH: process.env.PATH }, + }), + ); + const server = createInsecureTestServer(built.app, { + config: built.config, + modelCredentials: built.modelCredentials, + customProviders: built.customProviders, + customProviderTestRuns: built.customProviderTestRuns, + refreshCustomProviders: built.refreshCustomProviders, + customProviderHarnessTest: built.customProviderHarnessTest, + customProviderHarnessTestFence: built.customProviderHarnessTestFence, + admin: built.admin, + auditLog: built.auditLog, + harnessId: "pi", + }); + server.listen(0); + const base = `http://localhost:${(server.address() as AddressInfo).port}`; + try { + let result = await fetch(`${base}/v1/admin/custom-providers/responses`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ + name: "Responses", + protocol: "openai-responses", + baseUrl: upstreamUrl, + apiKey: "sk-responses", + models: [{ id: "gpt-5.6-luna", upstreamId: "gpt-5.6-luna" }], + }), + }); + assert.equal(result.status, 200); + const saved = (await result.json()) as { status: { models: Array<{ id: string; upstreamId?: string }> } }; + assert.deepEqual(saved.status.models, [{ id: "responses/gpt-5.6-luna", upstreamId: "gpt-5.6-luna" }]); + + for (const harness of ["pi", "opencode", "codex"]) { + result = await fetch(`${base}/v1/admin/custom-providers/responses/harness-test`, { + method: "POST", + headers: ADMIN, + body: JSON.stringify({ + modelId: "responses/gpt-5.6-luna", + harness, + requestId: `request-responses-${harness}-success`, + }), + }); + const responseText = await result.text(); + assert.equal(result.status, 200, `${harness} self-test succeeds: ${responseText}; calls=${JSON.stringify(seen)}`); + const body = JSON.parse(responseText) as { + reply: string; + modelId: string; + upstreamModelId: string; + harness: string; + maxOutputTokens: number; + requestedModel: string; + endpointAlias: string; + responseModel: string; + firstTokenMs: number; + providerTotalMs: number; + usage: { + inputTokens: number; + outputTokens: number; + totalTokens: number; + cachedInputTokens: number; + cacheCreationInputTokens: number; + }; + streamed: boolean; + upstreamRequests: number; + noDefaultEgress: boolean; + }; + assert.equal(body.reply, "RESPONSES QA REPLY"); + assert.equal(body.modelId, "responses/gpt-5.6-luna"); + assert.equal(body.upstreamModelId, "gpt-5.6-luna"); + assert.equal(body.harness, harness); + assert.equal(body.maxOutputTokens, 128); + assert.equal(body.requestedModel, "gpt-5.6-luna"); + assert.equal(body.endpointAlias, "Responses"); + assert.equal(body.responseModel, "gpt-5.6-luna"); + assert.ok(body.firstTokenMs >= 1); + assert.ok(body.providerTotalMs >= body.firstTokenMs); + assert.deepEqual(body.usage, { + inputTokens: 5, + outputTokens: 3, + totalTokens: 8, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + }); + assert.equal(body.streamed, true); + assert.equal(body.upstreamRequests, 1); + assert.equal(body.noDefaultEgress, true); + } + const calls = seen.filter((request) => request.path.endsWith("/responses")); + assert.equal(calls.length, 3); + assert.ok(calls.every((call) => call.auth === "Bearer sk-responses")); + assert.ok(calls.every((call) => call.model === "gpt-5.6-luna")); + assert.ok(calls.every((call) => call.maxTokens === 128)); + const audits = (await built.auditLog.events()).filter((event) => event.action === "custom-providers.test"); + assert.deepEqual( + audits.map((event) => [event.status, event.resource]), + ["pi", "opencode", "codex"].flatMap((harness) => [ + ["attempted", `responses/responses/gpt-5.6-luna/${harness}`], + ["succeeded", `responses/responses/gpt-5.6-luna/${harness}`], + ]), + ); + assert.ok(audits.every((event) => event.detail?.includes(`upstreamModelId=gpt-5.6-luna`))); + assert.ok(audits.every((event) => !JSON.stringify(event).includes("sk-responses"))); + failResponses = true; + for (const harness of ["pi", "opencode", "codex"]) { + const callsBeforeFailure = seen.filter((request) => request.path.endsWith("/responses")).length; + result = await fetch(`${base}/v1/admin/custom-providers/responses/harness-test`, { + method: "POST", + headers: ADMIN, + body: JSON.stringify({ + modelId: "responses/gpt-5.6-luna", + harness, + requestId: `request-responses-${harness}-failure`, + }), + }); + assert.equal(result.status, 502, `${harness} reports the failed single-attempt test`); + assert.equal( + seen.filter((request) => request.path.endsWith("/responses")).length, + callsBeforeFailure + 1, + `${harness} sends exactly one upstream request after a retryable failure`, + ); + } + const allAudits = (await built.auditLog.events()).filter((event) => event.action === "custom-providers.test"); + assert.deepEqual( + allAudits.slice(-6).map((event) => event.status), + ["attempted", "failed", "attempted", "failed", "attempted", "failed"], + ); + } finally { + await built.runtime.stop(); + await new Promise((resolve) => server.close(() => resolve())); + await new Promise((resolve) => upstream.close(() => resolve())); + } +}); + +test("QA: every harness rejects redirects, sends one tool-free request, and preserves the upstream host", async () => { + const targetSeen: string[] = []; + const target = createServer((req, res) => { + targetSeen.push(req.url ?? ""); + responsesReply(res, "gpt-5.6-luna", "REDIRECT TARGET MUST NOT RUN"); + }); + await new Promise((resolve) => target.listen(0, "127.0.0.1", resolve)); + const targetUrl = `http://127.0.0.1:${(target.address() as AddressInfo).port}/v1/responses`; + const originSeen: Array<{ + path: string; + auth?: string; + host?: string; + tools?: unknown; + toolChoice?: unknown; + parallelToolCalls?: unknown; + }> = []; + const origin = createServer((req, res) => { + let raw = ""; + req.on("data", (chunk) => (raw += chunk)); + req.on("end", () => { + const payload = raw ? (JSON.parse(raw) as Record) : {}; + originSeen.push({ + path: req.url ?? "", + auth: req.headers.authorization, + host: req.headers.host, + tools: payload.tools, + toolChoice: payload.tool_choice, + parallelToolCalls: payload.parallel_tool_calls, + }); + res.writeHead(307, { location: targetUrl }); + res.end(); + }); + }); + await new Promise((resolve) => origin.listen(0, "127.0.0.1", resolve)); + const originBase = `http://127.0.0.1:${(origin.address() as AddressInfo).port}/v1`; + const built = buildApp( + testConfig({ + dataDir: mkdtempSync(join(tmpdir(), "qa-redirect-")), + codexProcessEnv: { PATH: process.env.PATH }, + }), + ); + const server = createInsecureTestServer(built.app, { + config: built.config, + modelCredentials: built.modelCredentials, + customProviders: built.customProviders, + customProviderTestRuns: built.customProviderTestRuns, + refreshCustomProviders: built.refreshCustomProviders, + customProviderHarnessTest: built.customProviderHarnessTest, + customProviderHarnessTestFence: built.customProviderHarnessTestFence, + admin: built.admin, + auditLog: built.auditLog, + harnessId: "pi", + }); + server.listen(0); + const base = `http://localhost:${(server.address() as AddressInfo).port}`; + try { + let result = await fetch(`${base}/v1/admin/custom-providers/redirect`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ + name: "Redirect", + protocol: "openai-responses", + baseUrl: originBase, + apiKey: "sk-redirect", + models: [{ id: "gpt-5.6-luna", upstreamId: "gpt-5.6-luna" }], + validate: false, + }), + }); + assert.equal(result.status, 200); + for (const harness of ["pi", "opencode", "codex"]) { + const before = originSeen.length; + result = await fetch(`${base}/v1/admin/custom-providers/redirect/harness-test`, { + method: "POST", + headers: ADMIN, + body: JSON.stringify({ + modelId: "redirect/gpt-5.6-luna", + harness, + requestId: `request-redirect-${harness}`, + }), + }); + const responseText = await result.text(); + assert.equal(result.status, 502, `${harness} rejects the redirect: ${responseText}`); + assert.equal(originSeen.length, before + 1, `${harness} sends one request to the configured endpoint`); + assert.equal(targetSeen.length, 0, `${harness} never follows the redirect target`); + } + assert.ok(originSeen.every((request) => request.path === "/v1/responses")); + assert.ok(originSeen.every((request) => request.auth === "Bearer sk-redirect")); + assert.ok(originSeen.every((request) => request.host === new URL(originBase).host)); + assert.ok( + originSeen.every( + (request) => request.tools === undefined || (Array.isArray(request.tools) && request.tools.length === 0), + ), + ); + assert.ok(originSeen.every((request) => request.toolChoice === undefined)); + assert.ok(originSeen.every((request) => request.parallelToolCalls === undefined)); + } finally { + await built.runtime.stop(); + await new Promise((resolve) => server.close(() => resolve())); + await new Promise((resolve) => origin.close(() => resolve())); + await new Promise((resolve) => target.close(() => resolve())); + } +}); + +test("QA: every harness keeps the captured provider snapshot during an in-flight edit", async () => { + const seen: Array<{ path: string; auth?: string; model?: string }> = []; + let activeGate: + | { + seen: Promise; + notifySeen(): void; + released: Promise; + release(): void; + } + | undefined; + const newGate = () => { + let notifySeen!: () => void; + let release!: () => void; + const gate = { + seen: new Promise((resolve) => { + notifySeen = resolve; + }), + notifySeen: () => notifySeen(), + released: new Promise((resolve) => { + release = resolve; + }), + release: () => release(), + }; + activeGate = gate; + return gate; + }; + const upstream = createServer((req, res) => { + let requestBody = ""; + req.on("data", (chunk) => (requestBody += chunk)); + req.on("end", async () => { + if (!req.url?.endsWith("/responses")) { + res.writeHead(404); + return res.end(); + } + const payload = JSON.parse(requestBody) as { model?: string }; + seen.push({ path: req.url, auth: req.headers.authorization, model: payload.model }); + const gate = activeGate; + if (req.url.startsWith("/old/") && gate) { + gate.notifySeen(); + await gate.released; + } + responsesReply(res, payload.model ?? "unknown", "SNAPSHOT QA REPLY"); + }); + }); + await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve)); + const upstreamBase = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`; + const built = buildApp( + testConfig({ + dataDir: mkdtempSync(join(tmpdir(), "qa-snapshot-")), + codexProcessEnv: { PATH: process.env.PATH }, + }), + ); + const server = createInsecureTestServer(built.app, { + config: built.config, + modelCredentials: built.modelCredentials, + customProviders: built.customProviders, + customProviderTestRuns: built.customProviderTestRuns, + refreshCustomProviders: built.refreshCustomProviders, + customProviderHarnessTest: built.customProviderHarnessTest, + customProviderHarnessTestFence: built.customProviderHarnessTestFence, + admin: built.admin, + auditLog: built.auditLog, + harnessId: "pi", + }); + server.listen(0); + const base = `http://localhost:${(server.address() as AddressInfo).port}`; + const save = (baseUrl: string, apiKey: string, upstreamId: string) => + fetch(`${base}/v1/admin/custom-providers/snapshot`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ + name: "Snapshot", + protocol: "openai-responses", + baseUrl, + apiKey, + models: [{ id: "snapshot-model", upstreamId }], + validate: false, + }), + }); + try { + for (const harness of ["pi", "opencode", "codex"]) { + let saved = await save(`${upstreamBase}/old/v1`, "sk-old", "wire-old"); + assert.equal(saved.status, 200); + const gate = newGate(); + const pending = fetch(`${base}/v1/admin/custom-providers/snapshot/harness-test`, { + method: "POST", + headers: ADMIN, + body: JSON.stringify({ modelId: "snapshot-model", harness, requestId: `request-snapshot-${harness}` }), + }); + try { + await gate.seen; + saved = await save(`${upstreamBase}/new/v1`, "sk-new", "wire-new"); + assert.equal(saved.status, 200); + } finally { + gate.release(); + } + const result = await pending; + assert.equal(result.status, 409, `${harness} rejects success for the replaced provider revision`); + assert.equal(((await result.json()) as { error: string }).error, "provider_changed_during_test"); + } + const calls = seen.filter((request) => request.path.endsWith("/responses")); + assert.equal(calls.length, 3); + assert.ok(calls.every((request) => request.path === "/old/v1/responses")); + assert.ok(calls.every((request) => request.auth === "Bearer sk-old")); + assert.ok(calls.every((request) => request.model === "wire-old")); + const audits = (await built.auditLog.events()).filter((event) => event.action === "custom-providers.test"); + assert.equal(audits.length, 6); + assert.ok(audits.every((event) => event.detail?.includes("upstreamModelId=wire-old"))); + } finally { + activeGate?.release(); + await built.runtime.stop(); + await new Promise((resolve) => server.close(() => resolve())); + await new Promise((resolve) => upstream.close(() => resolve())); } }); 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 seen: Array<{ path: string; apiKeyHeader?: string; version?: string; model?: string; maxTokens?: number }> = []; const upstream = createServer((req, res) => { let body = ""; req.on("data", (c) => (body += c)); @@ -213,7 +878,9 @@ test("QA: anthropic-protocol custom provider serves a real turn (correct wire sh return res.end(JSON.stringify({ data: [] })); } if (req.url?.endsWith("/v1/messages")) { - record.model = (JSON.parse(body) as { model?: string }).model; + const payload = JSON.parse(body) as { model?: string; max_tokens?: number }; + record.model = payload.model; + record.maxTokens = payload.max_tokens; seen.push(record); res.writeHead(200, { "content-type": "text/event-stream" }); res.write( @@ -245,7 +912,10 @@ test("QA: anthropic-protocol custom provider serves a real turn (correct wire sh config: built.config, modelCredentials: built.modelCredentials, customProviders: built.customProviders, + customProviderTestRuns: built.customProviderTestRuns, refreshCustomProviders: built.refreshCustomProviders, + customProviderHarnessTest: built.customProviderHarnessTest, + customProviderHarnessTestFence: built.customProviderHarnessTestFence, admin: built.admin, auditLog: built.auditLog, harnessId: "pi", @@ -253,7 +923,7 @@ test("QA: anthropic-protocol custom provider serves a real turn (correct wire sh server.listen(0); const base = `http://localhost:${(server.address() as AddressInfo).port}`; try { - const r = await fetch(`${base}/v1/admin/custom-providers/antcompat`, { + let r = await fetch(`${base}/v1/admin/custom-providers/antcompat`, { method: "PUT", headers: ADMIN, body: JSON.stringify({ @@ -268,12 +938,18 @@ test("QA: anthropic-protocol custom provider serves a real turn (correct wire sh 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"); + r = await fetch(`${base}/v1/admin/custom-providers/antcompat/harness-test`, { + method: "POST", + headers: ADMIN, + body: JSON.stringify({ modelId: "claude-compat", requestId: "request-anthropic" }), + }); + assert.equal(r.status, 200); + assert.equal(((await r.json()) as { reply: string }).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"); + assert.equal(call!.maxTokens, 128); } finally { server.close(); upstream.close(); @@ -306,7 +982,7 @@ test("QA: registrations survive a restart (shared durable backing + same secret) // and the hydration path wires it into the runtime registry setCustomProviders(enabled); assert.ok(resolveModel("survivor-model"), "hydrated model resolves"); - setCustomProviders([]); + setCustomProviders([], []); }); test("QA: a corrupt stored key degrades that provider only — admin surface stays intact", async () => { diff --git a/test/custom-provider-route.test.ts b/test/custom-provider-route.test.ts index 910bdd9e6..2676c4e13 100644 --- a/test/custom-provider-route.test.ts +++ b/test/custom-provider-route.test.ts @@ -13,11 +13,35 @@ 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 ADMIN_BOB = { "content-type": "application/json", "x-admin-actor": "admin-bob@default-org" }; const USER = { "content-type": "application/json", "x-admin-actor": "bob@default-org" }; -afterEach(() => setCustomProviders([])); +function modelTestEvidence(model: string) { + return { + requestedModel: model, + responseModel: model, + firstTokenMs: 12, + totalMs: 40, + usage: { + inputTokens: 5, + outputTokens: 3, + totalTokens: 8, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + }, + streamed: true, + upstreamRequests: 1, + }; +} -function start(modelCredentialFetch: typeof fetch = async () => new Response(null, { status: 200 })): { +afterEach(() => setCustomProviders([], [])); + +function start( + modelCredentialFetch: typeof fetch = async () => new Response(null, { status: 200 }), + runtimeSchemaReady?: boolean, + customProviderHarnessTest?: BuiltApp["customProviderHarnessTest"], + customProviderHarnessTestFence?: BuiltApp["customProviderHarnessTestFence"], +): { base: string; built: BuiltApp; close: () => Promise; @@ -25,11 +49,18 @@ function start(modelCredentialFetch: typeof fetch = async () => new Response(nul const built = buildApp(testConfig({ dataDir: mkdtempSync(join(tmpdir(), "custom-provider-route-")) }), { modelCredentialFetch, }); + if (runtimeSchemaReady !== undefined) { + built.customProviders.runtimeSchemaReady = async () => runtimeSchemaReady; + built.customProviders.runtimeSchemaWritable = async () => runtimeSchemaReady; + } const server = createInsecureTestServer(built.app, { config: built.config, modelCredentials: built.modelCredentials, customProviders: built.customProviders, + customProviderTestRuns: built.customProviderTestRuns, refreshCustomProviders: built.refreshCustomProviders, + customProviderHarnessTest: customProviderHarnessTest ?? built.customProviderHarnessTest, + customProviderHarnessTestFence: customProviderHarnessTestFence ?? built.customProviderHarnessTestFence, modelCredentialFetch, harnessId: "pi", providerKeys: { anthropic: true, openai: false, openrouter: false }, @@ -84,6 +115,12 @@ test("custom provider lifecycle: register, list, resolve, delete — admin only, // Non-admin gets refused. const denied = await fetch(`${srv.base}/v1/admin/custom-providers`, { headers: USER }); assert.notEqual(denied.status, 200); + const deniedTest = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway/harness-test`, { + method: "POST", + headers: USER, + body: JSON.stringify({ modelId: "acme-large", requestId: "request-denied" }), + }); + assert.notEqual(deniedTest.status, 200); // Delete disables and clears the registry. const del = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { @@ -97,6 +134,835 @@ test("custom provider lifecycle: register, list, resolve, delete — admin only, } }); +test("generation self-test requires an active stored key", async () => { + const srv = start(); + try { + const put = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ ...BODY, apiKey: undefined, validate: false }), + }); + assert.equal(put.status, 200); + const tested = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway/harness-test`, { + method: "POST", + headers: ADMIN, + body: JSON.stringify({ modelId: "acme-large", requestId: "request-missing-key" }), + }); + assert.equal(tested.status, 400); + assert.deepEqual(await tested.json(), { + error: "missing_api_key", + message: "this provider has no active API key", + requestId: "request-missing-key", + }); + } finally { + await srv.close(); + } +}); + +test("generation self-test dispatches the selected real harness", async () => { + const calls: Array<{ harnessId: string; modelId: string }> = []; + const srv = start(undefined, undefined, async (input) => { + calls.push({ harnessId: input.harnessId, modelId: input.modelId }); + return { + reply: `${input.harnessId} ready`, + providerRevision: input.expectedRevision, + upstreamModelId: input.modelId, + evidence: modelTestEvidence(input.modelId), + maxOutputTokens: input.harnessId === "pi" ? 64 : 128, + }; + }); + try { + const put = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ + ...BODY, + protocol: "openai-responses", + models: [{ id: "responses-model" }], + validate: false, + }), + }); + assert.equal(put.status, 200); + + for (const harness of ["pi", "opencode", "codex"]) { + const tested = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway/harness-test`, { + method: "POST", + headers: ADMIN, + body: JSON.stringify({ modelId: "responses-model", harness, requestId: `request-${harness}` }), + }); + assert.equal(tested.status, 200); + const result = (await tested.json()) as { + harness: string; + reply: string; + requestedModel: string; + responseModel: string; + endpointAlias: string; + noDefaultEgress: boolean; + providerRevision: number; + testedAt: number; + maxOutputTokens: number; + }; + assert.equal(result.harness, harness); + assert.equal(result.reply, `${harness} ready`); + assert.equal(result.requestedModel, "responses-model"); + assert.equal(result.responseModel, "responses-model"); + assert.equal(result.endpointAlias, "Acme Gateway"); + assert.equal(result.noDefaultEgress, true); + assert.equal(result.providerRevision, 1); + assert.equal(result.maxOutputTokens, harness === "pi" ? 64 : 128); + assert.ok(Number.isFinite(result.testedAt)); + } + assert.deepEqual(calls, [ + { harnessId: "pi", modelId: "responses-model" }, + { harnessId: "opencode", modelId: "responses-model" }, + { harnessId: "codex", modelId: "responses-model" }, + ]); + const audits = (await srv.built.auditLog.events()).filter((event) => event.action === "custom-providers.test"); + assert.ok(audits.every((event) => event.detail?.includes("requestIdHash="))); + assert.ok(audits.every((event) => event.detail?.includes("requestFingerprint="))); + assert.ok(audits.every((event) => !event.detail?.includes("request-pi"))); + + const invalid = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway/harness-test`, { + method: "POST", + headers: ADMIN, + body: JSON.stringify({ modelId: "responses-model", harness: "slack", requestId: "request-invalid" }), + }); + assert.equal(invalid.status, 400); + assert.equal(calls.length, 3); + + const missingRequestId = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway/harness-test`, { + method: "POST", + headers: ADMIN, + body: JSON.stringify({ modelId: "responses-model", harness: "pi" }), + }); + assert.equal(missingRequestId.status, 400); + assert.match(((await missingRequestId.json()) as { message: string }).message, /requestId is required/); + assert.equal(calls.length, 3); + + const chatOnly = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ ...BODY, models: [{ id: "responses-model" }], validate: false }), + }); + assert.equal(chatOnly.status, 200); + const unsupported = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway/harness-test`, { + method: "POST", + headers: ADMIN, + body: JSON.stringify({ modelId: "responses-model", harness: "codex", requestId: "request-unsupported" }), + }); + assert.equal(unsupported.status, 400); + assert.equal(((await unsupported.json()) as { error: string; requestId: string }).requestId, "request-unsupported"); + assert.equal(calls.length, 3); + } finally { + await srv.close(); + } +}); + +test("generation self-test rejects incomplete or unsafe model evidence", async () => { + const variants = [ + { evidence: undefined, maxOutputTokens: 128 }, + { + evidence: { ...modelTestEvidence("responses-model"), responseModel: "gpt-5.6-sol" }, + maxOutputTokens: 128, + }, + { evidence: { ...modelTestEvidence("responses-model"), streamed: false }, maxOutputTokens: 128 }, + { + evidence: { + ...modelTestEvidence("responses-model"), + usage: { + inputTokens: 5, + outputTokens: 129, + totalTokens: 134, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + }, + }, + maxOutputTokens: 128, + }, + { + evidence: { + ...modelTestEvidence("responses-model"), + usage: { + inputTokens: -1, + outputTokens: 3, + totalTokens: 2, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + }, + }, + maxOutputTokens: 128, + }, + { + evidence: { + ...modelTestEvidence("responses-model"), + usage: { + inputTokens: 5, + outputTokens: 3, + totalTokens: 7, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + }, + }, + maxOutputTokens: 128, + }, + { + evidence: { + ...modelTestEvidence("responses-model"), + usage: { + inputTokens: 5, + outputTokens: 3, + totalTokens: 8, + cachedInputTokens: 6, + cacheCreationInputTokens: 0, + }, + }, + maxOutputTokens: 128, + }, + { + evidence: { + ...modelTestEvidence("responses-model"), + usage: { + inputTokens: 5, + outputTokens: 3, + totalTokens: 8, + cachedInputTokens: 3, + cacheCreationInputTokens: 3, + }, + }, + maxOutputTokens: 128, + }, + { + evidence: { ...modelTestEvidence("responses-model"), firstTokenMs: 41, totalMs: 40 }, + maxOutputTokens: 128, + }, + { + evidence: { ...modelTestEvidence("responses-model"), upstreamRequests: 2 }, + maxOutputTokens: 128, + }, + { evidence: modelTestEvidence("responses-model"), maxOutputTokens: 129 }, + ]; + let calls = 0; + const srv = start(undefined, undefined, async (input) => { + const variant = variants[calls++]; + assert.ok(variant); + return { + reply: "ready", + providerRevision: input.expectedRevision, + upstreamModelId: input.modelId, + ...variant, + }; + }); + try { + const put = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ + ...BODY, + protocol: "openai-responses", + models: [{ id: "responses-model" }], + validate: false, + }), + }); + assert.equal(put.status, 200); + for (let index = 0; index < variants.length; index += 1) { + const tested = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway/harness-test`, { + method: "POST", + headers: ADMIN, + body: JSON.stringify({ + modelId: "responses-model", + harness: "pi", + requestId: `request-unsafe-${index}`, + }), + }); + assert.equal(tested.status, 502); + const result = (await tested.json()) as Record; + assert.equal(result.error, "provider_test_failed"); + assert.equal(result.message, "the model response could not be verified"); + assert.equal(result.requestId, `request-unsafe-${index}`); + assert.equal(result.providerRevision, 1); + assert.ok(Number.isFinite(result.testedAt)); + } + assert.equal(calls, variants.length); + } finally { + await srv.close(); + } +}); + +test("generation self-test admits one paid call across independent admin clients and replays its result", async () => { + let calls = 0; + let notifyStarted!: () => void; + let releaseRunner!: () => void; + const started = new Promise((resolve) => { + notifyStarted = resolve; + }); + const released = new Promise((resolve) => { + releaseRunner = resolve; + }); + const srv = start(undefined, undefined, async (input) => { + calls += 1; + notifyStarted(); + await released; + return { + reply: "ready", + providerRevision: input.expectedRevision, + upstreamModelId: input.modelId, + evidence: modelTestEvidence(input.modelId), + maxOutputTokens: 128, + }; + }); + try { + const put = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ ...BODY, validate: false }), + }); + assert.equal(put.status, 200); + const url = `${srv.base}/v1/admin/custom-providers/acme-gateway/harness-test`; + const ownerBody = JSON.stringify({ modelId: "acme-large", harness: "pi", requestId: "request-owner" }); + const waiterBody = JSON.stringify({ modelId: "acme-large", harness: "pi", requestId: "request-waiter" }); + const firstPending = fetch(url, { method: "POST", headers: ADMIN, body: ownerBody }); + await started; + + const duplicate = await fetch(url, { method: "POST", headers: ADMIN_BOB, body: waiterBody }); + assert.equal(duplicate.status, 409); + const busy = (await duplicate.json()) as { + error: string; + replayExpected: boolean; + requestExpiresInMs: number; + retryAfterMs: number; + }; + assert.equal(busy.error, "harness_test_in_progress"); + assert.equal(busy.replayExpected, true); + assert.ok(busy.retryAfterMs > 0 && busy.retryAfterMs <= 2_000); + assert.ok(busy.requestExpiresInMs > busy.retryAfterMs); + assert.equal(duplicate.headers.get("retry-after"), "2"); + assert.equal(calls, 1); + + releaseRunner(); + const first = await firstPending; + assert.equal(first.status, 200); + assert.equal(((await first.json()) as { cached?: boolean }).cached, undefined); + + const replay = await fetch(url, { method: "POST", headers: ADMIN_BOB, body: waiterBody }); + assert.equal(replay.status, 200); + const replayed = (await replay.json()) as { cached: boolean; reply: string }; + assert.equal(replayed.cached, true); + assert.equal(replayed.reply, "ready"); + assert.equal(calls, 1); + + const ownerReplay = await fetch(url, { method: "POST", headers: ADMIN, body: ownerBody }); + assert.equal(ownerReplay.status, 200); + assert.equal(((await ownerReplay.json()) as { cached: boolean }).cached, true); + assert.equal(calls, 1); + + const fresh = await fetch(url, { + method: "POST", + headers: ADMIN, + body: JSON.stringify({ modelId: "acme-large", harness: "pi", requestId: "request-fresh" }), + }); + assert.equal(fresh.status, 200); + assert.equal(((await fresh.json()) as { cached?: boolean }).cached, undefined); + assert.equal(calls, 2); + const audits = (await srv.built.auditLog.events()).filter((event) => event.action === "custom-providers.test"); + assert.deepEqual( + audits.map((event) => event.status), + ["attempted", "busy", "succeeded", "replayed", "replayed", "attempted", "succeeded"], + ); + } finally { + releaseRunner(); + await srv.close(); + } +}); + +test("generation self-test persists a paid result after the requesting client disconnects", async () => { + let calls = 0; + let notifyStarted!: () => void; + let releaseRunner!: () => void; + const started = new Promise((resolve) => { + notifyStarted = resolve; + }); + const released = new Promise((resolve) => { + releaseRunner = resolve; + }); + const srv = start(undefined, undefined, async (input) => { + calls += 1; + notifyStarted(); + await released; + return { + reply: "survived disconnect", + providerRevision: input.expectedRevision, + upstreamModelId: input.modelId, + evidence: modelTestEvidence(input.modelId), + maxOutputTokens: 128, + }; + }); + try { + const put = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ ...BODY, validate: false }), + }); + assert.equal(put.status, 200); + const url = `${srv.base}/v1/admin/custom-providers/acme-gateway/harness-test`; + const body = JSON.stringify({ modelId: "acme-large", harness: "pi", requestId: "request-disconnected" }); + const controller = new AbortController(); + const disconnected = fetch(url, { method: "POST", headers: ADMIN, body, signal: controller.signal }); + await started; + controller.abort(); + await assert.rejects(disconnected, (error: Error) => error.name === "AbortError"); + releaseRunner(); + + let replayed: { cached: boolean; reply: string } | null = null; + for (let attempt = 0; attempt < 50; attempt += 1) { + const retry = await fetch(url, { method: "POST", headers: ADMIN_BOB, body }); + if (retry.status === 200) { + replayed = (await retry.json()) as { cached: boolean; reply: string }; + break; + } + assert.equal(retry.status, 409); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.equal(replayed?.cached, true); + assert.equal(replayed?.reply, "survived disconnect"); + assert.equal(calls, 1); + } finally { + releaseRunner(); + await srv.close(); + } +}); + +test("generation self-test does not spend when the durable billing guard cannot claim", async () => { + let calls = 0; + const srv = start(undefined, undefined, async (input) => { + calls += 1; + return { + reply: "ready", + providerRevision: input.expectedRevision, + upstreamModelId: input.modelId, + evidence: modelTestEvidence(input.modelId), + maxOutputTokens: 128, + }; + }); + try { + const put = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ ...BODY, validate: false }), + }); + assert.equal(put.status, 200); + srv.built.customProviderTestRuns.claim = async () => { + throw new Error("store unavailable"); + }; + const tested = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway/harness-test`, { + method: "POST", + headers: ADMIN, + body: JSON.stringify({ modelId: "acme-large", harness: "pi", requestId: "request-guard" }), + }); + assert.equal(tested.status, 503); + assert.equal(((await tested.json()) as { error: string }).error, "harness_test_guard_unavailable"); + assert.equal(calls, 0); + } finally { + await srv.close(); + } +}); + +test("generation self-test does not spend when an old request receipt has no recoverable result", async () => { + let calls = 0; + const srv = start(undefined, undefined, async (input) => { + calls += 1; + return { + reply: "ready", + providerRevision: input.expectedRevision, + upstreamModelId: input.modelId, + evidence: modelTestEvidence(input.modelId), + maxOutputTokens: 128, + }; + }); + try { + const put = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ ...BODY, validate: false }), + }); + assert.equal(put.status, 200); + srv.built.customProviderTestRuns.claim = async () => ({ + kind: "unresolved", + retryAfterMs: 3_000, + requestExpiresAt: Date.now() + 3_000, + }); + const tested = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway/harness-test`, { + method: "POST", + headers: ADMIN, + body: JSON.stringify({ modelId: "acme-large", harness: "pi", requestId: "request-unresolved" }), + }); + assert.equal(tested.status, 409); + assert.equal(tested.headers.get("retry-after"), "3"); + const result = (await tested.json()) as { error: string; requestExpiresInMs: number }; + assert.equal(result.error, "harness_test_result_unresolved"); + assert.ok(result.requestExpiresInMs > 0 && result.requestExpiresInMs <= 3_000); + assert.equal(calls, 0); + } finally { + await srv.close(); + } +}); + +test("generation self-test keeps the safety window closed when paid-result persistence fails", async () => { + let calls = 0; + const srv = start(undefined, undefined, async (input) => { + calls += 1; + return { + reply: "ready", + providerRevision: input.expectedRevision, + upstreamModelId: input.modelId, + evidence: modelTestEvidence(input.modelId), + maxOutputTokens: 128, + }; + }); + try { + const put = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ ...BODY, validate: false }), + }); + assert.equal(put.status, 200); + srv.built.customProviderTestRuns.complete = async () => { + throw new Error("store unavailable"); + }; + const url = `${srv.base}/v1/admin/custom-providers/acme-gateway/harness-test`; + const body = JSON.stringify({ modelId: "acme-large", harness: "pi", requestId: "request-unpersisted" }); + const tested = await fetch(url, { method: "POST", headers: ADMIN, body }); + assert.equal(tested.status, 503); + assert.equal(((await tested.json()) as { error: string }).error, "harness_test_result_not_durable"); + assert.equal(calls, 1); + + const retry = await fetch(url, { method: "POST", headers: ADMIN_BOB, body }); + assert.equal(retry.status, 409); + assert.equal(((await retry.json()) as { error: string }).error, "harness_test_in_progress"); + assert.equal(calls, 1); + } finally { + await srv.close(); + } +}); + +test("generation self-test stays closed until every live runtime supports its protocol", async () => { + let calls = 0; + const srv = start( + undefined, + undefined, + async (input) => { + calls += 1; + return { + reply: "ready", + providerRevision: input.expectedRevision, + upstreamModelId: input.modelId, + evidence: modelTestEvidence(input.modelId), + maxOutputTokens: 128, + }; + }, + async () => null, + ); + try { + const put = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ ...BODY, validate: false }), + }); + assert.equal(put.status, 200); + + const tested = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway/harness-test`, { + method: "POST", + headers: ADMIN, + body: JSON.stringify({ modelId: "acme-large", harness: "pi", requestId: "request-rollout-closed" }), + }); + assert.equal(tested.status, 409); + assert.equal(((await tested.json()) as { error: string }).error, "harness_test_rollout_incomplete"); + assert.equal(calls, 0); + + const legacy = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway/test`, { + method: "POST", + headers: ADMIN, + body: JSON.stringify({ modelId: "acme-large" }), + }); + assert.equal(legacy.status, 404); + } finally { + await srv.close(); + } +}); + +test("generation self-test cannot report success when the rollout fence changes in flight", async () => { + let rolloutFence = "epoch-1"; + let calls = 0; + let notifyStarted!: () => void; + let releaseRunner!: () => void; + const started = new Promise((resolve) => { + notifyStarted = resolve; + }); + const released = new Promise((resolve) => { + releaseRunner = resolve; + }); + const srv = start( + undefined, + undefined, + async (input) => { + calls += 1; + notifyStarted(); + await released; + return { + reply: "ready", + providerRevision: input.expectedRevision, + upstreamModelId: input.modelId, + evidence: modelTestEvidence(input.modelId), + maxOutputTokens: 128, + }; + }, + async () => rolloutFence, + ); + try { + const put = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ ...BODY, validate: false }), + }); + assert.equal(put.status, 200); + + const pending = fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway/harness-test`, { + method: "POST", + headers: ADMIN, + body: JSON.stringify({ modelId: "acme-large", harness: "pi", requestId: "request-rollout-change" }), + }); + await started; + rolloutFence = "epoch-2"; + const blocked = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway/harness-test`, { + method: "POST", + headers: ADMIN_BOB, + body: JSON.stringify({ modelId: "acme-large", harness: "pi", requestId: "request-rollout-waiter" }), + }); + assert.equal(blocked.status, 409); + const busy = (await blocked.json()) as { error: string; replayExpected: boolean }; + assert.equal(busy.error, "harness_test_in_progress"); + assert.equal(busy.replayExpected, false); + assert.equal(calls, 1); + releaseRunner(); + + const tested = await pending; + assert.equal(tested.status, 409); + assert.equal(((await tested.json()) as { error: string }).error, "harness_test_rollout_incomplete"); + const conflict = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway/harness-test`, { + method: "POST", + headers: ADMIN, + body: JSON.stringify({ modelId: "acme-large", harness: "pi", requestId: "request-rollout-change" }), + }); + assert.equal(conflict.status, 409); + assert.equal(((await conflict.json()) as { error: string }).error, "harness_test_request_conflict"); + assert.equal(calls, 1); + const audits = (await srv.built.auditLog.events()).filter((event) => event.action === "custom-providers.test"); + assert.deepEqual( + audits.map((event) => event.status), + ["attempted", "busy", "failed", "conflict"], + ); + } finally { + releaseRunner(); + await srv.close(); + } +}); + +test("generation self-test sees a provider update completed during its final fence read", async () => { + let runnerFinished = false; + let finalFenceBlocked = false; + let notifyFinalFence!: () => void; + let releaseFinalFence!: () => void; + const finalFenceStarted = new Promise((resolve) => { + notifyFinalFence = resolve; + }); + const finalFenceReleased = new Promise((resolve) => { + releaseFinalFence = resolve; + }); + const srv = start( + undefined, + undefined, + async (input) => { + runnerFinished = true; + return { + reply: "old revision ready", + providerRevision: input.expectedRevision, + upstreamModelId: "wire-old", + evidence: modelTestEvidence("wire-old"), + maxOutputTokens: 128, + }; + }, + async () => { + if (runnerFinished && !finalFenceBlocked) { + finalFenceBlocked = true; + notifyFinalFence(); + await finalFenceReleased; + } + return "epoch-1"; + }, + ); + try { + const put = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ + ...BODY, + protocol: "openai-responses", + models: [{ id: "race-model", upstreamId: "wire-old" }], + validate: false, + }), + }); + assert.equal(put.status, 200); + + const pending = fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway/harness-test`, { + method: "POST", + headers: ADMIN, + body: JSON.stringify({ modelId: "race-model", harness: "pi", requestId: "request-final-fence" }), + }); + await finalFenceStarted; + const update = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ + ...BODY, + protocol: "openai-responses", + apiKey: "sk-new-secret", + models: [{ id: "race-model", upstreamId: "wire-new" }], + validate: false, + }), + }); + assert.equal(update.status, 200); + releaseFinalFence(); + + const tested = await pending; + assert.equal(tested.status, 409); + assert.equal(((await tested.json()) as { error: string }).error, "provider_changed_during_test"); + const audits = (await srv.built.auditLog.events()).filter((event) => event.action === "custom-providers.test"); + assert.deepEqual( + audits.map((event) => event.status), + ["attempted", "failed"], + ); + } finally { + releaseFinalFence(); + await srv.close(); + } +}); + +test("generation self-test sees a rollout change between its final fence and provider snapshots", async () => { + let runnerFinished = false; + let finalFenceReads = 0; + const srv = start( + undefined, + undefined, + async (input) => { + runnerFinished = true; + return { + reply: "ready", + providerRevision: input.expectedRevision, + upstreamModelId: input.modelId, + evidence: modelTestEvidence(input.modelId), + maxOutputTokens: 128, + }; + }, + async () => { + if (!runnerFinished) return "epoch-1"; + finalFenceReads += 1; + return finalFenceReads === 1 ? "epoch-1" : "epoch-2"; + }, + ); + try { + const put = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ ...BODY, validate: false }), + }); + assert.equal(put.status, 200); + + const tested = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway/harness-test`, { + method: "POST", + headers: ADMIN, + body: JSON.stringify({ modelId: "acme-large", harness: "pi", requestId: "request-fence-snapshot" }), + }); + assert.equal(tested.status, 409); + assert.equal(((await tested.json()) as { error: string }).error, "harness_test_rollout_incomplete"); + assert.equal(finalFenceReads, 2); + const audits = (await srv.built.auditLog.events()).filter((event) => event.action === "custom-providers.test"); + assert.deepEqual( + audits.map((event) => event.status), + ["attempted", "failed"], + ); + } finally { + await srv.close(); + } +}); + +test("generation self-test cannot report success for a provider revision changed in flight", async () => { + let notifyStarted!: () => void; + let releaseRunner!: () => void; + const started = new Promise((resolve) => { + notifyStarted = resolve; + }); + const released = new Promise((resolve) => { + releaseRunner = resolve; + }); + const srv = start(undefined, undefined, async (input) => { + notifyStarted(); + await released; + return { + reply: "old revision ready", + providerRevision: input.expectedRevision, + upstreamModelId: "wire-old", + evidence: modelTestEvidence("wire-old"), + maxOutputTokens: 128, + }; + }); + try { + const put = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ + ...BODY, + protocol: "openai-responses", + models: [{ id: "race-model", upstreamId: "wire-old" }], + validate: false, + }), + }); + assert.equal(put.status, 200); + + const pending = fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway/harness-test`, { + method: "POST", + headers: ADMIN, + body: JSON.stringify({ modelId: "race-model", harness: "pi", requestId: "request-provider-change" }), + }); + await started; + + const update = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ + ...BODY, + protocol: "openai-responses", + apiKey: "sk-new-secret", + models: [{ id: "race-model", upstreamId: "wire-new" }], + validate: false, + }), + }); + assert.equal(update.status, 200); + releaseRunner(); + + const tested = await pending; + assert.equal(tested.status, 409); + assert.equal(((await tested.json()) as { error: string }).error, "provider_changed_during_test"); + const audits = (await srv.built.auditLog.events()).filter((event) => event.action === "custom-providers.test"); + assert.deepEqual( + audits.map((event) => event.status), + ["attempted", "failed"], + ); + assert.ok(audits.every((event) => event.detail?.includes("upstreamModelId=wire-old"))); + } finally { + releaseRunner(); + await srv.close(); + } +}); + test("a rejected key blocks registration unless validate:false", async () => { const srv = start(async () => new Response(null, { status: 401 })); try { @@ -126,6 +992,8 @@ test("bad specs are refused with a reason", async () => { [{ models: [] }, /at least one model/], [{ protocol: "grpc" }, /protocol/], [{ baseUrl: "https://x?y=1" }, /query/], + [{ models: [{ id: "acme-large", contextWindow: 0 }] }, /positive integer/], + [{ models: [{ id: "acme-large", maxTokens: 1.5 }] }, /positive integer/], ] as const) { const res = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { method: "PUT", @@ -147,3 +1015,66 @@ test("bad specs are refused with a reason", async () => { await srv.close(); } }); + +test("null JSON bodies return a client error", async () => { + const srv = start(); + try { + const put = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: "null", + }); + assert.equal(put.status, 400); + assert.equal(((await put.json()) as { error: string }).error, "bad_request"); + + const tested = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway/harness-test`, { + method: "POST", + headers: ADMIN, + body: "null", + }); + assert.equal(tested.status, 400); + assert.equal(((await tested.json()) as { error: string }).error, "bad_request"); + } finally { + await srv.close(); + } +}); + +test("model aliases remain inactive until every runtime supports wire ids", async () => { + const srv = start(undefined, false); + try { + const put = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ + ...BODY, + models: [{ id: "acme-luna", upstreamId: "gpt-5.6-luna" }], + validate: false, + }), + }); + assert.equal(put.status, 409); + assert.equal(((await put.json()) as { error: string }).error, "runtime_rollout_incomplete"); + assert.equal(resolveModel("acme-luna"), undefined); + } finally { + await srv.close(); + } +}); + +test("image capability remains inactive until every runtime supports schema 2", async () => { + const srv = start(undefined, false); + try { + const put = await fetch(`${srv.base}/v1/admin/custom-providers/acme-gateway`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ + ...BODY, + models: [{ id: "acme-luna", inputModalities: ["text", "image"] }], + validate: false, + }), + }); + assert.equal(put.status, 409); + assert.equal(((await put.json()) as { error: string }).error, "runtime_rollout_incomplete"); + assert.equal(resolveModel("acme-luna"), undefined); + } finally { + await srv.close(); + } +}); diff --git a/test/custom-provider-test-runs.test.ts b/test/custom-provider-test-runs.test.ts new file mode 100644 index 000000000..b1e419369 --- /dev/null +++ b/test/custom-provider-test-runs.test.ts @@ -0,0 +1,211 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createMemoryAdvisoryLock } from "../src/persistence/advisory-lock.ts"; +import { createMemoryMap } from "../src/persistence/durable-map.ts"; +import { + createCustomProviderTestRunStore, + type CustomProviderTestRunIdentity, + type StoredCustomProviderTestRun, +} from "../src/model/custom-provider-test-runs.ts"; + +const IDENTITY: CustomProviderTestRunIdentity = { + scopeId: "org:acme", + providerId: "gateway", + modelId: "luna", + harnessId: "pi", + providerRevision: 3, + rolloutFence: "epoch-7", +}; + +test("custom provider paid tests admit one owner and replay its durable result", async () => { + const now = 1_000; + let owner = 0; + const store = createCustomProviderTestRunStore({ + backing: createMemoryMap(), + advisoryLock: createMemoryAdvisoryLock(), + now: () => now, + ownerId: () => `owner-${++owner}`, + runningTtlMs: 100, + resultTtlMs: 200, + }); + assert.equal(store.durable, false); + + const first = await store.claim(IDENTITY, "request-a"); + assert.ok(first.kind === "claimed"); + const duplicate = await store.claim(IDENTITY, "request-b"); + assert.deepEqual(duplicate, { + kind: "running", + retryAfterMs: 100, + replayExpected: true, + requestExpiresAt: 1_200, + }); + const rolloutChanged = await store.claim({ ...IDENTITY, providerRevision: 4, rolloutFence: "epoch-8" }, "request-c"); + assert.deepEqual(rolloutChanged, { kind: "running", retryAfterMs: 100, replayExpected: false }); + const requestChanged = await store.claim({ ...IDENTITY, harnessId: "codex" }, "request-a"); + assert.deepEqual(requestChanged, { kind: "conflict" }); + assert.equal( + await store.complete(first, { + status: 200, + body: { ok: true, reply: "ready" }, + }), + true, + ); + + const replay = await store.claim(IDENTITY, "request-a"); + assert.equal(replay.kind, "replay"); + if (replay.kind === "replay") { + assert.equal(replay.response.status, 200); + assert.deepEqual(replay.response.body, { ok: true, reply: "ready" }); + assert.equal(replay.completedAt, 1_000); + assert.equal(replay.expiresAt, 1_200); + } + const changedReplay = await store.claim({ ...IDENTITY, providerRevision: 4, rolloutFence: "epoch-8" }, "request-a"); + assert.deepEqual(changedReplay, { kind: "conflict" }); + + const sharedReplay = await store.claim(IDENTITY, "request-b"); + assert.equal(sharedReplay.kind, "replay"); + const renewed = await store.claim(IDENTITY, "request-d"); + assert.equal(renewed.kind, "claimed"); + assert.notEqual(renewed.kind === "claimed" ? renewed.owner : "", first.owner); +}); + +test("a waiter near lease expiry cannot reuse its request id for a second paid call", async () => { + let now = 1_000; + const store = createCustomProviderTestRunStore({ + backing: createMemoryMap(), + advisoryLock: createMemoryAdvisoryLock(), + now: () => now, + runningTtlMs: 100, + resultTtlMs: 100, + }); + assert.equal((await store.claim(IDENTITY, "request-owner")).kind, "claimed"); + now = 1_099; + const waiter = await store.claim(IDENTITY, "request-waiter"); + assert.deepEqual(waiter, { + kind: "running", + retryAfterMs: 1, + replayExpected: true, + requestExpiresAt: 1_199, + }); + + now = 1_101; + assert.deepEqual(await store.claim(IDENTITY, "request-waiter"), { + kind: "unresolved", + retryAfterMs: 98, + requestExpiresAt: 1_199, + }); + assert.deepEqual(await store.claim(IDENTITY, "request-fresh-tab"), { + kind: "unresolved", + retryAfterMs: 98, + requestExpiresAt: 1_199, + }); + now = 1_200; + assert.equal((await store.claim(IDENTITY, "request-fresh-tab")).kind, "claimed"); +}); + +test("custom provider paid test expiry cannot delete or complete a replacement owner", async () => { + let now = 10; + const backing = createMemoryMap(); + const store = createCustomProviderTestRunStore({ + backing, + advisoryLock: createMemoryAdvisoryLock(), + now: () => now, + ownerId: () => `owner-${now}`, + runningTtlMs: 10, + resultTtlMs: 10, + }); + + const stale = await store.claim(IDENTITY, "request-stale"); + now = 21; + const current = await store.claim(IDENTITY, "request-current"); + assert.ok(stale.kind === "claimed"); + assert.ok(current.kind === "claimed"); + assert.equal( + await store.complete(stale, { + status: 200, + body: { reply: "stale" }, + }), + false, + ); + assert.equal(await store.sweep(), 0); + now = 32; + assert.equal(await store.sweep(), 2); + assert.deepEqual(await backing.all(), [{ active: {}, receipts: {} }]); +}); + +test("custom provider paid test completion is one durable state transition", async () => { + const memory = createMemoryMap(); + let throwAfterPut = false; + const store = createCustomProviderTestRunStore({ + backing: { + ...memory, + async put(id, value) { + await memory.put(id, value); + if (throwAfterPut) throw new Error("response lost after commit"); + }, + }, + advisoryLock: createMemoryAdvisoryLock(), + }); + const claim = await store.claim(IDENTITY, "request-ambiguous"); + assert.ok(claim.kind === "claimed"); + throwAfterPut = true; + await assert.rejects( + store.complete(claim, { status: 200, body: { ok: true, reply: "ready" } }), + /response lost after commit/, + ); + throwAfterPut = false; + const replay = await store.claim(IDENTITY, "request-ambiguous"); + assert.equal(replay.kind, "replay"); +}); + +test("a paid-test owner claim recovers only when an ambiguous put committed", async () => { + const memory = createMemoryMap(); + let failure: "before" | "after" | null = "after"; + const store = createCustomProviderTestRunStore({ + backing: { + ...memory, + async put(id, value) { + if (failure === "before") throw new Error("failed before commit"); + await memory.put(id, value); + if (failure === "after") throw new Error("response lost after commit"); + }, + }, + advisoryLock: createMemoryAdvisoryLock(), + ownerId: () => "owner-ambiguous-claim", + }); + + assert.equal((await store.claim(IDENTITY, "request-committed")).kind, "claimed"); + failure = "before"; + await assert.rejects(store.claim({ ...IDENTITY, harnessId: "codex" }, "request-not-committed"), /before commit/); + failure = null; + assert.equal((await store.claim({ ...IDENTITY, harnessId: "codex" }, "request-not-committed")).kind, "claimed"); +}); + +test("a waiter claim recovers only when an ambiguous put committed", async () => { + const memory = createMemoryMap(); + let failure: "before" | "after" | null = null; + const store = createCustomProviderTestRunStore({ + backing: { + ...memory, + async put(id, value) { + if (failure === "before") throw new Error("failed before commit"); + await memory.put(id, value); + if (failure === "after") throw new Error("response lost after commit"); + }, + }, + advisoryLock: createMemoryAdvisoryLock(), + }); + const owner = await store.claim(IDENTITY, "request-owner"); + assert.ok(owner.kind === "claimed"); + failure = "before"; + await assert.rejects(store.claim(IDENTITY, "request-waiter-before"), /before commit/); + assert.equal(Object.keys((await memory.all())[0]?.receipts ?? {}).length, 1); + failure = null; + assert.equal((await store.claim(IDENTITY, "request-waiter-before")).kind, "running"); + failure = "after"; + assert.equal((await store.claim(IDENTITY, "request-waiter-after")).kind, "running"); + failure = null; + assert.equal(await store.complete(owner, { status: 200, body: { reply: "ready" } }), true); + assert.equal((await store.claim(IDENTITY, "request-waiter-before")).kind, "replay"); + assert.equal((await store.claim(IDENTITY, "request-waiter-after")).kind, "replay"); +}); diff --git a/test/custom-providers.test.ts b/test/custom-providers.test.ts index 6621b64d9..a6f581f89 100644 --- a/test/custom-providers.test.ts +++ b/test/custom-providers.test.ts @@ -5,15 +5,28 @@ import { resolveCustomModel, isCustomModelId, customModelCatalog, + customModelsJsonForProviders, + customProvidersVersion, 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 { + createCustomProviderStore, + CUSTOM_PROVIDER_INPUT_MODALITIES_SCHEMA, + CUSTOM_PROVIDER_WIRE_ID_SCHEMA, +} from "../src/model/custom-provider-store.ts"; +import { + modelSupportedByHarness, + modelServiceable, + registerOpenRouterCatalogModel, + resolveModel, + resolveStaticModel, +} from "../src/model/pi-models.ts"; import { createMemoryMap } from "../src/persistence/durable-map.ts"; import type { StoredCustomProvider } from "../src/model/custom-provider-store.ts"; +import { createMemoryAdvisoryLock } from "../src/persistence/advisory-lock.ts"; -afterEach(() => setCustomProviders([])); +afterEach(() => setCustomProviders([], [])); const GATEWAY = { id: "acme-gateway", @@ -34,6 +47,34 @@ test("a registered custom model resolves with the provider's protocol and base U assert.equal(model.cost.input, 2); }); +test("custom image input capability reaches Pi runtime and models JSON", () => { + const provider = { + ...GATEWAY, + models: [{ id: "vision-model", inputModalities: ["text", "image"] as ("text" | "image")[] }], + }; + setCustomProviders([provider]); + assert.deepEqual(resolveCustomModel("vision-model")?.input, ["text", "image"]); + assert.deepEqual(customModelsJsonForProviders([provider]), { + providers: { + "acme-gateway": { + name: "Acme Gateway", + baseUrl: "https://llm.acme.internal/v1", + api: "openai-completions", + models: [ + { + id: "vision-model", + name: "vision-model", + contextWindow: 128_000, + maxTokens: 8_192, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ], + }, + }, + }); +}); + test("anthropic-protocol providers produce anthropic-messages models with defaults", () => { setCustomProviders([ { @@ -51,6 +92,34 @@ test("anthropic-protocol providers produce anthropic-messages models with defaul assert.equal(model.cost.input, 0); }); +test("OpenAI Responses providers are available to all three enterprise harnesses", () => { + setCustomProviders([ + { + ...GATEWAY, + protocol: "openai-responses", + models: [{ id: "responses-model" }], + }, + ]); + assert.equal(resolveCustomModel("responses-model")?.api, "openai-responses"); + assert.equal(modelSupportedByHarness("responses-model", "pi"), true); + assert.equal(modelSupportedByHarness("responses-model", "opencode"), true); + assert.equal(modelSupportedByHarness("responses-model", "codex"), true); +}); + +test("a custom selection alias resolves to its upstream wire model id", () => { + setCustomProviders([ + { + ...GATEWAY, + protocol: "openai-responses", + models: [{ id: "gateway/gpt-5.6-luna", upstreamId: "gpt-5.6-luna", name: "GPT 5.6 Luna" }], + }, + ]); + const selected = resolveCustomModel("gateway/gpt-5.6-luna"); + assert.equal(selected?.id, "gateway/gpt-5.6-luna"); + assert.equal(selected?.wireId, "gpt-5.6-luna"); + assert.equal(resolveModel("gateway/gpt-5.6-luna")?.provider, "acme-gateway"); +}); + 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"); @@ -58,6 +127,41 @@ test("resolveModel falls back to custom models; built-ins shadow custom ids", () assert.equal(String(resolveModel("claude-opus-5")?.provider), "anthropic"); }); +test("custom model source binding survives dynamic arrival order and removal", () => { + const lateId = "vendor/future-model-late"; + setCustomProviders([{ ...GATEWAY, models: [{ id: lateId, name: "Private Late" }] }]); + assert.equal(resolveModel(lateId)?.provider, GATEWAY.id); + registerOpenRouterCatalogModel({ + id: lateId, + name: "Public Late", + contextWindow: 128_000, + maxTokens: 8_192, + input: ["text"], + reasoning: false, + cost: { input: 0, output: 0 }, + }); + assert.equal(resolveStaticModel(lateId), undefined); + assert.equal(resolveModel(lateId)?.provider, GATEWAY.id); + + const earlyId = "vendor/future-model-early"; + registerOpenRouterCatalogModel({ + id: earlyId, + name: "Public Early", + contextWindow: 128_000, + maxTokens: 8_192, + input: ["text"], + reasoning: false, + cost: { input: 0, output: 0 }, + }); + assert.equal(resolveModel(earlyId)?.provider, "openrouter"); + setCustomProviders([{ ...GATEWAY, models: [{ id: earlyId, name: "Private Early" }] }]); + assert.equal(resolveModel(earlyId)?.provider, GATEWAY.id); + setCustomProviders([]); + assert.equal(resolveModel(earlyId), undefined); + setCustomProviders([], [earlyId]); + assert.equal(resolveModel(earlyId), undefined); +}); + test("custom models are gated to pi and mock harnesses", () => { setCustomProviders([GATEWAY]); assert.equal(modelSupportedByHarness("acme-large", "pi"), true); @@ -87,6 +191,45 @@ test("spec validation rejects reserved ids, bad slugs, bad URLs, and empty model 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/); + assert.throws( + () => validateCustomProviderSpec({ ...GATEWAY, models: [{ id: "a", contextWindow: 0 }] }), + /positive integer/, + ); + assert.throws( + () => validateCustomProviderSpec({ ...GATEWAY, models: [{ id: "a", maxTokens: 1.5 }] }), + /positive integer/, + ); + assert.doesNotThrow(() => validateCustomProviderSpec({ ...GATEWAY, models: [{ id: "a", input: 0, output: 0.5 }] })); + assert.doesNotThrow(() => + validateCustomProviderSpec({ ...GATEWAY, models: [{ id: "a", inputModalities: ["text", "image"] }] }), + ); + assert.throws( + () => validateCustomProviderSpec({ ...GATEWAY, models: [{ id: "a", inputModalities: ["image"] }] }), + /inputModalities/, + ); +}); + +test("spec validation rejects malformed model entries and upstream ids", () => { + assert.throws( + () => validateCustomProviderSpec({ ...GATEWAY, models: [null as unknown as { id: string }] }), + /every model needs an id/, + ); + assert.throws( + () => + validateCustomProviderSpec({ + ...GATEWAY, + models: [{ id: "gateway-model", upstreamId: 42 as unknown as string }], + }), + /upstreamId must be a non-empty string/, + ); + assert.throws( + () => + validateCustomProviderSpec({ + ...GATEWAY, + models: [{ id: "gateway-model", upstreamId: " gpt-5.6-luna " }], + }), + /upstreamId must be a non-empty string/, + ); }); test("store round-trip: upsert encrypts the key, statuses never leak it, delete disables", async () => { @@ -102,6 +245,7 @@ test("store round-trip: upsert encrypts the key, statuses never leak it, delete const raw = await backing.get("acme-gateway"); assert.ok(raw?.apiKeyEnc); assert.equal(raw!.apiKeyEnc!.includes("sk-secret-123"), false); + assert.equal(raw?.revision, 1); assert.equal(await store.resolveKey("acme-gateway"), "sk-secret-123"); assert.deepEqual(await store.enabled(), [GATEWAY]); @@ -109,11 +253,13 @@ test("store round-trip: upsert encrypts the key, statuses never leak it, delete // 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.resolveActive("acme-gateway"))?.revision, 2); 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 backing.get("acme-gateway"))?.revision, 3); assert.equal(await store.delete("never-existed", "admin@example.com"), false); }); @@ -125,6 +271,261 @@ test("store validates specs on upsert", async () => { await assert.rejects(store.upsert({ ...GATEWAY, id: "anthropic" }, "k", "a@b.c"), /reserved/); }); +test("wire-id providers stay legacy-disabled and activate only when every live runtime is compatible", async () => { + let ready = false; + const backing = createMemoryMap(); + const store = createCustomProviderStore({ + backing, + keyMaterial: "wire-id-key-material", + runtimeSchemaReady: async () => ready, + }); + const aliased = { + ...GATEWAY, + protocol: "openai-responses" as const, + models: [{ id: "acme/gpt-luna", upstreamId: "gpt-5.6-luna" }], + }; + + await assert.rejects(store.upsert(aliased, "sk-alias", "admin@example.com"), /compatibility rollout/); + ready = true; + await store.upsert(aliased, "sk-alias", "admin@example.com"); + const raw = await backing.get(GATEWAY.id); + assert.equal(raw?.disabled, true); + assert.equal(raw?.compatibilityDisabled, true); + assert.equal(raw?.runtimeSchema, 1); + assert.deepEqual(await store.enabled(), [aliased]); + assert.equal((await store.statuses())[0]?.disabled, false); + + ready = false; + assert.deepEqual(await store.enabled(), []); + assert.equal(await store.resolveActive(GATEWAY.id), null); + assert.equal((await store.statuses())[0]?.disabled, true); + + ready = true; + assert.equal(await store.delete(GATEWAY.id, "admin@example.com"), true); + assert.deepEqual(await store.enabled(), []); + const deleted = await backing.get(GATEWAY.id); + assert.equal(deleted?.disabled, true); + assert.equal(deleted?.compatibilityDisabled, false); +}); + +test("compatibility runtime reads wire-id records while production writes stay closed", async () => { + const backing = createMemoryMap(); + const store = createCustomProviderStore({ + backing, + keyMaterial: "compatibility-release-key", + runtimeSchemaReady: async () => true, + runtimeSchemaWritable: async () => false, + }); + const aliased = { + ...GATEWAY, + protocol: "openai-responses" as const, + models: [{ id: "acme/gpt-luna", upstreamId: "gpt-5.6-luna" }], + }; + + await backing.put(GATEWAY.id, { + ...aliased, + disabled: true, + compatibilityDisabled: true, + runtimeSchema: 1, + updatedAt: Date.now(), + updatedBy: "newer-release", + }); + + assert.deepEqual(await store.enabled(), [aliased]); + const before = await backing.get(GATEWAY.id); + await assert.rejects(store.upsert(aliased, "sk-alias", "admin@example.com"), /compatibility rollout/); + await assert.rejects( + store.upsert( + { ...GATEWAY, protocol: "openai-responses", models: [{ id: "acme/gpt-luna" }] }, + "sk-legacy-edit", + "admin@example.com", + ), + /compatibility rollout/, + ); + assert.deepEqual(await backing.get(GATEWAY.id), before); +}); + +test("image-capable providers wait for schema 2 and cannot be rewritten by an older runtime", async () => { + const backing = createMemoryMap(); + const writableSchemas = new Set([CUSTOM_PROVIDER_WIRE_ID_SCHEMA]); + const store = createCustomProviderStore({ + backing, + keyMaterial: "input-modalities-release-key", + runtimeSchemaReady: async (schema) => writableSchemas.has(schema), + runtimeSchemaWritable: async (schema) => writableSchemas.has(schema), + }); + const imageProvider = { + ...GATEWAY, + protocol: "openai-responses" as const, + models: [ + { + id: "acme/gpt-luna", + upstreamId: "gpt-5.6-luna", + inputModalities: ["text", "image"] as ("text" | "image")[], + }, + ], + }; + + await assert.rejects(store.upsert(imageProvider, "sk-image", "admin@example.com"), /compatibility rollout/); + assert.equal(await backing.get(GATEWAY.id), null); + + writableSchemas.add(CUSTOM_PROVIDER_INPUT_MODALITIES_SCHEMA); + await store.upsert(imageProvider, "sk-image", "admin@example.com"); + assert.equal((await backing.get(GATEWAY.id))?.runtimeSchema, CUSTOM_PROVIDER_INPUT_MODALITIES_SCHEMA); + assert.deepEqual(await store.enabled(), [imageProvider]); + + writableSchemas.delete(CUSTOM_PROVIDER_INPUT_MODALITIES_SCHEMA); + const before = await backing.get(GATEWAY.id); + await assert.rejects( + store.upsert( + { ...GATEWAY, protocol: "openai-responses", models: [{ id: "acme/gpt-luna" }] }, + undefined, + "older-admin@example.com", + ), + /compatibility rollout/, + ); + assert.deepEqual(await backing.get(GATEWAY.id), before); + assert.deepEqual(await store.enabled(), []); +}); + +test("schema 2 remains protected after image capability is intentionally removed", async () => { + const backing = createMemoryMap(); + const store = createCustomProviderStore({ backing, keyMaterial: "schema-downgrade-key" }); + const imageProvider = { + ...GATEWAY, + models: [{ id: "acme/gpt-luna", inputModalities: ["text", "image"] as ("text" | "image")[] }], + }; + + await store.upsert(imageProvider, "sk-image", "admin@example.com"); + await store.upsert({ ...GATEWAY, models: [{ id: "acme/gpt-luna" }] }, undefined, "admin@example.com"); + + const raw = await backing.get(GATEWAY.id); + assert.equal(raw?.runtimeSchema, CUSTOM_PROVIDER_INPUT_MODALITIES_SCHEMA); + assert.equal(raw?.compatibilityDisabled, true); +}); + +test("historical image fields cannot bypass schema 2 through schema 1 or schema-less records", async () => { + const backing = createMemoryMap(); + const store = createCustomProviderStore({ + backing, + keyMaterial: "historical-image-field-key", + runtimeSchemaReady: async (schema) => schema === CUSTOM_PROVIDER_WIRE_ID_SCHEMA, + }); + const historicalModel = { + id: "historical-model", + upstreamId: "gpt-5.6-luna", + inputModalities: ["text", "image"] as ("text" | "image")[], + }; + await backing.put("schema-one", { + ...GATEWAY, + id: "schema-one", + models: [historicalModel], + runtimeSchema: CUSTOM_PROVIDER_WIRE_ID_SCHEMA, + compatibilityDisabled: true, + disabled: true, + updatedAt: Date.now(), + updatedBy: "older-release", + }); + await backing.put("schema-less", { + ...GATEWAY, + id: "schema-less", + models: [{ ...historicalModel, id: "historical-schema-less" }], + updatedAt: Date.now(), + updatedBy: "older-release", + }); + + const enabled = await store.enabled(); + assert.deepEqual( + enabled.map((provider) => provider.models[0]), + [ + { id: "historical-schema-less", upstreamId: "gpt-5.6-luna" }, + { id: "historical-model", upstreamId: "gpt-5.6-luna" }, + ], + ); + assert.deepEqual( + (await store.statuses()).map((provider) => provider.models[0]), + [ + { id: "historical-schema-less", upstreamId: "gpt-5.6-luna" }, + { id: "historical-model", upstreamId: "gpt-5.6-luna" }, + ], + ); +}); + +test("concurrent provider writes cannot claim the same model id", async () => { + const backing = createMemoryMap(); + const advisoryLock = createMemoryAdvisoryLock(); + const first = createCustomProviderStore({ backing, keyMaterial: "k", advisoryLock }); + const second = createCustomProviderStore({ backing, keyMaterial: "k", advisoryLock }); + const results = await Promise.allSettled([ + first.upsert({ ...GATEWAY, id: "first-gateway" }, "sk-first", "admin@example.com"), + second.upsert({ ...GATEWAY, id: "second-gateway" }, "sk-second", "admin@example.com"), + ]); + assert.equal(results.filter((result) => result.status === "fulfilled").length, 1); + assert.equal(results.filter((result) => result.status === "rejected").length, 1); + assert.equal((await first.enabled()).length, 1); +}); + +test("an unchanged custom provider snapshot does not invalidate runtime caches", () => { + setCustomProviders([GATEWAY]); + const version = customProvidersVersion(); + setCustomProviders([{ ...GATEWAY, models: [...GATEWAY.models] }]); + assert.equal(customProvidersVersion(), version); +}); + +test("provider model history preserves removed custom identities", async () => { + const backing = createMemoryMap(); + const store = createCustomProviderStore({ + backing, + keyMaterial: "history-key-material", + advisoryLock: createMemoryAdvisoryLock(), + }); + await store.upsert({ ...GATEWAY, models: [{ id: "gpt-private" }] }, "sk-private", "admin@example.com"); + await store.upsert({ ...GATEWAY, models: [{ id: "replacement-model" }] }, undefined, "admin@example.com"); + assert.equal(await store.knowsModel("gpt-private"), true); + assert.equal(await store.knowsModel("replacement-model"), true); + assert.equal(await store.knowsModel("gpt-future-native"), false); + await store.delete(GATEWAY.id, "admin@example.com"); + const restarted = createCustomProviderStore({ backing, keyMaterial: "history-key-material" }); + assert.deepEqual((await restarted.knownModelIds()).sort(), ["gpt-private", "replacement-model"]); + registerOpenRouterCatalogModel({ + id: "gpt-private", + name: "Public Impostor", + contextWindow: 128_000, + maxTokens: 8_192, + input: ["text"], + reasoning: false, + cost: { input: 0, output: 0 }, + }); + setCustomProviders(await restarted.enabled(), await restarted.knownModelIds()); + assert.equal(resolveModel("gpt-private"), undefined); +}); + +test("active provider resolution keeps the endpoint and key from one durable snapshot", async () => { + const inner = createMemoryMap(); + const seed = createCustomProviderStore({ backing: inner, keyMaterial: "snapshot-key-material" }); + await seed.upsert(GATEWAY, "sk-old", "admin@example.com"); + const oldRecord = await inner.get(GATEWAY.id); + await seed.upsert({ ...GATEWAY, baseUrl: "https://new.example.com/v1" }, "sk-new", "admin@example.com"); + const newRecord = await inner.get(GATEWAY.id); + assert.ok(oldRecord && newRecord); + await inner.put(GATEWAY.id, oldRecord); + let reads = 0; + const backing = { + ...inner, + async get(id: string) { + reads += 1; + const value = await inner.get(id); + await inner.put(id, newRecord); + return value; + }, + }; + const store = createCustomProviderStore({ backing, keyMaterial: "snapshot-key-material" }); + const active = await store.resolveActive(GATEWAY.id); + assert.equal(reads, 1); + assert.equal(active?.provider.baseUrl, GATEWAY.baseUrl); + assert.equal(active?.apiKey, "sk-old"); +}); + test("registered models surface in the catalog and vanish on unregister", () => { setCustomProviders([ { @@ -151,11 +552,12 @@ test("opencode modelRef routes slashed custom model ids to the registered provid name: "LiteLLM", protocol: "openai", baseUrl: "https://litellm.example.com/v1", - models: [{ id: "bedrock/claude-opus-5" }], + models: [{ id: "bedrock/claude-opus-5" }, { id: "litellm/gpt-luna", upstreamId: "gpt-5.6-luna" }], }, ]); try { assert.deepEqual(modelRef("bedrock/claude-opus-5"), { providerID: "litellm", modelID: "bedrock/claude-opus-5" }); + assert.deepEqual(modelRef("litellm/gpt-luna"), { providerID: "litellm", modelID: "gpt-5.6-luna" }); // built-in slash convention untouched assert.deepEqual(modelRef("openrouter/auto"), { providerID: "openrouter", modelID: "auto" }); } finally { diff --git a/test/deploy-drain.test.ts b/test/deploy-drain.test.ts index 866bb5556..e885824ac 100644 --- a/test/deploy-drain.test.ts +++ b/test/deploy-drain.test.ts @@ -2,6 +2,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; +import { createInsecureTestServer } from "../src/api/server.ts"; +import type { App } from "../src/api/app.ts"; import { createMemoryRunStore } from "../src/runs/memory-run-store.ts"; import { createMemorySessionStore } from "../src/sessions/memory-session-store.ts"; import { createWorker } from "../src/runs/worker.ts"; @@ -112,11 +114,17 @@ test("task protection tracks busyness: asserted while a turn runs, released once test("noteBusy asserts protection at the idle→busy edge without waiting for a sweep", async () => { const puts: Array<{ ProtectionEnabled: boolean }> = []; + let releaseSeen!: () => void; + const released = new Promise((resolve) => { + releaseSeen = resolve; + }); const server = createServer((req, res) => { let body = ""; req.on("data", (c: Buffer) => (body += String(c))); req.on("end", () => { - puts.push(JSON.parse(body) as (typeof puts)[number]); + const put = JSON.parse(body) as (typeof puts)[number]; + puts.push(put); + if (!put.ProtectionEnabled) releaseSeen(); res.writeHead(200, { "content-type": "application/json" }); res.end("{}"); }); @@ -129,16 +137,25 @@ test("noteBusy asserts protection at the idle→busy edge without waiting for a busy: () => true, sweepMs: 60_000, }); - drain.noteBusy(); - await sleep(50); - assert.deepEqual(puts.length && puts[0]?.ProtectionEnabled, true, "protection asserted immediately on claim"); - drain.noteBusy(); - await sleep(30); - assert.equal(puts.length, 1, "already-on is a no-op"); - drain.stop(); - await sleep(30); - assert.equal(puts.at(-1)?.ProtectionEnabled, false, "stop releases protection best-effort"); - server.close(); + try { + drain.noteBusy(); + await sleep(50); + assert.deepEqual(puts.length && puts[0]?.ProtectionEnabled, true, "protection asserted immediately on claim"); + drain.noteBusy(); + await sleep(30); + assert.equal(puts.length, 1, "already-on is a no-op"); + drain.stop(); + await Promise.race([ + released, + sleep(1_000).then(() => { + throw new Error("task protection release was not observed"); + }), + ]); + assert.equal(puts.at(-1)?.ProtectionEnabled, false, "stop releases protection best-effort"); + } finally { + drain.stop(); + await new Promise((resolve) => server.close(() => resolve())); + } }); test("a failing protection endpoint degrades silently and canClaim stays governed by supersession only", async () => { @@ -149,8 +166,68 @@ test("a failing protection endpoint degrades silently and canClaim stays governe busy: () => true, sweepMs: 10, }); + await drain.ready(); drain.start(); await sleep(50); assert.equal(drain.canClaim(), true); + assert.equal(drain.readyForTraffic(), true); drain.stop(); }); + +test("traffic readiness closes after a registry failure and reopens only after a successful heartbeat", async () => { + let failing = false; + const drain = createDrainController({ + registry: { + beat: async () => { + if (failing) throw new Error("registry unavailable"); + return false; + }, + }, + protection: null, + busy: () => false, + sweepMs: 10, + freshnessMs: 40, + }); + await drain.ready(); + assert.equal(drain.readyForTraffic(), true); + failing = true; + drain.start(); + await sleep(25); + assert.equal(drain.readyForTraffic(), false); + failing = false; + await sleep(25); + assert.equal(drain.readyForTraffic(), true); + drain.stop(); +}); + +test("traffic readiness expires when the registry heartbeat stops advancing", async () => { + const drain = createDrainController({ + registry: { beat: async () => false }, + protection: null, + busy: () => false, + freshnessMs: 10, + }); + await drain.ready(); + assert.equal(drain.readyForTraffic(), true); + await sleep(20); + assert.equal(drain.readyForTraffic(), false); + drain.stop(); +}); + +test("healthz reflects traffic readiness", async () => { + let ready = true; + const server = createInsecureTestServer({} as App, { readyForTraffic: () => ready }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + try { + let response = await fetch(`${base}/healthz`); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ok: true }); + ready = false; + response = await fetch(`${base}/healthz`); + assert.equal(response.status, 503); + assert.deepEqual(await response.json(), { ok: false }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +}); diff --git a/test/model-test-proxy.test.ts b/test/model-test-proxy.test.ts new file mode 100644 index 000000000..be9250d1b --- /dev/null +++ b/test/model-test-proxy.test.ts @@ -0,0 +1,243 @@ +import assert from "node:assert/strict"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { test } from "node:test"; +import { createModelTestProxy, type ModelTestProxyEvidence } from "../src/harness/model-test-proxy.ts"; + +function body(req: IncomingMessage): Promise> { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")))); + req.on("error", reject); + }); +} + +async function upstream( + handler: (req: IncomingMessage, res: ServerResponse) => Promise, +): Promise<{ baseUrl: string; close(): Promise }> { + const server = createServer((req, res) => void handler(req, res)); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + return { + baseUrl: `http://127.0.0.1:${address.port}`, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +async function streamEvidence(events: Record[]): Promise { + const server = await upstream(async (req, res) => { + await body(req); + res.writeHead(200, { "content-type": "text/event-stream" }); + res.end(`${events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("")}data: [DONE]\n\n`); + }); + const proxy = await createModelTestProxy(server.baseUrl, { + expectedModel: "gpt-5.6-luna", + maxOutputTokens: 128, + }); + try { + const response = await fetch(`${proxy.baseUrl}/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "gpt-5.6-luna", stream: true, max_tokens: 128 }), + }); + assert.equal(response.status, 200); + await response.arrayBuffer(); + return proxy.evidence(); + } finally { + await proxy.close(); + await server.close(); + } +} + +test("model test proxy caps and verifies one streaming Chat Completions request", async () => { + const calls: Record[] = []; + const server = await upstream(async (req, res) => { + calls.push(await body(req)); + res.writeHead(200, { "content-type": "text/event-stream" }); + res.write(`data: ${JSON.stringify({ model: "gpt-5.6-luna", choices: [{ delta: { content: "works" } }] })}\n\n`); + res.end( + `data: ${JSON.stringify({ model: "gpt-5.6-luna", choices: [{ delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 11, completion_tokens: 2, total_tokens: 13 } })}\n\ndata: [DONE]\n\n`, + ); + }); + const proxy = await createModelTestProxy(`${server.baseUrl}/v1`, { + expectedModel: "gpt-5.6-luna", + maxOutputTokens: 128, + }); + try { + const response = await fetch(`${proxy.baseUrl}/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "gpt-5.6-luna", + stream: true, + max_tokens: 500, + tools: [{ type: "function" }], + }), + }); + assert.equal(response.status, 200); + assert.match(await response.text(), /works/); + assert.deepEqual(proxy.evidence().usage, { + inputTokens: 11, + outputTokens: 2, + totalTokens: 13, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + }); + assert.equal(proxy.evidence().responseModel, "gpt-5.6-luna"); + assert.equal(proxy.evidence().streamed, true); + assert.equal(proxy.evidence().upstreamRequests, 1); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.max_tokens, 128); + assert.deepEqual(calls[0]?.stream_options, { include_usage: true }); + assert.equal("tools" in calls[0]!, false); + + const duplicate = await fetch(`${proxy.baseUrl}/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "gpt-5.6-luna" }), + }); + assert.equal(duplicate.status, 400); + assert.equal(calls.length, 1); + } finally { + await proxy.close(); + await server.close(); + } +}); + +test("model test proxy rejects unverifiable response models without a retry", async () => { + let calls = 0; + let received: Record = {}; + const server = await upstream(async (req, res) => { + calls += 1; + received = await body(req); + const response = JSON.stringify({ + id: "response-1", + model: "gpt-5.6-sol", + output: [], + usage: { input_tokens: 7, output_tokens: 1, total_tokens: 8 }, + }); + res.writeHead(200, { "content-type": "application/json" }); + res.end(response); + }); + const proxy = await createModelTestProxy(server.baseUrl, { + expectedModel: "gpt-5.6-luna", + maxOutputTokens: 128, + }); + try { + const response = await fetch(`${proxy.baseUrl}/responses`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "gpt-5.6-luna", max_output_tokens: 500 }), + }); + assert.equal(response.status, 200); + await response.arrayBuffer(); + assert.throws(() => proxy.evidence(), /did not match/); + assert.equal(calls, 1); + assert.equal(received.max_output_tokens, 128); + } finally { + await proxy.close(); + await server.close(); + } +}); + +test("model test proxy merges Anthropic streaming usage", async () => { + let received: Record = {}; + const server = await upstream(async (req, res) => { + received = await body(req); + res.writeHead(200, { "content-type": "text/event-stream" }); + res.write( + `data: ${JSON.stringify({ type: "message_start", message: { model: "gpt-5.6-luna", usage: { input_tokens: 2, output_tokens: 0, cache_read_input_tokens: 100, cache_creation_input_tokens: 7 } } })}\n\n`, + ); + res.write(`data: ${JSON.stringify({ type: "content_block_delta", delta: { text: "works" } })}\n\n`); + res.end(`data: ${JSON.stringify({ type: "message_delta", usage: { output_tokens: 3 } })}\n\n`); + }); + const proxy = await createModelTestProxy(server.baseUrl, { + expectedModel: "gpt-5.6-luna", + maxOutputTokens: 128, + }); + try { + const response = await fetch(`${proxy.baseUrl}/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "gpt-5.6-luna", max_tokens: 500, tools: [{ name: "unused" }] }), + }); + assert.equal(response.status, 200); + await response.arrayBuffer(); + assert.deepEqual(proxy.evidence().usage, { + inputTokens: 109, + outputTokens: 3, + totalTokens: 112, + cachedInputTokens: 100, + cacheCreationInputTokens: 7, + }); + assert.equal(received.max_tokens, 128); + assert.equal("tools" in received, false); + } finally { + await proxy.close(); + await server.close(); + } +}); + +test("model test proxy rejects conflicting model observations in either order", async (context) => { + for (const [first, last] of [ + ["gpt-5.6-sol", "gpt-5.6-luna"], + ["gpt-5.6-luna", "gpt-5.6-sol"], + ]) { + await context.test(`${first} then ${last}`, async () => { + await assert.rejects( + streamEvidence([ + { model: first, choices: [{ delta: { content: "works" } }] }, + { + model: last, + choices: [{ delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 11, completion_tokens: 2, total_tokens: 13 }, + }, + ]), + /did not match/, + ); + }); + } +}); + +test("model test proxy rejects missing or malformed usage evidence", async (context) => { + const variants: Array<[string, Record]> = [ + ["empty", {}], + ["missing output", { prompt_tokens: 11, total_tokens: 11 }], + ["negative", { prompt_tokens: -1, completion_tokens: 2, total_tokens: 1 }], + ["decimal", { prompt_tokens: 11.5, completion_tokens: 2, total_tokens: 13.5 }], + ["numeric string", { prompt_tokens: "11", completion_tokens: 2, total_tokens: 13 }], + ]; + for (const [name, usage] of variants) { + await context.test(name, async () => { + await assert.rejects( + streamEvidence([ + { model: "gpt-5.6-luna", choices: [{ delta: { content: "works" } }] }, + { model: "gpt-5.6-luna", choices: [{ delta: {}, finish_reason: "stop" }], usage }, + ]), + /did not match/, + ); + }); + } +}); + +test("model test proxy rejects decreasing usage that hides an over-cap observation", async () => { + await assert.rejects( + streamEvidence([ + { + model: "gpt-5.6-luna", + choices: [{ delta: { content: "works" } }], + usage: { prompt_tokens: 11, completion_tokens: 200, total_tokens: 211 }, + }, + { + model: "gpt-5.6-luna", + choices: [{ delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 11, completion_tokens: 2, total_tokens: 13 }, + }, + ]), + /did not match/, + ); +}); diff --git a/test/opencode-harness.test.ts b/test/opencode-harness.test.ts index 091039139..5ed04b2a6 100644 --- a/test/opencode-harness.test.ts +++ b/test/opencode-harness.test.ts @@ -1,12 +1,17 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { assistantFailure, createOpenCodeHarness, latestAssistantParts } from "../src/harness/opencode-harness.ts"; import type { OpencodeClient } from "@opencode-ai/sdk"; import type { HarnessLlmRequestRecord, HarnessTurnInput } from "../src/harness/harness.ts"; import type { ScopeId, Session, SessionEntry } from "../src/types.ts"; +import { setCustomProviders } from "../src/model/custom-providers.ts"; + +const realOpenCodeBinary = resolve(import.meta.dirname, "../node_modules/.bin/opencode"); function fakeSidecar(dir: string, name: string, handlers: string): string { const script = join(dir, `${name}.js`); @@ -121,6 +126,26 @@ test("OpenCode surfaces a provider error as a non-retryable failure, never a suc assert.equal(llmRows[0]!.step, 0); }); +test("OpenCode fails closed before startup when an explicitly requested model is unavailable", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "qm-opencode-unavailable-")); + let resolutions = 0; + const harness = createOpenCodeHarness({ + binaryPath: fakeSidecar(dir, "unavailable", promptHandlers(okAssistant)), + resolveCustomProviders: async () => { + resolutions += 1; + return []; + }, + }); + t.after(async () => { + await harness.turns.close?.(); + rmSync(dir, { recursive: true, force: true }); + }); + const input = turnInput([], []); + input.model = "removed-custom-model"; + await assert.rejects(harness.turns.runTurn(input), /does not support requested model/); + assert.equal(resolutions, 0); +}); + test("OpenCode records real usage, cost, and timings for each captured model call", async (t) => { const dir = mkdtempSync(join(tmpdir(), "qm-opencode-test-")); const harness = createOpenCodeHarness({ binaryPath: fakeSidecar(dir, "usage", promptHandlers(okAssistant)) }); @@ -158,7 +183,7 @@ test("OpenCode startup failure reports the sidecar's real output and honors the const noisy = join(dir, "noisy"); writeFileSync(noisy, `#!/bin/sh\necho "FATAL: missing libfoo" >&2\nexec sleep 30\n`); chmodSync(noisy, 0o755); - const noisyHarness = createOpenCodeHarness({ binaryPath: noisy, startupTimeoutMs: 400 }); + const noisyHarness = createOpenCodeHarness({ binaryPath: noisy, startupTimeoutMs: 1500 }); t.after(async () => noisyHarness.turns.close?.()); await assert.rejects(noisyHarness.turns.runTurn(turnInput([], [])), (error: Error) => { assert.match(error.message, /did not start within \d+s/); @@ -168,7 +193,7 @@ test("OpenCode startup failure reports the sidecar's real output and honors the const silent = join(dir, "silent"); writeFileSync(silent, `#!/bin/sh\nexec sleep 30\n`); chmodSync(silent, 0o755); - const silentHarness = createOpenCodeHarness({ binaryPath: silent, startupTimeoutMs: 400 }); + const silentHarness = createOpenCodeHarness({ binaryPath: silent, startupTimeoutMs: 1500 }); t.after(async () => silentHarness.turns.close?.()); await assert.rejects(silentHarness.turns.runTurn(turnInput([], [])), (error: Error) => { assert.match(error.message, /did not start within \d+s: \(no output\)/); @@ -319,6 +344,22 @@ test("custom providers materialize into the opencode config (enabled + provider }, apiKey: "sk-lite", }, + { + spec: { + id: "responses-gateway", + name: "Responses Gateway", + protocol: "openai-responses" as const, + baseUrl: "http://responses.internal/v1", + models: [ + { + id: "responses-gateway/gpt-luna", + upstreamId: "gpt-5.6-luna", + inputModalities: ["text", "image"], + }, + ], + }, + apiKey: "sk-responses", + }, ], }); const entries: SessionEntry[] = []; @@ -331,9 +372,187 @@ test("custom providers materialize into the opencode config (enabled + provider 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 } }); + assert.deepEqual(litellm.models["deepseek-chat"], { + name: "DeepSeek", + attachment: false, + modalities: { input: ["text"], output: ["text"] }, + limit: { context: 128000, output: 8192 }, + }); + assert.equal(config.provider["responses-gateway"].npm, "@ai-sdk/openai"); + assert.equal(config.provider["responses-gateway"].options.baseURL, "http://responses.internal/v1"); + assert.deepEqual(config.provider["responses-gateway"].models["gpt-5.6-luna"].limit, { + context: 128000, + output: 8192, + }); + assert.equal(config.provider["responses-gateway"].models["gpt-5.6-luna"].attachment, true); + assert.deepEqual(config.provider["responses-gateway"].models["gpt-5.6-luna"].modalities.input, ["text", "image"]); } finally { await harness.turns.close?.(); rmSync(dir, { recursive: true, force: true }); } }); + +test( + "the installed OpenCode sidecar completes a turn through a saved Responses provider", + { skip: existsSync(realOpenCodeBinary) ? false : "opencode-ai is not installed" }, + async (t) => { + const requests: Array<{ path: string; auth?: string; model?: string; maxOutputTokens?: number; image?: boolean }> = + []; + const upstream = createServer((req, res) => { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { + const payload = body ? (JSON.parse(body) as { model?: string; max_output_tokens?: number }) : {}; + requests.push({ + path: req.url ?? "", + auth: req.headers.authorization, + model: payload.model, + maxOutputTokens: payload.max_output_tokens, + image: body.includes("iVBORw0KGgo"), + }); + if (!req.url?.endsWith("/responses")) { + res.writeHead(404); + return res.end(); + } + const item = { + id: "msg_opencode_qa", + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "OPENCODE RESPONSES OK", annotations: [] }], + }; + const response = { + id: "resp_opencode_qa", + object: "response", + created_at: Math.floor(Date.now() / 1000), + status: "completed", + model: "gpt-5.6-luna", + output: [item], + usage: { + input_tokens: 5, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 4, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 9, + }, + }; + const events = [ + { type: "response.created", response: { ...response, status: "in_progress", output: [] } }, + { + type: "response.output_item.added", + output_index: 0, + item: { ...item, status: "in_progress", content: [] }, + }, + { + type: "response.content_part.added", + output_index: 0, + item_id: item.id, + content_index: 0, + part: { type: "output_text", text: "", annotations: [] }, + }, + { + type: "response.output_text.delta", + output_index: 0, + item_id: item.id, + content_index: 0, + delta: "OPENCODE RESPONSES OK", + }, + { + type: "response.output_text.done", + output_index: 0, + item_id: item.id, + content_index: 0, + text: "OPENCODE RESPONSES OK", + }, + { + type: "response.content_part.done", + output_index: 0, + item_id: item.id, + content_index: 0, + part: item.content[0], + }, + { type: "response.output_item.done", output_index: 0, item }, + { type: "response.completed", response }, + ]; + res.writeHead(200, { "content-type": "text/event-stream" }); + events.forEach((event, sequence_number) => + res.write(`event: ${event.type}\ndata: ${JSON.stringify({ ...event, sequence_number })}\n\n`), + ); + res.end(); + }); + }); + await new Promise((resolveListen) => upstream.listen(0, "127.0.0.1", resolveListen)); + const baseUrl = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}/v1`; + setCustomProviders([ + { + id: "responses-gateway", + name: "Responses Gateway", + protocol: "openai-responses", + baseUrl, + models: [ + { + id: "responses-gateway/gpt-luna", + upstreamId: "gpt-5.6-luna", + inputModalities: ["text", "image"], + }, + ], + }, + ]); + const harness = createOpenCodeHarness({ + binaryPath: realOpenCodeBinary, + turnWallClockMs: 60_000, + resolveCustomProviders: async () => [ + { + spec: { + id: "responses-gateway", + name: "Responses Gateway", + protocol: "openai-responses", + baseUrl, + models: [ + { + id: "responses-gateway/gpt-luna", + upstreamId: "gpt-5.6-luna", + inputModalities: ["text", "image"], + }, + ], + }, + apiKey: "sk-opencode-qa", + }, + ], + }); + t.after(async () => { + await harness.turns.close?.(); + upstream.close(); + setCustomProviders([], []); + }); + const llmRows: HarnessLlmRequestRecord[] = []; + const modelCalls: Array<{ model: string }> = []; + const input = turnInput([], llmRows); + input.session = { id: "real-opencode-responses" } as Session; + input.model = "responses-gateway/gpt-luna"; + input.readOnly = true; + input.images = [ + { + mimeType: "image/png", + dataBase64: readFileSync(resolve(import.meta.dirname, "live-slack/fixtures/taylor-selfie.png")).toString( + "base64", + ), + }, + ]; + input.recordModelCall = (record) => modelCalls.push(record); + const result = await harness.turns.runTurn(input); + assert.equal(result.reply, "OPENCODE RESPONSES OK"); + assert.deepEqual(requests, [ + { + path: "/v1/responses", + auth: "Bearer sk-opencode-qa", + model: "gpt-5.6-luna", + maxOutputTokens: 8192, + image: true, + }, + ]); + assert.equal(modelCalls[0]?.model, "responses-gateway/gpt-luna"); + assert.equal(llmRows[0]?.model, "responses-gateway/gpt-luna"); + assert.equal(llmRows[0]?.transport?.modelId, "responses-gateway/gpt-5.6-luna"); + }, +); diff --git a/test/persistence-init-retry.test.ts b/test/persistence-init-retry.test.ts index 92586e862..370354af5 100644 --- a/test/persistence-init-retry.test.ts +++ b/test/persistence-init-retry.test.ts @@ -65,6 +65,16 @@ test("pg pool: a failed init is retried with a fresh attempt (rejection not cach await pg.close(); }); +test("pg pool: an empty schema still validates the connection and retries failures", async () => { + const pg = createPgPool("postgres://127.0.0.1:9/nope", []); + const first = await pg.pool().catch((e: unknown) => e); + const second = await pg.pool().catch((e: unknown) => e); + assert.ok(first instanceof Error); + assert.ok(second instanceof Error); + assert.notEqual(first, second); + await pg.close(); +}); + test("pg pool: an idle-client 'error' is logged, not fatal", { skip }, async () => { const pg = createPgPool(URL!, ["SELECT 1"]); const pool = await pg.pool(); diff --git a/test/pi-harness-oneshot.test.ts b/test/pi-harness-oneshot.test.ts index cb8e28635..1aea2ab73 100644 --- a/test/pi-harness-oneshot.test.ts +++ b/test/pi-harness-oneshot.test.ts @@ -29,6 +29,8 @@ import { import { DEFAULT_AGENT_MODEL_ID, auxiliaryModelFor, getRequiredModel, resolveModel } from "../src/model/pi-models.ts"; import { reconstructMessagesFromHistory } from "../src/harness/replay.ts"; import type { SessionEntry } from "../src/types.ts"; +import type { HarnessLlmRequestRecord, HarnessTurnInput } from "../src/harness/harness.ts"; +import type { CustomProviderSpec } from "../src/model/custom-providers.ts"; import { testConfig } from "./support/test-config.ts"; function countTempDirs(prefix: string): number { @@ -268,6 +270,93 @@ test("oneShot completes an authenticated Pi 0.82 turn", async (t) => { assert.match(requestBody, /hello/); }); +test("Pi records a custom local model id while sending its upstream wire id", async (t) => { + const requests: Array<{ auth?: string; model?: string }> = []; + const server = createServer((request, response) => { + let body = ""; + request.on("data", (chunk) => (body += String(chunk))); + request.on("end", () => { + const payload = JSON.parse(body) as { model?: string }; + requests.push({ auth: request.headers.authorization, model: payload.model }); + const item = { + id: "msg_pi_alias", + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "PI ALIAS OK", annotations: [] }], + }; + const completed = { + id: "resp_pi_alias", + object: "response", + created_at: Math.floor(Date.now() / 1000), + status: "completed", + model: "gpt-5.6-luna", + output: [item], + usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 }, + }; + response.writeHead(200, { "content-type": "text/event-stream" }); + for (const event of [ + { type: "response.created", response: { ...completed, status: "in_progress", output: [] } }, + { type: "response.output_item.added", output_index: 0, item: { ...item, status: "in_progress", content: [] } }, + { type: "response.output_text.delta", output_index: 0, content_index: 0, delta: "PI ALIAS OK" }, + { type: "response.output_item.done", output_index: 0, item }, + { type: "response.completed", response: completed }, + ]) { + response.write(`data: ${JSON.stringify(event)}\n\n`); + } + response.write("data: [DONE]\n\n"); + response.end(); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + assert(address && typeof address !== "string"); + const provider: CustomProviderSpec = { + id: "gateway", + name: "Gateway", + protocol: "openai-responses", + baseUrl: `http://127.0.0.1:${address.port}/v1`, + models: [{ id: "gateway/gpt-luna", upstreamId: "gpt-5.6-luna" }], + }; + const harness = createPiHarness({ + turnWallClockMs: 10_000, + resolveProviderRuntime: async () => ({ keys: { gateway: "sk-pi-alias" }, customProviders: [provider] }), + }); + t.after(async () => { + await harness.turns.close?.(); + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + }); + const modelCalls: Array<{ model: string }> = []; + const llmRows: HarnessLlmRequestRecord[] = []; + let seq = 0; + const scope = "org:test" as HarnessTurnInput["scopeLabel"]; + const result = await harness.turns.runTurn({ + session: { id: "pi-alias" } as HarnessTurnInput["session"], + input: "reply briefly", + model: "gateway/gpt-luna", + systemPrompt: "be concise", + history: [], + tools: {} as HarnessTurnInput["tools"], + scopeLabel: scope, + orgScopeId: scope, + readOnly: true, + emit: async (entry) => ({ ...entry, sessionId: "pi-alias", seq: ++seq, parentSeq: null, createdAt: Date.now() }), + recordModelCall: (record) => modelCalls.push(record), + recordLlmRequest: async (record) => { + llmRows.push(record); + }, + }); + + assert.equal(result.reply, "PI ALIAS OK"); + assert.deepEqual(requests, [{ auth: "Bearer sk-pi-alias", model: "gpt-5.6-luna" }]); + assert.equal(modelCalls[0]?.model, "gateway/gpt-luna"); + assert.equal(llmRows[0]?.model, "gateway/gpt-luna"); + assert.equal(llmRows[0]?.transport?.modelId, "gpt-5.6-luna"); +}); + test("Pi assistant error messages fail the turn instead of becoming a blank reply", () => { const session = { getLastAssistantText: () => undefined, diff --git a/test/postgres-custom-provider-test-runs.test.ts b/test/postgres-custom-provider-test-runs.test.ts new file mode 100644 index 000000000..2c1c80cf9 --- /dev/null +++ b/test/postgres-custom-provider-test-runs.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; +import { createCustomProviderTestRunStore } from "../src/model/custom-provider-test-runs.ts"; +import { createPostgresAdvisoryLock } from "../src/persistence/advisory-lock.ts"; +import { createPostgresMapFactory } from "../src/persistence/durable-map.ts"; + +const URL = process.env.DATABASE_URL; +const skip = URL ? false : "set DATABASE_URL (a Postgres) to run the custom provider paid-test guard test"; +const TABLE = "custom_provider_test_runs_it"; + +async function clean(): Promise { + if (!URL) return; + const pg = (await import("pg")).default; + const pool = new pg.Pool({ connectionString: URL }); + await pool.query(`DROP TABLE IF EXISTS ${TABLE} CASCADE`); + await pool.query("DELETE FROM durable_map_versions WHERE tbl = $1", [TABLE]).catch(() => undefined); + await pool.end(); +} + +before(clean); +after(clean); + +test("Postgres admits one paid-test owner across runtime instances and replays its result", { skip }, async () => { + const firstFactory = createPostgresMapFactory(URL!); + const secondFactory = createPostgresMapFactory(URL!); + const first = createCustomProviderTestRunStore({ + backing: firstFactory.map(TABLE), + advisoryLock: createPostgresAdvisoryLock(firstFactory.advisoryPool), + durable: true, + }); + const second = createCustomProviderTestRunStore({ + backing: secondFactory.map(TABLE), + advisoryLock: createPostgresAdvisoryLock(secondFactory.advisoryPool), + durable: true, + }); + const identity = { + scopeId: "org:acme", + providerId: "gateway", + modelId: "luna", + harnessId: "codex", + providerRevision: 8, + rolloutFence: "epoch-2", + }; + try { + assert.equal(first.durable, true); + const claims = await Promise.all([ + first.claim(identity, "request-first"), + second.claim(identity, "request-second"), + ]); + assert.deepEqual(claims.map((claim) => claim.kind).sort(), ["claimed", "running"]); + const owner = claims.find((claim) => claim.kind === "claimed"); + assert.ok(owner?.kind === "claimed"); + const rolloutChanged = await second.claim( + { ...identity, providerRevision: 9, rolloutFence: "epoch-3" }, + "request-rollout-changed", + ); + assert.equal(rolloutChanged.kind, "running"); + if (rolloutChanged.kind === "running") assert.equal(rolloutChanged.replayExpected, false); + assert.equal(await first.complete(owner, { status: 200, body: { reply: "ready" } }), true); + const replay = await second.claim(identity, owner.requestId); + assert.equal(replay.kind, "replay"); + if (replay.kind === "replay") assert.deepEqual(replay.response.body, { reply: "ready" }); + const waiterRequestId = owner.requestId === "request-first" ? "request-second" : "request-first"; + assert.equal((await second.claim(identity, waiterRequestId)).kind, "replay"); + assert.equal( + (await second.claim({ ...identity, providerRevision: 9, rolloutFence: "epoch-3" }, owner.requestId)).kind, + "conflict", + ); + const next = await second.claim(identity, "request-next"); + assert.equal(next.kind, "claimed"); + const independent = await Promise.all( + Array.from({ length: 12 }, (_, index) => + first.claim({ ...identity, providerId: `gateway-${index}` }, `request-parallel-${index}`), + ), + ); + assert.ok(independent.every((claim) => claim.kind === "claimed")); + } finally { + await Promise.all([ + firstFactory.pool.close(), + firstFactory.advisoryPool.close(), + secondFactory.pool.close(), + secondFactory.advisoryPool.close(), + ]); + } +}); diff --git a/test/postgres-instance-registry.test.ts b/test/postgres-instance-registry.test.ts index a09a551d2..4c47025c2 100644 --- a/test/postgres-instance-registry.test.ts +++ b/test/postgres-instance-registry.test.ts @@ -19,11 +19,35 @@ test( { skip }, async () => { const pool = createPostgresMapFactory(URL!).pool; + const capable = createPostgresInstanceRegistry(pool, { + instanceId: "i-capable", + buildSha: "sha-capable", + startedAt: 500, + livenessMs: 200, + incompatibleGraceMs: 400, + capabilities: ["wire-id-v1"], + }); + assert.equal(await capable.allLiveSupport!("wire-id-v1"), false, "zero rows never prove rollout readiness"); + await capable.beat(); + const firstCapable = await capable.capabilitySnapshot!("wire-id-v1"); + assert.equal( + await capable.allLiveSupport!("wire-id-v1"), + true, + "the current capable instance must heartbeat first", + ); + await capable.beat(); + assert.equal( + (await capable.capabilitySnapshot!("wire-id-v1")).epoch, + firstCapable.epoch, + "routine heartbeats do not move the rollout fence", + ); + const old = createPostgresInstanceRegistry(pool, { instanceId: "i-old", buildSha: "sha-a", startedAt: 1000, livenessMs: 200, + incompatibleGraceMs: 400, }); assert.equal(await old.beat(), false, "alone: not superseded"); @@ -32,6 +56,7 @@ test( buildSha: "sha-a", startedAt: 2000, livenessMs: 200, + incompatibleGraceMs: 400, }); await peer.beat(); assert.equal(await old.beat(), false, "same-sha peer (scale-out) never drains"); @@ -41,12 +66,34 @@ test( buildSha: "sha-b", startedAt: 3000, livenessMs: 200, + incompatibleGraceMs: 400, + capabilities: ["wire-id-v1"], }); await next.beat(); + const mixed = await next.capabilitySnapshot!("wire-id-v1"); assert.equal(await old.beat(), true, "newer build live: superseded"); assert.equal(await next.beat(), false, "the newest build itself is not superseded"); + assert.equal(await next.allLiveSupport!("wire-id-v1"), false, "a live old runtime blocks the capability"); + assert.notEqual(mixed.epoch, firstCapable.epoch, "a new instance moves the rollout fence"); await new Promise((r) => setTimeout(r, 250)); assert.equal(await old.beat(), false, "the newer build's beats went stale (failed deploy): claiming resumes"); + assert.equal(await next.allLiveSupport!("wire-id-v1"), false, "the refreshed old runtime still blocks activation"); + await new Promise((r) => setTimeout(r, 250)); + await next.beat(); + assert.equal( + await next.allLiveSupport!("wire-id-v1"), + false, + "an incompatible runtime keeps blocking after normal liveness while traffic drains", + ); + await new Promise((r) => setTimeout(r, 200)); + await next.beat(); + assert.equal(await next.allLiveSupport!("wire-id-v1"), true, "only capable live runtimes allow activation"); + const readyAgain = await next.capabilitySnapshot!("wire-id-v1"); + await new Promise((r) => setTimeout(r, 250)); + await old.beat(); + const resumedOld = await next.capabilitySnapshot!("wire-id-v1"); + assert.equal(resumedOld.ready, false, "a resumed incompatible instance closes activation again"); + assert.notEqual(resumedOld.epoch, readyAgain.epoch, "a stale-to-live transition moves the rollout fence"); }, ); diff --git a/test/postgres-pg-pool-schema-lock.test.ts b/test/postgres-pg-pool-schema-lock.test.ts new file mode 100644 index 000000000..fd2a7ec04 --- /dev/null +++ b/test/postgres-pg-pool-schema-lock.test.ts @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; +import { createPgPool } from "../src/persistence/pg-pool.ts"; + +const URL = process.env.DATABASE_URL; +const skip = URL ? false : "set DATABASE_URL (a Postgres) to run the schema-lock test"; +const TABLE = "schema_lock_concurrent_index_it"; +const PEER_TABLE = "schema_lock_peer_it"; +const INDEX = "schema_lock_concurrent_index_it_value_idx"; +const LEGACY_TABLE = "schema_lock_legacy_waiter_it"; +const LEGACY_INDEX = "schema_lock_legacy_waiter_it_value_idx"; +const TIMEOUT_TABLE = "schema_lock_timeout_restore_it"; +const TIMEOUT_INDEX = "schema_lock_timeout_restore_it_value_idx"; + +async function clean(): Promise { + if (!URL) return; + const pg = (await import("pg")).default; + const pool = new pg.Pool({ connectionString: URL }); + await pool.query(`DROP TABLE IF EXISTS ${TABLE}, ${PEER_TABLE}, ${LEGACY_TABLE}, ${TIMEOUT_TABLE} CASCADE`); + await pool.end(); +} + +before(clean); +after(clean); + +test("schema lock waiters do not deadlock a concurrent index during parallel startup", { skip }, async () => { + const pg = (await import("pg")).default; + const observer = new pg.Pool({ connectionString: URL }); + const first = createPgPool(URL!, []); + const second = createPgPool(URL!, []); + await Promise.all([first.pool(), second.pool()]); + await observer.query(`CREATE TABLE ${TABLE} (id BIGSERIAL PRIMARY KEY, value TEXT NOT NULL)`); + const blocker = await observer.connect(); + try { + await blocker.query("BEGIN"); + await blocker.query(`INSERT INTO ${TABLE} (value) VALUES ('held')`); + const firstDdl = first.schema!(`CREATE INDEX CONCURRENTLY IF NOT EXISTS ${INDEX} ON ${TABLE} (value)`); + let indexStarted = false; + for (let attempt = 0; attempt < 100; attempt += 1) { + const active = await observer.query<{ active: string }>( + "SELECT count(*)::text AS active FROM pg_stat_activity WHERE query LIKE $1 AND state <> 'idle'", + [`CREATE INDEX CONCURRENTLY IF NOT EXISTS ${INDEX}%`], + ); + if (active.rows[0]?.active !== "0") { + indexStarted = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.equal(indexStarted, true); + const secondDdl = second.schema!(`CREATE TABLE IF NOT EXISTS ${PEER_TABLE} (id BIGINT PRIMARY KEY)`); + await new Promise((resolve) => setTimeout(resolve, 50)); + await blocker.query("COMMIT"); + await Promise.race([ + Promise.all([firstDdl, secondDdl]), + new Promise((_, reject) => { + const timer = setTimeout(() => reject(new Error("parallel schema startup timed out")), 5_000); + timer.unref(); + }), + ]); + const state = await observer.query<{ valid: boolean }>( + "SELECT indisvalid AS valid FROM pg_index WHERE indexrelid = to_regclass($1)", + [INDEX], + ); + assert.equal(state.rows[0]?.valid, true); + const peer = await observer.query<{ name: string | null }>("SELECT to_regclass($1)::text AS name", [PEER_TABLE]); + assert.equal(peer.rows[0]?.name, PEER_TABLE); + } finally { + await blocker.query("ROLLBACK").catch(() => undefined); + blocker.release(); + await Promise.all([first.close(), second.close(), observer.end()]); + } +}); + +test("concurrent index startup yields to a legacy blocking schema-lock waiter", { skip }, async () => { + const pg = (await import("pg")).default; + const observer = new pg.Pool({ connectionString: URL }); + const current = createPgPool(URL!, []); + await current.pool(); + await observer.query(`CREATE TABLE ${LEGACY_TABLE} (id BIGSERIAL PRIMARY KEY, value TEXT NOT NULL)`); + const blocker = await observer.connect(); + const legacy = await observer.connect(); + try { + await blocker.query("BEGIN"); + await blocker.query(`INSERT INTO ${LEGACY_TABLE} (value) VALUES ('held')`); + const statement = `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${LEGACY_INDEX} ON ${LEGACY_TABLE} (value)`; + const currentDdl = current.schema!(statement); + let indexStarted = false; + for (let attempt = 0; attempt < 100; attempt += 1) { + const active = await observer.query<{ active: string }>( + "SELECT count(*)::text AS active FROM pg_stat_activity WHERE query LIKE $1 AND state <> 'idle'", + [`CREATE INDEX CONCURRENTLY IF NOT EXISTS ${LEGACY_INDEX}%`], + ); + if (active.rows[0]?.active !== "0") { + indexStarted = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.equal(indexStarted, true); + const legacyDdl = (async () => { + await legacy.query("SELECT pg_advisory_lock(hashtext('agent-platform:schema-init'))"); + try { + const existing = await legacy.query( + "SELECT NOT indisvalid OR NOT indisready AS invalid FROM pg_index WHERE indexrelid = to_regclass($1)", + [LEGACY_INDEX], + ); + if (existing.rows[0]?.invalid) await legacy.query(`DROP INDEX CONCURRENTLY ${LEGACY_INDEX}`); + await legacy.query(statement); + } finally { + await legacy.query("SELECT pg_advisory_unlock(hashtext('agent-platform:schema-init'))"); + } + })(); + await new Promise((resolve) => setTimeout(resolve, 600)); + await blocker.query("COMMIT"); + await Promise.race([ + Promise.all([currentDdl, legacyDdl]), + new Promise((_, reject) => { + const timer = setTimeout(() => reject(new Error("mixed-version schema startup timed out")), 8_000); + timer.unref(); + }), + ]); + const state = await observer.query<{ valid: boolean }>( + "SELECT indisvalid AS valid FROM pg_index WHERE indexrelid = to_regclass($1)", + [LEGACY_INDEX], + ); + assert.equal(state.rows[0]?.valid, true); + } finally { + await blocker.query("ROLLBACK").catch(() => undefined); + await legacy.query("SELECT pg_advisory_unlock_all()").catch(() => undefined); + blocker.release(); + legacy.release(); + await Promise.all([current.close(), observer.end()]); + } +}); + +test("concurrent index startup restores the caller's session lock timeout", { skip }, async () => { + const pg = (await import("pg")).default; + const observer = new pg.Pool({ connectionString: URL }); + const current = createPgPool(URL!, []); + try { + await current.pool(); + await observer.query(`CREATE TABLE ${TIMEOUT_TABLE} (id BIGSERIAL PRIMARY KEY, value TEXT NOT NULL)`); + const client = await (await current.pool()).connect(); + await client.query("SET lock_timeout = '1234ms'"); + client.release(); + + await current.schema!(`CREATE INDEX CONCURRENTLY IF NOT EXISTS ${TIMEOUT_INDEX} ON ${TIMEOUT_TABLE} (value)`); + const timeout = await current.q("SHOW lock_timeout"); + assert.equal(timeout[0]?.lock_timeout, "1234ms"); + } finally { + await Promise.all([current.close(), observer.end()]); + } +}); diff --git a/test/release-build-id.test.ts b/test/release-build-id.test.ts new file mode 100644 index 000000000..79a7ba9bd --- /dev/null +++ b/test/release-build-id.test.ts @@ -0,0 +1,11 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; + +test("the signed core image receives the release commit as GIT_SHA", () => { + const workflow = readFileSync(new URL("../.github/workflows/release-package.yml", import.meta.url), "utf8"); + assert.match( + workflow, + /- name: core\s+dockerfile: deploy\/core\/Dockerfile\s+build-args: GIT_SHA=\$\{\{ github\.sha \}\}/, + ); +}); diff --git a/test/runtime-selection.test.ts b/test/runtime-selection.test.ts index 81d92c91a..60f03f72d 100644 --- a/test/runtime-selection.test.ts +++ b/test/runtime-selection.test.ts @@ -60,6 +60,16 @@ test("runtime resolution falls back to the first approved harness when deploymen }); }); +test("runtime resolution fails closed when a stored runtime is unavailable", () => { + const config = createMemoryConfigStore("default-org"); + config.setApprovedHarnesses(["pi", "codex"]); + config.setRuntimeSelection(ORG, { harnessId: "codex", modelId: "removed-custom-model" }); + assert.throws( + () => resolveRuntimeChoice(config, ORG, PERSONAL, { harnessId: "pi", modelId: "claude-opus-4-8" }), + /configured runtime codex\/removed-custom-model is unavailable/, + ); +}); + test("runtime resolution reads approvals and selections from shared durable state on every turn", async () => { const baseModels = createMemoryMap(); const approvedHarnesses = createMemoryMap(); diff --git a/test/runtime-start.test.ts b/test/runtime-start.test.ts new file mode 100644 index 000000000..9a33eecfb --- /dev/null +++ b/test/runtime-start.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { startRuntime } from "../src/runtime-start.ts"; +import type { Runtime } from "../src/wiring.ts"; + +test("traffic stays closed until the first instance heartbeat succeeds", async () => { + let releaseHeartbeat!: () => void; + const heartbeat = new Promise((resolve) => { + releaseHeartbeat = resolve; + }); + let started = false; + let listening = false; + const runtime: Runtime = { + ready: () => heartbeat, + readyForTraffic: () => false, + start: () => { + started = true; + }, + stop: async () => {}, + releaseInFlightRuns: async () => {}, + }; + const pending = startRuntime(runtime, () => { + listening = true; + }); + await Promise.resolve(); + assert.equal(started, false); + assert.equal(listening, false); + releaseHeartbeat(); + await pending; + assert.equal(started, true); + assert.equal(listening, true); +}); + +test("a failed first heartbeat keeps traffic closed", async () => { + let started = false; + let listening = false; + const runtime: Runtime = { + ready: async () => { + throw new Error("heartbeat unavailable"); + }, + readyForTraffic: () => false, + start: () => { + started = true; + }, + stop: async () => {}, + releaseInFlightRuns: async () => {}, + }; + await assert.rejects( + startRuntime(runtime, () => { + listening = true; + }), + /heartbeat unavailable/, + ); + assert.equal(started, false); + assert.equal(listening, false); +}); diff --git a/test/worker-reaper.test.ts b/test/worker-reaper.test.ts index fbd89ca69..124f01021 100644 --- a/test/worker-reaper.test.ts +++ b/test/worker-reaper.test.ts @@ -502,6 +502,7 @@ test("a worker pool drains a queued run end-to-end", async () => { }); test("runtime.start() leaves queued runs idle when background work is disabled", async () => { + let capabilityBeats = 0; const built = buildApp( testConfig({ dataDir: mkdtempSync(join(tmpdir(), "wr-")), @@ -510,6 +511,14 @@ test("runtime.start() leaves queued runs idle when background work is disabled", leaseTtlMs: 5_000, reaperIntervalMs: 60_000, }), + { + instanceRegistry: { + beat: async () => { + capabilityBeats += 1; + return false; + }, + }, + }, ); built.runtime.start(); try { @@ -524,6 +533,7 @@ test("runtime.start() leaves queued runs idle when background work is disabled", assert.ok(ack.runId); await sleep(50); assert.equal((await built.runs.get(ack.runId!))?.status, "pending"); + assert.ok(capabilityBeats > 0, "a web-only runtime still advertises its deployment capability heartbeat"); } finally { await built.runtime.stop(); }