From 456a223352c9c822eb9edeafa0bb2f79d8136c29 Mon Sep 17 00:00:00 2001 From: zsanig22-dotcom Date: Thu, 17 Sep 2026 04:57:31 +0000 Subject: [PATCH] feat(llm): add OrcaRouter as a first-class provider with API-key and OAuth 2.0 + PKCE login Signed-off-by: zsanig22-dotcom --- .gitignore | 6 + README.md | 20 + config.researchclaw.example.yaml | 17 + docs/ORCAROUTER.md | 184 ++++ pyproject.toml | 14 +- researchclaw/cli.py | 425 ++++++++- researchclaw/llm/__init__.py | 33 + researchclaw/llm/client.py | 28 + researchclaw/llm/model_select.py | 290 ++++++ researchclaw/llm/orcarouter.py | 858 ++++++++++++++++++ researchclaw/llm/orcarouter_catalog.py | 617 +++++++++++++ researchclaw/llm/orcarouter_pkce.py | 553 +++++++++++ researchclaw/server/app.py | 13 + researchclaw/server/routes/providers.py | 382 ++++++++ researchclaw/server/static/index.html | 133 +++ .../server/static/orca-logo-classic.png | Bin 0 -> 74215 bytes researchclaw/server/static/providers.css | 181 ++++ researchclaw/server/static/providers.js | 448 +++++++++ scripts/orcarouter_live_check.py | 143 +++ scripts/orcarouter_ui_evidence.py | 365 ++++++++ tests/test_orcarouter_catalog.py | 471 ++++++++++ tests/test_orcarouter_cli.py | 352 +++++++ tests/test_orcarouter_live.py | 79 ++ tests/test_orcarouter_model_select.py | 325 +++++++ tests/test_orcarouter_pkce.py | 407 +++++++++ tests/test_orcarouter_provider.py | 556 ++++++++++++ tests/test_orcarouter_server.py | 449 +++++++++ tests/test_orcarouter_ui.py | 330 +++++++ 28 files changed, 7676 insertions(+), 3 deletions(-) create mode 100644 docs/ORCAROUTER.md create mode 100644 researchclaw/llm/model_select.py create mode 100644 researchclaw/llm/orcarouter.py create mode 100644 researchclaw/llm/orcarouter_catalog.py create mode 100644 researchclaw/llm/orcarouter_pkce.py create mode 100644 researchclaw/server/routes/providers.py create mode 100644 researchclaw/server/static/index.html create mode 100644 researchclaw/server/static/orca-logo-classic.png create mode 100644 researchclaw/server/static/providers.css create mode 100644 researchclaw/server/static/providers.js create mode 100644 scripts/orcarouter_live_check.py create mode 100644 scripts/orcarouter_ui_evidence.py create mode 100644 tests/test_orcarouter_catalog.py create mode 100644 tests/test_orcarouter_cli.py create mode 100644 tests/test_orcarouter_live.py create mode 100644 tests/test_orcarouter_model_select.py create mode 100644 tests/test_orcarouter_pkce.py create mode 100644 tests/test_orcarouter_provider.py create mode 100644 tests/test_orcarouter_server.py create mode 100644 tests/test_orcarouter_ui.py diff --git a/.gitignore b/.gitignore index 2f7462d26..76d149a41 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,12 @@ cost_log.jsonl # Playwright MCP logs .playwright-mcp/ +# OrcaRouter GUI evidence bundle: written into the working tree by +# ``scripts/orcarouter_ui_evidence.py`` so the screenshots a reviewer opens come +# from a run of the shipped code against the live catalogue. It is a build +# product of that run, never repository content, so it stays out of the tree. +orca-evidence/ + # Frontend (local dev only) frontend/ diff --git a/README.md b/README.md index 9432200b8..46408af27 100644 --- a/README.md +++ b/README.md @@ -769,6 +769,26 @@ and `MiniMax-M2.7` in the fallback chain. | `minimax` | China | OpenAI-compatible | `https://api.minimaxi.com/v1` | | `minimax-anthropic` | Global | Anthropic-compatible | `https://api.minimax.io/anthropic` | | `minimax-anthropic-cn` | China | Anthropic-compatible | `https://api.minimaxi.com/anthropic` | +| `orcarouter` | Global | OpenAI-compatible | `https://api.orcarouter.ai/v1` | +| `orcarouter-oauth` | Global | OpenAI-compatible | `https://api.orcarouter.ai/v1` | + +The two `orcarouter` entries use the same gateway and the same model +namespace; they differ only in how the key is obtained. `orcarouter` takes a +pasted `sk-orca-…` key, and `orcarouter-oauth` takes the key minted by an +OAuth 2.0 + PKCE login: + +```bash +researchclaw orcarouter key --set # paste an existing key +researchclaw orcarouter login # or authorize an account (browser callback) +researchclaw orcarouter login --flow oob # ...or paste a code, on a headless box +researchclaw orcarouter models # capability-filtered model list +``` + +The model dropdown for OrcaRouter is built from `GET +https://api.orcarouter.ai/v1/models` with your own key, filtered per entry +point — a text model cannot be selected once an image attachment makes the +request multimodal. See [docs/ORCAROUTER.md](docs/ORCAROUTER.md) for the +origins, the credential lifecycle, and the capability rules. Anthropic-compatible presets require `pip install "researchclaw[anthropic]"`. The global and China API references are available from the diff --git a/config.researchclaw.example.yaml b/config.researchclaw.example.yaml index 16bf8fd9f..f47e942b0 100644 --- a/config.researchclaw.example.yaml +++ b/config.researchclaw.example.yaml @@ -62,6 +62,23 @@ llm: # primary_model: "deepseek-ai/deepseek-v4-pro" # fallback_models: # - "deepseek-ai/deepseek-v4-flash" + # --- OrcaRouter example (api key) --- + # provider: "orcarouter" + # base_url: "https://api.orcarouter.ai/v1" + # api_key_env: "ORCAROUTER_API_KEY" # paste sk-orca-... or run: researchclaw orcarouter key --set + # primary_model: "orcarouter/auto" + # fallback_models: + # - "deepseek/deepseek-v4-pro" + # - "deepseek/deepseek-v4-flash" + # --- OrcaRouter example (account login, OAuth 2.0 + PKCE) --- + # provider: "orcarouter-oauth" + # base_url: "https://api.orcarouter.ai/v1" + # api_key_env: "" # the key comes from: researchclaw orcarouter login + # primary_model: "orcarouter/auto" + # fallback_models: + # - "deepseek/deepseek-v4-pro" + # Auth origin defaults to https://www.orcarouter.ai; override with + # ORCA_AUTH_BASE_URL / ORCA_API_BASE_URL (or a shared ORCA_BASE_URL). # --- Ollama (local) example --- # provider: "ollama" # base_url: "http://localhost:11434/v1" diff --git a/docs/ORCAROUTER.md b/docs/ORCAROUTER.md new file mode 100644 index 000000000..451026849 --- /dev/null +++ b/docs/ORCAROUTER.md @@ -0,0 +1,184 @@ +# OrcaRouter provider + +[OrcaRouter](https://www.orcarouter.ai) is an OpenAI-compatible AI gateway +built for both models and agents: adaptive routing, automatic failover, +zero-markup inference, observability, guardrails, and agent-tool governance. +It is a first-class provider here — it appears in the provider presets, the +`researchclaw init` wizard, the server's provider API and settings page, and +the model catalogue, exactly like every other preset. + +There are **two explicit ways to authenticate**, and both produce the same +kind of OrcaRouter API key: + +| Choice | Provider id | Label | Where the key comes from | +| --- | --- | --- | --- | +| Existing key | `orcarouter` | OrcaRouter — API | You paste an `sk-orca-…` key. | +| Account login | `orcarouter-oauth` | OrcaRouter — Auth | OAuth 2.0 + PKCE mints one for your account. | + +They are separate entries rather than one ambiguous button, because they +have different failure modes: an existing-key user must not be pushed into a +browser flow, and a revoked account grant must not be masked by a stale +pasted key. + +## Inference endpoint + +```yaml +llm: + provider: "orcarouter" # or "orcarouter-oauth" + base_url: "https://api.orcarouter.ai/v1" + api_key_env: "ORCAROUTER_API_KEY" + primary_model: "orcarouter/auto" + fallback_models: + - "deepseek/deepseek-v4-pro" + - "deepseek/deepseek-v4-flash" +``` + +The wire format is OpenAI-compatible, so the existing `LLMClient` is used +unchanged with `Authorization: Bearer `. Model ids keep their +`vendor/model` namespace. + +## Authentication and inference use different origins + +- consent screen: `https://www.orcarouter.ai/auth` +- code exchange: `https://www.orcarouter.ai/api/v1/auth/keys` +- inference and model list: `https://api.orcarouter.ai/v1` + +`https://api.orcarouter.ai/v1/auth/keys` is a 404 — the relay lives at `/v1` +on the API origin, the auth endpoints do not. The two origins are configured +independently and are never derived from one another: + +| Variable | Purpose | +| --- | --- | +| `ORCA_AUTH_BASE_URL` | Auth origin override (explicit wins) | +| `ORCA_API_BASE_URL` | Inference origin override (explicit wins) | +| `ORCA_BASE_URL` | Shared self-hosted base, used for whichever of the two is not set explicitly | + +Non-loopback origins must be HTTPS; plain HTTP is accepted only for +`localhost`, `127.0.0.1`, and `[::1]`. + +## Connecting an account (OAuth 2.0 + PKCE, S256) + +```bash +researchclaw orcarouter login # browser callback (loopback), when available +researchclaw orcarouter login --flow oob # print a URL, paste the code back +researchclaw orcarouter models # what this account can actually call +researchclaw orcarouter status +``` + +The **loopback** flow is the default on a workstation: the CLI binds +`127.0.0.1:0` and receives the code directly. **Out-of-band** is for SSH +sessions, containers, and the hosted web UI, where no browser callback can +reach the user's machine — there is no redirect URI to pre-register either +way. + +Properties enforced by the implementation: + +- the verifier is 32 bytes of `secrets.token_bytes` per attempt and never + leaves the process — not in a URL, a log line, an exception, or a + screenshot; +- only `base64url(sha256(verifier))` travels on the authorize URL, and the + method is always `S256` (never `plain`: the consent screen can hand a code + to a human even in the callback flow); +- `state` is compared with `hmac.compare_digest` before the code is used; +- denial, state mismatch, timeout, cancel, expired/reused code (403), + rejected request (400), rate limit (429), and transport errors all end the + attempt with an actionable message and release the listener. + +The grant is a **durable API key, not a refresh token**. It is stored at +`~/.researchclaw/orcarouter/credentials.json` (mode `0600`) — the project's +existing user-level state directory — and reused on every start until +OrcaRouter revokes it. There is no refresh grant to call, and none is +invented: `researchclaw orcarouter login` refuses to re-authorize when a +usable key already exists, because the consent endpoint allows 10 PKCE-issued +keys per user per 24 hours. + +### Revocation + +Revoke at . A `401` from +the relay is terminal: exactly the credential *generation* that made the +rejected request is marked `needs_reauth`, the stored secret is kept (so a +transient misclassification is not irreversible account loss), and a late +failure from an old request cannot mark a newer credential broken. + +## Models + +The catalogue is `GET https://api.orcarouter.ai/v1/models`, requested with +your own bearer key so the answer is what *your* workspace can call. When +live discovery succeeds it is authoritative; a small verified seed exists +only for a cold start or an outage, is labelled degraded in the UI, and is +never mixed into a successful live result. + +Each entry point gets its own filtered list: + +| Entry point | Filter | +| --- | --- | +| Text chat / agent | `?capability=chat`, text wire endpoint, excludes image/video/rerank-only models | +| Multimodal | the above, plus a **declared** `architecture.input_modalities` entry for the uploaded modality | +| Embedding | `?capability=embedding` / `embeddings` endpoint | +| Image generation | `?capability=image` / `image-generation` endpoint | +| Video generation | `openai-video` endpoint | +| Rerank | `jina-rerank` endpoint | + +Capability is never inferred from a model's name, and a model that does not +declare a required input modality is excluded rather than assumed +compatible. Changing the provider, the entry point, or the attachment type +recomputes the options and clears a selection that is no longer compatible. + +## Server / settings UI + +With `researchclaw serve`, the provider settings page is at `/providers` +and the JSON API at `/api/providers`. The API key stays on the server: the +model endpoint returns minimal model metadata and a masked key (`sk-orca…4f2a`) +only — a page never receives a credential it could leak. + +### Screenshots / evidence bundle + +The screenshots of that page are a **build product of a run**, regenerated by +the generator rather than drawn by hand: + +```bash +python scripts/orcarouter_ui_evidence.py # writes ./orca-evidence/ +python scripts/orcarouter_ui_evidence.py --out DIR # or ORCA_EVIDENCE_OUT +pytest tests/test_orcarouter_ui.py -k evidence # generates and checks it +``` + +The generator boots the real app, drives the real page in Chromium, and writes +`manifest.json` plus `auth-methods.png` and `text-model-dropdown.png` +(declared-modality chat models are only added by the multimodal entry point +when one exists). Output goes to `orca-evidence/` at the repository root, which +is git-ignored: evidence has to be the product of a run of the code under test, +so a bundle carried in a patch is stale by construction and is refused. Nothing +in the bundle is hand-written — the manifest records each PNG's `sha256`, and +the test suite re-checks those digests, so an edited or stale screenshot fails +rather than being believed. + +`manifest.json` uses the delivery gate's schema rather than a free-form report: +`automation` is an object carrying `framework`, `passed`, `catalog_source` +(the live `?capability=chat` catalogue URL) and the model counts, and every +entry in `artifacts` carries `kind`, `path`, `sha256` and the `ui` assertions +that screenshot has to prove. `validate_bundle` re-checks all of it — Playwright +provenance, the catalogue URL, count agreement, PNG size and digest, and the +per-screenshot UI assertions. It runs twice: once inside the generator (so a +bundle the gate would reject never leaves the machine) and once in +`test_gui_evidence_bundle_is_generated_into_the_repository`, which runs the +generator end to end and then holds the bundle it produced to the same +checklist. + +## Evidence + +| Item | Source | +| --- | --- | +| OpenAI-compatible inference | `https://api.orcarouter.ai/v1/chat/completions` (verified with a real request) | +| Model list | `https://api.orcarouter.ai/v1/models` — requires `Authorization: Bearer`; 16 models returned on 2026-09-16 | +| Consent screen | `https://www.orcarouter.ai/auth` | +| Code exchange | `POST https://www.orcarouter.ai/api/v1/auth/keys` | +| Discovery document | `https://www.orcarouter.ai/.well-known/openid-configuration` | +| Key management | `https://www.orcarouter.ai/console/token` | +| Revocation | `https://www.orcarouter.ai/console/authorized-apps` | +| Terms, privacy, subprocessors | OrcaRouter Trust Center — `https://www.orcarouter.ai/trust` (verified HTTP 200 on 2026-09-16; the public site exposes no `/terms` path, so the trust center is the authoritative legal document location) | +| Product / company | `https://www.orcarouter.ai` · pricing `https://www.orcarouter.ai/pricing` | +| Relay status / incidents | `https://status.orcarouter.ai` (linked from the site) | +| Community | Discord `discord.gg/YEubt8enRA` · X `https://x.com/OrcaRouter` | +| Maintenance owner | OrcaRouter team (engineering contact via the OrcaRouter Discord) | +| Verification date | 2026-09-16 | +| Affiliation | This integration is contributed by an engineer on the OrcaRouter team. | diff --git a/pyproject.toml b/pyproject.toml index 225c84e08..4fe5ce425 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,10 +14,15 @@ license = {text = "MIT"} [project.optional-dependencies] anthropic = ["httpx>=0.24"] +# Runtime stack of researchclaw.server (``researchclaw serve`` / ``dashboard``). +server = ["fastapi>=0.110", "uvicorn>=0.27", "pydantic>=2.5"] web = ["scholarly>=1.7", "crawl4ai>=0.2", "tavily-python>=0.3"] pdf = ["PyMuPDF>=1.23"] all = [ "httpx>=0.24", + "fastapi>=0.110", + "uvicorn>=0.27", + "pydantic>=2.5", "scholarly>=1.7", "crawl4ai>=0.2", "tavily-python>=0.3", @@ -26,7 +31,14 @@ all = [ "matplotlib>=3.7", "scipy>=1.10", ] -dev = ["pytest>=7.0", "pytest-asyncio>=0.21", "httpx>=0.24"] +dev = [ + "pytest>=7.0", + "pytest-asyncio>=0.21", + "httpx>=0.24", + "fastapi>=0.110", + "uvicorn>=0.27", + "pydantic>=2.5", +] [project.scripts] researchclaw = "researchclaw.cli:main" diff --git a/researchclaw/cli.py b/researchclaw/cli.py index 4c272dd8a..bad960927 100644 --- a/researchclaw/cli.py +++ b/researchclaw/cli.py @@ -4,6 +4,7 @@ import argparse import hashlib +import json import os import shutil import subprocess @@ -715,7 +716,7 @@ def cmd_serve(args: argparse.Namespace) -> int: import uvicorn except ImportError as exc: print( - f"Error: web dependencies not installed — pip install researchclaw[web]\n{exc}", + f"Error: server dependencies not installed — pip install researchclaw[server]\n{exc}", file=sys.stderr, ) return 1 @@ -741,7 +742,7 @@ def cmd_dashboard(args: argparse.Namespace) -> int: import uvicorn except ImportError as exc: print( - f"Error: web dependencies not installed — pip install researchclaw[web]\n{exc}", + f"Error: server dependencies not installed — pip install researchclaw[server]\n{exc}", file=sys.stderr, ) return 1 @@ -780,6 +781,8 @@ def cmd_wizard(args: argparse.Namespace) -> int: "8": ("minimax-anthropic", "MINIMAX_API_KEY"), "9": ("minimax-anthropic-cn", "MINIMAX_API_KEY"), "10": ("atlascloud", "ATLASCLOUD_API_KEY"), + "11": ("orcarouter", "ORCAROUTER_API_KEY"), + "12": ("orcarouter-oauth", ""), } _PROVIDER_URLS = { @@ -792,6 +795,8 @@ def cmd_wizard(args: argparse.Namespace) -> int: "minimax-anthropic-cn": "https://api.minimaxi.com/anthropic", "ollama": "http://localhost:11434/v1", "atlascloud": "https://api.atlascloud.ai/v1", + "orcarouter": "https://api.orcarouter.ai/v1", + "orcarouter-oauth": "https://api.orcarouter.ai/v1", } _MINIMAX_MODELS = ( @@ -815,6 +820,17 @@ def cmd_wizard(args: argparse.Namespace) -> int: "deepseek-ai/deepseek-v4-pro", ["deepseek-ai/deepseek-v4-flash"], ), + # Cold-start seed for OrcaRouter. Live discovery replaces this list when + # GET https://api.orcarouter.ai/v1/models succeeds; these entries are the + # verified fallback only (see researchclaw/llm/orcarouter_catalog.py). + "orcarouter": ( + "deepseek/deepseek-v4-pro", + ["deepseek/deepseek-v4-flash", "orcarouter/auto"], + ), + "orcarouter-oauth": ( + "deepseek/deepseek-v4-pro", + ["deepseek/deepseek-v4-flash", "orcarouter/auto"], + ), } @@ -856,6 +872,8 @@ def cmd_init(args: argparse.Namespace) -> int: print(" 8) minimax-global-anthropic (requires MINIMAX_API_KEY)") print(" 9) minimax-cn-anthropic (requires MINIMAX_API_KEY)") print(" 10) atlascloud (requires ATLASCLOUD_API_KEY)") + print(" 11) orcarouter (requires ORCAROUTER_API_KEY — paste sk-orca-…)") + print(" 12) orcarouter-oauth (connect an OrcaRouter account via PKCE)") try: raw = input("Choice [1]: ").strip() except (EOFError, KeyboardInterrupt): @@ -900,6 +918,11 @@ def cmd_init(args: argparse.Namespace) -> int: content = content.replace( 'api_key_env: "OPENAI_API_KEY"', f'api_key_env: "{api_key_env}"' ) + if provider == "orcarouter-oauth": + # No env var: the credential is the key the connect flow stores. + content = content.replace( + 'api_key_env: "ORCAROUTER_API_KEY"', 'api_key_env: ""' + ) if provider in _PROVIDER_MODELS: primary, fallbacks = _PROVIDER_MODELS[provider] @@ -931,6 +954,19 @@ def cmd_init(args: argparse.Namespace) -> int: print(" 2. Export your API key: export MINIMAX_API_KEY=...") print(" 3. Edit config.arc.yaml to customize your settings") print(" 4. Run: researchclaw doctor") + elif provider == "orcarouter": + print("\nNext steps:") + print(" 1. Paste an existing key: researchclaw orcarouter key --set") + print(" 2. Or export one: export ORCAROUTER_API_KEY=sk-orca-...") + print(" 3. Or connect an account: researchclaw orcarouter login") + print(" 4. Check the connection: researchclaw orcarouter status") + print(" 5. Run: researchclaw doctor") + elif provider == "orcarouter-oauth": + print("\nNext steps:") + print(" 1. Connect your OrcaRouter account: researchclaw orcarouter login") + print(" (add --flow oob on a machine with no browser callback)") + print(" 2. List available models: researchclaw orcarouter models") + print(" 3. Run: researchclaw doctor") else: env_var = api_key_env or "OPENAI_API_KEY" print(f"\nNext steps:") @@ -1481,6 +1517,60 @@ def build_parser() -> argparse.ArgumentParser: _ = cal_p.add_argument("--plan", help="Generate submission timeline for a venue") _ = cal_p.add_argument("--domains", nargs="+", help="Filter by domain") + # OrcaRouter provider: credentials and model catalogue + orca_p = sub.add_parser( + "orcarouter", + help="OrcaRouter provider: log in, manage the API key, list models", + ) + orca_sub = orca_p.add_subparsers(dest="orcarouter_command") + + orca_login = orca_sub.add_parser( + "login", help="Connect an OrcaRouter account with OAuth 2.0 + PKCE" + ) + _ = orca_login.add_argument( + "--flow", + choices=("auto", "loopback", "oob"), + default="auto", + help="loopback = local browser callback (default when possible); " + "oob = show a code to paste (no browser callback)", + ) + _ = orca_login.add_argument("--app-name", default="", help="Name shown on the consent screen") + _ = orca_login.add_argument("--scope", default="api", help="Requested scope (default: api)") + _ = orca_login.add_argument("--login-hint", default="", help="Pre-fill the account email") + _ = orca_login.add_argument("--no-browser", action="store_true", help="Print the URL instead of opening a browser") + + orca_key = orca_sub.add_parser("key", help="Store, show, or clear the OrcaRouter API key") + _ = orca_key.add_argument("--set", action="store_true", help="Prompt for an sk-orca-… key and store it") + _ = orca_key.add_argument("--stdin", action="store_true", help="Read the key from stdin instead of prompting") + _ = orca_key.add_argument("--clear", action="store_true", help="Delete the stored key") + + orca_status = orca_sub.add_parser("status", help="Show credential state for both OrcaRouter entries") + _ = orca_status.add_argument("--json", action="store_true", help="Emit JSON") + + orca_models = orca_sub.add_parser( + "models", help="List models available to this OrcaRouter account" + ) + _ = orca_models.add_argument( + "--capability", + choices=("chat", "embedding", "image", "video", "rerank"), + default="chat", + help="Which capability to list (default: chat)", + ) + _ = orca_models.add_argument( + "--modality", + action="append", + default=[], + help="Require a declared input modality (e.g. --modality image); " + "undecided models are excluded", + ) + _ = orca_models.add_argument("--json", action="store_true", help="Emit JSON") + _ = orca_models.add_argument("--refresh", action="store_true", help="Ignore the cached catalogue") + + orca_logout = orca_sub.add_parser( + "logout", help="Forget the PKCE-issued credential (revoke it in the console)" + ) + _ = orca_logout.add_argument("--yes", action="store_true", help="Do not ask for confirmation") + # HITL: Attach to running pipeline attach_p = sub.add_parser("attach", help="Attach to a running/paused pipeline for HITL interaction") _ = attach_p.add_argument("run_dir", help="Path to run artifacts directory") @@ -1548,6 +1638,8 @@ def main(argv: list[str] | None = None) -> int: return cmd_skills(args) elif command == "profile": return cmd_profile(args) + elif command == "orcarouter": + return cmd_orcarouter(args) elif command == "attach": return cmd_attach(args) elif command == "status": @@ -1568,6 +1660,335 @@ def main(argv: list[str] | None = None) -> int: # --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# OrcaRouter provider subcommand +# --------------------------------------------------------------------------- + + +def _orca_endpoints(): + from researchclaw.llm.orcarouter import resolve_endpoints + + return resolve_endpoints() + + +def _orca_print_endpoints(endpoints) -> None: + print("OrcaRouter endpoints") + print(f" auth (consent + exchange): {endpoints.auth_base} [{endpoints.auth_source}]") + print(f" inference + model list: {endpoints.api_base} [{endpoints.api_source}]") + print( + " overrides: ORCA_AUTH_BASE_URL / ORCA_API_BASE_URL, or the shared " + "self-hosted ORCA_BASE_URL" + ) + + +def cmd_orcarouter(args: argparse.Namespace) -> int: + """OrcaRouter credential lifecycle and model catalogue.""" + from researchclaw.llm import orcarouter as orca + + sub = getattr(args, "orcarouter_command", None) + store = orca.CredentialStore() + endpoints = _orca_endpoints() + + if sub == "key": + api_source = orca.ApiKeySource(store) + if getattr(args, "clear", False): + existed = store.status(orca.PROVIDER_ID).configured + api_source.clear() + print( + "Removed the stored OrcaRouter API key." + if existed + else "No stored OrcaRouter API key to remove." + ) + env_key = os.environ.get(orca.DEFAULT_API_KEY_ENV, "") + if env_key: + print( + f"Note: {orca.DEFAULT_API_KEY_ENV} is still set in this shell " + "and takes precedence." + ) + return 0 + if getattr(args, "set", False) or getattr(args, "stdin", False): + if getattr(args, "stdin", False) or not sys.stdin.isatty(): + raw = sys.stdin.readline() if not sys.stdin.isatty() else input( + "OrcaRouter API key (sk-orca-…): " + ) + else: + import getpass + + raw = getpass.getpass("OrcaRouter API key (sk-orca-…): ") + try: + status = api_source.save(raw) + except orca.OrcaConfigError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + print(f"Stored OrcaRouter API key for provider '{orca.PROVIDER_ID}'.") + print(f" {status.masked}") + print( + " stored in " + f"{store.path} (mode 0600). Replaces any previous key; " + "never logged." + ) + return 0 + status = api_source.status() + if status.configured: + print(f"OrcaRouter API key: {status.masked} (source: {status.grant_id or 'store'})") + else: + print("OrcaRouter API key: not configured") + print(f" set one with: researchclaw orcarouter key --set") + print(f" or export {orca.DEFAULT_API_KEY_ENV}=sk-orca-…") + print(f" keys are managed at {orca.KEY_DASHBOARD_URL}") + return 0 + + if sub == "status": + api_source, pkce_source = orca.build_credential_sources(store=store) + api_status = api_source.status() + pkce_status = pkce_source.status() + if getattr(args, "json", False): + payload = { + "endpoints": endpoints.describe(), + "providers": { + orca.PROVIDER_ID: api_status.as_dict(), + orca.PROVIDER_ID_PKCE: pkce_status.as_dict(), + }, + } + print(json.dumps(payload, indent=2)) + return 0 + _orca_print_endpoints(endpoints) + print() + print(f"{orca.PROVIDER_LABEL} (provider id: {orca.PROVIDER_ID})") + if api_status.configured: + print(f" key: {api_status.masked} (source: {api_status.grant_id or 'store'})") + else: + print(" key: not configured — researchclaw orcarouter key --set") + print() + print(f"{orca.PROVIDER_LABEL_PKCE} (provider id: {orca.PROVIDER_ID_PKCE})") + if pkce_status.configured: + print(f" key: {pkce_status.masked}") + print(f" account: {pkce_status.grant_id or 'unknown'}") + print(f" scope: {pkce_status.scope or 'api'}") + if pkce_status.needs_reauth: + print( + " state: needs reauthorization — the relay rejected this " + "credential (401). Run: researchclaw orcarouter login" + ) + else: + print(" state: connected (durable key; reused until revoked)") + else: + print(" state: not connected — researchclaw orcarouter login") + print() + print(f" revoke access at {orca.REVOCATION_URL}") + return 0 + + if sub == "logout": + status = store.status(orca.PROVIDER_ID_PKCE) + if not status.configured: + print("No PKCE-issued OrcaRouter credential is stored.") + return 0 + if not getattr(args, "yes", False) and sys.stdin.isatty(): + answer = input("Forget the stored OrcaRouter credential? [y/N] ").strip() + if answer.lower() not in ("y", "yes"): + print("Left in place.") + return 0 + store.clear(orca.PROVIDER_ID_PKCE) + print("Forgot the stored OrcaRouter credential.") + print(f"To revoke the key itself, use {orca.REVOCATION_URL}") + return 0 + + if sub == "models": + return _cmd_orcarouter_models(args, store=store, endpoints=endpoints) + + if sub in ("login", None): + return _cmd_orcarouter_login(args, store=store, endpoints=endpoints) + + print(f"Unknown orcarouter subcommand: {sub}", file=sys.stderr) + return 2 + + +def _cmd_orcarouter_login( + args: argparse.Namespace, *, store, endpoints +) -> int: + from researchclaw.llm import orcarouter as orca + from researchclaw.llm.orcarouter_pkce import ( + PkceCancelled, + PkceDenied, + PkceError, + PkceStateMismatch, + PkceTimeout, + build_exchange_url, + ) + + existing = store.status(orca.PROVIDER_ID_PKCE) + if existing.configured and not existing.needs_reauth: + print( + "Already connected to OrcaRouter " + f"({existing.masked}, account {existing.grant_id or 'unknown'})." + ) + print( + "The stored key is durable and is reused until revoked; it is not " + "re-issued on every start (OrcaRouter allows 10 PKCE keys per user " + "per day)." + ) + print("Use --force on a future release to reauthorize, or revoke at " + f"{orca.REVOCATION_URL}") + return 0 + + flow = getattr(args, "flow", "auto") + try: + # Fail before the user is sent to a browser if the configured auth + # origin is really an inference base — that mistake only surfaces at + # exchange time as a confusing 404. + build_exchange_url(endpoints.auth_base) + pending = orca.start_connect( + flow=flow, + app_name=(getattr(args, "app_name", "") or orca.DEFAULT_APP_NAME), + scope=getattr(args, "scope", "api") or "api", + endpoints=endpoints, + login_hint=getattr(args, "login_hint", "") or "", + ) + except (ValueError, orca.OrcaConfigError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + print(f"OrcaRouter login ({pending.flow} flow, S256 PKCE)") + print(f" auth origin: {endpoints.auth_base}") + print() + print("Open this URL to authorize:") + print(f" {pending.authorize_url}") + print() + + if pending.flow == "loopback": + if not getattr(args, "no_browser", False): + _open_browser(pending.authorize_url) + print(f"Waiting for the browser callback on {pending.callback_url} …") + try: + code = pending.receiver.wait(timeout=300.0) + except PkceTimeout: + pending.close() + print("Error: timed out waiting for the callback.", file=sys.stderr) + return 1 + except PkceStateMismatch as exc: + pending.close() + print(f"Error: {exc}", file=sys.stderr) + return 1 + except PkceDenied as exc: + pending.close() + print(f"Authorization denied: {exc}", file=sys.stderr) + return 1 + except PkceCancelled as exc: + pending.close() + print(f"Cancelled: {exc}", file=sys.stderr) + return 1 + finally: + if pending.receiver is not None: + pending.close() + else: + print("Approve in the browser, then paste the code shown on the consent screen.") + try: + code = input("Code: ").strip() + except (EOFError, KeyboardInterrupt): + print("\nCancelled.", file=sys.stderr) + return 1 + + try: + credential = orca.complete_connect(pending, code, store=store, endpoints=endpoints) + except PkceError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + except orca.OrcaConfigError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + print() + print("Connected to OrcaRouter.") + print(f" credential: {credential.masked} (durable, reused until revoked)") + print(f" account: {credential.grant_id or 'unknown'}") + print(f" granted scope: {credential.scope}") + if credential.scope != "api": + print( + " note: the workspace granted a scope other than 'api'; wider " + "operations may be unavailable." + ) + print(f" stored in {store.path} (mode 0600, never logged)") + print() + print("Set llm.provider to \"orcarouter-oauth\" (or use --provider) and run: " + "researchclaw orcarouter models") + return 0 + + +def _open_browser(url: str) -> None: + import webbrowser + + try: + if webbrowser.open(url): + return + except Exception: # noqa: BLE001 - never block the login on a browser + pass + print("(Could not open a browser automatically — use the URL above.)") + + +def _cmd_orcarouter_models(args: argparse.Namespace, *, store, endpoints) -> int: + from researchclaw.llm import orcarouter as orca + from researchclaw.llm import orcarouter_catalog as catalog + + capability = getattr(args, "capability", "chat") or "chat" + modalities = tuple(getattr(args, "modality", []) or ()) + try: + credential = orca.resolve_credential(store=store) + except orca.OrcaAuthRequired as exc: + print(f"Not connected to OrcaRouter: {exc}", file=sys.stderr) + print( + " researchclaw orcarouter key --set # paste an sk-orca-… key", + file=sys.stderr, + ) + print( + " researchclaw orcarouter login # authorize an account", + file=sys.stderr, + ) + return 1 + + result = catalog.discover_models( + endpoints.api_base, + credential.api_key, + capability=capability, + required_input_modalities=modalities, + use_cache=not getattr(args, "refresh", False), + ) + + if getattr(args, "json", False): + print(json.dumps(result.as_dict(), indent=2)) + return 0 + + _orca_print_endpoints(endpoints) + print() + source = { + "live": f"live catalogue ({result.live_model_count} models returned)", + "cache": "last-known-good catalogue (live discovery failed)", + "seed": "verified cold-start seed (live discovery failed)", + }.get(result.source, result.source) + print(f"Capability: {capability}" + (f", input modalities: {list(modalities)}" if modalities else "")) + print(f"Source: {source}") + if result.degraded and result.error: + print(f" degraded: {result.error}") + print(" these entries are the verified fallback and are marked as such") + print() + if not result.models: + print("No models match this capability for this account.") + return 0 + for model in result.models: + bits = [] + if model.context_length: + bits.append(f"ctx {model.context_length}") + if model.input_modalities: + bits.append("in=" + "/".join(model.input_modalities)) + if model.reasoning_efforts: + bits.append("effort=" + "/".join(model.reasoning_efforts)) + suffix = (" [" + ", ".join(bits) + "]") if bits else "" + print(f" {model.id}{suffix}") + print() + print(f"{len(result.models)} model(s). The dropdown for this capability is built " + "from exactly this list.") + return 0 + + # ---- Wizard helpers (TTY prompts + autocomplete pick-lists) --------------- def _is_tty() -> bool: diff --git a/researchclaw/llm/__init__.py b/researchclaw/llm/__init__.py index ecd006a37..dbadb3c4a 100644 --- a/researchclaw/llm/__init__.py +++ b/researchclaw/llm/__init__.py @@ -17,6 +17,16 @@ "openrouter": { "base_url": "https://openrouter.ai/api/v1", }, + "orcarouter": { + "base_url": "https://api.orcarouter.ai/v1", + "label": "OrcaRouter — API", + "auth": "api_key", + }, + "orcarouter-oauth": { + "base_url": "https://api.orcarouter.ai/v1", + "label": "OrcaRouter — Auth", + "auth": "pkce", + }, "deepseek": { "base_url": "https://api.deepseek.com/v1", }, @@ -64,6 +74,10 @@ def create_llm_client(config: RCConfig) -> LLMClient | ACPClient: - providers with an ``"anthropic"`` adapter → :class:`LLMClient` with Anthropic Messages API support - ``"openrouter"`` → :class:`LLMClient` with OpenRouter base URL + - ``"orcarouter"`` → :class:`LLMClient` with the OrcaRouter base URL, + authenticating with a pasted ``sk-orca-…`` API key + - ``"orcarouter-oauth"`` → the same OrcaRouter endpoint, authenticating + with the durable key minted by the OAuth 2.0 + PKCE connect flow - ``"openai"`` → :class:`LLMClient` with OpenAI base URL - ``"deepseek"`` → :class:`LLMClient` with DeepSeek base URL - ``"atlascloud"`` → :class:`LLMClient` with Atlas Cloud base URL @@ -79,6 +93,17 @@ def create_llm_client(config: RCConfig) -> LLMClient | ACPClient: from researchclaw.llm.acp_client import ACPClient as _ACP return _ACP.from_rc_config(config) + if config.llm.provider in ("orcarouter", "orcarouter-oauth"): + # Both OrcaRouter entries share one endpoint, one model namespace and + # one catalogue; they differ only in which credential adapter on the + # shared seam is preferred. No authentication logic lives downstream + # of that seam. + from researchclaw.llm.orcarouter import build_orcarouter_client + + return build_orcarouter_client( + config, prefer=str(config.llm.provider) + ) + from researchclaw.llm.client import LLMClient as _LLM # Use from_rc_config to properly initialize adapters (e.g., Anthropic) @@ -145,3 +170,11 @@ def build_panel_llms(config: RCConfig) -> list: return clients except Exception: # noqa: BLE001 - never block the pipeline on panel setup return [] + + +__all__ = [ + "PROVIDER_PRESETS", + "build_panel_llms", + "build_reviewer_llm", + "create_llm_client", +] diff --git a/researchclaw/llm/client.py b/researchclaw/llm/client.py index 17337a0fa..7597a54ff 100644 --- a/researchclaw/llm/client.py +++ b/researchclaw/llm/client.py @@ -127,6 +127,16 @@ def from_rc_config(cls, rc_config: Any) -> LLMClient: preset = PROVIDER_PRESETS.get(provider, {}) preset_base_url = preset.get("base_url") + # OrcaRouter has two provider entries (pasted API key vs. PKCE-minted + # key) that share one endpoint, one model namespace and one catalogue. + # Both resolve through the same credential seam, so the reviewer, + # debate-panel and every other caller of this factory get whichever + # credential the user configured without duplicating auth logic. + if provider in ("orcarouter", "orcarouter-oauth"): + from researchclaw.llm.orcarouter import build_orcarouter_client + + return build_orcarouter_client(rc_config, prefer=provider) + api_key = str( rc_config.llm.api_key or os.environ.get(rc_config.llm.api_key_env, "") or "" ) @@ -204,6 +214,24 @@ def reviewer_from_rc_config(cls, rc_config: Any) -> "LLMClient | None": preset = PROVIDER_PRESETS.get(provider, {}) preset_base_url = preset.get("base_url") + if provider in ("orcarouter", "orcarouter-oauth"): + # The reviewer talks to the same gateway with the same credential + # seam; only the model differs. It keeps an empty fallback chain so + # its judgement stays decoupled from the generator. + from researchclaw.llm.orcarouter import build_orcarouter_client + + base = build_orcarouter_client(rc_config, prefer=provider) + return cls( + LLMConfig( + base_url=base.config.base_url, + api_key=base.config.api_key, + wire_api=getattr(llm, "wire_api", "chat_completions"), + primary_model=reviewer_model, + fallback_models=[], + timeout_sec=getattr(llm, "timeout_sec", 600), + ) + ) + base_url = ( str(getattr(llm, "reviewer_base_url", "") or "").strip() or llm.base_url diff --git a/researchclaw/llm/model_select.py b/researchclaw/llm/model_select.py new file mode 100644 index 000000000..60413b08d --- /dev/null +++ b/researchclaw/llm/model_select.py @@ -0,0 +1,290 @@ +"""The model dropdown: one capability-filtered option list per entry point. + +This is the single place the rest of the project asks "which models may this +entry point offer right now?", so the filtering rules live here rather than +being re-implemented per surface. It is deliberately provider-aware: for any +provider other than OrcaRouter it returns ``None`` and callers keep their +existing behaviour untouched. + +Two OrcaRouter-specific guarantees: + +* When the user's attachment or task type changes, the *options handed to the + selector* change — filtering the dropdown is the control, not a pre-send + guard. +* A previously selected model that is no longer compatible is cleared, never + silently kept. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any, Mapping, Sequence + +from researchclaw.llm import orcarouter as orca +from researchclaw.llm import orcarouter_catalog as catalog +from researchclaw.llm.orcarouter_catalog import ( + CAPABILITY_CHAT, + CatalogModel, + CatalogResult, +) + +logger = logging.getLogger(__name__) + +ORCAROUTER_PROVIDERS = frozenset({orca.PROVIDER_ID, orca.PROVIDER_ID_PKCE}) + +#: Which declared input modality an AI entry point actually uploads. +MODALITY_IMAGE = "image" +MODALITY_AUDIO = "audio" +MODALITY_VIDEO = "video" + + +@dataclass(frozen=True) +class ModelOption: + """One selectable model, minimal metadata only.""" + + id: str + label: str + context_length: int = 0 + input_modalities: tuple[str, ...] = () + reasoning_efforts: tuple[str, ...] = () + verified: bool = False + + def as_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "label": self.label, + "context_length": self.context_length, + "input_modalities": list(self.input_modalities), + "reasoning_efforts": list(self.reasoning_efforts), + "verified": self.verified, + } + + +@dataclass +class ModelSelectorState: + """The options for one model selector, plus what happened to the old value.""" + + provider: str + capability: str + required_input_modalities: tuple[str, ...] = () + options: tuple[ModelOption, ...] = () + selected: str = "" + selection_cleared: bool = False + clear_reason: str = "" + source: str = "live" + degraded: bool = False + error: str = "" + catalog_source_url: str = "" + + @property + def ids(self) -> list[str]: + return [option.id for option in self.options] + + @property + def is_free_text(self) -> bool: + """A selector over a real catalogue is never free text.""" + return False + + def as_dict(self) -> dict[str, Any]: + return { + "provider": self.provider, + "capability": self.capability, + "required_input_modalities": list(self.required_input_modalities), + "options": [option.as_dict() for option in self.options], + "selected": self.selected, + "selection_cleared": self.selection_cleared, + "clear_reason": self.clear_reason, + "source": self.source, + "degraded": self.degraded, + "error": self.error, + "catalog_source_url": self.catalog_source_url, + } + + +def is_orcarouter(config: Any) -> bool: + return str(getattr(getattr(config, "llm", None), "provider", "") or "") in ( + ORCAROUTER_PROVIDERS + ) + + +def capability_for_entry_point(entry_point: str) -> str: + """Map a project AI entry point onto an OrcaRouter catalogue capability.""" + normalized = (entry_point or "").strip().lower() + if normalized in ("chat", "agent", "completion", "code", "review", "debate"): + return CAPABILITY_CHAT + if normalized in ("embedding", "embeddings", "rag"): + return catalog.CAPABILITY_EMBEDDING + if normalized in ("image", "image-generation", "figure"): + return catalog.CAPABILITY_IMAGE + if normalized in ("video", "video-generation"): + return catalog.CAPABILITY_VIDEO + if normalized in ("rerank", "reranking"): + return catalog.CAPABILITY_RERANK + raise ValueError(f"unknown AI entry point: {entry_point!r}") + + +def required_modalities_for_attachments(attachments: Sequence[str]) -> tuple[str, ...]: + """Declared modalities a request actually uploads. + + Anything that is not ``text`` is a hard requirement: a model that does + not declare it is excluded (fail closed). + """ + required: list[str] = [] + for attachment in attachments or (): + kind = (attachment or "").strip().lower() + if kind in ("", "text"): + continue + if kind not in (MODALITY_IMAGE, MODALITY_AUDIO, MODALITY_VIDEO): + # Unknown attachment kinds are their own modality, so no model + # can satisfy them and the list correctly comes back empty + # rather than quietly offering an incompatible model. + required.append(kind) + continue + if kind not in required: + required.append(kind) + return tuple(required) + + +def build_model_selector( + config: Any, + *, + entry_point: str = "chat", + attachments: Sequence[str] = (), + current_model: str = "", + store: orca.CredentialStore | None = None, + fetcher: catalog.Fetcher | None = None, + use_cache: bool = True, +) -> ModelSelectorState | None: + """Build the option list for one selector. + + Returns ``None`` for non-OrcaRouter providers so callers leave their + existing model controls alone. + """ + if not is_orcarouter(config): + return None + + provider = str(config.llm.provider) + capability = capability_for_entry_point(entry_point) + required = required_modalities_for_attachments(attachments) + endpoints = orca.resolve_endpoints() + + try: + credential = orca.resolve_credential( + store=store, + config_value=str(getattr(config.llm, "api_key", "") or ""), + api_key_env=str( + getattr(config.llm, "api_key_env", "") or orca.DEFAULT_API_KEY_ENV + ), + ) + api_key = credential.api_key + error = "" + except orca.OrcaAuthRequired as exc: + api_key = "" + error = str(exc) + + result: CatalogResult = catalog.discover_models( + endpoints.api_base, + api_key, + capability=capability, + required_input_modalities=required, + fetcher=fetcher, + use_cache=use_cache, + ) + + options = tuple( + ModelOption( + id=model.id, + label=model.label, + context_length=model.context_length, + input_modalities=model.input_modalities, + reasoning_efforts=model.reasoning_efforts, + verified=model.verified, + ) + for model in result.models + ) + + state = ModelSelectorState( + provider=provider, + capability=capability, + required_input_modalities=required, + options=options, + source=result.source, + degraded=result.degraded, + error=result.error or error, + catalog_source_url=catalog.catalog_url(endpoints.api_base, capability), + ) + apply_selection(state, current_model) + return state + + +def apply_selection(state: ModelSelectorState, current_model: str) -> ModelSelectorState: + """Keep a remembered model only if it is still in the filtered options.""" + candidate = (current_model or "").strip() + if not candidate: + return state + if candidate in state.ids: + state.selected = candidate + return state + state.selection_cleared = True + state.clear_reason = ( + f"{candidate!r} is not available for {state.capability}" + + ( + f" with input modalities {list(state.required_input_modalities)}" + if state.required_input_modalities + else "" + ) + + " on OrcaRouter; choose another model." + ) + return state + + +def model_options_for( + config: Any, + *, + entry_point: str = "chat", + attachments: Sequence[str] = (), + current_model: str = "", + **kwargs: Any, +) -> list[ModelOption] | None: + """Convenience wrapper for callers that only need the option list.""" + state = build_model_selector( + config, + entry_point=entry_point, + attachments=attachments, + current_model=current_model, + **kwargs, + ) + return None if state is None else list(state.options) + + +def resolve_primary_model(config: Any, models: Sequence[CatalogModel]) -> str: + """Pick the model the pipeline should use when none is configured. + + Prefers the configured model when it is still offered, then the routing + alias, then the first available model — never an invented id. + """ + configured = str(getattr(config.llm, "primary_model", "") or "").strip() + ids = [model.id for model in models] + if configured and configured in ids: + return configured + if "orcarouter/auto" in ids: + return "orcarouter/auto" + return ids[0] if ids else "" + + +__all__ = [ + "MODALITY_AUDIO", + "MODALITY_IMAGE", + "MODALITY_VIDEO", + "ModelOption", + "ModelSelectorState", + "ORCAROUTER_PROVIDERS", + "apply_selection", + "build_model_selector", + "capability_for_entry_point", + "is_orcarouter", + "model_options_for", + "required_modalities_for_attachments", + "resolve_primary_model", +] diff --git a/researchclaw/llm/orcarouter.py b/researchclaw/llm/orcarouter.py new file mode 100644 index 000000000..ea51493e3 --- /dev/null +++ b/researchclaw/llm/orcarouter.py @@ -0,0 +1,858 @@ +"""OrcaRouter as a first-class provider: origins, credentials, and the seam. + +OrcaRouter is an OpenAI-compatible AI gateway. This module holds the pieces +that are independent of the wire protocol: + +* **Origins.** Authentication and inference live on *different* public + origins — ``https://www.orcarouter.ai`` (consent screen at ``/auth``, + exchange at ``/api/v1/auth/keys``) and ``https://api.orcarouter.ai/v1`` + (inference and model discovery). Neither is derived from the other by + swapping a hostname or appending ``/v1``. +* **The credential seam.** :class:`CredentialSource` is a two-method + interface for *obtaining a credential*. :class:`ApiKeySource` (the user + pastes an ``sk-orca-…`` key) and :class:`PkceSource` (OAuth 2.0 + PKCE + mints one) are its two adapters. Both hand downstream code the same + :class:`OrcaCredential`, so the provider client, model discovery, and every + AI entry point stay ignorant of how the key was obtained and never + duplicate authentication logic. +* **Credential lifecycle.** A PKCE-issued key is a *durable API key*, not a + refreshable OAuth token: it is reused until OrcaRouter revokes it, and a + ``401`` from the relay is a terminal reauthentication for the exact + account *generation* that made the rejected request — never a refresh, and + never a state change for a newer credential. + +Keys are stored where the project already keeps user-level state, +``~/.researchclaw/``, in a ``0600`` file. No new secret store technology is +introduced, and no key is ever logged, printed, or returned to a browser. +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import time +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Mapping, Protocol, Sequence, runtime_checkable +from urllib.parse import urlparse + +from researchclaw.llm.orcarouter_pkce import ( + DEFAULT_APP_NAME, + DEFAULT_SCOPE, + PkceError, + PkceExchangeRejected, + PendingLogin, + ExchangeResult, + start_login, +) + +logger = logging.getLogger(__name__) + +PROVIDER_ID = "orcarouter" +PROVIDER_ID_PKCE = "orcarouter-oauth" +PROVIDER_LABEL = "OrcaRouter — API" +PROVIDER_LABEL_PKCE = "OrcaRouter — Auth" + +DEFAULT_AUTH_BASE = "https://www.orcarouter.ai" +DEFAULT_API_BASE = "https://api.orcarouter.ai/v1" +DEFAULT_API_KEY_ENV = "ORCAROUTER_API_KEY" +KEY_PREFIX = "sk-orca-" +KEY_DASHBOARD_URL = "https://www.orcarouter.ai/console/token" +REVOCATION_URL = "https://www.orcarouter.ai/console/authorized-apps" + +ENV_SHARED_BASE = "ORCA_BASE_URL" +ENV_AUTH_BASE = "ORCA_AUTH_BASE_URL" +ENV_API_BASE = "ORCA_API_BASE_URL" +ENV_CREDENTIALS_PATH = "ORCA_CREDENTIALS_PATH" + +_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "[::1]", "::1"}) + +_KEY_PATTERN = re.compile(r"sk-orca-[A-Za-z0-9._\-]{4,}") +_SK_PATTERN = re.compile(r"\bsk-[A-Za-z0-9._\-]{8,}") + + +class OrcaConfigError(ValueError): + """An origin or credential setting is unusable.""" + + +class OrcaAuthRequired(RuntimeError): + """No usable credential; the caller must run a login or paste a key.""" + + def __init__(self, reason: str, message: str = "") -> None: + super().__init__(message or reason) + self.reason = reason + + +def redact_secrets(text: str) -> str: + """Strip API-key-shaped substrings from text before it is logged/shown.""" + return _SK_PATTERN.sub("sk-***", _KEY_PATTERN.sub("sk-orca-***", text or "")) + + +def mask_secret(secret: str) -> str: + """A display-only mask. Never reversible, never the key itself.""" + if not secret: + return "" + if len(secret) <= 12: + return "•" * len(secret) + return f"{secret[:7]}…{secret[-4:]}" + + +def validate_origin(url: str, *, name: str) -> str: + """Require HTTPS for remote origins; HTTP only for loopback.""" + candidate = (url or "").strip().rstrip("/") + if not candidate: + raise OrcaConfigError(f"{name} must not be empty") + parsed = urlparse(candidate) + if parsed.scheme not in ("http", "https"): + raise OrcaConfigError(f"{name} must be an http(s) URL, got {candidate!r}") + host = (parsed.hostname or "").lower() + if not host: + raise OrcaConfigError(f"{name} has no host: {candidate!r}") + if parsed.scheme == "http" and host not in _LOOPBACK_HOSTS: + raise OrcaConfigError( + f"{name} must use HTTPS unless it is loopback, got {candidate!r}" + ) + if parsed.username or parsed.password: + raise OrcaConfigError(f"{name} must not contain userinfo") + return candidate + + +@dataclass(frozen=True) +class OrcaEndpoints: + """Resolved, validated origins for one OrcaRouter installation.""" + + auth_base: str = DEFAULT_AUTH_BASE + api_base: str = DEFAULT_API_BASE + auth_source: str = "default" + api_source: str = "default" + + @property + def authorize_url_base(self) -> str: + return self.auth_base + + def describe(self) -> dict[str, str]: + return { + "auth_base": self.auth_base, + "api_base": self.api_base, + "auth_source": self.auth_source, + "api_source": self.api_source, + } + + +def resolve_endpoints(env: Mapping[str, str] | None = None) -> OrcaEndpoints: + """Resolve origins from the environment. + + Precedence, explicit first: ``ORCA_AUTH_BASE_URL`` / ``ORCA_API_BASE_URL``, + then the shared self-hosted fallback ``ORCA_BASE_URL``, then the public + defaults. A single-origin self-hosted deployment sets only + ``ORCA_BASE_URL`` and both flows follow it. + """ + environ = os.environ if env is None else env + shared = (environ.get(ENV_SHARED_BASE) or "").strip() + auth_raw = (environ.get(ENV_AUTH_BASE) or "").strip() + api_raw = (environ.get(ENV_API_BASE) or "").strip() + + auth_source = "explicit" if auth_raw else ("shared" if shared else "default") + api_source = "explicit" if api_raw else ("shared" if shared else "default") + + auth_base = validate_origin( + auth_raw or shared or DEFAULT_AUTH_BASE, name=ENV_AUTH_BASE + ) + api_base = validate_origin(api_raw or shared or DEFAULT_API_BASE, name=ENV_API_BASE) + + # The relay lives at /v1 on the API origin; a shared self-hosted base is + # an origin, not a relay path, so the /v1 segment is added once here. + if not api_base.rstrip("/").endswith("/v1"): + api_base = f"{api_base.rstrip('/')}/v1" + return OrcaEndpoints( + auth_base=auth_base, + api_base=api_base, + auth_source=auth_source, + api_source=api_source, + ) + + +# --------------------------------------------------------------------------- +# Credential +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class OrcaCredential: + """The single downstream currency: a normal OrcaRouter API key. + + ``entry_id``/``generation`` identify the exact account credential a + request was made with, so a late ``401`` can be attributed to the + generation that earned it. + """ + + api_key: str + source: str # "api_key" | "pkce" + entry_id: str + generation: int = 0 + grant_id: str = "" + scope: str = DEFAULT_SCOPE + created_at: float = 0.0 + + @property + def masked(self) -> str: + return mask_secret(self.api_key) + + @property + def account_key(self) -> tuple[str, str, int]: + return (self.entry_id, self.grant_id, self.generation) + + +@dataclass(frozen=True) +class CredentialStatus: + """Everything a UI may show about one credential entry — no secrets.""" + + entry_id: str + source: str + configured: bool = False + masked: str = "" + scope: str = "" + grant_id: str = "" + generation: int = 0 + needs_reauth: bool = False + created_at: float = 0.0 + + def as_dict(self) -> dict[str, Any]: + return { + "entry_id": self.entry_id, + "source": self.source, + "configured": self.configured, + "secret_masked": self.masked, + "scope": self.scope, + "grant_id": self.grant_id, + "generation": self.generation, + "needs_reauth": self.needs_reauth, + "created_at": self.created_at, + } + + +class CredentialStore: + """The project's existing user-level state dir, with 0600 file mode. + + The file is *not* a new secret store: it is the same + ``~/.researchclaw/`` tree the CLI already uses for skills, profiles and + hooks. + """ + + def __init__(self, path: Path | str | None = None) -> None: + if path is None: + override = (os.environ.get(ENV_CREDENTIALS_PATH) or "").strip() + path = ( + Path(override).expanduser() + if override + else Path.home() / ".researchclaw" / "orcarouter" / "credentials.json" + ) + self.path = Path(path).expanduser() + + # -- io ------------------------------------------------------------ + def _read(self) -> dict[str, Any]: + try: + raw = self.path.read_text(encoding="utf-8") + except FileNotFoundError: + return {"version": 1, "accounts": {}} + except OSError as exc: + logger.warning("Could not read OrcaRouter credentials: %s", exc) + return {"version": 1, "accounts": {}} + try: + data = json.loads(raw) + except json.JSONDecodeError: + logger.warning("OrcaRouter credential file is corrupt; ignoring it") + return {"version": 1, "accounts": {}} + if not isinstance(data, dict): + return {"version": 1, "accounts": {}} + accounts = data.get("accounts") + if not isinstance(accounts, dict): + data["accounts"] = {} + return data + + def _write(self, data: dict[str, Any]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + try: + os.chmod(self.path.parent, 0o700) + except OSError: + pass + tmp = self.path.with_suffix(".tmp") + tmp.write_text(json.dumps(data, indent=2), encoding="utf-8") + os.chmod(tmp, 0o600) + tmp.replace(self.path) + + # -- api ----------------------------------------------------------- + def get(self, entry_id: str) -> dict[str, Any]: + entry = self._read()["accounts"].get(entry_id) + return dict(entry) if isinstance(entry, dict) else {} + + def entries(self) -> dict[str, dict[str, Any]]: + accounts = self._read()["accounts"] + return {k: dict(v) for k, v in accounts.items() if isinstance(v, dict)} + + def credential(self, entry_id: str) -> OrcaCredential | None: + entry = self.get(entry_id) + key = str(entry.get("api_key") or "") + if not key: + return None + return OrcaCredential( + api_key=key, + source=str(entry.get("source") or entry_id), + entry_id=entry_id, + generation=int(entry.get("generation") or 0), + grant_id=str(entry.get("grant_id") or ""), + scope=str(entry.get("scope") or DEFAULT_SCOPE), + created_at=float(entry.get("created_at") or 0.0), + ) + + def status(self, entry_id: str) -> CredentialStatus: + entry = self.get(entry_id) + key = str(entry.get("api_key") or "") + return CredentialStatus( + entry_id=entry_id, + source=str(entry.get("source") or entry_id), + configured=bool(key), + masked=mask_secret(key), + scope=str(entry.get("scope") or ""), + grant_id=str(entry.get("grant_id") or ""), + generation=int(entry.get("generation") or 0), + needs_reauth=bool(entry.get("needs_reauth")), + created_at=float(entry.get("created_at") or 0.0), + ) + + def save( + self, + entry_id: str, + api_key: str, + *, + source: str, + grant_id: str = "", + scope: str = DEFAULT_SCOPE, + ) -> OrcaCredential: + """Persist a key and bump the generation. + + Bumping the generation is what makes the later ``401`` transition + generation-safe: a failure reported by a request made with an older + generation cannot mark the new credential broken. + """ + data = self._read() + previous = data["accounts"].get(entry_id) or {} + generation = int(previous.get("generation") or 0) + 1 + data["accounts"][entry_id] = { + "api_key": api_key, + "source": source, + "grant_id": grant_id, + "scope": scope, + "generation": generation, + "created_at": time.time(), + "needs_reauth": False, + } + self._write(data) + return OrcaCredential( + api_key=api_key, + source=source, + entry_id=entry_id, + generation=generation, + grant_id=grant_id, + scope=scope, + created_at=float(data["accounts"][entry_id]["created_at"]), + ) + + def clear(self, entry_id: str) -> bool: + data = self._read() + existed = entry_id in data["accounts"] + data["accounts"].pop(entry_id, None) + if existed: + self._write(data) + return existed + + def mark_needs_reauth(self, entry_id: str, generation: int) -> bool: + """Mark *exactly* this credential generation as unusable. + + Returns True when the mark was applied. A stale generation is a + no-op: a late failure from an old request must never invalidate a + credential that has since been reauthorized. The stored secret is + deliberately kept so the user can see what was rejected and a + transient misclassification is not irreversible. + """ + data = self._read() + entry = data["accounts"].get(entry_id) + if not isinstance(entry, dict): + return False + if int(entry.get("generation") or 0) != int(generation): + logger.info( + "Ignoring stale OrcaRouter 401 for %s generation %s (current %s)", + entry_id, + generation, + entry.get("generation"), + ) + return False + if entry.get("needs_reauth"): + return False + entry["needs_reauth"] = True + self._write(data) + return True + + +# --------------------------------------------------------------------------- +# The credential seam: two adapters, one result type +# --------------------------------------------------------------------------- + + +@runtime_checkable +class CredentialSource(Protocol): + """Where a credential comes from. Nothing downstream needs more.""" + + id: str + + def acquire(self) -> OrcaCredential: # pragma: no cover - protocol + ... + + def status(self) -> CredentialStatus: # pragma: no cover - protocol + ... + + def clear(self) -> None: # pragma: no cover - protocol + ... + + +class ApiKeySource: + """Adapter 1 — a user-supplied ``sk-orca-…`` key. + + Resolution order matches the rest of the project: explicit config value, + then the env var named for the provider, then whatever the user stored + through the UI/CLI. No network call is made to "validate" the key: an + ``sk-orca-`` prefix is a format check, not proof, and OrcaRouter exposes + no non-billing validation request. + """ + + id = PROVIDER_ID + label = PROVIDER_LABEL + kind = "api_key" + + def __init__( + self, + store: CredentialStore, + *, + config_value: str = "", + api_key_env: str = DEFAULT_API_KEY_ENV, + environ: Mapping[str, str] | None = None, + ) -> None: + self.store = store + self.config_value = config_value or "" + self.api_key_env = api_key_env or DEFAULT_API_KEY_ENV + self._environ = environ + + def _env(self) -> Mapping[str, str]: + return os.environ if self._environ is None else self._environ + + @staticmethod + def looks_like_key(value: str) -> bool: + return bool(_KEY_PATTERN.fullmatch((value or "").strip())) + + def stored_key(self) -> str: + return str(self.store.get(self.id).get("api_key") or "") + + def raw_key(self) -> str: + return ( + self.config_value.strip() + or (self._env().get(self.api_key_env) or "").strip() + or self.stored_key() + ) + + def acquire(self) -> OrcaCredential: + key = self.raw_key() + if not key: + raise OrcaAuthRequired( + "no_api_key", + "No OrcaRouter API key configured. Paste an sk-orca-… key, or " + "use 'Connect with OrcaRouter' to authorize with your account.", + ) + entry = self.store.get(self.id) + if key == str(entry.get("api_key") or ""): + return self.store.credential(self.id) # type: ignore[return-value] + # A key from config/env is not persisted by us; it still gets a + # stable identity so 401 attribution stays generation-safe. + return OrcaCredential( + api_key=key, + source="api_key", + entry_id=f"{self.id}:{self.api_key_env}" if not self.config_value else self.id, + generation=0, + scope=DEFAULT_SCOPE, + ) + + def save(self, api_key: str) -> CredentialStatus: + value = (api_key or "").strip() + if not value: + raise OrcaConfigError("API key must not be empty") + if not self.looks_like_key(value): + raise OrcaConfigError( + "That does not look like an OrcaRouter key — they start with " + f"'{KEY_PREFIX}'. Copy one from {KEY_DASHBOARD_URL}." + ) + self.store.save(self.id, value, source="api_key") + return self.status() + + def status(self) -> CredentialStatus: + stored = self.store.status(self.id) + if stored.configured: + return stored + env_key = (self._env().get(self.api_key_env) or "").strip() + key = self.config_value.strip() or env_key + if not key: + return CredentialStatus(entry_id=self.id, source="api_key") + origin = "config" if self.config_value.strip() else self.api_key_env + return CredentialStatus( + entry_id=self.id, + source="api_key", + configured=True, + masked=mask_secret(key), + scope=DEFAULT_SCOPE, + grant_id=origin, + ) + + def clear(self) -> None: + self.store.clear(self.id) + + +class PkceSource: + """Adapter 2 — OAuth 2.0 + PKCE mints the same kind of key. + + The grant is durable: it is reused on every start until OrcaRouter + revokes it. There is no refresh grant to call, and this adapter never + invents one. + """ + + id = PROVIDER_ID_PKCE + label = PROVIDER_LABEL_PKCE + kind = "pkce" + + def __init__(self, store: CredentialStore) -> None: + self.store = store + + def acquire(self) -> OrcaCredential: + credential = self.store.credential(self.id) + if credential is None: + raise OrcaAuthRequired( + "not_connected", + "Not connected to OrcaRouter. Use 'Connect with OrcaRouter' to " + "authorize this app with your account.", + ) + if self.store.status(self.id).needs_reauth: + raise OrcaAuthRequired( + "needs_reauth", + "Your OrcaRouter authorization was revoked or rejected. " + f"Reconnect, or check {REVOCATION_URL}.", + ) + return credential + + def status(self) -> CredentialStatus: + return self.store.status(self.id) + + def persist(self, result: ExchangeResult) -> OrcaCredential: + """Store an exchange result as a durable credential.""" + return self.store.save( + self.id, + result.api_key, + source="pkce", + grant_id=result.user_id, + scope=result.scope or DEFAULT_SCOPE, + ) + + def clear(self) -> None: + self.store.clear(self.id) + + +def build_credential_sources( + *, + store: CredentialStore | None = None, + config_value: str = "", + api_key_env: str = DEFAULT_API_KEY_ENV, + environ: Mapping[str, str] | None = None, +) -> tuple[ApiKeySource, PkceSource]: + """The two adapters over one credential seam, in a stable order.""" + shared_store = store or CredentialStore() + return ( + ApiKeySource( + shared_store, + config_value=config_value, + api_key_env=api_key_env, + environ=environ, + ), + PkceSource(shared_store), + ) + + +def resolve_credential( + *, + store: CredentialStore | None = None, + config_value: str = "", + api_key_env: str = DEFAULT_API_KEY_ENV, + prefer: str = "", + environ: Mapping[str, str] | None = None, +) -> OrcaCredential: + """Resolve *one* credential through the seam, for any consumer. + + ``prefer`` pins a specific adapter id (the provider the user selected); + otherwise an existing API key wins over a stored PKCE grant, because the + API key is the more explicit choice. + """ + api_source, pkce_source = build_credential_sources( + store=store, + config_value=config_value, + api_key_env=api_key_env, + environ=environ, + ) + ordered: list[CredentialSource] = [api_source, pkce_source] + if prefer: + ordered.sort(key=lambda s: 0 if s.id == prefer else 1) + errors: list[OrcaAuthRequired] = [] + for source in ordered: + try: + return source.acquire() + except OrcaAuthRequired as exc: + errors.append(exc) + raise errors[-1] if errors else OrcaAuthRequired("no_credential") + + +def handle_unauthorized( + credential: OrcaCredential, *, store: CredentialStore | None = None +) -> bool: + """Terminal handling for a relay ``401``. + + Marks exactly the rejected credential generation as ``needs_reauth``. + Deliberately does NOT delete the stored secret (a transient or + misclassified failure must not become irreversible account loss) and + does NOT attempt a refresh — there is no refresh grant. + """ + target = store or CredentialStore() + if not credential.entry_id or credential.entry_id.startswith( + f"{PROVIDER_ID}:" + ): + # Key came from config/env; there is nothing of ours to mark. + return False + return target.mark_needs_reauth(credential.entry_id, credential.generation) + + +# --------------------------------------------------------------------------- +# Provider glue +# --------------------------------------------------------------------------- + + +@dataclass +class OrcaRouterProvider: + """OrcaRouter bound to one resolved credential and one endpoint pair. + + Every AI entry point in the project reaches OrcaRouter through here (via + :func:`build_orcarouter_client`), so none of them re-implement + authentication or catalogue logic. + """ + + credential: OrcaCredential + endpoints: OrcaEndpoints = field(default_factory=OrcaEndpoints) + wire_api: str = "chat_completions" + timeout_sec: int = 600 + + def llm_config( + self, + *, + primary_model: str = "", + fallback_models: Sequence[str] = (), + ): + """An :class:`LLMConfig` for the project's existing OpenAI client.""" + from researchclaw.llm.client import LLMConfig + + return LLMConfig( + base_url=self.endpoints.api_base, + api_key=self.credential.api_key, + wire_api=self.wire_api, + primary_model=primary_model or "orcarouter/auto", + fallback_models=list(fallback_models), + timeout_sec=self.timeout_sec, + ) + + def build_client( + self, + *, + primary_model: str = "", + fallback_models: Sequence[str] = (), + ): + """The project's standard :class:`LLMClient`, pointed at OrcaRouter.""" + from researchclaw.llm.client import LLMClient + + return LLMClient( + self.llm_config( + primary_model=primary_model, fallback_models=fallback_models + ) + ) + + @property + def masked(self) -> str: + return self.credential.masked + + +def build_orcarouter_client( + config: Any = None, + *, + store: CredentialStore | None = None, + prefer: str = "", + endpoints: OrcaEndpoints | None = None, +) -> Any: + """Build the project's OpenAI-compatible client for OrcaRouter. + + Used by :func:`researchclaw.llm.create_llm_client` for both the + ``orcarouter`` and ``orcarouter-oauth`` provider ids — the two entries + differ only in which credential adapter is preferred. + """ + llm = getattr(config, "llm", None) + credential = resolve_credential( + store=store, + config_value=str(getattr(llm, "api_key", "") or ""), + api_key_env=str( + getattr(llm, "api_key_env", "") or DEFAULT_API_KEY_ENV + ), + prefer=prefer, + ) + provider = OrcaRouterProvider( + credential=credential, + endpoints=endpoints or resolve_endpoints(), + wire_api=str(getattr(llm, "wire_api", "") or "chat_completions"), + timeout_sec=int(getattr(llm, "timeout_sec", 600) or 600), + ) + primary = str(getattr(llm, "primary_model", "") or "").strip() + fallbacks = tuple(getattr(llm, "fallback_models", ()) or ()) + if not primary: + # No model configured: resolve one from the account's own catalogue + # rather than inventing an id the workspace may not be able to call. + primary, fallbacks = _discover_default_models(provider) + return provider.build_client(primary_model=primary, fallback_models=fallbacks) + + +def _discover_default_models( + provider: OrcaRouterProvider, +) -> tuple[str, tuple[str, ...]]: + """A usable model chain from the live catalogue (or the verified seed). + + Only ids the catalogue actually offered are returned. + """ + from researchclaw.llm import orcarouter_catalog as _catalog + + try: + result = _catalog.discover_models( + provider.endpoints.api_base, + provider.credential.api_key, + capability=_catalog.CAPABILITY_CHAT, + ) + except Exception: # noqa: BLE001 - never block client construction + logger.debug("OrcaRouter catalogue unavailable while picking a model") + return "", () + ids = [model.id for model in result.models] + if not ids: + return "", () + # Prefer models that *declare* a text modality. The catalogue lists plain + # routing aliases too, and a given workspace key may not be able to call + # one (the relay answers 403 model_access_denied) — so an alias is a + # fallback of last resort, not the default. + declared = [ + model.id for model in result.models if "text" in model.input_modalities + ] or ids + primary = declared[0] + fallbacks = tuple(model_id for model_id in declared if model_id != primary)[:3] + return primary, fallbacks + + +def start_connect( + *, + flow: str = "auto", + app_name: str = DEFAULT_APP_NAME, + scope: str = DEFAULT_SCOPE, + endpoints: OrcaEndpoints | None = None, + login_hint: str = "", +) -> PendingLogin: + """Begin a PKCE login against the configured auth origin.""" + resolved = endpoints or resolve_endpoints() + return start_login( + flow, + auth_base=resolved.auth_base, + app_name=app_name, + scope=scope, + login_hint=login_hint, + ) + + +def complete_connect( + pending: PendingLogin, + code: str, + *, + store: CredentialStore | None = None, + endpoints: OrcaEndpoints | None = None, + post_json: Any = None, +) -> OrcaCredential: + """Exchange the code and persist the resulting durable key.""" + resolved = endpoints or resolve_endpoints() + result = pending.exchange(resolved.auth_base, code, post_json=post_json) + return PkceSource(store or CredentialStore()).persist(result) + + +def model_options_for_entry_point( + config: Any, + *, + entry_point: str = "chat", + attachments: Sequence[str] = (), + current_model: str = "", + **kwargs: Any, +): + """The capability-filtered model options for one AI entry point. + + Returns ``None`` for any provider that is not OrcaRouter, so callers keep + their existing model control untouched. + """ + from researchclaw.llm.model_select import build_model_selector + + return build_model_selector( + config, + entry_point=entry_point, + attachments=attachments, + current_model=current_model, + **kwargs, + ) + + +__all__ = [ + "ApiKeySource", + "CredentialSource", + "CredentialStatus", + "CredentialStore", + "DEFAULT_API_BASE", + "DEFAULT_API_KEY_ENV", + "DEFAULT_AUTH_BASE", + "KEY_DASHBOARD_URL", + "OrcaAuthRequired", + "OrcaConfigError", + "OrcaCredential", + "OrcaEndpoints", + "OrcaRouterProvider", + "PkceError", + "PkceExchangeRejected", + "PkceSource", + "PROVIDER_ID", + "PROVIDER_ID_PKCE", + "PROVIDER_LABEL", + "PROVIDER_LABEL_PKCE", + "REVOCATION_URL", + "build_credential_sources", + "build_orcarouter_client", + "model_options_for_entry_point", + "complete_connect", + "handle_unauthorized", + "mask_secret", + "model_options_for_entry_point", + "redact_secrets", + "resolve_credential", + "resolve_endpoints", + "start_connect", + "start_login", + "validate_origin", +] diff --git a/researchclaw/llm/orcarouter_catalog.py b/researchclaw/llm/orcarouter_catalog.py new file mode 100644 index 000000000..65e8b5c4d --- /dev/null +++ b/researchclaw/llm/orcarouter_catalog.py @@ -0,0 +1,617 @@ +"""OrcaRouter model discovery and capability filtering. + +The single source of truth for the model list is ``GET {api_base}/models`` on +the *inference* origin (``https://api.orcarouter.ai/v1/models``). The request +carries the user's own OrcaRouter bearer key, so the answer is the catalogue +that workspace can actually call. + +Rules implemented here, and the reason each exists: + +* **Bounded.** A catalogue response cannot consume unbounded time, bytes, or + memory: 10 s timeout, 512 KiB cap, 500 items, and only records whose shape + is understood. +* **Live is authoritative.** When discovery succeeds, only discovered models + are offered — a fallback seed is never mixed into a successful result. A + verified seed exists only for a cold start or an outage, is labelled as + degraded, and keeps its verified metadata. +* **Capability-filtered per entry point.** Text chat requires a text wire + endpoint and excludes image/video/rerank-only models; multimodal requires + the *declared* input modality, and fails closed when nothing is declared; + embedding/image/video/rerank require their exact endpoint type. Capability + is never inferred from a model's name. +* **Other providers are untouched.** Nothing here runs unless the selected + provider is OrcaRouter. +""" + +from __future__ import annotations + +import json +import logging +import os +import time +import urllib.error +import urllib.request +from dataclasses import dataclass, field, replace +from pathlib import Path +from typing import Any, Callable, Iterable, Mapping, Sequence + +logger = logging.getLogger(__name__) + +#: Wire endpoint types OrcaRouter advertises per model. +TEXT_ENDPOINT_TYPES = frozenset( + {"openai", "anthropic", "gemini", "openai-response"} +) +NON_TEXT_ENDPOINT_TYPES = frozenset( + {"image-generation", "openai-video", "jina-rerank"} +) + +CAPABILITY_CHAT = "chat" +CAPABILITY_EMBEDDING = "embedding" +CAPABILITY_IMAGE = "image" +CAPABILITY_VIDEO = "video" +CAPABILITY_RERANK = "rerank" + +#: capability -> (server-side ?capability= value, required endpoint types) +_CAPABILITY_RULES: dict[str, tuple[str | None, frozenset[str]]] = { + CAPABILITY_CHAT: (CAPABILITY_CHAT, TEXT_ENDPOINT_TYPES), + CAPABILITY_EMBEDDING: (CAPABILITY_EMBEDDING, frozenset({"embeddings"})), + CAPABILITY_IMAGE: (CAPABILITY_IMAGE, frozenset({"image-generation"})), + CAPABILITY_VIDEO: (None, frozenset({"openai-video"})), + CAPABILITY_RERANK: (None, frozenset({"jina-rerank"})), +} + +DEFAULT_TIMEOUT_SEC = 10.0 +DEFAULT_MAX_BYTES = 512 * 1024 +DEFAULT_MAX_ITEMS = 500 +DEFAULT_CACHE_TTL_SEC = 6 * 3600.0 + +ENV_CATALOG_TTL = "ORCA_CATALOG_TTL_SEC" +ENV_CACHE_DIR = "ORCA_CATALOG_CACHE_DIR" + + +@dataclass(frozen=True) +class CatalogModel: + """One catalogue row, reduced to what the client can act on.""" + + id: str + name: str = "" + context_length: int = 0 + max_completion_tokens: int = 0 + input_modalities: tuple[str, ...] = () + output_modalities: tuple[str, ...] = () + supported_endpoint_types: tuple[str, ...] = () + reasoning_efforts: tuple[str, ...] = () + verified: bool = False + provenance: str = "" + + @property + def label(self) -> str: + return self.name or self.id + + @property + def supports_chat(self) -> bool: + return bool(set(self.supported_endpoint_types) & TEXT_ENDPOINT_TYPES) + + def supports_input(self, modality: str) -> bool: + return modality in self.input_modalities + + def as_dict(self) -> dict[str, Any]: + """Minimal metadata for a browser — never a credential.""" + return { + "id": self.id, + "name": self.label, + "context_length": self.context_length, + "max_completion_tokens": self.max_completion_tokens, + "input_modalities": list(self.input_modalities), + "output_modalities": list(self.output_modalities), + "supported_endpoint_types": list(self.supported_endpoint_types), + "reasoning_efforts": list(self.reasoning_efforts), + "verified": self.verified, + } + + +def _seed() -> tuple[CatalogModel, ...]: + """A small, verified cold-start catalogue. + + Provenance, per entry: + + * ``orcarouter/auto`` — the routing alias documented by OrcaRouter; also + present in the live catalogue. + * ``openai/gpt-5.5`` — canonical OrcaRouter seed entry, retaining its + verified reasoning-effort ladder (low/medium/high/xhigh). Effort + metadata is not exposed by ``/v1/models``, so it is preserved here + rather than dropped. + * ``anthropic/claude-opus-4.8``, ``google/gemini-3.5-flash`` — canonical + OrcaRouter seed entries (no effort ladder claimed for them). + * ``deepseek/deepseek-v4-pro``, ``deepseek/deepseek-v4-flash`` — present + in the live catalogue on 2026-09-16 with a 1 048 576-token context and + declared ``text`` input. + """ + return ( + CatalogModel( + id="orcarouter/auto", + name="OrcaRouter: Auto", + supported_endpoint_types=("openai", "openai-response", "anthropic", "gemini"), + verified=True, + provenance="orcarouter routing alias", + ), + CatalogModel( + id="openai/gpt-5.5", + name="OpenAI: GPT-5.5", + context_length=400000, + input_modalities=("text", "image"), + output_modalities=("text",), + supported_endpoint_types=("openai", "openai-response"), + reasoning_efforts=("low", "medium", "high", "xhigh"), + verified=True, + provenance="OrcaRouter canonical seed (reasoning ladder verified)", + ), + CatalogModel( + id="anthropic/claude-opus-4.8", + name="Anthropic: Claude Opus 4.8", + context_length=200000, + input_modalities=("text", "image"), + output_modalities=("text",), + supported_endpoint_types=("anthropic", "openai"), + verified=True, + provenance="OrcaRouter canonical seed", + ), + CatalogModel( + id="google/gemini-3.5-flash", + name="Google: Gemini 3.5 Flash", + context_length=1000000, + input_modalities=("text", "image", "audio", "video"), + output_modalities=("text",), + supported_endpoint_types=("gemini", "openai"), + verified=True, + provenance="OrcaRouter canonical seed", + ), + CatalogModel( + id="deepseek/deepseek-v4-pro", + name="DeepSeek: DeepSeek V4 Pro", + context_length=1048576, + max_completion_tokens=384000, + input_modalities=("text",), + output_modalities=("text",), + supported_endpoint_types=("openai", "openai-response"), + verified=True, + provenance="live catalogue 2026-09-16", + ), + CatalogModel( + id="deepseek/deepseek-v4-flash", + name="DeepSeek: DeepSeek V4 Flash", + context_length=1048576, + max_completion_tokens=384000, + input_modalities=("text",), + output_modalities=("text",), + supported_endpoint_types=("openai", "openai-response"), + verified=True, + provenance="live catalogue 2026-09-16", + ), + ) + + +VERIFIED_SEED: tuple[CatalogModel, ...] = _seed() + + +def seed_for_capability(capability: str, required_input_modalities: Sequence[str] = ()) -> tuple[CatalogModel, ...]: + """The verified seed, filtered by the same capability rules as live.""" + return tuple( + filter_models(VERIFIED_SEED, capability, required_input_modalities=required_input_modalities) + ) + + +def _declares_only_non_text(model: CatalogModel) -> bool: + declared_in = set(model.input_modalities) + declared_out = set(model.output_modalities) + if declared_in and "text" not in declared_in: + return True + if declared_out and "text" not in declared_out: + return True + return False + + +def matches_capability( + model: CatalogModel, + capability: str, + *, + required_input_modalities: Sequence[str] = (), +) -> bool: + """Does *model* belong in a selector for *capability*? + + Fail-closed: a model that does not *declare* a required non-text input + modality is excluded, never assumed compatible. + """ + rule = _CAPABILITY_RULES.get(capability) + if rule is None: + raise ValueError(f"unknown capability: {capability!r}") + _, required_endpoints = rule + endpoints = set(model.supported_endpoint_types) + + if capability == CAPABILITY_CHAT: + if not (endpoints & TEXT_ENDPOINT_TYPES): + return False + if endpoints & NON_TEXT_ENDPOINT_TYPES: + return False + if _declares_only_non_text(model): + return False + for modality in required_input_modalities: + if modality == "text": + continue + if modality not in set(model.input_modalities): + return False + return True + + # Non-text capabilities require their exact wire endpoint. + if not (endpoints & required_endpoints): + return False + for modality in required_input_modalities: + if modality not in set(model.input_modalities): + return False + return True + + +def filter_models( + models: Iterable[CatalogModel], + capability: str, + *, + required_input_modalities: Sequence[str] = (), +) -> list[CatalogModel]: + return [ + model + for model in models + if matches_capability( + model, + capability, + required_input_modalities=required_input_modalities, + ) + ] + + +def parse_models(payload: Any, *, max_items: int = DEFAULT_MAX_ITEMS) -> list[CatalogModel]: + """Translate a ``/v1/models`` payload into :class:`CatalogModel` rows. + + Unknown records are skipped instead of trusted; the vendor/model + namespace in ``id`` is preserved verbatim. + """ + if isinstance(payload, dict): + rows = payload.get("data") + else: + rows = payload + if not isinstance(rows, list): + return [] + parsed: list[CatalogModel] = [] + for row in rows[:max_items]: + if not isinstance(row, dict): + continue + model_id = row.get("id") + if not isinstance(model_id, str) or not model_id.strip(): + continue + architecture = row.get("architecture") + architecture = architecture if isinstance(architecture, dict) else {} + + def _modalities(key: str) -> tuple[str, ...]: + value = architecture.get(key) + if not isinstance(value, list): + return () + return tuple(str(v) for v in value if isinstance(v, str) and v) + + endpoints = row.get("supported_endpoint_types") + endpoints = ( + tuple(str(v) for v in endpoints if isinstance(v, str) and v) + if isinstance(endpoints, list) + else () + ) + efforts = row.get("reasoning_efforts") or architecture.get("reasoning_efforts") + efforts = ( + tuple(str(v) for v in efforts if isinstance(v, str) and v) + if isinstance(efforts, list) + else () + ) + + def _int(value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 + + parsed.append( + CatalogModel( + id=model_id.strip(), + name=str(row.get("name") or ""), + context_length=_int(row.get("context_length")), + max_completion_tokens=_int(row.get("max_completion_tokens")), + input_modalities=_modalities("input_modalities"), + output_modalities=_modalities("output_modalities"), + supported_endpoint_types=endpoints, + reasoning_efforts=efforts, + verified=False, + provenance="live", + ) + ) + return parsed + + +def merge_verified_metadata( + models: Sequence[CatalogModel], seed: Sequence[CatalogModel] = VERIFIED_SEED +) -> list[CatalogModel]: + """Keep verified metadata for models the live catalogue also lists. + + Live discovery must not *reduce* a known model's capabilities (reasoning + ladder, declared modalities, context window), and must not *add* a model + the live catalogue did not return. + """ + by_id = {model.id: model for model in seed} + merged: list[CatalogModel] = [] + for model in models: + known = by_id.get(model.id) + if known is None: + merged.append(model) + continue + merged.append( + replace( + model, + name=model.name or known.name, + context_length=model.context_length or known.context_length, + max_completion_tokens=( + model.max_completion_tokens or known.max_completion_tokens + ), + input_modalities=model.input_modalities or known.input_modalities, + output_modalities=model.output_modalities or known.output_modalities, + supported_endpoint_types=( + model.supported_endpoint_types or known.supported_endpoint_types + ), + reasoning_efforts=model.reasoning_efforts or known.reasoning_efforts, + verified=True, + provenance="live+verified", + ) + ) + return merged + + +@dataclass(frozen=True) +class CatalogResult: + """Outcome of one catalogue lookup — always usable, never a bare error.""" + + models: tuple[CatalogModel, ...] + source: str # "live" | "cache" | "seed" + capability: str + required_input_modalities: tuple[str, ...] = () + degraded: bool = False + error: str = "" + fetched_at: float = 0.0 + live_model_count: int = 0 + considered: int = 0 + + @property + def count(self) -> int: + return len(self.models) + + @property + def ids(self) -> list[str]: + return [model.id for model in self.models] + + def as_dict(self) -> dict[str, Any]: + return { + "models": [model.as_dict() for model in self.models], + "source": self.source, + "capability": self.capability, + "required_input_modalities": list(self.required_input_modalities), + "degraded": self.degraded, + "error": self.error, + "fetched_at": self.fetched_at, + "live_model_count": self.live_model_count, + "count": self.count, + } + + +Fetcher = Callable[[str, str, float, int], Any] + + +def _default_fetcher(url: str, api_key: str, timeout: float, max_bytes: int) -> Any: + request = urllib.request.Request( + url, + headers={ + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + "User-Agent": "AutoResearchClaw-orcarouter/1.0", + }, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + raw = response.read(max_bytes + 1) + if len(raw) > max_bytes: + raise ValueError("model catalogue exceeded the size cap") + return json.loads(raw.decode("utf-8")) + + +def catalog_url(api_base: str, capability: str) -> str: + base = api_base.rstrip("/") + if not base.endswith("/v1"): + base = f"{base}/v1" + rule = _CAPABILITY_RULES.get(capability) + server_capability = rule[0] if rule else None + if server_capability: + return f"{base}/models?capability={server_capability}" + return f"{base}/models" + + +def _cache_path(capability: str, cache_dir: Path | None) -> Path: + if cache_dir is None: + override = (os.environ.get(ENV_CACHE_DIR) or "").strip() + cache_dir = ( + Path(override).expanduser() + if override + else Path.home() / ".researchclaw" / "orcarouter" + ) + return Path(cache_dir) / f"catalog_{capability}.json" + + +def read_cache( + capability: str, *, cache_dir: Path | None = None, ttl: float | None = None +) -> list[CatalogModel] | None: + """Last-known-good catalogue, if it is still fresh. Not a secret.""" + path = _cache_path(capability, cache_dir) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(data, dict): + return None + age = time.time() - float(data.get("fetched_at") or 0.0) + effective_ttl = ( + ttl + if ttl is not None + else float(os.environ.get(ENV_CATALOG_TTL, DEFAULT_CACHE_TTL_SEC) or 0.0) + ) + if effective_ttl <= 0 or age > effective_ttl: + return None + return parse_models(data.get("payload")) + + +def write_cache( + capability: str, payload: Any, *, cache_dir: Path | None = None +) -> None: + path = _cache_path(capability, cache_dir) + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"fetched_at": time.time(), "payload": payload}), + encoding="utf-8", + ) + except OSError as exc: + logger.debug("Could not cache OrcaRouter catalogue: %s", exc) + + +def discover_models( + api_base: str, + api_key: str, + *, + capability: str = CAPABILITY_CHAT, + required_input_modalities: Sequence[str] = (), + timeout: float = DEFAULT_TIMEOUT_SEC, + max_bytes: int = DEFAULT_MAX_BYTES, + max_items: int = DEFAULT_MAX_ITEMS, + fetcher: Fetcher | None = None, + cache_dir: Path | None = None, + cache_ttl: float | None = None, + use_cache: bool = True, +) -> CatalogResult: + """Resolve one capability's model list, degrading instead of failing.""" + url = catalog_url(api_base, capability) + fetch = fetcher or _default_fetcher + error = "" + + if api_key: + try: + payload = fetch(url, api_key, timeout, max_bytes) + except (urllib.error.URLError, OSError, ValueError, json.JSONDecodeError) as exc: + error = _describe_fetch_error(exc) + else: + live = merge_verified_metadata(parse_models(payload, max_items=max_items)) + if use_cache: + write_cache(capability, payload, cache_dir=cache_dir) + selected = filter_models( + live, + capability, + required_input_modalities=required_input_modalities, + ) + return CatalogResult( + models=tuple(selected), + source="live", + capability=capability, + required_input_modalities=tuple(required_input_modalities), + degraded=False, + fetched_at=time.time(), + live_model_count=len(live), + considered=len(live), + ) + else: + error = "no OrcaRouter credential configured" + + if use_cache: + cached = read_cache(capability, cache_dir=cache_dir, ttl=cache_ttl) + if cached: + selected = filter_models( + merge_verified_metadata(cached), + capability, + required_input_modalities=required_input_modalities, + ) + if selected: + return CatalogResult( + models=tuple(selected), + source="cache", + capability=capability, + required_input_modalities=tuple(required_input_modalities), + degraded=True, + error=error, + fetched_at=time.time(), + live_model_count=len(cached), + considered=len(cached), + ) + + seed_models = seed_for_capability( + capability, required_input_modalities=required_input_modalities + ) + return CatalogResult( + models=seed_models, + source="seed", + capability=capability, + required_input_modalities=tuple(required_input_modalities), + degraded=True, + error=error, + fetched_at=time.time(), + live_model_count=0, + considered=len(VERIFIED_SEED), + ) + + +def _describe_fetch_error(exc: BaseException) -> str: + """A user-facing reason that never contains a credential.""" + if isinstance(exc, urllib.error.HTTPError): + if exc.code == 401: + return "OrcaRouter rejected the credential (HTTP 401)" + if exc.code == 403: + return "this key may not list models (HTTP 403)" + if exc.code == 429: + return "rate limited while listing models (HTTP 429)" + return f"model catalogue request failed (HTTP {exc.code})" + if isinstance(exc, (urllib.error.URLError, OSError)): + return f"could not reach the model catalogue ({type(exc).__name__})" + return "model catalogue response was not usable" + + +def is_model_available( + model_id: str, + models: Sequence[CatalogModel], +) -> bool: + """Re-validate a remembered selection before restoring it.""" + return any(model.id == model_id for model in models) + + +__all__ = [ + "CAPABILITY_CHAT", + "CAPABILITY_EMBEDDING", + "CAPABILITY_IMAGE", + "CAPABILITY_RERANK", + "CAPABILITY_VIDEO", + "CatalogModel", + "CatalogResult", + "DEFAULT_MAX_BYTES", + "DEFAULT_MAX_ITEMS", + "DEFAULT_TIMEOUT_SEC", + "ENV_CACHE_DIR", + "ENV_CATALOG_TTL", + "NON_TEXT_ENDPOINT_TYPES", + "TEXT_ENDPOINT_TYPES", + "VERIFIED_SEED", + "catalog_url", + "discover_models", + "filter_models", + "is_model_available", + "matches_capability", + "merge_verified_metadata", + "parse_models", + "read_cache", + "seed_for_capability", + "write_cache", +] diff --git a/researchclaw/llm/orcarouter_pkce.py b/researchclaw/llm/orcarouter_pkce.py new file mode 100644 index 000000000..204c20aeb --- /dev/null +++ b/researchclaw/llm/orcarouter_pkce.py @@ -0,0 +1,553 @@ +"""OrcaRouter OAuth 2.0 + PKCE connect flow (authorization code). + +This module implements the two PKCE flows OrcaRouter supports for a client +that has no client secret and no pre-registered redirect URI: + +* **Flow A — loopback redirect** (default for the CLI): bind ``127.0.0.1:0``, + open ``https://www.orcarouter.ai/auth?callback_url=http://127.0.0.1:/cb``, + receive ``?code=…&state=…`` on the local listener. +* **Flow B — out-of-band code**: ``callback_url=oob``. The consent screen + displays a code that the user pastes back. Used by SSH/container users and + by the hosted web UI, which cannot be reached on the user's loopback. + +Flow C (RFC 8628 device grant) is intentionally not implemented: the +integration requires PKCE, and the device grant is an alternative to it, not +a substitute. + +Security properties enforced here: + +* the verifier is 32 bytes from :func:`secrets.token_bytes` per attempt and + never leaves this process until the exchange — it is not put in a URL, a + log line, an exception message, or the credential store; +* the challenge is ``base64url(sha256(verifier))`` with no padding, and the + method is always ``S256`` (never ``plain``: in Flow A the user can still + choose "Show me a code" on the consent screen, which hands a human the + code); +* ``state`` is compared with :func:`hmac.compare_digest` before the code is + used; +* denial, state mismatch, timeout, cancellation, an expired/reused code + (403), a rejected request (400), rate limiting (429) and transport errors + all end the attempt with an actionable message and release every resource. + +The exchanged result is a durable OrcaRouter API key, **not** a refreshable +OAuth token: there is no refresh grant here, and none is invented. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import http.server +import json +import logging +import secrets +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass, field +from typing import Any, Callable + +logger = logging.getLogger(__name__) + +#: Authorize endpoint path on the auth origin (fixed by the protocol). +AUTHORIZE_PATH = "/auth" +#: Code-exchange endpoint on the auth origin. Note: the relay lives at +#: ``/v1`` on the API origin; the auth endpoints do NOT. Deriving this from +#: the API base by appending ``/v1`` is the single most common integration +#: mistake and it 404s, so the path is a constant here and +#: :func:`build_exchange_url` refuses an API-style base. +EXCHANGE_PATH = "/api/v1/auth/keys" + +DEFAULT_SCOPE = "api" +DEFAULT_APP_NAME = "AutoResearchClaw" +DEFAULT_TIMEOUT_SEC = 300.0 + +_CLOSE_TAB_PAGE = ( + "" + "AutoResearchClaw" + "" + "

