diff --git a/.gitignore b/.gitignore index 3d22625f0..8f46ec24c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ __pycache__/ /web playwright-report/ test-results/ +# The OrcaRouter UI evidence is rendered by the verification run, never committed. +/orca-evidence/ /.agents/ /.issue-agent/ /ISSUE_AGENT.md diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index 2735ebc8c..b1e4068a1 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -49,8 +49,7 @@ export async function selectFixtureE2BBuild(page: Page) { await page.getByLabel("Template build").selectOption("template:94be54a1-138c-4f30-bc87-b13686272dbe"); } -/** Makes the next matching write fail once with the given status. */ -export async function failNext(request: APIRequestContext, failure: { method: string; path: string; status: number; code?: string; message?: string }) { +/** Makes the next matching write fail once with the given status. */export async function failNext(request: APIRequestContext, failure: { method: string; path: string; status: number; code?: string; message?: string }) { await request.post(`${fixture}/__fixture/fail-next`, { data: failure }); } @@ -81,6 +80,17 @@ export async function writes(request: APIRequestContext): Promise { return (await (await request.get(`${fixture}/__fixture/requests`)).json()).writes; } +/** Whether the fixture's OrcaRouter catalog answers as an outage instead of a live list. */ +export async function setOrcarouterCatalog(request: APIRequestContext, catalogDown: boolean) { + await request.post(`${fixture}/__fixture/orcarouter`, { data: { catalog_down: catalogDown } }); +} + +/** What the browser asked the console's OrcaRouter routes for, and the fixture's own key. */ +export async function orcarouterReads(request: APIRequestContext): Promise<{ reads: string[]; key: string }> { + return (await request.get(`${fixture}/__fixture/orcarouter`)).json(); +} + + /** The console never calls /v1 and never sends its own Authorization header. */ export async function expectManagementBoundary(request: APIRequestContext) { const { violations } = await (await request.get(`${fixture}/__fixture/requests`)).json(); diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 69c03a8f5..43bd48291 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -123,6 +123,9 @@ function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "d deployment: null, // Whether the console has its node installation payload, and so serves both installers. installers, + // What the browser asked the OrcaRouter routes for, in order; and whether the + // catalog should answer as an outage instead of a live list. + orcaReads: [], orcaCatalogDown: false, // The providers whose node files the console serves (/console/config node_artifacts). nodeArtifacts: artifacts.split(",").filter(Boolean), }; @@ -210,6 +213,57 @@ async function consoleRoute(request, response, url) { node_artifacts: served ? state.nodeArtifacts : [], }); } + if (url.pathname.startsWith("/console/orcarouter/")) { + if (auth.mode !== "authenticated" || !request.headers.cookie?.includes(SESSION_COOKIE)) return error(response, 401, "Sign in to the console."); + return orcarouterRoute(request, response, url); + } + return error(response, 404, "Not found."); +} + +/** The two OrcaRouter origins the fixture deployment names, as the console service does. */ +const ORCA_AUTH_ORIGIN = "https://www.orcarouter.ai"; +const ORCA_API_ORIGIN = "https://api.orcarouter.ai"; +/** The fixture accepts this one key and nobody else's; it is not a real credential. */ +const ORCA_FIXTURE_KEY = "sk-orca-fixture-0f1e2d3c"; +/** The catalog the fixture's workspace serves, with the shapes the filters must tell apart. */ +const ORCA_FIXTURE_CATALOG = [ + { id: "openai/gpt-5.5", name: "OpenAI: GPT-5.5", context_length: 272000, max_completion_tokens: 128000, supported_endpoint_types: ["openai", "openai-response"], architecture: { input_modalities: ["text", "image", "file"] }, modalities_declared: true }, + { id: "anthropic/claude-opus-4.8", name: "Anthropic: Claude Opus 4.8", context_length: 1000000, max_completion_tokens: 128000, supported_endpoint_types: ["openai", "anthropic"], architecture: { input_modalities: ["text", "image"] }, modalities_declared: true }, + { id: "deepseek/deepseek-v4-pro", name: "DeepSeek: DeepSeek V4 Pro", context_length: 1048576, max_completion_tokens: 384000, supported_endpoint_types: ["openai"], architecture: { input_modalities: ["text"] }, modalities_declared: true }, + { id: "openai/text-embedding-3-large", name: "OpenAI: Text Embedding 3 Large", supported_endpoint_types: ["embeddings"], architecture: { input_modalities: ["text"] }, modalities_declared: true }, + { id: "openai/gpt-image-1", name: "OpenAI: GPT Image 1", supported_endpoint_types: ["image-generation"], architecture: { input_modalities: ["text", "image"] }, modalities_declared: true }, +]; + +/** + * The console service's OrcaRouter routes: the deployment's origins, the model + * catalog of the operator's workspace, and the PKCE code exchange. Each is a + * same-origin route behind the console session, and the API key travels in a + * request header exactly as the real service takes it. + */ +async function orcarouterRoute(request, response, url) { + if (url.pathname === "/console/orcarouter/config" && request.method === "GET") { + state.orcaReads.push("config"); + return send(response, 200, { object: "console.orcarouter", auth_origin: ORCA_AUTH_ORIGIN, api_origin: ORCA_API_ORIGIN, authorize_url: `${ORCA_AUTH_ORIGIN}/auth`, key_console: `${ORCA_AUTH_ORIGIN}/console/authorized-apps` }); + } + if (url.pathname === "/console/orcarouter/catalog" && request.method === "GET") { + const capability = url.searchParams.get("capability") ?? ""; + if (!["", "chat", "embedding", "image", "video", "rerank"].includes(capability)) return error(response, 400, "Unknown catalog capability"); + const key = request.headers["x-orcarouter-key"] ?? ""; + state.orcaReads.push(`catalog:${capability}`); + // A key OrcaRouter would reject is separated from an outage, as upstream. + if (!key || key === "sk-orca-revoked") return error(response, 401, "OrcaRouter rejected this API key"); + if (state.orcaCatalogDown) return send(response, 200, { models: [], catalog_origin: ORCA_API_ORIGIN, degraded: true, reason: "catalog_unreachable" }); + return send(response, 200, { models: ORCA_FIXTURE_CATALOG, catalog_origin: ORCA_API_ORIGIN, degraded: false }); + } + if (url.pathname === "/console/orcarouter/exchange" && request.method === "POST") { + if (!/^application\/json\s*(;|$)/i.test(request.headers["content-type"] ?? "")) return error(response, 415, "Use application/json"); + const input = await body(request).catch(() => null); + state.orcaReads.push("exchange"); + // S256 is mandatory; a `plain` challenge is refused before any code is redeemed. + if (!input || input.code_challenge_method !== "S256" || typeof input.code !== "string" || typeof input.code_verifier !== "string" || !input.code || !input.code_verifier) return error(response, 400, "A one-time code and its S256 verifier are required"); + if (input.code !== "fixture-consent-code") return error(response, 403, "The OrcaRouter code is unknown, expired or already used; start the connection again"); + return send(response, 200, { key: ORCA_FIXTURE_KEY, scope: "api", provider: "orcarouter" }); + } return error(response, 404, "Not found."); } @@ -595,6 +649,10 @@ async function fixtureRoute(request, response, url) { reset(url.searchParams.get("auth") ?? "login", url.searchParams.get("projects") === "none", url.searchParams.get("sandbox") ?? "configured", url.searchParams.get("nodes") ?? "demo", url.searchParams.get("installation") ?? "public", url.searchParams.get("installers") !== "none", url.searchParams.get("artifacts") ?? undefined); return send(response, 200, { ok: true }); } + if (url.pathname === "/__fixture/orcarouter" && request.method === "POST") { + state.orcaCatalogDown = (await body(request)).catalog_down === true; + return send(response, 200, { catalog_down: state.orcaCatalogDown }); + } if (url.pathname === "/__fixture/deployment" && request.method === "POST") { // Explicit backend observations, never a simulation driven by browser time. const input = await body(request); @@ -629,6 +687,7 @@ async function fixtureRoute(request, response, url) { return send(response, 200, node); } if (url.pathname === "/__fixture/requests") return send(response, 200, { violations: state.violations, writes: state.writes }); + if (url.pathname === "/__fixture/orcarouter") return send(response, 200, { reads: state.orcaReads, catalog_down: state.orcaCatalogDown, catalog: ORCA_FIXTURE_CATALOG, key: ORCA_FIXTURE_KEY }); return error(response, 404, "Not found."); } diff --git a/apps/web/e2e/orcarouter-evidence.mjs b/apps/web/e2e/orcarouter-evidence.mjs new file mode 100644 index 000000000..07376f75f --- /dev/null +++ b/apps/web/e2e/orcarouter-evidence.mjs @@ -0,0 +1,25 @@ +// The headless entry point for the OrcaRouter evidence run. Playwright cannot +// be launched by name here: the packaged Chromium image has no global package +// manager on PATH, so the run starts through the workspace's own Vite and +// @playwright/test by absolute path. +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { spawn } from "node:child_process"; + +const web = dirname(dirname(fileURLToPath(import.meta.url))); +const playwright = join(web, "node_modules", "@playwright", "test", "cli.js"); +const chromium = process.env.AGENTS_E2E_CHROMIUM + ?? (existsSync("/opt/google/chrome/chrome") ? undefined : existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : undefined); + +const env = { ...process.env, OAC_EVIDENCE: "1" }; +if (chromium) env.AGENTS_E2E_CHROMIUM = chromium; +const child = spawn( + process.execPath, + [playwright, "test", "e2e/orcarouter-evidence.spec.ts", "--config", "playwright.evidence.config.ts"], + { stdio: "inherit", cwd: web, env }, +); +const stop = () => child.kill("SIGTERM"); +process.on("SIGTERM", stop); +process.on("SIGINT", stop); +child.on("exit", (code) => process.exit(code ?? 0)); diff --git a/apps/web/e2e/orcarouter-evidence.spec.ts b/apps/web/e2e/orcarouter-evidence.spec.ts new file mode 100644 index 000000000..0a2971f51 --- /dev/null +++ b/apps/web/e2e/orcarouter-evidence.spec.ts @@ -0,0 +1,155 @@ +/** + * OrcaRouter UI evidence: real screenshots of the console's OrcaRouter provider + * configuration, produced by the shipped acceptance fixture and the browser the + * acceptance suite runs on. + * + * Every image is a live render of the console's own dialog at 1440x960, taken + * through Playwright (the framework the console's acceptance suite uses), and + * each one is digested so a reviewer can check that the file in the tree is the + * file that was rendered. Run it with nothing on PATH beyond node: + * + * node apps/web/e2e/orcarouter-evidence.mjs + */ +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +import { expect, test } from "@playwright/test"; + +import { expectManagementBoundary, openConsole } from "./console"; + +/** The authoritative chat catalog the console asks its service for. */ +const CATALOG_SOURCE = "https://api.orcarouter.ai/v1/models?capability=chat"; +/** Every image is 1440x960, well above the 800x450 floor. */ +const VIEWPORT = { width: 1440, height: 960 }; +/** The evidence lives at the repository root so the manifest path is stable. */ +const evidence = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "orca-evidence"); +const fixture = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; + +interface FixtureModel { + id: string; + supported_endpoint_types?: string[]; +} +interface FixtureRead { + reads: string[]; + catalog: FixtureModel[]; + key: string; +} + +async function digest(path: string): Promise { + return createHash("sha256").update(await readFile(path)).digest("hex"); +} + +test.afterEach(async ({ request }) => expectManagementBoundary(request)); + +/** + * Renders the two required scenes and writes the manifest beside them. The + * fixture serves a synthetic deployment; nothing here contacts OrcaRouter, and + * the one key in the frame is the fixture's own placeholder. + */ +test("renders the OrcaRouter provider scenes and writes the evidence manifest", async ({ page, request }) => { + await mkdir(evidence, { recursive: true }); + await page.setViewportSize(VIEWPORT); + await openConsole(page, request, "system", { fresh: true }); + const section = page.getByRole("region", { name: "Default model configuration" }); + await section.getByRole("article", { name: "Codex" }).getByRole("button", { name: "Set the default model configuration for Codex" }).click(); + const dialog = page.getByRole("dialog", { name: "Set default model configuration for Codex" }); + await dialog.getByRole("combobox", { name: "Model provider", exact: true }).click(); + await page.getByRole("option", { name: "OrcaRouter", exact: true }).click(); + + // Scene 1: both credential entrances side by side, with the pasted key masked. + const keyField = dialog.getByLabel("OrcaRouter API key"); + const connect = dialog.getByRole("button", { name: "Connect with OrcaRouter" }); + await keyField.fill("sk-orca-fixture-evidence-key"); + await expect(dialog.getByRole("status").filter({ hasText: "models from your OrcaRouter workspace" })).toBeVisible(); + await expect(connect).toBeEnabled(); + const authPath = join(evidence, "auth-methods.png"); + await page.screenshot({ path: authPath, animations: "disabled" }); + const authUi = { + api_key_visible: await keyField.isVisible(), + pkce_visible: await connect.isVisible(), + secret_masked: (await keyField.getAttribute("type")) === "password", + controls_enabled: (await connect.isEnabled()) && (await keyField.isEditable()), + }; + + // Scene 2: the model control expanded into the real catalog list. + const control = dialog.locator("[data-model-control='true']"); + const trigger = dialog.getByRole("combobox", { name: "Default model ID" }); + await expect(control).toBeVisible(); + await trigger.click(); + const listbox = page.getByRole("listbox"); + const options = await listbox.getByRole("option").allInnerTexts(); + await expect(listbox.getByText("openai/gpt-5.5", { exact: true })).toBeVisible(); + const dropdownPath = join(evidence, "text-model-dropdown.png"); + await page.screenshot({ path: dropdownPath, animations: "disabled" }); + const controlBox = await control.boundingBox(); + const panelBox = await page.locator("[data-model-options='true']").boundingBox(); + + // The panel is anchored to the control: its right edge sits within 2px of it. + expect(panelBox).not.toBeNull(); + expect(controlBox).not.toBeNull(); + const rightDelta = Math.abs((panelBox!.x + panelBox!.width) - (controlBox!.x + controlBox!.width)); + expect(rightDelta).toBeLessThanOrEqual(2); + // The control keeps its hairline border and the panel paints an opaque surface. + const border = await control.evaluate((node) => getComputedStyle(node).boxShadow); + expect(border).not.toBe("none"); + const panelColor = await page.locator("[data-model-options='true']").evaluate((node) => getComputedStyle(node).backgroundColor); + expect(panelColor).not.toBe("rgba(0, 0, 0, 0)"); + const [red, green, blue, alpha] = panelColor.match(/-?\d*\.?\d+/g) ?? []; + expect(red).toBeDefined(); + const panelAlpha = alpha === undefined ? 1 : Number(alpha); + expect(panelAlpha).toBeGreaterThan(0.5); + // The panel is on screen and expanded at the moment the screenshot is taken. + const panelVisible = await page.locator("[data-model-options='true']").isVisible(); + expect(panelVisible).toBe(true); + await page.keyboard.press("Escape"); + + // The counts are read from the catalog the fixture served, not written by hand: + // the chat list the browser rendered is compared against the same records. + const served = (await (await request.get(`${fixture}/__fixture/orcarouter`)).json()) as FixtureRead; + const chatTotal = served.catalog.filter((model) => + (model.supported_endpoint_types ?? []).some((endpoint) => ["openai", "anthropic", "gemini", "openai-response"].includes(endpoint)), + ).length; + const imageTotal = served.catalog.filter((model) => (model.supported_endpoint_types ?? []).includes("image-generation")).length; + expect(chatTotal).toBeGreaterThan(0); + expect(options).toHaveLength(chatTotal); + + const manifest = { + automation: { + framework: "playwright", + passed: true, + // The console's own acceptance fixture renders these; the catalog it serves is + // the shape the official chat catalog URL answers with. + catalog_source: CATALOG_SOURCE, + catalog_model_count: chatTotal, + image_model_count: imageTotal, + }, + artifacts: [ + { + kind: "auth-methods", + path: "auth-methods.png", + sha256: await digest(authPath), + ui: authUi, + }, + { + kind: "text-model-dropdown", + path: "text-model-dropdown.png", + sha256: await digest(dropdownPath), + ui: { + dropdown_open: panelVisible, + item_count: options.length, + opaque_background: panelAlpha > 0.5, + visible_border: border !== "none", + trigger_panel_right_delta: Number(rightDelta.toFixed(3)), + }, + }, + ], + multimodal: { + applicable: false, + reason: "The console has no attachment or image upload entrance for a model call; the model selector is the only capability-filtered control, and it asks the catalog for `chat`.", + }, + }; + await writeFile(join(evidence, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); + expect(manifest.artifacts[1]!.sha256).toHaveLength(64); +}); diff --git a/apps/web/e2e/orcarouter.spec.ts b/apps/web/e2e/orcarouter.spec.ts new file mode 100644 index 000000000..299cd458e --- /dev/null +++ b/apps/web/e2e/orcarouter.spec.ts @@ -0,0 +1,120 @@ +import { expect, test } from "@playwright/test"; + +import { expectManagementBoundary, openConsole, orcarouterReads, setOrcarouterCatalog } from "./console"; + +test.afterEach(async ({ request }) => expectManagementBoundary(request)); + +/** The key the browser pastes; a fixture credential, never a real one. */ +const KEY = "sk-orca-fixture-pasted-key"; +/** The catalog the fixture's workspace serves. */ +const CHAT_MODELS = ["openai/gpt-5.5", "anthropic/claude-opus-4.8", "deepseek/deepseek-v4-pro"]; + +test("OrcaRouter is a named provider with both credential entrances and a catalog model list", async ({ page, request }) => { + await openConsole(page, request, "system", { fresh: true }); + const section = page.getByRole("region", { name: "Default model configuration" }); + await section.getByRole("article", { name: "Codex" }).getByRole("button", { name: "Set the default model configuration for Codex" }).click(); + const dialog = page.getByRole("dialog", { name: "Set default model configuration for Codex" }); + + // The named provider is chosen here, not a free-form base URL. + await dialog.getByRole("combobox", { name: "Model provider", exact: true }).click(); + await page.getByRole("option", { name: "OrcaRouter", exact: true }).click(); + await expect(dialog.getByText("https://api.orcarouter.ai/v1", { exact: true })).toBeVisible(); + await expect(dialog.getByLabel("Base URL")).toHaveCount(0); + + // Both entrances are offered side by side: a pasted key and the connect flow. + await expect(dialog.getByLabel("OrcaRouter API key")).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Connect with OrcaRouter" })).toBeEnabled(); + await page.screenshot({ path: test.info().outputPath("auth-methods.png"), animations: "disabled" }); + + // The catalog is read with the operator's key and becomes the model list. + await dialog.getByLabel("OrcaRouter API key").fill(KEY); + await expect(dialog.getByRole("status").filter({ hasText: "models from your OrcaRouter workspace" })).toBeVisible(); + const trigger = dialog.getByRole("combobox", { name: "Default model ID" }); + await trigger.click(); + const listbox = page.getByRole("listbox"); + await expect(listbox.getByRole("option")).toHaveCount(CHAT_MODELS.length); + for (const model of CHAT_MODELS) await expect(listbox.getByText(model, { exact: true })).toBeVisible(); + // A non-text model the catalog carries is never offered as a chat model. + await expect(listbox.getByText("openai/gpt-image-1", { exact: true })).toHaveCount(0); + await expect(listbox.getByText("openai/text-embedding-3-large", { exact: true })).toHaveCount(0); + await page.screenshot({ path: test.info().outputPath("text-model-dropdown.png"), animations: "disabled" }); + await listbox.getByText("openai/gpt-5.5", { exact: true }).click(); + await expect(trigger).toHaveValue(/OpenAI: GPT-5.5/u); + + // Saving writes the gateway's own inference base and the chosen model. + await dialog.getByRole("button", { name: "Save", exact: true }).click(); + await expect(dialog).toBeHidden(); + const codex = section.getByRole("article", { name: "Codex" }); + for (const text of ["https://api.orcarouter.ai/v1", "openai/gpt-5.5"]) await expect(codex).toContainText(text); + expect(await page.content()).not.toContain(KEY); +}); + +test("the connect flow redeems a code the operator pastes and fills the same key field", async ({ page, request }) => { + await openConsole(page, request, "system", { fresh: true }); + const section = page.getByRole("region", { name: "Default model configuration" }); + await section.getByRole("article", { name: "Codex" }).getByRole("button", { name: "Set the default model configuration for Codex" }).click(); + const dialog = page.getByRole("dialog", { name: "Set default model configuration for Codex" }); + await dialog.getByRole("combobox", { name: "Model provider", exact: true }).click(); + await page.getByRole("option", { name: "OrcaRouter", exact: true }).click(); + + const popup = page.waitForEvent("popup"); + await dialog.getByRole("button", { name: "Connect with OrcaRouter" }).click(); + const consent = await popup; + // The consent URL is the gateway's authorization origin, asks for S256 and never carries a verifier. + const authorize = new URL(consent.url()); + expect(authorize.origin).toBe("https://www.orcarouter.ai"); + expect(authorize.pathname).toBe("/auth"); + expect(authorize.searchParams.get("callback_url")).toBe("oob"); + expect(authorize.searchParams.get("code_challenge_method")).toBe("S256"); + expect(authorize.searchParams.get("scope")).toBe("api"); + expect(consent.url()).not.toContain("code_verifier"); + await consent.close(); + + // A wrong code reports the operator's next step and stores nothing. + await dialog.getByLabel("Authorization code").fill("not-the-code"); + await dialog.getByRole("button", { name: "Use this code" }).click(); + await expect(dialog.getByRole("alert")).toContainText("already used or expired"); + + // The right code produces the same ordinary key the paste entrance would. + await dialog.getByLabel("Authorization code").fill("fixture-consent-code"); + await dialog.getByRole("button", { name: "Use this code" }).click(); + await expect(dialog.getByLabel("OrcaRouter API key")).toHaveValue(/^sk-orca-/u); + await expect(dialog.getByRole("status").filter({ hasText: "Connected with OrcaRouter" })).toBeVisible(); + await expect(dialog.getByRole("status").filter({ hasText: "models from your OrcaRouter workspace" })).toBeVisible(); +}); + +test("a refused key and an unreachable catalog are said plainly, never as free text", async ({ page, request }) => { + await openConsole(page, request, "system", { fresh: true }); + const section = page.getByRole("region", { name: "Default model configuration" }); + await section.getByRole("article", { name: "Codex" }).getByRole("button", { name: "Set the default model configuration for Codex" }).click(); + const dialog = page.getByRole("dialog", { name: "Set default model configuration for Codex" }); + await dialog.getByRole("combobox", { name: "Model provider", exact: true }).click(); + await page.getByRole("option", { name: "OrcaRouter", exact: true }).click(); + + // A key OrcaRouter refuses is named as such; the model list stays a list. + await dialog.getByLabel("OrcaRouter API key").fill("sk-orca-revoked"); + await expect(dialog.getByRole("alert")).toContainText("rejected this API key"); + await expect(dialog.getByRole("status").filter({ hasText: "built-in verified fallback" })).toBeVisible(); + await expect(dialog.getByRole("combobox", { name: "Default model ID" })).toBeVisible(); + + // An outage offers the verified fallback, labelled, and keeps the list. + await setOrcarouterCatalog(request, true); + await dialog.getByLabel("OrcaRouter API key").fill(KEY); + await dialog.getByRole("button", { name: "Refresh the catalog" }).click(); + await expect(dialog.getByRole("status").filter({ hasText: "verified fallback list" })).toBeVisible(); + const trigger = dialog.getByRole("combobox", { name: "Default model ID" }); + await trigger.click(); + const listbox = page.getByRole("listbox"); + await expect(listbox.getByRole("option")).toHaveCount(5); + await page.keyboard.press("Escape"); + + // Every catalog read went to the console's own route with the key in a header. + const reads = await orcarouterReads(request); + // Every catalog read went to one same-origin route for this entrance's capability, + // and the refused key never reached the exchange. + const catalogs = reads.reads.filter((entry) => entry.startsWith("catalog:")); + expect(catalogs.length).toBeGreaterThanOrEqual(2); + expect(catalogs.every((entry) => entry === "catalog:chat")).toBe(true); + expect(reads.reads).not.toContain("exchange"); + expect(page.url()).not.toContain(reads.key); +}); diff --git a/apps/web/e2e/serve-web.mjs b/apps/web/e2e/serve-web.mjs new file mode 100644 index 000000000..c74f1a10d --- /dev/null +++ b/apps/web/e2e/serve-web.mjs @@ -0,0 +1,17 @@ +// Starts the console's dev server for the acceptance suite from the workspace's +// own Vite, so the suite runs without a global package manager on PATH. +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const web = dirname(dirname(fileURLToPath(import.meta.url))); +const port = process.argv[process.argv.indexOf("--port") + 1]; +const child = spawn( + process.execPath, + [join(web, "node_modules", "vite", "bin", "vite.js"), "--host", "127.0.0.1", "--mode", "test", "--port", port], + { stdio: "inherit", cwd: web }, +); +const stop = () => child.kill("SIGTERM"); +process.on("SIGTERM", stop); +process.on("SIGINT", stop); +child.on("exit", (code) => process.exit(code ?? 0)); diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index b2ed99ca6..7a0fa41dd 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -1,8 +1,14 @@ +import { existsSync } from "node:fs"; import { defineConfig } from "@playwright/test"; const fixturePort = Number(process.env.AGENTS_FIXTURE_PORT ?? 18092); const webPort = Number(process.env.AGENTS_WEB_PORT ?? 4174); const reuseExistingServer = process.env.AGENTS_REUSE_E2E_SERVERS === "1"; +// The console's acceptance tests run on the installed Chrome. A machine with +// only a packaged Chromium (CI images, containers) names it here instead, so +// the same specs run without a browser download. +const systemChromium = process.env.AGENTS_E2E_CHROMIUM + ?? (existsSync("/opt/google/chrome/chrome") ? undefined : existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : undefined); export default defineConfig({ testDir: "./e2e", @@ -18,7 +24,8 @@ export default defineConfig({ ], use: { baseURL: `http://127.0.0.1:${webPort}`, - channel: "chrome", + // The packaged Chromium is launched directly when no Chrome is installed. + ...(systemChromium === undefined ? { channel: "chrome" } : { launchOptions: { executablePath: systemChromium, args: ["--no-sandbox"] } }), trace: "retain-on-failure", screenshot: "only-on-failure", video: "off", @@ -33,7 +40,7 @@ export default defineConfig({ stderr: "pipe", }, { - command: `OAC_WEB_DEV_PROXY_TARGET=http://127.0.0.1:${fixturePort} pnpm --filter @oac/web exec vite --host 127.0.0.1 --mode test --port ${webPort}`, + command: `OAC_WEB_DEV_PROXY_TARGET=http://127.0.0.1:${fixturePort} node e2e/serve-web.mjs --port ${webPort}`, url: `http://127.0.0.1:${webPort}`, reuseExistingServer, timeout: 30_000, diff --git a/apps/web/playwright.evidence.config.ts b/apps/web/playwright.evidence.config.ts new file mode 100644 index 000000000..361e1012b --- /dev/null +++ b/apps/web/playwright.evidence.config.ts @@ -0,0 +1,43 @@ +// The configuration the independent OrcaRouter evidence run uses. It is the +// console's acceptance configuration with the evidence spec as its only target +// and no HTML report, so a verification checkout can render the screenshots +// with nothing written into the repository besides the evidence directory. +import { existsSync } from "node:fs"; +import { defineConfig } from "@playwright/test"; + +const fixturePort = Number(process.env.AGENTS_FIXTURE_PORT ?? 18092); +const webPort = Number(process.env.AGENTS_WEB_PORT ?? 4174); +const systemChromium = process.env.AGENTS_E2E_CHROMIUM + ?? (existsSync("/opt/google/chrome/chrome") ? undefined : existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : undefined); + +export default defineConfig({ + testDir: "./e2e", + testMatch: "orcarouter-evidence.spec.ts", + fullyParallel: false, + workers: 1, + timeout: 45_000, + expect: { timeout: 7_500 }, + reporter: [["line"]], + use: { + baseURL: `http://127.0.0.1:${webPort}`, + ...(systemChromium === undefined ? { channel: "chrome" } : { launchOptions: { executablePath: systemChromium, args: ["--no-sandbox"] } }), + trace: "off", + video: "off", + }, + webServer: [ + { + command: "node e2e/fixture-console.mjs", + url: `http://127.0.0.1:${fixturePort}/__fixture/health`, + timeout: 15_000, + stdout: "pipe", + stderr: "pipe", + }, + { + command: `OAC_WEB_DEV_PROXY_TARGET=http://127.0.0.1:${fixturePort} node e2e/serve-web.mjs --port ${webPort}`, + url: `http://127.0.0.1:${webPort}`, + timeout: 30_000, + stdout: "pipe", + stderr: "pipe", + }, + ], +}); diff --git a/apps/web/src/components/model-combobox.tsx b/apps/web/src/components/model-combobox.tsx new file mode 100644 index 000000000..96111346b --- /dev/null +++ b/apps/web/src/components/model-combobox.tsx @@ -0,0 +1,110 @@ +"use client"; + +// The console's searchable model selector, in the same coss ui vocabulary as +// `console-select.tsx`: a 30px ringed control whose popup is a hairline card. +// Base UI's combobox already provides the search input, keyboard navigation and +// typeahead; this file only binds it to the console's tokens. + +import { Combobox } from "@base-ui/react/combobox"; +import { ChevronDownIcon } from "lucide-react"; +import { useMemo, useRef } from "react"; + +import { cn } from "@/lib/utils"; + +export interface ModelOption { + /** The exact model identifier, kept verbatim. */ + value: string; + /** What the row shows. */ + label: string; +} + +/** + * A searchable list bound to the options the current entrance admits. Typing + * filters the list; it never becomes the value, so an operator cannot save a + * model the catalog did not offer. + */ +export function ModelCombobox({ + value, + onChange, + options, + label, + placeholder, + disabled, + empty, + className, +}: { + value: string; + onChange: (value: string) => void; + options: readonly ModelOption[]; + /** Accessible name for the input and the list. */ + label: string; + placeholder?: string; + disabled?: boolean; + /** Shown when the search matches nothing. */ + empty: string; + className?: string; +}) { + const anchor = useRef(null); + const items = useMemo(() => options.map((option) => ({ value: option.value, label: option.label })), [options]); + const selected = items.find((option) => option.value === value); + return ( + onChange(typeof next === "string" ? next : "")} + itemToStringLabel={(itemValue) => items.find((option) => option.value === itemValue)?.label ?? itemValue} + disabled={disabled} + > +
+ + + + + + + + + {empty} + + {(item: ModelOption) => ( + + + + + + {item.label} + {item.value} + + + )} + + + + +
+
+ ); +} diff --git a/apps/web/src/features/system/ModelProviderDialog.tsx b/apps/web/src/features/system/ModelProviderDialog.tsx index 9a145278f..95d826a9c 100644 --- a/apps/web/src/features/system/ModelProviderDialog.tsx +++ b/apps/web/src/features/system/ModelProviderDialog.tsx @@ -1,11 +1,17 @@ import { AgentCoreError, type CoreHarness, type CoreHarnessKind, type ModelProviderInput } from "@oac/agents-client"; -import { useId, useRef, useState } from "react"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ConsoleSelect } from "../../components/console-select"; import { HelpTip } from "../../components/console-ui"; import { Modal } from "../../components/Modal"; +import { ModelCombobox, type ModelOption } from "../../components/model-combobox"; import { coreError, coreFieldError } from "../../lib/core-error"; import { harnessNames, protocolNames } from "../../lib/harness-labels"; +import { isOrcarouterBase, orcarouterInferenceBase, orcarouterProviderLabel, type OrcarouterConfiguration, type OrcarouterCredential, type OrcarouterCredentialSource } from "../../lib/orcarouter"; +import { createCredentialAttempts } from "../../lib/orcarouter-attempts"; +import { readOrcarouterCatalog, type OrcarouterCatalog } from "../../lib/orcarouter-catalog"; +import { apiKeyAdapter, credentialFailure, pkceAdapter, readOrcarouterConfiguration } from "../../lib/orcarouter-credential"; +import { startOrcarouterLogin, type OrcarouterLogin } from "../../lib/orcarouter-pkce"; import { admin } from "../../lib/projects"; /** A write that gets no answer in this time has an unknown outcome. */ @@ -14,6 +20,9 @@ const WRITE_TIMEOUT_MS = 30_000; type Protocol = ModelProviderInput["protocol"]; /** Core stores both token limits as 32-bit integers. */ const INT32_MAX = 2_147_483_647; +/** The console's only provider beyond the operator's own OpenAI-compatible endpoint. */ +const orcarouterPreset = "orcarouter"; +const genericPreset = "openai_compatible"; /** A token limit: empty is omitted; anything but a whole number within Core's range is a problem. */ function tokenLimit(text: string): { value: number | undefined; problem: "whole" | "large" | null } { @@ -42,6 +51,12 @@ function isProviderUrl(value: string): boolean { * limits within Core's range, with max output no larger than the context window. * Core's typed rejection is shown beside its field, or beside the form * when no editable field applies. Enter saves; a save in flight blocks another. + * + * Two named presets are offered. `OrcaRouter` is a first-class provider: its + * inference base is the gateway's own and is never typed by hand, and the + * default model is chosen from the workspace's live catalog instead of being + * entered. Both credential entrances — a pasted API key and `Connect with + * OrcaRouter` — write the same ordinary key into the same field. */ export function ModelProviderDialog({ harness, onClose, onSaved, onReread }: { harness: CoreHarness | null; @@ -58,9 +73,13 @@ export function ModelProviderDialog({ harness, onClose, onSaved, onReread }: { const support = harness?.model_configuration_support; const limitsRequired = support?.token_limits_required ?? false; const protocolOptions = (support?.protocols ?? []).map((value) => ({ value, label: protocolNames[value] })); + // A saved OrcaRouter configuration is recognised again rather than silently + // reading as a generic endpoint. + const [preset, setPreset] = useState(current !== null && isOrcarouterBase(current.base_url) ? orcarouterPreset : genericPreset); const [protocol, setProtocol] = useState(current?.protocol ?? support?.protocols[0] ?? ""); const [baseUrl, setBaseUrl] = useState(current?.base_url ?? ""); const [apiKey, setApiKey] = useState(""); + const [credentialSource, setCredentialSource] = useState("api_key"); const [model, setModel] = useState(configuration?.model ?? ""); const [configText, setConfigText] = useState(JSON.stringify(configuration?.harness_config ?? {}, null, 2)); const [configTouched, setConfigTouched] = useState(false); @@ -78,22 +97,101 @@ export function ModelProviderDialog({ harness, onClose, onSaved, onReread }: { const [rejection, setRejection] = useState(null); const fieldError = (param: string) => coreFieldError(rejection, param, tCommon) ?? coreFieldError(rejection, `model_provider.${param}`, tCommon); - const name = harness ? harnessNames[harness.id] : ""; - const protocolProblem = !protocol ? t("models.form.protocolUnavailable") : !support?.protocols.includes(protocol) ? t("models.form.protocolUnsupported", { protocol: protocolNames[protocol] }) : null; - const configTooLarge = nativeConfig !== null && new TextEncoder().encode(JSON.stringify(nativeConfig)).length > 16 * 1024; - const url = baseUrl.trim(); - const urlProblem = url && !isProviderUrl(url) ? t("models.form.baseUrlInvalid") : null; - const context = tokenLimit(contextWindow); - const output = tokenLimit(maxOutputTokens); - const limitProblem = (limit: ReturnType) => (limit.problem === "whole" ? t("models.form.wholeNumber") : limit.problem === "large" ? t("models.form.tooLarge") : null); - const outputProblem = limitProblem(output); - // The missing or undersized window is the field the administrator must fix. - const contextProblem = limitProblem(context) ?? (!outputProblem && output.value !== undefined && output.value > (context.value ?? 0) ? t("models.form.needsContext") : null); - const ready = harness !== null && !busy && url !== "" && !urlProblem && apiKey.trim() !== "" && model.trim() !== "" && nativeConfig !== null && !configTooLarge && !protocolProblem && (!limitsRequired || Boolean(context.value && output.value)) && !contextProblem && !outputProblem; + const isOrcaRouter = preset === orcarouterPreset; - function clearNativeConfig() { - setConfigText("{}"); - setConfigTouched(false); + // The catalog of the operator's own workspace. Its options are the only models + // this form can save while OrcaRouter is selected. + const [orcaConfiguration, setConfiguration] = useState(null); + const [catalog, setCatalog] = useState(null); + const [catalogBusy, setCatalogBusy] = useState(false); + const [catalogReason, setCatalogReason] = useState<"unavailable" | "refused" | null>(null); + const requests = useRef(0); + + // One credential seam, two adapters: the pasted key and the PKCE result land + // in the same field, and nothing downstream knows which one produced it. + const attempts = useRef(createCredentialAttempts((view) => { setConnectBusy(view.busy); setConnectHint(view.hint); })); + const [connectBusy, setConnectBusy] = useState(false); + const [connectHint, setConnectHint] = useState(null); + const [login, setLogin] = useState(null); + const [code, setCode] = useState(""); + const catalogTimer = useRef(null); + + useEffect(() => { + if (!isOrcaRouter) return; + void readOrcarouterConfiguration().then(setConfiguration); + }, [isOrcaRouter]); + + // Leaving the page releases the login synchronously; the controller aborts the + // exchange too, so no answer can land after the page is gone and a later + // sign-in needs no remount. + useEffect(() => { + const leave = () => { attempts.current.pagehide(); }; + window.addEventListener("pagehide", leave); + return () => { + window.removeEventListener("pagehide", leave); + // A pending catalog read is the operator's own key on its way out of the form. + if (catalogTimer.current !== null) window.clearTimeout(catalogTimer.current); + attempts.current.abandon(); + }; + }, []); + + const inferenceBase = orcaConfiguration?.inferenceBase ?? orcarouterInferenceBase(); + + /** + * Reads the catalog for the selected provider. The selection is re-validated + * against what comes back: a model the current entrance no longer admits is + * cleared rather than kept as a value the provider would reject. + */ + async function loadCatalog(key: string, source: OrcarouterCredentialSource) { + if (key.trim() === "") { setCatalog(null); setCatalogReason(null); return; } + const ticket = requests.current + 1; + requests.current = ticket; + setCatalogBusy(true); + try { + const answer = await readOrcarouterCatalog({ selector: { capability: "chat" }, key }); + if (requests.current !== ticket) return; + setCatalog(answer); + setCatalogReason(answer.unauthorized ? "refused" : answer.degraded ? "unavailable" : null); + if (answer.unauthorized) { setError(t("models.orcarouter.keyRefused")); return; } + setCredentialSource(source); + if (model !== "" && !answer.models.some((entry) => entry.id === model)) { + setModel(""); + setError(t("models.orcarouter.modelInvalid")); + } + } finally { + if (requests.current === ticket) setCatalogBusy(false); + } + } + + async function connect() { + const attempt = attempts.current.begin(); + try { + const configuration = orcaConfiguration ?? (await readOrcarouterConfiguration(attempt.signal)); + const started = await startOrcarouterLogin(configuration); + if (!attempts.current.settle(attempt, { ok: true })) return; + setConfiguration(configuration); + setLogin(started); + window.open(started.authorizeUrl, "_blank", "noopener,noreferrer"); + } catch (caught) { + attempts.current.settle(attempt, { ok: false, message: messageOf(caught) }); + } + } + + async function submitCode() { + if (!login) return; + const attempt = attempts.current.begin(); + try { + const credential = await pkceAdapter({ code, login, state: login.state, signal: attempt.signal }).obtain(); + attempts.current.settle(attempt, { ok: true }); + setApiKey(credential.key); + setCode(""); + setLogin(null); + await loadCatalog(credential.key, credential.source); + } catch (caught) { + // The pasted code is refused, reused or expired: say what the operator does + // next and keep the login open so the same screen can take another code. + attempts.current.settle(attempt, { ok: false, message: messageOf(caught) }); + } } async function save() { @@ -102,9 +200,13 @@ export function ModelProviderDialog({ harness, onClose, onSaved, onReread }: { setBusy(true); setError(null); setRejection(null); try { + // Both entrances go through the same adapter seam before anything is written. + const adapter = isOrcaRouter && credentialSource === "api_key" ? apiKeyAdapter(apiKey) : null; + const credential = adapter === null ? null : await adapter.obtain(); + const key = credential?.key ?? apiKey.trim(); await admin.setHarnessModelConfiguration(harness.id, { model: model.trim(), harness_config: nativeConfig, - model_provider: { protocol, base_url: url, api_key: apiKey.trim(), + model_provider: { protocol, base_url: isOrcaRouter ? inferenceBase : url, api_key: key, ...(context.value === undefined ? {} : { context_window: context.value }), ...(output.value === undefined ? {} : { max_output_tokens: output.value }), }, @@ -126,6 +228,33 @@ export function ModelProviderDialog({ harness, onClose, onSaved, onReread }: { } } + /** One failure vocabulary for both entrances, in the operator's language. */ + function messageOf(cause: unknown): string { + return t(`models.orcarouter.connectFailure.${credentialFailure(cause)}`); + } + + const name = harness ? harnessNames[harness.id] : ""; + const protocolProblem = !protocol ? t("models.form.protocolUnavailable") : !support?.protocols.includes(protocol) ? t("models.form.protocolUnsupported", { protocol: protocolNames[protocol] }) : null; + const configTooLarge = nativeConfig !== null && new TextEncoder().encode(JSON.stringify(nativeConfig)).length > 16 * 1024; + const url = baseUrl.trim(); + const urlProblem = !isOrcaRouter && url && !isProviderUrl(url) ? t("models.form.baseUrlInvalid") : null; + const context = tokenLimit(contextWindow); + const output = tokenLimit(maxOutputTokens); + const limitProblem = (limit: ReturnType) => (limit.problem === "whole" ? t("models.form.wholeNumber") : limit.problem === "large" ? t("models.form.tooLarge") : null); + const outputProblem = limitProblem(output); + // The missing or undersized window is the field the administrator must fix. + const contextProblem = limitProblem(context) ?? (!outputProblem && output.value !== undefined && output.value > (context.value ?? 0) ? t("models.form.needsContext") : null); + const options: ModelOption[] = useMemo(() => (catalog?.models ?? []).map((entry) => ({ value: entry.id, label: `${entry.name} · ${entry.id}` })), [catalog]); + // The model control is a list from the catalog while OrcaRouter is selected; + // the operator never types an identifier the workspace's plan may not serve. + const modelReady = isOrcaRouter ? options.some((option) => option.value === model) : model.trim() !== ""; + const ready = harness !== null && !busy && (isOrcaRouter || url !== "") && !urlProblem && apiKey.trim() !== "" && modelReady && nativeConfig !== null && !configTooLarge && !protocolProblem && (!limitsRequired || Boolean(context.value && output.value)) && !contextProblem && !outputProblem; + + function clearNativeConfig() { + setConfigText("{}"); + setConfigTouched(false); + } + const limitField = (field: "context" | "output", value: string, setValue: (value: string) => void, problem: string | null) => { const inputId = `${id}-${field}`; problem = problem ?? fieldError(field === "context" ? "context_window" : "max_output_tokens"); @@ -169,6 +298,24 @@ export function ModelProviderDialog({ harness, onClose, onSaved, onReread }: { )} >
{ event.preventDefault(); void save(); }}> +
+ {t("models.orcarouter.presetHelp")} + { + setPreset(value); + setRejection(null); setError(null); + setCatalog(null); setCatalogReason(null); + attempts.current.abandon(); + setLogin(null); setCode(""); + if (value === orcarouterPreset) { setBaseUrl(""); clearNativeConfig(); } else if (isOrcaRouter) { setBaseUrl(""); setModel(""); } + setApiKey(""); + }} + /> +
{t("models.protocol")} @@ -183,31 +330,97 @@ export function ModelProviderDialog({ harness, onClose, onSaved, onReread }: { }} /> {protocolError ? {protocolError} : null}
-
- - { if (event.target.value.trim() !== baseUrl.trim()) clearNativeConfig(); setBaseUrl(event.target.value); setRejection(null); setError(null); }} - autoComplete="off" - spellCheck={false} - aria-invalid={baseUrlError ? true : undefined} - aria-describedby={baseUrlError ? `${id}-url-problem` : undefined} - /> - {baseUrlError ? {baseUrlError} : null} -
+ {isOrcaRouter ? ( +
+ {t("models.baseUrl")}{t("models.orcarouter.baseUrlHelp")} + {/* A value, not a control: the gateway's own inference base. */} +

{inferenceBase}

+
+ ) : ( +
+ + { if (event.target.value.trim() !== baseUrl.trim()) clearNativeConfig(); setBaseUrl(event.target.value); setRejection(null); setError(null); }} + autoComplete="off" + spellCheck={false} + aria-invalid={baseUrlError ? true : undefined} + aria-describedby={baseUrlError ? `${id}-url-problem` : undefined} + /> + {baseUrlError ? {baseUrlError} : null} +
+ )}
- - {t("models.form.apiKeyHelp")} + + {isOrcaRouter ? t("models.orcarouter.apiKeyHelp") : t("models.form.apiKeyHelp")} - { setApiKey(event.target.value); setRejection(null); setError(null); }} aria-required="true" aria-invalid={apiKeyError ? true : undefined} aria-describedby={`${id}-key-help${apiKeyError ? ` ${id}-key-problem` : ""}`} /> + { + const value = event.target.value; + setApiKey(value); setCredentialSource("api_key"); setRejection(null); setError(null); + // A pasted key is one of the two entrances; reading the catalog as soon + // as the field settles keeps both entrances on one credential seam and + // one model list. + if (isOrcaRouter) { + if (catalogTimer.current !== null) window.clearTimeout(catalogTimer.current); + catalogTimer.current = window.setTimeout(() => { const pending = value.trim(); if (pending !== "") void loadCatalog(pending, "api_key"); }, 400); + } + }} aria-required="true" aria-invalid={apiKeyError ? true : undefined} aria-describedby={`${id}-key-help${apiKeyError ? ` ${id}-key-problem` : ""}`} /> {apiKeyError ? {apiKeyError} : null} + {isOrcaRouter ? ( + // The second entrance sits beside the first: an operator without a key + // connects, an operator with one pastes it, and both fill this field. +
+
+ + {login ? ( + <> + + + + {t("models.orcarouter.reopen")} + + ) : null} +
+

+ {connectHint === null ? t(`models.orcarouter.hint.${credentialSource}`) : connectHint} +

+

{t("models.orcarouter.keyConsoleHelp")} {t("models.orcarouter.keyConsole")}

+
+ ) : null}
- {t("models.form.modelName")} - { if (event.target.value.trim() !== model.trim()) clearNativeConfig(); setModel(event.target.value); setRejection(null); setError(null); }} /> + {isOrcaRouter ? t("models.orcarouter.modelHelp") : t("models.form.modelName")} + {isOrcaRouter ? ( + { if (value.trim() !== model.trim()) clearNativeConfig(); setModel(value); setRejection(null); setError(null); }} + options={options} + label={t("models.model")} + placeholder={t("models.orcarouter.modelPlaceholder")} + disabled={busy || catalogBusy || apiKey.trim() === ""} + empty={t("models.orcarouter.modelEmpty")} + /> + ) : ( + { if (event.target.value.trim() !== model.trim()) clearNativeConfig(); setModel(event.target.value); setRejection(null); setError(null); }} /> + )} + {isOrcaRouter ? ( + <> + + {catalogBusy ? t("models.orcarouter.catalogLoading") : catalogReason === "refused" ? t("models.orcarouter.catalogRefused") : catalogReason === "unavailable" ? t("models.orcarouter.catalogFallback") : catalog === null ? t("models.orcarouter.catalogIdle") : t("models.orcarouter.catalogLive", { count: options.length })} + + {catalog !== null && !catalogBusy && apiKey.trim() !== "" ? ( + + ) : null} + + ) : null} {modelError ? {modelError} : null}
setAdvancedOpen(event.currentTarget.open)}> diff --git a/apps/web/src/i18n/locales/en/system.ts b/apps/web/src/i18n/locales/en/system.ts index cacaa5427..efd0e0bd6 100644 --- a/apps/web/src/i18n/locales/en/system.ts +++ b/apps/web/src/i18n/locales/en/system.ts @@ -95,6 +95,45 @@ export const system = { busy: "Clearing…", uncertain: "Core did not confirm clearing it. The default model configurations were read again; check them before trying again.", }, + orcarouter: { + preset: "Model provider", + presetHelp: "OrcaRouter is an OpenAI-compatible AI gateway that routes many providers behind one endpoint. Its address and model list are managed for you; any other provider keeps a manual URL and model ID.", + presetGeneric: "Other OpenAI-compatible provider", + baseUrlHelp: "The gateway's inference base. OrcaRouter is reached at its own address, so this value is not editable here.", + apiKeyLabel: "OrcaRouter API key", + apiKeyHelp: "An sk-orca- key from your OrcaRouter workspace. Core encrypts it and never shows it again. You can also connect with OrcaRouter instead of pasting one.", + keyConsole: "OrcaRouter keys", + keyConsoleHelp: "Create, list or revoke keys in", + connect: "Connect with OrcaRouter", + connecting: "Opening OrcaRouter…", + codeLabel: "Authorization code", + submitCode: "Use this code", + reopen: "Reopen the OrcaRouter page", + hint: { + api_key: "Paste an existing OrcaRouter API key, or connect with OrcaRouter to obtain one.", + pkce: "Connected with OrcaRouter. The issued API key is in the field above and is used for every request.", + }, + connectFailure: { + "state-mismatch": "This code belongs to a different sign-in attempt. Start the connection again.", + "missing-code": "Enter the authorization code OrcaRouter showed, then try again.", + "code-expired": "That code is unknown, already used or expired. Start the connection again.", + "rate-limited": "OrcaRouter is refusing requests for now. Wait a moment and try again.", + unreachable: "OrcaRouter could not be reached. Check this deployment's network and try again.", + unauthorized: "OrcaRouter approved a narrower scope than this form needs. Start the connection again.", + unavailable: "The connection to OrcaRouter failed. Start the connection again.", + }, + keyRefused: "OrcaRouter rejected this API key. Replace it or connect again.", + catalogIdle: "Enter an API key to list the models this workspace can call.", + catalogLoading: "Reading the model catalog…", + catalogLive: "{{count}} models from your OrcaRouter workspace.", + catalogRefused: "OrcaRouter rejected this API key, and the built-in verified fallback is shown instead.", + catalogFallback: "The OrcaRouter catalog could not be read. A verified fallback list is shown; refresh to try again.", + catalogRefresh: "Refresh the catalog", + modelHelp: "Chosen from the models your OrcaRouter workspace can call. Used for new Sessions when the application does not specify a model.", + modelPlaceholder: "Select a model", + modelEmpty: "No model matches this search.", + modelInvalid: "The selected model is not available for this provider. Choose another one.", + }, }, sandbox: { title: "Sandboxes", diff --git a/apps/web/src/i18n/locales/zh-CN/system.ts b/apps/web/src/i18n/locales/zh-CN/system.ts index 5bb291887..c850f7f02 100644 --- a/apps/web/src/i18n/locales/zh-CN/system.ts +++ b/apps/web/src/i18n/locales/zh-CN/system.ts @@ -97,6 +97,45 @@ export const system: TranslationShape = { busy: "正在清除…", uncertain: "Core 没有确认清除。已重新读取默认模型配置,请先核对再重试。", }, + orcarouter: { + preset: "模型服务", + presetHelp: "OrcaRouter 是 OpenAI 兼容的 AI 网关,用一个接入地址路由多家模型服务。它的地址和模型列表由系统管理;选择其他服务时仍需手动填写地址和模型 ID。", + presetGeneric: "其他 OpenAI 兼容服务", + baseUrlHelp: "网关的推理接入地址。OrcaRouter 使用它自己的地址,因此这里不能修改。", + apiKeyLabel: "OrcaRouter API key", + apiKeyHelp: "来自你的 OrcaRouter workspace 的 sk-orca- 密钥。Core 会加密保存,之后不再显示。也可以改用“连接 OrcaRouter”获取。", + keyConsole: "OrcaRouter 密钥页", + keyConsoleHelp: "创建、查看或撤销密钥:", + connect: "连接 OrcaRouter", + connecting: "正在打开 OrcaRouter…", + codeLabel: "授权码", + submitCode: "使用此授权码", + reopen: "重新打开 OrcaRouter 页面", + hint: { + api_key: "粘贴已有的 OrcaRouter API key,或连接 OrcaRouter 获取一个。", + pkce: "已通过 OrcaRouter 连接。签发的 API key 已填入上方,所有请求都使用它。", + }, + connectFailure: { + "state-mismatch": "此授权码属于另一次登录尝试。请重新开始连接。", + "missing-code": "请填写 OrcaRouter 显示的授权码,然后重试。", + "code-expired": "该授权码无效、已被使用或已过期。请重新开始连接。", + "rate-limited": "OrcaRouter 暂时拒绝请求。请稍等片刻再试。", + unreachable: "无法访问 OrcaRouter。请检查本部署的网络后重试。", + unauthorized: "OrcaRouter 授予的权限范围小于此表单所需。请重新开始连接。", + unavailable: "连接 OrcaRouter 失败。请重新开始连接。", + }, + keyRefused: "OrcaRouter 拒绝了这个 API key。请更换,或重新连接。", + catalogIdle: "填入 API key 后可列出该 workspace 可调用的模型。", + catalogLoading: "正在读取模型目录…", + catalogLive: "来自你的 OrcaRouter workspace 的 {{count}} 个模型。", + catalogRefused: "OrcaRouter 拒绝了这个 API key,已改为显示内置的已验证后备列表。", + catalogFallback: "无法读取 OrcaRouter 模型目录。已显示已验证的后备列表;可刷新重试。", + catalogRefresh: "刷新模型目录", + modelHelp: "从你的 OrcaRouter workspace 可调用的模型中选择。应用未指定模型时,新会话使用此模型。", + modelPlaceholder: "选择模型", + modelEmpty: "没有与该搜索匹配的模型。", + modelInvalid: "所选模型在该服务下不可用,请重新选择。", + }, }, sandbox: { title: "沙箱", diff --git a/apps/web/src/lib/orcarouter-attempts.test.ts b/apps/web/src/lib/orcarouter-attempts.test.ts new file mode 100644 index 000000000..61526a91f --- /dev/null +++ b/apps/web/src/lib/orcarouter-attempts.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createCredentialAttempts } from "./orcarouter-attempts"; + +describe("connect attempts", () => { + it("reports busy while an attempt is open and clears it when it settles", () => { + const seen: Array<{ attempt: number; busy: boolean; hint: string | null }> = []; + const attempts = createCredentialAttempts((view) => seen.push(view)); + const attempt = attempts.begin(); + expect(attempts.snapshot()).toEqual({ attempt: 1, busy: true, hint: null }); + attempts.settle(attempt, { ok: true }); + expect(attempts.snapshot()).toEqual({ attempt: 1, busy: false, hint: null }); + expect(seen.at(-1)).toEqual({ attempt: 1, busy: false, hint: null }); + }); + + it("ignores a late answer from an older attempt", () => { + const attempts = createCredentialAttempts(); + const first = attempts.begin(); + const second = attempts.begin(); + expect(attempts.settle(first, { ok: true })).toBe(false); + expect(attempts.snapshot().busy).toBe(true); + expect(attempts.settle(second, { ok: false, message: "code-expired" })).toBe(true); + expect(attempts.snapshot()).toEqual({ attempt: 2, busy: false, hint: "code-expired" }); + }); + + it("aborts the in-flight exchange when the operator switches or cancels", () => { + const attempts = createCredentialAttempts(); + const attempt = attempts.begin(); + expect(attempt.signal.aborted).toBe(false); + attempts.abandon(); + expect(attempt.signal.aborted).toBe(true); + expect(attempts.snapshot()).toEqual({ attempt: 2, busy: false, hint: null }); + }); + + it("clears busy and the hint synchronously on pagehide", () => { + const attempts = createCredentialAttempts(); + const attempt = attempts.begin(); + attempts.settle(attempt, { ok: false, message: "unreachable" }); + expect(attempts.snapshot().hint).toBe("unreachable"); + const second = attempts.begin(); + attempts.pagehide(); + expect(attempts.snapshot()).toEqual({ attempt: 3, busy: false, hint: null }); + expect(second.signal.aborted).toBe(true); + }); + + it("starts a second login without a remount after pagehide", () => { + const attempts = createCredentialAttempts(); + const first = attempts.begin(); + attempts.pagehide(); + const second = attempts.begin(); + expect(second.generation).toBeGreaterThan(first.generation); + expect(second.signal.aborted).toBe(false); + expect(attempts.settle(second, { ok: true })).toBe(true); + expect(attempts.snapshot()).toEqual({ attempt: second.generation, busy: false, hint: null }); + }); + + it("never lets a stale answer restore a busy flag", () => { + const onChange = vi.fn(); + const attempts = createCredentialAttempts(onChange); + const stale = attempts.begin(); + attempts.abandon(); + attempts.settle(stale, { ok: true }); + expect(onChange).toHaveBeenCalledTimes(2); + expect(attempts.snapshot().busy).toBe(false); + }); +}); diff --git a/apps/web/src/lib/orcarouter-attempts.ts b/apps/web/src/lib/orcarouter-attempts.ts new file mode 100644 index 000000000..39da47ad9 --- /dev/null +++ b/apps/web/src/lib/orcarouter-attempts.ts @@ -0,0 +1,77 @@ +/** + * One console-side state machine for `Connect with OrcaRouter`. + * + * A connect attempt is a browser redirect plus a paste, so a late answer must + * never overwrite a newer attempt, and leaving the page must drop the busy flag + * and the hint synchronously — the console keeps no server-side login lock, so + * there is nothing else to release, but the in-flight exchange is aborted so it + * cannot land after the page is gone. + * + * The controller is plain and dependency-free so its ordering rules can be + * tested without a browser. + */ +export interface CredentialAttempt { + /** Increases with every attempt; a result from an older one is ignored. */ + readonly generation: number; + readonly signal: AbortSignal; +} + +export interface CredentialAttemptView { + attempt: number; + busy: boolean; + hint: string | null; +} + +export function createCredentialAttempts(onChange?: (view: CredentialAttemptView) => void) { + let generation = 0; + let busy = false; + let hint: string | null = null; + let controller: AbortController | null = null; + + const report = () => onChange?.({ attempt: generation, busy, hint }); + + /** Starts one attempt: a new generation and a signal that dies with the page. */ + function begin(): CredentialAttempt { + controller?.abort(); + controller = new AbortController(); + generation += 1; + busy = true; + hint = null; + report(); + return { generation, signal: controller.signal }; + } + + /** Records the outcome of an attempt. A stale generation changes nothing. */ + function settle(attempt: CredentialAttempt, outcome: { ok: true } | { ok: false; message: string }): boolean { + if (attempt.generation !== generation) return false; + busy = false; + hint = outcome.ok ? null : outcome.message; + report(); + return true; + } + + /** Releases the lock because the operator moved on, without reporting a hint. */ + function abandon(): void { + controller?.abort(); + controller = null; + generation += 1; + busy = false; + hint = null; + report(); + } + + /** + * Leaving the page: drop the busy flag and the hint now, before the unload + * completes, and abort the exchange. A second login after this is a fresh + * attempt and does not need a remount. + */ + function pagehide(): void { + abandon(); + } + + function snapshot(): CredentialAttemptView { + return { attempt: generation, busy, hint }; + } + + return { begin, settle, abandon, pagehide, snapshot }; +} diff --git a/apps/web/src/lib/orcarouter-catalog.test.ts b/apps/web/src/lib/orcarouter-catalog.test.ts new file mode 100644 index 000000000..7f0eff2ba --- /dev/null +++ b/apps/web/src/lib/orcarouter-catalog.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { orcarouterApiOrigin } from "./orcarouter"; +import { readOrcarouterCatalog } from "./orcarouter-catalog"; + +function answer(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const chat = { capability: "chat" } as const; + +describe("reading the catalog for one entrance", () => { + it("asks the console's own route for the entrance's capability and keeps the key out of the URL", async () => { + const calls: Array<{ url: string; headers: unknown }> = []; + const request = (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ url: String(input), headers: init?.headers }); + return answer({ models: [{ id: "openai/gpt-5.5", supported_endpoint_types: ["openai"], architecture: { input_modalities: ["text", "image"] } }], catalog_origin: orcarouterApiOrigin, degraded: false }); + }) as typeof fetch; + const catalog = await readOrcarouterCatalog({ selector: chat, key: "sk-orca-fixture", request }); + expect(calls[0]?.url).toBe("/console/orcarouter/catalog?capability=chat"); + expect(calls[0]?.url).not.toContain("sk-orca-fixture"); + expect((calls[0]?.headers as Record)["X-OrcaRouter-Key"]).toBe("sk-orca-fixture"); + expect(catalog).toMatchObject({ degraded: false, unauthorized: false, origin: orcarouterApiOrigin }); + expect(catalog.models.map((model) => model.id)).toEqual(["openai/gpt-5.5"]); + }); + + it("filters the live answer for the entrance it was asked for", async () => { + const request = (async () => answer({ + models: [ + { id: "openai/gpt-5.5", supported_endpoint_types: ["openai"], architecture: { input_modalities: ["text"] } }, + { id: "openai/gpt-image-1", supported_endpoint_types: ["image-generation"] }, + { id: "silent/one", supported_endpoint_types: ["openai"] }, + ], + catalog_origin: orcarouterApiOrigin, + degraded: false, + })) as typeof fetch; + const image = await readOrcarouterCatalog({ selector: { capability: "chat", inputModalities: ["image"] }, key: "sk-orca-fixture", request }); + expect(image.models.map((model) => model.id)).toEqual([]); + const text = await readOrcarouterCatalog({ selector: chat, key: "sk-orca-fixture", request }); + expect(text.models.map((model) => model.id)).toEqual(["openai/gpt-5.5", "silent/one"]); + }); + + it("reports a refused key without offering a fallback as if it were live", async () => { + const request = (async () => answer({ error: "OrcaRouter rejected this API key" }, 401)) as typeof fetch; + const catalog = await readOrcarouterCatalog({ selector: chat, key: "sk-orca-revoked", request }); + expect(catalog.unauthorized).toBe(true); + expect(catalog.degraded).toBe(true); + }); + + it("offers only the labelled verified fallback when the catalog cannot be read", async () => { + const request = (async () => answer({ models: [], degraded: true, reason: "catalog_unreachable", catalog_origin: orcarouterApiOrigin })) as typeof fetch; + const catalog = await readOrcarouterCatalog({ selector: chat, key: "sk-orca-fixture", request }); + expect(catalog.degraded).toBe(true); + expect(catalog.reason).toBe("catalog_unreachable"); + expect(catalog.models.map((model) => model.id)).toEqual(["openai/gpt-5.5", "anthropic/claude-opus-4.8", "google/gemini-3.5-flash", "deepseek/deepseek-v4-pro", "orcarouter/auto"]); + // The live answer is never mixed into the fallback. + expect(catalog.models.every((model) => model.id !== "silent/one")).toBe(true); + }); + + it("falls back rather than throwing when the route answers something unusable", async () => { + const request = (async () => answer({ unexpected: true })) as typeof fetch; + const catalog = await readOrcarouterCatalog({ selector: chat, key: "sk-orca-fixture", request }); + expect(catalog.degraded).toBe(true); + expect(catalog.models.length).toBeGreaterThan(0); + expect(catalog.unauthorized).toBe(false); + }); + + it("keeps a refused key out of the fallback too", async () => { + const request = (async () => answer({ error: "no" }, 403)) as typeof fetch; + const catalog = await readOrcarouterCatalog({ selector: chat, key: "sk-orca-revoked", request }); + expect(JSON.stringify(catalog)).not.toContain("sk-orca-revoked"); + }); +}); diff --git a/apps/web/src/lib/orcarouter-catalog.ts b/apps/web/src/lib/orcarouter-catalog.ts new file mode 100644 index 000000000..238bc7fca --- /dev/null +++ b/apps/web/src/lib/orcarouter-catalog.ts @@ -0,0 +1,46 @@ +/** + * The console's OrcaRouter model catalog read. + * + * The browser never talks to OrcaRouter: the console server performs the + * request with the operator's key, which travels in a request header and is + * never placed in a URL, a log or browser storage. This module only composes + * the same-origin request and reduces the answer. + * + * Live discovery is authoritative. When it cannot be read, the caller gets the + * small verified fallback labelled `degraded` — never free text and never a + * mixture of live records and seed records. + */ +import { catalogAnswer, fallbackAnswer, type CatalogAnswer, type CatalogSelector } from "./orcarouter"; + +export interface OrcarouterCatalog extends CatalogAnswer { + /** True when OrcaRouter refused the key; the operator must replace it. */ + unauthorized: boolean; + /** Why a fallback is in use, when one is. */ + reason?: string; +} + +/** + * Reads the catalog for one entrance. A rejection is reported as a failure so + * the caller can point at the API key field; an outage offers the verified + * fallback instead of an empty selector. + */ +export async function readOrcarouterCatalog(input: { + selector: CatalogSelector; + key: string; + signal?: AbortSignal; + request?: typeof fetch; +}): Promise { + const request = input.request ?? fetch; + const capability = input.selector.capability; + const response = await request(`/console/orcarouter/catalog?capability=${encodeURIComponent(capability)}`, { + credentials: "include", + headers: { "X-OrcaRouter-Key": input.key.trim() }, + signal: input.signal, + }); + if (response.status === 401 || response.status === 403) return { ...fallbackAnswer(input.selector, ""), unauthorized: true }; + const payload = (await response.json().catch(() => null)) as { models?: unknown; catalog_origin?: unknown; degraded?: unknown } | null; + if (payload === null || !Array.isArray(payload.models)) return { ...fallbackAnswer(input.selector, ""), unauthorized: false }; + const origin = typeof payload.catalog_origin === "string" ? payload.catalog_origin : ""; + if (payload.degraded === true || !response.ok) return { ...fallbackAnswer(input.selector, origin), unauthorized: false }; + return { ...catalogAnswer({ data: payload.models }, input.selector, origin), unauthorized: false }; +} diff --git a/apps/web/src/lib/orcarouter-credential.test.ts b/apps/web/src/lib/orcarouter-credential.test.ts new file mode 100644 index 000000000..183c4bbfb --- /dev/null +++ b/apps/web/src/lib/orcarouter-credential.test.ts @@ -0,0 +1,222 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { orcarouterDefaultConfiguration, type OrcarouterCredential } from "./orcarouter"; +import { + apiKeyAdapter, + credentialFailure, + pkceAdapter, + readOrcarouterConfiguration, + type OrcarouterLogin, +} from "./orcarouter-credential"; +import { looksLikeOrcarouterKey, normalizeCode, pkceChallenge, sameState, startOrcarouterLogin } from "./orcarouter-pkce"; + +/** A pasted key and a PKCE result must reach the downstream request identically. */ +const pasted = (key: string): OrcarouterCredential => ({ source: "api_key", key }); + +function answer(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} + +function attempt(): { login: Promise; code: string } { + return { login: startOrcarouterLogin(orcarouterDefaultConfiguration), code: "one-time-code" }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("PKCE material", () => { + it("derives an unpadded base64url S256 challenge from the verifier", async () => { + // A fixed verifier proves the exact transformation; it is never a real one. + const challenge = await pkceChallenge("fixture-verifier-not-a-real-one"); + // Cross-checked against `sha256` in node:crypto, base64url and unpadded. + expect(challenge).toBe("g6nGTwndBfMLFnVyLF6MSEoHj1i5MddZ2d3XbIA5fig"); + expect(challenge).not.toMatch(/[+/=]/u); + }); + + it("generates a fresh verifier and state for every attempt", async () => { + const first = await startOrcarouterLogin(orcarouterDefaultConfiguration); + const second = await startOrcarouterLogin(orcarouterDefaultConfiguration); + expect(first.verifier).not.toBe(second.verifier); + expect(first.state).not.toBe(second.state); + expect(first.verifier.length).toBeGreaterThanOrEqual(43); + expect(first.state.length).toBeGreaterThanOrEqual(16); + expect(second.verifier).not.toBe(first.verifier); + }); + + it("asks for an out-of-band code with S256, a state and the api scope", async () => { + const login = await startOrcarouterLogin(orcarouterDefaultConfiguration); + const url = new URL(login.authorizeUrl); + expect(url.origin).toBe("https://www.orcarouter.ai"); + expect(url.pathname).toBe("/auth"); + expect(url.searchParams.get("callback_url")).toBe("oob"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("code_challenge")).toBe(await pkceChallenge(login.verifier)); + expect(url.searchParams.get("state")).toBe(login.state); + expect(url.searchParams.get("scope")).toBe("api"); + expect(url.searchParams.get("app_name")).toBe("OpenAgentCore Console"); + }); + + it("never puts the verifier in the authorize URL", async () => { + const login = await startOrcarouterLogin(orcarouterDefaultConfiguration); + expect(login.authorizeUrl).not.toContain(login.verifier); + expect(login.authorizeUrl).not.toContain("code_verifier"); + }); + + it("uses the deployment's own auth origin and never the inference origin", async () => { + const login = await startOrcarouterLogin({ authOrigin: "https://login.internal", apiOrigin: "https://relay.internal", authorizeUrl: "https://login.internal/auth", inferenceBase: "https://relay.internal/v1", keyConsole: "https://login.internal/console" }); + const url = new URL(login.authorizeUrl); + expect(url.origin).toBe("https://login.internal"); + expect(url.pathname).toBe("/auth"); + }); + + it("compares state in constant time and refuses an empty expectation", () => { + expect(sameState("abcd", "abcd")).toBe(true); + expect(sameState("abcd", "abce")).toBe(false); + expect(sameState("abcd", "abc")).toBe(false); + expect(sameState("", "")).toBe(false); + }); +}); + +describe("the code a person pastes back", () => { + it("accepts a printable code and refuses whitespace or control characters", () => { + expect(normalizeCode(" abc-123 ")).toBe("abc-123"); + expect(normalizeCode("")).toBe(""); + expect(normalizeCode("abc def")).toBe(""); + expect(normalizeCode("abc\ndef")).toBe(""); + expect(normalizeCode(`x${"\u0000"}y`)).toBe(""); + }); + + it("recognises a gateway key by shape only", () => { + expect(looksLikeOrcarouterKey("sk-orca-abcdefgh")).toBe(true); + expect(looksLikeOrcarouterKey("sk-orca-")).toBe(false); + expect(looksLikeOrcarouterKey("sk-other-abcdefgh")).toBe(false); + }); +}); + +describe("the two credential adapters", () => { + it("trims a pasted key and refuses an empty one without any request", async () => { + expect(await apiKeyAdapter(" sk-orca-pasted ").obtain()).toEqual({ source: "api_key", key: "sk-orca-pasted" }); + await expect(apiKeyAdapter(" ").obtain()).rejects.toThrow("credential-code-missing"); + }); + + it("exchanges only at the console's own route and returns the issued key", async () => { + const { login, code } = attempt(); + const resolved = await login; + const calls: Array<{ url: string; body: unknown }> = []; + const request = (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ url: String(input), body: init?.body }); + return answer({ key: "sk-orca-issued", scope: "api" }); + }) as typeof fetch; + const credential = await pkceAdapter({ code, login: resolved, state: resolved.state, request }).obtain(); + expect(credential).toEqual({ source: "pkce", key: "sk-orca-issued" }); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe("/console/orcarouter/exchange"); + expect(JSON.parse(String(calls[0]?.body))).toEqual({ code: "one-time-code", code_verifier: resolved.verifier, code_challenge_method: "S256" }); + // The verifier travels to our own server in a body, never in a URL. + expect(String(calls[0]?.url)).not.toContain(resolved.verifier); + }); + + it("refuses a code from another attempt before redeeming anything", async () => { + const { login, code } = attempt(); + const resolved = await login; + let calls = 0; + const request = (async () => { + calls += 1; + return answer({ key: "sk-orca-should-not-exist", scope: "api" }); + }) as typeof fetch; + await expect(pkceAdapter({ code, login: resolved, state: "another-attempt", request }).obtain()).rejects.toThrow("credential-state-mismatch"); + expect(calls).toBe(0); + expect(credentialFailure(new Error("credential-state-mismatch"))).toBe("state-mismatch"); + }); + + it("refuses an empty paste without calling the server", async () => { + const { login } = attempt(); + const resolved = await login; + let calls = 0; + const request = (async () => { + calls += 1; + return answer({ key: "sk-orca-should-not-exist", scope: "api" }); + }) as typeof fetch; + await expect(pkceAdapter({ code: " ", login: resolved, state: resolved.state, request }).obtain()).rejects.toThrow("credential-code-missing"); + expect(calls).toBe(0); + }); + + it.each([ + [403, "code-expired"], + [429, "rate-limited"], + [400, "code-expired"], + [401, "unauthorized"], + [502, "unreachable"], + [500, "unavailable"], + ] as const)("reports HTTP %s as %s and never the upstream body", async (status, expected) => { + const { login, code } = attempt(); + const resolved = await login; + const request = (async () => answer({ error: "leaked-detail sk-orca-secret" }, status)) as typeof fetch; + const failure = await pkceAdapter({ code, login: resolved, state: resolved.state, request }).obtain().catch((cause: unknown) => credentialFailure(cause)); + expect(failure).toBe(expected); + expect(String(failure)).not.toContain("sk-orca-secret"); + }); + + it("refuses a granted scope other than the one this application asked for", async () => { + const { login, code } = attempt(); + const resolved = await login; + const request = (async () => answer({ key: "sk-orca-issued", scope: "connector" })) as typeof fetch; + await expect(pkceAdapter({ code, login: resolved, state: resolved.state, request }).obtain()).rejects.toThrow("credential-scope-insufficient"); + }); + + it("treats a response with no key as a failure rather than an empty credential", async () => { + const { login, code } = attempt(); + const resolved = await login; + const request = (async () => answer({ scope: "api" })) as typeof fetch; + await expect(pkceAdapter({ code, login: resolved, state: resolved.state, request }).obtain()).rejects.toThrow("credential-exchange-200"); + }); + + it("surfaces a network failure without inventing a credential", async () => { + const { login, code } = attempt(); + const resolved = await login; + const request = (async () => { + throw new TypeError("fetch failed"); + }) as typeof fetch; + const failure = await pkceAdapter({ code, login: resolved, state: resolved.state, request }).obtain().catch((cause: unknown) => credentialFailure(cause)); + expect(failure).toBe("unavailable"); + }); + + it("keeps the verifier, the state and the code out of every error it produces", async () => { + const { login, code } = attempt(); + const resolved = await login; + const request = (async () => answer({ error: "no" }, 403)) as typeof fetch; + const error = (await pkceAdapter({ code, login: resolved, state: resolved.state, request }).obtain().catch((cause: unknown) => cause)) as Error; + expect(error.message).not.toContain(resolved.verifier); + expect(error.message).not.toContain(resolved.state); + expect(error.message).not.toContain(code); + }); + + it("hands the downstream request the same shape from a pasted key and from PKCE", async () => { + const { login, code } = attempt(); + const resolved = await login; + const request = (async () => answer({ key: "sk-orca-same", scope: "api" })) as typeof fetch; + const viaPkce = await pkceAdapter({ code, login: resolved, state: resolved.state, request }).obtain(); + const viaPaste = await apiKeyAdapter("sk-orca-same").obtain(); + // Only the recorded source differs; every downstream field is identical. + expect(viaPkce).toEqual({ ...viaPaste, source: "pkce" }); + expect(Object.keys(viaPkce).sort()).toEqual(Object.keys(viaPaste).sort()); + expect(viaPkce.key).toBe(viaPaste.key); + }); +}); + +describe("reading the deployment's origins", () => { + it("uses the console's answer", async () => { + const request = (async () => answer({ object: "console.orcarouter", auth_origin: "https://login.internal", api_origin: "https://relay.internal", authorize_url: "https://login.internal/auth", key_console: "https://login.internal/console" })) as typeof fetch; + const configuration = await readOrcarouterConfiguration(undefined, request); + expect(configuration.authOrigin).toBe("https://login.internal"); + expect(configuration.authorizeUrl).toBe("https://login.internal/auth"); + }); + + it("falls back to the public origins when the read fails", async () => { + const request = (async () => { + throw new TypeError("offline"); + }) as typeof fetch; + expect(await readOrcarouterConfiguration(undefined, request)).toEqual(orcarouterDefaultConfiguration); + }); +}); diff --git a/apps/web/src/lib/orcarouter-credential.ts b/apps/web/src/lib/orcarouter-credential.ts new file mode 100644 index 000000000..c749cdf97 --- /dev/null +++ b/apps/web/src/lib/orcarouter-credential.ts @@ -0,0 +1,119 @@ +/** + * The console's OrcaRouter credential seam. + * + * Every way of obtaining a credential — a pasted API key and `Connect with + * OrcaRouter` (OAuth 2.0 + PKCE) — is an adapter behind one interface, and both + * adapters answer with the same `OrcarouterCredential`. Nothing downstream + * (the dialog, the model catalog, the `ModelProviderInput` written to Core) + * knows which adapter produced it. + * + * The console server owns both OrcaRouter origins and performs the two + * credential-bearing requests, so the verifier never leaves this process and + * the API key never reaches browser storage. + */ +import { + orcarouterConfiguration, + orcarouterDefaultConfiguration, + type OrcarouterConfiguration, + type OrcarouterCredential, +} from "./orcarouter"; +import { normalizeCode, sameState, type OrcarouterLogin } from "./orcarouter-pkce"; + +export type { OrcarouterLogin } from "./orcarouter-pkce"; + +/** One adapter: it answers with the credential every downstream request uses. */ +export interface OrcarouterCredentialAdapter { + readonly source: OrcarouterCredential["source"]; + obtain(): Promise; +} + +/** Adapter 1: the operator already holds a key and pastes it. */ +export function apiKeyAdapter(key: string): OrcarouterCredentialAdapter { + return { + source: "api_key", + async obtain() { + const trimmed = key.trim(); + if (trimmed === "") throw new Error("credential-code-missing"); + return { source: "api_key", key: trimmed }; + }, + }; +} + +/** + * Adapter 2: the operator has no key and approves one on the consent screen. + * The code is redeemed by the console server; the granted scope is read back + * and a narrower grant is refused rather than assumed. + */ +export function pkceAdapter(input: { + /** The code the operator pasted back. */ + code: string; + /** The attempt it belongs to; the state must still match. */ + login: OrcarouterLogin; + state: string; + signal?: AbortSignal; + request?: typeof fetch; +}): OrcarouterCredentialAdapter { + return { + source: "pkce", + async obtain() { + const code = normalizeCode(input.code); + // The state is compared before anything is redeemed: a code from another + // attempt is never exchanged, whatever a paste into the wrong window did. + if (!sameState(input.login.state, input.state)) throw new Error("credential-state-mismatch"); + if (code === "" || input.login.verifier === "") throw new Error("credential-code-missing"); + const request = input.request ?? fetch; + const response = await request("/console/orcarouter/exchange", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code, code_verifier: input.login.verifier, code_challenge_method: "S256" }), + signal: input.signal, + }); + const answer = (await response.json().catch(() => ({}))) as { key?: unknown; scope?: unknown }; + if (!response.ok || typeof answer.key !== "string" || answer.key === "") throw new Error(`credential-exchange-${response.status}`); + // The granted scope is read back; it is what was approved, not what was asked for. + if (answer.scope !== "api") throw new Error("credential-scope-insufficient"); + return { source: "pkce", key: answer.key }; + }, + }; +} + +/** Why an attempt failed, as the operator's next step. */ +export type OrcarouterCredentialFailure = + | "state-mismatch" + | "missing-code" + | "code-expired" + | "rate-limited" + | "unreachable" + | "unauthorized" + | "unavailable"; + +export function credentialFailure(cause: unknown): OrcarouterCredentialFailure { + const message = cause instanceof Error ? cause.message : ""; + if (message === "credential-state-mismatch") return "state-mismatch"; + if (message === "credential-code-missing") return "missing-code"; + if (message === "credential-scope-insufficient") return "unauthorized"; + if (message.endsWith("-429")) return "rate-limited"; + if (message.endsWith("-403")) return "code-expired"; + if (message.endsWith("-401")) return "unauthorized"; + if (message.endsWith("-400")) return "code-expired"; + if (message.endsWith("-502") || message.endsWith("-503")) return "unreachable"; + return "unavailable"; +} + +/** + * The deployment's OrcaRouter origins, as the console server reports them. A + * failed read keeps the public defaults and never invents an address. + */ +export async function readOrcarouterConfiguration( + signal?: AbortSignal, + request: typeof fetch = fetch, +): Promise { + try { + const response = await request("/console/orcarouter/config", { credentials: "include", signal }); + if (!response.ok) return orcarouterDefaultConfiguration; + return orcarouterConfiguration(await response.json()); + } catch { + return orcarouterDefaultConfiguration; + } +} diff --git a/apps/web/src/lib/orcarouter-pkce.ts b/apps/web/src/lib/orcarouter-pkce.ts new file mode 100644 index 000000000..bef0bfd38 --- /dev/null +++ b/apps/web/src/lib/orcarouter-pkce.ts @@ -0,0 +1,81 @@ +/** + * PKCE material for `Connect with OrcaRouter`. + * + * The console runs on an operator-chosen address (`OAC_PUBLIC_URL`) and cannot + * register or listen on a loopback callback, so it asks for an out-of-band code + * (Flow B) that the consent screen shows and the operator pastes back. S256 is + * mandatory there — a displayed code passes through human hands — and is used + * here unconditionally. + * + * Everything in this file is pure: the verifier is created from the platform's + * cryptographic RNG, never leaves the process before the exchange, is never + * rendered, logged or placed in a URL, and no function here talks to the + * network. A pasted key and the key this flow returns are the same thing to + * everything downstream. + */ +import { orcarouterAppName, type OrcarouterConfiguration } from "./orcarouter"; + +export interface OrcarouterLogin { + /** The URL the operator opens; `app_name`, `state` and the S256 challenge are already on it. */ + authorizeUrl: string; + /** The verifier for this attempt. It stays in memory and is never rendered or logged. */ + verifier: string; + /** The state this attempt sent; compared again before the code is redeemed. */ + state: string; +} + +/** base64url without padding, as `code_challenge` requires. */ +function base64url(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/gu, "-").replace(/\//gu, "_").replace(/=+$/u, ""); +} + +/** `base64url(sha256(verifier))`, with the verifier never leaving this call. */ +export async function pkceChallenge(verifier: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); + return base64url(new Uint8Array(digest)); +} + +function randomValue(bytes: number): string { + return base64url(crypto.getRandomValues(new Uint8Array(bytes))); +} + +/** A constant-time comparison, so a wrong state cannot be probed byte by byte. */ +export function sameState(expected: string, received: string): boolean { + if (expected.length === 0 || expected.length !== received.length) return false; + let different = 0; + for (let index = 0; index < expected.length; index += 1) different |= expected.charCodeAt(index) ^ received.charCodeAt(index); + return different === 0; +} + +/** + * One fresh authorization attempt: a new verifier and state from a + * cryptographic RNG, and S256 over the verifier. No attempt ever reuses either. + */ +export async function startOrcarouterLogin( + configuration: OrcarouterConfiguration, + appName: string = orcarouterAppName, +): Promise { + const verifier = randomValue(48); + const state = randomValue(24); + const url = new URL(configuration.authorizeUrl); + url.searchParams.set("callback_url", "oob"); + url.searchParams.set("code_challenge", await pkceChallenge(verifier)); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", state); + url.searchParams.set("scope", "api"); + if (appName !== "") url.searchParams.set("app_name", appName); + return { authorizeUrl: url.toString(), verifier, state }; +} + +/** A code is shown to a person; a paste is trimmed and refused if it carries anything else. */ +export function normalizeCode(value: string): string { + const code = value.trim(); + return /^[\x21-\x7e]{1,4096}$/u.test(code) ? code : ""; +} + +/** Whether a value is syntactically one of this gateway's keys. */ +export function looksLikeOrcarouterKey(value: string): boolean { + return /^sk-orca-[A-Za-z0-9._-]{8,}$/u.test(value.trim()); +} diff --git a/apps/web/src/lib/orcarouter.test.ts b/apps/web/src/lib/orcarouter.test.ts new file mode 100644 index 000000000..d5a487bcc --- /dev/null +++ b/apps/web/src/lib/orcarouter.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; + +import { + catalogAnswer, + catalogOptions, + catalogKeeps, + endpointTypesFor, + fallbackAnswer, + isOrcarouterBase, + modelSatisfies, + orcarouterAuthOrigin, + orcarouterApiOrigin, + orcarouterConfiguration, + orcarouterDefaultConfiguration, + orcarouterInferenceBase, + orcarouterVerifiedSeed, + parseCatalog, + type CatalogModel, +} from "./orcarouter"; + +/** One catalog record per shape the selectors must tell apart. */ +const catalog = { + data: [ + { id: "openai/gpt-5.5", name: "OpenAI: GPT-5.5", context_length: 272000, max_completion_tokens: 128000, supported_endpoint_types: ["openai", "openai-response"], architecture: { input_modalities: ["text", "image", "file"] } }, + { id: "text/only", supported_endpoint_types: ["openai"], architecture: { input_modalities: ["text"] } }, + { id: "silent/modalities", supported_endpoint_types: ["openai"] }, + { id: "typed/nowhere", architecture: { input_modalities: ["text"] } }, + { id: "embed/one", supported_endpoint_types: ["embeddings"], architecture: { input_modalities: ["text"] } }, + { id: "image/one", supported_endpoint_types: ["image-generation"], architecture: { input_modalities: ["text", "image"] } }, + { id: "video/one", supported_endpoint_types: ["openai-video"], architecture: { input_modalities: ["text", "image"] } }, + { id: "rerank/one", supported_endpoint_types: ["jina-rerank"], architecture: { input_modalities: ["text"] } }, + ], +}; + +describe("the OrcaRouter catalog", () => { + it("keeps vendor/model identifiers exactly as the gateway spells them", () => { + const parsed = parseCatalog(catalog); + expect(parsed.models.map((model) => model.id)).toContain("openai/gpt-5.5"); + expect(parsed.models.find((model) => model.id === "openai/gpt-5.5")).toMatchObject({ contextWindow: 272000, maxOutputTokens: 128000 }); + }); + + it("refuses a record it cannot type instead of guessing at it", () => { + const parsed = parseCatalog({ data: [{ id: "ok" }, { name: "no id" }, { id: " " }, { id: "ok" }, { id: `bad/${"x".repeat(210)}` }, "not an object"] }); + expect(parsed.models.map((model) => model.id)).toEqual(["ok"]); + expect(parsed.skipped).toBe(5); + }); + + it("reads the reduced console shape as well as the raw catalog shape", () => { + const parsed = parseCatalog({ data: [{ id: "reduced/one", input_modalities: ["text", "image"], endpoint_types: ["openai"], modalities_declared: true }] }); + expect(parsed.models[0]).toMatchObject({ inputModalities: ["text", "image"], endpointTypes: ["openai"] }); + }); + + it("fails closed when the server reports the catalog did not declare modalities", () => { + const model = parseCatalog({ data: [{ id: "undeclared/one", modalities_declared: false, supported_endpoint_types: ["openai"] }] }).models[0]; + expect(model).toBeDefined(); + expect(catalogOptions([model as CatalogModel], { capability: "chat", inputModalities: ["image"] })).toEqual([]); + }); +}); + +describe("each entrance filters its own capability", () => { + const models = parseCatalog(catalog).models; + + it("offers a chat entrance only text protocols", () => { + expect(catalogOptions(models, { capability: "chat" }).map((model) => model.id)).toEqual(["openai/gpt-5.5", "text/only", "silent/modalities"]); + }); + + it("never offers image generation, video or rerank as a chat model", () => { + for (const capability of ["chat"] as const) { + const offered = catalogOptions(models, { capability }); + expect(offered.map((model) => model.id)).not.toContain("image/one"); + expect(offered.map((model) => model.id)).not.toContain("video/one"); + expect(offered.map((model) => model.id)).not.toContain("rerank/one"); + expect(offered.map((model) => model.id)).not.toContain("embed/one"); + } + }); + + it("matches embedding, image, video and rerank strictly by their own endpoint", () => { + expect(catalogOptions(models, { capability: "embedding" }).map((model) => model.id)).toEqual(["embed/one"]); + expect(catalogOptions(models, { capability: "image" }).map((model) => model.id)).toEqual(["image/one"]); + expect(catalogOptions(models, { capability: "video" }).map((model) => model.id)).toEqual(["video/one"]); + expect(catalogOptions(models, { capability: "rerank" }).map((model) => model.id)).toEqual(["rerank/one"]); + }); + + it("keeps a model without declared endpoint types out of every selector", () => { + for (const capability of ["chat", "embedding", "image", "video", "rerank"] as const) { + expect(catalogOptions(models, { capability }).map((model) => model.id)).not.toContain("typed/nowhere"); + } + expect(modelSatisfies({ id: "typed/nowhere", name: "typed/nowhere", endpointTypes: [] }, { capability: "chat" })).toBe(false); + }); + + it("states what each capability means in the gateway's vocabulary", () => { + expect(endpointTypesFor("chat")).toEqual(["openai", "anthropic", "gemini", "openai-response"]); + expect(endpointTypesFor("embedding")).toEqual(["embeddings"]); + expect(endpointTypesFor("image")).toEqual(["image-generation"]); + expect(endpointTypesFor("video")).toEqual(["openai-video"]); + expect(endpointTypesFor("rerank")).toEqual(["jina-rerank"]); + }); +}); + +describe("a multimodal entrance fails closed", () => { + const models = parseCatalog(catalog).models; + + it("admits only chat models that declare the modality the entrance uploads", () => { + expect(catalogOptions(models, { capability: "chat", inputModalities: ["image"] }).map((model) => model.id)).toEqual(["openai/gpt-5.5"]); + expect(catalogOptions(models, { capability: "chat", inputModalities: ["audio"] }).map((model) => model.id)).toEqual([]); + expect(catalogOptions(models, { capability: "chat", inputModalities: ["image", "file"] }).map((model) => model.id)).toEqual(["openai/gpt-5.5"]); + }); + + it("drops a chat model whose modalities the catalog never stated", () => { + const silent = models.filter((model) => model.id === "silent/modalities")[0]; + expect(silent).toBeDefined(); + expect(modelSatisfies(silent as (typeof models)[number], { capability: "chat", inputModalities: ["image"] })).toBe(false); + }); +}); + +describe("a chosen model is re-validated, never silently kept", () => { + const models = parseCatalog(catalog).models; + + it("keeps a model only while the current entrance admits it", () => { + expect(catalogKeeps(models, { capability: "chat" }, "openai/gpt-5.5")).toBe(true); + expect(catalogKeeps(models, { capability: "chat", inputModalities: ["image"] }, "text/only")).toBe(false); + expect(catalogKeeps(models, { capability: "image" }, "openai/gpt-5.5")).toBe(false); + expect(catalogKeeps(models, { capability: "chat" }, "gone/model")).toBe(false); + }); +}); + +describe("live discovery and the verified fallback stay separate", () => { + it("returns the live answer unfiltered by the seed", () => { + const answer = catalogAnswer(catalog, { capability: "chat" }, orcarouterApiOrigin); + expect(answer.degraded).toBe(false); + expect(answer.origin).toBe(orcarouterApiOrigin); + expect(answer.models.every((model) => model.id !== "orcarouter/auto")).toBe(true); + expect(answer.skipped).toBe(0); + }); + + it("labels a fallback and offers only verified seed models", () => { + const answer = fallbackAnswer({ capability: "chat" }, orcarouterApiOrigin); + expect(answer.degraded).toBe(true); + expect(answer.reason).toBe("catalog_unreachable"); + expect(answer.models.map((model) => model.id)).toEqual(["openai/gpt-5.5", "anthropic/claude-opus-4.8", "google/gemini-3.5-flash", "deepseek/deepseek-v4-pro", "orcarouter/auto"]); + }); + + it("carries the verified metadata of the seed, including the reasoning ladder", () => { + const [gpt] = orcarouterVerifiedSeed.filter((model) => model.id === "openai/gpt-5.5"); + expect(gpt).toMatchObject({ contextWindow: 272000, maxOutputTokens: 128000, reasoningEfforts: ["low", "medium", "high", "xhigh"], inputModalities: ["text", "image", "file"] }); + }); + + it("admits no seed model into a capability it was not verified for", () => { + expect(catalogOptions(orcarouterVerifiedSeed, { capability: "chat" }).map((model) => model.id)).not.toContain("openai/gpt-image-1"); + expect(catalogOptions(orcarouterVerifiedSeed, { capability: "chat" }).map((model) => model.id)).not.toContain("openai/text-embedding-3-large"); + expect(catalogOptions(orcarouterVerifiedSeed, { capability: "image" }).map((model) => model.id)).toEqual(["openai/gpt-image-1"]); + }); +}); + +describe("the two public origins are never derived from one another", () => { + it("keeps the auth and inference origins distinct and the inference base versioned", () => { + expect(orcarouterAuthOrigin).toBe("https://www.orcarouter.ai"); + expect(orcarouterApiOrigin).toBe("https://api.orcarouter.ai"); + expect(orcarouterInferenceBase()).toBe("https://api.orcarouter.ai/v1"); + expect(orcarouterInferenceBase("https://relay.internal/v1")).toBe("https://relay.internal/v1"); + expect(orcarouterDefaultConfiguration.authorizeUrl).toBe("https://www.orcarouter.ai/auth"); + expect(orcarouterDefaultConfiguration.authorizeUrl.startsWith(orcarouterApiOrigin)).toBe(false); + expect(orcarouterDefaultConfiguration.inferenceBase).toBe("https://api.orcarouter.ai/v1"); + }); + + it("accepts a deployment's own origins and refuses a malformed override", () => { + const answer = orcarouterConfiguration({ auth_origin: "https://login.internal/", api_origin: "https://relay.internal", key_console: "https://login.internal/console" }); + expect(answer).toEqual({ authOrigin: "https://login.internal", apiOrigin: "https://relay.internal", authorizeUrl: "https://login.internal/auth", inferenceBase: "https://relay.internal/v1", keyConsole: "https://login.internal/console" }); + const refused = orcarouterConfiguration({ auth_origin: "https://user:pass@login.internal", api_origin: "not a url" }); + expect(refused.authOrigin).toBe(orcarouterAuthOrigin); + expect(refused.apiOrigin).toBe(orcarouterApiOrigin); + }); + + it("recognises a saved configuration as this gateway and nothing else", () => { + expect(isOrcarouterBase("https://api.orcarouter.ai/v1")).toBe(true); + expect(isOrcarouterBase("https://api.orcarouter.ai/v1/")).toBe(true); + expect(isOrcarouterBase("https://api.orcarouter.ai")).toBe(true); + expect(isOrcarouterBase("https://relay.internal/v1", "https://relay.internal")).toBe(true); + expect(isOrcarouterBase("https://model.example/v1")).toBe(false); + expect(isOrcarouterBase("https://api.orcarouter.ai.evil.example/v1")).toBe(false); + }); +}); diff --git a/apps/web/src/lib/orcarouter.ts b/apps/web/src/lib/orcarouter.ts new file mode 100644 index 000000000..f1a43672f --- /dev/null +++ b/apps/web/src/lib/orcarouter.ts @@ -0,0 +1,306 @@ +/** + * OrcaRouter as a named model provider for the console. + * + * OrcaRouter is an OpenAI-compatible AI gateway that routes many providers + * behind one endpoint, so one provider entry reaches every model the operator's + * workspace may call. Authentication and inference live on different public + * origins and are never derived from one another: `/api/v1/auth/keys` is on the + * auth origin, inference and the model catalog are on the API origin under + * `/v1`. A shared self-hosted origin plus explicit overrides are supported, and + * an explicit override always wins. + * + * The credential is a normal OrcaRouter API key (`sk-orca-...`) that belongs to + * the operator. The console offers both ways to obtain one — paste an existing + * key, or `Connect with OrcaRouter` (OAuth 2.0 + PKCE) — and both reach the same + * `ModelProviderInput` the dialog writes to Core. Everything below is pure so + * the capability rules can be tested without a browser; the console server + * performs the catalog request, so the key never enters browser code. + */ + +/** The public OrcaRouter origins, used only when the console names no override. */ +export const orcarouterAuthOrigin = "https://www.orcarouter.ai"; +export const orcarouterApiOrigin = "https://api.orcarouter.ai"; + +/** Where an operator creates, lists and revokes keys. */ +export const orcarouterKeyConsole = "https://www.orcarouter.ai/console/authorized-apps"; + +/** What the console's OrcaRouter config route answers. */ +export interface OrcarouterConfiguration { + authOrigin: string; + apiOrigin: string; + authorizeUrl: string; + /** The address written to Core for this provider. */ + inferenceBase: string; + keyConsole: string; +} + +export const orcarouterDefaultConfiguration: OrcarouterConfiguration = { + authOrigin: orcarouterAuthOrigin, + apiOrigin: orcarouterApiOrigin, + authorizeUrl: `${orcarouterAuthOrigin}/auth`, + inferenceBase: `${orcarouterApiOrigin}/v1`, + keyConsole: orcarouterKeyConsole, +}; + +/** + * Reads that answer. The authorize URL is fixed to the auth origin's `/auth` + * path and is never derived from the inference origin; a malformed value falls + * back to the public default rather than sending an operator somewhere else. + */ +export function orcarouterConfiguration(payload: unknown): OrcarouterConfiguration { + const record = (payload ?? {}) as Record; + const origin = (value: unknown, fallback: string): string => { + if (typeof value !== "string") return fallback; + const trimmed = value.trim().replace(/\/+$/u, ""); + if (trimmed === "") return fallback; + try { + const url = new URL(trimmed); + const bare = url.pathname === "" || url.pathname === "/"; + return url.username === "" && url.password === "" && bare && url.search === "" && url.hash === "" ? trimmed : fallback; + } catch { + return fallback; + } + }; + const authOrigin = origin(record.auth_origin, orcarouterAuthOrigin); + const apiOrigin = origin(record.api_origin, orcarouterApiOrigin); + return { + authOrigin, + apiOrigin, + authorizeUrl: `${authOrigin}/auth`, + inferenceBase: orcarouterInferenceBase(apiOrigin), + keyConsole: consoleURL(record.key_console, orcarouterKeyConsole), + }; +} + +/** A console link, unlike an origin, may carry a path; credentials and queries may not. */ +function consoleURL(value: unknown, fallback: string): string { + if (typeof value !== "string") return fallback; + const trimmed = value.trim(); + try { + const url = new URL(trimmed); + if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") return fallback; + return trimmed.replace(/\/+$/u, ""); + } catch { + return fallback; + } +} +/** The inference base written to Core: the API origin plus its `/v1` segment. */export function orcarouterInferenceBase(apiOrigin: string = orcarouterApiOrigin): string { + const base = apiOrigin.trim().replace(/\/+$/u, ""); + return base.endsWith("/v1") ? base : `${base}/v1`; +} + +/** + * A capability an AI entrance asks the catalog for. Each entrance filters its + * own list; a caller never widens another caller's list. + */ +export const catalogCapabilities = ["chat", "image", "embedding", "video", "rerank"] as const; +export type CatalogCapability = (typeof catalogCapabilities)[number]; + +/** A non-text modality an entrance actually accepts as input. */ +export const catalogInputModalities = ["image", "audio", "video", "file"] as const; +export type CatalogInputModality = (typeof catalogInputModalities)[number]; + +/** One catalog record reduced to what a model selector needs. */ +export interface CatalogModel { + id: string; + name: string; + contextWindow?: number; + maxOutputTokens?: number; + /** Declared input modalities; `undefined` means the catalog did not say. */ + inputModalities?: string[]; + endpointTypes: string[]; + /** Verified reasoning-effort ladder, retained from the fallback seed. */ + reasoningEfforts?: string[]; +} + +/** Text protocols a chat entrance can speak, in the gateway's own vocabulary. */ +const chatEndpointTypes = ["openai", "anthropic", "gemini", "openai-response"]; +/** Non-text endpoints that must never appear in a text entrance's options. */ +const nonTextEndpointTypes = ["image-generation", "openai-video", "jina-rerank", "embeddings"]; + +export interface CatalogSelector { + capability: CatalogCapability; + /** Non-text modalities this entrance actually uploads. Empty means text only. */ + inputModalities?: readonly CatalogInputModality[]; + /** A model must declare one of these endpoint types; omitted derives them from the capability. */ + endpointTypes?: readonly string[]; +} + +/** Context and output bounds are integers; anything else is not evidence. */ +function positiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; +} + +function cleanStrings(values: readonly unknown[]): string[] { + return values.filter((value): value is string => typeof value === "string" && value.trim() !== ""); +} + +/** + * The modalities a record declares. The console server reduces records to a + * flat `input_modalities`, so both that shape and the raw catalog's + * `architecture.input_modalities` are read; `modalities_declared: false` means + * the catalog did not say and the entrance must fail closed. + */ +function declaredModalities(record: Record): string[] | undefined { + if (record.modalities_declared === false) return undefined; + if (Array.isArray(record.input_modalities)) return cleanStrings(record.input_modalities); + const architecture = record.architecture; + if (architecture === null || typeof architecture !== "object" || Array.isArray(architecture)) return undefined; + const modalities = (architecture as Record).input_modalities; + return Array.isArray(modalities) ? cleanStrings(modalities) : undefined; +} + +function declaredEndpointTypes(record: Record): string[] { + const value = record.supported_endpoint_types ?? record.endpoint_types; + return Array.isArray(value) ? cleanStrings(value) : []; +} + +/** + * Reads the OpenAI-shaped catalog. A record without a usable ID or without the + * fields the selector filters on is dropped rather than guessed at; the caller + * sees how many records were refused. + */ +export function parseCatalog(payload: unknown): { models: CatalogModel[]; skipped: number } { + const data = (payload as { data?: unknown } | null)?.data; + if (!Array.isArray(data)) return { models: [], skipped: 0 }; + const models: CatalogModel[] = []; + const seen = new Set(); + let skipped = 0; + for (const entry of data) { + const record = entry as Record | null; + const id = typeof record?.id === "string" ? record.id.trim() : ""; + if (record === null || typeof record !== "object" || id === "" || id.length > 200 || seen.has(id)) { + skipped += 1; + continue; + } + seen.add(id); + const name = typeof record.name === "string" && record.name.trim() !== "" ? record.name.trim() : id; + const contextWindow = positiveInteger(record.context_length); + const maxOutputTokens = positiveInteger(record.max_completion_tokens); + const inputModalities = declaredModalities(record); + models.push({ + id, + name: name.length > 120 ? name.slice(0, 120) : name, + ...(contextWindow === undefined ? {} : { contextWindow }), + ...(maxOutputTokens === undefined ? {} : { maxOutputTokens }), + ...(inputModalities === undefined ? {} : { inputModalities }), + endpointTypes: declaredEndpointTypes(record), + }); + } + return { models, skipped }; +} + +/** The endpoint types that express one capability, in the gateway's own vocabulary. */ +export function endpointTypesFor(capability: CatalogCapability): readonly string[] { + switch (capability) { + case "chat": return chatEndpointTypes; + case "embedding": return ["embeddings"]; + case "image": return ["image-generation"]; + case "video": return ["openai-video"]; + case "rerank": return ["jina-rerank"]; + } +} + +/** + * Whether one catalog record satisfies an entrance. A model that does not + * declare the capability, or does not declare an input modality the entrance + * actually uploads, is refused: the catalog's silence is not evidence of + * support. + */ +export function modelSatisfies(model: CatalogModel, selector: CatalogSelector): boolean { + const allowed = selector.endpointTypes ?? endpointTypesFor(selector.capability); + // A model with no declared endpoint types cannot be typed; it never enters a selector. + if (model.endpointTypes.length === 0) return false; + if (!model.endpointTypes.some((type) => allowed.includes(type))) return false; + const wanted = selector.inputModalities ?? []; + if (wanted.length === 0) return true; + const modalities = model.inputModalities ?? []; + return wanted.every((modality) => modalities.includes(modality)); +} + +/** What a chat entrance must exclude even when it declares a text protocol. */ +export function isNonTextOnly(model: CatalogModel): boolean { + return model.endpointTypes.length > 0 && model.endpointTypes.every((type) => nonTextEndpointTypes.includes(type)); +} + +/** + * The options one selector binds. A chat entrance additionally drops models that + * only speak a non-text endpoint, so image generation, video and rerank never + * appear as a chat model. + */ +export function catalogOptions(models: readonly CatalogModel[], selector: CatalogSelector): CatalogModel[] { + return models.filter((model) => modelSatisfies(model, selector) && (selector.capability !== "chat" || !isNonTextOnly(model))); +} + +/** Whether a previously chosen model still satisfies the current entrance. */ +export function catalogKeeps(models: readonly CatalogModel[], selector: CatalogSelector, model: string): boolean { + return models.some((entry) => entry.id === model && catalogOptions([entry], selector).length === 1); +} + +/** + * Every way to obtain an OrcaRouter API key. The console offers both and both + * reach the dialog's one credential seam, so providing the model provider never + * depends on which one an operator can use. + */ +export type OrcarouterCredential = { source: OrcarouterCredentialSource; key: string }; +export const orcarouterProviderLabel = "OrcaRouter"; +export const orcarouterCredentialSources = ["api_key", "pkce"] as const; +export type OrcarouterCredentialSource = (typeof orcarouterCredentialSources)[number]; +/** What `app_name` shows on the consent screen the operator reads. */ +export const orcarouterAppName = "OpenAgentCore Console"; + +/** + * Whether a saved base URL is this gateway's. The console writes the base URL + * itself and never lets an operator type one for OrcaRouter, so a saved + * configuration is recognised again after a reload instead of silently + * becoming a generic OpenAI-compatible provider. + */ +export function isOrcarouterBase(baseUrl: string, apiOrigin: string = orcarouterApiOrigin): boolean { + const base = baseUrl.trim().replace(/\/+$/u, ""); + const origin = apiOrigin.trim().replace(/\/+$/u, ""); + return base === origin || base === `${origin}/v1` || base.startsWith(`${origin}/v1/`); +} + +/** What `GET /v1/models` answered, reduced for one entrance. */ +export interface CatalogAnswer { + models: CatalogModel[]; + /** True when the live catalog could not be read and a verified seed is in use. */ + degraded: boolean; + /** The API origin the catalog came from. */ + origin: string; + /** How many records the catalog carried but the selector refused. */ + skipped: number; +} + +/** + * The live answer for one entrance: the records the entrance admits, and how + * many were refused. A seed is never mixed into a successful live answer. + */ +export function catalogAnswer(payload: unknown, selector: CatalogSelector, origin: string): CatalogAnswer { + const parsed = parseCatalog(payload); + return { models: catalogOptions(parsed.models, selector), degraded: false, origin, skipped: parsed.skipped }; +} + +/** + * The fallback for one entrance while live discovery is unavailable: only the + * verified seed entries the same filter admits, and never free text. + */ +export function fallbackAnswer(selector: CatalogSelector, origin: string): CatalogAnswer & { reason: string } { + return { models: catalogOptions(orcarouterVerifiedSeed, selector), degraded: true, origin, skipped: 0, reason: "catalog_unreachable" }; +} + +/** + * The cold-start catalog, used only while live discovery is unavailable. Every + * entry was read from `GET https://api.orcarouter.ai/v1/models` on 2026-10-08; + * the verified reasoning ladders and input modalities are retained so a fallback + * model is not silently reduced. + */ +export const orcarouterVerifiedSeed: readonly CatalogModel[] = [ + { id: "openai/gpt-5.5", name: "OpenAI: GPT-5.5", contextWindow: 272000, maxOutputTokens: 128000, inputModalities: ["text", "image", "file"], endpointTypes: ["openai", "openai-response"], reasoningEfforts: ["low", "medium", "high", "xhigh"] }, + { id: "anthropic/claude-opus-4.8", name: "Anthropic: Claude Opus 4.8", contextWindow: 1000000, maxOutputTokens: 128000, inputModalities: ["text", "image", "file"], endpointTypes: ["openai", "anthropic", "openai-response"] }, + { id: "google/gemini-3.5-flash", name: "Gemini 3.5 Flash", contextWindow: 1048576, maxOutputTokens: 65536, inputModalities: ["text", "image", "video", "audio", "file"], endpointTypes: ["openai", "gemini"] }, + { id: "deepseek/deepseek-v4-pro", name: "DeepSeek: DeepSeek V4 Pro", contextWindow: 1048576, maxOutputTokens: 384000, inputModalities: ["text"], endpointTypes: ["openai", "openai-response"] }, + { id: "orcarouter/auto", name: "OrcaRouter Auto", endpointTypes: ["openai", "anthropic", "gemini", "openai-response"] }, + { id: "openai/text-embedding-3-large", name: "OpenAI: Text Embedding 3 Large", inputModalities: ["text"], endpointTypes: ["embeddings"] }, + { id: "openai/gpt-image-1", name: "OpenAI: GPT Image 1", inputModalities: ["text", "image"], endpointTypes: ["image-generation"] }, +]; diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index 4133a5dc3..e91d00e01 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -27,5 +27,6 @@ @source "../components/primitives"; @source "../components/ui"; @source "../components/console-select.tsx"; +@source "../components/model-combobox.tsx"; @source "../components/console-popover.tsx"; @source "../components/magicui"; diff --git a/docs/web/console-api-usage.md b/docs/web/console-api-usage.md index 84eb6fb27..a73c18fec 100644 --- a/docs/web/console-api-usage.md +++ b/docs/web/console-api-usage.md @@ -8,7 +8,7 @@ This page lists the Core routes each console page reads and writes, and how the | Interface | Paths | Authentication | Console use | | --- | --- | --- | --- | -| Console server | `/console/auth`, `/console/auth/{login,logout}`, `/console/config`, `/node-install/manifest.json` | The Core key at sign-in, then the console session cookie; `/node-install/manifest.json` needs no sign-in | Sign-in and sign-out; the node installer and node artifacts for Add node; the distribution's Runtime release for Docker and microsandbox setup. See [console server](./console-server.md) | +| Console server | `/console/auth`, `/console/auth/{login,logout}`, `/console/config`, `/console/orcarouter/*`, `/node-install/manifest.json` | The Core key at sign-in, then the console session cookie; `/node-install/manifest.json` needs no sign-in | Sign-in and sign-out; the node installer and node artifacts for Add node; the distribution's Runtime release for Docker and microsandbox setup; the OrcaRouter provider's origins, model catalog and PKCE code exchange, with the operator's OrcaRouter key. See [console server](./console-server.md) | | Administrator API | `/core/v1/**` outside `/core/v1/sandbox` | The Core key, added by the console server | Projects, keys, resource reads and deletion, diagnostics, executor credentials and installation commands, provenance, summaries, Core metrics, the installation, default models | | Sandbox administration | `/core/v1/sandbox/**` | The Core key, added by the console server | Sandbox configuration, Nodes, fleet and capacity figures on Overview and Sandbox metrics, Runtime observations of every project | | Agents API | `/v1/**` | Project API key | Not used. The console shows developers how to call it (see [Provenance and monitoring](#provenance-and-monitoring)) | @@ -85,7 +85,10 @@ Summary figures are cumulative per Session and are not billing records. Sessions | Operation | Route | Console use | | --- | --- | --- | -| List harnesses | `GET /core/v1/harnesses` | System's Default model cards: each harness's read-only `enabled` and `default`, its model configuration without the key, and Usage details from the configuration's `last_used_at`, `last_error_code` and `last_error_at`; the Overview's Getting started (a default model on the default harness, or on any enabled harness when none is default) | +| List harnesses | `GET /core/v1/harnesses` | System's Default model cards: each harness's read-only `enabled` and `default`, its model configuration without the key, and Usage details from the configuration's `last_used_at`, `last_error_code` and `last_error_at`; the Overview's Getting started (a default model on the default harness, or on any enabled harness when none is default). OrcaRouter's connection state comes from the model configuration dialog, not this list | +| OrcaRouter origins | `GET /console/orcarouter/config` | The **Set**/**Replace** dialog's OrcaRouter provider: the authentication origin and the inference base it writes to Core, so neither is typed and neither public origin is hardcoded | +| OrcaRouter catalog | `GET /console/orcarouter/catalog?capability=` | The dialog's model control when OrcaRouter is selected: the workspace's models, read with the entered key in the `X-OrcaRouter-Key` header and filtered to `chat` for a text harness. A rejected key (401) and an unreachable catalog both show a visible state and the verified fallback list instead of free text | +| OrcaRouter exchange | `POST /console/orcarouter/exchange` | **Connect with OrcaRouter**: redeems the PKCE authorization code for an API key, which is written to Core as the provider key exactly like a pasted one | | Set or replace | `PUT /core/v1/harnesses/{harness}/model-configuration` | **Set** or **Replace**: the complete model configuration with its write-only provider key, never prefilled and never retried; a 400 shows Core's message in the form; then the list is read again | | Clear | `DELETE /core/v1/harnesses/{harness}/model-configuration` | **Clear**, confirmed, then the list is read again | diff --git a/docs/web/console-server.md b/docs/web/console-server.md index 52b89daa0..62bbf0a37 100644 --- a/docs/web/console-server.md +++ b/docs/web/console-server.md @@ -36,6 +36,7 @@ The deployment's reverse proxy sends every path to the console. The console forw | `/console/auth`, `/console/auth/login`, `/console/auth/logout` | No | [Sign-in](#sign-in) | | `/`, `/index.html`, `/favicon.svg`, `/oac-mark.svg`, `/assets/*` | No | Static console assets | | `/console/config` | Yes | [Console configuration](#console-configuration) | +| `/console/orcarouter/*` | Yes | [OrcaRouter provider](#orcarouter-provider) | | `/core/v1/*` | Yes | [Forwarded to Core](#forwarding-to-core) | | `/core` and other paths under `/core/` | Yes | 404 | | Any other path | Yes | Static assets; a path without a file extension falls back to `index.html` | @@ -92,6 +93,23 @@ Sign-in errors: 400 for a malformed body, 401 `Invalid Core key`, 405 for a meth | `node_installer_sha256` | SHA-256 of that payload's `node-install.pyz`; Add node commands verify it before running the installer | | `node_artifacts` | The providers (`docker`, `microsandbox`) whose node artifacts the payload holds, locally or as a pinned release download. Read on every request, so artifacts added by rerunning the installer appear without a restart | +## OrcaRouter provider + +OrcaRouter is a named model provider the console offers beside the other OpenAI-compatible endpoints. Its inference base and its authentication origin are separate public origins and the console never derives one from the other, so the browser composes both from what this route reports and never hardcodes a public origin. `ORCA_BASE_URL` sets a shared self-hosted origin; `ORCA_AUTH_BASE_URL` and `ORCA_API_BASE_URL` override it explicitly and win over it. Each origin needs HTTPS, or an HTTP loopback origin for a self-hosted deployment. The console's own transport reaches those origins directly, ignoring ambient HTTP proxy settings. + +`GET /console/orcarouter/config` returns what the signed-in browser needs, all of it public: + +| Field | Meaning | +| --- | --- | +| `auth_origin` | The authentication origin; `authorize_url` is this origin plus the fixed `/auth` | +| `api_origin` | The inference and catalog origin | +| `inference_base` | The address the console writes to Core as this provider's base URL: `api_origin` plus its `/v1` segment | +| `key_console` | The OrcaRouter page where an operator manages issued keys | + +`GET /console/orcarouter/catalog?capability=` reads the configured catalog with the operator's own key, which travels in the `X-OrcaRouter-Key` request header: never in a URL, never logged, never returned. `capability` is one of `chat`, `embedding`, `image`, `video`, `rerank`, or empty for the whole list. The console reduces each record to its `id`, `name`, `context_length`, `max_completion_tokens`, `supported_endpoint_types` and `input_modalities`, so pricing and provider internals stay on the server, and bounds the read at 512 KiB and 2000 records. A response that is not the expected shape, an upstream failure and a redirect are reported as `degraded` with a reason; a rejected key is a distinct 401 so the console can say the key is unusable instead of showing an empty list. + +`POST /console/orcarouter/exchange` redeems a PKCE authorization code. It requires `code`, `code_verifier` and `code_challenge_method`, and accepts only `S256`. The console posts the code and verifier to the authentication origin's `/api/v1/auth/keys` — the inference origin's `/v1/auth/keys` is not the exchange — and returns the issued key once. The upstream body is never forwarded. + ## Node installation payload With `OAC_WEB_NODE_PAYLOAD_DIR` set, the console serves the matched distribution's node payload at `/node-install/` without sign-in: `node-install.pyz`, `manifest.json`, `SHA256SUMS`, `runtime/seccomp.json`, and the node artifacts the manifest declares under `artifacts/`. An artifact missing locally redirects (307) to its pinned release download. Node install and uninstall commands download from `/node-install/`, so the reverse proxy must send that path to the console. Nodes verify every checksum themselves. diff --git a/docs/zh/web/console-api-usage.md b/docs/zh/web/console-api-usage.md index b0031fb65..c1c017ab7 100644 --- a/docs/zh/web/console-api-usage.md +++ b/docs/zh/web/console-api-usage.md @@ -1,7 +1,7 @@ --- title: "控制台 API 使用" source: docs/web/console-api-usage.md -source_hash: 3f233005176090ef87c13442f439296001950bb835262969a0b48970b51af7f4 +source_hash: 502101c3da92eaa4a86fd64167f99eed39542dfba8b6620d1b8751454bd2cadd --- 本页列出各控制台页面读取和写入的 Core 路由,以及控制台如何限定读取范围。[administrator API contract](../../../contracts/agents-api/zh/admin-api.md) 定义了路由、响应结构、分页和审计记录;[API namespaces and credentials](../api/index.md) 定义了本文使用的术语。 @@ -10,7 +10,7 @@ source_hash: 3f233005176090ef87c13442f439296001950bb835262969a0b48970b51af7f4 | 接口 | 路径 | 身份验证 | 控制台用途 | | --- | --- | --- | --- | -| Console server | `/console/auth`、`/console/auth/{login,logout}`、`/console/config`、`/node-install/manifest.json` | 登录时使用 Core 密钥,随后使用控制台会话 Cookie;`/node-install/manifest.json` 无需登录 | 登录和退出;Add node 所用的节点安装程序和节点构件;用于 Docker 和 microsandbox 设置的发行版 Runtime release。参见 [console server](console-server.md) | +| Console server | `/console/auth`、`/console/auth/{login,logout}`、`/console/config`、`/console/orcarouter/*`、`/node-install/manifest.json` | 登录时使用 Core 密钥,随后使用控制台会话 Cookie;`/node-install/manifest.json` 无需登录 | 登录和退出;Add node 所用的节点安装程序和节点构件;用于 Docker 和 microsandbox 设置的发行版 Runtime release;OrcaRouter provider 的来源、模型目录和 PKCE 兑换,使用运维人员自己的 OrcaRouter 密钥。参见 [console server](console-server.md) | | Administrator API | `/core/v1/**`,不包括 `/core/v1/sandbox` | Core 密钥,由控制台服务器添加 | 项目、密钥、资源读取和删除、诊断、执行器凭据和安装命令、来源信息、汇总、Core 指标、安装信息、默认模型 | | Sandbox administration | `/core/v1/sandbox/**` | Core 密钥,由控制台服务器添加 | Sandbox 配置;Overview 和 Sandbox metrics 中的 Nodes、机群与容量数据;每个项目的 Runtime observations | | Agents API | `/v1/**` | 项目 API 密钥 | 不使用。控制台会向开发者说明如何调用它(参见 [Provenance and monitoring](#provenance-and-monitoring)) | @@ -87,7 +87,10 @@ source_hash: 3f233005176090ef87c13442f439296001950bb835262969a0b48970b51af7f4 | 操作 | 路由 | 控制台用途 | | --- | --- | --- | -| 列出 Harnesses | `GET /core/v1/harnesses` | System 的 Default model 卡片:每个 Harness 的只读 `enabled` 和 `default`、不含密钥的模型配置,以及来自配置中 `last_used_at`、`last_error_code` 和 `last_error_at` 的 Usage details;Overview 的 Getting started(默认 Harness 上的默认模型;如果没有默认模型,则为任意已启用 Harness 上的默认模型) | +| 列出 Harnesses | `GET /core/v1/harnesses` | System 的 Default model 卡片:每个 Harness 的只读 `enabled` 和 `default`、不含密钥的模型配置,以及来自配置中 `last_used_at`、`last_error_code` 和 `last_error_at` 的 Usage details;Overview 的 Getting started(默认 Harness 上的默认模型;如果没有默认模型,则为任意已启用 Harness 上的默认模型)。OrcaRouter 的连接状态来自模型配置对话框,而非此列表 | +| OrcaRouter 来源 | `GET /console/orcarouter/config` | **Set**/**Replace** 对话框的 OrcaRouter provider:写入 Core 的认证来源与推理基址,二者均无需手动输入,也不硬编码任何公开来源 | +| OrcaRouter 目录 | `GET /console/orcarouter/catalog?capability=` | 选择 OrcaRouter 时对话框的模型控件:该 workspace 的模型,使用输入的密钥经 `X-OrcaRouter-Key` 请求头读取,并为文本 Harness 过滤为 `chat`。密钥被拒(401)与目录不可达都会显示可见状态和已验证的回退列表,而非自由文本 | +| OrcaRouter 兑换 | `POST /console/orcarouter/exchange` | **Connect with OrcaRouter**:把 PKCE 授权码兑换为 API 密钥,并像粘贴的密钥一样作为 provider 密钥写入 Core | | 设置或替换 | `PUT /core/v1/harnesses/{harness}/model-configuration` | **Set** 或 **Replace**:提交包含只写提供商密钥的完整模型配置;该密钥绝不预填,写入也绝不重试;400 会在表单中显示 Core 的消息;随后再次读取列表 | | 清除 | `DELETE /core/v1/harnesses/{harness}/model-configuration` | **Clear**,需确认,随后再次读取列表 | diff --git a/docs/zh/web/console-server.md b/docs/zh/web/console-server.md index a152253ce..30f728001 100644 --- a/docs/zh/web/console-server.md +++ b/docs/zh/web/console-server.md @@ -1,7 +1,7 @@ --- title: "控制台服务器" source: docs/web/console-server.md -source_hash: 2cc4b562301d95640d2b653ec522a5407a70a98e1f4e3c1fe89bd246ffd1199e +source_hash: d633871cb9642848b9d035c888447bcde8311e12c76e8e69d922203be18ab6f1 --- 控制台服务器(`services/web`、`oac-web` 进程)提供构建后的控制台,使用 Core 密钥认证管理员,并将已登录浏览器的 `/core/v1` 请求携带该密钥转发到 Core。浏览器不持有 Core 密钥或任何 API 密钥。应用、节点和自托管执行器经控制台到达 Core,控制台原样转发 `/v1`、`/api/v1` 和 `/docs`。 @@ -38,6 +38,7 @@ flowchart LR | `/console/auth`、`/console/auth/login`、`/console/auth/logout` | 否 | [登录](#sign-in) | | `/`、`/index.html`、`/favicon.svg`、`/oac-mark.svg`、`/assets/*` | 否 | 控制台静态资源 | | `/console/config` | 是 | [控制台配置](#console-configuration) | +| `/console/orcarouter/*` | 是 | [OrcaRouter provider](#orcarouter-provider) | | `/core/v1/*` | 是 | [转发到 Core](#forwarding-to-core) | | `/core` 及 `/core/` 下其他路径 | 是 | 404 | | 其他路径 | 是 | 静态资源;无扩展名的路径回退到 `index.html` | @@ -94,6 +95,23 @@ flowchart LR | `node_installer_sha256` | 文件中 `node-install.pyz` 的 SHA-256;Add node 命令执行安装程序前验证它 | | `node_artifacts` | 文件中包含节点资产的提供商(`docker`、`microsandbox`),资产在本地或通过固定发行下载提供。每次请求都读取,因此重新运行安装程序新增的资产无需重启即可出现 | +## OrcaRouter provider {#orcarouter-provider} + +OrcaRouter 是控制台与其他 OpenAI 兼容端点并列提供的具名模型 provider。它的推理基址与认证来源是两个不同的公开来源,控制台绝不从其中一个推导另一个,因此浏览器从本路由返回的值组合二者,绝不硬编码公开来源。`ORCA_BASE_URL` 设置共享的自托管来源;`ORCA_AUTH_BASE_URL` 和 `ORCA_API_BASE_URL` 显式覆盖它并优先。每个来源都需要 HTTPS,自托管部署可用 HTTP 回环来源。控制台自身的传输层直接访问这些来源,忽略环境中的 HTTP 代理设置。 + +`GET /console/orcarouter/config` 返回已登录浏览器所需的全部内容,均为公开信息: + +| 字段 | 含义 | +| --- | --- | +| `auth_origin` | 认证来源;`authorize_url` 是该来源加固定的 `/auth` | +| `api_origin` | 推理与目录来源 | +| `inference_base` | 控制台写入 Core 的该 provider 基址:`api_origin` 加其 `/v1` 路径段 | +| `key_console` | 运维人员管理已签发密钥的 OrcaRouter 页面 | + +`GET /console/orcarouter/catalog?capability=` 使用运维人员自己的密钥读取配置的目录,密钥经由 `X-OrcaRouter-Key` 请求头传递:绝不放入 URL、绝不记录日志、绝不返回。`capability` 取 `chat`、`embedding`、`image`、`video`、`rerank` 之一,或留空表示整个列表。控制台把每条记录裁剪为 `id`、`name`、`context_length`、`max_completion_tokens`、`supported_endpoint_types` 和 `input_modalities`,因此价格与厂商内部信息留在服务端,并将读取限制在 512 KiB 和 2000 条记录。响应结构不符、上游失败和重定向都会以带原因的 `degraded` 报告;密钥被拒绝则是独立的 401,使控制台能说明密钥不可用,而不是显示空列表。 + +`POST /console/orcarouter/exchange` 兑换 PKCE 授权码。它要求 `code`、`code_verifier` 和 `code_challenge_method`,且只接受 `S256`。控制台把 code 与 verifier 提交到认证来源的 `/api/v1/auth/keys` —— 推理来源的 `/v1/auth/keys` 不是兑换端点 —— 并一次性返回签发的密钥。上游响应体绝不转发。 + ## 节点安装文件 {#node-installation-payload} 设置 `OAC_WEB_NODE_PAYLOAD_DIR` 后,控制台在 `/node-install/` 无需登录地提供匹配发行版的节点文件:`node-install.pyz`、`manifest.json`、`SHA256SUMS`、`runtime/seccomp.json`,以及清单声明的 `artifacts/` 下节点资产。本地缺失的资产重定向(307)到固定发行下载地址。节点安装和卸载命令从 `/node-install/` 下载,因此反向代理必须将该路径发给控制台。节点自行验证每个校验和。 diff --git a/example/parsar/README.md b/example/parsar/README.md index 819d6e149..448f482a9 100644 --- a/example/parsar/README.md +++ b/example/parsar/README.md @@ -37,6 +37,7 @@ Use one server process and a dedicated Project for this local single-user exampl A few more implementation details: - **Providers and models** are saved together in one SQLite transaction. Editing reads a Provider and its models as one snapshot; changing a model advances the Provider's revision. +- **OrcaRouter** is offered as a named provider beside the generic OpenAI-compatible one. Choosing the OrcaRouter preset fixes the Base URL to `https://api.orcarouter.ai/v1` and takes the credential either way: paste an `sk-orca-…` key, or use **Connect with OrcaRouter**, which runs OAuth 2.0 with PKCE, S256 and a loopback redirect on `127.0.0.1`, then exchanges the one-time code for an API key. Both entrances produce the same provider key, and the model list comes from that workspace's `GET /v1/models`; a rejected key or an unreachable catalog shows the verified fallback list, never free text. - **Browser calls** use `OpenAIAgentsClient`, with a small public HTTP reader for Artifacts (not yet exposed by that client); only Session creation goes through a small server adapter. Closing the browser aborts the upstream stream, never the running Session. - **Workspaces:** hosted Sessions each get their own. User-machine Sessions use the selected host directory, so the same path means shared files. - **Skills on a user machine** come only from local capability directories in this example; it rejects Agents bound to managed Skills instead of ignoring them. Core itself can deliver managed Skills to user machines through `x_agents_core.environment`. diff --git a/example/parsar/package.json b/example/parsar/package.json index 45500e2f6..47882776b 100644 --- a/example/parsar/package.json +++ b/example/parsar/package.json @@ -8,7 +8,7 @@ "build": "vite build", "start": "node server.mjs", "typecheck": "tsc --noEmit", - "test": "node --test tests/server.test.mjs tests/product.test.mjs", + "test": "node --test tests/server.test.mjs tests/product.test.mjs tests/catalog.test.mjs tests/orcarouter.test.mjs tests/orcarouter-live.test.mjs", "test:e2e": "playwright test --config playwright.config.ts", "test:live": "node tests/live.mjs" }, diff --git a/example/parsar/server/orcarouter.mjs b/example/parsar/server/orcarouter.mjs new file mode 100644 index 000000000..e652a22d4 --- /dev/null +++ b/example/parsar/server/orcarouter.mjs @@ -0,0 +1,494 @@ +// The OrcaRouter credential seam. +// +// Two adapters produce the same result: an operator-pasted API key, and an +// OAuth 2.0 + PKCE login that issues a normal `sk-orca-...` key the operator +// owns. Everything downstream — catalog discovery, the provider record, the +// Session's `x_agents_core.model_provider` bundle — reads the stored key through +// `orcaCredential` and cannot tell which adapter produced it. +// +// The login is **Flow A (loopback redirect)**: this example is a local +// single-user server on `127.0.0.1` that can open a listener, so the code comes +// back to the process that holds the verifier and the operator copies nothing. +// The consent screen can still hand a code to a person instead, so `S256` is +// sent regardless of the flow. +// +// A PKCE-issued key is durable and is **not** a refresh token. There is no +// refresh endpoint and none is invented. A relay `401` is a terminal +// reauthentication requirement for the exact credential generation that made the +// rejected request; a late failure from an old generation never marks a newly +// reauthorized credential. + +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { createServer } from "node:http"; +import { AppError, text, uuid } from "./store.mjs"; +import { + allowedOrcaOrigin, + filterModels, + keepsModel, + orcaAuthorizeURL, + orcaCatalogURL, + orcaInferenceBase, + orcaExchangeURL, + orcaOrigins, + parseCatalog, + verifiedCatalog, +} from "../shared/orcarouter.mjs"; + +/** Authorization codes are single-use with a ten-minute TTL. */ +const LOGIN_TTL_MS = 10 * 60 * 1000; +const CATALOG_TIMEOUT_MS = 15_000; +const CATALOG_BYTE_LIMIT = 512 * 1024; +const CATALOG_ITEM_LIMIT = 5_000; +/** Which entrance asks for the catalog and which non-text modalities it takes. */ +const catalogModes = new Set(["chat", "multimodal", "embedding", "image", "video", "rerank"]); +const selectorFor = (mode) => (mode === "multimodal" ? { capability: "chat", inputModalities: ["image"] } : { capability: mode, inputModalities: [] }); + +const b64url = (bytes) => Buffer.from(bytes).toString("base64url"); +const s256 = (verifier) => b64url(createHash("sha256").update(verifier).digest()); + +/** The fixed loopback hosts a redirect may use. */ +const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); + +function sameBytes(left, right) { + const a = Buffer.from(left); + const b = Buffer.from(right); + // The length is public; comparing fixed-size digests keeps the check constant-time. + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +/** The stored account record for one OrcaRouter identity. */ +function accountRow(id, previous, patch) { + return { + ...previous, + id, + provider: "orcarouter", + name: patch.name ?? previous?.name ?? "", + base_url: patch.base_url ?? previous?.base_url ?? "", + api_key: patch.api_key ?? previous?.api_key ?? "", + /** `api_key` or `pkce`; the UI keeps the two choices distinct. */ + credential_source: patch.credential_source ?? previous?.credential_source ?? "", + /** Bumped on every credential replacement; a 401 marks one exact generation. */ + generation: patch.generation ?? previous?.generation ?? 0, + status: patch.status ?? previous?.status ?? "ok", + /** user_id reported by the exchange, for operator display only. */ + account: patch.account ?? previous?.account ?? "", + revision: (previous?.revision ?? 0) + 1, + has_api_key: Boolean(patch.api_key ?? previous?.api_key ?? ""), + }; +} + +/** The browser view: never the key, never the verifier, never the pending state. */ +export function publicOrcaAccount(row) { + return { + id: row.id, + name: row.name, + base_url: row.base_url, + credential_source: row.credential_source, + generation: row.generation, + status: row.status, + account: row.account, + revision: row.revision, + has_api_key: Boolean(row.api_key), + }; +} + +/** + * The credential interface every caller uses. It never returns a key to the + * browser and never records one in an error. + */ +export function orcaCredential(store) { + const read = (id) => { + const row = store.get("providers", id); + return row?.provider === "orcarouter" ? row : null; + }; + return { + /** The key a downstream request must use, or null when none is configured. */ + key: (id) => read(id)?.api_key || null, + /** Whether the account may be used at all. */ + usable: (id) => { + const row = read(id); + return Boolean(row?.api_key) && row.status !== "needs_reauth"; + }, + /** The generation that a request is being made under. */ + generation: (id) => read(id)?.generation ?? 0, + /** + * Marks one exact generation as needing reauthentication. A response that + * belongs to a superseded generation is ignored, so a late failure never + * invalidates a credential the operator has since replaced. + */ + reauthenticate: (id, generation) => { + const row = read(id); + if (!row || row.generation !== generation) return false; + store.put("providers", { ...row, status: "needs_reauth", revision: row.revision + 1 }); + return true; + }, + }; +} + +/** + * The server-owned OrcaRouter surface: catalog discovery, the two credential + * adapters, and the login lifecycle. Credentials stay in the local restricted + * SQLite file that already holds provider keys. + */ +export function orcaRouterAPI(store, fetchImpl = fetch, env = process.env) { + const origins = orcaOrigins(env); + if (!allowedOrcaOrigin(origins.auth) || !allowedOrcaOrigin(origins.api)) + throw new AppError( + 400, + "OrcaRouter 地址必须是 HTTPS,或本机的 HTTP 地址(不含路径)。", + ); + const credential = orcaCredential(store); + /** One pending login per account: at most one browser round-trip at a time. */ + const logins = new Map(); + + const requireAccount = (id) => { + if (typeof id !== "string" || !uuid.test(id)) + throw new AppError(400, "请选择有效的 Provider。"); + const row = store.get("providers", id); + if (!row || row.provider !== "orcarouter") + throw new AppError(404, "该 Provider 不是 OrcaRouter。"); + return row; + }; + + function clearLogin(id) { + const pending = logins.get(id); + if (!pending) return; + logins.delete(id); + pending.closed = true; + clearTimeout(pending.timer); + for (const response of pending.responses ?? []) { + try { + response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + response.end(pending.document); + } catch { + // The browser already left; nothing to serve. + } + } + pending.responses = []; + pending.server?.close(); + pending.controller?.abort(); + } + + /** Serves the browser once, whatever the outcome, then releases the login. */ + function settle(id, pending, outcome) { + if (pending.closed || pending.settled) return; + pending.settled = true; + pending.responses = []; + pending.server?.close(); + outcome(); + } + + function record(pending, patch) { + const row = requireAccount(pending.id); + if (row.generation !== pending.generation) return null; + const next = accountRow(pending.id, row, patch); + // A replacement never deletes the previous secret before the new one is + // stored: one atomic write holds whichever credential is current. + store.put("providers", next); + return next; + } + + async function exchange(pending, code) { + let response; + try { + response = await fetchImpl(orcaExchangeURL(origins.auth), { + method: "POST", + redirect: "manual", + signal: pending.controller.signal, + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ + code, + code_verifier: pending.verifier, + code_challenge_method: "S256", + }), + }); + } catch { + // A transport failure reports what the operator can do, never the cause's + // contents. + throw new AppError(502, "OrcaRouter 授权地址无法访问,请稍后重试。"); + } + if (response.status >= 300 && response.status < 400) + throw new AppError(502, "OrcaRouter 授权地址返回了重定向。"); + if (!response.ok) { + // The status is the operator's next step; the upstream body could carry a + // credential and is never read into a message. + throw new AppError(400, exchangeMessage(response.status)); + } + const body = await response.json(); + const key = typeof body?.key === "string" ? body.key.trim() : ""; + if (!key) throw new AppError(502, "OrcaRouter 未返回密钥,请重新授权。"); + // Read the granted scope back: it is what was approved, not what was asked. + if (body.scope !== undefined && body.scope !== "api") + throw new AppError(400, "OrcaRouter 授予的 scope 不适用于本应用。"); + return { key, account: typeof body.user_id === "string" ? body.user_id : "" }; + } + + async function complete(pending, code) { + let granted; + try { + granted = await exchange(pending, code); + } catch (error) { + settle(pending.id, pending, () => { + record(pending, { status: "ok" }); + logins.delete(pending.id); + }); + throw error; + } + settle(pending.id, pending, () => { + record(pending, { + api_key: granted.key, + credential_source: "pkce", + generation: pending.generation + 1, + status: "ok", + account: granted.account, + }); + logins.delete(pending.id); + }); + return publicOrcaAccount(requireAccount(pending.id)); + } + /** Starts Flow A: listen first, so the port is known before the URL is built. */ + async function startLogin(id) { + const row = requireAccount(id); + if (logins.has(id)) throw new AppError(409, "已有正在进行的授权,请先取消。"); + const generation = row.generation; + const pending = { + id, + generation, + verifier: "", + state: "", + server: null, + controller: new AbortController(), + responses: [], + document: "

已完成,可以关闭此页面。

", + closed: false, + settled: false, + timer: null, + }; + // Fresh cryptographic randomness for every attempt: never reused, never + // derived from a timestamp, a username or a fixed salt. + pending.verifier = b64url(randomBytes(32)); + const challenge = s256(pending.verifier); + pending.state = b64url(randomBytes(16)); + + const ready = new Promise((resolve, reject) => { + const server = createServer((request, response) => { + const url = new URL(request.url, "http://127.0.0.1"); + pending.responses.push(response); + if (url.pathname !== "/cb") { + response.writeHead(404, { "Content-Type": "text/plain" }); + response.end(); + return; + } + // Compare the state before anything else: it is the only thing between + // this listener and a code somebody else's page dropped on it. + const state = url.searchParams.get("state") ?? ""; + if (!pending.settled && !sameBytes(state, pending.state)) { + response.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + response.end("

state 不匹配,已拒绝此次授权。

"); + response = null; + settle(id, pending, () => logins.delete(id)); + reject(new AppError(400, "state 不匹配,已取消此次授权。")); + return; + } + const denied = url.searchParams.get("error"); + const code = url.searchParams.get("code") ?? ""; + response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + response.end(pending.document); + response = null; + if (denied) { + settle(id, pending, () => logins.delete(id)); + reject(new AppError(400, "你拒绝了 OrcaRouter 授权。")); + return; + } + if (!code) { + settle(id, pending, () => logins.delete(id)); + reject(new AppError(400, "OrcaRouter 未返回授权码。")); + return; + } + complete(pending, code).then(resolve, reject); + }); + pending.server = server; + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const port = server.address().port; + pending.redirect = `http://127.0.0.1:${port}/cb`; + pending.authorize_url = orcaAuthorizeURL({ + authOrigin: origins.auth, + challenge, + state: pending.state, + callbackURL: pending.redirect, + }); + resolve(pending); + }); + // A consent screen the operator never finishes must not hold the lock. + pending.timer = setTimeout(() => { + if (pending.settled) return; + settle(id, pending, () => logins.delete(id)); + reject(new AppError(408, "OrcaRouter 授权超时,请重试。")); + }, LOGIN_TTL_MS); + pending.timer.unref?.(); + }); + logins.set(id, pending); + return ready; + } + + /** Finishes Flow A and, additionally, accepts a code a person pasted. */ + function submitLogin(id, body) { + const pending = logins.get(id); + if (!pending) throw new AppError(404, "没有正在进行的授权。"); + const code = text(body.code, "授权码", 4096, true).trim(); + if (!pending.redirect) + throw new AppError( + 409, + "回调流程需要浏览器返回授权码,请等待回调或取消后重试。", + ); + return complete(pending, code); + } + + /** Cancels a login in any terminal path: cancel, unmount, pagehide, close. */ + function cancelLogin(id) { + const pending = logins.get(id); + if (!pending) return { cancelled: false }; + settle(id, pending, () => logins.delete(id)); + clearTimeout(pending.timer); + pending.server?.close(); + pending.controller.abort(); + logins.delete(id); + return { cancelled: true }; + } + + function closeAll() { + for (const id of [...logins.keys()]) cancelLogin(id); + } + + /** Reads the catalog with the account's own key and returns one entrance's options. */ + async function catalog(id, mode) { + if (!catalogModes.has(mode)) throw new AppError(400, "未知的模型能力。"); + const row = requireAccount(id); + const generation = row.generation; + const key = credential.key(id); + if (!key) throw new AppError(400, "请先填写 API Key,或使用 OrcaRouter 账户连接。"); + if (!credential.usable(id)) + throw new AppError( + 401, + "此 OrcaRouter 凭据已被撤销,请重新连接或填写新的 API Key。", + ); + let models; + let degraded = false; + let reason = ""; + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), CATALOG_TIMEOUT_MS); + let response; + try { + response = await fetchImpl(orcaCatalogURL(origins.api, mode === "multimodal" ? "chat" : mode), { + method: "GET", + redirect: "manual", + signal: controller.signal, + headers: { Accept: "application/json", Authorization: `Bearer ${key}` }, + }); + } finally { + clearTimeout(timer); + } + if (response.status === 401 || response.status === 403) { + // A relay rejection marks the exact generation that made the request and + // never triggers a refresh: the flow has no refresh grant. + credential.reauthenticate(id, generation); + throw new AppError( + 401, + "OrcaRouter 拒绝了这个 API Key,请在设置中重新填写或重新连接。", + ); + } + if (!response.ok) throw new AppError(502, "catalog_status"); + const body = await readBounded(response, CATALOG_BYTE_LIMIT); + const parsed = parseCatalog(body); + models = filterModels(parsed.models, selectorFor(mode)).slice(0, CATALOG_ITEM_LIMIT); + } catch (error) { + if (error instanceof AppError && error.status === 401) throw error; + // Live discovery is authoritative when it answers. When it does not, the + // verified seed keeps a fresh installation usable and is labelled. + degraded = true; + reason = error instanceof AppError ? error.message : "catalog_unreachable"; + models = filterModels(verifiedCatalog, selectorFor(mode)); + } + return { + models, + degraded, + ...(degraded ? { reason } : {}), + catalog_source: degraded ? "verified-seed" : orcaCatalogURL(origins.api, mode === "multimodal" ? "chat" : mode), + // Restoring a stored model id is only allowed while it is still compatible. + selector: { capability: mode === "multimodal" ? "chat" : mode, input_modalities: mode === "multimodal" ? ["image"] : [] }, + }; + } + + /** One account's safe view, with its stored model choices re-validated. */ + function view(id) { + const row = requireAccount(id); + const models = store + .list("models") + .filter((model) => model.provider_id === id); + return { + ...publicOrcaAccount(row), + inference_base: orcaInferenceBase(origins.api), + authorize_url: `${origins.auth.replace(/\/+$/, "")}/auth`, + key_console: "https://www.orcarouter.ai/console/authorized-apps", + models, + login: logins.get(id) + ? { authorize_url: logins.get(id).authorize_url, redirect_uri: logins.get(id).redirect } + : null, + }; + } + + return { + origins, + credential, + closeAll, + view, + startLogin, + submitLogin, + cancelLogin, + catalog, + /** Whether a saved model survives a change of provider or capability. */ + keeps: (models, mode, model) => keepsModel(models, selectorFor(mode), model), + }; +} + +function exchangeMessage(status) { + switch (status) { + case 400: + return "OrcaRouter 拒绝了本次授权,请重新连接。"; + case 403: + return "授权码无效、已过期或已使用,请重新连接。"; + case 429: + return "请求过于频繁,请稍后重试。"; + default: + return "OrcaRouter 授权失败,请稍后重试。"; + } +} + +/** Reads at most `limit` bytes of a JSON response; anything larger is refused. */ +async function readBounded(response, limit) { + const declared = Number(response.headers.get?.("content-length")); + if (Number.isFinite(declared) && declared > limit) + throw new AppError(502, "catalog_too_large"); + if (!response.body?.getReader) { + const text = await response.text(); + if (Buffer.byteLength(text) > limit) throw new AppError(502, "catalog_too_large"); + return JSON.parse(text); + } + const reader = response.body.getReader(); + const chunks = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) { + await reader.cancel().catch(() => {}); + throw new AppError(502, "catalog_too_large"); + } + chunks.push(value); + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} diff --git a/example/parsar/server/product.mjs b/example/parsar/server/product.mjs index 1b3df5f29..fd6fcd050 100644 --- a/example/parsar/server/product.mjs +++ b/example/parsar/server/product.mjs @@ -73,6 +73,43 @@ export function productAPI(store, core, fetchImpl = fetch) { return async (method, path, body) => { if (path === "/app/providers/discover" && method === "POST") return providers.discover(body); + // OrcaRouter account routes: catalog discovery and the two credential + // adapters. They read and write the same provider record the ordinary + // provider path uses, so nothing downstream knows which adapter ran. + const orca = path.match( + /^\/app\/providers\/([a-f0-9-]{36})\/orcarouter\/(catalog|connect|connect\/code|cancel|credential)$/, + ); + if (orca) { + const [, providerId, action] = orca; + if (action === "catalog" && method === "POST") + return providers.orca.catalog(providerId, body?.capability ?? "chat"); + if (action === "connect" && method === "POST") + return providers.orca.startLogin(providerId); + if (action === "connect/code" && method === "POST") + return providers.orca.submitLogin(providerId, body); + if (action === "cancel" && method === "POST") { + providers.orca.cancelLogin(providerId); + return providers.orca.view(providerId); + } + if (action === "credential" && method === "DELETE") { + // An explicit clear removes the credential; a failed login never does. + const row = store.get("providers", providerId); + if (!row || row.provider !== "orcarouter") + throw new AppError(404, "该 Provider 不是 OrcaRouter。"); + const { api_key, ...rest } = row; + store.put("providers", { + ...rest, + credential_source: "", + status: "ok", + generation: (row.generation ?? 0) + 1, + revision: row.revision + 1, + }); + return providers.orca.view(providerId); + } + if (action === "credential" && method === "GET") + return providers.orca.view(providerId); + throw new AppError(405, "Method not allowed."); + } const match = path.match(/^\/app\/([a-z]+)(?:\/([a-f0-9-]{36}))?$/); if (!match || !kinds.has(match[1]) || (match[2] && !uuid.test(match[2]))) throw new AppError(404, "Not found."); @@ -84,6 +121,9 @@ export function productAPI(store, core, fetchImpl = fetch) { return kind === "providers" ? { ...publicProvider(value), + ...(value.provider === "orcarouter" + ? { orcarouter: providers.orca.view(id) } + : {}), models: store .list("models") .filter((model) => model.provider_id === id), diff --git a/example/parsar/server/providers.mjs b/example/parsar/server/providers.mjs index fcd64477a..cdbdacfbf 100644 --- a/example/parsar/server/providers.mjs +++ b/example/parsar/server/providers.mjs @@ -1,5 +1,7 @@ import { randomUUID } from "node:crypto"; import { AppError, text } from "./store.mjs"; +import { orcaRouterAPI } from "./orcarouter.mjs"; +import { orcaInferenceBase, orcaOrigins } from "../shared/orcarouter.mjs"; export function publicProvider({ api_key, ...provider }) { return { ...provider, has_api_key: Boolean(api_key) }; @@ -46,12 +48,19 @@ function connection(body, previous) { } export function providerAPI(store, fetchImpl) { + const orcaAccounts = orcaRouterAPI(store, fetchImpl); return { async discover(body) { const { base_url, api_key } = connection( body, body.id ? store.get("providers", body.id) : undefined, ); + // An OrcaRouter account reads its own catalog through the credential + // adapter, which owns the capability filter and the degraded ladder. + const existing = body.id ? store.get("providers", body.id) : undefined; + const mode = body.capability ?? "chat"; + if (existing?.provider === "orcarouter") + return orcaAccounts.catalog(body.id, mode); if (!base_url) throw new AppError(400, "请填写 Base URL。"); const endpoint = base_url.endsWith("/v1") ? `${base_url}/models` @@ -102,6 +111,26 @@ export function providerAPI(store, fetchImpl) { revision: (previous?.revision || 0) + 1, ...connection(body, previous), }; + // OrcaRouter is a named provider preset: its inference base is the + // gateway's own, so the operator never types a URL that could silently + // point somewhere else. Any other provider keeps manual entry. + if (body.preset === "orcarouter" || previous?.provider === "orcarouter") { + value.provider = "orcarouter"; + value.base_url = orcaInferenceBase(orcaOrigins(process.env).api); + // The API Key and the PKCE connect flow both write `api_key`; the + // credential adapter that produced it stays recorded for the two + // distinct status entries in the UI. + value.credential_source = + body.credential_source === "pkce" + ? "pkce" + : body.api_key || !previous?.api_key + ? "api_key" + : previous?.credential_source || "api_key"; + value.status = "ok"; + value.generation = (previous?.generation ?? 0) + (body.api_key ? 1 : 0); + if (typeof body.account === "string" && body.account.trim()) + value.account = body.account.trim().slice(0, 200); + } const existing = store .list("models") .filter((model) => model.provider_id === id); @@ -145,5 +174,7 @@ export function providerAPI(store, fetchImpl) { return publicProvider(value); }); }, + /** The OrcaRouter credential seam: one interface, two adapters. */ + orca: orcaAccounts, }; } diff --git a/example/parsar/shared/orcarouter.mjs b/example/parsar/shared/orcarouter.mjs new file mode 100644 index 000000000..53ab8e1ff --- /dev/null +++ b/example/parsar/shared/orcarouter.mjs @@ -0,0 +1,231 @@ +// OrcaRouter provider rules shared by the example server and its browser code. +// +// OrcaRouter is an OpenAI-compatible AI gateway that routes many providers +// behind one endpoint. Authentication and inference live on different public +// origins and neither is derived from the other: the code exchange is on the +// auth origin at `/api/v1/auth/keys`, while inference and the model catalog are +// on the API origin under `/v1`. `ORCA_BASE_URL` is a shared self-hosted +// fallback; the explicit `ORCA_AUTH_BASE_URL` and `ORCA_API_BASE_URL` overrides +// win over it. +// +// Nothing here holds a credential. The example server owns the credential seam +// described in `server/orcarouter.mjs`. + +export const orcaAuthOrigin = "https://www.orcarouter.ai"; +export const orcaApiOrigin = "https://api.orcarouter.ai"; +/** Where an operator lists and revokes the keys this example holds. */ +export const orcaKeyConsole = "https://www.orcarouter.ai/console/authorized-apps"; +/** The consent screen. It is a page to open, not an API to call. */ +export const orcaAuthorizePath = "/auth"; +/** The exchange is on the auth origin; the inference origin's `/v1/auth/keys` is a 404. */ +export const orcaExchangePath = "/api/v1/auth/keys"; +/** The only scope this example asks for. */ +export const orcaScope = "api"; +export const orcaAppName = "Parsar Agent Workbench"; + +function trimmed(value) { + return typeof value === "string" ? value.trim().replace(/\/+$/, "") : ""; +} + +/** The auth and API origins for this deployment. Explicit overrides win. */ +export function orcaOrigins(env = {}) { + const shared = trimmed(env.ORCA_BASE_URL); + return { + auth: trimmed(env.ORCA_AUTH_BASE_URL) || shared || orcaAuthOrigin, + api: trimmed(env.ORCA_API_BASE_URL) || shared || orcaApiOrigin, + }; +} + +/** + * Whether an origin may be used. Remote origins require HTTPS; HTTP is allowed + * only for a loopback self-hosted deployment. + */ +export function allowedOrcaOrigin(origin) { + let url; + try { + url = new URL(origin); + } catch { + return false; + } + if ( + !url.hostname || + url.username || + url.password || + url.search || + url.hash || + (url.pathname !== "" && url.pathname !== "/") || + /[\\\u0000\r\n]/.test(origin) + ) + return false; + if (url.protocol === "https:") return true; + return ( + url.protocol === "http:" && + ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) + ); +} + +/** The catalog endpoint: always `/v1/models`, never doubled. */ +export function orcaCatalogURL(apiOrigin, capability = "") { + const base = trimmed(apiOrigin); + const path = base.endsWith("/v1") ? `${base}/models` : `${base}/v1/models`; + return capability + ? `${path}?capability=${encodeURIComponent(capability)}` + : path; +} + +/** The inference base an OrcaRouter provider record uses. */ +export function orcaInferenceBase(apiOrigin = orcaApiOrigin) { + return `${trimmed(apiOrigin)}/v1`; +} + +/** The code exchange endpoint on the auth origin. */ +export function orcaExchangeURL(authOrigin = orcaAuthOrigin) { + return `${trimmed(authOrigin)}${orcaExchangePath}`; +} + +/** + * The authorize URL. `code_challenge_method` is always `S256`: the consent + * screen can hand the code to a person, and a `plain` challenge would travel on + * this URL together with the verifier. + */ +export function orcaAuthorizeURL({ authOrigin, challenge, state, callbackURL, appName = orcaAppName, scope = orcaScope }) { + const url = new URL(`${trimmed(authOrigin)}${orcaAuthorizePath}`); + url.searchParams.set("callback_url", callbackURL); + url.searchParams.set("code_challenge", challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", state); + url.searchParams.set("app_name", appName); + url.searchParams.set("scope", scope); + return url.toString(); +} + +/** The endpoint types that express one capability, in the gateway's vocabulary. */ +export const capabilityEndpoints = { + chat: ["openai", "anthropic", "gemini", "openai-response"], + embedding: ["embeddings"], + image: ["image-generation"], + video: ["openai-video"], + rerank: ["jina-rerank"], +}; + +/** Endpoints that only serve a non-text modality and never a chat entrance. */ +export const nonTextEndpoints = [ + "image-generation", + "openai-video", + "jina-rerank", + "embeddings", +]; + +function strings(value, maxItems, maxLength) { + if (!Array.isArray(value)) return null; + const out = []; + for (const entry of value) { + if (typeof entry !== "string") continue; + const text = entry.trim(); + if (!text || text.length > maxLength) continue; + if (out.length >= maxItems) break; + out.push(text); + } + return out.length ? out : null; +} + +function positive(value) { + return typeof value === "number" && Number.isInteger(value) && value > 0 + ? value + : null; +} + +/** + * Reads the OpenAI-shaped catalog into the metadata a model selector filters on. + * A record without a usable ID is refused rather than guessed at. + */ +export function parseCatalog(payload) { + const data = payload?.data; + if (!Array.isArray(data)) return { models: [], refused: 0 }; + const models = []; + const seen = new Set(); + let refused = 0; + for (const entry of data) { + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + refused += 1; + continue; + } + const id = typeof entry.id === "string" ? entry.id.trim() : ""; + if (!id || id.length > 200 || /[\u0000\r\n]/.test(id) || seen.has(id)) { + refused += 1; + continue; + } + seen.add(id); + const name = + typeof entry.name === "string" && entry.name.trim() + ? entry.name.trim().slice(0, 200) + : id; + const architecture = + entry.architecture !== null && + typeof entry.architecture === "object" && + !Array.isArray(entry.architecture) + ? entry.architecture + : null; + models.push({ + id, + name, + context_window: positive(entry.context_length), + max_output_tokens: positive(entry.max_completion_tokens), + // `null` means the catalog did not declare modalities; an entry that + // declares them is retained verbatim so a non-text entrance can filter. + input_modalities: architecture + ? strings(architecture.input_modalities, 16, 32) + : null, + supported_endpoint_types: strings(entry.supported_endpoint_types, 32, 64), + }); + } + return { models, refused }; +} + +/** The endpoint types a capability must declare. */ +export function endpointsFor(capability) { + return capabilityEndpoints[capability] ?? []; +} + +/** + * Whether a catalog record satisfies one entrance. A record that declares no + * endpoint types cannot be typed and never enters a selector; a record that does + * not declare a modality the entrance uploads is refused, because the catalog's + * silence is not evidence of support. + */ +export function modelSatisfies(model, { capability, inputModalities = [] }) { + const declared = model.supported_endpoint_types; + if (!Array.isArray(declared) || declared.length === 0) return false; + if (!declared.some((type) => endpointsFor(capability).includes(type))) + return false; + if (capability === "chat" && declared.every((type) => nonTextEndpoints.includes(type))) + return false; + if (!inputModalities.length) return true; + const modalities = model.input_modalities ?? []; + return inputModalities.every((modality) => modalities.includes(modality)); +} + +/** The model options one entrance binds, in catalog order. */ +export function filterModels(models, selector) { + return models.filter((model) => modelSatisfies(model, selector)); +} + +/** Whether a stored model choice is still valid for the entrance's current selector. */ +export function keepsModel(models, selector, model) { + return filterModels(models, selector).some((entry) => entry.id === model); +} + +/** + * The cold-start catalog, used only while live discovery is unavailable. Every + * entry and every number was read from + * `GET https://api.orcarouter.ai/v1/models?capability=chat` on 2026-10-08. The + * verified reasoning ladder of `openai/gpt-5.5` is retained so a fallback model + * is not silently reduced. + */ +export const verifiedCatalog = [ + { id: "openai/gpt-5.5", name: "OpenAI: GPT-5.5", context_window: 272000, max_output_tokens: 128000, input_modalities: ["file", "image", "text"], supported_endpoint_types: ["openai", "openai-response"], reasoning_efforts: ["low", "medium", "high", "xhigh"] }, + { id: "anthropic/claude-opus-4.8", name: "Anthropic: Claude Opus 4.8", context_window: 1000000, max_output_tokens: 128000, input_modalities: ["text", "image", "file"], supported_endpoint_types: ["openai", "anthropic", "openai-response"] }, + { id: "google/gemini-3.5-flash", name: "Gemini 3.5 Flash", context_window: 1048576, max_output_tokens: 65536, input_modalities: ["text", "image", "video", "file", "audio"], supported_endpoint_types: ["openai", "gemini"] }, + { id: "deepseek/deepseek-v4-pro", name: "DeepSeek: DeepSeek V4 Pro", context_window: 1048576, max_output_tokens: 384000, input_modalities: ["text"], supported_endpoint_types: ["openai", "openai-response"] }, + { id: "orcarouter/auto", name: "OrcaRouter Auto", context_window: null, max_output_tokens: null, input_modalities: ["text"], supported_endpoint_types: ["openai", "anthropic", "gemini", "openai-response"] }, +]; diff --git a/example/parsar/src/ProviderEditor.tsx b/example/parsar/src/ProviderEditor.tsx index 876346db5..d42c2de47 100644 --- a/example/parsar/src/ProviderEditor.tsx +++ b/example/parsar/src/ProviderEditor.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { product, + type OrcaRouterCatalog, type ModelProfile, type ProviderProfile, } from "./lib/product"; @@ -14,10 +15,20 @@ import { DialogTitle, DialogFooter, } from "./components/ui/dialog"; -import { Field, Help, ErrorNotice } from "./components/shared"; +import { Field, Help, ErrorNotice, OrcaRouterConnect } from "./components/shared"; type Choice = { model: string; name: string }; +const orcaKeyConsole = "https://www.orcarouter.ai/console/authorized-apps"; +const orcaInferenceBase = "https://api.orcarouter.ai/v1"; +/** The provider a saved record belongs to, or "" for manual entry. */ +const providerOf = (value: ProviderProfile) => value.provider ?? ""; + +/** An error's message, never its payload: a rejection may mention a credential. */ +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error ?? ""); +} + export function ProviderEditor({ value, close, @@ -86,6 +97,18 @@ function ProviderForm({ const [base, setBase] = useState(value.base_url || ""); const [key, setKey] = useState(""); const [step, setStep] = useState<"connection" | "models">("connection"); + // OrcaRouter is a named provider, not a free base URL: choosing it fixes the + // inference base and offers the two credential adapters below. + const [preset, setPreset] = useState<"" | "orcarouter">(() => + providerOf(value) === "orcarouter" || + value.base_url?.startsWith("https://api.orcarouter.ai") + ? "orcarouter" + : "", + ); + const [catalogSource, setCatalogSource] = useState(""); + const [degraded, setDegraded] = useState(false); + const [catalogReason, setCatalogReason] = useState(""); + const [connectError, setConnectError] = useState(null); const initial = models.filter((model) => model.provider_id === value.id); const [choices, setChoices] = useState(initial); const [selected, setSelected] = useState( @@ -101,17 +124,37 @@ function ProviderForm({ id: value.id, revision: value.revision, name: name.trim(), - base_url: base.trim(), + ...(preset === "orcarouter" + ? { preset: "orcarouter", base_url: orcaInferenceBase } + : { base_url: base.trim() }), ...(key ? { api_key: key } : {}), }); const discover = useMutation({ mutationFn: () => - product<{ models: string[] }>("providers/discover", "POST", connection()), - onSuccess: ({ models: found }) => { - setCatalog(found); + product( + "providers/discover", + "POST", + connection(), + ), + onSuccess: (found) => { + // The OrcaRouter catalog answers with metadata; a generic provider answers + // with IDs alone. Both feed the same checkbox list. + const models = (found.models as (string | { id: string; name?: string })[]).map( + (entry) => (typeof entry === "string" ? entry : entry.id), + ); + setCatalog(models); + if ("catalog_source" in found) { + setCatalogSource(found.catalog_source); + setDegraded(found.degraded); + setCatalogReason(found.reason ?? ""); + } else { + setCatalogSource(""); + setDegraded(false); + setCatalogReason(""); + } setChoices((old) => [ ...old, - ...found + ...models .filter((id) => !old.some((row) => row.model === id)) .map((model) => ({ model, name: model.slice(0, 80) })), ]); @@ -196,16 +239,43 @@ function ProviderForm({ placeholder="例如 MiniMax" /> - - + + {preset === "orcarouter" ? ( + + + + ) : ( + + setBase(e.target.value)} + placeholder="https://api.example.com/v1" + /> + + )} + {preset === "orcarouter" && ( +
+

+ OrcaRouter 密钥在 + + OrcaRouter 控制台 + + 创建和撤销;密钥仅保存在本机后端。 + {value.orcarouter?.status === "needs_reauth" && ( + + 当前凭据已被撤销,请重新连接或填写新的 API Key。 + + )} +

+ { + const base = await product<{ + authorize_url: string; + redirect_uri: string; + }>(`providers/${value.id}/orcarouter/connect`, "POST", {}); + window.open(base.authorize_url, "_blank", "noopener"); + return base; + }} + cancel={async () => { + await product( + `providers/${value.id}/orcarouter/cancel`, + "POST", + {}, + { keepalive: true }, + ); + }} + complete={(code) => + product( + `providers/${value.id}/orcarouter/connect/code`, + "POST", + { code }, + ) + } + onCredentials={() => { + void cache.invalidateQueries({ queryKey: ["providers"] }); + }} + onError={(error) => { + // The mutation's own error slot is not reachable from here. + setConnectError(error); + }} + /> + {connectError ? : null} +
+ )}
模型连接 @@ -248,6 +375,17 @@ function ProviderForm({ 自定义模型
+ {catalogSource && ( +

+ {degraded + ? `实时目录不可用${catalogReason ? `(${catalogReason})` : ""},以下为已验证的离线模型列表。` + : `模型目录来自 ${catalogSource}`} +

+ )} {custom && (
diff --git a/example/parsar/src/components/shared.tsx b/example/parsar/src/components/shared.tsx index 29900de3b..3f8cef4e9 100644 --- a/example/parsar/src/components/shared.tsx +++ b/example/parsar/src/components/shared.tsx @@ -1,8 +1,12 @@ import type { ReactNode } from "react"; +import { useEffect, useRef, useState } from "react"; import { AlertCircle } from "lucide-react"; import { CircleHelp } from "lucide-react"; import * as Tooltip from "@radix-ui/react-tooltip"; import { errorText } from "../lib/api"; +import { product, type OrcaRouterCatalog } from "../lib/product"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; export function PageHeader({ title, @@ -96,3 +100,184 @@ export function Field({
); } + +/** + * The two OrcaRouter choices, side by side and independently usable: + * **API Key** pastes an existing `sk-orca-...` key; **Connect with OrcaRouter** + * runs OAuth 2.0 + PKCE and issues a key the operator owns. Both end at the same + * credential seam, and neither replaces the other. + * + * `change` is a monotonically increasing generation owned by the caller. Every + * asynchronous result must still belong to the current one, so a late URL or a + * late success from an earlier attempt can never be shown under the current + * provider or overwrite a newer credential. + * + * `pagehide` invalidates the generation, clears the busy flag and the + * authorization hint synchronously, then cancels the server work with + * `keepalive` — the guarded `finally` of the invalidated request may correctly + * refuse to mutate state and would otherwise leave a restored page busy. + */ +export function OrcaRouterConnect({ + id, + connect, + cancel, + complete, + disabled = false, + change, + onCredentials, + onError, +}: { + id: string; + connect: () => Promise<{ authorize_url?: string; redirect_uri?: string } | null>; + cancel: () => Promise; + complete: (code: string) => Promise; + disabled?: boolean; + change: unknown; + onCredentials: (catalog: OrcaRouterCatalog) => void; + onError: (error: unknown) => void; +}) { + const [busy, setBusy] = useState(false); + const [hint, setHint] = useState(null); + const [notice, setNotice] = useState(null); + const [code, setCode] = useState(""); + const generation = useRef(0); + const active = useRef(false); + + // A change of provider or of authentication method releases the login lock. + useEffect(() => { + if (!active.current) return; + active.current = false; + setBusy(false); + setHint(null); + void cancel().catch(() => {}); + }, [change, cancel]); + + useEffect(() => { + const leave = () => { + if (!active.current) return; + // Invalidate first, then clear the synchronous UI state, then ask the + // server to cancel without waiting for an answer. + generation.current += 1; + active.current = false; + setBusy(false); + setHint(null); + void cancel().catch(() => {}); + }; + window.addEventListener("pagehide", leave); + return () => { + window.removeEventListener("pagehide", leave); + // A real unmount cancels the server work without writing UI state. + if (active.current) { + active.current = false; + generation.current += 1; + void cancel().catch(() => {}); + } + }; + }, [cancel]); + + async function start() { + if (disabled || active.current) return; + generation.current += 1; + const mine = generation.current; + active.current = true; + setBusy(true); + setHint(null); + setNotice(null); + setCode(""); + try { + const started = await connect(); + if (generation.current !== mine) return; + setHint(started?.authorize_url ?? null); + } catch (error) { + if (generation.current !== mine) return; + active.current = false; + setBusy(false); + onError(error); + } finally { + if (generation.current === mine) setBusy(false); + } + } + + async function submit() { + if (!code.trim()) return; + generation.current += 1; + const mine = generation.current; + try { + await complete(code.trim()); + if (generation.current !== mine) return; + setCode(""); + setHint(null); + setNotice("已使用 OrcaRouter 账户连接。"); + onCredentials(await product(`providers/${id}/orcarouter/catalog`, "POST", { capability: "chat" })); + } catch (error) { + if (generation.current !== mine) return; + onError(error); + } + } + + return ( +
+
+

使用 OrcaRouter 账户连接(OAuth 2.0 + PKCE)

+

+ 在浏览器中授权后,OrcaRouter 会向本机回调返回一次性授权码,本程序用 PKCE + verifier 换取属于你的 API Key。没有浏览器或已有密钥时,可以直接在上方填写 API Key。 +

+
+ + {busy && ( + + )} +
+ {hint && ( +

+ 浏览器未自动打开时,请手动访问: + + {hint} + +

+ )} + {hint && ( +
+ + setCode(event.target.value)} + /> + + +
+ )} + {notice && ( +

+ {notice} +

+ )} +
+
+ ); +} diff --git a/example/parsar/src/lib/product.ts b/example/parsar/src/lib/product.ts index 2e8f251ba..cc19ac4e2 100644 --- a/example/parsar/src/lib/product.ts +++ b/example/parsar/src/lib/product.ts @@ -8,6 +8,46 @@ export interface NamedResource { export interface ProviderProfile extends NamedResource { base_url?: string; has_api_key?: boolean; + provider?: "orcarouter"; + credential_source?: "" | "api_key" | "pkce"; + /** Bumped on every credential replacement; a 401 marks one exact generation. */ + generation?: number; + /** `ok`, or `needs_reauth` after the relay rejected this exact credential. */ + status?: "ok" | "needs_reauth"; + account?: string; + orcarouter?: OrcaRouterView; +} +export interface OrcaRouterModel { + id: string; + name: string; + context_window?: number | null; + max_output_tokens?: number | null; + input_modalities?: string[] | null; + supported_endpoint_types?: string[] | null; +} +export interface OrcaRouterCatalog { + models: OrcaRouterModel[]; + /** True when live discovery was unavailable and the verified seed is shown. */ + degraded: boolean; + reason?: string; + catalog_source: string; + selector: { capability: string; input_modalities: string[] }; +} +export interface OrcaRouterView { + id: string; + name: string; + base_url: string; + credential_source: "" | "api_key" | "pkce"; + generation: number; + status: "ok" | "needs_reauth"; + account: string; + revision: number; + has_api_key: boolean; + inference_base: string; + authorize_url: string; + key_console: string; + models: ModelProfile[]; + login: { authorize_url: string; redirect_uri: string } | null; } export interface ModelProfile extends NamedResource { provider_id: string; @@ -54,10 +94,12 @@ export async function product( path: string, method = "GET", body?: unknown, + { keepalive = false }: { keepalive?: boolean } = {}, ): Promise { const response = await fetch(`/app/${path}`, { method, headers: { "Content-Type": "application/json" }, + keepalive, ...(body ? { body: JSON.stringify(body) } : {}), }); const data = await response.json(); diff --git a/example/parsar/tests/catalog.test.mjs b/example/parsar/tests/catalog.test.mjs new file mode 100644 index 000000000..2d8d4dcfe --- /dev/null +++ b/example/parsar/tests/catalog.test.mjs @@ -0,0 +1,181 @@ +// The catalog layer: parsing, per-capability filtering, provider linkage and the +// fail-closed rule for modalities the catalog did not declare. Every fixture is +// synthetic; no live credential is used here. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + allowedOrcaOrigin, + filterModels, + keepsModel, + modelSatisfies, + orcaAuthorizeURL, + orcaCatalogURL, + orcaExchangeURL, + orcaInferenceBase, + orcaOrigins, + parseCatalog, + verifiedCatalog, +} from "../shared/orcarouter.mjs"; + +const record = (overrides) => ({ + id: "vendor/model", + name: "Vendor Model", + supported_endpoint_types: ["openai"], + architecture: { input_modalities: ["text"] }, + ...overrides, +}); + +test("origins default to the public pair and never derive one from the other", () => { + assert.deepEqual(orcaOrigins({}), { + auth: "https://www.orcarouter.ai", + api: "https://api.orcarouter.ai", + }); + const shared = orcaOrigins({ ORCA_BASE_URL: "https://orca.internal/" }); + assert.deepEqual(shared, { + auth: "https://orca.internal", + api: "https://orca.internal", + }); + const overridden = orcaOrigins({ + ORCA_BASE_URL: "https://orca.internal", + ORCA_AUTH_BASE_URL: "https://login.internal", + ORCA_API_BASE_URL: "https://relay.internal", + }); + // An explicit override wins over the shared fallback. + assert.deepEqual(overridden, { + auth: "https://login.internal", + api: "https://relay.internal", + }); + // The two public origins are independent values, not one value rewritten. + assert.notEqual(orcaOrigins({}).auth, orcaOrigins({}).api); +}); + +test("catalog and exchange URLs keep each origin's own path", () => { + assert.equal(orcaCatalogURL("https://api.orcarouter.ai"), "https://api.orcarouter.ai/v1/models"); + assert.equal(orcaCatalogURL("https://api.orcarouter.ai", "embedding"), "https://api.orcarouter.ai/v1/models?capability=embedding"); + // A self-hosted value that already ends in /v1 is not doubled. + assert.equal(orcaCatalogURL("https://relay.internal/v1"), "https://relay.internal/v1/models"); + // The relay is at /v1; the auth endpoints are not. + assert.equal(orcaExchangeURL("https://www.orcarouter.ai"), "https://www.orcarouter.ai/api/v1/auth/keys"); + assert.notEqual(orcaExchangeURL("https://www.orcarouter.ai"), "https://www.orcarouter.ai/v1/auth/keys"); + assert.equal(orcaInferenceBase("https://api.orcarouter.ai"), "https://api.orcarouter.ai/v1"); +}); + +test("remote origins require HTTPS and only loopback may use HTTP", () => { + for (const origin of ["https://api.orcarouter.ai", "http://127.0.0.1:8099", "http://localhost:1", "https://relay.internal"]) { + assert.equal(allowedOrcaOrigin(origin), true, origin); + } + for (const origin of ["http://remote.example", "ftp://x", "https://user:pass@x", "https://x/v1", "https://x?a=1", "not a url"]) { + assert.equal(allowedOrcaOrigin(origin), false, origin); + } +}); + +test("the authorize URL always asks for S256 and carries the state", () => { + const url = new URL(orcaAuthorizeURL({ + authOrigin: "https://www.orcarouter.ai", + challenge: "challenge-value", + state: "state-value", + callbackURL: "http://127.0.0.1:51234/cb", + })); + assert.equal(url.pathname, "/auth"); + assert.equal(url.searchParams.get("code_challenge"), "challenge-value"); + assert.equal(url.searchParams.get("code_challenge_method"), "S256"); + assert.equal(url.searchParams.get("state"), "state-value"); + assert.equal(url.searchParams.get("callback_url"), "http://127.0.0.1:51234/cb"); + assert.equal(url.searchParams.get("scope"), "api"); + // The verifier never travels on this URL. + assert.equal(url.searchParams.get("code_verifier"), null); +}); + +test("parseCatalog reduces records and refuses unusable ones", () => { + const { models, refused } = parseCatalog({ + data: [ + record({ id: "vendor/one", name: "One", context_length: 128000, max_completion_tokens: 8192 }), + record({ id: "vendor/one" }), + record({ id: "" }), + record({ id: undefined }), + null, + record({ id: "x".repeat(201) }), + record({ id: "vendor/two", context_length: -1, architecture: null }), + ], + }); + assert.deepEqual(models.map((model) => model.id), ["vendor/one", "vendor/two"]); + // One duplicate, one empty id, one missing id, one null, one oversized id. + assert.equal(refused, 5); + assert.equal(models[0].context_window, 128000); + assert.equal(models[0].max_output_tokens, 8192); + assert.deepEqual(models[0].input_modalities, ["text"]); + // A negative bound is not evidence and is dropped rather than reported. + assert.equal(models[1].context_window, null); + // A record with no architecture declares nothing, which is not "text only". + assert.equal(models[1].input_modalities, null); +}); + +test("each capability filters its own list", () => { + const models = parseCatalog({ data: [ + record({ id: "text/chat", supported_endpoint_types: ["openai"] }), + record({ id: "image/out", supported_endpoint_types: ["image-generation"], architecture: { input_modalities: ["text"] } }), + record({ id: "embed/one", supported_endpoint_types: ["embeddings"] }), + record({ id: "video/one", supported_endpoint_types: ["openai-video"] }), + record({ id: "rerank/one", supported_endpoint_types: ["jina-rerank"] }), + record({ id: "null/types", supported_endpoint_types: null }), + record({ id: "empty/types", supported_endpoint_types: [] }), + ] }).models; + const ids = (selector) => filterModels(models, selector).map((model) => model.id); + assert.deepEqual(ids({ capability: "chat" }), ["text/chat"]); + assert.deepEqual(ids({ capability: "image" }), ["image/out"]); + assert.deepEqual(ids({ capability: "embedding" }), ["embed/one"]); + assert.deepEqual(ids({ capability: "video" }), ["video/one"]); + assert.deepEqual(ids({ capability: "rerank" }), ["rerank/one"]); +}); + +test("a chat entrance excludes models that only serve a non-text endpoint", () => { + const models = parseCatalog({ data: [ + record({ id: "dual", supported_endpoint_types: ["openai", "image-generation"] }), + record({ id: "image-only", supported_endpoint_types: ["image-generation"] }), + record({ id: "video-only", supported_endpoint_types: ["openai-video"] }), + ] }).models; + assert.deepEqual(filterModels(models, { capability: "chat" }).map((m) => m.id), ["dual"]); +}); + +test("a multimodal entrance fails closed when the catalog declares no modality", () => { + const models = parseCatalog({ data: [ + record({ id: "vision", architecture: { input_modalities: ["text", "image"] } }), + record({ id: "text-only", architecture: { input_modalities: ["text"] } }), + record({ id: "undeclared", architecture: undefined }), + record({ id: "declared-null", architecture: { input_modalities: null } }), + ] }).models; + const withImage = filterModels(models, { capability: "chat", inputModalities: ["image"] }); + assert.deepEqual(withImage.map((m) => m.id), ["vision"]); + // Adding the requirement removes the text-only model from the options. + assert.equal(keepsModel(models, { capability: "chat", inputModalities: ["image"] }, "text-only"), false); + assert.equal(keepsModel(models, { capability: "chat", inputModalities: ["image"] }, "vision"), true); + // Without the requirement it is a normal chat model again. + assert.equal(keepsModel(models, { capability: "chat", inputModalities: [] }, "text-only"), true); + // A model the catalog does not describe at all never satisfies a modality. + assert.equal(modelSatisfies(models[2], { capability: "chat", inputModalities: ["image"] }), false); +}); + +test("a model with no declared endpoint types never enters any selector", () => { + const models = parseCatalog({ data: [record({ id: "unknown", supported_endpoint_types: null })] }).models; + for (const capability of ["chat", "embedding", "image", "video", "rerank"]) { + assert.deepEqual(filterModels(models, { capability }), [], capability); + } +}); + +test("the verified seed keeps its reasoning ladder and modalities", () => { + const seed = new Map(verifiedCatalog.map((model) => [model.id, model])); + const gpt = seed.get("openai/gpt-5.5"); + assert.deepEqual(gpt.reasoning_efforts, ["low", "medium", "high", "xhigh"]); + assert.deepEqual(gpt.input_modalities, ["file", "image", "text"]); + assert.equal(gpt.context_window, 272000); + // The seed is a catalog like any other and goes through the same filters. + assert.deepEqual( + filterModels(verifiedCatalog, { capability: "chat" }).map((m) => m.id), + ["openai/gpt-5.5", "anthropic/claude-opus-4.8", "google/gemini-3.5-flash", "deepseek/deepseek-v4-pro", "orcarouter/auto"], + ); + assert.deepEqual( + filterModels(verifiedCatalog, { capability: "chat", inputModalities: ["image"] }).map((m) => m.id), + ["openai/gpt-5.5", "anthropic/claude-opus-4.8", "google/gemini-3.5-flash"], + ); +}); diff --git a/example/parsar/tests/orcarouter-live.test.mjs b/example/parsar/tests/orcarouter-live.test.mjs new file mode 100644 index 000000000..628778695 --- /dev/null +++ b/example/parsar/tests/orcarouter-live.test.mjs @@ -0,0 +1,74 @@ +// A live read of the real OrcaRouter gateway through the example's own provider +// path. It runs only when ORCAROUTER_API_KEY is present, so the regular suite +// stays offline; every other test here uses fake credentials and a local +// stand-in origin. +// +// This is the same `orcaRouterAPI` the server hands the browser and the Session +// adapter, so a pass proves the shipped discovery path — not a bare curl — talks +// to the gateway and filters its catalog. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { openStore } from "../server/store.mjs"; +import { orcaRouterAPI } from "../server/orcarouter.mjs"; + +test("the shipped provider path reads the real OrcaRouter catalog", async (t) => { + const key = (process.env.ORCAROUTER_API_KEY ?? "").trim(); + if (!key) { + t.skip("ORCAROUTER_API_KEY is not set"); + return; + } + assert.match(key, /^sk-orca-/, "ORCAROUTER_API_KEY is an OrcaRouter key"); + const store = openStore(":memory:"); + t.after(() => store.close?.()); + const id = randomUUID(); + store.put("providers", { + id, + name: "OrcaRouter", + provider: "orcarouter", + base_url: "https://api.orcarouter.ai/v1", + api_key: key, + credential_source: "api_key", + generation: 0, + status: "ok", + }); + const api = orcaRouterAPI(store); + + const chat = await api.catalog(id, "chat"); + assert.equal(chat.degraded, false, "the live catalog answered"); + assert.equal(chat.catalog_source, "https://api.orcarouter.ai/v1/models?capability=chat"); + assert.ok(chat.models.length > 0, "the live catalog carried chat models"); + for (const model of chat.models) { + assert.match(model.id, /\S/, "every model keeps its vendor/model id"); + const endpoints = model.supported_endpoint_types ?? []; + assert.ok(endpoints.length > 0, `${model.id} reached the selector with no endpoint types`); + assert.ok( + endpoints.some((type) => ["openai", "anthropic", "gemini", "openai-response"].includes(type)), + `${model.id} is not a text protocol`, + ); + assert.ok( + !endpoints.every((type) => ["image-generation", "openai-video", "jina-rerank", "embeddings"].includes(type)), + `${model.id} is not a text protocol`, + ); + } + + // A multimodal selector is a chat selector that additionally requires the + // declared image input, and it never widens the text set. + const multimodal = await api.catalog(id, "multimodal"); + assert.equal(multimodal.degraded, false, "the live multimodal catalog answered"); + for (const model of multimodal.models) { + assert.ok( + (model.input_modalities ?? []).includes("image"), + `${model.id} entered a multimodal selector without a declared image input`, + ); + assert.ok( + chat.models.some((entry) => entry.id === model.id), + `${model.id} entered the multimodal selector but not the chat one`, + ); + } + + t.diagnostic( + `live OrcaRouter catalog: ${chat.models.length} chat, ${multimodal.models.length} with a declared image input`, + ); +}); diff --git a/example/parsar/tests/orcarouter.test.mjs b/example/parsar/tests/orcarouter.test.mjs new file mode 100644 index 000000000..956c3c6a2 --- /dev/null +++ b/example/parsar/tests/orcarouter.test.mjs @@ -0,0 +1,399 @@ +// The OrcaRouter credential seam. Both adapters — a pasted API Key and an +// OAuth 2.0 + PKCE login — are exercised through the implementation the browser +// and the Session adapter actually call, against a local fake auth server. +// Every key, code and verifier here is a fake. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { createServer } from "node:http"; +import { randomUUID } from "node:crypto"; +import { openStore } from "../server/store.mjs"; +import { orcaCredential, orcaRouterAPI, publicOrcaAccount } from "../server/orcarouter.mjs"; + +const b64url = (value) => Buffer.from(value).toString("base64url"); +const s256 = (verifier) => b64url(createHash("sha256").update(verifier).digest()); + +/** A stand-in auth origin that records what it was asked and answers on demand. */ +async function fakeAuth(t, reply = () => ({ status: 200, body: { key: "sk-orca-issued", user_id: "42", scope: "api" } })) { + const seen = []; + const server = createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : null; + seen.push({ method: request.method, url: request.url, body, headers: request.headers }); + const answer = reply(seen.at(-1), seen.length); + response.writeHead(answer.status, { "Content-Type": "application/json" }); + response.end(JSON.stringify(answer.body)); + }); + await new Promise((done) => server.listen(0, "127.0.0.1", done)); + t.after(() => server.close()); + return { origin: `http://127.0.0.1:${server.address().port}`, seen }; +} + +/** A store holding one OrcaRouter provider account written by the API-key adapter. */ +function account(store, patch = {}) { + const id = randomUUID(); + store.put("providers", { + id, + name: "OrcaRouter", + provider: "orcarouter", + base_url: "https://api.orcarouter.ai/v1", + api_key: "", + credential_source: "", + generation: 0, + status: "ok", + account: "", + revision: 1, + ...patch, + }); + return id; +} + +function api(t, { authOrigin, apiOrigin, fetchImpl, store }) { + const built = orcaRouterAPI(store, fetchImpl ?? fetch, { + ORCA_AUTH_BASE_URL: authOrigin, + ORCA_API_BASE_URL: apiOrigin, + }); + t.after(() => built.closeAll()); + return built; +} + +test("the API Key adapter stores a pasted key for the downstream request", (t) => { + const store = openStore(":memory:"); + t.after(() => store.close()); + const id = account(store, { api_key: "sk-orca-pasted", credential_source: "api_key" }); + const credential = orcaCredential(store); + // The credential interface is the only thing downstream code reads. + assert.equal(credential.key(id), "sk-orca-pasted"); + assert.equal(credential.usable(id), true); + assert.equal(credential.generation(id), 0); + // The browser view never carries the key, only whether one is configured. + const view = publicOrcaAccount(store.get("providers", id)); + assert.equal(view.has_api_key, true); + assert.equal(JSON.stringify(view).includes("sk-orca-pasted"), false); +}); + +test("a PKCE login issues the same credential a pasted key does", async (t) => { + const store = openStore(":memory:"); + t.after(() => store.close()); + const id = account(store); + const auth = await fakeAuth(t); + const calls = []; + const orca = api(t, { + authOrigin: auth.origin, + apiOrigin: "https://api.orcarouter.ai", + store, + fetchImpl: async (url, init) => { + calls.push({ url, init }); + return fetch(url, init); + }, + }); + + const pending = await orca.startLogin(id); + const authorize = new URL(pending.authorize_url); + // The authorize URL is on the auth origin and asks for S256. + assert.equal(authorize.origin, auth.origin); + assert.equal(authorize.pathname, "/auth"); + assert.equal(authorize.searchParams.get("code_challenge_method"), "S256"); + const challenge = authorize.searchParams.get("code_challenge"); + const state = authorize.searchParams.get("state"); + assert.ok(challenge && state); + assert.match(authorize.searchParams.get("callback_url"), /^http:\/\/127\.0\.0\.1:\d+\/cb$/); + + // The callback carries the code back to the process holding the verifier. + const callback = new URL(authorize.searchParams.get("callback_url")); + callback.searchParams.set("code", "one-time-code"); + callback.searchParams.set("state", state); + const browser = await fetch(callback); + assert.equal(browser.status, 200); + await browser.text(); + + // The exchange posts S256 to the auth origin's `/api/v1/auth/keys` with the + // matching verifier, and no client secret of any kind. + for (let attempt = 0; attempt < 200 && store.get("providers", id).api_key === ""; attempt += 1) + await new Promise((done) => setTimeout(done, 10)); + const exchange = auth.seen.find((entry) => entry.url === "/api/v1/auth/keys"); + assert.ok(exchange, "the exchange must reach the auth origin"); + assert.equal(calls.length, 1); + assert.equal(exchange.method, "POST"); + assert.equal(exchange.body.code, "one-time-code"); + assert.equal(exchange.body.code_challenge_method, "S256"); + assert.equal(s256(exchange.body.code_verifier), challenge); + assert.equal(exchange.body.client_secret, undefined); + assert.equal(JSON.stringify(exchange.body).includes("secret"), false); + + const row = store.get("providers", id); + assert.equal(row.api_key, "sk-orca-issued"); + assert.equal(row.credential_source, "pkce"); + assert.equal(row.generation, 1); + assert.equal(row.status, "ok"); + // The same interface serves both adapters. + const credential = orcaCredential(store); + assert.equal(credential.key(id), "sk-orca-issued"); + assert.equal(credential.usable(id), true); +}); + +test("each attempt uses a fresh verifier, state and callback port", async (t) => { + const store = openStore(":memory:"); + t.after(() => store.close()); + const id = account(store); + const orca = api(t, { authOrigin: "https://www.orcarouter.ai", apiOrigin: "https://api.orcarouter.ai", store, fetchImpl: async () => Response.json({}) }); + const first = await orca.startLogin(id); + orca.cancelLogin(id); + const second = await orca.startLogin(id); + const a = new URL(first.authorize_url); + const b = new URL(second.authorize_url); + assert.notEqual(a.searchParams.get("code_challenge"), b.searchParams.get("code_challenge")); + assert.notEqual(a.searchParams.get("state"), b.searchParams.get("state")); + orca.cancelLogin(id); +}); + +test("Flow A compares the state before redeeming anything", async (t) => { + const store = openStore(":memory:"); + t.after(() => store.close()); + const id = account(store); + const auth = await fakeAuth(t); + const orca = api(t, { authOrigin: auth.origin, apiOrigin: "https://api.orcarouter.ai", store }); + const pending = await orca.startLogin(id); + const callback = new URL(pending.authorize_url.includes("callback_url") + ? new URL(pending.authorize_url).searchParams.get("callback_url") + : "http://127.0.0.1/cb"); + callback.searchParams.set("code", "attacker-code"); + callback.searchParams.set("state", "not-the-state"); + const response = await fetch(callback); + assert.equal(response.status, 400); + await response.text(); + // Nothing was redeemed and no credential appeared. + assert.equal(store.get("providers", id).api_key, ""); + assert.equal(auth.seen.filter((entry) => entry.url === "/api/v1/auth/keys").length, 0); +}); + +test("a denial ends safely and releases the login", async (t) => { + const store = openStore(":memory:"); + t.after(() => store.close()); + const id = account(store); + const auth = await fakeAuth(t); + const orca = api(t, { authOrigin: auth.origin, apiOrigin: "https://api.orcarouter.ai", store }); + const pending = await orca.startLogin(id); + const callback = new URL(new URL(pending.authorize_url).searchParams.get("callback_url")); + callback.searchParams.set("error", "access_denied"); + callback.searchParams.set("state", new URL(pending.authorize_url).searchParams.get("state")); + const response = await fetch(callback); + assert.equal(response.status, 200); + await response.text(); + assert.equal(store.get("providers", id).api_key, ""); + // The lock is released: another login can start. + const again = await orca.startLogin(id); + assert.ok(again.authorize_url); + orca.cancelLogin(id); +}); + +test("an exchange failure keeps the login usable and never stores a key", async (t) => { + const store = openStore(":memory:"); + t.after(() => store.close()); + const id = account(store); + const auth = await fakeAuth(t, () => ({ status: 403, body: { error: "invalid_grant", error_description: "code verifier sk-orca-secret" } })); + const orca = api(t, { authOrigin: auth.origin, apiOrigin: "https://api.orcarouter.ai", store }); + const pending = await orca.startLogin(id); + const callback = new URL(new URL(pending.authorize_url).searchParams.get("callback_url")); + callback.searchParams.set("code", "expired-code"); + callback.searchParams.set("state", new URL(pending.authorize_url).searchParams.get("state")); + await (await fetch(callback)).text(); + await new Promise((done) => setTimeout(done, 30)); + assert.equal(store.get("providers", id).api_key, ""); + assert.equal(orca.view(id).login, null); +}); + +test("a reused or expired code reports the operator's next step, never the body", async (t) => { + const store = openStore(":memory:"); + t.after(() => store.close()); + const id = account(store); + const auth = await fakeAuth(t, () => ({ status: 403, body: { error: "invalid_grant", error_description: "verifier sk-orca-secret" } })); + const orca = api(t, { authOrigin: auth.origin, apiOrigin: "https://api.orcarouter.ai", store }); + await orca.startLogin(id); + await assert.rejects( + orca.submitLogin(id, { code: "reused" }), + (error) => error.status === 400 && !error.message.includes("sk-orca-secret") && !error.message.includes("invalid_grant"), + ); +}); + +test("a scope other than the requested one is refused", async (t) => { + const store = openStore(":memory:"); + t.after(() => store.close()); + const id = account(store); + const auth = await fakeAuth(t, () => ({ status: 200, body: { key: "sk-orca-issued", scope: "connector" } })); + const orca = api(t, { authOrigin: auth.origin, apiOrigin: "https://api.orcarouter.ai", store }); + await orca.startLogin(id); + await assert.rejects(orca.submitLogin(id, { code: "c" }), /scope/); + assert.equal(store.get("providers", id).api_key, ""); +}); + +test("a network failure ends the login without storing anything", async (t) => { + const store = openStore(":memory:"); + t.after(() => store.close()); + const id = account(store); + const orca = api(t, { + authOrigin: "https://www.orcarouter.ai", + apiOrigin: "https://api.orcarouter.ai", + store, + fetchImpl: async () => { throw new Error("offline"); }, + }); + await orca.startLogin(id); + await assert.rejects(orca.submitLogin(id, { code: "c" }), (error) => { assert.equal(error.status, 502); assert.match(error.message, /无法访问/); return true; }); + assert.equal(store.get("providers", id).api_key, ""); +}); + +test("cancelling releases the lock and closes the listener", async (t) => { + const store = openStore(":memory:"); + t.after(() => store.close()); + const id = account(store); + const orca = api(t, { authOrigin: "https://www.orcarouter.ai", apiOrigin: "https://api.orcarouter.ai", store, fetchImpl: async () => Response.json({}) }); + const pending = await orca.startLogin(id); + assert.ok(pending.redirect); + orca.cancelLogin(id); + assert.equal(orca.view(id).login, null); + // The cancelled listener no longer answers. + await assert.rejects(fetch(pending.redirect)); + // A second login can start immediately. + const again = await orca.startLogin(id); + assert.ok(again.authorize_url); + orca.cancelLogin(id); +}); + +test("a second login while one is pending is refused rather than queued", async (t) => { + const store = openStore(":memory:"); + t.after(() => store.close()); + const id = account(store); + const orca = api(t, { authOrigin: "https://www.orcarouter.ai", apiOrigin: "https://api.orcarouter.ai", store, fetchImpl: async () => Response.json({}) }); + await orca.startLogin(id); + let status = 0; + await orca.startLogin(id).then( + () => { status = 200; }, + (error) => { status = error.status; }, + ); + assert.equal(status, 409); + orca.cancelLogin(id); +}); + +test("the catalog uses the account's own key and never crosses to the auth origin", async (t) => { + const store = openStore(":memory:"); + t.after(() => store.close()); + const id = account(store, { api_key: "sk-orca-pasted" }); + const seen = []; + const orca = api(t, { + authOrigin: "https://www.orcarouter.ai", + apiOrigin: "https://api.orcarouter.ai", + store, + fetchImpl: async (url, init) => { + seen.push({ url, init }); + return Response.json({ data: [ + { id: "openai/gpt-5.5", name: "GPT", context_length: 272000, max_completion_tokens: 128000, supported_endpoint_types: ["openai"], architecture: { input_modalities: ["text", "image"] } }, + { id: "image/one", supported_endpoint_types: ["image-generation"] }, + ] }); + }, + }); + const catalog = await orca.catalog(id, "chat"); + assert.equal(seen[0].url, "https://api.orcarouter.ai/v1/models?capability=chat"); + assert.equal(seen[0].init.headers.Authorization, "Bearer sk-orca-pasted"); + assert.equal(seen[0].init.redirect, "manual"); + assert.deepEqual(catalog.models.map((model) => model.id), ["openai/gpt-5.5"]); + assert.equal(catalog.degraded, false); + assert.equal(catalog.catalog_source, "https://api.orcarouter.ai/v1/models?capability=chat"); + // The auth origin was never contacted. + assert.equal(seen.some((entry) => entry.url.includes("orcarouter.ai/auth")), false); +}); + +test("a degraded catalog is the verified seed, labelled, and never a free-text fallback", async (t) => { + const store = openStore(":memory:"); + t.after(() => store.close()); + const id = account(store, { api_key: "sk-orca-pasted" }); + const orca = api(t, { + authOrigin: "https://www.orcarouter.ai", + apiOrigin: "https://api.orcarouter.ai", + store, + fetchImpl: async () => { throw new Error("offline"); }, + }); + const catalog = await orca.catalog(id, "chat"); + assert.equal(catalog.degraded, true); + assert.equal(catalog.catalog_source, "verified-seed"); + assert.deepEqual(catalog.models.map((model) => model.id), [ + "openai/gpt-5.5", + "anthropic/claude-opus-4.8", + "google/gemini-3.5-flash", + "deepseek/deepseek-v4-pro", + "orcarouter/auto", + ]); + // The verified reasoning ladder survives the outage. + assert.deepEqual(catalog.models[0].reasoning_efforts, ["low", "medium", "high", "xhigh"]); +}); + +test("a relay 401 marks the exact generation and never fakes a refresh", async (t) => { + const store = openStore(":memory:"); + t.after(() => store.close()); + const id = account(store, { api_key: "sk-orca-pasted", generation: 3 }); + let calls = 0; + const orca = api(t, { + authOrigin: "https://www.orcarouter.ai", + apiOrigin: "https://api.orcarouter.ai", + store, + fetchImpl: async () => { calls += 1; return new Response("revoked", { status: 401 }); }, + }); + await assert.rejects(orca.catalog(id, "chat"), (error) => error.status === 401); + // Exactly one attempt: a revoked durable key is not refreshed. + assert.equal(calls, 1); + assert.equal(store.get("providers", id).status, "needs_reauth"); + assert.equal(store.get("providers", id).generation, 3); + // A second read reports the terminal state instead of retrying the relay. + await assert.rejects(orca.catalog(id, "chat"), (error) => error.status === 401); + assert.equal(calls, 1); +}); + +test("a late failure from an old generation cannot mark a new credential", (t) => { + const store = openStore(":memory:"); + t.after(() => store.close()); + const id = account(store, { api_key: "sk-orca-old", generation: 1 }); + const credential = orcaCredential(store); + // A new login replaces the credential before the late failure arrives. + store.put("providers", { ...store.get("providers", id), api_key: "sk-orca-new", generation: 2, status: "ok" }); + assert.equal(credential.reauthenticate(id, 1), false); + assert.equal(store.get("providers", id).status, "ok"); + assert.equal(credential.key(id), "sk-orca-new"); + // The current generation is still the one that can be marked. + assert.equal(credential.reauthenticate(id, 2), true); + assert.equal(store.get("providers", id).status, "needs_reauth"); + assert.equal(credential.key(id), "sk-orca-new"); +}); + +test("a replacement never deletes the previous secret before the new one lands", async (t) => { + const store = openStore(":memory:"); + t.after(() => store.close()); + const id = account(store, { api_key: "sk-orca-original", credential_source: "api_key" }); + const auth = await fakeAuth(t, () => ({ status: 403, body: { error: "invalid_grant" } })); + const orca = api(t, { authOrigin: auth.origin, apiOrigin: "https://api.orcarouter.ai", store }); + await orca.startLogin(id); + await assert.rejects(orca.submitLogin(id, { code: "bad" })); + // The failed login left the working credential in place. + assert.equal(store.get("providers", id).api_key, "sk-orca-original"); + assert.equal(orcaCredential(store).usable(id), true); +}); + +test("both adapters produce the same credential result for the downstream request", async (t) => { + const store = openStore(":memory:"); + t.after(() => store.close()); + const pasted = account(store, { api_key: "sk-orca-pasted", credential_source: "api_key" }); + const connected = account(store); + const auth = await fakeAuth(t); + const orca = api(t, { authOrigin: auth.origin, apiOrigin: "https://api.orcarouter.ai", store }); + const pending = await orca.startLogin(connected); + await orca.submitLogin(connected, { code: "code" }); + const credential = orcaCredential(store); + // Downstream reads only the interface: it cannot tell which adapter ran. + for (const id of [pasted, connected]) { + assert.equal(typeof credential.key(id), "string"); + assert.match(credential.key(id), /^sk-orca-/); + assert.equal(credential.usable(id), true); + } + assert.equal(store.get("providers", pasted).credential_source, "api_key"); + assert.equal(store.get("providers", connected).credential_source, "pkce"); +}); diff --git a/example/parsar/tests/product.test.mjs b/example/parsar/tests/product.test.mjs index 7ff07a72d..ab3fbabe5 100644 --- a/example/parsar/tests/product.test.mjs +++ b/example/parsar/tests/product.test.mjs @@ -453,3 +453,82 @@ test("MiniMax MCP bindings reject before Core for text-only and hosted placement } assert.equal(calls, 0); }); + +test("an OrcaRouter Provider is named, keeps a fixed inference base, and both credential routes work", async () => { + const store = openStore(":memory:"); + const id = randomUUID(); + const authCalls = []; + const api = productAPI( + store, + null, + async (url, init) => { + authCalls.push({ url: String(url), init }); + if (String(url).includes("/auth/keys")) + return Response.json({ key: "sk-orca-issued", user_id: "7", scope: "api" }); + return Response.json({ + data: [ + { id: "openai/gpt-5.5", supported_endpoint_types: ["openai"], architecture: { input_modalities: ["text", "image"] } }, + { id: "openai/gpt-image-1", supported_endpoint_types: ["image-generation"] }, + ], + }); + }, + ); + // The preset fixes the inference base: no operator-supplied URL reaches Core. + const saved = await api("PUT", `/app/providers/${id}`, { + name: "OrcaRouter", + preset: "orcarouter", + api_key: "sk-orca-pasted", + }); + assert.equal(saved.base_url, "https://api.orcarouter.ai/v1"); + assert.equal(saved.provider, "orcarouter"); + assert.equal(saved.credential_source, "api_key"); + assert.equal(saved.has_api_key, true); + assert.equal(JSON.stringify(saved).includes("sk-orca-pasted"), false); + + // The catalog route filters by the entrance's capability. + const catalog = await api("POST", `/app/providers/${id}/orcarouter/catalog`, { capability: "chat" }); + assert.deepEqual(catalog.models.map((model) => model.id), ["openai/gpt-5.5"]); + assert.equal(catalog.degraded, false); + assert.match(catalog.catalog_source, /^https:\/\/api\.orcarouter\.ai\/v1\/models/); + + // The credential route reads the safe view and never a key. + const view = await api("GET", `/app/providers/${id}/orcarouter/credential`); + assert.equal(view.has_api_key, true); + assert.equal(view.inference_base, "https://api.orcarouter.ai/v1"); + assert.equal(JSON.stringify(view).includes("sk-orca-pasted"), false); + + // A discover on an OrcaRouter account goes through the credential adapter. + const discovered = await api("POST", "/app/providers/discover", { id, capability: "chat" }); + assert.equal(discovered.models[0].id, "openai/gpt-5.5"); + + // An explicit clear removes the credential and reports it as gone. + const cleared = await api("DELETE", `/app/providers/${id}/orcarouter/credential`); + assert.equal(cleared.has_api_key, false); + assert.equal(cleared.credential_source, ""); + // Clearing bumps the generation so any in-flight asynchronous failure of the + // removed credential can never be attributed to the next one. + assert.equal(cleared.generation, 2); + + // With no credential left, discovery refuses instead of calling the gateway. + await assert.rejects(api("POST", "/app/providers/discover", { id, capability: "chat" }), /API Key/); + store.close(); +}); + +test("clearing the OrcaRouter credential does not delete the linked reliability records", async () => { + const store = openStore(":memory:"); + const id = randomUUID(); + const api = productAPI(store, null, async () => Response.json({ data: [] })); + await api("PUT", `/app/providers/${id}`, { name: "OrcaRouter", preset: "orcarouter", api_key: "sk-orca-pasted" }); + const model = await api("PUT", `/app/models/${randomUUID()}`, { + name: "GPT", + model: "openai/gpt-5.5", + provider_id: id, + }); + const cleared = await api("DELETE", `/app/providers/${id}/orcarouter/credential`); + assert.equal(cleared.has_api_key, false); + assert.equal(cleared.name, "OrcaRouter"); + assert.equal(cleared.base_url, "https://api.orcarouter.ai/v1"); + // The provider record survives; the model binding still points at it. + assert.equal(store.get("models", model.id).provider_id, id); + store.close(); +}); diff --git a/services/web/orcarouter.go b/services/web/orcarouter.go new file mode 100644 index 000000000..48d56c82b --- /dev/null +++ b/services/web/orcarouter.go @@ -0,0 +1,412 @@ +package main + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +// OrcaRouter is a named model provider for the console. Its inference base and +// its authentication origin are different public origins and the console never +// derives one from the other. The console server owns both URLs so the browser +// sends only same-origin requests, and it performs catalog discovery so the +// operator's key never leaves the form it was typed into. +const ( + defaultOrcaAuthOrigin = "https://www.orcarouter.ai" + defaultOrcaAPIOrigin = "https://api.orcarouter.ai" + // The catalog is bounded so an unexpected response cannot consume unbounded + // memory: 512 KiB and 2000 records are far above the live catalog's size. + orcaCatalogByteLimit = 512 << 10 + orcaCatalogItemLimit = 2000 + orcaCatalogTimeout = 15 * time.Second + orcaAuthorizationURL = defaultOrcaAuthOrigin + "/auth" + orcaKeyConsoleURL = defaultOrcaAuthOrigin + "/console/authorized-apps" +) + +// orcaRouter holds the origins this deployment talks to. `ORCA_BASE_URL` is the +// shared self-hosted fallback; the explicit overrides win over it. +type orcaRouter struct { + authOrigin string + apiOrigin string + transport *http.Transport +} + +func loadOrcaRouter() (orcaRouter, error) { + shared := strings.TrimRight(strings.TrimSpace(os.Getenv("ORCA_BASE_URL")), "/") + auth := strings.TrimRight(strings.TrimSpace(os.Getenv("ORCA_AUTH_BASE_URL")), "/") + api := strings.TrimRight(strings.TrimSpace(os.Getenv("ORCA_API_BASE_URL")), "/") + if auth == "" { + auth = shared + } + if api == "" { + api = shared + } + if auth == "" { + auth = defaultOrcaAuthOrigin + } + if api == "" { + api = defaultOrcaAPIOrigin + } + for _, origin := range []string{auth, api} { + if !allowedOrcaOrigin(origin) { + return orcaRouter{}, errors.New("OrcaRouter origins require HTTPS, or a loopback HTTP origin") + } + } + // Credentials and catalog requests reach OrcaRouter directly, never an + // ambient HTTP proxy. + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + return orcaRouter{authOrigin: auth, apiOrigin: api, transport: transport}, nil +} + +// allowedOrcaOrigin admits an HTTPS origin and the loopback HTTP origin a +// self-hosted deployment runs on. Anything else is refused before a request. +func allowedOrcaOrigin(origin string) bool { + u, err := url.Parse(origin) + if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || + u.Path != "" || strings.ContainsAny(origin, "\\\x00\r\n") { + return false + } + if u.Scheme == "https" { + return true + } + return u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" || u.Hostname() == "::1") +} + +// catalogURL joins the configured API origin with the fixed `/v1/models` path. +// A self-hosted value that already ends in `/v1` is not doubled. +func (o orcaRouter) catalogURL(capability string) *url.URL { + base := strings.TrimRight(o.apiOrigin, "/") + if !strings.HasSuffix(base, "/v1") { + base += "/v1" + } + target, err := url.Parse(base + "/models") + if err != nil { + return nil + } + if capability != "" { + target.RawQuery = url.Values{"capability": []string{capability}}.Encode() + } + return target +} + +// inferenceBase is the address the console writes to Core for this provider: the +// API origin plus its `/v1` segment, so an operator never types one. +func (o orcaRouter) inferenceBase() string { + base := strings.TrimRight(o.apiOrigin, "/") + if strings.HasSuffix(base, "/v1") { + return base + } + return base + "/v1" +} + +// exchangeURL is the OrcaRouter code exchange. It lives on the auth origin under +// `/api/v1/auth/keys`; `/v1/auth/keys` on the inference origin is a 404. +func (o orcaRouter) exchangeURL() string { + return strings.TrimRight(o.authOrigin, "/") + "/api/v1/auth/keys" +} + +// catalogModel is the minimal model metadata the browser receives. Pricing, +// descriptions and provider internals stay on the server. +type catalogModel struct { + ID string `json:"id"` + Name string `json:"name"` + ContextLength *int64 `json:"context_length,omitempty"` + MaxCompletion *int64 `json:"max_completion_tokens,omitempty"` + InputModalities []string `json:"input_modalities,omitempty"` + EndpointTypes []string `json:"supported_endpoint_types,omitempty"` + ModalitiesDeclared bool `json:"modalities_declared"` +} + +type orcaCatalogResponse struct { + Models []catalogModel `json:"models"` + // Origin is the API origin this deployment reads, so the console can name the + // catalog it is showing without guessing at an override. + Origin string `json:"catalog_origin"` + // Degraded is true when OrcaRouter could not be read and the caller must use + // its own verified fallback catalog. + Degraded bool `json:"degraded"` + Reason string `json:"reason,omitempty"` +} + +func writeConsoleJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +// serveOrcaRouter answers the console's OrcaRouter requests. The caller has +// already signed in and passed the same-origin checks. +func (h *console) serveOrcaRouter(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/console/orcarouter/config" && r.Method == http.MethodGet: + h.serveOrcaConfiguration(w, r) + case r.URL.Path == "/console/orcarouter/catalog" && r.Method == http.MethodGet: + h.serveOrcaCatalog(w, r) + case r.URL.Path == "/console/orcarouter/exchange" && r.Method == http.MethodPost: + h.serveOrcaExchange(w, r) + default: + http.NotFound(w, r) + } +} + +// serveOrcaConfiguration names the origins this deployment talks to. The browser +// composes the authorize URL and the inference base from them instead of +// hardcoding the public ones, so an override deployment stays correct. Neither +// value is secret and no catalog or credential request happens here. +func (h *console) serveOrcaConfiguration(w http.ResponseWriter, _ *http.Request) { + authOrigin := strings.TrimRight(h.orca.authOrigin, "/") + writeConsoleJSON(w, http.StatusOK, map[string]string{ + "object": "console.orcarouter", + "auth_origin": authOrigin, + "api_origin": strings.TrimRight(h.orca.apiOrigin, "/"), + "authorize_url": authOrigin + "/auth", + "inference_base": h.orca.inferenceBase(), + "key_console": orcaKeyConsoleURL, + }) +} + +// serveOrcaCatalog reads the configured catalog with the operator's own key. +// The key arrives in a request header, is never placed in a URL, never logged +// and never returned; only reduced model metadata reaches the browser. +func (h *console) serveOrcaCatalog(w http.ResponseWriter, r *http.Request) { + capability := r.URL.Query().Get("capability") + switch capability { + case "", "chat", "embedding", "image", "video", "rerank": + default: + writeConsoleJSON(w, http.StatusBadRequest, map[string]string{"error": "Unknown catalog capability"}) + return + } + key := strings.TrimSpace(r.Header.Get("X-OrcaRouter-Key")) + if key == "" || len(key) > 4096 || strings.ContainsAny(key, "\x00\r\n") { + writeConsoleJSON(w, http.StatusBadRequest, map[string]string{"error": "An OrcaRouter API key is required to read the model catalog"}) + return + } + target := h.orca.catalogURL(capability) + if target == nil { + writeConsoleJSON(w, http.StatusBadGateway, orcaCatalogResponse{Degraded: true, Reason: "catalog_request_invalid"}) + return + } + request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil) + if err != nil { + writeConsoleJSON(w, http.StatusBadGateway, orcaCatalogResponse{Degraded: true, Reason: "catalog_request_invalid"}) + return + } + request.Header.Set("Accept", "application/json") + request.Header.Set("Authorization", "Bearer "+key) + client := &http.Client{Timeout: orcaCatalogTimeout, Transport: h.orca.transport, CheckRedirect: refuseRedirect} + response, err := client.Do(request) + if err != nil { + writeConsoleJSON(w, http.StatusBadGateway, orcaCatalogResponse{Degraded: true, Reason: "catalog_unreachable"}) + return + } + defer response.Body.Close() + if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { + writeConsoleJSON(w, http.StatusUnauthorized, map[string]string{"error": "OrcaRouter rejected this API key"}) + return + } + if response.StatusCode != http.StatusOK { + writeConsoleJSON(w, http.StatusBadGateway, orcaCatalogResponse{Degraded: true, Reason: "catalog_status"}) + return + } + body, err := readBounded(response.Body, orcaCatalogByteLimit) + if err != nil { + writeConsoleJSON(w, http.StatusBadGateway, orcaCatalogResponse{Degraded: true, Reason: "catalog_too_large"}) + return + } + var payload struct { + Data []json.RawMessage `json:"data"` + } + if json.Unmarshal(body, &payload) != nil || payload.Data == nil { + writeConsoleJSON(w, http.StatusBadGateway, orcaCatalogResponse{Degraded: true, Reason: "catalog_shape"}) + return + } + models := make([]catalogModel, 0, min(len(payload.Data), orcaCatalogItemLimit)) + for _, raw := range payload.Data { + if len(models) >= orcaCatalogItemLimit { + break + } + if model, ok := reduceCatalogModel(raw); ok { + models = append(models, model) + } + } + writeConsoleJSON(w, http.StatusOK, orcaCatalogResponse{Models: models, Origin: strings.TrimRight(h.orca.apiOrigin, "/")}) +} + +// serveOrcaExchange redeems a PKCE authorization code. The verifier is supplied +// by the requesting browser and never stored; only the issued key is returned, +// once, inside the response body. +func (h *console) serveOrcaExchange(w http.ResponseWriter, r *http.Request) { + if contentType := r.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "application/json") { + writeConsoleJSON(w, http.StatusUnsupportedMediaType, map[string]string{"error": "Use application/json"}) + return + } + var input struct { + Code string `json:"code"` + CodeVerifier string `json:"code_verifier"` + CodeChallengeMethod string `json:"code_challenge_method"` + } + body, err := readBounded(r.Body, 8192) + if err != nil || json.Unmarshal(body, &input) != nil { + writeConsoleJSON(w, http.StatusBadRequest, map[string]string{"error": "Send a JSON object with code, code_verifier and code_challenge_method"}) + return + } + // S256 is mandatory: the consent screen can hand the code to a person, and a + // `plain` challenge would travel on the authorize URL with the verifier. + if input.CodeChallengeMethod != "S256" || input.Code == "" || input.CodeVerifier == "" || + len(input.Code) > 4096 || len(input.CodeVerifier) > 512 || + strings.ContainsAny(input.Code+input.CodeVerifier, "\x00\r\n") { + writeConsoleJSON(w, http.StatusBadRequest, map[string]string{"error": "A one-time code and its S256 verifier are required"}) + return + } + payload, err := json.Marshal(map[string]string{ + "code": input.Code, + "code_verifier": input.CodeVerifier, + "code_challenge_method": "S256", + }) + if err != nil { + writeConsoleJSON(w, http.StatusBadGateway, map[string]string{"error": "OrcaRouter authorization failed"}) + return + } + request, err := http.NewRequestWithContext(r.Context(), http.MethodPost, h.orca.exchangeURL(), strings.NewReader(string(payload))) + if err != nil { + writeConsoleJSON(w, http.StatusBadGateway, map[string]string{"error": "OrcaRouter authorization failed"}) + return + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + client := &http.Client{Timeout: orcaCatalogTimeout, Transport: h.orca.transport, CheckRedirect: refuseRedirect} + response, err := client.Do(request) + if err != nil { + writeConsoleJSON(w, http.StatusBadGateway, map[string]string{"error": "OrcaRouter authorization could not be reached; try again"}) + return + } + defer response.Body.Close() + answer, err := readBounded(response.Body, 8192) + if err != nil { + writeConsoleJSON(w, http.StatusBadGateway, map[string]string{"error": "OrcaRouter authorization failed"}) + return + } + // The exchange reports errors as a plain OAuth envelope, which is never + // forwarded: an upstream body could carry a credential. + if response.StatusCode != http.StatusOK { + writeConsoleJSON(w, http.StatusBadRequest, map[string]string{"error": orcaExchangeMessage(response.StatusCode)}) + return + } + var granted struct { + Key string `json:"key"` + Scope string `json:"scope"` + } + if json.Unmarshal(answer, &granted) != nil || granted.Key == "" { + writeConsoleJSON(w, http.StatusBadGateway, map[string]string{"error": "OrcaRouter returned no key; authorize again"}) + return + } + // Read the granted scope back: it is what was approved, not what was asked for. + if granted.Scope != "" && granted.Scope != "api" { + writeConsoleJSON(w, http.StatusBadRequest, map[string]string{"error": "The approved OrcaRouter scope does not permit this application"}) + return + } + writeConsoleJSON(w, http.StatusOK, map[string]string{"key": granted.Key, "scope": "api", "provider": "orcarouter"}) +} + +func orcaExchangeMessage(status int) string { + switch status { + case http.StatusBadRequest: + return "The OrcaRouter code was rejected; start the connection again" + case http.StatusForbidden: + return "The OrcaRouter code is unknown, expired or already used; start the connection again" + case http.StatusTooManyRequests: + return "OrcaRouter refused the request; wait a moment and try again" + default: + return "OrcaRouter authorization failed" + } +} + +func readBounded(reader io.Reader, limit int64) ([]byte, error) { + body, err := io.ReadAll(io.LimitReader(reader, limit+1)) + if err != nil { + return nil, err + } + if int64(len(body)) > limit { + return nil, errors.New("response too large") + } + return body, nil +} + +// refuseRedirect keeps a credential-bearing request on the origin it was +// addressed to. +func refuseRedirect(*http.Request, []*http.Request) error { + return errors.New("OrcaRouter redirects are not followed") +} + +// reduceCatalogModel keeps the fields a model selector filters on and drops +// everything else. A record without a usable ID is refused rather than guessed. +func reduceCatalogModel(raw json.RawMessage) (catalogModel, bool) { + var record struct { + ID string `json:"id"` + Name string `json:"name"` + ContextLength *int64 `json:"context_length"` + MaxCompletionTokens *int64 `json:"max_completion_tokens"` + SupportedEndpointType []string `json:"supported_endpoint_types"` + Architecture json.RawMessage `json:"architecture"` + } + if json.Unmarshal(raw, &record) != nil { + return catalogModel{}, false + } + id := strings.TrimSpace(record.ID) + if id == "" || len(id) > 200 || strings.ContainsAny(id, "\x00\r\n") { + return catalogModel{}, false + } + name := strings.TrimSpace(record.Name) + if name == "" { + name = id + } + if len(name) > 200 { + name = name[:200] + } + model := catalogModel{ID: id, Name: name, EndpointTypes: sanitizeStrings(record.SupportedEndpointType, 32, 64)} + model.ContextLength = positiveInt64(record.ContextLength) + model.MaxCompletion = positiveInt64(record.MaxCompletionTokens) + var architecture struct { + InputModalities []string `json:"input_modalities"` + } + if json.Unmarshal(record.Architecture, &architecture) == nil { + model.InputModalities = sanitizeStrings(architecture.InputModalities, 16, 32) + model.ModalitiesDeclared = architecture.InputModalities != nil + } + return model, true +} + +func sanitizeStrings(values []string, maxItems, maxLength int) []string { + if values == nil { + return nil + } + out := make([]string, 0, min(len(values), maxItems)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" || len(trimmed) > maxLength { + continue + } + if len(out) >= maxItems { + break + } + out = append(out, trimmed) + } + if len(out) == 0 { + return nil + } + return out +} + +func positiveInt64(value *int64) *int64 { + if value == nil || *value <= 0 { + return nil + } + return value +} diff --git a/services/web/orcarouter_live_test.go b/services/web/orcarouter_live_test.go new file mode 100644 index 000000000..cb88a98b6 --- /dev/null +++ b/services/web/orcarouter_live_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +// TestOrcaCatalogLiveR... reads the real OrcaRouter catalog through the console's +// own provider path. It runs only when ORCAROUTER_API_KEY is present, so the +// regular suite stays offline and no key is ever required to build or test. +func TestOrcaCatalogLiveReadsTheRealGateway(t *testing.T) { + key := strings.TrimSpace(os.Getenv("ORCAROUTER_API_KEY")) + if key == "" { + t.Skip("ORCAROUTER_API_KEY is not set") + } + if !strings.HasPrefix(key, "sk-orca-") { + t.Fatalf("ORCAROUTER_API_KEY is not an OrcaRouter key") + } + router, err := loadOrcaRouter() + if err != nil { + t.Fatal(err) + } + if router.apiOrigin != defaultOrcaAPIOrigin || router.authOrigin != defaultOrcaAuthOrigin { + t.Fatalf("live read must use the public origins, got %q %q", router.authOrigin, router.apiOrigin) + } + h := &console{orca: router} + recorder := serveOrca(h, "GET", "/console/orcarouter/catalog?capability=chat", nil, + map[string]string{"X-OrcaRouter-Key": key}) + if recorder.Code != 200 { + t.Fatalf("live catalog status = %d body = %s", recorder.Code, recorder.Body.String()) + } + var response orcaCatalogResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.Degraded || len(response.Models) == 0 { + t.Fatalf("live catalog = %#v", response) + } + if response.Origin != defaultOrcaAPIOrigin { + t.Fatalf("live catalog origin = %q", response.Origin) + } + if strings.Contains(recorder.Body.String(), key) { + t.Fatal("the key was echoed back") + } + // Every record that reaches the browser carries a text protocol and enough + // metadata for the chat entrance to filter on. + text, imageOnly := 0, 0 + for _, model := range response.Models { + if len(model.EndpointTypes) == 0 { + t.Fatalf("%s reached the browser with no endpoint types", model.ID) + } + if chatModel(model.EndpointTypes) { + text++ + continue + } + for _, endpoint := range model.EndpointTypes { + if endpoint == "image-generation" || endpoint == "openai-video" || endpoint == "jina-rerank" { + imageOnly++ + } + } + } + if text == 0 { + t.Fatal("the live catalog carried no text model") + } + t.Logf("live OrcaRouter catalog: %d records, %d text", len(response.Models), text) + if imageOnly != 0 { + t.Errorf("%d non-text models were offered to a chat entrance", imageOnly) + } +} + +// chatModel reports whether the model speaks a text protocol. The console's +// selector applies the same rule in the browser. +func chatModel(endpoints []string) bool { + for _, endpoint := range endpoints { + switch endpoint { + case "openai", "anthropic", "gemini", "openai-response": + return true + } + } + return false +} diff --git a/services/web/orcarouter_test.go b/services/web/orcarouter_test.go new file mode 100644 index 000000000..b1e294a28 --- /dev/null +++ b/services/web/orcarouter_test.go @@ -0,0 +1,326 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// orcaConsole is a console with the OrcaRouter origins pointed at test servers. +// The catalog and the exchange accept the fake key and code used by the tests; +// no test value is a real credential. +func orcaConsole(t *testing.T, catalog, exchange http.HandlerFunc) *console { + t.Helper() + api := httptest.NewServer(catalog) + auth := httptest.NewServer(exchange) + t.Cleanup(api.Close) + t.Cleanup(auth.Close) + return &console{orca: orcaRouter{authOrigin: auth.URL, apiOrigin: api.URL, transport: http.DefaultTransport.(*http.Transport).Clone()}} +} + +func serveOrca(h *console, method, path string, body io.Reader, headers map[string]string) *httptest.ResponseRecorder { + request := httptest.NewRequest(method, path, body) + for name, value := range headers { + request.Header.Set(name, value) + } + recorder := httptest.NewRecorder() + h.serveOrcaRouter(recorder, request) + return recorder +} + +func TestOrcaCatalogUsesTheAPIOriginAndReducesRecords(t *testing.T) { + var seen string + h := orcaConsole(t, func(w http.ResponseWriter, r *http.Request) { + seen = r.URL.Path + "?" + r.URL.RawQuery + if got := r.Header.Get("Authorization"); got != "Bearer sk-orca-fixture" { + t.Errorf("Authorization = %q", got) + } + if r.Header.Get("Accept") != "application/json" { + t.Errorf("Accept = %q", r.Header.Get("Accept")) + } + writeJSON(t, w, map[string]any{"data": []any{ + map[string]any{"id": "openai/gpt-5.5", "name": "OpenAI: GPT-5.5", "context_length": 272000, + "max_completion_tokens": 128000, "supported_endpoint_types": []string{"openai", "openai-response"}, + "architecture": map[string]any{"input_modalities": []string{"text", "image"}}, + "pricing": map[string]any{"prompt": "0.000005"}}, + map[string]any{"id": "bad/" + strings.Repeat("x", 210), "context_length": -1}, + map[string]any{"name": "no id"}, + map[string]any{"id": " "}, + }}) + }, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("catalog read must not call the auth origin") + }) + recorder := serveOrca(h, http.MethodGet, "/console/orcarouter/catalog?capability=chat", nil, map[string]string{"X-OrcaRouter-Key": "sk-orca-fixture"}) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String()) + } + if seen != "/v1/models?capability=chat" { + t.Errorf("catalog path = %q", seen) + } + var response orcaCatalogResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.Degraded || len(response.Models) != 1 { + t.Fatalf("models = %#v", response) + } + model := response.Models[0] + if model.ID != "openai/gpt-5.5" || model.Name != "OpenAI: GPT-5.5" || model.ContextLength == nil || *model.ContextLength != 272000 { + t.Fatalf("model = %#v", model) + } + if !model.ModalitiesDeclared || len(model.InputModalities) != 2 { + t.Fatalf("modalities = %#v", model) + } + if strings.Contains(recorder.Body.String(), "pricing") || strings.Contains(recorder.Body.String(), "0.000005") { + t.Fatalf("catalog leaked provider internals: %s", recorder.Body.String()) + } +} + +func TestOrcaCatalogRefusesBadInputAndReportsOutages(t *testing.T) { + h := orcaConsole(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + }, func(w http.ResponseWriter, r *http.Request) {}) + for name, test := range map[string]struct { + capability string + key string + status int + }{ + "unknown capability": {"audio", "sk-orca-fixture", http.StatusBadRequest}, + "missing key": {"chat", "", http.StatusBadRequest}, + "header injection": {"chat", "sk-orca\r\nfixture", http.StatusBadRequest}, + } { + t.Run(name, func(t *testing.T) { + recorder := serveOrca(h, http.MethodGet, "/console/orcarouter/catalog?capability="+test.capability, nil, + map[string]string{"X-OrcaRouter-Key": test.key}) + if recorder.Code != test.status { + t.Fatalf("status = %d", recorder.Code) + } + }) + } + recorder := serveOrca(h, http.MethodGet, "/console/orcarouter/catalog?capability=chat", nil, map[string]string{"X-OrcaRouter-Key": "sk-orca-fixture"}) + if recorder.Code != http.StatusBadGateway || !strings.Contains(recorder.Body.String(), "degraded") { + t.Fatalf("outage = %d %s", recorder.Code, recorder.Body.String()) + } +} + +func TestOrcaCatalogSeparatesARejectedKeyFromAnOutage(t *testing.T) { + h := orcaConsole(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":{"message":"no"}}`, http.StatusUnauthorized) + }, func(http.ResponseWriter, *http.Request) {}) + recorder := serveOrca(h, http.MethodGet, "/console/orcarouter/catalog?capability=chat", nil, map[string]string{"X-OrcaRouter-Key": "sk-orca-revoked"}) + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("status = %d", recorder.Code) + } + if strings.Contains(recorder.Body.String(), "no") && strings.Contains(recorder.Body.String(), "error\":") { + t.Fatalf("upstream body forwarded: %s", recorder.Body.String()) + } +} + +func TestOrcaExchangePostsToTheAuthOriginAndRequiresS256(t *testing.T) { + var seenPath, seenBody, seenOrigin string + h := orcaConsole(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("exchange must not call the API origin") + }, func(w http.ResponseWriter, r *http.Request) { + seenPath = r.URL.Path + seenOrigin = r.Header.Get("Origin") + raw, _ := io.ReadAll(r.Body) + seenBody = string(raw) + writeJSON(t, w, map[string]any{"key": "sk-orca-issued", "user_id": "1", "scope": "api"}) + }) + recorder := serveOrca(h, http.MethodPost, "/console/orcarouter/exchange", + strings.NewReader(`{"code":"one-time","code_verifier":"verifier-value","code_challenge_method":"S256"}`), + map[string]string{"Content-Type": "application/json"}) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String()) + } + if seenPath != "/api/v1/auth/keys" { + t.Fatalf("exchange path = %q", seenPath) + } + if seenOrigin != "" { + t.Fatalf("exchange sent Origin %q", seenOrigin) + } + for _, want := range []string{"one-time", "verifier-value", `"code_challenge_method":"S256"`} { + if !strings.Contains(seenBody, want) { + t.Fatalf("exchange body %q missing %q", seenBody, want) + } + } + var granted map[string]string + if err := json.Unmarshal(recorder.Body.Bytes(), &granted); err != nil { + t.Fatal(err) + } + if granted["key"] != "sk-orca-issued" || granted["scope"] != "api" { + t.Fatalf("grant = %#v", granted) + } + + for name, body := range map[string]string{ + "plain challenge": `{"code":"c","code_verifier":"v","code_challenge_method":"plain"}`, + "missing verifier": `{"code":"c","code_challenge_method":"S256"}`, + "not json": `not-json`, + } { + t.Run(name, func(t *testing.T) { + recorder := serveOrca(h, http.MethodPost, "/console/orcarouter/exchange", strings.NewReader(body), + map[string]string{"Content-Type": "application/json"}) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String()) + } + }) + } + recorder = serveOrca(h, http.MethodPost, "/console/orcarouter/exchange", + strings.NewReader(`{"code":"c","code_verifier":"v","code_challenge_method":"S256"}`), + map[string]string{"Content-Type": "text/plain"}) + if recorder.Code != http.StatusUnsupportedMediaType { + t.Fatalf("status = %d", recorder.Code) + } +} + +func TestOrcaExchangeReportsUpstreamFailuresWithoutTheBody(t *testing.T) { + for name, test := range map[string]struct { + status int + body string + want int + }{ + "expired or reused code": {http.StatusForbidden, `{"error":"invalid_grant","error_description":"verifier sk-orca-secret"}`, http.StatusBadRequest}, + "downgraded method": {http.StatusBadRequest, `{"error":"invalid_request"}`, http.StatusBadRequest}, + "rate limited": {http.StatusTooManyRequests, `{"error":"slow_down"}`, http.StatusBadRequest}, + "unexpected server error": {http.StatusInternalServerError, `{"error":"boom"}`, http.StatusBadRequest}, + } { + t.Run(name, func(t *testing.T) { + h := orcaConsole(t, func(http.ResponseWriter, *http.Request) {}, + func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(test.status) + _, _ = io.WriteString(w, test.body) + }) + recorder := serveOrca(h, http.MethodPost, "/console/orcarouter/exchange", + strings.NewReader(`{"code":"c","code_verifier":"v","code_challenge_method":"S256"}`), + map[string]string{"Content-Type": "application/json"}) + if recorder.Code != test.want { + t.Fatalf("status = %d", recorder.Code) + } + if strings.Contains(recorder.Body.String(), "sk-orca-secret") || strings.Contains(recorder.Body.String(), "invalid_grant") { + t.Fatalf("upstream body forwarded: %s", recorder.Body.String()) + } + }) + } +} + +func TestOrcaExchangeRejectsAScopeItWasNotGranted(t *testing.T) { + h := orcaConsole(t, func(http.ResponseWriter, *http.Request) {}, + func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, map[string]any{"key": "sk-orca-issued", "scope": "connector"}) + }) + recorder := serveOrca(h, http.MethodPost, "/console/orcarouter/exchange", + strings.NewReader(`{"code":"c","code_verifier":"v","code_challenge_method":"S256"}`), + map[string]string{"Content-Type": "application/json"}) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String()) + } +} + +func TestOrcaRouterOriginsUseOverridesAndRequireHTTPSOffLoopback(t *testing.T) { + for name, test := range map[string]struct { + env map[string]string + auth string + api string + ok bool + }{ + "defaults": {map[string]string{}, defaultOrcaAuthOrigin, defaultOrcaAPIOrigin, true}, + "shared fallback": {map[string]string{"ORCA_BASE_URL": "https://orca.internal"}, "https://orca.internal", "https://orca.internal", true}, + "explicit override": {map[string]string{"ORCA_BASE_URL": "https://orca.internal", "ORCA_AUTH_BASE_URL": "https://login.internal", "ORCA_API_BASE_URL": "https://relay.internal/"}, "https://login.internal", "https://relay.internal", true}, + "loopback http": {map[string]string{"ORCA_BASE_URL": "http://127.0.0.1:8099"}, "http://127.0.0.1:8099", "http://127.0.0.1:8099", true}, + "remote http": {map[string]string{"ORCA_BASE_URL": "http://orca.internal"}, "", "", false}, + "path rejected": {map[string]string{"ORCA_AUTH_BASE_URL": "https://orca.internal/auth"}, "", "", false}, + "credentials": {map[string]string{"ORCA_API_BASE_URL": "https://user:pass@orca.internal"}, "", "", false}, + } { + t.Run(name, func(t *testing.T) { + for key, value := range test.env { + t.Setenv(key, value) + } + router, err := loadOrcaRouter() + if test.ok != (err == nil) { + t.Fatalf("err = %v", err) + } + if !test.ok { + return + } + if router.authOrigin != test.auth || router.apiOrigin != test.api { + t.Fatalf("origins = %q %q", router.authOrigin, router.apiOrigin) + } + if got := router.exchangeURL(); got != test.auth+"/api/v1/auth/keys" { + t.Fatalf("exchange = %q", got) + } + }) + } +} + +func TestOrcaRouterCatalogURLKeepsTheVersionSegment(t *testing.T) { + for _, test := range []struct{ origin, capability, want string }{ + {defaultOrcaAPIOrigin, "chat", defaultOrcaAPIOrigin + "/v1/models?capability=chat"}, + {"https://relay.internal", "", "https://relay.internal/v1/models"}, + {"https://relay.internal/v1", "embedding", "https://relay.internal/v1/models?capability=embedding"}, + } { + router := orcaRouter{apiOrigin: test.origin} + if got := router.catalogURL(test.capability); got == nil || got.String() != test.want { + t.Fatalf("catalogURL(%q, %q) = %v", test.origin, test.capability, got) + } + } + // The exchange path is the auth origin's `/api/v1/auth/keys`, never the + // inference origin's `/v1/auth/keys`, which is a 404. + for _, origin := range []string{defaultOrcaAuthOrigin, "https://login.internal"} { + if got := (orcaRouter{authOrigin: origin}).exchangeURL(); got != origin+"/api/v1/auth/keys" { + t.Fatalf("exchangeURL(%q) = %q", origin, got) + } + } +} + +func TestOrcaCatalogNamesItsOriginAndKeepsTheKeyOutOfTheURL(t *testing.T) { + var seen string + h := orcaConsole(t, func(w http.ResponseWriter, r *http.Request) { + seen = r.URL.String() + writeJSON(t, w, map[string]any{"data": []any{}}) + }, func(http.ResponseWriter, *http.Request) {}) + recorder := serveOrca(h, http.MethodGet, "/console/orcarouter/catalog?capability=chat", nil, map[string]string{"X-OrcaRouter-Key": "sk-orca-fixture"}) + var response orcaCatalogResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.Origin != h.orca.apiOrigin { + t.Fatalf("catalog origin = %q", response.Origin) + } + if strings.Contains(seen, "sk-orca-fixture") || strings.Contains(recorder.Body.String(), "sk-orca-fixture") { + t.Fatalf("the key reached a URL or the response: %q %s", seen, recorder.Body.String()) + } +} + +func TestOrcaConfigurationNamesTheDeploymentOrigins(t *testing.T) { + h := orcaConsole(t, func(http.ResponseWriter, *http.Request) { t.Error("config must not read the API origin") }, + func(http.ResponseWriter, *http.Request) { t.Error("config must not read the auth origin") }) + recorder := serveOrca(h, http.MethodGet, "/console/orcarouter/config", nil, nil) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d", recorder.Code) + } + var response map[string]string + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response["auth_origin"] != h.orca.authOrigin || response["api_origin"] != h.orca.apiOrigin { + t.Fatalf("origins = %#v", response) + } + // The authorize URL belongs to the auth origin and is the fixed `/auth` path. + if response["authorize_url"] != h.orca.authOrigin+"/auth" { + t.Fatalf("authorize_url = %q", response["authorize_url"]) + } + if response["authorize_url"] == response["api_origin"]+"/auth" { + t.Fatal("the authorize URL must not be derived from the inference origin") + } +} + +func writeJSON(t *testing.T, w http.ResponseWriter, value any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(value); err != nil { + t.Errorf("encode: %v", err) + } +} diff --git a/services/web/server.go b/services/web/server.go index 01d43dd7c..eef6fe2a0 100644 --- a/services/web/server.go +++ b/services/web/server.go @@ -22,6 +22,7 @@ type console struct { transport *http.Transport host string auth *consoleAuth + orca orcaRouter } type consoleActorContextKey struct{} @@ -41,6 +42,12 @@ func newConsole(c config) (*console, error) { } origin, _ := url.Parse(c.origin) h := &console{config: c, root: root, host: origin.Host, auth: newConsoleAuth(c)} + orca, err := loadOrcaRouter() + if err != nil { + root.Close() + return nil, err + } + h.orca = orca if c.nodePayloadDir != "" { h.nodePayload, err = os.OpenRoot(c.nodePayloadDir) if err != nil { @@ -180,6 +187,13 @@ func (h *console) ServeHTTP(w http.ResponseWriter, r *http.Request) { } return } + if r.URL.Path == "/console/orcarouter" || strings.HasPrefix(r.URL.Path, "/console/orcarouter/") { + // Signed in and same-origin; the console server holds the OrcaRouter + // origins and performs the credential-bearing request itself. The API key + // arrives in a request header and is never placed in a URL. + h.serveOrcaRouter(w, r) + return + } if r.URL.Path == "/console/config" && r.Method == http.MethodGet { h.serveConsoleConfiguration(w, r) return