diff --git a/README.md b/README.md index 768874586..539f30862 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Oracle chooses API mode when an OpenAI key is available and browser mode otherwi | Browser | You want Oracle to use a signed-in ChatGPT or Gemini browser session. | Install Chrome and complete the one-time login flow. | | Render | You want to inspect, copy, or paste the bundle yourself. | No account or key is required. | -API mode supports OpenAI, Azure OpenAI, Anthropic, Gemini, xAI, OpenRouter, and compatible endpoints. Browser mode uses Chrome automation for ChatGPT and a cookie-based Gemini client. See [browser mode](docs/browser-mode.md) and [provider endpoints](docs/openai-endpoints.md) for setup and limits. +API mode supports OpenAI, Azure OpenAI, Anthropic, Gemini, xAI, OpenRouter, OrcaRouter, and compatible endpoints. Browser mode uses Chrome automation for ChatGPT and a cookie-based Gemini client. See [browser mode](docs/browser-mode.md) and [provider endpoints](docs/openai-endpoints.md) for setup and limits. ## Control the context @@ -102,14 +102,14 @@ For agent integrations, run the `oracle-mcp` stdio server or install the Oracle ## Documentation -| Topic | Guide | -| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| Installation and first run | [Install](docs/install.md) · [Quickstart](docs/quickstart.md) | -| Browser automation | [Browser mode](docs/browser-mode.md) · [Linux](docs/linux.md) · [Windows](docs/windows.md) | -| Providers | [OpenAI and Azure](docs/openai-endpoints.md) · [Anthropic](docs/anthropic.md) · [Gemini](docs/gemini.md) · [OpenRouter](docs/openrouter.md) | -| Runs and models | [Sessions](docs/sessions.md) · [Follow-ups](docs/followup.md) · [Multi-model](docs/multimodel.md) | -| Configuration and commands | [Configuration](docs/configuration.md) · [CLI reference](docs/cli-reference.md) | -| Agent integrations | [Agents](docs/agents.md) · [MCP](docs/mcp.md) · [Bridge](docs/bridge.md) | +| Topic | Guide | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Installation and first run | [Install](docs/install.md) · [Quickstart](docs/quickstart.md) | +| Browser automation | [Browser mode](docs/browser-mode.md) · [Linux](docs/linux.md) · [Windows](docs/windows.md) | +| Providers | [OpenAI and Azure](docs/openai-endpoints.md) · [Anthropic](docs/anthropic.md) · [Gemini](docs/gemini.md) · [OpenRouter](docs/openrouter.md) · [OrcaRouter](docs/orcarouter.md) | +| Runs and models | [Sessions](docs/sessions.md) · [Follow-ups](docs/followup.md) · [Multi-model](docs/multimodel.md) | +| Configuration and commands | [Configuration](docs/configuration.md) · [CLI reference](docs/cli-reference.md) | +| Agent integrations | [Agents](docs/agents.md) · [MCP](docs/mcp.md) · [Bridge](docs/bridge.md) | ## Related projects diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 1732986c5..4294875ca 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -74,7 +74,7 @@ Notes: | `--azure-deployment` | Azure deployment name. | | `--azure-api-version` | Azure API version. | -See [OpenAI / Azure / OpenRouter](openai-endpoints.md) and [OpenRouter](openrouter.md). +See [OpenAI / Azure / OpenRouter / OrcaRouter](openai-endpoints.md), [OpenRouter](openrouter.md), and [OrcaRouter](orcarouter.md). ## Browser mode diff --git a/docs/followup.md b/docs/followup.md index 7cdee34c1..82c29b164 100644 --- a/docs/followup.md +++ b/docs/followup.md @@ -65,6 +65,7 @@ Without `--followup-model`, Oracle errors with the available lineage. | Anthropic | ❌ no Oracle-side response id chaining yet | | Gemini | ❌ | | OpenRouter | ❌ | +| OrcaRouter | ❌ | | Custom `--base-url` | ❌ — unknown whether the upstream preserves the id | If you try to follow up on an unsupported provider, Oracle errors clearly instead of silently starting fresh. diff --git a/docs/index.md b/docs/index.md index 1ef627c3c..69cc5673e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,7 +30,7 @@ oracle --followup -p "Re-evaluate with this new context" --file "src/**/*.t ## What Oracle does -- **One CLI to a stable of pro agents.** GPT-5.5 Pro (default), GPT-5.5, GPT-5.4 Pro, GPT-5.4, GPT-5.2 Pro, GPT-5.1 Pro, GPT-5.1 Codex, Gemini 3.1 Pro, Gemini 3.5 Flash, Gemini 3.1 Flash-Lite, Claude Sonnet 4.6, Claude Opus 4.1 — plus any OpenRouter id. +- **One CLI to a stable of pro agents.** GPT-5.5 Pro (default), GPT-5.5, GPT-5.4 Pro, GPT-5.4, GPT-5.2 Pro, GPT-5.1 Pro, GPT-5.1 Codex, Gemini 3.1 Pro, Gemini 3.5 Flash, Gemini 3.1 Flash-Lite, Claude Sonnet 4.6, Claude Opus 4.1 — plus any OpenRouter or OrcaRouter id. - **Engines, plural.** API mode for reliability, browser mode (Chrome over CDP) when you don't want to pay or want the Pro tier, `--render --copy` when neither is an option. - **Multi-model in one run.** Aggregate cost, token usage, and lineage across providers in a single command. - **Recoverable panels.** `doctor --providers`, `--preflight`, `--route`, and `--allow-partial` make provider/key failures clear without losing successful model output. diff --git a/docs/install.md b/docs/install.md index cad066eed..e93a49d84 100644 --- a/docs/install.md +++ b/docs/install.md @@ -46,6 +46,7 @@ API mode is opt-in and reads keys from the environment. Set whichever providers | Google | `GEMINI_API_KEY` | Gemini 3.1 Pro, Gemini 3.5 Flash, Gemini 3.1 Flash-Lite | | Anthropic | `ANTHROPIC_API_KEY` | Claude Sonnet 4.6, Claude Opus 4.1 | | OpenRouter | `OPENROUTER_API_KEY` | Any OpenRouter id (e.g. `minimax/minimax-m2`) | +| OrcaRouter | `ORCAROUTER_API_KEY` | Any OrcaRouter id (e.g. `orcarouter/auto`) | If no key is set, Oracle defaults to **browser mode** and drives ChatGPT directly — see [Browser Mode](browser-mode.md) for the manual-login flow. diff --git a/docs/mythical-pro-agents.md b/docs/mythical-pro-agents.md index acc48c235..83bb53ae2 100644 --- a/docs/mythical-pro-agents.md +++ b/docs/mythical-pro-agents.md @@ -24,7 +24,7 @@ The headline frontier models — the ones marked **Pro** — are slow, expensive | Claude Opus 4.1 | API only | `claude-4.1-opus` | — | Deepest single-shot reasoning | | Claude Sonnet 4.6 | API only | `claude-4.6-sonnet` | — | Fast Claude | -Plus any **OpenRouter** id — e.g. `minimax/minimax-m2`, `openai/gpt-4o-mini`, `qwen/qwen-2.5-coder-32b-instruct` — when you set `OPENROUTER_API_KEY`. +Plus any **OpenRouter** or **OrcaRouter** id — e.g. `minimax/minimax-m2`, `openai/gpt-4o-mini`, `orcarouter/auto` — when you set `OPENROUTER_API_KEY` or `ORCAROUTER_API_KEY`. ## When to reach for which @@ -106,7 +106,7 @@ oracle --engine browser --model gpt-5.5-pro \ - **GPT-5.x Pro** (browser): "free" with ChatGPT Pro / Plus subscription, but slow. - **Gemini 3.1 Pro / 3.5 Flash / 3.1 Flash-Lite** (browser): available through a signed-in Google account, subject to account access. - **Claude Opus 4.1**: per-token API only. -- **OpenRouter ids**: pricing varies wildly per provider; always preview with `--dry-run summary`. +- **OpenRouter / OrcaRouter ids**: pricing varies wildly per provider; always preview with `--dry-run summary`. `--files-report` plus `--dry-run summary` is the right reflex before any Pro run on a large bundle. Token counts ≠ dollars, but they're a close-enough proxy. @@ -125,4 +125,4 @@ oracle --engine browser --model gpt-5.5-pro \ | Deep Research | — | ✅ | — | | `--render --copy` fallback | ✅ | ✅ | ✅ | -See provider-specific docs for the gory details: [OpenAI / Azure / OpenRouter](openai-endpoints.md), [Gemini](gemini.md), [Anthropic](anthropic.md), [Grok](grok.md). +See provider-specific docs for the gory details: [OpenAI / Azure / OpenRouter / OrcaRouter](openai-endpoints.md), [Gemini](gemini.md), [Anthropic](anthropic.md), [Grok](grok.md). diff --git a/docs/openai-endpoints.md b/docs/openai-endpoints.md index b7dc73794..bf5380868 100644 --- a/docs/openai-endpoints.md +++ b/docs/openai-endpoints.md @@ -171,3 +171,16 @@ oracle --model minimax/minimax-m2 --prompt "Summarize the notes" - If `OPENROUTER_API_KEY` is set and no provider-specific key is available for the chosen model, Oracle defaults the base URL to `https://openrouter.ai/api/v1`. - You can still set `--base-url` explicitly; if it points at OpenRouter (with or without a trailing `/responses`), Oracle will use `OPENROUTER_API_KEY` and forward optional attribution headers (`OPENROUTER_REFERER` / `OPENROUTER_TITLE`). - Multi-model runs accept OpenRouter ids alongside built-in ones. See `docs/openrouter.md` for details. + +## OrcaRouter + +Oracle can also talk to OrcaRouter (OpenAI-compatible) with any model id: + +```bash +export ORCAROUTER_API_KEY="sk-orca-..." +oracle --model orcarouter/auto --prompt "Summarize the notes" +``` + +- If `ORCAROUTER_API_KEY` is set and no provider-specific key is available for the chosen model, Oracle defaults the base URL to `https://api.orcarouter.ai/v1`. +- You can still set `--base-url` explicitly; if it points at OrcaRouter (with or without a trailing `/responses`), Oracle will use `ORCAROUTER_API_KEY` and forward optional attribution headers (`ORCAROUTER_REFERER` / `ORCAROUTER_TITLE`). +- Multi-model runs accept OrcaRouter ids alongside built-in ones. See `docs/orcarouter.md` for details. diff --git a/docs/orcarouter.md b/docs/orcarouter.md new file mode 100644 index 000000000..8fae7395d --- /dev/null +++ b/docs/orcarouter.md @@ -0,0 +1,40 @@ +# OrcaRouter + +Oracle can target any OpenAI-compatible model on OrcaRouter with minimal setup. + +## Setup + +```bash +export ORCAROUTER_API_KEY="sk-orca-..." +# Optional but recommended for attribution: +export ORCAROUTER_REFERER="https://your-app.example" +export ORCAROUTER_TITLE="Oracle CLI" +``` + +- If you set `ORCAROUTER_API_KEY` and don’t provide another provider key, Oracle automatically routes API runs to `https://api.orcarouter.ai/v1`. +- You can still point explicitly with `--base-url https://api.orcarouter.ai/v1` (Oracle will trim a trailing `/responses` if you include it). +- First-party keys win: if `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, or `XAI_API_KEY` is present, Oracle will prefer those providers unless you set an OrcaRouter base URL. + +## Models + +- `--model` accepts any OrcaRouter model id, e.g. `orcarouter/auto`, `openai/gpt-5.5`, `anthropic/claude-sonnet-5`. +- `--models` can mix first-party and OrcaRouter ids: + `oracle --engine api --models "gpt-5-pro,orcarouter/auto,anthropic/claude-sonnet-5" -p "Summarize..."`. +- `orcarouter/auto` is OrcaRouter's adaptive routing model — it selects the best upstream per request based on your routing config at https://www.orcarouter.ai/console/routing. The full catalog is at https://www.orcarouter.ai/models. + +## Headers + +When hitting OrcaRouter, Oracle forwards optional attribution headers: + +- `HTTP-Referer` from `ORCAROUTER_REFERER` (or `ORCAROUTER_HTTP_REFERER`) +- `X-Title` from `ORCAROUTER_TITLE` + +## Sessions and logs + +- Model ids that contain `/` are stored with a safe slug (`/` → `__`) for per-model log filenames, but the original id remains visible in session metadata and CLI output. + +## Tips + +- If a model id isn’t found in the OrcaRouter catalog, Oracle still sends the request with the id you provided. +- Pricing/context limits are pulled from the `/api/pricing` catalog when available; otherwise, Oracle uses conservative defaults (200k tokens, cost unknown). +- `orcarouter/auto` routes to whatever upstream OrcaRouter picks, so context limits and pricing are best-effort until the route resolves. diff --git a/src/cli/engine.ts b/src/cli/engine.ts index 9059290d2..4ae8ba458 100644 --- a/src/cli/engine.ts +++ b/src/cli/engine.ts @@ -59,7 +59,7 @@ export function resolveEngine({ } function hasApiEnvironment(env: NodeJS.ProcessEnv): boolean { - return Boolean(env.OPENAI_API_KEY || env.OPENROUTER_API_KEY); + return Boolean(env.OPENAI_API_KEY || env.OPENROUTER_API_KEY || env.ORCAROUTER_API_KEY); } function normalizeEngineMode(raw: unknown): EngineMode | null { diff --git a/src/cli/sessionRunner.ts b/src/cli/sessionRunner.ts index 49074d875..0b209f6a4 100644 --- a/src/cli/sessionRunner.ts +++ b/src/cli/sessionRunner.ts @@ -190,6 +190,7 @@ export async function performSessionRun({ const modelConfig = await resolveModelConfig(primaryModel, { baseUrl: runOptions.baseUrl, openRouterApiKey: process.env.OPENROUTER_API_KEY, + orcaRouterApiKey: process.env.ORCAROUTER_API_KEY, modelOverrides: runOptions.modelOverrides, }); const files = await readFiles(runOptions.file ?? [], { diff --git a/src/oracle/client.ts b/src/oracle/client.ts index 878cb3239..822654546 100644 --- a/src/oracle/client.ts +++ b/src/oracle/client.ts @@ -18,7 +18,7 @@ import type { } from "./types.js"; import { createGeminiClient } from "./gemini.js"; import { createClaudeClient } from "./claude.js"; -import { isOpenRouterBaseUrl } from "./modelResolver.js"; +import { isOpenRouterBaseUrl, isOrcaRouterBaseUrl } from "./modelResolver.js"; import { isCustomBaseUrl } from "./baseUrl.js"; export function buildAzureResponsesBaseUrl(endpoint: string): string { @@ -39,12 +39,13 @@ export function createDefaultClientFactory(): ClientFactory { }, ): ClientLike => { const openRouter = isOpenRouterBaseUrl(options?.baseUrl); + const orcaRouter = isOrcaRouterBaseUrl(options?.baseUrl); const customProxy = isCustomBaseUrl(options?.baseUrl); - // When using any custom/proxy base URL (OpenRouter, LiteLLM, vLLM, Together, etc.), + // When using any custom/proxy base URL (OpenRouter, OrcaRouter, LiteLLM, vLLM, Together, etc.), // route ALL models through the OpenAI chat/completions adapter instead of native SDKs // which would reject the proxy's API key. - if (!openRouter && !customProxy) { + if (!openRouter && !orcaRouter && !customProxy) { if (options?.model?.startsWith("gemini")) { // Gemini client uses its own SDK; allow passing the already-resolved id for transparency/logging. return createGeminiClient(key, options.model, options.resolvedModelId); @@ -57,7 +58,9 @@ export function createDefaultClientFactory(): ClientFactory { let instance: OpenAI; const defaultHeaders: Record | undefined = openRouter ? buildOpenRouterHeaders() - : undefined; + : orcaRouter + ? buildOrcaRouterHeaders() + : undefined; const httpTimeoutMs = typeof options?.httpTimeoutMs === "number" && @@ -80,7 +83,7 @@ export function createDefaultClientFactory(): ClientFactory { }); } - if (openRouter || customProxy) { + if (openRouter || orcaRouter || customProxy) { return buildOpenRouterCompletionClient(instance); } @@ -113,6 +116,24 @@ function buildOpenRouterHeaders(): Record | undefined { return headers; } +function buildOrcaRouterHeaders(): Record | undefined { + const headers: Record = {}; + // OrcaRouter accepts the same attribution headers as OpenRouter so the + // console can attribute traffic to Oracle. + const referer = + process.env.ORCAROUTER_REFERER ?? + process.env.ORCAROUTER_HTTP_REFERER ?? + "https://github.com/steipete/oracle"; + const title = process.env.ORCAROUTER_TITLE ?? "Oracle CLI"; + if (referer) { + headers["HTTP-Referer"] = referer; + } + if (title) { + headers["X-Title"] = title; + } + return headers; +} + function loadCustomClientFactory(): ClientFactory | null { const override = process.env.ORACLE_CLIENT_FACTORY; if (!override) { diff --git a/src/oracle/modelResolver.ts b/src/oracle/modelResolver.ts index fb0fa9440..60c2eac49 100644 --- a/src/oracle/modelResolver.ts +++ b/src/oracle/modelResolver.ts @@ -13,6 +13,9 @@ import { pricingFromUsdPerToken } from "tokentally"; const OPENROUTER_DEFAULT_BASE = "https://openrouter.ai/api/v1"; const OPENROUTER_MODELS_ENDPOINT = "https://openrouter.ai/api/v1/models"; +const ORCAROUTER_DEFAULT_BASE = "https://api.orcarouter.ai/v1"; +const ORCAROUTER_MODELS_ENDPOINT = "https://api.orcarouter.ai/v1/models"; +const ORCAROUTER_PRICING_ENDPOINT = "https://www.orcarouter.ai/api/pricing"; const require = createRequire(import.meta.url); let countTokensGpt5ProImpl: TokenizerFn | undefined; @@ -57,6 +60,37 @@ export function normalizeOpenRouterBaseUrl(baseUrl: string): string { } } +export function isOrcaRouterBaseUrl(baseUrl: string | undefined): boolean { + if (!baseUrl) return false; + try { + const url = new URL(baseUrl); + const host = url.hostname.toLowerCase(); + // Boundary-safe check: only the documented api.orcarouter.ai API host (or a + // subdomain of it) is treated as OrcaRouter, so a lookalike hostname or the + // www marketing site never receives OrcaRouter credentials or headers. + return host === "api.orcarouter.ai" || host.endsWith(".api.orcarouter.ai"); + } catch { + return false; + } +} + +export function defaultOrcaRouterBaseUrl(): string { + return ORCAROUTER_DEFAULT_BASE; +} + +export function normalizeOrcaRouterBaseUrl(baseUrl: string): string { + try { + const url = new URL(baseUrl); + // Same contract as OpenRouter: trim a trailing /responses so the client does not double-append. + if (url.pathname.endsWith("/responses")) { + url.pathname = url.pathname.replace(/\/responses\/?$/, ""); + } + return url.toString().replace(/\/+$/, ""); + } catch { + return baseUrl; + } +} + export function safeModelSlug(model: string): string { return model.replace(/[/\\]/g, "__").replace(/[:*?"<>|]/g, "_"); } @@ -142,6 +176,85 @@ async function fetchOpenRouterCatalog( return models; } +/** + * Convert OrcaRouter pricing ratios to USD-per-token OpenRouter-style pricing. + * OrcaRouter bills against a `model_ratio` base of $2 per 1M input tokens, with + * `completion_ratio` scaling the output price (see https://www.orcarouter.ai/api/pricing). + */ +function orcaRouterPricingEntry( + ratio: number | undefined, + completionRatio: number | undefined, +): OpenRouterModelInfo["pricing"] { + if ( + typeof ratio !== "number" || + !Number.isFinite(ratio) || + ratio < 0 || + typeof completionRatio !== "number" || + !Number.isFinite(completionRatio) || + completionRatio < 0 + ) { + return undefined; + } + return { + prompt: (ratio * 2) / 1_000_000, + completion: (ratio * completionRatio * 2) / 1_000_000, + }; +} + +async function fetchOrcaRouterCatalog( + apiKey: string, + fetcher: FetchFn, +): Promise { + const now = Date.now(); + const cached = catalogCache.get(apiKey); + if (cached && now - cached.fetchedAt < CACHE_TTL_MS) { + return cached.models; + } + const [modelsResponse, pricingResponse] = await Promise.all([ + fetcher(ORCAROUTER_MODELS_ENDPOINT, { + headers: { + authorization: `Bearer ${apiKey}`, + }, + }), + // /api/pricing is a public endpoint (no auth) that carries the pricing ratios. + fetcher(ORCAROUTER_PRICING_ENDPOINT), + ]); + if (!modelsResponse.ok) { + throw new Error(`Failed to load OrcaRouter models (${modelsResponse.status})`); + } + const modelsJson = (await modelsResponse.json()) as { data?: OpenRouterModelInfo[] }; + const models = modelsJson?.data ?? []; + const pricingByModel = new Map(); + if (pricingResponse.ok) { + try { + const pricingJson = (await pricingResponse.json()) as { + data?: Array<{ + model_name?: string; + model_ratio?: number; + completion_ratio?: number; + }>; + }; + for (const entry of pricingJson?.data ?? []) { + if (!entry.model_name) continue; + const pricing = orcaRouterPricingEntry(entry.model_ratio, entry.completion_ratio); + if (pricing) { + pricingByModel.set(entry.model_name, pricing); + } + } + } catch { + // Pricing is best-effort; keep the /v1/models metadata when the ratios fail to parse. + } + } + const modelsWithPricing = models.map((model) => ({ + ...model, + pricing: pricingByModel.get(model.id), + })); + catalogCache.set(apiKey, { fetchedAt: now, models: modelsWithPricing }); + // Prune after insert so the max-size constraint is strictly enforced. + pruneCatalogCache(now); + return modelsWithPricing; +} + function mapToOpenRouterId( candidate: string, catalog: OpenRouterModelInfo[], @@ -163,6 +276,7 @@ export async function resolveModelConfig( options: { baseUrl?: string; openRouterApiKey?: string; + orcaRouterApiKey?: string; fetcher?: FetchFn; modelOverrides?: ModelOverridesConfig; } = {}, @@ -178,6 +292,7 @@ async function resolveBaseModelConfig( options: { baseUrl?: string; openRouterApiKey?: string; + orcaRouterApiKey?: string; fetcher?: FetchFn; } = {}, ): Promise { @@ -185,12 +300,14 @@ async function resolveBaseModelConfig( const fetcher: FetchFn = options.fetcher ?? globalThis.fetch.bind(globalThis); const openRouterActive = isOpenRouterBaseUrl(options.baseUrl) || Boolean(options.openRouterApiKey); + const orcaRouterActive = + isOrcaRouterBaseUrl(options.baseUrl) || Boolean(options.orcaRouterApiKey); - if (known && !openRouterActive) { + if (known && !openRouterActive && !orcaRouterActive) { return known; } - // Try to enrich from OpenRouter catalog when available. + // Try to enrich from the OpenRouter catalog when available. if (openRouterActive && options.openRouterApiKey) { try { const catalog = await fetchOpenRouterCatalog(options.openRouterApiKey, fetcher); @@ -237,6 +354,51 @@ async function resolveBaseModelConfig( } } + // Enrich from the OrcaRouter catalog (same OpenRouter-shaped response) when available. + if (orcaRouterActive && options.orcaRouterApiKey) { + try { + const catalog = await fetchOrcaRouterCatalog(options.orcaRouterApiKey, fetcher); + const targetId = mapToOpenRouterId( + typeof model === "string" ? model : String(model), + catalog, + known?.provider, + ); + const info = catalog.find((entry) => entry.id === targetId) ?? null; + if (info) { + return { + ...(known ?? { + model, + tokenizer: countTokensGpt5Pro as TokenizerFn, + inputLimit: info.context_length ?? 200_000, + reasoning: null, + }), + apiModel: targetId, + provider: known?.provider ?? "other", + inputLimit: info.context_length ?? known?.inputLimit ?? 200_000, + pricing: openRouterPricing(info.pricing) ?? known?.pricing ?? null, + supportsBackground: known?.supportsBackground ?? true, + supportsSearch: known?.supportsSearch ?? true, + }; + } + // No metadata hit; fall through to synthesized config. + return { + ...(known ?? { + model, + tokenizer: countTokensGpt5Pro as TokenizerFn, + inputLimit: 200_000, + reasoning: null, + }), + apiModel: targetId, + provider: known?.provider ?? "other", + supportsBackground: known?.supportsBackground ?? true, + supportsSearch: known?.supportsSearch ?? true, + pricing: known?.pricing ?? null, + }; + } catch { + // If catalog fetch fails, fall back to a synthesized config. + } + } + // Synthesized generic config for custom endpoints or failed catalog fetch. return { ...(known ?? { diff --git a/src/oracle/providerFailures.ts b/src/oracle/providerFailures.ts index d2f4d85e2..0d93d2f55 100644 --- a/src/oracle/providerFailures.ts +++ b/src/oracle/providerFailures.ts @@ -132,6 +132,7 @@ export function sanitizeProviderMessage(message: string): string { "$1$2[redacted]", ) .replace(/\bsk-(?:ant-|or-)?[A-Za-z0-9_-]{8,}\b/g, "sk-...[redacted]") + .replace(/\bsk-orca-[A-Za-z0-9_-]{8,}\b/g, "sk-orca-...[redacted]") .replace(/\bxai-[A-Za-z0-9_-]{8,}\b/g, "xai-...[redacted]") .replace(/\bAIza[0-9A-Za-z_-]{8,}\b/g, "AIza...[redacted]"); } @@ -160,15 +161,24 @@ function inferFailureRoute(context: ProviderFailureContext): { if (plan.providerLabel === "OpenRouter" || plan.keySource.includes("OPENROUTER_API_KEY")) { return { provider: "openrouter", keySource }; } + if (plan.providerLabel === "OrcaRouter" || plan.keySource.includes("ORCAROUTER_API_KEY")) { + return { provider: "orcarouter", keySource }; + } if (plan.provider === "azure") return { provider: "azure", keySource }; if (plan.provider === "google") return { provider: "gemini", keySource }; return { provider: plan.provider, keySource }; } const normalized = context.model?.toLowerCase() ?? ""; const baseUrl = context.baseUrl?.toLowerCase() ?? ""; + if (baseUrl.includes("orcarouter.ai")) { + return { provider: "orcarouter", keySource: keyEnvForProvider("orcarouter") }; + } if (baseUrl.includes("openrouter.ai") || (normalized.includes("/") && !baseUrl)) { return { provider: "openrouter", keySource: keyEnvForProvider("openrouter") }; } + if (normalized.startsWith("orcarouter/")) { + return { provider: "orcarouter", keySource: keyEnvForProvider("orcarouter") }; + } if ( context.azure?.endpoint?.trim() && context.providerMode !== "openai" && @@ -209,6 +219,8 @@ function keyEnvForProvider(provider: string): string | undefined { return "AZURE_OPENAI_API_KEY"; case "openrouter": return "OPENROUTER_API_KEY"; + case "orcarouter": + return "ORCAROUTER_API_KEY"; case "openai": return "OPENAI_API_KEY"; default: diff --git a/src/oracle/providerRoutePlan.ts b/src/oracle/providerRoutePlan.ts index 1a8f097dc..3f910326a 100644 --- a/src/oracle/providerRoutePlan.ts +++ b/src/oracle/providerRoutePlan.ts @@ -2,8 +2,11 @@ import { isCustomBaseUrl } from "./baseUrl.js"; import { formatBaseUrlForLog, maskApiKey } from "./logging.js"; import { defaultOpenRouterBaseUrl, + defaultOrcaRouterBaseUrl, isOpenRouterBaseUrl, + isOrcaRouterBaseUrl, normalizeOpenRouterBaseUrl, + normalizeOrcaRouterBaseUrl, } from "./modelResolver.js"; import { resolveProviderRoutingState, validateProviderRouting } from "./providerRouting.js"; import type { ApiProviderMode, AzureOptions, ModelConfig, ModelName } from "./types.js"; @@ -45,6 +48,7 @@ export interface ResolvedProviderRoute extends ProviderRoutePlan { baseUrl?: string; apiKey?: string; openRouterFallback: boolean; + orcaRouterFallback: boolean; azureEndpoint?: string; } @@ -58,6 +62,7 @@ export function buildProviderRoutePlan(input: ProviderRoutePlanInput): ProviderR baseUrl: _baseUrl, nativeProvider: _nativeProvider, openRouterFallback: _openRouterFallback, + orcaRouterFallback: _orcaRouterFallback, azureEndpoint: _azureEndpoint, ...plan } = buildResolvedProviderRoute(input); @@ -92,6 +97,7 @@ function buildResolvedProviderRoute(input: ProviderRoutePlanInput): ResolvedProv isAzureOpenAI, baseUrl: input.baseUrl, openRouterFallback: false, + orcaRouterFallback: false, apiKey: input.apiKey, env, }); @@ -110,6 +116,7 @@ function buildResolvedProviderRoute(input: ProviderRoutePlanInput): ResolvedProv nativeProvider: provider, baseUrl: input.baseUrl, openRouterFallback: false, + orcaRouterFallback: false, isAzureOpenAI, azureEndpoint: state?.azureEndpoint ?? input.azure?.endpoint, azureConfigured, @@ -128,11 +135,25 @@ function buildResolvedProviderRoute(input: ProviderRoutePlanInput): ResolvedProv const isAzureOpenAI = state.isAzureOpenAI; let baseUrl = input.baseUrl?.trim(); const providerQualifiedOpenRouterCandidate = - !isAzureOpenAI && providerMode !== "openai" && input.model.includes("/"); + !isAzureOpenAI && + providerMode !== "openai" && + input.model.includes("/") && + !input.model.startsWith("orcarouter/"); + const providerQualifiedOrcaRouterCandidate = + !isAzureOpenAI && providerMode !== "openai" && input.model.startsWith("orcarouter/"); if ( baseUrl && providerQualifiedOpenRouterCandidate && !isOpenRouterBaseUrl(baseUrl) && + !isOrcaRouterBaseUrl(baseUrl) && + !isCustomBaseUrl(baseUrl) + ) { + baseUrl = undefined; + } + if ( + baseUrl && + providerQualifiedOrcaRouterCandidate && + !isOrcaRouterBaseUrl(baseUrl) && !isCustomBaseUrl(baseUrl) ) { baseUrl = undefined; @@ -146,7 +167,10 @@ function buildResolvedProviderRoute(input: ProviderRoutePlanInput): ResolvedProv } else { envBaseUrl = env.OPENAI_BASE_URL?.trim(); } - if (!providerQualifiedOpenRouterCandidate || (envBaseUrl && isCustomBaseUrl(envBaseUrl))) { + if ( + (!providerQualifiedOpenRouterCandidate && !providerQualifiedOrcaRouterCandidate) || + (envBaseUrl && isCustomBaseUrl(envBaseUrl)) + ) { baseUrl = envBaseUrl; } } @@ -160,9 +184,10 @@ function buildResolvedProviderRoute(input: ProviderRoutePlanInput): ResolvedProv env, }); const providerQualifiedOpenRouterRoute = providerQualifiedOpenRouterCandidate && !baseUrl; + const providerQualifiedOrcaRouterRoute = providerQualifiedOrcaRouterCandidate && !baseUrl; const providerKeyMissing = !isAzureOpenAI && - (providerQualifiedOpenRouterRoute + (providerQualifiedOpenRouterRoute || providerQualifiedOrcaRouterRoute ? true : providerMode === "openai" ? !nativeKey.present @@ -178,6 +203,11 @@ function buildResolvedProviderRoute(input: ProviderRoutePlanInput): ResolvedProv (providerMode !== "openai" && providerKeyMissing && (provider === "other" || openRouterKey.present))); + // Explicit orcarouter/ model ids always route through OrcaRouter, even when the + // key is missing (so the error reports the right env var). Unprefixed custom + // model ids keep the OpenRouter fallback below, preserving the existing behavior + // for generic OpenAI-compatible ids. + const orcaRouterFallback = !baseUrl && providerQualifiedOrcaRouterRoute; if (openRouterFallback) { baseUrl = defaultOpenRouterBaseUrl(); @@ -185,6 +215,12 @@ function buildResolvedProviderRoute(input: ProviderRoutePlanInput): ResolvedProv if (baseUrl && isOpenRouterBaseUrl(baseUrl)) { baseUrl = normalizeOpenRouterBaseUrl(baseUrl); } + if (orcaRouterFallback) { + baseUrl = defaultOrcaRouterBaseUrl(); + } + if (baseUrl && isOrcaRouterBaseUrl(baseUrl)) { + baseUrl = normalizeOrcaRouterBaseUrl(baseUrl); + } const key = getKeyForRoute({ model: input.model, @@ -193,6 +229,7 @@ function buildResolvedProviderRoute(input: ProviderRoutePlanInput): ResolvedProv isAzureOpenAI, baseUrl, openRouterFallback, + orcaRouterFallback, apiKey: input.apiKey, env, }); @@ -200,6 +237,7 @@ function buildResolvedProviderRoute(input: ProviderRoutePlanInput): ResolvedProv provider, baseUrl, openRouterFallback, + orcaRouterFallback, isAzureOpenAI, }); const fallbackHost = DEFAULT_PROVIDER_HOSTS[provider] ?? DEFAULT_PROVIDER_HOSTS.openai; @@ -219,6 +257,7 @@ function buildResolvedProviderRoute(input: ProviderRoutePlanInput): ResolvedProv nativeProvider: provider, baseUrl, openRouterFallback, + orcaRouterFallback, isAzureOpenAI, azureEndpoint: state.azureEndpoint, azureConfigured, @@ -250,6 +289,7 @@ function getNativeKey({ isAzureOpenAI, baseUrl: undefined, openRouterFallback: false, + orcaRouterFallback: false, apiKey, env, }); @@ -262,6 +302,7 @@ function getKeyForRoute({ isAzureOpenAI, baseUrl, openRouterFallback, + orcaRouterFallback, apiKey, env, }: { @@ -271,6 +312,7 @@ function getKeyForRoute({ isAzureOpenAI: boolean; baseUrl?: string; openRouterFallback: boolean; + orcaRouterFallback: boolean; apiKey?: string; env: NodeJS.ProcessEnv; }): { source: string; preview: string; present: boolean; value?: string } { @@ -288,6 +330,9 @@ function getKeyForRoute({ if (providerMode === "openai") { return readKey(["OPENAI_API_KEY"], env); } + if (isOrcaRouterBaseUrl(baseUrl) || orcaRouterFallback || model.startsWith("orcarouter/")) { + return readKey(["ORCAROUTER_API_KEY"], env); + } if (isOpenRouterBaseUrl(baseUrl) || openRouterFallback) { return readKey(["OPENROUTER_API_KEY"], env); } @@ -334,14 +379,17 @@ function routeProviderLabel({ provider, baseUrl, openRouterFallback, + orcaRouterFallback, isAzureOpenAI, }: { provider: NonNullable; baseUrl?: string; openRouterFallback: boolean; + orcaRouterFallback: boolean; isAzureOpenAI: boolean; }): string { if (isAzureOpenAI) return "Azure OpenAI"; + if (isOrcaRouterBaseUrl(baseUrl) || orcaRouterFallback) return "OrcaRouter"; if (isOpenRouterBaseUrl(baseUrl) || openRouterFallback) return "OpenRouter"; if (baseUrl && isCustomBaseUrl(baseUrl)) return "OpenAI-compatible"; return providerLabel(provider); diff --git a/src/oracle/run.ts b/src/oracle/run.ts index 8583ee7a3..5c6d3228e 100644 --- a/src/oracle/run.ts +++ b/src/oracle/run.ts @@ -42,6 +42,7 @@ import { formatTokenEstimate, formatTokenValue, resolvePreviewMode } from "./run import { estimateUsdCost } from "tokentally"; import { isOpenRouterBaseUrl, + isOrcaRouterBaseUrl, isProModel, resolveModelConfig, resolveOverriddenApiModel, @@ -101,6 +102,13 @@ function runtimeKeySource({ if (providerMode === "openai") { return "OPENAI_API_KEY"; } + if ( + isOrcaRouterBaseUrl(route.baseUrl) || + route.orcaRouterFallback || + route.model.startsWith("orcarouter/") + ) { + return "ORCAROUTER_API_KEY"; + } if (isOpenRouterBaseUrl(route.baseUrl) || route.openRouterFallback || route.model.includes("/")) { return "OPENROUTER_API_KEY"; } @@ -142,11 +150,13 @@ function validateReasoningOptions(options: RunOracleOptions, route: ResolvedProv reasoningMode && !route.isAzureOpenAI && (route.openRouterFallback || + route.orcaRouterFallback || isOpenRouterBaseUrl(route.baseUrl) || + isOrcaRouterBaseUrl(route.baseUrl) || isCustomBaseUrl(route.baseUrl)) ) { throw new PromptValidationError( - "--reasoning-mode requires the OpenAI or Azure OpenAI Responses API; OpenRouter and custom --base-url routes use the Chat Completions adapter.", + "--reasoning-mode requires the OpenAI or Azure OpenAI Responses API; OpenRouter, OrcaRouter, and custom --base-url routes use the Chat Completions adapter.", { model: options.model, reasoningMode }, ); } @@ -208,6 +218,7 @@ export async function runOracle( const { isAzureOpenAI, azureDeploymentName } = route; const baseUrl = route.baseUrl; const openRouterFallback = route.openRouterFallback; + const orcaRouterFallback = route.orcaRouterFallback; validateReasoningOptions(options, route); const logVerbose = (message: string): void => { @@ -222,9 +233,11 @@ export async function runOracle( ? "AZURE_OPENAI_API_KEY (or OPENAI_API_KEY)" : providerMode === "openai" ? "OPENAI_API_KEY" - : isOpenRouterBaseUrl(baseUrl) || openRouterFallback - ? "OPENROUTER_API_KEY" - : route.keySource; + : isOrcaRouterBaseUrl(baseUrl) || orcaRouterFallback + ? "ORCAROUTER_API_KEY" + : isOpenRouterBaseUrl(baseUrl) || openRouterFallback + ? "OPENROUTER_API_KEY" + : route.keySource; const browserModeHint = options.model.startsWith("gpt") ? ' If you have a ChatGPT Pro subscription, retry with --engine browser (or MCP engine:"browser" / preset:"chatgpt-pro-heavy"); browser mode uses your signed-in ChatGPT session instead of an API key.' : ""; @@ -251,9 +264,12 @@ export async function runOracle( const resolverOpenRouterApiKey = openRouterFallback || isOpenRouterBaseUrl(baseUrl) ? apiKey : undefined; + const resolverOrcaRouterApiKey = + orcaRouterFallback || isOrcaRouterBaseUrl(baseUrl) ? apiKey : undefined; const modelConfig = await resolveModelConfig(options.model, { baseUrl, openRouterApiKey: resolverOpenRouterApiKey, + orcaRouterApiKey: resolverOrcaRouterApiKey, modelOverrides: options.modelOverrides, }); const isLongRunningModel = isProTierModel; @@ -452,7 +468,9 @@ export async function runOracle( } const proxyCompatibleBaseUrl = - !isAzureOpenAI && baseUrl && (isOpenRouterBaseUrl(baseUrl) || isCustomBaseUrl(baseUrl)) + !isAzureOpenAI && + baseUrl && + (isOpenRouterBaseUrl(baseUrl) || isOrcaRouterBaseUrl(baseUrl) || isCustomBaseUrl(baseUrl)) ? baseUrl : undefined; const apiEndpoint = isAzureOpenAI diff --git a/tests/engine.test.ts b/tests/engine.test.ts index 51b4fd40e..1b18265b3 100644 --- a/tests/engine.test.ts +++ b/tests/engine.test.ts @@ -10,6 +10,7 @@ delete envWithoutKey.ANTHROPIC_API_KEY; delete envWithoutKey.GEMINI_API_KEY; delete envWithoutKey.XAI_API_KEY; delete envWithoutKey.OPENROUTER_API_KEY; +delete envWithoutKey.ORCAROUTER_API_KEY; delete envWithKey.ORACLE_ENGINE; delete envWithoutKey.ORACLE_ENGINE; @@ -24,6 +25,12 @@ describe("resolveEngine", () => { expect(engine).toBe("browser"); }); + it("prefers api when ORCAROUTER_API_KEY is set", () => { + const env = { ...envWithoutKey, ORCAROUTER_API_KEY: "sk-orca-test" } as NodeJS.ProcessEnv; + const engine = resolveEngine({ engine: undefined, browserFlag: false, env }); + expect(engine).toBe("api"); + }); + it("respects ORACLE_ENGINE=browser even when OPENAI_API_KEY is set", () => { const env = { ...envWithKey } as NodeJS.ProcessEnv; // biome-ignore lint/complexity/useLiteralKeys: env var names are uppercase with underscores diff --git a/tests/oracle/providerFailures.test.ts b/tests/oracle/providerFailures.test.ts index 718c86c32..e2d45207f 100644 --- a/tests/oracle/providerFailures.test.ts +++ b/tests/oracle/providerFailures.test.ts @@ -125,6 +125,19 @@ describe("provider failure classification", () => { }); }); + test("uses OrcaRouter key hints for orcarouter/ model routes", () => { + expect( + classifyProviderFailure(new Error("invalid api key"), { + model: "orcarouter/auto", + env: { ORCAROUTER_API_KEY: "sk-orca-secret123456789" }, + }), + ).toMatchObject({ + category: "auth-failed", + provider: "orcarouter", + keyEnv: "ORCAROUTER_API_KEY", + }); + }); + test("uses actual key source in recovery hints", () => { expect( classifyProviderFailure(new Error("401 invalid api key"), { diff --git a/tests/oracle/providerRoutePlan.test.ts b/tests/oracle/providerRoutePlan.test.ts index 4cfc77d8a..602fde0c7 100644 --- a/tests/oracle/providerRoutePlan.test.ts +++ b/tests/oracle/providerRoutePlan.test.ts @@ -182,6 +182,67 @@ describe("provider route plan", () => { expect(plan.keySource).toBe("OPENROUTER_API_KEY"); }); + test("orcarouter/ model ids route through OrcaRouter", () => { + const plan = buildProviderRoutePlan({ + model: "orcarouter/auto", + providerMode: "auto", + env: { + ORCAROUTER_API_KEY: "sk-orca-test-key", + }, + }); + + expect(plan.ok).toBe(true); + expect(plan.providerLabel).toBe("OrcaRouter"); + expect(plan.base).toBe("api.orcarouter.ai/v1"); + expect(plan.keySource).toBe("ORCAROUTER_API_KEY"); + }); + + test("orcarouter/ model ids report missing OrcaRouter key", () => { + const plan = buildProviderRoutePlan({ + model: "orcarouter/auto", + providerMode: "auto", + env: {}, + }); + + expect(plan.ok).toBe(false); + expect(plan.providerLabel).toBe("OrcaRouter"); + expect(plan.base).toBe("api.orcarouter.ai/v1"); + expect(plan.keySource).toBe("ORCAROUTER_API_KEY"); + expect(plan.error).toBe("Missing ORCAROUTER_API_KEY."); + }); + + test("explicit OrcaRouter base URL routes through OrcaRouter", () => { + const plan = buildProviderRoutePlan({ + model: "openai/gpt-4o-mini", + providerMode: "auto", + baseUrl: "https://api.orcarouter.ai/v1", + env: { + ORCAROUTER_API_KEY: "sk-orca-test-key", + }, + }); + + expect(plan.ok).toBe(true); + expect(plan.providerLabel).toBe("OrcaRouter"); + expect(plan.base).toBe("api.orcarouter.ai/v1"); + expect(plan.keySource).toBe("ORCAROUTER_API_KEY"); + }); + + test("unprefixed custom ids still fall back to OpenRouter, not OrcaRouter", () => { + const plan = buildProviderRoutePlan({ + model: "llama-3", + providerMode: "auto", + env: { + ORCAROUTER_API_KEY: "sk-orca-test-key", + OPENROUTER_API_KEY: "or-openrouter-test-key", + }, + }); + + expect(plan.ok).toBe(true); + expect(plan.providerLabel).toBe("OpenRouter"); + expect(plan.base).toBe("openrouter.ai/api/..."); + expect(plan.keySource).toBe("OPENROUTER_API_KEY"); + }); + test("provider-qualified ids ignore native provider base URLs", () => { const plan = buildProviderRoutePlan({ model: "anthropic/claude-sonnet-4.5", diff --git a/tests/orcarouter.test.ts b/tests/orcarouter.test.ts new file mode 100644 index 000000000..24b205825 --- /dev/null +++ b/tests/orcarouter.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi } from "vitest"; +import { + resolveModelConfig, + safeModelSlug, + isOrcaRouterBaseUrl, + defaultOrcaRouterBaseUrl, + normalizeOrcaRouterBaseUrl, + resetOpenRouterCatalogCacheForTest, +} from "../src/oracle/modelResolver.js"; + +describe("OrcaRouter helpers", () => { + it("detects OrcaRouter base URLs", () => { + expect(isOrcaRouterBaseUrl("https://api.orcarouter.ai/v1")).toBe(true); + expect(isOrcaRouterBaseUrl("https://api.orcarouter.ai/v1/responses")).toBe(true); + expect(isOrcaRouterBaseUrl("https://openrouter.ai/api/v1")).toBe(false); + expect(isOrcaRouterBaseUrl("https://api.openai.com")).toBe(false); + }); + + it("rejects lookalike OrcaRouter hostnames", () => { + // The hostname classifier must not send OrcaRouter credentials to a lookalike + // domain (mirrors the trust-boundary finding from the Requesty review). + expect(isOrcaRouterBaseUrl("https://notorcarouter.ai/v1")).toBe(false); + expect(isOrcaRouterBaseUrl("https://orcarouter.ai.evil.com/v1")).toBe(false); + expect(isOrcaRouterBaseUrl("https://api.orcarouter.ai.evil.com/v1")).toBe(false); + expect(isOrcaRouterBaseUrl("https://orcarouter.ai")).toBe(false); + expect(isOrcaRouterBaseUrl("https://www.orcarouter.ai")).toBe(false); + }); + + it("returns the default OrcaRouter base URL", () => { + expect(defaultOrcaRouterBaseUrl()).toBe("https://api.orcarouter.ai/v1"); + }); + + it("normalizes a trailing /responses segment", () => { + expect(normalizeOrcaRouterBaseUrl("https://api.orcarouter.ai/v1/responses")).toBe( + "https://api.orcarouter.ai/v1", + ); + expect(normalizeOrcaRouterBaseUrl("https://api.orcarouter.ai/v1/")).toBe( + "https://api.orcarouter.ai/v1", + ); + }); + + it("hydrates config from the OrcaRouter catalog", async () => { + resetOpenRouterCatalogCacheForTest(); + const fetcher = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: [ + { + id: "orcarouter/auto", + context_length: 1_000_000, + supported_endpoint_types: ["openai"], + }, + ], + }), + }) + // The pricing endpoint is fetched in parallel; return an empty payload so the + // models list is used as-is. + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ data: [] }), + }) as unknown as typeof fetch; + + const config = await resolveModelConfig("orcarouter/auto", { + orcaRouterApiKey: "sk-orca-dummy", + fetcher, + }); + + expect(config.apiModel).toBe("orcarouter/auto"); + expect(config.inputLimit).toBe(1_000_000); + expect(config.provider).toBe("other"); + }); + + it("keeps first-party model ids unprefixed when OrcaRouter is inactive", async () => { + const claude = await resolveModelConfig("claude-4.6-sonnet"); + expect(claude.apiModel ?? claude.model).toBe("claude-sonnet-4-6"); + }); + + it("slugifies OrcaRouter model ids with slashes", () => { + expect(safeModelSlug("orcarouter/auto")).toBe("orcarouter__auto"); + }); +});