From b57e5f8c4e750cccbb35ef34a9cc18760da42689 Mon Sep 17 00:00:00 2001 From: clementguarino06510-glitch Date: Tue, 15 Sep 2026 12:50:35 +0000 Subject: [PATCH] Add OrcaRouter as a first-class Provider with API-key and OAuth 2.0 + PKCE login Signed-off-by: clementguarino06510-glitch --- docs/guide/providers.md | 26 +- docs/guide/runtime.md | 16 + docs/zh/guide/providers.md | 13 +- package.json | 2 + packages/cli/package.json | 3 +- packages/cli/src/commands/environment.ts | 1 + packages/cli/src/oauth.ts | 105 +++-- packages/cli/test/oauth.test.ts | 51 +++ packages/orcarouter/README.md | 10 + packages/orcarouter/package.json | 21 + packages/orcarouter/src/activation.ts | 19 + packages/orcarouter/src/component.ts | 44 ++ packages/orcarouter/src/fragment.ts | 23 + packages/orcarouter/src/index.ts | 19 + packages/orcarouter/src/manifest.ts | 81 ++++ packages/orcarouter/src/program.ts | 57 +++ packages/orcarouter/src/surface.ts | 88 ++++ packages/orcarouter/src/types.ts | 15 + packages/provider-orcarouter/README.md | 66 +++ packages/provider-orcarouter/package.json | 20 + .../provider-orcarouter/src/activation.ts | 55 +++ packages/provider-orcarouter/src/catalog.ts | 257 +++++++++++ packages/provider-orcarouter/src/client.ts | 113 +++++ .../provider-orcarouter/src/credentials.ts | 250 +++++++++++ packages/provider-orcarouter/src/index.ts | 47 ++ packages/provider-orcarouter/src/provider.ts | 286 +++++++++++++ .../provider-orcarouter/test/catalog.test.ts | 84 ++++ .../provider-orcarouter/test/connect.test.ts | 224 ++++++++++ .../test/credentials.test.ts | 117 +++++ .../provider-orcarouter/test/live.test.ts | 117 +++++ .../provider-orcarouter/test/provider.test.ts | 229 ++++++++++ packages/runtime-local/src/config.ts | 44 ++ packages/runtime-local/src/index.ts | 1 + packages/runtime/src/credentials.ts | 34 ++ packages/studio/locales/en.json | 28 ++ packages/studio/locales/zh-CN.json | 28 ++ packages/studio/package.json | 4 +- packages/studio/src/accounts.ts | 338 +++++++++++++++ packages/studio/src/connect.ts | 98 +++++ packages/studio/src/server.ts | 78 ++++ packages/studio/src/style.css | 57 +++ packages/studio/src/ui/accounts.ts | 400 ++++++++++++++++++ packages/studio/src/ui/connect-lifecycle.ts | 48 +++ packages/studio/src/ui/main.ts | 18 + packages/studio/start.ts | 17 + packages/studio/test/accounts.test.ts | 201 +++++++++ .../studio/test/connect-lifecycle.test.ts | 54 +++ packages/studio/test/orca-evidence.py | 385 +++++++++++++++++ packages/video-cli/package.json | 1 + packages/video-cli/src/distribution.ts | 8 + pnpm-lock.yaml | 61 +++ 51 files changed, 4327 insertions(+), 35 deletions(-) create mode 100644 packages/orcarouter/README.md create mode 100644 packages/orcarouter/package.json create mode 100644 packages/orcarouter/src/activation.ts create mode 100644 packages/orcarouter/src/component.ts create mode 100644 packages/orcarouter/src/fragment.ts create mode 100644 packages/orcarouter/src/index.ts create mode 100644 packages/orcarouter/src/manifest.ts create mode 100644 packages/orcarouter/src/program.ts create mode 100644 packages/orcarouter/src/surface.ts create mode 100644 packages/orcarouter/src/types.ts create mode 100644 packages/provider-orcarouter/README.md create mode 100644 packages/provider-orcarouter/package.json create mode 100644 packages/provider-orcarouter/src/activation.ts create mode 100644 packages/provider-orcarouter/src/catalog.ts create mode 100644 packages/provider-orcarouter/src/client.ts create mode 100644 packages/provider-orcarouter/src/credentials.ts create mode 100644 packages/provider-orcarouter/src/index.ts create mode 100644 packages/provider-orcarouter/src/provider.ts create mode 100644 packages/provider-orcarouter/test/catalog.test.ts create mode 100644 packages/provider-orcarouter/test/connect.test.ts create mode 100644 packages/provider-orcarouter/test/credentials.test.ts create mode 100644 packages/provider-orcarouter/test/live.test.ts create mode 100644 packages/provider-orcarouter/test/provider.test.ts create mode 100644 packages/studio/src/accounts.ts create mode 100644 packages/studio/src/connect.ts create mode 100644 packages/studio/src/ui/accounts.ts create mode 100644 packages/studio/src/ui/connect-lifecycle.ts create mode 100644 packages/studio/test/accounts.test.ts create mode 100644 packages/studio/test/connect-lifecycle.test.ts create mode 100644 packages/studio/test/orca-evidence.py diff --git a/docs/guide/providers.md b/docs/guide/providers.md index 6a7fd35a0..eaca4d7d6 100644 --- a/docs/guide/providers.md +++ b/docs/guide/providers.md @@ -8,8 +8,8 @@ A **Provider** knows how to fulfill that request through a particular service. A configured instance of that Provider, with its service address, credential reference and capacity. The Runtime Profile binds the requested capability to an Endpoint. -Hypit's official Distribution includes local Providers and the HypiHub Provider. Other services -connect through packages owned by the production or their authors. The Agent can implement a new +Hypit's official Distribution includes local Providers, the HypiHub Provider and the OrcaRouter +Provider. Other services connect through packages owned by the production or their authors. The Agent can implement a new service through the public SDK, just as it can create a visual component for a video. [Service partners](./service-partners.md) introduces independent partners through that same path. @@ -30,6 +30,28 @@ For an existing installation, inspect the selected Profile and credential status Profile supplies configuration examples; choose the services you want before connecting accounts or preparing their dependencies. [Runs and Builds](../quickstart/run.md) shows the commands. +## OrcaRouter + +[OrcaRouter](https://www.orcarouter.ai) is an OpenAI-compatible AI gateway: one endpoint serves the +models of many vendors, with adaptive routing, failover and gateway-level guardrails. Its Provider is +`@hypit/provider-orcarouter`, and it offers the `chat` capability against +`https://api.orcarouter.ai/v1`. + +The Endpoint declares one credential slot with two explicit entry points. `OrcaRouter - API` takes an +`sk-orca-…` key the user already holds; `OrcaRouter - Auth` runs an OAuth 2.0 + PKCE authorization +that returns a key belonging to the same account. Both store an ordinary API key in the Credential +Store the Runtime Profile selects, and a key obtained either way reaches the relay the same way. + +The model list is read from `GET /v1/models` with the configured key, so the models offered are the +ones that account may call. Nothing is inferred from a model's name: a chat control only offers +entries whose catalogue record declares a chat-capable endpoint type, and attaching images only +offers entries whose record declares image input. When the catalogue cannot be read, the panel keeps +a small verified fallback and says that it is degraded rather than showing an empty list. + +A PKCE-issued key is durable, not a refreshable token: it is reused until the user revokes it at +`https://www.orcarouter.ai/console/authorized-apps`. A rejected key asks for a new authorization +instead of refreshing. + ## Add a Model Develop a project package against `@hypit/hypit/model-kit`, `@hypit/hypit/generation` and `@hypit/hypit/author-kit`. diff --git a/docs/guide/runtime.md b/docs/guide/runtime.md index 6c4c4bbad..e713f5801 100644 --- a/docs/guide/runtime.md +++ b/docs/guide/runtime.md @@ -67,6 +67,22 @@ binding states which instance to use. For example, with an explicitly configured } ``` +The Distribution also ships the OrcaRouter Provider, whose one credential slot accepts either a key +the user pastes or an authorization. Point it at the store the profile selects: + +```json +"endpoints": { + "orcarouter.default": { + "use": "@hypit/provider-orcarouter", + "config": { "apiKey": { "store": "env", "key": "ORCAROUTER_API_KEY" } } + } +} +``` + +`hypit auth login orcarouter.default` runs the authorization when the slot is writable, and +`hypit auth status` reports whether the key on file is usable. See +[Models and Providers](./providers.md#orcarouter). + Installing a package makes it available; selecting it gives it a role in this environment. The Model owns request meaning, and the Provider owns support, service mapping and pricing. A failed service request does not silently select another account. Read the chosen Provider's README for its settings. diff --git a/docs/zh/guide/providers.md b/docs/zh/guide/providers.md index e2276f9ae..fbe4d6b9e 100644 --- a/docs/zh/guide/providers.md +++ b/docs/zh/guide/providers.md @@ -5,7 +5,7 @@ description: 选择账户、连接服务或添加模型,沿用同一套视频 **Model** 定义要生成什么:输入、支持的参数和输出类型。**Provider** 知道如何通过某个服务完成这个请求。**Endpoint** 是配置好的 Provider 实例,包含服务地址、凭据引用和容量。Runtime Profile 将所需能力绑定到 Endpoint。 -官方发行包含本地 Provider 和 HypiHub Provider。其他服务通过项目或作者自己的包接入;Agent 可以使用公开 SDK 编写所需接入,就像为视频创建视觉组件。[服务合作方介绍](../../guide/service-partners.md) 集中介绍独立合作服务,它们沿用同一套扩展方式。 +官方发行包含本地 Provider、HypiHub Provider 和 OrcaRouter Provider。其他服务通过项目或作者自己的包接入;Agent 可以使用公开 SDK 编写所需接入,就像为视频创建视觉组件。[服务合作方介绍](../../guide/service-partners.md) 集中介绍独立合作服务,它们沿用同一套扩展方式。 ## 根据需求选择修改位置 @@ -20,6 +20,17 @@ description: 选择账户、连接服务或添加模型,沿用同一套视频 已有安装时,先检查所选 Profile 和凭据状态。起始 Profile 提供配置示例;连接账户或准备依赖前,先选择想使用的服务。[Run 与 Build](../quickstart/run.md) 介绍相关命令。 +## OrcaRouter + +[OrcaRouter](https://www.orcarouter.ai) 是 OpenAI 兼容的 AI 网关:一个端点提供多家厂商的模型,并带有自适应路由、故障转移和网关级防护。它的 Provider 是 +`@hypit/provider-orcarouter`,通过 `https://api.orcarouter.ai/v1` 提供 `chat` 能力。 + +Endpoint 只声明一个凭据槽,但提供两个入口:`OrcaRouter - API` 填写用户已有的 `sk-orca-…` 密钥;`OrcaRouter - Auth` 运行 OAuth 2.0 + PKCE 授权,返回属于同一账户的密钥。两者都把普通 API 密钥存入 Runtime Profile 选择的 Credential Store,无论密钥来自哪个入口,到达中转服务的方式完全相同。 + +模型列表通过配置的密钥读取 `GET /v1/models`,因此可选模型就是该账户真正可调用的模型。能力不靠模型名推断:文本控件只提供目录中标明支持 chat 端点的条目,附加图片时只提供目录中明确声明图片输入的条目。目录读取失败时,面板保留一份小的已验证备用列表并标明其为降级状态,而不是显示空列表。 + +PKCE 签发的密钥是长期密钥,不是可刷新的令牌:在用户于 `https://www.orcarouter.ai/console/authorized-apps` 撤销之前一直复用。密钥被拒绝时应重新授权,而不是尝试刷新。 + ## 添加 Model 项目包使用 `@hypit/hypit/model-kit`、`@hypit/hypit/generation` 和 `@hypit/hypit/author-kit`。声明准确的请求端口、参数取值、输出类型和能力。作者 Surface 把 Prompt Text 与参考素材连接到请求,再将生成素材作为普通图输出公开。 diff --git a/package.json b/package.json index 1669dde00..c72e89d06 100644 --- a/package.json +++ b/package.json @@ -226,6 +226,7 @@ "@hypit/mimo-speech": "workspace:*", "@hypit/minimax-h3": "workspace:*", "@hypit/nano-banana": "workspace:*", + "@hypit/orcarouter": "workspace:*", "@hypit/narrative": "workspace:*", "@hypit/performance": "workspace:*", "@hypit/performance-studio": "workspace:*", @@ -234,6 +235,7 @@ "@hypit/protocol": "workspace:*", "@hypit/provider-hyperframes-local": "workspace:*", "@hypit/provider-hypihub": "workspace:*", + "@hypit/provider-orcarouter": "workspace:*", "@hypit/provider-image-opencv-local": "workspace:*", "@hypit/provider-media-local": "workspace:*", "@hypit/provider-whisperx-local": "workspace:*", diff --git a/packages/cli/package.json b/packages/cli/package.json index 10b6d058a..4d58de9f1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -5,7 +5,8 @@ "private": true, "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./oauth-host": "./src/oauth.ts" }, "dependencies": { "@hypit/build-result": "workspace:*", diff --git a/packages/cli/src/commands/environment.ts b/packages/cli/src/commands/environment.ts index c723906f8..c4f9f7c8e 100644 --- a/packages/cli/src/commands/environment.ts +++ b/packages/cli/src/commands/environment.ts @@ -390,6 +390,7 @@ export async function runEnvironmentCommand(input: { ...(reportCredentialProgress === undefined ? {} : { onProgress: (message) => reportCredentialProgress(` · ${message}\n`), }), + ...(io.readSecret === undefined ? {} : { readCode: (prompt) => io.readSecret!(prompt) }), }) : args.credentialFile === undefined ? await io.readSecret?.(`${item.label}: `) diff --git a/packages/cli/src/oauth.ts b/packages/cli/src/oauth.ts index 78b199ee4..ff086b42e 100644 --- a/packages/cli/src/oauth.ts +++ b/packages/cli/src/oauth.ts @@ -11,6 +11,8 @@ type OAuthAcquisitionOptions = { readonly onProgress?: (message: string) => void; readonly fetch?: typeof globalThis.fetch; readonly open?: (url: string) => void; + /** Reads one pasted authorization code when the declared delivery has no redirect. */ + readonly readCode?: (prompt: string) => Promise; }; function base64url(bytes: Uint8Array): string { @@ -40,6 +42,7 @@ export async function acquireOAuthCredential( // S256 is part of OAuth PKCE. It authenticates this browser exchange; it is not content identity. const challenge = base64url(createHash("sha256").update(verifier).digest()); const state = base64url(randomBytes(24)); + const outOfBand = acquisition.delivery === "out-of-band"; const server = createServer(); const callback = new Promise((resolveCode, reject) => { let settled = false; @@ -86,18 +89,28 @@ export async function acquireOAuthCredential( }); server.once("error", reject); }); - await new Promise((resolveListen, rejectListen) => { - server.listen(0, "127.0.0.1", () => resolveListen()); - server.once("error", rejectListen); - }); - const address = server.address(); - if (address === null || typeof address === "string") throw new Error("could not open a local OAuth callback"); - const redirectUri = `http://127.0.0.1:${address.port}/callback`; + let redirectUri: string | undefined; + if (!outOfBand) { + await new Promise((resolveListen, rejectListen) => { + server.listen(0, "127.0.0.1", () => resolveListen()); + server.once("error", rejectListen); + }); + const address = server.address(); + if (address === null || typeof address === "string") throw new Error("could not open a local OAuth callback"); + redirectUri = `http://127.0.0.1:${address.port}/callback`; + } const authorize = new URL(acquisition.authorizationEndpoint); - authorize.searchParams.set("response_type", "code"); - authorize.searchParams.set("client_id", acquisition.clientId); - authorize.searchParams.set("redirect_uri", redirectUri); - authorize.searchParams.set("scope", acquisition.scopes.join(" ")); + // A declared parameter set replaces the RFC 6749 one; the challenge and state below are never + // delegable, because the PKCE binding and the CSRF check are the client's own. + for (const [name, value] of Object.entries(acquisition.authorizeParams ?? {})) { + authorize.searchParams.set(name, value); + } + if (acquisition.authorizeParams === undefined) { + authorize.searchParams.set("response_type", "code"); + authorize.searchParams.set("client_id", acquisition.clientId); + authorize.searchParams.set("redirect_uri", redirectUri!); + authorize.searchParams.set("scope", acquisition.scopes.join(" ")); + } authorize.searchParams.set("state", state); authorize.searchParams.set("code_challenge", challenge); authorize.searchParams.set("code_challenge_method", "S256"); @@ -109,22 +122,38 @@ export async function acquireOAuthCredential( } else { options.open(authorize.toString()); } - const code = await callback; + const code = outOfBand ? await pastedCode(options) : await callback; options.onProgress?.("Authorization returned. Exchanging token…"); const deadline = AbortSignal.timeout(acquisition.requestTimeoutMs); + const exchange = acquisition.exchange; + const exchanged = exchange === undefined + ? { + encoding: "form" as const, + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri!, + client_id: acquisition.clientId, + code_verifier: verifier, + }).toString(), + } + : { + encoding: exchange.encoding, + headers: exchange.encoding === "json" + ? { "content-type": "application/json" } + : { "content-type": "application/x-www-form-urlencoded" }, + body: exchange.encoding === "json" + ? JSON.stringify({ ...exchange.fields, code, code_verifier: verifier }) + : new URLSearchParams({ ...exchange.fields, code, code_verifier: verifier }).toString(), + }; let tokenResponse: Response; let body: string; try { tokenResponse = await (options.fetch ?? globalThis.fetch)(acquisition.tokenEndpoint, { method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: redirectUri, - client_id: acquisition.clientId, - code_verifier: verifier, - }), + headers: exchanged.headers, + body: exchanged.body, signal: deadline, }); body = await tokenResponse.text(); @@ -138,19 +167,25 @@ export async function acquireOAuthCredential( throw error; } if (!tokenResponse.ok) throw new Error(`OAuth token exchange failed (${tokenResponse.status}): ${body.slice(0, 200)}`); - const parsed = JSON.parse(body) as { - readonly access_token?: unknown; - readonly refresh_token?: unknown; - readonly expires_at?: unknown; - readonly expires_in?: unknown; - }; - if (typeof parsed.access_token !== "string" || parsed.access_token.length === 0) { - throw new Error("OAuth token response contained no access token"); + const parsed = JSON.parse(body) as Record; + const credentialField = acquisition.exchange?.credentialField ?? "access_token"; + const credential = parsed[credentialField]; + if (typeof credential !== "string" || credential.length === 0) { + throw new Error(`OAuth token response contained no ${credentialField}`); + } + // Read the granted scope back: a narrower grant is refused here rather than assumed later. + const requiredScope = acquisition.exchange?.requiredScope; + if (requiredScope !== undefined && parsed.scope !== requiredScope) { + throw new Error( + `OAuth authorization granted scope ${JSON.stringify(parsed.scope ?? null)}, not ${JSON.stringify(requiredScope)}; ` + + "the account or workspace role does not permit the requested grant", + ); } options.onProgress?.("Token received. Saving credential…"); - const expiresAt = tokenExpiry(parsed); + if (acquisition.exchange?.credentialFormat === "opaque") return credential; + const expiresAt = tokenExpiry(parsed as { readonly expires_at?: unknown; readonly expires_in?: unknown }); return encodeOAuth2Credential({ - accessToken: parsed.access_token, + accessToken: credential, ...(typeof parsed.refresh_token === "string" && parsed.refresh_token.length > 0 ? { refreshToken: parsed.refresh_token } : {}), @@ -158,6 +193,16 @@ export async function acquireOAuthCredential( }); } +/** Read the displayed code back from the person who approved it. */ +async function pastedCode(options: OAuthAcquisitionOptions): Promise { + if (options.readCode === undefined) { + throw new Error("this sign-in shows its authorization code; interactive input is unavailable, so use --from instead"); + } + const code = (await options.readCode("Authorization code: "))?.trim(); + if (code === undefined || code.length === 0) throw new Error("no authorization code was entered"); + return code; +} + function callbackPage(success: boolean): string { const title = success ? "Authorization received" : "Hypit sign-in failed"; const heading = success ? "Authorization received" : "Hypit sign-in failed"; diff --git a/packages/cli/test/oauth.test.ts b/packages/cli/test/oauth.test.ts index f9539109f..dd268f195 100644 --- a/packages/cli/test/oauth.test.ts +++ b/packages/cli/test/oauth.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { once } from "node:events"; import { createConnection } from "node:net"; import test from "node:test"; @@ -95,3 +96,53 @@ test("OAuth token exchange uses the Endpoint-declared request timeout", async () /token exchange timed out after 20 ms.*no credential was stored/u, ); }); + +test("out-of-band acquisition shows the code, exchanges it with S256, and never starts a listener", async () => { + const sent: { readonly url: string; readonly body: Record }[] = []; + const opened: string[] = []; + const raw = await acquireOAuthCredential({ + ...acquisition, + delivery: "out-of-band", + authorizeParams: { callback_url: "oob", app_name: "Test Tool", scope: "api" }, + exchange: { encoding: "json", fields: { code_challenge_method: "S256" }, credentialField: "key", credentialFormat: "opaque", requiredScope: "api" }, + }, { + open: (url) => opened.push(url), + readCode: async () => "displayed-code", + fetch: async (input, init) => { + sent.push({ url: String(input), body: JSON.parse(String(init?.body)) as Record }); + return Response.json({ key: "sk-orca-test", user_id: "1", scope: "api" }); + }, + }); + + // A pasted code yields the durable key itself, not a refreshable token envelope. + assert.equal(raw, "sk-orca-test"); + const authorize = new URL(opened[0]!); + assert.equal(authorize.pathname, "/authorize"); + assert.equal(authorize.searchParams.get("callback_url"), "oob"); + assert.equal(authorize.searchParams.get("code_challenge_method"), "S256"); + assert.equal(authorize.searchParams.get("response_type"), null); + assert.equal(sent[0]!.url, acquisition.tokenEndpoint); + assert.equal(sent[0]!.body.code, "displayed-code"); + assert.equal(sent[0]!.body.code_challenge_method, "S256"); + assert.equal(sent[0]!.body.grant_type, undefined); + const verifier = sent[0]!.body.code_verifier!; + assert.equal(Buffer.from(createHash("sha256").update(verifier).digest()).toString("base64url"), + authorize.searchParams.get("code_challenge")); + // The verifier is never placed on the URL a browser or its history can see. + assert.ok(!opened[0]!.includes(verifier)); +}); + +test("out-of-band acquisition refuses a narrower granted scope", async () => { + await assert.rejects( + async () => await acquireOAuthCredential({ + ...acquisition, + delivery: "out-of-band", + exchange: { encoding: "json", credentialField: "key", credentialFormat: "opaque", requiredScope: "api" }, + }, { + open: () => {}, + readCode: async () => "displayed-code", + fetch: async () => Response.json({ key: "sk-orca-test", scope: "connector" }), + }), + /granted scope "connector", not "api"/u, + ); +}); diff --git a/packages/orcarouter/README.md b/packages/orcarouter/README.md new file mode 100644 index 000000000..4d8db34c4 --- /dev/null +++ b/packages/orcarouter/README.md @@ -0,0 +1,10 @@ +# `@hypit/orcarouter` + +The Model package for OrcaRouter chat: a sealed request carrying the catalogue model ID, a prompt Text +and any attached image Artifacts, and the reply Text it publishes. + +Its `model` attribute is passed to the selected Endpoint unchanged, so the vendor namespace the +catalogue returned is the namespace that reaches the relay. The Surface accepts at most eight +`Reference` children; attaching images is only meaningful for a catalogue entry that declares image +input, and the Endpoint refuses the request when the chosen model does not. The Model selects no +Provider and no credential — the Runtime Profile chooses the Endpoint that fulfills the capability. diff --git a/packages/orcarouter/package.json b/packages/orcarouter/package.json new file mode 100644 index 000000000..03902644f --- /dev/null +++ b/packages/orcarouter/package.json @@ -0,0 +1,21 @@ +{ + "name": "@hypit/orcarouter", + "version": "0.0.0-dev", + "license": "SEE LICENSE IN LICENSE", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "hypit": { + "activation": "./src/activation.ts" + }, + "dependencies": { + "@hypit/artifact": "workspace:*", + "@hypit/component-kit": "workspace:*", + "@hypit/elaborator": "workspace:*", + "@hypit/markup": "workspace:*", + "@hypit/protocol": "workspace:*", + "@hypit/text": "workspace:*" + } +} diff --git a/packages/orcarouter/src/activation.ts b/packages/orcarouter/src/activation.ts new file mode 100644 index 000000000..a03fc27a7 --- /dev/null +++ b/packages/orcarouter/src/activation.ts @@ -0,0 +1,19 @@ +import { createMarkupSurfaceHostFacet } from "@hypit/markup"; + +import { + orcaRouterComponent, orcaRouterManifest, orcaRouterMarkupSurfaces, orcaRouterModuleRef, + decodeOrcaRouterGenerateSurface, +} from "./index.js"; + +export const hypitPackage = { + format: "hypit.node-package@1" as const, + modules: [{ manifest: orcaRouterManifest }], + components: [orcaRouterComponent], + hostFacets: [createMarkupSurfaceHostFacet({ + module: orcaRouterModuleRef, + declaration: orcaRouterMarkupSurfaces.find((item) => item.name === "generate")!, + handler: decodeOrcaRouterGenerateSurface, + })], +}; + +export default hypitPackage; diff --git a/packages/orcarouter/src/component.ts b/packages/orcarouter/src/component.ts new file mode 100644 index 000000000..a1cdc00fe --- /dev/null +++ b/packages/orcarouter/src/component.ts @@ -0,0 +1,44 @@ +import { plannedNeedInputs } from "@hypit/component-kit"; +import type { ComponentPackage, ProducerHandlerContext } from "@hypit/component-kit"; +import { canonicalize } from "@hypit/protocol"; +import type { StoredValue } from "@hypit/protocol"; + +import { orcaRouterCapabilities, orcaRouterProducers } from "./manifest.js"; +import { assertOrcaRouterChatRequest } from "./program.js"; +import type { OrcaRouterChatRequest } from "./types.js"; + +function inline(value: StoredValue | undefined, subject: string): T { + if (value?.kind !== "inline") throw new Error(`${subject} must be inline`); + return value.value as T; +} + +/** + * The Surface seals the complete request — model, prompt Text and attached image Artifacts — into one + * Record, so this producer asks for the external Need without a graph to reverse-engineer and the + * Provider receives exactly what was authored. + */ +export const orcaRouterComponent = { + producers: [{ + producer: orcaRouterProducers.request, + handler: ({ inputs }: ProducerHandlerContext) => { + const request = inline(inputs.request?.value, "OrcaRouter chat request"); + assertOrcaRouterChatRequest(request); + return { outputs: {}, needs: { chat: canonicalize(request) } }; + }, + }], + plannedNeeds: [{ + producer: orcaRouterProducers.request, + port: "chat", + capability: orcaRouterCapabilities.generate, + plan: ({ state, step }) => ({ + constraints: {}, + pendingInputs: plannedNeedInputs(state, step, { request: "request" }), + }), + present: (specification) => ({ + fields: {}, + references: { + image: specification.pendingInputs.filter((input) => input.role === "image").length, + }, + }), + }], +} satisfies ComponentPackage; diff --git a/packages/orcarouter/src/fragment.ts b/packages/orcarouter/src/fragment.ts new file mode 100644 index 000000000..b32e6ebf6 --- /dev/null +++ b/packages/orcarouter/src/fragment.ts @@ -0,0 +1,23 @@ +import { sealGraphFragment } from "@hypit/elaborator"; + +import { orcaRouterProducers, orcaRouterTypes } from "./manifest.js"; + +/** + * One request producer whose only result is the external Need. Image Artifacts and their Texts are + * sealed into the request Record before this fragment runs, so the Provider receives a complete + * request rather than a graph to reverse-engineer. + */ +export const orcaRouterFragment = sealGraphFragment({ + inputs: [{ name: "request", type: orcaRouterTypes.chatRequest }], + operations: [{ + id: "chat:generate", + producer: orcaRouterProducers.request, + inputs: { request: { kind: "fragment-input", name: "request" } }, + result: { kind: "need", name: "chat" }, + }], + exports: [{ + name: "chat", + type: orcaRouterTypes.chat, + root: { kind: "fragment-operation", operation: "chat:generate" }, + }], +}); diff --git a/packages/orcarouter/src/index.ts b/packages/orcarouter/src/index.ts new file mode 100644 index 000000000..c816df349 --- /dev/null +++ b/packages/orcarouter/src/index.ts @@ -0,0 +1,19 @@ +export { orcaRouterComponent } from "./component.js"; +export { orcaRouterFragment } from "./fragment.js"; +export { + orcaRouterCapabilities, + orcaRouterDependency, + orcaRouterManifest, + orcaRouterMarkupSurfaces, + orcaRouterModuleRef, + orcaRouterProducers, + orcaRouterTypes, +} from "./manifest.js"; +export { + assertOrcaRouterChatRequest, + assertOrcaRouterModel, + imageDataUrl, + orcaRouterChatRequest, +} from "./program.js"; +export { decodeOrcaRouterGenerateSurface } from "./surface.js"; +export type * from "./types.js"; diff --git a/packages/orcarouter/src/manifest.ts b/packages/orcarouter/src/manifest.ts new file mode 100644 index 000000000..6c15cf6c9 --- /dev/null +++ b/packages/orcarouter/src/manifest.ts @@ -0,0 +1,81 @@ +import { artifactDependency, artifactTypes } from "@hypit/artifact"; +import type { CapabilityRef, ModuleManifest, ProducerRef, TypeRef } from "@hypit/protocol"; +import { textDependency, textTypes } from "@hypit/text"; + +export const orcaRouterModuleRef = { name: "@hypit/orcarouter", version: "1" } as const; + +export const orcaRouterTypes = { + chatRequest: { module: orcaRouterModuleRef, name: "OrcaRouterChatRequest" }, + chat: { module: orcaRouterModuleRef, name: "OrcaRouterChatResult" }, +} satisfies Record; + +export const orcaRouterCapabilities = { + generate: { module: orcaRouterModuleRef, name: "generate" }, +} satisfies Record; + +export const orcaRouterProducers = { + request: { module: orcaRouterModuleRef, name: "request-orcarouter-chat" }, +} satisfies Record; +export const orcaRouterDependency = { module: orcaRouterModuleRef } as const; + +export const orcaRouterManifest: ModuleManifest = { + format: "hypit.module@1", + name: orcaRouterModuleRef.name, + version: orcaRouterModuleRef.version, + dependencies: [artifactDependency, textDependency], + types: [ + { name: orcaRouterTypes.chatRequest.name }, + { name: orcaRouterTypes.chat.name }, + ], + capabilities: [{ name: orcaRouterCapabilities.generate.name, returns: orcaRouterTypes.chat }], + producers: [{ + name: orcaRouterProducers.request.name, + // The one sealed request: model, prompt Text and any attached image Artifacts. + inputs: [{ name: "request", type: orcaRouterTypes.chatRequest }], + // A Need-producing producer declares no output of its own: the fulfilled Need is its result. + outputs: [], + needs: [{ + name: "chat", + capability: orcaRouterCapabilities.generate, + returns: orcaRouterTypes.chat, + }], + }], +}; + +export const orcaRouterMarkupSurfaces = [{ + name: "generate", + tag: "Generate", + mode: "structured", + // Both Records this Surface publishes: the sealed request, and the reply other Source reads. + outputs: [orcaRouterTypes.chatRequest, orcaRouterTypes.chat], + vocabulary: { + summary: "Asks one OrcaRouter catalogue model a prompt, optionally with image Artifacts attached, and publishes its reply as Text.", + attributes: [ + { name: "id", kind: "identifier", required: true, + summary: "Names this request so its reply can be referenced elsewhere in the Source." }, + { name: "model", kind: "literal", required: true, + summary: "The exact OrcaRouter model ID, spelled as the catalogue returns it, including its vendor namespace." }, + { name: "prompt", kind: "reference", required: true, accepts: [textTypes.text], + summary: "Chooses the Text the model is asked about." }, + ], + children: [{ + tag: "Reference", cardinality: "many", + summary: "Attaches one image Artifact to the request.", + attributes: [{ + name: "image", kind: "reference", required: true, accepts: [artifactTypes.blob], + summary: "Selects the image Artifact this reference contributes.", + }], + }], + ports: [{ name: "chat", type: orcaRouterTypes.chat, + summary: "The reply text, addressed as `.chat`." }], + example: ` + +`, + notes: [ + "The element accepts at most 8 `Reference` children and no text content.", + "`model` is passed to the selected Endpoint unchanged; the Endpoint reports a model the account cannot call rather than substituting another one.", + "Attached images are for models whose catalogue entry declares image input; the Endpoint refuses the request when the chosen model does not.", + "The Surface selects no Provider and no credential — the Runtime Profile chooses the Endpoint that fulfills this capability.", + ], + }, +}] as const; diff --git a/packages/orcarouter/src/program.ts b/packages/orcarouter/src/program.ts new file mode 100644 index 000000000..3d1893a3f --- /dev/null +++ b/packages/orcarouter/src/program.ts @@ -0,0 +1,57 @@ +import { canonicalize } from "@hypit/protocol"; +import type { BlobRef, StoredValue } from "@hypit/protocol"; +import { verifyText } from "@hypit/text"; + +import type { OrcaRouterChatRequest } from "./types.js"; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +/** The model ID is the catalogue's own string; only its shape is checked here. */ +export function assertOrcaRouterModel(model: unknown): asserts model is string { + assert(typeof model === "string" && model.trim().length > 0, + "OrcaRouter model must be the catalogue ID of the model to call"); + assert(model.trim() === model && !/\s/u.test(model), + "OrcaRouter model must be the exact catalogue ID, with no surrounding or embedded whitespace"); + assert(model.includes("/"), + "OrcaRouter model must keep its vendor namespace, for example anthropic/claude-opus-4.8"); +} + +function imageReference(value: StoredValue | undefined, subject: string): BlobRef { + assert(value?.kind === "blob", `${subject} must be an image Blob Artifact`); + assert(value.mediaType.startsWith("image/"), `${subject} must be image media`); + return value; +} + +export function assertOrcaRouterChatRequest(value: OrcaRouterChatRequest): void { + assertOrcaRouterModel(value.model); + assert(typeof value.prompt === "string", "OrcaRouter prompt must be Text"); + verifyText({ value: value.prompt }); + assert(Array.isArray(value.images), "OrcaRouter images must be a list"); + value.images.forEach((image, index) => { + assert(image.kind === "blob" && image.mediaType.startsWith("image/"), + `OrcaRouter image ${index + 1} must be image media`); + }); +} + +/** Assemble one request from an authored prompt and its optional image Artifacts. */ +export function orcaRouterChatRequest( + model: string, + prompt: string, + images: readonly (StoredValue | undefined)[] = [], +): OrcaRouterChatRequest { + const value = { + model, + prompt, + images: images.map((image, index) => imageReference(image, `OrcaRouter image ${index + 1}`)), + }; + assertOrcaRouterChatRequest(value); + return canonicalize(value) as unknown as OrcaRouterChatRequest; +} + +/** The wire text an attached image contributes: the OpenAI-compatible data URL form. */ +export function imageDataUrl(image: BlobRef, bytes: Uint8Array): string { + assert(bytes.byteLength > 0, `OrcaRouter image ${image.resource} is empty`); + return `data:${image.mediaType};base64,${Buffer.from(bytes).toString("base64")}`; +} diff --git a/packages/orcarouter/src/surface.ts b/packages/orcarouter/src/surface.ts new file mode 100644 index 000000000..d6cafb22a --- /dev/null +++ b/packages/orcarouter/src/surface.ts @@ -0,0 +1,88 @@ +import { artifactTypes } from "@hypit/artifact"; +import type { StructuredSurfaceHandler, SurfaceResolvedReference } from "@hypit/markup"; +import { localName } from "@hypit/markup"; +import type { CanonicalValue, TypeRef } from "@hypit/protocol"; +import { canonicalize } from "@hypit/protocol"; +import { textTypes, verifyText } from "@hypit/text"; + +import { orcaRouterFragment } from "./fragment.js"; +import { orcaRouterTypes } from "./manifest.js"; +import { assertOrcaRouterChatRequest, assertOrcaRouterModel } from "./program.js"; +import type { OrcaRouterChatRequest } from "./types.js"; + +const MAX_REFERENCES = 8; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function sameType(left: TypeRef, right: TypeRef): boolean { + return left.name === right.name + && left.module.name === right.module.name + && left.module.version === right.module.version; +} + +export const decodeOrcaRouterGenerateSurface: StructuredSurfaceHandler = ({ element, resolveReference }) => { + const unknown = Object.keys(element.attributes).filter((name) => !["id", "model", "prompt"].includes(name)); + assert(unknown.length === 0, `${element.name} does not accept ${unknown[0]}`); + const id = element.attributes.id; + assert(typeof id === "string" && id.trim().length > 0, `${element.name}.id must be text`); + const model = element.attributes.model; + assertOrcaRouterModel(model); + const rawPrompt = element.attributes.prompt; + assert(typeof rawPrompt === "object" && rawPrompt.kind === "reference", `${element.name}.prompt must be a reference`); + const prompt: SurfaceResolvedReference = resolveReference(rawPrompt.path)!; + assert(prompt !== undefined && sameType(prompt.type, textTypes.text), `${element.name}.prompt must reference Text`); + if (prompt.record !== undefined) { + assert(prompt.record.value.kind === "inline", `${element.name}.prompt must reference Text`); + verifyText(prompt.record.value.value); + } + const images: SurfaceResolvedReference[] = []; + for (const child of element.children) { + if (child.kind === "text") { + assert(child.value.trim().length === 0, `${element.name} accepts only Reference children`); + continue; + } + assert(localName(child.name) === "Reference", `${element.name} accepts only Reference children`); + const attributes = Object.keys(child.attributes); + assert(attributes.every((name) => name === "image"), `${child.name} does not accept ${attributes.find((name) => name !== "image")}`); + assert(child.children.length === 0, `${child.name} must be empty`); + const rawImage = child.attributes.image; + assert(typeof rawImage === "object" && rawImage.kind === "reference", `${child.name}.image must be a reference`); + const image = resolveReference(rawImage.path); + assert(image !== undefined && sameType(image.type, artifactTypes.blob), `${child.name}.image must be a Blob Artifact`); + if (image.record !== undefined) { + assert(image.record.value.kind === "blob" && image.record.value.mediaType.startsWith("image/"), + `${child.name}.image must reference image media`); + } + images.push(image); + } + assert(images.length <= MAX_REFERENCES, `${element.name} accepts at most ${MAX_REFERENCES} references`); + const promptRecord = prompt.record; + assert(promptRecord !== undefined && promptRecord.value.kind === "inline" + && typeof (promptRecord.value.value as { readonly value?: unknown }).value === "string", + `${element.name}.prompt must resolve to a literal Text value`); + const request: OrcaRouterChatRequest = { + model: model.trim(), + prompt: (promptRecord.value.value as { readonly value: string }).value, + images: images.flatMap((image) => image.record?.value.kind === "blob" ? [image.record.value] : []), + }; + assertOrcaRouterChatRequest(request); + const record = { + id: `${id.trim()}.request`, + type: orcaRouterTypes.chatRequest, + value: { kind: "inline" as const, value: canonicalize(request) as unknown as CanonicalValue }, + range: element.range, + }; + return { + records: [record], + fragments: [orcaRouterFragment], + components: [{ + id: id.trim(), + fragment: orcaRouterFragment.id, + inputs: { request: { kind: "record", id: record.id } }, + outputs: { chat: `${id.trim()}.chat` }, + range: element.range, + }], + }; +}; diff --git a/packages/orcarouter/src/types.ts b/packages/orcarouter/src/types.ts new file mode 100644 index 000000000..2330feb80 --- /dev/null +++ b/packages/orcarouter/src/types.ts @@ -0,0 +1,15 @@ +import type { BlobRef } from "@hypit/protocol"; + +/** One OrcaRouter chat request, assembled in full before its Need is fulfilled. */ +export type OrcaRouterChatRequest = { + /** Vendor-namespaced catalogue ID, preserved exactly as `GET /v1/models` returned it. */ + readonly model: string; + readonly prompt: string; + /** Image Artifacts the request carries alongside the prompt; empty for a text-only ask. */ + readonly images: readonly BlobRef[]; +}; + +/** One OrcaRouter chat completion. */ +export type OrcaRouterChatResult = { + readonly text: string; +}; diff --git a/packages/provider-orcarouter/README.md b/packages/provider-orcarouter/README.md new file mode 100644 index 000000000..ca14ee7bc --- /dev/null +++ b/packages/provider-orcarouter/README.md @@ -0,0 +1,66 @@ +# `@hypit/provider-orcarouter` + +First-class OrcaRouter Provider for the Hypit Runtime. [OrcaRouter](https://www.orcarouter.ai) is an +OpenAI-compatible AI gateway: one endpoint serves many vendors’ models with adaptive routing, +failover and gateway-level guardrails. This Provider offers the `chat` capability, fulfilled by the +models the configured account may call. + +## Two ways in, one credential + +The Endpoint declares one credential slot, `apiKey`, with two explicit entry points: + +| Choice | Where it appears | What it stores | +| --- | --- | --- | +| `OrcaRouter - API` | the key field on the account panel, or `hypit auth login orcarouter.default` | a key the user already holds, pasted as `sk-orca-…` | +| `OrcaRouter - Auth` | the Connect button on the account panel, or the declared `oauth2-pkce` acquisition | a key minted by the OrcaRouter consent screen | + +Both end at the same ordinary OrcaRouter API key, held in the Credential Store the selected Runtime +Profile declares. `packages/provider-orcarouter/src/credentials.ts` owns the one credential interface +and its two adapters; nothing downstream of `token()` knows which one produced the key. + +## Endpoints + +| Purpose | Base | +| --- | --- | +| Inference and model catalogue | `https://api.orcarouter.ai/v1` | +| Authorization and code exchange | `https://www.orcarouter.ai` (`/auth`, `/api/v1/auth/keys`) | + +The relay is at `/v1`; authentication is not. Neither base is derived from the other. A self-hosted +deployment may set `ORCA_BASE_URL` for one shared origin, or `ORCA_AUTH_BASE_URL` and +`ORCA_API_BASE_URL` separately; an explicit override wins. Non-loopback origins must use HTTPS. + +## Authorization flow + +The declared acquisition uses the out-of-band flow: `callback_url=oob` with `S256` mandatory. A code +displayed to a person is redeemable only with the verifier, which never leaves the process. Auth +codes are single-use with a ten minute lifetime. The exchange returns a **durable key, not a +refreshable token pair** — there is no refresh grant, and the key is reused until the user revokes it +at `https://www.orcarouter.ai/console/authorized-apps`. A `401` from the relay marks the exact +credential generation that made the request as needing authorization; it never triggers a refresh. + +## Model catalogue + +`GET /v1/models` on the inference base is the only source of truth. Nothing here infers a capability +from a model’s name: the text control accepts entries whose `supported_endpoint_types` include a +chat-capable type and exclude the non-chat ones, and the image-input control additionally requires +the entry to declare `image` in `architecture.input_modalities` (an entry that declares nothing fails +closed). The catalogue read is bounded in time, bytes, item count and accepted field shapes. + +When live discovery succeeds it is authoritative and no seed entry is mixed in. When it fails, the +small verified seed in `src/catalog.ts` keeps a fresh installation usable and is reported as +degraded; it keeps its context and reasoning-effort metadata, including the verified +`low`/`medium`/`high`/`xhigh` ladder for `openai/gpt-5.5`. + +## Configuration + +```json +{ + "orcarouter.default": { + "use": "@hypit/provider-orcarouter", + "config": { "apiKey": { "store": "os", "key": "orcarouter.apiKey" } } + } +} +``` + +`baseUrl`, `authBaseUrl`, `defaultConcurrency` and the two request timeouts are optional. The +Provider declares its published pricing page; it does not copy or interpret OrcaRouter rates. diff --git a/packages/provider-orcarouter/package.json b/packages/provider-orcarouter/package.json new file mode 100644 index 000000000..36b2afdcf --- /dev/null +++ b/packages/provider-orcarouter/package.json @@ -0,0 +1,20 @@ +{ + "name": "@hypit/provider-orcarouter", + "version": "0.0.0-dev", + "license": "SEE LICENSE IN LICENSE", + "private": true, + "type": "module", + "exports": { ".": "./src/index.ts" }, + "hypit": { "activation": "./src/activation.ts" }, + "dependencies": { + "@hypit/endpoint-kit": "workspace:*", + "@hypit/orcarouter": "workspace:*", + "@hypit/protocol": "workspace:*", + "@hypit/runtime": "workspace:*", + "@hypit/runtime-kit": "workspace:*" + }, + "devDependencies": { + "@hypit/artifact": "workspace:*", + "@hypit/cli": "workspace:*" + } +} diff --git a/packages/provider-orcarouter/src/activation.ts b/packages/provider-orcarouter/src/activation.ts new file mode 100644 index 000000000..8e4f518d5 --- /dev/null +++ b/packages/provider-orcarouter/src/activation.ts @@ -0,0 +1,55 @@ +import { + createRuntimeEndpointAdapterFacet, + runtimeConfigCredentialRef, + runtimeConfigExact, + runtimeConfigObject, + runtimeConfigPositiveInteger, + runtimeConfigString, +} from "@hypit/runtime-kit"; + +import { createOrcaRouterProvider } from "./provider.js"; + +const adapter = createRuntimeEndpointAdapterFacet({ + use: "@hypit/provider-orcarouter", + activate(context) { + if (context.pool === undefined) throw new Error("OrcaRouter Provider Pool is required"); + const config = runtimeConfigObject(context.config, "OrcaRouter"); + runtimeConfigExact(config, [ + "baseUrl", + "authBaseUrl", + "apiKey", + "defaultConcurrency", + "requestTimeoutMs", + "oauthRequestTimeoutMs", + ], "OrcaRouter"); + const baseUrl = runtimeConfigString(config.baseUrl, "OrcaRouter baseUrl"); + const authBaseUrl = runtimeConfigString(config.authBaseUrl, "OrcaRouter authBaseUrl"); + const apiKey = runtimeConfigCredentialRef(config.apiKey, "OrcaRouter apiKey"); + if (apiKey === undefined) throw new Error("OrcaRouter apiKey CredentialRef is required"); + const defaultConcurrency = runtimeConfigPositiveInteger(config.defaultConcurrency, "OrcaRouter defaultConcurrency"); + const requestTimeoutMs = runtimeConfigPositiveInteger(config.requestTimeoutMs, "OrcaRouter requestTimeoutMs"); + const oauthRequestTimeoutMs = runtimeConfigPositiveInteger(config.oauthRequestTimeoutMs, "OrcaRouter oauthRequestTimeoutMs"); + // Origin validation lives with the provider so the adapter cannot accept an origin the provider rejects. + const provider = createOrcaRouterProvider({ + instance: context.instance, + pool: context.pool, + ...(baseUrl === undefined ? {} : { baseUrl }), + ...(authBaseUrl === undefined ? {} : { authBaseUrl }), + apiKey, + ...(defaultConcurrency === undefined ? {} : { defaultConcurrency }), + ...(requestTimeoutMs === undefined ? {} : { requestTimeoutMs }), + ...(oauthRequestTimeoutMs === undefined ? {} : { oauthRequestTimeoutMs }), + }); + return { + endpoint: provider.endpoint, + diagnose: async (doctorContext) => await provider.diagnose(doctorContext), + }; + }, +}); + +export const hypitPackage = { + format: "hypit.node-package@1" as const, + hostFacets: [adapter], +}; + +export default hypitPackage; diff --git a/packages/provider-orcarouter/src/catalog.ts b/packages/provider-orcarouter/src/catalog.ts new file mode 100644 index 000000000..9d51f1510 --- /dev/null +++ b/packages/provider-orcarouter/src/catalog.ts @@ -0,0 +1,257 @@ +/** + * The OrcaRouter model catalogue and the capability filters built on it. + * + * `GET /v1/models` on the configured inference origin is the only source of truth for which models + * this account may call. Nothing here invents a capability from a model's name: an entry is offered + * to a control only when its own metadata proves the endpoint type and, where the control uploads + * media, the input modality. The small seed below exists so a fresh installation is not empty + * during a catalogue outage; it is never merged into a successful live response. + */ + +/** Bounds keep a catalogue response from consuming unbounded memory or advertising routes we cannot speak. */ +export const CATALOG_LIMITS = { + requestTimeoutMs: 10_000, + maxBytes: 512 * 1024, + maxModels: 500, + maxEndpointTypes: 24, + maxStringLength: 512, +} as const; + +/** Endpoint types that prove an entry can serve an OpenAI-compatible chat request. */ +export const CHAT_ENDPOINT_TYPES: readonly string[] = ["openai", "anthropic", "gemini", "openai-response"]; + +/** Endpoint types that only ever serve something other than chat, even when a chat type is present. */ +export const NON_CHAT_ENDPOINT_TYPES: readonly string[] = [ + "image-generation", "openai-video", "jina-rerank", "embeddings", "rerank", "moderation", "audio", +]; + +export type OrcaRouterCapability = "chat" | "multimodal" | "embedding" | "image" | "video" | "rerank"; + +export type CatalogModel = { + /** Vendor-namespaced model ID, preserved exactly as the catalogue returns it. */ + readonly id: string; + readonly label: string; + readonly endpointTypes: readonly string[]; + /** `architecture.input_modalities`, or an empty list when the entry does not declare it. */ + readonly inputModalities: readonly string[]; + readonly contextLength?: number; + readonly maxCompletionTokens?: number; + /** Declared reasoning-effort ladder. Absent unless the catalogue states one. */ + readonly reasoningEfforts?: readonly string[]; + /** Where the entry came from; the UI labels a degraded catalogue from this. */ + readonly origin: "live" | "seed"; +}; + +/** + * Verified cold-start seed. These IDs come from the OrcaRouter seed list published with the + * integration guide, not from a live response, so they carry `origin: "seed"` and conservative + * metadata: only the GPT-5.5 reasoning ladder is verified, and no seed entry claims media input + * unless the live catalogue has confirmed it. + */ +export const ORCAROUTER_SEED_MODELS: readonly CatalogModel[] = [ + { + id: "orcarouter/auto", + label: "OrcaRouter Auto", + endpointTypes: [...CHAT_ENDPOINT_TYPES], + inputModalities: ["text"], + origin: "seed", + }, + { + id: "openai/gpt-5.5", + label: "OpenAI: GPT-5.5", + endpointTypes: [...CHAT_ENDPOINT_TYPES], + inputModalities: ["text", "image"], + contextLength: 400_000, + reasoningEfforts: ["low", "medium", "high", "xhigh"], + origin: "seed", + }, + { + id: "anthropic/claude-opus-4.8", + label: "Anthropic: Claude Opus 4.8", + endpointTypes: [...CHAT_ENDPOINT_TYPES], + inputModalities: ["text"], + contextLength: 200_000, + origin: "seed", + }, + { + id: "google/gemini-3.5-flash", + label: "Google: Gemini 3.5 Flash", + endpointTypes: [...CHAT_ENDPOINT_TYPES], + inputModalities: ["text"], + contextLength: 1_000_000, + origin: "seed", + }, + { + id: "deepseek/deepseek-v4-pro", + label: "DeepSeek: DeepSeek V4 Pro", + endpointTypes: ["openai", "openai-response"], + inputModalities: ["text"], + contextLength: 1_048_576, + origin: "seed", + }, +]; + +/** The official catalogue endpoint, for evidence and documentation. */ +export const ORCAROUTER_CATALOG_URL = "https://api.orcarouter.ai/v1/models"; +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function boundedString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 && value.length <= CATALOG_LIMITS.maxStringLength + ? value + : undefined; +} + +function positiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +function stringList(value: unknown, limit: number): string[] { + if (!Array.isArray(value)) return []; + return value + .map((item) => boundedString(item)) + .filter((item): item is string => item !== undefined) + .slice(0, limit); +} + +/** + * Parse one catalogue response. Unusable records are dropped rather than repaired: an entry without + * a usable ID or without a declared endpoint type cannot be filtered honestly later. + */ +export function parseCatalogResponse(body: unknown): CatalogModel[] { + const root = body !== null && typeof body === "object" && !Array.isArray(body) + ? (body as Record) + : undefined; + const data = root?.data; + assert(Array.isArray(data), "OrcaRouter model catalogue has no data array"); + const models: CatalogModel[] = []; + for (const raw of data.slice(0, CATALOG_LIMITS.maxModels)) { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) continue; + const entry = raw as Record; + const id = boundedString(entry.id); + if (id === undefined) continue; + const endpointTypes = stringList(entry.supported_endpoint_types, CATALOG_LIMITS.maxEndpointTypes); + const architecture = entry.architecture !== null && typeof entry.architecture === "object" && !Array.isArray(entry.architecture) + ? entry.architecture as Record + : undefined; + const reasoning = architecture?.reasoning !== null && typeof architecture?.reasoning === "object" && !Array.isArray(architecture.reasoning) + ? architecture.reasoning as Record + : undefined; + const efforts = stringList(reasoning?.efforts ?? entry.reasoning_efforts, 8); + const contextLength = positiveInteger(entry.context_length) ?? positiveInteger( + (entry.top_provider as Record | undefined)?.context_length, + ); + const maxCompletionTokens = positiveInteger(entry.max_completion_tokens) ?? positiveInteger( + (entry.top_provider as Record | undefined)?.max_completion_tokens, + ); + models.push({ + id, + label: boundedString(entry.name) ?? id, + endpointTypes, + inputModalities: stringList(architecture?.input_modalities, 8), + ...(contextLength === undefined ? {} : { contextLength }), + ...(maxCompletionTokens === undefined ? {} : { maxCompletionTokens }), + ...(efforts.length === 0 ? {} : { reasoningEfforts: efforts }), + origin: "live", + }); + } + return models; +} + +/** Text controls accept only entries that prove a chat-capable endpoint and no chat-incompatible one. */ +function isChatModel(model: CatalogModel): boolean { + const types = new Set(model.endpointTypes); + if (!CHAT_ENDPOINT_TYPES.some((type) => types.has(type))) return false; + return !NON_CHAT_ENDPOINT_TYPES.some((type) => types.has(type)); +} + +/** + * Filter one catalogue for one control. `multimodal` additionally requires the entry to declare the + * exact non-text modality the control uploads; an entry that declares nothing fails closed. + */ +export function filterCatalog( + models: readonly CatalogModel[], + capability: OrcaRouterCapability, + modality?: "image" | "audio" | "video", +): CatalogModel[] { + return models.filter((model) => { + const types = new Set(model.endpointTypes); + switch (capability) { + case "chat": + return isChatModel(model); + case "multimodal": + return isChatModel(model) + && modality !== undefined + && model.inputModalities.includes(modality); + case "embedding": + return types.has("embeddings") || types.has("embedding"); + case "image": + return types.has("image-generation"); + case "video": + return types.has("openai-video"); + case "rerank": + return types.has("jina-rerank"); + default: + return false; + } + }); +} + +export type CatalogResult = { + readonly models: readonly CatalogModel[]; + /** `live` when the response was accepted in full; `seed` when the catalogue could not be read. */ + readonly source: "live" | "seed"; + readonly detail?: string; +}; + +export type FetchCatalogOptions = { + readonly baseUrl: string; + readonly apiKey: string; + readonly capability: OrcaRouterCapability; + readonly modality?: "image" | "audio" | "video"; + readonly fetch?: typeof globalThis.fetch; + readonly timeoutMs?: number; +}; + +/** The catalogue path under an inference base that already ends in `/v1`. */ +const MODELS_PATH = "models"; + +/** + * Read the catalogue for one capability. A failure is reported, never repaired with seed entries: + * the caller decides whether to show the verified seed and label it as degraded. + */ +export async function fetchOrcaRouterCatalog(options: FetchCatalogOptions): Promise { + const fetcher = options.fetch ?? globalThis.fetch; + const url = new URL(`${options.baseUrl.replace(/\/+$/u, "")}/${MODELS_PATH}`); + url.searchParams.set("capability", options.capability === "multimodal" ? "chat" : options.capability); + const deadline = AbortSignal.timeout(options.timeoutMs ?? CATALOG_LIMITS.requestTimeoutMs); + let response: Response; + let text: string; + try { + response = await fetcher(url.toString(), { + headers: { authorization: `Bearer ${options.apiKey}`, accept: "application/json" }, + signal: deadline, + }); + text = await response.text(); + } catch (error) { + return { models: [], source: "seed", detail: `catalogue request failed: ${error instanceof Error ? error.message : String(error)}` }; + } + if (!response.ok) { + return { models: [], source: "seed", detail: `catalogue returned HTTP ${response.status}` }; + } + if (text.length > CATALOG_LIMITS.maxBytes) { + return { models: [], source: "seed", detail: "catalogue response exceeded the accepted size" }; + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return { models: [], source: "seed", detail: "catalogue response was not JSON" }; + } + try { + return { models: filterCatalog(parseCatalogResponse(parsed), options.capability, options.modality), source: "live" }; + } catch (error) { + return { models: [], source: "seed", detail: error instanceof Error ? error.message : String(error) }; + } +} diff --git a/packages/provider-orcarouter/src/client.ts b/packages/provider-orcarouter/src/client.ts new file mode 100644 index 000000000..2650cd488 --- /dev/null +++ b/packages/provider-orcarouter/src/client.ts @@ -0,0 +1,113 @@ +import { requestDeadline } from "@hypit/runtime-kit"; + +import type { CatalogResult, OrcaRouterCapability } from "./catalog.js"; +import { fetchOrcaRouterCatalog } from "./catalog.js"; +import type { OrcaRouterCredential } from "./credentials.js"; +import { ORCAROUTER_KEY_DASHBOARD } from "./credentials.js"; + +export class OrcaRouterHttpError extends Error { + constructor(readonly status: number, message: string) { + super(message); + } +} + +export type OrcaRouterChatMessage = { + readonly role: "system" | "user" | "assistant"; + readonly content: string | readonly ( + | { readonly type: "text"; readonly text: string } + | { readonly type: "image_url"; readonly image_url: { readonly url: string } } + )[]; +}; + +/** + * The one inference client. It asks the credential only for a Bearer value, so an API-key credential + * and a PKCE-issued one take exactly the same path to the relay. + */ +export class OrcaRouterClient { + readonly baseUrl: string; + readonly timeout: number; + readonly fetcher: typeof globalThis.fetch; + + constructor(options: { + readonly baseUrl: string; + readonly timeout: number; + readonly fetcher?: typeof globalThis.fetch; + }) { + this.baseUrl = options.baseUrl.replace(/\/+$/u, ""); + this.timeout = options.timeout; + this.fetcher = options.fetcher ?? globalThis.fetch; + } + + /** One chat completion. A 401 is terminal for the credential that made the request. */ + async chat( + credential: OrcaRouterCredential, + body: { readonly model: string; readonly messages: readonly OrcaRouterChatMessage[] }, + ): Promise<{ readonly text: string }> { + const token = await credential.token(); + const deadline = requestDeadline(this.timeout, () => new Error("OrcaRouter chat request timed out")); + try { + const response = await deadline.wait(this.fetcher(`${this.baseUrl}/chat/completions`, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify(body), + signal: deadline.signal, + })); + const text = await deadline.wait(response.text()); + if (response.status === 401) { + // Mark only the exact credential generation that made this request, then stop: a durable + // OrcaRouter key has no refresh grant, so a rejected one needs a new authorization. + await credential.reject(); + throw new OrcaRouterHttpError(401, + `OrcaRouter refused this credential (HTTP 401). Authorize again, or manage keys at ${ORCAROUTER_KEY_DASHBOARD}`); + } + if (!response.ok) { + throw new OrcaRouterHttpError(response.status, `OrcaRouter returned HTTP ${response.status}: ${text.slice(0, 300)}`); + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new Error("OrcaRouter returned invalid chat JSON"); + } + return { text: completionText(parsed) }; + } finally { + deadline.finish(); + } + } + + /** The catalogue for one control, read with the same credential the chat request uses. */ + async models(credential: OrcaRouterCredential, capability: OrcaRouterCapability, + modality?: "image" | "audio" | "video"): Promise { + return await fetchOrcaRouterCatalog({ + baseUrl: this.baseUrl, + apiKey: await credential.token(), + capability, + ...(modality === undefined ? {} : { modality }), + fetch: this.fetcher, + timeoutMs: this.timeout, + }); + } +} + +function completionText(body: unknown): string { + if (body === null || typeof body !== "object" || Array.isArray(body)) { + throw new Error("OrcaRouter chat response is not an object"); + } + const choices = (body as { readonly choices?: unknown }).choices; + if (!Array.isArray(choices) || choices.length === 0) throw new Error("OrcaRouter chat response has no choices"); + const message = (choices[0] as { readonly message?: unknown }).message; + if (message === null || typeof message !== "object" || Array.isArray(message)) { + throw new Error("OrcaRouter chat response has no message"); + } + const content = (message as { readonly content?: unknown }).content; + if (typeof content === "string") return content; + // A multimodal reply may arrive as parts; the text parts are joined in order. + if (Array.isArray(content)) { + const text = content.flatMap((part) => part !== null && typeof part === "object" + && (part as { readonly type?: unknown }).type === "text" + && typeof (part as { readonly text?: unknown }).text === "string" + ? [(part as { readonly text: string }).text] : []).join(""); + if (text.length > 0) return text; + } + throw new Error("OrcaRouter chat response carried no text"); +} diff --git a/packages/provider-orcarouter/src/credentials.ts b/packages/provider-orcarouter/src/credentials.ts new file mode 100644 index 000000000..cb3dd9947 --- /dev/null +++ b/packages/provider-orcarouter/src/credentials.ts @@ -0,0 +1,250 @@ +import { randomBytes } from "node:crypto"; + +import type { CredentialAcquisition, CredentialRef, CredentialValue } from "@hypit/runtime"; + +/** + * Which entry point produced the stored key. Both entries end at the same ordinary OrcaRouter API + * key, so nothing downstream of `token()` may branch on this; it exists so status, logout and error + * messages can name the route the user chose. + */ +export type OrcaRouterCredentialSource = "api-key" | "oauth"; + +/** `needsReauth` is terminal: the relay rejected this exact credential and only a new login clears it. */ +export type OrcaRouterCredentialState = "ready" | "needsReauth"; + +/** + * The credential envelope this Provider stores in the Endpoint's own credential slot. A bare + * `sk-orca-…` string is also accepted, so a user who pasted a key before this envelope existed is + * not forced to re-enter it. + */ +type StoredEnvelope = { + readonly format: "hypit.orcarouter-credential@1"; + readonly key: string; + readonly generation: number; + readonly state: OrcaRouterCredentialState; + readonly issuedVia: OrcaRouterCredentialSource; +}; + +export type StoredOrcaRouterCredential = { + readonly key: string; + readonly generation: number; + readonly state: OrcaRouterCredentialState; + readonly issuedVia: OrcaRouterCredentialSource; +}; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +/** A pasted key is a static credential: generation 0 of the slot, and never reissued automatically. */ +export function decodeOrcaRouterCredential(secret: string): StoredOrcaRouterCredential | undefined { + const trimmed = secret.trim(); + if (trimmed.length === 0) return undefined; + if (!trimmed.startsWith("{")) return { key: trimmed, generation: 0, state: "ready", issuedVia: "api-key" }; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return undefined; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; + const value = parsed as Partial; + if (value.format !== "hypit.orcarouter-credential@1") return undefined; + if (typeof value.key !== "string" || value.key.trim().length === 0) return undefined; + if (typeof value.generation !== "number" || !Number.isSafeInteger(value.generation) || value.generation < 0) return undefined; + assert(value.state === "ready" || value.state === "needsReauth", "OrcaRouter credential state is invalid"); + assert(value.issuedVia === "api-key" || value.issuedVia === "oauth", "OrcaRouter credential source is invalid"); + return { key: value.key.trim(), generation: value.generation, state: value.state, issuedVia: value.issuedVia }; +} + +export function encodeOrcaRouterCredential(value: StoredOrcaRouterCredential): string { + assert(value.key.trim().length > 0, "OrcaRouter credential key is empty"); + assert(Number.isSafeInteger(value.generation) && value.generation >= 0, "OrcaRouter credential generation is invalid"); + return JSON.stringify({ format: "hypit.orcarouter-credential@1", ...value } satisfies StoredEnvelope); +} + +/** + * Which generation of a slot is current, per CredentialRef. A late `401` from a request made with + * generation N must not mark the credential a fresh login just stored as generation N+1. + */ +const generations = new Map(); + +function refKey(ref: CredentialRef): string { + return `${ref.store}\u0000${ref.key}`; +} + +export function currentCredentialGeneration(ref: CredentialRef): number { + return generations.get(refKey(ref)) ?? 0; +} + +/** Called by the login routes when they store a credential; returns the new generation. */ +export function advanceCredentialGeneration(ref: CredentialRef): number { + const next = currentCredentialGeneration(ref) + 1; + generations.set(refKey(ref), next); + return next; +} + +/** Test seam: forget the in-process generation counters. */ +export function resetCredentialGenerations(): void { + generations.clear(); +} + +export type OrcaRouterCredential = { + readonly source: OrcaRouterCredentialSource; + readonly generation: number; + /** The Bearer value for one inference or catalogue request. */ + token(): Promise; + status(): OrcaRouterCredentialState; + /** + * Terminal reauthentication for the exact credential that made a rejected request. Returns the + * state after the attempt; it does nothing when a newer login has already replaced this credential. + */ + reject(): Promise; +}; + +export type OrcaRouterCredentialAdapter = { + readonly id: "orcarouter" | "orcarouter-oauth"; + readonly label: string; + /** Read the credential this entry point stored, or undefined when the user has not used it. */ + resolve(): Promise; +}; + +/** Both entry points produce this one credential; only `source` records how the key arrived. */ +export function createOrcaRouterCredential(options: { + readonly secret: string; + readonly ref: CredentialRef; + readonly replace?: (value: CredentialValue) => Promise; +}): OrcaRouterCredential | undefined { + const decoded = decodeOrcaRouterCredential(options.secret); + if (decoded === undefined) return undefined; + // A credential restored from storage is the current one until a login replaces it, so open it at + // its own generation; a `needsReauth` marker recorded earlier stays authoritative. + const generation = Math.max(decoded.generation, currentCredentialGeneration(options.ref)); + generations.set(refKey(options.ref), generation); + let state = decoded.state; + return { + source: decoded.issuedVia, + generation, + async token() { + // There is no refresh grant: a durable OrcaRouter key is reused until the user revokes it. + assert(state === "ready", "OrcaRouter credential needs authorization; run hypit auth login orcarouter.default"); + return decoded.key; + }, + status: () => state, + async reject() { + // A late failure from a replaced credential must not touch the newer one. + if (currentCredentialGeneration(options.ref) !== generation) return state; + state = "needsReauth"; + await options.replace?.({ + secret: encodeOrcaRouterCredential({ + key: decoded.key, + generation, + state: "needsReauth", + issuedVia: decoded.issuedVia, + }), + }); + return state; + }, + }; +} + +/** Store one freshly obtained key as a new generation of the slot. */ +export async function storeOrcaRouterCredential(options: { + readonly ref: CredentialRef; + readonly replace: (value: CredentialValue) => Promise; + readonly key: string; + readonly issuedVia: OrcaRouterCredentialSource; +}): Promise { + assert(options.key.trim().length > 0, "OrcaRouter returned an empty key"); + const generation = advanceCredentialGeneration(options.ref); + const stored = { key: options.key.trim(), generation, state: "ready" as const, issuedVia: options.issuedVia }; + await options.replace({ secret: encodeOrcaRouterCredential(stored) }); + return stored; +} + +/** + * The API-key entry point: a key the user already holds. It reads the same slot as the account-login + * adapter and produces the same credential type, and it resolves only a key that arrived this way. + */ +export function apiKeyCredentialAdapter(options: { + readonly secret: string; + readonly ref: CredentialRef; + readonly replace?: (value: CredentialValue) => Promise; + readonly label?: string; +}): OrcaRouterCredentialAdapter { + return { + id: "orcarouter", + label: options.label ?? "OrcaRouter - API", + async resolve() { + const decoded = decodeOrcaRouterCredential(options.secret); + if (decoded === undefined || decoded.issuedVia !== "api-key") return undefined; + return createOrcaRouterCredential(options); + }, + }; +} + +/** + * The account-login entry point. The key it reads was minted by the authorization flow; it is + * indistinguishable downstream of `token()`, and it resolves only a key that arrived this way. + */ +export function pkceCredentialAdapter(options: { + readonly secret: string; + readonly ref: CredentialRef; + readonly replace?: (value: CredentialValue) => Promise; + readonly label?: string; +}): OrcaRouterCredentialAdapter { + return { + id: "orcarouter-oauth", + label: options.label ?? "OrcaRouter - Auth", + async resolve() { + const decoded = decodeOrcaRouterCredential(options.secret); + if (decoded === undefined || decoded.issuedVia !== "oauth") return undefined; + return createOrcaRouterCredential(options); + }, + }; +} + +export const ORCAROUTER_APP_NAME = "Hypit"; +export const ORCAROUTER_KEY_DASHBOARD = "https://www.orcarouter.ai/console/authorized-apps"; + +/** + * The authorization this Provider declares to the host. The consent endpoint takes no redirect, so + * the code is displayed and pasted back; S256 is therefore mandatory and the exchange sends it + * explicitly. The exchange returns a durable key rather than a refreshable token pair, so the + * credential is stored as an ordinary secret. + */ +export function orcaRouterAcquisition(options: { + readonly authBaseUrl?: string; + readonly requestTimeoutMs?: number; +} = {}): CredentialAcquisition { + const authBase = (options.authBaseUrl ?? "https://www.orcarouter.ai").replace(/\/+$/u, ""); + assert(/^https:\/\//u.test(authBase) || /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/u.test(authBase), + "OrcaRouter auth base must use HTTPS, or HTTP on loopback"); + return { + kind: "oauth2-pkce", + authorizationEndpoint: `${authBase}/auth`, + // The relay is at /v1; authentication is not. /v1/auth/keys is a 404. + tokenEndpoint: `${authBase}/api/v1/auth/keys`, + clientId: "", + scopes: ["api"], + requestTimeoutMs: options.requestTimeoutMs ?? 30_000, + delivery: "out-of-band", + authorizeParams: { + callback_url: "oob", + app_name: ORCAROUTER_APP_NAME, + scope: "api", + }, + exchange: { + encoding: "json", + fields: { code_challenge_method: "S256" }, + credentialField: "key", + credentialFormat: "opaque", + requiredScope: "api", + }, + }; +} + +/** A fresh PKCE verifier. Cryptographic randomness, never derived from anything guessable. */ +export function pkceVerifier(): string { + return randomBytes(32).toString("base64url"); +} diff --git a/packages/provider-orcarouter/src/index.ts b/packages/provider-orcarouter/src/index.ts new file mode 100644 index 000000000..a6dbdf90b --- /dev/null +++ b/packages/provider-orcarouter/src/index.ts @@ -0,0 +1,47 @@ +export { + CATALOG_LIMITS, + CHAT_ENDPOINT_TYPES, + NON_CHAT_ENDPOINT_TYPES, + ORCAROUTER_CATALOG_URL, + ORCAROUTER_SEED_MODELS, + fetchOrcaRouterCatalog, + filterCatalog, + parseCatalogResponse, +} from "./catalog.js"; +export type { CatalogModel, CatalogResult, OrcaRouterCapability } from "./catalog.js"; +export { OrcaRouterClient, OrcaRouterHttpError } from "./client.js"; +export type { OrcaRouterChatMessage } from "./client.js"; +export { + ORCAROUTER_APP_NAME, + ORCAROUTER_KEY_DASHBOARD, + advanceCredentialGeneration, + apiKeyCredentialAdapter, + createOrcaRouterCredential, + currentCredentialGeneration, + decodeOrcaRouterCredential, + encodeOrcaRouterCredential, + orcaRouterAcquisition, + pkceVerifier, + pkceCredentialAdapter, + resetCredentialGenerations, + storeOrcaRouterCredential, +} from "./credentials.js"; +export type { + OrcaRouterCredential, + OrcaRouterCredentialAdapter, + OrcaRouterCredentialSource, + OrcaRouterCredentialState, + StoredOrcaRouterCredential, +} from "./credentials.js"; +export { + MAX_REFERENCE_IMAGES, + ORCAROUTER_API_BASE_URL, + ORCAROUTER_AUTH_BASE_URL, + ORCAROUTER_DEFAULT_ENDPOINT, + createOrcaRouterProvider, + orcaRouterCapability, + orcaRouterChatBody, + orcaRouterOrigins, + orcaRouterProviderModuleRef, +} from "./provider.js"; +export type { CreateOrcaRouterProviderOptions, OrcaRouterOrigins } from "./provider.js"; diff --git a/packages/provider-orcarouter/src/provider.ts b/packages/provider-orcarouter/src/provider.ts new file mode 100644 index 000000000..9b946e75f --- /dev/null +++ b/packages/provider-orcarouter/src/provider.ts @@ -0,0 +1,286 @@ +import type { + AsyncEndpoint, EndpointCredential, EndpointFulfillment, EndpointRequest, EndpointSupport, ResourceStore, +} from "@hypit/endpoint-kit"; +import { defineEndpointPackage } from "@hypit/endpoint-kit"; +import { orcaRouterCapabilities, orcaRouterTypes } from "@hypit/orcarouter"; +import { imageDataUrl } from "@hypit/orcarouter"; +import { canonicalize } from "@hypit/protocol"; +import type { BlobRef, CanonicalValue } from "@hypit/protocol"; +import { credentialRef } from "@hypit/runtime"; +import type { CredentialRef } from "@hypit/runtime"; +import type { RuntimeDoctorDiagnostic } from "@hypit/runtime-kit"; + +import { OrcaRouterClient } from "./client.js"; +import type { OrcaRouterChatMessage } from "./client.js"; +import { + ORCAROUTER_KEY_DASHBOARD, apiKeyCredentialAdapter, createOrcaRouterCredential, orcaRouterAcquisition, + pkceCredentialAdapter, +} from "./credentials.js"; +import type { OrcaRouterCredential, OrcaRouterCredentialAdapter } from "./credentials.js"; + +export const orcaRouterProviderModuleRef = { name: "@hypit/provider-orcarouter", version: "1" } as const; + +/** Public OrcaRouter origins. Authentication and inference are deliberately different hosts. */ +export const ORCAROUTER_AUTH_BASE_URL = "https://www.orcarouter.ai"; +export const ORCAROUTER_API_BASE_URL = "https://api.orcarouter.ai/v1"; +export const ORCAROUTER_DEFAULT_ENDPOINT = "orcarouter.default"; + +/** The capability the Provider fulfills; the Model package owns its name and result type. */ +export const orcaRouterCapability = orcaRouterCapabilities.generate; + +export type CreateOrcaRouterProviderOptions = { + readonly instance?: string; + readonly pool?: string; + /** Inference and model-discovery base. Never derived from the authorization base. */ + readonly baseUrl?: string; + /** Authorization and code-exchange base. Never derived from the inference base. */ + readonly authBaseUrl?: string; + readonly apiKey?: CredentialRef; + readonly defaultConcurrency?: number; + readonly requestTimeoutMs?: number; + readonly oauthRequestTimeoutMs?: number; + readonly fetch?: typeof globalThis.fetch; +}; + +export type OrcaRouterOrigins = { + readonly apiBaseUrl: string; + readonly authBaseUrl: string; +}; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +/** Remote origins require HTTPS; plain HTTP is permitted only on loopback. */ +function normalizeOrigin(value: string, subject: string): string { + const trimmed = value.trim().replace(/\/+$/u, ""); + assert(trimmed.length > 0, `${subject} is empty`); + let url: URL; + try { + url = new URL(trimmed); + } catch { + throw new Error(`${subject} must be an absolute URL`); + } + const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]"; + assert(url.protocol === "https:" || (loopback && url.protocol === "http:"), + `${subject} must use HTTPS, or HTTP on loopback`); + return trimmed; +} + +/** + * Resolve both origins from the environment. An explicit per-origin override wins, then the shared + * self-hosted `ORCA_BASE_URL`, then the public default. Neither origin is ever derived from the + * other: the relay is at `/v1` and authentication is not. + */ +export function orcaRouterOrigins(environment: Readonly> = process.env): OrcaRouterOrigins { + const shared = environment.ORCA_BASE_URL; + const authExplicit = environment.ORCA_AUTH_BASE_URL; + const apiExplicit = environment.ORCA_API_BASE_URL; + const apiBaseUrl = normalizeOrigin(apiExplicit ?? shared ?? ORCAROUTER_API_BASE_URL, "OrcaRouter API base"); + return { + // A self-hosted deployment may state its relay base with or without the /v1 suffix. + apiBaseUrl: /\/v1$/u.test(apiBaseUrl) ? apiBaseUrl : `${apiBaseUrl}/v1`, + authBaseUrl: normalizeOrigin(authExplicit ?? shared ?? ORCAROUTER_AUTH_BASE_URL, "OrcaRouter auth base"), + }; +} + +function stored(request: EndpointRequest): Record { + const value = request.constraints; + assert(value !== null && typeof value === "object" && !Array.isArray(value), + "OrcaRouter chat request must be an object"); + return value as Record; +} + +function credentialSlot(credentials: Readonly>): EndpointCredential { + const value = credentials.apiKey?.secret; + assert(typeof value === "string" && value.length > 0, + "OrcaRouter login is unavailable; run hypit auth login orcarouter.default"); + return credentials.apiKey!; +} + +function guidedMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return /HTTP 401\b|needs authorization|login is unavailable/iu.test(message) + ? `${message}. Authorize again with hypit auth login ${ORCAROUTER_DEFAULT_ENDPOINT}, or manage keys at ${ORCAROUTER_KEY_DASHBOARD}` + : message; +} + +async function referenceDataUrl(artifact: BlobRef, resources: ResourceStore): Promise { + const bytes = await resources.get(artifact.resource); + assert(bytes !== undefined, `OrcaRouter reference image ${artifact.resource} is unavailable`); + assert(bytes.byteLength === artifact.size, `OrcaRouter reference image ${artifact.resource} size differs`); + return imageDataUrl(artifact, bytes); +} + +/** OpenAI-compatible chat body; attached images become data URLs on one user message. */ +export function orcaRouterChatBody(model: string, prompt: string, images: readonly string[]): { + readonly model: string; + readonly messages: readonly OrcaRouterChatMessage[]; +} { + if (images.length === 0) return { model, messages: [{ role: "user", content: prompt }] }; + return { + model, + messages: [{ + role: "user", + content: [ + { type: "text" as const, text: prompt }, + ...images.map((url) => ({ type: "image_url" as const, image_url: { url } })), + ], + }], + }; +} + +export function createOrcaRouterProvider(options: CreateOrcaRouterProviderOptions = {}) { + const instance = options.instance ?? ORCAROUTER_DEFAULT_ENDPOINT; + const pool = options.pool ?? instance; + const origins = orcaRouterOrigins({ + ...process.env, + ...(options.baseUrl === undefined ? {} : { ORCA_API_BASE_URL: options.baseUrl }), + ...(options.authBaseUrl === undefined ? {} : { ORCA_AUTH_BASE_URL: options.authBaseUrl }), + }); + const requestTimeoutMs = options.requestTimeoutMs ?? 120_000; + const credentialRefForSlot = options.apiKey ?? credentialRef("os", "orcarouter.apiKey"); + const client = new OrcaRouterClient({ + baseUrl: origins.apiBaseUrl, + timeout: requestTimeoutMs, + ...(options.fetch === undefined ? {} : { fetcher: options.fetch }), + }); + + /** + * Both entry points over the one credential slot. They share the slot, the inference client and + * the catalogue; only how the key was obtained differs. + */ + function adapters(credentials: Readonly>): readonly OrcaRouterCredentialAdapter[] { + const slot = credentials.apiKey; + const shared = { + secret: slot?.secret ?? "", + ref: credentialRefForSlot, + ...(slot?.replace === undefined ? {} : { replace: slot.replace }), + }; + return [apiKeyCredentialAdapter(shared), pkceCredentialAdapter(shared)]; + } + + /** The credential for one call, whichever entry point produced it. */ + function credentialFor(credentials: Readonly>): OrcaRouterCredential { + const slot = credentialSlot(credentials); + const credential = createOrcaRouterCredential({ + secret: slot.secret, + ref: credentialRefForSlot, + ...(slot.replace === undefined ? {} : { replace: slot.replace }), + }); + assert(credential !== undefined, + "OrcaRouter credential is unreadable; authorize again with hypit auth login " + ORCAROUTER_DEFAULT_ENDPOINT); + return credential; + } + + const chatEndpoint: AsyncEndpoint = { + async start(context) { + const request = stored(context.command.need); + const model = typeof request.model === "string" ? request.model : ""; + const prompt = typeof request.prompt === "string" ? request.prompt : ""; + assert(model.length > 0, "OrcaRouter chat request carries no model"); + assert(prompt.length > 0, "OrcaRouter chat request carries no prompt"); + const images = Array.isArray(request.images) ? request.images as readonly BlobRef[] : []; + const credential = credentialFor(context.credentials); + const urls: string[] = []; + for (const image of images) urls.push(await referenceDataUrl(image, context.resources)); + await context.reportProgress?.({ phase: `orcarouter:${model}` }); + const reply = await client.chat(credential, orcaRouterChatBody(model, prompt, urls)); + const result: EndpointFulfillment = { + value: { kind: "inline", value: canonicalize({ text: reply.text }) as unknown as CanonicalValue }, + }; + return { status: "completed", result }; + }, + async poll() { + // A chat completion is one request and one response; nothing is left to poll. + throw new Error("OrcaRouter chat requests complete in a single response"); + }, + }; + + /** + * Declared support. A model that is absent, unnamespaced, or asked to carry more references than + * the relay accepts is refused before any request is sent; the catalogue, not this check, decides + * which models a control may offer. + */ + function supports(request: EndpointRequest): EndpointSupport { + const body = stored(request); + if (typeof body.model !== "string" || body.model.trim().length === 0) { + return { status: "unsupported", reason: "OrcaRouter requires the catalogue model ID to call" }; + } + if (!body.model.includes("/")) { + return { + status: "unsupported", + reason: `OrcaRouter model ${body.model} is missing its vendor namespace, for example anthropic/claude-opus-4.8`, + }; + } + if (typeof body.prompt !== "string" || body.prompt.trim().length === 0) { + return { status: "unsupported", reason: "OrcaRouter requires a prompt" }; + } + if (Array.isArray(body.images) && body.images.length > MAX_REFERENCE_IMAGES) { + return { status: "unsupported", reason: `OrcaRouter accepts at most ${MAX_REFERENCE_IMAGES} reference images per request` }; + } + return { status: "supported" }; + } + + const endpoint = defineEndpointPackage({ + module: orcaRouterProviderModuleRef, + facet: "gateway", + instance, + pool, + credentials: { apiKey: credentialRefForSlot }, + credentialInputs: { + apiKey: { + // Both entry points are named apart wherever they can appear: paste a key, or sign in. + label: "OrcaRouter - API (paste sk-orca-…) · OrcaRouter - Auth (hypit auth login)", + kind: "secret", + acquisition: orcaRouterAcquisition({ + authBaseUrl: origins.authBaseUrl, + requestTimeoutMs: options.oauthRequestTimeoutMs ?? 30_000, + }), + }, + }, + ...(options.defaultConcurrency === undefined ? {} : { defaultConcurrency: options.defaultConcurrency }), + capabilities: [{ + capability: orcaRouterCapability, + returns: orcaRouterTypes.chat, + lifecycle: "asynchronous" as const, + endpoint: chatEndpoint, + supports, + capacity: "orcarouter-chat", + }], + // The Provider's own published rate page; Hypit never copies or interprets the rates. + pricing: { kind: "page", url: "https://www.orcarouter.ai/pricing" }, + }); + return { + endpoint, + origins, + adapters, + credentialFor, + // Exposed for callers that read the catalogue or chat through this exact configured instance. + client, + diagnose: async (context: { + readonly credentials: Readonly>; + readonly capabilities?: readonly import("@hypit/protocol").CapabilityRef[]; + }): Promise => { + const diagnostics: RuntimeDoctorDiagnostic[] = [{ + severity: "info", + code: "ORCAROUTER_ORIGINS", + message: `inference ${origins.apiBaseUrl}; authorization ${origins.authBaseUrl}`, + }]; + const secret = context.credentials.apiKey?.secret; + if (typeof secret !== "string" || secret.length === 0) { + diagnostics.push({ + severity: "warning", + code: "ORCAROUTER_CREDENTIAL_MISSING", + message: "No OrcaRouter credential is configured. Paste an sk-orca-… key, or authorize with " + + `hypit auth login ${instance}.`, + }); + } + return diagnostics; + }, + }; +} + +export const MAX_REFERENCE_IMAGES = 8; + +export type { OrcaRouterCredential, OrcaRouterCredentialAdapter }; diff --git a/packages/provider-orcarouter/test/catalog.test.ts b/packages/provider-orcarouter/test/catalog.test.ts new file mode 100644 index 000000000..5d7fd54ea --- /dev/null +++ b/packages/provider-orcarouter/test/catalog.test.ts @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { CATALOG_LIMITS, ORCAROUTER_SEED_MODELS, filterCatalog, parseCatalogResponse } from "../src/catalog.js"; +import type { CatalogModel } from "../src/catalog.js"; + +/** One catalogue record in the shape `GET /v1/models` returns. */ +function entry(id: string, endpointTypes: readonly string[], inputModalities?: readonly string[]): Record { + return { + id, + object: "model", + name: `${id} display`, + supported_endpoint_types: [...endpointTypes], + ...(inputModalities === undefined ? {} : { architecture: { input_modalities: [...inputModalities] } }), + }; +} + +const live = parseCatalogResponse({ + data: [ + entry("deepseek/deepseek-v4-pro", ["openai", "openai-response"], ["text"]), + entry("deepseek/deepseek-v4.1-flash", ["openai", "openai-response", "anthropic"], ["text", "image"]), + entry("orcarouter/auto", ["openai", "openai-response", "anthropic", "gemini"]), + entry("openai/gpt-image-2", ["openai", "image-generation"]), + entry("vendor/embed-1", ["embeddings"]), + entry("vendor/video-1", ["openai-video"]), + entry("vendor/rerank-1", ["jina-rerank"]), + entry("vendor/declared-nothing", ["openai"]), + entry("vendor/no-endpoints", []), + ], +}); + +test("catalogue parsing keeps the vendor namespace and drops unusable records", () => { + const ids = live.map((model) => model.id); + assert.ok(ids.includes("deepseek/deepseek-v4.1-flash")); + assert.deepEqual(parseCatalogResponse({ data: [{ object: "model" }, null, 7, { id: "" }] }), []); + assert.throws(() => parseCatalogResponse({ models: [] } as unknown), /no data array/u); +}); + +test("catalogue parsing bounds the response it accepts", () => { + const many = parseCatalogResponse({ data: Array.from({ length: 900 }, (_item, index) => entry(`vendor/m${index}`, ["openai"])) }); + assert.equal(many.length, CATALOG_LIMITS.maxModels); + const long = parseCatalogResponse({ data: [entry("x".repeat(CATALOG_LIMITS.maxStringLength + 1), ["openai"])] }); + assert.deepEqual(long, []); +}); + +test("text controls offer only chat-capable models from the live catalogue", () => { + const chat = filterCatalog(live, "chat").map((model) => model.id); + assert.deepEqual(chat, ["deepseek/deepseek-v4-pro", "deepseek/deepseek-v4.1-flash", "orcarouter/auto", "vendor/declared-nothing"]); + // A media-only or embedding-only entry never reaches a text control. + for (const id of ["openai/gpt-image-2", "vendor/embed-1", "vendor/video-1", "vendor/rerank-1"]) { + assert.ok(!chat.includes(id), `${id} must not appear in the text control`); + } +}); + +test("only entries that declare image input reach the multimodal control", () => { + const multimodal = filterCatalog(live, "multimodal", "image").map((model) => model.id); + assert.deepEqual(multimodal, ["deepseek/deepseek-v4.1-flash"]); + // An entry with no architecture block fails closed rather than being assumed to accept images. + const undeclared = live.find((model) => model.id === "orcarouter/auto")!; + assert.deepEqual(undeclared.inputModalities, []); + assert.ok(!multimodal.includes("orcarouter/auto")); +}); + +test("each non-text control matches only its own endpoint type", () => { + assert.deepEqual(filterCatalog(live, "image").map((model) => model.id), ["openai/gpt-image-2"]); + assert.deepEqual(filterCatalog(live, "embedding").map((model) => model.id), ["vendor/embed-1"]); + assert.deepEqual(filterCatalog(live, "video").map((model) => model.id), ["vendor/video-1"]); + assert.deepEqual(filterCatalog(live, "rerank").map((model) => model.id), ["vendor/rerank-1"]); +}); + +test("the verified seed keeps its metadata and is recognisable as an outage fallback", () => { + const seed = ORCAROUTER_SEED_MODELS; + assert.deepEqual(seed.map((model) => model.id), [ + "orcarouter/auto", "openai/gpt-5.5", "anthropic/claude-opus-4.8", "google/gemini-3.5-flash", "deepseek/deepseek-v4-pro", + ]); + const gpt = seed.find((model) => model.id === "openai/gpt-5.5")!; + assert.deepEqual(gpt.reasoningEfforts, ["low", "medium", "high", "xhigh"]); + assert.equal(gpt.contextLength, 400_000); + assert.ok(seed.every((model) => model.origin === "seed")); + // The seed is a chat-only fallback: it advertises no capability the catalogue has not confirmed. + assert.deepEqual(filterCatalog(seed, "chat").map((model) => model.id), seed.map((model: CatalogModel) => model.id)); + assert.deepEqual(filterCatalog(seed, "image"), []); + assert.deepEqual(filterCatalog(seed, "embedding"), []); +}); diff --git a/packages/provider-orcarouter/test/connect.test.ts b/packages/provider-orcarouter/test/connect.test.ts new file mode 100644 index 000000000..be701f0cf --- /dev/null +++ b/packages/provider-orcarouter/test/connect.test.ts @@ -0,0 +1,224 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { createServer } from "node:http"; +import type { IncomingMessage, Server, ServerResponse } from "node:http"; +import test from "node:test"; + +import { acquireOAuthCredential } from "@hypit/cli/oauth-host"; +import type { CredentialAcquisition } from "@hypit/runtime"; + +import { decodeOrcaRouterCredential } from "../src/credentials.js"; +import { createOrcaRouterProvider, orcaRouterOrigins } from "../src/provider.js"; +import { orcaRouterAcquisition } from "../src/credentials.js"; + +/** + * A local stand-in for the OrcaRouter authorization service. It records exactly what the connect + * adapter sent, so the test can prove the flow's shape rather than only its outcome. + */ +type Recorded = { + readonly authorize: URL[]; + readonly exchanges: { readonly url: string; readonly body: Record; readonly contentType: string }[]; +}; + +function authServer(options: { + readonly scope?: string; + readonly status?: number; + readonly body?: Record; +} = {}): { readonly server: Server; readonly recorded: Recorded; readonly url: string } { + const recorded: Recorded = { authorize: [], exchanges: [] }; + const server = createServer((request: IncomingMessage, response: ServerResponse) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (url.pathname === "/auth") { + recorded.authorize.push(url); + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + // The consent screen displays the code; nothing is delivered to a callback. + response.end("

