diff --git a/cheetahclaws/commands/config_cmd.py b/cheetahclaws/commands/config_cmd.py index 3c3b788..edd3532 100644 --- a/cheetahclaws/commands/config_cmd.py +++ b/cheetahclaws/commands/config_cmd.py @@ -41,6 +41,8 @@ def cmd_model(args: str, _state, config) -> bool: info(" e.g. /model gpt-4o") info(" e.g. /model ollama/qwen2.5-coder") info(" e.g. /model kimi:moonshot-v1-32k") + info(" e.g. /model openrouter/deepseek/deepseek-v4-flash (multi-level: provider/upstream/model)") + info(" e.g. /model openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8 (pin provider/quantization)") else: m = args.strip() # "/model ollama" with no model name → interactive picker diff --git a/cheetahclaws/providers.py b/cheetahclaws/providers.py index 4fb57da..515c61a 100644 --- a/cheetahclaws/providers.py +++ b/cheetahclaws/providers.py @@ -9,6 +9,7 @@ qwen — Alibaba DashScope (qwen-max, qwen-plus, ...) zhipu — Zhipu GLM (glm-4, glm-4-plus, ...) deepseek — DeepSeek (deepseek-v4-flash, deepseek-v4-pro, deepseek-chat, deepseek-reasoner) + openrouter — OpenRouter (openrouter/deepseek/deepseek-v4-flash, ...) minimax — MiniMax (MiniMax-Text-01, abab6.5s-chat, ...) ollama — Local Ollama (llama3.3, qwen2.5-coder, ...) lmstudio — Local LM Studio (any loaded model) @@ -19,6 +20,13 @@ "gpt-4o" auto-detected → openai "ollama/qwen2.5-coder" explicit provider prefix "custom/my-model" uses CUSTOM_BASE_URL from config + "openrouter//" multi-level path: the first segment is the + provider, everything after it is passed through as the upstream model ID + (e.g. "openrouter/deepseek/deepseek-v4-flash"). Also used by nim/ and litellm/. + "openrouter//@[/]" optional routing suffix: + pins the secondary OpenRouter provider (and quantization level) via the + `provider` request body, keeping the model field a real model ID. + (e.g. "openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8"). """ from __future__ import annotations import json @@ -105,6 +113,32 @@ "deepseek-chat", "deepseek-coder", "deepseek-reasoner", ], }, + # OpenRouter — 400+ models from many vendors behind one OpenAI-compatible + # endpoint. Get a key at https://openrouter.ai/keys. Model IDs keep the + # upstream / path, so use the double-prefixed form + # /model openrouter/deepseek/deepseek-v4-flash + # The first segment is the provider; the rest is passed through verbatim. + # To force a secondary provider / quantization, append "@[/]" + # (e.g. openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8) — it is sent as + # the `provider` request body, not glued into the model ID. + "openrouter": { + "type": "openai", + "api_key_env": "OPENROUTER_API_KEY", + "base_url": "https://openrouter.ai/api/v1", + "context_limit": 128000, + "max_completion_tokens": 16384, + "models": [ + "deepseek/deepseek-v4-flash", + "deepseek/deepseek-v4-pro", + "deepseek/deepseek-chat", + "anthropic/claude-sonnet-4-6", + "openai/gpt-5", + "google/gemini-2.5-pro", + "meta-llama/llama-3.3-70b-instruct", + "qwen/qwen3-235b-a22b", + "mistralai/mistral-large-latest", + ], + }, "minimax": { "type": "openai", "api_key_env": "MINIMAX_API_KEY", @@ -288,6 +322,45 @@ def bare_model(model: str) -> str: return model.split("/", 1)[1] if "/" in model else model +# Quantization levels OpenRouter accepts in provider.quantizations. +_OR_QUANTIZATIONS = frozenset({"fp4", "fp8", "int4", "int8"}) + + +def parse_openrouter_routing(model_id: str) -> tuple[str, dict | None]: + """Split an OpenRouter model string into (model ID, provider-routing body). + + OpenRouter model IDs are always the upstream ``/`` path, + e.g. ``deepseek/deepseek-v4-flash``. Provider selection and quantization + are *request-body* elements (the ``provider`` object), not part of the + model ID — gluing them in ("gmicloud/fp8") makes OpenRouter reject the + request as an unknown model. To pin the secondary provider (and optionally + a quantization level), append ``@[/]`` to the + real model ID: + + openrouter/deepseek/deepseek-v4-flash → model only + openrouter/deepseek/deepseek-v4-flash@gmicloud → provider.order=["gmicloud"] + openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8 → + provider.quantizations=["fp8"] + openrouter/deepseek/deepseek-v4-flash@fp8 → quantizations only + + Returns (clean model ID, provider body dict or None). ``@`` never appears + in OpenRouter catalog IDs, so the suffix is unambiguous with plain + passthrough. + """ + model_id, _, routing = model_id.partition("@") + if not routing: + return model_id, None + parts = routing.split("/") + body: dict = {} + if parts[0] in _OR_QUANTIZATIONS: + body["quantizations"] = parts + else: + body["order"] = [parts[0]] + body["allow_fallbacks"] = False + if len(parts) > 1: + body["quantizations"] = parts[1:] + return model_id, body + + def nim_next_model(current: str) -> str | None: """Return the next NIM model after `current` in the curated chain, or None. @@ -1369,6 +1442,11 @@ def stream_openai_compat( # "auto" requires vLLM --enable-auto-tool-choice; omit if server doesn't support it if not config.get("disable_tool_choice"): kwargs["tool_choice"] = "auto" + # OpenRouter: forward pinned provider routing (order / quantizations) as a + # request-body element so the model field stays a real OpenRouter model ID. + _or_prov = config.get("_openrouter_provider") + if _or_prov: + kwargs.setdefault("extra_body", {})["provider"] = _or_prov _prov = detect_provider(model) # DeepSeek v4: thinking is ON by default and controlled via extra_body. @@ -1858,6 +1936,14 @@ def stream( provider_name = detect_provider(model) model_name = bare_model(model) prov = PROVIDERS.get(provider_name, PROVIDERS["openai"]) + # OpenRouter: peel the optional "@provider[/quantization]" routing suffix + # off the upstream model ID. Provider selection is a request-body element + # (the `provider` object), never part of the model ID — see + # parse_openrouter_routing. + if provider_name == "openrouter": + model_name, or_routing = parse_openrouter_routing(model_name) + if or_routing: + config = {**config, "_openrouter_provider": or_routing} api_key = get_api_key(provider_name, config) session_id = config.get("_session_id", "default") diff --git a/tests/test_openrouter_provider.py b/tests/test_openrouter_provider.py new file mode 100644 index 0000000..eb98b38 --- /dev/null +++ b/tests/test_openrouter_provider.py @@ -0,0 +1,165 @@ +"""Tests for the OpenRouter provider entry + multi-level model routing. + +OpenRouter serves 400+ models from many vendors behind one OpenAI-compatible +endpoint. Model IDs keep the upstream / path, so calls use the +double-prefixed form `openrouter//`, e.g. +`openrouter/deepseek/deepseek-v4-flash` — the first segment is the provider +and everything after it is passed through verbatim to the API. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import pytest + +from cheetahclaws.providers import ( + PROVIDERS, AssistantTurn, TextChunk, + bare_model, detect_provider, parse_openrouter_routing, + stream, stream_openai_compat, +) + + +# ── Provider registration ──────────────────────────────────────────────── + + +def test_openrouter_provider_entry_present(): + assert "openrouter" in PROVIDERS + e = PROVIDERS["openrouter"] + assert e["type"] == "openai" + assert e["base_url"] == "https://openrouter.ai/api/v1" + assert e["api_key_env"] == "OPENROUTER_API_KEY" + assert len(e["models"]) >= 5, "expect a curated model list for the /model picker" + + +@pytest.mark.parametrize("model_id,expected_bare", [ + ("openrouter/deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-flash"), + ("openrouter/deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-pro"), + ("openrouter/anthropic/claude-sonnet-4-6", "anthropic/claude-sonnet-4-6"), +]) +def test_openrouter_routing_strips_only_first_segment(model_id, expected_bare): + """`openrouter//` must route to openrouter and keep the + vendor/model bare — that's exactly the ID OpenRouter's API expects.""" + assert detect_provider(model_id) == "openrouter" + assert bare_model(model_id) == expected_bare + + +# ── Provider / quantization routing (@suffix) ──────────────────────────── + + +@pytest.mark.parametrize("model_id,expected_model,expected_body", [ + # plain passthrough — no routing + ("deepseek/deepseek-v4-flash", + "deepseek/deepseek-v4-flash", None), + # pin a secondary provider + ("deepseek/deepseek-v4-flash@gmicloud", + "deepseek/deepseek-v4-flash", + {"order": ["gmicloud"], "allow_fallbacks": False}), + # pin provider + quantization — the user-reported case + ("deepseek/deepseek-v4-flash@gmicloud/fp8", + "deepseek/deepseek-v4-flash", + {"order": ["gmicloud"], "allow_fallbacks": False, + "quantizations": ["fp8"]}), + # quantization only (no provider pin) + ("deepseek/deepseek-v4-flash@fp8", + "deepseek/deepseek-v4-flash", + {"quantizations": ["fp8"]}), + # multiple quantizations + ("deepseek/deepseek-v4-flash@fp4/int8", + "deepseek/deepseek-v4-flash", + {"quantizations": ["fp4", "int8"]}), +]) +def test_parse_openrouter_routing(model_id, expected_model, expected_body): + """`@[/]` must be split off the model ID into a provider + routing body; the model field keeps the real vendor/model ID.""" + assert parse_openrouter_routing(model_id) == (expected_model, expected_body) + + +def test_stream_dispatches_to_openrouter_endpoint(monkeypatch): + """`stream()` must resolve openrouter/... to the OpenRouter base_url and + pass the full vendor/model ID through, using the OPENROUTER_API_KEY.""" + captured = {} + + def fake_stream(api_key, base_url, model, system, messages, tool_schemas, config): + captured["api_key"] = api_key + captured["base_url"] = base_url + captured["model"] = model + captured["config"] = config + yield TextChunk("hi") + yield AssistantTurn("hi", [], in_tokens=1, out_tokens=1) + + monkeypatch.setattr("cheetahclaws.providers.stream_openai_compat", fake_stream) + + cfg = {"openrouter_api_key": "sk-test-123"} + events = list(stream( + "openrouter/deepseek/deepseek-v4-flash", + "sys", [], [], cfg, + )) + + assert captured["api_key"] == "sk-test-123" + assert captured["base_url"] == "https://openrouter.ai/api/v1" + assert captured["model"] == "deepseek/deepseek-v4-flash" + assert any(isinstance(ev, AssistantTurn) for ev in events) + + +def test_stream_splits_routing_suffix_off_model(monkeypatch): + """`openrouter//@gmicloud/fp8` must send the real model ID + in the model field and the provider/quantization routing via config (which + stream_openai_compat turns into the `provider` request body).""" + captured = {} + + def fake_stream(api_key, base_url, model, system, messages, tool_schemas, config): + captured["model"] = model + captured["provider_body"] = config.get("_openrouter_provider") + yield TextChunk("hi") + yield AssistantTurn("hi", [], in_tokens=1, out_tokens=1) + + monkeypatch.setattr("cheetahclaws.providers.stream_openai_compat", fake_stream) + + cfg = {"openrouter_api_key": "sk-test-123"} + events = list(stream( + "openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8", + "sys", [], [], cfg, + )) + + assert captured["model"] == "deepseek/deepseek-v4-flash" + assert captured["provider_body"] == { + "order": ["gmicloud"], + "allow_fallbacks": False, + "quantizations": ["fp8"], + } + assert any(isinstance(ev, AssistantTurn) for ev in events) + + +def test_openai_compat_sends_provider_as_request_body(monkeypatch): + """`stream_openai_compat` must forward the parsed routing as the `provider` + request-body element while the `model` field keeps the real model ID.""" + captured = {} + + class FakeCompletions: + def create(self, **kwargs): + captured["kwargs"] = kwargs + return [] # no chunks → function yields a clean AssistantTurn + + class FakeChat: + completions = FakeCompletions() + + class FakeOpenAI: + def __init__(self, *args, **kwargs): + self.chat = FakeChat + + monkeypatch.setattr("openai.OpenAI", FakeOpenAI) + + routing = {"order": ["gmicloud"], "allow_fallbacks": False, + "quantizations": ["fp8"]} + cfg = {"_openrouter_provider": routing} + events = list(stream_openai_compat( + "sk-x", "https://openrouter.ai/api/v1", "deepseek/deepseek-v4-flash", + "sys", [], [], cfg, + )) + + assert captured["kwargs"]["model"] == "deepseek/deepseek-v4-flash" + assert captured["kwargs"]["extra_body"]["provider"] == routing + assert any(isinstance(ev, AssistantTurn) for ev in events)