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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ The schema follows a small, versioned semantic contract. See the [Tracing Standa

## Providers and Gateways

Raven supports API-key, OAuth, local, and OpenAI-compatible providers. The onboarding catalog includes OpenRouter, OpenAI, Anthropic, Gemini, MiniMax, DeepSeek, Z.ai, DashScope, Moonshot, VolcEngine, SiliconFlow, Groq, AiHubMix, Azure OpenAI, GitHub Copilot OAuth, OpenAI Codex OAuth, Ollama, and hosted vLLM.
Raven supports API-key, OAuth, local, and OpenAI-compatible providers. The onboarding catalog includes OpenRouter, OrcaRouter, OpenAI, Anthropic, Gemini, MiniMax, DeepSeek, Z.ai, DashScope, Moonshot, VolcEngine, SiliconFlow, Groq, AiHubMix, Azure OpenAI, GitHub Copilot OAuth, OpenAI Codex OAuth, Ollama, and hosted vLLM.

Twelve gateway adapters connect Raven to Telegram, Slack, Discord, WhatsApp, Matrix, Feishu, WeCom, Mochat, QQ, DingTalk, Email, and WeChat.

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ Schema 遵循一个精简、带版本的语义契约。Span 名称、属性、ar

## Providers 和 Gateways

Raven 支持 API key、OAuth、本地和 OpenAI-compatible providers。Onboarding catalog 包括 OpenRouter、OpenAI、Anthropic、Gemini、MiniMax、DeepSeek、Z.ai、DashScope、Moonshot、VolcEngine、SiliconFlow、Groq、AiHubMix、Azure OpenAI、GitHub Copilot OAuth、OpenAI Codex OAuth、Ollama 和托管 vLLM。
Raven 支持 API key、OAuth、本地和 OpenAI-compatible providers。Onboarding catalog 包括 OpenRouter、OrcaRouter、OpenAI、Anthropic、Gemini、MiniMax、DeepSeek、Z.ai、DashScope、Moonshot、VolcEngine、SiliconFlow、Groq、AiHubMix、Azure OpenAI、GitHub Copilot OAuth、OpenAI Codex OAuth、Ollama 和托管 vLLM。

十二个 gateway adapters 可以把 Raven 接入 Telegram、Slack、Discord、WhatsApp、Matrix、Feishu、WeCom、Mochat、QQ、DingTalk、Email 和 WeChat。

Expand Down
5 changes: 5 additions & 0 deletions raven/cli/onboard_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ def _t(en: str, zh: str) -> str:
"label": "OpenRouter (recommended - one key, many models)",
"label_zh": "OpenRouter(推荐 · 一个 Key 调用多家模型)",
},
{
"name": "orcarouter",
"label": "OrcaRouter (one key, many models)",
"label_zh": "OrcaRouter(一个 Key 调用多家模型)",
},
{"name": "openai", "label": "OpenAI", "label_zh": "OpenAI"},
{"name": "anthropic", "label": "Anthropic", "label_zh": "Anthropic"},
{"name": "gemini", "label": "Gemini", "label_zh": "Gemini"},
Expand Down
1 change: 1 addition & 0 deletions raven/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,7 @@ def _merge_renamed_sections(cls, data: Any) -> Any:
anthropic: ProviderConfig = Field(default_factory=ProviderConfig)
openai: ProviderConfig = Field(default_factory=ProviderConfig)
openrouter: ProviderConfig = Field(default_factory=ProviderConfig)
orcarouter: ProviderConfig = Field(default_factory=ProviderConfig)
deepseek: ProviderConfig = Field(default_factory=ProviderConfig)
groq: ProviderConfig = Field(default_factory=ProviderConfig)
# Z.ai, the vendor's current brand and LiteLLM's name for it. Configs
Expand Down
12 changes: 12 additions & 0 deletions raven/providers/common_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@
"openrouter/moonshotai/kimi-k2.6",
"openrouter/qwen/qwen3.7-max",
],
"orcarouter": [
"orcarouter/auto",
"orcarouter/openai/gpt-5.5",
"orcarouter/google/gemini-3.5-flash",
"orcarouter/anthropic/claude-opus-4.8",
"orcarouter/anthropic/claude-sonnet-5",
"orcarouter/anthropic/claude-haiku-4.5",
"orcarouter/grok/grok-4.3",
"orcarouter/deepseek/deepseek-v4-pro",
"orcarouter/minimax/minimax-m2.7",
"orcarouter/qwen/qwen3.7-max",
],
"openai": [
"openai/gpt-5.5",
"openai/gpt-5.5-pro",
Expand Down
30 changes: 30 additions & 0 deletions raven/providers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ class ProviderSpec:
# A vendor LiteLLM merely spells differently is NOT this: adopt LiteLLM's
# spelling as `name` and keep ours in `name_aliases` (see hosted_vllm).
via_driver: str = ""
# When set on a gateway, the upstream expects the model id to keep its full
# namespace ("orcarouter/anthropic/claude-x" reaches the gateway as
# "anthropic/claude-x"), so the wire form replaces only the gateway's own
# prefix with the driver's. A bare rest ("orcarouter/auto") is a router name
# that belongs to the gateway, so it keeps the gateway prefix -- the upstream
# does not accept it naked.
via_driver_preserves_namespace: bool = False
# Prefix LiteLLM's metadata table files this provider's models under, when it
# differs from the routing prefix ("minimax-global/MiniMax-M3" is priced at
# "minimax/MiniMax-M3"). None: the two coincide.
Expand Down Expand Up @@ -248,6 +255,29 @@ def claims(self, model: str) -> bool:
supports_prompt_caching=True,
default_model="openrouter/anthropic/claude-sonnet-4-5",
),
# OrcaRouter: global gateway, OpenAI-compatible interface, keys start with
# "sk-orca-". LiteLLM carries no "orcarouter" provider, so it is reached
# through the OpenAI driver; the gateway expects the full model namespace
# (the wire form is "openai/<namespace>/<model>", and "orcarouter/auto"
# keeps its prefix as a router name).
ProviderSpec(
name="orcarouter",
keywords=("orcarouter",),
env_key="ORCAROUTER_API_KEY",
display_name="OrcaRouter",
via_driver="openai",
via_driver_preserves_namespace=True,
skip_prefixes=(),
env_extras=(),
is_gateway=True,
is_local=False,
detect_by_key_prefix="sk-orca-",
detect_by_base_keyword="orcarouter",
default_api_base="https://api.orcarouter.ai/v1",
strip_model_prefix=False,
model_overrides=(),
default_model="orcarouter/openai/gpt-5.5",
),
# AiHubMix: global gateway, OpenAI-compatible interface.
# strip_model_prefix=True: it doesn't understand "anthropic/claude-3",
# so we strip to bare "claude-3" then re-prefix as "openai/claude-3".
Expand Down
11 changes: 11 additions & 0 deletions raven/providers/wire.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,17 @@ def _through_gateway(model: str, gateway: ProviderSpec) -> str:
# "hosted_vllm/hosted-vllm/x". The two branches answering one question
# differently is what this module exists to end.
model = _canonical_prefix(model, gateway, prefix)
if gateway.via_driver_preserves_namespace:
# The upstream keeps the full model namespace, so only the gateway's own
# prefix was swapped for the driver's. A bare rest ("orcarouter/auto")
# is a router name that belongs to the gateway, not a namespace, and the
# upstream does not accept it naked -- route it through the driver with
# the gateway prefix kept on the router name. Everything else (an id
# carrying no gateway prefix at all) falls through to the generic
# driver-prefix rule below.
head, rest = split_model_id(model)
if head == normalize_provider_name(prefix) and rest and "/" not in rest:
return f"{prefix}/{gateway.name}/{rest}"
if prefix and not model.startswith(f"{prefix}/"):
model = f"{prefix}/{model}"
return model
Expand Down
9 changes: 9 additions & 0 deletions tests/data/wire_model_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,15 @@
"vendorx/zz-probe-1": "openrouter/vendorx/zz-probe-1",
"zz-probe-1": "openrouter/zz-probe-1"
},
"orcarouter": {
"openai/zz-probe-1": "openai/orcarouter/zz-probe-1",
"orcarouter-probe": "openai/orcarouter-probe",
"orcarouter/openai/gpt-5.5": "openai/openai/gpt-5.5",
"orcarouter/vendorx/zz-probe-1": "openai/vendorx/zz-probe-1",
"orcarouter/zz-probe-1": "openai/orcarouter/zz-probe-1",
"vendorx/zz-probe-1": "openai/vendorx/zz-probe-1",
"zz-probe-1": "openai/zz-probe-1"
},
"siliconflow": {
"openai/zz-probe-1": "openai/zz-probe-1",
"siliconflow-probe": "openai/siliconflow-probe",
Expand Down
52 changes: 49 additions & 3 deletions tests/test_provider_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"custom",
"azure_openai",
"openrouter",
"orcarouter",
"aihubmix",
"siliconflow",
"volcengine",
Expand All @@ -39,9 +40,9 @@
}


def test_registry_has_exactly_21_providers() -> None:
assert len(PROVIDERS) == 21
assert len(EXPECTED_PROVIDER_NAMES) == 21
def test_registry_has_exactly_22_providers() -> None:
assert len(PROVIDERS) == 22
assert len(EXPECTED_PROVIDER_NAMES) == 22


def test_registry_provider_name_set_is_pinned() -> None:
Expand Down Expand Up @@ -200,6 +201,48 @@ def test_every_gateway_prefixes_the_models_it_routes() -> None:
assert resolved.startswith(f"{spec.model_prefix}/"), f"{spec.name}: {resolved}"


def test_orcarouter_gateway_is_detected_by_key_and_base() -> None:
"""OrcaRouter is a gateway: detected by its key prefix and base keyword."""
from raven.providers.registry import find_gateway

by_key = find_gateway(api_key="sk-orca-abc123")
assert by_key is not None and by_key.name == "orcarouter"

by_base = find_gateway(api_base="https://api.orcarouter.ai/v1")
assert by_base is not None and by_base.name == "orcarouter"

by_name = find_gateway(provider_name="orcarouter")
assert by_name is not None and by_name.name == "orcarouter"


def test_orcarouter_wire_id_reaches_litellm_openai_driver() -> None:
"""The stored "orcarouter/<ns>/<model>" id must resolve through LiteLLM.

LiteLLM carries no "orcarouter" provider, so the wire form goes out under the
OpenAI driver with the full namespace intact -- the gateway then routes on
that namespace. This is what makes the request reach the right upstream.
"""
import pytest
from litellm import get_llm_provider