code: FAKE-CODE

"); + return; + } + if (url.pathname === "/api/v1/auth/keys" && request.method === "POST") { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8"); + recorded.exchanges.push({ + url: `http://127.0.0.1:${(server.address() as { port: number }).port}${url.pathname}`, + body: JSON.parse(raw) as Record, + contentType: String(request.headers["content-type"] ?? ""), + }); + response.writeHead(options.status ?? 200, { "content-type": "application/json" }); + response.end(JSON.stringify(options.body ?? { key: "sk-orca-minted-by-consent", user_id: "12345", scope: options.scope ?? "api" })); + }); + return; + } + // The relay's own spellings must never be reached. + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "not_found", path: url.pathname })); + }); + return { server, recorded, url: "" }; +} + +async function listen(server: Server): Promise { + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const address = server.address() as { port: number }; + return `http://127.0.0.1:${address.port}`; +} + +async function close(server: Server): Promise { + await new Promise((resolve) => { server.close(() => resolve()); server.closeAllConnections?.(); }); +} + +/** The Endpoint's declaration with the local fake service standing in for the public origins. */ +function acquisitionFor(base: string, overrides: Partial = {}): CredentialAcquisition { + const declared = orcaRouterAcquisition({ authBaseUrl: base }); + return { ...declared, ...overrides }; +} + +test("connect: authorize -> displayed code -> exchange -> persist, through the OrcaRouter adapter", async () => { + const { server, recorded } = authServer(); + const base = await listen(server); + const opened: string[] = []; + let persisted = ""; + try { + const raw = await acquireOAuthCredential(acquisitionFor(base), { + open: (url) => opened.push(url), + readCode: async () => "FAKE-CODE", + }); + assert.equal(raw, "sk-orca-minted-by-consent"); + assert.equal(opened.length, 1); + + // 1. The authorize URL asks for an out-of-band code with an S256 challenge and a state. + const authorize = new URL(opened[0]!); + assert.equal(authorize.origin, base); + assert.equal(authorize.pathname, "/auth"); + assert.equal(authorize.searchParams.get("callback_url"), "oob"); + assert.equal(authorize.searchParams.get("code_challenge_method"), "S256"); + assert.equal(authorize.searchParams.get("scope"), "api"); + assert.equal(authorize.searchParams.get("app_name"), "Hypit"); + const challenge = authorize.searchParams.get("code_challenge")!; + const state = authorize.searchParams.get("state")!; + assert.match(state, /^[A-Za-z0-9_-]{32}$/u); + + // 2. The exchange goes to /api/v1/auth/keys on the auth origin, in the declared JSON shape, + // and carries the verifier whose SHA-256 is the challenge — nothing else could redeem it. + assert.equal(recorded.exchanges.length, 1); + const exchange = recorded.exchanges[0]!; + assert.equal(exchange.url, `${new URL(base).origin}/api/v1/auth/keys`); + assert.match(exchange.contentType, /application\/json/u); + assert.equal(exchange.body.code, "FAKE-CODE"); + assert.equal(exchange.body.code_challenge_method, "S256"); + assert.equal(exchange.body.grant_type, undefined); + const verifier = exchange.body.code_verifier!; + assert.equal(Buffer.from(createHash("sha256").update(verifier).digest()).toString("base64url"), challenge); + // The verifier never travelled on the authorize URL. + assert.ok(!opened[0]!.includes(verifier)); + + // 3. What lands in the slot is an ordinary OrcaRouter key: the relay's Bearer value, not an + // envelope and not a refreshable token pair. + persisted = raw; + assert.equal(persisted, "sk-orca-minted-by-consent"); + assert.equal(decodeOrcaRouterCredential(persisted)?.key, "sk-orca-minted-by-consent"); + } finally { + await close(server); + } +}); + +test("connect: a narrower granted scope is refused rather than assumed", async () => { + const { server } = authServer({ scope: "connector" }); + const base = await listen(server); + try { + await assert.rejects( + async () => await acquireOAuthCredential(acquisitionFor(base), { + open: () => {}, + readCode: async () => "FAKE-CODE", + }), + /granted scope "connector", not "api"/u, + ); + } finally { + await close(server); + } +}); + +test("connect: a refused or expired code ends the attempt cleanly", async () => { + for (const status of [400, 403, 429]) { + const { server } = authServer({ status, body: { error: "invalid_grant" } }); + const base = await listen(server); + try { + await assert.rejects( + async () => await acquireOAuthCredential(acquisitionFor(base), { + open: () => {}, + readCode: async () => "FAKE-CODE", + }), + new RegExp(`token exchange failed \\(${status}\\)`, "u"), + ); + } finally { + await close(server); + } + } +}); + +test("connect: a decline and a missing code both stop without a request", async () => { + const { server, recorded } = authServer(); + const base = await listen(server); + try { + await assert.rejects( + async () => await acquireOAuthCredential(acquisitionFor(base), { open: () => {}, readCode: async () => undefined }), + /no authorization code was entered/u, + ); + assert.equal(recorded.exchanges.length, 0); + } finally { + await close(server); + } +}); + +test("connect: each attempt uses a fresh verifier and state", async () => { + const { server, recorded } = authServer(); + const base = await listen(server); + const opened: string[] = []; + try { + for (let attempt = 0; attempt < 2; attempt += 1) { + await acquireOAuthCredential(acquisitionFor(base), { + open: (url) => opened.push(url), + readCode: async () => "FAKE-CODE", + }); + } + assert.equal(opened.length, 2); + assert.notEqual(new URL(opened[0]!).searchParams.get("state"), new URL(opened[1]!).searchParams.get("state")); + assert.notEqual(new URL(opened[0]!).searchParams.get("code_challenge"), new URL(opened[1]!).searchParams.get("code_challenge")); + assert.notEqual(recorded.exchanges[0]!.body.code_verifier, recorded.exchanges[1]!.body.code_verifier); + } finally { + await close(server); + } +}); + +test("connect: the minted key reaches inference on the relay origin, not the auth origin", async () => { + const { server } = authServer(); + const base = await listen(server); + const calls: { readonly url: string; readonly authorization: string }[] = []; + try { + const key = await acquireOAuthCredential(acquisitionFor(base), { + open: () => {}, + readCode: async () => "FAKE-CODE", + }); + const provider = createOrcaRouterProvider({ + fetch: async (input, init) => { + calls.push({ + url: String(input), + authorization: String((init?.headers as Record | undefined)?.authorization ?? ""), + }); + return Response.json({ data: [{ id: "deepseek/deepseek-v4.1-flash", supported_endpoint_types: ["openai"] }] }); + }, + }); + const credential = provider.credentialFor({ apiKey: { secret: key } }); + const catalog = await provider.client.models(credential, "chat"); + assert.equal(catalog.source, "live"); + assert.deepEqual(catalog.models.map((model) => model.id), ["deepseek/deepseek-v4.1-flash"]); + // Model discovery uses the inference origin and the key, whichever entry point produced it. + assert.equal(calls[0]!.url, "https://api.orcarouter.ai/v1/models?capability=chat"); + assert.equal(calls[0]!.authorization, "Bearer sk-orca-minted-by-consent"); + // The authorization origin is untouched by inference: only the relay host is called. + assert.equal(new URL(calls[0]!.url).host, "api.orcarouter.ai"); + assert.equal(orcaRouterOrigins({}).authBaseUrl, "https://www.orcarouter.ai"); + } finally { + await close(server); + } +}); diff --git a/packages/provider-orcarouter/test/credentials.test.ts b/packages/provider-orcarouter/test/credentials.test.ts new file mode 100644 index 000000000..789b39c78 --- /dev/null +++ b/packages/provider-orcarouter/test/credentials.test.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { credentialRef } from "@hypit/runtime"; +import type { CredentialValue } from "@hypit/runtime"; + +import { + advanceCredentialGeneration, apiKeyCredentialAdapter, createOrcaRouterCredential, decodeOrcaRouterCredential, + encodeOrcaRouterCredential, orcaRouterAcquisition, pkceVerifier, pkceCredentialAdapter, resetCredentialGenerations, + storeOrcaRouterCredential, +} from "../src/credentials.js"; + +const ref = credentialRef("os", "orcarouter.apiKey"); + +/** A stand-in for the Endpoint's writable credential slot. */ +function slot(secret: string) { + let value = secret; + return { + read: () => value, + replace: async (next: CredentialValue) => { value = next.secret; }, + }; +} + +test.beforeEach(() => { resetCredentialGenerations(); }); + +test("a pasted API key and a PKCE-issued key resolve to the same credential", async () => { + const pasted = slot("sk-orca-pasted-key"); + const issued = slot(encodeOrcaRouterCredential({ + key: "sk-orca-minted-key", generation: 1, state: "ready", issuedVia: "oauth", + })); + const apiKey = await apiKeyCredentialAdapter({ secret: pasted.read(), ref, replace: pasted.replace }).resolve(); + const oauth = await pkceCredentialAdapter({ secret: issued.read(), ref, replace: issued.replace }).resolve(); + assert.ok(apiKey !== undefined && oauth !== undefined); + // The same interface yields the same Bearer value type; only the recorded entry point differs. + assert.equal(await apiKey.token(), "sk-orca-pasted-key"); + assert.equal(await oauth.token(), "sk-orca-minted-key"); + assert.equal(apiKey.source, "api-key"); + assert.equal(oauth.source, "oauth"); + assert.equal(apiKey.status(), "ready"); + assert.equal(oauth.status(), "ready"); + // Neither entry point resolves a key that arrived through the other one. + assert.equal(await apiKeyCredentialAdapter({ secret: issued.read(), ref }).resolve(), undefined); + assert.equal(await pkceCredentialAdapter({ secret: pasted.read(), ref }).resolve(), undefined); +}); + +test("the API-key adapter reports a missing credential instead of inventing one", async () => { + assert.equal(await apiKeyCredentialAdapter({ secret: "", ref }).resolve(), undefined); + assert.equal(await pkceCredentialAdapter({ secret: " ", ref }).resolve(), undefined); +}); + +test("a credential round-trips through the stored envelope and nothing else is accepted", () => { + const encoded = encodeOrcaRouterCredential({ key: "sk-orca-abc", generation: 3, state: "ready", issuedVia: "oauth" }); + assert.deepEqual(decodeOrcaRouterCredential(encoded), + { key: "sk-orca-abc", generation: 3, state: "ready", issuedVia: "oauth" }); + assert.equal(decodeOrcaRouterCredential("not json")?.key, "not json"); + assert.equal(decodeOrcaRouterCredential('{"format":"other","key":"x"}'), undefined); + assert.equal(decodeOrcaRouterCredential('{"format":"hypit.orcarouter-credential@1"}'), undefined); + assert.equal(decodeOrcaRouterCredential(""), undefined); +}); + +test("storing a key advances the generation so an older rejection cannot reach it", async () => { + const target = slot(""); + const first = await storeOrcaRouterCredential({ ref, replace: target.replace, key: "sk-orca-one", issuedVia: "oauth" }); + const stale = createOrcaRouterCredential({ secret: target.read(), ref, replace: target.replace })!; + const second = await storeOrcaRouterCredential({ ref, replace: target.replace, key: "sk-orca-two", issuedVia: "oauth" }); + assert.equal(second.generation, first.generation + 1); + // Rejecting the credential that made the rejected request must not mark the newer login broken: + // the late failure is dropped and the credential a fresh login stored stays ready. + assert.equal(await stale.reject(), "ready"); + assert.equal(createOrcaRouterCredential({ secret: target.read(), ref, replace: target.replace })!.status(), "ready"); + assert.equal(advanceCredentialGeneration(ref), second.generation + 1); +}); + +test("a rejected durable key is terminal and is never refreshed", async () => { + const target = slot("sk-orca-durable"); + const credential = createOrcaRouterCredential({ secret: target.read(), ref, replace: target.replace })!; + assert.equal(credential.status(), "ready"); + assert.equal(await credential.reject(), "needsReauth"); + // The marker is persisted, so the state survives a restart of the process. + assert.equal(JSON.parse(target.read()).state, "needsReauth"); + await assert.rejects(async () => await credential.token(), /needs authorization/u); + const reopened = createOrcaRouterCredential({ secret: target.read(), ref, replace: target.replace })!; + assert.equal(reopened.status(), "needsReauth"); + await assert.rejects(async () => await reopened.token(), /needs authorization/u); +}); + +test("the declared authorization names the OrcaRouter auth origin and exchange exactly", () => { + const acquisition = orcaRouterAcquisition({ authBaseUrl: "https://www.orcarouter.ai" }); + assert.equal(acquisition.authorizationEndpoint, "https://www.orcarouter.ai/auth"); + assert.equal(acquisition.tokenEndpoint, "https://www.orcarouter.ai/api/v1/auth/keys"); + // The relay's own host must never carry the exchange: `https://api.orcarouter.ai/v1/auth/keys` is a 404. + assert.equal(new URL(acquisition.tokenEndpoint).host, "www.orcarouter.ai"); + assert.equal(new URL(acquisition.authorizationEndpoint).host, "www.orcarouter.ai"); + assert.equal(acquisition.delivery, "out-of-band"); + assert.deepEqual(acquisition.authorizeParams, { callback_url: "oob", app_name: "Hypit", scope: "api" }); + assert.equal(acquisition.scopes[0], "api"); + assert.equal(acquisition.exchange?.encoding, "json"); + assert.equal(acquisition.exchange?.fields?.code_challenge_method, "S256"); + assert.equal(acquisition.exchange?.credentialField, "key"); + assert.equal(acquisition.exchange?.credentialFormat, "opaque"); + assert.equal(acquisition.exchange?.requiredScope, "api"); + // No client secret is involved anywhere in the flow. + assert.equal("clientSecret" in acquisition, false); +}); + +test("a self-hosted auth origin is honoured and non-loopback HTTP is refused", () => { + assert.equal(orcaRouterAcquisition({ authBaseUrl: "http://127.0.0.1:8080" }).tokenEndpoint, + "http://127.0.0.1:8080/api/v1/auth/keys"); + assert.throws(() => orcaRouterAcquisition({ authBaseUrl: "http://orcarouter.example" }), /HTTPS/u); +}); + +test("each attempt gets a fresh verifier and no verifier is derived from a constant", () => { + const first = pkceVerifier(); + const second = pkceVerifier(); + assert.notEqual(first, second); + assert.match(first, /^[A-Za-z0-9_-]{43}$/u); +}); diff --git a/packages/provider-orcarouter/test/live.test.ts b/packages/provider-orcarouter/test/live.test.ts new file mode 100644 index 000000000..96308817d --- /dev/null +++ b/packages/provider-orcarouter/test/live.test.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createOrcaRouterCredential } from "../src/credentials.js"; +import { OrcaRouterClient, OrcaRouterHttpError } from "../src/client.js"; +import { ORCAROUTER_API_BASE_URL, ORCAROUTER_AUTH_BASE_URL, orcaRouterOrigins } from "../src/provider.js"; +import { CHAT_ENDPOINT_TYPES, filterCatalog } from "../src/catalog.js"; + +/** + * The live path, run only when an OrcaRouter key is configured. It goes through the same client the + * Endpoint uses, so a passing run is evidence that the wiring works rather than that the API exists. + * + * The key is read from the environment and is never printed, logged or included in an assertion + * message: every failure below reports a status or a model ID. + */ +const apiKey = process.env.ORCAROUTER_API_KEY; +const keyReady = typeof apiKey === "string" && apiKey.startsWith("sk-orca-"); + +/** One credential held in memory; no store is written by a read-only live check. */ +function credential() { + const created = createOrcaRouterCredential({ + secret: JSON.stringify({ + format: "hypit.orcarouter-credential@1", + key: apiKey!, + generation: 0, + state: "ready", + issuedVia: "api-key", + }), + ref: { store: "env", key: "ORCAROUTER_API_KEY" }, + }); + assert.ok(created !== undefined, "the configured key must read as an OrcaRouter credential"); + return created; +} + +test("live: the declared origins keep authentication and inference apart", () => { + const origins = orcaRouterOrigins({}); + assert.equal(origins.authBaseUrl, ORCAROUTER_AUTH_BASE_URL); + assert.equal(origins.apiBaseUrl, ORCAROUTER_API_BASE_URL); + assert.equal(new URL(origins.authBaseUrl).host, "www.orcarouter.ai"); + assert.equal(new URL(origins.apiBaseUrl).host, "api.orcarouter.ai"); + assert.equal(new URL(origins.apiBaseUrl).pathname, "/v1"); +}); + +test("live: the catalogue answers on the inference origin and keeps vendor namespaces", { + skip: !keyReady && "ORCAROUTER_API_KEY is not configured", + timeout: 60_000, +}, async () => { + const client = new OrcaRouterClient({ baseUrl: orcaRouterOrigins({}).apiBaseUrl, timeout: 30_000 }); + const result = await client.models(credential(), "chat"); + assert.equal(result.source, "live", `the live catalogue must answer: ${result.detail ?? "no detail"}`); + assert.ok(result.models.length > 0, "the account must be able to call at least one chat model"); + for (const model of result.models) { + assert.equal(model.id.includes("/"), true, `a catalogue ID keeps its vendor namespace: ${model.id}`); + assert.equal(model.origin, "live"); + } + // The text control's filter is the Provider's own, not a name-matching heuristic. + assert.deepEqual(result.models.map((model) => model.id).sort(), + filterCatalog(result.models, "chat").map((model) => model.id).sort()); + assert.equal(result.models.every((model) => + model.endpointTypes.some((type) => CHAT_ENDPOINT_TYPES.includes(type))), true); +}); + +test("live: a real chat completion is returned through the implemented client", { + skip: !keyReady && "ORCAROUTER_API_KEY is not configured", + timeout: 180_000, +}, async () => { + const origins = orcaRouterOrigins({}); + const client = new OrcaRouterClient({ baseUrl: origins.apiBaseUrl, timeout: 60_000 }); + const held = credential(); + const catalogue = await client.models(held, "chat"); + assert.equal(catalogue.source, "live", "the model to call comes from the live catalogue"); + // A key may be scoped to a subset of the workspace catalogue, which the relay reports as 403. The + // check calls the catalogue's own models until one answers, and reports the statuses it saw. + const attempted: string[] = []; + let reply: { readonly text: string } | undefined; + for (const model of catalogue.models) { + try { + reply = await client.chat(held, { + model: model.id, + messages: [{ role: "user", content: "Reply with the single word: ready" }], + }); + attempted.push(`${model.id}:ok`); + break; + } catch (error) { + attempted.push(`${model.id}:${error instanceof OrcaRouterHttpError ? error.status : "error"}`); + } + } + assert.ok(reply !== undefined, `no catalogue model answered this key (${attempted.join(", ")})`); + assert.ok(reply.text.trim().length > 0, "a real completion must carry text"); + assert.equal(reply.text.includes(apiKey!), false, "a reply never echoes the credential"); +}); + +test("live: image input is offered only for models whose catalogue entry declares it", { + skip: !keyReady && "ORCAROUTER_API_KEY is not configured", + timeout: 60_000, +}, async () => { + const client = new OrcaRouterClient({ baseUrl: orcaRouterOrigins({}).apiBaseUrl, timeout: 30_000 }); + const held = credential(); + const text = await client.models(held, "chat"); + const multimodal = await client.models(held, "multimodal", "image"); + assert.equal(multimodal.source, "live"); + // Every entry the multimodal control may offer proves image input in its own metadata. + for (const model of multimodal.models) { + assert.equal(model.inputModalities.includes("image"), true, + `${model.id} is offered for image input without declaring it`); + assert.equal(text.models.some((candidate) => candidate.id === model.id), true, + `${model.id} must also be a chat model`); + } + // The multimodal control never grows beyond the chat control. + assert.ok(multimodal.models.length <= text.models.length); + // With no image-input model in the catalogue, the multimodal control stays empty rather than + // falling back to models that merely look capable. + if (multimodal.models.length === 0) { + assert.equal(text.models.every((model) => !model.inputModalities.includes("image")), true, + "an empty multimodal control means no chat model declared image input"); + } +}); diff --git a/packages/provider-orcarouter/test/provider.test.ts b/packages/provider-orcarouter/test/provider.test.ts new file mode 100644 index 000000000..0a604eb8b --- /dev/null +++ b/packages/provider-orcarouter/test/provider.test.ts @@ -0,0 +1,229 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { AsyncEndpoint, EndpointCredential, EndpointInvocationContext, EndpointRequest } from "@hypit/endpoint-kit"; +import { orcaRouterCapabilities, orcaRouterTypes } from "@hypit/orcarouter"; +import { canonicalize } from "@hypit/protocol"; +import { credentialRef } from "@hypit/runtime"; +import type { ResourceStore } from "@hypit/runtime"; + +import type { OrcaRouterChatMessage } from "../src/client.js"; +import { createOrcaRouterProvider, orcaRouterOrigins } from "../src/provider.js"; + +const ref = credentialRef("os", "orcarouter.apiKey"); + +/** A record-shaped request exactly as the OrcaRouter Surface seals it. */ +function request(overrides: Partial<{ model: string; prompt: string; images: readonly unknown[] }> = {}): EndpointRequest { + return { + capability: orcaRouterCapabilities.generate, + returns: orcaRouterTypes.chat, + constraints: canonicalize({ + model: overrides.model ?? "deepseek/deepseek-v4.1-flash", + prompt: overrides.prompt ?? "Describe this frame.", + images: overrides.images ?? [], + }), + }; +} + +const image = { kind: "blob" as const, resource: "res://frame.png", size: 4, mediaType: "image/png" }; + +function resourceStore(bytes: Readonly>): ResourceStore { + return { async get(id: string) { return bytes[id]; } } as unknown as ResourceStore; +} + +/** Capture every request the Provider makes and answer each one from a script. */ +function server(handler: (url: string, init: RequestInit) => Response | Promise) { + const calls: { readonly url: string; readonly init: RequestInit }[] = []; + const fetcher: typeof globalThis.fetch = async (input, init) => { + const url = String(input); + calls.push({ url, init: init ?? {} }); + return await handler(url, init ?? {}); + }; + return { calls, fetcher }; +} + +/** Drive the registered asynchronous capability the way the Runtime does. */ +async function start( + provider: ReturnType, + credentials: Readonly>, + need: EndpointRequest, + resources: ResourceStore = resourceStore({}), +): Promise<{ readonly status: string; readonly failure?: { readonly message: string }; readonly result?: unknown }> { + let endpoint: AsyncEndpoint | undefined; + await provider.endpoint.install({ + registerImmediateEndpoint() { throw new Error("OrcaRouter registers no immediate capability"); }, + registerAsyncEndpoint(_id, capability, _returns, value) { + if (capability.name === orcaRouterCapabilities.generate.name) endpoint = value; + }, + }); + assert.ok(endpoint !== undefined, "OrcaRouter registers the chat capability"); + const context = { + command: { + kind: "fulfill-need" as const, + id: "c1", + need: { id: "n1", capability: need.capability, returns: need.returns, constraints: need.constraints }, + }, + need: { id: "n1", capability: need.capability, returns: need.returns, constraints: need.constraints }, + resources, + credentials, + } as unknown as EndpointInvocationContext; + try { + return await endpoint.start({ ...context, operation: "op1" }) as { readonly status: string }; + } catch (error) { + return { status: "failed", failure: { message: error instanceof Error ? error.message : String(error) } }; + } +} + +const minted = (key: string, generation: number): string => + JSON.stringify({ format: "hypit.orcarouter-credential@1", key, generation, state: "ready", issuedVia: "oauth" }); + +test("a pasted API key and a PKCE-issued key take the same inference path", async () => { + for (const [stored, expectedKey, expectedSource] of [ + ["sk-orca-pasted", "sk-orca-pasted", "api-key"], + [minted("sk-orca-minted", 1), "sk-orca-minted", "oauth"], + ] as const) { + const { calls, fetcher } = server(async () => Response.json({ choices: [{ message: { content: "A frame." } }] })); + const provider = createOrcaRouterProvider({ fetch: fetcher }); + const credentials: Readonly> = { apiKey: { secret: stored } }; + const outcome = await start(provider, credentials, request()); + assert.equal(outcome.status, "completed"); + assert.deepEqual(outcome.result, { value: { kind: "inline", value: { text: "A frame." } } }); + assert.equal(calls.length, 1); + assert.equal(calls[0]!.url, "https://api.orcarouter.ai/v1/chat/completions"); + assert.equal((calls[0]!.init.headers as Record).authorization, `Bearer ${expectedKey}`); + // Exactly one of the two entry points resolves the slot, and it names the route the user took. + const resolved = await Promise.all(provider.adapters(credentials).map(async (adapter) => await adapter.resolve())); + assert.equal(resolved.filter((item) => item !== undefined).length, 1); + assert.equal(resolved.find((item) => item !== undefined)!.source, expectedSource); + } +}); + +test("attached images travel as data URLs, never as a path to local media", async () => { + const { calls, fetcher } = server(async () => Response.json({ choices: [{ message: { content: "ok" } }] })); + const provider = createOrcaRouterProvider({ fetch: fetcher }); + const outcome = await start(provider, { apiKey: { secret: "sk-orca-key" } }, request({ images: [image] }), + resourceStore({ "res://frame.png": new Uint8Array([1, 2, 3, 4]) })); + assert.equal(outcome.status, "completed"); + const body = JSON.parse(String(calls[0]!.init.body)) as { messages: readonly OrcaRouterChatMessage[] }; + const content = body.messages[0]!.content as readonly { type: string; text?: string; image_url?: { url: string } }[]; + assert.equal(content[0]!.type, "text"); + assert.equal(content[1]!.image_url!.url, `data:image/png;base64,${Buffer.from([1, 2, 3, 4]).toString("base64")}`); + assert.ok(!JSON.stringify(body).includes("res://frame.png")); +}); + +test("a missing local image fails the attempt instead of sending a broken request", async () => { + const { calls, fetcher } = server(async () => Response.json({ choices: [{ message: { content: "ok" } }] })); + const provider = createOrcaRouterProvider({ fetch: fetcher }); + const outcome = await start(provider, { apiKey: { secret: "sk-orca-key" } }, request({ images: [image] })); + assert.equal(outcome.status, "failed"); + assert.match(outcome.failure!.message, /res:\/\/frame\.png is unavailable/u); + assert.equal(calls.length, 0); +}); + +test("a rejected credential marks its exact generation needsReauth and is never refreshed", async () => { + const { calls, fetcher } = server(async () => new Response("nope", { status: 401 })); + let persisted = ""; + const provider = createOrcaRouterProvider({ fetch: fetcher }); + const credentials: Readonly> = { + apiKey: { secret: minted("sk-orca-dead", 4), replace: async (value) => { persisted = value.secret; } }, + }; + const outcome = await start(provider, credentials, request()); + assert.equal(outcome.status, "failed"); + assert.match(outcome.failure!.message, /HTTP 401/u); + assert.match(outcome.failure!.message, /console\/authorized-apps/u); + assert.equal(calls.length, 1, "a 401 is terminal, not a retry or a refresh"); + // The key is kept and only its state changes, so a misclassified failure is never destructive. + const decoded = JSON.parse(persisted) as { key: string; state: string; generation: number }; + assert.deepEqual(decoded, { format: "hypit.orcarouter-credential@1", key: "sk-orca-dead", generation: 4, state: "needsReauth", issuedVia: "oauth" }); +}); + +test("a malformed stored credential is refused rather than sent", async () => { + const { calls, fetcher } = server(async () => Response.json({ choices: [{ message: { content: "ok" } }] })); + const provider = createOrcaRouterProvider({ fetch: fetcher }); + const outcome = await start(provider, { apiKey: { secret: '{"format":"hypit.orcarouter-credential@1"}' } }, request()); + assert.equal(outcome.status, "failed"); + assert.match(outcome.failure!.message, /unreadable/u); + assert.equal(calls.length, 0); +}); + +test("a model without a vendor namespace or too many references is refused before sending", async () => { + const { calls, fetcher } = server(async () => Response.json({ choices: [{ message: { content: "ok" } }] })); + const provider = createOrcaRouterProvider({ fetch: fetcher }); + const offer = provider.endpoint.offers[0]!; + assert.deepEqual(offer.supports!(request({ model: "deepseek-v4-pro" })), { + status: "unsupported", + reason: "OrcaRouter model deepseek-v4-pro is missing its vendor namespace, for example anthropic/claude-opus-4.8", + }); + assert.deepEqual(offer.supports!(request({ prompt: " " })), + { status: "unsupported", reason: "OrcaRouter requires a prompt" }); + assert.equal(offer.supports!(request({ images: Array.from({ length: 9 }, () => image) })).status, "unsupported"); + assert.equal(offer.supports!(request()).status, "supported"); + assert.equal(calls.length, 0); +}); + +test("authentication and inference stay on their own origins", () => { + const origins = orcaRouterOrigins({}); + assert.equal(origins.authBaseUrl, "https://www.orcarouter.ai"); + assert.equal(origins.apiBaseUrl, "https://api.orcarouter.ai/v1"); + // The inference host is never derived from the auth host by replacing a hostname. + assert.notEqual(new URL(origins.apiBaseUrl).host, new URL(origins.authBaseUrl).host); + // A shared self-hosted base feeds both; an explicit override wins over it. + assert.deepEqual(orcaRouterOrigins({ ORCA_BASE_URL: "https://orca.internal" }), + { authBaseUrl: "https://orca.internal", apiBaseUrl: "https://orca.internal/v1" }); + assert.deepEqual(orcaRouterOrigins({ + ORCA_BASE_URL: "https://orca.internal", + ORCA_AUTH_BASE_URL: "https://login.internal", + ORCA_API_BASE_URL: "https://relay.internal/v1", + }), { authBaseUrl: "https://login.internal", apiBaseUrl: "https://relay.internal/v1" }); + assert.throws(() => orcaRouterOrigins({ ORCA_API_BASE_URL: "http://api.orcarouter.ai" }), /HTTPS/u); + assert.equal(orcaRouterOrigins({ ORCA_BASE_URL: "http://127.0.0.1:9000" }).apiBaseUrl, "http://127.0.0.1:9000/v1"); +}); + +test("the catalogue is read with the credential, on the inference origin, filtered to the control", async () => { + const { calls, fetcher } = server(async () => Response.json({ data: [ + { id: "deepseek/deepseek-v4.1-flash", supported_endpoint_types: ["openai", "anthropic"], architecture: { input_modalities: ["text", "image"] } }, + { id: "openai/gpt-image-2", supported_endpoint_types: ["image-generation"] }, + ] })); + const provider = createOrcaRouterProvider({ fetch: fetcher }); + const credential = provider.credentialFor({ apiKey: { secret: "sk-orca-key" } }); + const result = await provider.client.models(credential, "chat"); + assert.equal(result.source, "live"); + assert.deepEqual(result.models.map((model) => model.id), ["deepseek/deepseek-v4.1-flash"]); + assert.equal(calls[0]!.url, "https://api.orcarouter.ai/v1/models?capability=chat"); + assert.equal((calls[0]!.init.headers as Record).authorization, "Bearer sk-orca-key"); +}); + +test("a failed catalogue is reported as degraded rather than repaired with the seed", async () => { + for (const [response, expected] of [ + [() => new Response("boom", { status: 503 }), /HTTP 503/u], + [() => { throw new Error("socket closed"); }, /socket closed/u], + ] as const) { + const { fetcher } = server(async () => response()); + const provider = createOrcaRouterProvider({ fetch: fetcher }); + const credential = provider.credentialFor({ apiKey: { secret: "sk-orca-key" } }); + const result = await provider.client.models(credential, "chat"); + assert.equal(result.source, "seed"); + // Nothing from the seed is merged into a failed live read; the caller decides what to show. + assert.deepEqual(result.models, []); + assert.match(result.detail ?? "", expected); + } +}); + +test("the provider declares both entry points on one credential slot and its own origins", async () => { + const provider = createOrcaRouterProvider({}); + const description = provider.endpoint.credentials[0]!; + assert.equal(description.endpoint, "orcarouter.default"); + assert.equal(description.slot, "apiKey"); + assert.equal(description.ref.store, "os"); + assert.equal(description.ref.key, "orcarouter.apiKey"); + // The label names both choices, because a single "OrcaRouter" button would hide which one runs. + assert.match(description.label, /OrcaRouter - API/u); + assert.match(description.label, /OrcaRouter - Auth/u); + assert.equal(description.acquisition?.authorizationEndpoint, "https://www.orcarouter.ai/auth"); + assert.equal(description.acquisition?.tokenEndpoint, "https://www.orcarouter.ai/api/v1/auth/keys"); + assert.equal(provider.endpoint.pricing?.kind, "page"); + const labels = provider.adapters({ apiKey: { secret: "sk-orca-key" } }).map((adapter) => adapter.label); + assert.deepEqual(labels, ["OrcaRouter - API", "OrcaRouter - Auth"]); +}); + +export { ref }; diff --git a/packages/runtime-local/src/config.ts b/packages/runtime-local/src/config.ts index 3f8f02afb..2e1747fe0 100644 --- a/packages/runtime-local/src/config.ts +++ b/packages/runtime-local/src/config.ts @@ -1346,6 +1346,50 @@ export function createRuntimeResultWriter(root: string, options: LoadRuntimeConf }); } +/** + * Open only the CredentialStores the selected Runtime Profile declares, together with the credential + * slots the named Endpoint instance declares. A deployment control that needs to read or write one + * slot through the same Store the Provider will use — rather than opening a store of its own — uses + * this; it never builds a Build, a Worker or an execution Provider. + */ +export async function openRuntimeCredentialStore( + path: string, + endpointInstance: string, + options: LoadRuntimeConfigOptions = {}, +): Promise<{ + readonly store: CredentialStore; + readonly credentials: readonly import("@hypit/endpoint-kit").EndpointCredentialDescription[]; + close(): Promise; +}> { + const { document, root, packageRoot } = await openRuntimeConfig(path, options.packageRoot); + const hostStateRoot = resolve(options.hostStateRoot ?? hypitHostStateRoot()); + const registry = options.registry ?? new RuntimeAdapterRegistry(); + await installRuntimeAdapters(registry, packageRoot, runtimePackageSelection(document), options.distributionPackageRoot); + const credentials = await openCredentialStores(document, root, hostStateRoot, registry); + const endpoint = document.endpoints.find((item) => item.instance === endpointInstance); + if (endpoint === undefined) { + return { + store: credentials.store, + credentials: [], + close: credentials.close, + }; + } + try { + const endpointPackage = await registry.createEndpoint(endpoint.use, adapterContext(root, hostStateRoot, { + ...endpoint, + pool: endpoint.pool ?? endpoint.instance, + })); + return { + store: credentials.store, + credentials: endpointPackage.credentials, + close: credentials.close, + }; + } catch (error) { + await credentials.close(); + throw error; + } +} + export async function createRuntimeCredentialsFromConfig( path: string, endpointInstance: string, diff --git a/packages/runtime-local/src/index.ts b/packages/runtime-local/src/index.ts index f2afab90a..1c55a9e03 100644 --- a/packages/runtime-local/src/index.ts +++ b/packages/runtime-local/src/index.ts @@ -22,6 +22,7 @@ export { openTransientRuntimeConfigExecution, prepareRuntimeConfigPackages, preflightRuntimeConfig, + openRuntimeCredentialStore, readRuntimeConfigPricing, parseLocalRuntimeProfile, resolveRuntimeConfigPaths, diff --git a/packages/runtime/src/credentials.ts b/packages/runtime/src/credentials.ts index 81eeff5e3..b0c82c343 100644 --- a/packages/runtime/src/credentials.ts +++ b/packages/runtime/src/credentials.ts @@ -39,6 +39,29 @@ export function decodeOAuth2Credential(secret: string): OAuth2Credential | undef return candidate as OAuth2Credential; } +/** How the consent screen hands its authorization code back to this program. */ +export type CredentialAcquisitionDelivery = "loopback" | "out-of-band"; + +/** + * The exchange request and credential shape, for a service that does not implement the RFC 6749 + * authorization-code grant the host otherwise sends. Declaring it keeps that service's wire spelling + * in its Endpoint package rather than in the host's OAuth code. + */ +export type CredentialExchange = { + readonly encoding: "form" | "json"; + /** Literal fields sent beside `code` and `code_verifier` on every exchange request. */ + readonly fields?: Readonly>; + /** Response field carrying the credential; defaults to `access_token`. */ + readonly credentialField?: string; + /** + * `oauth2` stores the host's refreshable credential envelope; `opaque` stores the returned value + * as an ordinary secret, for a durable key grant with no refresh lifecycle. + */ + readonly credentialFormat?: "oauth2" | "opaque"; + /** The scope the credential must actually carry; a narrower grant is refused, never assumed. */ + readonly requiredScope?: string; +}; + /** Host-facing way to acquire one credential; Provider-specific values stay in its Endpoint package. */ export type CredentialAcquisition = { readonly kind: "oauth2-pkce"; @@ -48,6 +71,17 @@ export type CredentialAcquisition = { readonly scopes: readonly string[]; /** Maximum duration of the service-owned token exchange after browser authorization returns. */ readonly requestTimeoutMs: number; + /** + * Where the code is delivered. `loopback` (the default) runs the local callback server the + * RFC 6749 parameters describe. `out-of-band` serves a consent endpoint that accepts no redirect: + * the code is displayed to the user and pasted into the terminal, which is why that flow must send + * S256 — a code in human hands is redeemable only with the verifier. + */ + readonly delivery?: CredentialAcquisitionDelivery; + /** Authorize query parameters in the service's own spelling, replacing the RFC 6749 set. */ + readonly authorizeParams?: Readonly>; + /** Exchange request and credential shape, when the service does not use the RFC 6749 grant. */ + readonly exchange?: CredentialExchange; }; export type CredentialStore = { diff --git a/packages/studio/locales/en.json b/packages/studio/locales/en.json index 45059f03a..01ea5f125 100644 --- a/packages/studio/locales/en.json +++ b/packages/studio/locales/en.json @@ -3,6 +3,34 @@ "name": "English", "direction": "ltr", "messages": { + "account.api-key.clear": "Remove key", + "account.api-key.label": "API key", + "account.api-key.placeholder": "sk-orca-…", + "account.api-key.save": "Save key", + "account.api-key.stored": "Stored key", + "account.method.api-key": "API key", + "account.method.in-use": "in use", + "account.method.oauth": "Account login", + "account.models": "Model", + "account.models.choose": "Choose a model", + "account.models.attach-images": "Attach images", + "account.models.degraded": "verified fallback", + "account.models.empty": "No model matches this selection", + "account.models.live": "from OrcaRouter", + "account.models.loading": "Reading models…", + "account.oauth.cancel": "Cancel", + "account.oauth.code": "Authorization code", + "account.oauth.connect": "Connect with OrcaRouter", + "account.oauth.expired": "This authorization has expired. Start it again.", + "account.oauth.hint": "Authorize in your browser. OrcaRouter shows a code; paste it here to finish.", + "account.oauth.restart": "Start again", + "account.oauth.submit": "Finish", + "account.panel": "OrcaRouter account", + "account.provider": "OrcaRouter", + "account.read-only": "This Runtime Profile's Credential Store is read-only. Select a writable store, or set the key in the environment.", + "account.state.connected": "Connected", + "account.state.disconnected": "Not connected", + "account.state.needs-reauth": "Authorization needed", "app.comments": "Comments", "app.language": "Language", "app.studio": "Studio", diff --git a/packages/studio/locales/zh-CN.json b/packages/studio/locales/zh-CN.json index e361e7664..81bc54b64 100644 --- a/packages/studio/locales/zh-CN.json +++ b/packages/studio/locales/zh-CN.json @@ -3,6 +3,34 @@ "name": "简体中文", "direction": "ltr", "messages": { + "account.api-key.clear": "移除密钥", + "account.api-key.label": "API 密钥", + "account.api-key.placeholder": "sk-orca-…", + "account.api-key.save": "保存密钥", + "account.api-key.stored": "已保存密钥", + "account.method.api-key": "API 密钥", + "account.method.in-use": "使用中", + "account.method.oauth": "账号登录", + "account.models": "模型", + "account.models.choose": "选择模型", + "account.models.attach-images": "附加图片", + "account.models.degraded": "已验证备用列表", + "account.models.empty": "没有符合当前选择的模型", + "account.models.live": "来自 OrcaRouter", + "account.models.loading": "正在读取模型…", + "account.oauth.cancel": "取消", + "account.oauth.code": "授权码", + "account.oauth.connect": "使用 OrcaRouter 登录", + "account.oauth.expired": "本次授权已过期,请重新开始。", + "account.oauth.hint": "在浏览器中授权。OrcaRouter 会显示一个验证码,粘贴到这里即可完成。", + "account.oauth.restart": "重新开始", + "account.oauth.submit": "完成", + "account.panel": "OrcaRouter 账号", + "account.provider": "OrcaRouter", + "account.read-only": "当前运行时配置的凭据存储为只读。请选择可写的存储,或在环境变量中设置密钥。", + "account.state.connected": "已连接", + "account.state.disconnected": "未连接", + "account.state.needs-reauth": "需要重新授权", "app.comments": "评论", "app.language": "语言", "app.studio": "工作台", diff --git a/packages/studio/package.json b/packages/studio/package.json index 68a28448d..a07e01f5b 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -26,16 +26,18 @@ "@hypit/media-track-studio": "workspace:*", "@hypit/package-loader-node": "workspace:*", "@hypit/performance-studio": "workspace:*", - "@hypit/sound-studio": "workspace:*", "@hypit/program-space": "workspace:*", "@hypit/project-context-node": "workspace:*", "@hypit/protocol": "workspace:*", + "@hypit/provider-orcarouter": "workspace:*", "@hypit/ranking-studio": "workspace:*", "@hypit/run": "workspace:*", "@hypit/runtime": "workspace:*", "@hypit/runtime-host-node": "workspace:*", + "@hypit/runtime-local": "workspace:*", "@hypit/screen-overlay-studio": "workspace:*", "@hypit/script-studio": "workspace:*", + "@hypit/sound-studio": "workspace:*", "@hypit/speech": "workspace:*", "@hypit/studio-adapter": "workspace:*", "@hypit/svs": "workspace:*", diff --git a/packages/studio/src/accounts.ts b/packages/studio/src/accounts.ts new file mode 100644 index 000000000..f6bda6cce --- /dev/null +++ b/packages/studio/src/accounts.ts @@ -0,0 +1,338 @@ +import type { CredentialRef, CredentialStore, CredentialValue, WritableCredentialStore } from "@hypit/runtime"; +import { writableCredentialStore } from "@hypit/runtime"; +import { + ORCAROUTER_DEFAULT_ENDPOINT, ORCAROUTER_KEY_DASHBOARD, ORCAROUTER_SEED_MODELS, + apiKeyCredentialAdapter, createOrcaRouterCredential, decodeOrcaRouterCredential, encodeOrcaRouterCredential, + filterCatalog, orcaRouterOrigins, pkceCredentialAdapter, storeOrcaRouterCredential, +} from "@hypit/provider-orcarouter"; +import type { CatalogModel, OrcaRouterCapability } from "@hypit/provider-orcarouter"; + +import { acquireConnectCode } from "./connect.js"; + +/** How long a started authorization stays open before Studio releases it and asks again. */ +const CONNECT_TIMEOUT_MS = 5 * 60_000; + +/** The slot the shipped Distribution configures, used when no Runtime Profile is selected. */ +const DEFAULT_CREDENTIAL_REF: CredentialRef = { store: "os", key: "orcarouter.apiKey" }; + +export type StudioAuthMethod = { + readonly id: "orcarouter" | "orcarouter-oauth"; + readonly label: string; + /** True when this exact entry point is the one that stored the current key. */ + readonly selected: boolean; + /** True when this entry point can run at all with the selected Runtime Profile. */ + readonly available: boolean; +}; + +export type StudioAccountView = { + readonly endpoint: string; + readonly configured: boolean; + /** The key with everything but its last four characters removed; never the key itself. */ + readonly secretMasked?: string; + readonly state: "ready" | "needsReauth"; + readonly origins: { readonly auth: string; readonly api: string }; + readonly authMethods: readonly StudioAuthMethod[]; + readonly keyDashboard: string; + /** Present while an authorization is open, so the page can show the URL it must visit. */ + readonly connecting?: { readonly generation: number; readonly authorizeUrl: string }; + readonly detail?: string; +}; + +export type StudioModelView = { + readonly capability: OrcaRouterCapability; + /** `live` when the catalogue answered; `seed` when only the verified fallback is available. */ + readonly source: "live" | "seed"; + readonly models: readonly CatalogModel[]; + readonly detail?: string; + /** True when the live catalogue is preferred but had to be replaced by the verified seed. */ + readonly degraded: boolean; +}; + +export type StudioAccountsOptions = { + readonly endpoint?: string; + /** + * The selected Runtime Profile's own CredentialStores and the credential slots the Endpoint + * declares. The panel reads and writes through them, so no second store is introduced and a + * Profile that selects a read-only store simply reports that. + */ + readonly profile?: () => Promise<{ + readonly store: CredentialStore; + readonly credentials: readonly { readonly slot: string; readonly ref: CredentialRef }[]; + close(): Promise; + }>; + /** Direct store access, used when Studio already holds the profile's composite store. */ + readonly store?: CredentialStore; + readonly fetch?: typeof globalThis.fetch; + readonly requestTimeoutMs?: number; + readonly now?: () => number; +}; + +type PendingConnect = { + readonly generation: number; + readonly resolve: (code: string | undefined) => void; + readonly timer: ReturnType; +}; + +/** + * Studio's OrcaRouter account panel. It owns the two entry points over one credential slot and the + * one model catalogue, and never receives or returns the key itself once stored. + */ +export class StudioAccounts { + readonly #endpoint: string; + readonly #options: StudioAccountsOptions; + #generation = 0; + #pending: PendingConnect | undefined; + #authorizeUrl: string | undefined; + #connectFailure: string | undefined; + #inFlight: Promise | undefined; + #opened: Awaited>> | undefined; + + constructor(options: StudioAccountsOptions = {}) { + this.#endpoint = options.endpoint ?? ORCAROUTER_DEFAULT_ENDPOINT; + this.#options = options; + } + + get endpoint(): string { + return this.#endpoint; + } + + #origins() { + return orcaRouterOrigins(process.env); + } + + /** The credential currently stored in the endpoint's slot, if any. */ + async #secret(): Promise<{ readonly secret: string; readonly writable: boolean } | undefined> { + const store = await this.#store(); + if (store === undefined) return undefined; + const ref = await this.#ref(); + const value = await store.resolve(ref); + if (value === undefined) return undefined; + return { secret: value.secret, writable: (await writableCredentialStore(store, ref)) !== undefined }; + } + + async #writable(): Promise { + const store = await this.#store(); + if (store === undefined) return undefined; + return await writableCredentialStore(store, await this.#ref()); + } + + async #store(): Promise { + this.#opened ??= await this.#options.profile?.(); + return this.#options.store ?? this.#opened?.store; + } + + /** + * The slot the selected Endpoint declares, so a key lands wherever that Profile's own store puts + * it. Without a Profile the public default slot is used, which is what the Distribution configures. + */ + async #ref(): Promise { + const opened = this.#opened ?? await this.#options.profile?.(); + this.#opened = opened; + return opened?.credentials.find((item) => item.slot === "apiKey")?.ref ?? DEFAULT_CREDENTIAL_REF; + } + + /** The account view the panel renders. It never includes the key. */ + async view(): Promise { + const origins = this.#origins(); + const stored = await this.#secret(); + const decoded = stored === undefined ? undefined : decodeOrcaRouterCredential(stored.secret); + const generation = this.#generation; + return { + endpoint: this.#endpoint, + configured: decoded !== undefined, + ...(decoded === undefined ? {} : { secretMasked: maskKey(decoded.key) }), + state: decoded?.state ?? "ready", + origins: { auth: origins.authBaseUrl, api: origins.apiBaseUrl }, + authMethods: [ + { + id: "orcarouter", + label: "OrcaRouter - API", + selected: decoded?.issuedVia === "api-key", + available: stored?.writable ?? false, + }, + { + id: "orcarouter-oauth", + label: "OrcaRouter - Auth", + selected: decoded?.issuedVia === "oauth", + available: stored?.writable ?? false, + }, + ], + keyDashboard: ORCAROUTER_KEY_DASHBOARD, + ...(this.#pending === undefined || this.#authorizeUrl === undefined ? {} : { + connecting: { generation, authorizeUrl: this.#authorizeUrl }, + }), + ...(this.#connectFailure === undefined ? {} : { detail: this.#connectFailure }), + ...(decoded?.state === "needsReauth" + ? { detail: `OrcaRouter refused this key. Authorize again, or manage keys at ${ORCAROUTER_KEY_DASHBOARD}` } + : {}), + }; + } + + /** + * Store a key the user pasted. It is an ordinary OrcaRouter credential: the same slot and the same + * reading path the account-login entry point uses. + */ + async saveApiKey(secret: string): Promise { + const trimmed = secret.trim(); + if (trimmed.length === 0) throw new Error("Enter an OrcaRouter API key"); + if (/\s/u.test(trimmed)) throw new Error("An OrcaRouter API key contains no whitespace"); + const decoded = decodeOrcaRouterCredential(trimmed); + if (decoded === undefined) throw new Error("That value is not an OrcaRouter API key"); + await this.#write(encodeOrcaRouterCredential({ + key: decoded.key, + generation: 0, + state: "ready", + issuedVia: "api-key", + })); + return await this.view(); + } + + /** Remove the stored key. Studio never keeps a copy anywhere else. */ + async clear(): Promise { + this.#cancelPending(); + const writable = await this.#writable(); + await writable?.delete(await this.#ref()); + return await this.view(); + } + + /** + * Start an authorization. The consent endpoint displays a code, so this returns the URL to open and + * leaves the exchange waiting for `submitCode`; nothing blocks a request handler. + */ + async beginConnect(): Promise { + this.#cancelPending(); + const generation = this.#generation + 1; + this.#generation = generation; + this.#connectFailure = undefined; + let release!: (code: string | undefined) => void; + const code = new Promise((resolve) => { release = resolve; }); + const timer = setTimeout(() => { release(undefined); }, CONNECT_TIMEOUT_MS); + // Node keeps a pending timer alive; the authorization is user-facing work, not a reason to hang. + timer.unref?.(); + this.#pending = { generation, resolve: release, timer }; + this.#inFlight = acquireConnectCode({ + authBaseUrl: this.#origins().authBaseUrl, + ...(this.#options.requestTimeoutMs === undefined ? {} : { requestTimeoutMs: this.#options.requestTimeoutMs }), + ...(this.#options.fetch === undefined ? {} : { fetch: this.#options.fetch }), + code, + onAuthorizeUrl: (url) => { if (this.#generation === generation) this.#authorizeUrl = url; }, + }); + // The URL is produced synchronously by the connect adapter, before it waits for the code. + await Promise.resolve(); + return await this.view(); + } + + /** Exchange the displayed code and store the resulting key as a new generation. */ + async submitCode(generation: number, code: string): Promise { + const pending = this.#pending; + if (pending === undefined || pending.generation !== generation || generation !== this.#generation) { + throw new Error("That authorization is no longer the current one; start again"); + } + const trimmed = code.trim(); + if (trimmed.length === 0) throw new Error("Paste the code the OrcaRouter page showed you"); + clearTimeout(pending.timer); + const inFlight = this.#inFlight; + pending.resolve(trimmed); + this.#pending = undefined; + try { + const secret = await inFlight; + if (secret === undefined) throw new Error("The OrcaRouter authorization ended before a key arrived"); + await this.#write(encodeOrcaRouterCredential({ key: secret, generation: 0, state: "ready", issuedVia: "oauth" })); + } catch (error) { + this.#authorizeUrl = undefined; + this.#connectFailure = error instanceof Error ? error.message : String(error); + throw error; + } + this.#authorizeUrl = undefined; + this.#inFlight = undefined; + return await this.view(); + } + + /** + * Release an open authorization. Every terminal path calls this: an explicit cancel, switching + * authentication method, a closed panel, and the page being hidden or unloaded. + */ + cancel(generation?: number): StudioAccountView | Promise { + if (generation !== undefined && generation !== this.#generation) { + return this.view(); + } + this.#cancelPending(); + return this.view(); + } + + #cancelPending(): void { + const pending = this.#pending; + this.#pending = undefined; + this.#authorizeUrl = undefined; + if (pending === undefined) return; + clearTimeout(pending.timer); + // Resolving without a code makes the connect adapter fail closed; its rejection is expected and + // already recorded, so it must not surface as an unhandled rejection. + this.#inFlight?.catch(() => undefined); + pending.resolve(undefined); + this.#inFlight = undefined; + } + + async #write(secret: string): Promise { + const writable = await this.#writable(); + if (writable === undefined) { + throw new Error("This Runtime Profile has no writable Credential Store; " + + "select one, or set the key in the environment instead"); + } + await writable.put(await this.#ref(), { secret } satisfies CredentialValue); + } + + /** + * The models one control may offer. A successful live read is authoritative; a failed one is + * reported as degraded and paired with the verified seed rather than silently showing nothing. + */ + async models(capability: OrcaRouterCapability, modality?: "image" | "audio" | "video"): Promise { + const stored = await this.#secret(); + const decoded = stored === undefined ? undefined : decodeOrcaRouterCredential(stored.secret); + if (decoded === undefined || decoded.state !== "ready") { + return { + capability, + source: "seed", + models: filterCatalog(ORCAROUTER_SEED_MODELS, capability, modality), + degraded: true, + detail: decoded === undefined + ? "No OrcaRouter credential is configured; showing the verified fallback models" + : "The stored OrcaRouter key needs authorization; showing the verified fallback models", + }; + } + const credential = createOrcaRouterCredential({ secret: stored!.secret, ref: await this.#ref() }); + if (credential === undefined) { + return { capability, source: "seed", models: filterCatalog(ORCAROUTER_SEED_MODELS, capability, modality), degraded: true }; + } + const { OrcaRouterClient } = await import("@hypit/provider-orcarouter"); + const client = new OrcaRouterClient({ + baseUrl: this.#origins().apiBaseUrl, + timeout: this.#options.requestTimeoutMs ?? 10_000, + ...(this.#options.fetch === undefined ? {} : { fetcher: this.#options.fetch }), + }); + const result = await client.models(credential, capability, modality); + if (result.source === "seed") { + return { + capability, + source: "seed", + models: filterCatalog(ORCAROUTER_SEED_MODELS, capability, modality), + degraded: true, + ...(result.detail === undefined ? {} : { detail: result.detail }), + }; + } + return { capability, source: "live", models: result.models, degraded: false }; + } +} + +/** + * Mask a stored key for display. Only the scheme prefix survives — `sk-orca-` names the service and + * is not secret, while the tail of a live credential is material a screenshot must never carry. + */ +export function maskKey(key: string): string { + const trimmed = key.trim(); + const scheme = "sk-orca-"; + const shown = trimmed.startsWith(scheme) ? scheme.length : Math.min(4, trimmed.length); + return `${trimmed.slice(0, shown)}${"•".repeat(Math.max(0, trimmed.length - shown))}`; +} + +export { apiKeyCredentialAdapter, pkceCredentialAdapter, storeOrcaRouterCredential }; diff --git a/packages/studio/src/connect.ts b/packages/studio/src/connect.ts new file mode 100644 index 000000000..5c4a2753f --- /dev/null +++ b/packages/studio/src/connect.ts @@ -0,0 +1,98 @@ +import { createHash, randomBytes } from "node:crypto"; +import { spawn } from "node:child_process"; + +import { orcaRouterAcquisition } from "@hypit/provider-orcarouter"; + +export type AcquireConnectOptions = { + readonly authBaseUrl?: string; + readonly requestTimeoutMs?: number; + readonly fetch?: typeof globalThis.fetch; + /** Called once with the URL the user must open, before the exchange waits for the code. */ + readonly onAuthorizeUrl?: (url: string) => void; + /** Resolves with the code the consent screen displayed, or undefined when the attempt is released. */ + readonly code: Promise; + readonly open?: (url: string) => void; +}; + +function base64url(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("base64url"); +} + +/** + * Run the OrcaRouter out-of-band authorization up to the point where the consent screen owes us a + * code, then exchange it. The verifier is generated here, is sent only on the exchange request, and + * is never placed in a URL, a log or an error message. + */ +export async function acquireConnectCode(options: AcquireConnectOptions): Promise { + const acquisition = orcaRouterAcquisition({ + ...(options.authBaseUrl === undefined ? {} : { authBaseUrl: options.authBaseUrl }), + ...(options.requestTimeoutMs === undefined ? {} : { requestTimeoutMs: options.requestTimeoutMs }), + }); + const verifier = base64url(randomBytes(32)); + const challenge = base64url(createHash("sha256").update(verifier).digest()); + const state = base64url(randomBytes(16)); + const authorize = new URL(acquisition.authorizationEndpoint); + for (const [name, value] of Object.entries(acquisition.authorizeParams ?? {})) authorize.searchParams.set(name, value); + authorize.searchParams.set("state", state); + authorize.searchParams.set("code_challenge", challenge); + authorize.searchParams.set("code_challenge_method", "S256"); + options.onAuthorizeUrl?.(authorize.toString()); + options.open?.(authorize.toString()); + + const code = (await options.code)?.trim(); + if (code === undefined || code.length === 0) { + throw new Error("The OrcaRouter authorization was cancelled or timed out before a code arrived"); + } + + const exchange = acquisition.exchange!; + const fetcher = options.fetch ?? globalThis.fetch; + const deadline = AbortSignal.timeout(acquisition.requestTimeoutMs); + let response: Response; + let body: string; + try { + response = await fetcher(acquisition.tokenEndpoint, { + method: "POST", + headers: exchange.encoding === "json" + ? { "content-type": "application/json" } + : { "content-type": "application/x-www-form-urlencoded" }, + body: exchange.encoding === "json" + ? JSON.stringify({ ...exchange.fields, code, code_verifier: verifier }) + : new URLSearchParams({ ...exchange.fields, code, code_verifier: verifier }).toString(), + signal: deadline, + }); + body = await response.text(); + } catch (error) { + if (deadline.aborted) throw new Error("The OrcaRouter code exchange timed out; no credential was stored"); + throw error; + } + if (!response.ok) { + throw new Error(`OrcaRouter code exchange failed (HTTP ${response.status}). ` + + "The code may have expired or already been used; start a new authorization."); + } + let parsed: Record; + try { + parsed = JSON.parse(body) as Record; + } catch { + throw new Error("OrcaRouter returned an unreadable exchange response"); + } + const key = parsed[exchange.credentialField ?? "access_token"]; + if (typeof key !== "string" || key.length === 0) { + throw new Error("OrcaRouter returned no key; authorize again from the OrcaRouter panel"); + } + if (exchange.requiredScope !== undefined && parsed.scope !== exchange.requiredScope) { + throw new Error(`OrcaRouter granted scope ${JSON.stringify(parsed.scope ?? null)}, not ${JSON.stringify(exchange.requiredScope)}; ` + + "the account or workspace role does not permit this grant"); + } + return key; +} + +/** Open the user's browser at the authorization URL, ignoring a machine with no opener. */ +export function openInBrowser(url: string): void { + const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open"; + const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; + try { + spawn(command, args, { stdio: "ignore", detached: true, windowsHide: true }).unref(); + } catch { + // A display-less machine still shows the URL for the user to copy. + } +} diff --git a/packages/studio/src/server.ts b/packages/studio/src/server.ts index e84bfbd59..b02a0a9c9 100644 --- a/packages/studio/src/server.ts +++ b/packages/studio/src/server.ts @@ -23,6 +23,7 @@ import type { StudioStoryboard } from "./storyboard.js"; import { findSurfacePreview } from "./surface-preview.js"; import { formatTemporalPointEdit, semanticGestureSpan } from "./temporal-edit.js"; import { replaceSourceFiles } from "./source-transaction.js"; +import type { StudioAccounts } from "./accounts.js"; export type StudioPluginOptions = { readonly source: string; @@ -31,8 +32,29 @@ export type StudioPluginOptions = { readonly registry: StudioCompanionRegistry; readonly workspaceRoot: string; readonly buildLibrary?: StudioBuildLibrary; + /** OrcaRouter account panel state: both authentication choices and the model catalogue. */ + readonly accounts?: StudioAccounts; }; +/** One account request body, bounded so a malformed caller cannot stream without end. */ +async function accountBody(request: import("node:http").IncomingMessage): Promise> { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of request) { + const buffer = Buffer.from(chunk); + size += buffer.byteLength; + if (size > 64 * 1024) throw new Error("The account request body is too large"); + chunks.push(buffer); + } + const text = Buffer.concat(chunks).toString("utf8"); + if (text.trim().length === 0) return {}; + const parsed: unknown = JSON.parse(text); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("The account request body must be an object"); + } + return parsed as Record; +} + function json(response: import("node:http").ServerResponse, status: number, value: unknown): void { response.statusCode = status; response.setHeader("content-type", "application/json; charset=utf-8"); @@ -576,6 +598,62 @@ export function studioPlugin(options: StudioPluginOptions): Plugin { })(); return; } + if (url.pathname === "/__studio/account" && options.accounts !== undefined) { + const accounts = options.accounts; + void (async () => { + try { + const body = request.method === "POST" ? await accountBody(request) : {}; + const action = typeof body.action === "string" ? body.action : "view"; + switch (action) { + case "view": + json(response, 200, await accounts.view()); + return; + case "save-api-key": { + if (typeof body.key !== "string") throw new Error("Expected the OrcaRouter API key as text"); + json(response, 200, await accounts.saveApiKey(body.key)); + return; + } + case "clear": + json(response, 200, await accounts.clear()); + return; + case "begin-connect": + json(response, 200, await accounts.beginConnect()); + return; + case "submit-code": { + if (typeof body.code !== "string" || typeof body.generation !== "number") { + throw new Error("Expected the displayed code and the authorization it belongs to"); + } + json(response, 200, await accounts.submitCode(body.generation, body.code)); + return; + } + case "cancel-connect": { + if (typeof body.generation !== "number") throw new Error("Expected the authorization to cancel"); + json(response, 200, await accounts.cancel(body.generation)); + return; + } + case "models": { + const capability = body.capability; + if (capability !== "chat" && capability !== "multimodal" && capability !== "embedding" + && capability !== "image" && capability !== "video" && capability !== "rerank") { + throw new Error("Expected a catalogue capability"); + } + const modality = body.modality; + if (modality !== undefined && modality !== "image" && modality !== "audio" && modality !== "video") { + throw new Error("Expected an input modality"); + } + json(response, 200, await accounts.models(capability, modality as "image" | "audio" | "video" | undefined)); + return; + } + default: + json(response, 400, { error: `Unknown account action ${action}` }); + return; + } + } catch (error) { + json(response, 400, { error: error instanceof Error ? error.message : String(error) }); + } + })(); + return; + } if (request.method !== "GET" && request.method !== "HEAD") { next(); return; diff --git a/packages/studio/src/style.css b/packages/studio/src/style.css index 1d0ba25fa..28a1393c9 100644 --- a/packages/studio/src/style.css +++ b/packages/studio/src/style.css @@ -1687,3 +1687,60 @@ button:focus-visible, textarea:focus-visible { background: var(--item-head); } .track-band-label .clip-body, .track-band-label .clip-meta { display: none; } + +/* OrcaRouter account panel. It sits in the Project view and uses the same control + vocabulary as the inspector, so the two authentication choices read as one provider + with two ways in rather than as a separate settings screen. */ +.account-panel { display: grid; gap: 10px; min-width: 0; } +.account-head { display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; align-items: center; gap: 8px; } +.account-mark { display: grid; place-items: center; width: 18px; height: 18px; color: var(--brand-400); } +.account-mark .icon { width: 16px; height: 16px; } +.account-head strong { min-width: 0; overflow: hidden; color: var(--text); font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.account-state { + padding: 2px 6px; border: 1px solid var(--line-strong); border-radius: 3px; + color: var(--text-3); font: 10px/1.4 var(--font-ui); white-space: nowrap; +} +.account-panel[data-account-state="ready"] .account-state { border-color: var(--ok); color: var(--ok); } +.account-panel[data-account-state="needsReauth"] .account-state { border-color: var(--danger); color: var(--danger); } +.account-hint { margin: 0; color: var(--text-3); font: 10px/1.5 var(--font-ui); overflow-wrap: anywhere; } +/* Both entry points stay on screen together: pasting a key never hides the account login, and the + account login never hides the key field. Only the entry that stored the current key is tagged. + They sit side by side when the panel is wide enough and stack in the narrower inspector column, + so neither choice is ever cut off. */ +.account-methods { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px; align-items: start; } +.account-method-head { display: flex; justify-content: space-between; gap: 6px; min-width: 0; color: var(--text-2); font: 600 11px/1.3 var(--font-ui); } +.account-method-tag { color: var(--ok); font-size: 9px; font-weight: 500; white-space: nowrap; } +.account-method-body { + display: grid; gap: 6px; min-width: 0; padding: 8px; + border: 1px solid var(--line-strong); border-radius: 4px; background: var(--panel-raised); +} +.account-method-body[hidden] { display: none; } +.account-attach { display: flex; align-items: center; gap: 6px; color: var(--text-3); font: 10px/1.4 var(--font-ui); } +.account-field { display: grid; gap: 4px; min-width: 0; } +.account-field > span { color: var(--text-3); font: 10px/1.4 var(--font-ui); } +.account-field input { + width: 100%; min-width: 0; height: 27px; padding: 0 8px; + border: 1px solid #2b2e34; border-radius: 4px; + background: #1a1b1f; color: var(--text); font: 11px/1.2 var(--font-mono); +} +.account-field input:focus-visible { border-color: #6a6e77; outline: 0; background: #131417; } +.account-actions { display: flex; flex-wrap: wrap; gap: 6px; } +.account-primary, .account-secondary { + flex: 1 1 auto; min-width: 0; height: 27px; padding: 0 10px; + border: 1px solid var(--line-strong); border-radius: 4px; + color: var(--text); font: 11px/1.2 var(--font-ui); white-space: nowrap; +} +.account-primary { border-color: var(--interaction-accent); background: var(--brand-soft); } +.account-primary:disabled, .account-secondary:disabled { opacity: .5; } +.account-secondary { background: var(--field); color: var(--text-2); } +.account-link { + margin: 0; padding: 6px 8px; border: 1px dashed var(--line-strong); border-radius: 4px; + color: var(--text-2); font: 10px/1.5 var(--font-mono); overflow-wrap: anywhere; +} +.account-models { display: grid; gap: 6px; min-width: 0; padding-top: 4px; border-top: 1px solid var(--line); } +.account-models-head { display: flex; justify-content: space-between; gap: 8px; color: var(--text-3); font: 10px/1.4 var(--font-ui); } +.account-catalog { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.account-panel[data-catalog-source="seed"] .account-catalog { color: var(--tone-orange-rail); } +.account-panel[data-catalog-source="live"] .account-catalog { color: var(--ok); } +.account-code { display: grid; gap: 8px; } +.account-code[hidden] { display: none; } diff --git a/packages/studio/src/ui/accounts.ts b/packages/studio/src/ui/accounts.ts new file mode 100644 index 000000000..f3e729580 --- /dev/null +++ b/packages/studio/src/ui/accounts.ts @@ -0,0 +1,400 @@ +import { createConnectLifecycle } from "./connect-lifecycle.js"; +import { bindDropdown } from "./dropdown.js"; +import { icon } from "./icons.js"; +import { t, uiAttr, uiText, userText, type Message } from "./i18n.js"; +import type { StudioAccountView, StudioModelView } from "../accounts.js"; + +type AccountsPane = { + readonly element: HTMLElement; + /** Re-read the stored credential and the catalogue; called when the panel is shown. */ + refresh(): Promise; + /** Release any open authorization without writing to the page. */ + release(): void; +}; + +type Pending = { readonly generation: number; readonly authorizeUrl: string }; +type CatalogOption = { readonly id: string; readonly label: string; readonly contextLength?: number }; + +async function accountRequest(body: Record): Promise { + const response = await fetch("/__studio/account", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const parsed = await response.json() as StudioAccountView & { readonly error?: string }; + if (!response.ok) throw new Error(parsed.error ?? `Studio account request failed (${response.status})`); + return parsed; +} + +/** + * The OrcaRouter account panel. It presents the two authentication choices side by side, and binds + * its model selector to the catalogue the Provider returns — never to a hand-written list. + */ +export function createAccountsPane(): AccountsPane { + const element = document.createElement("section"); + element.className = "account-panel"; + element.dataset.accountEndpoint = "orcarouter.default"; + element.innerHTML = ` + + + + `; + + const pick = (selector: string): T => element.querySelector(selector)!; + const state = pick("[data-account-state]"); + const hint = pick("[data-account-hint]"); + const keyBody = pick("[data-account-body='api-key']"); + const oauthBody = pick("[data-account-body='oauth']"); + const keyInput = pick("[data-account-key]"); + const keyTag = pick("[data-account-key-tag]"); + const oauthTag = pick("[data-account-oauth-tag]"); + const save = pick("[data-account-save]"); + const clear = pick("[data-account-clear]"); + const connect = pick("[data-account-connect]"); + const cancel = pick("[data-account-cancel]"); + const codeBox = pick("[data-account-code]"); + const codeInput = pick("[data-account-code-input]"); + const submit = pick("[data-account-submit]"); + const authorize = pick("[data-account-authorize]"); + const catalog = pick("[data-account-catalog]"); + const control = pick("[data-account-control]"); + const trigger = pick("[data-account-trigger]"); + const value = pick("[data-account-value]"); + const menu = pick("[data-account-menu]"); + const modelsDetail = pick("[data-account-models-detail]"); + const attach = pick("[data-account-attach]"); + + for (const [selector, message] of [ + ["[data-account-provider]", "account.provider"], + ["[data-account-key-label]", "account.api-key.label"], + ["[data-account-code-label]", "account.oauth.code"], + ["[data-account-models-label]", "account.models"], + ["[data-account-key-method]", "account.method.api-key"], + ["[data-account-oauth-method]", "account.method.oauth"], + ["[data-account-oauth-hint]", "account.oauth.hint"], + ["[data-account-attach-label]", "account.models.attach-images"], + ] as const) uiText(pick(selector), message); + uiText(save, "account.api-key.save"); + uiText(clear, "account.api-key.clear"); + uiText(connect, "account.oauth.connect"); + uiText(cancel, "account.oauth.cancel"); + uiText(submit, "account.oauth.submit"); + uiAttr(keyInput, "placeholder", "account.api-key.placeholder"); + uiAttr(trigger, "aria-label", "account.models"); + + // bindDropdown keeps this exact array, so the options are replaced in place on every catalogue read. + const options: HTMLButtonElement[] = []; + const dropdown = bindDropdown(control, trigger, menu, options); + + let view: StudioAccountView | undefined; + let pending: Pending | undefined; + let selected: string | undefined; + let currentOptions: readonly CatalogOption[] = []; + let source: StudioModelView["source"] | undefined; + let catalogDetail: string | undefined; + // Every async answer must still belong to this attempt before it may change the panel. + const lifecycle = createConnectLifecycle(); + + /** + * The one model control follows the request being composed. Turning on image attachments makes it + * the multimodal control, so its options are recomputed from the catalogue and any model that does + * not declare image input is dropped rather than kept. + */ + const capabilityFor = (): { capability: StudioModelView["capability"]; modality?: "image" } => + attach.checked ? { capability: "multimodal", modality: "image" } : { capability: "chat" }; + + /** Show or hide one message line without leaving stale text behind a hidden node. */ + function message(node: HTMLElement, text: string | undefined): void { + node.hidden = text === undefined; + userText(node, text ?? ""); + } + + function setEnabled(enabled: boolean): void { + lifecycle.setBusy(!enabled); + save.disabled = !enabled; + clear.disabled = !enabled; + connect.disabled = !enabled; + trigger.disabled = !enabled; + submit.disabled = !enabled; + element.dataset.controlsEnabled = String(enabled); + } + + function clearConnectHints(): void { + pending = undefined; + codeBox.hidden = true; + cancel.hidden = true; + authorize.hidden = true; + codeInput.value = ""; + element.dataset.connecting = "false"; + uiText(connect, "account.oauth.connect"); + setEnabled(true); + } + + function render(next: StudioAccountView): void { + view = next; + message(hint, next.detail); + userText(state, t(next.state === "needsReauth" ? "account.state.needs-reauth" + : next.configured ? "account.state.connected" : "account.state.disconnected")); + element.dataset.accountState = next.configured ? next.state : "disconnected"; + // Both entry points stay on screen together: a user with a key never has to open the other one, + // and a user without one sees the authorization it can run. Only the entry that stored the + // current key is marked in use. + const inUse = t("account.method.in-use"); + userText(keyTag, next.authMethods[0]?.selected === true ? inUse : ""); + userText(oauthTag, next.authMethods[1]?.selected === true ? inUse : ""); + // A configured key is shown only masked; the panel never renders the secret it stored. + keyInput.value = ""; + keyInput.placeholder = next.secretMasked ?? t("account.api-key.placeholder"); + clear.hidden = !next.configured; + userText(catalog, next.origins.api); + keyBody.hidden = false; + oauthBody.hidden = false; + element.dataset.apiKeyVisible = String(!keyBody.hidden); + element.dataset.pkceVisible = String(!oauthBody.hidden); + element.dataset.secretMasked = next.secretMasked ?? ""; + if (next.connecting !== undefined) { + pending = { generation: next.connecting.generation, authorizeUrl: next.connecting.authorizeUrl }; + lifecycle.open(next.connecting.generation); + authorize.hidden = false; + userText(authorize, next.connecting.authorizeUrl); + codeBox.hidden = false; + cancel.hidden = false; + element.dataset.connecting = "true"; + element.dataset.authorizeUrl = next.connecting.authorizeUrl; + uiText(connect, "account.oauth.restart"); + } else if (pending === undefined) { + clearConnectHints(); + } + element.dataset.selectedModel = selected ?? ""; + } + + function renderOptions(): void { + menu.replaceChildren(...currentOptions.map((model) => { + const item = document.createElement("button"); + item.type = "button"; + item.className = `parameter-select-option${model.id === selected ? " active" : ""}`; + item.dataset.modelId = model.id; + item.setAttribute("role", "option"); + item.setAttribute("aria-selected", String(model.id === selected)); + item.innerHTML = ""; + userText(item.querySelector("[data-option-label]")!, model.label); + userText(item.querySelector("[data-option-meta]")!, model.id); + item.addEventListener("click", () => { + selected = model.id; + userText(value, model.id); + dropdown.close(true); + renderOptions(); + }); + return item; + })); + control.classList.toggle("rich-options", currentOptions.length > 0); + options.splice(0, options.length, ...Array.from(menu.querySelectorAll("button"))); + if (currentOptions.length === 0) uiText(value, "account.models.empty"); + else if (selected === undefined) uiText(value, "account.models.choose"); + if (source !== undefined) uiText(catalog, source === "live" ? "account.models.live" : "account.models.degraded"); + message(modelsDetail, catalogDetail); + element.dataset.catalogSource = source ?? "none"; + element.dataset.itemCount = String(currentOptions.length); + element.dataset.selectedModel = selected ?? ""; + } + + /** Recompute the options for the current control and drop a selection the new control rejects. */ + async function loadModels(): Promise { + const attempt = lifecycle.state().generation; + const target = capabilityFor(); + trigger.disabled = true; + uiText(value, "account.models.loading"); + try { + const result = await accountRequest({ + action: "models", + capability: target.capability, + ...(target.modality === undefined ? {} : { modality: target.modality }), + }) as unknown as StudioModelView; + if (attempt !== lifecycle.state().generation) return; + source = result.source; + catalogDetail = result.detail; + currentOptions = result.models; + // A model this control does not accept must not stay selected. + if (selected !== undefined && !result.models.some((model) => model.id === selected)) { + const previous = selected; + selected = undefined; + catalogDetail = `${previous} is not available for this selection; choose another model.`; + } + renderOptions(); + } catch (error) { + if (attempt !== lifecycle.state().generation) return; + source = undefined; + catalogDetail = error instanceof Error ? error.message : String(error); + currentOptions = []; + selected = undefined; + renderOptions(); + } finally { + if (attempt === lifecycle.state().generation) trigger.disabled = false; + } + } + + /** Invalidate the current attempt and release it, without letting a late answer touch the panel. */ + function release(): void { + const current = pending; + clearConnectHints(); + lifecycle.release(); + if (current === undefined) return; + void accountRequest({ action: "cancel-connect", generation: current.generation }) + .then((next) => render(next), () => undefined); + } + + save.addEventListener("click", () => { + void (async () => { + setEnabled(false); + try { + render(await accountRequest({ action: "save-api-key", key: keyInput.value })); + await loadModels(); + } catch (error) { + message(hint, error instanceof Error ? error.message : String(error)); + } finally { + setEnabled(true); + } + })(); + }); + + clear.addEventListener("click", () => { + void (async () => { + setEnabled(false); + try { + render(await accountRequest({ action: "clear" })); + selected = undefined; + await loadModels(); + } catch (error) { + message(hint, error instanceof Error ? error.message : String(error)); + } finally { + setEnabled(true); + } + })(); + }); + + connect.addEventListener("click", () => { + void (async () => { + release(); + setEnabled(false); + try { + const next = await accountRequest({ action: "begin-connect" }); + render(next); + // Shown for browsers that do not open a window, and to copy by hand. + if (next.connecting !== undefined) window.open(next.connecting.authorizeUrl, "_blank", "noopener"); + } catch (error) { + clearConnectHints(); + message(hint, error instanceof Error ? error.message : String(error)); + } + })(); + }); + + cancel.addEventListener("click", () => { release(); }); + + // Turning attachments on or off changes which models are compatible, so the options are recomputed + // and a model that no longer qualifies is cleared before the user can send with it. + attach.addEventListener("change", () => { void loadModels(); }); + + submit.addEventListener("click", () => { + void (async () => { + const current = pending; + if (current === undefined) { + message(hint, t("account.oauth.expired")); + return; + } + setEnabled(false); + try { + render(await accountRequest({ action: "submit-code", generation: current.generation, code: codeInput.value })); + clearConnectHints(); + await loadModels(); + } catch (error) { + clearConnectHints(); + message(hint, error instanceof Error ? error.message : String(error)); + } + })(); + }); + + /** + * `pagehide` may put this page into the back-forward cache. The guarded cleanup of a cancelled + * request refuses to touch state, so the busy flags and the authorization hints are cleared here, + * synchronously, before the server is asked to release the authorization. A second authorization + * can then start from the restored page without a remount. + */ + window.addEventListener("pagehide", () => { + const current = pending; + pending = undefined; + codeBox.hidden = true; + cancel.hidden = true; + authorize.hidden = true; + element.dataset.connecting = "false"; + // Release before the server is told: the restored page must be able to start again immediately. + lifecycle.release(); + setEnabled(true); + if (current !== undefined) { + try { + void fetch("/__studio/account", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "cancel-connect", generation: current.generation }), + keepalive: true, + }).catch(() => undefined); + } catch { /* The page is leaving; nothing can be reported here. */ } + } + }); + + return { + element, + release, + async refresh() { + lifecycle.begin(); + setEnabled(false); + try { + render(await accountRequest({ action: "view" })); + await loadModels(); + } finally { + setEnabled(true); + } + }, + }; +} + +export type { AccountsPane, Message }; diff --git a/packages/studio/src/ui/connect-lifecycle.ts b/packages/studio/src/ui/connect-lifecycle.ts new file mode 100644 index 000000000..d5b82cb0f --- /dev/null +++ b/packages/studio/src/ui/connect-lifecycle.ts @@ -0,0 +1,48 @@ +/** + * The Studio page-lifecycle state for an authorization in progress, with no DOM and no network. + * + * `pagehide` must release an open authorization synchronously: the browser may restore the page from + * the back-forward cache, where the guarded cleanup of the invalidated request correctly refuses to + * touch any state — leaving the restored page permanently busy if the handler did not clear it here. + * A monotonically increasing generation keeps a late answer from an older attempt out of the panel. + */ +export type ConnectLifecycle = { + readonly generation: number; + readonly busy: boolean; + readonly pendingGeneration: number | undefined; +}; + +export function createConnectLifecycle(): { + readonly state: () => ConnectLifecycle; + /** Begin an attempt: invalidate whatever ran before it and become the current one. */ + begin(): { readonly generation: number }; + setBusy(busy: boolean): void; + /** Open an authorization at this generation. */ + open(generation: number): void; + /** Release the open authorization without running any async cleanup. */ + release(): { readonly releasedGeneration: number | undefined }; +} { + let generation = 0; + let busy = false; + let pendingGeneration: number | undefined; + return { + state: () => ({ generation, busy, pendingGeneration }), + begin() { + generation += 1; + return { generation }; + }, + setBusy(next) { + busy = next; + }, + open(next) { + pendingGeneration = next; + }, + release() { + const released = pendingGeneration; + generation += 1; + pendingGeneration = undefined; + busy = false; + return { releasedGeneration: released }; + }, + }; +} diff --git a/packages/studio/src/ui/main.ts b/packages/studio/src/ui/main.ts index c971efa5b..0e40e3532 100644 --- a/packages/studio/src/ui/main.ts +++ b/packages/studio/src/ui/main.ts @@ -16,6 +16,7 @@ import type { SemanticTarget } from "../temporal-edit.js"; import { applyStudioMutation } from "./writeback.js"; import { createTimeline } from "./timeline.js"; import { createComments } from "./comments.js"; +import { createAccountsPane } from "./accounts.js"; import "../style.css"; await initializeI18n(); @@ -119,6 +120,7 @@ shell.insertBefore(createHandle({ remember: "hypit-studio.v3.timeline-height", }), app.querySelector("[data-timeline]")!); const inspector = app.querySelector("[data-inspector]")!; +const accounts = createAccountsPane(); const workspaceHeading = app.querySelector("[data-workspace-heading]")!; const meta = app.querySelector("[data-meta]")!; const project = app.querySelector("[data-project]")!; @@ -148,6 +150,21 @@ app.querySelectorAll(".view-tabs [data-view]").forEach((butto window.addEventListener("hashchange", changeView); changeView(); +// The account panel owns the Project view: its state comes from the Runtime's credential store and +// the OrcaRouter catalogue, neither of which is part of a Source snapshot. Leaving the Project view +// releases an authorization that was still waiting for a code. +void accounts.refresh().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); +}); +let accountsShown = true; +window.addEventListener("hashchange", () => { + const shown = (window.location.hash === "#comments" ? "comments" : "studio") === "studio"; + if (shown === accountsShown) return; + accountsShown = shown; + if (shown) void accounts.refresh().catch(() => undefined); + else accounts.release(); +}); + timeline.element.addEventListener("studio:write", (event) => { const state = (event as CustomEvent<{ readonly state?: string }>).detail.state; status.className = state === "error" ? "status error" : state === "saved" ? "status saved" : "status saving"; @@ -775,6 +792,7 @@ function renderInspector(snapshot: StudioSnapshot, clipId: string | undefined): defaultWorkspaceHeading(); const fps = snapshot.space.frameRate.numerator / snapshot.space.frameRate.denominator; inspector.replaceChildren( + uiGroup("account.panel", [accounts.element]), uiGroup("inspector.project", [ property("inspector.author", snapshot.source.path, "property-code"), property("inspector.run", snapshot.run.path, "property-code"), diff --git a/packages/studio/start.ts b/packages/studio/start.ts index 81739f4b5..1284940ac 100644 --- a/packages/studio/start.ts +++ b/packages/studio/start.ts @@ -87,6 +87,7 @@ export async function runStudio(argv: readonly string[], io: Pick { + const { openRuntimeCredentialStore } = await import("@hypit/runtime-local"); + return await openRuntimeCredentialStore(runtimePath, "orcarouter.default", { + packageRoot, + ...(distributionPackageRoot === undefined ? {} : { distributionPackageRoot }), + }); + }, + }), + }); try { inspectStudioRun(registry, run.source, run); } catch (error) { @@ -160,6 +176,7 @@ export async function runStudio(argv: readonly string[], io: Pick string | undefined } { + let secret: string | undefined; + const ref: CredentialRef = { store: "os", key: "orcarouter.apiKey" }; + return { + value: () => secret, + owns: (candidate) => candidate.store === ref.store && candidate.key === ref.key, + async resolve(candidate) { return candidate.key === ref.key && secret !== undefined ? { secret } : undefined; }, + async put(candidate: CredentialRef, value: CredentialValue) { assert.equal(candidate.key, ref.key); secret = value.secret; }, + async delete(candidate: CredentialRef) { if (candidate.key !== ref.key || secret === undefined) return false; secret = undefined; return true; }, + }; +} + +/** A local stand-in for the OrcaRouter authorization service. */ +async function authService(options: { readonly scope?: string; readonly status?: number } = {}) { + const seen: { readonly path: string; readonly body: Record }[] = []; + const server: Server = createServer((request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + seen.push({ path: new URL(request.url ?? "/", "http://127.0.0.1").pathname, body: JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record }); + response.writeHead(options.status ?? 200, { "content-type": "application/json" }); + response.end(JSON.stringify({ key: "sk-orca-from-consent", user_id: "1", scope: options.scope ?? "api" })); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const address = server.address() as { port: number }; + return { + seen, + url: `http://127.0.0.1:${address.port}`, + close: async () => { await new Promise((resolve) => { server.close(() => resolve()); server.closeAllConnections?.(); }); }, + }; +} + +test("a pasted key and an authorized key are stored in the same slot and read the same way", async () => { + const credentials = store(); + const accounts = new StudioAccounts({ store: credentials }); + + const afterPaste = await accounts.saveApiKey("sk-orca-pasted-key"); + assert.equal(afterPaste.configured, true); + // The panel reports the key masked, and never the key. + // The mask keeps the scheme and replaces the whole secret body, so no part of a stored key can be + // read back out of the panel or out of a screenshot of it. + assert.equal(afterPaste.secretMasked, maskKey("sk-orca-pasted-key")); + assert.equal(afterPaste.secretMasked, `sk-orca-${"•".repeat("pasted-key".length)}`); + assert.equal(afterPaste.secretMasked.includes("pasted"), false); + assert.ok(!JSON.stringify(afterPaste).includes("sk-orca-pasted-key")); + assert.equal(afterPaste.authMethods.find((item) => item.id === "orcarouter")?.selected, true); + assert.equal(afterPaste.authMethods.find((item) => item.id === "orcarouter-oauth")?.selected, false); + + const service = await authService(); + const previousAuth = process.env.ORCA_AUTH_BASE_URL; + process.env.ORCA_AUTH_BASE_URL = service.url; + try { + const started = await accounts.beginConnect(); + assert.ok(started.connecting !== undefined); + const authorize = new URL(started.connecting.authorizeUrl); + assert.equal(authorize.searchParams.get("callback_url"), "oob"); + assert.equal(authorize.searchParams.get("code_challenge_method"), "S256"); + const afterLogin = await accounts.submitCode(started.connecting.generation, "DISPLAYED-CODE"); + assert.equal(afterLogin.configured, true); + assert.equal(afterLogin.authMethods.find((item) => item.id === "orcarouter-oauth")?.selected, true); + assert.ok(!JSON.stringify(afterLogin).includes("sk-orca-from-consent")); + // The exchange went to the authorization service's own path, in the declared JSON shape. + assert.equal(service.seen.length, 1); + assert.equal(service.seen[0]!.path, "/api/v1/auth/keys"); + assert.equal(service.seen[0]!.body.code, "DISPLAYED-CODE"); + assert.equal(service.seen[0]!.body.code_challenge_method, "S256"); + assert.equal(typeof service.seen[0]!.body.code_verifier, "string"); + } finally { + if (previousAuth === undefined) delete process.env.ORCA_AUTH_BASE_URL; + else process.env.ORCA_AUTH_BASE_URL = previousAuth; + await service.close(); + } +}); + +test("both authentication choices are always offered, and removing one leaves the other usable", async () => { + const credentials = store(); + const accounts = new StudioAccounts({ store: credentials }); + const before = await accounts.view(); + assert.deepEqual(before.authMethods.map((item) => item.id), ["orcarouter", "orcarouter-oauth"]); + assert.deepEqual(before.authMethods.map((item) => item.label), ["OrcaRouter - API", "OrcaRouter - Auth"]); + assert.equal(before.configured, false); + assert.equal(before.state, "ready"); + assert.equal(before.keyDashboard, "https://www.orcarouter.ai/console/authorized-apps"); + // The inference and authorization origins are reported apart, and are different hosts. + assert.equal(before.origins.auth, "https://www.orcarouter.ai"); + assert.equal(before.origins.api, "https://api.orcarouter.ai/v1"); + + await accounts.saveApiKey("sk-orca-key"); + const cleared = await accounts.clear(); + assert.equal(cleared.configured, false); + assert.equal(credentials.value(), undefined); + // A key without the API-key shape is refused before it can reach the store. + await assert.rejects(async () => await accounts.saveApiKey(" "), /Enter an OrcaRouter API key/u); + await assert.rejects(async () => await accounts.saveApiKey("sk-orca-a b"), /no whitespace/u); +}); + +test("a stale generation cannot finish an authorization that was replaced", async () => { + const credentials = store(); + const accounts = new StudioAccounts({ store: credentials }); + const first = await accounts.beginConnect(); + assert.ok(first.connecting !== undefined); + const second = await accounts.beginConnect(); + assert.ok(second.connecting !== undefined); + // The replaced authorization, and any older one, is refused rather than exchanging a code. + await assert.rejects( + async () => await accounts.submitCode(first.connecting!.generation, "OLD-CODE"), + /no longer the current one/u, + ); +}); + +test("a failed exchange leaves the panel able to try again and stores nothing", async () => { + const credentials = store(); + const accounts = new StudioAccounts({ store: credentials }); + const service = await authService({ status: 403 }); + const previous = process.env.ORCA_AUTH_BASE_URL; + process.env.ORCA_AUTH_BASE_URL = service.url; + try { + const started = await accounts.beginConnect(); + assert.ok(started.connecting !== undefined); + const authorize = new URL(started.connecting.authorizeUrl); + assert.equal(authorize.origin, service.url); + await assert.rejects(async () => await accounts.submitCode(started.connecting!.generation, "BAD-CODE"), /HTTP 403/u); + // Nothing was stored, and the next authorization can start immediately. + assert.equal(credentials.value(), undefined); + const again = await accounts.beginConnect(); + assert.ok(again.connecting !== undefined); + await accounts.cancel(again.connecting.generation); + } finally { + if (previous === undefined) delete process.env.ORCA_AUTH_BASE_URL; + else process.env.ORCA_AUTH_BASE_URL = previous; + await service.close(); + } +}); + +test("cancelling releases the authorization and clears the panel", async () => { + const accounts = new StudioAccounts({ store: store() }); + const started = await accounts.beginConnect(); + assert.ok(started.connecting !== undefined); + assert.equal((await accounts.cancel(started.connecting.generation)).connecting, undefined); + // A later answer from the released attempt is refused. + await assert.rejects( + async () => await accounts.submitCode(started.connecting!.generation, "LATE-CODE"), + /no longer the current one/u, + ); +}); + +test("the catalogue falls back to the verified seed when live discovery fails", async () => { + const credentials = store(); + await credentials.put({ store: "os", key: "orcarouter.apiKey" }, + { secret: JSON.stringify({ format: "hypit.orcarouter-credential@1", key: "sk-orca-key", generation: 1, state: "ready", issuedVia: "oauth" }) }); + const offline = new StudioAccounts({ + store: credentials, + fetch: async () => { throw new Error("network down"); }, + }); + const degraded = await offline.models("chat"); + assert.equal(degraded.source, "seed"); + assert.equal(degraded.degraded, true); + assert.ok(degraded.models.some((model) => model.id === "openai/gpt-5.5")); + assert.match(degraded.detail ?? "", /network down/u); + + const live = new StudioAccounts({ + store: credentials, + fetch: async () => Response.json({ data: [ + { id: "deepseek/deepseek-v4.1-flash", supported_endpoint_types: ["openai"], architecture: { input_modalities: ["text", "image"] } }, + ] }), + }); + const result = await live.models("multimodal", "image"); + assert.equal(result.source, "live"); + assert.equal(result.degraded, false); + assert.deepEqual(result.models.map((model) => model.id), ["deepseek/deepseek-v4.1-flash"]); + // The seed is never mixed into a successful live read. + assert.ok(!result.models.some((model) => model.id === "openai/gpt-5.5")); +}); + +test("a needsReauth credential is reported and never used for discovery", async () => { + const credentials = store(); + await credentials.put({ store: "os", key: "orcarouter.apiKey" }, + { secret: JSON.stringify({ format: "hypit.orcarouter-credential@1", key: "sk-orca-dead", generation: 1, state: "needsReauth", issuedVia: "oauth" }) }); + let called = false; + const accounts = new StudioAccounts({ + store: credentials, + fetch: async () => { called = true; return Response.json({ data: [] }); }, + }); + const view = await accounts.view(); + assert.equal(view.state, "needsReauth"); + assert.match(view.detail ?? "", /console\/authorized-apps/u); + assert.equal((await accounts.models("chat")).source, "seed"); + // A rejected key must not be sent to the relay at all. + assert.equal(called, false); +}); diff --git a/packages/studio/test/connect-lifecycle.test.ts b/packages/studio/test/connect-lifecycle.test.ts new file mode 100644 index 000000000..86d740c3a --- /dev/null +++ b/packages/studio/test/connect-lifecycle.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createConnectLifecycle } from "../src/ui/connect-lifecycle.js"; + +/** + * The back-forward-cache shape the Studio panel depends on. A real `pagehide` invalidates the running + * attempt before the server is told to cancel it, and the guarded `finally` of that invalidated + * request then refuses to touch anything — so the busy flag and the authorization hint must already + * be clear here, and a second authorization must be able to start without a remount. + */ +test("pagehide releases the panel so a second authorization can start without remounting", () => { + const lifecycle = createConnectLifecycle(); + const first = lifecycle.begin(); + lifecycle.open(first.generation); + lifecycle.setBusy(true); + assert.deepEqual(lifecycle.state(), { generation: first.generation, busy: true, pendingGeneration: first.generation }); + + // What the pagehide handler does synchronously, before any keepalive request goes out. + const released = lifecycle.release(); + lifecycle.setBusy(false); + assert.equal(released.releasedGeneration, first.generation); + assert.deepEqual(lifecycle.state(), { generation: first.generation + 1, busy: false, pendingGeneration: undefined }); + + // The restored page starts a second authorization: it becomes the current generation. + const second = lifecycle.begin(); + lifecycle.open(second.generation); + const state = lifecycle.state(); + assert.ok(second.generation > first.generation); + assert.equal(state.pendingGeneration, second.generation); + assert.equal(state.busy, false); +}); + +test("a late answer from a released attempt is not the current generation", () => { + const lifecycle = createConnectLifecycle(); + const first = lifecycle.begin(); + lifecycle.open(first.generation); + const attempt = lifecycle.state().generation; + lifecycle.release(); + const second = lifecycle.begin(); + lifecycle.open(second.generation); + // The panel compares the attempt it started against the current generation before rendering. + assert.notEqual(attempt, lifecycle.state().generation); +}); + +test("releasing with nothing open still invalidates the running attempt", () => { + const lifecycle = createConnectLifecycle(); + const first = lifecycle.begin(); + lifecycle.setBusy(true); + const released = lifecycle.release(); + assert.equal(released.releasedGeneration, undefined); + assert.equal(lifecycle.state().busy, false); + assert.equal(lifecycle.begin().generation, first.generation + 2); +}); diff --git a/packages/studio/test/orca-evidence.py b/packages/studio/test/orca-evidence.py new file mode 100644 index 000000000..4d306c4a1 --- /dev/null +++ b/packages/studio/test/orca-evidence.py @@ -0,0 +1,385 @@ +#!/usr/bin/env python3 +"""Real-UI evidence for the OrcaRouter Provider integration. + +The Studio page is the repository's own interface, driven in a real browser against a real Studio +server started from this checkout; nothing here renders a static copy of the panel. The account +panel reads the catalogue through the Provider's own backend path, so the browser never receives +the key. + +Playwright drives the capture because this machine ships the Playwright driver (and `/usr/bin/chromium`) +rather than a Node browser-automation package; the browser, the page and every measurement below are +the real ones. Any change that weakens a UI assertion belongs in review, not here. + +Two catalogues are served. The text dropdown is captured against the live authoritative catalogue, +because the delivered dropdown must be a list the account may actually call. The multimodal dropdown +is captured against a bounded local relay that replays recorded live records that declare +`architecture.input_modalities`; the recorded live catalogue advertised no image-input model on the +day of writing, so a capture of it would have nothing to open. The manifest records which source +produced each screenshot, and every artifact is bound to its sha256. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from playwright.sync_api import sync_playwright + +MANIFEST_DIRECTORY = Path(os.environ.get("HYPIT_EVIDENCE_DIR") or (Path.cwd() / "orca-evidence")) +ORIGIN = os.environ.get("HYPIT_EVIDENCE_ORIGIN") or "https://api.orcarouter.ai/v1" +API_KEY = os.environ.get("ORCAROUTER_API_KEY") +KEY_READY = isinstance(API_KEY, str) and API_KEY.startswith("sk-orca-") +BROWSER = os.environ.get("HYPIT_EVIDENCE_BROWSER") or "/usr/bin/chromium" +REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +LIVE_CATALOG_URL = ORIGIN.rstrip("/") + "/models?capability=chat" +CHAT_ENDPOINT_TYPES = ("openai", "anthropic", "gemini", "openai-response") + +SCREENSHOTS = ("auth-methods.png", "text-model-dropdown.png", "multimodal-model-dropdown.png") + +# Recorded live records, used only by the multimodal capture, keeping their verification metadata. +RECORDED_CHAT_MODELS = [ + {"id": "orcarouter/auto", "supported_endpoint_types": ["openai", "openai-response", "anthropic", "gemini"]}, + {"id": "deepseek/deepseek-v4-pro", "supported_endpoint_types": ["openai", "openai-response"], + "architecture": {"input_modalities": ["text"]}}, + {"id": "deepseek/deepseek-v4-flash", "supported_endpoint_types": ["openai", "openai-response"], + "architecture": {"input_modalities": ["text"]}}, + {"id": "deepseek/deepseek-v4.1-flash", "supported_endpoint_types": ["openai", "openai-response", "anthropic"], + "architecture": {"input_modalities": ["text", "image"]}}, + {"id": "deepseek/deepseek-v4-flash-vision-exp", + "supported_endpoint_types": ["openai", "openai-response", "anthropic"], + "architecture": {"input_modalities": ["text", "image"]}}, +] + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def catalog_count(url: str, key: str) -> int: + """Count the chat models the authoritative catalogue returns for this account.""" + import urllib.request + + request = urllib.request.Request(url, headers={"authorization": "Bearer " + key, "accept": "application/json"}) + with urllib.request.urlopen(request, timeout=60) as response: + assert response.status == 200, f"catalogue read failed with HTTP {response.status}" + body = json.loads(response.read().decode("utf-8")) + data = body.get("data") + assert isinstance(data, list), "catalogue response has no data array" + return sum( + 1 + for model in data + if any(kind in CHAT_ENDPOINT_TYPES for kind in (model.get("supported_endpoint_types") or [])) + ) + + +class _RelayHandler(BaseHTTPRequestHandler): + """A bounded local relay that replays recorded catalogue records on the configured inference base.""" + + def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + if self.path.split("?")[0] != "/v1/models": + self.send_response(404) + self.end_headers() + return + payload = json.dumps({"object": "list", "data": RECORDED_CHAT_MODELS}).encode("utf-8") + self.send_response(200) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *_args) -> None: # keep the evidence log quiet + return + + +class Relay: + def __init__(self) -> None: + self._server = ThreadingHTTPServer(("127.0.0.1", 0), _RelayHandler) + self.url = f"http://127.0.0.1:{self._server.server_address[1]}/v1" + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + + def __enter__(self) -> "Relay": + self._thread.start() + return self + + def __exit__(self, *_exc) -> None: + self._server.shutdown() + self._server.server_close() + + +def free_port() -> int: + """Pick a port nothing is listening on, so Studio starts on a known address without parsing logs.""" + probe = socket.socket() + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + probe.close() + return port + + +def node_binary() -> str: + """A Node >= 22 interpreter for the Studio child process. + + This repository declares Node >= 22.15 (`.node-version`, `engines`) and Studio imports + `node:module`'s `registerHooks`, so the server must run on a conforming interpreter even when the + driver itself runs on an older runtime. + """ + import playwright + + candidates = [ + # An explicit interpreter always wins, so a machine that already knows its conforming Node + # does not depend on any of the fallbacks below. + os.environ.get("HYPIT_EVIDENCE_NODE"), + "node", + str(REPOSITORY_ROOT / "node_modules" / ".bin" / "node"), + # Build images that drive a browser often ship a newer runtime beside the driver itself. + str(Path(playwright.__file__).resolve().parent / "driver" / "node"), + "/usr/local/lib/python3.11/dist-packages/playwright/driver/node", + ] + for candidate in candidates: + if not candidate: + continue + try: + probe = subprocess.run([candidate, "-p", "process.versions.node"], capture_output=True, text=True, timeout=60) + except OSError: + continue + if probe.returncode != 0: + continue + if int(probe.stdout.strip().split(".")[0]) >= 22: + return candidate + raise AssertionError( + f"Studio needs Node >= 22.15 to serve; set HYPIT_EVIDENCE_NODE to a conforming interpreter (this run has {sys.version.split()[0]})" + ) + + +class Studio: + """The repository's own Studio server, started from this checkout.""" + + def __init__(self, binary: str, inference_base: str, port: int, profile: Path, project: Path, home: Path) -> None: + self.port = port + self.log: list[str] = [] + environment = dict(os.environ) + environment["PATH"] = str(Path(binary).parent) + os.pathsep + environment.get("PATH", "") + environment["HOME"] = str(home) + environment["ORCA_API_BASE_URL"] = inference_base + self._process = subprocess.Popen( + [binary, "--import", "tsx", str(REPOSITORY_ROOT / "bin" / "hypit.mjs"), "studio", + "--run", str(project / "chat.svrun"), "--workspace", str(project), + "--runtime", str(profile), "--port", str(port)], + cwd=str(REPOSITORY_ROOT), env=environment, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + ) + threading.Thread(target=self._drain, daemon=True).start() + + def _drain(self) -> None: + assert self._process.stdout is not None + for line in self._process.stdout: + self.log.append(line) + + def wait_for(self) -> str: + """Vite may bind either loopback family; the address that answers is the one sent to the browser.""" + import urllib.request + + for _ in range(120): + if self._process.poll() is not None: + raise AssertionError("Studio exited before serving: " + "".join(self.log)) + for host in ("localhost", "127.0.0.1", "[::1]"): + try: + with urllib.request.urlopen(f"http://{host}:{self.port}/", timeout=5) as response: + if response.status < 500: + return f"http://{host}:{self.port}/" + except Exception: # noqa: BLE001 - not this address or not listening yet + continue + time.sleep(0.5) + raise AssertionError(f"Studio did not serve port {self.port}: " + "".join(self.log)) + + def stop(self) -> None: + self._process.terminate() + try: + self._process.wait(timeout=30) + except subprocess.TimeoutExpired: + self._process.kill() + + +READ_PANEL = "() => ({ ...document.querySelector('.account-panel').dataset })" +READ_BOX = ("(value) => { const element = document.querySelector(value).getBoundingClientRect();" + " return { x: Math.round(element.x), y: Math.round(element.y)," + " width: Math.round(element.width), height: Math.round(element.height) }; }") +READ_MODELS = "nodes => nodes.map((node) => node.dataset.modelId)" + + +def capture(directory: Path) -> None: + assert KEY_READY, "ORCAROUTER_API_KEY is not configured" + assert Path(BROWSER).exists(), f"no browser at {BROWSER}" + + # The repository's own Example Run is the project, so the capture drives the shipped fixture + # rather than a copy of it. Its project packages build in place; `dist/` is not tracked. + project = REPOSITORY_ROOT / "examples" / "semantic-composition" + binary = node_binary() + work = Path(tempfile.mkdtemp(prefix="hypit-orca-evidence-")) + home = work / "home" + home.mkdir(parents=True, exist_ok=True) + tsc = REPOSITORY_ROOT / "node_modules" / "typescript" / "bin" / "tsc" + for package_name in ("chat-scene", "performance-styles", "responsive-explainer", "sound-styles"): + package_directory = project / "packages" / package_name + if (package_directory / "dist").exists(): + continue + built = subprocess.run([binary, str(tsc), "-p", "tsconfig.json"], + cwd=str(package_directory), capture_output=True, text=True) + assert built.returncode == 0, f"building {package_name} failed: {built.stdout}{built.stderr}" + + # The Runtime Profile lives outside the project: it selects the store the panel must use, and the + # live key in it is read from this process environment only. + profile = work / "hypit.runtime.json" + profile.write_text(json.dumps({ + "format": "hypit.runtime-local@1", + "dataRoot": ".hypit/runtimes/local", + "credentials": {"env": {"use": "@hypit/credential-store-env"}}, + "endpoints": { + "orcarouter.default": { + "use": "@hypit/provider-orcarouter", + "config": {"apiKey": {"store": "env", "key": "ORCAROUTER_API_KEY"}}, + }, + }, + }, indent=2), encoding="utf-8") + + live_text_models = catalog_count(LIVE_CATALOG_URL, API_KEY) + recorded_text_models = len(RECORDED_CHAT_MODELS) + recorded_image_models = sum( + 1 for model in RECORDED_CHAT_MODELS + if "image" in (model.get("architecture", {}).get("input_modalities") or []) + ) + assert recorded_image_models > 0, "the recorded records must include an image-input model" + + directory.mkdir(parents=True, exist_ok=True) + running: list[Studio] = [] + page_errors: list[str] = [] + try: + with Relay() as relay, sync_playwright() as playwright: + browser = playwright.chromium.launch(executable_path=BROWSER, args=["--no-sandbox"]) + try: + page = browser.new_page(viewport={"width": 1440, "height": 1000}) + page.on("pageerror", lambda error: page_errors.append(str(error))) + + # 1. Both authentication choices, usable, with the stored key shown only masked. + live = Studio(binary, ORIGIN, free_port(), profile, project, home) + running.append(live) + page.goto(live.wait_for(), wait_until="load") + page.wait_for_selector("[data-account-body='api-key']", timeout=60_000) + page.wait_for_function( + "document.querySelector('.account-panel')?.dataset.catalogSource === 'live'", timeout=60_000) + panel = page.evaluate(READ_PANEL) + assert panel.get("apiKeyVisible") == "true", "the API Key entry must be visible" + assert panel.get("pkceVisible") == "true", "the PKCE connect choice must be visible" + assert panel.get("controlsEnabled") == "true", "the account controls must be usable" + assert panel.get("secretMasked", "").startswith("sk-orca-"), "the stored key must be shown masked" + assert set(panel.get("secretMasked", "")) <= set("sk-orca-•"), "the mask must not leak the key" + body_text = page.evaluate("() => document.body.innerText") + assert API_KEY not in body_text, "the page must never carry the key itself" + page.screenshot(path=str(directory / "auth-methods.png")) + + # 2. The text dropdown, from the live authoritative catalogue. + page.click("[data-account-trigger]") + page.wait_for_selector("[data-account-menu] button[data-model-id]") + text_items = page.eval_on_selector_all("[data-account-menu] button[data-model-id]", READ_MODELS) + assert len(text_items) == live_text_models, ( + "the text dropdown must carry exactly the chat models the live catalogue returned") + text_trigger = page.evaluate(READ_BOX, "[data-account-trigger]") + text_menu = page.evaluate(READ_BOX, "[data-account-menu]") + page.screenshot(path=str(directory / "text-model-dropdown.png")) + + # 3. The multimodal dropdown. The recorded live records are served by a bounded local + # relay on the configured inference base, so the panel still reads them through the + # Provider's own catalogue path; the live catalogue lists no image-input model to open. + page.click("[data-account-trigger]") + live.stop() + + recorded = Studio(binary, relay.url, free_port(), profile, project, home) + running.append(recorded) + page.goto(recorded.wait_for(), wait_until="load") + page.wait_for_function( + "document.querySelector('.account-panel')?.dataset.catalogSource === 'live'", timeout=60_000) + page.click("[data-account-trigger]") + page.wait_for_selector("[data-account-menu] button[data-model-id]") + page.click("[data-account-menu] button[data-model-id='deepseek/deepseek-v4-flash']") + assert page.evaluate(READ_PANEL).get("selectedModel") == "deepseek/deepseek-v4-flash" + + page.click("[data-account-attach]") + page.wait_for_function( + f"document.querySelector('.account-panel')?.dataset.itemCount === '{recorded_image_models}'", + timeout=30_000) + assert page.evaluate(READ_PANEL).get("selectedModel") == "", ( + "a model the multimodal control rejects must not stay selected") + + page.click("[data-account-trigger]") + page.wait_for_selector("[data-account-menu] button[data-model-id]") + image_items = page.eval_on_selector_all("[data-account-menu] button[data-model-id]", READ_MODELS) + assert image_items == ["deepseek/deepseek-v4.1-flash", "deepseek/deepseek-v4-flash-vision-exp"], ( + "the multimodal dropdown must list exactly the records declaring image input") + image_trigger = page.evaluate(READ_BOX, "[data-account-trigger]") + image_menu = page.evaluate(READ_BOX, "[data-account-menu]") + menu_style = page.evaluate( + "() => { const menu = document.querySelector('[data-account-menu]');" + " const style = getComputedStyle(menu);" + " return { background: style.backgroundColor, borderWidth: style.borderTopWidth }; }") + assert menu_style["background"] != "rgba(0, 0, 0, 0)", "the dropdown needs an opaque background" + assert menu_style["borderWidth"] != "0px", "the dropdown needs a visible border" + page.screenshot(path=str(directory / "multimodal-model-dropdown.png")) + finally: + browser.close() + finally: + for studio in running: + studio.stop() + shutil.rmtree(work, ignore_errors=True) + + assert page_errors == [], f"the page reported errors: {page_errors}" + + manifest = { + "automation": { + "framework": "playwright", + "engine": "python-playwright", + "passed": True, + "catalog_source": LIVE_CATALOG_URL, + "catalog_model_count": live_text_models, + "image_model_count": recorded_image_models, + "multimodal_catalog_source": ( + "recorded live records replayed by a bounded local relay; the live catalogue advertised " + "no image-input model, see catalog_source_note"), + "catalog_source_note": ( + f"the multimodal dropdown was captured against {recorded_text_models} recorded live " + "records on the configured inference base"), + "evidence": "real Studio UI at packages/studio, driven with Playwright", + }, + "artifacts": [], + } + ui_by_kind = { + "auth-methods": {"api_key_visible": True, "pkce_visible": True, "secret_masked": True, + "controls_enabled": True}, + "text-model-dropdown": {"dropdown_open": True, "item_count": len(text_items), + "opaque_background": True, "visible_border": True, + "trigger_panel_right_delta": text_trigger["x"] + text_trigger["width"] - (text_menu["x"] + text_menu["width"])}, + "multimodal-model-dropdown": {"dropdown_open": True, "item_count": len(image_items), + "opaque_background": True, "visible_border": True, + "trigger_panel_right_delta": image_trigger["x"] + image_trigger["width"] - (image_menu["x"] + image_menu["width"])}, + } + for artifact in SCREENSHOTS: + kind = artifact[: -len(".png")] + path = directory / artifact + assert path.stat().st_size > 10_000, f"{artifact} is too small to be a real capture" + # The key must not be legible in any capture. + assert API_KEY.encode() not in path.read_bytes(), f"{artifact} must not carry the key" + manifest["artifacts"].append({"kind": kind, "path": artifact, "sha256": sha256(path), "ui": ui_by_kind[kind]}) + (directory / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + + +if __name__ == "__main__": + capture(MANIFEST_DIRECTORY) + print("orca evidence captured in", MANIFEST_DIRECTORY) diff --git a/packages/video-cli/package.json b/packages/video-cli/package.json index 1524e8aaa..8f12b5326 100644 --- a/packages/video-cli/package.json +++ b/packages/video-cli/package.json @@ -23,6 +23,7 @@ "@hypit/project-context-node": "workspace:*", "@hypit/protocol": "workspace:*", "@hypit/provider-hypihub": "workspace:*", + "@hypit/provider-orcarouter": "workspace:*", "@hypit/runtime": "workspace:*", "@hypit/runtime-host-node": "workspace:*", "@hypit/runtime-local": "workspace:*", diff --git a/packages/video-cli/src/distribution.ts b/packages/video-cli/src/distribution.ts index a88155c84..0f158eea7 100644 --- a/packages/video-cli/src/distribution.ts +++ b/packages/video-cli/src/distribution.ts @@ -43,6 +43,14 @@ export const videoCliDistribution: CliDistribution = { "hyperframes.local": { use: "@hypit/provider-hyperframes-local", }, + // A first-class account beside HypiHub. Its one credential slot accepts either a pasted + // `sk-orca-…` key or an account authorization; both yield the same OrcaRouter key. + "orcarouter.default": { + use: "@hypit/provider-orcarouter", + config: { + apiKey: { store: "os", key: "orcarouter.apiKey" }, + }, + }, }, }, createCompiler: createVideoCompiler, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6aa40f1e3..7e39273b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -162,6 +162,9 @@ importers: '@hypit/narrative': specifier: workspace:* version: link:packages/narrative + '@hypit/orcarouter': + specifier: workspace:* + version: link:packages/orcarouter '@hypit/performance': specifier: workspace:* version: link:packages/performance @@ -189,6 +192,9 @@ importers: '@hypit/provider-media-local': specifier: workspace:* version: link:packages/provider-media-local + '@hypit/provider-orcarouter': + specifier: workspace:* + version: link:packages/provider-orcarouter '@hypit/provider-whisperx-local': specifier: workspace:* version: link:packages/provider-whisperx-local @@ -2043,6 +2049,27 @@ importers: specifier: workspace:* version: link:../protocol + packages/orcarouter: + dependencies: + '@hypit/artifact': + specifier: workspace:* + version: link:../artifact + '@hypit/component-kit': + specifier: workspace:* + version: link:../component-kit + '@hypit/elaborator': + specifier: workspace:* + version: link:../elaborator + '@hypit/markup': + specifier: workspace:* + version: link:../markup + '@hypit/protocol': + specifier: workspace:* + version: link:../protocol + '@hypit/text': + specifier: workspace:* + version: link:../text + packages/package-loader-node: dependencies: '@hypit/component-kit': @@ -2318,6 +2345,31 @@ importers: specifier: workspace:* version: link:../driver-node + packages/provider-orcarouter: + dependencies: + '@hypit/endpoint-kit': + specifier: workspace:* + version: link:../endpoint-kit + '@hypit/orcarouter': + specifier: workspace:* + version: link:../orcarouter + '@hypit/protocol': + specifier: workspace:* + version: link:../protocol + '@hypit/runtime': + specifier: workspace:* + version: link:../runtime + '@hypit/runtime-kit': + specifier: workspace:* + version: link:../runtime-kit + devDependencies: + '@hypit/artifact': + specifier: workspace:* + version: link:../artifact + '@hypit/cli': + specifier: workspace:* + version: link:../cli + packages/provider-whisperx-local: dependencies: '@hypit/endpoint-kit': @@ -3031,6 +3083,9 @@ importers: '@hypit/protocol': specifier: workspace:* version: link:../protocol + '@hypit/provider-orcarouter': + specifier: workspace:* + version: link:../provider-orcarouter '@hypit/ranking-studio': specifier: workspace:* version: link:../ranking-studio @@ -3043,6 +3098,9 @@ importers: '@hypit/runtime-host-node': specifier: workspace:* version: link:../runtime-host-node + '@hypit/runtime-local': + specifier: workspace:* + version: link:../runtime-local '@hypit/screen-overlay-studio': specifier: workspace:* version: link:../screen-overlay-studio @@ -3373,6 +3431,9 @@ importers: '@hypit/provider-hypihub': specifier: workspace:* version: link:../provider-hypihub + '@hypit/provider-orcarouter': + specifier: workspace:* + version: link:../provider-orcarouter '@hypit/runtime': specifier: workspace:* version: link:../runtime