Skip to content

feat: add OrcaRouter provider with API-key and PKCE login - #461

Open
nissrin2020ali-ux wants to merge 1 commit into
KeygraphHQ:mainfrom
nissrin2020ali-ux:orcarouter/task-6618
Open

nissrin2020ali-ux wants to merge 1 commit into
KeygraphHQ:mainfrom
nissrin2020ali-ux:orcarouter/task-6618

Conversation

@nissrin2020ali-ux

Copy link
Copy Markdown

What

Adds OrcaRouter as a first-class model provider, plus a connect flow so a user can either paste an existing API key or authorize with their own OrcaRouter account.

  • Provider entry in apps/worker/src/ai/orcarouter/provider.tsbaseUrl = https://api.orcarouter.ai/v1, registered as an openai-completions extension provider, apiKey: $ORCAROUTER_API_KEY
  • Catalog in apps/worker/src/ai/orcarouter/catalog.ts — live GET /v1/models read with the run's own key, bounded and capability-filtered, with a verified seed fallback
  • orcarouter added to CURATED_PROVIDERS and PROVIDER_API_KEY_ENV in apps/worker/src/ai/models.ts, mirrored in apps/cli/src/model-spec.ts
  • Inference is actually routed: resolveModelSelection registers the provider before resolving, so SHANNON_AI_MODEL=orcarouter:<vendor/model> resolves against the live catalog instead of pi's builtin registry
  • API-key choice: orcarouter — key read from ORCAROUTER_API_KEY (aliases ORCA_API_KEY, ORCA_KEY, SHANNON_AI_API_KEY)
  • Connect flow: OAuth 2.0 + PKCE, Flow A loopback redirect by default (shannon connect), with Flow B out-of-band available as shannon connect --pkce --oob. Flow A is the default because Shannon's client is a local process that can bind 127.0.0.1:<port>; Flow B exists for a headless or sandboxed shell where it cannot
  • CLI surface: shannon connect [--api-key|--pkce], documented in shannon help connect and the dispatcher's command list

Affiliation disclosure: I'm an engineer on the OrcaRouter team.

How the credential works

The key belongs to the user, not to this project: it is issued to their OrcaRouter account, billed to them, listed in their console, and revocable at any time from https://www.orcarouter.ai/console/authorized-apps. No client secret is involved — PKCE binds the auth code to this process, so an intercepted code cannot be redeemed by anyone else. The discovery document confirms it: token_endpoint_auth_methods_supported: ["none"].

Both entry points produce the same thing — an ordinary sk-orca-… API key — so they resolve through one seam:

OrcaCredentialSource ─┬─ apiKeySource()   (user pasted a key)     ─┐
                      └─ connectSource()  (PKCE sign-in produced) ─┴─> OrcaCredential
                                                                        { apiKey, method, scope }

method is display and support metadata only. The request path, the catalog, and the model descriptors never branch on it — provider.test.ts drives both entries through the registered streamSimple and asserts the resulting HTTP request is byte-identical apart from the reported provenance.

A PKCE-issued key is durable but is not a refresh token, and no code here pretends otherwise. A relay 401 takes a terminal reauthentication path: recordOrcaRelayRejection(orcaGeneration) marks only the exact account and credential generation that made the rejected request. A late failure from a request issued before a re-login names a generation that is no longer current, so applied is false and the freshly reauthorized credential is left alone. The old secret is not deleted before a successful replacement.

Storage: the project's existing secret mechanism, not a new store — ~/.shannon/config.toml (0o600) in npx mode, ./.env in a clone. Empty/absent key is a normal starting state, not an error.

Origins: auth on https://www.orcarouter.ai (/auth authorize, /api/v1/auth/keys exchange), inference and discovery on https://api.orcarouter.ai/v1. Neither is derived from the other by hostname substitution or appending /v1. ORCA_BASE_URL is a shared self-hosted fallback with ORCA_AUTH_BASE_URL / ORCA_API_BASE_URL overrides taking precedence; non-loopback origins must be HTTPS.

Model discovery and capability filtering

GET <apiBase>/models is the only source of truth, read with the user's key so the list is what that workspace can call. Model IDs keep their vendor/model namespace verbatim. The response is bounded (10 s timeout, 512 KiB, 1000 entries, accepted item shape, supported endpoint types) so a hostile or broken response cannot consume unbounded memory or advertise a route the adapter cannot speak.

Each entry point filters independently:

Entry point Rule
text chat/agent ?capability=chat, supported_endpoint_types ∩ {openai, openai-response, anthropic, gemini}, non-text endpoints excluded
multimodal input must first satisfy chat, then architecture.input_modalities must explicitly declare the modality — undeclared fails closed
embedding ?capability=embedding / strict embeddings match
image generation ?capability=image / strict image-generation match
video strict openai-video
rerank strict jina-rerank

