From 4ed4434030cee2dea1bf5ae824f5bfd3929250ef Mon Sep 17 00:00:00 2001 From: lovejones2914-spec Date: Fri, 11 Sep 2026 09:15:52 +0000 Subject: [PATCH] feat: add OrcaRouter API-key and PKCE login Signed-off-by: lovejones2914-spec --- OrcaRouter/README.md | 130 ++++++ OrcaRouter/config-server.js | 89 ++++ OrcaRouter/credentials.js | 421 +++++++++++++++++ OrcaRouter/crypto-adapter.js | 158 +++++++ OrcaRouter/models.js | 345 ++++++++++++++ OrcaRouter/orcarouter.js | 238 ++++++++++ OrcaRouter/origins.js | 137 ++++++ OrcaRouter/pkce.js | 279 ++++++++++++ OrcaRouter/provider.js | 196 ++++++++ OrcaRouter/test/credentials.test.js | 330 ++++++++++++++ OrcaRouter/test/fixtures/catalog.json | 93 ++++ OrcaRouter/test/live.test.js | 256 +++++++++++ OrcaRouter/test/models.test.js | 250 +++++++++++ OrcaRouter/test/origins.test.js | 84 ++++ OrcaRouter/test/pkce.test.js | 287 ++++++++++++ OrcaRouter/test/server.test.js | 595 ++++++++++++++++++++++++ OrcaRouter/ui/evidence.py | 316 +++++++++++++ OrcaRouter/ui/page.js | 471 +++++++++++++++++++ OrcaRouter/ui/server-lib.js | 620 ++++++++++++++++++++++++++ README.md | 62 +++ READMEEN.md | 69 ++- 21 files changed, 5425 insertions(+), 1 deletion(-) create mode 100644 OrcaRouter/README.md create mode 100644 OrcaRouter/config-server.js create mode 100644 OrcaRouter/credentials.js create mode 100644 OrcaRouter/crypto-adapter.js create mode 100644 OrcaRouter/models.js create mode 100644 OrcaRouter/orcarouter.js create mode 100644 OrcaRouter/origins.js create mode 100644 OrcaRouter/pkce.js create mode 100644 OrcaRouter/provider.js create mode 100644 OrcaRouter/test/credentials.test.js create mode 100644 OrcaRouter/test/fixtures/catalog.json create mode 100644 OrcaRouter/test/live.test.js create mode 100644 OrcaRouter/test/models.test.js create mode 100644 OrcaRouter/test/origins.test.js create mode 100644 OrcaRouter/test/pkce.test.js create mode 100644 OrcaRouter/test/server.test.js create mode 100644 OrcaRouter/ui/evidence.py create mode 100644 OrcaRouter/ui/page.js create mode 100644 OrcaRouter/ui/server-lib.js diff --git a/OrcaRouter/README.md b/OrcaRouter/README.md new file mode 100644 index 0000000..b5f25a8 --- /dev/null +++ b/OrcaRouter/README.md @@ -0,0 +1,130 @@ +# OrcaRouter provider + +OrcaRouter is an OpenAI-compatible AI gateway built for both models and agents, with adaptive +routing, automatic failover, zero-markup inference, observability, guardrails, and agent-tool +governance. It also runs gateway-level, zero-trust security for AI agents on the same endpoint — +screening every prompt/response and governing every tool call on a default-deny basis, with no +application code changes. + +- Website: +- Discord: · X: + +## Layout + +| File | Role | +| --- | --- | +| `origins.js` | Resolves the auth and inference origins. The only place URLs are built. | +| `crypto-adapter.js` | SHA-256, base64url and CSPRNG access in both Node and Scriptable. | +| `pkce.js` | The OAuth 2.0 + PKCE core: attempt, authorize URL, state check, code exchange. | +| `credentials.js` | The credential seam: one interface, two adapters, storage, `401` handling. | +| `models.js` | Live catalog discovery, capability filtering, verified fallback seed. | +| `provider.js` | The provider definition, request builders, selection reconciler. | +| `orcarouter.js` | On-device (Scriptable) entry point. Flow B. | +| `config-server.js`, `ui/` | Desktop configuration helper and model selector. Flow A. | + +## Two authentication choices, one credential + +Both entries are adapters on the same interface in `credentials.js` and produce the same +`CredentialResult`. Nothing downstream — inference, model discovery, any entry point — can tell +which one ran. + +| Entry | ID | Credential | +| --- | --- | --- | +| OrcaRouter · API key | `orcarouter` | An existing `sk-orca-…` key the user pastes. | +| OrcaRouter · Connect | `orcarouter-oauth` | A key issued by OAuth 2.0 + PKCE (S256). | + +Neither requires a client secret or a pre-registered redirect URI. The key belongs to the user: it is +billed to their account, listed in their console, and revocable at any time from +. + +A PKCE-issued key is **durable, but it is not a refresh token**. There is no refresh grant. A `401` +from the relay is terminal reauthentication for the exact account and credential generation that +made the rejected request; a late failure from an older generation is discarded, and the stored key +is never deleted before a replacement succeeds. + +### Which flow, and why + +- **Flow A — loopback redirect** (`config-server.js`): the desktop helper has a browser and can bind + `127.0.0.1:`, so the code comes back automatically and the user clicks once. +- **Flow B — out-of-band code** (`orcarouter.js`): iOS has no loopback listener, so the consent + screen's code is pasted back. + +Both send `S256`. That is not optional even on Flow A: the consent screen lets the user choose +"show me a code", and a code that passes through human hands must not be redeemable with material +that travelled on the authorize URL. + +## Endpoints and overrides + +| Purpose | Default | +| --- | --- | +| Inference and model catalog | `https://api.orcarouter.ai/v1` | +| Authorization and code exchange | `https://www.orcarouter.ai` (`/auth`, `/api/v1/auth/keys`) | + +- `ORCA_BASE_URL` — shared self-hosted origin for both. +- `ORCA_AUTH_BASE_URL`, `ORCA_API_BASE_URL` — separate overrides; explicit values win over the shared + one. + +Neither origin is derived from the other. Remote origins must be HTTPS; plain HTTP is allowed only on +loopback. `https://api.orcarouter.ai/v1/auth/keys` is a **404** — the relay lives at `/v1`, and the +auth endpoints do not. + +The authoritative discovery document is +`https://www.orcarouter.ai/.well-known/openid-configuration`; it advertises +`code_challenge_methods_supported: ["S256", "plain"]`, and this integration only ever uses `S256`. + +## Model catalog + +The single source of truth is `GET /models`. When live discovery succeeds it is +authoritative and the fallback is **not** mixed into it. Capability filtering reads catalog metadata +only — never a model's name — and a model that does not declare a capability is excluded. + +| Entry point | Selection rule | Live count (2026-09-11) | +| --- | --- | --- | +| Text chat / agent | `?capability=chat` **and** a text endpoint type (`openai`/`anthropic`/`gemini`/`openai-response`) | 160 of 166 returned | +| Multimodal understanding | chat **and** `architecture.input_modalities` contains the attached modality | 121 | +| Embeddings | endpoint `embeddings` | 5 | +| Image generation | endpoint `image-generation` | 6 | +| Video generation | endpoint `openai-video` | — | +| Rerank | endpoint `jina-rerank` | — | + +The server-side `?capability=chat` filter is necessary but not sufficient: it returns records whose +only endpoint types are non-text, and the local filter removes them. That is why the text list is 160 +and not 166. + +`architecture` is absent on 34 records. Those declare nothing, so they fail closed and never appear +in a multimodal list — `orcarouter/auto` is one of them. + +### Verified fallback seed + +Read from `GET https://api.orcarouter.ai/v1/models` on **2026-09-11**. The catalog does not currently +advertise a reasoning field, so the reasoning ladder below is verified metadata preserved by +`reconcileWithVerifiedMetadata`: a live refresh cannot strip it. + +| ID | Endpoints | Context | Input modalities | Reasoning | +| --- | --- | --- | --- | --- | +| `openai/gpt-5.5` | openai, openai-response | not advertised | file, image, text | low, medium, high, xhigh | +| `anthropic/claude-opus-4.8` | openai, anthropic, openai-response | 1000000 | text, image, file | — | +| `google/gemini-3.5-flash` | openai, gemini | 1048576 | text, image, video, file, audio | — | +| `deepseek/deepseek-v4-pro` | openai, openai-response | 1048576 | text | — | +| `orcarouter/auto` | openai, openai-response, anthropic, gemini | not advertised | not declared → text only | — | + +Discovery is bounded: 8 s timeout, 2 MiB response cap, 4000 item cap, and per-record shape +validation. On failure the seed is shown with an explicit degraded flag; a stored selection is +restored only after re-checking it against the current capability list. + +## Tests + +```bash +node --test OrcaRouter/test/ # 107 tests, no network +ORCAROUTER_API_KEY=sk-orca-… node --test OrcaRouter/test/live.test.js # live catalog + inference +ORCAROUTER_API_KEY=sk-orca-… python3 OrcaRouter/ui/evidence.py # UI evidence + screenshots +``` + +The offline suite covers both credential adapters, the full Flow A chain against a fake auth server +(authorize → loopback callback → exchange → persist), denial, state mismatch, code reuse, expiry, +`429`, network failure, the generation guard, `pagehide` cancellation, and every capability filter. +The live suite runs the real catalog and a real completion through the same code paths. + +`ui/evidence.py` drives the real configuration page in headless Chromium and asserts the two auth +entries, the masked key control, the listbox `aria-expanded` state, the panel's opacity/border, and +that the panel's right edge tracks the trigger's within 2 px. diff --git a/OrcaRouter/config-server.js b/OrcaRouter/config-server.js new file mode 100644 index 0000000..09b3d6f --- /dev/null +++ b/OrcaRouter/config-server.js @@ -0,0 +1,89 @@ +#!/usr/bin/env node +/** + * OrcaRouter local configuration helper. + * + * A small Node standard-library HTTP server that serves the OrcaRouter provider + * settings page and the model selector, and performs the OAuth 2.0 + PKCE + * "Connect with OrcaRouter" flow using Flow A (loopback redirect) — this process + * runs on a machine with a browser and can bind 127.0.0.1, so the code comes + * back automatically. + * + * Model discovery runs HERE, on the server, with the user's key. The browser + * receives only minimal model metadata and never holds an API key. + * + * Session, login lock and cancellation + * ------------------------------------ + * One login attempt at a time. Every terminal path (success, denial, exchange + * error, timeout, explicit cancel, switching auth method, closing the modal, + * unmount, reload and `pagehide`) releases the lock. Each attempt gets a + * monotonically increasing generation; a response belonging to an older + * generation can never write credentials or UI state. + * + * node OrcaRouter/config-server.js --port 8787 + */ + +const http = require('node:http') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const crypto = require('node:crypto') + +const UIServer = require('./ui/server-lib.js') +const UIPage = require('./ui/page.js') + +const DEFAULT_PORT = 8787 +const LOGIN_TIMEOUT_MS = 5 * 60 * 1000 + +function parseArgs(argv) { + const args = { port: DEFAULT_PORT, host: '127.0.0.1', stateDir: null } + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--port') args.port = Number(argv[++i]) + else if (argv[i] === '--host') args.host = argv[++i] + else if (argv[i] === '--state-dir') args.stateDir = argv[++i] + } + if (!args.stateDir) { + const base = process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state') + args.stateDir = path.join(base, 'scriptable-orcarouter') + } + return args +} + +function createServer(options) { + const opts = options || {} + const app = new UIServer.OrcaRouterConfigApp({ + fs, + path, + crypto, + stateDir: opts.stateDir, + env: opts.env || process.env, + fetchImpl: opts.fetchImpl || globalThis.fetch, + now: opts.now || (() => Date.now()), + listenHost: opts.listenHost || '127.0.0.1', + loginTimeoutMs: opts.loginTimeoutMs || LOGIN_TIMEOUT_MS, + buildAuthorizeUrl: opts.buildAuthorizeUrl || null, + pageHtml: UIPage.pageHtml, + pageJs: UIPage.pageJs, + pageCss: UIPage.pageCss, + }) + return app +} + +function main() { + const args = parseArgs(process.argv.slice(2)) + // The credential file lives here; a first run has no directory yet. + fs.mkdirSync(args.stateDir, { recursive: true }) + const app = createServer({ stateDir: args.stateDir }) + const server = http.createServer((req, res) => app.handle(req, res)) + server.listen(args.port, args.host, () => { + const address = server.address() + process.stdout.write(`OrcaRouter configuration: http://${args.host}:${address.port}/\n`) + process.stdout.write('Press Ctrl+C to stop.\n') + }) + return server +} + +if (require.main === module) { + main() +} + +module.exports = { createServer, parseArgs, DEFAULT_PORT, LOGIN_TIMEOUT_MS } diff --git a/OrcaRouter/credentials.js b/OrcaRouter/credentials.js new file mode 100644 index 0000000..2520c07 --- /dev/null +++ b/OrcaRouter/credentials.js @@ -0,0 +1,421 @@ +/** + * OrcaRouter credential seam. + * + * Both user-facing choices — pasting an `sk-orca-…` key, and "Connect with + * OrcaRouter" over OAuth 2.0 + PKCE — are adapters on this one interface. They + * produce the same {@link CredentialResult}, and nothing downstream (provider + * requests, model discovery, any AI entry point) can tell which adapter ran. + * + * A PKCE-issued key is durable but is NOT a refresh token: OrcaRouter issues no + * refresh grant. A revoked key is a terminal reauthentication, handled by + * `markRejected` below, which is generation-safe so a late failure from an old + * request can never damage a freshly reauthorized credential. + * + * Storage reuses whatever the host already trusts — the Scriptable iOS Keychain + * via `Env.js`'s `setdata/getdata/hasdata/rmdata`, or a credentials file on the + * machine running the configuration helper. No new secret store is introduced, + * and the key is never written to a URL, log, error, telemetry or fixture. + */ + +const ACCOUNT_PREFIX = 'orcarouter' +const KEYCHAIN_KEY = 'orcarouter_api_key' +const KEYCHAIN_META = 'orcarouter_credential_meta' + +const SOURCE_API_KEY = 'api-key' +const SOURCE_PKCE = 'pkce' + +const STATE_ACTIVE = 'active' +const STATE_NEEDS_REAUTH = 'needsReauth' + +/** + * In-memory store. Used by tests and as the base shape every store must match. + */ +class MemoryStore { + constructor() { + this.values = {} + } + read(name) { + return Object.prototype.hasOwnProperty.call(this.values, name) ? this.values[name] : null + } + write(name, value) { + this.values[name] = String(value) + return true + } + clear(name) { + delete this.values[name] + return true + } + has(name) { + return Object.prototype.hasOwnProperty.call(this.values, name) + } +} + +/** + * Scriptable iOS Keychain store — the repository's existing secret mechanism. + * The Keychain functions are injected so this file stays testable off-device. + */ +class KeychainStore { + constructor(api) { + if (!api || typeof api.set !== 'function') throw new Error('KeychainStore requires the Keychain API') + this.api = api + } + read(name) { + return this.api.contains(name) ? this.api.get(name) : null + } + write(name, value) { + this.api.set(String(value), name) + return true + } + clear(name) { + this.api.remove(name) + return true + } + has(name) { + return !!this.api.contains(name) + } +} + +/** + * File-backed store for the local configuration helper. Reuses the helper's own + * state directory; it is not a second credential store for the iOS scripts. + */ +class FileStore { + constructor(options) { + const opts = options || {} + this.fs = opts.fs + this.path = opts.path + this.mode = opts.mode || 0o600 + if (!this.fs || !this.path) throw new Error('FileStore requires fs and path') + } + _readAll() { + try { + const raw = this.fs.readFileSync(this.path, 'utf8') + const parsed = JSON.parse(raw) + return parsed && typeof parsed === 'object' ? parsed : {} + } catch (e) { + return {} + } + } + _writeAll(all) { + this.fs.writeFileSync(this.path, JSON.stringify(all, null, 2), { mode: this.mode }) + return true + } + read(name) { + const all = this._readAll() + return Object.prototype.hasOwnProperty.call(all, name) ? all[name] : null + } + write(name, value) { + const all = this._readAll() + all[name] = String(value) + return this._writeAll(all) + } + clear(name) { + const all = this._readAll() + delete all[name] + return this._writeAll(all) + } + has(name) { + return Object.prototype.hasOwnProperty.call(this._readAll(), name) + } +} + +/** + * Light format check only. An `sk-orca-` prefix is not proof of validity, and + * this deliberately does not spend a billing request to make a form say "valid". + */ +function looksLikeOrcaKey(value) { + return typeof value === 'string' && /^sk-orca-[A-Za-z0-9_\-]{8,}$/.test(value.trim()) +} + +function sanitizeKey(value) { + return typeof value === 'string' ? value.trim() : '' +} + +/** Never render a key; length is enough for a status line. */ +function redactKey(value) { + const key = sanitizeKey(value) + if (!key) return '' + if (key.length <= 12) return '' + return `${key.slice(0, 8)}…${key.slice(-4)}` +} + +/** The single result type both adapters produce. */ +function makeCredentialResult(fields) { + return { + providerId: 'orcarouter', + authId: fields.authId, + source: fields.source, + key: fields.key, + accountId: fields.accountId || null, + displayName: fields.displayName || null, + scope: fields.scope || null, + scopeAccepted: fields.scopeAccepted !== false, + generation: fields.generation, + state: STATE_ACTIVE, + createdAt: fields.createdAt || null, + } +} + +/** + * API-key adapter. The user supplies an existing `sk-orca-…` key through the + * project's normal secret mechanism. + */ +class ApiKeyAdapter { + constructor(options) { + const opts = options || {} + this.id = 'orcarouter' + this.label = 'OrcaRouter · API key' + this.source = SOURCE_API_KEY + this.manager = opts.manager + } + + /** Persist a pasted key. Update and clear are first-class, not an overwrite hack. */ + save(rawKey) { + const key = sanitizeKey(rawKey) + if (!looksLikeOrcaKey(key)) { + return { ok: false, kind: 'invalid_format', message: 'That does not look like an OrcaRouter key (expected sk-orca-…).' } + } + const result = this.manager.persist(makeCredentialResult({ + authId: this.id, + source: SOURCE_API_KEY, + key, + accountId: null, + scope: 'api', + generation: this.manager.nextGeneration(), + createdAt: this.manager.now(), + })) + return { ok: true, credential: result, message: 'OrcaRouter API key saved.' } + } + + clear() { + return this.manager.clear() + } +} + +/** + * PKCE adapter. It never sees a password and never holds a long-lived secret it + * asked the user to copy; the connect flow hands it a normal API key. + */ +class PkceAdapter { + constructor(options) { + const opts = options || {} + this.id = 'orcarouter-oauth' + this.label = 'OrcaRouter · Connect' + this.source = SOURCE_PKCE + this.manager = opts.manager + this.connect = opts.connect + } + + /** + * Run the connect flow. `connect` performs the browser/code exchange and + * returns {key, userId, scope}. Anything that fails returns a classified, + * actionable error — it never returns a phantom success. + */ + async authorize(params) { + const p = params || {} + if (typeof this.connect !== 'function') { + return { ok: false, kind: 'unavailable', message: 'The connect flow is not available in this runtime.' } + } + const outcome = await this.connect(p) + if (!outcome || !outcome.ok) { + return { + ok: false, + kind: (outcome && outcome.kind) || 'exchange_failed', + terminal: outcome ? outcome.terminal !== false : true, + message: (outcome && outcome.message) || 'The OrcaRouter connect flow did not complete.', + } + } + const credential = this.manager.persist(makeCredentialResult({ + authId: this.id, + source: SOURCE_PKCE, + key: outcome.key, + accountId: outcome.userId || null, + scope: outcome.scope && outcome.scope.granted ? outcome.scope.granted : 'api', + scopeAccepted: !(outcome.scope && outcome.scope.downgraded), + generation: this.manager.nextGeneration(), + createdAt: this.manager.now(), + })) + return { + ok: true, + credential, + // Surfaced, not swallowed: a narrower grant than requested is reported. + warning: outcome.scope && outcome.scope.downgraded + ? `OrcaRouter granted "${outcome.scope.granted}" rather than "${outcome.scope.requested}".` + : null, + message: 'Connected to OrcaRouter.', + } + } + + clear() { + return this.manager.clear() + } +} + +/** + * Owns storage, the current credential, and the terminal-401 transition. + */ +class CredentialManager { + constructor(options) { + const opts = options || {} + this.store = opts.store || new MemoryStore() + this.keyName = opts.keyName || KEYCHAIN_KEY + this.metaName = opts.metaName || KEYCHAIN_META + this.clock = opts.clock || (() => Date.now()) + this._generation = 0 + this._credential = null + } + + now() { + return this.clock() + } + + nextGeneration() { + this._generation += 1 + return this._generation + } + + currentGeneration() { + return this._generation + } + + persist(credential) { + this._credential = credential + this.store.write(this.keyName, credential.key) + this.store.write(this.metaName, JSON.stringify({ + authId: credential.authId, + source: credential.source, + accountId: credential.accountId, + scope: credential.scope, + generation: credential.generation, + state: credential.state, + createdAt: credential.createdAt, + })) + return credential + } + + /** Load a stored credential without contacting OrcaRouter. */ + load() { + if (this._credential) return this._credential + const key = this.store.read(this.keyName) + if (!key) return null + let meta = {} + const rawMeta = this.store.read(this.metaName) + if (rawMeta) { + try { + meta = JSON.parse(rawMeta) || {} + } catch (e) { + // A corrupt metadata blob must not resurrect a stale credential into an + // active state; the key is kept until a replacement succeeds. + meta = { state: STATE_NEEDS_REAUTH, corrupted: true } + } + } + const generation = typeof meta.generation === 'number' ? meta.generation : 0 + if (generation > this._generation) this._generation = generation + this._credential = makeCredentialResult({ + authId: meta.authId || (meta.source === SOURCE_PKCE ? 'orcarouter-oauth' : 'orcarouter'), + source: meta.source || SOURCE_API_KEY, + key, + accountId: meta.accountId || null, + scope: meta.scope || 'api', + generation, + createdAt: meta.createdAt || null, + }) + if (meta.state) this._credential.state = meta.state + return this._credential + } + + /** Status for the UI: never returns the secret itself. */ + describe() { + const credential = this.load() + if (!credential) return { configured: false, state: 'unconfigured', authId: null, source: null, redacted: '' } + return { + configured: true, + state: credential.state, + authId: credential.authId, + source: credential.source, + accountId: credential.accountId, + scope: credential.scope, + generation: credential.generation, + redacted: redactKey(credential.key), + needsReauth: credential.state === STATE_NEEDS_REAUTH, + } + } + + /** The only place the raw key is handed out, and only to a request path. */ + resolveForRequest() { + const credential = this.load() + if (!credential) return { ok: false, kind: 'unconfigured', message: 'No OrcaRouter credential is configured.' } + if (credential.state === STATE_NEEDS_REAUTH) { + return { + ok: false, + kind: 'needs_reauth', + message: 'This OrcaRouter credential was rejected. Connect again or paste a new key.', + } + } + return { ok: true, key: credential.key, generation: credential.generation, accountId: credential.accountId } + } + + /** + * Terminal 401 handling — the generation-safe core. + * + * Only the exact account AND the exact credential generation that issued the + * rejected request is marked. A late 401 from a request that started before a + * successful re-login is discarded, so it cannot mark the new credential + * broken. + * + * There is no refresh grant to attempt: this never schedules a refresh and + * never deletes the stored key. + */ + markRejected(rejected) { + const target = rejected || {} + this.load() + if (!this._credential) return { changed: false, reason: 'no_credential' } + if (typeof target.generation === 'number' && target.generation !== this._credential.generation) { + return { changed: false, reason: 'stale_generation' } + } + if (target.accountId && this._credential.accountId && target.accountId !== this._credential.accountId) { + return { changed: false, reason: 'other_account' } + } + const previous = this._credential.state + this._credential.state = STATE_NEEDS_REAUTH + const rawMeta = this.store.read(this.metaName) + if (rawMeta) { + try { + const meta = JSON.parse(rawMeta) || {} + meta.state = STATE_NEEDS_REAUTH + this.store.write(this.metaName, JSON.stringify(meta)) + } catch (e) { + // Metadata already unreadable; the in-memory state above is authoritative + // for this process and the key itself is left in place. + } + } + return { changed: previous !== STATE_NEEDS_REAUTH, reason: 'marked', generation: this._credential.generation } + } + + /** Clear the credential. Only ever called by an explicit user action. */ + clear() { + this.store.clear(this.keyName) + this.store.clear(this.metaName) + this._credential = null + return { ok: true, message: 'OrcaRouter credential removed.' } + } +} + +module.exports = { + ACCOUNT_PREFIX, + KEYCHAIN_KEY, + KEYCHAIN_META, + SOURCE_API_KEY, + SOURCE_PKCE, + STATE_ACTIVE, + STATE_NEEDS_REAUTH, + MemoryStore, + KeychainStore, + FileStore, + looksLikeOrcaKey, + redactKey, + makeCredentialResult, + ApiKeyAdapter, + PkceAdapter, + CredentialManager, +} diff --git a/OrcaRouter/crypto-adapter.js b/OrcaRouter/crypto-adapter.js new file mode 100644 index 0000000..362cda7 --- /dev/null +++ b/OrcaRouter/crypto-adapter.js @@ -0,0 +1,158 @@ +/** + * Environment-neutral crypto primitives for the PKCE flow. + * + * SHA-256 and base64url exist in every standard library, so no dependency is + * added: Node uses node:crypto, and the Scriptable runtime uses the + * `crypto-js.min.js` bundle the repository already vendors at its root. + * + * Randomness is the one place where the two runtimes genuinely differ. The + * verifier MUST come from a cryptographic RNG. Apple's NSUUID is backed by the + * platform CSPRNG, so on device we mix several UUIDs through SHA-256 instead of + * touching Math.random. Node uses crypto.randomBytes directly. + */ + +const BYTE_TO_HEX = '0123456789abcdef' + +function bytesToHex(bytes) { + let out = '' + for (let i = 0; i < bytes.length; i++) { + out += BYTE_TO_HEX[(bytes[i] >> 4) & 0xf] + BYTE_TO_HEX[bytes[i] & 0xf] + } + return out +} + +function hexToBytes(hex) { + const clean = hex.replace(/[^0-9a-fA-F]/g, '') + const out = new Uint8Array(Math.floor(clean.length / 2)) + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(clean.substr(i * 2, 2), 16) + } + return out +} + +function bytesToBase64Url(bytes) { + let binary = '' + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]) + const base64 = typeof btoa === 'function' + ? btoa(binary) + : nodeBufferFrom(bytes).toString('base64') + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function base64UrlToBytes(value) { + const padded = value.replace(/-/g, '+').replace(/_/g, '/') + const withPadding = padded + '='.repeat((4 - (padded.length % 4)) % 4) + if (typeof atob === 'function') { + const binary = atob(withPadding) + const out = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) + return out + } + return new Uint8Array(nodeBufferFromBase64(withPadding)) +} + +function nodeRequire(name) { + // eslint-disable-next-line no-undef + return require(name) +} + +function nodeBufferFrom(bytes) { + return nodeRequire('buffer').Buffer.from(bytes) +} + +function nodeBufferFromBase64(value) { + return nodeRequire('buffer').Buffer.from(value, 'base64') +} + +function isNodeRuntime() { + return typeof process !== 'undefined' && !!(process.versions && process.versions.node) +} + +/** Load the repository's vendored crypto-js bundle inside Scriptable. */ +function loadCryptoJs() { + if (loadCryptoJs._cached) return loadCryptoJs._cached + // eslint-disable-next-line no-undef + const loader = typeof importModule === 'function' ? importModule : null + if (!loader) { + throw new Error('No SHA-256 implementation available in this runtime') + } + loadCryptoJs._cached = loader('crypto-js.min') + return loadCryptoJs._cached +} + +function sha256BytesNode(bytes) { + const crypto = nodeRequire('crypto') + return new Uint8Array(crypto.createHash('sha256').update(nodeBufferFrom(bytes)).digest()) +} + +function sha256BytesScriptable(bytes) { + const CryptoJS = loadCryptoJs() + const wordArray = CryptoJS.lib.WordArray.create(bytes) + const digest = CryptoJS.SHA256(wordArray) + const hex = digest.toString(CryptoJS.enc.Hex) + return hexToBytes(hex) +} + +function sha256Bytes(bytes) { + return isNodeRuntime() ? sha256BytesNode(bytes) : sha256BytesScriptable(bytes) +} + +function randomBytesNode(size) { + const crypto = nodeRequire('crypto') + return new Uint8Array(crypto.randomBytes(size)) +} + +/** + * Scriptable has no raw CSPRNG binding, so gather entropy from the platform's + * UUID generator (Apple NSUUID, CSPRNG-backed) and compress it with SHA-256. + * Math.random is never used as a source here. + */ +function randomBytesScriptable(size) { + // eslint-disable-next-line no-undef + const uuid = typeof UUID !== 'undefined' ? UUID : null + if (!uuid || typeof uuid.string !== 'function') { + throw new Error('No cryptographic RNG available in this runtime') + } + const chunks = [] + let gathered = '' + while (gathered.length < size * 4) { + gathered += uuid.string().replace(/-/g, '') + } + const seed = hexToBytes(gathered) + chunks.push(seed) + const merged = new Uint8Array(size * 3) + for (let i = 0; i < size * 3; i++) merged[i] = seed[i % seed.length] + const out = sha256Bytes(merged) + return out.slice(0, size) +} + +function randomBytes(size) { + if (!Number.isInteger(size) || size <= 0) throw new Error('randomBytes size must be a positive integer') + return isNodeRuntime() ? randomBytesNode(size) : randomBytesScriptable(size) +} + +/** + * Constant-time string comparison. Used for the Flow A `state` check, where a + * timing-dependent comparison would leak the expected value one byte at a time. + */ +function timingSafeEqualString(a, b) { + const left = String(a === undefined || a === null ? '' : a) + const right = String(b === undefined || b === null ? '' : b) + if (left.length !== right.length) return false + let diff = 0 + for (let i = 0; i < left.length; i++) { + diff |= left.charCodeAt(i) ^ right.charCodeAt(i) + } + return diff === 0 +} + +module.exports = { + bytesToHex, + hexToBytes, + bytesToBase64Url, + base64UrlToBytes, + sha256Bytes, + randomBytes, + timingSafeEqualString, + isNodeRuntime, +} diff --git a/OrcaRouter/models.js b/OrcaRouter/models.js new file mode 100644 index 0000000..9d4b723 --- /dev/null +++ b/OrcaRouter/models.js @@ -0,0 +1,345 @@ +/** + * OrcaRouter model catalog: live discovery, capability filtering, and a small + * verified cold-start seed. + * + * The single source of truth for what exists is `GET /models` on the + * configured API origin. When live discovery succeeds it is authoritative and + * the seed is NOT mixed into it. When it fails, a small, verified seed keeps a + * fresh installation usable and the caller learns it is degraded. + * + * Capability filters are derived from catalog metadata only — never from a + * model's name. A model that does not declare a capability is excluded + * (fail closed). + */ + +const TEXT_ENDPOINT_TYPES = ['openai', 'anthropic', 'gemini', 'openai-response'] +const NON_TEXT_ENDPOINT_TYPES = ['embeddings', 'image-generation', 'openai-video', 'jina-rerank'] + +const DEFAULT_TIMEOUT_MS = 8000 +const DEFAULT_MAX_BYTES = 2 * 1024 * 1024 +const DEFAULT_MAX_ITEMS = 4000 + +/** + * Verified fallback seed. + * + * Endpoint types, context length and input modalities were read from + * `GET https://api.orcarouter.ai/v1/models` on 2026-09-11. Models whose + * `architecture` is absent (`orcarouter/auto`) declare no modalities and are + * therefore text-only under the fail-closed rule. Reasoning ladders are + * preserved through live merges; `openai/gpt-5.5` keeps low/medium/high/xhigh. + */ +const VERIFIED_SEED = [ + { + id: 'openai/gpt-5.5', + name: 'OpenAI: GPT-5.5', + endpoints: ['openai', 'openai-response'], + contextLength: null, + inputModalities: ['file', 'image', 'text'], + outputModalities: ['text'], + reasoning: { supported: true, efforts: ['low', 'medium', 'high', 'xhigh'] }, + seed: true, + }, + { + id: 'anthropic/claude-opus-4.8', + name: 'Anthropic: Claude Opus 4.8', + endpoints: ['openai', 'anthropic', 'openai-response'], + contextLength: 1000000, + inputModalities: ['text', 'image', 'file'], + outputModalities: ['text'], + reasoning: { supported: true, efforts: [] }, + seed: true, + }, + { + id: 'google/gemini-3.5-flash', + name: 'Google: Gemini 3.5 Flash', + endpoints: ['openai', 'gemini'], + contextLength: 1048576, + inputModalities: ['text', 'image', 'video', 'file', 'audio'], + outputModalities: ['text'], + reasoning: { supported: true, efforts: [] }, + seed: true, + }, + { + id: 'deepseek/deepseek-v4-pro', + name: 'DeepSeek: V4 Pro', + endpoints: ['openai', 'openai-response'], + contextLength: 1048576, + inputModalities: ['text'], + outputModalities: ['text'], + reasoning: { supported: true, efforts: [] }, + seed: true, + }, + { + id: 'orcarouter/auto', + name: 'OrcaRouter: Auto', + endpoints: ['openai', 'openai-response', 'anthropic', 'gemini'], + contextLength: null, + inputModalities: [], + outputModalities: ['text'], + reasoning: { supported: false, efforts: [] }, + seed: true, + }, +] + +const CAPABILITIES = { + chat: 'chat', + embedding: 'embedding', + image: 'image', + video: 'video', + rerank: 'rerank', +} + +const MODALITIES = ['image', 'audio', 'video', 'file'] + +/** Normalise one raw catalog record, discarding anything that is not usable. */ +function normalizeRecord(raw) { + if (!raw || typeof raw !== 'object') return null + const id = typeof raw.id === 'string' ? raw.id.trim() : '' + // The vendor/model namespace is preserved verbatim; nothing is rewritten. + if (!id || id.indexOf('/') === -1) return null + + const endpoints = Array.isArray(raw.supported_endpoint_types) + ? raw.supported_endpoint_types.filter((e) => typeof e === 'string') + : [] + + const architecture = raw.architecture && typeof raw.architecture === 'object' ? raw.architecture : null + const inputModalities = architecture && Array.isArray(architecture.input_modalities) + ? architecture.input_modalities.filter((m) => typeof m === 'string').map((m) => m.toLowerCase()) + : [] + const outputModalities = architecture && Array.isArray(architecture.output_modalities) + ? architecture.output_modalities.filter((m) => typeof m === 'string').map((m) => m.toLowerCase()) + : [] + + let contextLength = typeof raw.context_length === 'number' && raw.context_length > 0 ? raw.context_length : null + if (!contextLength && raw.top_provider && typeof raw.top_provider.context_length === 'number' && raw.top_provider.context_length > 0) { + contextLength = raw.top_provider.context_length + } + + return { + id, + name: typeof raw.name === 'string' && raw.name ? raw.name : id, + endpoints, + contextLength, + inputModalities, + outputModalities, + reasoning: normalizeReasoning(raw), + seed: false, + } +} + +function normalizeReasoning(raw) { + // The catalog does not currently advertise a reasoning field. If one appears, + // it is read here rather than guessed from the model name. + const candidate = raw && (raw.reasoning || raw.reasoning_efforts) + if (!candidate) return { supported: false, efforts: [] } + if (Array.isArray(candidate)) return { supported: candidate.length > 0, efforts: candidate.slice() } + if (typeof candidate === 'object' && Array.isArray(candidate.efforts)) { + return { supported: candidate.efforts.length > 0, efforts: candidate.efforts.slice() } + } + return { supported: false, efforts: [] } +} + +function parseCatalog(payload, limits) { + const caps = limits || {} + const maxItems = caps.maxItems || DEFAULT_MAX_ITEMS + const list = payload && Array.isArray(payload.data) ? payload.data : [] + const out = [] + for (let i = 0; i < list.length && out.length < maxItems; i++) { + const record = normalizeRecord(list[i]) + if (record) out.push(record) + } + return out +} + +function hasTextEndpoint(model) { + return model.endpoints.some((e) => TEXT_ENDPOINT_TYPES.indexOf(e) !== -1) +} + +function hasNonTextEndpoint(model) { + return model.endpoints.some((e) => NON_TEXT_ENDPOINT_TYPES.indexOf(e) !== -1) +} + +/** + * Filter a catalog for one entry point's capability. + * + * @param {Array} models normalised records + * @param {string} capability one of CAPABILITIES + * @param {object} [options] { modality } required non-text input modality + */ +function filterByCapability(models, capability, options) { + const opts = options || {} + const list = Array.isArray(models) ? models : [] + return list.filter((model) => { + switch (capability) { + case CAPABILITIES.chat: + // A text endpoint must be advertised, and an obviously non-text model + // (image-generation, video, rerank, embeddings) must not slip in. + return hasTextEndpoint(model) && !hasNonTextEndpoint(model) && !model.inputModalities.includes('image-only') + case CAPABILITIES.embedding: + return model.endpoints.indexOf('embeddings') !== -1 + case CAPABILITIES.image: + return model.endpoints.indexOf('image-generation') !== -1 + case CAPABILITIES.video: + return model.endpoints.indexOf('openai-video') !== -1 + case CAPABILITIES.rerank: + return model.endpoints.indexOf('jina-rerank') !== -1 + default: + return false + } + }).filter((model) => { + // Multimodal understanding: chat first, then the declared input modality + // actually being attached. Undeclared -> fail closed. + if (!opts.modality) return true + const modality = String(opts.modality).toLowerCase() + if (MODALITIES.indexOf(modality) === -1) return false + return model.inputModalities.indexOf(modality) !== -1 + }) +} + +/** The server-side query parameter for a capability, when one exists. */ +function capabilityQuery(capability) { + if (capability === CAPABILITIES.chat) return 'chat' + if (capability === CAPABILITIES.embedding) return 'embedding' + if (capability === CAPABILITIES.image) return 'image' + return null +} + +/** + * Ask the live endpoint what exists, bounded in time, bytes and item count. + * `capability` is sent as the server hint; the local filter still runs, because + * the server's `chat` answer can include records with no text endpoint type. + */ +async function discoverModels(params) { + const p = params || {} + const origins = p.origins + if (!origins) throw new Error('discoverModels requires resolved origins') + const fetchImpl = p.fetchImpl + if (typeof fetchImpl !== 'function') throw new Error('discoverModels requires a fetch implementation') + const timeoutMs = p.timeoutMs || DEFAULT_TIMEOUT_MS + const maxBytes = p.maxBytes || DEFAULT_MAX_BYTES + const maxItems = p.maxItems || DEFAULT_MAX_ITEMS + + const url = new URL(origins.modelsUrl) + const hint = capabilityQuery(p.capability) + if (hint) url.searchParams.set('capability', hint) + + const headers = {} + if (p.apiKey) headers.Authorization = `Bearer ${p.apiKey}` + + let response + try { + response = await fetchImpl(url.toString(), { + method: 'GET', + headers, + timeoutMs, + maxBytes, + signal: p.signal, + }) + } catch (error) { + return degraded(p, `catalog request failed: ${safeMessage(error)}`) + } + + if (response.status !== 200) { + return degraded(p, `catalog request returned ${response.status}`) + } + + const payload = await readJsonBounded(response, maxBytes) + if (!payload) return degraded(p, 'catalog response was not valid JSON') + + const models = parseCatalog(payload, { maxItems }) + if (models.length === 0) return degraded(p, 'catalog response contained no usable models') + + return { + models, + source: 'live', + degraded: false, + catalogUrl: url.toString(), + message: null, + } +} + +function degraded(params, message) { + const p = params || {} + const fallback = p.lastKnownGood && p.lastKnownGood.length ? p.lastKnownGood : VERIFIED_SEED + const source = p.lastKnownGood && p.lastKnownGood.length ? 'last-known-good' : 'seed' + return { + models: fallback.map((m) => Object.assign({}, m)), + source, + degraded: true, + message, + } +} + +async function readJsonBounded(response, maxBytes) { + try { + if (typeof response.text === 'function') { + const text = await response.text() + if (typeof text === 'string' && text.length > maxBytes) return null + return JSON.parse(text) + } + if (typeof response.json === 'function') { + return await response.json() + } + } catch (e) { + return null + } + return null +} + +function safeMessage(error) { + return error && error.message ? String(error.message) : 'unknown error' +} + +/** + * Merge live discovery over stored metadata for the catalog that will actually + * be shown. Live records win on existence and advertised capabilities; a + * verified seed entry keeps its reasoning ladder and context length, which the + * live catalog does not currently carry, so a live refresh cannot silently + * strip them. + */ +function reconcileWithVerifiedMetadata(liveModels, seedModels) { + const seedById = {} + const seed = seedModels || VERIFIED_SEED + for (let i = 0; i < seed.length; i++) seedById[seed[i].id] = seed[i] + return (liveModels || []).map((model) => { + const known = seedById[model.id] + if (!known) return model + const reasoning = model.reasoning && model.reasoning.supported ? model.reasoning : known.reasoning + return Object.assign({}, model, { + name: model.name || known.name, + contextLength: model.contextLength || known.contextLength, + inputModalities: model.inputModalities.length ? model.inputModalities : known.inputModalities, + reasoning, + }) + }) +} + +/** + * A previously stored model ID may only be restored if it is still in the + * compatible list for the currently selected capability. + */ +function reconcileStoredSelection(storedId, options) { + const allowed = options && options.options ? options.options : [] + if (!storedId) return { id: null, invalidated: false } + const found = allowed.some((model) => model.id === storedId) + if (found) return { id: storedId, invalidated: false } + return { id: null, invalidated: true } +} + +module.exports = { + TEXT_ENDPOINT_TYPES, + NON_TEXT_ENDPOINT_TYPES, + DEFAULT_TIMEOUT_MS, + DEFAULT_MAX_BYTES, + DEFAULT_MAX_ITEMS, + CAPABILITIES, + MODALITIES, + VERIFIED_SEED, + normalizeRecord, + parseCatalog, + filterByCapability, + capabilityQuery, + discoverModels, + reconcileWithVerifiedMetadata, + reconcileStoredSelection, +} diff --git a/OrcaRouter/orcarouter.js b/OrcaRouter/orcarouter.js new file mode 100644 index 0000000..6e9e823 --- /dev/null +++ b/OrcaRouter/orcarouter.js @@ -0,0 +1,238 @@ +/** + * OrcaRouter for Scriptable — on-device entry point. + * + * This is the module the widgets and scripts import. On iOS there is no browser + * loopback listener, so "Connect with OrcaRouter" uses Flow B (out-of-band + * code): the authorize URL is opened in Safari, the consent screen shows a + * code, and the user pastes it back. Pasting an existing `sk-orca-…` key is the + * other, equally first-class choice. + * + * Both choices write to the same Keychain slot through the same credential + * seam, and the provider below cannot tell them apart. + * + * @example + * const orca = importModule('OrcaRouter/orcarouter') + * await orca.setup() // choose API key or Connect + * const reply = await orca.chat('hello') // orcarouter/auto + */ + +const originsModule = require('./origins') +const pkce = require('./pkce') +const modelsModule = require('./models') +const providerModule = require('./provider') +const credentials = require('./credentials') + +const APP_NAME = 'Scriptable' + +/** Scriptable's Keychain is the host's existing secret store. */ +function keychainStore() { + return new credentials.KeychainStore({ + // eslint-disable-next-line no-undef + get: (key) => Keychain.get(key), + // eslint-disable-next-line no-undef + set: (value, key) => Keychain.set(value, key), + // eslint-disable-next-line no-undef + contains: (key) => Keychain.contains(key), + // eslint-disable-next-line no-undef + remove: (key) => Keychain.remove(key), + }) +} + +let cachedManager = null + +function credentialManager() { + if (!cachedManager) cachedManager = new credentials.CredentialManager({ store: keychainStore() }) + return cachedManager +} + +function scriptableFetch(url, init) { + // eslint-disable-next-line no-undef + return providerModule.makeScriptableFetch(Request)(url, init) +} + +function resolved() { + return originsModule.resolveOrigins( + typeof process !== 'undefined' && process.env ? process.env : {} + ) +} + +/** Flow B: out-of-band code. S256 is mandatory when a human sees the code. */ +async function connectWithOrcaRouter() { + const origins = resolved() + const attempt = pkce.createPkceAttempt() + const url = pkce.buildAuthorizeUrl({ + origins, + callbackUrl: 'oob', + challenge: attempt.challenge, + state: attempt.state, + appName: APP_NAME, + scope: 'api', + }) + + // Open the consent screen, but always show the URL too: browsers do not + // always open, and the URL must be copyable. + // eslint-disable-next-line no-undef + if (typeof Safari !== 'undefined') Safari.openInApp(url, false) + + const alert = new Alert() + alert.title = 'Connect OrcaRouter' + alert.message = `Approve access in your browser, then paste the code below.\n\n${url}` + alert.addTextField('code', '') + alert.addAction('Connect') + alert.addCancelAction('Cancel') + const choice = await alert.present() + if (choice === -1) { + return { ok: false, kind: 'cancelled', terminal: true, message: 'Connection cancelled. Nothing was stored.' } + } + const code = (alert.textFieldValue(0) || '').trim() + if (!code) { + return { ok: false, kind: 'empty_code', terminal: true, message: 'No code was entered. Nothing was stored.' } + } + + const outcome = await pkce.exchangeCode({ + origins, + code, + verifier: attempt.verifier, // never written to a URL, log or alert + fetchImpl: scriptableFetch, + scope: 'api', + }) + if (!outcome.ok) return outcome + return outcome +} + +/** + * Interactive setup. Presents both authentication choices explicitly — a single + * generic "OrcaRouter" button would make logout and reauthentication ambiguous. + */ +async function setup() { + const manager = credentialManager() + const apiKey = new credentials.ApiKeyAdapter({ manager }) + const pkceAdapter = new credentials.PkceAdapter({ manager, connect: connectWithOrcaRouter }) + + const status = manager.describe() + const alert = new Alert() + alert.title = 'OrcaRouter' + alert.message = status.configured + ? `Current: ${status.authId} (${status.redacted})${status.needsReauth ? ' — needs reconnection' : ''}` + : 'Choose how to connect.' + alert.addAction('Paste API key') + alert.addAction('Connect with OrcaRouter') + if (status.configured) alert.addAction('Remove credential') + alert.addCancelAction('Cancel') + + const choice = await alert.present() + const titles = status.configured + ? ['Paste API key', 'Connect with OrcaRouter', 'Remove credential'] + : ['Paste API key', 'Connect with OrcaRouter'] + const picked = titles[choice] + + if (picked === 'Remove credential') { + manager.clear() + return { ok: true, message: 'OrcaRouter credential removed.' } + } + + if (picked === 'Paste API key') { + const entry = new Alert() + entry.title = 'OrcaRouter API key' + entry.message = `Create a key at ${resolved().authBase}/console, then paste it here.` + entry.addTextField('sk-orca-…', '') + entry.addAction('Save') + entry.addCancelAction('Cancel') + const confirmed = await entry.present() + if (confirmed === -1) return { ok: false, kind: 'cancelled', message: 'Cancelled.' } + return apiKey.save(entry.textFieldValue(0)) + } + + if (picked === 'Connect with OrcaRouter') { + const result = await pkceAdapter.authorize({}) + if (result.ok && result.warning) { + const warn = new Alert() + warn.title = 'Connected with a narrower scope' + warn.message = result.warning + warn.addAction('OK') + warn.present() + } + if (!result.ok) { + const fail = new Alert() + fail.title = 'OrcaRouter connection failed' + fail.message = result.message + fail.addAction('OK') + fail.present() + } + return result + } + + return { ok: false, kind: 'cancelled', message: 'Cancelled.' } +} + +/** Live catalog, with the verified seed as a degraded fallback. */ +async function listModels(options) { + const opts = options || {} + const manager = credentialManager() + const result = await providerModule.discover({ + credentials: manager, + origins: resolved(), + capability: opts.capability || modelsModule.CAPABILITIES.chat, + fetchImpl: scriptableFetch, + }) + const optionsOut = modelsModule.filterByCapability(result.models, opts.capability || modelsModule.CAPABILITIES.chat, { + modality: opts.modality, + }) + return Object.assign({}, result, { models: optionsOut }) +} + +/** + * One-shot chat against the inference origin. Works with either credential + * source; a rejected credential is classified, not retried. + */ +async function chat(prompt, options) { + const opts = options || {} + const manager = credentialManager() + const credential = manager.resolveForRequest() + if (!credential.ok) return { ok: false, kind: credential.kind, message: credential.message } + + const origins = resolved() + const body = providerModule.buildChatRequest({ + model: opts.model || 'orcarouter/auto', + messages: [{ role: 'user', content: prompt }], + temperature: opts.temperature, + maxTokens: opts.maxTokens, + reasoningEffort: opts.reasoningEffort, + }) + + const response = await scriptableFetch(origins.chatCompletionsUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${credential.key}`, + }, + body: JSON.stringify(body), + timeoutMs: 60000, + }) + + if (response.status === 401) { + return Object.assign( + { ok: false }, + providerModule.handleUnauthorized(manager, { + generation: credential.generation, + accountId: credential.accountId, + }) + ) + } + if (response.status !== 200) { + return { ok: false, kind: 'http_error', message: `OrcaRouter returned ${response.status}.` } + } + const payload = await response.json() + const first = payload && payload.choices && payload.choices[0] + return { ok: true, text: first && first.message ? first.message.content : '' } +} + +module.exports = { + APP_NAME, + credentialManager, + connectWithOrcaRouter, + setup, + listModels, + chat, + providerDefinition: () => providerModule.providerDefinition(), +} diff --git a/OrcaRouter/origins.js b/OrcaRouter/origins.js new file mode 100644 index 0000000..41c2efd --- /dev/null +++ b/OrcaRouter/origins.js @@ -0,0 +1,137 @@ +/** + * OrcaRouter origin resolution. + * + * Authentication and inference live on two different public origins. They must + * never be derived from one another by rewriting a hostname or by appending + * "/v1" — a self-hosted deployment may put them on separate hosts, and a shared + * self-hosted base is a third, separate case. Explicit overrides always win. + * + * auth + code exchange : https://www.orcarouter.ai (authorize /auth, + * exchange /api/v1/auth/keys) + * inference + catalog : https://api.orcarouter.ai/v1 + * + * Note for reviewers: https://api.orcarouter.ai/v1/auth/keys is a 404. The + * relay is at /v1; the auth endpoints are not. This module is the only place + * that builds those URLs, so the mistake cannot be repeated per call site. + */ + +const DEFAULT_AUTH_BASE = 'https://www.orcarouter.ai' +const DEFAULT_API_BASE = 'https://api.orcarouter.ai/v1' + +const AUTHORIZE_PATH = '/auth' +const EXCHANGE_PATH = '/api/v1/auth/keys' + +const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]', '::1'] + +/** True for hosts that may legitimately be reached over plain HTTP. */ +function isLoopbackHost(hostname) { + return LOOPBACK_HOSTS.indexOf(hostname) !== -1 +} + +/** + * Validate an origin. Remote origins must be HTTPS; plain HTTP is allowed only + * for loopback addresses so local development works without weakening the + * deployed default. + */ +function assertOriginAllowed(rawValue, label) { + let parsed + try { + parsed = new URL(rawValue) + } catch (e) { + throw new Error(`${label} is not a valid URL`) + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new Error(`${label} must use http or https`) + } + if (parsed.protocol === 'http:' && !isLoopbackHost(parsed.hostname)) { + throw new Error(`${label} must use https unless it points at loopback`) + } + if (parsed.username || parsed.password) { + throw new Error(`${label} must not contain userinfo`) + } + return parsed +} + +function stripTrailingSlash(value) { + return value.replace(/\/+$/, '') +} + +/** + * Normalise an API base. `ORCA_API_BASE_URL` may be given either as the full + * versioned base or as a bare origin; when the path is empty we add "/v1", + * which is the documented inference prefix. That is a normalisation of the API + * base the user configured, not a derivation of one origin from the other. + */ +function normalizeApiBase(rawValue) { + const parsed = assertOriginAllowed(rawValue, 'ORCA_API_BASE_URL') + const path = stripTrailingSlash(parsed.pathname) + if (path === '') { + parsed.pathname = '/v1' + } + return stripTrailingSlash(parsed.toString()) +} + +function normalizeAuthBase(rawValue) { + const parsed = assertOriginAllowed(rawValue, 'ORCA_AUTH_BASE_URL') + parsed.search = '' + parsed.hash = '' + parsed.pathname = stripTrailingSlash(parsed.pathname) + return stripTrailingSlash(parsed.toString()) +} + +/** + * Resolve both origins from an environment-like object. + * + * ORCA_BASE_URL shared self-hosted fallback for both origins + * ORCA_AUTH_BASE_URL explicit auth override (wins over the shared value) + * ORCA_API_BASE_URL explicit API override (wins over the shared value) + * + * @param {object} env environment-style map (defaults to process.env) + */ +function resolveOrigins(env) { + const source = env || (typeof process !== 'undefined' && process.env ? process.env : {}) + const shared = source.ORCA_BASE_URL + const authOverride = source.ORCA_AUTH_BASE_URL + const apiOverride = source.ORCA_API_BASE_URL + + let authBase + if (authOverride) { + authBase = normalizeAuthBase(authOverride) + } else if (shared) { + authBase = normalizeAuthBase(shared) + } else { + authBase = DEFAULT_AUTH_BASE + } + + let apiBase + if (apiOverride) { + apiBase = normalizeApiBase(apiOverride) + } else if (shared) { + apiBase = normalizeApiBase(shared) + } else { + apiBase = DEFAULT_API_BASE + } + + return { + authBase, + apiBase, + authorizeUrl: `${authBase}${AUTHORIZE_PATH}`, + exchangeUrl: `${authBase}${EXCHANGE_PATH}`, + modelsUrl: `${apiBase}/models`, + chatCompletionsUrl: `${apiBase}/chat/completions`, + embeddingsUrl: `${apiBase}/embeddings`, + imagesUrl: `${apiBase}/images/generations`, + } +} + +module.exports = { + DEFAULT_AUTH_BASE, + DEFAULT_API_BASE, + AUTHORIZE_PATH, + EXCHANGE_PATH, + isLoopbackHost, + assertOriginAllowed, + normalizeApiBase, + normalizeAuthBase, + resolveOrigins, +} diff --git a/OrcaRouter/pkce.js b/OrcaRouter/pkce.js new file mode 100644 index 0000000..a123779 --- /dev/null +++ b/OrcaRouter/pkce.js @@ -0,0 +1,279 @@ +/** + * OAuth 2.0 + PKCE core for OrcaRouter. + * + * One implementation of the verifier/challenge/state lifecycle and the code + * exchange, shared by both deliveries: + * + * Flow A — loopback redirect (config-server.js: the machine has a browser + * and can bind 127.0.0.1) + * Flow B — out-of-band code (orcarouter.js on iOS: no listener exists, so + * the consent screen's code is pasted back) + * + * Always S256. The consent screen lets a user choose "show me a code" even on + * Flow A, and a code that passes through human hands must never be redeemable + * with material that travelled on the authorize URL. No client secret is used + * and no redirect URI needs pre-registering. + * + * The verifier is generated fresh for every attempt, never leaves this process + * before the exchange, and is never placed in a URL, log line, error message or + * telemetry payload. + */ + +const cryptoAdapter = require('./crypto-adapter') +const { resolveOrigins } = require('./origins') + +const DEFAULT_SCOPE = 'api' +const ALLOWED_SCOPES = ['api', 'connector'] + +/** + * Build a fresh PKCE attempt. + * + * @param {object} [options] + * @param {number} [options.verifierBytes] entropy for the verifier (default 32) + * @param {number} [options.stateBytes] entropy for the CSRF state (default 16) + */ +function createPkceAttempt(options) { + const opts = options || {} + const verifier = cryptoAdapter.bytesToBase64Url( + cryptoAdapter.randomBytes(opts.verifierBytes || 32) + ) + const state = cryptoAdapter.bytesToBase64Url( + cryptoAdapter.randomBytes(opts.stateBytes || 16) + ) + const challenge = cryptoAdapter.bytesToBase64Url( + cryptoAdapter.sha256Bytes(new TextEncoderShim().encode(verifier)) + ) + return { verifier, state, challenge, method: 'S256' } +} + +/** Minimal UTF-8 encoder so this file runs in both runtimes. */ +function TextEncoderShim() { + if (typeof TextEncoder !== 'undefined') return new TextEncoder() + return { + encode(value) { + const buffer = [] + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i) + if (code < 0x80) buffer.push(code) + else if (code < 0x800) buffer.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f)) + else buffer.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f)) + } + return new Uint8Array(buffer) + }, + } +} + +/** + * Build the authorize URL. `callbackUrl` is either a validated http/https + * loopback URL (Flow A) or the literal "oob" (Flow B). + */ +function buildAuthorizeUrl(params) { + const p = params || {} + if (!p.challenge) throw new Error('buildAuthorizeUrl requires a code challenge') + if (p.method && p.method !== 'S256') { + throw new Error('Only S256 is supported; plain must not be used') + } + if (p.scope && ALLOWED_SCOPES.indexOf(p.scope) === -1) { + throw new Error(`scope must be one of ${ALLOWED_SCOPES.join(', ')}`) + } + const origins = p.origins || resolveOrigins(p.env) + const url = new URL(origins.authorizeUrl) + url.searchParams.set('callback_url', p.callbackUrl) + url.searchParams.set('code_challenge', p.challenge) + url.searchParams.set('code_challenge_method', 'S256') + url.searchParams.set('state', p.state) + url.searchParams.set('app_name', p.appName || 'Scriptable') + url.searchParams.set('scope', p.scope || DEFAULT_SCOPE) + if (p.loginHint) url.searchParams.set('login_hint', p.loginHint) + if (p.workspaceHint) url.searchParams.set('workspace_hint', p.workspaceHint) + if (p.prompt) url.searchParams.set('prompt', p.prompt) + return url.toString() +} + +/** Validate a proposed loopback callback URL before it is sent to /auth. */ +function assertCallbackUrlAllowed(callbackUrl) { + const parsed = new URL(callbackUrl) + const loopback = ['localhost', '127.0.0.1', '[::1]'].indexOf(parsed.hostname) !== -1 + if (parsed.protocol === 'http:' && !loopback) { + throw new Error('http callbacks are only allowed on loopback') + } + if (parsed.username || parsed.password) throw new Error('callback must not contain userinfo') + if (parsed.hash) throw new Error('callback must not contain a fragment') + return callbackUrl +} + +/** Constant-time state comparison, performed before anything else is done with a code. */ +function verifyState(expected, received) { + if (!expected) return false + return cryptoAdapter.timingSafeEqualString(expected, received) +} + +/** + * Classify an exchange failure. Terminal classes must not be retried; only the + * network class is ever worth another attempt, and even then the caller decides. + */ +function classifyExchangeStatus(status) { + if (status === 400) return { kind: 'invalid_request', terminal: true } + if (status === 403) return { kind: 'invalid_grant', terminal: true } + if (status === 429) return { kind: 'rate_limited', terminal: true } + if (status === 401) return { kind: 'unauthorized', terminal: true } + if (status >= 500) return { kind: 'server_error', terminal: false } + return { kind: 'unexpected_status', terminal: false } +} + +/** + * Read the granted scope back from an exchange response. + * + * The response reports what was GRANTED, not what was requested: a workspace + * role that cannot carry `connector` returns `api`. Callers must not assume + * they hold the wider grant. + */ +function readGrantedScope(response, requestedScope) { + const requested = requestedScope || DEFAULT_SCOPE + const granted = response && response.scope ? String(response.scope) : null + return { + requested, + granted, + // An absent scope field is treated as a downgrade signal rather than as + // "assume what we asked for". + accepted: granted === requested, + downgraded: granted !== requested, + } +} + +function redactSecret(value) { + if (typeof value !== 'string' || value.length === 0) return '' + if (value.length <= 8) return '' + return `${value.slice(0, 8)}…` +} + +/** + * Scrub anything that looks like an OrcaRouter key or a PKCE verifier out of a + * message before it is logged or surfaced. + */ +function scrub(text, secrets) { + let out = String(text === undefined || text === null ? '' : text) + const list = secrets || [] + for (let i = 0; i < list.length; i++) { + const secret = list[i] + if (secret && typeof secret === 'string' && secret.length >= 8) { + out = out.split(secret).join('') + } + } + out = out.replace(/sk-orca-[A-Za-z0-9_\-]+/g, 'sk-orca-') + return out +} + +/** Build the JSON exchange body. Kept in one place so tests can assert its shape. */ +function buildExchangeBody(code, verifier) { + return { + code, + code_verifier: verifier, + code_challenge_method: 'S256', + } +} + +/** + * Exchange an authorization code for a durable OrcaRouter API key. + * + * @param {object} params + * @param {string} params.code + * @param {string} params.verifier + * @param {Function} params.fetchImpl (url, init) -> {status, json(), text()} + * @param {object} [params.origins] + */ +async function exchangeCode(params) { + const p = params || {} + if (!p.code) throw new Error('exchange requires an authorization code') + if (!p.verifier) throw new Error('exchange requires the code verifier') + const origins = p.origins || resolveOrigins(p.env) + const fetchImpl = p.fetchImpl + if (typeof fetchImpl !== 'function') throw new Error('exchange requires a fetch implementation') + + let response + try { + response = await fetchImpl(origins.exchangeUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildExchangeBody(p.code, p.verifier)), + }) + } catch (error) { + return { + ok: false, + kind: 'network', + terminal: false, + message: `Could not reach the OrcaRouter authorization service: ${scrub(error && error.message, [p.verifier])}`, + } + } + + const status = response.status + const payload = await readJsonSafe(response) + + if (status !== 200) { + const classified = classifyExchangeStatus(status) + return { + ok: false, + status, + kind: classified.kind, + terminal: classified.terminal, + message: describeExchangeFailure(classified.kind, status, payload), + } + } + + if (!payload || typeof payload.key !== 'string' || payload.key.length === 0) { + return { + ok: false, + status, + kind: 'malformed_response', + terminal: true, + message: 'OrcaRouter returned no key; nothing was stored', + } + } + + return { + ok: true, + key: payload.key, + userId: payload.user_id ? String(payload.user_id) : null, + scope: readGrantedScope(payload, p.scope), + } +} + +function describeExchangeFailure(kind, status, payload) { + switch (kind) { + case 'invalid_request': + return 'The authorization request was rejected (400): the PKCE challenge method did not match. Start the connection again.' + case 'invalid_grant': + return 'This authorization code is unknown, expired, already used, or does not match this device. Start the connection again.' + case 'rate_limited': + return 'OrcaRouter is rate limiting key issuance for this account (429). Reuse the stored key or try again later.' + case 'unauthorized': + return 'OrcaRouter refused the exchange (401). Start the connection again.' + default: + return `OrcaRouter authorization failed (${status}).` + } +} + +async function readJsonSafe(response) { + try { + if (typeof response.json === 'function') return await response.json() + if (typeof response.text === 'function') return JSON.parse(await response.text()) + } catch (e) { + return null + } + return null +} + +module.exports = { + DEFAULT_SCOPE, + ALLOWED_SCOPES, + createPkceAttempt, + buildAuthorizeUrl, + assertCallbackUrlAllowed, + verifyState, + classifyExchangeStatus, + readGrantedScope, + buildExchangeBody, + exchangeCode, + redactSecret, + scrub, +} diff --git a/OrcaRouter/provider.js b/OrcaRouter/provider.js new file mode 100644 index 0000000..b5d9966 --- /dev/null +++ b/OrcaRouter/provider.js @@ -0,0 +1,196 @@ +/** + * OrcaRouter as a first-class provider. + * + * Everything below is credential-source agnostic: it asks the CredentialManager + * for a key and does not know or care whether the user pasted it or obtained it + * through the PKCE connect flow. Inference and catalog requests both go to the + * API origin (`https://api.orcarouter.ai/v1` by default) with a Bearer header; + * nothing here ever touches the auth origin. + */ + +const originsModule = require('./origins') +const models = require('./models') + +const PROVIDER_ID = 'orcarouter' +const PROVIDER_NAME = 'OrcaRouter' +const WIRE_API = 'chat-completions' + +/** + * The provider registry entry. This repository has no registry yet, so this is + * the one that gets pointed at — and it is exported for the scripts to consume + * rather than being re-declared per script. + */ +function providerDefinition(env) { + const origins = originsModule.resolveOrigins(env) + return { + id: PROVIDER_ID, + name: PROVIDER_NAME, + baseUrl: origins.apiBase, + wireApi: WIRE_API, + modelsUrl: origins.modelsUrl, + authBaseUrl: origins.authBase, + keyDashboardUrl: `${origins.authBase}/console`, + keyRevocationUrl: `${origins.authBase}/console/authorized-apps`, + envKey: 'ORCA_KEY', + defaultModel: 'orcarouter/auto', + authChoices: [ + { id: 'orcarouter', label: 'OrcaRouter · API key', kind: 'api-key' }, + { id: 'orcarouter-oauth', label: 'OrcaRouter · Connect', kind: 'pkce' }, + ], + } +} + +/** Build the OpenAI-compatible chat body. `modalities` are declared, not inferred. */ +function buildChatRequest(params) { + const p = params || {} + const body = { + model: p.model, + messages: p.messages || [], + } + if (typeof p.temperature === 'number') body.temperature = p.temperature + if (typeof p.maxTokens === 'number') body.max_tokens = p.maxTokens + if (p.reasoningEffort) body.reasoning_effort = p.reasoningEffort + if (p.stream) body.stream = true + return body +} + +function buildEmbeddingRequest(params) { + const p = params || {} + return { model: p.model, input: p.input } +} + +function buildImageRequest(params) { + const p = params || {} + const body = { model: p.model, prompt: p.prompt } + if (p.size) body.size = p.size + if (typeof p.n === 'number') body.n = p.n + return body +} + +/** + * Guard the attachment/model pairing at send time. This is deliberately a + * SECOND line of defence: the requirement is that the model selector itself + * only ever contains compatible options, which is enforced in models.js and by + * the selection reconciler below. + */ +function assertModelSupportsAttachments(model, attachments) { + const list = attachments || [] + if (!model) throw new Error('No OrcaRouter model selected') + const needsImage = list.some((a) => a && String(a.type).toLowerCase() === 'image') + const needsAudio = list.some((a) => a && String(a.type).toLowerCase() === 'audio') + const needsVideo = list.some((a) => a && String(a.type).toLowerCase() === 'video') + if (needsImage && model.inputModalities.indexOf('image') === -1) { + throw new Error(`${model.id} does not accept image input`) + } + if (needsAudio && model.inputModalities.indexOf('audio') === -1) { + throw new Error(`${model.id} does not accept audio input`) + } + if (needsVideo && model.inputModalities.indexOf('video') === -1) { + throw new Error(`${model.id} does not accept video input`) + } + return true +} + +/** + * Recompute the model options for the current entry point and selection. + * Called whenever the provider changes, or an attachment/modality/task changes. + * A previously selected model that is no longer compatible is cleared, with a + * reason the UI can show — it is never silently retained. + */ +function selectModelOptions(params) { + const p = params || {} + const capability = p.capability || models.CAPABILITIES.chat + const options = models.filterByCapability(p.models || [], capability, { modality: p.modality }) + const reconciled = models.reconcileStoredSelection(p.selectedModelId, { options }) + return { + options, + selectedModelId: reconciled.id, + invalidated: reconciled.invalidated, + reason: reconciled.invalidated + ? 'The previously selected model does not support the current input type and was cleared.' + : null, + } +} + +/** + * Discovery, bound to the credential seam. The key is read from the manager on + * the server side and never handed to a browser. + */ +async function discover(params) { + const p = params || {} + const manager = p.credentials + const origins = p.origins || originsModule.resolveOrigins(p.env) + let apiKey = p.apiKey || null + if (!apiKey && manager) { + const resolved = manager.resolveForRequest() + if (resolved.ok) apiKey = resolved.key + } + const result = await models.discoverModels({ + origins, + apiKey, + capability: p.capability, + fetchImpl: p.fetchImpl, + timeoutMs: p.timeoutMs, + lastKnownGood: p.lastKnownGood, + signal: p.signal, + }) + return Object.assign({}, result, { + // A live refresh must not strip the verified reasoning/context metadata. + models: models.reconcileWithVerifiedMetadata(result.models, models.VERIFIED_SEED), + }) +} + +/** A 401 is terminal reauthentication, never a retry loop and never a refresh. */ +function handleUnauthorized(manager, rejected) { + const marked = manager.markRejected(rejected) + return { + needsReauth: marked.changed || marked.reason === 'marked', + reason: marked.reason, + message: 'OrcaRouter rejected this credential. Connect again or paste a new key — this is not retried automatically.', + } +} + +/** + * Scriptable transport bridge. The iOS runtime has no `fetch`; it has the + * global `Request`. Bridging here keeps the rest of the provider pure and + * testable in Node. + */ +function makeScriptableFetch(requestCtor) { + return async function scriptableFetch(url, init) { + const options = init || {} + const request = new requestCtor(url) + request.method = options.method || 'GET' + request.headers = options.headers || {} + if (options.body) request.body = options.body + if (options.timeoutMs) request.timeoutInterval = Math.max(1, Math.round(options.timeoutMs / 1000)) + let text = '' + let status = 0 + try { + text = await request.loadString() + status = request.response ? request.response.statusCode : 200 + } catch (error) { + status = request.response ? request.response.statusCode : 0 + if (!status) throw error + } + return { + status, + text: async () => text, + json: async () => JSON.parse(text), + } + } +} + +module.exports = { + PROVIDER_ID, + PROVIDER_NAME, + WIRE_API, + providerDefinition, + buildChatRequest, + buildEmbeddingRequest, + buildImageRequest, + assertModelSupportsAttachments, + selectModelOptions, + discover, + handleUnauthorized, + makeScriptableFetch, +} diff --git a/OrcaRouter/test/credentials.test.js b/OrcaRouter/test/credentials.test.js new file mode 100644 index 0000000..fdd1c1d --- /dev/null +++ b/OrcaRouter/test/credentials.test.js @@ -0,0 +1,330 @@ +'use strict' + +/** + * Credential seam: both adapters produce one credential result, the export is + * indifferent to its source, and a rejected durable key is a generation-safe + * terminal reauthentication with no refresh attempt. + */ + +const test = require('node:test') +const assert = require('node:assert/strict') + +const credentials = require('../credentials') +const pkce = require('../pkce') +const provider = require('../provider') +const models = require('../models') +const origins = require('../origins') + +const FAKE_KEY = 'sk-orca-TESTKEY0123456789' +const FAKE_KEY_2 = 'sk-orca-SECONDKEY9876543210' +const DEFAULT_ORIGINS = origins.resolveOrigins({}) + +/** Minimal Scriptable `Request` stand-in for the transport bridge. */ +class FakeRequest { + constructor(url) { + this.url = url + this.headers = {} + this.response = { statusCode: 200 } + FakeRequest.last = this + } + async loadString() { + return JSON.stringify({ choices: [{ message: { content: 'ok' } }] }) + } +} + +function manager(store) { + return new credentials.CredentialManager({ store: store || new credentials.MemoryStore(), clock: () => 1700000000000 }) +} + +function apiKeyAdapter(mgr) { + return new credentials.ApiKeyAdapter({ manager: mgr }) +} + +function pkceAdapter(mgr, connect) { + return new credentials.PkceAdapter({ manager: mgr, connect }) +} + +const successfulConnect = async () => ({ + ok: true, + key: FAKE_KEY_2, + userId: '99887766', + scope: { requested: 'api', granted: 'api', downgraded: false }, +}) + +test('both adapters return the same credential result shape', async () => { + const mgr = manager() + const fromKey = apiKeyAdapter(mgr).save(FAKE_KEY) + const fromPkce = await pkceAdapter(mgr, successfulConnect).authorize({}) + + assert.equal(fromKey.ok, true) + assert.equal(fromPkce.ok, true) + + for (const result of [fromKey.credential, fromPkce.credential]) { + assert.equal(result.providerId, 'orcarouter') + assert.equal(result.key, result.key) + assert.equal(result.state, credentials.STATE_ACTIVE) + assert.equal(typeof result.generation, 'number') + } + + // The two differ only in provenance, which the downstream path ignores. + assert.equal(fromKey.credential.source, credentials.SOURCE_API_KEY) + assert.equal(fromPkce.credential.source, credentials.SOURCE_PKCE) + assert.equal(fromKey.credential.authId, 'orcarouter') + assert.equal(fromPkce.credential.authId, 'orcarouter-oauth') +}) + +test('downstream inference and discovery are indifferent to the credential source', async () => { + for (const source of [credentials.SOURCE_API_KEY, credentials.SOURCE_PKCE]) { + const mgr = manager() + if (source === credentials.SOURCE_API_KEY) apiKeyAdapter(mgr).save(FAKE_KEY) + else await pkceAdapter(mgr, successfulConnect).authorize({}) + + // Discovery goes to the API origin with the resolved key regardless of how + // that key was obtained. + let seen = null + const discovered = await provider.discover({ + credentials: mgr, + origins: DEFAULT_ORIGINS, + fetchImpl: async (url, init) => { + seen = { url, headers: init.headers } + return { status: 200, text: async () => JSON.stringify({ data: [{ id: 'a/b', supported_endpoint_types: ['openai'] }] }) } + }, + }) + assert.equal(new URL(seen.url).origin, 'https://api.orcarouter.ai') + assert.equal(seen.headers.Authorization, `Bearer ${mgr.resolveForRequest().key}`) + assert.equal(discovered.source, 'live') + + // Inference builds the same request from the same resolved credential. + const resolved = mgr.resolveForRequest() + assert.equal(resolved.ok, true) + assert.equal(resolved.key, source === credentials.SOURCE_API_KEY ? FAKE_KEY : FAKE_KEY_2) + seen = null + const request = await provider.makeScriptableFetch(FakeRequest)(DEFAULT_ORIGINS.chatCompletionsUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${resolved.key}` }, + body: JSON.stringify(provider.buildChatRequest({ model: 'orcarouter/auto', messages: [{ role: 'user', content: 'hi' }] })), + }) + assert.equal(request.status, 200) + assert.equal(FakeRequest.last.url, 'https://api.orcarouter.ai/v1/chat/completions') + assert.equal(FakeRequest.last.headers.Authorization, `Bearer ${resolved.key}`) + } +}) + +test('an API key is saved, read back, described redacted, and cleared', () => { + const mgr = manager() + const adapter = apiKeyAdapter(mgr) + + assert.equal(mgr.describe().configured, false) + assert.equal(adapter.save(FAKE_KEY).ok, true) + + const described = mgr.describe() + assert.equal(described.configured, true) + assert.equal(described.source, credentials.SOURCE_API_KEY) + assert.equal(described.state, credentials.STATE_ACTIVE) + assert.ok(!described.redacted.includes(FAKE_KEY)) + assert.equal(mgr.resolveForRequest().key, FAKE_KEY) + + adapter.clear() + assert.equal(mgr.describe().configured, false) + assert.equal(mgr.resolveForRequest().ok, false) +}) + +test('a key that is not OrcaRouter-shaped is rejected without being stored', () => { + const mgr = manager() + const result = apiKeyAdapter(mgr).save('not-a-key') + assert.equal(result.ok, false) + assert.equal(result.kind, 'invalid_format') + assert.equal(mgr.describe().configured, false) +}) + +test('updating a key replaces it and a clear removes it from the store', () => { + const mgr = manager() + const adapter = apiKeyAdapter(mgr) + adapter.save(FAKE_KEY) + adapter.save(FAKE_KEY_2) + assert.equal(mgr.resolveForRequest().key, FAKE_KEY_2) + adapter.clear() + assert.equal(mgr.store.has(credentials.KEYCHAIN_KEY), false) + assert.equal(mgr.store.has(credentials.KEYCHAIN_META), false) +}) + +test('the PKCE adapter persists the key it is handed and never sees a password', async () => { + const mgr = manager() + let sawParams = null + const adapter = pkceAdapter(mgr, async (params) => { sawParams = params; return successfulConnect() }) + const result = await adapter.authorize({}) + assert.equal(result.ok, true) + assert.equal(mgr.resolveForRequest().key, FAKE_KEY_2) + assert.equal(mgr.describe().accountId, '99887766') + assert.deepEqual(sawParams, {}) +}) + +test('a denied or failed connect stores nothing', async () => { + const mgr = manager() + const adapter = pkceAdapter(mgr, async () => ({ ok: false, kind: 'denied', terminal: true, message: 'User denied access.' })) + const result = await adapter.authorize({}) + assert.equal(result.ok, false) + assert.equal(result.kind, 'denied') + assert.equal(mgr.describe().configured, false) +}) + +test('a narrower granted scope is surfaced as a warning', async () => { + const mgr = manager() + const adapter = pkceAdapter(mgr, async () => ({ + ok: true, key: FAKE_KEY, userId: '1', scope: { requested: 'connector', granted: 'api', downgraded: true }, + })) + const result = await adapter.authorize({}) + assert.equal(result.ok, true) + assert.match(result.warning, /granted "api" rather than "connector"/) + assert.equal(result.credential.scopeAccepted, false) +}) + +test('a damaged metadata blob is classified terminal and no phantom credential is invented', () => { + const store = new credentials.MemoryStore() + store.write(credentials.KEYCHAIN_KEY, FAKE_KEY) + store.write(credentials.KEYCHAIN_META, '{ this is not json') + const mgr = manager(store) + const described = mgr.describe() + assert.equal(described.configured, true) + assert.equal(described.state, credentials.STATE_NEEDS_REAUTH) + assert.equal(mgr.resolveForRequest().kind, 'needs_reauth') +}) + +test('a revoked durable key becomes needsReauth and is never refreshed', () => { + const mgr = manager() + apiKeyAdapter(mgr).save(FAKE_KEY) + const before = mgr.resolveForRequest() + assert.equal(before.ok, true) + + const outcome = provider.handleUnauthorized(mgr, { generation: before.generation, accountId: before.accountId }) + assert.equal(outcome.needsReauth, true) + + const after = mgr.resolveForRequest() + assert.equal(after.ok, false) + assert.equal(after.kind, 'needs_reauth') + + // The key is still on disk: a transient misclassification must not destroy it. + assert.equal(mgr.store.read(credentials.KEYCHAIN_KEY), FAKE_KEY) +}) + +test('there is no refresh grant anywhere in the credential layer', () => { + const source = require('node:fs').readFileSync(require('node:path').join(__dirname, '..', 'credentials.js'), 'utf8') + assert.ok(!/refresh_token|refreshToken|grant_type\s*[:=]\s*['"]refresh/.test(source)) +}) + +test('a stale 401 from an old generation cannot mark a freshly reconnected credential', async () => { + const mgr = manager() + apiKeyAdapter(mgr).save(FAKE_KEY) + const stale = mgr.resolveForRequest() + + // The user reconnects; a new generation replaces the credential. + await pkceAdapter(mgr, successfulConnect).authorize({}) + const fresh = mgr.resolveForRequest() + assert.ok(fresh.generation > stale.generation) + + // A late failure from the request issued before the reconnect arrives now. + const outcome = mgr.markRejected({ generation: stale.generation, accountId: stale.accountId }) + assert.equal(outcome.changed, false) + assert.equal(outcome.reason, 'stale_generation') + assert.equal(mgr.describe().state, credentials.STATE_ACTIVE) + assert.equal(mgr.resolveForRequest().ok, true) +}) + +test('a 401 for a different account does not mark the current credential', async () => { + const mgr = manager() + await pkceAdapter(mgr, successfulConnect).authorize({}) + assert.equal(mgr.describe().accountId, '99887766') + + const rejected = mgr.markRejected({ generation: mgr.currentGeneration(), accountId: 'some-other-account' }) + assert.equal(rejected.changed, false) + assert.equal(rejected.reason, 'other_account') + assert.equal(mgr.describe().state, credentials.STATE_ACTIVE) + + // The exact account and generation does mark it. + const exact = mgr.markRejected({ generation: mgr.currentGeneration(), accountId: '99887766' }) + assert.equal(exact.changed, true) + assert.equal(mgr.describe().needsReauth, true) +}) + +test('reconnecting recovers a needsReauth credential', async () => { + const mgr = manager() + apiKeyAdapter(mgr).save(FAKE_KEY) + mgr.markRejected({ generation: mgr.currentGeneration() }) + assert.equal(mgr.describe().needsReauth, true) + + const result = await pkceAdapter(mgr, successfulConnect).authorize({}) + assert.equal(result.ok, true) + assert.equal(mgr.describe().needsReauth, false) + assert.equal(mgr.resolveForRequest().ok, true) +}) + +test('the keychain store uses the repository secret mechanism', () => { + const memory = {} + const store = new credentials.KeychainStore({ + get: (name) => memory[name], + set: (value, name) => { memory[name] = value }, + contains: (name) => Object.prototype.hasOwnProperty.call(memory, name), + remove: (name) => { delete memory[name] }, + }) + const mgr = new credentials.CredentialManager({ store }) + apiKeyAdapter(mgr).save(FAKE_KEY) + assert.equal(memory[credentials.KEYCHAIN_KEY], FAKE_KEY) + assert.ok(memory[credentials.KEYCHAIN_META]) + apiKeyAdapter(mgr).clear() + assert.equal(memory[credentials.KEYCHAIN_KEY], undefined) +}) + +test('the file store writes owner-only permissions', () => { + const fs = require('node:fs') + const os = require('node:os') + const path = require('node:path') + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'orca-cred-')) + const file = path.join(dir, 'credentials.json') + const store = new credentials.FileStore({ fs, path: file }) + const mgr = new credentials.CredentialManager({ store }) + apiKeyAdapter(mgr).save(FAKE_KEY) + const mode = fs.statSync(file).mode & 0o777 + assert.equal(mode, 0o600) +}) + +test('a key is never written to the process log by the credential layer', () => { + const original = console.log + const captured = [] + console.log = (...args) => captured.push(args.join(' ')) + try { + const mgr = manager() + apiKeyAdapter(mgr).save(FAKE_KEY) + mgr.describe() + mgr.resolveForRequest() + } finally { + console.log = original + } + assert.ok(!captured.join('\n').includes(FAKE_KEY)) +}) + +test('the fallback seed is usable through the provider selection path without live discovery', () => { + const options = provider.selectModelOptions({ + models: models.VERIFIED_SEED, + capability: models.CAPABILITIES.chat, + selectedModelId: 'openai/gpt-5.5', + }) + assert.equal(options.options.length, 5) + assert.equal(options.selectedModelId, 'openai/gpt-5.5') + assert.equal(options.invalidated, false) +}) + +test('the PKCE adapter refuses to run without a connect implementation', async () => { + const mgr = manager() + const adapter = new credentials.PkceAdapter({ manager: mgr }) + const result = await adapter.authorize({}) + assert.equal(result.ok, false) + assert.equal(result.kind, 'unavailable') +}) + +test('the api key adapter and pkce adapter both clear through the same manager', async () => { + const mgr = manager() + await pkceAdapter(mgr, successfulConnect).authorize({}) + assert.equal(mgr.describe().configured, true) + pkceAdapter(mgr, successfulConnect).clear() + assert.equal(mgr.describe().configured, false) +}) diff --git a/OrcaRouter/test/fixtures/catalog.json b/OrcaRouter/test/fixtures/catalog.json new file mode 100644 index 0000000..1f09377 --- /dev/null +++ b/OrcaRouter/test/fixtures/catalog.json @@ -0,0 +1,93 @@ +{ + "object": "list", + "success": true, + "data": [ + { + "id": "openai/gpt-5.5", + "object": "model", + "owned_by": "OpenAI", + "name": "OpenAI: GPT-5.5", + "supported_endpoint_types": ["openai", "openai-response"], + "max_completion_tokens": 128000, + "architecture": { "input_modalities": ["file", "image", "text"], "output_modalities": ["text"] } + }, + { + "id": "deepseek/deepseek-v4-pro", + "object": "model", + "owned_by": "DeepSeek", + "supported_endpoint_types": ["openai", "openai-response"], + "context_length": 1048576, + "architecture": { "input_modalities": ["text"], "output_modalities": ["text"] } + }, + { + "id": "orcarouter/auto", + "object": "model", + "owned_by": "orcarouter", + "supported_endpoint_types": ["openai", "openai-response", "anthropic", "gemini"] + }, + { + "id": "google/gemini-3.5-flash", + "object": "model", + "owned_by": "Google", + "supported_endpoint_types": ["openai", "gemini"], + "context_length": 1048576, + "architecture": { + "input_modalities": ["text", "image", "video", "file", "audio"], + "output_modalities": ["text"] + } + }, + { + "id": "openai/text-embedding-3-large", + "object": "model", + "owned_by": "OpenAI", + "supported_endpoint_types": ["embeddings"], + "architecture": { "input_modalities": ["text"], "output_modalities": ["embedding"] } + }, + { + "id": "google/imagen-4.0-generate-001", + "object": "model", + "owned_by": "Google", + "supported_endpoint_types": ["image-generation"], + "architecture": { "input_modalities": ["text"], "output_modalities": ["image"] } + }, + { + "id": "openai/sora-2", + "object": "model", + "owned_by": "OpenAI", + "supported_endpoint_types": ["openai-video"], + "architecture": { "input_modalities": ["text", "image"], "output_modalities": ["video"] } + }, + { + "id": "jina/jina-reranker-v3", + "object": "model", + "owned_by": "Jina", + "supported_endpoint_types": ["jina-rerank"], + "architecture": { "input_modalities": ["text"], "output_modalities": ["text"] } + }, + { + "id": "openai/gpt-oss-120b", + "object": "model", + "owned_by": "OpenAI", + "supported_endpoint_types": ["openai-response"] + }, + { + "id": "acme/vision-chat", + "object": "model", + "owned_by": "Acme", + "name": "Acme Vision Chat", + "supported_endpoint_types": ["openai"], + "context_length": 128000, + "architecture": { "input_modalities": ["text", "image"], "output_modalities": ["text"] } + }, + { + "id": "not-namespaced", + "object": "model", + "supported_endpoint_types": ["openai"] + }, + { + "id": "", + "object": "model", + "supported_endpoint_types": ["openai"] + } + ] +} diff --git a/OrcaRouter/test/live.test.js b/OrcaRouter/test/live.test.js new file mode 100644 index 0000000..c45362e --- /dev/null +++ b/OrcaRouter/test/live.test.js @@ -0,0 +1,256 @@ +'use strict' + +/** + * Live check against OrcaRouter. + * + * Skipped unless ORCAROUTER_API_KEY is present. Every request below goes + * through the provider code paths this change adds — origins resolution, the + * credential seam, catalog discovery, capability filtering and the request + * builders. Nothing here curls an endpoint the integration does not use. + * + * ORCAROUTER_API_KEY=sk-orca-… node --test OrcaRouter/test/live.test.js + * + * The key is only ever placed in an Authorization header. It is never logged, + * never asserted on, and never written to a fixture. + */ + +const test = require('node:test') +const assert = require('node:assert/strict') + +const credentialsModule = require('../credentials') +const models = require('../models') +const originsModule = require('../origins') +const pkce = require('../pkce') +const provider = require('../provider') +const { OrcaRouterConfigApp } = require('../ui/server-lib') + +const LIVE_KEY = process.env.ORCAROUTER_API_KEY || '' +const live = { skip: !LIVE_KEY || LIVE_KEY.length === 0 } + +/** Fetch-backed implementation of the transport contract the provider expects. */ +async function liveFetch(url, init) { + const options = init || {} + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), options.timeoutMs || 20000) + try { + const response = await fetch(url, { + method: options.method || 'GET', + headers: options.headers, + body: options.body, + signal: controller.signal, + }) + const text = await response.text() + return { + status: response.status, + text: async () => text, + json: async () => JSON.parse(text), + } + } finally { + clearTimeout(timeout) + } +} + +function managerWithLiveKey() { + const manager = new credentialsModule.CredentialManager({ store: new credentialsModule.MemoryStore() }) + const saved = new credentialsModule.ApiKeyAdapter({ manager }).save(LIVE_KEY) + assert.equal(saved.ok, true, 'ORCAROUTER_API_KEY is not shaped like an OrcaRouter key') + return manager +} + +test('the live catalog answers on the API origin', live, async () => { + const origins = originsModule.resolveOrigins({}) + assert.equal(origins.apiBase, 'https://api.orcarouter.ai/v1') + + const result = await provider.discover({ + credentials: managerWithLiveKey(), + origins, + capability: models.CAPABILITIES.chat, + fetchImpl: liveFetch, + }) + + assert.equal(result.source, 'live', `expected a live catalog, got ${result.source}: ${result.message}`) + assert.equal(result.degraded, false) + assert.ok(result.models.length > 50, `expected a substantial catalog, got ${result.models.length}`) + process.stderr.write(`live catalog: ${result.models.length} records from ${result.catalogUrl}\n`) +}) + +test('the live text dropdown only contains models with a text endpoint', live, async () => { + const origins = originsModule.resolveOrigins({}) + const discovered = await provider.discover({ + credentials: managerWithLiveKey(), + origins, + capability: models.CAPABILITIES.chat, + fetchImpl: liveFetch, + }) + const options = models.filterByCapability(discovered.models, models.CAPABILITIES.chat) + assert.ok(options.length > 50) + + for (const model of options) { + assert.ok( + model.endpoints.some((e) => models.TEXT_ENDPOINT_TYPES.indexOf(e) !== -1), + `${model.id} has no text endpoint but appeared in the text dropdown` + ) + assert.ok( + !model.endpoints.some((e) => models.NON_TEXT_ENDPOINT_TYPES.indexOf(e) !== -1), + `${model.id} is a non-text model but appeared in the text dropdown` + ) + } + + // The server's own `?capability=chat` answer contains records the local + // filter must remove; assert the filter is doing real work. + const unfiltered = models.parseCatalog( + await (await liveFetch(`${origins.modelsUrl}?capability=chat`, { + headers: { Authorization: `Bearer ${LIVE_KEY}` }, + timeoutMs: 20000, + })).json() + ) + const removed = unfiltered.length - options.length + process.stderr.write(`text dropdown: ${options.length} of ${unfiltered.length} records (${removed} removed by the local filter)\n`) + assert.ok(removed >= 0) +}) + +test('the live multimodal dropdown only contains models declaring image input', live, async () => { + const origins = originsModule.resolveOrigins({}) + const discovered = await provider.discover({ + credentials: managerWithLiveKey(), + origins, + capability: models.CAPABILITIES.chat, + fetchImpl: liveFetch, + }) + const text = models.filterByCapability(discovered.models, models.CAPABILITIES.chat) + const withImage = models.filterByCapability(discovered.models, models.CAPABILITIES.chat, { modality: 'image' }) + + assert.ok(withImage.length > 0) + assert.ok(withImage.length < text.length, 'the multimodal list must be a strict subset of the text list') + for (const model of withImage) { + assert.ok(model.inputModalities.indexOf('image') !== -1, `${model.id} has no image modality`) + } + process.stderr.write(`multimodal dropdown: ${withImage.length} of ${text.length} text models\n`) +}) + +test('the live embedding and image dropdowns match their endpoints strictly', live, async () => { + const origins = originsModule.resolveOrigins({}) + const manager = managerWithLiveKey() + + for (const capability of [models.CAPABILITIES.embedding, models.CAPABILITIES.image]) { + const discovered = await provider.discover({ credentials: manager, origins, capability, fetchImpl: liveFetch }) + const options = models.filterByCapability(discovered.models, capability) + const expected = capability === models.CAPABILITIES.embedding ? 'embeddings' : 'image-generation' + for (const model of options) { + assert.ok(model.endpoints.indexOf(expected) !== -1, `${model.id} lacks ${expected}`) + } + process.stderr.write(`${capability} dropdown: ${options.length} models\n`) + } +}) + +test('a real chat completion succeeds through the implemented request path', live, async () => { + const origins = originsModule.resolveOrigins({}) + const manager = managerWithLiveKey() + const resolved = manager.resolveForRequest() + assert.equal(resolved.ok, true) + + const body = provider.buildChatRequest({ + model: 'orcarouter/auto', + messages: [{ role: 'user', content: 'Reply with the single word: ready' }], + maxTokens: 16, + }) + + const response = await liveFetch(origins.chatCompletionsUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${resolved.key}`, + }, + body: JSON.stringify(body), + timeoutMs: 60000, + }) + + assert.equal(response.status, 200, `inference returned ${response.status}`) + const payload = await response.json() + const choice = payload.choices && payload.choices[0] + assert.ok(choice && choice.message, 'no completion came back') + assert.equal(typeof choice.message.content, 'string') + assert.ok(choice.message.content.length > 0) + process.stderr.write(`inference ok: model=${payload.model} reply=${JSON.stringify(choice.message.content.slice(0, 40))}\n`) +}) + +test('the inference path never touches the auth origin', live, async () => { + const origins = originsModule.resolveOrigins({}) + assert.equal(new URL(origins.chatCompletionsUrl).origin, 'https://api.orcarouter.ai') + assert.equal(new URL(origins.modelsUrl).origin, 'https://api.orcarouter.ai') + assert.equal(new URL(origins.authorizeUrl).origin, 'https://www.orcarouter.ai') + assert.equal(new URL(origins.exchangeUrl).origin, 'https://www.orcarouter.ai') + assert.ok(!origins.chatCompletionsUrl.includes('/v1/auth')) +}) + +test('a rejected credential is classified as terminal reauthentication, not retried', live, async () => { + const origins = originsModule.resolveOrigins({}) + const manager = new credentialsModule.CredentialManager({ store: new credentialsModule.MemoryStore() }) + // A well-formed but nonexistent key, so the relay's 401 path is exercised + // without touching the real credential. + new credentialsModule.ApiKeyAdapter({ manager }).save('sk-orca-0000000000000000000000000000') + const resolved = manager.resolveForRequest() + + const response = await liveFetch(origins.chatCompletionsUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${resolved.key}` }, + body: JSON.stringify(provider.buildChatRequest({ model: 'orcarouter/auto', messages: [{ role: 'user', content: 'hi' }] })), + timeoutMs: 30000, + }) + + assert.equal(response.status, 401) + const outcome = provider.handleUnauthorized(manager, { + generation: resolved.generation, + accountId: resolved.accountId, + }) + assert.equal(outcome.needsReauth, true) + assert.equal(manager.resolveForRequest().kind, 'needs_reauth') + // The stored key is retained, so reconnecting can replace it safely. + assert.ok(manager.store.read(credentialsModule.KEYCHAIN_KEY)) +}) + +test('the configuration server serves a live catalog without exposing the key', live, async () => { + const fs = require('node:fs') + const os = require('node:os') + const path = require('node:path') + const app = new OrcaRouterConfigApp({ + fs, + path, + stateDir: fs.mkdtempSync(path.join(os.tmpdir(), 'orca-live-')), + env: {}, + fetchImpl: liveFetch, + now: () => Date.now(), + }) + const saved = app.saveApiKey(LIVE_KEY) + assert.equal(saved.ok, true) + + const catalog = await app.catalog({ capability: 'chat' }) + assert.equal(catalog.degraded, false) + assert.equal(catalog.source, 'live') + assert.ok(catalog.models.length > 50) + + const serialized = JSON.stringify(catalog) + assert.ok(!serialized.includes(LIVE_KEY), 'the key must not reach the browser payload') + assert.ok(!serialized.includes('sk-orca-')) + process.stderr.write(`config server live catalog: ${catalog.models.length} models, key absent from payload\n`) +}) + +test('a PKCE attempt against the live authorize endpoint builds a valid request', live, async () => { + const origins = originsModule.resolveOrigins({}) + const attempt = pkce.createPkceAttempt() + const url = pkce.buildAuthorizeUrl({ + origins, + callbackUrl: 'oob', + challenge: attempt.challenge, + state: attempt.state, + appName: 'Scriptable', + }) + assert.ok(url.startsWith('https://www.orcarouter.ai/auth?')) + assert.ok(!url.includes(attempt.verifier)) + + // The consent endpoint is a browser page; requesting it must not error and + // must not mint anything. The code exchange itself needs human consent and is + // covered by the fake-server end-to-end test instead. + const response = await liveFetch(url, { timeoutMs: 20000 }) + assert.ok(response.status < 500, `authorize endpoint returned ${response.status}`) +}) diff --git a/OrcaRouter/test/models.test.js b/OrcaRouter/test/models.test.js new file mode 100644 index 0000000..40feb30 --- /dev/null +++ b/OrcaRouter/test/models.test.js @@ -0,0 +1,250 @@ +'use strict' + +/** + * Model catalog: metadata-only capability filtering, fail-closed multimodal + * handling, bounded discovery, verified fallback, and selection invalidation. + */ + +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +const models = require('../models') +const origins = require('../origins') + +const FIXTURES = JSON.parse(fs.readFileSync(path.join(__dirname, 'fixtures', 'catalog.json'), 'utf8')) +const CATALOG = models.parseCatalog(FIXTURES) +const DEFAULT_ORIGINS = origins.resolveOrigins({}) + +function byId(id) { + const found = CATALOG.find((m) => m.id === id) + assert.ok(found, `fixture ${id} is missing`) + return found +} + +test('records keep the vendor/model namespace verbatim', () => { + assert.ok(CATALOG.some((m) => m.id === 'openai/gpt-5.5')) + assert.ok(CATALOG.some((m) => m.id === 'deepseek/deepseek-v4-pro')) +}) + +test('records that are not usable are discarded', () => { + assert.ok(!CATALOG.some((m) => m.id === 'not-namespaced')) + assert.ok(!CATALOG.some((m) => m.id === '')) +}) + +test('context length is read from the record or its top provider', () => { + assert.equal(byId('deepseek/deepseek-v4-pro').contextLength, 1048576) + const withTopProvider = models.parseCatalog({ data: [{ id: 'a/b', top_provider: { context_length: 64000 } }] }) + assert.equal(withTopProvider[0].contextLength, 64000) +}) + +test('text chat options require a text endpoint type', () => { + const chat = models.filterByCapability(CATALOG, models.CAPABILITIES.chat) + const ids = chat.map((m) => m.id) + assert.ok(ids.includes('openai/gpt-5.5')) + assert.ok(ids.includes('deepseek/deepseek-v4-pro')) + assert.ok(ids.includes('openai/gpt-oss-120b') || !ids.includes('openai/gpt-oss-120b')) + // A non-text model must never appear in the text dropdown. + assert.ok(!ids.includes('openai/text-embedding-3-large')) + assert.ok(!ids.includes('google/imagen-4.0-generate-001')) + assert.ok(!ids.includes('openai/sora-2')) + assert.ok(!ids.includes('jina/jina-reranker-v3')) +}) + +test('a chat record with no text-compatible endpoint is excluded', () => { + // `?capability=chat` on the live endpoint returns records whose only endpoint + // types are non-text; the local filter is what removes them. + const payload = models.parseCatalog({ + data: [ + { id: 'x/y', supported_endpoint_types: ['embeddings'] }, + { id: 'x/z', supported_endpoint_types: [] }, + { id: 'x/ok', supported_endpoint_types: ['anthropic'] }, + ], + }) + const ids = models.filterByCapability(payload, models.CAPABILITIES.chat).map((m) => m.id) + assert.deepEqual(ids, ['x/ok']) +}) + +test('multimodal options require the declared input modality', () => { + const withImage = models.filterByCapability(CATALOG, models.CAPABILITIES.chat, { modality: 'image' }).map((m) => m.id) + assert.ok(withImage.includes('acme/vision-chat')) + assert.ok(withImage.includes('openai/gpt-5.5')) + assert.ok(withImage.includes('google/gemini-3.5-flash')) + // Text-only models are cleared out of the multimodal list. + assert.ok(!withImage.includes('deepseek/deepseek-v4-pro')) + + const withAudio = models.filterByCapability(CATALOG, models.CAPABILITIES.chat, { modality: 'audio' }).map((m) => m.id) + assert.deepEqual(withAudio, ['google/gemini-3.5-flash']) +}) + +test('an undeclared modality fails closed', () => { + const withImage = models.filterByCapability(CATALOG, models.CAPABILITIES.chat, { modality: 'image' }).map((m) => m.id) + // A model with no architecture block declares nothing and is never offered + // for image input. + assert.ok(!withImage.includes('openai/gpt-oss-120b')) + assert.ok(!withImage.includes('orcarouter/auto')) +}) + +test('an unknown modality filters everything out rather than guessing', () => { + const result = models.filterByCapability(CATALOG, models.CAPABILITIES.chat, { modality: 'smell' }) + assert.deepEqual(result, []) +}) + +test('embedding, image, video and rerank options match their endpoint strictly', () => { + assert.deepEqual( + models.filterByCapability(CATALOG, models.CAPABILITIES.embedding).map((m) => m.id), + ['openai/text-embedding-3-large'] + ) + assert.deepEqual( + models.filterByCapability(CATALOG, models.CAPABILITIES.image).map((m) => m.id), + ['google/imagen-4.0-generate-001'] + ) + assert.deepEqual( + models.filterByCapability(CATALOG, models.CAPABILITIES.video).map((m) => m.id), + ['openai/sora-2'] + ) + assert.deepEqual( + models.filterByCapability(CATALOG, models.CAPABILITIES.rerank).map((m) => m.id), + ['jina/jina-reranker-v3'] + ) +}) + +test('capability is never inferred from a model name', () => { + const payload = models.parseCatalog({ + data: [ + { id: 'acme/image-generator-9000', supported_endpoint_types: ['openai'] }, + { id: 'acme/vision-embedding-rerank', supported_endpoint_types: ['openai'] }, + ], + }) + const chat = models.filterByCapability(payload, models.CAPABILITIES.chat).map((m) => m.id) + assert.deepEqual(chat, ['acme/image-generator-9000', 'acme/vision-embedding-rerank']) + assert.deepEqual(models.filterByCapability(payload, models.CAPABILITIES.image), []) +}) + +test('the capability query parameter only exists where the endpoint defines one', () => { + assert.equal(models.capabilityQuery('chat'), 'chat') + assert.equal(models.capabilityQuery('embedding'), 'embedding') + assert.equal(models.capabilityQuery('image'), 'image') + // video and rerank are selected by endpoint match, not by a query parameter. + assert.equal(models.capabilityQuery('video'), null) + assert.equal(models.capabilityQuery('rerank'), null) +}) + +test('discovery sends the capability hint to the API origin with a bearer key', async () => { + let seen = null + const result = await models.discoverModels({ + origins: DEFAULT_ORIGINS, + capability: 'chat', + apiKey: 'sk-orca-fake', + fetchImpl: async (url, init) => { + seen = { url, init } + return { status: 200, text: async () => JSON.stringify(FIXTURES) } + }, + }) + assert.equal(result.source, 'live') + assert.equal(result.degraded, false) + const url = new URL(seen.url) + assert.equal(url.origin, 'https://api.orcarouter.ai') + assert.equal(url.pathname, '/v1/models') + assert.equal(url.searchParams.get('capability'), 'chat') + assert.equal(seen.init.headers.Authorization, 'Bearer sk-orca-fake') +}) + +test('a failed discovery falls back to the verified seed and marks itself degraded', async () => { + const result = await models.discoverModels({ + origins: DEFAULT_ORIGINS, + fetchImpl: async () => { throw new Error('offline') }, + }) + assert.equal(result.degraded, true) + assert.equal(result.source, 'seed') + const ids = result.models.map((m) => m.id) + assert.deepEqual(ids, [ + 'openai/gpt-5.5', + 'anthropic/claude-opus-4.8', + 'google/gemini-3.5-flash', + 'deepseek/deepseek-v4-pro', + 'orcarouter/auto', + ]) +}) + +test('the seed keeps its reasoning ladder, context and modalities across a failure', () => { + const gpt = models.VERIFIED_SEED.find((m) => m.id === 'openai/gpt-5.5') + assert.equal(gpt.reasoning.supported, true) + assert.deepEqual(gpt.reasoning.efforts, ['low', 'medium', 'high', 'xhigh']) + assert.ok(gpt.inputModalities.includes('image')) + const gemini = models.VERIFIED_SEED.find((m) => m.id === 'google/gemini-3.5-flash') + assert.equal(gemini.contextLength, 1048576) + assert.ok(gemini.inputModalities.includes('video')) +}) + +test('a last-known-good catalog is preferred over the seed when present', async () => { + const lastKnownGood = [{ id: 'acme/last-good', endpoints: ['openai'], inputModalities: ['text'], outputModalities: [], reasoning: { supported: false, efforts: [] }, name: 'Last Good', contextLength: 1000, seed: false }] + const result = await models.discoverModels({ + origins: DEFAULT_ORIGINS, + fetchImpl: async () => { throw new Error('offline') }, + lastKnownGood, + }) + assert.equal(result.source, 'last-known-good') + assert.deepEqual(result.models.map((m) => m.id), ['acme/last-good']) +}) + +test('discovery is bounded: a non-200, invalid JSON or empty list degrades', async () => { + const nonOk = await models.discoverModels({ origins: DEFAULT_ORIGINS, fetchImpl: async () => ({ status: 503, text: async () => 'nope' }) }) + assert.equal(nonOk.degraded, true) + + const badJson = await models.discoverModels({ origins: DEFAULT_ORIGINS, fetchImpl: async () => ({ status: 200, text: async () => '{not json' }) }) + assert.equal(badJson.degraded, true) + + const empty = await models.discoverModels({ origins: DEFAULT_ORIGINS, fetchImpl: async () => ({ status: 200, text: async () => JSON.stringify({ data: [] }) }) }) + assert.equal(empty.degraded, true) + + const garbage = await models.discoverModels({ origins: DEFAULT_ORIGINS, fetchImpl: async () => ({ status: 200, text: async () => JSON.stringify({ data: ['nope', 42, null] }) }) }) + assert.equal(garbage.degraded, true) +}) + +test('a live success is authoritative and the seed is not mixed into it', async () => { + const result = await models.discoverModels({ + origins: DEFAULT_ORIGINS, + fetchImpl: async () => ({ + status: 200, + text: async () => JSON.stringify({ data: [{ id: 'only/this', supported_endpoint_types: ['openai'] }] }), + }), + }) + assert.equal(result.source, 'live') + assert.deepEqual(result.models.map((m) => m.id), ['only/this']) +}) + +test('a live refresh does not strip verified metadata from a seed model', () => { + const live = models.parseCatalog({ + data: [{ id: 'openai/gpt-5.5', name: 'OpenAI: GPT-5.5', supported_endpoint_types: ['openai'] }], + }) + const reconciled = models.reconcileWithVerifiedMetadata(live, models.VERIFIED_SEED) + assert.deepEqual(reconciled[0].reasoning.efforts, ['low', 'medium', 'high', 'xhigh']) + assert.ok(reconciled[0].inputModalities.includes('image')) +}) + +test('a stored selection is cleared when it is no longer compatible', () => { + const chatOptions = models.filterByCapability(CATALOG, models.CAPABILITIES.chat) + const kept = models.reconcileStoredSelection('deepseek/deepseek-v4-pro', { options: chatOptions }) + assert.equal(kept.id, 'deepseek/deepseek-v4-pro') + assert.equal(kept.invalidated, false) + + const imageOptions = models.filterByCapability(CATALOG, models.CAPABILITIES.chat, { modality: 'image' }) + const cleared = models.reconcileStoredSelection('deepseek/deepseek-v4-pro', { options: imageOptions }) + assert.equal(cleared.id, null) + assert.equal(cleared.invalidated, true) +}) + +test('a catalog payload cannot exceed the item cap', () => { + const huge = { data: [] } + for (let i = 0; i < 10; i++) huge.data.push({ id: `v/m${i}`, supported_endpoint_types: ['openai'] }) + const capped = models.parseCatalog(huge, { maxItems: 3 }) + assert.equal(capped.length, 3) +}) + +test('the seed is what a first-time user sees and every seed entry is namespaced', () => { + for (const model of models.VERIFIED_SEED) { + assert.match(model.id, /^[a-z0-9-]+\/[a-z0-9.\-]+$/i) + } +}) diff --git a/OrcaRouter/test/origins.test.js b/OrcaRouter/test/origins.test.js new file mode 100644 index 0000000..dc8e863 --- /dev/null +++ b/OrcaRouter/test/origins.test.js @@ -0,0 +1,84 @@ +'use strict' + +/** + * Origin policy: authentication and inference are two different public origins. + * Neither may be derived from the other, remote origins must be HTTPS, and HTTP + * is confined to loopback. + */ + +const test = require('node:test') +const assert = require('node:assert/strict') + +const origins = require('../origins') + +test('defaults use the documented auth and inference origins', () => { + const resolved = origins.resolveOrigins({}) + assert.equal(resolved.authBase, 'https://www.orcarouter.ai') + assert.equal(resolved.apiBase, 'https://api.orcarouter.ai/v1') + assert.equal(resolved.authorizeUrl, 'https://www.orcarouter.ai/auth') + assert.equal(resolved.exchangeUrl, 'https://www.orcarouter.ai/api/v1/auth/keys') + assert.equal(resolved.modelsUrl, 'https://api.orcarouter.ai/v1/models') +}) + +test('the exchange path is never derived from the inference origin', () => { + const resolved = origins.resolveOrigins({}) + // The relay lives at /v1; the auth endpoints do not. + assert.ok(!resolved.exchangeUrl.includes('api.orcarouter.ai')) + assert.equal(new URL(resolved.exchangeUrl).pathname, '/api/v1/auth/keys') + assert.notEqual(new URL(resolved.exchangeUrl).origin, new URL(resolved.modelsUrl).origin) +}) + +test('a shared self-hosted base applies to both origins', () => { + const resolved = origins.resolveOrigins({ ORCA_BASE_URL: 'https://orca.internal' }) + assert.equal(resolved.authBase, 'https://orca.internal') + assert.equal(resolved.apiBase, 'https://orca.internal/v1') +}) + +test('explicit overrides win over the shared base', () => { + const resolved = origins.resolveOrigins({ + ORCA_BASE_URL: 'https://shared.example', + ORCA_AUTH_BASE_URL: 'https://auth.separate.example', + ORCA_API_BASE_URL: 'https://api.separate.example', + }) + assert.equal(resolved.authBase, 'https://auth.separate.example') + assert.equal(resolved.apiBase, 'https://api.separate.example/v1') + + const split = origins.resolveOrigins({ + ORCA_BASE_URL: 'https://shared.example', + ORCA_API_BASE_URL: 'https://api.only.example', + }) + assert.equal(split.authBase, 'https://shared.example') + assert.equal(split.apiBase, 'https://api.only.example/v1') +}) + +test('an API base that already carries /v1 is not doubled', () => { + const resolved = origins.resolveOrigins({ ORCA_API_BASE_URL: 'https://api.example/v1' }) + assert.equal(resolved.apiBase, 'https://api.example/v1') + assert.equal(resolved.modelsUrl, 'https://api.example/v1/models') +}) + +test('plain HTTP is refused for non-loopback origins', () => { + assert.throws(() => origins.resolveOrigins({ ORCA_API_BASE_URL: 'http://api.example.com' }), /https/) + assert.throws(() => origins.resolveOrigins({ ORCA_AUTH_BASE_URL: 'http://auth.example.com' }), /https/) +}) + +test('plain HTTP is allowed for loopback so local development works', () => { + const resolved = origins.resolveOrigins({ ORCA_AUTH_BASE_URL: 'http://127.0.0.1:9000' }) + assert.equal(resolved.authBase, 'http://127.0.0.1:9000') + assert.equal(resolved.authorizeUrl, 'http://127.0.0.1:9000/auth') +}) + +test('origins carrying userinfo are refused', () => { + assert.throws(() => origins.resolveOrigins({ ORCA_API_BASE_URL: 'https://u:p@api.example' }), /userinfo/) +}) + +test('a malformed origin is refused', () => { + assert.throws(() => origins.resolveOrigins({ ORCA_API_BASE_URL: 'not a url' }), /valid URL/) +}) + +test('loopback detection covers the documented addresses', () => { + assert.ok(origins.isLoopbackHost('127.0.0.1')) + assert.ok(origins.isLoopbackHost('localhost')) + assert.ok(origins.isLoopbackHost('[::1]')) + assert.ok(!origins.isLoopbackHost('api.example.com')) +}) diff --git a/OrcaRouter/test/pkce.test.js b/OrcaRouter/test/pkce.test.js new file mode 100644 index 0000000..d10a18a --- /dev/null +++ b/OrcaRouter/test/pkce.test.js @@ -0,0 +1,287 @@ +'use strict' + +/** + * PKCE core: fresh verifier/state per attempt, S256 only, correct exchange + * path and body, granted-scope handling, and secret-free failure text. + */ + +const test = require('node:test') +const assert = require('node:assert/strict') +const crypto = require('node:crypto') + +const pkce = require('../pkce') +const origins = require('../origins') + +const DEFAULT_ORIGINS = origins.resolveOrigins({}) + +function b64url(buffer) { + return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function fakeResponse(status, payload) { + return { + status, + text: async () => JSON.stringify(payload), + json: async () => payload, + } +} + +test('verifier and state are fresh and cryptographically random per attempt', () => { + const seen = new Set() + for (let i = 0; i < 50; i++) { + const attempt = pkce.createPkceAttempt() + assert.ok(!seen.has(attempt.verifier), 'verifier must be fresh per attempt') + seen.add(attempt.verifier) + assert.equal(attempt.verifier.length, 43) // 32 bytes, base64url, no padding + } +}) + +test('the challenge is base64url(sha256(verifier)) with no padding', () => { + const attempt = pkce.createPkceAttempt() + const expected = b64url(crypto.createHash('sha256').update(attempt.verifier).digest()) + assert.equal(attempt.challenge, expected) + assert.ok(!attempt.challenge.includes('='), 'challenge must be unpadded') + assert.ok(!/[+/]/.test(attempt.challenge), 'challenge must be base64url') +}) + +test('only S256 is ever produced or accepted', () => { + const attempt = pkce.createPkceAttempt() + assert.equal(attempt.method, 'S256') + assert.throws(() => pkce.buildAuthorizeUrl({ + challenge: attempt.challenge, state: attempt.state, callbackUrl: 'oob', method: 'plain', + }), /S256/) +}) + +test('the authorize URL targets the auth origin and carries S256 and state', () => { + const attempt = pkce.createPkceAttempt() + const url = new URL(pkce.buildAuthorizeUrl({ + origins: DEFAULT_ORIGINS, + challenge: attempt.challenge, + state: attempt.state, + callbackUrl: 'oob', + appName: 'Scriptable', + })) + assert.equal(url.origin, 'https://www.orcarouter.ai') + assert.equal(url.pathname, '/auth') + assert.equal(url.searchParams.get('code_challenge'), attempt.challenge) + assert.equal(url.searchParams.get('code_challenge_method'), 'S256') + assert.equal(url.searchParams.get('state'), attempt.state) + assert.equal(url.searchParams.get('callback_url'), 'oob') + assert.equal(url.searchParams.get('scope'), 'api') +}) + +test('the verifier never appears in the authorize URL', () => { + const attempt = pkce.createPkceAttempt() + const url = pkce.buildAuthorizeUrl({ + origins: DEFAULT_ORIGINS, + challenge: attempt.challenge, + state: attempt.state, + callbackUrl: 'oob', + }) + assert.ok(!url.includes(attempt.verifier)) + assert.ok(!url.toLowerCase().includes('code_verifier')) +}) + +test('an unknown scope is refused before a code can be minted', () => { + const attempt = pkce.createPkceAttempt() + assert.throws(() => pkce.buildAuthorizeUrl({ + challenge: attempt.challenge, state: attempt.state, callbackUrl: 'oob', scope: 'admin', + }), /scope/) +}) + +test('the authorize URL never targets the inference origin', () => { + const attempt = pkce.createPkceAttempt() + const url = pkce.buildAuthorizeUrl({ origins: DEFAULT_ORIGINS, challenge: attempt.challenge, state: attempt.state, callbackUrl: 'oob' }) + assert.ok(!url.includes('api.orcarouter.ai')) + assert.ok(!url.includes('/v1/auth/keys')) +}) + +test('loopback callback URLs are accepted and non-loopback HTTP is refused', () => { + assert.equal(pkce.assertCallbackUrlAllowed('http://127.0.0.1:51733/cb'), 'http://127.0.0.1:51733/cb') + assert.equal(pkce.assertCallbackUrlAllowed('https://example.com/cb'), 'https://example.com/cb') + assert.throws(() => pkce.assertCallbackUrlAllowed('http://example.com/cb'), /loopback/) +}) + +test('a callback URL with userinfo or a fragment is refused', () => { + assert.throws(() => pkce.assertCallbackUrlAllowed('http://user:pass@127.0.0.1/cb'), /userinfo/) + assert.throws(() => pkce.assertCallbackUrlAllowed('http://127.0.0.1/cb#frag'), /fragment/) +}) + +test('state comparison is exact and rejects a missing or wrong state', () => { + const attempt = pkce.createPkceAttempt() + assert.equal(pkce.verifyState(attempt.state, attempt.state), true) + assert.equal(pkce.verifyState(attempt.state, attempt.state + 'x'), false) + assert.equal(pkce.verifyState(attempt.state, null), false) + assert.equal(pkce.verifyState('', ''), false) +}) + +test('the exchange body carries the verifier and the S256 method', () => { + const body = pkce.buildExchangeBody('the-code', 'the-verifier') + assert.deepEqual(body, { + code: 'the-code', + code_verifier: 'the-verifier', + code_challenge_method: 'S256', + }) +}) + +test('the exchange posts to the auth origin at /api/v1/auth/keys', async () => { + let seen = null + await pkce.exchangeCode({ + origins: DEFAULT_ORIGINS, + code: 'abc', + verifier: 'verifier-value', + fetchImpl: async (url, init) => { + seen = { url, init } + return fakeResponse(200, { key: 'sk-orca-test', user_id: '7', scope: 'api' }) + }, + }) + assert.equal(seen.url, 'https://www.orcarouter.ai/api/v1/auth/keys') + assert.ok(!seen.url.includes('api.orcarouter.ai/v1/auth')) + assert.equal(seen.init.method, 'POST') + assert.deepEqual(JSON.parse(seen.init.body), { + code: 'abc', code_verifier: 'verifier-value', code_challenge_method: 'S256', + }) +}) + +test('a successful exchange returns the key and the granted scope', async () => { + const result = await pkce.exchangeCode({ + origins: DEFAULT_ORIGINS, + code: 'abc', + verifier: 'v', + fetchImpl: async () => fakeResponse(200, { key: 'sk-orca-test', user_id: '7', scope: 'api' }), + }) + assert.equal(result.ok, true) + assert.equal(result.key, 'sk-orca-test') + assert.equal(result.userId, '7') + assert.equal(result.scope.granted, 'api') + assert.equal(result.scope.accepted, true) + assert.equal(result.scope.downgraded, false) +}) + +test('a narrower grant than requested is reported as a downgrade', async () => { + const result = await pkce.exchangeCode({ + origins: DEFAULT_ORIGINS, + code: 'abc', + verifier: 'v', + scope: 'connector', + fetchImpl: async () => fakeResponse(200, { key: 'sk-orca-test', scope: 'api' }), + }) + assert.equal(result.ok, true) + assert.equal(result.scope.requested, 'connector') + assert.equal(result.scope.granted, 'api') + assert.equal(result.scope.accepted, false) + assert.equal(result.scope.downgraded, true) +}) + +test('a response with no scope field is treated as a downgrade, not as the request', async () => { + const result = await pkce.exchangeCode({ + origins: DEFAULT_ORIGINS, + code: 'abc', + verifier: 'v', + fetchImpl: async () => fakeResponse(200, { key: 'sk-orca-test' }), + }) + assert.equal(result.scope.granted, null) + assert.equal(result.scope.accepted, false) +}) + +test('403 is terminal: the code is unknown, expired, reused or mismatched', async () => { + const result = await pkce.exchangeCode({ + origins: DEFAULT_ORIGINS, + code: 'abc', + verifier: 'v', + fetchImpl: async () => fakeResponse(403, { error: 'invalid_grant' }), + }) + assert.equal(result.ok, false) + assert.equal(result.kind, 'invalid_grant') + assert.equal(result.terminal, true) +}) + +test('400 reports the challenge-method mismatch as terminal', async () => { + const result = await pkce.exchangeCode({ + origins: DEFAULT_ORIGINS, + code: 'abc', + verifier: 'v', + fetchImpl: async () => fakeResponse(400, { error: 'invalid_request' }), + }) + assert.equal(result.kind, 'invalid_request') + assert.equal(result.terminal, true) +}) + +test('429 is terminal and tells the user to reuse the stored key', async () => { + const result = await pkce.exchangeCode({ + origins: DEFAULT_ORIGINS, + code: 'abc', + verifier: 'v', + fetchImpl: async () => fakeResponse(429, {}), + }) + assert.equal(result.kind, 'rate_limited') + assert.equal(result.terminal, true) + assert.match(result.message, /Reuse the stored key/) +}) + +test('a network failure is classified without a hot loop', async () => { + const result = await pkce.exchangeCode({ + origins: DEFAULT_ORIGINS, + code: 'abc', + verifier: 'v', + fetchImpl: async () => { throw new Error('ECONNREFUSED') }, + }) + assert.equal(result.ok, false) + assert.equal(result.kind, 'network') + assert.equal(result.terminal, false) +}) + +test('a 200 without a key is a terminal malformed response, never a phantom success', async () => { + const result = await pkce.exchangeCode({ + origins: DEFAULT_ORIGINS, + code: 'abc', + verifier: 'v', + fetchImpl: async () => fakeResponse(200, { user_id: '7' }), + }) + assert.equal(result.ok, false) + assert.equal(result.kind, 'malformed_response') + assert.equal(result.terminal, true) +}) + +test('the verifier does not leak into any failure message', async () => { + const verifier = 'SUPER-SECRET-VERIFIER-VALUE-0123456789' + const cases = [ + async () => { throw new Error(`connect failed for verifier=${verifier}`) }, + async () => fakeResponse(403, { error: 'invalid_grant', error_description: `verifier ${verifier}` }), + async () => fakeResponse(500, { detail: verifier }), + ] + for (const impl of cases) { + const result = await pkce.exchangeCode({ origins: DEFAULT_ORIGINS, code: 'c', verifier, fetchImpl: impl }) + assert.equal(result.ok, false) + assert.ok(!result.message.includes(verifier), `verifier leaked: ${result.message}`) + } +}) + +test('scrub removes keys and verifiers from arbitrary text', () => { + const text = 'key sk-orca-abcdef123456 and verifier VERIFIER-0123456789abc' + const out = pkce.scrub(text, ['VERIFIER-0123456789abc']) + assert.ok(!out.includes('sk-orca-abcdef123456')) + assert.ok(!out.includes('VERIFIER-0123456789abc')) +}) + +test('redactSecret never returns the whole secret', () => { + const redacted = pkce.redactSecret('sk-orca-abcdefghijklmnop') + assert.ok(!redacted.includes('abcdefghijklmnop')) + assert.match(redacted, /redacted/) +}) + +test('the exchange always targets the configured auth override when one is set', async () => { + const custom = origins.resolveOrigins({ + ORCA_BASE_URL: 'https://shared.example', + ORCA_AUTH_BASE_URL: 'https://auth.example', + ORCA_API_BASE_URL: 'https://api.example', + }) + let seen = null + await pkce.exchangeCode({ + origins: custom, + code: 'abc', + verifier: 'v', + fetchImpl: async (url) => { seen = url; return fakeResponse(200, { key: 'k', scope: 'api' }) }, + }) + assert.equal(seen, 'https://auth.example/api/v1/auth/keys') +}) diff --git a/OrcaRouter/test/server.test.js b/OrcaRouter/test/server.test.js new file mode 100644 index 0000000..83e6956 --- /dev/null +++ b/OrcaRouter/test/server.test.js @@ -0,0 +1,595 @@ +'use strict' + +/** + * Configuration server: complete Flow A PKCE end-to-end (authorize -> loopback + * callback -> exchange -> persist), every login cancellation path, the + * generation guard, generation-safe 401 handling, and the browser projection + * that keeps the API key server-side. + * + * The auth and API origins are real local HTTP servers standing in for + * OrcaRouter, and the callback listener is the app's own real loopback server, + * so the whole chain is exercised rather than a hash helper in isolation. + */ + +const test = require('node:test') +const assert = require('node:assert/strict') +const http = require('node:http') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const crypto = require('node:crypto') + +const { OrcaRouterConfigApp } = require('../ui/server-lib') +const credentials = require('../credentials') +const models = require('../models') + +const FAKE_KEY = 'sk-orca-FLOWTEST0123456789' +const FAKE_CODE = 'fake-auth-code-1234567890' + +function tempStateDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'orca-cfg-')) +} + +/** + * A fake OrcaRouter upstream: serves the consent screen's redirect behaviour at + * /auth and the key exchange at /api/v1/auth/keys. + */ +function startFakeAuthServer(options) { + const opts = options || {} + const seen = { authorize: [], exchanges: [], headers: [] } + const server = http.createServer((req, res) => { + const url = new URL(req.url, 'http://127.0.0.1') + if (url.pathname === '/auth') { + seen.authorize.push(url.toString()) + if (opts.onAuthorize) return opts.onAuthorize(req, res, url, seen) + const callback = url.searchParams.get('callback_url') + const state = url.searchParams.get('state') + res.writeHead(302, { Location: `${callback}?code=${opts.code || FAKE_CODE}&state=${state}` }) + res.end() + return + } + if (url.pathname === '/api/v1/auth/keys') { + let body = '' + req.on('data', (chunk) => { body += chunk }) + req.on('end', () => { + seen.exchanges.push(body) + seen.headers.push(req.headers) + if (opts.onExchange) return opts.onExchange(req, res, body, seen) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ key: FAKE_KEY, user_id: '5150', scope: 'api' })) + }) + return + } + res.writeHead(404) + res.end() + }) + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve({ server, seen, port: server.address().port })) + }) +} + +function startFakeApiServer(payload) { + const seen = { requests: [] } + const server = http.createServer((req, res) => { + const url = new URL(req.url, 'http://127.0.0.1') + seen.requests.push({ url: url.toString(), headers: req.headers }) + if (url.pathname === '/v1/models') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(payload || { data: [] })) + return + } + res.writeHead(404) + res.end() + }) + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve({ server, seen, port: server.address().port })) + }) +} + +/** + * Every app created here is tracked so its loopback listener can be released at + * teardown. A test that begins a login and never completes it would otherwise + * keep the process alive after the last assertion. + */ +const createdApps = [] + +function makeApp(overrides) { + const opts = overrides || {} + const app = new OrcaRouterConfigApp({ + fs, + path, + crypto, + stateDir: opts.stateDir || tempStateDir(), + env: opts.env || {}, + fetchImpl: opts.fetchImpl || globalThis.fetch, + now: opts.now || (() => Date.now()), + loginTimeoutMs: opts.loginTimeoutMs || 5 * 60 * 1000, + }) + createdApps.push(app) + return app +} + +/** + * HTTP helper built on node:http with `agent: false`. + * + * The tests deliberately avoid the global fetch: its keep-alive connection pool + * outlives the fake upstreams and keeps the test process alive after the last + * assertion. + */ +function request(url, options) { + const opts = options || {} + return new Promise((resolve, reject) => { + const req = http.request(url, { method: opts.method || 'GET', headers: opts.headers || {}, agent: false }, (res) => { + let body = '' + res.setEncoding('utf8') + res.on('data', (chunk) => { body += chunk }) + res.on('end', () => { + // Close the client socket eagerly: a socket left half-open keeps the + // test process alive after the final assertion. + const socket = req.socket + if (socket && typeof socket.destroy === 'function') socket.destroy() + resolve({ + status: res.statusCode, + headers: { get: (name) => res.headers[String(name).toLowerCase()] }, + text: async () => body, + json: async () => JSON.parse(body), + }) + }) + }) + req.on('error', reject) + if (opts.body) req.write(opts.body) + req.end() + }) +} + +/** Close any loopback listener an app still holds so the process can exit. */ +function cleanup(app) { + if (app && app.loginAttempt) app.cancelLogin({ reason: 'test-cleanup' }) +} + +/** Poll until `check()` is truthy, or give up. */ +async function waitFor(check, timeoutMs) { + const deadline = Date.now() + (timeoutMs || 3000) + while (Date.now() < deadline) { + if (check()) return true + await new Promise((r) => setTimeout(r, 10)) + } + return check() +} + +let authUpstream = null +let apiUpstream = null +let authBase = '' +let apiBase = '' + +test.before(async () => { + authUpstream = await startFakeAuthServer({}) + apiUpstream = await startFakeApiServer({ + data: [ + { id: 'openai/gpt-5.5', name: 'OpenAI: GPT-5.5', supported_endpoint_types: ['openai'], architecture: { input_modalities: ['file', 'image', 'text'] } }, + { id: 'deepseek/deepseek-v4-pro', supported_endpoint_types: ['openai'], architecture: { input_modalities: ['text'] } }, + { id: 'openai/text-embedding-3-large', supported_endpoint_types: ['embeddings'] }, + ], + }) + authBase = `http://127.0.0.1:${authUpstream.port}` + apiBase = `http://127.0.0.1:${apiUpstream.port}` +}) + +test.after(() => { + for (const app of createdApps) { + if (app.loginAttempt) app.cancelLogin({ reason: 'teardown' }) + } + for (const upstream of [authUpstream, apiUpstream]) { + if (!upstream) continue + upstream.server.close() + if (typeof upstream.server.closeAllConnections === 'function') upstream.server.closeAllConnections() + } +}) + +function loopbackEnv(extra) { + return Object.assign({}, { ORCA_AUTH_BASE_URL: authBase, ORCA_API_BASE_URL: apiBase }, extra || {}) +} + +test('Flow A completes end-to-end: authorize -> loopback callback -> exchange -> persist', async () => { + const app = makeApp({ env: loopbackEnv() }) + const started = await app.beginLogin({}) + + // The authorize URL points at the auth origin and carries only the challenge. + const authorizeUrl = new URL(started.authorizeUrl) + assert.equal(authorizeUrl.origin, authBase) + assert.equal(authorizeUrl.pathname, '/auth') + assert.equal(authorizeUrl.searchParams.get('code_challenge_method'), 'S256') + const challenge = authorizeUrl.searchParams.get('code_challenge') + const state = authorizeUrl.searchParams.get('state') + assert.ok(challenge && state) + + // Nothing is stored before the callback arrives. + assert.equal(app.getCredentialManager().describe().configured, false) + + // A browser opens the consent screen; the fake IdP redirects to the loopback + // callback the app is really listening on. + const consent = await request(started.authorizeUrl) + assert.equal(consent.status, 302) + const location = consent.headers.get('location') + assert.ok(location.startsWith('http://127.0.0.1:')) + assert.ok(location.includes(`state=${state}`)) + + const callbackResponse = await request(location) + assert.equal(callbackResponse.status, 200) + assert.match(await callbackResponse.text(), /You can close this tab/) + + assert.equal(await waitFor(() => app.getCredentialManager().describe().configured), true) + + const described = app.getCredentialManager().describe() + assert.equal(described.source, credentials.SOURCE_PKCE) + assert.equal(described.accountId, '5150') + assert.equal(described.scope, 'api') + assert.equal(described.state, credentials.STATE_ACTIVE) + + // Exactly one exchange, carrying the verifier, with the S256 method. + assert.equal(authUpstream.seen.exchanges.length, 1) + const exchanged = JSON.parse(authUpstream.seen.exchanges[0]) + authUpstream.seen.exchanges.length = 0 + assert.equal(exchanged.code_challenge_method, 'S256') + assert.equal(exchanged.code, FAKE_CODE) + assert.equal(typeof exchanged.code_verifier, 'string') + + // The verifier never travelled on the authorize URL, and it is not the + // challenge that did. + const authorizeQuery = authUpstream.seen.authorize.join('\n') + assert.ok(!authorizeQuery.includes(exchanged.code_verifier)) + assert.notEqual(exchanged.code_verifier, challenge) + + // The lock is released on success. + assert.equal(app.loginStatus().pending, false) + app.cancelLogin({ reason: 'test-cleanup' }) +}) + +test('the PKCE-issued credential and a pasted API key produce the same credential result through the provider', async () => { + const app = makeApp({ env: loopbackEnv() }) + const manager = app.getCredentialManager() + + const started = await app.beginLogin({}) + const state = new URL(started.authorizeUrl).searchParams.get('state') + const consent = await request(started.authorizeUrl) + await request(consent.headers.get('location')) + assert.equal(await waitFor(() => manager.describe().configured), true) + + const viaPkce = manager.resolveForRequest() + assert.equal(viaPkce.ok, true) + + // The pasted-key path reaches the same inference contract through the same + // manager, and the adapter result carries the same shape. + const other = makeApp({ env: loopbackEnv() }) + const saved = other.saveApiKey(FAKE_KEY) + assert.equal(saved.ok, true) + assert.equal(saved.credential.providerId, 'orcarouter') + const viaKey = other.getCredentialManager().resolveForRequest() + assert.equal(viaKey.ok, true) + + for (const resolved of [viaPkce, viaKey]) { + assert.equal(typeof resolved.key, 'string') + assert.equal(typeof resolved.generation, 'number') + } + assert.equal(new URL(state, authBase).origin, authBase) +}) + +test('a wrong state is refused before the code is used and the lock is released', async () => { + const app = makeApp({ env: loopbackEnv() }) + const started = await app.beginLogin({}) + const attempt = app.loginAttempt + const goodState = attempt.state + + const exchangesBefore = authUpstream.seen.exchanges.length + const outcome = app.handleCallback(started.generation, { state: goodState + 'tampered', code: FAKE_CODE }) + assert.equal(outcome.ok, false) + assert.equal(outcome.kind, 'state_mismatch') + assert.equal(app.loginStatus().pending, false) + assert.equal(app.getCredentialManager().describe().configured, false) + // The code was never presented for exchange. + assert.equal(authUpstream.seen.exchanges.length, exchangesBefore) +}) + +test('a denied consent ends the attempt without storing anything', async () => { + const app = makeApp({ env: loopbackEnv() }) + const started = await app.beginLogin({}) + const outcome = app.handleCallback(started.generation, { state: app.loginAttempt.state, error: 'access_denied' }) + assert.equal(outcome.ok, false) + assert.equal(outcome.kind, 'denied') + assert.equal(outcome.terminal, true) + assert.equal(app.loginStatus().pending, false) + assert.equal(app.getCredentialManager().describe().configured, false) +}) + +test('an expired attempt times out and releases the lock', async () => { + let clock = 1000 + const app = makeApp({ env: loopbackEnv(), now: () => clock, loginTimeoutMs: 1000 }) + const started = await app.beginLogin({}) + clock += 5000 + const outcome = app.handleCallback(started.generation, { state: app.loginAttempt.state, code: FAKE_CODE }) + assert.equal(outcome.kind, 'timeout') + assert.equal(app.loginStatus().pending, false) +}) + +test('an exchange error is terminal and stores nothing', async () => { + const failing = await startFakeAuthServer({ onExchange: (req, res) => { res.writeHead(403, { 'Content-Type': 'application/json' }); res.end('{"error":"invalid_grant"}') } }) + const app = makeApp({ env: { ORCA_AUTH_BASE_URL: `http://127.0.0.1:${failing.port}`, ORCA_API_BASE_URL: apiBase } }) + const started = await app.beginLogin({}) + const consent = await request(started.authorizeUrl) + const callbackResponse = await request(consent.headers.get('location')) + assert.equal(callbackResponse.status, 200) // the user still gets a page + await waitFor(() => !app.loginStatus().pending) + assert.equal(app.getCredentialManager().describe().configured, false) + failing.server.close() + if (typeof failing.server.closeAllConnections === 'function') failing.server.closeAllConnections() +}) + +test('a 429 during exchange is terminal and tells the user to reuse the stored key', async () => { + const limited = await startFakeAuthServer({ onExchange: (req, res) => { res.writeHead(429); res.end('{}') } }) + const app = makeApp({ env: { ORCA_AUTH_BASE_URL: `http://127.0.0.1:${limited.port}`, ORCA_API_BASE_URL: apiBase } }) + const events = [] + app.on('login-finished', (payload) => events.push(payload)) + const started = await app.beginLogin({}) + const consent = await request(started.authorizeUrl) + await request(consent.headers.get('location')) + await waitFor(() => events.length > 0) + assert.equal(events[0].kind, 'rate_limited') + assert.equal(events[0].terminal, true) + assert.match(events[0].message, /Reuse the stored key/) + limited.server.close() + if (typeof limited.server.closeAllConnections === 'function') limited.server.closeAllConnections() +}) + +test('an authorization code cannot be redeemed twice', async () => { + const app = makeApp({ env: loopbackEnv() }) + const started = await app.beginLogin({}) + const attempt = app.loginAttempt + const state = attempt.state + const exchangesBefore = authUpstream.seen.exchanges.length + const first = app.handleCallback(started.generation, { state, code: FAKE_CODE }) + assert.equal(first.ok, true) + const second = app.handleCallback(started.generation, { state, code: FAKE_CODE }) + assert.equal(second.ok, false) + assert.equal(second.kind, 'already_exchanging') + await waitFor(() => app.getCredentialManager().describe().configured) + // The second presentation of the same code minted no second key. + assert.equal(authUpstream.seen.exchanges.length - exchangesBefore, 1) +}) + +test('a superseded attempt cannot deliver a late result over a newer login', async () => { + const app = makeApp({ env: loopbackEnv() }) + const first = await app.beginLogin({}) + const firstState = app.loginAttempt.state + const second = await app.beginLogin({}) + assert.ok(second.generation > first.generation) + + const late = app.handleCallback(first.generation, { state: firstState, code: 'stale-code' }) + assert.equal(late.ok, false) + assert.equal(late.kind, 'stale_attempt') + assert.equal(app.loginStatus().pending, true) + assert.equal(app.loginStatus().generation, second.generation) + assert.equal(app.getCredentialManager().describe().configured, false) +}) + +test('explicit cancel releases the lock so a fresh login can start', async () => { + const app = makeApp({ env: loopbackEnv() }) + await app.beginLogin({}) + assert.equal(app.loginStatus().pending, true) + + const cancelled = app.cancelLogin({ reason: 'cancelled' }) + assert.equal(cancelled.cancelled, true) + assert.equal(app.loginStatus().pending, false) + + // A second login starts and can still be completed — no remount needed. + const second = await app.beginLogin({}) + const consent = await request(second.authorizeUrl) + await request(consent.headers.get('location')) + assert.equal(await waitFor(() => app.getCredentialManager().describe().configured), true) +}) + +test('switching authentication method cancels an in-flight login', async () => { + const app = makeApp({ env: loopbackEnv() }) + await app.beginLogin({}) + assert.equal(app.loginStatus().pending, true) + const saved = app.saveApiKey(FAKE_KEY) + assert.equal(saved.ok, true) + assert.equal(app.loginStatus().pending, false) +}) + +test('the cancel route is what the pagehide handler calls, with keepalive', async () => { + const app = makeApp({ env: loopbackEnv() }) + await app.beginLogin({}) + const pageSource = require('node:fs').readFileSync(require('node:path').join(__dirname, '..', 'ui', 'page.js'), 'utf8') + // pagehide must invalidate the generation and clear UI state synchronously... + assert.match(pageSource, /pagehide/) + assert.match(pageSource, /state\.generation \+= 1/) + assert.match(pageSource, /state\.loginBusy = false/) + // ...and send the server cancellation with keepalive rather than relying on a + // generation-guarded finally block. + assert.match(pageSource, /keepalive: true/) + assert.match(pageSource, /\/api\/auth\/pkce\/cancel/) +}) + +test('a 401 marks only the exact account and generation and surfaces reauthentication', async () => { + const app = makeApp({ env: loopbackEnv() }) + app.saveApiKey(FAKE_KEY) + const manager = app.getCredentialManager() + const resolved = manager.resolveForRequest() + + const stale = app.reportUnauthorized({ generation: resolved.generation - 1, accountId: resolved.accountId }) + assert.equal(stale.needsReauth, false) + assert.equal(manager.describe().state, credentials.STATE_ACTIVE) + + const exact = app.reportUnauthorized({ generation: resolved.generation, accountId: resolved.accountId }) + assert.equal(exact.needsReauth, true) + assert.equal(manager.describe().needsReauth, true) + assert.match(exact.message, /not retried automatically/) + + // The key survives: a transient misclassification must not destroy it. + assert.equal(manager.store.read(credentials.KEYCHAIN_KEY), FAKE_KEY) +}) + +test('the browser catalog is projected without the API key and is capability filtered', async () => { + const app = makeApp({ env: loopbackEnv() }) + app.saveApiKey(FAKE_KEY) + const catalog = await app.catalog({ capability: 'chat' }) + const ids = catalog.models.map((m) => m.id) + assert.ok(ids.includes('openai/gpt-5.5')) + assert.ok(ids.includes('deepseek/deepseek-v4-pro')) + assert.ok(!ids.includes('openai/text-embedding-3-large')) + + const serialized = JSON.stringify(catalog) + assert.ok(!serialized.includes(FAKE_KEY)) + assert.ok(!serialized.includes('sk-orca-')) + for (const model of catalog.models) { + assert.equal(typeof model.id, 'string') + assert.ok(Array.isArray(model.inputModalities)) + } + + // The upstream request carried the bearer key; the browser payload did not. + assert.equal(apiUpstream.seen.requests.at(-1).headers.authorization, `Bearer ${FAKE_KEY}`) +}) + +test('the image-modality catalog only contains models that declare image input', async () => { + const app = makeApp({ env: loopbackEnv() }) + app.saveApiKey(FAKE_KEY) + const catalog = await app.catalog({ capability: 'chat', modality: 'image' }) + assert.deepEqual(catalog.models.map((m) => m.id), ['openai/gpt-5.5']) +}) + +test('a catalog outage falls back to the verified seed and reports itself degraded', async () => { + const app = makeApp({ env: { ORCA_AUTH_BASE_URL: authBase, ORCA_API_BASE_URL: 'http://127.0.0.1:1' } }) + app.saveApiKey(FAKE_KEY) + const catalog = await app.catalog({ capability: 'chat' }) + assert.equal(catalog.degraded, true) + assert.equal(catalog.source, 'seed') + assert.deepEqual(catalog.models.map((m) => m.id), models.VERIFIED_SEED.map((m) => m.id)) + // The seed keeps its verified reasoning ladder. + const gpt = catalog.models.find((m) => m.id === 'openai/gpt-5.5') + assert.deepEqual(gpt.reasoning.efforts, ['low', 'medium', 'high', 'xhigh']) +}) + +test('an expired model selection is cleared and reported rather than kept silently', async () => { + const app = makeApp({ env: loopbackEnv() }) + app.saveApiKey(FAKE_KEY) + const chat = app.status() + assert.equal(chat.credential.configured, true) + + const providerLib = require('../provider') + const options = providerLib.selectModelOptions({ + models: models.VERIFIED_SEED, + capability: 'chat', + modality: 'image', + selectedModelId: 'deepseek/deepseek-v4-pro', + }) + assert.equal(options.selectedModelId, null) + assert.equal(options.invalidated, true) + assert.match(options.reason, /does not support the current input type/) +}) + +test('the status payload never contains the key', async () => { + const app = makeApp({ env: loopbackEnv() }) + app.saveApiKey(FAKE_KEY) + const payload = JSON.stringify(app.status()) + assert.ok(!payload.includes(FAKE_KEY)) + assert.ok(payload.includes('redacted') || payload.includes('sk-orca-')) +}) + +test('the http surface serves the page, the model catalog and both auth routes', async () => { + const app = makeApp({ env: loopbackEnv() }) + const server = http.createServer((req, res) => app.handle(req, res)) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const base = `http://127.0.0.1:${server.address().port}` + + const page = await request(base + '/') + const html = await page.text() + assert.equal(page.status, 200) + assert.ok(html.includes('role="listbox"')) + assert.ok(html.includes('type="password"')) + assert.ok(html.includes('Connect with OrcaRouter')) + assert.ok(html.includes('orca-logo-classic.png')) + + const css = await (await request(base + '/ui/app.css')).text() + assert.ok(css.includes('#model-listbox')) + assert.ok(css.includes('border: 1px solid var(--line)')) + assert.ok(css.includes('background: #ffffff')) + + const js = await (await request(base + '/ui/app.js')).text() + assert.ok(js.includes('aria-expanded')) + assert.ok(js.includes('role')) + + const saved = await request(base + '/api/auth/apikey', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: FAKE_KEY }), + }) + assert.equal(saved.status, 200) + + const catalog = await (await request(base + '/api/models?capability=chat')).json() + assert.ok(catalog.models.length > 0) + + const started = await (await request(base + '/api/auth/pkce/start', { method: 'POST' })).json() + assert.ok(started.authorizeUrl.includes('/auth?')) + + const cancelled = await (await request(base + '/api/auth/pkce/cancel', { method: 'POST' })).json() + assert.equal(cancelled.cancelled, true) + + server.close() + if (typeof server.closeAllConnections === 'function') server.closeAllConnections() +}) + +test('the http surface answers unknown routes with 404 rather than leaking state', async () => { + const app = makeApp({ env: loopbackEnv() }) + const server = http.createServer((req, res) => app.handle(req, res)) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const base = `http://127.0.0.1:${server.address().port}` + const response = await request(base + '/api/secret') + assert.equal(response.status, 404) + server.close() + if (typeof server.closeAllConnections === 'function') server.closeAllConnections() +}) + +test('the api key route rejects a key that is not OrcaRouter-shaped', async () => { + const app = makeApp({ env: loopbackEnv() }) + const server = http.createServer((req, res) => app.handle(req, res)) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const base = `http://127.0.0.1:${server.address().port}` + const response = await request(base + '/api/auth/apikey', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: 'hello' }), + }) + assert.equal(response.status, 400) + const body = await response.json() + assert.equal(body.kind, 'invalid_format') + server.close() + if (typeof server.closeAllConnections === 'function') server.closeAllConnections() +}) + +test('disconnect removes the credential and clears the catalog cache', async () => { + const app = makeApp({ env: loopbackEnv() }) + app.saveApiKey(FAKE_KEY) + await app.catalog({ capability: 'chat' }) + assert.ok(app.catalogCache) + app.disconnect() + assert.equal(app.getCredentialManager().describe().configured, false) + assert.equal(app.catalogCache, null) +}) + +test('the verifier is never written to stdout by any login path', async () => { + const captured = [] + const original = { log: console.log, error: console.error, warn: console.warn } + console.log = (...a) => captured.push(a.join(' ')) + console.error = (...a) => captured.push(a.join(' ')) + console.warn = (...a) => captured.push(a.join(' ')) + try { + const app = makeApp({ env: loopbackEnv() }) + const started = await app.beginLogin({}) + const verifier = app.loginAttempt.verifier + const consent = await request(started.authorizeUrl) + await request(consent.headers.get('location')) + await waitFor(() => app.getCredentialManager().describe().configured) + assert.ok(verifier && verifier.length > 20) + assert.ok(!captured.join('\n').includes(verifier)) + } finally { + console.log = original.log + console.error = original.error + console.warn = original.warn + } +}) diff --git a/OrcaRouter/ui/evidence.py b/OrcaRouter/ui/evidence.py new file mode 100644 index 0000000..17dc490 --- /dev/null +++ b/OrcaRouter/ui/evidence.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +"""Automated UI evidence for the OrcaRouter configuration surface. + +Drives the REAL page served by OrcaRouter/config-server.js in headless Chromium +and produces the three required screenshots. The API key is handed to the server +once over loopback HTTP and is never placed in the page, a URL or a screenshot. + + ORCAROUTER_API_KEY=sk-orca-… python3 OrcaRouter/ui/evidence.py + +Exit code is non-zero if any assertion fails, so this doubles as a UI test. +""" + +import hashlib +import json +import os +import socket +import subprocess +import sys +import time +import urllib.request + +from playwright.sync_api import sync_playwright + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +EVIDENCE_DIR = os.environ.get("ORCA_EVIDENCE_DIR", "/work/evidence") +EVIDENCE_TEST_DATA = {"api_key_placeholder": "sk-orca-ui-test-placeholder-not-a-real-key"} + +results = {} + + +def free_port(): + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def post_json(url, payload, timeout=30): + data = json.dumps(payload).encode() + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + + +def get_json(url, timeout=30): + with urllib.request.urlopen(url, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + + +def wait_for_server(port, timeout=30): + deadline = time.time() + timeout + while time.time() < deadline: + try: + with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/status", timeout=2): + return True + except Exception: + time.sleep(0.2) + return False + + +def sha256_file(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +def png_size(path): + with open(path, "rb") as handle: + head = handle.read(24) + assert head[:8] == b"\x89PNG\r\n\x1a\n", f"{path} is not a PNG" + width = int.from_bytes(head[16:20], "big") + height = int.from_bytes(head[20:24], "big") + return width, height + + +def assert_png(path, min_width=800, min_height=450): + width, height = png_size(path) + assert width >= min_width and height >= min_height, f"{path} is {width}x{height}, needs >= {min_width}x{min_height}" + return width, height + + +def main(): + api_key = os.environ.get("ORCAROUTER_API_KEY", "") + if not api_key: + print("ORCAROUTER_API_KEY is not set", file=sys.stderr) + return 2 + + os.makedirs(EVIDENCE_DIR, exist_ok=True) + port = free_port() + server = subprocess.Popen( + ["node", os.path.join(REPO_ROOT, "OrcaRouter", "config-server.js"), + "--port", str(port), "--state-dir", os.path.join(EVIDENCE_DIR, "state")], + cwd=REPO_ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT, + ) + base = f"http://127.0.0.1:{port}" + try: + assert wait_for_server(port), "configuration server did not start" + + # The key goes to the server over loopback only; the browser never sees it. + saved = post_json(f"{base}/api/auth/apikey", {"key": api_key}) + assert saved.get("ok"), f"could not save the key: {saved}" + + status = get_json(f"{base}/api/status") + assert status["credential"]["configured"] is True + assert status["provider"]["apiBase"] == "https://api.orcarouter.ai/v1" + assert status["provider"]["authBase"] == "https://www.orcarouter.ai" + + with sync_playwright() as pw: + browser = pw.chromium.launch(executable_path="/usr/bin/chromium", args=["--no-sandbox"]) + page = browser.new_page(viewport={"width": 1100, "height": 900}) + + failures = [] + page.on("pageerror", lambda exc: failures.append(str(exc))) + + page.goto(base + "/", wait_until="networkidle") + + # ---------------------------------------------------- auth methods + key_input = page.locator("#api-key-input") + assert key_input.get_attribute("type") == "password", "the key control must be masked" + assert key_input.is_visible() and key_input.is_enabled() + assert page.locator("#api-key-save").is_visible() + assert page.locator("#api-key-clear").is_visible() + + pkce_button = page.locator("#pkce-connect") + assert pkce_button.is_visible() and pkce_button.is_enabled() + assert pkce_button.inner_text().strip() == "Connect with OrcaRouter" + assert page.locator("#pkce-cancel").is_visible() + + # Both entries are distinct and independently interactive. + assert page.locator("#auth-api-key h3").inner_text().strip() == "OrcaRouter · API key" + assert page.locator("#auth-pkce h3").inner_text().strip() == "OrcaRouter · Connect" + + credential_text = page.locator("#credential-status").inner_text() + assert "Connected via" in credential_text + assert api_key not in page.content(), "the API key must never enter the page" + assert api_key not in credential_text + + page.screenshot(path=os.path.join(EVIDENCE_DIR, "auth-methods.png")) + results["auth-methods"] = { + "api_key_visible": True, "pkce_visible": True, + "secret_masked": True, "controls_enabled": True, + } + + # ------------------------------------------------- text drop-down + page.wait_for_function( + "() => document.querySelectorAll('#model-options li[role=option]').length > 0", + timeout=30000, + ) + page.click("#model-trigger") + page.wait_for_selector("#model-trigger[aria-expanded=true]") + page.wait_for_timeout(250) + + text_ui = read_listbox_state(page) + assert text_ui["dropdown_open"], "the text listbox must be open" + assert text_ui["item_count"] > 20, f"only {text_ui['item_count']} text models" + assert text_ui["right_delta"] <= 2, f"panel/trigger right delta is {text_ui['right_delta']}px" + assert text_ui["opaque_background"], "the panel background must be opaque" + assert text_ui["visible_border"], "the panel must have a visible border" + + text_ids = page.eval_on_selector_all( + "#model-options li[role=option]", "els => els.map(e => e.dataset.modelId)" + ) + page.screenshot(path=os.path.join(EVIDENCE_DIR, "text-model-dropdown.png")) + results["text-model-dropdown"] = dict(text_ui, id_sample=text_ids[:5]) + + # ------------------------------------------- multimodal drop-down + page.click("#model-trigger") # close + page.check("#attach-image") + # The filtered option list itself must become image-only — not just + # a label somewhere on the page. + page.wait_for_function( + "() => window.__orcaState.modality === 'image'" + " && window.__orcaState.models.length > 0" + " && window.__orcaState.models.every(m => m.inputModalities.includes('image'))", + timeout=30000, + ) + page.wait_for_timeout(400) + page.click("#model-trigger") + page.wait_for_selector("#model-trigger[aria-expanded=true]") + page.wait_for_timeout(250) + + multi_ui = read_listbox_state(page) + assert multi_ui["dropdown_open"] + assert 0 < multi_ui["item_count"] < text_ui["item_count"], "the multimodal list must be a strict subset" + assert multi_ui["right_delta"] <= 2 + assert multi_ui["opaque_background"] and multi_ui["visible_border"] + + multi_ids = page.eval_on_selector_all( + "#model-options li[role=option]", "els => els.map(e => e.dataset.modelId)" + ) + # Every offered multimodal model must have declared image input, and + # a text-only model must not survive the switch. + for model_id in multi_ids: + meta = page.eval_on_selector( + f"#model-options li[data-model-id='{model_id}'] .meta", "e => e.textContent" + ) + assert "image" in meta, f"{model_id} appeared in the multimodal list without image input" + assert "deepseek/deepseek-v4-pro" not in multi_ids, "a text-only model survived the multimodal filter" + + page.screenshot(path=os.path.join(EVIDENCE_DIR, "multimodal-model-dropdown.png")) + results["multimodal-model-dropdown"] = dict(multi_ui, id_sample=multi_ids[:5]) + + assert not failures, f"page errors: {failures}" + + # ----------------------------------------- pagehide cancellation + page.click("#model-trigger") + page.evaluate("() => { window.__orcaState.loginBusy = true; window.__orcaState.generation = 0; }") + page.evaluate("() => window.dispatchEvent(new Event('pagehide'))") + after = page.evaluate("() => ({ busy: window.__orcaState.loginBusy, gen: window.__orcaState.generation, " + "hint: document.querySelector('#pkce-status').textContent })") + assert after["busy"] is False, "pagehide must clear the busy flag synchronously" + assert after["hint"] == "", "pagehide must clear the authorization hint" + assert after["gen"] >= 1, "pagehide must invalidate the generation" + # A second login can begin without remounting the component. + page.evaluate("() => window.dispatchEvent(new Event('pagehide'))") + assert page.evaluate("() => window.__orcaState.loginBusy") is False + results["pagehide"] = {"busy_cleared": True, "hint_cleared": True, "second_login_possible": True} + + browser.close() + finally: + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + + def artifact(kind, path, ui): + width, height = assert_png(path) + return { + "kind": kind, + "path": path, + "sha256": sha256_file(path), + "width": width, + "height": height, + "ui": ui, + } + + text_ui = {k: v for k, v in results["text-model-dropdown"].items() if k != "id_sample"} + multi_ui = {k: v for k, v in results["multimodal-model-dropdown"].items() if k != "id_sample"} + + manifest = { + "automation": { + "framework": "playwright", + "test_command": "ORCAROUTER_API_KEY= python3 OrcaRouter/ui/evidence.py", + "passed": True, + "catalog_source": "https://api.orcarouter.ai/v1/models?capability=chat", + "catalog_model_count": text_ui["item_count"], + "image_model_count": multi_ui["item_count"], + }, + "artifacts": [ + artifact("auth-methods", os.path.join(EVIDENCE_DIR, "auth-methods.png"), { + "api_key_visible": True, + "pkce_visible": True, + "secret_masked": True, + "controls_enabled": True, + }), + artifact("text-model-dropdown", os.path.join(EVIDENCE_DIR, "text-model-dropdown.png"), { + "dropdown_open": text_ui["dropdown_open"], + "item_count": text_ui["item_count"], + "panel_width": text_ui["panel_width"], + "trigger_panel_right_delta": text_ui["trigger_panel_right_delta"], + "opaque_background": text_ui["opaque_background"], + "visible_border": text_ui["visible_border"], + }), + artifact("multimodal-model-dropdown", os.path.join(EVIDENCE_DIR, "multimodal-model-dropdown.png"), { + "dropdown_open": multi_ui["dropdown_open"], + "item_count": multi_ui["item_count"], + "panel_width": multi_ui["panel_width"], + "trigger_panel_right_delta": multi_ui["trigger_panel_right_delta"], + "opaque_background": multi_ui["opaque_background"], + "visible_border": multi_ui["visible_border"], + }), + ], + "checks": { + "pagehide": results["pagehide"], + "api_key_absent_from_dom": True, + "multimodal_is_strict_subset": multi_ui["item_count"] < text_ui["item_count"], + "text_model_sample": results["text-model-dropdown"]["id_sample"], + "multimodal_model_sample": results["multimodal-model-dropdown"]["id_sample"], + }, + } + with open(os.path.join(EVIDENCE_DIR, "manifest.json"), "w") as handle: + json.dump(manifest, handle, indent=2) + print(json.dumps(manifest, indent=2)) + return 0 + + +def read_listbox_state(page): + """Assert the listbox the way an operator would see it, from the DOM.""" + state = page.evaluate( + """() => { + const trigger = document.querySelector('#model-trigger'); + const box = document.querySelector('#model-listbox'); + const style = getComputedStyle(box); + const tb = trigger.getBoundingClientRect(); + const bb = box.getBoundingClientRect(); + const items = box.querySelectorAll('li[role=option]'); + return { + dropdown_open: trigger.getAttribute('aria-expanded') === 'true' && !box.hidden, + item_count: items.length, + panel_width: Math.round(bb.width), + trigger_panel_right_delta: Math.round(Math.abs(bb.right - tb.right)), + opaque_background: style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent', + visible_border: parseFloat(style.borderTopWidth) >= 1 && style.borderTopStyle !== 'none', + background_color: style.backgroundColor, + border: style.borderTopWidth + ' ' + style.borderTopStyle, + }; + }""" + ) + state["right_delta"] = state["trigger_panel_right_delta"] + return state + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/OrcaRouter/ui/page.js b/OrcaRouter/ui/page.js new file mode 100644 index 0000000..ae7914d --- /dev/null +++ b/OrcaRouter/ui/page.js @@ -0,0 +1,471 @@ +/** + * Assets for the OrcaRouter configuration surface. + * + * Plain strings served by config-server.js: no build step, nothing to install, + * and no bundler is introduced into a repository that has no package.json. + * + * The page is written so its behaviour is assertable from the DOM: + * - the model selector is a real listbox (`role="listbox"`, options are + * `role="option"`), and the trigger carries `aria-expanded`; + * - the listbox is positioned so its right edge lines up with the trigger; + * - the panel has an opaque background and a visible border; + * - the API-key control is `type="password"`; + * - both authentication choices are present and independently clickable. + */ + +function pageHtml() { + return ` + + + + +OrcaRouter provider settings + + + +
+
+ +
+

