Skip to content

Commit 45cee68

Browse files
committed
Release 0.7.11 routing safeguards
1 parent b5dbbbc commit 45cee68

14 files changed

Lines changed: 301 additions & 12 deletions

openclaw-plugin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@anjieyang/uncommon-route",
3-
"version": "0.7.10",
3+
"version": "0.7.11",
44
"description": "OpenClaw plugin for UncommonRoute, the local LLM router that cuts premium-model spend",
55
"type": "module",
66
"main": "src/index.js",

openclaw-plugin/src/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import { spawn, execSync } from "node:child_process";
1717
import { setTimeout as sleep } from "node:timers/promises";
1818

19-
const VERSION = "0.7.10";
19+
const VERSION = "0.7.11";
2020
const DEFAULT_PORT = 8403;
2121
const DEFAULT_UPSTREAM = "";
2222
const HEALTH_TIMEOUT_MS = 15_000;

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uncommon-route"
3-
version = "0.7.10"
3+
version = "0.7.11"
44
description = "Local LLM router that cuts premium-model spend with adaptive 3-tier routing, OpenAI + Anthropic compatible"
55
requires-python = ">=3.11"
66
license = "MIT"

tests/test_model_experience.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,17 @@ def test_infer_capabilities_marks_gpt5_family_as_reasoning() -> None:
7171
assert capabilities.reasoning is True
7272

7373