A live response is authoritative and the seed is never mixed into it. On discovery failure the small verified seed is used and the result is reported as degraded, so a fresh install survives an outage instead of appearing to support no models. Capability is never inferred from a model name, and the seed keeps its verified metadata — including the low/medium/high/xhigh reasoning ladder on openai/gpt-5.5.

Live catalog observed (GET https://api.orcarouter.ai/v1/models?capability=chat, 2026-09-16, HTTP 200): 16 chat models, of which 2 declare image input.

Scope

Covered AI input entry points: the scan's text prompt path (runPiPromptcreateAgentSession) is the repository's only model-facing entry point — the prompt is a plain string and there is no image, audio, video, embedding, rerank, or attachment control anywhere in the tree. So the text/agent selector is the one wired to a real control; the other capability filters are implemented and tested in the shared catalog layer for the entry points this repository has.

Multimodal is therefore not applicable to a UI here — there is no attachment control to drive a dropdown, and no rendered interface at all (no react-dom/vue/svelte/electron/next dependency in any tracked package.json, no .vue/.svelte file, no gradio/streamlit). This is a CLI + Temporal worker repository, so the evidence is CLI/server integration, not screenshots.

Testing

Every check below was run on a fresh clone of main at 25b90b0 with this patch applied, in a clean environment (env -i, fresh HOME), after npm install --no-package-lock --no-save --ignore-scripts ./apps/cli ./apps/worker.

Focused suites — 146 passed, 3 skipped, 0 failed (149):

Suite Result
provider.test.ts + wiring.test.ts 2 files, 25 passed
pkce.test.ts 1 file, 26 passed
catalog.test.ts + catalog.mirror.test.ts + endpoints.test.ts + selection.test.ts 4 files, 55 passed
credentials.test.ts + env.orcarouter.test.ts 2 files, 40 passed
live.test.ts 1 file, 3 passed (requires ORCAROUTER_API_KEY; skips without it)

Whole suite: 9 files passed, 1 skipped; 146 passed, 3 skipped, 0 failed (149).

What the tests actually prove, beyond "it compiles":

  • Dual auth is real, not two buttons. Both adapters are driven through the registered streamSimple and produce the same request; only credential.method differs. One input path or a single connect button would not satisfy this.
  • Origins are bound. Auth requests go only to www.orcarouter.ai (or an explicit override), inference and discovery only to api.orcarouter.ai/v1. Nothing derives one origin from the other.
  • PKCE end-to-end through the real adapter, against a local fake consent server: authorize → loopback callback / OOB paste → exchange → persist. Plus fresh verifier+state per attempt, unpadded base64url(sha256(verifier)), S256 on every flow, constant-time state comparison, and assertions that the verifier never appears on the authorize URL, in an error message, or in a request URL.
  • Error semantics: denial, state mismatch, code reuse/expiry (403), downgrade defence (400), scope downgrade, 429, malformed response, and transport fault each map to a distinct actionable kind.
  • Terminal reauth: a revoked durable key does not trigger a fake refresh or a retry loop; a stale generation cannot poison a newer credential.
  • Capability filters, including multimodal fail-closed, no capability inferred from a name, and the seed never mixed into a live result.

Skipped/limited: 3 skipped tests are the live suite in the runs where ORCAROUTER_API_KEY is unset. The live suite ran green here with a real key. pnpm is unavailable in this environment, so dependency installation used npm install --no-package-lock --no-save; the repository's pnpm-based turbo run build/check were not run, and both packages were typechecked directly with tsc --noEmit (clean) and linted with biome check (output identical to the unmodified base — 6 warnings, 2 infos, no errors).

Live run observed: discovery returned HTTP 200 with 16 chat models; text filtering kept only chat-capable entries; the image-input subset was a strict subset of the chat list; a real completion through the registered provider returned text. Discovery origin https://api.orcarouter.ai/v1, auth origin https://www.orcarouter.ai.

One note on wiring.test.ts: it replaces the @earendil-works/pi-coding-agent module with a registry stand-in, because pi's own module graph requires Node ≥ 22.19 while the suite runs on whatever node the developer has. Nothing under test is replaced — the catalog is read, the alias is adopted, and the provider is registered through the same registerProvider call a run makes; only the registry that call lands in is local.

Provider evidence

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.

Discord: discord.gg/YEubt8enRA · X: https://x.com/OrcaRouter

Notes for review

This repository has no CONTRIBUTING.md, AGENTS.md, CODEOWNERS, PR template, or test workflow, so there was no contributor contract to follow beyond CLAUDE.md. If an authentication-boundary change like this needs a maintainer sponsor or a label, could a maintainer take a look at the current head?

Signed-off-by: nissrin2020ali-ux <nissrin2020ali-ux@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant