From 9ddc08a30a8e93811fdb6b39de5b9f2bd27a13b0 Mon Sep 17 00:00:00 2001 From: XiaoHuo888 Date: Tue, 11 Aug 2026 23:51:10 +0800 Subject: [PATCH] feat: route orcarouter/ models through the OrcaRouter gateway Adds OrcaRouter as a named OpenAI-compatible provider to the local-mode LLM routing layer. 'orcarouter/...' models (e.g. 'orcarouter/deepseek/deepseek-v4-pro') now use the OpenAI SDK against https://api.orcarouter.ai/v1 with ORCAROUTER_API_KEY, mirroring how the 'openai/' prefix is handled; the routing prefix is stripped before the request, except for OrcaRouter's own 'orcarouter/auto' alias. Updates the CLI key gates (run_pageindex.py, tree_optimize.py) to require ORCAROUTER_API_KEY for orcarouter models, keeps retrieve_model normalization passthrough in sync, and documents the provider in README.md and config.yaml. Verified: pytest suite (76 passed; the one failure is a pre-existing Windows-only path test), sync + async live calls through llm_completion/llm_acompletion against the real endpoint (deepseek model and orcarouter/auto), and 401 rejection for an invalid key. --- README.md | 12 ++++ pageindex/client.py | 2 +- pageindex/config.yaml | 4 ++ pageindex/tree_optimize.py | 11 ++-- pageindex/utils.py | 112 +++++++++++++++++++++++++++++++------ run_pageindex.py | 8 ++- tests/test_client.py | 57 ++++++++++++++++++- 7 files changed, 180 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 5ce0ca5e6..e02631e74 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,18 @@ Create a `.env` file in the root directory with your LLM API key. Multi-LLM is s OPENAI_API_KEY=your_openai_key_here ``` +[OrcaRouter](https://www.orcarouter.ai) is also supported as a named OpenAI-compatible gateway. With one `ORCAROUTER_API_KEY` (keys start with `sk-orca-`) you get 150+ models from OpenAI, Anthropic, Google, DeepSeek, Qwen, MiniMax and xAI behind a single `https://api.orcarouter.ai/v1` endpoint. Pick it with an `orcarouter/`-prefixed model — e.g. `orcarouter/deepseek/deepseek-v4-pro`, or `orcarouter/orcarouter/auto` for OrcaRouter's auto-routing alias: + +```bash +ORCAROUTER_API_KEY=sk-orca-your_key_here +``` + +Then set the model in `pageindex/config.yaml`: + +```yaml +model: "orcarouter/deepseek/deepseek-v4-pro" +``` + ### 3. Generate PageIndex structure for your PDF ```bash diff --git a/pageindex/client.py b/pageindex/client.py index 158c9b6f7..f9707bc69 100644 --- a/pageindex/client.py +++ b/pageindex/client.py @@ -22,7 +22,7 @@ def _parse_pages(pages: str) -> list[int]: def _normalize_retrieve_model(model: str) -> str: """Preserve supported Agents SDK prefixes and route other provider paths via LiteLLM.""" - passthrough_prefixes = ("litellm/", "openai/") + passthrough_prefixes = ("litellm/", "openai/", "orcarouter/") if not model or "/" not in model: return model if model.startswith(passthrough_prefixes): diff --git a/pageindex/config.yaml b/pageindex/config.yaml index 73a512c7a..dde3be94c 100644 --- a/pageindex/config.yaml +++ b/pageindex/config.yaml @@ -1,7 +1,11 @@ # Models without a provider prefix use the OpenAI SDK directly. # For other providers, use "provider/model" format (e.g. "anthropic/claude-sonnet-4-6"). +# "orcarouter/..." routes through the OrcaRouter gateway (OpenAI-compatible, +# https://api.orcarouter.ai/v1, key: ORCAROUTER_API_KEY) using your own key. model: "gpt-4o-2024-11-20" # model: "anthropic/claude-sonnet-4-6" +# model: "orcarouter/deepseek/deepseek-v4-pro" +# model: "orcarouter/orcarouter/auto" summary_model: "gpt-5.6-luna" retrieve_model: "gpt-5.4" # defaults to `model` if not set toc_check_page_num: 20 diff --git a/pageindex/tree_optimize.py b/pageindex/tree_optimize.py index 04719ccb2..221c0f47c 100644 --- a/pageindex/tree_optimize.py +++ b/pageindex/tree_optimize.py @@ -61,8 +61,8 @@ import sys from types import SimpleNamespace -from .utils import (ConfigLoader, _is_openai_model, _is_unrecoverable, - llm_acompletion, strip_internal_keys) +from .utils import (ConfigLoader, _api_key_env_for, _is_openai_model, + _is_unrecoverable, llm_acompletion, strip_internal_keys) TRIGGER_PAGES = 5 # only look ahead on nodes larger than this ROUTING_COST = 1 # R(v), in pages @@ -872,9 +872,10 @@ async def main(): args = parser.parse_args() model = args.model or default_model() - if args.expand and not args.plan and _is_openai_model(model) \ - and not os.getenv("OPENAI_API_KEY"): - sys.exit(f"OPENAI_API_KEY is not set (expand model: {model}).") + if args.expand and not args.plan and _is_openai_model(model): + key_env = _api_key_env_for(model) + if not os.getenv(key_env): + sys.exit(f"{key_env} is not set (expand model: {model}).") original = json.load(open(args.structure)) structure = copy.deepcopy(original["structure"]) diff --git a/pageindex/utils.py b/pageindex/utils.py index 97f60a942..ec872db54 100644 --- a/pageindex/utils.py +++ b/pageindex/utils.py @@ -23,6 +23,15 @@ if not os.getenv("OPENAI_API_KEY") and os.getenv("CHATGPT_API_KEY"): os.environ["OPENAI_API_KEY"] = os.getenv("CHATGPT_API_KEY") +# OrcaRouter is an OpenAI-compatible gateway. Models use the same +# 'provider/model' shape as every other provider (e.g. +# 'orcarouter/deepseek/deepseek-v4-pro'); the leading 'orcarouter/' +# segment selects the OrcaRouter endpoint and is stripped before the +# request is sent, mirroring how the 'openai/' prefix is handled. +ORCAROUTER_BASE_URL = "https://api.orcarouter.ai/v1" +ORCAROUTER_API_KEY_ENV = "ORCAROUTER_API_KEY" +ORCAROUTER_MODEL_PREFIX = "orcarouter/" + def count_tokens(text, model=None): if not text: return 0 @@ -38,14 +47,91 @@ def _strip_prefix(s, prefix): def _is_openai_model(model): """Models without a provider prefix (no '/') use the openai SDK directly. - For other providers, use 'provider/model' format (e.g. 'anthropic/claude-sonnet-4-6').""" + 'openai/...' and 'orcarouter/...' are OpenAI-compatible and also use the + SDK (the latter through the OrcaRouter gateway). For other providers, use + 'provider/model' format (e.g. 'anthropic/claude-sonnet-4-6').""" if not model or model.startswith('litellm/'): return False - return '/' not in model or model.startswith('openai/') + return '/' not in model or model.startswith(('openai/', ORCAROUTER_MODEL_PREFIX)) + + +def _is_orcarouter_model(model): + """True for 'orcarouter/...' models routed through the OrcaRouter gateway.""" + return bool(model) and model.startswith(ORCAROUTER_MODEL_PREFIX) + + +def _orcarouter_model_id(model): + """OrcaRouter model id for an 'orcarouter/...' routing string. + + The leading 'orcarouter/' segment selects the gateway endpoint and is + stripped before the request (mirroring the 'openai/' prefix). The one + exception is OrcaRouter's own 'orcarouter/auto' alias, whose remainder + is a bare name: there the full string is the model id. + """ + remainder = _strip_prefix(model, ORCAROUTER_MODEL_PREFIX) + return remainder if "/" in remainder else model + + +def _require_orcarouter_key(): + import openai + api_key = os.getenv(ORCAROUTER_API_KEY_ENV) + if not api_key: + raise openai.OpenAIError( + "The api_key client option must be set either by passing " + "api_key to the client or by setting the " + f"{ORCAROUTER_API_KEY_ENV} environment variable" + ) + return api_key + + +def _api_key_env_for(model): + """Environment variable that must hold the key for an OpenAI-SDK-routed + model ('OPENAI_API_KEY' or 'ORCAROUTER_API_KEY').""" + return ORCAROUTER_API_KEY_ENV if _is_orcarouter_model(model) else "OPENAI_API_KEY" _openai_sync_client = None _openai_async_client = None +_orcarouter_sync_client = None +_orcarouter_async_client = None + + +def _sync_client(orcarouter): + """OpenAI-compatible sync client for the endpoint — OrcaRouter when + `orcarouter` is True, the default OpenAI endpoint otherwise.""" + global _openai_sync_client, _orcarouter_sync_client + if orcarouter: + if _orcarouter_sync_client is None: + import openai + _orcarouter_sync_client = openai.OpenAI( + api_key=_require_orcarouter_key(), + base_url=ORCAROUTER_BASE_URL, + max_retries=0, + ) + return _orcarouter_sync_client + if _openai_sync_client is None: + import openai + _openai_sync_client = openai.OpenAI(max_retries=0) + return _openai_sync_client + + +def _async_client(orcarouter): + """OpenAI-compatible async client for the endpoint — OrcaRouter when + `orcarouter` is True, the default OpenAI endpoint otherwise.""" + global _openai_async_client, _orcarouter_async_client + if orcarouter: + if _orcarouter_async_client is None: + import openai + _orcarouter_async_client = openai.AsyncOpenAI( + api_key=_require_orcarouter_key(), + base_url=ORCAROUTER_BASE_URL, + max_retries=0, + ) + return _orcarouter_async_client + if _openai_async_client is None: + import openai + _openai_async_client = openai.AsyncOpenAI(max_retries=0) + return _openai_async_client # Misconfiguration: no retry can fix a rejected key or a model that does not @@ -61,21 +147,18 @@ def _is_unrecoverable(exc: Exception) -> bool: def llm_completion(model, prompt, chat_history=None, return_finish_reason=False): use_openai_sdk = _is_openai_model(model) + use_orcarouter = _is_orcarouter_model(model) if model: model = _strip_prefix(model, "litellm/") if use_openai_sdk: - model = _strip_prefix(model, "openai/") + model = _orcarouter_model_id(model) if use_orcarouter else _strip_prefix(model, "openai/") max_retries = 10 messages = list(chat_history) + [{"role": "user", "content": prompt}] if chat_history else [{"role": "user", "content": prompt}] - if use_openai_sdk: - global _openai_sync_client - if _openai_sync_client is None: - import openai - _openai_sync_client = openai.OpenAI(max_retries=0) + client = _sync_client(use_orcarouter) if use_openai_sdk else None for i in range(max_retries): try: if use_openai_sdk: - response = _openai_sync_client.chat.completions.create( + response = client.chat.completions.create( model=model, messages=messages, ) @@ -107,21 +190,18 @@ def llm_completion(model, prompt, chat_history=None, return_finish_reason=False) async def llm_acompletion(model, prompt): use_openai_sdk = _is_openai_model(model) + use_orcarouter = _is_orcarouter_model(model) if model: model = _strip_prefix(model, "litellm/") if use_openai_sdk: - model = _strip_prefix(model, "openai/") + model = _orcarouter_model_id(model) if use_orcarouter else _strip_prefix(model, "openai/") max_retries = 10 messages = [{"role": "user", "content": prompt}] - if use_openai_sdk: - global _openai_async_client - if _openai_async_client is None: - import openai - _openai_async_client = openai.AsyncOpenAI(max_retries=0) + client = _async_client(use_orcarouter) if use_openai_sdk else None for i in range(max_retries): try: if use_openai_sdk: - response = await _openai_async_client.chat.completions.create( + response = await client.chat.completions.create( model=model, messages=messages, ) diff --git a/run_pageindex.py b/run_pageindex.py index 452f08174..7283d0042 100644 --- a/run_pageindex.py +++ b/run_pageindex.py @@ -72,10 +72,12 @@ from pageindex.flash import page_index_flash if args.optimize == 'full': from pageindex.tree_optimize import default_model - from pageindex.utils import _is_openai_model + from pageindex.utils import _is_openai_model, _api_key_env_for expand_model = args.model or default_model() - if _is_openai_model(expand_model) and not os.getenv("OPENAI_API_KEY"): - raise SystemExit(f"OPENAI_API_KEY is not set (expand model: {expand_model}).") + if _is_openai_model(expand_model): + key_env = _api_key_env_for(expand_model) + if not os.getenv(key_env): + raise SystemExit(f"{key_env} is not set (expand model: {expand_model}).") toc_with_page_number = page_index_flash( args.pdf_path, optimize=args.optimize is not None, diff --git a/tests/test_client.py b/tests/test_client.py index 50b7f5178..5f4ca3ef4 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -74,10 +74,65 @@ def resolved(retrieve_model): storage_path=str(tmp_path / "s")).retrieve_model assert resolved("anthropic/claude-sonnet-4-6") == "litellm/anthropic/claude-sonnet-4-6" - for already_routable in ("gpt-4o", "openai/gpt-4o", "litellm/anthropic/claude-sonnet-4-6"): + for already_routable in ("gpt-4o", "openai/gpt-4o", + "orcarouter/deepseek/deepseek-v4-pro", + "orcarouter/orcarouter/auto", + "litellm/anthropic/claude-sonnet-4-6"): assert resolved(already_routable) == already_routable +def test_orcarouter_routing_helpers(): + from pageindex.utils import (_api_key_env_for, _is_openai_model, + _is_orcarouter_model, _orcarouter_model_id) + # 'orcarouter/...' is OpenAI-SDK-routed (like 'openai/...'), not LiteLLM. + assert _is_openai_model("orcarouter/deepseek/deepseek-v4-pro") + assert _is_openai_model("orcarouter/orcarouter/auto") + assert not _is_openai_model("litellm/orcarouter/deepseek/deepseek-v4-pro") + assert _is_orcarouter_model("orcarouter/deepseek/deepseek-v4-pro") + assert not _is_orcarouter_model("openai/gpt-4o") + assert not _is_orcarouter_model("gpt-4o") + # The routing prefix is stripped; OrcaRouter's bare 'auto' alias is kept whole. + assert _orcarouter_model_id("orcarouter/deepseek/deepseek-v4-pro") == "deepseek/deepseek-v4-pro" + assert _orcarouter_model_id("orcarouter/orcarouter/auto") == "orcarouter/auto" + # CLI key gates resolve the right env var. + assert _api_key_env_for("orcarouter/deepseek/deepseek-v4-pro") == "ORCAROUTER_API_KEY" + assert _api_key_env_for("openai/gpt-4o") == "OPENAI_API_KEY" + assert _api_key_env_for("gpt-4o") == "OPENAI_API_KEY" + + +def test_orcarouter_completion_missing_key_raises_immediately(monkeypatch): + import openai + monkeypatch.delenv("ORCAROUTER_API_KEY", raising=False) + monkeypatch.setattr(pageindex.utils, "_orcarouter_sync_client", None) + monkeypatch.setattr(pageindex.utils, "_orcarouter_async_client", None) + with pytest.raises(openai.OpenAIError): + pageindex.utils.llm_completion("orcarouter/deepseek/deepseek-v4-pro", "probe") + with pytest.raises(openai.OpenAIError): + asyncio.run(pageindex.utils.llm_acompletion("orcarouter/deepseek/deepseek-v4-pro", "probe")) + + +def test_orcarouter_completion_routes_model_to_gateway(monkeypatch): + captured = {} + + class FakeCompletions: + def create(self, **kwargs): + captured["model"] = kwargs["model"] + captured["messages"] = kwargs["messages"] + return types.SimpleNamespace(choices=[types.SimpleNamespace( + message=types.SimpleNamespace(content="ok"), + finish_reason="stop")]) + + class FakeClient: + chat = types.SimpleNamespace(completions=FakeCompletions()) + + monkeypatch.setattr(pageindex.utils, "_sync_client", lambda orcarouter: FakeClient()) + monkeypatch.setenv("ORCAROUTER_API_KEY", "sk-orca-test") + assert pageindex.utils.llm_completion( + "orcarouter/deepseek/deepseek-v4-pro", "hi") == "ok" + assert captured["model"] == "deepseek/deepseek-v4-pro" + assert captured["messages"] == [{"role": "user", "content": "hi"}] + + def test_explicit_mode_clients(tmp_path): from pageindex import PageIndexCloudClient, PageIndexLocalClient