diff --git a/README.md b/README.md index 13b801f2..8ae41637 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 24d4d000..1e775bb0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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。 diff --git a/raven/cli/onboard_commands.py b/raven/cli/onboard_commands.py index 0ba0fd5e..60e21291 100644 --- a/raven/cli/onboard_commands.py +++ b/raven/cli/onboard_commands.py @@ -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"}, diff --git a/raven/config/schema.py b/raven/config/schema.py index 14d454ba..f8d4b249 100644 --- a/raven/config/schema.py +++ b/raven/config/schema.py @@ -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 diff --git a/raven/providers/common_models.py b/raven/providers/common_models.py index d5da8c65..906c9873 100644 --- a/raven/providers/common_models.py +++ b/raven/providers/common_models.py @@ -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", diff --git a/raven/providers/registry.py b/raven/providers/registry.py index 69d9c862..3cd0a26c 100644 --- a/raven/providers/registry.py +++ b/raven/providers/registry.py @@ -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. @@ -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//", 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". diff --git a/raven/providers/wire.py b/raven/providers/wire.py index ad9e1847..8a5ca0a0 100644 --- a/raven/providers/wire.py +++ b/raven/providers/wire.py @@ -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 diff --git a/tests/data/wire_model_baseline.json b/tests/data/wire_model_baseline.json index 469541d5..23509dc9 100644 --- a/tests/data/wire_model_baseline.json +++ b/tests/data/wire_model_baseline.json @@ -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", diff --git a/tests/test_provider_catalog.py b/tests/test_provider_catalog.py index c27e5e17..40499b95 100644 --- a/tests/test_provider_catalog.py +++ b/tests/test_provider_catalog.py @@ -18,6 +18,7 @@ "custom", "azure_openai", "openrouter", + "orcarouter", "aihubmix", "siliconflow", "volcengine", @@ -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: @@ -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//" 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. @@ -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", diff --git a/tests/test_provider_resolution_invariants.py b/tests/test_provider_resolution_invariants.py index 337b0a18..0f1d11e8 100644 --- a/tests/test_provider_resolution_invariants.py +++ b/tests/test_provider_resolution_invariants.py @@ -21,6 +21,7 @@ import ast import json +import re from pathlib import Path import pytest @@ -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) @@ -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 ``/`` 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.