# A bare stored id is not routable on its own: LiteLLM has no "orcarouter"
# provider, so it raises rather than sending the request somewhere.
with pytest.raises(Exception):
get_llm_provider(model="orcarouter/openai/gpt-5.5")

# The wire form, by contrast, resolves -- the OpenAI driver accepts the
# namespaced id and the gateway receives the full namespace. (This version of
# LiteLLM reports the resolved route in the provider slot; the point is that
# it resolves at all, which the bare stored id cannot.)
for wired in (
"openai/openai/gpt-5.5",
"openai/anthropic/claude-haiku-4.5",
"openai/orcarouter/auto",
):
provider, _, _, _ = get_llm_provider(model=wired)
assert provider != "orcarouter", f"{wired}: did not route through OpenAI driver"


def test_a_failed_catalogue_read_is_not_cached_for_the_life_of_the_process() -> None:
"""The index is cached; a failure to build it must not be.

Expand Down Expand Up @@ -469,6 +512,9 @@ def test_an_overlay_written_bare_matches_the_qualified_id() -> None:
# that is the variable the driver handling the request reads. LiteLLM names
# the vendor's own variable, which nothing here sets.
"volcengine": "OPENAI_API_KEY",
# OrcaRouter is reached through the OpenAI driver the same way; the key is
# still declared under its own name and passed as an explicit api_key kwarg.
"orcarouter": "ORCAROUTER_API_KEY",
# A local deployment takes an address, not a key. LiteLLM answers with the
# address variable, which is a different field of ours.
"ollama_chat": "OLLAMA_API_KEY",
Expand Down
37 changes: 36 additions & 1 deletion tests/test_provider_resolution_invariants.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import ast
import json
import re
from pathlib import Path

import pytest
Expand Down Expand Up @@ -281,11 +282,14 @@ def test_only_the_registry_reads_the_raw_via_driver_field() -> None:
exactly what a borrowed driver decides.
"""
exempt = {"benchmarks/pinchbench/direct/raven_executor.py"}
# A longer field name ("via_driver_preserves_namespace") carries the driver
# word but is a different flag, not the raw driver-name read this guard is
# about -- so match the field name exactly rather than the substring.
offenders = [
f"{_rel(path)}:{i}"
for path in _production_files()
for i, line in enumerate(path.read_text().splitlines(), 1)
if "via_driver" in line and not line.lstrip().startswith("#") and _rel(path) not in exempt
if re.search(r"\.via_driver(?!_)", line) and not line.lstrip().startswith("#") and _rel(path) not in exempt
]
assert not offenders, "read spec.model_prefix instead of the raw field: " + ", ".join(offenders)

Expand Down Expand Up @@ -570,6 +574,37 @@ def test_a_prefix_stripping_gateway_drops_one_segment_not_all_but_the_last(confi
assert provider._resolve_model(configured) == sent


@pytest.mark.parametrize(
("configured", "sent"),
[
# A namespaced id keeps its full namespace through the OpenAI driver,
# so the gateway receives the exact vendor/model it routes on.
("orcarouter/openai/gpt-5.5", "openai/openai/gpt-5.5"),
("orcarouter/anthropic/claude-haiku-4.5", "openai/anthropic/claude-haiku-4.5"),
("orcarouter/google/gemini-3.5-flash", "openai/google/gemini-3.5-flash"),
("orcarouter/deepseek/deepseek-v4-flash", "openai/deepseek/deepseek-v4-flash"),
# A router name ("auto") is not a namespace; it keeps the gateway prefix
# so the upstream recognises it.
("orcarouter/auto", "openai/orcarouter/auto"),
# A bare id with no gateway prefix falls back to the generic rule.
("probe-model", "openai/probe-model"),
],
)
def test_a_namespace_preserving_gateway_swaps_only_its_own_prefix(configured: str, sent: str) -> None:
"""OrcaRouter keeps the full model namespace behind the OpenAI driver.

Unlike AiHubMix (which strips to a bare vendor id), OrcaRouter routes on the
complete ``<vendor>/<model>`` namespace -- so the wire form replaces only the
gateway's own prefix with the driver's. ``orcarouter/auto`` is the one
exception: ``auto`` is a router name belonging to the gateway, and the
upstream does not accept it naked.
"""
from raven.providers.litellm_provider import LiteLLMProvider

provider = LiteLLMProvider(api_key="K", provider_name="orcarouter", default_model="probe-model")
assert provider._resolve_model(configured) == sent


def test_a_metadata_prefix_is_declared_only_where_it_differs_from_routing() -> None:
"""One answer to "what is this model", asked of the registry.

Expand Down