OrcaRouter connected

" + "

You can close this tab and return to your terminal.

" +) + + +class PkceError(RuntimeError): + """Base class for every terminal PKCE failure. + + Messages are written to be shown to a user. They never contain the + verifier, the auth code, or an issued key. + """ + + kind = "error" + + +class PkceDenied(PkceError): + kind = "access_denied" + + +class PkceStateMismatch(PkceError): + kind = "state_mismatch" + + +class PkceTimeout(PkceError): + kind = "timeout" + + +class PkceCancelled(PkceError): + kind = "cancelled" + + +class PkceExchangeRejected(PkceError): + """The exchange endpoint refused the code.""" + + kind = "exchange_rejected" + + def __init__(self, status: int, kind: str, message: str) -> None: + super().__init__(message) + self.status = status + self.reason = kind + + +class PkceNetworkError(PkceError): + kind = "network" + + +def _b64url(raw: bytes) -> str: + """base64url without padding (RFC 7636 §A).""" + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def generate_verifier() -> str: + """A fresh 43-character verifier from a cryptographic RNG.""" + return _b64url(secrets.token_bytes(32)) + + +def challenge_for(verifier: str) -> str: + """``base64url(sha256(verifier))`` with no padding.""" + return _b64url(hashlib.sha256(verifier.encode("ascii")).digest()) + + +def generate_state() -> str: + """A fresh opaque CSRF token.""" + return _b64url(secrets.token_bytes(16)) + + +def build_authorize_url( + auth_base: str, + *, + callback_url: str, + challenge: str, + state: str, + app_name: str = DEFAULT_APP_NAME, + scope: str = DEFAULT_SCOPE, + login_hint: str = "", + workspace_hint: str = "", +) -> str: + """Build the consent-screen URL. + + The verifier is deliberately absent: only its SHA-256 challenge travels. + """ + if not challenge: + raise ValueError("code_challenge is required") + query = [ + ("callback_url", callback_url), + ("code_challenge", challenge), + ("code_challenge_method", "S256"), + ("state", state), + ("app_name", app_name), + ("scope", scope), + ] + if login_hint: + query.append(("login_hint", login_hint)) + if workspace_hint: + query.append(("workspace_hint", workspace_hint)) + base = auth_base.rstrip("/") + return f"{base}{AUTHORIZE_PATH}?{urllib.parse.urlencode(query)}" + + +def build_exchange_url(auth_base: str) -> str: + """Build the code-exchange URL, guarding against the ``/v1/auth/keys`` bug.""" + base = auth_base.rstrip("/") + if base.lower().endswith("/v1"): + raise ValueError( + "the exchange endpoint is on the auth origin, not on the " + f"inference origin: {base!r} looks like an API base (…/v1). " + "Authentication is at https://www.orcarouter.ai/api/v1/auth/keys; " + "https://api.orcarouter.ai/v1/auth/keys is a 404." + ) + return f"{base}{EXCHANGE_PATH}" + + +@dataclass(frozen=True) +class ExchangeResult: + """A durable OrcaRouter API key and the scope actually granted.""" + + api_key: str + scope: str = DEFAULT_SCOPE + user_id: str = "" + raw: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + if not self.api_key: + raise PkceExchangeRejected(200, "malformed_response", "no key in response") + if self.scope != DEFAULT_SCOPE: + logger.warning( + "OrcaRouter granted scope %r, not %r — the workspace role may " + "not permit the wider grant.", + self.scope, + DEFAULT_SCOPE, + ) + + +def _default_post_json( + url: str, payload: dict[str, Any], *, timeout: float +) -> tuple[int, bytes]: + body = json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + url, + data=body, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.status, response.read() + except urllib.error.HTTPError as exc: # status-code errors are answers + try: + return exc.code, exc.read() + except Exception: # noqa: BLE001 - body is optional + return exc.code, b"" + + +PostJson = Callable[..., tuple[int, bytes]] + + +def exchange_code( + auth_base: str, + *, + code: str, + verifier: str, + timeout: float = 30.0, + post_json: PostJson | None = None, +) -> ExchangeResult: + """Redeem an auth code for a durable API key. + + ``post_json`` is injectable so tests can drive the real code path against + a local fake auth server instead of the network. + """ + if not code: + raise PkceExchangeRejected(0, "missing_code", "no authorization code") + if not verifier: + raise PkceExchangeRejected(0, "missing_verifier", "no code verifier") + url = build_exchange_url(auth_base) + payload = { + "code": code, + "code_verifier": verifier, + "code_challenge_method": "S256", + } + poster = post_json or _default_post_json + try: + status, body = poster(url, payload, timeout=timeout) + except Exception as exc: # noqa: BLE001 - transport failures are terminal here + raise PkceNetworkError( + f"could not reach {url.split('/api/')[0]}: {type(exc).__name__}" + ) from exc + + if status == 200: + try: + data = json.loads(body.decode("utf-8")) + except Exception as exc: # noqa: BLE001 + raise PkceExchangeRejected( + 200, "malformed_response", "exchange returned non-JSON" + ) from exc + if not isinstance(data, dict): + raise PkceExchangeRejected( + 200, "malformed_response", "exchange returned non-object JSON" + ) + return ExchangeResult( + api_key=str(data.get("key") or ""), + scope=str(data.get("scope") or DEFAULT_SCOPE), + user_id=str(data.get("user_id") or ""), + raw=data, + ) + if status == 400: + raise PkceExchangeRejected( + status, + "invalid_request", + "the authorization code or its PKCE method was refused; start a new " + "login (S256 is always sent, so this usually means the code came " + "from a different authorization attempt)", + ) + if status == 403: + raise PkceExchangeRejected( + status, + "invalid_grant", + "that code is unknown, expired, or already used — start a new login", + ) + if status == 429: + raise PkceExchangeRejected( + status, + "rate_limited", + "too many authorizations for this account; wait a moment before " + "connecting again (OrcaRouter allows 10 PKCE keys per user per day)", + ) + raise PkceExchangeRejected( + status, "unexpected_status", f"exchange failed with HTTP {status}" + ) + + +class LoopbackReceiver: + """Flow A listener on ``127.0.0.1:`` serving ``/cb``.""" + + def __init__(self, state: str, *, path: str = "/cb") -> None: + self._state = state + self._path = path + self._result: str | None = None + self._error: PkceError | None = None + self._event = threading.Event() + self._server: http.server.HTTPServer | None = None + self._thread: threading.Thread | None = None + self.port = 0 + + # -- lifecycle ----------------------------------------------------- + def start(self) -> int: + receiver = self + + class _Handler(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *args: Any) -> None: # noqa: D102 - silence + return + + def do_GET(self) -> None: # noqa: N802 - stdlib naming + parsed = urllib.parse.urlparse(self.path) + if parsed.path != receiver._path: + self.send_response(404) + self.send_header("Content-Length", "0") + self.end_headers() + return + + params = urllib.parse.parse_qs(parsed.query) + body = _CLOSE_TAB_PAGE.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + got_state = (params.get("state") or [""])[0] + # Constant-time compare before the code is allowed to be used. + if not hmac.compare_digest(str(got_state), receiver._state): + receiver._fail( + PkceStateMismatch( + "the callback carried a different state than the one " + "this login sent; the code was discarded" + ) + ) + return + err = (params.get("error") or [""])[0] + if err: + receiver._fail( + PkceDenied( + "authorization was denied" + if err == "access_denied" + else f"authorization failed: {err}" + ) + ) + return + code = (params.get("code") or [""])[0] + if not code: + receiver._fail( + PkceExchangeRejected(0, "missing_code", "no code in callback") + ) + return + receiver._result = code + receiver._event.set() + + self._server = http.server.HTTPServer(("127.0.0.1", 0), _Handler) + self.port = int(self._server.server_address[1]) + self._thread = threading.Thread( + target=self._server.serve_forever, kwargs={"poll_interval": 0.2}, daemon=True + ) + self._thread.start() + return self.port + + def _fail(self, error: PkceError) -> None: + self._error = error + self._event.set() + + @property + def callback_url(self) -> str: + return f"http://127.0.0.1:{self.port}{self._path}" + + def wait(self, timeout: float) -> str: + if not self._event.wait(timeout): + raise PkceTimeout( + "timed out waiting for the browser to return the authorization code" + ) + if self._error is not None: + raise self._error + assert self._result is not None + return self._result + + def cancel(self) -> None: + self._fail(PkceCancelled("login cancelled")) + + def close(self) -> None: + server, thread = self._server, self._thread + self._server = None + self._thread = None + if server is not None: + try: + server.shutdown() + except Exception: # noqa: BLE001 - shutdown races are harmless + pass + server.server_close() + if thread is not None and thread.is_alive(): + thread.join(timeout=1.0) + + +@dataclass +class PendingLogin: + """One authorization attempt. Never persisted, never logged.""" + + flow: str + verifier: str + state: str + authorize_url: str + callback_url: str + receiver: LoopbackReceiver | None = None + created_at: float = 0.0 + generation: int = 0 + cancelled: bool = False + _verifier_used: bool = False + + def authorize_hint(self) -> str: + return self.authorize_url + + def take_code(self, code: str) -> str: + """Consume the code exactly once, guarding against reuse.""" + if self._verifier_used: + raise PkceExchangeRejected( + 403, "invalid_grant", "this login attempt was already completed" + ) + self._verifier_used = True + return code + + def exchange( + self, auth_base: str, code: str, *, post_json: PostJson | None = None + ) -> ExchangeResult: + return exchange_code( + auth_base, + code=self.take_code(code), + verifier=self.verifier, + post_json=post_json, + ) + + def close(self) -> None: + if self.receiver is not None: + self.receiver.close() + self.receiver = None + + def cancel(self) -> None: + """Release the listener and fail any waiter with a cancellation.""" + self.cancelled = True + if self.receiver is not None: + self.receiver.cancel() + self.receiver.close() + self.receiver = None + + +def start_loopback_login( + auth_base: str, + *, + app_name: str = DEFAULT_APP_NAME, + scope: str = DEFAULT_SCOPE, + login_hint: str = "", +) -> PendingLogin: + """Flow A: listen first (so the port is known), then build the URL.""" + verifier = generate_verifier() + state = generate_state() + receiver = LoopbackReceiver(state) + receiver.start() + return PendingLogin( + flow="loopback", + verifier=verifier, + state=state, + authorize_url=build_authorize_url( + auth_base, + callback_url=receiver.callback_url, + challenge=challenge_for(verifier), + state=state, + app_name=app_name, + scope=scope, + login_hint=login_hint, + ), + callback_url=receiver.callback_url, + receiver=receiver, + created_at=time.time(), + ) + + +def start_oob_login( + auth_base: str, + *, + app_name: str = DEFAULT_APP_NAME, + scope: str = DEFAULT_SCOPE, + login_hint: str = "", +) -> PendingLogin: + """Flow B: ``callback_url=oob``; S256 is mandatory and is always sent.""" + verifier = generate_verifier() + state = generate_state() + return PendingLogin( + flow="oob", + verifier=verifier, + state=state, + authorize_url=build_authorize_url( + auth_base, + callback_url="oob", + challenge=challenge_for(verifier), + state=state, + app_name=app_name, + scope=scope, + login_hint=login_hint, + ), + callback_url="oob", + created_at=time.time(), + ) + + +def start_login(flow: str = "auto", **kwargs: Any) -> PendingLogin: + """Start a PKCE login. + + ``flow="auto"`` picks loopback when a browser and a bindable loopback + interface are both available, and falls back to out-of-band otherwise. + """ + normalized = (flow or "auto").strip().lower() + if normalized in ("auto", ""): + normalized = "loopback" if loopback_available() else "oob" + if normalized in ("loopback", "a"): + return start_loopback_login(**kwargs) + if normalized in ("oob", "b", "out-of-band"): + return start_oob_login(**kwargs) + raise ValueError(f"unknown flow: {flow!r} (use 'loopback', 'oob', or 'auto')") + + +def loopback_available() -> bool: + """True when this host can bind a loopback listener for Flow A.""" + import socket + + probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + probe.bind(("127.0.0.1", 0)) + return True + except OSError: + return False + finally: + probe.close() diff --git a/researchclaw/server/app.py b/researchclaw/server/app.py index 965a8701f..2d501ae50 100644 --- a/researchclaw/server/app.py +++ b/researchclaw/server/app.py @@ -86,9 +86,11 @@ async def config_summary() -> dict[str, Any]: # --- Routes --- from researchclaw.server.routes.pipeline import router as pipeline_router from researchclaw.server.routes.projects import router as projects_router + from researchclaw.server.routes.providers import router as providers_router app.include_router(pipeline_router) app.include_router(projects_router) + app.include_router(providers_router) if not dashboard_only: from researchclaw.server.routes.chat import router as chat_router, set_chat_manager @@ -129,6 +131,17 @@ async def events_ws(websocket: WebSocket) -> None: async def index() -> FileResponse: return FileResponse(str(frontend_dir / "index.html")) + # --- Provider settings UI (self-contained, ships with the package) --- + provider_ui_dir = Path(__file__).resolve().parent / "static" + if provider_ui_dir.is_dir(): + # html=True serves index.html at the mount root, so the page and its + # assets share one prefix and the page can use relative URLs. + app.mount( + "/providers", + StaticFiles(directory=str(provider_ui_dir), html=True), + name="provider-ui", + ) + # --- Background tasks --- @app.on_event("startup") async def startup() -> None: diff --git a/researchclaw/server/routes/providers.py b/researchclaw/server/routes/providers.py new file mode 100644 index 000000000..08cf4ea89 --- /dev/null +++ b/researchclaw/server/routes/providers.py @@ -0,0 +1,382 @@ +"""OrcaRouter provider routes: status, model discovery, and PKCE connect. + +The browser never holds an OrcaRouter credential. The key lives in the +server's credential store (``~/.researchclaw/``, mode 0600) and the +catalogue endpoint returns minimal model metadata only, so a page cannot +leak a key it was never given. + +The connect flow keeps a single server-side login lock. Every terminal path +— success, denial, exchange error, timeout, explicit cancel, and a browser +``pagehide`` cancel — releases it, and attempts carry a monotonically +increasing generation so a late response can never overwrite a newer login. +""" + +from __future__ import annotations + +import logging +import threading +import time +import uuid +from dataclasses import dataclass, field +from typing import Any, Callable + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from researchclaw.llm import orcarouter as orca +from researchclaw.llm import orcarouter_catalog as catalog +from researchclaw.llm.orcarouter_pkce import ( + PkceCancelled, + PkceDenied, + PkceError, + PkceStateMismatch, + PkceTimeout, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/providers", tags=["providers"]) + +_LOGIN_TTL_SEC = 600.0 +_LOGIN_WAIT_SEC = 300.0 + + +@dataclass +class LoginAttempt: + """One in-flight PKCE login. Holds a verifier — never serialized.""" + + attempt_id: str + generation: int + flow: str + authorize_url: str + callback_url: str + pending: Any + busy: bool = True + status: str = "pending" # pending | connected | denied | error | cancelled + hint: str = "" + error: str = "" + secret_masked: str = "" + account: str = "" + scope: str = "" + created_at: float = 0.0 + thread: threading.Thread | None = None + lock: threading.Lock = field(default_factory=threading.Lock) + + def public(self) -> dict[str, Any]: + """State safe to hand a browser: no verifier, no code, no key.""" + with self.lock: + return { + "attempt_id": self.attempt_id, + "generation": self.generation, + "flow": self.flow, + "status": self.status, + "busy": self.busy, + "hint": self.hint, + "error": self.error, + "secret_masked": self.secret_masked, + "account": self.account, + "scope": self.scope, + "needs_code": self.flow == "oob" and self.status == "pending", + "authorize_url": self.authorize_url if self.status == "pending" else "", + } + + def finish( + self, + status: str, + *, + error: str = "", + credential: Any = None, + ) -> None: + with self.lock: + if self.status != "pending": + return # a terminal state was already recorded + self.status = status + self.busy = False + self.error = error + self.hint = "" + if credential is not None: + self.secret_masked = credential.masked + self.account = credential.grant_id + self.scope = credential.scope + + def cancel(self) -> None: + pending = self.pending + if pending is not None and hasattr(pending, "cancel"): + try: + pending.cancel() + except Exception: # noqa: BLE001 - cancelling must never raise + pass + self.close() + self.finish("cancelled", error="cancelled") + + def close(self) -> None: + pending = self.pending + if pending is not None and hasattr(pending, "close"): + try: + pending.close() + except Exception: # noqa: BLE001 + pass + self.pending = None + + +class _LoginRegistry: + """The single server-side login lock, plus attempt history.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._active: LoginAttempt | None = None + self._generation = 0 + + def _gc(self) -> None: + active = self._active + if active is None: + return + age = time.time() - active.created_at + if active.status == "pending" and age > _LOGIN_TTL_SEC: + active.cancel() + elif active.status != "pending" and age > _LOGIN_TTL_SEC: + active.close() + self._active = None + + def start(self, *, flow: str, app_name: str, scope: str) -> LoginAttempt: + with self._lock: + self._gc() + if self._active is not None and self._active.status == "pending": + raise HTTPException( + status_code=409, + detail=( + "A login is already in progress. Cancel it before " + "starting another." + ), + ) + self._generation += 1 + endpoints = orca.resolve_endpoints() + pending = orca.start_connect( + flow=flow, app_name=app_name, scope=scope, endpoints=endpoints + ) + attempt = LoginAttempt( + attempt_id=uuid.uuid4().hex, + generation=self._generation, + flow=pending.flow, + authorize_url=pending.authorize_url, + callback_url=pending.callback_url, + pending=pending, + created_at=time.time(), + hint="Waiting for you to approve access in the browser.", + ) + self._active = attempt + return attempt + + def get(self, attempt_id: str) -> LoginAttempt: + with self._lock: + active = self._active + if active is None or active.attempt_id != attempt_id: + raise HTTPException(status_code=404, detail="Unknown login attempt") + return active + + def active(self) -> LoginAttempt | None: + with self._lock: + self._gc() + return self._active + + def release(self, attempt: LoginAttempt) -> None: + with self._lock: + if self._active is attempt and attempt.status != "pending": + attempt.close() + + +_registry = _LoginRegistry() + + +def reset_for_tests() -> None: + """Drop all login state. Used by tests; not part of the public API.""" + with _registry._lock: # noqa: SLF001 - deliberate test hook + if _registry._active is not None: + _registry._active.close() + _registry._active = None + _registry._generation = 0 + + +class LoginRequest(BaseModel): + flow: str = "auto" + app_name: str = "AutoResearchClaw" + scope: str = "api" + + +class CodeRequest(BaseModel): + code: str + + +def _store() -> orca.CredentialStore: + return orca.CredentialStore() + + +@router.get("") +def list_providers() -> dict[str, Any]: + """Both OrcaRouter entries, with their non-secret credential state.""" + store = _store() + api_source, pkce_source = orca.build_credential_sources(store=store) + endpoints = orca.resolve_endpoints() + return { + "providers": [ + { + "id": orca.PROVIDER_ID, + "label": orca.PROVIDER_LABEL, + "kind": "api_key", + "base_url": endpoints.api_base, + "status": api_source.status().as_dict(), + }, + { + "id": orca.PROVIDER_ID_PKCE, + "label": orca.PROVIDER_LABEL_PKCE, + "kind": "pkce", + "base_url": endpoints.api_base, + "status": pkce_source.status().as_dict(), + }, + ], + "endpoints": endpoints.describe(), + "key_dashboard_url": orca.KEY_DASHBOARD_URL, + "revocation_url": orca.REVOCATION_URL, + } + + +@router.post("/orcarouter/key") +def save_api_key(payload: dict[str, str]) -> dict[str, Any]: + """Store a pasted ``sk-orca-…`` key. The key is never echoed back.""" + api_key = str(payload.get("api_key") or "") + source = orca.ApiKeySource(_store()) + try: + status = source.save(api_key) + except orca.OrcaConfigError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"status": status.as_dict()} + + +@router.delete("/orcarouter/key") +def clear_api_key() -> dict[str, Any]: + source = orca.ApiKeySource(_store()) + source.clear() + return {"status": source.status().as_dict()} + + +@router.get("/orcarouter/models") +def list_models( + capability: str = catalog.CAPABILITY_CHAT, + modality: str = "", + refresh: bool = False, +) -> dict[str, Any]: + """Capability-filtered model list for the selectors. + + Returns minimal metadata from the configured origin's ``/v1/models``; + the credential stays on the server. + """ + required = tuple(m for m in (modality or "").split(",") if m) + try: + credential = orca.resolve_credential(store=_store()) + except orca.OrcaAuthRequired as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + endpoints = orca.resolve_endpoints() + result = catalog.discover_models( + endpoints.api_base, + credential.api_key, + capability=capability, + required_input_modalities=required, + use_cache=not refresh, + ) + payload = result.as_dict() + payload["catalog_source"] = catalog.catalog_url(endpoints.api_base, capability) + payload["credential_source"] = credential.source + return payload + + +@router.get("/orcarouter/auth") +def auth_state() -> dict[str, Any]: + attempt = _registry.active() + return {"attempt": attempt.public() if attempt else None} + + +@router.post("/orcarouter/auth/login") +def auth_login(request: LoginRequest) -> dict[str, Any]: + attempt = _registry.start( + flow=request.flow, app_name=request.app_name, scope=request.scope + ) + if attempt.flow == "loopback": + attempt.thread = threading.Thread( + target=_await_loopback_callback, + args=(attempt,), + daemon=True, + ) + attempt.thread.start() + return {"attempt": attempt.public()} + + +def _await_loopback_callback(attempt: LoginAttempt) -> None: + """Background waiter: the HTTP request must not block on the browser.""" + pending = attempt.pending + try: + code = pending.receiver.wait(timeout=_LOGIN_WAIT_SEC) + _complete(attempt, code) + except PkceTimeout: + attempt.finish("error", error="Timed out waiting for the browser callback.") + except PkceStateMismatch as exc: + attempt.finish("error", error=str(exc)) + except PkceDenied as exc: + attempt.finish("denied", error=str(exc)) + except PkceCancelled: + attempt.finish("cancelled", error="cancelled") + except PkceError as exc: + attempt.finish("error", error=str(exc)) + except Exception as exc: # noqa: BLE001 - never leave the lock held + logger.exception("OrcaRouter loopback login failed") + attempt.finish("error", error=orca.redact_secrets(str(exc))) + finally: + attempt.close() + + +def _complete(attempt: LoginAttempt, code: str) -> None: + """Exchange a code and persist it, generation-guarded.""" + current = _registry.active() + if current is not attempt or attempt.generation != current.generation: + # A newer login superseded this one; drop the result on the floor. + return + try: + credential = orca.complete_connect(attempt.pending, code, store=_store()) + except PkceError as exc: + attempt.finish("error", error=str(exc)) + return + except orca.OrcaConfigError as exc: + attempt.finish("error", error=str(exc)) + return + current2 = _registry.active() + if current2 is not attempt or attempt.generation != current2.generation: + return + attempt.finish("connected", credential=credential) + + +@router.post("/orcarouter/auth/{attempt_id}/code") +def auth_submit_code(attempt_id: str, request: CodeRequest) -> dict[str, Any]: + attempt = _registry.get(attempt_id) + if attempt.status != "pending": + raise HTTPException(status_code=409, detail="This login attempt already ended") + _complete(attempt, request.code.strip()) + return {"attempt": attempt.public()} + + +@router.post("/orcarouter/auth/{attempt_id}/cancel") +def auth_cancel(attempt_id: str) -> dict[str, Any]: + """Release the login lock. Safe to call twice (e.g. unload + pagehide).""" + attempt = _registry.get(attempt_id) + attempt.cancel() + _registry.release(attempt) + return {"attempt": attempt.public()} + + +@router.post("/orcarouter/reauth") +def mark_reauth(payload: dict[str, Any]) -> dict[str, Any]: + """Terminal 401 handling for one exact credential generation.""" + entry_id = str(payload.get("entry_id") or "") + generation = int(payload.get("generation") or 0) + store = _store() + applied = store.mark_needs_reauth(entry_id, generation) + return {"applied": applied, "status": store.status(entry_id).as_dict()} diff --git a/researchclaw/server/static/index.html b/researchclaw/server/static/index.html new file mode 100644 index 000000000..a5d2e063c --- /dev/null +++ b/researchclaw/server/static/index.html @@ -0,0 +1,133 @@ + + + + + + ResearchClaw — OrcaRouter provider settings + + + +
+ + +
+ +
+

OrcaRouter — API

+

+ Use an existing sk-orca-… key. Stored server-side; it is + never sent to this page and never logged. +

+ +
+ + + Manage keys +
+

No key stored

+
+ + +
+

OrcaRouter — Auth

+

+ One click in your browser. The key is issued to your account, billed to + it, and revocable at any time. No client secret, no redirect URI to + pre-register. +

+
+ + + +
+

+ + +

Not connected

+
+ + +
+

Models

+

+ The list comes from the configured origin's /v1/models. It is + filtered per entry point: chat models for text, and only models that + declare image input when an attachment is present. +

+
+ + + +
+ +
+ + +
+

+ + +
+
+ + +
+ + + + diff --git a/researchclaw/server/static/orca-logo-classic.png b/researchclaw/server/static/orca-logo-classic.png new file mode 100644 index 0000000000000000000000000000000000000000..fcf66b698de9613492ecdcb06be88a7a8dacf6d6 GIT binary patch literal 74215 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 p_UcHjK? int: + report: dict[str, object] = {} + + if not os.environ.get("ORCAROUTER_API_KEY"): + print("ORCAROUTER_API_KEY is not set — cannot run the live check.", file=sys.stderr) + return 2 + + endpoints = orca.resolve_endpoints() + report["auth_origin"] = endpoints.auth_base + report["inference_origin"] = endpoints.api_base + + # 1. Catalogue through the shipped discovery path. + credential = orca.resolve_credential() + report["credential_source"] = credential.source + report["credential_masked"] = credential.masked + + chat = catalog.discover_models( + endpoints.api_base, credential.api_key, capability=catalog.CAPABILITY_CHAT + ) + report["catalog_source"] = catalog.catalog_url(endpoints.api_base, "chat") + report["chat_model_count"] = chat.count + report["chat_models"] = chat.ids + report["catalog_backend"] = chat.source + + # 2. The same options the UI/model selector receives. + selector = build_model_selector(_config()) + assert selector is not None + report["selector_option_count"] = len(selector.options) + # The relay does not guarantee a stable tail order; membership and count + # are what matter, and both come from the same live response. + report["selector_matches_catalog"] = ( + selector.ids == chat.ids or set(selector.ids) == set(chat.ids) + ) + report["selector_source"] = selector.source + + multimodal = build_model_selector(_config(), attachments=["image"]) + assert multimodal is not None + report["image_capable_chat_models"] = multimodal.ids + report["multimodal_is_subset"] = set(multimodal.ids) <= set(chat.ids) + report["multimodal_declared_only"] = all( + "image" in option.input_modalities for option in multimodal.options + ) + + # 3. A real inference call through the client the pipeline uses. The + # catalogue lists what the gateway can route; the *key* may still be + # scoped to a subset (the relay answers 403 model_access_denied), so the + # client's own fallback chain is exercised here. + # No model configured: the provider resolves one from the account's own + # catalogue, which is exactly the path a fresh OrcaRouter config takes. + client = create_llm_client(_config()) + report["inference_url"] = client._endpoint_url(client.config.base_url) + report["inference_chain"] = client._model_chain + report["inference_primary"] = client.config.primary_model + report["primary_is_from_catalog"] = client.config.primary_model in chat.ids + + try: + response = client.chat( + [{"role": "user", "content": "Reply with the single word: pong"}], + # Reasoning models spend budget on hidden reasoning first; a tiny + # cap can leave the visible answer empty and look like a failure. + max_tokens=256, + temperature=0, + ) + except Exception as exc: # noqa: BLE001 - report, do not crash + report["inference_ok"] = False + report["inference_error"] = orca.redact_secrets(str(exc))[:300] + else: + report["inference_ok"] = bool(response.content) + report["inference_reply_model"] = response.model + report["inference_reply"] = response.content[:60] + report["inference_usage"] = { + "prompt_tokens": response.prompt_tokens, + "completion_tokens": response.completion_tokens, + } + + # 4. Embedding/rerank entry points have no compatible model here. + report["embedding_model_count"] = len( + catalog.discover_models( + endpoints.api_base, + credential.api_key, + capability=catalog.CAPABILITY_EMBEDDING, + ).ids + ) + report["image_generation_model_count"] = len( + catalog.discover_models( + endpoints.api_base, + credential.api_key, + capability=catalog.CAPABILITY_IMAGE, + ).ids + ) + + ok = bool(report.get("inference_ok")) and report["selector_matches_catalog"] + report["passed"] = ok + print(json.dumps(report, indent=2)) + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/orcarouter_ui_evidence.py b/scripts/orcarouter_ui_evidence.py new file mode 100644 index 000000000..58c0dbe14 --- /dev/null +++ b/scripts/orcarouter_ui_evidence.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +"""Generate the OrcaRouter GUI evidence bundle. + +Boots the real FastAPI app (the one ``researchclaw serve`` builds), drives the +real ``/providers`` page in Chromium with Playwright, and writes +``{manifest.json,auth-methods.png,text-model-dropdown.png}``. + +The bundle is the reviewed artifact and a build product of a run: it is written +to ``orca-evidence/`` at the repository root, where the delivery checklist reads +it, and that directory is git-ignored so a bundle can never be carried in a +patch. ``--out`` / ``ORCA_EVIDENCE_OUT`` redirect it. +``tests/test_orcarouter_ui.py`` runs this script end to end and then holds the +bundle it wrote to the same checklist. + +The manifest is written in the gate's schema rather than a free-form report:: + + {"automation": {"framework": "playwright", "passed": true, + "catalog_source": "", + "catalog_model_count": N, "image_model_count": M}, + "artifacts": [{"kind": "auth-methods", "path": ..., "sha256": ..., "ui": {}}]} + +``validate_bundle`` re-checks that shape before the files are handed over, so a +bundle the gate would reject fails here instead of after the run. + +The API key used for the screenshots is a clearly-fake, key-shaped string, so +no real credential material can end up in an artifact. The live catalogue +request uses whatever credential the environment provides. + +Usage: + python scripts/orcarouter_ui_evidence.py [--out DIR] +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import socket +import struct +import sys +import threading +import time +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) +EVIDENCE_KEY = "sk-orca-evidence-placeholder-not-a-real-key" +CATALOG_URL = "https://api.orcarouter.ai/v1/models?capability=chat" + + +def _free_port() -> int: + probe = socket.socket() + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + probe.close() + return port + + +def _serve(port: int, state_dir: Path) -> threading.Thread: + os.environ.setdefault("ORCA_CREDENTIALS_PATH", str(state_dir / "credentials.json")) + os.environ.setdefault("ORCA_CATALOG_CACHE_DIR", str(state_dir / "catalog")) + + from researchclaw.config import RCConfig + from researchclaw.server.app import create_app + import uvicorn + + config = RCConfig.load( + str(REPO_ROOT / "config.researchclaw.example.yaml"), check_paths=False + ) + app = create_app(config) + server = uvicorn.Server( + uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning") + ) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + + for _ in range(100): + if getattr(server, "started", False): + break + time.sleep(0.1) + return thread + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _opaque_background(page, selector: str) -> bool: + """The panel must not be see-through.""" + value = page.eval_on_selector( + selector, "el => getComputedStyle(el).backgroundColor" + ) + if not value or value in ("transparent", "rgba(0, 0, 0, 0)"): + return False + if value.startswith("rgba"): + alpha = float(value.rsplit(",", 1)[1].strip().rstrip(")")) + return alpha >= 0.95 + return True + + +def _has_visible_border(page, selector: str) -> bool: + return page.eval_on_selector( + selector, + "el => { const s = getComputedStyle(el);" + " return parseFloat(s.borderTopWidth) > 0 && s.borderTopStyle !== 'none'; }", + ) + + +DEFAULT_OUT = os.environ.get("ORCA_EVIDENCE_OUT", str(REPO_ROOT / "orca-evidence")) +GATE_CATALOG_URL = "https://api.orcarouter.ai/v1/models?capability=chat" +REQUIRED_SHOTS = ("auth-methods", "text-model-dropdown") + + +def _png_size(path: Path) -> tuple[int, int]: + """Width/height straight out of the PNG IHDR chunk.""" + header = path.read_bytes()[:24] + if header[:8] != b"\x89PNG\r\n\x1a\n": + raise ValueError(f"{path.name} is not a PNG") + return struct.unpack(">II", header[16:24]) + + +def validate_bundle(bundle: dict, out: Path, multimodal: bool = False) -> dict: + """Reject a bundle the delivery gate would refuse, while it is still local. + + Mirrors the gate's checklist: Playwright provenance, the authoritative chat + catalogue URL, counts that agree with what was actually scraped, one PNG per + required entry point at 800x450 or larger with a matching digest, and the UI + assertions each screenshot has to prove. + """ + automation = bundle.get("automation") + if not isinstance(automation, dict) or automation.get("framework") != "playwright": + raise ValueError("evidence must come from Playwright") + if automation.get("passed") is not True: + raise ValueError("evidence automation did not pass") + if automation.get("catalog_source") != GATE_CATALOG_URL: + raise ValueError("evidence did not use the authoritative chat catalogue") + total = automation.get("catalog_model_count") + image = automation.get("image_model_count") + if not isinstance(total, int) or not isinstance(image, int) or not 0 <= image <= total: + raise ValueError("evidence manifest has invalid model counts") + if multimodal and image == 0: + raise ValueError("a multimodal entry point needs image models in the catalogue") + + kinds = REQUIRED_SHOTS + (("multimodal-model-dropdown",) if multimodal else ()) + declared = {item.get("kind"): item for item in bundle.get("artifacts") or []} + for kind in kinds: + item = declared.get(kind) + if not item: + raise ValueError(f"missing evidence screenshot: {kind}") + path = out / item["path"] + if not path.is_file() or path.stat().st_size < 10_000: + raise ValueError(f"evidence screenshot is missing or too small: {path}") + width, height = _png_size(path) + if width < 800 or height < 450: + raise ValueError(f"evidence screenshot must be at least 800x450: {path}") + if item.get("sha256") != _sha256(path): + raise ValueError(f"evidence checksum mismatch: {path}") + ui = item.get("ui") or {} + if kind == "auth-methods": + for field in ("api_key_visible", "pkce_visible", "secret_masked", "controls_enabled"): + if ui.get(field) is not True: + raise ValueError( + "evidence does not show usable API Key and PKCE authentication" + ) + else: + expected = total if kind == "text-model-dropdown" else image + if ui.get("dropdown_open") is not True or ui.get("item_count") != expected: + raise ValueError(f"evidence does not show the required open dropdown: {kind}") + if ui.get("opaque_background") is not True or ui.get("visible_border") is not True: + raise ValueError(f"evidence dropdown has no visible container: {kind}") + delta = ui.get("trigger_panel_right_delta") + if not isinstance(delta, (int, float)) or abs(delta) > 2: + raise ValueError(f"evidence dropdown is not aligned to its trigger: {kind}") + return bundle + + +def generate(out_dir: Path, state_dir: Path | None = None) -> dict: + """Boot the real app, drive ``/providers``, write the bundle, return it.""" + out = Path(out_dir).resolve() + out.mkdir(parents=True, exist_ok=True) + state_dir = ( + Path(state_dir).resolve() + if state_dir + else Path("/tmp/orcarouter-evidence-state") + ) + state_dir.mkdir(parents=True, exist_ok=True) + + port = _free_port() + _serve(port, state_dir) + base = f"http://127.0.0.1:{port}" + + from playwright.sync_api import sync_playwright + + ui: dict[str, object] = {} + playwright_errors: list[str] = [] + + with sync_playwright() as pw: + browser = pw.chromium.launch( + executable_path="/usr/bin/chromium", + args=["--no-sandbox", "--disable-dev-shm-usage"], + ) + page = browser.new_page(viewport={"width": 1280, "height": 900}) + page.goto(f"{base}/providers", wait_until="networkidle") + + # --- API-key entry: store a placeholder and confirm it is masked --- + page.fill('[data-testid="api-key-input"]', EVIDENCE_KEY) + page.click('[data-testid="api-key-save"]') + page.wait_for_function( + "() => document.querySelector('[data-testid=\"secret-masked\"]')" + ".dataset.masked === 'true'", + timeout=15000, + ) + masked_text = page.inner_text('[data-testid="secret-masked"]') + ui["api_key_visible"] = page.is_visible('[data-testid="api-key-input"]') + ui["pkce_visible"] = page.is_visible('[data-testid="pkce-connect"]') + ui["secret_masked"] = EVIDENCE_KEY not in masked_text and "sk-orca" in masked_text + ui["controls_enabled"] = page.is_enabled('[data-testid="api-key-save"]') and page.is_enabled( + '[data-testid="pkce-connect"]' + ) + ui["stored_key_never_rendered"] = EVIDENCE_KEY not in page.content() + + # --- Live catalogue drives the dropdown --- + page.wait_for_function( + "() => window.rcOrcaProviders" + " && window.rcOrcaProviders.state.models.length > 0" + " && document.querySelector('[data-testid=\"model-trigger-label\"]')" + " .textContent !== 'Loading…'", + timeout=30000, + ) + state = page.evaluate("() => window.rcOrcaProviders.state") + model_ids = [m["id"] for m in state["models"]] + catalog_source = page.evaluate( + "() => document.querySelector('[data-testid=\"model-status\"]').dataset.source" + ) + + page.screenshot(path=str(out / "auth-methods.png"), full_page=True) + + page.click('[data-testid="model-trigger"]') + page.wait_for_selector('[data-testid="model-panel"]:not([hidden])') + page.wait_for_timeout(250) + + if not playwright_errors: + ui["dropdown_open"] = page.is_visible('[data-testid="model-panel"]') + ui["item_count"] = page.eval_on_selector_all( + '[data-testid="model-option"]', "els => els.length" + ) + ui["opaque_background"] = _opaque_background(page, '[data-testid="model-panel"]') + ui["visible_border"] = _has_visible_border(page, '[data-testid="model-panel"]') + trigger = page.eval_on_selector( + '[data-testid="model-trigger"]', + "el => { const r = el.getBoundingClientRect();" + " return { left: r.right, width: r.width }; }", + ) + panel = page.eval_on_selector( + '[data-testid="model-panel"]', + "el => { const r = el.getBoundingClientRect();" + " return { left: r.right, width: r.width }; }", + ) + # The panel is anchored to the trigger: right edges within 2px. + ui["trigger_panel_right_delta"] = round(abs(trigger["left"] - panel["left"]), 2) + + page.screenshot(path=str(out / "text-model-dropdown.png"), full_page=True) + + screenshot_size = page.evaluate( + "() => ({ w: document.documentElement.scrollWidth, h: document.documentElement.scrollHeight })" + ) + # How many image-generation models this workspace can call. Recorded + # so the manifest shows the capability filter ran for that entry point + # too, even though no entry point in this repo can use one. + image_catalog = page.evaluate( + "async () => {" + " const r = await fetch('/api/providers/orcarouter/models?capability=image');" + " if (!r.ok) return { count: 0, ids: [] };" + " const j = await r.json();" + " return { count: j.count, ids: (j.models || []).map(m => m.id) };" + "}" + ) + browser.close() + + passed = bool( + ui.get("api_key_visible") + and ui.get("pkce_visible") + and ui.get("secret_masked") + and ui.get("controls_enabled") + and ui.get("stored_key_never_rendered") + and ui.get("dropdown_open") + and ui.get("opaque_background") + and ui.get("visible_border") + and int(ui.get("item_count") or 0) > 0 + and ui.get("trigger_panel_right_delta") is not None + and float(ui["trigger_panel_right_delta"]) <= 2 + ) + + artifacts = [] + for kind, name in ( + ("auth-methods", "auth-methods.png"), + ("text-model-dropdown", "text-model-dropdown.png"), + ): + path = out / name + artifacts.append( + { + "kind": kind, + "path": name, + "bytes": path.stat().st_size, + "sha256": _sha256(path), + "ui": {k: v for k, v in ui.items() if k != "stored_key_never_rendered"}, + } + ) + + manifest = { + # The gate reads ``automation`` as an object: provenance and the counts + # the screenshots have to agree with live here, not at the top level. + "automation": { + "framework": "playwright", + "passed": passed, + "catalog_source": GATE_CATALOG_URL, + "catalog_model_count": len(model_ids), + "image_model_count": image_catalog["count"], + }, + "catalog_models": model_ids, + "image_models": image_catalog["ids"], + "multimodal_applicable": False, + "multimodal_reason": ( + "No AI entry point in this repository sends an image to the chat " + "client: messages are {role, content} strings " + "(researchclaw/llm/client.py) and the only image generator is " + "pinned to Gemini's native API " + "(researchclaw/agents/figure_agent/nano_banana.py). The capability " + "filter is implemented and unit-tested for image input, so a " + "future multimodal entry point fails closed." + ), + "page": f"{base}/providers", + "viewport": {"width": 1280, "height": 900}, + "page_scroll_size": screenshot_size, + "runtime_source": catalog_source, + "ui_assertions": ui, + "artifacts": artifacts, + "generated_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + } + + (out / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + # Fail before handing over a bundle the gate would reject, and never hand + # over one that is not internally consistent. + validate_bundle(manifest, out, multimodal=bool(manifest["multimodal_applicable"])) + + print(json.dumps({k: manifest[k] for k in ( + "ui_assertions", "catalog_models", "image_models" + )}, indent=2)) + return manifest + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--out", default=DEFAULT_OUT) + parser.add_argument("--state-dir", default="") + args = parser.parse_args() + + manifest = generate(Path(args.out), Path(args.state_dir) if args.state_dir else None) + return 0 if manifest["automation"]["passed"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_orcarouter_catalog.py b/tests/test_orcarouter_catalog.py new file mode 100644 index 000000000..19f403fc7 --- /dev/null +++ b/tests/test_orcarouter_catalog.py @@ -0,0 +1,471 @@ +"""OrcaRouter model catalogue: parsing, capability filtering, degradation. + +Every fixture here is a real shape returned by +``GET https://api.orcarouter.ai/v1/models`` on 2026-09-16 (trimmed), so the +filtering rules are tested against the actual payload rather than a guess. +No network access, no credentials. +""" + +from __future__ import annotations + +import json +import urllib.error +from pathlib import Path + +import pytest + +from researchclaw.llm.orcarouter_catalog import ( + CAPABILITY_CHAT, + CAPABILITY_EMBEDDING, + CAPABILITY_IMAGE, + CAPABILITY_RERANK, + CAPABILITY_VIDEO, + VERIFIED_SEED, + CatalogModel, + catalog_url, + discover_models, + filter_models, + is_model_available, + matches_capability, + merge_verified_metadata, + parse_models, + read_cache, + seed_for_capability, + write_cache, +) + +# -------------------------------------------------------------------------- +# Fixtures shaped like the live catalogue +# -------------------------------------------------------------------------- + +TEXT_ONLY_CHAT = { + "id": "deepseek/deepseek-v4-pro", + "object": "model", + "owned_by": "deepseek", + "supported_endpoint_types": ["openai", "openai-response"], + "context_length": 1048576, + "max_completion_tokens": 384000, + "architecture": {"input_modalities": ["text"], "output_modalities": ["text"]}, +} + +IMAGE_INPUT_CHAT = { + "id": "deepseek/deepseek-v4.1-flash", + "object": "model", + "owned_by": "deepseek", + "supported_endpoint_types": ["openai", "openai-response", "anthropic"], + "context_length": 1048576, + "architecture": {"input_modalities": ["text", "image"], "output_modalities": ["text"]}, +} + +AUDIO_INPUT_CHAT = { + "id": "vendor/audio-understanding", + "object": "model", + "supported_endpoint_types": ["openai"], + "architecture": {"input_modalities": ["text", "audio"], "output_modalities": ["text"]}, +} + +ROUTING_ALIAS = { + "id": "orcarouter/auto", + "object": "model", + "owned_by": "orcarouter", + "supported_endpoint_types": ["openai", "openai-response", "anthropic", "gemini"], +} + +EMBEDDING = { + "id": "vendor/text-embed-3", + "object": "model", + "supported_endpoint_types": ["embeddings"], + "architecture": {"input_modalities": ["text"], "output_modalities": ["embedding"]}, +} + +IMAGE_GEN = { + "id": "vendor/nano-banana", + "object": "model", + "supported_endpoint_types": ["image-generation"], + "architecture": {"input_modalities": ["text"], "output_modalities": ["image"]}, +} + +VIDEO_GEN = { + "id": "vendor/veo-3", + "object": "model", + "supported_endpoint_types": ["openai-video"], + "architecture": {"input_modalities": ["text"], "output_modalities": ["video"]}, +} + +RERANK = { + "id": "jina/jina-rerank-v3", + "object": "model", + "supported_endpoint_types": ["jina-rerank"], +} + +LIVE_PAYLOAD = { + "object": "list", + "success": True, + "data": [ + ROUTING_ALIAS, + TEXT_ONLY_CHAT, + IMAGE_INPUT_CHAT, + AUDIO_INPUT_CHAT, + EMBEDDING, + IMAGE_GEN, + VIDEO_GEN, + RERANK, + ], +} + + +def _fetcher_returning(payload): + def _fetch(url: str, api_key: str, timeout: float, max_bytes: int): + return payload + + return _fetch + + +# -------------------------------------------------------------------------- +# Parsing +# -------------------------------------------------------------------------- + + +def test_parses_vendor_namespace_verbatim() -> None: + models = parse_models(LIVE_PAYLOAD) + assert "deepseek/deepseek-v4-pro" in [m.id for m in models] + assert "orcarouter/auto" in [m.id for m in models] + + +def test_parses_declared_capability_metadata() -> None: + models = {m.id: m for m in parse_models(LIVE_PAYLOAD)} + text_only = models["deepseek/deepseek-v4-pro"] + assert text_only.input_modalities == ("text",) + assert text_only.context_length == 1048576 + assert text_only.max_completion_tokens == 384000 + assert text_only.supports_chat is True + assert models["deepseek/deepseek-v4.1-flash"].input_modalities == ("text", "image") + assert models["vendor/nano-banana"].input_modalities == ("text",) + + +def test_unknown_records_are_skipped_not_trusted() -> None: + payload = { + "data": [ + {"id": "ok/model", "supported_endpoint_types": ["openai"]}, + {"object": "model"}, # no id + {"id": "", "supported_endpoint_types": []}, # empty id + "not-a-dict", + {"id": "ok/second", "architecture": "nonsense"}, + ] + } + ids = [m.id for m in parse_models(payload)] + assert ids == ["ok/model", "ok/second"] + + +def test_parsing_is_bounded() -> None: + payload = {"data": [{"id": f"m/{i}"} for i in range(50)]} + assert len(parse_models(payload, max_items=10)) == 10 + + +def test_non_list_payload_yields_nothing() -> None: + assert parse_models({"data": "nope"}) == [] + assert parse_models(None) == [] + + +# -------------------------------------------------------------------------- +# Capability filtering +# -------------------------------------------------------------------------- + + +def test_chat_capability_uses_text_wire_endpoints() -> None: + models = parse_models(LIVE_PAYLOAD) + chat = {m.id for m in filter_models(models, CAPABILITY_CHAT)} + + assert "deepseek/deepseek-v4-pro" in chat + assert "deepseek/deepseek-v4.1-flash" in chat + assert "orcarouter/auto" in chat + # Non-text-only entries never appear in a text dropdown. + assert "vendor/nano-banana" not in chat + assert "vendor/veo-3" not in chat + assert "jina/jina-rerank-v3" not in chat + assert "vendor/text-embed-3" not in chat + + +def test_chat_capability_hits_the_documented_query() -> None: + assert ( + catalog_url("https://api.orcarouter.ai/v1", CAPABILITY_CHAT) + == "https://api.orcarouter.ai/v1/models?capability=chat" + ) + assert "capability=embedding" in catalog_url("https://api.orcarouter.ai/v1", CAPABILITY_EMBEDDING) + assert "capability=image" in catalog_url("https://api.orcarouter.ai/v1", CAPABILITY_IMAGE) + # Video and rerank have no server-side filter param; they filter locally. + assert "?" not in catalog_url("https://api.orcarouter.ai/v1", CAPABILITY_VIDEO) + assert "?" not in catalog_url("https://api.orcarouter.ai/v1", CAPABILITY_RERANK) + + +def test_image_attachment_fails_closed_on_undeclared_modality() -> None: + models = parse_models(LIVE_PAYLOAD) + multimodal = { + m.id + for m in filter_models(models, CAPABILITY_CHAT, required_input_modalities=("image",)) + } + assert multimodal == {"deepseek/deepseek-v4.1-flash"} + # The text-only model and the routing alias declare nothing, so they are + # excluded rather than assumed compatible. + assert "deepseek/deepseek-v4-pro" not in multimodal + assert "orcarouter/auto" not in multimodal + + +def test_audio_attachment_only_matches_declared_audio_input() -> None: + models = parse_models(LIVE_PAYLOAD) + audio = { + m.id + for m in filter_models(models, CAPABILITY_CHAT, required_input_modalities=("audio",)) + } + assert audio == {"vendor/audio-understanding"} + + +@pytest.mark.parametrize( + ("capability", "expected"), + [ + (CAPABILITY_EMBEDDING, {"vendor/text-embed-3"}), + (CAPABILITY_IMAGE, {"vendor/nano-banana"}), + (CAPABILITY_VIDEO, {"vendor/veo-3"}), + (CAPABILITY_RERANK, {"jina/jina-rerank-v3"}), + ], +) +def test_each_entry_point_gets_its_own_models(capability: str, expected: set[str]) -> None: + models = parse_models(LIVE_PAYLOAD) + assert {m.id for m in filter_models(models, capability)} == expected + + +def test_capability_is_never_guessed_from_the_model_name() -> None: + """A name that says 'image' still needs the declared endpoint/capability.""" + liar = CatalogModel( + id="vendor/super-image-embed-rerank-model", + supported_endpoint_types=("openai",), + input_modalities=("text",), + ) + assert matches_capability(liar, CAPABILITY_CHAT) is True + assert matches_capability(liar, CAPABILITY_IMAGE) is False + assert matches_capability(liar, CAPABILITY_EMBEDDING) is False + assert matches_capability(liar, CAPABILITY_RERANK) is False + assert ( + matches_capability(liar, CAPABILITY_CHAT, required_input_modalities=("image",)) + is False + ) + + +def test_unknown_capability_is_an_error_not_a_silent_pass() -> None: + with pytest.raises(ValueError): + matches_capability(CatalogModel(id="x"), "telepathy") + + +# -------------------------------------------------------------------------- +# Live discovery, caching, degradation +# -------------------------------------------------------------------------- + + +def test_live_discovery_is_authoritative_and_never_mixes_in_the_seed(tmp_path: Path) -> None: + result = discover_models( + "https://api.orcarouter.ai/v1", + "sk-orca-fake", + fetcher=_fetcher_returning(LIVE_PAYLOAD), + cache_dir=tmp_path, + ) + assert result.source == "live" + assert result.degraded is False + assert result.live_model_count == len(LIVE_PAYLOAD["data"]) + # A model that exists only in the seed is NOT offered. + assert "openai/gpt-5.5" not in result.ids + assert result.ids == [m.id for m in result.models] + + +def test_live_discovery_sends_the_bearer_key_to_the_configured_origin( + tmp_path: Path, +) -> None: + seen: dict[str, str] = {} + + def _fetch(url: str, api_key: str, timeout: float, max_bytes: int): + seen["url"] = url + seen["key"] = api_key + assert max_bytes > 0 + return LIVE_PAYLOAD + + discover_models( + "https://api.orcarouter.ai/v1", + "sk-orca-fake", + fetcher=_fetch, + cache_dir=tmp_path, + ) + assert seen["url"] == "https://api.orcarouter.ai/v1/models?capability=chat" + assert seen["key"] == "sk-orca-fake" + + +def test_outage_falls_back_to_the_verified_seed_only(tmp_path: Path) -> None: + def _explode(url: str, api_key: str, timeout: float, max_bytes: int): + raise urllib.error.URLError("down") + + result = discover_models( + "https://api.orcarouter.ai/v1", + "sk-orca-fake", + fetcher=_explode, + cache_dir=tmp_path, + ) + assert result.source == "seed" + assert result.degraded is True + assert result.models == seed_for_capability(CAPABILITY_CHAT) + assert "could not reach" in result.error + assert "sk-orca-fake" not in result.error + + +def test_seed_carries_verified_metadata_and_reasoning_ladder() -> None: + seed = {m.id: m for m in VERIFIED_SEED} + gpt = seed["openai/gpt-5.5"] + assert gpt.reasoning_efforts == ("low", "medium", "high", "xhigh") + assert "image" in gpt.input_modalities + assert gpt.verified is True + assert seed["deepseek/deepseek-v4-pro"].context_length == 1048576 + assert all("orcarouter" in m.provenance or m.provenance for m in VERIFIED_SEED) + + +def test_seed_is_capability_filtered_too() -> None: + # The seed has no embedding/image/video/rerank entries, so those + # dropdowns stay empty rather than offering a chat model. + assert seed_for_capability(CAPABILITY_EMBEDDING) == () + assert seed_for_capability(CAPABILITY_IMAGE) == () + chat_seed = seed_for_capability(CAPABILITY_CHAT, required_input_modalities=("image",)) + assert all("image" in m.input_modalities for m in chat_seed) + + +def test_no_credential_means_no_catalogue_call(tmp_path: Path) -> None: + called = {"n": 0} + + def _fetch(url: str, api_key: str, timeout: float, max_bytes: int): + called["n"] += 1 + return LIVE_PAYLOAD + + result = discover_models( + "https://api.orcarouter.ai/v1", "", fetcher=_fetch, cache_dir=tmp_path + ) + assert called["n"] == 0 + assert result.source == "seed" + assert result.degraded is True + assert "credential" in result.error + + +def test_auth_error_is_reported_without_the_key(tmp_path: Path) -> None: + def _fetch(url: str, api_key: str, timeout: float, max_bytes: int): + raise urllib.error.HTTPError(url, 401, "unauthorized", {}, None) + + result = discover_models( + "https://api.orcarouter.ai/v1", "sk-orca-fake", fetcher=_fetch, cache_dir=tmp_path + ) + assert result.degraded is True + assert "401" in result.error + assert "sk-orca-fake" not in result.error + + +def test_last_known_good_cache_is_preferred_over_the_seed_on_outage(tmp_path: Path) -> None: + discover_models( + "https://api.orcarouter.ai/v1", + "sk-orca-fake", + fetcher=_fetcher_returning(LIVE_PAYLOAD), + cache_dir=tmp_path, + ) + assert read_cache(CAPABILITY_CHAT, cache_dir=tmp_path, ttl=60) + + def _explode(url: str, api_key: str, timeout: float, max_bytes: int): + raise urllib.error.URLError("down") + + result = discover_models( + "https://api.orcarouter.ai/v1", + "sk-orca-fake", + fetcher=_explode, + cache_dir=tmp_path, + cache_ttl=60, + ) + assert result.source == "cache" + assert result.degraded is True + assert "deepseek/deepseek-v4-pro" in result.ids + + +def test_cache_expires(tmp_path: Path) -> None: + write_cache(CAPABILITY_CHAT, LIVE_PAYLOAD, cache_dir=tmp_path) + assert read_cache(CAPABILITY_CHAT, cache_dir=tmp_path, ttl=3600) is not None + assert read_cache(CAPABILITY_CHAT, cache_dir=tmp_path, ttl=0) is None + + +def test_corrupt_cache_is_ignored_not_fatal(tmp_path: Path) -> None: + (tmp_path / "catalog_chat.json").write_text("{oops", encoding="utf-8") + assert read_cache(CAPABILITY_CHAT, cache_dir=tmp_path) is None + + +def test_cached_catalogue_never_stores_a_credential(tmp_path: Path) -> None: + discover_models( + "https://api.orcarouter.ai/v1", + "sk-orca-fake", + fetcher=_fetcher_returning(LIVE_PAYLOAD), + cache_dir=tmp_path, + ) + blob = (tmp_path / "catalog_chat.json").read_text(encoding="utf-8") + assert "sk-orca-fake" not in blob + assert "Authorization" not in blob + + +# -------------------------------------------------------------------------- +# Metadata preservation and stale selections +# -------------------------------------------------------------------------- + + +def test_live_discovery_does_not_erase_verified_metadata() -> None: + """Live rows replace capability claims only when live actually has them.""" + live = parse_models({"data": [{"id": "openai/gpt-5.5", "context_length": 0}]}) + merged = {m.id: m for m in merge_verified_metadata(live)} + gpt = merged["openai/gpt-5.5"] + assert gpt.reasoning_efforts == ("low", "medium", "high", "xhigh") + assert gpt.context_length == 400000 + assert gpt.input_modalities == ("text", "image") + assert gpt.verified is True + + +def test_live_discovery_does_not_add_models_it_did_not_return() -> None: + live = parse_models({"data": [{"id": "only/this-one"}]}) + merged = merge_verified_metadata(live) + assert [m.id for m in merged] == ["only/this-one"] + + +def test_live_modalities_win_when_present() -> None: + live = parse_models( + { + "data": [ + { + "id": "openai/gpt-5.5", + "architecture": {"input_modalities": ["text"]}, + } + ] + } + ) + merged = {m.id: m for m in merge_verified_metadata(live)} + assert merged["openai/gpt-5.5"].input_modalities == ("text",) + # ...and the reasoning ladder is still preserved. + assert merged["openai/gpt-5.5"].reasoning_efforts == ("low", "medium", "high", "xhigh") + + +def test_a_remembered_selection_is_revalidated_before_restoring() -> None: + models = parse_models(LIVE_PAYLOAD) + assert is_model_available("deepseek/deepseek-v4-pro", models) is True + # The user's attachment change invalidated it: not in the filtered list. + multimodal = filter_models(models, CAPABILITY_CHAT, required_input_modalities=("image",)) + assert is_model_available("deepseek/deepseek-v4-pro", multimodal) is False + assert is_model_available("deepseek/deepseek-v4.1-flash", multimodal) is True + + +def test_result_payload_is_serializable_and_carries_no_secret(tmp_path: Path) -> None: + result = discover_models( + "https://api.orcarouter.ai/v1", + "sk-orca-fake", + fetcher=_fetcher_returning(LIVE_PAYLOAD), + cache_dir=tmp_path, + ) + blob = json.dumps(result.as_dict()) + assert "sk-orca-fake" not in blob + payload = json.loads(blob) + assert payload["source"] == "live" + assert payload["models"][0]["id"] + assert "authorization" not in blob.lower() diff --git a/tests/test_orcarouter_cli.py b/tests/test_orcarouter_cli.py new file mode 100644 index 000000000..7f5bc2bbc --- /dev/null +++ b/tests/test_orcarouter_cli.py @@ -0,0 +1,352 @@ +"""The ``researchclaw orcarouter`` command group. + +A CLI project must expose *both* credential choices as discoverable +commands, so these tests exercise both paths and the catalogue command that +feeds model selection. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import pytest + +from researchclaw.cli import build_parser, cmd_orcarouter, cmd_init +from researchclaw.llm.orcarouter import CredentialStore + +FAKE_KEY = "sk-orca-fake-0000000000000000000001" + + +@pytest.fixture +def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> CredentialStore: + path = tmp_path / "creds.json" + monkeypatch.setenv("ORCA_CREDENTIALS_PATH", str(path)) + monkeypatch.setenv("ORCA_CATALOG_CACHE_DIR", str(tmp_path / "catalog")) + monkeypatch.delenv("ORCAROUTER_API_KEY", raising=False) + for var in ("ORCA_BASE_URL", "ORCA_AUTH_BASE_URL", "ORCA_API_BASE_URL"): + monkeypatch.delenv(var, raising=False) + return CredentialStore(path) + + +def _args(**kwargs) -> argparse.Namespace: + return argparse.Namespace(**kwargs) + + +# -------------------------------------------------------------------------- +# Discoverability +# -------------------------------------------------------------------------- + + +def test_the_command_group_parses_with_both_entry_points() -> None: + parser = build_parser() + key_cmd = parser.parse_args(["orcarouter", "key", "--set"]) + assert key_cmd.command == "orcarouter" + assert key_cmd.orcarouter_command == "key" + assert key_cmd.set is True + + login_cmd = parser.parse_args(["orcarouter", "login", "--flow", "oob"]) + assert login_cmd.orcarouter_command == "login" + assert login_cmd.flow == "oob" + + models_cmd = parser.parse_args( + ["orcarouter", "models", "--capability", "chat", "--modality", "image"] + ) + assert models_cmd.modality == ["image"] + + for sub in ("status", "models", "logout", "login", "key"): + assert parser.parse_args(["orcarouter", sub]).orcarouter_command == sub + + +def test_init_wizard_offers_both_orcarouter_choices() -> None: + from researchclaw.cli import _PROVIDER_CHOICES + + kinds = {value[0] for value in _PROVIDER_CHOICES.values()} + assert "orcarouter" in kinds, "the pasted-key entry must be selectable" + assert "orcarouter-oauth" in kinds, "the account-login entry must be selectable" + + +def test_init_wizard_writes_orcarouter_defaults( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import sys + + class _TTY: + def isatty(self) -> bool: + return True + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "stdin", _TTY()) + from researchclaw.cli import _PROVIDER_CHOICES + + choice = next(k for k, v in _PROVIDER_CHOICES.items() if v[0] == "orcarouter") + monkeypatch.setattr("builtins.input", lambda _prompt: choice) + monkeypatch.setattr("researchclaw.cli._prompt_open_install", lambda: False, raising=False) + monkeypatch.setattr("researchclaw.cli._prompt_opencode_install", lambda: False) + + assert cmd_init(_args(force=False)) == 0 + content = (tmp_path / "config.arc.yaml").read_text(encoding="utf-8") + assert 'provider: "orcarouter"' in content + assert 'base_url: "https://api.orcarouter.ai/v1"' in content + assert 'api_key_env: "ORCAROUTER_API_KEY"' in content + + +def test_init_wizard_oauth_entry_writes_no_api_key_env( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import sys + + class _TTY: + def isatty(self) -> bool: + return True + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "stdin", _TTY()) + from researchclaw.cli import _PROVIDER_CHOICES + + choice = next(k for k, v in _PROVIDER_CHOICES.items() if v[0] == "orcarouter-oauth") + monkeypatch.setattr("builtins.input", lambda _prompt: choice) + monkeypatch.setattr("researchclaw.cli._prompt_opencode_install", lambda: False) + + assert cmd_init(_args(force=False)) == 0 + content = (tmp_path / "config.arc.yaml").read_text(encoding="utf-8") + assert 'provider: "orcarouter-oauth"' in content + assert 'api_key_env: ""' in content + + +# -------------------------------------------------------------------------- +# key subcommand +# -------------------------------------------------------------------------- + + +def test_key_set_from_stdin_stores_and_masks(store, monkeypatch, capsys) -> None: + import io + import sys + + monkeypatch.setattr(sys, "stdin", io.StringIO(f"{FAKE_KEY}\n")) + assert cmd_orcarouter(_args(orcarouter_command="key", set=True, stdin=True, clear=False)) == 0 + out = capsys.readouterr().out + assert FAKE_KEY not in out + assert "Stored OrcaRouter API key" in out + assert store.status("orcarouter").configured is True + + +def test_key_rejects_a_malformed_value(store, monkeypatch, capsys) -> None: + import io + import sys + + monkeypatch.setattr(sys, "stdin", io.StringIO("sk-openai-nope\n")) + assert cmd_orcarouter(_args(orcarouter_command="key", set=True, stdin=True, clear=False)) == 1 + assert "sk-orca" in capsys.readouterr().err + + +def test_key_status_and_clear(store, capsys) -> None: + assert cmd_orcarouter(_args(orcarouter_command="key", set=False, stdin=False, clear=False)) == 0 + assert "not configured" in capsys.readouterr().out + + store.save("orcarouter", FAKE_KEY, source="api_key") + assert cmd_orcarouter(_args(orcarouter_command="key", set=False, stdin=False, clear=False)) == 0 + out = capsys.readouterr().out + assert FAKE_KEY not in out + assert "sk-orca" in out + + assert cmd_orcarouter(_args(orcarouter_command="key", set=False, stdin=False, clear=True)) == 0 + assert store.status("orcarouter").configured is False + + +# -------------------------------------------------------------------------- +# status subcommand +# -------------------------------------------------------------------------- + + +def test_status_reports_both_entries_without_secrets(store, capsys) -> None: + store.save("orcarouter", FAKE_KEY, source="api_key") + assert cmd_orcarouter(_args(orcarouter_command="status", json=False)) == 0 + out = capsys.readouterr().out + assert "OrcaRouter — API" in out + assert "OrcaRouter — Auth" in out + assert "www.orcarouter.ai" in out + assert "api.orcarouter.ai/v1" in out + assert FAKE_KEY not in out + + +def test_status_json_is_machine_readable_and_secret_free(store, capsys) -> None: + store.save("orcarouter", FAKE_KEY, source="api_key") + assert cmd_orcarouter(_args(orcarouter_command="status", json=True)) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["endpoints"]["auth_base"] == "https://www.orcarouter.ai" + assert payload["providers"]["orcarouter"]["configured"] is True + assert FAKE_KEY not in json.dumps(payload) + assert "secret_masked" in payload["providers"]["orcarouter"] + + +def test_status_flags_a_revoked_credential(store, capsys) -> None: + credential = store.save("orcarouter-oauth", FAKE_KEY, source="pkce", grant_id="1") + store.mark_needs_reauth("orcarouter-oauth", credential.generation) + assert cmd_orcarouter(_args(orcarouter_command="status", json=False)) == 0 + out = capsys.readouterr().out + assert "reauthorization" in out + assert "orcarouter login" in out + + +# -------------------------------------------------------------------------- +# models subcommand +# -------------------------------------------------------------------------- + + +def test_models_requires_a_credential(store, capsys) -> None: + assert cmd_orcarouter(_args(orcarouter_command="models", capability="chat", modality=[], json=False, refresh=False)) == 1 + err = capsys.readouterr().err + assert "orcarouter key --set" in err + assert "orcarouter login" in err + + +def test_models_lists_the_live_catalogue(store, capsys, monkeypatch) -> None: + store.save("orcarouter", FAKE_KEY, source="api_key") + live = { + "data": [ + { + "id": "deepseek/deepseek-v4-pro", + "supported_endpoint_types": ["openai"], + "architecture": {"input_modalities": ["text"]}, + "context_length": 1048576, + }, + { + "id": "deepseek/deepseek-v4.1-flash", + "supported_endpoint_types": ["openai"], + "architecture": {"input_modalities": ["text", "image"]}, + }, + ] + } + monkeypatch.setattr( + "researchclaw.llm.orcarouter_catalog._default_fetcher", + lambda url, key, timeout, max_bytes: live, + ) + assert cmd_orcarouter(_args(orcarouter_command="models", capability="chat", modality=[], json=False, refresh=True)) == 0 + out = capsys.readouterr().out + assert "deepseek/deepseek-v4-pro" in out + assert "live catalogue" in out + assert FAKE_KEY not in out + + # With an image attached, only the model that declares image input remains. + assert cmd_orcarouter(_args(orcarouter_command="models", capability="chat", modality=["image"], json=False, refresh=True)) == 0 + out = capsys.readouterr().out + assert "deepseek/deepseek-v4.1-flash" in out + assert "deepseek-v4-pro" not in out + + +def test_models_json_reports_source_and_degradation(store, capsys, monkeypatch) -> None: + store.save("orcarouter", FAKE_KEY, source="api_key") + import urllib.error + + monkeypatch.setattr( + "researchclaw.llm.orcarouter_catalog._default_fetcher", + lambda url, key, timeout, max_bytes: (_ for _ in ()).throw( + urllib.error.URLError("down") + ), + ) + assert cmd_orcarouter(_args(orcarouter_command="models", capability="chat", modality=[], json=True, refresh=True)) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["degraded"] is True + assert payload["source"] == "seed" + assert FAKE_KEY not in json.dumps(payload) + + +# -------------------------------------------------------------------------- +# login subcommand +# -------------------------------------------------------------------------- + + +def test_login_reuses_an_existing_durable_credential(store, capsys) -> None: + """Re-authorizing on every launch would burn the 10-keys-per-day cap.""" + store.save("orcarouter-oauth", FAKE_KEY, source="pkce", grant_id="42") + assert cmd_orcarouter( + _args(orcarouter_command="login", flow="auto", app_name="", scope="api", + login_hint="", no_browser=True) + ) == 0 + out = capsys.readouterr().out + assert "Already connected" in out + assert "reused until revoked" in out + assert FAKE_KEY not in out + + +def test_login_oob_prints_the_authorize_url_and_persists_the_key( + store, capsys, monkeypatch +) -> None: + monkeypatch.setattr("builtins.input", lambda _p: "the-code") + monkeypatch.setattr( + "researchclaw.llm.orcarouter_pkce._default_post_json", + lambda url, payload, timeout: ( + 200, + json.dumps({"key": FAKE_KEY, "user_id": "5", "scope": "api"}).encode(), + ), + ) + assert cmd_orcarouter( + _args(orcarouter_command="login", flow="oob", app_name="", scope="api", + login_hint="", no_browser=True) + ) == 0 + out = capsys.readouterr().out + assert "https://www.orcarouter.ai/auth?" in out + assert "code_challenge_method=S256" in out + assert "api.orcarouter.ai/v1/auth/keys" not in out + assert FAKE_KEY not in out + assert store.status("orcarouter-oauth").configured is True + + +def test_login_denied_exits_cleanly(store, capsys, monkeypatch) -> None: + monkeypatch.setattr("builtins.input", lambda _p: "the-code") + monkeypatch.setattr( + "researchclaw.llm.orcarouter_pkce._default_post_json", + lambda url, payload, timeout: (403, b'{"error":"invalid_grant"}'), + ) + assert cmd_orcarouter( + _args(orcarouter_command="login", flow="oob", app_name="", scope="api", + login_hint="", no_browser=True) + ) == 1 + err = capsys.readouterr().err + assert "expired" in err or "already used" in err + assert store.status("orcarouter-oauth").configured is False + + +def test_login_rejects_an_api_shaped_auth_origin(store, capsys, monkeypatch) -> None: + """The classic bug: pointing auth at the inference origin.""" + monkeypatch.setenv("ORCA_AUTH_BASE_URL", "https://api.orcarouter.ai/v1") + monkeypatch.setattr("builtins.input", lambda _p: "the-code") + assert cmd_orcarouter( + _args(orcarouter_command="login", flow="oob", app_name="", scope="api", + login_hint="", no_browser=True) + ) == 1 + assert "404" in capsys.readouterr().err + + +def test_login_cancel_does_not_hang(store, capsys, monkeypatch) -> None: + def _interrupt(_prompt: str) -> str: + raise KeyboardInterrupt + + monkeypatch.setattr("builtins.input", _interrupt) + assert cmd_orcarouter( + _args(orcarouter_command="login", flow="oob", app_name="", scope="api", + login_hint="", no_browser=True) + ) == 1 + assert "Cancelled" in capsys.readouterr().err + + +# -------------------------------------------------------------------------- +# logout subcommand +# -------------------------------------------------------------------------- + + +def test_logout_forgets_the_pkce_credential_only(store, capsys) -> None: + store.save("orcarouter", FAKE_KEY, source="api_key") + store.save("orcarouter-oauth", FAKE_KEY, source="pkce", grant_id="1") + assert cmd_orcarouter(_args(orcarouter_command="logout", yes=True)) == 0 + assert store.status("orcarouter-oauth").configured is False + assert store.status("orcarouter").configured is True + out = capsys.readouterr().out + assert "console/authorized-apps" in out + + +def test_logout_with_nothing_stored_is_a_noop(store, capsys) -> None: + assert cmd_orcarouter(_args(orcarouter_command="logout", yes=True)) == 0 + assert "No PKCE-issued" in capsys.readouterr().out diff --git a/tests/test_orcarouter_live.py b/tests/test_orcarouter_live.py new file mode 100644 index 000000000..3e81ef772 --- /dev/null +++ b/tests/test_orcarouter_live.py @@ -0,0 +1,79 @@ +"""Live check against OrcaRouter, driven through the shipped provider path. + +Requires ``ORCAROUTER_API_KEY``; without it the test skips rather than fails, +so the ordinary suite stays green on machines that have no credential. The +work itself lives in ``scripts/orcarouter_live_check.py`` (same code a +maintainer can run by hand) so nothing about the wiring is duplicated here. + +The assertion is deliberately about *the implementation*: the catalogue the +model selector is given must come from the live ``/v1/models`` response, the +multimodal selector must be a declared-modality subset of it, and a real chat +completion must succeed through ``create_llm_client``. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPT = REPO_ROOT / "scripts" / "orcarouter_live_check.py" + + +@pytest.fixture(scope="module") +def report() -> dict: + if not os.environ.get("ORCAROUTER_API_KEY"): + pytest.skip("ORCAROUTER_API_KEY is not set — no live OrcaRouter run") + + proc = subprocess.run( + [sys.executable, str(SCRIPT)], + capture_output=True, + text=True, + timeout=300, + cwd=str(REPO_ROOT), + ) + assert proc.returncode == 0, ( + f"live check failed (exit {proc.returncode}):\n" + f"{proc.stdout[-3000:]}\n{proc.stderr[-3000:]}" + ) + return json.loads(proc.stdout) + + +def test_origins_are_the_documented_ones(report: dict) -> None: + assert report["auth_origin"] == "https://www.orcarouter.ai" + assert report["inference_origin"] == "https://api.orcarouter.ai/v1" + assert report["inference_url"] == "https://api.orcarouter.ai/v1/chat/completions" + + +def test_catalogue_is_live_and_builds_the_selector(report: dict) -> None: + assert report["catalog_backend"] == "live" + assert report["catalog_source"] == "https://api.orcarouter.ai/v1/models?capability=chat" + assert report["chat_model_count"] > 0 + assert report["selector_matches_catalog"] is True + assert report["selector_option_count"] == report["chat_model_count"] + # Every option is namespaced `vendor/model`, never a hand-written example. + assert all("/" in model_id for model_id in report["chat_models"]) + + +def test_multimodal_options_are_declared_modality_only(report: dict) -> None: + assert report["multimodal_is_subset"] is True + assert report["multimodal_declared_only"] is True + assert set(report["image_capable_chat_models"]) <= set(report["chat_models"]) + + +def test_a_real_completion_succeeds_through_the_provider(report: dict) -> None: + assert report["primary_is_from_catalog"] is True + assert report["inference_ok"] is True + assert report["inference_reply"].strip(), "the model returned no visible text" + + +def test_no_credential_leaks_into_the_report(report: dict) -> None: + rendered = json.dumps(report) + key = os.environ.get("ORCAROUTER_API_KEY", "") + assert key and key not in rendered + assert "code_verifier" not in rendered diff --git a/tests/test_orcarouter_model_select.py b/tests/test_orcarouter_model_select.py new file mode 100644 index 000000000..c0824f40e --- /dev/null +++ b/tests/test_orcarouter_model_select.py @@ -0,0 +1,325 @@ +"""Model selector options: capability filtering, invalidation, degradation. + +The requirement these tests exist for: when the provider, the entry point, +or the attachment type changes, the *options handed to the selector* change +— and an option that is no longer compatible is cleared rather than kept. +A pre-send guard is not a substitute. +""" + +from __future__ import annotations + +import json +import urllib.error +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +from researchclaw.llm import orcarouter as orca +from researchclaw.llm.model_select import ( + MODALITY_IMAGE, + apply_selection, + build_model_selector, + capability_for_entry_point, + is_orcarouter, + model_options_for, + required_modalities_for_attachments, + resolve_primary_model, +) + +FAKE_KEY = "sk-orca-fake-0000000000000000000001" + +LIVE = { + "data": [ + { + "id": "deepseek/deepseek-v4-pro", + "supported_endpoint_types": ["openai"], + "architecture": {"input_modalities": ["text"]}, + "context_length": 1048576, + }, + { + "id": "deepseek/deepseek-v4.1-flash", + "supported_endpoint_types": ["openai", "anthropic"], + "architecture": {"input_modalities": ["text", "image"]}, + }, + { + "id": "vendor/nano-banana", + "supported_endpoint_types": ["image-generation"], + }, + { + "id": "vendor/text-embed-3", + "supported_endpoint_types": ["embeddings"], + }, + ] +} + + +def _config(provider: str = "orcarouter", **llm_overrides: Any): + llm = dict( + provider=provider, + api_key=FAKE_KEY, + api_key_env="ORCAROUTER_API_KEY", + base_url="", + primary_model="", + fallback_models=(), + ) + llm.update(llm_overrides) + return SimpleNamespace(llm=SimpleNamespace(**llm)) + + +@pytest.fixture(autouse=True) +def _isolated_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ORCA_CREDENTIALS_PATH", str(tmp_path / "creds.json")) + monkeypatch.setenv("ORCA_CATALOG_CACHE_DIR", str(tmp_path / "catalog")) + monkeypatch.delenv("ORCAROUTER_API_KEY", raising=False) + for var in ("ORCA_BASE_URL", "ORCA_AUTH_BASE_URL", "ORCA_API_BASE_URL"): + monkeypatch.delenv(var, raising=False) + + +def _fetcher(payload: Any): + def _fetch(url: str, api_key: str, timeout: float, max_bytes: int): + return payload + + return _fetch + + +# -------------------------------------------------------------------------- +# Provider awareness — other providers are untouched +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("provider", ["openai", "openrouter", "anthropic", "ollama", "acp"]) +def test_non_orcarouter_providers_get_no_override(provider: str) -> None: + assert is_orcarouter(_config(provider)) is False + assert build_model_selector(_config(provider)) is None + assert model_options_for(_config(provider)) is None + + +def test_both_orcarouter_entries_are_recognised() -> None: + for provider in ("orcarouter", "orcarouter-oauth"): + assert is_orcarouter(_config(provider)) is True + state = build_model_selector(_config(provider), fetcher=_fetcher(LIVE)) + assert state is not None + assert state.provider == provider + + +# -------------------------------------------------------------------------- +# Entry-point mapping +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("entry_point", "capability"), + [ + ("chat", "chat"), + ("agent", "chat"), + ("code", "chat"), + ("review", "chat"), + ("debate", "chat"), + ("embedding", "embedding"), + ("rag", "embedding"), + ("image", "image"), + ("figure", "image"), + ("video", "video"), + ("rerank", "rerank"), + ], +) +def test_entry_points_map_to_capabilities(entry_point: str, capability: str) -> None: + assert capability_for_entry_point(entry_point) == capability + + +def test_unknown_entry_point_is_an_error_not_a_default() -> None: + with pytest.raises(ValueError): + capability_for_entry_point("telepathy") + + +# -------------------------------------------------------------------------- +# Attachment-driven filtering +# -------------------------------------------------------------------------- + + +def test_attachment_modalities_are_hard_requirements() -> None: + assert required_modalities_for_attachments([]) == () + assert required_modalities_for_attachments(["text"]) == () + assert required_modalities_for_attachments(["image"]) == (MODALITY_IMAGE,) + assert required_modalities_for_attachments(["image", "image"]) == (MODALITY_IMAGE,) + assert required_modalities_for_attachments(["image", "audio"]) == ("image", "audio") + # An unknown attachment kind cannot be satisfied by anything. + assert required_modalities_for_attachments(["hologram"]) == ("hologram",) + + +def test_options_come_from_the_api_not_a_handwritten_list() -> None: + state = build_model_selector(_config(), fetcher=_fetcher(LIVE)) + assert state.source == "live" + assert state.ids == [ + "deepseek/deepseek-v4-pro", + "deepseek/deepseek-v4.1-flash", + ] + assert state.catalog_source_url.endswith("/v1/models?capability=chat") + # A catalogue model only exists here because the API returned it. + assert "vendor/nano-banana" not in state.ids + assert state.is_free_text is False + + +def test_adding_an_image_attachment_removes_undeclared_models() -> None: + before = build_model_selector(_config(), fetcher=_fetcher(LIVE)) + assert "deepseek/deepseek-v4-pro" in before.ids + + after = build_model_selector( + _config(), + attachments=["image"], + current_model="deepseek/deepseek-v4-pro", + fetcher=_fetcher(LIVE), + ) + assert after.ids == ["deepseek/deepseek-v4.1-flash"] + # The text model that was selected is cleared, with a reason. + assert after.selection_cleared is True + assert after.selected == "" + assert "deepseek/deepseek-v4-pro" in after.clear_reason + assert "image" in after.clear_reason + + +def test_switching_entry_point_recomputes_the_options() -> None: + chat = build_model_selector(_config(), entry_point="chat", fetcher=_fetcher(LIVE)) + embedding = build_model_selector( + _config(), entry_point="embedding", fetcher=_fetcher(LIVE) + ) + image = build_model_selector(_config(), entry_point="image", fetcher=_fetcher(LIVE)) + + assert chat.ids == ["deepseek/deepseek-v4-pro", "deepseek/deepseek-v4.1-flash"] + assert embedding.ids == ["vendor/text-embed-3"] + assert image.ids == ["vendor/nano-banana"] + + +def test_switching_provider_recomputes_the_options() -> None: + api = build_model_selector(_config("orcarouter"), fetcher=_fetcher(LIVE)) + oauth = build_model_selector(_config("orcarouter-oauth"), fetcher=_fetcher(LIVE)) + assert api.ids == oauth.ids # one catalogue, two credential entries + assert api.provider != oauth.provider + + other = build_model_selector(_config("openrouter"), fetcher=_fetcher(LIVE)) + assert other is None, "another provider's control must not be replaced" + + +def test_a_still_compatible_selection_is_kept() -> None: + state = build_model_selector( + _config(), + current_model="deepseek/deepseek-v4.1-flash", + fetcher=_fetcher(LIVE), + ) + assert state.selected == "deepseek/deepseek-v4.1-flash" + assert state.selection_cleared is False + + +def test_a_model_that_vanished_from_the_catalogue_is_cleared() -> None: + state = build_model_selector( + _config(), + current_model="vendor/retired-model", + fetcher=_fetcher(LIVE), + ) + assert state.selected == "" + assert state.selection_cleared is True + assert "not available" in state.clear_reason + + +def test_apply_selection_is_idempotent() -> None: + state = build_model_selector(_config(), fetcher=_fetcher(LIVE)) + apply_selection(state, "") + assert state.selected == "" + assert state.selection_cleared is False + + +# -------------------------------------------------------------------------- +# Degradation: still a real list, never free text, never an unverified example +# -------------------------------------------------------------------------- + + +def test_catalogue_failure_offers_only_the_labelled_verified_fallback() -> None: + def _explode(url: str, api_key: str, timeout: float, max_bytes: int): + raise urllib.error.URLError("down") + + state = build_model_selector(_config(), fetcher=_explode) + assert state.degraded is True + assert state.source == "seed" + assert state.options, "an outage must not leave the selector empty" + assert all(option.verified for option in state.options) + assert "orcarouter/auto" in state.ids + # And it is still a list, not a text box. + assert state.is_free_text is False + + +def test_the_fallback_survives_attachment_filtering() -> None: + def _explode(url: str, api_key: str, timeout: float, max_bytes: int): + raise urllib.error.URLError("down") + + state = build_model_selector(_config(), attachments=["image"], fetcher=_explode) + assert state.degraded is True + assert state.ids, "documented image-capable seed entries must survive" + assert all( + "image" in option.input_modalities for option in state.options + ), "fail closed: nothing without a declared image input may appear" + + +def test_no_credential_degrades_without_leaking_anything() -> None: + state = build_model_selector(_config(api_key="", api_key_env=""), fetcher=_fetcher(LIVE)) + assert state.degraded is True + assert state.source == "seed" + assert FAKE_KEY not in json.dumps(state.as_dict()) + + +def test_degraded_state_is_reported_so_the_ui_can_say_so() -> None: + def _explode(url: str, api_key: str, timeout: float, max_bytes: int): + raise urllib.error.URLError("down") + + payload = build_model_selector(_config(), fetcher=_explode).as_dict() + assert payload["degraded"] is True + assert payload["source"] == "seed" + assert payload["error"] + assert payload["options"] + + +def test_options_payload_carries_no_credential() -> None: + payload = json.dumps(build_model_selector(_config(), fetcher=_fetcher(LIVE)).as_dict()) + assert FAKE_KEY not in payload + assert "authorization" not in payload.lower() + assert "api_key" not in payload.lower() + + +# -------------------------------------------------------------------------- +# Default model resolution +# -------------------------------------------------------------------------- + + +def test_default_model_prefers_the_routing_alias_then_the_configured_value() -> None: + from researchclaw.llm.orcarouter_catalog import parse_models + + models = parse_models(LIVE) + assert resolve_primary_model(_config(), models) == "deepseek/deepseek-v4-pro" + configured = _config(primary_model="deepseek/deepseek-v4.1-flash") + assert resolve_primary_model(configured, models) == "deepseek/deepseek-v4.1-flash" + + +def test_default_model_never_invents_an_id() -> None: + from researchclaw.llm.orcarouter_catalog import parse_models + + models = parse_models({"data": [{"id": "orcarouter/auto"}]}) + assert resolve_primary_model(_config(primary_model="ghost/model"), models) == "orcarouter/auto" + assert resolve_primary_model(_config(), []) == "" + + +def test_client_without_a_configured_model_uses_the_catalogue() -> None: + """An empty primary_model resolves from the live catalogue, not a guess.""" + from researchclaw.llm import create_llm_client + + import researchclaw.llm.orcarouter_catalog as catalog_mod + + original = catalog_mod._default_fetcher + catalog_mod._default_fetcher = _fetcher(LIVE) + try: + client = create_llm_client(_config(primary_model="")) + finally: + catalog_mod._default_fetcher = original + + assert client.config.base_url == "https://api.orcarouter.ai/v1" + assert client.config.primary_model in {m["id"] for m in LIVE["data"]} diff --git a/tests/test_orcarouter_pkce.py b/tests/test_orcarouter_pkce.py new file mode 100644 index 000000000..1dfce88cc --- /dev/null +++ b/tests/test_orcarouter_pkce.py @@ -0,0 +1,407 @@ +"""PKCE protocol tests for the OrcaRouter connect flow. + +These drive the real adapter code path — authorize URL construction, the +loopback listener, and the exchange — against a local fake auth server. They +never touch the network and never use a real credential. +""" + +from __future__ import annotations + +import base64 +import hashlib +import http.server +import json +import threading +import urllib.parse +import urllib.request + +import pytest + +from researchclaw.llm.orcarouter_pkce import ( + AUTHORIZE_PATH, + EXCHANGE_PATH, + PkceDenied, + PkceExchangeRejected, + PkceNetworkError, + PkceStateMismatch, + PkceTimeout, + build_authorize_url, + build_exchange_url, + challenge_for, + exchange_code, + generate_state, + generate_verifier, + start_login, + start_loopback_login, + start_oob_login, +) + +FAKE_KEY = "sk-orca-fake-key-for-tests-0001" + + +def _b64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + +# -------------------------------------------------------------------------- +# Fake auth server: a real HTTP server, so the adapter's own HTTP code runs. +# -------------------------------------------------------------------------- + + +class FakeAuthServer: + """A minimal OrcaRouter auth origin. Records what it was asked.""" + + def __init__(self) -> None: + self.requests: list[dict] = [] + self.responder = None # callable(payload) -> (status, dict) + self._server = http.server.HTTPServer(("127.0.0.1", 0), self._handler()) + self.port = int(self._server.server_address[1]) + self._thread = threading.Thread( + target=self._server.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True + ) + self._thread.start() + + @property + def base_url(self) -> str: + return f"http://127.0.0.1:{self.port}" + + def _handler(self): + outer = self + + class _Handler(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *args): # noqa: D102 + return + + def do_POST(self): # noqa: N802 + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"{}" + try: + payload = json.loads(raw.decode("utf-8")) + except json.JSONDecodeError: + payload = {"_raw": raw.decode("utf-8", "replace")} + outer.requests.append({"path": self.path, "payload": payload, + "content_type": self.headers.get("Content-Type")}) + status, body = outer.responder(payload) if outer.responder else (200, {}) + encoded = json.dumps(body).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + return _Handler + + def close(self) -> None: + self._server.shutdown() + self._server.server_close() + + +@pytest.fixture +def auth_server(): + server = FakeAuthServer() + try: + yield server + finally: + server.close() + + +# -------------------------------------------------------------------------- +# verifier / challenge / state +# -------------------------------------------------------------------------- + + +def test_verifier_is_fresh_crypto_randomness() -> None: + verifiers = {generate_verifier() for _ in range(200)} + assert len(verifiers) == 200, "verifier must be fresh per attempt" + for verifier in verifiers: + # RFC 7636 §4.1: 43-128 chars from the unreserved set. + assert 43 <= len(verifier) <= 128 + assert set(verifier) <= set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + ) + assert "=" not in verifier, "base64url must be unpadded" + + assert len({generate_state() for _ in range(200)}) == 200 + + +def test_challenge_is_unpadded_base64url_sha256() -> None: + verifier = "A" * 43 + expected = _b64url(hashlib.sha256(verifier.encode("ascii")).digest()) + assert challenge_for(verifier) == expected + assert "=" not in challenge_for(verifier) + + +def test_authorize_url_sends_only_the_challenge_never_the_verifier() -> None: + pending = start_oob_login("https://auth.example.test", app_name="Tool X") + parsed = urllib.parse.urlparse(pending.authorize_url) + params = urllib.parse.parse_qs(parsed.query) + + assert parsed.path == AUTHORIZE_PATH + assert parsed.netloc == "auth.example.test" + assert params["code_challenge_method"] == ["S256"] + assert params["code_challenge"] == [challenge_for(pending.verifier)] + assert params["callback_url"] == ["oob"] + assert params["app_name"] == ["Tool X"] + assert params["scope"] == ["api"] + assert pending.state in params["state"] + + # The verifier must not appear anywhere in the URL, in any encoding. + assert pending.verifier not in pending.authorize_url + assert urllib.parse.quote(pending.verifier, safe="") not in pending.authorize_url + + +def test_loopback_authorize_url_points_at_the_bound_port() -> None: + pending = start_loopback_login("https://auth.example.test") + try: + assert pending.flow == "loopback" + assert pending.receiver is not None + params = urllib.parse.parse_qs( + urllib.parse.urlparse(pending.authorize_url).query + ) + assert params["callback_url"] == [f"http://127.0.0.1:{pending.receiver.port}/cb"] + assert params["code_challenge_method"] == ["S256"] + finally: + pending.close() + + +def test_auto_flow_falls_back_to_oob_without_loopback(monkeypatch) -> None: + import researchclaw.llm.orcarouter_pkce as mod + + monkeypatch.setattr(mod, "loopback_available", lambda: False) + pending = mod.start_login("auto", auth_base="https://auth.example.test") + assert pending.flow == "oob" + + +def test_unknown_flow_is_rejected() -> None: + with pytest.raises(ValueError): + start_login("device", auth_base="https://auth.example.test") + + +# -------------------------------------------------------------------------- +# Exchange +# -------------------------------------------------------------------------- + + +def test_exchange_uses_the_auth_origin_and_the_documented_body(auth_server) -> None: + auth_server.responder = lambda payload: ( + 200, + {"key": FAKE_KEY, "user_id": "42", "scope": "api"}, + ) + result = exchange_code( + auth_server.base_url, code="the-code", verifier="the-verifier" + ) + + assert len(auth_server.requests) == 1 + sent = auth_server.requests[0] + assert sent["path"] == EXCHANGE_PATH, "exchange must POST to /api/v1/auth/keys" + assert sent["path"] != "/v1/auth/keys" + assert sent["payload"] == { + "code": "the-code", + "code_verifier": "the-verifier", + "code_challenge_method": "S256", + } + assert result.api_key == FAKE_KEY + assert result.scope == "api" + assert result.user_id == "42" + + +def test_exchange_never_returns_the_wrong_origin_path() -> None: + url = build_exchange_url("https://www.orcarouter.ai") + assert url == "https://www.orcarouter.ai/api/v1/auth/keys" + assert "api.orcarouter.ai/v1/auth/keys" not in url + + with pytest.raises(ValueError) as excinfo: + build_exchange_url("https://api.orcarouter.ai/v1") + assert "404" in str(excinfo.value) + + +@pytest.mark.parametrize( + ("status", "reason_fragment"), + [ + (400, "PKCE method"), + (403, "expired"), + (429, "too many"), + ], +) +def test_exchange_terminal_errors_are_actionable( + auth_server, status, reason_fragment +) -> None: + auth_server.responder = lambda payload: (status, {"error": "x"}) + with pytest.raises(PkceExchangeRejected) as excinfo: + exchange_code(auth_server.base_url, code="c", verifier="v") + assert excinfo.value.status == status + message = str(excinfo.value) + assert any(word in message.lower() for word in ("login", "authoriz", "wait")) + # The verifier never leaks into an error message. + assert "v" != message + assert "code_verifier" not in message + + +def test_exchange_403_is_classified_as_invalid_grant(auth_server) -> None: + auth_server.responder = lambda payload: (403, {"error": "invalid_grant"}) + with pytest.raises(PkceExchangeRejected) as excinfo: + exchange_code(auth_server.base_url, code="used-code", verifier="v") + assert excinfo.value.reason == "invalid_grant" + + +def test_exchange_malformed_200_is_rejected(auth_server) -> None: + auth_server.responder = lambda payload: (200, {"scope": "api"}) + with pytest.raises(PkceExchangeRejected) as excinfo: + exchange_code(auth_server.base_url, code="c", verifier="v") + assert excinfo.value.reason == "malformed_response" + + +def test_exchange_transport_failure_is_terminal_not_a_hot_loop() -> None: + # Nothing is listening on this port. + with pytest.raises(PkceNetworkError): + exchange_code("http://127.0.0.1:9", code="c", verifier="v", timeout=2) + + +def test_scope_downgrade_is_surfaced(auth_server, caplog) -> None: + auth_server.responder = lambda payload: ( + 200, + {"key": FAKE_KEY, "user_id": "7", "scope": "read"}, + ) + with caplog.at_level("WARNING"): + result = exchange_code(auth_server.base_url, code="c", verifier="v") + assert result.scope == "read" + assert any("scope" in record.getMessage() for record in caplog.records) + + +# -------------------------------------------------------------------------- +# Flow A end-to-end through the adapter (fake auth origin, real listener) +# -------------------------------------------------------------------------- + + +def _drive_callback(callback_url: str, query: str) -> int: + with urllib.request.urlopen(f"{callback_url}?{query}", timeout=5) as response: + return response.status + + +def test_flow_a_happy_path_through_the_adapter(auth_server) -> None: + auth_server.responder = lambda payload: ( + 200, + {"key": FAKE_KEY, "user_id": "9", "scope": "api"}, + ) + pending = start_loopback_login(auth_server.base_url) + try: + assert pending.receiver is not None + holder: dict = {} + + def deliver() -> None: + holder["status"] = _drive_callback( + pending.callback_url, f"code=one-time-code&state={pending.state}" + ) + + thread = threading.Thread(target=deliver, daemon=True) + thread.start() + + code = pending.receiver.wait(timeout=10) + thread.join(timeout=5) + assert holder["status"] == 200, "the browser must get a closeable page" + + credential = pending.exchange(auth_server.base_url, code) + assert credential.api_key == FAKE_KEY + assert auth_server.requests[-1]["payload"]["code_verifier"] == pending.verifier + finally: + pending.close() + + +def test_flow_a_state_mismatch_discards_the_code() -> None: + pending = start_loopback_login("https://auth.example.test") + try: + assert pending.receiver is not None + + def deliver() -> None: + try: + _drive_callback(pending.callback_url, "code=stolen&state=not-our-state") + except Exception: # noqa: BLE001 - the response may be cut short + pass + + threading.Thread(target=deliver, daemon=True).start() + with pytest.raises(PkceStateMismatch): + pending.receiver.wait(timeout=10) + finally: + pending.close() + + +def test_flow_a_denial_is_reported_as_denial() -> None: + pending = start_loopback_login("https://auth.example.test") + try: + assert pending.receiver is not None + threading.Thread( + target=lambda: _drive_callback( + pending.callback_url, f"error=access_denied&state={pending.state}" + ), + daemon=True, + ).start() + with pytest.raises(PkceDenied): + pending.receiver.wait(timeout=10) + finally: + pending.close() + + +def test_flow_a_cancel_and_timeout_release_the_listener() -> None: + pending = start_loopback_login("https://auth.example.test") + receiver = pending.receiver + assert receiver is not None + pending.cancel() + with pytest.raises(Exception): + receiver.wait(timeout=5) + pending.close() + assert pending.receiver is None + + timed_out = start_loopback_login("https://auth.example.test") + try: + with pytest.raises(PkceTimeout): + timed_out.receiver.wait(timeout=0.3) + finally: + timed_out.close() + + +def test_code_cannot_be_redeemed_twice_from_one_attempt(auth_server) -> None: + auth_server.responder = lambda payload: (200, {"key": FAKE_KEY, "scope": "api"}) + pending = start_oob_login(auth_server.base_url) + pending.exchange(auth_server.base_url, "one-time-code") + with pytest.raises(PkceExchangeRejected) as excinfo: + pending.exchange(auth_server.base_url, "one-time-code") + assert excinfo.value.status == 403 + assert len(auth_server.requests) == 1, "a reused code must not be re-sent" + + +# -------------------------------------------------------------------------- +# Secrets stay out of logs and errors +# -------------------------------------------------------------------------- + + +def test_verifier_and_key_never_appear_in_logs_or_errors(auth_server, caplog, capsys) -> None: + auth_server.responder = lambda payload: (403, {"error": "invalid_grant"}) + pending = start_oob_login(auth_server.base_url) + with caplog.at_level("DEBUG"): + with pytest.raises(PkceExchangeRejected) as excinfo: + pending.exchange(auth_server.base_url, "code-value") + message = str(excinfo.value) + + captured = capsys.readouterr() + for haystack in ( + caplog.text, + captured.out, + captured.err, + message, + ): + assert pending.verifier not in haystack + assert "code-value" not in haystack + assert FAKE_KEY not in haystack + # The authorize URL deliberately carries the challenge and the state (the + # server echoes the state back so we can compare it) — but never the + # verifier, which is what makes an intercepted code unredeemable. + assert pending.verifier not in pending.authorize_url + assert challenge_for(pending.verifier) in pending.authorize_url + assert pending.state in pending.authorize_url + + +def test_build_authorize_url_requires_a_challenge() -> None: + with pytest.raises(ValueError): + build_authorize_url("https://a.test", callback_url="oob", challenge="", state="s") diff --git a/tests/test_orcarouter_provider.py b/tests/test_orcarouter_provider.py new file mode 100644 index 000000000..720a45133 --- /dev/null +++ b/tests/test_orcarouter_provider.py @@ -0,0 +1,556 @@ +"""OrcaRouter provider seam: origins, credential lifecycle, dual auth. + +The load-bearing assertions here are that the two credential adapters +(``orcarouter`` for a pasted key, ``orcarouter-oauth`` for the PKCE grant) +produce the *same* :class:`OrcaCredential` and that everything downstream — +the OpenAI-compatible client and model discovery — is indifferent to which +one produced it. +""" + +from __future__ import annotations + +import json +import os +import stat +import urllib.request +from pathlib import Path +from typing import Any, Mapping + +import pytest + +from researchclaw.llm import PROVIDER_PRESETS, create_llm_client +from researchclaw.llm.client import LLMClient +from researchclaw.llm.orcarouter import ( + ApiKeySource, + CredentialStore, + OrcaAuthRequired, + OrcaConfigError, + OrcaCredential, + PkceSource, + build_credential_sources, + build_orcarouter_client, + complete_connect, + handle_unauthorized, + mask_secret, + redact_secrets, + resolve_credential, + resolve_endpoints, + start_connect, + validate_origin, +) +from researchclaw.llm.orcarouter_pkce import ( + ExchangeResult, + build_exchange_url, + start_oob_login, +) + +FAKE_KEY = "sk-orca-fake-0000000000000000000001" +FAKE_KEY_2 = "sk-orca-fake-0000000000000000000002" + + +# -------------------------------------------------------------------------- +# Origins +# -------------------------------------------------------------------------- + + +def test_defaults_use_two_distinct_public_origins() -> None: + endpoints = resolve_endpoints({}) + assert endpoints.auth_base == "https://www.orcarouter.ai" + assert endpoints.api_base == "https://api.orcarouter.ai/v1" + assert endpoints.auth_source == "default" + assert endpoints.api_source == "default" + + +def test_the_two_origins_are_never_derived_from_each_other() -> None: + endpoints = resolve_endpoints({}) + # The single most common integration mistake: swapping the hostname. + assert endpoints.auth_base.replace("www.", "api.") != endpoints.api_base + assert not endpoints.auth_base.endswith("/v1") + assert endpoints.api_base.endswith("/v1") + + +def test_shared_self_hosted_base_is_a_fallback_for_both() -> None: + endpoints = resolve_endpoints({"ORCA_BASE_URL": "https://orca.internal"}) + assert endpoints.auth_base == "https://orca.internal" + assert endpoints.api_base == "https://orca.internal/v1" + assert endpoints.auth_source == endpoints.api_source == "shared" + + +def test_explicit_overrides_win_over_the_shared_base() -> None: + endpoints = resolve_endpoints( + { + "ORCA_BASE_URL": "https://orca.internal", + "ORCA_AUTH_BASE_URL": "https://login.internal", + "ORCA_API_BASE_URL": "https://relay.internal/v1", + } + ) + assert endpoints.auth_base == "https://login.internal" + assert endpoints.api_base == "https://relay.internal/v1" + assert endpoints.auth_source == endpoints.api_source == "explicit" + + # ...and the shared base survives for whichever one is not overridden. + one_sided = resolve_endpoints( + {"ORCA_BASE_URL": "https://orca.internal", "ORCA_AUTH_BASE_URL": "https://login.internal"} + ) + assert one_sided.auth_base == "https://login.internal" + assert one_sided.api_base == "https://orca.internal/v1" + + +@pytest.mark.parametrize( + "url", + ["http://orca.example.com", "ftp://orca.example.com", "https://u:p@orca.example.com", "not-a-url"], +) +def test_remote_origins_must_be_https_without_userinfo(url: str) -> None: + with pytest.raises(OrcaConfigError): + validate_origin(url, name="ORCA_AUTH_BASE_URL") + + +@pytest.mark.parametrize( + "url", ["http://localhost:8080", "http://127.0.0.1:51733", "http://[::1]:9000"] +) +def test_http_is_allowed_only_for_loopback(url: str) -> None: + assert validate_origin(url, name="x").startswith("http://") + + +# -------------------------------------------------------------------------- +# The seam: two adapters, one credential +# -------------------------------------------------------------------------- + + +def test_both_adapters_yield_the_same_credential_shape(tmp_path: Path) -> None: + store = CredentialStore(tmp_path / "creds.json") + + api_adapter, pkce_adapter = build_credential_sources( + store=store, environ={"ORCAROUTER_API_KEY": FAKE_KEY} + ) + pkce_adapter.persist( + ExchangeResult(api_key=FAKE_KEY_2, scope="api", user_id="12345") + ) + + from_api = api_adapter.acquire() + from_pkce = pkce_adapter.acquire() + + assert isinstance(from_api, OrcaCredential) + assert isinstance(from_pkce, OrcaCredential) + assert from_api.api_key == FAKE_KEY + assert from_pkce.api_key == FAKE_KEY_2 + # Same type, same fields, same downstream treatment. + assert set(from_api.__dataclass_fields__) == set(from_pkce.__dataclass_fields__) + assert from_api.source == "api_key" + assert from_pkce.source == "pkce" + + +def test_downstream_client_and_catalogue_are_indifferent_to_credential_source( + tmp_path: Path, +) -> None: + """The provider client and model discovery never branch on the source.""" + store = CredentialStore(tmp_path / "creds.json") + api_adapter, pkce_adapter = build_credential_sources( + store=store, environ={"ORCAROUTER_API_KEY": FAKE_KEY} + ) + pkce_adapter.persist(ExchangeResult(api_key=FAKE_KEY_2, scope="api", user_id="7")) + + def _client_for(credential: OrcaCredential) -> LLMClient: + from researchclaw.llm.orcarouter import OrcaRouterProvider + + return OrcaRouterProvider(credential=credential).build_client() + + api_client = _client_for(api_adapter.acquire()) + pkce_client = _client_for(pkce_adapter.acquire()) + + assert api_client.config.base_url == pkce_client.config.base_url + assert api_client.config.base_url == "https://api.orcarouter.ai/v1" + # Only the secret differs; the wire configuration is identical. + assert api_client.config.api_key != pkce_client.config.api_key + assert ( + api_client._endpoint_path() == pkce_client._endpoint_path() == "/chat/completions" + ) + + +def test_the_project_owns_no_second_key_store(tmp_path: Path) -> None: + """The store lives in the project's existing ~/.researchclaw tree.""" + default = CredentialStore() + assert default.path == Path.home() / ".researchclaw" / "orcarouter" / "credentials.json" + + +# -------------------------------------------------------------------------- +# API-key adapter: save / read / clear / mask +# -------------------------------------------------------------------------- + + +def test_api_key_adapter_save_read_clear(tmp_path: Path) -> None: + store = CredentialStore(tmp_path / "creds.json") + adapter = ApiKeySource(store, api_key_env="ORCAROUTER_API_KEY", environ={}) + + assert adapter.status().configured is False + with pytest.raises(OrcaAuthRequired): + adapter.acquire() + + status = adapter.save(FAKE_KEY) + assert status.configured is True + assert status.masked == mask_secret(FAKE_KEY) + assert FAKE_KEY not in json.dumps(status.as_dict()) + assert adapter.acquire().api_key == FAKE_KEY + + adapter.clear() + assert adapter.status().configured is False + assert store.get("orcarouter") == {} + + +def test_api_key_adapter_updates_in_place_and_bumps_generation(tmp_path: Path) -> None: + store = CredentialStore(tmp_path / "creds.json") + adapter = ApiKeySource(store, environ={}) + first = adapter.save(FAKE_KEY) + second = adapter.save(FAKE_KEY_2) + assert second.generation == first.generation + 1 + assert adapter.acquire().api_key == FAKE_KEY_2 + + +@pytest.mark.parametrize("bad", ["", " ", "sk-openai-not-orca", "orca-1234"]) +def test_api_key_adapter_rejects_obviously_wrong_input(tmp_path: Path, bad: str) -> None: + adapter = ApiKeySource(CredentialStore(tmp_path / "c.json"), environ={}) + with pytest.raises(OrcaConfigError): + adapter.save(bad) + + +def test_stored_key_file_is_owner_only(tmp_path: Path) -> None: + path = tmp_path / "creds.json" + ApiKeySource(CredentialStore(path), environ={}).save(FAKE_KEY) + mode = stat.S_IMODE(os.stat(path).st_mode) + assert mode == 0o600, f"credential file must be 0600, got {oct(mode)}" + + +def test_config_and_env_take_precedence_over_the_store(tmp_path: Path) -> None: + store = CredentialStore(tmp_path / "creds.json") + ApiKeySource(store, environ={}).save(FAKE_KEY) + + from_env = ApiKeySource(store, environ={"ORCAROUTER_API_KEY": FAKE_KEY_2}) + assert from_env.acquire().api_key == FAKE_KEY_2 + + from_config = ApiKeySource(store, config_value="sk-orca-from-config-0001", environ={}) + assert from_config.acquire().api_key == "sk-orca-from-config-0001" + + +def test_pkce_adapter_does_not_use_the_api_key_env(tmp_path: Path) -> None: + """Choosing OrcaRouter — Auth must not silently fall back to an env key.""" + store = CredentialStore(tmp_path / "creds.json") + with pytest.raises(OrcaAuthRequired) as excinfo: + PkceSource(store).acquire() + assert excinfo.value.reason == "not_connected" + + +# -------------------------------------------------------------------------- +# PKCE persistence through the project's own connect adapter +# -------------------------------------------------------------------------- + + +def test_complete_connect_persists_a_durable_credential(tmp_path: Path) -> None: + store = CredentialStore(tmp_path / "creds.json") + pending = start_oob_login("https://auth.example.test") + credential = complete_connect( + pending, + "auth-code", + store=store, + endpoints=resolve_endpoints({}), + post_json=lambda url, payload, timeout: ( + 200, + json.dumps({"key": FAKE_KEY, "user_id": "12345", "scope": "api"}).encode(), + ), + ) + + assert credential.api_key == FAKE_KEY + assert credential.source == "pkce" + assert credential.grant_id == "12345" + + # Restarting reuses the stored key instead of minting a second one. + reused = PkceSource(store).acquire() + assert reused.api_key == FAKE_KEY + assert reused.generation == credential.generation + + # The exchange went to the auth origin, never the inference origin. + assert build_exchange_url("https://www.orcarouter.ai").endswith("/api/v1/auth/keys") + + +def test_exchange_targets_the_auth_origin_config_under_test(tmp_path: Path) -> None: + store = CredentialStore(tmp_path / "creds.json") + seen: dict[str, str] = {} + pending = start_oob_login("https://auth.example.test") + + def _post(url: str, payload: Mapping[str, Any], *, timeout: float): + seen["url"] = url + return 200, json.dumps({"key": FAKE_KEY, "scope": "api"}).encode() + + complete_connect( + pending, + "auth-code", + store=store, + endpoints=resolve_endpoints({}), + post_json=_post, + ) + assert seen["url"] == "https://www.orcarouter.ai/api/v1/auth/keys" + + +# -------------------------------------------------------------------------- +# Durable key lifecycle: no refresh, generation-safe 401 +# -------------------------------------------------------------------------- + + +def test_revoked_key_enters_needs_reauth_and_never_refreshes(tmp_path: Path) -> None: + store = CredentialStore(tmp_path / "creds.json") + adapter = PkceSource(store) + credential = adapter.persist(ExchangeResult(api_key=FAKE_KEY, user_id="9")) + + assert handle_unauthorized(credential, store=store) is True + assert store.status("orcarouter-oauth").needs_reauth is True + + # The secret is retained: a transient failure must not be irreversible. + assert store.get("orcarouter-oauth")["api_key"] == FAKE_KEY + + with pytest.raises(OrcaAuthRequired) as excinfo: + adapter.acquire() + assert excinfo.value.reason == "needs_reauth" + assert "revoke" in str(excinfo.value).lower() or "reconnect" in str(excinfo.value).lower() + + +def test_there_is_no_refresh_grant_to_call(tmp_path: Path) -> None: + """A PKCE-issued key is durable, not a refreshable OAuth token.""" + source = Path("researchclaw/llm/orcarouter.py").read_text(encoding="utf-8") + source += Path("researchclaw/llm/orcarouter_pkce.py").read_text(encoding="utf-8") + for forbidden in ("grant_type=refresh_token", "refresh_token", "/oauth/token"): + assert forbidden not in source, f"fake refresh machinery: {forbidden}" + + +def test_a_late_401_does_not_poison_a_newer_credential(tmp_path: Path) -> None: + store = CredentialStore(tmp_path / "creds.json") + adapter = PkceSource(store) + stale = adapter.persist(ExchangeResult(api_key=FAKE_KEY, user_id="9")) + + # The user reauthorizes; the generation moves on. + fresh = adapter.persist(ExchangeResult(api_key=FAKE_KEY_2, user_id="9")) + assert fresh.generation == stale.generation + 1 + + # A delayed 401 from the *old* request arrives now. + assert handle_unauthorized(stale, store=store) is False + status = store.status("orcarouter-oauth") + assert status.needs_reauth is False, "the new credential must stay usable" + assert ad_adapter_key(store) == FAKE_KEY_2 + + +def ad_adapter_key(store: CredentialStore) -> str: + return str(store.get("orcarouter-oauth")["api_key"]) + + +def test_401_for_an_unmarked_entry_is_a_noop(tmp_path: Path) -> None: + store = CredentialStore(tmp_path / "creds.json") + stranger = OrcaCredential( + api_key=FAKE_KEY, source="pkce", entry_id="orcarouter-oauth", generation=99 + ) + assert handle_unauthorized(stranger, store=store) is False + + +def test_a_repeat_401_is_not_rewritten(tmp_path: Path) -> None: + store = CredentialStore(tmp_path / "creds.json") + credential = PkceSource(store).persist(ExchangeResult(api_key=FAKE_KEY, user_id="1")) + assert handle_unauthorized(credential, store=store) is True + assert handle_unauthorized(credential, store=store) is False + + +def test_corrupt_credential_file_is_terminal_not_a_crash(tmp_path: Path) -> None: + path = tmp_path / "creds.json" + path.write_text("{ this is not json", encoding="utf-8") + store = CredentialStore(path) + assert store.status("orcarouter-oauth").configured is False + with pytest.raises(OrcaAuthRequired): + PkceSource(store).acquire() + + +# -------------------------------------------------------------------------- +# Provider registry integration +# -------------------------------------------------------------------------- + + +def _rc_config(provider: str, **overrides: Any): + from types import SimpleNamespace + + llm = dict( + provider=provider, + base_url="", + api_key="", + api_key_env="ORCAROUTER_API_KEY", + wire_api="chat_completions", + primary_model="orcarouter/auto", + fallback_models=("deepseek/deepseek-v4-pro",), + timeout_sec=60, + reviewer_model="", + reviewer_provider="", + reviewer_base_url="", + reviewer_api_key="", + reviewer_api_key_env="", + ) + llm.update(overrides) + return SimpleNamespace(llm=SimpleNamespace(**llm)) + + +def test_both_entries_are_first_class_registered_providers() -> None: + assert PROVIDER_PRESETS["orcarouter"]["base_url"] == "https://api.orcarouter.ai/v1" + assert PROVIDER_PRESETS["orcarouter-oauth"]["base_url"] == "https://api.orcarouter.ai/v1" + # They are separate, explicitly labelled choices — not one ambiguous button. + assert PROVIDER_PRESETS["orcarouter"]["label"] != PROVIDER_PRESETS["orcarouter-oauth"]["label"] + assert PROVIDER_PRESETS["orcarouter"]["auth"] == "api_key" + assert PROVIDER_PRESETS["orcarouter-oauth"]["auth"] == "pkce" + + from researchclaw.cli import _PROVIDER_CHOICES, _PROVIDER_MODELS, _PROVIDER_URLS + + chosen = {value[0] for value in _PROVIDER_CHOICES.values()} + assert {"orcarouter", "orcarouter-oauth"} <= chosen + assert _PROVIDER_URLS["orcarouter"] == "https://api.orcarouter.ai/v1" + # Both entries seed the same chain: a model verified in the live + # catalogue, with the routing alias kept as a fallback. + assert _PROVIDER_MODELS["orcarouter"] == _PROVIDER_MODELS["orcarouter-oauth"] + assert _PROVIDER_MODELS["orcarouter"][0] in ( + "deepseek/deepseek-v4-pro", + "orcarouter/auto", + ) + assert "orcarouter/auto" in _PROVIDER_MODELS["orcarouter"][1] + + +def test_provider_factory_routes_inference_to_the_orcarouter_relay( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("ORCA_CREDENTIALS_PATH", str(tmp_path / "creds.json")) + monkeypatch.setenv("ORCA_CATALOG_CACHE_DIR", str(tmp_path / "catalog")) + monkeypatch.setenv("ORCAROUTER_API_KEY", FAKE_KEY) + for provider in ("orcarouter", "orcarouter-oauth"): + client = create_llm_client(_rc_config(provider)) + assert isinstance(client, LLMClient) + assert client.config.base_url == "https://api.orcarouter.ai/v1" + assert client.config.api_key == FAKE_KEY + assert client._model_chain == ["orcarouter/auto", "deepseek/deepseek-v4-pro"] + + +def test_provider_factory_uses_the_pkce_grant_for_the_oauth_entry( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("ORCA_CREDENTIALS_PATH", str(tmp_path / "creds.json")) + monkeypatch.setenv("ORCA_CATALOG_CACHE_DIR", str(tmp_path / "catalog")) + monkeypatch.delenv("ORCAROUTER_API_KEY", raising=False) + CredentialStore(tmp_path / "creds.json") + PkceSource(CredentialStore(tmp_path / "creds.json")).persist( + ExchangeResult(api_key=FAKE_KEY_2, scope="api", user_id="5") + ) + + client = create_llm_client(_rc_config("orcarouter-oauth")) + assert client.config.api_key == FAKE_KEY_2 + assert client.config.base_url == "https://api.orcarouter.ai/v1" + + +def test_missing_credential_fails_closed_with_an_actionable_message( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("ORCA_CREDENTIALS_PATH", str(tmp_path / "creds.json")) + monkeypatch.setenv("ORCA_CATALOG_CACHE_DIR", str(tmp_path / "catalog")) + monkeypatch.delenv("ORCAROUTER_API_KEY", raising=False) + with pytest.raises(OrcaAuthRequired) as excinfo: + create_llm_client(_rc_config("orcarouter")) + message = str(excinfo.value) + assert "Connect with OrcaRouter" in message or "sk-orca" in message + + +def test_reviewer_and_panel_paths_reuse_the_same_seam( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """No AI entry point re-implements OrcaRouter authentication.""" + monkeypatch.setenv("ORCA_CREDENTIALS_PATH", str(tmp_path / "creds.json")) + monkeypatch.setenv("ORCA_CATALOG_CACHE_DIR", str(tmp_path / "catalog")) + monkeypatch.setenv("ORCAROUTER_API_KEY", FAKE_KEY) + config = _rc_config("orcarouter", reviewer_model="orcarouter/auto") + reviewer = LLMClient.reviewer_from_rc_config(config) + assert reviewer is not None + assert reviewer.config.base_url == "https://api.orcarouter.ai/v1" + assert reviewer.config.api_key == FAKE_KEY + + +def test_bearer_header_is_sent_to_the_orcarouter_relay( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("ORCA_CREDENTIALS_PATH", str(tmp_path / "creds.json")) + monkeypatch.setenv("ORCA_CATALOG_CACHE_DIR", str(tmp_path / "catalog")) + captured: dict[str, Any] = {} + + class _Response: + def read(self) -> bytes: + return json.dumps( + {"choices": [{"message": {"content": "pong"}, "finish_reason": "stop"}]} + ).encode() + + def __enter__(self): + return self + + def __exit__(self, *args: object) -> None: + return None + + def _fake_urlopen(request: urllib.request.Request, timeout: int): + captured["request"] = request + return _Response() + + monkeypatch.setattr(urllib.request, "urlopen", _fake_urlopen) + client = build_orcarouter_client( + _rc_config("orcarouter", api_key=FAKE_KEY, api_key_env="") + ) + client.chat([{"role": "user", "content": "ping"}]) + + request = captured["request"] + assert request.full_url == "https://api.orcarouter.ai/v1/chat/completions" + assert request.get_header("Authorization") == f"Bearer {FAKE_KEY}" + + +# -------------------------------------------------------------------------- +# Redaction +# -------------------------------------------------------------------------- + + +def test_redaction_hides_key_shaped_text() -> None: + text = f"failed with {FAKE_KEY} for user" + assert FAKE_KEY not in redact_secrets(text) + assert "sk-orca-***" in redact_secrets(text) + assert FAKE_KEY not in redact_secrets(f"Bearer {FAKE_KEY}") + assert FAKE_KEY not in redact_secrets(json.dumps({"key": FAKE_KEY})) + + +def test_mask_is_not_reversible_and_not_the_key() -> None: + masked = mask_secret(FAKE_KEY) + assert FAKE_KEY not in masked + assert masked.endswith(FAKE_KEY[-4:]) + assert mask_secret("") == "" + assert mask_secret("short") == "•" * 5 + + +def test_status_payloads_never_carry_a_secret(tmp_path: Path) -> None: + store = CredentialStore(tmp_path / "creds.json") + adapter = PkceSource(store) + credential = adapter.persist(ExchangeResult(api_key=FAKE_KEY, user_id="3")) + payload = json.dumps( + { + "api": build_credential_sources( + store=store, environ={"ORCAROUTER_API_KEY": FAKE_KEY} + )[0].status().as_dict(), + "pkce": adapter.status().as_dict(), + "credential": credential.masked, + "endpoints": resolve_endpoints({}).describe(), + } + ) + assert FAKE_KEY not in payload + assert "code_verifier" not in payload + + +def test_no_hardcoded_real_key_anywhere_in_the_implementation() -> None: + import subprocess + + result = subprocess.run( + ["git", "grep", "-nE", r"sk-orca-[A-Za-z0-9]{16,}", "--", "researchclaw/"], + capture_output=True, + text=True, + ) + hits = [line for line in result.stdout.splitlines() if line.strip()] + # Only the documented placeholder prefix and test-shaped fixtures may match. + assert not hits, f"hardcoded OrcaRouter key material: {hits}" diff --git a/tests/test_orcarouter_server.py b/tests/test_orcarouter_server.py new file mode 100644 index 000000000..81042ebd5 --- /dev/null +++ b/tests/test_orcarouter_server.py @@ -0,0 +1,449 @@ +"""OrcaRouter provider API and the browser lifecycle of a PKCE login. + +The server holds the single login lock; these tests drive every terminal +path through the real routes and assert the lock is released each time and +that a late response cannot repaint a newer attempt. +""" + +from __future__ import annotations + +import json +import threading +from pathlib import Path +from typing import Any + +import pytest + +fastapi = pytest.importorskip("fastapi") +from fastapi.testclient import TestClient # noqa: E402 + +from researchclaw.llm import orcarouter as orca # noqa: E402 +from researchclaw.llm.orcarouter import CredentialStore # noqa: E402 +from researchclaw.llm.orcarouter_pkce import ExchangeResult # noqa: E402 +from researchclaw.server.routes import providers as providers_route # noqa: E402 + +FAKE_KEY = "sk-orca-fake-0000000000000000000001" + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ORCA_CREDENTIALS_PATH", str(tmp_path / "creds.json")) + monkeypatch.setenv("ORCA_CATALOG_CACHE_DIR", str(tmp_path / "catalog")) + monkeypatch.delenv("ORCAROUTER_API_KEY", raising=False) + for var in ("ORCA_BASE_URL", "ORCA_AUTH_BASE_URL", "ORCA_API_BASE_URL"): + monkeypatch.delenv(var, raising=False) + providers_route.reset_for_tests() + + from researchclaw.config import RCConfig + from researchclaw.server.app import create_app + + config = RCConfig.load("config.researchclaw.example.yaml", check_paths=False) + with TestClient(create_app(config)) as test_client: + yield test_client + providers_route.reset_for_tests() + + +# -------------------------------------------------------------------------- +# Provider state +# -------------------------------------------------------------------------- + + +def test_both_orcarouter_entries_are_listed_with_stable_ids(client) -> None: + body = client.get("/api/providers").json() + by_id = {p["id"]: p for p in body["providers"]} + assert set(by_id) == {"orcarouter", "orcarouter-oauth"} + assert by_id["orcarouter"]["kind"] == "api_key" + assert by_id["orcarouter-oauth"]["kind"] == "pkce" + assert by_id["orcarouter"]["label"] != by_id["orcarouter-oauth"]["label"] + assert by_id["orcarouter"]["base_url"] == "https://api.orcarouter.ai/v1" + assert body["endpoints"]["auth_base"] == "https://www.orcarouter.ai" + + +def test_api_key_round_trip_masks_the_secret(client) -> None: + response = client.post("/api/providers/orcarouter/key", json={"api_key": FAKE_KEY}) + assert response.status_code == 200 + status = response.json()["status"] + assert status["configured"] is True + assert status["secret_masked"] != FAKE_KEY + assert FAKE_KEY not in response.text + + listed = client.get("/api/providers").json() + api_entry = next(p for p in listed["providers"] if p["id"] == "orcarouter") + assert FAKE_KEY not in json.dumps(listed) + assert api_entry["status"]["secret_masked"] == status["secret_masked"] + + cleared = client.request("DELETE", "/api/providers/orcarouter/key").json() + assert cleared["status"]["configured"] is False + + +def test_invalid_key_is_rejected_without_echoing_it(client) -> None: + response = client.post("/api/providers/orcarouter/key", json={"api_key": "not-a-key"}) + assert response.status_code == 400 + assert "sk-orca" in response.json()["detail"] + + +def test_models_requires_a_credential(client) -> None: + response = client.get("/api/providers/orcarouter/models") + assert response.status_code == 409 + assert "OrcaRouter" in response.json()["detail"] + + +# -------------------------------------------------------------------------- +# Model catalogue endpoint +# -------------------------------------------------------------------------- + + +def _seed_credential(tmp_path_unused: Any = None) -> None: + orca.CredentialStore().save(orca.PROVIDER_ID, FAKE_KEY, source="api_key") + + +def test_models_endpoint_filters_by_capability( + client, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _seed_credential() + live = { + "data": [ + { + "id": "deepseek/deepseek-v4-pro", + "supported_endpoint_types": ["openai"], + "architecture": {"input_modalities": ["text"]}, + }, + { + "id": "deepseek/deepseek-v4.1-flash", + "supported_endpoint_types": ["openai"], + "architecture": {"input_modalities": ["text", "image"]}, + }, + { + "id": "vendor/nano-banana", + "supported_endpoint_types": ["image-generation"], + }, + ] + } + monkeypatch.setattr( + "researchclaw.llm.orcarouter_catalog._default_fetcher", + lambda url, key, timeout, max_bytes: live, + ) + + text = client.get("/api/providers/orcarouter/models?capability=chat").json() + assert [m["id"] for m in text["models"]] == [ + "deepseek/deepseek-v4-pro", + "deepseek/deepseek-v4.1-flash", + ] + assert text["source"] == "live" + assert text["catalog_source"].endswith("/v1/models?capability=chat") + + multimodal = client.get( + "/api/providers/orcarouter/models?capability=chat&modality=image" + ).json() + assert [m["id"] for m in multimodal["models"]] == ["deepseek/deepseek-v4.1-flash"] + + images = client.get("/api/providers/orcarouter/models?capability=image").json() + assert [m["id"] for m in images["models"]] == ["vendor/nano-banana"] + + +def test_models_endpoint_sends_the_key_server_side_only( + client, monkeypatch: pytest.MonkeyPatch +) -> None: + _seed_credential() + seen: dict[str, str] = {} + + def _fetch(url: str, api_key: str, timeout: float, max_bytes: int): + seen["key"] = api_key + seen["url"] = url + return {"data": []} + + monkeypatch.setattr( + "researchclaw.llm.orcarouter_catalog._default_fetcher", _fetch + ) + body = client.get("/api/providers/orcarouter/models").json() + assert seen["key"] == FAKE_KEY + assert seen["url"].startswith("https://api.orcarouter.ai/v1/models") + assert FAKE_KEY not in json.dumps(body), "the key must never reach the browser" + + +def test_degraded_catalogue_is_labelled_with_its_source( + client, monkeypatch: pytest.MonkeyPatch +) -> None: + _seed_credential() + import urllib.error + + def _explode(url: str, api_key: str, timeout: float, max_bytes: int): + raise urllib.error.URLError("down") + + monkeypatch.setattr( + "researchclaw.llm.orcarouter_catalog._default_fetcher", _explode + ) + body = client.get("/api/providers/orcarouter/models").json() + assert body["degraded"] is True + assert body["source"] == "seed" + assert body["count"] > 0 + assert "verified" in json.dumps(body).lower() + + +# -------------------------------------------------------------------------- +# PKCE login lifecycle through the routes +# -------------------------------------------------------------------------- + + +def test_login_start_returns_an_authorize_url_and_locks_the_attempt(client) -> None: + response = client.post( + "/api/providers/orcarouter/auth/login", json={"flow": "oob"} + ) + assert response.status_code == 200 + attempt = response.json()["attempt"] + assert attempt["status"] == "pending" + assert attempt["busy"] is True + assert attempt["flow"] == "oob" + assert attempt["authorize_url"].startswith("https://www.orcarouter.ai/auth?") + assert "code_challenge_method=S256" in attempt["authorize_url"] + assert attempt["needs_code"] is True + # A verifier is never handed to the browser, in any field. + assert "code_verifier" not in json.dumps(attempt) + assert "verifier" not in json.dumps(attempt).lower() + + +def test_a_second_login_is_refused_while_one_is_pending(client) -> None: + assert client.post("/api/providers/orcarouter/auth/login", json={"flow": "oob"}).status_code == 200 + second = client.post("/api/providers/orcarouter/auth/login", json={"flow": "oob"}) + assert second.status_code == 409 + assert "already in progress" in second.json()["detail"] + + +def test_successful_exchange_persists_and_releases_the_lock( + client, monkeypatch: pytest.MonkeyPatch +) -> None: + started = client.post("/api/providers/orcarouter/auth/login", json={"flow": "oob"}).json() + attempt_id = started["attempt"]["attempt_id"] + + monkeypatch.setattr( + "researchclaw.llm.orcarouter_pkce._default_post_json", + lambda url, payload, timeout: ( + 200, + json.dumps({"key": FAKE_KEY, "user_id": "12345", "scope": "api"}).encode(), + ), + ) + done = client.post( + f"/api/providers/orcarouter/auth/{attempt_id}/code", json={"code": "the-code"} + ).json()["attempt"] + + assert done["status"] == "connected" + assert done["busy"] is False + assert done["secret_masked"] != FAKE_KEY + assert FAKE_KEY not in json.dumps(done) + assert done["account"] == "12345" + + # The credential is persisted for the OrcaRouter — Auth entry. + auth_state = client.get("/api/providers").json() + pkce = next(p for p in auth_state["providers"] if p["id"] == "orcarouter-oauth") + assert pkce["status"]["configured"] is True + + # The lock is free again, so a fresh login (e.g. after revocation) works. + assert client.post( + "/api/providers/orcarouter/auth/login", json={"flow": "oob"} + ).status_code == 200 + + +def test_denied_authorization_releases_the_lock(client, monkeypatch) -> None: + started = client.post("/api/providers/orcarouter/auth/login", json={"flow": "oob"}).json() + attempt_id = started["attempt"]["attempt_id"] + monkeypatch.setattr( + "researchclaw.llm.orcarouter_pkce._default_post_json", + lambda url, payload, timeout: (403, b'{"error":"invalid_grant"}'), + ) + done = client.post( + f"/api/providers/orcarouter/auth/{attempt_id}/code", json={"code": "bad"} + ).json()["attempt"] + + assert done["status"] == "error" + assert done["busy"] is False + assert "login" in done["error"].lower() or "code" in done["error"].lower() + assert client.post( + "/api/providers/orcarouter/auth/login", json={"flow": "oob"} + ).status_code == 200 + + +def test_explicit_cancel_releases_the_lock_and_is_idempotent(client) -> None: + started = client.post("/api/providers/orcarouter/auth/login", json={"flow": "oob"}).json() + attempt_id = started["attempt"]["attempt_id"] + + first = client.post(f"/api/providers/orcarouter/auth/{attempt_id}/cancel").json() + assert first["attempt"]["status"] == "cancelled" + assert first["attempt"]["busy"] is False + assert first["attempt"]["authorize_url"] == "" + + # The pagehide handler may fire after an explicit cancel: never an error. + second = client.post(f"/api/providers/orcarouter/auth/{attempt_id}/cancel") + assert second.status_code == 200 + + assert client.post( + "/api/providers/orcarouter/auth/login", json={"flow": "oob"} + ).status_code == 200 + + +def test_switching_flow_mid_login_requires_and_allows_a_cancel(client) -> None: + loopback = client.post( + "/api/providers/orcarouter/auth/login", json={"flow": "loopback"} + ).json()["attempt"] + assert loopback["flow"] == "loopback" + client.post(f"/api/providers/orcarouter/auth/{loopback['attempt_id']}/cancel") + assert client.post( + "/api/providers/orcarouter/auth/login", json={"flow": "oob"} + ).status_code == 200 + + +def test_timeout_releases_the_lock(client, monkeypatch) -> None: + started = client.post("/api/providers/orcarouter/auth/login", json={"flow": "oob"}).json() + attempt_id = started["attempt"]["attempt_id"] + + class _Boom(Exception): + pass + + def _timeout(url: str, payload: Any, *, timeout: float): + raise TimeoutError("no answer") + + monkeypatch.setattr( + "researchclaw.llm.orcarouter_pkce._default_post_json", _timeout + ) + done = client.post( + f"/api/providers/orcarouter/auth/{attempt_id}/code", json={"code": "c"} + ).json()["attempt"] + assert done["status"] == "error" + assert done["busy"] is False + assert FAKE_KEY not in json.dumps(done) + + +def test_a_submitted_code_cannot_be_resubmitted(client, monkeypatch) -> None: + started = client.post("/api/providers/orcarouter/auth/login", json={"flow": "oob"}).json() + attempt_id = started["attempt"]["attempt_id"] + monkeypatch.setattr( + "researchclaw.llm.orcarouter_pkce._default_post_json", + lambda url, payload, timeout: ( + 200, + json.dumps({"key": FAKE_KEY, "scope": "api"}).encode(), + ), + ) + assert client.post( + f"/api/providers/orcarouter/auth/{attempt_id}/code", json={"code": "c"} + ).status_code == 200 + again = client.post( + f"/api/providers/orcarouter/auth/{attempt_id}/code", json={"code": "c"} + ) + assert again.status_code == 409 + + +def test_unknown_attempt_id_is_a_404(client) -> None: + assert client.get("/api/providers/orcarouter/auth/nope").status_code == 404 + assert client.post("/api/providers/orcarouter/auth/nope/cancel").status_code == 404 + + +def test_a_stale_response_cannot_overwrite_a_newer_attempt( + client, monkeypatch: pytest.MonkeyPatch +) -> None: + """Generation guard: the server refuses to finish a superseded attempt.""" + stale = client.post( + "/api/providers/orcarouter/auth/login", json={"flow": "oob"} + ).json()["attempt"] + + monkeypatch.setattr( + "researchclaw.llm.orcarouter_pkce._default_post_json", + lambda url, payload, timeout: ( + 200, + json.dumps({"key": FAKE_KEY, "scope": "api"}).encode(), + ), + ) + + # A newer attempt replaces the old one in the registry before the old + # response lands. + replacement = providers_route.LoginAttempt( + attempt_id="newer", + generation=stale["generation"] + 1, + flow="oob", + authorize_url="https://www.orcarouter.ai/auth?x=1", + callback_url="oob", + pending=None, + created_at=0.0, + ) + with providers_route._registry._lock: # noqa: SLF001 - deliberate test hook + providers_route._registry._active.close() + providers_route._registry._active = replacement + + response = client.post( + f"/api/providers/orcarouter/auth/{stale['attempt_id']}/code", + json={"code": "late-code"}, + ) + assert response.status_code == 404, "a superseded attempt must not be completable" + + # ...and nothing was written for the newer attempt either. + assert CredentialStore().status("orcarouter-oauth").configured is False + + +def test_pagehide_cancel_after_success_does_not_drop_the_credential( + client, monkeypatch: pytest.MonkeyPatch +) -> None: + started = client.post("/api/providers/orcarouter/auth/login", json={"flow": "oob"}).json() + attempt_id = started["attempt"]["attempt_id"] + monkeypatch.setattr( + "researchclaw.llm.orcarouter_pkce._default_post_json", + lambda url, payload, timeout: ( + 200, + json.dumps({"key": FAKE_KEY, "scope": "api", "user_id": "1"}).encode(), + ), + ) + client.post(f"/api/providers/orcarouter/auth/{attempt_id}/code", json={"code": "c"}) + client.post(f"/api/providers/orcarouter/auth/{attempt_id}/cancel") + + assert CredentialStore().status("orcarouter-oauth").configured is True + + +# -------------------------------------------------------------------------- +# Reauthentication endpoint +# -------------------------------------------------------------------------- + + +def test_reauth_marks_only_the_rejected_generation(client) -> None: + store = CredentialStore() + first = store.save("orcarouter-oauth", FAKE_KEY, source="pkce", grant_id="1") + second = store.save("orcarouter-oauth", FAKE_KEY + "b", source="pkce", grant_id="1") + + stale = client.post( + "/api/providers/orcarouter/reauth", + json={"entry_id": "orcarouter-oauth", "generation": first.generation}, + ).json() + assert stale["applied"] is False + assert stale["status"]["needs_reauth"] is False + + fresh = client.post( + "/api/providers/orcarouter/reauth", + json={"entry_id": "orcarouter-oauth", "generation": second.generation}, + ).json() + assert fresh["applied"] is True + assert fresh["status"]["needs_reauth"] is True + # The stored key is kept, so a misclassified failure is reversible. + assert CredentialStore().get("orcarouter-oauth")["api_key"] == FAKE_KEY + "b" + + +# -------------------------------------------------------------------------- +# The shipped settings page +# -------------------------------------------------------------------------- + + +def test_settings_page_exposes_both_auth_entries(client) -> None: + response = client.get("/providers") + assert response.status_code == 200 + html = response.text + assert 'data-testid="api-key-input"' in html + assert 'data-testid="pkce-connect"' in html + assert "OrcaRouter — API" in html + assert "OrcaRouter — Auth" in html + assert "orca-logo-classic.png" in html + + +def test_settings_assets_are_served(client) -> None: + assert client.get("/providers/providers.js").status_code == 200 + assert client.get("/providers/providers.css").status_code == 200 + + +def test_settings_page_never_renders_a_secret(client) -> None: + client.post("/api/providers/orcarouter/key", json={"api_key": FAKE_KEY}) + page = client.get("/providers").text + assert FAKE_KEY not in page diff --git a/tests/test_orcarouter_ui.py b/tests/test_orcarouter_ui.py new file mode 100644 index 000000000..783bb838d --- /dev/null +++ b/tests/test_orcarouter_ui.py @@ -0,0 +1,330 @@ +"""Browser tests for the OrcaRouter settings page. + +These drive the real page in Chromium against the real FastAPI app. The +load-bearing case is the back-forward-cache shape: after ``pagehide`` the +busy state and the authorization hint must clear synchronously, and a second +login must be startable *without remounting the page* — a generation guard +alone leaves a bfcache-restored page permanently busy. + +The last test produces the screenshot/manifest bundle by running +``scripts/orcarouter_ui_evidence.py`` end to end (own server, own browser) +against the live catalogue and then holds it to the delivery checklist. The +bundle is a build product of that run — it is written to ``orca-evidence/`` in +the working tree, stays git-ignored, and is never carried in a patch. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import socket +import struct +import subprocess +import sys +import threading +import time +from pathlib import Path + +import pytest + +pytest.importorskip("playwright.sync_api") +uvicorn = pytest.importorskip("uvicorn") + +from playwright.sync_api import sync_playwright # noqa: E402 + +REPO_ROOT = Path(__file__).resolve().parent.parent +FAKE_KEY = "sk-orca-ui-test-placeholder-000000" +# Captured before the server fixture clears the variable so the evidence run +# can still reach the live catalogue. Never rendered or logged. +LIVE_KEY = os.environ.get("ORCAROUTER_API_KEY", "") + + +def _free_port() -> int: + probe = socket.socket() + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + probe.close() + return port + + +@pytest.fixture(scope="module") +def live_server(tmp_path_factory): + state = tmp_path_factory.mktemp("orca-ui-state") + saved = { + name: os.environ.get(name) + for name in ("ORCA_CREDENTIALS_PATH", "ORCA_CATALOG_CACHE_DIR", "ORCAROUTER_API_KEY") + } + os.environ["ORCA_CREDENTIALS_PATH"] = str(state / "creds.json") + os.environ["ORCA_CATALOG_CACHE_DIR"] = str(state / "catalog") + os.environ.pop("ORCAROUTER_API_KEY", None) + + from researchclaw.config import RCConfig + from researchclaw.server.app import create_app + + config = RCConfig.load( + str(REPO_ROOT / "config.researchclaw.example.yaml"), check_paths=False + ) + port = _free_port() + server = uvicorn.Server( + uvicorn.Config(create_app(config), host="127.0.0.1", port=port, log_level="error") + ) + threading.Thread(target=server.run, daemon=True).start() + for _ in range(120): + if getattr(server, "started", False): + break + time.sleep(0.1) + try: + yield f"http://127.0.0.1:{port}" + finally: + # These are process-global: leaking them makes other modules' default + # paths (and the credential they resolve) depend on collection order. + for name, value in saved.items(): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value + + +@pytest.fixture(scope="module") +def browser(): + with sync_playwright() as pw: + instance = pw.chromium.launch( + executable_path="/usr/bin/chromium", + args=["--no-sandbox", "--disable-dev-shm-usage"], + ) + yield instance + instance.close() + + +@pytest.fixture +def page(browser, live_server): + page = browser.new_page(viewport={"width": 1280, "height": 900}) + page.goto(f"{live_server}/providers", wait_until="domcontentloaded") + page.wait_for_function("() => !!window.rcOrcaProviders", timeout=15000) + yield page + page.close() + + +def test_both_authentication_entries_are_usable_on_one_page(page) -> None: + assert page.is_visible('[data-testid="api-key-input"]') + assert page.is_visible('[data-testid="pkce-connect"]') + assert page.is_enabled('[data-testid="api-key-save"]') + assert page.is_enabled('[data-testid="pkce-connect"]') + text = page.inner_text("#providers-app") + assert "OrcaRouter — API" in text + assert "OrcaRouter — Auth" in text + + +def test_the_page_never_receives_the_stored_key(page) -> None: + page.fill('[data-testid="api-key-input"]', FAKE_KEY) + page.click('[data-testid="api-key-save"]') + page.wait_for_function( + "() => document.querySelector('[data-testid=\"secret-masked\"]')" + ".dataset.masked === 'true'", + timeout=15000, + ) + assert FAKE_KEY not in page.content() + assert "sk-orca" in page.inner_text('[data-testid="secret-masked"]') + # ...and clearing it is a first-class action. + page.click('[data-testid="api-key-clear"]') + page.wait_for_function( + "() => document.querySelector('[data-testid=\"secret-masked\"]')" + ".dataset.masked === 'false'", + timeout=15000, + ) + + +def test_pagehide_clears_busy_state_and_allows_a_second_login(page) -> None: + """The bfcache shape: no remount, but the page must not stay busy.""" + page.select_option('[data-testid="pkce-flow"]', "oob") + page.click('[data-testid="pkce-connect"]') + page.wait_for_function( + "() => window.rcOrcaProviders.state.attemptId !== null", timeout=15000 + ) + first_attempt = page.evaluate("() => window.rcOrcaProviders.state.attemptId") + assert page.evaluate("() => window.rcOrcaProviders.state.busy") is True + assert page.is_visible('[data-testid="pkce-code-row"]') + assert page.inner_text('[data-testid="pkce-hint"]') + assert page.is_visible('[data-testid="pkce-authorize-url"]') + + # A real pagehide — the browser may restore this page from bfcache. + page.evaluate("() => window.dispatchEvent(new Event('pagehide'))") + + assert page.evaluate("() => window.rcOrcaProviders.state.busy") is False + assert page.inner_text('[data-testid="pkce-hint"]') == "" + assert page.is_enabled('[data-testid="pkce-connect"]') + assert page.is_disabled('[data-testid="pkce-cancel"]') + assert not page.is_visible('[data-testid="pkce-code-row"]') + assert not page.is_visible('[data-testid="pkce-authorize-url"]') + + # A second login must start without remounting the component. + page.click('[data-testid="pkce-connect"]') + page.wait_for_function( + "() => window.rcOrcaProviders.state.busy === true" + " && window.rcOrcaProviders.state.attemptId !== null", + timeout=15000, + ) + second_attempt = page.evaluate("() => window.rcOrcaProviders.state.attemptId") + assert second_attempt, "a second login must be startable after pagehide" + assert second_attempt != first_attempt + # No verifier material is reachable from the page. + assert "code_verifier" not in page.content() + page.click('[data-testid="pkce-cancel"]') + + +def test_explicit_cancel_clears_the_lock(page) -> None: + page.select_option('[data-testid="pkce-flow"]', "oob") + page.click('[data-testid="pkce-connect"]') + page.wait_for_function( + "() => window.rcOrcaProviders.state.busy === true", timeout=15000 + ) + page.click('[data-testid="pkce-cancel"]') + page.wait_for_function( + "() => window.rcOrcaProviders.state.busy === false", timeout=15000 + ) + assert page.is_enabled('[data-testid="pkce-connect"]') + assert "Cancelled." in page.inner_text('[data-testid="pkce-hint"]') + + page.click('[data-testid="pkce-connect"]') + page.wait_for_function( + "() => window.rcOrcaProviders.state.busy === true", timeout=15000 + ) + page.click('[data-testid="pkce-cancel"]') + + +def test_switching_flow_mid_login_releases_and_restarts(page) -> None: + page.select_option('[data-testid="pkce-flow"]', "loopback") + page.click('[data-testid="pkce-connect"]') + page.wait_for_function( + "() => window.rcOrcaProviders.state.busy === true", timeout=15000 + ) + page.click('[data-testid="pkce-cancel"]') + page.wait_for_function( + "() => window.rcOrcaProviders.state.busy === false", timeout=15000 + ) + page.select_option('[data-testid="pkce-flow"]', "oob") + page.click('[data-testid="pkce-connect"]') + page.wait_for_function( + "() => window.rcOrcaProviders.state.attemptId !== null" + " && window.rcOrcaProviders.state.busy === true", + timeout=15000, + ) + assert page.is_visible('[data-testid="pkce-code-row"]') + page.click('[data-testid="pkce-cancel"]') + + +def _png_size(path: Path) -> tuple[int, int]: + """Width/height straight out of the PNG IHDR chunk.""" + header = path.read_bytes()[:24] + assert header[:8] == b"\x89PNG\r\n\x1a\n", f"{path.name} is not a PNG" + width, height = struct.unpack(">II", header[16:24]) + return width, height + + +def _bundle_digest(root: Path) -> dict[str, str]: + """Every file in a bundle directory, keyed by relative path.""" + if not root.is_dir(): + return {} + return { + str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in sorted(root.rglob("*")) + if path.is_file() + } + + +def test_gui_evidence_bundle_is_generated_into_the_repository(tmp_path) -> None: + """Produce the reviewed bundle, then hold it to the delivery checklist. + + Runs the generator the way a maintainer would — real server, real Chromium, + live catalogue when a credential is present — with its default output, so + the screenshots are written to ``orca-evidence/`` in this working tree by + this run. They are a build product and stay git-ignored: the delivery gate + only accepts evidence produced while the tree under test is being checked, + so nothing here may be tracked. + """ + bundle_dir = REPO_ROOT / "orca-evidence" + shutil.rmtree(bundle_dir, ignore_errors=True) + + env = {k: v for k, v in os.environ.items() if k != "ORCAROUTER_API_KEY"} + if LIVE_KEY: + env["ORCAROUTER_API_KEY"] = LIVE_KEY + proc = subprocess.run( + [ + sys.executable, + str(REPO_ROOT / "scripts" / "orcarouter_ui_evidence.py"), + "--state-dir", + str(tmp_path / "evidence-state"), + ], + capture_output=True, + text=True, + timeout=480, + env=env, + ) + assert proc.returncode == 0, f"evidence run failed:\n{proc.stdout[-4000:]}\n{proc.stderr[-4000:]}" + + # The bundle is generated, never carried: a tracked one is stale by + # construction and the delivery gate refuses a patch that contains it. + tracked = subprocess.run( + ["git", "ls-files", "--error-unmatch", "--", "orca-evidence"], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + ) + assert tracked.returncode != 0, ( + "the evidence bundle is tracked; it must be generated by this run instead:" + f" {tracked.stdout.strip()}" + ) + assert set(_bundle_digest(bundle_dir)) == { + "manifest.json", + "auth-methods.png", + "text-model-dropdown.png", + } + + manifest = json.loads((bundle_dir / "manifest.json").read_text(encoding="utf-8")) + automation = manifest["automation"] + ui = manifest["ui_assertions"] + + assert automation["framework"] == "playwright" + assert automation["passed"] is True + assert automation["catalog_source"] == "https://api.orcarouter.ai/v1/models?capability=chat" + assert automation["catalog_model_count"] > 0 + assert automation["image_model_count"] >= 0 + assert manifest["catalog_models"], "the dropdown must be backed by a real catalogue" + + # --- both authentication choices are on screen, key stays masked --- + assert ui["api_key_visible"] is True + assert ui["pkce_visible"] is True + assert ui["secret_masked"] is True + assert ui["controls_enabled"] is True + + # --- the dropdown really opened, anchored to its trigger --- + assert ui["dropdown_open"] is True + assert int(ui["item_count"]) == automation["catalog_model_count"] + assert ui["opaque_background"] is True + assert ui["visible_border"] is True + assert float(ui["trigger_panel_right_delta"]) <= 2 + + # --- artifacts exist, are real PNGs, and match their recorded digests --- + by_kind = {a["kind"]: a for a in manifest["artifacts"]} + assert set(by_kind) == {"auth-methods", "text-model-dropdown"} + for artifact in manifest["artifacts"]: + assert artifact["ui"], f"{artifact['kind']} carries no UI assertions" + path = bundle_dir / artifact["path"] + assert path.is_file(), f"{artifact['path']} was not written" + assert path.stat().st_size == artifact["bytes"] + assert hashlib.sha256(path.read_bytes()).hexdigest() == artifact["sha256"] + width, height = _png_size(path) + assert width >= 800 and height >= 450, f"{artifact['path']} is {width}x{height}" + + # The generator re-checks the gate's checklist before it returns, and this + # is the same function the generator calls — a bundle that fails it never + # reaches the delivery run. + sys.path.insert(0, str(REPO_ROOT / "scripts")) + try: + import orcarouter_ui_evidence + + orcarouter_ui_evidence.validate_bundle(manifest, bundle_dir) + finally: + sys.path.remove(str(REPO_ROOT / "scripts"))