From 2f1bd42845aa81135834611048663d3c83da696a Mon Sep 17 00:00:00 2001 From: maxile Date: Mon, 18 May 2026 18:25:34 +0800 Subject: [PATCH] feat: add OrcaRouter (orcarouter/) as a first-class provider OrcaRouter (https://www.orcarouter.ai) is an OpenAI-compatible LLM meta-router. This adds it as a first-class provider in aider: - New OrcaRouterModelManager that fetches the public /api/pricing catalog (no auth) and exposes per-model context_length / pricing in litellm get_model_info shape. Pricing uses OrcaRouter's published quota formula (model_ratio * 2 USD/1M for input, * completion_ratio for output). - ModelInfoManager delegates to it for orcarouter/-prefixed models when litellm has no built-in info. - Model.send_completion auto-routes orcarouter// to api.orcarouter.ai/v1 via litellm's OpenAI-compatible client, using ORCAROUTER_API_KEY from the environment. HTTP-Referer / X-Title attribution headers are injected so OrcaRouter's console can credit the traffic to aider. - ORCAROUTER_API_KEY added to fast_validate_environment keymap. - "orcarouter-auto" alias added for the orcarouter/orcarouter/auto adaptive router. - Docs page at aider/website/docs/llms/orcarouter.md. - Unit tests in tests/basic/test_orcarouter.py mirroring the existing OpenRouter test pattern. I'm an engineer on the OrcaRouter team. --- aider/models.py | 33 ++++++ aider/orcarouter.py | 160 ++++++++++++++++++++++++++ aider/website/docs/llms/orcarouter.md | 77 +++++++++++++ tests/basic/test_orcarouter.py | 109 ++++++++++++++++++ 4 files changed, 379 insertions(+) create mode 100644 aider/orcarouter.py create mode 100644 aider/website/docs/llms/orcarouter.md create mode 100644 tests/basic/test_orcarouter.py diff --git a/aider/models.py b/aider/models.py index 5b0f2166aa4..1bfac11756a 100644 --- a/aider/models.py +++ b/aider/models.py @@ -20,6 +20,7 @@ from aider.dump import dump # noqa: F401 from aider.llm import litellm from aider.openrouter import OpenRouterModelManager +from aider.orcarouter import OrcaRouterModelManager from aider.sendchat import ensure_alternating_roles, sanity_check_messages from aider.utils import check_pip_install_extra @@ -113,6 +114,8 @@ "gemini-exp": "gemini/gemini-2.5-pro-exp-03-25", "grok3": "xai/grok-3-beta", "optimus": "openrouter/openrouter/optimus-alpha", + # OrcaRouter (https://www.orcarouter.ai) + "orcarouter-auto": "orcarouter/orcarouter/auto", } # Model metadata loaded from resources and user's files. @@ -168,11 +171,15 @@ def __init__(self): # Manager for the cached OpenRouter model database self.openrouter_manager = OpenRouterModelManager() + # Manager for the cached OrcaRouter model database + self.orcarouter_manager = OrcaRouterModelManager() def set_verify_ssl(self, verify_ssl): self.verify_ssl = verify_ssl if hasattr(self, "openrouter_manager"): self.openrouter_manager.set_verify_ssl(verify_ssl) + if hasattr(self, "orcarouter_manager"): + self.orcarouter_manager.set_verify_ssl(verify_ssl) def _load_cache(self): if self._cache_loaded: @@ -253,6 +260,11 @@ def get_model_info(self, model): if litellm_info: return litellm_info + if not cached_info and model.startswith("orcarouter/"): + orcarouter_info = self.orcarouter_manager.get_model_info(model) + if orcarouter_info: + return orcarouter_info + if not cached_info and model.startswith("openrouter/"): # First try using the locally cached OpenRouter model database openrouter_info = self.openrouter_manager.get_model_info(model) @@ -715,6 +727,7 @@ def fast_validate_environment(self): keymap = dict( openrouter="OPENROUTER_API_KEY", + orcarouter="ORCAROUTER_API_KEY", openai="OPENAI_API_KEY", deepseek="DEEPSEEK_API_KEY", gemini="GEMINI_API_KEY", @@ -1026,6 +1039,26 @@ def send_completion(self, messages, functions, stream, temperature=None): self.github_copilot_token_to_open_ai_key(kwargs["extra_headers"]) + # OrcaRouter (https://www.orcarouter.ai): route via the OpenAI-compatible + # endpoint at api.orcarouter.ai/v1 using ORCAROUTER_API_KEY. The user-facing + # model name keeps the orcarouter/ prefix; we rewrite it to openai/ + # only for the underlying litellm call. + if isinstance(kwargs.get("model"), str) and kwargs["model"].startswith("orcarouter/"): + rest = kwargs["model"][len("orcarouter/"):] + kwargs["model"] = "openai/" + rest + kwargs.setdefault("api_base", "https://api.orcarouter.ai/v1") + orca_key = os.environ.get("ORCAROUTER_API_KEY") + if orca_key and "api_key" not in kwargs: + kwargs["api_key"] = orca_key + attribution = { + "HTTP-Referer": "https://aider.chat/", + "X-Title": "aider", + } + existing_headers = kwargs.get("extra_headers") or {} + for hk, hv in attribution.items(): + existing_headers.setdefault(hk, hv) + kwargs["extra_headers"] = existing_headers + res = litellm.completion(**kwargs) return hash_object, res diff --git a/aider/orcarouter.py b/aider/orcarouter.py new file mode 100644 index 00000000000..86bbfcc88ef --- /dev/null +++ b/aider/orcarouter.py @@ -0,0 +1,160 @@ +""" +OrcaRouter model metadata caching and lookup. + +OrcaRouter (https://www.orcarouter.ai) is an OpenAI-compatible LLM meta-router +that exposes 150+ upstream models under the ``orcarouter//`` +naming convention plus a virtual ``orcarouter/auto`` adaptive router. + +This module keeps a local cached copy of the OrcaRouter pricing/catalog feed +(downloaded from ``https://www.orcarouter.ai/api/pricing`` -- a public endpoint +that needs no auth) and exposes a helper class that returns metadata for a +given model in a format compatible with litellm's ``get_model_info``. + +Pricing formula (per OrcaRouter quota constant ``QuotaPerUnit = 500_000``, +i.e. $2 per 1M tokens for ``model_ratio = 1``): + + input USD/token = model_ratio * 2 / 1_000_000 + output USD/token = model_ratio * completion_ratio * 2 / 1_000_000 +""" +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Dict + +import requests + + +# $2 / 1M tokens per unit ratio. Source: OrcaRouter common/constants.go QuotaPerUnit. +_USD_PER_M_TOKENS_AT_RATIO_1 = 2.0 + + +def _per_token(usd_per_million): + """Convert USD-per-1M-tokens to USD-per-token, or None on bad input.""" + if usd_per_million is None: + return None + try: + return float(usd_per_million) / 1_000_000.0 + except (TypeError, ValueError): + return None + + +class OrcaRouterModelManager: + MODELS_URL = "https://www.orcarouter.ai/api/pricing" + CACHE_TTL = 60 * 60 * 24 # 24 h + + def __init__(self): + self.cache_dir = Path.home() / ".aider" / "caches" + self.cache_file = self.cache_dir / "orcarouter_models.json" + self.content = None + self.verify_ssl = True + self._cache_loaded = False + + # ------------------------------------------------------------------ # + # Public API # + # ------------------------------------------------------------------ # + def set_verify_ssl(self, verify_ssl): + """Enable/disable SSL verification for API requests.""" + self.verify_ssl = verify_ssl + + def get_model_info(self, model): + """ + Return metadata for *model* or an empty ``dict`` when unknown. + + ``model`` should use the aider naming convention, e.g. + ``orcarouter/openai/gpt-4o`` or ``orcarouter/anthropic/claude-opus-4.7``. + """ + self._ensure_content() + records = self._records() + if not records: + return {} + + route = self._strip_prefix(model) + record = next( + (item for item in records if item.get("model_name") == route), + None, + ) + if not record: + return {} + + try: + model_ratio = float(record.get("model_ratio") or 0) + except (TypeError, ValueError): + model_ratio = 0.0 + try: + completion_ratio = float(record.get("completion_ratio") or 1) + except (TypeError, ValueError): + completion_ratio = 1.0 + + input_per_m = model_ratio * _USD_PER_M_TOKENS_AT_RATIO_1 + output_per_m = model_ratio * completion_ratio * _USD_PER_M_TOKENS_AT_RATIO_1 + + context_length = record.get("context_length") or None + max_output = record.get("max_completion_tokens") or context_length + + return { + "max_input_tokens": context_length, + "max_tokens": context_length, + "max_output_tokens": max_output, + "input_cost_per_token": _per_token(input_per_m), + "output_cost_per_token": _per_token(output_per_m), + "litellm_provider": "orcarouter", + } + + # ------------------------------------------------------------------ # + # Internal helpers # + # ------------------------------------------------------------------ # + def _strip_prefix(self, model): + return model[len("orcarouter/"):] if model.startswith("orcarouter/") else model + + def _records(self): + """Return list of model records regardless of whether the API wraps them.""" + if isinstance(self.content, list): + return self.content + if isinstance(self.content, dict): + for key in ("data", "models", "pricing"): + val = self.content.get(key) + if isinstance(val, list): + return val + return [] + + def _ensure_content(self): + self._load_cache() + if not self.content: + self._update_cache() + + def _load_cache(self): + if self._cache_loaded: + return + try: + self.cache_dir.mkdir(parents=True, exist_ok=True) + if self.cache_file.exists(): + cache_age = time.time() - self.cache_file.stat().st_mtime + if cache_age < self.CACHE_TTL: + try: + self.content = json.loads(self.cache_file.read_text()) + except json.JSONDecodeError: + self.content = None + except OSError: + pass + + self._cache_loaded = True + + def _update_cache(self): + try: + response = requests.get( + self.MODELS_URL, timeout=10, verify=self.verify_ssl + ) + if response.status_code == 200: + self.content = response.json() + try: + self.cache_file.write_text(json.dumps(self.content, indent=2)) + except OSError: + pass + except Exception as ex: # noqa: BLE001 + print(f"Failed to fetch OrcaRouter model list: {ex}") + try: + self.cache_file.write_text("{}") + except OSError: + pass diff --git a/aider/website/docs/llms/orcarouter.md b/aider/website/docs/llms/orcarouter.md new file mode 100644 index 00000000000..f52eec3baf4 --- /dev/null +++ b/aider/website/docs/llms/orcarouter.md @@ -0,0 +1,77 @@ +--- +parent: Connecting to LLMs +nav_order: 510 +--- + +# OrcaRouter + +Aider can connect to [models provided by OrcaRouter](https://www.orcarouter.ai/models), +an OpenAI-compatible LLM meta-router that exposes 150+ upstream models +(OpenAI, Anthropic, Google, DeepSeek, xAI, Qwen, MiniMax, Kimi, Z.ai, ...) under +a single API key. + +You'll need an [OrcaRouter API key](https://www.orcarouter.ai). + +First, install aider: + +{% include install.md %} + +Then configure your API key: + +``` +export ORCAROUTER_API_KEY= # Mac/Linux +setx ORCAROUTER_API_KEY # Windows, restart shell after setx +``` + +Start working with aider and OrcaRouter on your codebase: + +```bash +# Change directory into your codebase +cd /to/your/project + +# Use any model from OrcaRouter's catalog +aider --model orcarouter/openai/gpt-4o +aider --model orcarouter/anthropic/claude-opus-4.7 +aider --model orcarouter/deepseek/deepseek-v3.1 + +# List models known to aider with the orcarouter/ prefix +aider --list-models orcarouter/ +``` + +## Adaptive routing with `orcarouter/auto` + +OrcaRouter ships a virtual router named `auto` that automatically picks +an upstream for each request based on configurable strategies (`cheapest`, +`balanced`, `quality`, `adaptive`, `gated_adaptive`). You can target it with +the built-in alias: + +```bash +aider --model orcarouter-auto +# equivalent to: aider --model orcarouter/orcarouter/auto +``` + +The routing policy and the upstream pool are configured in the +[OrcaRouter console](https://www.orcarouter.ai/console/routing). + +{: .tip } +When using `orcarouter/orcarouter/auto` with aider's tool-calling features, +make sure the router pool only contains tool-capable upstream models, or pin +a specific model such as `orcarouter/openai/gpt-4o` to avoid 4xx errors from +upstreams that do not support function calling. + +## Provider routing preferences + +OrcaRouter accepts an `extra_body` block on chat completion requests for +routing preferences (e.g. fallback chains). You can configure it via a +`.aider.model.settings.yml` file: + +```yaml +- name: orcarouter/openai/gpt-4o + extra_params: + extra_body: + models: ["openai/gpt-4o-mini", "openai/gpt-4o"] + route: "fallback" +``` + +See [Advanced model settings](https://aider.chat/docs/config/adv-model-settings.html#model-settings) +for more details about model settings files. diff --git a/tests/basic/test_orcarouter.py b/tests/basic/test_orcarouter.py new file mode 100644 index 00000000000..d9123aafbda --- /dev/null +++ b/tests/basic/test_orcarouter.py @@ -0,0 +1,109 @@ +from pathlib import Path + +from aider.models import ModelInfoManager +from aider.orcarouter import OrcaRouterModelManager + + +class DummyResponse: + """Minimal stand-in for requests.Response used in tests.""" + + def __init__(self, json_data): + self.status_code = 200 + self._json_data = json_data + + def json(self): + return self._json_data + + +def test_orcarouter_get_model_info_from_cache(monkeypatch, tmp_path): + """ + OrcaRouterModelManager should return correct metadata derived from the + /api/pricing payload using the model_ratio / completion_ratio formula. + + For model_ratio=1.25 and completion_ratio=4 (e.g. openai/gpt-4o): + input per token = 1.25 * 2 / 1_000_000 = 2.5e-6 ($2.50 / 1M) + output per token = 1.25 * 4 * 2 / 1_000_000 = 1.0e-5 ($10.00 / 1M) + """ + payload = [ + { + "model_name": "openai/gpt-4o", + "model_ratio": 1.25, + "completion_ratio": 4, + "context_length": 128000, + "max_completion_tokens": 16384, + } + ] + + monkeypatch.setattr("requests.get", lambda *a, **k: DummyResponse(payload)) + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + + manager = OrcaRouterModelManager() + info = manager.get_model_info("orcarouter/openai/gpt-4o") + + assert info["max_input_tokens"] == 128000 + assert info["max_output_tokens"] == 16384 + assert info["input_cost_per_token"] == 2.5e-6 + assert info["output_cost_per_token"] == 1.0e-5 + assert info["litellm_provider"] == "orcarouter" + + +def test_orcarouter_get_model_info_wrapped_payload(monkeypatch, tmp_path): + """Accept the alternative {"data": [...]} wrapper shape.""" + payload = { + "data": [ + { + "model_name": "anthropic/claude-opus-4.7", + "model_ratio": 2.5, + "completion_ratio": 5, + "context_length": 200000, + } + ] + } + + monkeypatch.setattr("requests.get", lambda *a, **k: DummyResponse(payload)) + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + + manager = OrcaRouterModelManager() + info = manager.get_model_info("orcarouter/anthropic/claude-opus-4.7") + + assert info["max_input_tokens"] == 200000 + assert info["input_cost_per_token"] == 5.0e-6 # $5/M + assert info["output_cost_per_token"] == 2.5e-5 # $25/M + assert info["litellm_provider"] == "orcarouter" + + +def test_orcarouter_unknown_model_returns_empty(monkeypatch, tmp_path): + monkeypatch.setattr("requests.get", lambda *a, **k: DummyResponse([])) + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + + manager = OrcaRouterModelManager() + info = manager.get_model_info("orcarouter/unknown/model") + + assert info == {} + + +def test_model_info_manager_uses_orcarouter_manager(monkeypatch): + """ + ModelInfoManager should delegate to OrcaRouterModelManager when litellm + provides no data for an orcarouter/-prefixed model. + """ + monkeypatch.setattr("aider.models.litellm.get_model_info", lambda *a, **k: {}) + + stub_info = { + "max_input_tokens": 1024, + "max_tokens": 1024, + "max_output_tokens": 1024, + "input_cost_per_token": 1.0e-6, + "output_cost_per_token": 2.0e-6, + "litellm_provider": "orcarouter", + } + + monkeypatch.setattr( + "aider.models.OrcaRouterModelManager.get_model_info", + lambda self, model: stub_info, + ) + + mim = ModelInfoManager() + info = mim.get_model_info("orcarouter/fake/model") + + assert info == stub_info