Skip to content
Merged
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: 2 additions & 0 deletions cheetahclaws/commands/config_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions cheetahclaws/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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/<vendor>/<model>" 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/<vendor>/<model>@<provider>[/<quant>]" 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
Expand Down Expand Up @@ -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 <vendor>/<model> 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 "@<slug>[/<quant>]"
# (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",
Expand Down Expand Up @@ -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 ``<vendor>/<model>`` 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 ``@<provider-slug>[/<quantization>]`` 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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")

Expand Down
165 changes: 165 additions & 0 deletions tests/test_openrouter_provider.py
Original file line number Diff line number Diff line change
@@ -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 <vendor>/<model> path, so calls use the
double-prefixed form `openrouter/<vendor>/<model>`, 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/<vendor>/<model>` 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):
"""`@<provider>[/<quant>]` 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/<vendor>/<model>@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)
Loading