((resolve) => {
+ settle = resolve;
+ });
+ const server = createServer((request, response) => {
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
+ if (url.pathname !== "/cb") {
+ response.writeHead(404, { "Content-Type": "text/plain" });
+ response.end("Not found");
+ return;
+ }
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
+ response.end(
+ "OrcaRouterConnected to OrcaRouter. You can close this tab.",
+ );
+
+ const receivedState = url.searchParams.get("state");
+ const receivedError = url.searchParams.get("error");
+ // The state check is the only thing between this listener and a code
+ // somebody else's page dropped on it. Compare it first, in constant time.
+ if (
+ receivedState === null ||
+ !constantTimeEqual(receivedState, session.attempt.state)
+ ) {
+ finish(
+ session,
+ "error",
+ "The OrcaRouter authorization response did not match this request. Nothing was stored.",
+ );
+ return;
+ }
+ if (receivedError) {
+ finish(
+ session,
+ "error",
+ `OrcaRouter authorization was declined (${receivedError}).`,
+ );
+ return;
+ }
+ const code = url.searchParams.get("code");
+ if (!code) {
+ finish(
+ session,
+ "error",
+ "The OrcaRouter authorization response did not include a code.",
+ );
+ return;
+ }
+ session.status = "awaiting_code";
+ settle?.(code);
+ });
+
+ await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", () => resolve());
+ });
+ const address = server.address();
+ const port = typeof address === "object" && address ? address.port : 0;
+ session.server = server;
+ session.callbackUrl = `http://127.0.0.1:${port}/cb`;
+
+ void codePromise.then((code) => {
+ if (session.status === "cancelled" || session.status === "expired")
+ return;
+ void completeExchange(session, code, origins);
+ });
+ } else {
+ session.status = "awaiting_code";
+ session.callbackUrl = "oob";
+ }
+
+ session.authorizeUrl = buildOrcaAuthorizeUrl({
+ origins,
+ callbackUrl: session.callbackUrl ?? "oob",
+ attempt,
+ appName: APP_NAME,
+ });
+
+ session.timer = setTimeout(() => {
+ if (session.status === "connected") return;
+ finish(
+ session,
+ "expired",
+ "OrcaRouter authorization timed out. Start again to get a new code.",
+ );
+ }, CONNECT_SESSION_TTL_MS);
+
+ const active = registry();
+ active.sessions.set(session.id, session);
+ active.activeSessionId = session.id;
+ return view(session);
+}
+
+/** Out-of-band completion: the user pasted the displayed code. */
+export async function submitOrcaConnectCode(
+ sessionId: string,
+ code: string,
+): Promise {
+ const session = registry().sessions.get(sessionId);
+ if (!session) return null;
+ if (session.status === "connected") return view(session);
+ if (session.status === "cancelled" || session.status === "expired") {
+ return view(session);
+ }
+ const trimmed = code?.trim() ?? "";
+ if (!trimmed) {
+ finish(session, "error", "Enter the code shown on the OrcaRouter page.");
+ return view(session);
+ }
+ const { origins } = getOrcaServerState();
+ await completeExchange(session, trimmed, origins);
+ return view(session);
+}
+
+/** Test seam: drop all sessions and listeners. */
+export function resetOrcaConnectSessions(): void {
+ const active = registry();
+ for (const session of active.sessions.values()) {
+ if (session.timer) clearTimeout(session.timer);
+ closeListener(session);
+ }
+ active.sessions.clear();
+ active.activeSessionId = null;
+}
+
+export interface OrcaModelDiscoveryResult {
+ readonly catalog: OrcaCatalog;
+ readonly maskedKey: string | null;
+}
+
+/**
+ * Discover models through the provider code path. Live discovery is
+ * authoritative; on failure the verified seed is returned and flagged
+ * degraded. A failed discovery never falls back to free-text entry.
+ */
+export async function discoverOrcaModels(): Promise {
+ const { store, origins } = getOrcaServerState();
+ const credential = store.getUsableCredential();
+ if (!credential) {
+ return {
+ catalog: orcarouterSeedCatalog(
+ "Connect to OrcaRouter to list the models your account can call.",
+ ),
+ maskedKey: null,
+ };
+ }
+ try {
+ const catalog = await fetchOrcaCatalog({
+ origins,
+ apiKey: credential.apiKey,
+ capability: "chat",
+ });
+ return { catalog, maskedKey: maskKey(credential.apiKey) };
+ } catch (error) {
+ if (
+ error instanceof OrcaAuthError ||
+ (typeof error === "object" &&
+ error !== null &&
+ (error as { status?: number }).status === 401)
+ ) {
+ store.recordInferenceStatus(
+ credential.accountId,
+ credential.generation,
+ 401,
+ );
+ }
+ return {
+ catalog: orcarouterSeedCatalog(
+ error instanceof Error
+ ? error.message
+ : "The OrcaRouter model catalog is unavailable.",
+ ),
+ maskedKey: maskKey(credential.apiKey),
+ };
+ }
+}
diff --git a/apps/web/lib/orcarouter/constants.ts b/apps/web/lib/orcarouter/constants.ts
new file mode 100644
index 00000000..5e293ef6
--- /dev/null
+++ b/apps/web/lib/orcarouter/constants.ts
@@ -0,0 +1,15 @@
+/**
+ * Client-safe OrcaRouter constants.
+ *
+ * Re-exported rather than imported from the package barrel so the browser
+ * bundle does not pull in the server-side catalog and credential modules.
+ */
+
+export const ORCAROUTER_KEY_DASHBOARD_URL =
+ "https://www.orcarouter.ai/console/authorized-apps";
+
+export const ORCAROUTER_PROVIDER_ID = "orcarouter";
+export const ORCAROUTER_AUTH_PROVIDER_ID = "orcarouter-oauth";
+export const ORCAROUTER_API_KEY_LABEL = "OrcaRouter - API";
+export const ORCAROUTER_AUTH_LABEL = "OrcaRouter - Auth";
+export const ORCAROUTER_DEFAULT_MODEL = "orcarouter/auto";
diff --git a/apps/web/lib/orcarouter/evidence.test.ts b/apps/web/lib/orcarouter/evidence.test.ts
new file mode 100644
index 00000000..8389687c
--- /dev/null
+++ b/apps/web/lib/orcarouter/evidence.test.ts
@@ -0,0 +1,116 @@
+// @vitest-environment node
+/**
+ * Contract test for the generated UI evidence.
+ *
+ * `scripts/orca-ui-evidence.py` captures `orca-evidence/manifest.json` and the
+ * three screenshots from the running playground. The directory is generated
+ * output (gitignored), so this suite skips when it is absent — which is the
+ * case in CI, where no browser run happens — and validates the capture whenever
+ * it is present.
+ *
+ * The assertions mirror the delivery contract for GUI evidence: the screenshots
+ * exist at a usable size with a matching digest, the manifest names the official
+ * chat catalog, and the recorded UI assertions actually hold.
+ */
+
+import { createHash } from "node:crypto";
+import { existsSync, readFileSync } from "node:fs";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+
+const ROOT = path.resolve(__dirname, "../../../..");
+const EVIDENCE = path.join(ROOT, "orca-evidence");
+const MANIFEST = path.join(EVIDENCE, "manifest.json");
+const CATALOG_URL = "https://api.orcarouter.ai/v1/models?capability=chat";
+
+const REQUIRED_KINDS = [
+ "auth-methods",
+ "text-model-dropdown",
+ "multimodal-model-dropdown",
+] as const;
+
+interface EvidenceArtifact {
+ kind: string;
+ path: string;
+ sha256: string;
+ ui: Record;
+}
+
+interface EvidenceManifest {
+ automation: {
+ framework: string;
+ passed: boolean;
+ catalog_source: string;
+ catalog_model_count: number;
+ image_model_count: number;
+ };
+ artifacts: EvidenceArtifact[];
+}
+
+/** PNG width/height straight from the IHDR chunk. */
+function pngSize(buffer: Buffer): { width: number; height: number } {
+ if (buffer.subarray(0, 8).toString("latin1") !== "\x89PNG\r\n\x1a\n") {
+ throw new Error("not a PNG");
+ }
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
+}
+
+describe.skipIf(!existsSync(MANIFEST))("generated OrcaRouter UI evidence", () => {
+ const manifest = existsSync(MANIFEST)
+ ? (JSON.parse(readFileSync(MANIFEST, "utf-8")) as EvidenceManifest)
+ : null;
+
+ it("is produced by a passing Playwright run against the official chat catalog", () => {
+ expect(manifest?.automation.framework).toBe("playwright");
+ expect(manifest?.automation.passed).toBe(true);
+ expect(manifest?.automation.catalog_source).toBe(CATALOG_URL);
+ expect(manifest?.automation.catalog_model_count).toBeGreaterThan(0);
+ expect(manifest?.automation.image_model_count).toBeGreaterThan(0);
+ expect(manifest?.automation.image_model_count).toBeLessThanOrEqual(
+ manifest!.automation.catalog_model_count,
+ );
+ });
+
+ it("ships every required screenshot with a matching digest", () => {
+ const kinds = (manifest?.artifacts ?? []).map((item) => item.kind);
+ expect(kinds).toEqual(expect.arrayContaining([...REQUIRED_KINDS]));
+ for (const artifact of manifest?.artifacts ?? []) {
+ const file = path.join(EVIDENCE, artifact.path);
+ expect(existsSync(file)).toBe(true);
+ const image = readFileSync(file);
+ const digest = createHash("sha256").update(image).digest("hex");
+ expect(artifact.sha256).toBe(digest);
+ const { width, height } = pngSize(image);
+ expect(width).toBeGreaterThanOrEqual(800);
+ expect(height).toBeGreaterThanOrEqual(450);
+ expect(image.byteLength).toBeGreaterThan(10_000);
+ }
+ });
+
+ it("records the authentication and dropdown assertions from the real UI", () => {
+ const byKind = new Map(
+ (manifest?.artifacts ?? []).map((item) => [item.kind, item]),
+ );
+ const auth = byKind.get("auth-methods")!.ui;
+ expect(auth.api_key_visible).toBe(true);
+ expect(auth.pkce_visible).toBe(true);
+ expect(auth.secret_masked).toBe(true);
+ expect(auth.controls_enabled).toBe(true);
+
+ for (const kind of ["text-model-dropdown", "multimodal-model-dropdown"]) {
+ const ui = byKind.get(kind)!.ui;
+ expect(ui.dropdown_open).toBe(true);
+ expect(ui.item_count).toBeGreaterThan(0);
+ expect(ui.opaque_background).toBe(true);
+ expect(ui.visible_border).toBe(true);
+ expect(Math.abs(Number(ui.trigger_panel_right_delta))).toBeLessThanOrEqual(2);
+ }
+ // The live catalog is the only source of the text dropdown.
+ expect(byKind.get("text-model-dropdown")!.ui.item_count).toBe(
+ manifest!.automation.catalog_model_count,
+ );
+ expect(byKind.get("multimodal-model-dropdown")!.ui.item_count).toBe(
+ manifest!.automation.image_model_count,
+ );
+ });
+});
diff --git a/apps/web/lib/orcarouter/live.test.ts b/apps/web/lib/orcarouter/live.test.ts
new file mode 100644
index 00000000..d9cffbe2
--- /dev/null
+++ b/apps/web/lib/orcarouter/live.test.ts
@@ -0,0 +1,100 @@
+// @vitest-environment node
+/**
+ * Live OrcaRouter check.
+ *
+ * Unlike the other suites, this one talks to the real gateway and only runs when
+ * ORCAROUTER_API_KEY is present, so it skips in a credential-free checkout. It
+ * goes through the code this integration adds rather than a bare HTTP call:
+ * `discoverOrcaModels` (the catalog path both API routes use),
+ * `createOrcarouterTransport` (the provider path), and `selectOrcaModels` for
+ * the capability and modality filters that build the dropdown options.
+ */
+
+import { describe, expect, it } from "vitest";
+import { generateText } from "ai";
+import { selectOrcaModels } from "@json-render/core";
+import { discoverOrcaModels } from "./connect-session";
+import { createOrcarouterTransport } from "./provider";
+import { getOrcaServerState } from "./server-store";
+
+const hasKey = Boolean(process.env.ORCAROUTER_API_KEY?.trim());
+
+describe.skipIf(!hasKey)("OrcaRouter live", () => {
+ it("discovers the live catalog through the project's discovery path", async () => {
+ const { catalog, maskedKey } = await discoverOrcaModels();
+
+ expect(catalog.source).toBe("live");
+ expect(catalog.models.length).toBeGreaterThan(0);
+ // The browser only ever receives the masked form.
+ expect(maskedKey).not.toContain(
+ process.env.ORCAROUTER_API_KEY!.trim().slice(8, 20),
+ );
+
+ const chat = selectOrcaModels(catalog, { capability: "chat" });
+ expect(chat.length).toBeGreaterThan(0);
+ for (const model of chat) {
+ // Vendor namespace is preserved verbatim.
+ expect(model.id).toContain("/");
+ }
+ });
+
+ it("filters the live catalog to models that declare image input", async () => {
+ const { catalog } = await discoverOrcaModels();
+ const chat = selectOrcaModels(catalog, { capability: "chat" });
+ const multimodal = selectOrcaModels(catalog, {
+ capability: "chat",
+ requiredInputModalities: ["image"],
+ });
+
+ for (const model of multimodal) {
+ expect(model.architecture?.inputModalities).toContain("image");
+ }
+ // The filter must actually remove models, never pass everything through.
+ expect(multimodal.length).toBeLessThanOrEqual(chat.length);
+ });
+
+ it("completes a real chat request through the project's transport", async () => {
+ const { catalog } = await discoverOrcaModels();
+ const chat = selectOrcaModels(catalog, { capability: "chat" });
+
+ // Prefer a small, known-good chat model; fall back to the rest of the live
+ // catalog so the check does not depend on one deployment's model list.
+ const preferred = [
+ "deepseek/deepseek-v4-flash",
+ "deepseek/deepseek-v4.1-flash",
+ ];
+ const candidates = [
+ ...preferred.filter((id) => chat.some((model) => model.id === id)),
+ ...chat.map((model) => model.id),
+ ].slice(0, 4);
+ expect(candidates.length).toBeGreaterThan(0);
+
+ let lastError: unknown = null;
+ let completed = false;
+ for (const target of candidates) {
+ try {
+ const { model, maskedKey } = createOrcarouterTransport(target);
+ expect(maskedKey).toContain("••••");
+ const result = await generateText({
+ model,
+ prompt: "Reply with the single word: ok",
+ maxOutputTokens: 256,
+ });
+ expect(result.text.trim().length).toBeGreaterThan(0);
+ expect(result.usage.totalTokens).toBeGreaterThan(0);
+ completed = true;
+ break;
+ } catch (error) {
+ lastError = error;
+ }
+ }
+ if (!completed) throw lastError;
+ }, 120_000);
+
+ it("keeps authorization and inference on separate origins", () => {
+ const { origins } = getOrcaServerState();
+ expect(origins.authBaseUrl).toBe("https://www.orcarouter.ai");
+ expect(origins.apiBaseUrl).toBe("https://api.orcarouter.ai");
+ expect(origins.apiBaseUrl).not.toContain("/auth");
+ });
+});
diff --git a/apps/web/lib/orcarouter/provider.ts b/apps/web/lib/orcarouter/provider.ts
new file mode 100644
index 00000000..823cf893
--- /dev/null
+++ b/apps/web/lib/orcarouter/provider.ts
@@ -0,0 +1,89 @@
+/**
+ * The OrcaRouter provider entry for the json-render web app.
+ *
+ * OrcaRouter is an OpenAI-compatible AI gateway that routes many providers
+ * behind one endpoint. It appears as a named provider with two explicit
+ * authentication choices: paste an `sk-orca-…` API key, or sign in with an
+ * OrcaRouter account (OAuth 2.0 + PKCE).
+ */
+
+import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
+import {
+ resolveOrcarouterOriginsFromEnv,
+ type OrcarouterOrigins,
+} from "@json-render/core";
+import { getOrcaServerState } from "./server-store";
+
+export const ORCAROUTER_PROVIDER_ID = "orcarouter";
+export const ORCAROUTER_API_KEY_PROVIDER_ID = "orcarouter";
+export const ORCAROUTER_AUTH_PROVIDER_ID = "orcarouter-oauth";
+
+export const ORCAROUTER_API_KEY_LABEL = "OrcaRouter - API";
+export const ORCAROUTER_AUTH_LABEL = "OrcaRouter - Auth";
+
+export const ORCAROUTER_DEFAULT_MODEL = "orcarouter/auto";
+
+/** The AI SDK model string for a provider selection. */
+export function toModelString(provider: string, model: string): string {
+ return `${provider}/${model}`;
+}
+
+/** Whether a provider selection should route through OrcaRouter. */
+export function isOrcarouterProvider(provider: string): boolean {
+ return (
+ provider === ORCAROUTER_API_KEY_PROVIDER_ID ||
+ provider === ORCAROUTER_AUTH_PROVIDER_ID
+ );
+}
+
+export interface OrcarouterTransport {
+ /** The AI SDK model instance to hand to `streamText`. */
+ model: ReturnType["chatModel"]>;
+ origins: OrcarouterOrigins;
+ /** Masked, safe to display. */
+ maskedKey: string;
+ /** Which entry point supplied the credential. */
+ method: string;
+}
+
+/**
+ * Build the OrcaRouter transport from the active credential. Throws when no
+ * usable credential exists, so callers can return an actionable message instead
+ * of sending an unauthenticated request.
+ */
+export function createOrcarouterTransport(
+ modelId: string,
+): OrcarouterTransport {
+ const { store, origins } = getOrcaServerState();
+ const credential = store.getUsableCredential();
+ if (!credential) {
+ throw new OrcaRouterNotConnectedError(
+ store.needsReauth
+ ? (store.getCredential()?.reauthReason ??
+ "Your OrcaRouter key is no longer accepted. Reconnect to issue a new one.")
+ : "Connect to OrcaRouter with an API key or your OrcaRouter account first.",
+ );
+ }
+ const provider = createOpenAICompatible({
+ name: ORCAROUTER_PROVIDER_ID,
+ baseURL: `${origins.apiBaseUrl}/v1`,
+ apiKey: credential.apiKey,
+ });
+ return {
+ model: provider.chatModel(modelId),
+ origins,
+ maskedKey: `${credential.apiKey.slice(0, 8)}••••${credential.apiKey.slice(-4)}`,
+ method: credential.method,
+ };
+}
+
+export class OrcaRouterNotConnectedError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "OrcaRouterNotConnectedError";
+ }
+}
+
+export function orcarouterOrigins(): OrcarouterOrigins {
+ return resolveOrcarouterOriginsFromEnv();
+}
diff --git a/apps/web/lib/orcarouter/server-store.test.ts b/apps/web/lib/orcarouter/server-store.test.ts
new file mode 100644
index 00000000..c08bf8b7
--- /dev/null
+++ b/apps/web/lib/orcarouter/server-store.test.ts
@@ -0,0 +1,211 @@
+// @vitest-environment node
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import {
+ clearOrcaCredential,
+ getOrcaServerState,
+ resetOrcaServerState,
+ saveOrcaCredential,
+ upsertEnvLine,
+} from "./server-store";
+
+/** A per-suite env file so parallel suites never share project state. */
+const ENV_FILE = join(
+ process.cwd(),
+ `.orca-test-env-${process.pid}-server-store-test-ts`,
+);
+process.env.ORCAROUTER_ENV_FILE = ENV_FILE;
+const originalEnv = process.env.ORCAROUTER_API_KEY;
+
+async function restoreEnvFile(previous: string | null) {
+ if (previous === null) {
+ await rm(ENV_FILE, { force: true, recursive: true });
+ } else {
+ await writeFile(ENV_FILE, previous, "utf-8");
+ }
+}
+
+describe("upsertEnvLine", () => {
+ it("inserts a new key into an existing file", () => {
+ const result = upsertEnvLine(
+ "A=1\nB=2\n",
+ "ORCAROUTER_API_KEY",
+ "sk-orca-x",
+ );
+ expect(result).toBe("A=1\nB=2\nORCAROUTER_API_KEY=sk-orca-x\n");
+ });
+
+ it("replaces an existing key in place without touching other lines", () => {
+ const result = upsertEnvLine(
+ "# comment\nA=1\nORCAROUTER_API_KEY=old\nB=2\n",
+ "ORCAROUTER_API_KEY",
+ "new",
+ );
+ expect(result).toBe("# comment\nA=1\nORCAROUTER_API_KEY=new\nB=2\n");
+ });
+
+ it("removes the key and collapses duplicates", () => {
+ const result = upsertEnvLine(
+ "A=1\nORCAROUTER_API_KEY=one\nORCAROUTER_API_KEY=two\nB=2\n",
+ "ORCAROUTER_API_KEY",
+ null,
+ );
+ expect(result).not.toContain("ORCAROUTER_API_KEY");
+ expect(result).toContain("A=1");
+ expect(result).toContain("B=2");
+ });
+
+ it("does not match a key that merely shares a prefix", () => {
+ const result = upsertEnvLine(
+ "ORCAROUTER_API_KEY_BACKUP=keep\n",
+ "ORCAROUTER_API_KEY",
+ "new",
+ );
+ expect(result).toContain("ORCAROUTER_API_KEY_BACKUP=keep");
+ expect(result).toContain("ORCAROUTER_API_KEY=new");
+ });
+
+ it("creates a single line for an empty file", () => {
+ expect(upsertEnvLine("", "ORCAROUTER_API_KEY", "sk-orca-x")).toBe(
+ "ORCAROUTER_API_KEY=sk-orca-x\n",
+ );
+ });
+});
+
+describe("server credential store", () => {
+ let previousEnvFile: string | null = null;
+
+ beforeEach(async () => {
+ try {
+ previousEnvFile = await readFile(ENV_FILE, "utf-8");
+ } catch {
+ previousEnvFile = null;
+ }
+ delete process.env.ORCAROUTER_API_KEY;
+ resetOrcaServerState();
+ });
+
+ afterEach(async () => {
+ resetOrcaServerState();
+ await restoreEnvFile(previousEnvFile);
+ if (originalEnv === undefined) delete process.env.ORCAROUTER_API_KEY;
+ else process.env.ORCAROUTER_API_KEY = originalEnv;
+ });
+
+ it("starts disconnected when no key is configured", () => {
+ const state = getOrcaServerState();
+ expect(state.store.isConnected()).toBe(false);
+ expect(state.origins.authBaseUrl).toBe("https://www.orcarouter.ai");
+ expect(state.origins.apiBaseUrl).toBe("https://api.orcarouter.ai");
+ });
+
+ it("adopts an env-configured key without starting a login", () => {
+ process.env.ORCAROUTER_API_KEY = "sk-orca-from-env";
+ resetOrcaServerState();
+ const credential = getOrcaServerState().store.getCredential();
+ expect(credential?.apiKey).toBe("sk-orca-from-env");
+ expect(credential?.method).toBe("api_key");
+ });
+
+ it("persists a PKCE-issued credential with the project's env mechanism", async () => {
+ const status = await saveOrcaCredential({
+ apiKey: "sk-orca-from-pkce",
+ method: "orcarouter-oauth",
+ grantedScope: "api",
+ accountId: "u-1",
+ });
+ expect(status.persisted).toBe(true);
+ expect(status.location).toBe(ENV_FILE);
+ const written = await readFile(ENV_FILE, "utf-8");
+ expect(written).toContain("ORCAROUTER_API_KEY=sk-orca-from-pkce");
+ expect(getOrcaServerState().store.getCredential()?.method).toBe(
+ "orcarouter-oauth",
+ );
+ });
+
+ it("keeps a previously configured unrelated key intact when saving", async () => {
+ await writeFile(ENV_FILE, "AI_GATEWAY_API_KEY=keep-me\n", "utf-8");
+ await saveOrcaCredential({
+ apiKey: "sk-orca-new",
+ method: "api_key",
+ grantedScope: null,
+ accountId: null,
+ });
+ const written = await readFile(ENV_FILE, "utf-8");
+ expect(written).toContain("AI_GATEWAY_API_KEY=keep-me");
+ expect(written).toContain("ORCAROUTER_API_KEY=sk-orca-new");
+ });
+
+ it("clears both memory and the env file on disconnect", async () => {
+ await saveOrcaCredential({
+ apiKey: "sk-orca-doomed",
+ method: "api_key",
+ grantedScope: null,
+ accountId: null,
+ });
+ await clearOrcaCredential();
+ expect(getOrcaServerState().store.getCredential()).toBeNull();
+ const written = await readFile(ENV_FILE, "utf-8").catch(() => "");
+ expect(written).not.toContain("sk-orca-doomed");
+ });
+
+ it("reuses the stored key across a restart instead of re-authorizing", async () => {
+ await saveOrcaCredential({
+ apiKey: "sk-orca-durable",
+ method: "orcarouter-oauth",
+ grantedScope: "api",
+ accountId: "u-1",
+ });
+ // Simulate a restart: the in-memory store is dropped, the file is not.
+ resetOrcaServerState();
+ process.env.ORCAROUTER_API_KEY = "sk-orca-durable";
+ resetOrcaServerState();
+ expect(getOrcaServerState().store.getCredential()?.apiKey).toBe(
+ "sk-orca-durable",
+ );
+ });
+
+ it("resolves overrides from the environment", () => {
+ process.env.ORCA_AUTH_BASE_URL = "https://auth.example";
+ process.env.ORCA_API_BASE_URL = "https://relay.example";
+ resetOrcaServerState();
+ const state = getOrcaServerState();
+ expect(state.origins.authBaseUrl).toBe("https://auth.example");
+ expect(state.origins.apiBaseUrl).toBe("https://relay.example");
+ delete process.env.ORCA_AUTH_BASE_URL;
+ delete process.env.ORCA_API_BASE_URL;
+ });
+
+ it("never reports the key through the persistence status", async () => {
+ const status = await saveOrcaCredential({
+ apiKey: "sk-orca-should-not-appear",
+ method: "api_key",
+ grantedScope: null,
+ accountId: null,
+ });
+ expect(JSON.stringify(status)).not.toContain("sk-orca-should-not-appear");
+ });
+
+ it("does not throw when the env file cannot be written", async () => {
+ // A directory at the env-file path makes the write fail without mocking
+ // ESM module internals.
+ await rm(ENV_FILE, { force: true, recursive: true });
+ await mkdir(ENV_FILE, { recursive: true });
+ try {
+ const status = await saveOrcaCredential({
+ apiKey: "sk-orca-readonly",
+ method: "api_key",
+ grantedScope: null,
+ accountId: null,
+ });
+ expect(status.persisted).toBe(false);
+ expect(status.problem).toBeTruthy();
+ expect(JSON.stringify(status)).not.toContain("sk-orca-readonly");
+ // The credential is still usable for this process.
+ expect(getOrcaServerState().store.isConnected()).toBe(true);
+ } finally {
+ await rm(ENV_FILE, { force: true, recursive: true });
+ }
+ });
+});
diff --git a/apps/web/lib/orcarouter/server-store.ts b/apps/web/lib/orcarouter/server-store.ts
new file mode 100644
index 00000000..746ed9c4
--- /dev/null
+++ b/apps/web/lib/orcarouter/server-store.ts
@@ -0,0 +1,155 @@
+/**
+ * Server-side OrcaRouter credential persistence.
+ *
+ * json-render keeps provider secrets in the Next.js env files it already uses
+ * (`apps/web/.env.local`, ignored by `.gitignore`). This module reuses that
+ * mechanism rather than introducing a second credential store: a key obtained
+ * from either entry point is written to the same place a hand-configured
+ * `ORCAROUTER_API_KEY` would live, so a restart reuses it instead of asking the
+ * user to authorize again.
+ *
+ * When the filesystem is read-only (a hosted deployment), the credential is
+ * kept for the lifetime of the process and persistence is reported as
+ * unavailable instead of failing the login.
+ */
+
+import { readFile, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import {
+ OrcaCredentialStore,
+ resolveOrcarouterOriginsFromEnv,
+ type OrcaCredentialResult,
+ type OrcarouterOrigins,
+} from "@json-render/core";
+
+const ENV_KEY = "ORCAROUTER_API_KEY";
+
+export interface OrcaPersistenceStatus {
+ readonly persisted: boolean;
+ /** Where the credential was written, relative to the app root. */
+ readonly location: string | null;
+ /** Why persistence was skipped. Never contains the key. */
+ readonly problem: string | null;
+}
+
+export interface OrcaServerState {
+ store: OrcaCredentialStore;
+ origins: OrcarouterOrigins;
+ persistence: OrcaPersistenceStatus;
+}
+
+/**
+ * The env file this app already uses for provider secrets. `ORCAROUTER_ENV_FILE`
+ * lets a deployment point at a writable location, and lets tests use a
+ * per-suite path instead of the shared project file.
+ */
+function appEnvFile(): string {
+ return (
+ process.env.ORCAROUTER_ENV_FILE?.trim() || join(process.cwd(), ".env.local")
+ );
+}
+
+/** Replace or insert one key, leaving every other line untouched. */
+export function upsertEnvLine(
+ source: string,
+ key: string,
+ value: string | null,
+): string {
+ const lines = source.split("\n");
+ const kept: string[] = [];
+ let replaced = false;
+ for (const line of lines) {
+ const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
+ if (match && match[1] === key) {
+ if (!replaced && value !== null) {
+ kept.push(`${key}=${value}`);
+ replaced = true;
+ }
+ continue;
+ }
+ kept.push(line);
+ }
+ if (value !== null && !replaced) {
+ const withoutTrailingBlanks = kept.join("\n").replace(/\n*$/, "");
+ return `${withoutTrailingBlanks}${withoutTrailingBlanks ? "\n" : ""}${key}=${value}\n`;
+ }
+ return kept.join("\n");
+}
+
+async function writeEnvKey(
+ value: string | null,
+): Promise {
+ const envFile = appEnvFile();
+ try {
+ let current = "";
+ try {
+ current = await readFile(envFile, "utf-8");
+ } catch {
+ current = "";
+ }
+ await writeFile(envFile, upsertEnvLine(current, ENV_KEY, value), {
+ encoding: "utf-8",
+ mode: 0o600,
+ });
+ return { persisted: true, location: envFile, problem: null };
+ } catch {
+ return {
+ persisted: false,
+ location: null,
+ problem:
+ "This deployment cannot write a local env file, so the key is only kept for the current process.",
+ };
+ }
+}
+
+function initialState(): OrcaServerState {
+ const fromEnv = process.env[ENV_KEY]?.trim();
+ const store = new OrcaCredentialStore();
+ if (fromEnv) {
+ store.setCredential({
+ apiKey: fromEnv,
+ method: "api_key",
+ grantedScope: null,
+ accountId: null,
+ });
+ }
+ return {
+ store,
+ origins: resolveOrcarouterOriginsFromEnv(),
+ persistence: { persisted: false, location: null, problem: null },
+ };
+}
+
+// Survives Next.js dev-server module reloads.
+const globalState = globalThis as unknown as {
+ __jsonRenderOrcaState?: OrcaServerState;
+};
+
+export function getOrcaServerState(): OrcaServerState {
+ if (!globalState.__jsonRenderOrcaState) {
+ globalState.__jsonRenderOrcaState = initialState();
+ }
+ return globalState.__jsonRenderOrcaState;
+}
+
+/** Install a credential from either entry point and persist it. */
+export async function saveOrcaCredential(
+ result: OrcaCredentialResult,
+): Promise {
+ const state = getOrcaServerState();
+ state.store.setCredential(result);
+ state.persistence = await writeEnvKey(result.apiKey);
+ return state.persistence;
+}
+
+/** Remove the credential from memory and from the env file. */
+export async function clearOrcaCredential(): Promise {
+ const state = getOrcaServerState();
+ state.store.clear();
+ state.persistence = await writeEnvKey(null);
+}
+
+/** Test seam: reset the process-wide state between cases. */
+export function resetOrcaServerState(): void {
+ globalState.__jsonRenderOrcaState = undefined;
+}
diff --git a/apps/web/lib/orcarouter/use-orca-connect.test.tsx b/apps/web/lib/orcarouter/use-orca-connect.test.tsx
new file mode 100644
index 00000000..7a8444cd
--- /dev/null
+++ b/apps/web/lib/orcarouter/use-orca-connect.test.tsx
@@ -0,0 +1,290 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
+import { useOrcaConnect } from "./use-orca-connect";
+
+interface SessionPayload {
+ sessionId: string;
+ flow: "loopback" | "oob";
+ status: string;
+ authorizeUrl: string;
+ callbackUrl: string | null;
+ expiresAt: string;
+ message: string | null;
+ maskedKey: string | null;
+}
+
+function session(overrides: Partial = {}): SessionPayload {
+ return {
+ sessionId: "session-1",
+ flow: "oob",
+ status: "awaiting_code",
+ authorizeUrl: "https://www.orcarouter.ai/auth?state=s&code_challenge=c",
+ callbackUrl: "oob",
+ expiresAt: new Date(Date.now() + 600_000).toISOString(),
+ message: null,
+ maskedKey: null,
+ ...overrides,
+ };
+}
+
+describe("useOrcaConnect", () => {
+ let fetchMock: ReturnType;
+
+ beforeEach(() => {
+ fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+ vi.stubGlobal("open", vi.fn());
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+ });
+
+ function respond(...payloads: Array<{ ok?: boolean; body?: unknown }>) {
+ for (const payload of payloads) {
+ fetchMock.mockResolvedValueOnce({
+ ok: payload.ok ?? true,
+ json: async () => payload.body ?? {},
+ });
+ }
+ }
+
+ function mount() {
+ return renderHook(() => useOrcaConnect());
+ }
+
+ function deleteCalls() {
+ return fetchMock.mock.calls.filter(
+ (call) => (call[1] as RequestInit | undefined)?.method === "DELETE",
+ );
+ }
+
+ it("starts idle with nothing busy", () => {
+ const { result } = mount();
+ expect(result.current.status).toBe("idle");
+ expect(result.current.busy).toBe(false);
+ expect(result.current.authorizeUrl).toBeNull();
+ });
+
+ it("starts a login, exposes the authorize URL and marks itself busy", async () => {
+ const { result } = mount();
+ respond({ body: session() }, { body: session() });
+ await act(async () => {
+ await result.current.start();
+ });
+ expect(result.current.busy).toBe(true);
+ expect(result.current.authorizeUrl).toContain(
+ "https://www.orcarouter.ai/auth",
+ );
+ expect(result.current.sessionId).toBe("session-1");
+ });
+
+ it("opens the browser for the loopback flow", async () => {
+ const openSpy = vi.fn();
+ vi.stubGlobal("open", openSpy);
+ const { result } = mount();
+ respond(
+ {
+ body: session({
+ flow: "loopback",
+ callbackUrl: "http://127.0.0.1:1/cb",
+ }),
+ },
+ { body: session({ flow: "loopback" }) },
+ );
+ await act(async () => {
+ await result.current.start();
+ });
+ expect(openSpy).toHaveBeenCalled();
+ });
+
+ it("cancels explicitly, releasing the busy state and the server lock", async () => {
+ const { result } = mount();
+ respond({ body: session() }, { body: session() });
+ await act(async () => {
+ await result.current.start();
+ });
+ await act(async () => {
+ result.current.cancel();
+ });
+ expect(result.current.status).toBe("idle");
+ expect(result.current.busy).toBe(false);
+ expect(result.current.authorizeUrl).toBeNull();
+ expect(deleteCalls()).toHaveLength(1);
+ expect(String(deleteCalls()[0]![0])).toContain("session-1");
+ });
+
+ it("releases the login when the authentication method is switched", async () => {
+ const { result } = mount();
+ respond({ body: session() }, { body: session() });
+ await act(async () => {
+ await result.current.start();
+ });
+ expect(result.current.flow).toBe("loopback");
+ await act(async () => {
+ result.current.setFlow("oob");
+ });
+ expect(result.current.flow).toBe("oob");
+ expect(result.current.busy).toBe(false);
+ expect(result.current.sessionId).toBeNull();
+ });
+
+ it("clears busy and the hint on pagehide without a remount, and a second login can start", async () => {
+ const { result } = mount();
+ respond({ body: session() }, { body: session() });
+ await act(async () => {
+ await result.current.start();
+ });
+ expect(result.current.busy).toBe(true);
+ expect(result.current.authorizeUrl).not.toBeNull();
+
+ // The browser enters the back-forward cache: no unmount happens.
+ await act(async () => {
+ window.dispatchEvent(new Event("pagehide"));
+ });
+
+ expect(result.current.busy).toBe(false);
+ expect(result.current.authorizeUrl).toBeNull();
+ expect(result.current.status).toBe("idle");
+
+ // The server cancellation is sent with keepalive, not only via the guard.
+ expect(
+ deleteCalls().some((call) => (call[1] as RequestInit).keepalive === true),
+ ).toBe(true);
+
+ // A second login must be possible without remounting the component.
+ respond(
+ { body: session({ sessionId: "session-2" }) },
+ { body: session({ sessionId: "session-2" }) },
+ );
+ await act(async () => {
+ await result.current.start();
+ });
+ expect(result.current.busy).toBe(true);
+ expect(result.current.sessionId).toBe("session-2");
+ });
+
+ it("ignores a late response from a superseded login generation", async () => {
+ const { result } = mount();
+ let resolveFirst: ((value: unknown) => void) | null = null;
+ fetchMock.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveFirst = resolve;
+ }),
+ );
+ const newer = {
+ ok: true,
+ json: async () =>
+ session({
+ sessionId: "session-new",
+ authorizeUrl: "https://www.orcarouter.ai/auth?new=1",
+ }),
+ };
+ fetchMock.mockResolvedValue(newer);
+
+ let firstStart: Promise = Promise.resolve();
+ await act(async () => {
+ firstStart = result.current.start();
+ });
+ await act(async () => {
+ await result.current.start();
+ });
+ const afterSecond = result.current.authorizeUrl;
+
+ await act(async () => {
+ resolveFirst!({
+ ok: true,
+ json: async () =>
+ session({
+ sessionId: "session-stale",
+ authorizeUrl: "https://www.orcarouter.ai/auth?stale=1",
+ }),
+ });
+ await firstStart;
+ });
+
+ // The stale response must not have overwritten the newer login.
+ expect(result.current.authorizeUrl).toBe(afterSecond);
+ expect(result.current.sessionId).toBe("session-new");
+ expect(result.current.authorizeUrl).not.toContain("stale=1");
+ });
+
+ it("surfaces a denial from the server as a terminal error and stops polling", async () => {
+ const { result } = mount();
+ respond(
+ { body: session() },
+ {
+ body: session({
+ status: "error",
+ message: "OrcaRouter authorization was declined (access_denied).",
+ }),
+ },
+ );
+ await act(async () => {
+ await result.current.start();
+ });
+ await waitFor(() => expect(result.current.status).toBe("error"));
+ expect(result.current.error).toContain("declined");
+ expect(result.current.busy).toBe(false);
+ expect(result.current.sessionId).toBeNull();
+ });
+
+ it("clears the login state on unmount", async () => {
+ const { result, unmount } = mount();
+ respond({ body: session() }, { body: session() });
+ await act(async () => {
+ await result.current.start();
+ });
+ unmount();
+ await waitFor(() => expect(deleteCalls().length).toBeGreaterThan(0));
+ });
+
+ it("reports a failed session read instead of hanging", async () => {
+ const { result } = mount();
+ respond({ body: session() }, { ok: false, body: {} });
+ await act(async () => {
+ await result.current.start();
+ });
+ await waitFor(() => expect(result.current.status).toBe("error"));
+ expect(result.current.error).toBeTruthy();
+ expect(result.current.busy).toBe(false);
+ });
+
+ it("submits an out-of-band code and records the masked key", async () => {
+ const { result } = mount();
+ respond({ body: session() }, { body: session() });
+ await act(async () => {
+ await result.current.start();
+ });
+ fetchMock.mockResolvedValueOnce({
+ ok: true,
+ json: async () =>
+ session({ status: "connected", maskedKey: "sk-orca-••••-key" }),
+ });
+ await act(async () => {
+ await result.current.submitCode("the-code");
+ });
+ expect(result.current.status).toBe("connected");
+ expect(result.current.maskedKey).toBe("sk-orca-••••-key");
+ expect(result.current.sessionId).toBeNull();
+ });
+
+ it("never stores the verifier or the raw key in the hook state", async () => {
+ const { result } = mount();
+ respond({ body: session() }, { body: session() });
+ await act(async () => {
+ await result.current.start();
+ });
+ const serialized = JSON.stringify({
+ authorizeUrl: result.current.authorizeUrl,
+ sessionId: result.current.sessionId,
+ error: result.current.error,
+ maskedKey: result.current.maskedKey,
+ });
+ expect(serialized).not.toContain("code_verifier");
+ expect(serialized).not.toContain("codeVerifier");
+ });
+});
diff --git a/apps/web/lib/orcarouter/use-orca-connect.ts b/apps/web/lib/orcarouter/use-orca-connect.ts
new file mode 100644
index 00000000..d795ed2d
--- /dev/null
+++ b/apps/web/lib/orcarouter/use-orca-connect.ts
@@ -0,0 +1,309 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+export type OrcaConnectFlow = "loopback" | "oob";
+
+export type OrcaConnectStatus =
+ | "idle"
+ | "starting"
+ | "pending"
+ | "awaiting_code"
+ | "exchanging"
+ | "connected"
+ | "error"
+ | "cancelled"
+ | "expired";
+
+export interface OrcaConnectSessionView {
+ sessionId: string;
+ flow: OrcaConnectFlow;
+ status: OrcaConnectStatus;
+ authorizeUrl: string;
+ callbackUrl: string | null;
+ expiresAt: string;
+ message: string | null;
+ maskedKey: string | null;
+}
+
+export interface UseOrcaConnectReturn {
+ status: OrcaConnectStatus;
+ /** Non-null while a login is in flight. */
+ busy: boolean;
+ /** The authorize URL to show when a browser does not open automatically. */
+ authorizeUrl: string | null;
+ /** Non-null for the out-of-band flow while the user must paste a code. */
+ sessionId: string | null;
+ error: string | null;
+ maskedKey: string | null;
+ flow: OrcaConnectFlow;
+ setFlow: (flow: OrcaConnectFlow) => void;
+ start: () => Promise;
+ submitCode: (code: string) => Promise;
+ cancel: () => void;
+ reset: () => void;
+}
+
+const POLL_INTERVAL_MS = 1000;
+
+async function cancelServerSession(sessionId: string, keepalive: boolean) {
+ const url = `/api/orcarouter/connect/session?sessionId=${encodeURIComponent(sessionId)}`;
+ try {
+ await fetch(url, { method: "DELETE", keepalive });
+ } catch {
+ // Cancellation is best effort; the server also expires sessions on a timer.
+ }
+}
+
+/**
+ * Drives the OrcaRouter PKCE login from the browser.
+ *
+ * The verifier and the loopback listener live on the server, so this hook only
+ * relays status. It owns the UI half of the login lock: every terminal path and
+ * every way of leaving the page must clear `busy` and the authorization hint.
+ */
+export function useOrcaConnect(): UseOrcaConnectReturn {
+ const [status, setStatus] = useState("idle");
+ const [authorizeUrl, setAuthorizeUrl] = useState(null);
+ const [sessionId, setSessionId] = useState(null);
+ const [error, setError] = useState(null);
+ const [maskedKey, setMaskedKey] = useState(null);
+ const [flow, setFlowState] = useState("loopback");
+
+ // Monotonic attempt id. Every async response must confirm it still belongs to
+ // the current generation before it touches state, so a late URL or success
+ // from a superseded login can never appear under a newer one.
+ const generationRef = useRef(0);
+ const sessionIdRef = useRef(null);
+ const pollTimerRef = useRef | null>(null);
+ const mountedRef = useRef(true);
+
+ const clearPoll = useCallback(() => {
+ if (pollTimerRef.current) {
+ clearTimeout(pollTimerRef.current);
+ pollTimerRef.current = null;
+ }
+ }, []);
+
+ /** Clear UI state without touching the server. */
+ const clearUi = useCallback(() => {
+ clearPoll();
+ setStatus("idle");
+ setAuthorizeUrl(null);
+ setSessionId(null);
+ sessionIdRef.current = null;
+ }, [clearPoll]);
+
+ const cancel = useCallback(() => {
+ generationRef.current += 1;
+ const pending = sessionIdRef.current;
+ clearUi();
+ setError(null);
+ if (pending) void cancelServerSession(pending, false);
+ }, [clearUi]);
+
+ const reset = useCallback(() => {
+ cancel();
+ setMaskedKey(null);
+ }, [cancel]);
+
+ const setFlow = useCallback(
+ (next: OrcaConnectFlow) => {
+ // Switching authentication method releases the in-flight login.
+ if (next !== flow) {
+ cancel();
+ setFlowState(next);
+ }
+ },
+ [cancel, flow],
+ );
+
+ const poll = useCallback(
+ (id: string, generation: number) => {
+ const tick = async () => {
+ if (generation !== generationRef.current) return;
+ let session: OrcaConnectSessionView;
+ try {
+ const response = await fetch(
+ `/api/orcarouter/connect/session?sessionId=${encodeURIComponent(id)}`,
+ { cache: "no-store" },
+ );
+ if (generation !== generationRef.current) return;
+ if (!response.ok) {
+ setStatus("error");
+ setError("The OrcaRouter login session could not be read.");
+ return;
+ }
+ session = (await response.json()) as OrcaConnectSessionView;
+ } catch {
+ if (generation !== generationRef.current) return;
+ setStatus("error");
+ setError("Lost contact with the OrcaRouter login session.");
+ return;
+ }
+ if (generation !== generationRef.current) return;
+
+ setStatus(session.status);
+ setAuthorizeUrl(session.authorizeUrl);
+ if (session.message)
+ setError(session.status === "connected" ? null : session.message);
+ if (session.maskedKey) setMaskedKey(session.maskedKey);
+
+ if (
+ session.status === "connected" ||
+ session.status === "error" ||
+ session.status === "cancelled" ||
+ session.status === "expired"
+ ) {
+ clearPoll();
+ sessionIdRef.current = null;
+ setSessionId(null);
+ return;
+ }
+ pollTimerRef.current = setTimeout(tick, POLL_INTERVAL_MS);
+ };
+ void tick();
+ },
+ [clearPoll],
+ );
+
+ const start = useCallback(async () => {
+ const generation = generationRef.current + 1;
+ generationRef.current = generation;
+ clearPoll();
+ setError(null);
+ setStatus("starting");
+ setAuthorizeUrl(null);
+
+ try {
+ const response = await fetch("/api/orcarouter/connect/session", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ flow }),
+ });
+ if (generation !== generationRef.current) return;
+ if (!response.ok) {
+ setStatus("error");
+ setError("The OrcaRouter login could not be started.");
+ return;
+ }
+ const session = (await response.json()) as OrcaConnectSessionView;
+ if (generation !== generationRef.current) {
+ // A newer attempt owns the UI now; release the one we just created.
+ void cancelServerSession(session.sessionId, false);
+ return;
+ }
+ sessionIdRef.current = session.sessionId;
+ setSessionId(session.sessionId);
+ setAuthorizeUrl(session.authorizeUrl);
+ setStatus(session.status);
+ if (session.flow === "loopback") {
+ // The server returns a loopback URL the local browser can open.
+ if (session.callbackUrl)
+ window.open(session.authorizeUrl, "_blank", "noopener");
+ }
+ poll(session.sessionId, generation);
+ } catch {
+ if (generation !== generationRef.current) return;
+ setStatus("error");
+ setError("The OrcaRouter login could not be started.");
+ }
+ }, [clearPoll, flow, poll]);
+
+ const submitCode = useCallback(
+ async (code: string) => {
+ const id = sessionIdRef.current;
+ if (!id) return;
+ const generation = generationRef.current;
+ setStatus("exchanging");
+ try {
+ const response = await fetch("/api/orcarouter/connect/session", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ sessionId: id, code }),
+ });
+ if (generation !== generationRef.current) return;
+ if (!response.ok) {
+ setStatus("error");
+ setError("The OrcaRouter authorization code could not be submitted.");
+ return;
+ }
+ const session = (await response.json()) as OrcaConnectSessionView;
+ if (generation !== generationRef.current) return;
+ setStatus(session.status);
+ if (session.status === "connected") {
+ clearPoll();
+ sessionIdRef.current = null;
+ setSessionId(null);
+ if (session.maskedKey) setMaskedKey(session.maskedKey);
+ setError(null);
+ return;
+ }
+ if (session.message) setError(session.message);
+ } catch {
+ if (generation !== generationRef.current) return;
+ setStatus("error");
+ setError("The OrcaRouter authorization code could not be submitted.");
+ }
+ },
+ [clearPoll],
+ );
+
+ // A page entering the back-forward cache is never unmounted, so a guarded
+ // `finally` would leave it permanently busy. Invalidate the generation and
+ // clear the busy flag and hint synchronously, then ask the server to cancel.
+ useEffect(() => {
+ const onPageHide = () => {
+ generationRef.current += 1;
+ clearPoll();
+ setStatus("idle");
+ setAuthorizeUrl(null);
+ setSessionId(null);
+ setError(null);
+ const pending = sessionIdRef.current;
+ sessionIdRef.current = null;
+ if (pending) {
+ const url = `/api/orcarouter/connect/session?sessionId=${encodeURIComponent(pending)}`;
+ try {
+ void fetch(url, { method: "DELETE", keepalive: true });
+ } catch {
+ // Best effort.
+ }
+ }
+ };
+ window.addEventListener("pagehide", onPageHide);
+ return () => window.removeEventListener("pagehide", onPageHide);
+ }, [clearPoll]);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ generationRef.current += 1;
+ if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
+ const pending = sessionIdRef.current;
+ sessionIdRef.current = null;
+ // Cancel the server work without writing component state on unmount.
+ if (pending) void cancelServerSession(pending, false);
+ };
+ }, []);
+
+ return {
+ status,
+ busy:
+ status === "starting" ||
+ status === "pending" ||
+ status === "awaiting_code" ||
+ status === "exchanging",
+ authorizeUrl,
+ sessionId,
+ error,
+ maskedKey,
+ flow,
+ setFlow,
+ start,
+ submitCode,
+ cancel,
+ reset,
+ };
+}
diff --git a/apps/web/lib/orcarouter/use-orca-models.test.tsx b/apps/web/lib/orcarouter/use-orca-models.test.tsx
new file mode 100644
index 00000000..d62415f1
--- /dev/null
+++ b/apps/web/lib/orcarouter/use-orca-models.test.tsx
@@ -0,0 +1,260 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
+import { useOrcaModels } from "./use-orca-models";
+
+/** A catalog response as the server route shapes it. */
+function response(overrides: Record = {}) {
+ return {
+ models: [
+ {
+ id: "vendor/text-only",
+ name: "Text Only",
+ contextLength: 1000,
+ maxCompletionTokens: null,
+ inputModalities: ["text"],
+ reasoningEfforts: null,
+ },
+ ],
+ source: "live",
+ degraded: false,
+ degradedReason: null,
+ fetchedAt: "2026-09-20T00:00:00.000Z",
+ capability: "chat",
+ requiredInputModalities: [],
+ maskedKey: "sk-orca-••••-key",
+ selectionStillValid: null,
+ ...overrides,
+ };
+}
+
+describe("useOrcaModels", () => {
+ let fetchMock: ReturnType;
+
+ beforeEach(() => {
+ fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ });
+
+ function mount(options: Parameters[0]) {
+ return renderHook(() => useOrcaModels(options));
+ }
+
+ function lastUrl(): URL {
+ return new URL(
+ String(fetchMock.mock.calls[fetchMock.mock.calls.length - 1]![0]),
+ "http://localhost",
+ );
+ }
+
+ it("requests the chat capability from the API when enabled", async () => {
+ fetchMock.mockResolvedValue({ ok: true, json: async () => response() });
+ const { result } = mount({ enabled: true, capability: "chat" });
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(lastUrl().pathname).toBe("/api/orcarouter/models");
+ expect(lastUrl().searchParams.get("capability")).toBe("chat");
+ expect(result.current.models.map((m) => m.id)).toEqual([
+ "vendor/text-only",
+ ]);
+ expect(result.current.source).toBe("live");
+ });
+
+ it("adds the attachment modalities to the request and re-fetches on change", async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: async () =>
+ response({
+ models: [
+ {
+ id: "vendor/vision",
+ name: "Vision",
+ contextLength: 1000,
+ maxCompletionTokens: null,
+ inputModalities: ["text", "image"],
+ reasoningEfforts: null,
+ },
+ ],
+ requiredInputModalities: ["image"],
+ }),
+ });
+ const { result, rerender } = mount({
+ enabled: true,
+ capability: "chat",
+ requiredInputModalities: [],
+ });
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(lastUrl().searchParams.get("input")).toBeNull();
+
+ rerender();
+ const { result: withImage } = mount({
+ enabled: true,
+ capability: "chat",
+ requiredInputModalities: ["image"],
+ });
+ await waitFor(() => expect(withImage.current.loading).toBe(false));
+ expect(lastUrl().searchParams.get("input")).toBe("image");
+ // Only the model that declares image input remains in the options.
+ expect(withImage.current.models.map((m) => m.id)).toEqual([
+ "vendor/vision",
+ ]);
+ });
+
+ it("sends the current selection so the server can invalidate it", async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: async () => response({ selectionStillValid: false }),
+ });
+ const { result } = mount({
+ enabled: true,
+ capability: "chat",
+ selected: "vendor/text-only",
+ });
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(lastUrl().searchParams.get("selected")).toBe("vendor/text-only");
+ expect(result.current.selectionInvalidated).toBe(true);
+ });
+
+ it("reports a degraded seed without degrading into free text", async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: async () =>
+ response({
+ models: [
+ {
+ id: "openai/gpt-5.5",
+ name: "GPT-5.5",
+ contextLength: 400000,
+ maxCompletionTokens: null,
+ inputModalities: ["text", "image"],
+ reasoningEfforts: ["low", "medium", "high", "xhigh"],
+ },
+ ],
+ source: "seed",
+ degraded: true,
+ degradedReason: "Could not reach the OrcaRouter model catalog.",
+ fetchedAt: null,
+ }),
+ });
+ const { result } = mount({ enabled: true, capability: "chat" });
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.degraded).toBe(true);
+ expect(result.current.source).toBe("seed");
+ expect(result.current.degradedReason).toBeTruthy();
+ expect(result.current.models.map((m) => m.id)).toEqual(["openai/gpt-5.5"]);
+ // The verified reasoning ladder survives the fallback.
+ expect(result.current.models[0]!.reasoningEfforts).toEqual([
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ ]);
+ });
+
+ it("surfaces a failure as an error with no options, never as free text", async () => {
+ fetchMock.mockResolvedValue({ ok: false, json: async () => ({}) });
+ const { result } = mount({ enabled: true, capability: "chat" });
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.error).toBeTruthy();
+ expect(result.current.models).toEqual([]);
+ });
+
+ it("surfaces a transport failure the same way", async () => {
+ fetchMock.mockRejectedValue(new Error("offline"));
+ const { result } = mount({ enabled: true, capability: "chat" });
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.error).toBeTruthy();
+ expect(result.current.models).toEqual([]);
+ });
+
+ it("fetches nothing when the provider is not OrcaRouter", async () => {
+ const { result } = mount({ enabled: false, capability: "chat" });
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(result.current.models).toEqual([]);
+ });
+
+ it("refresh re-reads the catalog", async () => {
+ fetchMock.mockResolvedValue({ ok: true, json: async () => response() });
+ const { result } = mount({ enabled: true, capability: "chat" });
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ const before = fetchMock.mock.calls.length;
+ await act(async () => {
+ result.current.refresh();
+ });
+ await waitFor(() =>
+ expect(fetchMock.mock.calls.length).toBeGreaterThan(before),
+ );
+ });
+
+ it("ignores a stale response for the same hook when the capability changes", async () => {
+ let resolveSlow: ((value: unknown) => void) | null = null;
+ fetchMock.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveSlow = resolve;
+ }),
+ );
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: async () =>
+ response({
+ capability: "embedding",
+ models: [
+ {
+ id: "vendor/embed",
+ name: null,
+ contextLength: null,
+ maxCompletionTokens: null,
+ inputModalities: [],
+ reasoningEfforts: null,
+ },
+ ],
+ }),
+ });
+
+ // One hook instance, whose capability changes while a request is in flight.
+ const { result, rerender } = renderHook(
+ (capability: "chat" | "embedding") =>
+ useOrcaModels({ enabled: true, capability }),
+ { initialProps: "chat" as "chat" | "embedding" },
+ );
+ rerender("embedding");
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.models.map((m) => m.id)).toEqual(["vendor/embed"]);
+
+ // The superseded chat response resolves late and must not overwrite it.
+ await act(async () => {
+ resolveSlow!({
+ ok: true,
+ json: async () =>
+ response({
+ models: [
+ {
+ id: "vendor/stale-chat",
+ name: null,
+ contextLength: null,
+ maxCompletionTokens: null,
+ inputModalities: ["text"],
+ reasoningEfforts: null,
+ },
+ ],
+ }),
+ });
+ });
+ expect(result.current.models.map((m) => m.id)).toEqual(["vendor/embed"]);
+ });
+
+ it("never places the raw key in the browser: only the masked form arrives", async () => {
+ fetchMock.mockResolvedValue({ ok: true, json: async () => response() });
+ const { result } = mount({ enabled: true, capability: "chat" });
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.maskedKey).toBe("sk-orca-••••-key");
+ // The request carries no Authorization header or key of its own.
+ const init = fetchMock.mock.calls[0]![1] as RequestInit | undefined;
+ expect(JSON.stringify(init ?? {})).not.toContain("sk-orca");
+ });
+});
diff --git a/apps/web/lib/orcarouter/use-orca-models.ts b/apps/web/lib/orcarouter/use-orca-models.ts
new file mode 100644
index 00000000..411de1bd
--- /dev/null
+++ b/apps/web/lib/orcarouter/use-orca-models.ts
@@ -0,0 +1,156 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+export interface OrcaModelOption {
+ id: string;
+ name: string | null;
+ contextLength: number | null;
+ maxCompletionTokens: number | null;
+ inputModalities: string[];
+ reasoningEfforts: string[] | null;
+}
+
+export interface OrcaModelsResponse {
+ models: OrcaModelOption[];
+ source: "live" | "seed";
+ degraded: boolean;
+ degradedReason: string | null;
+ fetchedAt: string | null;
+ capability: string;
+ requiredInputModalities: string[];
+ maskedKey: string | null;
+ selectionStillValid: boolean | null;
+}
+
+export interface UseOrcaModelsOptions {
+ readonly enabled: boolean;
+ readonly capability: "chat" | "embedding" | "image" | "video" | "rerank";
+ /**
+ * Non-text modalities this entry point actually uploads. Changing this
+ * recomputes the selector's options.
+ */
+ readonly requiredInputModalities?: readonly string[];
+ /** Currently selected model id, revalidated on every recomputation. */
+ readonly selected?: string | null;
+}
+
+export interface UseOrcaModelsReturn {
+ models: OrcaModelOption[];
+ loading: boolean;
+ /** The catalog could not be refreshed and a verified fallback is in use. */
+ degraded: boolean;
+ degradedReason: string | null;
+ source: "live" | "seed" | null;
+ error: string | null;
+ /** True when the previous selection is no longer compatible. */
+ selectionInvalidated: boolean;
+ maskedKey: string | null;
+ refresh: () => void;
+}
+
+const EMPTY: OrcaModelOption[] = [];
+
+function modalityKey(modalities: readonly string[] | undefined): string {
+ return [...(modalities ?? [])].sort().join(",");
+}
+
+/**
+ * Fetch the model list for one capability from the server. The API key never
+ * reaches the browser; only minimal model metadata does.
+ *
+ * A live result is authoritative. When discovery fails, the server returns its
+ * verified seed and marks the response degraded — the selector never degrades
+ * into a free-text field.
+ */
+export function useOrcaModels(
+ options: UseOrcaModelsOptions,
+): UseOrcaModelsReturn {
+ const { enabled, capability, selected } = options;
+ const inputKey = modalityKey(options.requiredInputModalities);
+
+ const [models, setModels] = useState(EMPTY);
+ const [loading, setLoading] = useState(false);
+ const [degraded, setDegraded] = useState(false);
+ const [degradedReason, setDegradedReason] = useState(null);
+ const [source, setSource] = useState<"live" | "seed" | null>(null);
+ const [error, setError] = useState(null);
+ const [selectionInvalidated, setSelectionInvalidated] = useState(false);
+ const [maskedKey, setMaskedKey] = useState(null);
+ const [reloadToken, setReloadToken] = useState(0);
+
+ const generationRef = useRef(0);
+ const selectedRef = useRef(selected);
+ selectedRef.current = selected;
+
+ useEffect(() => {
+ if (!enabled) {
+ generationRef.current += 1;
+ setModels(EMPTY);
+ setLoading(false);
+ setError(null);
+ setDegraded(false);
+ setDegradedReason(null);
+ setSource(null);
+ setSelectionInvalidated(false);
+ return;
+ }
+
+ const generation = generationRef.current + 1;
+ generationRef.current = generation;
+ setLoading(true);
+ setError(null);
+
+ const params = new URLSearchParams({ capability });
+ if (inputKey) params.set("input", inputKey);
+ if (selectedRef.current) params.set("selected", selectedRef.current);
+
+ void (async () => {
+ try {
+ const response = await fetch(
+ `/api/orcarouter/models?${params.toString()}`,
+ { cache: "no-store" },
+ );
+ if (generation !== generationRef.current) return;
+ if (!response.ok) {
+ setModels(EMPTY);
+ setLoading(false);
+ setError("The OrcaRouter model list could not be loaded.");
+ setSelectionInvalidated(false);
+ return;
+ }
+ const payload = (await response.json()) as OrcaModelsResponse;
+ if (generation !== generationRef.current) return;
+ setModels(payload.models);
+ setSource(payload.source);
+ setDegraded(payload.degraded);
+ setDegradedReason(payload.degradedReason);
+ setMaskedKey(payload.maskedKey);
+ setSelectionInvalidated(payload.selectionStillValid === false);
+ setLoading(false);
+ } catch {
+ if (generation !== generationRef.current) return;
+ setModels(EMPTY);
+ setLoading(false);
+ setError("The OrcaRouter model list could not be loaded.");
+ setSelectionInvalidated(false);
+ }
+ })();
+ }, [capability, enabled, inputKey, reloadToken]);
+
+ const refresh = useCallback(() => {
+ setReloadToken((value) => value + 1);
+ }, []);
+
+ return {
+ models,
+ loading,
+ degraded,
+ degradedReason,
+ source,
+ error,
+ selectionInvalidated,
+ maskedKey,
+ refresh,
+ };
+}
diff --git a/apps/web/lib/page-titles.ts b/apps/web/lib/page-titles.ts
index 69053cef..071a729e 100644
--- a/apps/web/lib/page-titles.ts
+++ b/apps/web/lib/page-titles.ts
@@ -33,6 +33,7 @@ export const PAGE_TITLES: Record = {
"docs/custom-schema": "Custom Schema & Renderer",
"docs/devtools": "Devtools",
"docs/ai-sdk": "AI SDK Integration",
+ "docs/orcarouter": "OrcaRouter Integration",
"docs/jev": "Jev (Experimental)",
"docs/adaptive-cards": "Adaptive Cards Integration",
"docs/openapi": "OpenAPI Integration",
diff --git a/apps/web/lib/use-playground-stream.ts b/apps/web/lib/use-playground-stream.ts
index 03a11bee..34fb094b 100644
--- a/apps/web/lib/use-playground-stream.ts
+++ b/apps/web/lib/use-playground-stream.ts
@@ -17,6 +17,9 @@ import { applySpecPatch } from "./spec-patch";
export type PlaygroundModel = "default" | "typesafe-ai/jev";
+/** Which provider serves the generation. */
+export type PlaygroundProvider = "gateway" | "orcarouter" | "orcarouter-oauth";
+
export interface CompositionSummary {
stopReason: "finish" | "limit" | "unavailable";
elapsedMs: number;
@@ -40,6 +43,12 @@ export interface UsePlaygroundStreamOptions {
model?: PlaygroundModel;
format: StreamFormat;
editModes?: EditMode[];
+ /** Defaults to the Vercel AI Gateway provider. */
+ provider?: PlaygroundProvider;
+ /** OrcaRouter model id, chosen from the live catalog. */
+ orcaModel?: string | null;
+ /** Non-text modalities this request uploads. Drives the model filter. */
+ attachmentModalities?: readonly string[];
onError?: (error: Error) => void;
onComplete?: (spec: Spec) => void;
}
@@ -109,6 +118,9 @@ export function usePlaygroundStream({
model = "default",
format,
editModes,
+ provider = "gateway",
+ orcaModel = null,
+ attachmentModalities,
onError,
onComplete,
}: UsePlaygroundStreamOptions): UsePlaygroundStreamReturn {
@@ -133,6 +145,12 @@ export function usePlaygroundStream({
formatRef.current = format;
const editModesRef = useRef(editModes);
editModesRef.current = editModes;
+ const providerRef = useRef(provider);
+ providerRef.current = provider;
+ const orcaModelRef = useRef(orcaModel);
+ orcaModelRef.current = orcaModel;
+ const attachmentModalitiesRef = useRef(attachmentModalities);
+ attachmentModalitiesRef.current = attachmentModalities;
const stop = useCallback(() => abortControllerRef.current?.abort(), []);
const clear = useCallback(() => {
@@ -179,6 +197,11 @@ export function usePlaygroundStream({
model: requestModel,
format: requestFormat,
editModes: editModesRef.current,
+ provider: providerRef.current,
+ orcaModel: orcaModelRef.current,
+ attachments: (attachmentModalitiesRef.current ?? []).map(
+ (modality) => ({ modality }),
+ ),
}),
signal: controller.signal,
});
diff --git a/apps/web/package.json b/apps/web/package.json
index 92e95941..5adbe04c 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -14,6 +14,7 @@
},
"dependencies": {
"@ai-sdk/gateway": "^3.0.13",
+ "@ai-sdk/openai-compatible": "^2.0.75",
"@ai-sdk/react": "3.0.79",
"@json-render/codegen": "workspace:*",
"@json-render/core": "workspace:*",
@@ -40,6 +41,7 @@
"diff": "^8.0.3",
"embla-carousel-react": "^8.6.0",
"geist": "1.7.0",
+ "just-bash": "^2.14.5",
"lucide-react": "^0.562.0",
"next": "16.1.1",
"next-themes": "^0.4.6",
diff --git a/apps/web/public/orcarouter-mark.png b/apps/web/public/orcarouter-mark.png
new file mode 100644
index 00000000..fcf66b69
Binary files /dev/null and b/apps/web/public/orcarouter-mark.png differ
diff --git a/packages/core/README.md b/packages/core/README.md
index 0f808627..02d9646a 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -59,6 +59,17 @@ V1 supports standard flat Spec catalogs, literals, `$state`, `$bindState`, state
See the [Jev guide](https://json-render.dev/docs/jev) for complete catalog/candidate examples, source-build installation, rendering, custom evaluators, limitations, and feedback. The [playground implementation](../../apps/web/lib/jev) uses these same APIs.
+## OrcaRouter provider
+
+[OrcaRouter](https://www.orcarouter.ai) is an OpenAI-compatible AI gateway that routes many providers behind one endpoint. `@json-render/core` exports the provider seam so an app can offer it without duplicating auth or catalog logic. The inference transport stays in the host app (`@ai-sdk/openai-compatible`), so this package keeps no gateway dependency.
+
+- **Origins** — `resolveOrcarouterOrigins` keeps authorization (`https://www.orcarouter.ai`, `/auth` and `/api/v1/auth/keys`) separate from inference and the model catalog (`https://api.orcarouter.ai/v1`). Per-role overrides win over a shared `ORCA_BASE_URL`; remote origins must be HTTPS, with plain HTTP allowed only for loopback.
+- **Credentials** — `createOrcaApiKeySource` and `createOrcaPkceSource` are two adapters over one `OrcaCredentialSource` interface, and both resolve to the same `OrcaCredentialResult`. The PKCE adapter implements Flow A (loopback redirect) and Flow B (out-of-band code) with S256, a fresh verifier and state per attempt, and a constant-time state comparison. The device grant is not implemented (`ORCAROUTER_UNSUPPORTED_FLOWS`).
+- **Store** — `OrcaCredentialStore` is generation-aware: `markNeedsReauth(accountId, generation, reason)` only affects the exact account and generation whose request was rejected, so a late 401 cannot invalidate a credential the user just reconnected.
+- **Catalog** — `fetchOrcaCatalog` requests `/v1/models` with the user's key, and `selectOrcaModels` filters per capability and per required input modality. A model without an explicit `architecture.input_modalities` entry is never treated as multimodal. `orcarouterSeedCatalog` is a five-model outage fallback, labelled as degraded and never merged into a live result.
+
+See the [OrcaRouter guide](https://json-render.dev/docs/orcarouter) for setup and the [playground implementation](../../apps/web/lib/orcarouter) for a worked example.
+
## Key Concepts
- **Schema**: Defines the structure of specs and catalogs
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 51fa3755..5f557067 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -244,3 +244,6 @@ export type {
} from "./experimental-compose";
export { experimental_createEvaluator } from "./experimental-evaluator";
export type { Experimental_EvaluatorOptions } from "./experimental-evaluator";
+
+// OrcaRouter — a first-class, OpenAI-compatible provider.
+export * from "./orcarouter";
diff --git a/packages/core/src/orcarouter/catalog.test.ts b/packages/core/src/orcarouter/catalog.test.ts
new file mode 100644
index 00000000..b39798fe
--- /dev/null
+++ b/packages/core/src/orcarouter/catalog.test.ts
@@ -0,0 +1,439 @@
+// @vitest-environment node
+import { describe, expect, it, vi } from "vitest";
+import {
+ DEFAULT_CATALOG_TIMEOUT_MS,
+ MAX_CATALOG_BYTES,
+ OrcaCatalogError,
+ fetchOrcaCatalog,
+ isOrcaModelCompatible,
+ modelSupportsCapability,
+ modelSupportsInputModality,
+ orcaCatalogUrl,
+ orcarouterSeedCatalog,
+ parseOrcaCatalogPayload,
+ parseOrcaModelRecord,
+ selectOrcaModels,
+ type OrcaCatalog,
+ type OrcaModel,
+} from "./catalog";
+import { resolveOrcarouterOrigins } from "./origins";
+
+const origins = resolveOrcarouterOrigins();
+
+const textOnly = {
+ id: "deepseek/deepseek-v4-pro",
+ object: "model",
+ supported_endpoint_types: ["openai", "openai-response"],
+ context_length: 1048576,
+ architecture: { input_modalities: ["text"], output_modalities: ["text"] },
+};
+const imageInputChat = {
+ id: "deepseek/deepseek-v4-flash-vision-exp",
+ supported_endpoint_types: ["openai", "anthropic"],
+ context_length: 1048576,
+ architecture: {
+ input_modalities: ["text", "image"],
+ output_modalities: ["text"],
+ },
+};
+const embedding = {
+ id: "vendor/embed-1",
+ supported_endpoint_types: ["embeddings"],
+};
+const imageGeneration = {
+ id: "vendor/image-1",
+ supported_endpoint_types: ["image-generation"],
+};
+const video = {
+ id: "vendor/video-1",
+ supported_endpoint_types: ["openai-video"],
+};
+const rerank = {
+ id: "vendor/rerank-1",
+ supported_endpoint_types: ["jina-rerank"],
+};
+
+function catalogOf(records: unknown[]): OrcaCatalog {
+ return {
+ models: parseOrcaCatalogPayload({ data: records }),
+ source: "live",
+ degraded: false,
+ degradedReason: null,
+ fetchedAt: "2026-09-20T00:00:00.000Z",
+ };
+}
+
+function catalogOfModels(models: OrcaModel[]): OrcaCatalog {
+ return { ...catalogOf([]), models };
+}
+
+describe("parseOrcaModelRecord", () => {
+ it("preserves the vendor/model namespace verbatim", () => {
+ const model = parseOrcaModelRecord(textOnly);
+ expect(model?.id).toBe("deepseek/deepseek-v4-pro");
+ expect(model?.contextLength).toBe(1048576);
+ expect(model?.endpointTypes).toEqual(["openai", "openai-response"]);
+ expect(model?.architecture?.inputModalities).toEqual(["text"]);
+ });
+
+ it("drops records without a usable id", () => {
+ expect(parseOrcaModelRecord({ id: " " })).toBeNull();
+ expect(parseOrcaModelRecord({ id: 42 })).toBeNull();
+ expect(parseOrcaModelRecord(null)).toBeNull();
+ expect(parseOrcaModelRecord("vendor/model")).toBeNull();
+ });
+
+ it("ignores unknown modalities and non-integer context lengths", () => {
+ const model = parseOrcaModelRecord({
+ id: "vendor/x",
+ context_length: -1,
+ supported_endpoint_types: ["openai", 7],
+ architecture: { input_modalities: ["text", "smell"] },
+ });
+ expect(model?.contextLength).toBeNull();
+ expect(model?.endpointTypes).toEqual(["openai"]);
+ expect(model?.architecture?.inputModalities).toEqual(["text"]);
+ });
+});
+
+describe("parseOrcaCatalogPayload", () => {
+ it("accepts the OpenAI-shaped envelope and a bare array", () => {
+ expect(parseOrcaCatalogPayload({ data: [textOnly] })).toHaveLength(1);
+ expect(parseOrcaCatalogPayload([textOnly])).toHaveLength(1);
+ });
+
+ it("rejects an unexpected shape", () => {
+ expect(() => parseOrcaCatalogPayload({ models: [] })).toThrow(
+ OrcaCatalogError,
+ );
+ expect(() => parseOrcaCatalogPayload(null)).toThrow(OrcaCatalogError);
+ });
+
+ it("de-duplicates repeated ids", () => {
+ expect(
+ parseOrcaCatalogPayload({ data: [textOnly, textOnly] }),
+ ).toHaveLength(1);
+ });
+});
+
+describe("capability filtering", () => {
+ const catalog = catalogOf([
+ textOnly,
+ imageInputChat,
+ embedding,
+ imageGeneration,
+ video,
+ rerank,
+ ]);
+
+ it("treats text endpoint types as chat and excludes non-text families", () => {
+ const ids = selectOrcaModels(catalog, { capability: "chat" }).map(
+ (m) => m.id,
+ );
+ expect(ids).toEqual([
+ "deepseek/deepseek-v4-pro",
+ "deepseek/deepseek-v4-flash-vision-exp",
+ ]);
+ expect(ids).not.toContain("vendor/image-1");
+ expect(ids).not.toContain("vendor/video-1");
+ expect(ids).not.toContain("vendor/rerank-1");
+ expect(ids).not.toContain("vendor/embed-1");
+ });
+
+ it("matches embedding, image, video and rerank strictly on endpoint type", () => {
+ expect(
+ selectOrcaModels(catalog, { capability: "embedding" }).map((m) => m.id),
+ ).toEqual(["vendor/embed-1"]);
+ expect(
+ selectOrcaModels(catalog, { capability: "image" }).map((m) => m.id),
+ ).toEqual(["vendor/image-1"]);
+ expect(
+ selectOrcaModels(catalog, { capability: "video" }).map((m) => m.id),
+ ).toEqual(["vendor/video-1"]);
+ expect(
+ selectOrcaModels(catalog, { capability: "rerank" }).map((m) => m.id),
+ ).toEqual(["vendor/rerank-1"]);
+ });
+
+ it("accepts each of the four text endpoint types", () => {
+ for (const endpointType of [
+ "openai",
+ "anthropic",
+ "gemini",
+ "openai-response",
+ ]) {
+ const model = parseOrcaModelRecord({
+ id: `vendor/${endpointType}`,
+ supported_endpoint_types: [endpointType],
+ }) as OrcaModel;
+ expect(modelSupportsCapability(model, "chat")).toBe(true);
+ }
+ });
+
+ it("excludes a model that mixes a text type with a non-text family", () => {
+ const mixed = parseOrcaModelRecord({
+ id: "vendor/mixed",
+ supported_endpoint_types: ["openai", "image-generation"],
+ }) as OrcaModel;
+ expect(modelSupportsCapability(mixed, "chat")).toBe(false);
+ });
+
+ it("offers only chat models that declare the required input modality", () => {
+ const ids = selectOrcaModels(catalog, {
+ capability: "chat",
+ requiredInputModalities: ["image"],
+ }).map((m) => m.id);
+ expect(ids).toEqual(["deepseek/deepseek-v4-flash-vision-exp"]);
+ });
+
+ it("fails closed for a chat model with no architecture block", () => {
+ const undeclared = parseOrcaModelRecord({
+ id: "vendor/undeclared",
+ supported_endpoint_types: ["openai"],
+ }) as OrcaModel;
+ expect(modelSupportsCapability(undeclared, "chat")).toBe(true);
+ expect(modelSupportsInputModality(undeclared, "image")).toBe(false);
+ expect(
+ selectOrcaModels(catalogOfModels([undeclared]), {
+ capability: "chat",
+ requiredInputModalities: ["image"],
+ }),
+ ).toEqual([]);
+ });
+
+ it("fails closed for audio and video input, and ignores text requirements", () => {
+ const model = parseOrcaModelRecord(imageInputChat) as OrcaModel;
+ expect(modelSupportsInputModality(model, "audio")).toBe(false);
+ expect(modelSupportsInputModality(model, "video")).toBe(false);
+ expect(modelSupportsInputModality(model, "text")).toBe(true);
+ expect(
+ selectOrcaModels(catalogOfModels([model]), {
+ capability: "chat",
+ requiredInputModalities: ["text"],
+ }),
+ ).toHaveLength(1);
+ });
+
+ it("never infers image input from a model name", () => {
+ const named = parseOrcaModelRecord({
+ id: "vendor/super-vision-ultra",
+ supported_endpoint_types: ["openai"],
+ architecture: { input_modalities: ["text"] },
+ }) as OrcaModel;
+ expect(modelSupportsInputModality(named, "image")).toBe(false);
+ });
+});
+
+describe("isOrcaModelCompatible", () => {
+ const catalog = catalogOf([textOnly, imageInputChat]);
+
+ it("keeps a compatible selection and invalidates an incompatible one", () => {
+ expect(
+ isOrcaModelCompatible(catalog, "deepseek/deepseek-v4-pro", {
+ capability: "chat",
+ }),
+ ).toBe(true);
+ expect(
+ isOrcaModelCompatible(catalog, "deepseek/deepseek-v4-pro", {
+ capability: "chat",
+ requiredInputModalities: ["image"],
+ }),
+ ).toBe(false);
+ expect(
+ isOrcaModelCompatible(catalog, "vendor/gone", { capability: "chat" }),
+ ).toBe(false);
+ });
+});
+
+describe("orcaCatalogUrl", () => {
+ it("builds capability-scoped URLs on the inference origin", () => {
+ expect(orcaCatalogUrl(origins)).toBe("https://api.orcarouter.ai/v1/models");
+ expect(orcaCatalogUrl(origins, "chat")).toBe(
+ "https://api.orcarouter.ai/v1/models?capability=chat",
+ );
+ expect(orcaCatalogUrl(origins, "embedding")).toBe(
+ "https://api.orcarouter.ai/v1/models?capability=embedding",
+ );
+ expect(orcaCatalogUrl(origins, "image")).toBe(
+ "https://api.orcarouter.ai/v1/models?capability=image",
+ );
+ // The protocol has no video or rerank capability query; those are filtered
+ // strictly on the returned endpoint type instead.
+ expect(orcaCatalogUrl(origins, "video")).toBe(
+ "https://api.orcarouter.ai/v1/models",
+ );
+ });
+});
+
+describe("fetchOrcaCatalog", () => {
+ it("sends the key as a Bearer token to the inference origin", async () => {
+ const fetch = vi.fn(async () =>
+ Response.json({ data: [textOnly] }),
+ );
+ const catalog = await fetchOrcaCatalog({
+ origins,
+ apiKey: "sk-orca-test",
+ capability: "chat",
+ fetch,
+ });
+ expect(catalog.source).toBe("live");
+ expect(catalog.degraded).toBe(false);
+ expect(catalog.models).toHaveLength(1);
+ const [url, init] = fetch.mock.calls[0]!;
+ expect(url).toBe("https://api.orcarouter.ai/v1/models?capability=chat");
+ expect(init?.headers).toMatchObject({
+ Authorization: "Bearer sk-orca-test",
+ });
+ });
+
+ it.each([
+ [401, /rejected while listing models/],
+ [403, /rejected while listing models/],
+ [500, /status 500/],
+ ])(
+ "reports status %i without echoing the key or body",
+ async (status, pattern) => {
+ const error = await fetchOrcaCatalog({
+ origins,
+ apiKey: "sk-orca-secret-value",
+ fetch: async () => new Response("upstream detail", { status }),
+ }).catch((e: unknown) => e as OrcaCatalogError);
+ expect(error).toBeInstanceOf(OrcaCatalogError);
+ expect(error.status).toBe(status);
+ expect(error.message).toMatch(pattern);
+ expect(error.message).not.toContain("sk-orca-secret-value");
+ expect(error.message).not.toContain("upstream detail");
+ },
+ );
+
+ it("requires a key before making a request", async () => {
+ const fetch = vi.fn();
+ await expect(
+ fetchOrcaCatalog({ origins, apiKey: " ", fetch }),
+ ).rejects.toThrow(/API key is required/);
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it("surfaces a transport failure and invalid JSON as catalog errors", async () => {
+ await expect(
+ fetchOrcaCatalog({
+ origins,
+ apiKey: "sk-orca-test",
+ fetch: async () => {
+ throw new TypeError("fetch failed");
+ },
+ }),
+ ).rejects.toThrow(/Could not reach the OrcaRouter model catalog/);
+ await expect(
+ fetchOrcaCatalog({
+ origins,
+ apiKey: "sk-orca-test",
+ fetch: async () => new Response("nope"),
+ }),
+ ).rejects.toThrow(/not valid JSON/);
+ await expect(
+ fetchOrcaCatalog({
+ origins,
+ apiKey: "sk-orca-test",
+ fetch: async () => Response.json({ models: [] }),
+ }),
+ ).rejects.toThrow(/unexpected shape/);
+ });
+
+ it("bounds the response size and the item count", async () => {
+ await expect(
+ fetchOrcaCatalog({
+ origins,
+ apiKey: "sk-orca-test",
+ fetch: async () =>
+ new Response("x".repeat(MAX_CATALOG_BYTES + 1), { status: 200 }),
+ }),
+ ).rejects.toThrow(/larger than this client accepts/);
+
+ const many = Array.from({ length: 900 }, (_, i) => ({
+ id: `vendor/model-${i}`,
+ supported_endpoint_types: ["openai"],
+ }));
+ const catalog = await fetchOrcaCatalog({
+ origins,
+ apiKey: "sk-orca-test",
+ fetch: async () => Response.json({ data: many }),
+ });
+ expect(catalog.models.length).toBeLessThanOrEqual(500);
+ });
+
+ it("applies a bounded default timeout", () => {
+ expect(DEFAULT_CATALOG_TIMEOUT_MS).toBeLessThanOrEqual(30_000);
+ });
+
+ it("aborts on caller cancellation instead of hanging", async () => {
+ const controller = new AbortController();
+ controller.abort();
+ await expect(
+ fetchOrcaCatalog({
+ origins,
+ apiKey: "sk-orca-test",
+ signal: controller.signal,
+ fetch: async (_url, init) => {
+ if (init?.signal?.aborted)
+ throw new DOMException("aborted", "AbortError");
+ return Response.json({ data: [] });
+ },
+ }),
+ ).rejects.toThrow(OrcaCatalogError);
+ });
+
+ it("follows an explicit self-hosted API origin", async () => {
+ const selfHosted = resolveOrcarouterOrigins({
+ apiBaseUrl: "https://relay.example",
+ });
+ const fetch = vi.fn(async () =>
+ Response.json({ data: [] }),
+ );
+ await fetchOrcaCatalog({
+ origins: selfHosted,
+ apiKey: "sk-orca-test",
+ fetch,
+ });
+ expect(fetch.mock.calls[0]![0]).toBe("https://relay.example/v1/models");
+ });
+});
+
+describe("orcarouterSeedCatalog", () => {
+ it("is marked degraded and labelled as a seed, never as live", () => {
+ const seed = orcarouterSeedCatalog("outage");
+ expect(seed.source).toBe("seed");
+ expect(seed.degraded).toBe(true);
+ expect(seed.degradedReason).toBe("outage");
+ expect(seed.fetchedAt).toBeNull();
+ });
+
+ it("keeps the verified fallback ids and their metadata intact", () => {
+ const seed = orcarouterSeedCatalog("outage");
+ const ids = seed.models.map((m) => m.id);
+ expect(ids).toEqual([
+ "openai/gpt-5.5",
+ "anthropic/claude-opus-4.8",
+ "google/gemini-3.5-flash",
+ "deepseek/deepseek-v4-pro",
+ "orcarouter/auto",
+ ]);
+ const gpt55 = seed.models[0]!;
+ expect(gpt55.reasoningEfforts).toEqual(["low", "medium", "high", "xhigh"]);
+ expect(gpt55.contextLength).toBeGreaterThan(0);
+ expect(gpt55.architecture?.inputModalities).toContain("image");
+ });
+
+ it("still satisfies the chat selector and the multimodal selector", () => {
+ const seed = orcarouterSeedCatalog("outage");
+ expect(
+ selectOrcaModels(seed, { capability: "chat" }).length,
+ ).toBeGreaterThan(0);
+ expect(
+ selectOrcaModels(seed, {
+ capability: "chat",
+ requiredInputModalities: ["image"],
+ }).length,
+ ).toBeGreaterThan(0);
+ });
+});
diff --git a/packages/core/src/orcarouter/catalog.ts b/packages/core/src/orcarouter/catalog.ts
new file mode 100644
index 00000000..ebd085bf
--- /dev/null
+++ b/packages/core/src/orcarouter/catalog.ts
@@ -0,0 +1,432 @@
+/**
+ * OrcaRouter model catalog.
+ *
+ * `GET {api}/v1/models` is the only source of truth for which models a
+ * workspace can actually call. The catalog drives the model selector; a
+ * capability that the catalog metadata cannot prove is never offered.
+ */
+
+import { ORCAROUTER_MODELS_PATH, type OrcarouterOrigins } from "./origins";
+
+export type OrcaCapability =
+ | "chat"
+ | "embedding"
+ | "image"
+ | "video"
+ | "rerank";
+
+export type OrcaInputModality = "text" | "image" | "audio" | "video";
+
+/** Endpoint types that can carry a text chat completion. */
+const TEXT_ENDPOINT_TYPES = [
+ "openai",
+ "anthropic",
+ "gemini",
+ "openai-response",
+] as const;
+
+/** Endpoint types that belong to a non-text model family. */
+const NON_TEXT_ENDPOINT_TYPES = [
+ "image-generation",
+ "openai-video",
+ "jina-rerank",
+] as const;
+
+const CAPABILITY_ENDPOINT_TYPE: Record<
+ Exclude,
+ string
+> = {
+ embedding: "embeddings",
+ image: "image-generation",
+ video: "openai-video",
+ rerank: "jina-rerank",
+};
+
+const CAPABILITY_QUERY: Record = {
+ chat: "chat",
+ embedding: "embedding",
+ image: "image",
+ video: null,
+ rerank: null,
+};
+
+export interface OrcaModelArchitecture {
+ readonly inputModalities: readonly OrcaInputModality[];
+ readonly outputModalities: readonly OrcaInputModality[];
+}
+
+export interface OrcaModel {
+ /** The vendor/model identifier, preserved verbatim. */
+ readonly id: string;
+ readonly name: string | null;
+ readonly description: string | null;
+ readonly contextLength: number | null;
+ readonly maxCompletionTokens: number | null;
+ readonly endpointTypes: readonly string[];
+ readonly architecture: OrcaModelArchitecture | null;
+ /** Reasoning effort levels this model accepts, when verified. */
+ readonly reasoningEfforts: readonly string[] | null;
+}
+
+export interface OrcaCatalog {
+ readonly models: readonly OrcaModel[];
+ /** `live` results are authoritative; `seed` results are an outage fallback. */
+ readonly source: "live" | "seed";
+ readonly degraded: boolean;
+ /** Why the live catalog could not be used, when `degraded` is true. */
+ readonly degradedReason: string | null;
+ readonly fetchedAt: string | null;
+}
+
+export class OrcaCatalogError extends Error {
+ readonly status?: number;
+
+ constructor(message: string, status?: number) {
+ super(message);
+ this.name = "OrcaCatalogError";
+ this.status = status;
+ }
+}
+
+/** Bounds so a catalog response cannot consume unbounded memory. */
+export const MAX_CATALOG_BYTES = 1_048_576;
+export const MAX_CATALOG_MODELS = 500;
+export const DEFAULT_CATALOG_TIMEOUT_MS = 10_000;
+
+const MODALITIES = new Set(["text", "image", "audio", "video"]);
+
+function asModalities(value: unknown): OrcaInputModality[] {
+ if (!Array.isArray(value)) return [];
+ return value.filter(
+ (item): item is OrcaInputModality =>
+ typeof item === "string" && MODALITIES.has(item),
+ );
+}
+
+function asPositiveInteger(value: unknown): number | null {
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0
+ ? value
+ : null;
+}
+
+/**
+ * Parse one catalog record. Records that do not carry a usable id or a
+ * recognised endpoint list are dropped rather than guessed at.
+ */
+export function parseOrcaModelRecord(record: unknown): OrcaModel | null {
+ if (typeof record !== "object" || record === null) return null;
+ const raw = record as Record;
+ const id = typeof raw.id === "string" ? raw.id.trim() : "";
+ if (!id) return null;
+
+ const endpointTypes = Array.isArray(raw.supported_endpoint_types)
+ ? raw.supported_endpoint_types.filter(
+ (item): item is string => typeof item === "string",
+ )
+ : [];
+
+ let architecture: OrcaModelArchitecture | null = null;
+ if (typeof raw.architecture === "object" && raw.architecture !== null) {
+ const arch = raw.architecture as Record;
+ architecture = {
+ inputModalities: asModalities(arch.input_modalities),
+ outputModalities: asModalities(arch.output_modalities),
+ };
+ }
+
+ return {
+ id,
+ name: typeof raw.name === "string" && raw.name.trim() ? raw.name : null,
+ description:
+ typeof raw.description === "string" && raw.description.trim()
+ ? raw.description
+ : null,
+ contextLength: asPositiveInteger(raw.context_length),
+ maxCompletionTokens: asPositiveInteger(raw.max_completion_tokens),
+ endpointTypes,
+ architecture,
+ reasoningEfforts: null,
+ };
+}
+
+/** Parse a catalog payload. Accepts `{ data: [...] }` and a bare array. */
+export function parseOrcaCatalogPayload(payload: unknown): OrcaModel[] {
+ const items = Array.isArray(payload)
+ ? payload
+ : typeof payload === "object" &&
+ payload !== null &&
+ Array.isArray((payload as Record).data)
+ ? ((payload as Record).data as unknown[])
+ : null;
+ if (!items) {
+ throw new OrcaCatalogError(
+ "The OrcaRouter model catalog response had an unexpected shape.",
+ );
+ }
+ const models: OrcaModel[] = [];
+ const seen = new Set();
+ for (const item of items.slice(0, MAX_CATALOG_MODELS)) {
+ const model = parseOrcaModelRecord(item);
+ if (model && !seen.has(model.id)) {
+ seen.add(model.id);
+ models.push(model);
+ }
+ }
+ return models;
+}
+
+/** `GET {api}/v1/models[?capability=…]` for one capability. */
+export function orcaCatalogUrl(
+ origins: OrcarouterOrigins,
+ capability?: OrcaCapability,
+): string {
+ const query = capability ? CAPABILITY_QUERY[capability] : null;
+ const url = `${origins.apiBaseUrl}${ORCAROUTER_MODELS_PATH}`;
+ return query ? `${url}?capability=${query}` : url;
+}
+
+export interface FetchOrcaCatalogOptions {
+ readonly origins: OrcarouterOrigins;
+ /** Bearer credential. Never logged, never included in an error message. */
+ readonly apiKey: string;
+ readonly capability?: OrcaCapability;
+ readonly fetch?: typeof globalThis.fetch;
+ readonly signal?: AbortSignal;
+ readonly timeoutMs?: number;
+}
+
+/**
+ * Fetch the live catalog. Throws `OrcaCatalogError` on any failure so the
+ * caller decides how to degrade; error messages never include the key or the
+ * upstream body.
+ */
+export async function fetchOrcaCatalog(
+ options: FetchOrcaCatalogOptions,
+): Promise {
+ const apiKey = options.apiKey?.trim();
+ if (!apiKey) {
+ throw new OrcaCatalogError(
+ "An OrcaRouter API key is required to list models.",
+ );
+ }
+ const fetchImpl = options.fetch ?? globalThis.fetch;
+ const timeoutMs = options.timeoutMs ?? DEFAULT_CATALOG_TIMEOUT_MS;
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
+ const signal = options.signal
+ ? AbortSignal.any([options.signal, timeoutSignal])
+ : timeoutSignal;
+
+ let response: Response;
+ try {
+ response = await fetchImpl(
+ orcaCatalogUrl(options.origins, options.capability),
+ {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ Accept: "application/json",
+ },
+ signal,
+ cache: "no-store",
+ },
+ );
+ } catch {
+ throw new OrcaCatalogError(
+ "Could not reach the OrcaRouter model catalog. Check your network and try again.",
+ );
+ }
+
+ if (!response.ok) {
+ const message =
+ response.status === 401 || response.status === 403
+ ? `The OrcaRouter API key was rejected while listing models (status ${response.status}).`
+ : `The OrcaRouter model catalog request failed with status ${response.status}.`;
+ throw new OrcaCatalogError(message, response.status);
+ }
+
+ const body = await response.text().catch(() => null);
+ if (body === null) {
+ throw new OrcaCatalogError(
+ "The OrcaRouter model catalog response could not be read.",
+ );
+ }
+ if (body.length > MAX_CATALOG_BYTES) {
+ throw new OrcaCatalogError(
+ "The OrcaRouter model catalog response was larger than this client accepts.",
+ );
+ }
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(body);
+ } catch {
+ throw new OrcaCatalogError(
+ "The OrcaRouter model catalog response was not valid JSON.",
+ );
+ }
+
+ return {
+ models: parseOrcaCatalogPayload(parsed),
+ source: "live",
+ degraded: false,
+ degradedReason: null,
+ fetchedAt: new Date().toISOString(),
+ };
+}
+
+interface SeedModel {
+ readonly id: string;
+ readonly name: string;
+ readonly contextLength: number;
+ readonly endpointTypes: readonly string[];
+ readonly inputModalities: readonly OrcaInputModality[];
+ readonly reasoningEfforts: readonly string[] | null;
+}
+
+/**
+ * Verified cold-start seed. Used only when live discovery fails, so a fresh
+ * installation is not left with an empty selector during an outage. When live
+ * discovery succeeds its result is authoritative and this seed is not merged
+ * into it.
+ */
+export const ORCAROUTER_SEED_MODELS: readonly SeedModel[] = [
+ {
+ id: "openai/gpt-5.5",
+ name: "GPT-5.5",
+ contextLength: 400_000,
+ endpointTypes: ["openai", "openai-response", "anthropic"],
+ inputModalities: ["text", "image"],
+ reasoningEfforts: ["low", "medium", "high", "xhigh"],
+ },
+ {
+ id: "anthropic/claude-opus-4.8",
+ name: "Claude Opus 4.8",
+ contextLength: 200_000,
+ endpointTypes: ["anthropic", "openai"],
+ inputModalities: ["text", "image"],
+ reasoningEfforts: ["low", "medium", "high"],
+ },
+ {
+ id: "google/gemini-3.5-flash",
+ name: "Gemini 3.5 Flash",
+ contextLength: 1_000_000,
+ endpointTypes: ["gemini", "openai"],
+ inputModalities: ["text", "image", "audio", "video"],
+ reasoningEfforts: ["low", "medium", "high"],
+ },
+ {
+ id: "deepseek/deepseek-v4-pro",
+ name: "DeepSeek V4 Pro",
+ contextLength: 1_048_576,
+ endpointTypes: ["openai", "openai-response"],
+ inputModalities: ["text"],
+ reasoningEfforts: null,
+ },
+ {
+ id: "orcarouter/auto",
+ name: "OrcaRouter Auto",
+ contextLength: 400_000,
+ endpointTypes: ["openai", "openai-response", "anthropic", "gemini"],
+ inputModalities: ["text"],
+ reasoningEfforts: null,
+ },
+];
+
+export function orcarouterSeedCatalog(reason: string): OrcaCatalog {
+ return {
+ models: ORCAROUTER_SEED_MODELS.map((seed) => ({
+ id: seed.id,
+ name: seed.name,
+ description: null,
+ contextLength: seed.contextLength,
+ maxCompletionTokens: null,
+ endpointTypes: [...seed.endpointTypes],
+ architecture: {
+ inputModalities: [...seed.inputModalities],
+ outputModalities: ["text"],
+ },
+ reasoningEfforts: seed.reasoningEfforts
+ ? [...seed.reasoningEfforts]
+ : null,
+ })),
+ source: "seed",
+ degraded: true,
+ degradedReason: reason,
+ fetchedAt: null,
+ };
+}
+
+function hasTextEndpoint(model: OrcaModel): boolean {
+ return model.endpointTypes.some((type) =>
+ (TEXT_ENDPOINT_TYPES as readonly string[]).includes(type),
+ );
+}
+
+function hasNonTextEndpoint(model: OrcaModel): boolean {
+ return model.endpointTypes.some((type) =>
+ (NON_TEXT_ENDPOINT_TYPES as readonly string[]).includes(type),
+ );
+}
+
+/** Whether a model can serve the given capability, from catalog metadata only. */
+export function modelSupportsCapability(
+ model: OrcaModel,
+ capability: OrcaCapability,
+): boolean {
+ if (capability === "chat") {
+ return hasTextEndpoint(model) && !hasNonTextEndpoint(model);
+ }
+ return model.endpointTypes.includes(CAPABILITY_ENDPOINT_TYPE[capability]);
+}
+
+/**
+ * Whether a model declares the given non-text input modality. Fails closed: a
+ * model that does not declare an `architecture` block is never treated as
+ * multimodal.
+ */
+export function modelSupportsInputModality(
+ model: OrcaModel,
+ modality: OrcaInputModality,
+): boolean {
+ if (modality === "text") return true;
+ return model.architecture?.inputModalities.includes(modality) ?? false;
+}
+
+export interface SelectOrcaModelsOptions {
+ readonly capability: OrcaCapability;
+ /** Non-text modalities this entry point actually uploads. */
+ readonly requiredInputModalities?: readonly OrcaInputModality[];
+}
+
+/**
+ * Filter the catalog down to the models an entry point may offer. Every
+ * requirement must be provable from metadata, so this never guesses from a
+ * model name.
+ */
+export function selectOrcaModels(
+ catalog: OrcaCatalog,
+ options: SelectOrcaModelsOptions,
+): OrcaModel[] {
+ const modalities = (options.requiredInputModalities ?? []).filter(
+ (modality) => modality !== "text",
+ );
+ return catalog.models.filter((model) => {
+ if (!modelSupportsCapability(model, options.capability)) return false;
+ return modalities.every((modality) =>
+ modelSupportsInputModality(model, modality),
+ );
+ });
+}
+
+/**
+ * Whether a previously selected model is still offered for the current
+ * capability requirements. A stale selection must be cleared, not kept.
+ */
+export function isOrcaModelCompatible(
+ catalog: OrcaCatalog,
+ modelId: string,
+ options: SelectOrcaModelsOptions,
+): boolean {
+ return selectOrcaModels(catalog, options).some(
+ (model) => model.id === modelId,
+ );
+}
diff --git a/packages/core/src/orcarouter/credential-store.ts b/packages/core/src/orcarouter/credential-store.ts
new file mode 100644
index 00000000..7d9a2ad9
--- /dev/null
+++ b/packages/core/src/orcarouter/credential-store.ts
@@ -0,0 +1,178 @@
+/**
+ * Generation-safe credential store for OrcaRouter.
+ *
+ * The store holds whatever credential the active source produced. It is
+ * deliberately storage-agnostic: callers persist the serialized form wherever
+ * their deployment already keeps secrets. Nothing here writes a side file or
+ * introduces a new secret store.
+ */
+
+import {
+ classifyOrcaInferenceFailure,
+ type OrcaCredentialMethod,
+ type OrcaCredentialResult,
+ type OrcaStoredCredential,
+} from "./credential";
+
+export interface OrcaCredentialStoreSnapshot {
+ readonly credential: OrcaStoredCredential | null;
+}
+
+export class OrcaCredentialStore {
+ private current: OrcaStoredCredential | null;
+ private generation = 0;
+
+ constructor(initial: OrcaStoredCredential | null = null) {
+ this.current = initial;
+ this.generation = initial?.generation ?? 0;
+ }
+
+ /**
+ * Install a credential from either source. Bumps the generation so a late
+ * failure from a request made with the previous credential cannot mark this
+ * one as broken.
+ */
+ setCredential(result: OrcaCredentialResult): OrcaStoredCredential {
+ this.generation += 1;
+ const stored: OrcaStoredCredential = {
+ apiKey: result.apiKey,
+ method: result.method,
+ grantedScope: result.grantedScope,
+ accountId: result.accountId,
+ generation: this.generation,
+ status: "active",
+ issuedAt: new Date().toISOString(),
+ reauthReason: null,
+ };
+ this.current = stored;
+ return stored;
+ }
+
+ getCredential(): OrcaStoredCredential | null {
+ return this.current;
+ }
+
+ /** The credential to send, or null when the account needs reauthentication. */
+ getUsableCredential(): OrcaStoredCredential | null {
+ return this.current?.status === "active" ? this.current : null;
+ }
+
+ isConnected(): boolean {
+ return this.getUsableCredential() !== null;
+ }
+
+ get needsReauth(): boolean {
+ return this.current?.status === "needs_reauth";
+ }
+
+ /**
+ * Mark the credential that made a rejected request as needing
+ * reauthentication.
+ *
+ * Only the exact account and credential generation that made the request is
+ * touched: a late `401` from a request issued before a re-login leaves the
+ * new credential active. The stored secret is kept so the user can retry
+ * without losing the account.
+ */
+ markNeedsReauth(
+ accountId: string | null,
+ generation: number,
+ reason: string,
+ ): boolean {
+ const credential = this.current;
+ if (!credential) return false;
+ if (credential.generation !== generation) return false;
+ if (credential.accountId !== accountId) return false;
+ this.current = {
+ ...credential,
+ status: "needs_reauth",
+ reauthReason: reason,
+ };
+ return true;
+ }
+
+ /**
+ * Record an inference failure against the credential that made the request.
+ * Non-terminal statuses leave the credential untouched.
+ */
+ recordInferenceStatus(
+ accountId: string | null,
+ generation: number,
+ status: number,
+ ): boolean {
+ const classification = classifyOrcaInferenceFailure(status);
+ if (!classification.terminal || !classification.reason) return false;
+ return this.markNeedsReauth(accountId, generation, classification.reason);
+ }
+
+ /** Remove the credential. The caller decides when the user asked for this. */
+ clear(): void {
+ this.current = null;
+ }
+
+ snapshot(): OrcaCredentialStoreSnapshot {
+ return { credential: this.current };
+ }
+}
+
+export interface SerializedOrcaCredentialStore {
+ readonly version: 1;
+ readonly credential: OrcaStoredCredential | null;
+}
+
+/**
+ * Serialize for persistence. Callers must write the result wherever the
+ * deployment already stores secrets; it is not encrypted here.
+ */
+export function serializeOrcaCredentialStore(
+ store: OrcaCredentialStore,
+): SerializedOrcaCredentialStore {
+ return { version: 1, credential: store.snapshot().credential };
+}
+
+export function deserializeOrcaCredentialStore(
+ value: unknown,
+): OrcaCredentialStore {
+ if (typeof value !== "object" || value === null) {
+ return new OrcaCredentialStore(null);
+ }
+ const record = value as Record;
+ const raw = record.credential;
+ if (typeof raw !== "object" || raw === null) {
+ return new OrcaCredentialStore(null);
+ }
+ const candidate = raw as Record;
+ const apiKey = candidate.apiKey;
+ const generation = candidate.generation;
+ if (
+ typeof apiKey !== "string" ||
+ !apiKey ||
+ typeof generation !== "number" ||
+ !Number.isSafeInteger(generation) ||
+ generation < 0
+ ) {
+ return new OrcaCredentialStore(null);
+ }
+ const method: OrcaCredentialMethod =
+ candidate.method === "orcarouter-oauth" ? "orcarouter-oauth" : "api_key";
+ return new OrcaCredentialStore({
+ apiKey,
+ method,
+ grantedScope:
+ typeof candidate.grantedScope === "string"
+ ? candidate.grantedScope
+ : null,
+ accountId:
+ typeof candidate.accountId === "string" ? candidate.accountId : null,
+ generation,
+ status: candidate.status === "needs_reauth" ? "needs_reauth" : "active",
+ issuedAt:
+ typeof candidate.issuedAt === "string"
+ ? candidate.issuedAt
+ : new Date(0).toISOString(),
+ reauthReason:
+ typeof candidate.reauthReason === "string"
+ ? candidate.reauthReason
+ : null,
+ });
+}
diff --git a/packages/core/src/orcarouter/credential.test.ts b/packages/core/src/orcarouter/credential.test.ts
new file mode 100644
index 00000000..ed12d7e8
--- /dev/null
+++ b/packages/core/src/orcarouter/credential.test.ts
@@ -0,0 +1,546 @@
+// @vitest-environment node
+import { describe, expect, it, vi } from "vitest";
+import {
+ ORCA_API_KEY_PREFIX,
+ classifyOrcaInferenceFailure,
+ createOrcaApiKeySource,
+ createOrcaPkceSource,
+ redactOrcaApiKey,
+ validateOrcaApiKey,
+ type OrcaCodeReceiver,
+ type OrcaCredentialResult,
+ type OrcaCredentialSource,
+} from "./credential";
+import { OrcaAuthError } from "./pkce";
+import { resolveOrcarouterOrigins } from "./origins";
+import {
+ OrcaCredentialStore,
+ deserializeOrcaCredentialStore,
+ serializeOrcaCredentialStore,
+} from "./credential-store";
+import { selectOrcaModels } from "./catalog";
+import { orcarouterSeedCatalog } from "./catalog";
+
+const origins = resolveOrcarouterOrigins();
+
+describe("redactOrcaApiKey", () => {
+ it("never returns the whole key", () => {
+ const masked = redactOrcaApiKey("sk-orca-abcdefghijklmnop");
+ expect(masked).not.toContain("ijklmnop");
+ expect(masked).toBe("sk-orca-••••mnop");
+ });
+
+ it("handles empty and short values without throwing", () => {
+ expect(redactOrcaApiKey("")).toBe("");
+ expect(redactOrcaApiKey(null)).toBe("");
+ expect(redactOrcaApiKey(undefined)).toBe("");
+ expect(redactOrcaApiKey("short")).toBe("••••");
+ });
+});
+
+describe("validateOrcaApiKey", () => {
+ it("accepts a well-formed key", () => {
+ expect(validateOrcaApiKey("sk-orca-abc123")).toEqual({
+ ok: true,
+ problem: null,
+ });
+ });
+
+ it.each([
+ ["", /Enter an OrcaRouter API key/],
+ [" ", /Enter an OrcaRouter API key/],
+ ["sk-orca-abc 123", /cannot contain whitespace/],
+ ["sk-live-abc", /starts with/],
+ [ORCA_API_KEY_PREFIX, /incomplete/],
+ ])("rejects %j", (value, pattern) => {
+ const result = validateOrcaApiKey(value);
+ expect(result.ok).toBe(false);
+ expect(result.problem).toMatch(pattern);
+ expect(result.problem).not.toContain("abc123");
+ });
+
+ it("reports format as format, not as proof of validity", () => {
+ expect(validateOrcaApiKey("sk-orca-not-a-real-key").ok).toBe(true);
+ });
+});
+
+describe("createOrcaApiKeySource", () => {
+ it("produces the shared credential result shape", async () => {
+ const source = createOrcaApiKeySource(" sk-orca-test-key ");
+ expect(source.method).toBe("api_key");
+ expect(source.label).toBe("OrcaRouter - API");
+ await expect(source.acquire()).resolves.toEqual({
+ apiKey: "sk-orca-test-key",
+ method: "api_key",
+ grantedScope: null,
+ accountId: null,
+ });
+ });
+
+ it("refuses a malformed key without leaking it", async () => {
+ const source = createOrcaApiKeySource("nope");
+ const error = await source.acquire().catch((e: unknown) => e as Error);
+ expect(error).toBeInstanceOf(OrcaAuthError);
+ expect(error.message).not.toContain("nope");
+ });
+});
+
+function receiverFor(
+ callback: {
+ code?: string | null;
+ error?: string | null;
+ state?: string | null;
+ },
+ callbackUrl = "http://127.0.0.1:51733/cb",
+): OrcaCodeReceiver {
+ return {
+ callbackUrl,
+ async waitForCallback() {
+ return {
+ code: callback.code ?? null,
+ error: callback.error ?? null,
+ state: callback.state ?? null,
+ };
+ },
+ cancel: vi.fn(),
+ };
+}
+
+const exchangeOk = async () => ({
+ key: "sk-orca-from-pkce",
+ userId: "u-1",
+ grantedScope: "api",
+});
+
+describe("createOrcaPkceSource", () => {
+ it("is a second, independently usable adapter on the same seam", () => {
+ const source = createOrcaPkceSource({
+ origins,
+ appName: "json-render",
+ flow: "loopback",
+ });
+ expect(source.method).toBe("orcarouter-oauth");
+ expect(source.label).toBe("OrcaRouter - Auth");
+ });
+
+ it("completes authorize -> callback -> exchange -> credential on loopback", async () => {
+ const attempt = {
+ codeVerifier: "v",
+ codeChallenge: "c",
+ state: "expected-state",
+ };
+ const receiver = receiverFor({
+ code: "the-code",
+ state: "expected-state",
+ });
+ const exchange = vi.fn(exchangeOk);
+ const source = createOrcaPkceSource({
+ origins,
+ appName: "json-render",
+ flow: "loopback",
+ createCodeReceiver: async () => receiver,
+ createAttempt: async () => attempt,
+ exchange,
+ });
+
+ await expect(source.acquire()).resolves.toEqual({
+ apiKey: "sk-orca-from-pkce",
+ method: "orcarouter-oauth",
+ grantedScope: "api",
+ accountId: "u-1",
+ });
+ expect(exchange).toHaveBeenCalledWith(
+ expect.objectContaining({ code: "the-code", attempt }),
+ );
+ });
+
+ it("builds the authorize URL on the auth origin and opens it", async () => {
+ const openBrowser = vi.fn();
+ const attempt = {
+ codeVerifier: "v",
+ codeChallenge: "the-challenge",
+ state: "the-state",
+ };
+ const source = createOrcaPkceSource({
+ origins,
+ appName: "json-render",
+ flow: "loopback",
+ createCodeReceiver: async () =>
+ receiverFor({ code: "c", state: "the-state" }),
+ createAttempt: async () => attempt,
+ exchange: exchangeOk,
+ openBrowser,
+ });
+ await source.acquire();
+ const url = new URL(openBrowser.mock.calls[0]![0] as string);
+ expect(url.origin).toBe("https://www.orcarouter.ai");
+ expect(url.pathname).toBe("/auth");
+ expect(url.searchParams.get("code_challenge")).toBe("the-challenge");
+ expect(url.searchParams.get("code_challenge_method")).toBe("S256");
+ expect(openBrowser.mock.calls[0]![0]).not.toContain("v");
+ });
+
+ it("rejects a state mismatch before touching the code and never exchanges", async () => {
+ const exchange = vi.fn(exchangeOk);
+ const source = createOrcaPkceSource({
+ origins,
+ appName: "json-render",
+ flow: "loopback",
+ createCodeReceiver: async () =>
+ receiverFor({ code: "attacker-code", state: "wrong-state" }),
+ createAttempt: async () => ({
+ codeVerifier: "v",
+ codeChallenge: "c",
+ state: "expected-state",
+ }),
+ exchange,
+ });
+ const error = await source
+ .acquire()
+ .catch((e: unknown) => e as OrcaAuthError);
+ expect(error.code).toBe("state_mismatch");
+ expect(exchange).not.toHaveBeenCalled();
+ });
+
+ it("treats a missing state as a mismatch", async () => {
+ const source = createOrcaPkceSource({
+ origins,
+ appName: "json-render",
+ flow: "loopback",
+ createCodeReceiver: async () => receiverFor({ code: "c" }),
+ createAttempt: async () => ({
+ codeVerifier: "v",
+ codeChallenge: "c",
+ state: "expected-state",
+ }),
+ exchange: exchangeOk,
+ });
+ await expect(source.acquire()).rejects.toMatchObject({
+ code: "state_mismatch",
+ });
+ });
+
+ it("reports a denial as a terminal error after the state check", async () => {
+ const source = createOrcaPkceSource({
+ origins,
+ appName: "json-render",
+ flow: "loopback",
+ createCodeReceiver: async () =>
+ receiverFor({ error: "access_denied", state: "s" }),
+ createAttempt: async () => ({
+ codeVerifier: "v",
+ codeChallenge: "c",
+ state: "s",
+ }),
+ exchange: exchangeOk,
+ });
+ const error = await source
+ .acquire()
+ .catch((e: unknown) => e as OrcaAuthError);
+ expect(error.code).toBe("denied");
+ expect(error.message).toContain("access_denied");
+ });
+
+ it("releases the listener when the wait fails", async () => {
+ const cancel = vi.fn();
+ const source = createOrcaPkceSource({
+ origins,
+ appName: "json-render",
+ flow: "loopback",
+ createCodeReceiver: async () => ({
+ callbackUrl: "http://127.0.0.1:1/cb",
+ waitForCallback: async () => {
+ throw new Error("socket closed");
+ },
+ cancel,
+ }),
+ createAttempt: async () => ({
+ codeVerifier: "v",
+ codeChallenge: "c",
+ state: "s",
+ }),
+ exchange: exchangeOk,
+ });
+ await expect(source.acquire()).rejects.toMatchObject({ code: "cancelled" });
+ expect(cancel).toHaveBeenCalled();
+ });
+
+ it("runs the out-of-band flow with callback_url=oob", async () => {
+ const exchange = vi.fn(exchangeOk);
+ let seenUrl = "";
+ const source = createOrcaPkceSource({
+ origins,
+ appName: "json-render",
+ flow: "oob",
+ createAttempt: async () => ({
+ codeVerifier: "v",
+ codeChallenge: "c",
+ state: "s",
+ }),
+ requestCode: async (url) => {
+ seenUrl = url;
+ return " pasted-code ";
+ },
+ exchange,
+ });
+ await expect(source.acquire()).resolves.toMatchObject({
+ method: "orcarouter-oauth",
+ });
+ const parsed = new URL(seenUrl);
+ expect(parsed.searchParams.get("callback_url")).toBe("oob");
+ expect(parsed.searchParams.get("code_challenge_method")).toBe("S256");
+ expect(exchange).toHaveBeenCalledWith(
+ expect.objectContaining({ code: "pasted-code" }),
+ );
+ });
+
+ it("treats an empty pasted code as a cancellation, not an exchange", async () => {
+ const exchange = vi.fn(exchangeOk);
+ const source = createOrcaPkceSource({
+ origins,
+ appName: "json-render",
+ flow: "oob",
+ createAttempt: async () => ({
+ codeVerifier: "v",
+ codeChallenge: "c",
+ state: "s",
+ }),
+ requestCode: async () => " ",
+ exchange,
+ });
+ await expect(source.acquire()).rejects.toMatchObject({ code: "cancelled" });
+ expect(exchange).not.toHaveBeenCalled();
+ });
+
+ it("uses a fresh verifier and state on every attempt", async () => {
+ const seen: string[] = [];
+ const source = createOrcaPkceSource({
+ origins,
+ appName: "json-render",
+ flow: "oob",
+ requestCode: async () => "c",
+ exchange: async (options) => {
+ seen.push(options.attempt.codeVerifier);
+ return { key: "sk-orca-x", userId: null, grantedScope: "api" };
+ },
+ });
+ await source.acquire();
+ await source.acquire();
+ expect(new Set(seen).size).toBe(2);
+ });
+
+ it("never places the verifier in the authorize URL or an error message", async () => {
+ const urls: string[] = [];
+ const source = createOrcaPkceSource({
+ origins,
+ appName: "json-render",
+ flow: "oob",
+ requestCode: async (url) => {
+ urls.push(url);
+ throw new OrcaAuthError("denied", "declined");
+ },
+ exchange: exchangeOk,
+ });
+ const error = await source.acquire().catch((e: unknown) => e as Error);
+ const attemptVerifier = new URL(urls[0]!).searchParams.get(
+ "code_challenge",
+ )!;
+ expect(urls[0]).not.toContain("code_verifier");
+ expect(error.message).not.toContain(attemptVerifier);
+ });
+});
+
+describe("credential seam equivalence", () => {
+ const sources: OrcaCredentialSource[] = [
+ createOrcaApiKeySource("sk-orca-pasted-key"),
+ createOrcaPkceSource({
+ origins,
+ appName: "json-render",
+ flow: "oob",
+ requestCode: async () => "code",
+ exchange: async () => ({
+ key: "sk-orca-pkce-key",
+ userId: "u-1",
+ grantedScope: "api",
+ }),
+ }),
+ ];
+
+ it("yields one credential shape from both adapters", async () => {
+ const results = await Promise.all(sources.map((s) => s.acquire()));
+ for (const result of results) {
+ expect(Object.keys(result).sort()).toEqual([
+ "accountId",
+ "apiKey",
+ "grantedScope",
+ "method",
+ ]);
+ expect(typeof result.apiKey).toBe("string");
+ expect(result.apiKey.startsWith(ORCA_API_KEY_PREFIX)).toBe(true);
+ }
+ expect(results.map((r) => r.method)).toEqual([
+ "api_key",
+ "orcarouter-oauth",
+ ]);
+ });
+
+ it("downstream consumers never learn which adapter produced the credential", async () => {
+ const catalog = orcarouterSeedCatalog("test");
+ for (const source of sources) {
+ const credential: OrcaCredentialResult = await source.acquire();
+ const store = new OrcaCredentialStore();
+ store.setCredential(credential);
+ const usable = store.getUsableCredential();
+ expect(usable).not.toBeNull();
+
+ // Model discovery consumes only the key, never the method.
+ const options = { capability: "chat" as const };
+ const forKeyA = selectOrcaModels(catalog, options).map((m) => m.id);
+ const forKeyB = selectOrcaModels(catalog, options).map((m) => m.id);
+ expect(forKeyA).toEqual(forKeyB);
+ expect(
+ usable!.method === "api_key" || usable!.method === "orcarouter-oauth",
+ ).toBe(true);
+ }
+ });
+});
+
+describe("classifyOrcaInferenceFailure", () => {
+ it("treats 401 and 403 as terminal reauthentication", () => {
+ for (const status of [401, 403]) {
+ const result = classifyOrcaInferenceFailure(status);
+ expect(result.terminal).toBe(true);
+ expect(result.reason).toBeTruthy();
+ }
+ });
+
+ it("leaves transient statuses non-terminal", () => {
+ for (const status of [408, 429, 500, 502, 503]) {
+ expect(classifyOrcaInferenceFailure(status)).toEqual({
+ terminal: false,
+ reason: null,
+ });
+ }
+ });
+
+ it("describes a revoked key as needing a new connection, not a refresh", () => {
+ const reason = classifyOrcaInferenceFailure(401).reason!;
+ expect(reason).toMatch(/Reconnect/);
+ expect(reason.toLowerCase()).not.toContain("refresh");
+ });
+});
+
+describe("OrcaCredentialStore", () => {
+ const result = (
+ method: "api_key" | "orcarouter-oauth",
+ accountId: string | null,
+ ): OrcaCredentialResult => ({
+ apiKey: `sk-orca-${accountId ?? "anon"}-${method}`,
+ method,
+ grantedScope: "api",
+ accountId,
+ });
+
+ it("installs credentials from either source and bumps the generation", () => {
+ const store = new OrcaCredentialStore();
+ expect(store.isConnected()).toBe(false);
+ const first = store.setCredential(result("api_key", "u-1"));
+ const second = store.setCredential(result("orcarouter-oauth", "u-1"));
+ expect(second.generation).toBe(first.generation + 1);
+ expect(store.isConnected()).toBe(true);
+ expect(store.needsReauth).toBe(false);
+ });
+
+ it("marks only the exact account generation that was rejected", () => {
+ const store = new OrcaCredentialStore();
+ const first = store.setCredential(result("api_key", "u-1"));
+ // A newer login replaces the credential while the old request is in flight.
+ store.setCredential(result("orcarouter-oauth", "u-1"));
+ expect(store.markNeedsReauth("u-1", first.generation, "revoked")).toBe(
+ false,
+ );
+ expect(store.needsReauth).toBe(false);
+ expect(store.isConnected()).toBe(true);
+ });
+
+ it("does not mark a different account for reauthentication", () => {
+ const store = new OrcaCredentialStore();
+ const credential = store.setCredential(result("api_key", "u-1"));
+ expect(store.markNeedsReauth("u-2", credential.generation, "revoked")).toBe(
+ false,
+ );
+ expect(store.needsReauth).toBe(false);
+ });
+
+ it("enters needsReauth on 401 and keeps the stored secret", () => {
+ const store = new OrcaCredentialStore();
+ const credential = store.setCredential(result("orcarouter-oauth", "u-1"));
+ expect(store.recordInferenceStatus("u-1", credential.generation, 401)).toBe(
+ true,
+ );
+ expect(store.needsReauth).toBe(true);
+ expect(store.isConnected()).toBe(false);
+ expect(store.getUsableCredential()).toBeNull();
+ // The secret is retained so a transient failure is not irreversible.
+ expect(store.getCredential()?.apiKey).toBe(credential.apiKey);
+ expect(store.getCredential()?.reauthReason).toBeTruthy();
+ });
+
+ it("ignores a non-terminal status", () => {
+ const store = new OrcaCredentialStore();
+ const credential = store.setCredential(result("api_key", "u-1"));
+ expect(store.recordInferenceStatus("u-1", credential.generation, 429)).toBe(
+ false,
+ );
+ expect(store.isConnected()).toBe(true);
+ });
+
+ it("never attempts a refresh: a new login is a new credential, not a rotation", () => {
+ const store = new OrcaCredentialStore();
+ const credential = store.setCredential(result("orcarouter-oauth", "u-1"));
+ store.recordInferenceStatus("u-1", credential.generation, 401);
+ const fresh = store.setCredential(result("orcarouter-oauth", "u-1"));
+ expect(fresh.generation).toBeGreaterThan(credential.generation);
+ expect(store.needsReauth).toBe(false);
+ expect(store.isConnected()).toBe(true);
+ });
+
+ it("clears on request", () => {
+ const store = new OrcaCredentialStore();
+ store.setCredential(result("api_key", "u-1"));
+ store.clear();
+ expect(store.getCredential()).toBeNull();
+ expect(store.isConnected()).toBe(false);
+ });
+
+ it("round-trips through serialization without dropping the generation", () => {
+ const store = new OrcaCredentialStore();
+ const credential = store.setCredential(result("orcarouter-oauth", "u-1"));
+ const restored = deserializeOrcaCredentialStore(
+ serializeOrcaCredentialStore(store),
+ );
+ expect(restored.getCredential()).toEqual(credential);
+ expect(restored.isConnected()).toBe(true);
+ // A restored credential still refuses a stale generation.
+ expect(
+ restored.markNeedsReauth("u-1", credential.generation - 1, "revoked"),
+ ).toBe(false);
+ expect(
+ restored.markNeedsReauth("u-1", credential.generation, "revoked"),
+ ).toBe(true);
+ });
+
+ it.each([
+ ["null", null],
+ ["a non-object", 42],
+ ["a missing credential", {}],
+ ["a credential with no key", { credential: { generation: 1 } }],
+ [
+ "a credential with a bad generation",
+ { credential: { apiKey: "k", generation: -1 } },
+ ],
+ ])("restores an empty store from %s", (_label, value) => {
+ const store = deserializeOrcaCredentialStore(value);
+ expect(store.getCredential()).toBeNull();
+ });
+});
diff --git a/packages/core/src/orcarouter/credential.ts b/packages/core/src/orcarouter/credential.ts
new file mode 100644
index 00000000..720a1ed8
--- /dev/null
+++ b/packages/core/src/orcarouter/credential.ts
@@ -0,0 +1,310 @@
+/**
+ * OrcaRouter credentials.
+ *
+ * Both entry points — a pasted API key and an OAuth 2.0 + PKCE login — produce
+ * the same thing: a normal, durable OrcaRouter API key. Everything downstream
+ * (inference, model discovery) consumes `OrcaCredentialResult` and never learns
+ * where the key came from.
+ */
+
+import {
+ OrcaAuthError,
+ buildOrcaAuthorizeUrl,
+ constantTimeEqual,
+ createOrcaPkceAttempt,
+ exchangeOrcaAuthCode,
+ type OrcaAuthorizationScope,
+} from "./pkce";
+import type { OrcarouterOrigins } from "./origins";
+
+export type OrcaCredentialMethod = "api_key" | "orcarouter-oauth";
+
+export const ORCA_API_KEY_PREFIX = "sk-orca-";
+
+/** A credential handed to the transport. Never serialized, never logged. */
+export interface OrcaCredentialResult {
+ /** The bearer token. A durable key, not a refresh token. */
+ readonly apiKey: string;
+ readonly method: OrcaCredentialMethod;
+ /** The scope the server reported as granted, when it reported one. */
+ readonly grantedScope: string | null;
+ /** Stable account identifier for the issuing user, when known. */
+ readonly accountId: string | null;
+}
+
+export interface OrcaStoredCredential extends OrcaCredentialResult {
+ /**
+ * Monotonic counter, incremented on every successful replacement. Used to
+ * make a `401` from an in-flight request unable to mark a newer credential
+ * as broken.
+ */
+ readonly generation: number;
+ readonly status: "active" | "needs_reauth";
+ readonly issuedAt: string;
+ /** Present only when `status` is `needs_reauth`. */
+ readonly reauthReason: string | null;
+}
+
+/** Mask a key for display. The plaintext never reaches a UI or a log. */
+export function redactOrcaApiKey(apiKey: string | null | undefined): string {
+ if (typeof apiKey !== "string" || !apiKey) return "";
+ const trimmed = apiKey.trim();
+ if (trimmed.length <= 8) return "••••";
+ return `${trimmed.slice(0, 8)}••••${trimmed.slice(-4)}`;
+}
+
+export interface OrcaApiKeyValidation {
+ readonly ok: boolean;
+ /** User-facing problem description. Never contains the key. */
+ readonly problem: string | null;
+}
+
+/**
+ * Lightweight format check only. An `sk-orca-` prefix is not proof that a key
+ * is valid; there is no free validation request, so the first real call
+ * establishes validity.
+ */
+export function validateOrcaApiKey(apiKey: string): OrcaApiKeyValidation {
+ const trimmed = apiKey?.trim() ?? "";
+ if (!trimmed) {
+ return { ok: false, problem: "Enter an OrcaRouter API key." };
+ }
+ if (/\s/.test(trimmed)) {
+ return {
+ ok: false,
+ problem: "An OrcaRouter API key cannot contain whitespace.",
+ };
+ }
+ if (!trimmed.startsWith(ORCA_API_KEY_PREFIX)) {
+ return {
+ ok: false,
+ problem: `An OrcaRouter API key starts with "${ORCA_API_KEY_PREFIX}".`,
+ };
+ }
+ if (trimmed.length <= ORCA_API_KEY_PREFIX.length) {
+ return { ok: false, problem: "That OrcaRouter API key is incomplete." };
+ }
+ return { ok: true, problem: null };
+}
+
+/**
+ * The credential seam. Adding an authentication method means adding one
+ * adapter; nothing downstream changes.
+ */
+export interface OrcaCredentialSource {
+ readonly method: OrcaCredentialMethod;
+ readonly label: string;
+ acquire(signal?: AbortSignal): Promise;
+}
+
+export function createOrcaApiKeySource(apiKey: string): OrcaCredentialSource {
+ return {
+ method: "api_key",
+ label: "OrcaRouter - API",
+ async acquire() {
+ const validation = validateOrcaApiKey(apiKey);
+ if (!validation.ok) {
+ throw new OrcaAuthError(
+ "invalid_response",
+ validation.problem ?? "That OrcaRouter API key is not usable.",
+ );
+ }
+ return {
+ apiKey: apiKey.trim(),
+ method: "api_key",
+ grantedScope: null,
+ accountId: null,
+ };
+ },
+ };
+}
+
+/** What a loopback listener or a prompt hands back to the PKCE adapter. */
+export interface OrcaCallbackResult {
+ readonly code: string | null;
+ readonly error: string | null;
+ readonly state: string | null;
+}
+
+export interface OrcaCodeReceiver {
+ /** The `callback_url` to send to the consent screen. */
+ readonly callbackUrl: string;
+ waitForCallback(): Promise;
+ /** Release the listener and any pending wait. Idempotent. */
+ cancel(): void;
+}
+
+export interface OrcaPkceSourceDependencies {
+ readonly origins: OrcarouterOrigins;
+ /** Shown on the consent screen as a claim by the requesting app. */
+ readonly appName: string;
+ /**
+ * `loopback` opens a browser and receives the code on `127.0.0.1`;
+ * `oob` displays the authorize URL and reads the code back from the user.
+ */
+ readonly flow: "loopback" | "oob";
+ /** Required for `loopback`. */
+ readonly createCodeReceiver?: () => Promise;
+ /** Required for `oob`. Receives the authorize URL, resolves with the code. */
+ readonly requestCode?: (
+ authorizeUrl: string,
+ signal?: AbortSignal,
+ ) => Promise;
+ readonly openBrowser?: (url: string) => void;
+ readonly fetch?: typeof globalThis.fetch;
+ readonly scope?: OrcaAuthorizationScope;
+ readonly acceptedScopes?: readonly string[];
+ readonly loginHint?: string;
+ readonly workspaceHint?: string;
+ /** Test seam; production callers use the default. */
+ readonly createAttempt?: typeof createOrcaPkceAttempt;
+ readonly buildAuthorizeUrl?: typeof buildOrcaAuthorizeUrl;
+ readonly exchange?: typeof exchangeOrcaAuthCode;
+}
+
+/**
+ * The OAuth 2.0 + PKCE adapter. It never holds a client secret and never needs
+ * a pre-registered redirect URI.
+ */
+export function createOrcaPkceSource(
+ dependencies: OrcaPkceSourceDependencies,
+): OrcaCredentialSource {
+ return {
+ method: "orcarouter-oauth",
+ label: "OrcaRouter - Auth",
+ async acquire(signal) {
+ const createAttempt = dependencies.createAttempt ?? createOrcaPkceAttempt;
+ const buildUrl = dependencies.buildAuthorizeUrl ?? buildOrcaAuthorizeUrl;
+ const exchange = dependencies.exchange ?? exchangeOrcaAuthCode;
+
+ // A fresh verifier and state for every attempt.
+ const attempt = await createAttempt();
+
+ let receiver: OrcaCodeReceiver | null = null;
+ let code: string;
+ if (dependencies.flow === "loopback") {
+ if (!dependencies.createCodeReceiver) {
+ throw new OrcaAuthError(
+ "invalid_response",
+ "The loopback flow needs a code receiver.",
+ );
+ }
+ receiver = await dependencies.createCodeReceiver();
+ const authorizeUrl = buildUrl({
+ origins: dependencies.origins,
+ callbackUrl: receiver.callbackUrl,
+ attempt,
+ appName: dependencies.appName,
+ scope: dependencies.scope,
+ loginHint: dependencies.loginHint,
+ workspaceHint: dependencies.workspaceHint,
+ });
+ dependencies.openBrowser?.(authorizeUrl);
+ let callback: OrcaCallbackResult;
+ try {
+ callback = await receiver.waitForCallback();
+ } catch (error) {
+ receiver.cancel();
+ if (error instanceof OrcaAuthError) throw error;
+ throw new OrcaAuthError(
+ "cancelled",
+ "OrcaRouter authorization was cancelled.",
+ );
+ }
+ // The state check is the only thing standing between this listener and
+ // a code somebody else's page dropped on it. Compare before anything
+ // else, and before the code is touched.
+ if (
+ callback.state === null ||
+ !constantTimeEqual(callback.state, attempt.state)
+ ) {
+ throw new OrcaAuthError(
+ "state_mismatch",
+ "The OrcaRouter authorization response did not match this request. Nothing was stored.",
+ );
+ }
+ if (callback.error) {
+ throw new OrcaAuthError(
+ "denied",
+ `OrcaRouter authorization was declined (${callback.error}).`,
+ );
+ }
+ if (!callback.code) {
+ throw new OrcaAuthError(
+ "invalid_response",
+ "The OrcaRouter authorization response did not include a code.",
+ );
+ }
+ code = callback.code;
+ } else {
+ if (!dependencies.requestCode) {
+ throw new OrcaAuthError(
+ "invalid_response",
+ "The out-of-band flow needs a way to read the code back.",
+ );
+ }
+ const authorizeUrl = buildUrl({
+ origins: dependencies.origins,
+ callbackUrl: "oob",
+ attempt,
+ appName: dependencies.appName,
+ scope: dependencies.scope,
+ loginHint: dependencies.loginHint,
+ workspaceHint: dependencies.workspaceHint,
+ });
+ dependencies.openBrowser?.(authorizeUrl);
+ const pasted = await dependencies.requestCode(authorizeUrl, signal);
+ const trimmed = pasted?.trim() ?? "";
+ if (!trimmed) {
+ throw new OrcaAuthError(
+ "cancelled",
+ "No OrcaRouter authorization code was entered.",
+ );
+ }
+ code = trimmed;
+ }
+
+ const result = await exchange({
+ origins: dependencies.origins,
+ code,
+ attempt,
+ fetch: dependencies.fetch,
+ signal,
+ acceptedScopes: dependencies.acceptedScopes,
+ });
+
+ return {
+ apiKey: result.key,
+ method: "orcarouter-oauth",
+ grantedScope: result.grantedScope,
+ accountId: result.userId,
+ };
+ },
+ };
+}
+
+/**
+ * Classify an inference failure. A `401` is terminal: the credential was
+ * revoked or is invalid, and the answer is to re-run the connect flow, never to
+ * attempt a refresh — OrcaRouter issues durable keys with no refresh grant.
+ */
+export function classifyOrcaInferenceFailure(status: number): {
+ readonly terminal: boolean;
+ readonly reason: string | null;
+} {
+ if (status === 401) {
+ return {
+ terminal: true,
+ reason:
+ "OrcaRouter rejected this key. It may have been revoked. Reconnect with OrcaRouter to issue a new key.",
+ };
+ }
+ if (status === 403) {
+ return {
+ terminal: true,
+ reason:
+ "OrcaRouter denied this key access to the requested resource. Check the workspace role that granted it.",
+ };
+ }
+ return { terminal: false, reason: null };
+}
diff --git a/packages/core/src/orcarouter/index.ts b/packages/core/src/orcarouter/index.ts
new file mode 100644
index 00000000..c1f54d97
--- /dev/null
+++ b/packages/core/src/orcarouter/index.ts
@@ -0,0 +1,106 @@
+/**
+ * OrcaRouter as a first-class provider for json-render.
+ *
+ * OrcaRouter is an OpenAI-compatible AI gateway that routes many providers
+ * behind one endpoint. This module is the reusable seam: origin resolution,
+ * the two credential sources (a pasted API key and an OAuth 2.0 + PKCE login),
+ * the generation-safe credential store, and live model discovery.
+ *
+ * The inference transport itself is supplied by the host through
+ * `@ai-sdk/openai-compatible`, so this package stays dependency-light.
+ */
+
+export {
+ DEFAULT_ORCAROUTER_API_BASE_URL,
+ DEFAULT_ORCAROUTER_AUTH_BASE_URL,
+ ORCAROUTER_AUTHORIZE_PATH,
+ ORCAROUTER_CHAT_COMPLETIONS_PATH,
+ ORCAROUTER_DEVICE_CODE_PATH,
+ ORCAROUTER_DEVICE_TOKEN_PATH,
+ ORCAROUTER_EXCHANGE_PATH,
+ ORCAROUTER_KEY_DASHBOARD_URL,
+ ORCAROUTER_MODELS_PATH,
+ isLoopbackHostname,
+ orcarouterAuthorizeUrl,
+ orcarouterChatCompletionsUrl,
+ orcarouterExchangeUrl,
+ orcarouterModelsUrl,
+ readOrcarouterOriginOverrides,
+ resolveOrcarouterOrigins,
+ resolveOrcarouterOriginsFromEnv,
+ validateCallbackUrl,
+} from "./origins";
+export type { OrcarouterOriginOverrides, OrcarouterOrigins } from "./origins";
+
+export {
+ ORCAROUTER_UNSUPPORTED_FLOWS,
+ OrcaAuthError,
+ base64UrlEncode,
+ buildOrcaAuthorizeUrl,
+ constantTimeEqual,
+ createOrcaPkceAttempt,
+ exchangeOrcaAuthCode,
+} from "./pkce";
+export type {
+ OrcaAuthErrorCode,
+ OrcaAuthorizationScope,
+ OrcaAuthorizeUrlOptions,
+ OrcaExchangeOptions,
+ OrcaExchangeResult,
+ OrcaPkceAttempt,
+} from "./pkce";
+
+export {
+ ORCAROUTER_SEED_MODELS,
+ OrcaCatalogError,
+ DEFAULT_CATALOG_TIMEOUT_MS,
+ MAX_CATALOG_BYTES,
+ MAX_CATALOG_MODELS,
+ fetchOrcaCatalog,
+ isOrcaModelCompatible,
+ modelSupportsCapability,
+ modelSupportsInputModality,
+ orcaCatalogUrl,
+ orcarouterSeedCatalog,
+ parseOrcaCatalogPayload,
+ parseOrcaModelRecord,
+ selectOrcaModels,
+} from "./catalog";
+export type {
+ FetchOrcaCatalogOptions,
+ OrcaCapability,
+ OrcaCatalog,
+ OrcaInputModality,
+ OrcaModel,
+ OrcaModelArchitecture,
+ SelectOrcaModelsOptions,
+} from "./catalog";
+
+export {
+ ORCA_API_KEY_PREFIX,
+ classifyOrcaInferenceFailure,
+ createOrcaApiKeySource,
+ createOrcaPkceSource,
+ redactOrcaApiKey,
+ validateOrcaApiKey,
+} from "./credential";
+export type {
+ OrcaApiKeyValidation,
+ OrcaCallbackResult,
+ OrcaCodeReceiver,
+ OrcaCredentialMethod,
+ OrcaCredentialResult,
+ OrcaCredentialSource,
+ OrcaPkceSourceDependencies,
+ OrcaStoredCredential,
+} from "./credential";
+
+export {
+ OrcaCredentialStore,
+ deserializeOrcaCredentialStore,
+ serializeOrcaCredentialStore,
+} from "./credential-store";
+export type {
+ OrcaCredentialStoreSnapshot,
+ SerializedOrcaCredentialStore,
+} from "./credential-store";
diff --git a/packages/core/src/orcarouter/origins.test.ts b/packages/core/src/orcarouter/origins.test.ts
new file mode 100644
index 00000000..71a7cb7a
--- /dev/null
+++ b/packages/core/src/orcarouter/origins.test.ts
@@ -0,0 +1,152 @@
+// @vitest-environment node
+import { describe, expect, it } from "vitest";
+import {
+ DEFAULT_ORCAROUTER_API_BASE_URL,
+ DEFAULT_ORCAROUTER_AUTH_BASE_URL,
+ isLoopbackHostname,
+ orcarouterAuthorizeUrl,
+ orcarouterChatCompletionsUrl,
+ orcarouterExchangeUrl,
+ orcarouterModelsUrl,
+ resolveOrcarouterOrigins,
+ validateCallbackUrl,
+} from "./origins";
+
+describe("resolveOrcarouterOrigins", () => {
+ it("defaults auth to www and inference to api, and never derives one from the other", () => {
+ const origins = resolveOrcarouterOrigins();
+ expect(origins.authBaseUrl).toBe(DEFAULT_ORCAROUTER_AUTH_BASE_URL);
+ expect(origins.apiBaseUrl).toBe(DEFAULT_ORCAROUTER_API_BASE_URL);
+ expect(orcarouterAuthorizeUrl(origins)).toBe(
+ "https://www.orcarouter.ai/auth",
+ );
+ expect(orcarouterExchangeUrl(origins)).toBe(
+ "https://www.orcarouter.ai/api/v1/auth/keys",
+ );
+ expect(orcarouterModelsUrl(origins)).toBe(
+ "https://api.orcarouter.ai/v1/models",
+ );
+ expect(orcarouterChatCompletionsUrl(origins)).toBe(
+ "https://api.orcarouter.ai/v1/chat/completions",
+ );
+ });
+
+ it("never produces the inference origin carrying an auth path", () => {
+ const origins = resolveOrcarouterOrigins();
+ expect(orcarouterExchangeUrl(origins)).not.toContain("api.orcarouter.ai");
+ expect(orcarouterExchangeUrl(origins)).not.toBe(
+ "https://api.orcarouter.ai/v1/auth/keys",
+ );
+ expect(orcarouterModelsUrl(origins)).not.toContain("www.orcarouter.ai");
+ });
+
+ it("uses a shared self-hosted base for both roles", () => {
+ const origins = resolveOrcarouterOrigins({
+ baseUrl: "https://orca.internal.example",
+ });
+ expect(origins.authBaseUrl).toBe("https://orca.internal.example");
+ expect(origins.apiBaseUrl).toBe("https://orca.internal.example");
+ });
+
+ it("lets explicit per-role overrides win over the shared base", () => {
+ const origins = resolveOrcarouterOrigins({
+ baseUrl: "https://shared.example",
+ authBaseUrl: "https://auth.example",
+ apiBaseUrl: "https://relay.example",
+ });
+ expect(origins.authBaseUrl).toBe("https://auth.example");
+ expect(origins.apiBaseUrl).toBe("https://relay.example");
+ expect(orcarouterExchangeUrl(origins)).toBe(
+ "https://auth.example/api/v1/auth/keys",
+ );
+ expect(orcarouterModelsUrl(origins)).toBe(
+ "https://relay.example/v1/models",
+ );
+ });
+
+ it("rejects plain http for a non-loopback origin", () => {
+ expect(() =>
+ resolveOrcarouterOrigins({ baseUrl: "http://orca.example" }),
+ ).toThrow(/https unless it points at loopback/);
+ expect(() =>
+ resolveOrcarouterOrigins({ apiBaseUrl: "http://10.0.0.4:8080" }),
+ ).toThrow(/https unless it points at loopback/);
+ });
+
+ it("permits plain http on loopback for local development", () => {
+ const origins = resolveOrcarouterOrigins({
+ baseUrl: "http://127.0.0.1:8080",
+ });
+ expect(origins.authBaseUrl).toBe("http://127.0.0.1:8080");
+ expect(origins.apiBaseUrl).toBe("http://127.0.0.1:8080");
+ });
+
+ it.each([
+ ["not-a-url", /not a valid absolute URL/],
+ ["ftp://orca.example", /must use http or https/],
+ ["https://user:pass@orca.example", /must not contain userinfo/],
+ ["https://orca.example?a=1", /must not contain a query or fragment/],
+ ["https://orca.example#frag", /must not contain a query or fragment/],
+ ])("rejects %s", (value, pattern) => {
+ expect(() => resolveOrcarouterOrigins({ baseUrl: value })).toThrow(pattern);
+ });
+
+ it("falls back to the defaults when an override is empty or whitespace", () => {
+ for (const value of ["", " "]) {
+ const origins = resolveOrcarouterOrigins({ baseUrl: value });
+ expect(origins.authBaseUrl).toBe(DEFAULT_ORCAROUTER_AUTH_BASE_URL);
+ expect(origins.apiBaseUrl).toBe(DEFAULT_ORCAROUTER_API_BASE_URL);
+ }
+ });
+
+ it("strips a trailing slash so paths never double up", () => {
+ const origins = resolveOrcarouterOrigins({
+ baseUrl: "https://orca.example/",
+ });
+ expect(orcarouterModelsUrl(origins)).toBe("https://orca.example/v1/models");
+ });
+});
+
+describe("isLoopbackHostname", () => {
+ it.each(["localhost", "127.0.0.1", "::1", "[::1]", "LOCALHOST"])(
+ "accepts %s",
+ (host) => {
+ expect(isLoopbackHostname(host)).toBe(true);
+ },
+ );
+
+ it.each(["orca.example", "127.0.0.1.evil.example", "0.0.0.0"])(
+ "rejects %s",
+ (host) => {
+ expect(isLoopbackHostname(host)).toBe(false);
+ },
+ );
+});
+
+describe("validateCallbackUrl", () => {
+ it("accepts the literal oob marker", () => {
+ expect(validateCallbackUrl("oob")).toBe("oob");
+ });
+
+ it("accepts http on loopback and https on any host", () => {
+ expect(validateCallbackUrl("http://127.0.0.1:51733/cb")).toContain(
+ "127.0.0.1:51733",
+ );
+ expect(validateCallbackUrl("http://localhost:1234/cb")).toContain(
+ "localhost:1234",
+ );
+ expect(validateCallbackUrl("https://tool.example/cb")).toContain(
+ "https://tool.example",
+ );
+ });
+
+ it.each([
+ ["http://tool.example/cb", /only allowed for localhost/],
+ ["https://user:pass@tool.example/cb", /must not contain userinfo/],
+ ["https://tool.example/cb#frag", /must not contain a fragment/],
+ ["/cb", /not a valid absolute URL/],
+ ["ftp://tool.example/cb", /must use https/],
+ ])("rejects %s", (value, pattern) => {
+ expect(() => validateCallbackUrl(value)).toThrow(pattern);
+ });
+});
diff --git a/packages/core/src/orcarouter/origins.ts b/packages/core/src/orcarouter/origins.ts
new file mode 100644
index 00000000..cdbc69dd
--- /dev/null
+++ b/packages/core/src/orcarouter/origins.ts
@@ -0,0 +1,177 @@
+/**
+ * OrcaRouter origin resolution.
+ *
+ * Authentication and inference live on two different public origins. They must
+ * never be derived from one another by swapping a hostname or appending `/v1`:
+ * `https://api.orcarouter.ai/v1/auth/keys` is a 404.
+ */
+
+export const DEFAULT_ORCAROUTER_AUTH_BASE_URL = "https://www.orcarouter.ai";
+export const DEFAULT_ORCAROUTER_API_BASE_URL = "https://api.orcarouter.ai";
+
+/** Consent screen. Opened in a browser; not an API. */
+export const ORCAROUTER_AUTHORIZE_PATH = "/auth";
+/** Auth-code exchange. On the auth origin, not under `/v1`. */
+export const ORCAROUTER_EXCHANGE_PATH = "/api/v1/auth/keys";
+export const ORCAROUTER_DEVICE_CODE_PATH = "/api/v1/auth/device/code";
+export const ORCAROUTER_DEVICE_TOKEN_PATH = "/api/v1/auth/device/token";
+/** Model catalog, on the inference origin. */
+export const ORCAROUTER_MODELS_PATH = "/v1/models";
+export const ORCAROUTER_CHAT_COMPLETIONS_PATH = "/v1/chat/completions";
+
+export const ORCAROUTER_KEY_DASHBOARD_URL =
+ "https://www.orcarouter.ai/console/authorized-apps";
+
+export interface OrcarouterOrigins {
+ /** Origin used for `/auth` and the code exchange. */
+ readonly authBaseUrl: string;
+ /** Origin used for `/v1/models` and inference. */
+ readonly apiBaseUrl: string;
+}
+
+export interface OrcarouterOriginOverrides {
+ /** One origin serving both roles, for self-hosted deployments. */
+ readonly baseUrl?: string;
+ /** Explicit override; wins over `baseUrl`. */
+ readonly authBaseUrl?: string;
+ /** Explicit override; wins over `baseUrl`. */
+ readonly apiBaseUrl?: string;
+}
+
+const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
+
+export function isLoopbackHostname(hostname: string): boolean {
+ return LOOPBACK_HOSTNAMES.has(hostname.toLowerCase());
+}
+
+function normalizeBaseUrl(value: string, role: string): string {
+ const trimmed = value.trim();
+ if (!trimmed) {
+ throw new Error(`The OrcaRouter ${role} base URL must not be empty.`);
+ }
+ let url: URL;
+ try {
+ url = new URL(trimmed);
+ } catch {
+ throw new Error(
+ `The OrcaRouter ${role} base URL is not a valid absolute URL.`,
+ );
+ }
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
+ throw new Error(`The OrcaRouter ${role} base URL must use http or https.`);
+ }
+ // Plain HTTP is only acceptable when the target is the local machine.
+ if (url.protocol === "http:" && !isLoopbackHostname(url.hostname)) {
+ throw new Error(
+ `The OrcaRouter ${role} base URL must use https unless it points at loopback.`,
+ );
+ }
+ if (url.username || url.password) {
+ throw new Error(
+ `The OrcaRouter ${role} base URL must not contain userinfo.`,
+ );
+ }
+ if (url.search || url.hash) {
+ throw new Error(
+ `The OrcaRouter ${role} base URL must not contain a query or fragment.`,
+ );
+ }
+ const pathname = url.pathname.replace(/\/+$/, "");
+ return `${url.origin}${pathname}`;
+}
+
+/**
+ * Resolve the auth and inference origins. Explicit per-role overrides take
+ * precedence over the shared self-hosted base, which in turn falls back to the
+ * public defaults.
+ */
+export function resolveOrcarouterOrigins(
+ overrides: OrcarouterOriginOverrides = {},
+): OrcarouterOrigins {
+ const shared = overrides.baseUrl?.trim();
+ const auth =
+ overrides.authBaseUrl?.trim() || shared || DEFAULT_ORCAROUTER_AUTH_BASE_URL;
+ const api =
+ overrides.apiBaseUrl?.trim() || shared || DEFAULT_ORCAROUTER_API_BASE_URL;
+ return {
+ authBaseUrl: normalizeBaseUrl(auth, "auth"),
+ apiBaseUrl: normalizeBaseUrl(api, "inference"),
+ };
+}
+
+/** Read overrides from the process environment, if present. */
+export function readOrcarouterOriginOverrides(
+ env: Record,
+): OrcarouterOriginOverrides {
+ return {
+ baseUrl: env.ORCA_BASE_URL,
+ authBaseUrl: env.ORCA_AUTH_BASE_URL,
+ apiBaseUrl: env.ORCA_API_BASE_URL,
+ };
+}
+
+/** Read overrides from `process.env` without requiring Node types. */
+export function resolveOrcarouterOriginsFromEnv(): OrcarouterOrigins {
+ const env = (
+ globalThis as { process?: { env?: Record } }
+ ).process?.env;
+ return resolveOrcarouterOrigins(
+ env ? readOrcarouterOriginOverrides(env) : {},
+ );
+}
+
+function joinUrl(baseUrl: string, path: string): string {
+ return `${baseUrl}${path}`;
+}
+
+export function orcarouterAuthorizeUrl(origins: OrcarouterOrigins): string {
+ return joinUrl(origins.authBaseUrl, ORCAROUTER_AUTHORIZE_PATH);
+}
+
+export function orcarouterExchangeUrl(origins: OrcarouterOrigins): string {
+ return joinUrl(origins.authBaseUrl, ORCAROUTER_EXCHANGE_PATH);
+}
+
+export function orcarouterModelsUrl(origins: OrcarouterOrigins): string {
+ return joinUrl(origins.apiBaseUrl, ORCAROUTER_MODELS_PATH);
+}
+
+export function orcarouterChatCompletionsUrl(
+ origins: OrcarouterOrigins,
+): string {
+ return joinUrl(origins.apiBaseUrl, ORCAROUTER_CHAT_COMPLETIONS_PATH);
+}
+
+/**
+ * Validate a `callback_url` the way the consent endpoint does, before a browser
+ * is ever opened, so a doomed attempt fails in-process.
+ */
+export function validateCallbackUrl(callbackUrl: string): string {
+ if (callbackUrl === "oob") return callbackUrl;
+ let url: URL;
+ try {
+ url = new URL(callbackUrl);
+ } catch {
+ throw new Error("The OAuth callback URL is not a valid absolute URL.");
+ }
+ if (url.username || url.password) {
+ throw new Error("The OAuth callback URL must not contain userinfo.");
+ }
+ if (url.hash) {
+ throw new Error("The OAuth callback URL must not contain a fragment.");
+ }
+ if (url.protocol === "http:") {
+ if (!isLoopbackHostname(url.hostname)) {
+ throw new Error(
+ "An http OAuth callback URL is only allowed for localhost, 127.0.0.1 or [::1].",
+ );
+ }
+ return url.toString();
+ }
+ if (url.protocol !== "https:") {
+ throw new Error(
+ "The OAuth callback URL must use https, or http on loopback.",
+ );
+ }
+ return url.toString();
+}
diff --git a/packages/core/src/orcarouter/pkce.test.ts b/packages/core/src/orcarouter/pkce.test.ts
new file mode 100644
index 00000000..2a7d6fcd
--- /dev/null
+++ b/packages/core/src/orcarouter/pkce.test.ts
@@ -0,0 +1,348 @@
+// @vitest-environment node
+import { describe, expect, it, vi } from "vitest";
+import {
+ OrcaAuthError,
+ base64UrlEncode,
+ buildOrcaAuthorizeUrl,
+ constantTimeEqual,
+ createOrcaPkceAttempt,
+ exchangeOrcaAuthCode,
+} from "./pkce";
+import { resolveOrcarouterOrigins } from "./origins";
+
+const origins = resolveOrcarouterOrigins();
+const originsFor = (base: string) =>
+ resolveOrcarouterOrigins({ authBaseUrl: base, apiBaseUrl: base });
+
+describe("base64UrlEncode", () => {
+ it("produces unpadded base64url", () => {
+ const encoded = base64UrlEncode(new Uint8Array([251, 255, 191, 0, 1]));
+ expect(encoded).not.toMatch(/[+/=]/);
+ expect(encoded).toBe("-_-_AAE");
+ });
+
+ it("handles every input length without padding", () => {
+ for (let length = 0; length <= 8; length += 1) {
+ const encoded = base64UrlEncode(new Uint8Array(length).fill(255));
+ expect(encoded).not.toContain("=");
+ }
+ });
+});
+
+describe("createOrcaPkceAttempt", () => {
+ it("derives an S256 challenge from the verifier", async () => {
+ const attempt = await createOrcaPkceAttempt();
+ const expected = base64UrlEncode(
+ new Uint8Array(
+ await globalThis.crypto.subtle.digest(
+ "SHA-256",
+ new TextEncoder().encode(attempt.codeVerifier),
+ ),
+ ),
+ );
+ expect(attempt.codeChallenge).toBe(expected);
+ expect(attempt.codeChallenge).not.toBe(attempt.codeVerifier);
+ expect(attempt.codeChallenge).toHaveLength(43);
+ expect(attempt.codeChallenge).not.toContain("=");
+ expect(attempt.codeVerifier.length).toBeGreaterThanOrEqual(43);
+ expect(attempt.state.length).toBeGreaterThanOrEqual(20);
+ });
+
+ it("uses a fresh verifier and state for every attempt", async () => {
+ const attempts = await Promise.all(
+ Array.from({ length: 16 }, () => createOrcaPkceAttempt()),
+ );
+ expect(new Set(attempts.map((a) => a.codeVerifier)).size).toBe(16);
+ expect(new Set(attempts.map((a) => a.state)).size).toBe(16);
+ expect(new Set(attempts.map((a) => a.codeChallenge)).size).toBe(16);
+ });
+
+ it("does not derive the verifier from anything guessable", async () => {
+ const attempt = await createOrcaPkceAttempt();
+ expect(attempt.codeVerifier).not.toContain(
+ String(new Date().getFullYear()),
+ );
+ // 32 random bytes encoded is exactly 43 base64url characters.
+ expect(attempt.codeVerifier).toHaveLength(43);
+ });
+});
+
+describe("constantTimeEqual", () => {
+ it("compares equal and unequal values", () => {
+ expect(constantTimeEqual("abc", "abc")).toBe(true);
+ expect(constantTimeEqual("abc", "abd")).toBe(false);
+ expect(constantTimeEqual("abc", "abcd")).toBe(false);
+ expect(constantTimeEqual("", "")).toBe(true);
+ });
+});
+
+describe("buildOrcaAuthorizeUrl", () => {
+ const attempt = {
+ codeVerifier: "verifier-value",
+ codeChallenge: "challenge-value",
+ state: "state-value",
+ };
+
+ it("targets /auth on the auth origin with S256 and no plain fallback", () => {
+ const url = new URL(
+ buildOrcaAuthorizeUrl({
+ origins,
+ callbackUrl: "http://127.0.0.1:51733/cb",
+ attempt,
+ appName: "json-render",
+ }),
+ );
+ expect(url.origin).toBe("https://www.orcarouter.ai");
+ expect(url.pathname).toBe("/auth");
+ expect(url.searchParams.get("code_challenge_method")).toBe("S256");
+ expect(url.searchParams.get("code_challenge")).toBe("challenge-value");
+ expect(url.searchParams.get("state")).toBe("state-value");
+ expect(url.searchParams.get("scope")).toBe("api");
+ expect(url.searchParams.get("app_name")).toBe("json-render");
+ });
+
+ it("never puts the verifier in the URL", () => {
+ const url = buildOrcaAuthorizeUrl({
+ origins,
+ callbackUrl: "oob",
+ attempt,
+ appName: "json-render",
+ });
+ expect(url).not.toContain(attempt.codeVerifier);
+ expect(url).not.toContain("code_verifier");
+ expect(new URL(url).searchParams.get("callback_url")).toBe("oob");
+ });
+
+ it("sends S256 even on the loopback flow, because the user may pick a shown code", () => {
+ const url = new URL(
+ buildOrcaAuthorizeUrl({
+ origins,
+ callbackUrl: "https://tool.example/cb",
+ attempt,
+ appName: "json-render",
+ }),
+ );
+ expect(url.searchParams.get("code_challenge_method")).toBe("S256");
+ });
+
+ it("passes optional hints through and omits absent ones", () => {
+ const url = new URL(
+ buildOrcaAuthorizeUrl({
+ origins,
+ callbackUrl: "oob",
+ attempt,
+ appName: "json-render",
+ loginHint: "user@example.test",
+ workspaceHint: "ws-1",
+ prompt: "consent",
+ scope: "api",
+ }),
+ );
+ expect(url.searchParams.get("login_hint")).toBe("user@example.test");
+ expect(url.searchParams.get("workspace_hint")).toBe("ws-1");
+ expect(url.searchParams.get("prompt")).toBe("consent");
+
+ const bare = new URL(
+ buildOrcaAuthorizeUrl({
+ origins,
+ callbackUrl: "oob",
+ attempt,
+ appName: "json-render",
+ }),
+ );
+ expect(bare.searchParams.has("login_hint")).toBe(false);
+ expect(bare.searchParams.has("prompt")).toBe(false);
+ });
+
+ it("rejects a callback URL the consent endpoint would reject", () => {
+ expect(() =>
+ buildOrcaAuthorizeUrl({
+ origins,
+ callbackUrl: "http://tool.example/cb",
+ attempt,
+ appName: "json-render",
+ }),
+ ).toThrow(/only allowed for localhost/);
+ });
+});
+
+describe("exchangeOrcaAuthCode", () => {
+ const attempt = {
+ codeVerifier: "the-verifier",
+ codeChallenge: "the-challenge",
+ state: "the-state",
+ };
+
+ it("posts the code, verifier and method to the auth origin's exchange path", async () => {
+ const fetch = vi.fn(async () =>
+ Response.json({ key: "sk-orca-test-key", user_id: "42", scope: "api" }),
+ );
+ const result = await exchangeOrcaAuthCode({
+ origins,
+ code: "the-code",
+ attempt,
+ fetch,
+ });
+
+ expect(result).toEqual({
+ key: "sk-orca-test-key",
+ userId: "42",
+ grantedScope: "api",
+ });
+
+ const [url, init] = fetch.mock.calls[0]!;
+ expect(url).toBe("https://www.orcarouter.ai/api/v1/auth/keys");
+ expect(url).not.toContain("api.orcarouter.ai");
+ expect(url).not.toMatch(/^https:\/\/[^/]+\/v1\/auth/);
+ expect(init?.method).toBe("POST");
+ expect(JSON.parse(init!.body as string)).toEqual({
+ code: "the-code",
+ code_verifier: "the-verifier",
+ code_challenge_method: "S256",
+ });
+ });
+
+ it("reports a 403 as a terminal exchange failure without echoing the body", async () => {
+ const fetch = vi.fn(async () =>
+ Response.json(
+ { error: "invalid_grant", error_description: "sensitive detail" },
+ { status: 403 },
+ ),
+ );
+ const error = await exchangeOrcaAuthCode({
+ origins,
+ code: "used-code",
+ attempt,
+ fetch,
+ }).catch((e: unknown) => e);
+ expect(error).toBeInstanceOf(OrcaAuthError);
+ expect((error as OrcaAuthError).code).toBe("exchange_failed");
+ expect((error as OrcaAuthError).status).toBe(403);
+ expect((error as Error).message).toContain("invalid_grant");
+ expect((error as Error).message).not.toContain("sensitive detail");
+ expect((error as Error).message).not.toContain("used-code");
+ expect((error as Error).message).not.toContain("the-verifier");
+ });
+
+ it("reports 400, 429 and other statuses distinctly", async () => {
+ const at = (status: number) =>
+ exchangeOrcaAuthCode({
+ origins,
+ code: "c",
+ attempt,
+ fetch: async () => new Response("", { status }),
+ }).catch((e: unknown) => e as OrcaAuthError);
+
+ await expect(at(400)).resolves.toMatchObject({
+ code: "exchange_failed",
+ status: 400,
+ });
+ await expect(at(429)).resolves.toMatchObject({
+ code: "exchange_failed",
+ status: 429,
+ });
+ const other = await at(500);
+ expect(other.message).toContain("status 500");
+ });
+
+ it("treats a granted scope narrower than the client needs as unusable", async () => {
+ const error = await exchangeOrcaAuthCode({
+ origins,
+ code: "c",
+ attempt,
+ fetch: async () =>
+ Response.json({ key: "sk-orca-x", scope: "read-only" }),
+ }).catch((e: unknown) => e as OrcaAuthError);
+ expect(error.code).toBe("insufficient_scope");
+ expect(error.message).toContain("read-only");
+ });
+
+ it("accepts a response with no scope field, and an empty accepted set is honoured", async () => {
+ const result = await exchangeOrcaAuthCode({
+ origins,
+ code: "c",
+ attempt,
+ fetch: async () => Response.json({ key: "sk-orca-x" }),
+ });
+ expect(result.grantedScope).toBeNull();
+ expect(result.userId).toBeNull();
+
+ const strict = await exchangeOrcaAuthCode({
+ origins,
+ code: "c",
+ attempt,
+ acceptedScopes: [],
+ fetch: async () => Response.json({ key: "sk-orca-x", scope: "api" }),
+ }).catch((e: unknown) => e as OrcaAuthError);
+ expect(strict.code).toBe("insufficient_scope");
+ });
+
+ it.each([
+ ["a missing key", { user_id: "1", scope: "api" }],
+ ["an empty key", { key: " " }],
+ ["a non-string key", { key: 123 }],
+ ])("rejects a malformed success response: %s", async (_label, body) => {
+ const error = await exchangeOrcaAuthCode({
+ origins,
+ code: "c",
+ attempt,
+ fetch: async () => Response.json(body),
+ }).catch((e: unknown) => e as OrcaAuthError);
+ expect(error.code).toBe("invalid_response");
+ });
+
+ it("surfaces a transport failure as a network error, not a hang", async () => {
+ const error = await exchangeOrcaAuthCode({
+ origins,
+ code: "c",
+ attempt,
+ fetch: async () => {
+ throw new TypeError("fetch failed");
+ },
+ }).catch((e: unknown) => e as OrcaAuthError);
+ expect(error.code).toBe("network");
+ });
+
+ it("reports cancellation when the caller aborts", async () => {
+ const controller = new AbortController();
+ const error = await exchangeOrcaAuthCode({
+ origins,
+ code: "c",
+ attempt,
+ signal: controller.signal,
+ fetch: async () => {
+ controller.abort();
+ throw new DOMException("aborted", "AbortError");
+ },
+ }).catch((e: unknown) => e as OrcaAuthError);
+ expect(error.code).toBe("cancelled");
+ });
+
+ it("rejects an empty code before making a request", async () => {
+ const fetch = vi.fn();
+ const error = await exchangeOrcaAuthCode({
+ origins,
+ code: " ",
+ attempt,
+ fetch,
+ }).catch((e: unknown) => e as OrcaAuthError);
+ expect(error.code).toBe("invalid_response");
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it("follows an explicit self-hosted auth origin", async () => {
+ const selfHosted = originsFor("https://orca.internal.example");
+ const fetch = vi.fn(async () =>
+ Response.json({ key: "sk-orca-x", scope: "api" }),
+ );
+ await exchangeOrcaAuthCode({
+ origins: selfHosted,
+ code: "c",
+ attempt,
+ fetch,
+ });
+ expect(fetch.mock.calls[0]![0]).toBe(
+ "https://orca.internal.example/api/v1/auth/keys",
+ );
+ });
+});
diff --git a/packages/core/src/orcarouter/pkce.ts b/packages/core/src/orcarouter/pkce.ts
new file mode 100644
index 00000000..22f7ab74
--- /dev/null
+++ b/packages/core/src/orcarouter/pkce.ts
@@ -0,0 +1,302 @@
+/**
+ * OAuth 2.0 authorization-code flow with PKCE for OrcaRouter.
+ *
+ * Flow A (loopback redirect) and Flow B (out-of-band code) share everything
+ * except how the code reaches the process. The exchange always presents the
+ * original verifier; the verifier never leaves the process and is never logged,
+ * printed, or placed in a URL.
+ *
+ * No client secret is involved and no redirect URI is pre-registered.
+ */
+
+import {
+ ORCAROUTER_AUTHORIZE_PATH,
+ ORCAROUTER_EXCHANGE_PATH,
+ validateCallbackUrl,
+ type OrcarouterOrigins,
+} from "./origins";
+
+export type OrcaAuthErrorCode =
+ | "denied"
+ | "state_mismatch"
+ | "cancelled"
+ | "timeout"
+ | "exchange_failed"
+ | "invalid_response"
+ | "insufficient_scope"
+ | "network";
+
+/**
+ * A terminal, actionable failure. Messages are built only from status codes and
+ * protocol-level error names; upstream response bodies are never echoed because
+ * they may contain credential material.
+ */
+export class OrcaAuthError extends Error {
+ readonly code: OrcaAuthErrorCode;
+ readonly status?: number;
+
+ constructor(code: OrcaAuthErrorCode, message: string, status?: number) {
+ super(message);
+ this.name = "OrcaAuthError";
+ this.code = code;
+ this.status = status;
+ }
+}
+
+export interface OrcaPkceAttempt {
+ /** High-entropy secret. Stays in-process until the exchange. */
+ readonly codeVerifier: string;
+ /** `base64url(sha256(verifier))`, no padding. Safe to put in a URL. */
+ readonly codeChallenge: string;
+ /** Opaque CSRF token, echoed back by the consent screen. */
+ readonly state: string;
+}
+
+const BASE64URL_ALPHABET =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
+
+/** Unpadded base64url. Implemented locally so no Buffer is required. */
+export function base64UrlEncode(bytes: Uint8Array): string {
+ let out = "";
+ for (let i = 0; i < bytes.length; i += 3) {
+ const b0 = bytes[i]!;
+ const b1 = bytes[i + 1];
+ const b2 = bytes[i + 2];
+ out += BASE64URL_ALPHABET[b0 >> 2];
+ out += BASE64URL_ALPHABET[((b0 & 0x03) << 4) | ((b1 ?? 0) >> 4)];
+ if (b1 === undefined) break;
+ out += BASE64URL_ALPHABET[((b1 & 0x0f) << 2) | ((b2 ?? 0) >> 6)];
+ if (b2 === undefined) break;
+ out += BASE64URL_ALPHABET[b2 & 0x3f];
+ }
+ return out;
+}
+
+function randomBytes(length: number): Uint8Array {
+ const bytes = new Uint8Array(length);
+ globalThis.crypto.getRandomValues(bytes);
+ return bytes;
+}
+
+async function sha256(value: string): Promise {
+ const digest = await globalThis.crypto.subtle.digest(
+ "SHA-256",
+ new TextEncoder().encode(value),
+ );
+ return new Uint8Array(digest);
+}
+
+/**
+ * Build a fresh attempt. A new verifier and state are generated for every
+ * authorization attempt from a cryptographic RNG.
+ */
+export async function createOrcaPkceAttempt(): Promise {
+ const codeVerifier = base64UrlEncode(randomBytes(32));
+ return {
+ codeVerifier,
+ codeChallenge: base64UrlEncode(await sha256(codeVerifier)),
+ state: base64UrlEncode(randomBytes(16)),
+ };
+}
+
+/** Length-independent comparison, used for the Flow A `state` check. */
+export function constantTimeEqual(a: string, b: string): boolean {
+ const left = new TextEncoder().encode(a);
+ const right = new TextEncoder().encode(b);
+ let diff = left.length ^ right.length;
+ const max = Math.max(left.length, right.length);
+ for (let i = 0; i < max; i += 1) {
+ diff |= (left[i] ?? 0) ^ (right[i] ?? 0);
+ }
+ return diff === 0;
+}
+
+export type OrcaAuthorizationScope = "api" | "connector";
+
+export interface OrcaAuthorizeUrlOptions {
+ readonly origins: OrcarouterOrigins;
+ /** An absolute URL, or the literal `oob` for the out-of-band flow. */
+ readonly callbackUrl: string;
+ readonly attempt: OrcaPkceAttempt;
+ readonly appName: string;
+ /** Defaults to `api`, the scope inference requires. */
+ readonly scope?: OrcaAuthorizationScope;
+ readonly loginHint?: string;
+ readonly workspaceHint?: string;
+ readonly prompt?: "consent";
+}
+
+export function buildOrcaAuthorizeUrl(
+ options: OrcaAuthorizeUrlOptions,
+): string {
+ const url = new URL(ORCAROUTER_AUTHORIZE_PATH, options.origins.authBaseUrl);
+ url.searchParams.set(
+ "callback_url",
+ validateCallbackUrl(options.callbackUrl),
+ );
+ url.searchParams.set("code_challenge", options.attempt.codeChallenge);
+ // Always S256: the consent screen lets the user choose "show me a code" even
+ // on a redirect flow, and a displayed code must not be redeemable by anyone
+ // who merely saw the authorize URL.
+ url.searchParams.set("code_challenge_method", "S256");
+ url.searchParams.set("state", options.attempt.state);
+ url.searchParams.set("app_name", options.appName);
+ url.searchParams.set("scope", options.scope ?? "api");
+ if (options.loginHint) url.searchParams.set("login_hint", options.loginHint);
+ if (options.workspaceHint)
+ url.searchParams.set("workspace_hint", options.workspaceHint);
+ if (options.prompt) url.searchParams.set("prompt", options.prompt);
+ return url.toString();
+}
+
+export interface OrcaExchangeResult {
+ /** A normal, durable OrcaRouter API key. Not a refresh token. */
+ readonly key: string;
+ readonly userId: string | null;
+ /** The scope that was *granted*, or null when the server did not report one. */
+ readonly grantedScope: string | null;
+}
+
+const SAFE_ERROR_NAME = /^[a-z_]{1,40}$/;
+
+/** Extract only a protocol-level error name; never the rest of the body. */
+function safeErrorName(body: unknown): string | null {
+ if (typeof body !== "object" || body === null) return null;
+ const value = (body as Record).error;
+ return typeof value === "string" && SAFE_ERROR_NAME.test(value)
+ ? value
+ : null;
+}
+
+export interface OrcaExchangeOptions {
+ readonly origins: OrcarouterOrigins;
+ readonly code: string;
+ readonly attempt: OrcaPkceAttempt;
+ readonly fetch?: typeof globalThis.fetch;
+ readonly signal?: AbortSignal;
+ /** Scopes this client can actually use. Defaults to `["api"]`. */
+ readonly acceptedScopes?: readonly string[];
+}
+
+/**
+ * Redeem an auth code at the auth origin's `/api/v1/auth/keys`. Auth codes are
+ * single-use with a 10 minute TTL.
+ */
+export async function exchangeOrcaAuthCode(
+ options: OrcaExchangeOptions,
+): Promise {
+ const fetchImpl = options.fetch ?? globalThis.fetch;
+ const code = options.code?.trim();
+ if (!code) {
+ throw new OrcaAuthError(
+ "invalid_response",
+ "No authorization code was provided.",
+ );
+ }
+
+ let response: Response;
+ try {
+ response = await fetchImpl(
+ `${options.origins.authBaseUrl}${ORCAROUTER_EXCHANGE_PATH}`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ code,
+ code_verifier: options.attempt.codeVerifier,
+ code_challenge_method: "S256",
+ }),
+ signal: options.signal,
+ cache: "no-store",
+ },
+ );
+ } catch (error) {
+ if (error instanceof OrcaAuthError) throw error;
+ if (options.signal?.aborted) {
+ throw new OrcaAuthError(
+ "cancelled",
+ "OrcaRouter authorization was cancelled.",
+ );
+ }
+ throw new OrcaAuthError(
+ "network",
+ "Could not reach the OrcaRouter authorization service. Check your network and try again.",
+ );
+ }
+
+ if (!response.ok) {
+ const body = await response.json().catch(() => null);
+ const name = safeErrorName(body);
+ const suffix = name ? ` (${name})` : "";
+ if (response.status === 403) {
+ throw new OrcaAuthError(
+ "exchange_failed",
+ `OrcaRouter rejected this authorization code: it is unknown, expired, already used, or the verifier did not match${suffix}. Start a new connection.`,
+ 403,
+ );
+ }
+ if (response.status === 400) {
+ throw new OrcaAuthError(
+ "exchange_failed",
+ `OrcaRouter rejected the code challenge method${suffix}. Start a new connection.`,
+ 400,
+ );
+ }
+ if (response.status === 429) {
+ throw new OrcaAuthError(
+ "exchange_failed",
+ "Too many OrcaRouter authorizations were started recently. Wait for an existing key or try again later.",
+ 429,
+ );
+ }
+ throw new OrcaAuthError(
+ "exchange_failed",
+ `OrcaRouter authorization failed with status ${response.status}${suffix}.`,
+ response.status,
+ );
+ }
+
+ const payload = (await response.json().catch(() => null)) as unknown;
+ if (typeof payload !== "object" || payload === null) {
+ throw new OrcaAuthError(
+ "invalid_response",
+ "OrcaRouter returned an authorization response that could not be read.",
+ );
+ }
+ const record = payload as Record;
+ const key = record.key;
+ if (typeof key !== "string" || !key.trim()) {
+ throw new OrcaAuthError(
+ "invalid_response",
+ "OrcaRouter returned an authorization response without a usable key.",
+ );
+ }
+
+ const grantedScope =
+ typeof record.scope === "string" && record.scope.trim()
+ ? record.scope.trim()
+ : null;
+ const accepted = options.acceptedScopes ?? ["api"];
+ // A granted scope narrower than what this client needs is not usable. Do not
+ // treat the requested scope as if it had been granted.
+ if (grantedScope !== null && !accepted.includes(grantedScope)) {
+ throw new OrcaAuthError(
+ "insufficient_scope",
+ `OrcaRouter granted the "${grantedScope}" scope, which cannot be used for inference. Ask a workspace owner to grant "${accepted.join('" or "')}".`,
+ );
+ }
+
+ return {
+ key,
+ userId: typeof record.user_id === "string" ? record.user_id : null,
+ grantedScope,
+ };
+}
+
+/**
+ * The device grant (Flow C) is a supported protocol but is not implemented by
+ * this integration: a loopback listener is available on the server that runs
+ * the connect flow, and the out-of-band flow covers deployments where it is
+ * not. Exported so callers can detect and explain the gap.
+ */
+export const ORCAROUTER_UNSUPPORTED_FLOWS = ["device_grant"] as const;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9bd26542..cf8863dd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -74,6 +74,9 @@ importers:
'@ai-sdk/gateway':
specifier: ^3.0.13
version: 3.0.57(zod@4.3.6)
+ '@ai-sdk/openai-compatible':
+ specifier: ^2.0.75
+ version: 2.0.75(zod@4.3.6)
'@ai-sdk/react':
specifier: 3.0.79
version: 3.0.79(react@19.2.3)(zod@4.3.6)
@@ -136,7 +139,7 @@ importers:
version: 6.0.103(zod@4.3.6)
bash-tool:
specifier: 1.3.14
- version: 1.3.14(@vercel/sandbox@2.2.0)(ai@6.0.103(zod@4.3.6))
+ version: 1.3.14(@vercel/sandbox@2.2.0)(ai@6.0.103(zod@4.3.6))(just-bash@2.14.5)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -152,6 +155,9 @@ importers:
geist:
specifier: 1.7.0
version: 1.7.0(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
+ just-bash:
+ specifier: ^2.14.5
+ version: 2.14.5
lucide-react:
specifier: ^0.562.0
version: 0.562.0(react@19.2.3)
@@ -390,7 +396,7 @@ importers:
version: 2.1.1
drizzle-orm:
specifier: ^0.45.1
- version: 0.45.1(@opentelemetry/api@1.9.0)(@upstash/redis@1.37.0)(postgres@3.4.8)
+ version: 0.45.1(@opentelemetry/api@1.9.0)(@upstash/redis@1.37.0)(postgres@3.4.8)(sql.js@1.14.2)
lucide-react:
specifier: ^0.562.0
version: 0.562.0(react@19.2.3)
@@ -2865,6 +2871,12 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
+ '@ai-sdk/openai-compatible@2.0.75':
+ resolution: {integrity: sha512-W9w3tCoYrevct2Ips2X4EFOow8Kzrg0nqVJi9jm0lHiMlTmTON4nzaCv3Zkzg6eFCJJVH60kd4+t7tHgtOxx9Q==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ zod: ^3.25.76 || ^4.1.8
+
'@ai-sdk/provider-utils@4.0.14':
resolution: {integrity: sha512-7bzKd9lgiDeXM7O4U4nQ8iTxguAOkg8LZGD9AfDVZYjO5cKYRwBPwVjboFcVrxncRHu0tYxZtXZtiLKpG4pEng==}
engines: {node: '>=18'}
@@ -2889,12 +2901,22 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
+ '@ai-sdk/provider-utils@4.0.51':
+ resolution: {integrity: sha512-ukLTs9x1Xm6lxSIwbJIxYQxbZHmVeIczNntARZrBcOa6pjdBhqeZnATNnJMHa7M9dZ8Ji5NJbpKpvz7o5XyBtg==}
+ engines: {node: '>=18.17'}
+ peerDependencies:
+ zod: ^3.25.76 || ^4.1.8
+
'@ai-sdk/provider-utils@5.0.0-canary.48':
resolution: {integrity: sha512-340rMqAnqHDZOKS9ayrRbNr0/Om+orcopAnqJ7ftfEskxDUCTcudmhAhctFMSQeMzDnKaBPgz+4iff/hn7PkqQ==}
engines: {node: '>=22'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
+ '@ai-sdk/provider@3.0.16':
+ resolution: {integrity: sha512-9Av6kg0t/IN/dcYAAmEJ4B9OPhcEJqwSR+GfHEA8olRkindXpUHadc3p3cyvgFUQFTVm1G5thtsmgZ9yVb2w3A==}
+ engines: {node: '>=18'}
+
'@ai-sdk/provider@3.0.8':
resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==}
engines: {node: '>=18'}
@@ -3579,6 +3601,9 @@ packages:
'@bcoe/v8-coverage@0.2.3':
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
+ '@borewit/text-codec@0.2.2':
+ resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
+
'@braintree/sanitize-url@7.1.2':
resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==}
@@ -3681,11 +3706,11 @@ packages:
'@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
- deprecated: 'Merged into tsx: https://tsx.is'
+ deprecated: 'Merged into tsx: https://tsx.hirok.io'
'@esbuild-kit/esm-loader@2.6.5':
resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
- deprecated: 'Merged into tsx: https://tsx.is'
+ deprecated: 'Merged into tsx: https://tsx.hirok.io'
'@esbuild/aix-ppc64@0.25.0':
resolution: {integrity: sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==}
@@ -4833,6 +4858,21 @@ packages:
resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+ '@jitl/quickjs-ffi-types@0.32.0':
+ resolution: {integrity: sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==}
+
+ '@jitl/quickjs-wasmfile-debug-asyncify@0.32.0':
+ resolution: {integrity: sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==}
+
+ '@jitl/quickjs-wasmfile-debug-sync@0.32.0':
+ resolution: {integrity: sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==}
+
+ '@jitl/quickjs-wasmfile-release-asyncify@0.32.0':
+ resolution: {integrity: sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==}
+
+ '@jitl/quickjs-wasmfile-release-sync@0.32.0':
+ resolution: {integrity: sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==}
+
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -4956,6 +4996,9 @@ packages:
'@mistralai/mistralai@2.2.1':
resolution: {integrity: sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==}
+ '@mixmark-io/domino@2.2.0':
+ resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==}
+
'@modelcontextprotocol/ext-apps@1.2.0':
resolution: {integrity: sha512-ijUQJX/FmNq8PWgOLzph/BAfy84sUZxoIRuHzr+F37wYtWjhdl8pliBJybapYolppY+XJ8oqjFZmTOuMqxwbWQ==}
peerDependencies:
@@ -4989,6 +5032,10 @@ packages:
'@cfworker/json-schema':
optional: true
+ '@mongodb-js/zstd@7.0.0':
+ resolution: {integrity: sha512-mQ2s0pYYiav+tzCDR05Zptem8Ey2v8s11lri5RKGhTtL4COVCvVCk5vtyRYNT+9L8qSfyOqqefF9UtnW8mC5jA==}
+ engines: {node: '>= 20.19.0'}
+
'@monogrid/gainmap-js@3.4.0':
resolution: {integrity: sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==}
peerDependencies:
@@ -7417,6 +7464,13 @@ packages:
vitest:
optional: true
+ '@tokenizer/inflate@0.4.1':
+ resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
+ engines: {node: '>=18'}
+
+ '@tokenizer/token@0.3.0':
+ resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
+
'@tootallnate/once@1.1.2':
resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==}
engines: {node: '>= 6'}
@@ -8566,6 +8620,9 @@ packages:
'@internationalized/date': ^3.8.1
svelte: ^5.33.0
+ bl@4.1.0:
+ resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
+
body-parser@2.2.2:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'}
@@ -8590,10 +8647,6 @@ packages:
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
- brace-expansion@5.0.4:
- resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==}
- engines: {node: 18 || 20 || >=22}
-
brace-expansion@5.0.6:
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
engines: {node: 18 || 20 || >=22}
@@ -8735,6 +8788,9 @@ packages:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
+ chownr@1.1.4:
+ resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
+
chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
@@ -8884,6 +8940,10 @@ packages:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
+ commander@6.2.1:
+ resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==}
+ engines: {node: '>= 6'}
+
commander@7.2.0:
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
engines: {node: '>= 10'}
@@ -8987,6 +9047,7 @@ packages:
crypto-js@4.2.0:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
+ deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained.
crypto-random-string@2.0.0:
resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==}
@@ -9262,6 +9323,10 @@ packages:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'}
+ decompress-response@6.0.0:
+ resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
+ engines: {node: '>=10'}
+
dedent-js@1.0.1:
resolution: {integrity: sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==}
@@ -9811,6 +9876,7 @@ packages:
eslint@9.39.2:
resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
hasBin: true
peerDependencies:
jiti: '*'
@@ -9932,6 +9998,10 @@ packages:
resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==}
engines: {node: '>= 0.8.0'}
+ expand-template@2.0.3:
+ resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
+ engines: {node: '>=6'}
+
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -10167,6 +10237,10 @@ packages:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
+ file-type@21.3.4:
+ resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==}
+ engines: {node: '>=20'}
+
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
@@ -10246,6 +10320,9 @@ packages:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
+ fs-constants@1.0.0:
+ resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
+
fs-extra@11.3.3:
resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==}
engines: {node: '>=14.14'}
@@ -10357,6 +10434,9 @@ packages:
resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==}
engines: {node: '>=6'}
+ github-from-package@0.0.0:
+ resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
+
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@@ -10684,6 +10764,10 @@ packages:
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
+ ini@6.0.0:
+ resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==}
+ engines: {node: ^20.17.0 || >=22.9.0}
+
ink@6.8.0:
resolution: {integrity: sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==}
engines: {node: '>=20'}
@@ -11339,6 +11423,10 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
+ just-bash@2.14.5:
+ resolution: {integrity: sha512-MCBGnRlDeZ/MM7mcw+ZuSGFMBsggajrmKz6e/hrOAN7syvVZkjiY+Vh2wyCwN/CdcnAX5SxbiQB51n5nrQuX+g==}
+ hasBin: true
+
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
@@ -12106,9 +12194,9 @@ packages:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'}
- minimatch@10.2.4:
- resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
- engines: {node: 18 || 20 || >=22}
+ mimic-response@3.1.0:
+ resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
+ engines: {node: '>=10'}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
@@ -12140,6 +12228,9 @@ packages:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'}
+ mkdirp-classic@0.5.3:
+ resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
+
mkdirp@1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
engines: {node: '>=10'}
@@ -12148,6 +12239,10 @@ packages:
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
+ modern-tar@0.7.7:
+ resolution: {integrity: sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==}
+ engines: {node: '>=18.0.0'}
+
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
@@ -12197,6 +12292,9 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ napi-build-utils@2.0.0:
+ resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
+
natural-compare-lite@1.4.0:
resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==}
@@ -12332,6 +12430,14 @@ packages:
sass:
optional: true
+ node-abi@3.96.0:
+ resolution: {integrity: sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==}
+ engines: {node: '>=10'}
+
+ node-addon-api@8.9.2:
+ resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==}
+ engines: {node: ^18 || ^20 || >= 21}
+
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
@@ -12345,9 +12451,18 @@ packages:
resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==}
engines: {node: '>= 6.13.0'}
+ node-gyp-build@4.8.4:
+ resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
+ hasBin: true
+
node-int64@0.4.0:
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
+ node-liblzma@2.2.0:
+ resolution: {integrity: sha512-s0KzNOWwOJJgPG6wxg6cKohnAl9Wk/oW1KrQaVzJBjQwVcUGPQCzpR46Ximygjqj/3KhOrtJXnYMp/xYAXp75g==}
+ engines: {node: '>=16.0.0'}
+ hasBin: true
+
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
@@ -12536,6 +12651,9 @@ packages:
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+ papaparse@5.7.0:
+ resolution: {integrity: sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==}
+
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -12774,6 +12892,12 @@ packages:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
+ prebuild-install@7.1.3:
+ resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
+ engines: {node: '>=10'}
+ deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
+ hasBin: true
+
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -12880,6 +13004,13 @@ packages:
queue@6.0.2:
resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
+ quickjs-emscripten-core@0.32.0:
+ resolution: {integrity: sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==}
+
+ quickjs-emscripten@0.32.0:
+ resolution: {integrity: sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==}
+ engines: {node: '>=16.0.0'}
+
radix-ui@1.4.3:
resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==}
peerDependencies:
@@ -12908,6 +13039,9 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
+ re2js@1.3.3:
+ resolution: {integrity: sha512-s/I5zEAo79SUK0Qw4dpZKpiMwbQ6Gz0KU2NRr7eaO4x/p2g7Vvmn3hdeXDg8VsaUjfj/ora+e9oi27LX/C9+mw==}
+
react-confetti-explosion@3.0.3:
resolution: {integrity: sha512-ow5ns/1ttzXsIlbbfJmWJNiyQK8lTHBL6lRSUXGaK44K/3NIMngR57Ja96l+D6txTeFhfe0BfXGvORMxhtRDng==}
peerDependencies:
@@ -13097,6 +13231,10 @@ packages:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'}
+ readable-stream@3.6.2:
+ resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
+ engines: {node: '>= 6'}
+
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
@@ -13431,6 +13569,10 @@ packages:
resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
engines: {node: '>=4'}
+ seek-bzip@2.0.0:
+ resolution: {integrity: sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg==}
+ hasBin: true
+
selderee@0.11.0:
resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==}
@@ -13578,6 +13720,12 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
+ simple-concat@1.0.1:
+ resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
+
+ simple-get@4.0.1:
+ resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
+
simple-plist@1.3.1:
resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==}
@@ -13607,6 +13755,10 @@ packages:
resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==}
engines: {node: '>=8.0.0'}
+ smol-toml@1.8.0:
+ resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==}
+ engines: {node: '>= 18'}
+
solid-js@1.9.11:
resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==}
@@ -13659,6 +13811,12 @@ packages:
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
+ sprintf-js@1.1.3:
+ resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
+
+ sql.js@1.14.2:
+ resolution: {integrity: sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw==}
+
stack-utils@2.0.6:
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
engines: {node: '>=10'}
@@ -13834,6 +13992,10 @@ packages:
strnum@2.4.0:
resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==}
+ strtok3@10.3.5:
+ resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
+ engines: {node: '>=18'}
+
structured-headers@0.4.1:
resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==}
@@ -13986,6 +14148,13 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
+ tar-fs@2.1.5:
+ resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==}
+
+ tar-stream@2.2.0:
+ resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
+ engines: {node: '>=6'}
+
tar-stream@3.1.7:
resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
@@ -14112,6 +14281,10 @@ packages:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
+ token-types@6.1.2:
+ resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
+ engines: {node: '>=14.16'}
+
totalist@3.0.1:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
@@ -14238,6 +14411,9 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
+ tunnel-agent@0.6.0:
+ resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
+
tunnel-rat@0.1.2:
resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==}
@@ -14275,6 +14451,10 @@ packages:
resolution: {integrity: sha512-bkO4AddmDishzJB2ze7aYYPaejMoJVfS0XnaR6RCdXFOY8JGJfQE+l9fKiV7uDPa5Ut44gmOWJL3894CIMeH9g==}
hasBin: true
+ turndown@7.2.4:
+ resolution: {integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==}
+ engines: {node: '>=18', npm: '>=9'}
+
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
@@ -14361,6 +14541,10 @@ packages:
ufo@1.6.2:
resolution: {integrity: sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q==}
+ uint8array-extras@1.5.0:
+ resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
+ engines: {node: '>=18'}
+
unbox-primitive@1.1.0:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
@@ -14375,6 +14559,10 @@ packages:
resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==}
engines: {node: '>=18.17'}
+ undici@6.28.1:
+ resolution: {integrity: sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==}
+ engines: {node: '>=18.17'}
+
undici@7.27.2:
resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==}
engines: {node: '>=20.18.1'}
@@ -15243,6 +15431,12 @@ snapshots:
pkce-challenge: 5.0.1
zod: 4.3.6
+ '@ai-sdk/openai-compatible@2.0.75(zod@4.3.6)':
+ dependencies:
+ '@ai-sdk/provider': 3.0.16
+ '@ai-sdk/provider-utils': 4.0.51(zod@4.3.6)
+ zod: 4.3.6
+
'@ai-sdk/provider-utils@4.0.14(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 3.0.8
@@ -15271,6 +15465,14 @@ snapshots:
eventsource-parser: 3.0.6
zod: 4.3.6
+ '@ai-sdk/provider-utils@4.0.51(zod@4.3.6)':
+ dependencies:
+ '@ai-sdk/provider': 3.0.16
+ '@standard-schema/spec': 1.1.0
+ eventsource-parser: 3.1.0
+ undici: 6.28.1
+ zod: 4.3.6
+
'@ai-sdk/provider-utils@5.0.0-canary.48(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 4.0.0-canary.18
@@ -15287,6 +15489,10 @@ snapshots:
eventsource-parser: 3.1.0
zod: 4.3.6
+ '@ai-sdk/provider@3.0.16':
+ dependencies:
+ json-schema: 0.4.0
+
'@ai-sdk/provider@3.0.8':
dependencies:
json-schema: 0.4.0
@@ -16237,6 +16443,8 @@ snapshots:
'@bcoe/v8-coverage@0.2.3': {}
+ '@borewit/text-codec@0.2.2': {}
+
'@braintree/sanitize-url@7.1.2': {}
'@chevrotain/types@11.1.2': {}
@@ -17628,6 +17836,24 @@ snapshots:
'@types/yargs': 17.0.35
chalk: 4.1.2
+ '@jitl/quickjs-ffi-types@0.32.0': {}
+
+ '@jitl/quickjs-wasmfile-debug-asyncify@0.32.0':
+ dependencies:
+ '@jitl/quickjs-ffi-types': 0.32.0
+
+ '@jitl/quickjs-wasmfile-debug-sync@0.32.0':
+ dependencies:
+ '@jitl/quickjs-ffi-types': 0.32.0
+
+ '@jitl/quickjs-wasmfile-release-asyncify@0.32.0':
+ dependencies:
+ '@jitl/quickjs-ffi-types': 0.32.0
+
+ '@jitl/quickjs-wasmfile-release-sync@0.32.0':
+ dependencies:
+ '@jitl/quickjs-ffi-types': 0.32.0
+
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -17709,7 +17935,7 @@ snapshots:
'@mdx-js/mdx': 3.1.1
source-map: 0.7.6
optionalDependencies:
- webpack: 5.96.1(esbuild@0.25.0)
+ webpack: 5.96.1
transitivePeerDependencies:
- supports-color
@@ -17764,6 +17990,8 @@ snapshots:
- bufferutil
- utf-8-validate
+ '@mixmark-io/domino@2.2.0': {}
+
'@modelcontextprotocol/ext-apps@1.2.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6)':
dependencies:
'@modelcontextprotocol/sdk': 1.27.1(zod@4.3.6)
@@ -17816,6 +18044,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@mongodb-js/zstd@7.0.0':
+ dependencies:
+ node-addon-api: 8.9.2
+ prebuild-install: 7.1.3
+ optional: true
+
'@monogrid/gainmap-js@3.4.0(three@0.182.0)':
dependencies:
promise-worker-transferable: 1.0.4
@@ -21393,12 +21627,21 @@ snapshots:
vite: 7.3.1(@types/node@22.19.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vitest: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@22.19.6)(jiti@2.7.0)(jsdom@27.4.0)(lightningcss@1.32.0)(msw@2.12.10(@types/node@22.19.6)(typescript@5.9.2))(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+ '@tokenizer/inflate@0.4.1':
+ dependencies:
+ debug: 4.4.3
+ token-types: 6.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ '@tokenizer/token@0.3.0': {}
+
'@tootallnate/once@1.1.2': {}
'@ts-morph/common@0.27.0':
dependencies:
fast-glob: 3.3.3
- minimatch: 10.2.4
+ minimatch: 10.2.5
path-browserify: 1.0.1
'@tweenjs/tween.js@23.1.3': {}
@@ -22762,7 +23005,7 @@ snapshots:
baseline-browser-mapping@2.9.14: {}
- bash-tool@1.3.14(@vercel/sandbox@2.2.0)(ai@6.0.103(zod@4.3.6)):
+ bash-tool@1.3.14(@vercel/sandbox@2.2.0)(ai@6.0.103(zod@4.3.6))(just-bash@2.14.5):
dependencies:
ai: 6.0.103(zod@4.3.6)
fast-glob: 3.3.3
@@ -22770,6 +23013,7 @@ snapshots:
zod: 3.25.76
optionalDependencies:
'@vercel/sandbox': 2.2.0
+ just-bash: 2.14.5
better-opn@3.0.2:
dependencies:
@@ -22811,6 +23055,13 @@ snapshots:
transitivePeerDependencies:
- '@sveltejs/kit'
+ bl@4.1.0:
+ dependencies:
+ buffer: 5.7.1
+ inherits: 2.0.4
+ readable-stream: 3.6.2
+ optional: true
+
body-parser@2.2.2:
dependencies:
bytes: 3.1.2
@@ -22848,10 +23099,6 @@ snapshots:
dependencies:
balanced-match: 1.0.2
- brace-expansion@5.0.4:
- dependencies:
- balanced-match: 4.0.4
-
brace-expansion@5.0.6:
dependencies:
balanced-match: 4.0.4
@@ -22985,6 +23232,9 @@ snapshots:
dependencies:
readdirp: 5.0.0
+ chownr@1.1.4:
+ optional: true
+
chownr@3.0.0: {}
chrome-launcher@0.15.2:
@@ -23116,6 +23366,8 @@ snapshots:
commander@4.1.1: {}
+ commander@6.2.1: {}
+
commander@7.2.0: {}
commander@8.3.0: {}
@@ -23507,6 +23759,11 @@ snapshots:
decode-uri-component@0.2.2: {}
+ decompress-response@6.0.0:
+ dependencies:
+ mimic-response: 3.1.0
+ optional: true
+
dedent-js@1.0.1: {}
dedent@0.7.0: {}
@@ -23654,11 +23911,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
- drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@upstash/redis@1.37.0)(postgres@3.4.8):
+ drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@upstash/redis@1.37.0)(postgres@3.4.8)(sql.js@1.14.2):
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@upstash/redis': 1.37.0
postgres: 3.4.8
+ sql.js: 1.14.2
dunder-proto@1.0.1:
dependencies:
@@ -24324,6 +24582,9 @@ snapshots:
exit@0.1.2: {}
+ expand-template@2.0.3:
+ optional: true
+
expect-type@1.3.0: {}
expect@27.5.1:
@@ -24764,6 +25025,15 @@ snapshots:
dependencies:
flat-cache: 4.0.1
+ file-type@21.3.4:
+ dependencies:
+ '@tokenizer/inflate': 0.4.1
+ strtok3: 10.3.5
+ token-types: 6.1.2
+ uint8array-extras: 1.5.0
+ transitivePeerDependencies:
+ - supports-color
+
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@@ -24867,6 +25137,9 @@ snapshots:
fresh@2.0.0: {}
+ fs-constants@1.0.0:
+ optional: true
+
fs-extra@11.3.3:
dependencies:
graceful-fs: 4.2.11
@@ -24984,6 +25257,9 @@ snapshots:
getenv@2.0.0: {}
+ github-from-package@0.0.0:
+ optional: true
+
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@@ -25005,13 +25281,13 @@ snapshots:
glob@13.0.1:
dependencies:
- minimatch: 10.2.4
+ minimatch: 10.2.5
minipass: 7.1.2
path-scurry: 2.0.1
glob@13.0.6:
dependencies:
- minimatch: 10.2.4
+ minimatch: 10.2.5
minipass: 7.1.3
path-scurry: 2.0.2
@@ -25393,6 +25669,8 @@ snapshots:
ini@1.3.8: {}
+ ini@6.0.0: {}
+
ink@6.8.0(@types/react@19.2.14)(react-devtools-core@6.1.5)(react@19.2.4):
dependencies:
'@alcalzone/ansi-tokenize': 0.2.5
@@ -26369,6 +26647,29 @@ snapshots:
object.assign: 4.1.7
object.values: 1.2.1
+ just-bash@2.14.5:
+ dependencies:
+ diff: 8.0.4
+ fast-xml-parser: 5.7.3
+ file-type: 21.3.4
+ ini: 6.0.0
+ minimatch: 10.2.5
+ modern-tar: 0.7.7
+ papaparse: 5.7.0
+ quickjs-emscripten: 0.32.0
+ re2js: 1.3.3
+ seek-bzip: 2.0.0
+ smol-toml: 1.8.0
+ sprintf-js: 1.1.3
+ sql.js: 1.14.2
+ turndown: 7.2.4
+ yaml: 2.9.0
+ optionalDependencies:
+ '@mongodb-js/zstd': 7.0.0
+ node-liblzma: 2.2.0
+ transitivePeerDependencies:
+ - supports-color
+
jwa@2.0.1:
dependencies:
buffer-equal-constant-time: 1.0.1
@@ -26998,7 +27299,7 @@ snapshots:
metro-cache: 0.83.3
metro-core: 0.83.3
metro-runtime: 0.83.3
- yaml: 2.8.2
+ yaml: 2.9.0
transitivePeerDependencies:
- bufferutil
- supports-color
@@ -27431,9 +27732,8 @@ snapshots:
mimic-function@5.0.1: {}
- minimatch@10.2.4:
- dependencies:
- brace-expansion: 5.0.4
+ mimic-response@3.1.0:
+ optional: true
minimatch@10.2.5:
dependencies:
@@ -27461,6 +27761,9 @@ snapshots:
dependencies:
minipass: 7.1.2
+ mkdirp-classic@0.5.3:
+ optional: true
+
mkdirp@1.0.4: {}
mlly@1.8.0:
@@ -27470,6 +27773,8 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.2
+ modern-tar@0.7.7: {}
+
mri@1.2.0: {}
mrmime@2.0.1: {}
@@ -27548,6 +27853,9 @@ snapshots:
nanoid@3.3.11: {}
+ napi-build-utils@2.0.0:
+ optional: true
+
natural-compare-lite@1.4.0: {}
natural-compare@1.4.0: {}
@@ -27702,6 +28010,14 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
+ node-abi@3.96.0:
+ dependencies:
+ semver: 7.7.4
+ optional: true
+
+ node-addon-api@8.9.2:
+ optional: true
+
node-domexception@1.0.0: {}
node-fetch@3.3.2:
@@ -27712,8 +28028,17 @@ snapshots:
node-forge@1.3.3: {}
+ node-gyp-build@4.8.4:
+ optional: true
+
node-int64@0.4.0: {}
+ node-liblzma@2.2.0:
+ dependencies:
+ node-addon-api: 8.9.2
+ node-gyp-build: 4.8.4
+ optional: true
+
node-releases@2.0.27: {}
nopt@7.2.1:
@@ -27920,6 +28245,8 @@ snapshots:
pako@1.0.11: {}
+ papaparse@5.7.0: {}
+
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -28133,6 +28460,22 @@ snapshots:
powershell-utils@0.1.0: {}
+ prebuild-install@7.1.3:
+ dependencies:
+ detect-libc: 2.1.2
+ expand-template: 2.0.3
+ github-from-package: 0.0.0
+ minimist: 1.2.8
+ mkdirp-classic: 0.5.3
+ napi-build-utils: 2.0.0
+ node-abi: 3.96.0
+ pump: 3.0.3
+ rc: 1.2.8
+ simple-get: 4.0.1
+ tar-fs: 2.1.5
+ tunnel-agent: 0.6.0
+ optional: true
+
prelude-ls@1.2.1: {}
prettier@3.7.4: {}
@@ -28256,6 +28599,18 @@ snapshots:
dependencies:
inherits: 2.0.4
+ quickjs-emscripten-core@0.32.0:
+ dependencies:
+ '@jitl/quickjs-ffi-types': 0.32.0
+
+ quickjs-emscripten@0.32.0:
+ dependencies:
+ '@jitl/quickjs-wasmfile-debug-asyncify': 0.32.0
+ '@jitl/quickjs-wasmfile-debug-sync': 0.32.0
+ '@jitl/quickjs-wasmfile-release-asyncify': 0.32.0
+ '@jitl/quickjs-wasmfile-release-sync': 0.32.0
+ quickjs-emscripten-core: 0.32.0
+
radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -28402,6 +28757,8 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
+ re2js@1.3.3: {}
+
react-confetti-explosion@3.0.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
@@ -28751,6 +29108,13 @@ snapshots:
react@19.2.4: {}
+ readable-stream@3.6.2:
+ dependencies:
+ inherits: 2.0.4
+ string_decoder: 1.3.0
+ util-deprecate: 1.0.2
+ optional: true
+
readdirp@4.1.2: {}
readdirp@5.0.0: {}
@@ -29222,6 +29586,10 @@ snapshots:
extend-shallow: 2.0.1
kind-of: 6.0.3
+ seek-bzip@2.0.0:
+ dependencies:
+ commander: 6.2.1
+
selderee@0.11.0:
dependencies:
parseley: 0.12.1
@@ -29469,6 +29837,16 @@ snapshots:
signal-exit@4.1.0: {}
+ simple-concat@1.0.1:
+ optional: true
+
+ simple-get@4.0.1:
+ dependencies:
+ decompress-response: 6.0.0
+ once: 1.4.0
+ simple-concat: 1.0.1
+ optional: true
+
simple-plist@1.3.1:
dependencies:
bplist-creator: 0.1.0
@@ -29501,6 +29879,8 @@ snapshots:
slugify@1.6.6: {}
+ smol-toml@1.8.0: {}
+
solid-js@1.9.11:
dependencies:
csstype: 3.2.3
@@ -29551,6 +29931,10 @@ snapshots:
sprintf-js@1.0.3: {}
+ sprintf-js@1.1.3: {}
+
+ sql.js@1.14.2: {}
+
stack-utils@2.0.6:
dependencies:
escape-string-regexp: 2.0.0
@@ -29793,6 +30177,10 @@ snapshots:
dependencies:
anynum: 1.0.0
+ strtok3@10.3.5:
+ dependencies:
+ '@tokenizer/token': 0.3.0
+
structured-headers@0.4.1: {}
style-loader@4.0.0(webpack@5.96.1(esbuild@0.25.0)):
@@ -30005,6 +30393,23 @@ snapshots:
tapable@2.3.0: {}
+ tar-fs@2.1.5:
+ dependencies:
+ chownr: 1.1.4
+ mkdirp-classic: 0.5.3
+ pump: 3.0.3
+ tar-stream: 2.2.0
+ optional: true
+
+ tar-stream@2.2.0:
+ dependencies:
+ bl: 4.1.0
+ end-of-stream: 1.4.5
+ fs-constants: 1.0.0
+ inherits: 2.0.4
+ readable-stream: 3.6.2
+ optional: true
+
tar-stream@3.1.7:
dependencies:
b4a: 1.8.1
@@ -30042,6 +30447,16 @@ snapshots:
optionalDependencies:
esbuild: 0.25.0
+ terser-webpack-plugin@5.3.16(webpack@5.96.1):
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.31
+ jest-worker: 27.5.1
+ schema-utils: 4.3.3
+ serialize-javascript: 6.0.2
+ terser: 5.46.0
+ webpack: 5.96.1
+ optional: true
+
terser@5.46.0:
dependencies:
'@jridgewell/source-map': 0.3.11
@@ -30140,6 +30555,12 @@ snapshots:
toidentifier@1.0.1: {}
+ token-types@6.1.2:
+ dependencies:
+ '@borewit/text-codec': 0.2.2
+ '@tokenizer/token': 0.3.0
+ ieee754: 1.2.1
+
totalist@3.0.1: {}
tough-cookie@4.1.4:
@@ -30307,6 +30728,11 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
+ tunnel-agent@0.6.0:
+ dependencies:
+ safe-buffer: 5.2.1
+ optional: true
+
tunnel-rat@0.1.2(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4):
dependencies:
zustand: 4.5.7(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4)
@@ -30342,6 +30768,10 @@ snapshots:
turbo-windows-64: 2.7.4
turbo-windows-arm64: 2.7.4
+ turndown@7.2.4:
+ dependencies:
+ '@mixmark-io/domino': 2.2.0
+
tw-animate-css@1.4.0: {}
type-check@0.4.0:
@@ -30428,6 +30858,8 @@ snapshots:
ufo@1.6.2: {}
+ uint8array-extras@1.5.0: {}
+
unbox-primitive@1.1.0:
dependencies:
call-bound: 1.0.4
@@ -30441,6 +30873,8 @@ snapshots:
undici@6.23.0: {}
+ undici@6.28.1: {}
+
undici@7.27.2: {}
undici@8.3.0: {}
@@ -30965,6 +31399,37 @@ snapshots:
webpack-sources@3.3.3: {}
+ webpack@5.96.1:
+ dependencies:
+ '@types/eslint-scope': 3.7.7
+ '@types/estree': 1.0.8
+ '@webassemblyjs/ast': 1.14.1
+ '@webassemblyjs/wasm-edit': 1.14.1
+ '@webassemblyjs/wasm-parser': 1.14.1
+ acorn: 8.16.0
+ browserslist: 4.28.1
+ chrome-trace-event: 1.0.4
+ enhanced-resolve: 5.19.0
+ es-module-lexer: 1.7.0
+ eslint-scope: 5.1.1
+ events: 3.3.0
+ glob-to-regexp: 0.4.1
+ graceful-fs: 4.2.11
+ json-parse-even-better-errors: 2.3.1
+ loader-runner: 4.3.1
+ mime-types: 2.1.35
+ neo-async: 2.6.2
+ schema-utils: 3.3.0
+ tapable: 2.3.0
+ terser-webpack-plugin: 5.3.16(webpack@5.96.1)
+ watchpack: 2.5.1
+ webpack-sources: 3.3.3
+ transitivePeerDependencies:
+ - '@swc/core'
+ - esbuild
+ - uglify-js
+ optional: true
+
webpack@5.96.1(esbuild@0.25.0):
dependencies:
'@types/eslint-scope': 3.7.7
diff --git a/scripts/orca-ui-evidence.py b/scripts/orca-ui-evidence.py
new file mode 100644
index 00000000..89ec3c5b
--- /dev/null
+++ b/scripts/orca-ui-evidence.py
@@ -0,0 +1,400 @@
+#!/usr/bin/env python3
+"""Capture the OrcaRouter UI evidence from the real json-render playground.
+
+Run from the root of a json-render checkout whose dependencies are installed and
+whose workspace packages are built, or through the repository's own driver:
+
+ ORCAROUTER_API_KEY=... node scripts/orca-verify.mjs ui-evidence
+
+The script starts the app's own Next.js dev server, drives the real playground
+with Playwright/Chromium, and writes `orca-evidence/manifest.json` plus the
+screenshots next to the repository root. The catalog phase uses the real
+`ORCAROUTER_API_KEY`, so the dropdown reflects the live catalog; the auth phase
+installs a dedicated fixture key, so the masked secret that appears in a
+screenshot is test data and never a fragment of a real key.
+
+`orca-evidence/` is generated output and is not committed.
+"""
+
+import hashlib
+import json
+import os
+import re
+import shutil
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+import urllib.error
+import urllib.request
+
+from playwright.sync_api import sync_playwright
+
+REPO = os.getcwd()
+OUT = os.path.join(REPO, "orca-evidence")
+CATALOG_SOURCE = "https://api.orcarouter.ai/v1/models?capability=chat"
+FIXTURE_KEY = "sk-orca-evidencefixture0000000000fixture"
+SERVER_LOG = os.path.join(tempfile.gettempdir(), "orca-evidence-server.log")
+
+
+def sha256(path):
+ with open(path, "rb") as handle:
+ return hashlib.sha256(handle.read()).hexdigest()
+
+
+def free_port():
+ with socket.socket() as sock:
+ sock.bind(("127.0.0.1", 0))
+ return sock.getsockname()[1]
+
+
+def http_json(url, body=None, timeout=30):
+ data = json.dumps(body).encode() if body is not None else None
+ headers = {"Content-Type": "application/json"} if data else {}
+ request = urllib.request.Request(
+ url, data=data, headers=headers, method="POST" if data else "GET"
+ )
+ with urllib.request.urlopen(request, timeout=timeout) as response:
+ return json.loads(response.read())
+
+
+def wait_for_server(base, timeout=240):
+ deadline = time.time() + timeout
+ last = None
+ while time.time() < deadline:
+ try:
+ with urllib.request.urlopen(base + "/playground", timeout=15) as response:
+ if response.status == 200:
+ return
+ except (urllib.error.URLError, TimeoutError, ConnectionError) as exc:
+ last = exc
+ time.sleep(1)
+ raise RuntimeError(f"dev server did not become ready: {last}")
+
+
+def set_credential(base, key):
+ """Install a credential through the app's own connect endpoint."""
+ return http_json(
+ base + "/api/orcarouter/connect", {"method": "api_key", "apiKey": key}
+ )
+
+
+def visible(page, testid):
+ """The rendered control.
+
+ The playground renders a desktop and a mobile toolbar, so each test id can
+ appear twice; only one is displayed at the 1440px viewport used here.
+ """
+ return page.locator(f"[data-testid='{testid}']:visible").first
+
+
+def click_testid(page, testid, timeout=60_000, settle=250):
+ """Click a control through its DOM `click` handler.
+
+ The Next.js development overlay intercepts pointer events, so a synthetic
+ click is used; React's delegated handler treats it exactly like a real one.
+ """
+ control = visible(page, testid)
+ control.wait_for(state="visible", timeout=timeout)
+ control.dispatch_event("click")
+ page.wait_for_timeout(settle)
+
+
+def is_visible(page, testid):
+ return page.locator(f"[data-testid='{testid}']:visible").count() > 0
+
+
+def open_playground(page, base):
+ """Load the playground and wait for the app's own ready signal.
+
+ `networkidle` never settles here: the development server keeps a
+ hot-reload socket open, so readiness is the rendered toolbar instead.
+ """
+ page.goto(base + "/playground", wait_until="domcontentloaded", timeout=120_000)
+ page.wait_for_selector(
+ "[data-testid='orca-provider-api']:visible", state="visible", timeout=120_000
+ )
+ page.wait_for_timeout(1500)
+
+
+def dismiss_dialog(page):
+ """Close the connect dialog if the app opened it, so it stays out of shots."""
+ if is_visible(page, "orca-connect-panel"):
+ page.keyboard.press("Escape")
+ page.wait_for_selector(
+ "[data-testid='orca-connect-panel']", state="hidden", timeout=20_000
+ )
+ page.wait_for_timeout(400)
+
+
+def select_provider(page):
+ """Select OrcaRouter, reopening the panel when the provider is already on."""
+ click_testid(page, "orca-provider-api", settle=800)
+ click_testid(page, "orca-provider-api", settle=600)
+
+
+def capture_auth_methods(page, base):
+ set_credential(base, FIXTURE_KEY)
+ open_playground(page, base)
+ select_provider(page)
+ page.wait_for_selector(
+ "[data-testid='orca-connect-panel']", state="visible", timeout=60_000
+ )
+ page.wait_for_timeout(1500)
+
+ panel_text = visible(page, "orca-connect-panel").inner_text()
+ api_visible = is_visible(page, "orca-choice-api-key")
+ pkce_visible = is_visible(page, "orca-choice-oauth")
+ secret_masked = "\u2022\u2022\u2022\u2022" in panel_text
+ if not (api_visible and pkce_visible):
+ raise AssertionError("both authentication choices must be visible")
+ if not secret_masked:
+ raise AssertionError("the stored secret must be rendered masked")
+ if re.search(r"sk-orca-[A-Za-z0-9]{12,}", panel_text):
+ raise AssertionError("the panel must never render a usable key")
+
+ visible(page, "orca-api-key-input").fill("sk-orca-evidencefixture0000")
+ page.wait_for_timeout(300)
+ api_save_enabled = visible(page, "orca-save-api-key").is_enabled()
+ click_testid(page, "orca-choice-oauth", settle=600)
+ pkce_start_enabled = visible(page, "orca-start-connect").is_enabled()
+ if not api_save_enabled:
+ raise AssertionError("Save key must be enabled once a value is entered")
+ if not pkce_start_enabled:
+ raise AssertionError("Connect with OrcaRouter must be enabled")
+ click_testid(page, "orca-choice-api-key", settle=600)
+
+ path = os.path.join(OUT, "auth-methods.png")
+ page.screenshot(path=path)
+ return {
+ "kind": "auth-methods",
+ "path": "auth-methods.png",
+ "sha256": sha256(path),
+ "ui": {
+ "api_key_visible": api_visible,
+ "pkce_visible": pkce_visible,
+ "secret_masked": secret_masked,
+ "controls_enabled": bool(api_save_enabled and pkce_start_enabled),
+ },
+ }
+
+
+def open_dropdown(page, trigger):
+ """Open the listbox, whether or not it is already open (the trigger toggles)."""
+ if page.locator("[data-testid='orca-model-listbox']:visible").count() == 0:
+ trigger.dispatch_event("click")
+ page.wait_for_selector(
+ "[data-testid='orca-model-listbox']", state="visible", timeout=60_000
+ )
+ page.wait_for_timeout(1000)
+
+
+def dropdown_state(page, trigger):
+ listbox = visible(page, "orca-model-listbox")
+ options = listbox.locator("[data-testid='orca-model-option']")
+ count = options.count()
+ ids = [options.nth(i).get_attribute("data-model-id") for i in range(count)]
+ box = listbox.bounding_box()
+ style = listbox.evaluate(
+ "el => { const s = getComputedStyle(el);"
+ " return { bg: s.backgroundColor, border: s.borderTopWidth,"
+ " borderStyle: s.borderTopStyle }; }"
+ )
+ trigger_box = trigger.bounding_box()
+ right_delta = abs(
+ (trigger_box["x"] + trigger_box["width"]) - (box["x"] + box["width"])
+ )
+ if re.search(r"sk-orca", listbox.inner_text()):
+ raise AssertionError("the model control must never render key material")
+ return {
+ "count": count,
+ "ids": ids,
+ "ui": {
+ "dropdown_open": True,
+ "item_count": count,
+ "opaque_background": style["bg"] not in ("rgba(0, 0, 0, 0)", "transparent"),
+ "visible_border": (
+ style["border"] not in ("0px", "") and style["borderStyle"] != "none"
+ ),
+ "trigger_panel_right_delta": right_delta,
+ },
+ }
+
+
+def capture_model_dropdowns(page, base, api_key):
+ set_credential(base, api_key)
+ open_playground(page, base)
+ click_testid(page, "orca-provider-api", settle=1500)
+ dismiss_dialog(page)
+
+ # The dropdown must be populated from the live catalog, never from the
+ # outage seed. Fail loudly if discovery silently degraded.
+ discovery = http_json(base + "/api/orcarouter/models?capability=chat")
+ if discovery.get("source") != "live" or discovery.get("degraded"):
+ raise AssertionError(
+ "model discovery did not return the live catalog: "
+ f"source={discovery.get('source')} degraded={discovery.get('degraded')} "
+ f"reason={discovery.get('degradedReason')}"
+ )
+ live = discovery.get("models") or []
+ if not live:
+ raise AssertionError("the live catalog is empty")
+ for model in live:
+ if "/" not in model["id"]:
+ raise AssertionError(f"vendor namespace lost for {model['id']}")
+
+ trigger = visible(page, "orca-model-trigger")
+ open_dropdown(page, trigger)
+ text = dropdown_state(page, trigger)
+ if text["count"] != len(live):
+ raise AssertionError(
+ "the rendered dropdown does not match the live catalog: "
+ f"{text['count']} rendered vs {len(live)} live"
+ )
+ # The upstream catalog ordering is not stable between requests, so the
+ # rendered set (not the sequence) is what must match the live response.
+ if sorted(text["ids"]) != sorted(model["id"] for model in live):
+ raise AssertionError(
+ "the dropdown contents differ from the live catalog: "
+ f"{text['ids']} vs {[model['id'] for model in live]}"
+ )
+
+ path = os.path.join(OUT, "text-model-dropdown.png")
+ page.screenshot(path=path)
+ text_artifact = {
+ "kind": "text-model-dropdown",
+ "path": "text-model-dropdown.png",
+ "sha256": sha256(path),
+ "ui": text["ui"],
+ }
+
+ # Choosing a text-only model and then requesting image input must clear the
+ # selection and drop every model that does not declare image input.
+ # Start from the captured listbox (it is open) and pick its first option;
+ # selecting closes it, so nothing has to be reopened before the click below.
+ listbox = visible(page, "orca-model-listbox")
+ listbox.locator("[data-testid='orca-model-option']").first.dispatch_event("click")
+ page.wait_for_timeout(800)
+ chosen = trigger.inner_text().strip()
+ page.wait_for_selector(
+ "[data-testid='orca-model-listbox']", state="hidden", timeout=20_000
+ )
+ click_testid(page, "orca-attachment-image", settle=2500)
+
+ open_dropdown(page, trigger)
+ image = dropdown_state(page, trigger)
+ if image["count"] <= 0:
+ raise AssertionError("image-capable chat models must stay selectable")
+ if image["count"] >= text["count"]:
+ raise AssertionError("the image filter must remove text-only models")
+ declared = {model["id"]: model.get("inputModalities") or [] for model in live}
+ for model_id in image["ids"]:
+ if "image" not in declared.get(model_id, []):
+ raise AssertionError(
+ f"{model_id} is offered for image input without declaring it"
+ )
+ cleared = trigger.inner_text().strip()
+ if cleared not in ("select model", "loading models..."):
+ raise AssertionError(
+ f"an incompatible selection must be cleared, got {cleared!r}"
+ )
+
+ mm_path = os.path.join(OUT, "multimodal-model-dropdown.png")
+ page.screenshot(path=mm_path)
+ image_artifact = {
+ "kind": "multimodal-model-dropdown",
+ "path": "multimodal-model-dropdown.png",
+ "sha256": sha256(mm_path),
+ "ui": image["ui"],
+ }
+ return {
+ "count": text["count"],
+ "image_count": image["count"],
+ "artifacts": [text_artifact, image_artifact],
+ "text_ids": text["ids"],
+ "image_ids": image["ids"],
+ "chosen": chosen,
+ }
+
+
+def main():
+ api_key = (os.environ.get("ORCAROUTER_API_KEY") or "").strip()
+ if not api_key:
+ raise RuntimeError("ORCAROUTER_API_KEY is required for the live catalog phase")
+
+ if os.path.isdir(OUT):
+ shutil.rmtree(OUT)
+ os.makedirs(OUT)
+
+ port = free_port()
+ base = f"http://127.0.0.1:{port}"
+ # Credentials and browser state stay outside the repository, so a run never
+ # modifies a tracked file.
+ scratch = tempfile.mkdtemp(prefix="orca-evidence-")
+ env = {
+ **os.environ,
+ "ORCAROUTER_ENV_FILE": os.path.join(scratch, ".env.local"),
+ "NEXT_TELEMETRY_DISABLED": "1",
+ "CI": "1",
+ }
+ # The key is installed at runtime through the app's own connect endpoint, so
+ # the server process does not need it in its environment.
+ env.pop("ORCAROUTER_API_KEY", None)
+
+ # A stale build lock from an interrupted run would stop the server starting.
+ shutil.rmtree(
+ os.path.join(REPO, "apps", "web", ".next", "dev", "lock"), ignore_errors=True
+ )
+
+ server = subprocess.Popen(
+ ["npx", "next", "dev", "--port", str(port), "--hostname", "127.0.0.1"],
+ cwd=os.path.join(REPO, "apps", "web"),
+ env=env,
+ stdout=open(SERVER_LOG, "wb"),
+ stderr=subprocess.STDOUT,
+ start_new_session=True,
+ )
+ try:
+ wait_for_server(base)
+ with sync_playwright() as pw:
+ browser = pw.chromium.launch(
+ executable_path="/usr/bin/chromium",
+ args=["--no-sandbox", "--disable-dev-shm-usage"],
+ )
+ try:
+ page = browser.new_page(viewport={"width": 1440, "height": 900})
+ catalog = capture_model_dropdowns(page, base, api_key)
+ auth = capture_auth_methods(page, base)
+ finally:
+ browser.close()
+ finally:
+ server.terminate()
+ try:
+ server.wait(timeout=30)
+ except subprocess.TimeoutExpired:
+ server.kill()
+
+ manifest = {
+ "automation": {
+ "framework": "playwright",
+ "passed": True,
+ "catalog_source": CATALOG_SOURCE,
+ "catalog_model_count": catalog["count"],
+ "image_model_count": catalog["image_count"],
+ },
+ "artifacts": [auth, *catalog["artifacts"]],
+ "text_model_ids": catalog["text_ids"],
+ "multimodal_model_ids": catalog["image_ids"],
+ "selection_cleared_on_incompatible_attachment": True,
+ "text_only_selection_before_attachment": catalog["chosen"],
+ }
+ with open(os.path.join(OUT, "manifest.json"), "w") as handle:
+ json.dump(manifest, handle, indent=2)
+ handle.write("\n")
+ print(json.dumps(manifest["automation"], indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/orca-verify.mjs b/scripts/orca-verify.mjs
new file mode 100644
index 00000000..0d6114a9
--- /dev/null
+++ b/scripts/orca-verify.mjs
@@ -0,0 +1,118 @@
+#!/usr/bin/env node
+/**
+ * One reproducible entry point for verifying the OrcaRouter integration.
+ *
+ * The repository is a pnpm workspace, so every mode first makes sure the pinned
+ * package manager is available, the workspace dependencies are installed, and
+ * the `@json-render/*` packages are built; without the build, the web app and
+ * the package tests cannot resolve `@json-render/core`. Each step is
+ * idempotent, so only the first mode in a verification run pays for it.
+ *
+ * node scripts/orca-verify.mjs install
+ * node scripts/orca-verify.mjs unit [paths...]
+ * node scripts/orca-verify.mjs check-types
+ * node scripts/orca-verify.mjs lint
+ * node scripts/orca-verify.mjs version
+ * node scripts/orca-verify.mjs ui-evidence
+ *
+ * `pnpm` is resolved from a workspace-local bootstrap, then from `PATH`, then
+ * through `npx`, so this works in a minimal environment that only has Node.js.
+ */
+
+import { spawnSync } from "node:child_process";
+import { existsSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const PNPM_VERSION = "11.1.3";
+const PNPM_BOOTSTRAP = path.join(root, "node_modules", ".orca-pnpm");
+const BOOTSTRAPPED_PNPM = path.join(
+ PNPM_BOOTSTRAP,
+ "node_modules/pnpm/bin/pnpm.cjs",
+);
+
+function run(argv) {
+ process.stdout.write(`\n$ ${argv.join(" ")}\n`);
+ const result = spawnSync(argv[0], argv.slice(1), {
+ cwd: root,
+ stdio: "inherit",
+ env: process.env,
+ });
+ if (result.error) throw result.error;
+ if (result.status !== 0) process.exit(result.status ?? 1);
+}
+
+/** The pnpm invocation for this environment. */
+function pnpm() {
+ if (existsSync(BOOTSTRAPPED_PNPM)) return [process.execPath, BOOTSTRAPPED_PNPM];
+ return ["npx", "--yes", `pnpm@${PNPM_VERSION}`];
+}
+
+/**
+ * The pinned package manager, installed into `node_modules/` (ignored) so the
+ * workspace stays byte-identical. Corepack is not usable here: it resolves the
+ * version over the network on every call and is not on `PATH` in a minimal
+ * environment.
+ */
+function ensurePnpm() {
+ if (existsSync(BOOTSTRAPPED_PNPM)) return;
+ run([
+ "npm",
+ "install",
+ "--prefix",
+ PNPM_BOOTSTRAP,
+ "--no-package-lock",
+ `pnpm@${PNPM_VERSION}`,
+ ]);
+}
+
+function ensure() {
+ ensurePnpm();
+ const command = pnpm();
+ if (!existsSync(path.join(root, "node_modules", ".pnpm"))) {
+ run([...command, "install", "--frozen-lockfile"]);
+ }
+ if (!existsSync(path.join(root, "packages", "core", "dist"))) {
+ // turbo cannot find a package manager binary outside PATH, so build the
+ // workspace packages with pnpm itself.
+ run([...command, "-r", "--filter", "./packages/*", "run", "build"]);
+ }
+}
+
+const [mode, ...rest] = process.argv.slice(2);
+if (!mode) {
+ process.stderr.write("usage: node scripts/orca-verify.mjs [args...]\n");
+ process.exit(2);
+}
+
+if (mode === "install") {
+ ensure();
+ process.exit(0);
+}
+
+ensure();
+
+switch (mode) {
+ case "unit":
+ run(["npx", "vitest", "run", ...rest]);
+ break;
+ case "node-test":
+ run(["node", "--test", ...rest]);
+ break;
+ case "check-types":
+ run([...pnpm(), "--filter", "web", "run", "check-types"]);
+ break;
+ case "lint":
+ run([...pnpm(), "--filter", "web", "run", "lint"]);
+ break;
+ case "version":
+ run(["node", "scripts/check-version-sync.js"]);
+ break;
+ case "ui-evidence":
+ run(["python3", "scripts/orca-ui-evidence.py"]);
+ break;
+ default:
+ process.stderr.write(`unknown mode: ${mode}\n`);
+ process.exit(2);
+}
diff --git a/scripts/repo-gates.test.mjs b/scripts/repo-gates.test.mjs
new file mode 100644
index 00000000..8251b211
--- /dev/null
+++ b/scripts/repo-gates.test.mjs
@@ -0,0 +1,43 @@
+// @ts-check
+/**
+ * Regression test for the repository's own mandatory gates.
+ *
+ * `.github/workflows/ci.yml` runs `pnpm lint`, `pnpm type-check` and the
+ * version-sync check on every pull request. Those gates are scripts rather than
+ * test files, so this suite runs them through the repository's driver
+ * (`scripts/orca-verify.mjs`) and fails with their output if any of them stops
+ * passing.
+ *
+ * node --test scripts/repo-gates.test.mjs
+ */
+
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import path from "node:path";
+import { test } from "node:test";
+import { fileURLToPath } from "node:url";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const driver = path.join("scripts", "orca-verify.mjs");
+
+function gate(mode) {
+ return spawnSync(process.execPath, [driver, mode], {
+ cwd: root,
+ encoding: "utf-8",
+ });
+}
+
+for (const [mode, label] of [
+ ["version", "version:check"],
+ ["lint", "lint"],
+ ["check-types", "type-check"],
+]) {
+ test(`the repository's ${label} gate passes`, () => {
+ const result = gate(mode);
+ assert.equal(
+ result.status,
+ 0,
+ `${label} failed:\n${result.stdout}\n${result.stderr}`,
+ );
+ });
+}
diff --git a/turbo.json b/turbo.json
index a963a592..08507b06 100644
--- a/turbo.json
+++ b/turbo.json
@@ -1,7 +1,7 @@
{
"$schema": "https://turborepo.dev/schema.json",
"ui": "tui",
- "globalEnv": ["AI_GATEWAY_API_KEY", "JEV_AI_GATEWAY_API_KEY", "ANTHROPIC_API_KEY", "CLAUDE_CODE_MODEL", "OPENAI_API_KEY", "CODEX_MODEL", "PI_MODEL", "AI_GATEWAY_MODEL", "ELEVENLABS_API_KEY", "KV_REST_API_URL", "KV_REST_API_TOKEN", "RATE_LIMIT_PER_MINUTE", "RATE_LIMIT_PER_DAY"],
+ "globalEnv": ["AI_GATEWAY_API_KEY", "JEV_AI_GATEWAY_API_KEY", "ANTHROPIC_API_KEY", "CLAUDE_CODE_MODEL", "OPENAI_API_KEY", "CODEX_MODEL", "PI_MODEL", "AI_GATEWAY_MODEL", "ELEVENLABS_API_KEY", "KV_REST_API_URL", "KV_REST_API_TOKEN", "RATE_LIMIT_PER_MINUTE", "RATE_LIMIT_PER_DAY", "ORCAROUTER_API_KEY", "ORCAROUTER_ENV_FILE", "ORCA_BASE_URL", "ORCA_AUTH_BASE_URL", "ORCA_API_BASE_URL"],
"tasks": {
"build": {
"dependsOn": ["^build"],
diff --git a/vitest.config.mts b/vitest.config.mts
index 0656ccda..0c5f2f2b 100644
--- a/vitest.config.mts
+++ b/vitest.config.mts
@@ -29,6 +29,7 @@ export default defineConfig({
"react-dom": path.resolve(__dirname, "node_modules/react-dom"),
vue: path.resolve(__dirname, "packages/vue/node_modules/vue"),
"solid-js": path.resolve(__dirname, "node_modules/solid-js"),
+ "@": path.resolve(__dirname, "apps/web"),
},
},
test: {
@@ -38,6 +39,8 @@ export default defineConfig({
"packages/**/*.test.ts",
"packages/**/*.test.tsx",
"apps/web/lib/jev/**/*.test.ts",
+ "apps/web/lib/orcarouter/**/*.test.ts",
+ "apps/web/lib/orcarouter/**/*.test.tsx",
"apps/web/lib/use-playground-stream.test.ts",
],
server: {