Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pageindex/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
4 changes: 4 additions & 0 deletions pageindex/config.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 6 additions & 5 deletions pageindex/tree_optimize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"])
Expand Down
112 changes: 96 additions & 16 deletions pageindex/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
)
Expand Down Expand Up @@ -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,
)
Expand Down
8 changes: 5 additions & 3 deletions run_pageindex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
57 changes: 56 additions & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down