74+
def test_infer_capabilities_does_not_mark_image_generation_as_tool_chat() -> None:
75+
capabilities = infer_capabilities(
76+
"google/gemini-3-pro-image-preview",
77+
ModelPricing(2.0, 12.0),
78+
has_explicit_pricing=True,
79+
)
80+
81+
assert capabilities.vision is True
82+
assert capabilities.tool_calling is False
83+
84+
7485
def test_invalid_upstream_pricing_is_not_treated_as_free() -> None:
7586
pricing = _parse_upstream_pricing({"prompt": "not-a-number", "completion": "0"})
7687
capabilities = infer_capabilities(
@@ -429,6 +440,51 @@ def test_best_mode_uses_higher_quality_threshold() -> None:
429440
"Same model should have same predicted quality regardless of mode"
430441

431442

443+
def test_auto_mode_cost_sanity_guard_blocks_dominated_expensive_exploration(monkeypatch) -> None:
444+
"""AUTO exploration should not pick pricier same-quality peers with weaker priors."""
445+
import uncommon_route.benchmark as benchmark
446+
import uncommon_route.router.selector as selector
447+
448+
class DummyBenchmarkCache:
449+
def get_all_qualities(self, models):
450+
return {
451+
"anthropic/claude-opus-4-7": 0.84,
452+
"google/gemini-3.1-pro-preview": 0.88,
453+
}
454+
455+
monkeypatch.setattr(benchmark, "get_benchmark_cache", lambda: DummyBenchmarkCache())
456+
draws = iter([0.99, 0.10])
457+
monkeypatch.setattr(selector._rng, "betavariate", lambda _alpha, _beta: next(draws))
458+
459+
pricing = {
460+
"anthropic/claude-opus-4-7": ModelPricing(5.0, 25.0),
461+
"google/gemini-3.1-pro-preview": ModelPricing(2.0, 12.0),
462+
}
463+
capabilities = {
464+
model: infer_capabilities(model, model_pricing, has_explicit_pricing=True)
465+
for model, model_pricing in pricing.items()
466+
}
467+
468+
decision = select_from_pool(
469+
complexity=0.90,
470+
mode=RoutingMode.AUTO,
471+
confidence=0.9,
472+
reasoning_text="test",
473+
available_models=list(pricing),
474+
estimated_input_tokens=4_000,
475+
max_output_tokens=800,
476+
prompt="Solve a hard reasoning bug.",
477+
pricing=pricing,
478+
capabilities=capabilities,
479+
requirements=RequestRequirements(prefers_reasoning=True),
480+
selection_weights=get_selection_weights(DEFAULT_CONFIG, RoutingMode.AUTO),
481+
bandit_config=BanditConfig(enabled=True, enabled_tiers=(Tier.COMPLEX,)),
482+
)
483+
484+
assert decision.model == "google/gemini-3.1-pro-preview"
485+
assert "cost-sanity=anthropic/claude-opus-4-7->google/gemini-3.1-pro-preview" in decision.reasoning
486+
487+
432488
def test_feedback_collector_updates_model_experience() -> None:
433489
store = ModelExperienceStore(storage=InMemoryModelExperienceStorage())
434490
feedback = FeedbackCollector(model_experience=store)

tests/test_quality_routing.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ def test_model_served_quality_normalizes_moonshot_provider_aliases() -> None:
5757
assert model_served_quality("moonshotai/kimi-k2.5", CapabilityLane.GENERAL, caps) is ServedQuality.BALANCED
5858

5959

60+
def test_openai_nano_is_not_premium_for_reasoning_lane() -> None:
61+
caps = ModelCapabilities(tool_calling=True, reasoning=True)
62+
63+
assert model_served_quality(
64+
"openai/gpt-5.4-nano-2026-03-17",
65+
CapabilityLane.REASONING,
66+
caps,
67+
) is ServedQuality.ECONOMY
68+
69+
6070
def test_quality_guards_keep_auto_complex_at_balanced_floor_without_opus_only_pool() -> None:
6171
caps = _caps()
6272
guard = apply_quality_guards(

tests/test_reported_issues.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,9 @@ def test_issue6_legacy_model_ids_resolve_to_live_successors():
420420

421421
live_models = [
422422
"x-ai/grok-4-1-fast-non-reasoning",
423+
"x-ai/grok-4.1-fast-reasoning",
424+
"x-ai/grok-code-fast-1",
425+
"google/gemini-3.1-pro-preview",
423426
"openai/gpt-4.1",
424427
"openai/gpt-5.3-codex",
425428
"openai/gpt-5.4-mini-2026-03-17",
@@ -441,6 +444,11 @@ def test_issue6_legacy_model_ids_resolve_to_live_successors():
441444
mapper._discovered = True
442445

443446
assert mapper.resolve("xai/grok-4-0709") == "x-ai/grok-4-1-fast-non-reasoning"
447+
assert mapper.resolve("xai/grok-4-1-fast-reasoning") == "x-ai/grok-4.1-fast-reasoning"
448+
assert mapper.resolve("xai/grok-4-1-fast-non-reasoning") == "x-ai/grok-4-1-fast-non-reasoning"
449+
assert mapper.resolve("xai/grok-code-fast-1") == "x-ai/grok-code-fast-1"
450+
assert mapper.resolve("google/gemini-3-pro-preview") == "google/gemini-3.1-pro-preview"
451+
assert mapper.resolve("google/gemini-3.1-pro") == "google/gemini-3.1-pro-preview"
444452
assert mapper.resolve("openai/gpt-4o") == "openai/gpt-4.1"
445453
assert mapper.resolve("openai/gpt-5.2-codex") == "openai/gpt-5.3-codex"
446454
assert mapper.resolve("openai/o1-mini") == "openai/gpt-5.4-mini-2026-03-17"
@@ -450,6 +458,11 @@ def test_issue6_legacy_model_ids_resolve_to_live_successors():
450458
model in mapper.unresolved_models()
451459
for model in (
452460
"xai/grok-4-0709",
461+
"xai/grok-4-1-fast-reasoning",
462+
"xai/grok-4-1-fast-non-reasoning",
463+
"xai/grok-code-fast-1",
464+
"google/gemini-3-pro-preview",
465+
"google/gemini-3.1-pro",
453466
"openai/gpt-4o",
454467
"openai/gpt-5.2-codex",
455468
"openai/o1-mini",
@@ -459,6 +472,38 @@ def test_issue6_legacy_model_ids_resolve_to_live_successors():
459472
)
460473

461474

475+
def test_issue6_image_generation_models_do_not_enter_chat_routing_pool():
476+
"""Image-generation catalog entries should not become chat candidates."""
477+
from uncommon_route.model_map import DiscoveredModel, ModelMapper, is_routable_chat_model
478+
from uncommon_route.router.types import ModelCapabilities, ModelPricing
479+
480+
mapper = ModelMapper("https://api.commonstack.ai/v1")
481+
for model_id in [
482+
"google/gemini-3-pro-image-preview",
483+
"google/gemini-3.1-flash-image-preview",
484+
"google/gemini-3.1-pro-preview",
485+
"minimax/minimax-m2.7",
486+
]:
487+
provider = model_id.split("/", 1)[0]
488+
mapper._pool[model_id] = DiscoveredModel(
489+
id=model_id,
490+
provider=provider,
491+
owned_by=provider,
492+
pricing=ModelPricing(0.2, 0.8),
493+
capabilities=ModelCapabilities(tool_calling=True, vision=True),
494+
)
495+
mapper._upstream_models.add(model_id)
496+
mapper._build_map()
497+
mapper._discovered = True
498+
499+
assert not is_routable_chat_model("google/gemini-3-pro-image-preview")
500+
assert mapper.resolve("google/gemini-3-pro-preview") == "google/gemini-3.1-pro-preview"
501+
assert "google/gemini-3-pro-image-preview" in mapper.available_models
502+
assert "google/gemini-3-pro-image-preview" not in mapper.routable_models
503+
assert "google/gemini-3.1-flash-image-preview" not in mapper.routable_models
504+
assert "google/gemini-3.1-pro-preview" in mapper.routable_models
505+
506+
462507
# ─── Summary: combined issue reproduction ───
463508

464509
def test_combined_claude_code_session_simulation():

tests/test_v2_routing_logic.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from __future__ import annotations
2+
3+
from uncommon_route.router import api
4+
from uncommon_route.router.types import RoutingFeatures
5+
from uncommon_route.signals.base import TierVote
6+
7+
8+
class _FakeSignal:
9+
def __init__(self, vote: TierVote) -> None:
10+
self.vote = vote
11+
self._embed_fn = None
12+
13+
def predict(self, row: dict) -> TierVote:
14+
return self.vote
15+
16+
17+
def test_soft_structural_floor_keeps_short_implementation_out_of_economy(monkeypatch) -> None:
18+
"""A short implementation ask should not be flattened to SIMPLE by A+C."""
19+
monkeypatch.setattr(api, "_ensure_v2_signals", lambda: None)
20+
monkeypatch.setattr(api, "_v2_sig_a", _FakeSignal(TierVote(0, 0.75)))
21+
monkeypatch.setattr(api, "_v2_sig_b", _FakeSignal(TierVote(3, 0.70)))
22+
monkeypatch.setattr(api, "_v2_sig_c", _FakeSignal(TierVote(0, 0.44)))
23+
monkeypatch.setattr(api, "_v2_calibrator", None)
24+
25+
result = api._v2_classify(
26+
"Implement a Python CLI app with tests, packaging, error handling, and documentation.",
27+
system_prompt=None,
28+
messages=None,
29+
routing_features=RoutingFeatures(),
30+
context_features=None,
31+
)
32+
33+
assert result.tier_id == 1
34+
assert "v2:structural-medium-floor" in result.signals_text

uncommon_route/cli.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -773,7 +773,10 @@ async def _check_provider(base_url: str, api_key: str) -> bool:
773773
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=3.0)) as client:
774774
resp = await client.get(
775775
f"{base_url.rstrip('/')}/models",
776-
headers={"authorization": f"Bearer {api_key}"},
776+
headers={
777+
"authorization": f"Bearer {api_key}",
778+
"user-agent": "uncommon-route/provider-check",
779+
},
777780
)
778781
return resp.status_code == 200
779782
except Exception: # noqa: BLE001

uncommon_route/model_map.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,25 @@ def detect_provider(url: str) -> tuple[str, bool]:
8383
"google/gemini-2.5-flash-lite": [
8484
"google/gemini-3.1-flash-lite-preview",
8585
],
86+
"google/gemini-3-pro-preview": [
87+
"google/gemini-3.1-pro-preview",
88+
],
89+
"google/gemini-3.1-pro": [
90+
"google/gemini-3.1-pro-preview",
91+
],
8692
"xai/grok-4-0709": [
8793
"x-ai/grok-4-1-fast-non-reasoning",
8894
],
95+
"xai/grok-4-1-fast-reasoning": [
96+
"x-ai/grok-4.1-fast-reasoning",
97+
"x-ai/grok-4-1-fast-reasoning",
98+
],
99+
"xai/grok-4-1-fast-non-reasoning": [
100+
"x-ai/grok-4-1-fast-non-reasoning",
101+
],
102+
"xai/grok-code-fast-1": [
103+
"x-ai/grok-code-fast-1",
104+
],
89105
"openai/gpt-4o": [
90106
"openai/gpt-4.1",
91107
],
@@ -139,6 +155,28 @@ def _parse_upstream_pricing(raw: dict | None) -> ModelPricing:
139155

140156
_REASONING_POSITIVE = ("reason", "r1", "thinking", "think", "o1-", "o3", "o4-", "gpt-5")
141157
_REASONING_NEGATIVE = ("non-reason", "non_reason", "no-reason", "non-thinking")
158+
_NON_CHAT_MODEL_MARKERS = (
159+
"-image",
160+
"image-",
161+
"-video",
162+
"video-",
163+
"-audio",
164+
"audio-",
165+
"speech",
166+
"tts",
167+
"embedding",
168+
"embed",
169+
)
170+
171+
172+
def is_routable_chat_model(model_id: str) -> bool:
173+
"""Return False for modality-specific generation models in /v1/models.
174+
175+
Gateways often list image, video, audio, and embedding models beside chat
176+
models. The chat router should not select those for text/tool requests.
177+
"""
178+
core = _core(str(model_id or "").lower())
179+
return not any(marker in core for marker in _NON_CHAT_MODEL_MARKERS)
142180

143181

144182
def infer_capabilities(
@@ -163,6 +201,8 @@ def infer_capabilities(
163201
if provider == "anthropic" and "opus" in core:
164202
reasoning = True
165203

204+
image_generation = not is_routable_chat_model(model_id) and "image" in core
205+
166206
vision = False
167207
if any(p in core for p in ("-vl", "vision", "image")):
168208
vision = True
@@ -171,7 +211,7 @@ def infer_capabilities(
171211
if provider == "openai" and any(p in core for p in ("4o", "gpt-5", "gpt-4")):
172212
vision = True
173213

174-
tool_calling = True
214+
tool_calling = not image_generation
175215

176216
free = (
177217
has_explicit_pricing
@@ -367,6 +407,8 @@ def _fuzzy_match(self, internal: str) -> str | None:
367407
best: str | None = None
368408

369409
for upstream in self._upstream_models:
410+
if is_routable_chat_model(internal) and not is_routable_chat_model(upstream):
411+
continue
370412
up_core = _core(upstream)
371413
up_norm = _normalize(up_core)
372414
up_prov = _provider_prefix(upstream)
@@ -459,6 +501,11 @@ def available_models(self) -> list[str]:
459501
"""All available upstream model IDs, sorted."""
460502
return sorted(self._pool.keys())
461503

504+
@property
505+
def routable_models(self) -> list[str]:
506+
"""Chat-routable upstream model IDs, sorted."""
507+
return [model for model in self.available_models if is_routable_chat_model(model)]
508+
462509
def get_pricing(self, model_id: str) -> ModelPricing | None:
463510
"""Look up pricing for a single model (internal or upstream ID)."""
464511
if model_id in self._pool:

uncommon_route/providers.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,10 @@ def verify_key(base_url: str, api_key: str) -> tuple[bool, str]:
196196
try:
197197
resp = httpx.get(
198198
url,
199-
headers={"authorization": f"Bearer {api_key}"},
199+
headers={
200+
"authorization": f"Bearer {api_key}",
201+
"user-agent": "uncommon-route/provider-check",
202+
},
200203
timeout=httpx.Timeout(8.0, connect=4.0),
201204
)
202205
if resp.status_code == 200:

0 commit comments

Comments
 (0)