Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions OrcaRouter/README.md
Original file line number Diff line number Diff line change
@@ -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: <https://www.orcarouter.ai>
- Discord: <https://discord.gg/YEubt8enRA> · X: <https://x.com/OrcaRouter>

## 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
<https://www.orcarouter.ai/console/authorized-apps>.

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:<port>`, 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 <apiBase>/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.
89 changes: 89 additions & 0 deletions OrcaRouter/config-server.js
Original file line number Diff line number Diff line change
@@ -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 }
Loading