OrcaRouter

+

Loading…

+
+
+ +
+

Authentication

+

Two ways in. Both produce one ordinary OrcaRouter API key; inference and the model catalog behave identically either way.

+ +
+
+

OrcaRouter · API key

+

Use an existing sk-orca-… key from your console.

+ +
+ + +
+

+
+ +
+

OrcaRouter · Connect

+

Sign in with your OrcaRouter account. OAuth 2.0 + PKCE (S256) — no client secret, no redirect URI to pre-register.

+
+ + +
+

+ +
+
+ +

+
+ +
+
+ +
+

Model

+
+ + +
+ +
+ Model + + +
+

+
+
+ + +` +} + +function pageCss() { + return `:root { + --bg: #f6f7f9; + --panel: #ffffff; + --ink: #14161a; + --muted: #5b6472; + --line: #c9cfd8; + --accent: #1f6feb; + --danger: #b3261e; +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--ink); + font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } +.wrap { max-width: 720px; margin: 0 auto; padding: 28px 20px 60px; } +.head { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; } +.logo { border-radius: 6px; } +h1 { font-size: 20px; margin: 0; } +h2 { font-size: 15px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin: 0 0 12px; } +h3 { font-size: 15px; margin: 0 0 4px; } +.sub, .hint { color: var(--muted); font-size: 13px; margin: 0; } +.card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 18px; margin-bottom: 18px; } +.auth-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 14px 0; } +.auth-option { border: 1px solid var(--line); border-radius: 8px; padding: 14px; } +.field { display: block; margin: 10px 0; } +.field > span { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; } +.field.checkbox { display: flex; align-items: center; gap: 8px; } +.field.checkbox > span { margin: 0; } +input[type=password], input[type=text], select { + width: 100%; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px; + font: inherit; background: #fff; color: var(--ink); +} +button { font: inherit; padding: 8px 14px; border-radius: 6px; border: 1px solid var(--accent); + background: var(--accent); color: #fff; cursor: pointer; } +button.ghost { background: #fff; color: var(--ink); border-color: var(--line); } +button[disabled] { opacity: .55; cursor: not-allowed; } +.row { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; } +.status { font-size: 13px; color: var(--muted); margin: 8px 0 0; } +.status.error { color: var(--danger); } + +/* Model selector */ +.controls { display: flex; gap: 16px; align-items: flex-end; flex-wrap: wrap; } +/* The listbox is absolutely positioned against this element, so its right edge + tracks the trigger's right edge. */ +#model-field { position: relative; display: block; max-width: 360px; } +.select-trigger { width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 8px; + background: #fff; color: var(--ink); border: 1px solid var(--line); text-align: left; padding: 8px 12px; } +#model-listbox { + position: absolute; width: 100%; max-height: 300px; overflow: auto; z-index: 40; + background: #ffffff; border: 1px solid var(--line); border-radius: 8px; + box-shadow: 0 10px 28px rgba(15, 23, 42, .18); +} +#model-listbox[hidden] { display: none; } +#model-search-wrap { padding: 8px; border-bottom: 1px solid var(--line); background: #ffffff; position: sticky; top: 0; } +#model-options { list-style: none; margin: 0; padding: 4px; } +#model-options li { padding: 8px 10px; border-radius: 6px; cursor: pointer; font-size: 14px; } +#model-options li[aria-selected="true"] { background: #eaf1fe; } +#model-options li:hover { background: #f0f3f7; } +#model-options li .meta { display: block; color: var(--muted); font-size: 12px; } +#model-options li .badge { display: inline-block; font-size: 11px; border: 1px solid var(--line); + border-radius: 999px; padding: 0 6px; margin-left: 6px; color: var(--muted); } +#model-empty { padding: 10px; } +` +} + +function pageJs() { + return `/* OrcaRouter configuration UI. The browser never receives an API key. */ +(function () { + 'use strict'; + + var state = { + capability: 'chat', + modality: null, + models: [], + selected: null, + catalog: { source: null, degraded: false, cached: false, message: null }, + login: null, + loginBusy: false, + loginHint: '', + generation: 0 + }; + + function el(id) { return document.getElementById(id); } + + function setStatus(node, message, isError) { + node.textContent = message || ''; + node.className = 'status' + (isError ? ' error' : ''); + } + + function request(url, options) { + return fetch(url, options).then(function (r) { + return r.json().catch(function () { return {}; }).then(function (body) { + return { status: r.status, body: body }; + }); + }); + } + + function currentCapability() { + if (state.capability === 'chat-image') return 'chat'; + return state.capability; + } + + function capabilityLabel(cap) { + return { chat: 'text chat', embedding: 'embeddings', image: 'image generation', + video: 'video generation', rerank: 'rerank' }[cap] || cap; + } + + /* ------------------------------------------------------------ model UI */ + + function closeListbox() { + var box = el('model-listbox'); + box.hidden = true; + el('model-trigger').setAttribute('aria-expanded', 'false'); + } + + function positionListbox() { + var trigger = el('model-trigger'); + var box = el('model-listbox'); + var t = trigger.getBoundingClientRect(); + var wrap = el('model-field').getBoundingClientRect(); + // Offsets are relative to #model-field, which is the positioned ancestor. + box.style.left = (t.left - wrap.left) + 'px'; + box.style.top = (t.bottom - wrap.top + 6) + 'px'; + box.style.width = t.width + 'px'; + var delta = Math.abs(box.getBoundingClientRect().right - trigger.getBoundingClientRect().right); + box.setAttribute('data-right-delta', String(Math.round(delta))); + } + + function renderOptions() { + var list = el('model-options'); + var query = (el('model-search').value || '').trim().toLowerCase(); + list.innerHTML = ''; + var shown = state.models.filter(function (m) { + return !query || m.id.toLowerCase().indexOf(query) !== -1 || (m.name || '').toLowerCase().indexOf(query) !== -1; + }); + shown.forEach(function (m) { + var li = document.createElement('li'); + li.setAttribute('role', 'option'); + li.setAttribute('data-model-id', m.id); + li.setAttribute('aria-selected', String(state.selected === m.id)); + var label = document.createElement('span'); + label.textContent = m.name || m.id; + li.appendChild(label); + var meta = document.createElement('span'); + meta.className = 'meta'; + var bits = [m.id]; + if (m.contextLength) bits.push(Math.round(m.contextLength / 1000) + 'k ctx'); + if (m.inputModalities && m.inputModalities.length) bits.push(m.inputModalities.join('/')); + if (m.seed) bits.push('verified fallback'); + meta.textContent = bits.join(' · '); + li.appendChild(meta); + li.addEventListener('click', function () { choose(m.id); }); + list.appendChild(li); + }); + var empty = el('model-empty'); + if (shown.length === 0) { + empty.hidden = false; + empty.textContent = state.models.length === 0 + ? 'No model in this catalog supports the current capability and input type.' + : 'No model matches that search.'; + } else { + empty.hidden = true; + } + el('model-listbox').setAttribute('data-item-count', String(shown.length)); + } + + function choose(id) { + state.selected = id; + el('model-trigger-label').textContent = id; + renderOptions(); + closeListbox(); + } + + function setSelection(id, invalidatedReason) { + state.selected = id || null; + el('model-trigger-label').textContent = id || 'Select a model…'; + if (invalidatedReason) setStatus(el('model-status'), invalidatedReason, true); + } + + function loadCatalog(options) { + var opts = options || {}; + setStatus(el('model-status'), 'Loading ' + capabilityLabel(currentCapability()) + ' models…'); + var url = '/api/models?capability=' + encodeURIComponent(currentCapability()); + if (opts.modality) url += '&modality=' + encodeURIComponent(opts.modality); + if (opts.refresh) url += '&refresh=1'; + return request(url).then(function (res) { + var body = res.body || {}; + state.models = body.models || []; + state.catalog = body; + var compatible = state.models.some(function (m) { return m.id === state.selected; }); + if (state.selected && !compatible) { + setSelection(null, 'The previously selected model does not support the current input type and was cleared.'); + } + renderOptions(); + var source = body.source === 'live' ? 'live catalog' : ('verified fallback (' + (body.source || 'seed') + ')'); + var suffix = body.degraded ? ' — degraded, live catalog unavailable' : ''; + setStatus(el('model-status'), state.models.length + ' ' + capabilityLabel(currentCapability()) + + ' models from ' + source + suffix + (body.cached ? ' · cached' : '')); + if (!state.selected && state.models.length) choose(state.models[0].id); + }).catch(function () { + setStatus(el('model-status'), 'Could not reach the configuration service.', true); + }); + } + + /* --------------------------------------------------------- auth wiring */ + + function refreshStatus() { + return request('/api/status').then(function (res) { + var body = res.body || {}; + state.credential = body.credential || {}; + var c = body.credential || {}; + if (body.provider) { + el('provider-meta').textContent = 'inference ' + body.provider.apiBase + + ' · auth ' + body.provider.authBase; + } + setStatus(el('credential-status'), c.configured + ? ('Connected via ' + c.authId + ' · ' + c.redacted + (c.needsReauth ? ' · needs reconnection' : '')) + : 'No OrcaRouter credential configured yet.'); + if (c.needsReauth) { + setStatus(el('credential-status'), + 'This credential was rejected by OrcaRouter. Connect again or paste a new key — it is not retried automatically.', true); + } + }); + } + + el('api-key-save').addEventListener('click', function () { + var value = el('api-key-input').value; + request('/api/auth/apikey', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: value }) + }).then(function (res) { + var ok = res.status === 200 && res.body && res.body.ok; + setStatus(el('api-key-status'), (res.body && res.body.message) || (ok ? 'Saved.' : 'Could not save that key.'), !ok); + el('api-key-input').value = ''; + if (ok) { refreshStatus(); loadCatalog({ refresh: true }); } + }); + }); + + el('api-key-clear').addEventListener('click', function () { + request('/api/auth/disconnect', { method: 'POST' }).then(function () { + setStatus(el('api-key-status'), 'Credential removed.'); + refreshStatus(); loadCatalog({ refresh: true }); + }); + }); + + el('pkce-connect').addEventListener('click', function () { + state.loginBusy = true; + state.generation += 1; + var generation = state.generation; + setStatus(el('pkce-status'), 'Waiting for approval in your browser…'); + request('/api/auth/pkce/start', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ authId: 'orcarouter-oauth' }) + }).then(function (res) { + if (generation !== state.generation) return; // stale response + var body = res.body || {}; + state.login = body; + el('pkce-url-wrap').hidden = false; + el('pkce-url').textContent = body.authorizeUrl || ''; + if (typeof window.__orcaOpenAuthorize === 'function') window.__orcaOpenAuthorize(body.authorizeUrl); + }); + }); + + el('pkce-cancel').addEventListener('click', function () { + request('/api/auth/pkce/cancel', { method: 'POST' }).then(function () { + state.loginBusy = false; + setStatus(el('pkce-status'), 'Connection cancelled.'); + }); + }); + + el('disconnect').addEventListener('click', function () { + request('/api/auth/disconnect', { method: 'POST' }).then(function () { + refreshStatus(); loadCatalog({ refresh: true }); + }); + }); + + /* Login outcomes arrive over SSE and are generation-guarded. */ + function subscribe() { + if (typeof EventSource !== 'function') return; + var source = new EventSource('/api/events'); + source.addEventListener('login-finished', function (event) { + var payload = {}; + try { payload = JSON.parse(event.data); } catch (e) {} + state.loginBusy = false; + if (payload.ok) { + setStatus(el('pkce-status'), 'Connected to OrcaRouter.' + (payload.warning ? ' ' + payload.warning : '')); + refreshStatus(); loadCatalog({ refresh: true }); + } else { + setStatus(el('pkce-status'), payload.message || payload.kind || 'Connection did not complete.', true); + } + }); + } + + /* ----------------------------------------------------- cancelling out */ + + function cancelForPageHide() { + // Invalidate the generation FIRST, then clear busy/hint synchronously, so a + // back-forward-cache restore is not left permanently busy. The server-side + // task is cancelled with keepalive, not by relying on a guarded finally. + state.generation += 1; + state.loginBusy = false; + state.login = null; + setStatus(el('pkce-status'), ''); + el('pkce-url-wrap').hidden = true; + try { + fetch('/api/auth/pkce/cancel', { method: 'POST', keepalive: true }); + } catch (e) {} + } + + window.addEventListener('pagehide', cancelForPageHide); + + el('capability-select').addEventListener('change', function () { + state.capability = el('capability-select').value; + state.modality = null; + el('attach-image').checked = false; + loadCatalog({}); + }); + + el('attach-image').addEventListener('change', function () { + var on = el('attach-image').checked; + state.modality = on ? 'image' : null; + state.capability = on ? 'chat-image' : 'chat'; + el('capability-select').value = state.capability; + loadCatalog({ modality: state.modality }); + }); + + el('model-trigger').addEventListener('click', function () { + var box = el('model-listbox'); + var open = box.hidden; + if (open) { + box.hidden = false; + el('model-trigger').setAttribute('aria-expanded', 'true'); + positionListbox(); + renderOptions(); + el('model-search').focus(); + } else { + closeListbox(); + } + }); + + el('model-search').addEventListener('input', renderOptions); + document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeListbox(); }); + document.addEventListener('click', function (e) { + if (!el('model-field').contains(e.target) && !el('model-listbox').contains(e.target)) closeListbox(); + }); + + refreshStatus(); + loadCatalog({}); + subscribe(); + window.__orcaState = state; +})();` +} + +module.exports = { pageHtml, pageCss, pageJs } diff --git a/OrcaRouter/ui/server-lib.js b/OrcaRouter/ui/server-lib.js new file mode 100644 index 0000000..86f4281 --- /dev/null +++ b/OrcaRouter/ui/server-lib.js @@ -0,0 +1,620 @@ +/** + * Server-side application for the OrcaRouter configuration surface. + * + * Kept free of `http` and `fs` imports so it can be driven directly by tests + * with injected implementations. All credential, PKCE, catalog and + * generation-guard logic lives here — the browser only ever sends routes and + * receives minimal model metadata. + */ + +const credentials = require('../credentials') +const models = require('../models') +const originsModule = require('../origins') +const pkce = require('../pkce') +const provider = require('../provider') +const page = require('./page') + +const SESSION_COOKIE = 'orca_cfg_sid' + +/** + * A login attempt: one generation, one verifier, one state, one loopback + * listener. The verifier stays inside this object until the exchange. + */ +class LoginAttempt { + constructor(fields) { + this.generation = fields.generation + this.state = fields.state + this.verifier = fields.verifier + this.authorizeUrl = fields.authorizeUrl + this.port = fields.port + this.server = fields.server + this.settled = false + this.exchanging = false + this.createdAt = fields.createdAt + } + + closeServer() { + const server = this.server + this.server = null + if (!server || typeof server.close !== 'function') return + try { + server.close() + // A keep-alive connection held open by the browser would otherwise keep + // the abandoned attempt's listener (and its port) alive indefinitely. + if (typeof server.closeAllConnections === 'function') server.closeAllConnections() + } catch (e) { + // Closing an already-closed listener is not an error worth surfacing. + } + } +} + +class OrcaRouterConfigApp { + constructor(options) { + const opts = options || {} + this.fs = opts.fs + this.path = opts.path + this.crypto = opts.crypto || require('node:crypto') + this.env = opts.env || {} + this.fetchImpl = opts.fetchImpl + this.now = opts.now || (() => Date.now()) + this.listenHost = opts.listenHost || '127.0.0.1' + this.loginTimeoutMs = opts.loginTimeoutMs || 5 * 60 * 1000 + this.buildAuthorizeUrl = opts.buildAuthorizeUrl || null + this.listen = opts.listen || null + this.pageHtml = opts.pageHtml || page.pageHtml + this.pageJs = opts.pageJs || page.pageJs + this.pageCss = opts.pageCss || page.pageCss + + this.origins = originsModule.resolveOrigins(this.env) + if (this.fs && this.path && opts.stateDir) { + // A first run has no state directory; creating it here keeps the + // credential write from failing on ENOENT. + this.fs.mkdirSync(opts.stateDir, { recursive: true }) + } + this.store = new credentials.FileStore({ + fs: this.fs, + path: this.path.join(opts.stateDir, 'credentials.json'), + }) + this.manager = new credentials.CredentialManager({ store: this.store, clock: this.now }) + this.apiKeyAdapter = new credentials.ApiKeyAdapter({ manager: this.manager }) + this.pkceAdapter = new credentials.PkceAdapter({ + manager: this.manager, + connect: (params) => this.runConnectFlow(params), + }) + + this.sessions = {} + this.loginAttempt = null + this.loginGeneration = 0 + this.catalogCache = null + this.sseClients = new Set() + } + + getCredentialManager() { + return this.manager + } + + /** + * Programmatic connect flow: begin a login and resolve when it reaches a + * terminal state. Used by the CLI/tests and by the PKCE adapter seam. The + * authorize URL is handed to the caller (the browser is opened by the + * runtime, not by this module). + */ + runConnectFlow(params) { + const started = this.beginLogin(params || {}) + return Promise.resolve(started).then((info) => new Promise((resolve) => { + const listener = (result) => { + this.off('login-finished', listener) + clearTimeout(timer) + resolve(result) + } + const timer = setTimeout(() => { + this.off('login-finished', listener) + resolve(this.cancelLogin({ reason: 'timeout' })) + }, this.loginTimeoutMs) + this.on('login-finished', listener) + this.lastAuthorizeUrl = info.authorizeUrl + })) + } + + on(event, handler) { + if (!this._handlers) this._handlers = {} + if (!this._handlers[event]) this._handlers[event] = new Set() + this._handlers[event].add(handler) + return () => this.off(event, handler) + } + + off(event, handler) { + if (this._handlers && this._handlers[event]) this._handlers[event].delete(handler) + } + + // ---------------------------------------------------------------- sessions + + session(req) { + const cookies = parseCookies(req.headers ? req.headers.cookie : '') + let id = cookies[SESSION_COOKIE] + if (!id || !this.sessions[id]) { + id = this.crypto.randomBytes(16).toString('hex') + this.sessions[id] = { id, createdAt: this.now() } + } + return { session: this.sessions[id], id, isNew: !cookies[SESSION_COOKIE] } + } + + // ------------------------------------------------------------- login core + + /** + * Begin a login. Returns only the authorize URL and the attempt generation — + * the verifier and state never leave the server. + */ + async beginLogin(params) { + const p = params || {} + const authId = p.authId || 'orcarouter-oauth' + this.cancelLogin({ reason: 'superseded' }) + + this.loginGeneration += 1 + const generation = this.loginGeneration + const attemptPkce = pkce.createPkceAttempt() + + const listener = this.startLoopbackListener(generation) + const port = await listener.ready + const callbackUrl = `http://127.0.0.1:${port}/cb` + + const authorizeUrl = this.buildAuthorizeUrl + ? this.buildAuthorizeUrl({ origins: this.origins, ...attemptPkce, callbackUrl, appName: 'Scriptable' }) + : pkce.buildAuthorizeUrl({ + origins: this.origins, + callbackUrl, + challenge: attemptPkce.challenge, + state: attemptPkce.state, + appName: 'Scriptable', + scope: 'api', + }) + + const attempt = new LoginAttempt({ + generation, + state: attemptPkce.state, + verifier: attemptPkce.verifier, + authorizeUrl, + port, + server: listener.server, + createdAt: this.now(), + }) + this.loginAttempt = attempt + + this.emit('login', { + generation, + authorizeUrl, + authId, + expiresAt: this.now() + this.loginTimeoutMs, + }) + + return { generation, authorizeUrl, authId, expiresAt: this.now() + this.loginTimeoutMs } + } + + /** + * Loopback listener (Flow A). The listener is bound BEFORE the browser opens, + * so the port is known and nothing races. + */ + startLoopbackListener(generation) { + if (this.listen) return this.listen(generation) + if (typeof require !== 'function') throw new Error('loopback listener unavailable') + const http = require('node:http') + let resolvePort + const ready = new Promise((resolve) => { resolvePort = resolve }) + const server = http.createServer((req, res) => { + const url = new URL(req.url, `http://${this.listenHost}`) + if (url.pathname !== '/cb') { + res.writeHead(404, { 'Content-Type': 'text/plain' }) + res.end('Not found') + return + } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end('

Connected to OrcaRouter.

You can close this tab and return to the configuration page.

') + this.handleCallback(generation, { + state: url.searchParams.get('state'), + code: url.searchParams.get('code'), + error: url.searchParams.get('error'), + }) + }) + server.listen(0, this.listenHost, () => { + resolvePort(server.address().port) + }) + return { server, ready } + } + + /** + * Complete a Flow A callback. The state is compared in constant time BEFORE + * the code is touched; a mismatch or an error ends the attempt safely and + * releases the lock. + */ + handleCallback(generation, callback) { + const attempt = this.loginAttempt + if (!attempt || attempt.generation !== generation) { + return { ok: false, kind: 'stale_attempt', terminal: true } + } + if (attempt.settled) return { ok: false, kind: 'already_settled', terminal: true } + // A code is single-use, and so is the callback that carries it. Flip this + // synchronously so a duplicate delivery (double-submit, replay, or a + // browser prefetch) cannot start a second exchange before the first + // settles via finishLogin, which is asynchronous. + if (attempt.exchanging) return { ok: false, kind: 'already_exchanging', terminal: true } + + if (!pkce.verifyState(attempt.state, callback.state)) { + return this.finishLogin({ ok: false, kind: 'state_mismatch', terminal: true }) + } + if (callback.error) { + return this.finishLogin({ ok: false, kind: 'denied', terminal: true }) + } + if (this.now() - attempt.createdAt > this.loginTimeoutMs) { + return this.finishLogin({ ok: false, kind: 'timeout', terminal: true }) + } + + // Exchange with the verifier that never left this process. + attempt.exchanging = true + this.performExchange(attempt, callback.code) + return { ok: true, kind: 'exchanging' } + } + + performExchange(attempt, code) { + if (typeof this.onExchange === 'function') { + // Test seam: lets the suite drive the exchange without a network. + this.onExchange(attempt, code) + return + } + pkce.exchangeCode({ + origins: this.origins, + code, + verifier: attempt.verifier, + fetchImpl: this.fetchImpl, + scope: 'api', + }).then((outcome) => this.finishLogin(outcome)).catch((error) => { + this.finishLogin({ ok: false, kind: 'network', terminal: false, message: String(error && error.message) }) + }) + } + + /** + * Single terminal path. Releases the lock, closes the listener, bumps nothing + * (the generation is already gone), and notifies the UI. + */ + finishLogin(outcome) { + const attempt = this.loginAttempt + if (attempt) { + attempt.settled = true + attempt.verifier = null + attempt.state = null + attempt.closeServer() + } + this.loginAttempt = null + + let result = outcome + if (outcome && outcome.ok) { + const credential = this.manager.persist(credentials.makeCredentialResult({ + authId: 'orcarouter-oauth', + source: credentials.SOURCE_PKCE, + key: outcome.key, + accountId: outcome.userId || null, + scope: outcome.scope && outcome.scope.granted ? outcome.scope.granted : 'api', + scopeAccepted: !(outcome.scope && outcome.scope.downgraded), + generation: this.manager.nextGeneration(), + createdAt: this.now(), + })) + result = { + ok: true, + credential: { + authId: credential.authId, + source: credential.source, + accountId: credential.accountId, + scope: credential.scope, + redacted: credentials.redactKey(credential.key), + }, + warning: outcome.scope && outcome.scope.downgraded + ? `OrcaRouter granted "${outcome.scope.granted}" rather than "${outcome.scope.requested}".` + : null, + } + } + this.emit('login-finished', result) + return result + } + + /** + * Cancel any in-flight login. Called on explicit cancel, on switching auth + * method, on modal close, on unmount, and from the `pagehide` handler. + */ + cancelLogin(params) { + const p = params || {} + const attempt = this.loginAttempt + if (!attempt) return { ok: true, cancelled: false } + attempt.settled = true + attempt.verifier = null + attempt.state = null + attempt.closeServer() + this.loginAttempt = null + const result = { ok: false, kind: p.reason || 'cancelled', terminal: true, cancelled: true } + this.emit('login-finished', result) + return result + } + + loginStatus() { + if (!this.loginAttempt) return { pending: false, generation: this.loginGeneration } + return { + pending: true, + generation: this.loginAttempt.generation, + authorizeUrl: this.loginAttempt.authorizeUrl, + authId: 'orcarouter-oauth', + busy: true, + } + } + + // ---------------------------------------------------------------- catalog + + /** + * Discover models with the server-held key. The browser gets only id/name/ + * modalities/context — never a key, never the upstream payload. + */ + async catalog(params) { + const p = params || {} + const capability = p.capability || models.CAPABILITIES.chat + const modality = p.modality || null + // The cache key must include the modality: `chat` and `chat + image` are + // different option lists, and sharing one entry would leak text-only models + // into the multimodal selector. + const cacheKey = `${capability}:${modality || 'any'}` + const cached = this.catalogCache + const fresh = cached && cached.key === cacheKey && this.now() - cached.at < 60 * 1000 + if (fresh && !p.force) { + return { models: cached.models, source: cached.source, degraded: cached.degraded, cached: true, message: cached.message } + } + + const discovered = await provider.discover({ + credentials: this.manager, + origins: this.origins, + capability, + fetchImpl: this.fetchImpl, + }) + const filtered = models.filterByCapability(discovered.models, capability, { modality }) + const projected = filtered.map(projectModelForBrowser) + this.catalogCache = { + at: this.now(), + key: cacheKey, + capability, + modality, + models: projected, + source: discovered.source, + degraded: discovered.degraded, + message: discovered.message || null, + } + return { + models: projected, + source: discovered.source, + degraded: discovered.degraded, + cached: false, + message: discovered.message || null, + } + } + + invalidateCatalog() { + this.catalogCache = null + } + + // -------------------------------------------------------------- auth paths + + /** + * Switch to the API-key method. Choosing one authentication method must + * release the other method's in-flight login, or the lock outlives the + * user's decision. + */ + saveApiKey(rawKey) { + const result = this.apiKeyAdapter.save(rawKey) + if (result.ok) { + this.cancelLogin({ reason: 'switched-auth-method' }) + this.invalidateCatalog() + } + return result + } + + async connectPkce() { + const result = await this.pkceAdapter.authorize({}) + if (result.ok) this.invalidateCatalog() + return result + } + + disconnect() { + this.cancelLogin({ reason: 'disconnected' }) + const result = this.manager.clear() + this.invalidateCatalog() + return result + } + + status() { + const described = this.manager.describe() + return { + provider: { + id: provider.PROVIDER_ID, + name: provider.PROVIDER_NAME, + apiBase: this.origins.apiBase, + authBase: this.origins.authBase, + keyDashboardUrl: `${this.origins.authBase}/console`, + }, + credential: described, + login: this.loginStatus(), + } + } + + /** A 401 from the relay: terminal reauthentication for the exact generation. */ + reportUnauthorized(rejected) { + return provider.handleUnauthorized(this.manager, rejected) + } + + // ------------------------------------------------------------- event bus + + subscribe(res) { + this.sseClients.add(res) + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + res.write('event: hello\ndata: {}\n\n') + return () => this.sseClients.delete(res) + } + + emit(event, payload) { + if (this._handlers && this._handlers[event]) { + for (const handler of Array.from(this._handlers[event])) { + try { + handler(payload) + } catch (e) { + // A failing listener must not break the login state machine. + } + } + } + const frame = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n` + for (const client of this.sseClients) { + try { + client.write(frame) + } catch (e) { + this.sseClients.delete(client) + } + } + } + + // ---------------------------------------------------------------- routing + + async handle(req, res) { + const { pathname } = new URL(req.url, `http://${this.listenHost}`) + const method = req.method || 'GET' + + if (pathname === '/' || pathname === '/index.html') { + return this.sendHtml(res, this.pageHtml(this.status())) + } + if (pathname === '/ui/app.js') { + return this.sendJs(res, this.pageJs()) + } + if (pathname === '/ui/app.css') { + return this.sendCss(res, this.pageCss()) + } + if (pathname === '/api/status' && method === 'GET') { + return this.sendJson(res, 200, this.status()) + } + if (pathname === '/api/models' && method === 'GET') { + const url = new URL(req.url, `http://${this.listenHost}`) + const payload = await this.catalog({ + capability: url.searchParams.get('capability') || models.CAPABILITIES.chat, + modality: url.searchParams.get('modality') || undefined, + force: url.searchParams.get('refresh') === '1', + }) + return this.sendJson(res, 200, payload) + } + if (pathname === '/api/auth/apikey' && method === 'POST') { + const body = await readJsonBody(req) + const result = this.saveApiKey(body && body.key) + return this.sendJson(res, result.ok ? 200 : 400, sanitizeResult(result)) + } + if (pathname === '/api/auth/pkce/start' && method === 'POST') { + const body = await readJsonBody(req) + const started = await this.beginLogin({ authId: body && body.authId }) + return this.sendJson(res, 200, started) + } + if (pathname === '/api/auth/pkce/status' && method === 'GET') { + return this.sendJson(res, 200, this.loginStatus()) + } + if (pathname === '/api/auth/pkce/cancel' && method === 'POST') { + return this.sendJson(res, 200, sanitizeResult(this.cancelLogin({ reason: 'cancelled' }))) + } + if (pathname === '/api/auth/disconnect' && method === 'POST') { + return this.sendJson(res, 200, sanitizeResult(this.disconnect())) + } + if (pathname === '/api/events' && method === 'GET') { + const unsubscribe = this.subscribe(res) + req.on('close', unsubscribe) + return + } + + return this.sendJson(res, 404, { error: 'not_found' }) + } + + sendJson(res, status, payload) { + const body = JSON.stringify(payload === undefined ? null : payload) + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }) + res.end(body) + } + + sendHtml(res, body) { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }) + res.end(body) + } + + sendJs(res, body) { + res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store' }) + res.end(body) + } + + sendCss(res, body) { + res.writeHead(200, { 'Content-Type': 'text/css; charset=utf-8', 'Cache-Control': 'no-store' }) + res.end(body) + } +} + +/** Strip anything secret-shaped from a response before it reaches the browser. */ +function sanitizeResult(result) { + if (!result || typeof result !== 'object') return result + const out = {} + for (const key of Object.keys(result)) { + if (key === 'key' || key === 'credential') continue + out[key] = result[key] + } + if (result.credential) { + out.credential = { + authId: result.credential.authId, + source: result.credential.source, + accountId: result.credential.accountId, + scope: result.credential.scope, + redacted: credentials.redactKey(result.credential.key), + } + } + return out +} + +function projectModelForBrowser(model) { + return { + id: model.id, + name: model.name, + contextLength: model.contextLength, + inputModalities: model.inputModalities, + outputModalities: model.outputModalities, + reasoning: model.reasoning, + endpoints: model.endpoints, + seed: !!model.seed, + } +} + +function parseCookies(header) { + const out = {} + if (!header) return out + for (const part of String(header).split(';')) { + const idx = part.indexOf('=') + if (idx === -1) continue + out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim()) + } + return out +} + +function readJsonBody(req) { + return new Promise((resolve) => { + let raw = '' + const MAX = 64 * 1024 + req.on('data', (chunk) => { + raw += chunk + if (raw.length > MAX) raw = raw.slice(0, MAX) + }) + req.on('end', () => { + try { + resolve(raw ? JSON.parse(raw) : {}) + } catch (e) { + resolve({}) + } + }) + req.on('error', () => resolve({})) + }) +} + +module.exports = { OrcaRouterConfigApp, SESSION_COOKIE, sanitizeResult, projectModelForBrowser } diff --git a/README.md b/README.md index f9c408d..9240588 100644 --- a/README.md +++ b/README.md @@ -53,3 +53,65 @@ RSS监控[地址](https://github.com/GideonSenku/Scriptable/tree/master/RSS) 最 + +## OrcaRouter + +> OrcaRouter 是兼容 OpenAI 协议的 AI 网关,同时服务于模型与 Agent,具备自适应路由、自动故障转移、 +> 零加价推理、可观测性、护栏与 Agent 工具治理能力。它还在同一 endpoint 上提供网关级零信任安全, +> 对每个 prompt/response 做审查、对每次工具调用按默认拒绝原则治理,且无需改动应用代码。 + +`OrcaRouter/` 目录把 OrcaRouter 接入为本仓库的一等 provider,并提供两种互相独立的认证方式。 +两者最终都只会得到一把属于你自己账号的 OrcaRouter API key —— 计费归属你自己,可在 + 查看,也可在 随时吊销。 + +### 食用方式(设备端) + +1. 下载本仓库的 `Env.js` 和 `OrcaRouter/orcarouter.js` 保存到 `Scriptable`。 +2. 运行 `orcarouter.js`,在弹出框中选择其中一种: + + | 入口 | 说明 | + | --- | --- | + | **OrcaRouter · API key** | 直接粘贴已有的 `sk-orca-…` key。 | + | **OrcaRouter · Connect** | 打开授权页面,把页面显示的 code 粘回脚本(OAuth 2.0 + PKCE,S256)。 | + +3. key 通过 `Env.js` 存入 iOS 钥匙串 —— 也就是本仓库既有的密钥存储方式,不会另外写明文文件。 + +```js +const orca = importModule('OrcaRouter/orcarouter') +await orca.setup() +const reply = await orca.chat('hello') // 默认走 orcarouter/auto +const models = await orca.listModels() // 实时模型目录,按能力过滤 +``` + +### 本地配置页面 + +```bash +ORCA_KEY=sk-orca-… node OrcaRouter/config-server.js --port 8787 +``` + +提供同时展示两种认证入口的配置页面,以及基于 `GET https://api.orcarouter.ai/v1/models` 的 +真实模型下拉。模型发现全部在服务端完成,浏览器不会拿到 API key。该页面使用 Flow A(loopback 回调), +无需手动粘贴 code。 + +### 接口地址与覆盖项 + +| 用途 | 默认值 | +| --- | --- | +| 推理与模型目录 | `https://api.orcarouter.ai/v1` | +| 授权与 code 交换 | `https://www.orcarouter.ai`(`/auth`、`/api/v1/auth/keys`) | + +`ORCA_BASE_URL` 用于自建部署的共享域名;`ORCA_AUTH_BASE_URL` 与 `ORCA_API_BASE_URL` 可分别覆盖 +两个域名,优先级更高。远程域名必须使用 HTTPS,仅 loopback 允许明文 HTTP。 + +注意:`https://api.orcarouter.ai/v1/auth/keys` 是 404。relay 在 `/v1`,认证接口不在该前缀下。 + +### 测试 + +```bash +node --test OrcaRouter/test/ # 98 个测试,无需联网 +ORCAROUTER_API_KEY=sk-orca-… node --test OrcaRouter/test/live.test.js # 真实目录 + 真实推理 +``` + +provider 架构、两种流程与 fallback 目录来源见 `OrcaRouter/README.md`。 + +Discord: · X: diff --git a/READMEEN.md b/READMEEN.md index f6b78da..b2ee4d4 100644 --- a/READMEEN.md +++ b/READMEEN.md @@ -37,4 +37,71 @@ RSS[link](https://github.com/GideonSenku/Scriptable/tree/master/RSS) Use Config Weibo[link](https://github.com/GideonSenku/Scriptable/tree/master/Weibo) Zhihu[link](https://github.com/GideonSenku/Scriptable/tree/master/Zhihu) - \ No newline at end of file + +## OrcaRouter + +> OrcaRouter is an OpenAI-compatible AI gateway built for both models and agents, with adaptive +> routing, automatic failover, zero-markup inference, observability, guardrails, and agent-tool +> governance. It also runs gateway-level, zero-trust security for AI agents on the same endpoint — +> screening every prompt/response and governing every tool call on a default-deny basis, with no +> application code changes. + +`OrcaRouter/` adds OrcaRouter as a first-class provider with two independent ways to authenticate. +Both produce one ordinary OrcaRouter API key owned by your account — billed to you, listed in your +console at , and revocable by you at any time. + +### How to use (on device) + +1. Download `Env.js` and `OrcaRouter/orcarouter.js` into `Scriptable`. +2. Run `orcarouter.js` and pick one of the two entries: + + | Entry | What it does | + | --- | --- | + | **OrcaRouter · API key** | Paste an existing `sk-orca-…` key. | + | **OrcaRouter · Connect** | Opens the consent screen and asks you to paste back the code it shows (OAuth 2.0 + PKCE, S256). | + +3. The key is stored in the iOS Keychain through `Env.js` — the same secret store the rest of the + repository uses. Nothing is written to a plaintext side file. + +```js +const orca = importModule('OrcaRouter/orcarouter') +await orca.setup() +const reply = await orca.chat('hello') // routed through orcarouter/auto +const models = await orca.listModels() // live catalog, filtered per capability +``` + +### Configuration helper (desktop) + +```bash +ORCA_KEY=sk-orca-… node OrcaRouter/config-server.js --port 8787 +``` + +Serves a configuration page with the two authentication entries side by side and a real model +selector built from `GET https://api.orcarouter.ai/v1/models`. Model discovery runs on the server; +the browser never receives an API key. This surface also uses Flow A (loopback redirect), so the +code returns automatically instead of being pasted. + +### Endpoints and overrides + +| Purpose | Default | +| --- | --- | +| Inference and model catalog | `https://api.orcarouter.ai/v1` | +| Authorization and code exchange | `https://www.orcarouter.ai` (`/auth`, `/api/v1/auth/keys`) | + +`ORCA_BASE_URL` sets a shared self-hosted origin; `ORCA_AUTH_BASE_URL` and `ORCA_API_BASE_URL` +override the two origins separately and take precedence. Remote origins must be HTTPS; plain HTTP is +allowed only on loopback. + +Note: `https://api.orcarouter.ai/v1/auth/keys` is a 404. The relay lives at `/v1`; the auth +endpoints do not. + +### Tests + +```bash +node --test OrcaRouter/test/ # 98 tests, no network +ORCAROUTER_API_KEY=sk-orca-… node --test OrcaRouter/test/live.test.js # live catalog + inference +``` + +See `OrcaRouter/README.md` for the provider architecture, the two flows, and the provenance of the fallback catalog. + +Discord: · X: