From 577ad2569f8d408df8f21db7709cbd5117316c97 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Sat, 15 Aug 2026 19:42:18 -0500 Subject: [PATCH] feat(router): port Router Core into the Python SDK, replacing the local tier tables The Python SDK routed on its own hand-maintained tier tables and a 14-dimension scorer while the TypeScript SDK and the gateway had both moved to @blockrun/router-core. The same request could pick different models on each SDK, and the Python tables drifted independently. router-core ships as an npm tarball pinned to a commit, so parity requires a port: blockrun_llm/router_core/ is that port, at upstream commit 18bf4ab (one ahead of the TS pin, which predates the deepseek-v4-flash NVIDIA EOL). blockrun_llm/router_adapter.py ports the TS SDK's src/router-adapter.ts, and router.py becomes a back-compat shim. What Python did not have before: portfolio (V3) ranking instead of tier lookup, hard capability filtering (a model that cannot hold the conversation, emit the requested max_tokens, call tools or read images is dropped before scoring), task classification, and explainable decisions (candidates, candidate_scores, task_type, reasoning). Adds client.route() for a dry-run decision. Two live bugs fell out of the port: - The free profile pointed at models NVIDIA has retired (deepseek-v4-flash, 410 on 2026-08-12; llama-4-maverick; qwen3-coder-480b), so free routing depended entirely on the gateway's redirect. It now routes the live free lineup, and the adapter drops any candidate the catalog does not price at $0. - Catalog rows marked available: false no longer enter the pricing map; every smart call to one would have failed with a non-transient error. Behavior change: routing.method is now "portfolio" by default ("rules" for the free profile and the config-only V2 rollback). tests/unit/test_router_core.py ports all four upstream vitest suites (88 cases) as the parity guard; test_router_adapter.py covers the host layer. Verified on Python 3.9 (the CI floor) and end-to-end against the live gateway. --- CHANGELOG.md | 65 + CLAUDE.md | 4 +- README.md | 84 +- VERSION | 2 +- blockrun_llm/__init__.py | 6 +- blockrun_llm/client.py | 79 +- blockrun_llm/router.py | 685 +------- blockrun_llm/router_adapter.py | 342 ++++ blockrun_llm/router_core/__init__.py | 114 ++ blockrun_llm/router_core/_js.py | 82 + blockrun_llm/router_core/config.py | 1311 ++++++++++++++++ .../router_core/model_capabilities.py | 297 ++++ .../router_core/model_profiles.generated.json | 242 +++ blockrun_llm/router_core/model_profiles.py | 134 ++ blockrun_llm/router_core/portfolio.py | 1375 +++++++++++++++++ blockrun_llm/router_core/rules.py | 326 ++++ blockrun_llm/router_core/selector.py | 244 +++ blockrun_llm/router_core/strategy.py | 276 ++++ blockrun_llm/router_core/tool_intent.py | 72 + blockrun_llm/router_core/types.py | 307 ++++ blockrun_llm/types.py | 39 +- pyproject.toml | 2 +- tests/unit/test_router_adapter.py | 242 +++ tests/unit/test_router_core.py | 1237 +++++++++++++++ 24 files changed, 6905 insertions(+), 662 deletions(-) create mode 100644 blockrun_llm/router_adapter.py create mode 100644 blockrun_llm/router_core/__init__.py create mode 100644 blockrun_llm/router_core/_js.py create mode 100644 blockrun_llm/router_core/config.py create mode 100644 blockrun_llm/router_core/model_capabilities.py create mode 100644 blockrun_llm/router_core/model_profiles.generated.json create mode 100644 blockrun_llm/router_core/model_profiles.py create mode 100644 blockrun_llm/router_core/portfolio.py create mode 100644 blockrun_llm/router_core/rules.py create mode 100644 blockrun_llm/router_core/selector.py create mode 100644 blockrun_llm/router_core/strategy.py create mode 100644 blockrun_llm/router_core/tool_intent.py create mode 100644 blockrun_llm/router_core/types.py create mode 100644 tests/unit/test_router_adapter.py create mode 100644 tests/unit/test_router_core.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c19082..efc2c02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,71 @@ All notable changes to blockrun-llm will be documented in this file. +## 1.11.0 — 2026-08-15 + +### Added +- **Router Core lands in the Python SDK.** `blockrun_llm/router_core/` is a + faithful port of [`@blockrun/router-core`](https://github.com/BlockRunAI/router-core) + (upstream commit `18bf4ab`) — the product-neutral routing engine the + TypeScript SDK bundles and the gateway runs. The same request now routes + identically across all three. `blockrun_llm/router_adapter.py` is the host + glue (catalog id resolution, x402 payment floors, capacity filtering), ported + from the TypeScript SDK's `src/router-adapter.ts`. + + What the Python SDK did not have before: + - **Portfolio (V3) ranking**, not just tier lookup: candidates are scored on + task affinity, cost, speed and reliability, so the cheapest *capable* model + wins instead of a hardcoded tier primary. + - **Hard capability filtering.** A model that cannot hold the conversation, + emit the requested `max_tokens`, call tools, or read images is dropped + before scoring — previously `smart_chat` could route to a model the request + would fail on with a non-transient 400. + - **Task classification** (`chat`, `code_edit`, `code_agent`, `tool_agent`, + `tool_agent_parallel`, `reasoning_math`, `reasoning_mcq`, `long_context`, + `extraction`, `vision`, `debug`) with per-task calibrated model evidence. + - **Explainable decisions**: `routing.candidates`, `routing.candidate_scores` + (quality / cost / speed / reliability per model), `routing.task_type`, + `routing.profile` and `routing.router_version` are now on the response. + - **Live tier configuration**, shared with the other products, replacing this + SDK's separately hand-maintained tables. + +- **`client.route(prompt, ...)`** returns the routing decision without making or + paying for a model call (TypeScript SDK parity). The first call may fetch the + public catalog for prices; routing itself is local and free. + +### Fixed +- **The `free` profile pointed at models NVIDIA has retired.** Its tier table + led with `nvidia/deepseek-v4-flash` (EOL 2026-08-12, HTTP 410) and fell back + to `nvidia/llama-4-maverick` and `nvidia/qwen3-coder-480b` (also EOL), so free + routing depended entirely on the gateway's redirect safety net. It now routes + over the live free lineup (Step 3.7 Flash, Mistral Nemotron, Nemotron Nano + Omni / 9B / 12B VL), and the adapter drops any candidate the catalog does not + price at $0 — a paid model can no longer leak into a free-profile call. +- **Models the catalog marks unavailable no longer win routing.** `/v1/models` + rows with `available: false` are skipped when building the pricing map; every + smart call to one would have failed with a non-transient error. + +### Changed +- `routing.method` is now `"portfolio"` for the default strategy (`"rules"` for + the free profile and the config-only V2 rollback). Code that asserted + `method == "rules"` needs updating. +- `blockrun_llm/router.py` is now a thin compatibility shim over the core: + `route()` and `classify_by_rules()` keep working, and `RoutingDecision` keeps + its previous keys plus the new metadata. Its hand-maintained `AUTO_TIERS` / + `ECO_TIERS` / `PREMIUM_TIERS` tables are gone — tier configuration lives in + `router_core.DEFAULT_ROUTING_CONFIG`, and `FREE_TIERS` moved to + `router_adapter`. +- Routing cost estimates now include the server margin and the x402 minimum + payment, so `routing.cost_estimate` matches what the gateway actually + charges. Free models are never floored up to the paid minimum. + +### Tests +- `tests/unit/test_router_core.py` ports all four upstream vitest suites + (88 cases) as the parity guard — the Python port must keep choosing the same + models as the TypeScript SDK. `tests/unit/test_router_adapter.py` covers the + host layer: `free/*` → `nvidia/*` id resolution, the payment floor, capacity + filtering, and the free-profile guarantees. + ## 1.10.0 — 2026-07-28 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 05cd9c6..5e5c2ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,9 @@ blockrun_llm/ ├── wallet.py # EVM wallet management ├── solana_wallet.py # Solana wallet management ├── x402.py # x402 payment protocol -├── router.py # Model routing +├── router_core/ # Port of @blockrun/router-core (shared with the TS SDK + gateway) +├── router_adapter.py # Host glue: catalog ids, payment floors, free profile +├── router.py # Back-compat shim over router_core ├── types.py # Type definitions ├── validation.py # Input validation ├── cache.py # Response caching diff --git a/README.md b/README.md index 5a2dab1..96ec70a 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ export SOLANA_WALLET_KEY="your-bs58-solana-key" > what to switch to instead of failing with a cryptic "must be 66 characters" > error. -## Smart Routing (ClawRouter) +## Smart Routing (Router Core) Let the SDK automatically pick the cheapest capable model for each request: @@ -130,25 +130,38 @@ from blockrun_llm import LLMClient client = LLMClient() -# Auto-routes to cheapest capable model -result = client.smart_chat("What is 2+2?") -print(result.response) # '4' -print(result.model) # 'moonshot/kimi-k2.6' (Moonshot flagship — vision + reasoning_content) -print(f"Saved {result.routing.savings * 100:.0f}%") # 'Saved 94%' +# Auto-routes to the cheapest capable model +result = client.smart_chat("Summarize this changelog entry in one line") +print(result.response) +print(result.model) # 'google/gemini-2.5-flash' +print(result.routing.task_type) # 'chat' +print(f"Saved {result.routing.savings * 100:.0f}%") # 'Saved 90%' -# Complex reasoning task -> routes to reasoning model +# Complex reasoning task -> routes to a reasoning model result = client.smart_chat("Prove the Riemann hypothesis step by step") -print(result.model) # 'deepseek/deepseek-reasoner' +print(result.model) # 'deepseek/deepseek-v4-pro' +``` + +Want to see the decision without paying for a call? `client.route(...)` runs the +same routing locally and returns the decision only: + +```python +decision = client.route("Prove the Riemann hypothesis step by step") +print(decision.model) # 'deepseek/deepseek-v4-pro' +print(decision.tier) # 'REASONING' +print(decision.task_type) # 'reasoning' +print(decision.candidates) # ordered chain; smart_chat walks it on a 5xx/timeout +print(decision.reasoning) # human-readable explanation of the pick ``` ### Routing Profiles | Profile | Description | Best For | |---------|-------------|----------| -| `free` | NVIDIA free tier — smart-routes across 5 models (DeepSeek V4 Pro/Flash, Nemotron Nano Omni, Qwen3, GLM-4.7, Llama 4, Mistral) | Zero-cost testing, dev, prod | -| `eco` | Cheapest models per tier (DeepSeek, NVIDIA) | Cost-sensitive production | +| `free` | NVIDIA free tier — smart-routes across the 5 $0 models (Step 3.7 Flash, Mistral Nemotron, Nemotron Nano Omni / 9B / 12B VL) | Zero-cost testing, dev, prod | +| `eco` | Cheapest capable model per tier | Cost-sensitive production | | `auto` | Best balance of cost/quality (default) | General use | -| `premium` | Top-tier models (OpenAI, Anthropic) | Quality-critical tasks | +| `premium` | Top-tier models (Anthropic, OpenAI, Moonshot) | Quality-critical tasks | ```python # Use premium models for complex tasks @@ -156,28 +169,45 @@ result = client.smart_chat( "Write production-grade async Python code", routing_profile="premium" ) -print(result.model) # 'openai/gpt-5.4' +print(result.model) # 'openai/gpt-5.3-codex' ``` ### How It Works -ClawRouter uses a 14-dimension rule-based classifier to analyze each request: - -- **Token count** - Short vs long prompts -- **Code presence** - Programming keywords -- **Reasoning markers** - "prove", "step by step", etc. -- **Technical terms** - Architecture, optimization, etc. -- **Creative markers** - Story, poem, brainstorm, etc. -- **Agentic patterns** - Multi-step, tool use indicators - -The classifier runs in <1ms, 100% locally, and routes to one of four tiers: +Routing runs on [Router Core](https://github.com/BlockRunAI/router-core) — the +same product-neutral engine the TypeScript SDK and the BlockRun gateway use, so +an identical request routes identically across all three. It is 100% local and +takes <1ms; no extra model call is made to decide. + +Three stages: + +1. **Classify** — a 15-dimension weighted + scorer maps the request onto a capability tier (token count, code presence, + reasoning markers, technical terms, creative markers, agentic patterns, and + more), and a task classifier labels the *shape* of the work: `chat`, + `code_edit`, `code_agent`, `tool_agent`, `reasoning_math`, `long_context`, + `extraction`, `vision`, … +2. **Filter** — capability constraints are hard filters, not preferences. A + model that cannot hold the conversation, emit the requested output length, + call tools, or read images is dropped before scoring, so the router never + picks a model the request would fail on. +3. **Rank** — surviving candidates are scored on task affinity, cost, speed and + reliability. The winner serves the request; the rest become the ordered + fallback chain that `smart_chat` walks on a timeout or 5xx. + +The four capability tiers: | Tier | Example Tasks | Auto Profile Model | |------|---------------|-------------------| -| SIMPLE | "What is 2+2?", definitions | moonshot/kimi-k2.6 | -| MEDIUM | Code snippets, explanations | google/gemini-2.5-flash | +| SIMPLE | Short questions, definitions | google/gemini-2.5-flash | +| MEDIUM | Code snippets, explanations | moonshot/kimi-k2.7 | | COMPLEX | Architecture, long documents | google/gemini-3.1-pro | -| REASONING | Proofs, multi-step reasoning | deepseek/deepseek-reasoner | +| REASONING | Proofs, math, multi-step reasoning | deepseek/deepseek-v4-pro | + +Every decision is explainable — `result.routing` carries the tier, the task +type, the confidence, the ranked `candidates`, the per-candidate +`candidate_scores` (quality / cost / speed / reliability) and a `reasoning` +string describing why that model won. ## How Payment Works @@ -1671,8 +1701,8 @@ blockrun-llm is a Python SDK that provides pay-per-request access to 43+ large l ### How does payment work? When you make an API call, the SDK automatically handles x402 payment. It signs a USDC transaction locally using your wallet private key (which never leaves your machine), and includes the payment proof in the request header. Settlement is non-custodial and instant on Base or Solana. -### What is smart routing / ClawRouter? -ClawRouter is a built-in smart routing engine that analyzes your request across 15 dimensions and automatically picks the cheapest model capable of handling it. Routing happens locally in under 1ms. It can save up to 88% on LLM costs compared to using premium models for every request. +### What is smart routing / Router Core? +Router Core is BlockRun's built-in routing engine — shared with the TypeScript SDK and the gateway, so the same request routes the same way everywhere. It scores your request across 15 dimensions, drops every model that can't actually handle it (context, output length, tools, vision), then picks the cheapest capable one and keeps the rest as a fallback chain. Routing happens locally in under 1ms and makes no extra model call. It can save up to 88% on LLM costs compared to using premium models for every request. ### How much does it cost? Pay only for what you use. Prices start at **FREE** (11 NVIDIA-hosted models). Paid models start at $0.10/M tokens. There are no minimums, subscriptions, or monthly fees. $5 in USDC gets you thousands of requests. diff --git a/VERSION b/VERSION index 4dae298..1cac385 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.10.1 +1.11.0 diff --git a/blockrun_llm/__init__.py b/blockrun_llm/__init__.py index 4ce1c53..a7b88c1 100644 --- a/blockrun_llm/__init__.py +++ b/blockrun_llm/__init__.py @@ -101,6 +101,8 @@ APIError, AudioModel, AudioTrack, + # Smart routing types + CandidateScore, ChatChunkChoice, ChatChunkDelta, ChatChunkFunctionCall, @@ -184,7 +186,7 @@ create_wallet as generate_wallet, # User-friendly alias ) -__version__ = "1.10.1" +__version__ = "1.11.0" __all__ = [ "NETWORK_ALIASES", "SUPPORTED_NETWORKS", @@ -196,6 +198,8 @@ "AsyncSolanaLLMClient", "AudioModel", "AudioTrack", + # Smart routing types + "CandidateScore", "ChatChunkChoice", "ChatChunkDelta", "ChatChunkFunctionCall", diff --git a/blockrun_llm/client.py b/blockrun_llm/client.py index 6e56a34..f6ba258 100644 --- a/blockrun_llm/client.py +++ b/blockrun_llm/client.py @@ -50,7 +50,7 @@ from dotenv import load_dotenv from eth_account import Account -from .router import route as route_request +from .router_adapter import BASE_MINIMUM_PAYMENT_USD, route_with_catalog from .tx_log import ( TransactionLogger, _resolve_log_dir, @@ -490,6 +490,10 @@ def _get_model_pricing(self) -> dict[str, dict[str, float]]: pricing: dict[str, dict[str, float]] = {} for model in models: model_id = model.get("id", "") + # A model the catalog marks unavailable must not win routing — every + # smart call to it would fail with a non-transient error. + if model.get("available") is False: + continue block = model.get("pricing") or {} input_price = block.get("input", model.get("inputPrice", model.get("input_price", 0))) output_price = block.get( @@ -504,6 +508,38 @@ def _get_model_pricing(self) -> dict[str, dict[str, float]]: self._model_pricing_cache = pricing return pricing + def route( + self, + prompt: str, + *, + system: str | None = None, + max_tokens: int | None = None, + routing_profile: RoutingProfile = "auto", + requires_structured_output: bool = False, + ) -> RoutingDecision: + """ + Inspect a routing decision without making or paying for a model call. + + The first invocation may fetch the public model catalog for current + prices; routing itself is local and costs nothing. + + Example: + decision = client.route("Prove the Riemann hypothesis") + print(decision.model) # 'deepseek/deepseek-v4-pro' + print(decision.task_type) # 'reasoning' + print(decision.candidates) # ordered fallback chain + """ + decision = route_with_catalog( + prompt, + system, + max_tokens or self.DEFAULT_MAX_TOKENS, + self._get_model_pricing(), + routing_profile=routing_profile, + requires_structured_output=requires_structured_output, + minimum_payment_usd=BASE_MINIMUM_PAYMENT_USD, + ) + return RoutingDecision(**decision) + def smart_chat( self, prompt: str, @@ -516,8 +552,12 @@ def smart_chat( """ Smart chat with automatic model routing. - Routes requests to the cheapest capable model using ClawRouter's - 14-dimension rule-based scoring algorithm (<1ms, 100% local). + Uses BlockRun's product-neutral Router Core portfolio strategy — the + same engine the TypeScript SDK and the gateway run. It classifies the + task shape locally (<1ms, no extra model call), enforces capability + constraints as hard filters, and ranks an ordered candidate portfolio: + the cheapest model that can handle the request wins, and the rest become + the transient-error fallback chain. Args: prompt: User message @@ -525,18 +565,19 @@ def smart_chat( max_tokens: Max tokens to generate (default: 1024) temperature: Sampling temperature routing_profile: "free" | "eco" | "auto" | "premium" - - free: nvidia/gpt-oss-120b only (FREE) - - eco: Cheapest models per tier (DeepSeek, xAI) + - free: NVIDIA's $0 models only — no wallet needed + - eco: Cheapest capable model per tier - auto: Best balance of cost/quality (default) - - premium: Top-tier models (OpenAI, Anthropic) + - premium: Top-tier models (Anthropic, OpenAI, Moonshot) Returns: SmartChatResponse with response, model, and routing decision Example: result = client.smart_chat("What is 2+2?") - print(result.response) # '4' - print(result.model) # 'google/gemini-2.5-flash' + print(result.response) # '4' + print(result.model) # 'google/gemini-3.5-flash' + print(result.routing.method) # 'portfolio' print(f"Saved {result.routing.savings * 100:.0f}%") # With routing profile @@ -545,22 +586,18 @@ def smart_chat( routing_profile="premium" # Use top-tier models for complex tasks ) """ - # Get model pricing for routing decision - model_pricing = self._get_model_pricing() - max_output_tokens = max_tokens or self.DEFAULT_MAX_TOKENS - - # Route the request - decision = route_request( - prompt=prompt, - system_prompt=system, - max_output_tokens=max_output_tokens, - model_pricing=model_pricing, + decision = route_with_catalog( + prompt, + system, + max_tokens or self.DEFAULT_MAX_TOKENS, + self._get_model_pricing(), routing_profile=routing_profile, + minimum_payment_usd=BASE_MINIMUM_PAYMENT_USD, ) - # Make the chat request with selected model. Pass the tier's remaining - # models as fallbacks so a hung upstream (e.g. NVIDIA NIM) doesn't - # hard-fail when smart_chat could just walk to the next visible model. + # Make the chat request with selected model. Pass the remaining ranked + # candidates as fallbacks so a hung upstream (e.g. NVIDIA NIM) doesn't + # hard-fail when smart_chat could just walk to the next capable model. response = self.chat( model=decision["model"], prompt=prompt, diff --git a/blockrun_llm/router.py b/blockrun_llm/router.py index 0fd8367..711dc27 100644 --- a/blockrun_llm/router.py +++ b/blockrun_llm/router.py @@ -1,551 +1,91 @@ """ Smart Router for BlockRun LLM SDK -Port of ClawRouter's 14-dimension rule-based scoring algorithm. -Routes requests to the cheapest capable model in <1ms, 100% local. +Thin compatibility shim over :mod:`blockrun_llm.router_core` — the Python port +of `@blockrun/router-core `_, the +same routing engine the TypeScript SDK and the BlockRun gateway run. + +Routing decisions are local and deterministic (<1ms, no extra model call): the +core classifies the task shape, applies capability constraints as hard filters, +and ranks an ordered candidate portfolio; :mod:`blockrun_llm.router_adapter` +then resolves that ranking against the live catalog. + +Until 1.10.1 this module carried its own hand-maintained tier tables and a +14-dimension scorer. Those have been replaced by the shared core, so tier +configuration now lives in :data:`blockrun_llm.router_core.DEFAULT_ROUTING_CONFIG` +(and, for the SDK-only ``free`` profile, in +:data:`blockrun_llm.router_adapter.FREE_TIERS`). Usage: from blockrun_llm import LLMClient client = LLMClient() result = client.smart_chat("What is 2+2?") - print(result["response"]) # '4' - print(result["model"]) # 'moonshot/kimi-k2.6' (AUTO Simple picks here) - print(f"Saved {result['routing']['savings'] * 100:.0f}%") + print(result.response) # '4' + print(result.model) # 'google/gemini-3.5-flash' + print(f"Saved {result.routing.savings * 100:.0f}%") """ from __future__ import annotations -import math -import re -from typing import Literal, TypedDict - -# Type definitions -Tier = Literal["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"] -RoutingProfile = Literal["free", "eco", "auto", "premium"] - - -class RoutingDecision(TypedDict): - model: str - tier: Tier - confidence: float - method: Literal["rules"] - reasoning: str - cost_estimate: float - baseline_cost: float - savings: float # 0-1 percentage - fallbacks: list[str] # remaining models in tier order, for runtime fallback - - -class TierConfig(TypedDict): - primary: str - fallback: list[str] - - -class ScoringResult(TypedDict): - score: float - tier: Tier | None - confidence: float - signals: list[str] - agentic_score: float - - -# ─── Scoring Config ─── -# Multilingual keywords for 14-dimension scoring - -CODE_KEYWORDS = [ - "function", - "class", - "import", - "def", - "SELECT", - "async", - "await", - "const", - "let", - "var", - "return", - "```", - "函数", - "类", - "导入", - "定义", - "查询", - "异步", - "等待", - "常量", - "变量", - "返回", - "関数", - "クラス", - "インポート", - "非同期", - "定数", - "変数", - "функция", - "класс", - "импорт", - "определ", - "запрос", - "асинхронный", -] - -REASONING_KEYWORDS = [ - "prove", - "theorem", - "derive", - "step by step", - "chain of thought", - "formally", - "mathematical", - "proof", - "logically", - "证明", - "定理", - "推导", - "逐步", - "思维链", - "形式化", - "数学", - "逻辑", - "доказать", - "теорема", - "вывести", - "шаг за шагом", - "логически", -] - -SIMPLE_KEYWORDS = [ - "what is", - "define", - "translate", - "hello", - "yes or no", - "capital of", - "how old", - "who is", - "when was", - "什么是", - "定义", - "翻译", - "你好", - "是否", - "首都", - "что такое", - "определение", - "перевести", - "привет", +from collections.abc import Mapping + +from .router_adapter import ( + BASE_MINIMUM_PAYMENT_USD, + FREE_TIERS, + ResolvedRoutingDecision, + route_with_catalog, +) +from .router_core import DEFAULT_ROUTING_CONFIG +from .router_core import classify_by_rules as _classify_by_rules +from .router_core.types import ModelPricing, ScoringResult, Tier, TierConfig +from .types import RoutingProfile + +#: Back-compat alias — this module used to define its own decision TypedDict. +RoutingDecision = ResolvedRoutingDecision + +__all__ = [ + "DEFAULT_ROUTING_CONFIG", + "FREE_TIERS", + "ModelPricing", + "ResolvedRoutingDecision", + "RoutingDecision", + "RoutingProfile", + "ScoringResult", + "Tier", + "TierConfig", + "classify_by_rules", + "route", ] -TECHNICAL_KEYWORDS = [ - "algorithm", - "optimize", - "architecture", - "distributed", - "kubernetes", - "microservice", - "database", - "infrastructure", - "算法", - "优化", - "架构", - "分布式", - "微服务", - "数据库", -] - -CREATIVE_KEYWORDS = [ - "story", - "poem", - "compose", - "brainstorm", - "creative", - "imagine", - "write a", - "故事", - "诗", - "创作", - "头脑风暴", - "创意", - "想象", -] - -AGENTIC_KEYWORDS = [ - "read file", - "read the file", - "look at", - "check the", - "open the", - "edit", - "modify", - "update the", - "change the", - "write to", - "create file", - "execute", - "deploy", - "install", - "npm", - "pip", - "compile", - "after that", - "and also", - "once done", - "step 1", - "step 2", - "fix", - "debug", - "until it works", - "keep trying", - "iterate", - "make sure", - "verify", - "confirm", -] - -# Tier boundaries on weighted score axis -TIER_BOUNDARIES = { - "simple_medium": 0.0, - "medium_complex": 0.3, - "complex_reasoning": 0.5, -} - -# Dimension weights (sum to ~1.0) -DIMENSION_WEIGHTS = { - "token_count": 0.08, - "code_presence": 0.15, - "reasoning_markers": 0.18, - "technical_terms": 0.10, - "creative_markers": 0.05, - "simple_indicators": 0.02, - "multi_step_patterns": 0.12, - "question_complexity": 0.05, - "agentic_task": 0.04, -} - -# ─── Tier Configs by Profile ─── - -AUTO_TIERS: dict[Tier, TierConfig] = { - "SIMPLE": { - # moonshot/kimi-k2.7 is Moonshot's current flagship (256K context, - # image+video input, reasoning_content). It is the only k2 visible in - # /v1/models — k2.6 and k2.5 are now hidden:true (superseded), so they - # no longer appear in pricing and would be skipped by the availability - # check below. The primary MUST be a non-hidden model or SIMPLE silently - # degrades to gemini-2.5-flash-lite. k2.6 retained as a documented - # previous-gen fallback for clients that pricing-pin to it. - "primary": "moonshot/kimi-k2.7", - "fallback": [ - "moonshot/kimi-k2.6", - "google/gemini-2.5-flash-lite", - "deepseek/deepseek-chat", - "nvidia/llama-4-maverick", - ], - }, - "MEDIUM": { - "primary": "google/gemini-2.5-flash", - "fallback": [ - "deepseek/deepseek-chat", - "nvidia/llama-4-maverick", - ], - }, - "COMPLEX": { - "primary": "google/gemini-3.1-pro", - "fallback": [ - "google/gemini-3.5-flash", - "google/gemini-3-flash-preview", - "google/gemini-2.5-pro", - "deepseek/deepseek-chat", - ], - }, - "REASONING": { - # deepseek/deepseek-reasoner is V4 Flash thinking ($0.20/$0.40, 1M ctx) - # — the cheapest production-grade reasoner. deepseek/deepseek-v4-pro - # ($0.435/$0.87 — the 75% launch promo became DeepSeek's permanent - # list price after 2026-05-31; MMLU-Pro 87.5, GPQA 90.1, SWE-bench - # 80.6) is the strongest open-weight reasoner we serve; first - # fallback when V4 Flash thinking is unavailable. - "primary": "deepseek/deepseek-reasoner", - "fallback": ["deepseek/deepseek-v4-pro", "openai/o3", "openai/o3-mini"], - }, -} - -ECO_TIERS: dict[Tier, TierConfig] = { - "SIMPLE": { - # See AUTO_TIERS note: kimi-k2.7 is the catalog flagship. k2.6 and k2.5 - # are hidden so the SDK no longer sees their pricing; primary must stay - # on the non-hidden k2.7 or this tier silently falls back. - "primary": "moonshot/kimi-k2.7", - "fallback": ["moonshot/kimi-k2.6", "deepseek/deepseek-chat", "nvidia/llama-4-maverick"], - }, - "MEDIUM": { - # deepseek/deepseek-chat is V4 Flash non-thinking ($0.20/$0.40, 1M ctx - # — DeepSeek upstream now serves the legacy alias as V4 Flash chat). - "primary": "deepseek/deepseek-chat", - "fallback": ["google/gemini-2.5-flash-lite", "google/gemini-2.5-flash"], - }, - "COMPLEX": { - # 2026-06-06: the whole GLM flat-rate promo family ended (glm-5 now - # $0.60/$1.92 per-token), so no GLM earns a cheap-fallback slot here - # anymore — the per-token chain below already covers every price - # point (v4-pro $0.435/$0.87 is both cheaper and stronger). - "primary": "google/gemini-2.5-pro", - "fallback": [ - "deepseek/deepseek-v4-pro", - "deepseek/deepseek-chat", - "google/gemini-2.5-flash", - ], - }, - "REASONING": { - # V4 Flash thinking ($0.20/$0.40) preferred over V4 Pro ($0.435/$0.87) - # in eco mode — V4 Pro retained as fallback for harder reasoning. - "primary": "deepseek/deepseek-reasoner", - "fallback": ["deepseek/deepseek-v4-pro", "openai/o3-mini"], - }, -} - -PREMIUM_TIERS: dict[Tier, TierConfig] = { - "SIMPLE": { - "primary": "google/gemini-2.5-flash", - "fallback": ["openai/gpt-5.4-nano", "anthropic/claude-haiku-4.5"], - }, - "MEDIUM": { - "primary": "openai/gpt-5.5", - "fallback": ["openai/gpt-5.4", "google/gemini-2.5-pro", "anthropic/claude-sonnet-4.6"], - }, - "COMPLEX": { - # claude-opus-4.8 (1M context, agentic coding + adaptive thinking) is - # Anthropic's strongest current Claude. opus-4.7/4.5 retained as - # fallbacks for clients pricing-pinned to them. - "primary": "anthropic/claude-opus-4.8", - "fallback": [ - "anthropic/claude-opus-4.7", - "anthropic/claude-opus-4.5", - "openai/gpt-5.2-pro", - "google/gemini-3.1-pro", - "openai/gpt-5.2", - ], - }, - "REASONING": { - "primary": "openai/o3", - "fallback": ["openai/o1", "anthropic/claude-opus-4.8"], - }, -} - -FREE_TIERS: dict[Tier, TierConfig] = { - # NVIDIA free tier refresh 2026-04-28: retired nvidia/gpt-oss-120b and - # nvidia/gpt-oss-20b (NVIDIA's free build.nvidia.com tier reserves the - # right to use prompts/outputs for service improvement, conflicting with - # our data-privacy policy). Added nvidia/deepseek-v4-pro and - # nvidia/deepseek-v4-flash (1M context); v4-pro currently hidden because - # NVIDIA's NIM deployment for it is hung — backend MODEL_REDIRECTS sends - # callers to v4-flash transparently. nvidia/deepseek-v3.2 is also hidden - # for the same hang. Primaries here are pinned to visible models so the - # Python pricing dict (built from /v1/models) can resolve them. - # - # 2026-06-07 sweep (live-probed every visible free model): - # - nvidia/qwen3-next-80b-a3b-thinking hit NVIDIA END-OF-LIFE 2026-05-21 - # (HTTP 410 Gone; backend marks it hidden + unavailable and redirects to - # llama-4-maverick). Dropped as COMPLEX/REASONING primary. - # - nvidia/mistral-small-4-119b is timing out upstream (3/3 probes >60s). - # Dropped as SIMPLE primary and from all fallback chains. - # - nvidia/deepseek-v4-flash RECOVERED from the 05-09 NIM regression - # (896ms probe) — reinstated as SIMPLE primary (1M context, fastest - # capable free chat). - # - nvidia/nemotron-3-nano-omni-30b-a3b-reasoning (681ms, 256K ctx, - # explicit reasoning + vision) takes the REASONING primary. - # - nvidia/qwen3-coder-480b (871ms, 480B MoE) takes the COMPLEX primary. - "SIMPLE": { - "primary": "nvidia/deepseek-v4-flash", - "fallback": ["nvidia/llama-4-maverick"], - }, - "MEDIUM": { - "primary": "nvidia/llama-4-maverick", - "fallback": ["nvidia/qwen3-coder-480b", "nvidia/deepseek-v4-flash"], - }, - "COMPLEX": { - "primary": "nvidia/qwen3-coder-480b", - "fallback": ["nvidia/llama-4-maverick", "nvidia/deepseek-v4-flash"], - }, - "REASONING": { - "primary": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", - "fallback": ["nvidia/llama-4-maverick", "nvidia/deepseek-v4-flash"], - }, -} - - -def _score_keyword_match( - text: str, - keywords: list[str], - thresholds: tuple = (1, 2), - scores: tuple = (0, 0.5, 1.0), -) -> tuple: - """Score keyword matches, returning (score, matched_keywords).""" - matches = [kw for kw in keywords if kw.lower() in text] - if len(matches) >= thresholds[1]: - return scores[2], matches[:3] - if len(matches) >= thresholds[0]: - return scores[1], matches[:3] - return scores[0], [] - - -def _calibrate_confidence(distance: float, steepness: float = 12) -> float: - """Sigmoid confidence calibration.""" - return 1 / (1 + math.exp(-steepness * distance)) - def classify_by_rules( prompt: str, - system_prompt: str | None, - estimated_tokens: int, + system_prompt: str | None = None, + estimated_tokens: int | None = None, ) -> ScoringResult: - """ - 14-dimension rule-based classifier. - Returns tier classification with confidence score. - """ - text = f"{system_prompt or ''} {prompt}".lower() - user_text = prompt.lower() - signals: list[str] = [] - - # Dimension scores - scores: dict[str, float] = {} - - # 1. Token count - if estimated_tokens < 50: - scores["token_count"] = -1.0 - signals.append(f"short ({estimated_tokens} tokens)") - elif estimated_tokens > 500: - scores["token_count"] = 1.0 - signals.append(f"long ({estimated_tokens} tokens)") - else: - scores["token_count"] = 0.0 - - # 2. Code presence - score, matches = _score_keyword_match(text, CODE_KEYWORDS) - scores["code_presence"] = score - if matches: - signals.append(f"code ({', '.join(matches[:3])})") - - # 3. Reasoning markers (user text only) - score, matches = _score_keyword_match(user_text, REASONING_KEYWORDS, scores=(0, 0.7, 1.0)) - scores["reasoning_markers"] = score - if matches: - signals.append(f"reasoning ({', '.join(matches[:3])})") - - # 4. Technical terms - score, matches = _score_keyword_match(text, TECHNICAL_KEYWORDS, thresholds=(2, 4)) - scores["technical_terms"] = score - if matches: - signals.append(f"technical ({', '.join(matches[:3])})") - - # 5. Creative markers - score, matches = _score_keyword_match(text, CREATIVE_KEYWORDS, scores=(0, 0.5, 0.7)) - scores["creative_markers"] = score - if matches: - signals.append(f"creative ({', '.join(matches[:3])})") - - # 6. Simple indicators - score, matches = _score_keyword_match(text, SIMPLE_KEYWORDS, scores=(0, -1.0, -1.0)) - scores["simple_indicators"] = score - if matches: - signals.append(f"simple ({', '.join(matches[:3])})") - - # 7. Multi-step patterns - patterns = [r"first.*then", r"step \d", r"\d\.\s"] - if any(re.search(p, text, re.IGNORECASE) for p in patterns): - scores["multi_step_patterns"] = 0.5 - signals.append("multi-step") - else: - scores["multi_step_patterns"] = 0.0 - - # 8. Question complexity - question_count = text.count("?") - if question_count > 3: - scores["question_complexity"] = 0.5 - signals.append(f"{question_count} questions") - else: - scores["question_complexity"] = 0.0 - - # 9. Agentic task indicators - agentic_matches = [kw for kw in AGENTIC_KEYWORDS if kw.lower() in text] - if len(agentic_matches) >= 4: - scores["agentic_task"] = 1.0 - agentic_score = 1.0 - signals.append(f"agentic ({', '.join(agentic_matches[:3])})") - elif len(agentic_matches) >= 3: - scores["agentic_task"] = 0.6 - agentic_score = 0.6 - signals.append(f"agentic ({', '.join(agentic_matches[:3])})") - elif len(agentic_matches) >= 1: - scores["agentic_task"] = 0.2 - agentic_score = 0.2 - else: - scores["agentic_task"] = 0.0 - agentic_score = 0.0 - - # Compute weighted score - weighted_score = sum(scores.get(dim, 0) * weight for dim, weight in DIMENSION_WEIGHTS.items()) + """Classify a prompt into a tier with the shared 15-dimension scorer. - # Check for reasoning override (2+ reasoning markers = REASONING) - reasoning_matches = [kw for kw in REASONING_KEYWORDS if kw.lower() in user_text] - if len(reasoning_matches) >= 2: - confidence = _calibrate_confidence(max(weighted_score, 0.3)) - return { - "score": weighted_score, - "tier": "REASONING", - "confidence": max(confidence, 0.85), - "signals": signals, - "agentic_score": agentic_score, - } - - # Map score to tier - if weighted_score < TIER_BOUNDARIES["simple_medium"]: - tier: Tier = "SIMPLE" - distance = TIER_BOUNDARIES["simple_medium"] - weighted_score - elif weighted_score < TIER_BOUNDARIES["medium_complex"]: - tier = "MEDIUM" - distance = min( - weighted_score - TIER_BOUNDARIES["simple_medium"], - TIER_BOUNDARIES["medium_complex"] - weighted_score, - ) - elif weighted_score < TIER_BOUNDARIES["complex_reasoning"]: - tier = "COMPLEX" - distance = min( - weighted_score - TIER_BOUNDARIES["medium_complex"], - TIER_BOUNDARIES["complex_reasoning"] - weighted_score, - ) - else: - tier = "REASONING" - distance = weighted_score - TIER_BOUNDARIES["complex_reasoning"] - - confidence = _calibrate_confidence(distance) - - # Ambiguous if confidence too low - if confidence < 0.7: - return { - "score": weighted_score, - "tier": None, - "confidence": confidence, - "signals": signals, - "agentic_score": agentic_score, - } - - return { - "score": weighted_score, - "tier": tier, - "confidence": confidence, - "signals": signals, - "agentic_score": agentic_score, - } + ``estimated_tokens`` defaults to the ~4-chars-per-token estimate the router + itself uses. + """ + if estimated_tokens is None: + full_text = f"{system_prompt or ''} {prompt}" + estimated_tokens = -(-len(full_text) // 4) # ceil + return _classify_by_rules( + prompt, system_prompt, estimated_tokens, DEFAULT_ROUTING_CONFIG["scoring"] + ) def route( prompt: str, system_prompt: str | None, max_output_tokens: int, - model_pricing: dict[str, dict[str, float]], + model_pricing: Mapping[str, ModelPricing], routing_profile: RoutingProfile = "auto", -) -> RoutingDecision: + *, + minimum_payment_usd: float = BASE_MINIMUM_PAYMENT_USD, +) -> ResolvedRoutingDecision: """ Route a request to the cheapest capable model. @@ -553,94 +93,23 @@ def route( prompt: User message system_prompt: Optional system prompt max_output_tokens: Max tokens to generate - model_pricing: Dict of model_id -> {"input_price": x, "output_price": y} + model_pricing: Dict of model_id -> {"input_price": x, "output_price": y, + "flat_price": z}, as built from ``/v1/models`` routing_profile: "free" | "eco" | "auto" | "premium" + minimum_payment_usd: x402 per-request floor applied to the cost + estimate; defaults to the Base chain's $0.002 Returns: - RoutingDecision with model, tier, confidence, reasoning, costs + The routing decision: selected ``model``, the ordered ``fallbacks`` + chain, ``tier``, ``confidence``, ``method``, ``reasoning``, cost + metadata, plus the portfolio's ``candidates`` / ``candidate_scores`` / + ``task_type`` when the portfolio strategy ran. """ - # Estimate input tokens (~4 chars per token) - full_text = f"{system_prompt or ''} {prompt}" - estimated_tokens = len(full_text) // 4 - - # Classify by rules - result = classify_by_rules(prompt, system_prompt, estimated_tokens) - - # Select tier configs based on profile - if routing_profile == "free": - tier_configs = FREE_TIERS - profile_suffix = " | free" - elif routing_profile == "eco": - tier_configs = ECO_TIERS - profile_suffix = " | eco" - elif routing_profile == "premium": - tier_configs = PREMIUM_TIERS - profile_suffix = " | premium" - else: - tier_configs = AUTO_TIERS - profile_suffix = "" - - # Handle large context override - if estimated_tokens > 100_000: - tier: Tier = "COMPLEX" - confidence = 0.95 - reasoning = f"Input exceeds 100K tokens{profile_suffix}" - elif result["tier"] is None: - # Ambiguous - default to MEDIUM - tier = "MEDIUM" - confidence = 0.5 - reasoning = f"score={result['score']:.2f} | {', '.join(result['signals'])} | ambiguous -> default: MEDIUM{profile_suffix}" - else: - tier = result["tier"] - confidence = result["confidence"] - reasoning = f"score={result['score']:.2f} | {', '.join(result['signals'])}{profile_suffix}" - - # Select model from tier - config = tier_configs[tier] - model = config["primary"] - - # Check if model is available in pricing - if model not in model_pricing: - for fallback in config["fallback"]: - if fallback in model_pricing: - model = fallback - break - - # Build runtime fallback chain — every model in the tier other than the - # chosen one, in tier-defined order, filtered to those with known pricing. - # chat_completion() walks this list on timeout / 5xx so a hung upstream - # does not break smart_chat. - ordered = [config["primary"], *config["fallback"]] - fallbacks = [m for m in ordered if m != model and m in model_pricing] - - # Calculate costs. Flat-billed models (ZAI GLM-5 family) charge a fixed - # USD/call regardless of token count; honor that instead of computing - # per-token cost as zero. - pricing = model_pricing.get(model, {"input_price": 0, "output_price": 0, "flat_price": 0}) - flat_price = pricing.get("flat_price", 0) - if flat_price: - cost_estimate = float(flat_price) - else: - input_cost = (estimated_tokens / 1_000_000) * pricing.get("input_price", 0) - output_cost = (max_output_tokens / 1_000_000) * pricing.get("output_price", 0) - cost_estimate = input_cost + output_cost - - # Baseline cost (GPT-5.5 pricing: $5.00/$30) - baseline_input = (estimated_tokens / 1_000_000) * 5.00 - baseline_output = (max_output_tokens / 1_000_000) * 30.0 - baseline_cost = baseline_input + baseline_output - - # Savings calculation - savings = max(0, (baseline_cost - cost_estimate) / baseline_cost) if baseline_cost > 0 else 0 - - return { - "model": model, - "fallbacks": fallbacks, - "tier": tier, - "confidence": confidence, - "method": "rules", - "reasoning": reasoning, - "cost_estimate": cost_estimate, - "baseline_cost": baseline_cost, - "savings": savings, - } + return route_with_catalog( + prompt, + system_prompt, + max_output_tokens, + model_pricing, + routing_profile=routing_profile, + minimum_payment_usd=minimum_payment_usd, + ) diff --git a/blockrun_llm/router_adapter.py b/blockrun_llm/router_adapter.py new file mode 100644 index 0000000..67405a1 --- /dev/null +++ b/blockrun_llm/router_adapter.py @@ -0,0 +1,342 @@ +""" +Host glue between the BlockRun catalog and :mod:`blockrun_llm.router_core`. + +Python port of the TypeScript SDK's ``src/router-adapter.ts``. Router Core is +deliberately product-neutral, so everything BlockRun-specific lives here: + +* catalog id resolution (the router's ``free/*`` namespace vs the gateway's + ``nvidia/*`` ids), +* the x402 per-request payment floors used for cost metadata, +* capacity filtering against the full conversation, not just the last message, +* the SDK-only ``free`` routing profile, which Router Core does not model. +""" + +from __future__ import annotations + +import math +from collections.abc import Mapping, Sequence +from typing import Any + +from .router_core import ( + DEFAULT_MODEL_CAPABILITIES, + DEFAULT_ROUTING_CONFIG, + calculate_model_cost, + filter_candidates_by_capacity, + get_fallback_chain, + route, +) +from .router_core.types import ( + Capacity, + ModelPricing, + RouterOptions, + RoutingConfig, + RoutingDecision, + TierConfig, +) + + +class ResolvedRoutingDecision(RoutingDecision, total=False): + """A Router Core decision resolved against the live BlockRun catalog. + + Adds ``fallbacks`` — the remaining candidates in ranked order, which + ``chat()`` walks when an upstream fails transiently. + """ + + fallbacks: list[str] + + +#: Virtual model ids that select a routing profile instead of a concrete model. +AUTO_ROUTING_PROFILES: Mapping[str, str] = { + "blockrun/auto": "auto", + "blockrun/eco": "eco", + "blockrun/premium": "premium", +} + +# x402 per-request payment floors, used only for cost METADATA (the real charge +# is always the gateway's 402 quote). Free models settle at $0 and are never +# floored. +BASE_MINIMUM_PAYMENT_USD = 0.002 +SOLANA_MINIMUM_PAYMENT_USD = 0.001 + +#: The BlockRun free tier is a gateway concept, not a Router Core profile: the +#: core's tiers rank paid models by task affinity, and its evidence candidates +#: are paid ids. ``routing_profile="free"`` therefore routes on the rules +#: strategy over this NVIDIA-only tier table, and the adapter additionally +#: drops any candidate the catalog does not price at $0. +#: +#: Refreshed 2026-08-15 against the live catalog. NVIDIA has EOL'd (HTTP 410) +#: the free DeepSeek family — ``deepseek-v4-flash`` was the last to go on +#: 2026-08-12 — plus ``llama-4-maverick`` and the qwen3 SKUs, which is what the +#: previous table pointed at. ``gpt-oss-120b/20b`` stay out of the primaries: +#: they are hidden from ``/v1/models`` (so they carry no catalog price) over +#: the NVIDIA free tier's prompt-retention policy. +FREE_TIERS: dict[str, TierConfig] = { + "SIMPLE": { + "primary": "nvidia/step-3.7-flash", # 131K ctx, fast general chat + reasoning + "fallback": [ + "nvidia/nemotron-nano-9b-v2", + "nvidia/mistral-nemotron", + "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", + ], + }, + "MEDIUM": { + "primary": "nvidia/step-3.7-flash", + "fallback": [ + "nvidia/mistral-nemotron", + "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", + "nvidia/nemotron-nano-9b-v2", + ], + }, + "COMPLEX": { + # Largest free context (256K) and the only free vision model, so it also + # absorbs long or multi-modal requests. + "primary": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", + "fallback": [ + "nvidia/step-3.7-flash", + "nvidia/mistral-nemotron", + "nvidia/nemotron-nano-12b-v2-vl", + ], + }, + "REASONING": { + "primary": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", + "fallback": [ + "nvidia/step-3.7-flash", + "nvidia/nemotron-nano-9b-v2", + ], + }, +} + + +def routing_profile_for_model(model: str) -> str | None: + """Map a ``blockrun/auto``-style virtual model id to a routing profile.""" + return AUTO_ROUTING_PROFILES.get(model.lower()) + + +def _is_free(pricing: ModelPricing | None) -> bool: + if pricing is None: + return False + return ( + pricing.get("input_price", 0) == 0 + and pricing.get("output_price", 0) == 0 + and not pricing.get("flat_price") + ) + + +def _capacity(model_id: str) -> Capacity | None: + capabilities = DEFAULT_MODEL_CAPABILITIES.get(model_id) + if capabilities is None: + return None + return { + "context_window": capabilities["context_window"], + "max_output": capabilities["max_output_tokens"], + } + + +def _free_config(config: RoutingConfig) -> RoutingConfig: + """A rules-only config whose every profile lands on the free tier table.""" + free_config: RoutingConfig = dict(config) # type: ignore[assignment] + free_config["strategy"] = "rules" + free_config["tiers"] = FREE_TIERS + free_config["eco_tiers"] = FREE_TIERS + free_config["premium_tiers"] = FREE_TIERS + free_config["agentic_tiers"] = FREE_TIERS + # Promotions promote paid models; they must never leak into the free tier. + free_config["promotions"] = [] + return free_config + + +def routing_text(messages: Sequence[Mapping[str, Any]]) -> dict[str, Any]: + """Extract the routing view of a chat transcript. + + Returns ``prompt`` (last user text), ``system_prompt``, ``conversation_chars`` + (the FULL transcript size — capacity checks must see the whole conversation, + not just the last user message) and ``has_vision``. + """ + system_parts = [ + message["content"] + for message in messages + if message.get("role") == "system" and isinstance(message.get("content"), str) + ] + system_prompt = "\n".join(system_parts) or None + + last_user = next( + ( + message["content"] + for message in reversed(list(messages)) + if message.get("role") == "user" and isinstance(message.get("content"), str) + ), + None, + ) + last_text = next( + ( + message["content"] + for message in reversed(list(messages)) + if isinstance(message.get("content"), str) + ), + None, + ) + + conversation_chars = 0 + has_vision = False + for message in messages: + content = message.get("content") + if isinstance(content, str): + conversation_chars += len(content) + elif isinstance(content, list): + for part in content: + if not isinstance(part, Mapping): + continue + if part.get("type") in ("image_url", "image"): + has_vision = True + text = part.get("text") + if isinstance(text, str): + conversation_chars += len(text) + + return { + "prompt": last_user if last_user is not None else (last_text or ""), + "system_prompt": system_prompt, + "conversation_chars": conversation_chars, + "has_vision": has_vision, + } + + +def route_with_catalog( + prompt: str, + system_prompt: str | None, + max_output_tokens: int, + model_pricing: Mapping[str, ModelPricing], + *, + routing_profile: str | None = None, + requires_structured_output: bool = False, + tools: Sequence[Mapping[str, Any]] | None = None, + tool_choice: Any = None, + minimum_payment_usd: float = SOLANA_MINIMUM_PAYMENT_USD, + conversation_chars: int | None = None, + has_vision: bool = False, + config: RoutingConfig | None = None, + now: Any = None, +) -> ResolvedRoutingDecision: + """Route a request and resolve the ranking against the live catalog. + + ``routing_profile`` accepts Router Core's ``"eco" | "auto" | "premium"`` + plus the SDK-only ``"free"``. + """ + tool_list = list(tools or []) + if tool_choice == "none": + requires_tools: bool | None = False + elif tool_choice == "required" or isinstance(tool_choice, Mapping): + requires_tools = True + else: + requires_tools = None + + is_free_profile = routing_profile == "free" + active_config = config or DEFAULT_ROUTING_CONFIG + if is_free_profile: + active_config = _free_config(active_config) + core_profile = None if is_free_profile else routing_profile + + options: RouterOptions = { + "config": active_config, + "model_pricing": model_pricing, + "routing_profile": core_profile, # type: ignore[typeddict-item] + "has_tools": len(tool_list) > 0, + "tool_count": len(tool_list), + "tool_names": [ + tool.get("function", {}).get("name", "") + for tool in tool_list + if isinstance(tool.get("function"), Mapping) + ], + "has_vision": has_vision, + "requires_structured_output": requires_structured_output, + } + if requires_tools is not None: + options["requires_tools"] = requires_tools + if now is not None: + options["now"] = now + + decision = route(prompt, system_prompt, max_output_tokens, options) + + # Turn the ranking into a gateway-callable list. The ranking is trusted + # as-is — including ids withheld from /v1/models (e.g. moonshot/kimi-k2.7), + # which the gateway serves by direct id — with one exception: the router + # names its free tier `free/`, a namespace resolved by ClawRouter's + # proxy. The gateway's ids are `nvidia/`, and an unmapped `free/*` id + # draws a hard 400 (non-transient, so the fallback chain would never + # engage). Map `free/*` to its catalog-listed `nvidia/*` id and drop it when + # there is none (the proxy-only gpt-oss pair). + tier_configs = decision.get("tier_configs") or active_config["tiers"] + ranked = decision.get("candidates") or [ + decision["model"], + *get_fallback_chain(decision["tier"], tier_configs), + ] + callable_models: list[str] = [] + for model_id in ranked: + if not model_id.startswith("free/"): + resolved: str | None = model_id + else: + nvidia_id = f"nvidia/{model_id[5:]}" + resolved = nvidia_id if nvidia_id in model_pricing else None + if resolved and resolved not in callable_models: + callable_models.append(resolved) + + if is_free_profile: + # Belt and braces: the free profile must never emit a billable model, + # even if a host config or promotion smuggles one into the tier table. + free_only = [ + model_id for model_id in callable_models if _is_free(model_pricing.get(model_id)) + ] + if free_only: + callable_models = free_only + + # Capacity check against the FULL conversation, not just the routing prompt + # — an agent transcript can be 100x the last user message, and a context + # overflow is a non-transient 400 the fallback chain won't save. Models + # unknown to the capability snapshot are kept (benefit of the doubt). + estimated_input_tokens = math.ceil( + max(conversation_chars or 0, len(f"{system_prompt or ''} {prompt}")) / 4 + ) + fitting = filter_candidates_by_capacity( + callable_models, estimated_input_tokens, max_output_tokens, _capacity + ) + available_candidates = fitting if fitting else callable_models + + # If nothing survived (a chain of proxy-only free ids), call the router's + # pick as-is so the gateway's real error surfaces rather than an invented + # one here. + model = available_candidates[0] if available_candidates else decision["model"] + + costs = calculate_model_cost( + model, model_pricing, estimated_input_tokens, max_output_tokens, routing_profile + ) + # Free models settle at $0 (no payment is signed) — never floor them up to + # the paid minimum. Detected from the catalog pricing, because Router Core's + # calculate_model_cost applies its own internal floor even to $0 models. + entry = model_pricing.get(model) + is_free = _is_free(entry) if entry is not None else False + cost_estimate = 0.0 if is_free else max(costs["cost_estimate"], minimum_payment_usd) + baseline_cost = costs["baseline_cost"] + if routing_profile == "premium" or baseline_cost <= 0: + savings = 0.0 + elif entry is not None: + savings = max(0.0, (baseline_cost - cost_estimate) / baseline_cost) + else: + savings = decision["savings"] + + resolved_decision: ResolvedRoutingDecision = dict(decision) # type: ignore[assignment] + resolved_decision["baseline_cost"] = baseline_cost + resolved_decision["cost_estimate"] = cost_estimate + resolved_decision["savings"] = savings + resolved_decision["model"] = model + if model != decision["model"]: + resolved_decision["reasoning"] = f"{decision['reasoning']} | catalog fallback: {model}" + resolved_decision["candidates"] = available_candidates + if "candidate_scores" in decision: + resolved_decision["candidate_scores"] = [ + score + for score in decision["candidate_scores"] + if score["model"] in available_candidates + ] + # `fallbacks` is the SDK's runtime retry chain: every remaining candidate in + # ranked order, which chat() walks on a transient upstream failure. + resolved_decision["fallbacks"] = available_candidates[1:] + return resolved_decision diff --git a/blockrun_llm/router_core/__init__.py b/blockrun_llm/router_core/__init__.py new file mode 100644 index 0000000..69505cc --- /dev/null +++ b/blockrun_llm/router_core/__init__.py @@ -0,0 +1,114 @@ +""" +Router Core — deterministic, constraint-first model routing. + +Python port of `@blockrun/router-core `_ +(upstream commit ``18bf4ab``), the same routing engine the TypeScript SDK and +the BlockRun gateway use. The package is deliberately product-neutral: task +classification, hard capability filtering, portfolio scoring, ordered +fallbacks, and routing configuration. It contains no wallet, gateway client, +proxy server, agent loop, payment handling or telemetry transport — the SDK +supplies those through :mod:`blockrun_llm.router_adapter`. + +Hosts provide request capabilities and current model pricing, and may override +model capability and performance observations without adding a network call on +the routing hot path. + +Usage:: + + from blockrun_llm.router_core import DEFAULT_ROUTING_CONFIG, route + + decision = route(prompt, system_prompt, max_output_tokens, { + "config": DEFAULT_ROUTING_CONFIG, + "model_pricing": pricing, + "has_tools": True, + "requires_tools": True, + }) + +Routing is local and deterministic for identical inputs, configuration, model +metadata, and time. +""" + +from __future__ import annotations + +from .config import DEFAULT_ROUTING_CONFIG +from .model_capabilities import DEFAULT_MODEL_CAPABILITIES +from .model_profiles import HISTORICAL_MODEL_PROFILES, LIVE_MODEL_PROFILES +from .portfolio import PortfolioStrategy, classify_task +from .rules import classify_by_rules +from .selector import ( + calculate_model_cost, + filter_by_exclude_list, + filter_by_tool_calling, + filter_by_vision, + filter_candidates_by_capacity, + get_fallback_chain, + get_fallback_chain_filtered, +) +from .strategy import RouterStrategy, RulesStrategy, get_strategy, register_strategy +from .tool_intent import infer_tool_requirement +from .types import ( + Capacity, + ModelCapabilities, + ModelPerformanceProfile, + ModelPricing, + RouterOptions, + RoutingConfig, + RoutingDecision, + RoutingProfile, + TaskType, + Tier, + TierConfig, +) + +# Registered here instead of in strategy.py so PortfolioStrategy can reuse the +# stable RulesStrategy without introducing a module cycle. +register_strategy(PortfolioStrategy()) + + +def route( + prompt: str, + system_prompt: str | None, + max_output_tokens: int, + options: RouterOptions, +) -> RoutingDecision: + """Route a request to the cheapest capable model. + + Delegates to the configured strategy (``PortfolioStrategy`` by default). + """ + strategy = get_strategy(options["config"].get("strategy") or "portfolio") + return strategy.route(prompt, system_prompt, max_output_tokens, options) + + +__all__ = [ + "DEFAULT_MODEL_CAPABILITIES", + "DEFAULT_ROUTING_CONFIG", + "HISTORICAL_MODEL_PROFILES", + "LIVE_MODEL_PROFILES", + "Capacity", + "ModelCapabilities", + "ModelPerformanceProfile", + "ModelPricing", + "PortfolioStrategy", + "RouterOptions", + "RouterStrategy", + "RoutingConfig", + "RoutingDecision", + "RoutingProfile", + "RulesStrategy", + "TaskType", + "Tier", + "TierConfig", + "calculate_model_cost", + "classify_by_rules", + "classify_task", + "filter_by_exclude_list", + "filter_by_tool_calling", + "filter_by_vision", + "filter_candidates_by_capacity", + "get_fallback_chain", + "get_fallback_chain_filtered", + "get_strategy", + "infer_tool_requirement", + "register_strategy", + "route", +] diff --git a/blockrun_llm/router_core/_js.py b/blockrun_llm/router_core/_js.py new file mode 100644 index 0000000..7dc0bc7 --- /dev/null +++ b/blockrun_llm/router_core/_js.py @@ -0,0 +1,82 @@ +""" +Small JavaScript-semantics helpers used by the Router Core port. + +The router is a line-by-line port of ``@blockrun/router-core``. A handful of +JS behaviours differ from their obvious Python equivalents in ways that change +routing output, so they are isolated here instead of being approximated at +each call site: + +* ``Number.prototype.toFixed`` rounds half away from zero on the exact binary + value; Python's format spec rounds half to even. +* ``Date.parse`` accepts a bare ``YYYY-MM-DD`` (UTC midnight) and a trailing + ``Z``; ``datetime.fromisoformat`` before 3.11 accepts neither combination. +* Template literals stringify booleans as ``true`` / ``false``, and the + reasoning strings the router emits are asserted on by hosts and tests. + +Ported regexes are compiled with ``re.ASCII`` so ``\\b``, ``\\w``, ``\\d`` and +``\\s`` keep JavaScript's ASCII-only meaning. Without it a pattern like +``\\b(?:urgent|fast)\\b`` silently stops matching inside CJK text, because +Python treats the surrounding Han characters as word characters while +JavaScript does not. +""" + +from __future__ import annotations + +import math +import re +from datetime import datetime, timezone +from decimal import ROUND_HALF_DOWN, ROUND_HALF_UP, Decimal + +#: Flag set applied to every ported regex (see module docstring). +JS_FLAGS = re.ASCII +JS_FLAGS_I = re.ASCII | re.IGNORECASE + + +def js_regex(pattern: str, *, ignorecase: bool = False, multiline: bool = False) -> re.Pattern[str]: + """Compile ``pattern`` with JavaScript-compatible flag semantics.""" + flags = JS_FLAGS_I if ignorecase else JS_FLAGS + if multiline: + flags |= re.MULTILINE + return re.compile(pattern, flags) + + +def to_fixed(value: float, digits: int) -> str: + """Port of ``Number.prototype.toFixed`` (round half away from zero).""" + if not math.isfinite(value): # NaN / Infinity, which toFixed passes through + return str(value) + quantum = Decimal(1).scaleb(-digits) + # toFixed resolves a tie to the larger integer, which is away from zero for + # positives and toward zero for negatives. + rounding = ROUND_HALF_UP if value >= 0 else ROUND_HALF_DOWN + return str(Decimal(value).quantize(quantum, rounding=rounding)) + + +def js_bool(value: bool) -> str: + """Port of JS template-literal boolean stringification.""" + return "true" if value else "false" + + +def parse_date(value: str) -> datetime | None: + """Port of ``Date.parse`` for the ISO forms the router config uses. + + Returns an aware UTC datetime, or ``None`` when the value is unparseable + (``Date.parse`` yields ``NaN``, which the portfolio scorer treats as "no + observation" rather than propagating a NaN score). + """ + text = value.strip() + if not text: + return None + if text.endswith(("Z", "z")): + text = f"{text[:-1]}+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=timezone.utc) + + +def as_utc(value: object | None) -> datetime: + """Normalize a caller-supplied ``now`` to an aware UTC datetime.""" + if isinstance(value, datetime): + return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc) + return datetime.now(timezone.utc) diff --git a/blockrun_llm/router_core/config.py b/blockrun_llm/router_core/config.py new file mode 100644 index 0000000..2e27588 --- /dev/null +++ b/blockrun_llm/router_core/config.py @@ -0,0 +1,1311 @@ +""" +Default Routing Config + +Python port of ``@blockrun/router-core`` ``config.ts`` (upstream commit +``18bf4ab``, 2026-08-12 — one commit ahead of the pin the TypeScript SDK +bundles, which predates the deepseek-v4-flash NVIDIA EOL). + +All routing parameters as a module constant. Hosts override by passing their +own ``RoutingConfig`` in ``RouterOptions["config"]``. + +Scoring uses 15 weighted dimensions with sigmoid confidence calibration. +Keys are snake_case; ``dimension_weights`` keys stay camelCase because they +are dimension *names* emitted by the classifier, not config fields. +""" + +from __future__ import annotations + +from .types import RoutingConfig + +DEFAULT_ROUTING_CONFIG: RoutingConfig = { + "version": "3.4", + "strategy": "portfolio", + "portfolio": { + "auto": { + "quality": 0.47, + "capability": 0.2, + "cost": 0.18, + "speed": 0.07, + "reliability": 0.03, + "legacy": 0.05, + }, + "eco": { + "quality": 0.36, + "capability": 0.2, + "cost": 0.28, + "speed": 0.1, + "reliability": 0.04, + "legacy": 0.02, + }, + "premium": { + "quality": 0.58, + "capability": 0.2, + "cost": 0.08, + "speed": 0.06, + "reliability": 0.06, + "legacy": 0.02, + }, + "high_stakes_boost": {"quality": 0.08, "reliability": 0.05}, + "latency_sensitive_speed_boost": 0.08, + "affinity_floor_gap": {"auto": 0.1, "eco": 0.22, "premium": 0.05}, + }, + "classifier": { + "llm_model": "google/gemini-2.5-flash", + "llm_max_tokens": 10, + "llm_temperature": 0, + "prompt_truncation_chars": 500, + "cache_ttl_ms": 3_600_000, # 1 hour + }, + "scoring": { + "token_count_thresholds": {"simple": 50, "complex": 500}, + # Multilingual keywords: EN + ZH + JA + RU + DE + ES + PT + KO + AR + "code_keywords": [ + # English + "function", + "class", + "import", + "def", + "SELECT", + "async", + "await", + "const", + "let", + "var", + "return", + "```", + # Chinese + "函数", + "类", + "导入", + "定义", + "查询", + "异步", + "等待", + "常量", + "变量", + "返回", + # Japanese + "関数", + "クラス", + "インポート", + "非同期", + "定数", + "変数", + # Russian + "функция", + "класс", + "импорт", + "определ", + "запрос", + "асинхронный", + "ожидать", + "константа", + "переменная", + "вернуть", + # German + "funktion", + "klasse", + "importieren", + "definieren", + "abfrage", + "asynchron", + "erwarten", + "konstante", + "variable", + "zurückgeben", + # Spanish + "función", + "clase", + "importar", + "definir", + "consulta", + "asíncrono", + "esperar", + "constante", + "variable", + "retornar", + # Portuguese + "função", + "classe", + "importar", + "definir", + "consulta", + "assíncrono", + "aguardar", + "constante", + "variável", + "retornar", + # Korean + "함수", + "클래스", + "가져오기", + "정의", + "쿼리", + "비동기", + "대기", + "상수", + "변수", + "반환", + # Arabic + "دالة", + "فئة", + "استيراد", + "تعريف", + "استعلام", + "غير متزامن", + "انتظار", + "ثابت", + "متغير", + "إرجاع", + ], + "reasoning_keywords": [ + # English + "prove", + "theorem", + "derive", + "step by step", + "chain of thought", + "formally", + "mathematical", + "proof", + "logically", + # Chinese + "证明", + "定理", + "推导", + "逐步", + "思维链", + "形式化", + "数学", + "逻辑", + # Japanese + "証明", + "定理", + "導出", + "ステップバイステップ", + "論理的", + # Russian + "доказать", + "докажи", + "доказательств", + "теорема", + "вывести", + "шаг за шагом", + "пошагово", + "поэтапно", + "цепочка рассуждений", + "рассуждени", + "формально", + "математически", + "логически", + # German + "beweisen", + "beweis", + "theorem", + "ableiten", + "schritt für schritt", + "gedankenkette", + "formal", + "mathematisch", + "logisch", + # Spanish + "demostrar", + "teorema", + "derivar", + "paso a paso", + "cadena de pensamiento", + "formalmente", + "matemático", + "prueba", + "lógicamente", + # Portuguese + "provar", + "teorema", + "derivar", + "passo a passo", + "cadeia de pensamento", + "formalmente", + "matemático", + "prova", + "logicamente", + # Korean + "증명", + "정리", + "도출", + "단계별", + "사고의 연쇄", + "형식적", + "수학적", + "논리적", + # Arabic + "إثبات", + "نظرية", + "اشتقاق", + "خطوة بخطوة", + "سلسلة التفكير", + "رسمياً", + "رياضي", + "برهان", + "منطقياً", + ], + "simple_keywords": [ + # English + "what is", + "define", + "translate", + "hello", + "yes or no", + "capital of", + "how old", + "who is", + "when was", + # Chinese + "什么是", + "定义", + "翻译", + "你好", + "是否", + "首都", + "多大", + "谁是", + "何时", + # Japanese + "とは", + "定義", + "翻訳", + "こんにちは", + "はいかいいえ", + "首都", + "誰", + # Russian + "что такое", + "определение", + "перевести", + "переведи", + "привет", + "да или нет", + "столица", + "сколько лет", + "кто такой", + "когда", + "объясни", + # German + "was ist", + "definiere", + "übersetze", + "hallo", + "ja oder nein", + "hauptstadt", + "wie alt", + "wer ist", + "wann", + "erkläre", + # Spanish + "qué es", + "definir", + "traducir", + "hola", + "sí o no", + "capital de", + "cuántos años", + "quién es", + "cuándo", + # Portuguese + "o que é", + "definir", + "traduzir", + "olá", + "sim ou não", + "capital de", + "quantos anos", + "quem é", + "quando", + # Korean + "무엇", + "정의", + "번역", + "안녕하세요", + "예 또는 아니오", + "수도", + "누구", + "언제", + # Arabic + "ما هو", + "تعريف", + "ترجم", + "مرحبا", + "نعم أو لا", + "عاصمة", + "من هو", + "متى", + ], + "technical_keywords": [ + # English + "algorithm", + "optimize", + "architecture", + "distributed", + "kubernetes", + "microservice", + "database", + "infrastructure", + # Chinese + "算法", + "优化", + "架构", + "分布式", + "微服务", + "数据库", + "基础设施", + # Japanese + "アルゴリズム", + "最適化", + "アーキテクチャ", + "分散", + "マイクロサービス", + "データベース", + # Russian + "алгоритм", + "оптимизировать", + "оптимизаци", + "оптимизируй", + "архитектура", + "распределённый", + "микросервис", + "база данных", + "инфраструктура", + # German + "algorithmus", + "optimieren", + "architektur", + "verteilt", + "kubernetes", + "mikroservice", + "datenbank", + "infrastruktur", + # Spanish + "algoritmo", + "optimizar", + "arquitectura", + "distribuido", + "microservicio", + "base de datos", + "infraestructura", + # Portuguese + "algoritmo", + "otimizar", + "arquitetura", + "distribuído", + "microsserviço", + "banco de dados", + "infraestrutura", + # Korean + "알고리즘", + "최적화", + "아키텍처", + "분산", + "마이크로서비스", + "데이터베이스", + "인프라", + # Arabic + "خوارزمية", + "تحسين", + "بنية", + "موزع", + "خدمة مصغرة", + "قاعدة بيانات", + "بنية تحتية", + ], + "creative_keywords": [ + # English + "story", + "poem", + "compose", + "brainstorm", + "creative", + "imagine", + "write a", + # Chinese + "故事", + "诗", + "创作", + "头脑风暴", + "创意", + "想象", + "写一个", + # Japanese + "物語", + "詩", + "作曲", + "ブレインストーム", + "創造的", + "想像", + # Russian + "история", + "рассказ", + "стихотворение", + "сочинить", + "сочини", + "мозговой штурм", + "творческий", + "представить", + "придумай", + "напиши", + # German + "geschichte", + "gedicht", + "komponieren", + "brainstorming", + "kreativ", + "vorstellen", + "schreibe", + "erzählung", + # Spanish + "historia", + "poema", + "componer", + "lluvia de ideas", + "creativo", + "imaginar", + "escribe", + # Portuguese + "história", + "poema", + "compor", + "criativo", + "imaginar", + "escreva", + # Korean + "이야기", + "시", + "작곡", + "브레인스토밍", + "창의적", + "상상", + "작성", + # Arabic + "قصة", + "قصيدة", + "تأليف", + "عصف ذهني", + "إبداعي", + "تخيل", + "اكتب", + ], + # New dimension keyword lists (multilingual) + "imperative_verbs": [ + # English + "build", + "create", + "implement", + "design", + "develop", + "construct", + "generate", + "deploy", + "configure", + "set up", + # Chinese + "构建", + "创建", + "实现", + "设计", + "开发", + "生成", + "部署", + "配置", + "设置", + # Japanese + "構築", + "作成", + "実装", + "設計", + "開発", + "生成", + "デプロイ", + "設定", + # Russian + "построить", + "построй", + "создать", + "создай", + "реализовать", + "реализуй", + "спроектировать", + "разработать", + "разработай", + "сконструировать", + "сгенерировать", + "сгенерируй", + "развернуть", + "разверни", + "настроить", + "настрой", + # German + "erstellen", + "bauen", + "implementieren", + "entwerfen", + "entwickeln", + "konstruieren", + "generieren", + "bereitstellen", + "konfigurieren", + "einrichten", + # Spanish + "construir", + "crear", + "implementar", + "diseñar", + "desarrollar", + "generar", + "desplegar", + "configurar", + # Portuguese + "construir", + "criar", + "implementar", + "projetar", + "desenvolver", + "gerar", + "implantar", + "configurar", + # Korean + "구축", + "생성", + "구현", + "설계", + "개발", + "배포", + "설정", + # Arabic + "بناء", + "إنشاء", + "تنفيذ", + "تصميم", + "تطوير", + "توليد", + "نشر", + "إعداد", + ], + "constraint_indicators": [ + # English + "under", + "at most", + "at least", + "within", + "no more than", + "o(", + "maximum", + "minimum", + "limit", + "budget", + # Chinese + "不超过", + "至少", + "最多", + "在内", + "最大", + "最小", + "限制", + "预算", + # Japanese + "以下", + "最大", + "最小", + "制限", + "予算", + # Russian + "не более", + "не менее", + "как минимум", + "в пределах", + "максимум", + "минимум", + "ограничение", + "бюджет", + # German + "höchstens", + "mindestens", + "innerhalb", + "nicht mehr als", + "maximal", + "minimal", + "grenze", + "budget", + # Spanish + "como máximo", + "al menos", + "dentro de", + "no más de", + "máximo", + "mínimo", + "límite", + "presupuesto", + # Portuguese + "no máximo", + "pelo menos", + "dentro de", + "não mais que", + "máximo", + "mínimo", + "limite", + "orçamento", + # Korean + "이하", + "이상", + "최대", + "최소", + "제한", + "예산", + # Arabic + "على الأكثر", + "على الأقل", + "ضمن", + "لا يزيد عن", + "أقصى", + "أدنى", + "حد", + "ميزانية", + ], + "output_format_keywords": [ + # English + "json", + "yaml", + "xml", + "table", + "csv", + "markdown", + "schema", + "format as", + "structured", + # Chinese + "表格", + "格式化为", + "结构化", + # Japanese + "テーブル", + "フォーマット", + "構造化", + # Russian + "таблица", + "форматировать как", + "структурированный", + # German + "tabelle", + "formatieren als", + "strukturiert", + # Spanish + "tabla", + "formatear como", + "estructurado", + # Portuguese + "tabela", + "formatar como", + "estruturado", + # Korean + "테이블", + "형식", + "구조화", + # Arabic + "جدول", + "تنسيق", + "منظم", + ], + "reference_keywords": [ + # English + "above", + "below", + "previous", + "following", + "the docs", + "the api", + "the code", + "earlier", + "attached", + # Chinese + "上面", + "下面", + "之前", + "接下来", + "文档", + "代码", + "附件", + # Japanese + "上記", + "下記", + "前の", + "次の", + "ドキュメント", + "コード", + # Russian + "выше", + "ниже", + "предыдущий", + "следующий", + "документация", + "код", + "ранее", + "вложение", + # German + "oben", + "unten", + "vorherige", + "folgende", + "dokumentation", + "der code", + "früher", + "anhang", + # Spanish + "arriba", + "abajo", + "anterior", + "siguiente", + "documentación", + "el código", + "adjunto", + # Portuguese + "acima", + "abaixo", + "anterior", + "seguinte", + "documentação", + "o código", + "anexo", + # Korean + "위", + "아래", + "이전", + "다음", + "문서", + "코드", + "첨부", + # Arabic + "أعلاه", + "أدناه", + "السابق", + "التالي", + "الوثائق", + "الكود", + "مرفق", + ], + "negation_keywords": [ + # English + "don't", + "do not", + "avoid", + "never", + "without", + "except", + "exclude", + "no longer", + # Chinese + "不要", + "避免", + "从不", + "没有", + "除了", + "排除", + # Japanese + "しないで", + "避ける", + "決して", + "なしで", + "除く", + # Russian + "не делай", + "не надо", + "нельзя", + "избегать", + "никогда", + "без", + "кроме", + "исключить", + "больше не", + # German + "nicht", + "vermeide", + "niemals", + "ohne", + "außer", + "ausschließen", + "nicht mehr", + # Spanish + "no hagas", + "evitar", + "nunca", + "sin", + "excepto", + "excluir", + # Portuguese + "não faça", + "evitar", + "nunca", + "sem", + "exceto", + "excluir", + # Korean + "하지 마", + "피하다", + "절대", + "없이", + "제외", + # Arabic + "لا تفعل", + "تجنب", + "أبداً", + "بدون", + "باستثناء", + "استبعاد", + ], + "domain_specific_keywords": [ + # English + "quantum", + "fpga", + "vlsi", + "risc-v", + "asic", + "photonics", + "genomics", + "proteomics", + "topological", + "homomorphic", + "zero-knowledge", + "lattice-based", + # Chinese + "量子", + "光子学", + "基因组学", + "蛋白质组学", + "拓扑", + "同态", + "零知识", + "格密码", + # Japanese + "量子", + "フォトニクス", + "ゲノミクス", + "トポロジカル", + # Russian + "квантовый", + "фотоника", + "геномика", + "протеомика", + "топологический", + "гомоморфный", + "с нулевым разглашением", + "на основе решёток", + # German + "quanten", + "photonik", + "genomik", + "proteomik", + "topologisch", + "homomorph", + "zero-knowledge", + "gitterbasiert", + # Spanish + "cuántico", + "fotónica", + "genómica", + "proteómica", + "topológico", + "homomórfico", + # Portuguese + "quântico", + "fotônica", + "genômica", + "proteômica", + "topológico", + "homomórfico", + # Korean + "양자", + "포토닉스", + "유전체학", + "위상", + "동형", + # Arabic + "كمي", + "ضوئيات", + "جينوميات", + "طوبولوجي", + "تماثلي", + ], + # Agentic task keywords - file ops, execution, multi-step, iterative work + # Pruned: removed overly common words like "then", "first", "run", "test", "build" + "agentic_task_keywords": [ + # English - File operations (clearly agentic) + "read file", + "read the file", + "look at", + "check the", + "open the", + "edit", + "modify", + "update the", + "change the", + "write to", + "create file", + # English - Execution (specific commands only) + "execute", + "deploy", + "install", + "npm", + "pip", + "compile", + # English - Multi-step patterns (specific only) + "after that", + "and also", + "once done", + "step 1", + "step 2", + # English - Iterative work + "fix", + "debug", + "until it works", + "keep trying", + "iterate", + "make sure", + "verify", + "confirm", + # Chinese (keep specific ones) + "读取文件", + "查看", + "打开", + "编辑", + "修改", + "更新", + "创建", + "执行", + "部署", + "安装", + "第一步", + "第二步", + "修复", + "调试", + "直到", + "确认", + "验证", + # Spanish + "leer archivo", + "editar", + "modificar", + "actualizar", + "ejecutar", + "desplegar", + "instalar", + "paso 1", + "paso 2", + "arreglar", + "depurar", + "verificar", + # Portuguese + "ler arquivo", + "editar", + "modificar", + "atualizar", + "executar", + "implantar", + "instalar", + "passo 1", + "passo 2", + "corrigir", + "depurar", + "verificar", + # Korean + "파일 읽기", + "편집", + "수정", + "업데이트", + "실행", + "배포", + "설치", + "단계 1", + "단계 2", + "디버그", + "확인", + # Arabic + "قراءة ملف", + "تحرير", + "تعديل", + "تحديث", + "تنفيذ", + "نشر", + "تثبيت", + "الخطوة 1", + "الخطوة 2", + "إصلاح", + "تصحيح", + "تحقق", + ], + # Dimension weights (sum to 1.0) + "dimension_weights": { + "tokenCount": 0.08, + "codePresence": 0.15, + "reasoningMarkers": 0.18, + "technicalTerms": 0.1, + "creativeMarkers": 0.05, + "simpleIndicators": 0.02, # Reduced from 0.12 to make room for agenticTask + "multiStepPatterns": 0.12, + "questionComplexity": 0.05, + "imperative_verbs": 0.03, + "constraintCount": 0.04, + "outputFormat": 0.03, + "referenceComplexity": 0.02, + "negationComplexity": 0.01, + "domainSpecificity": 0.02, + "agenticTask": 0.04, # Reduced - agentic signals influence tier selection, not dominate it + }, + # Tier boundaries on weighted score axis + "tier_boundaries": { + "simple_medium": 0.0, + "medium_complex": 0.3, # Raised from 0.18 - prevent simple tasks from reaching expensive COMPLEX tier + "complex_reasoning": 0.5, # Raised from 0.4 - reserve for true reasoning tasks + }, + # Sigmoid steepness for confidence calibration + "confidence_steepness": 12, + # Below this confidence → ambiguous (null tier) + "confidence_threshold": 0.7, + }, + # Auto (balanced) tier configs - current default smart routing + # Benchmark-tuned 2026-03-16: balancing quality (retention) + latency + "tiers": { + "SIMPLE": { + "primary": "google/gemini-2.5-flash", # 1,238ms, IQ 20, 60% retention (best) — fast AND quality + "fallback": [ + "google/gemini-3-flash-preview", # 1,398ms, IQ 46 — smarter fallback + "deepseek/deepseek-chat", # V4 Flash chat ($0.20/$0.40, 1M ctx) — repriced 2026-04-24 + "moonshot/kimi-k2.5", # 1,646ms, IQ 47, strong quality + "google/gemini-3.1-flash-lite", # $0.25/$1.50, 1M context — newest flash-lite + "google/gemini-2.5-flash-lite", # 1,353ms, $0.10/$0.40 + "openai/gpt-5.4-nano", # $0.20/$1.25, 1M context + "xai/grok-4-fast-non-reasoning", # 1,143ms, $0.20/$0.50 — fast fallback + "free/gpt-oss-120b", # 1,252ms, FREE fallback (hidden from /v1/models but direct calls work) + ], + }, + "MEDIUM": { + "primary": "moonshot/kimi-k2.7", # $0.95/$4.00, 256K ctx, multi-modal + reasoning — Moonshot flagship; promoted from K2.6 (2026-06-14) after BlockRun added K2.7 + hid K2.6. Same price as K2.6. + "fallback": [ + "moonshot/kimi-k2.6", # identical-cost in-family hot swap (K2.6 still routable) + "moonshot/kimi-k2.5", # $0.60/$3.00 — graceful-degradation backstop + "google/gemini-3-flash-preview", # 1,398ms, IQ 46 — nearly same IQ, faster + cheaper + "deepseek/deepseek-chat", # 1,431ms, IQ 32, 41% retention + "google/gemini-2.5-flash", # 1,238ms, 60% retention + "google/gemini-3.1-flash-lite", # $0.25/$1.50, 1M context + "google/gemini-2.5-flash-lite", # 1,353ms, $0.10/$0.40 + "xai/grok-4-1-fast-non-reasoning", # 1,244ms, fast fallback + "xai/grok-3-mini", # 1,202ms, $0.30/$0.50 + ], + }, + "COMPLEX": { + "primary": "google/gemini-3.1-pro", # 1,609ms, IQ 57 — fast flagship quality + "fallback": [ + "google/gemini-3-flash-preview", # 1,398ms, IQ 46 — fast + smart + "xai/grok-4-0709", # 1,348ms, IQ 41 + "google/gemini-2.5-pro", # 1,294ms + "anthropic/claude-sonnet-5", # near-Opus quality at Sonnet cost, 1M ctx + "anthropic/claude-sonnet-4.6", # 2,110ms, IQ 52 — quality fallback + "deepseek/deepseek-chat", # 1,431ms, IQ 32 + "google/gemini-2.5-flash", # 1,238ms, IQ 20 — cheap last resort + "openai/gpt-5.6-terra", # GPT-5.6 balanced tier — newest generation, stable (Sol excluded: #202) + "openai/gpt-5.5", # Prior OpenAI flagship — 1M+ ctx, native agent + computer use; benchmark TBD + "openai/gpt-5.4", # 6,213ms, IQ 57 — previous flagship, benchmarked + ], + }, + "REASONING": { + "primary": "xai/grok-4-1-fast-reasoning", # 1,454ms, $0.20/$0.50 + "fallback": [ + "xai/grok-4-fast-reasoning", # 1,298ms, $0.20/$0.50 + "deepseek/deepseek-reasoner", # V4 Flash thinking ($0.20/$0.40, 1M ctx) + "deepseek/deepseek-v4-pro", # V4 Pro flagship ($0.50/$1.00 promo through 2026-05-31, list $2/$4) — strongest open-weight reasoner + "openai/o4-mini", # 2,328ms ($1.10/$4.40) + "openai/o3", # 2,862ms + ], + }, + }, + # Eco tier configs - absolute cheapest (blockrun/eco) + "eco_tiers": { + "SIMPLE": { + "primary": "free/gpt-oss-120b", # FREE! $0.00/$0.00 — heavy user default + "fallback": [ + "free/gpt-oss-20b", # FREE — smaller, faster + # deepseek-v4-flash and seed-oss-36b sat here until NVIDIA EOL'd them + # (410; 2026-08-12 and 2026-08-03 respectively). gpt-oss-120b/20b already + # head this chain, so the rungs are dropped, not retargeted. + "google/gemini-3.1-flash-lite", # $0.25/$1.50 — newest flash-lite + "openai/gpt-5.4-nano", # $0.20/$1.25 — fast nano + "google/gemini-2.5-flash-lite", # $0.10/$0.40 + "xai/grok-4-fast-non-reasoning", # $0.20/$0.50 + ], + }, + "MEDIUM": { + "primary": "google/gemini-3.1-flash-lite", # $0.25/$1.50 — newest flash-lite + "fallback": [ + "openai/gpt-5.4-nano", # $0.20/$1.25 + "google/gemini-2.5-flash-lite", # $0.10/$0.40 + "xai/grok-4-fast-non-reasoning", + "google/gemini-2.5-flash", + ], + }, + "COMPLEX": { + "primary": "google/gemini-3.1-flash-lite", # $0.25/$1.50 + "fallback": [ + "google/gemini-2.5-flash-lite", + "xai/grok-4-0709", + "google/gemini-2.5-flash", + "deepseek/deepseek-chat", + ], + }, + "REASONING": { + "primary": "xai/grok-4-1-fast-reasoning", # $0.20/$0.50 + "fallback": [ + "xai/grok-4-fast-reasoning", + "deepseek/deepseek-reasoner", # V4 Flash thinking — $0.20/$0.40 + "deepseek/deepseek-v4-pro", # V4 Pro flagship — $0.50/$1.00 promo, post-promo $2/$4 + ], + }, + }, + # Premium tier configs - best quality (blockrun/premium) + # codex=complex coding, kimi=simple coding, sonnet=reasoning/instructions, opus=architecture/PM/audits + "premium_tiers": { + "SIMPLE": { + "primary": "moonshot/kimi-k2.7", # $0.95/$4.00 - Moonshot flagship (256K ctx, multi-modal + reasoning); promoted from K2.6 (2026-06-14), same price + "fallback": [ + "moonshot/kimi-k2.6", # identical-cost in-family hot swap (K2.6 still routable) + "moonshot/kimi-k2.5", # $0.60/$3.00 - proven reliable backstop when Moonshot direct API falters + "google/gemini-2.5-flash", # 60% retention, fast growth + "anthropic/claude-haiku-4.5", + "google/gemini-2.5-flash-lite", + "deepseek/deepseek-chat", + ], + }, + "MEDIUM": { + "primary": "openai/gpt-5.3-codex", # $1.75/$14 - 400K context, 128K output, replaces 5.2 + "fallback": [ + "moonshot/kimi-k2.7", # Moonshot flagship + "moonshot/kimi-k2.6", + "moonshot/kimi-k2.5", + "google/gemini-2.5-flash", # 60% retention, good coding capability + "google/gemini-2.5-pro", + "xai/grok-4-0709", + "anthropic/claude-sonnet-5", + "anthropic/claude-sonnet-4.6", + ], + }, + "COMPLEX": { + # fable-5 was promoted here 2026-06-11, force-reverted 2026-06-13 when Anthropic + # withdrew the offer, and restored 2026-07-14 now that BlockRun has relisted it. + "primary": "anthropic/claude-fable-5", # Best quality for complex tasks — Mythos-class flagship above Opus ($10/$50, 1M ctx, always-on thinking) + # Fallback chain de-Gemini'd 2026-04-22: when Anthropic 503s, Gemini is + # also prone to "high demand" 503s (correlated failure — everyone falls + # back to Google at the same time). Prefer xAI Grok → Moonshot → OpenAI + # flagship → DeepSeek → NVIDIA free instead. + "fallback": [ + "anthropic/claude-opus-5", # in-family hot swap first (half the price, 1M ctx + adaptive thinking) + "anthropic/claude-opus-4.8", # in-family hot swap (identical cost to 5) + "anthropic/claude-opus-4.7", # in-family hot swap (identical cost to 4.8) + "anthropic/claude-opus-4.6", # in-family hot swap + "anthropic/claude-sonnet-5", # Sonnet-tier drop-down, near-Opus quality + "anthropic/claude-sonnet-4.6", + "xai/grok-4.5", # xAI flagship — 503-resistant, direct-xAI SKU (added 2026-07-14) + "xai/grok-4-0709", # 503-resistant flagship + "moonshot/kimi-k2.7", # Moonshot flagship, independent infra + "moonshot/kimi-k2.6", + "moonshot/kimi-k2.5", + "openai/gpt-5.6-terra", # GPT-5.6 balanced tier — newest generation, stable (Sol excluded: #202) + "openai/gpt-5.5", # Prior OpenAI flagship — 1M+ ctx, native agent + computer use + "openai/gpt-5.4", # Previous flagship (slow but stable, benchmarked at 6,213ms) + "openai/gpt-5.3-codex", + "deepseek/deepseek-chat", # Cheap, reliable + "free/gpt-oss-120b", # NVIDIA free ultimate backstop (was seed-oss-36b; EOL'd 2026-08-03) + ], + }, + "REASONING": { + "primary": "anthropic/claude-sonnet-4.6", # 2,110ms, $3/$15 - best for reasoning/instructions + "fallback": [ + "anthropic/claude-sonnet-5", # in-family hot swap — same cost, adaptive thinking, 1M ctx + "anthropic/claude-opus-5", # Newest flagship Opus w/ adaptive thinking + "anthropic/claude-opus-4.8", # Prior flagship Opus — identical cost to 5 + "anthropic/claude-opus-4.7", # Flagship Opus w/ adaptive thinking + "anthropic/claude-opus-4.6", # 2,139ms + "xai/grok-4-1-fast-reasoning", # 1,454ms, cheap fast reasoning + "openai/o4-mini", # 2,328ms ($1.10/$4.40) + "openai/o3", # 2,862ms + ], + }, + }, + # Agentic tier configs - models that excel at multi-step autonomous tasks + "agentic_tiers": { + "SIMPLE": { + "primary": "openai/gpt-4o-mini", # $0.15/$0.60 - best tool compliance at lowest cost + "fallback": [ + "moonshot/kimi-k2.5", # 1,646ms, strong tool use quality + "anthropic/claude-haiku-4.5", # 2,305ms + "xai/grok-4-1-fast-non-reasoning", # 1,244ms, fast fallback + ], + }, + "MEDIUM": { + "primary": "moonshot/kimi-k2.7", # $0.95/$4.00 — Moonshot flagship, strong tool use; promoted from K2.6 (2026-06-14) after BlockRun added K2.7 + hid K2.6. Same price. + "fallback": [ + "moonshot/kimi-k2.6", # identical-cost in-family hot swap (K2.6 still routable) + "moonshot/kimi-k2.5", # $0.60/$3.00 — graceful-degradation backstop + "xai/grok-4-1-fast-non-reasoning", # 1,244ms, fast fallback + "openai/gpt-4o-mini", # 2,764ms, reliable tool calling + "anthropic/claude-haiku-4.5", # 2,305ms + "deepseek/deepseek-chat", # 1,431ms + ], + }, + "COMPLEX": { + "primary": "anthropic/claude-sonnet-4.6", # 2,110ms — best agentic quality + # Fallback chain de-Gemini'd 2026-04-22: Gemini's "high demand" 503s + # correlate with Anthropic outages (everyone falls back together). + # Prefer 503-resistant providers first. + "fallback": [ + "anthropic/claude-sonnet-5", # in-family hot swap — same cost, near-Opus agentic quality + "anthropic/claude-opus-5", # Newest flagship Opus — in-family hot swap + "anthropic/claude-opus-4.8", # Prior flagship Opus — identical cost to 5 + "anthropic/claude-opus-4.7", # Flagship Opus — in-family hot swap + "anthropic/claude-opus-4.6", # 2,139ms + "xai/grok-4-0709", # 1,348ms — strong tool use, independent infra + "moonshot/kimi-k2.7", # Moonshot flagship — strong tool use, independent infra + "moonshot/kimi-k2.5", # cost-stability backstop + "openai/gpt-5.6-terra", # GPT-5.6 balanced tier — newest generation, stable (Sol excluded: #202) + "openai/gpt-5.5", # Prior flagship — native agent + computer use (exactly the agentic-tier use case) + "openai/gpt-5.4", # Previous flagship — 6,213ms, reliable + "deepseek/deepseek-chat", # 1,431ms — cheap, reliable + "free/gpt-oss-120b", # NVIDIA free ultimate backstop (was seed-oss-36b; EOL'd 2026-08-03) + ], + }, + "REASONING": { + "primary": "anthropic/claude-sonnet-4.6", # 2,110ms — strong tool use + reasoning + "fallback": [ + "anthropic/claude-sonnet-5", # in-family hot swap — same cost, adaptive thinking + "anthropic/claude-opus-5", # Newest flagship Opus w/ adaptive thinking + "anthropic/claude-opus-4.8", # Prior flagship Opus — identical cost to 5 + "anthropic/claude-opus-4.7", # Flagship Opus w/ adaptive thinking + "anthropic/claude-opus-4.6", # 2,139ms + "xai/grok-4-1-fast-reasoning", # 1,454ms + "deepseek/deepseek-reasoner", # 1,454ms + ], + }, + }, + # Time-windowed promotions — auto-applied when active, ignored when expired + "promotions": [ + { + "name": "GLM-5.1 Launch Promo ($0.001 flat)", + "start_date": "2026-04-01", + "end_date": "2026-05-01", + "tier_overrides": { + "SIMPLE": {"primary": "zai/glm-5.1"}, + }, + "profiles": ["auto"], # only auto profile — eco stays free, premium stays premium + }, + ], + "overrides": { + "max_tokens_force_complex": 100_000, + "structured_output_min_tier": "MEDIUM", + "ambiguous_default_tier": "MEDIUM", + # agenticMode left undefined → auto-detect via tools/agenticScore. + # Set to `true` to force agentic tiers; `false` to disable them entirely. + }, +} diff --git a/blockrun_llm/router_core/model_capabilities.py b/blockrun_llm/router_core/model_capabilities.py new file mode 100644 index 0000000..9451070 --- /dev/null +++ b/blockrun_llm/router_core/model_capabilities.py @@ -0,0 +1,297 @@ +""" +Model capabilities used for hard routing constraints. + +Python port of ``@blockrun/router-core`` ``model-capabilities.ts``. + +Hosts may inject fresher values through ``RouterOptions["model_capabilities"]``. +Keeping a small built-in snapshot makes the core safe and useful when a +product catalog is temporarily unavailable, without importing product code. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from types import MappingProxyType + +from .types import ModelCapabilities + +DEFAULT_MODEL_CAPABILITIES: Mapping[str, ModelCapabilities] = MappingProxyType( + { + "anthropic/claude-fable-5": { + "context_window": 1_000_000, + "max_output_tokens": 128_000, + "supports_tools": True, + "supports_vision": True, + }, + "anthropic/claude-haiku-4.5": { + "context_window": 200_000, + "max_output_tokens": 8_192, + "supports_tools": True, + "supports_vision": True, + }, + "anthropic/claude-opus-4.6": { + "context_window": 1_000_000, + "max_output_tokens": 128_000, + "supports_tools": True, + "supports_vision": True, + }, + "anthropic/claude-opus-4.7": { + "context_window": 1_000_000, + "max_output_tokens": 128_000, + "supports_tools": True, + "supports_vision": True, + }, + "anthropic/claude-opus-4.8": { + "context_window": 1_000_000, + "max_output_tokens": 128_000, + "supports_tools": True, + "supports_vision": True, + }, + "anthropic/claude-opus-5": { + "context_window": 1_000_000, + "max_output_tokens": 128_000, + "supports_tools": True, + "supports_vision": True, + }, + "anthropic/claude-sonnet-4.6": { + "context_window": 200_000, + "max_output_tokens": 64_000, + "supports_tools": True, + "supports_vision": True, + }, + "anthropic/claude-sonnet-5": { + "context_window": 1_000_000, + "max_output_tokens": 128_000, + "supports_tools": True, + "supports_vision": True, + }, + "deepseek/deepseek-chat": { + "context_window": 1_000_000, + "max_output_tokens": 8_192, + "supports_tools": True, + "supports_vision": False, + }, + "deepseek/deepseek-reasoner": { + "context_window": 1_000_000, + "max_output_tokens": 8_192, + "supports_tools": True, + "supports_vision": False, + }, + "deepseek/deepseek-v4-pro": { + "context_window": 1_048_576, + "max_output_tokens": 65_536, + "supports_tools": True, + "supports_vision": False, + }, + "free/deepseek-v4-flash": { + "context_window": 1_000_000, + "max_output_tokens": 16_384, + "supports_tools": False, + "supports_vision": False, + }, + "free/gpt-oss-120b": { + "context_window": 128_000, + "max_output_tokens": 16_384, + "supports_tools": False, + "supports_vision": False, + }, + "free/gpt-oss-20b": { + "context_window": 128_000, + "max_output_tokens": 16_384, + "supports_tools": False, + "supports_vision": False, + }, + "free/seed-oss-36b": { + "context_window": 131_072, + "max_output_tokens": 16_384, + "supports_tools": False, + "supports_vision": False, + }, + "google/gemini-2.5-flash": { + "context_window": 1_000_000, + "max_output_tokens": 65_536, + "supports_tools": True, + "supports_vision": True, + }, + "google/gemini-2.5-flash-lite": { + "context_window": 1_000_000, + "max_output_tokens": 65_536, + "supports_tools": True, + "supports_vision": False, + }, + "google/gemini-2.5-pro": { + "context_window": 1_050_000, + "max_output_tokens": 65_536, + "supports_tools": True, + "supports_vision": True, + }, + "google/gemini-3-flash-preview": { + "context_window": 1_000_000, + "max_output_tokens": 65_536, + "supports_tools": False, + "supports_vision": True, + }, + "google/gemini-3.1-flash-lite": { + "context_window": 1_000_000, + "max_output_tokens": 8_192, + "supports_tools": True, + "supports_vision": False, + }, + "google/gemini-3.1-pro": { + "context_window": 1_050_000, + "max_output_tokens": 65_536, + "supports_tools": True, + "supports_vision": True, + }, + "google/gemini-3.5-flash": { + "context_window": 1_048_576, + "max_output_tokens": 65_536, + "supports_tools": True, + "supports_vision": True, + }, + "moonshot/kimi-k2.5": { + "context_window": 262_144, + "max_output_tokens": 16_384, + "supports_tools": True, + "supports_vision": True, + }, + "moonshot/kimi-k2.6": { + "context_window": 262_144, + "max_output_tokens": 65_536, + "supports_tools": True, + "supports_vision": True, + }, + "moonshot/kimi-k2.7": { + "context_window": 262_144, + "max_output_tokens": 65_536, + "supports_tools": True, + "supports_vision": True, + }, + "moonshot/kimi-k3": { + "context_window": 1_048_576, + "max_output_tokens": 65_536, + "supports_tools": True, + "supports_vision": True, + }, + "openai/gpt-4.1": { + "context_window": 128_000, + "max_output_tokens": 16_384, + "supports_tools": True, + "supports_vision": True, + }, + "openai/gpt-4o-mini": { + "context_window": 128_000, + "max_output_tokens": 16_384, + "supports_tools": True, + "supports_vision": False, + }, + "openai/gpt-5-mini": { + "context_window": 200_000, + "max_output_tokens": 65_536, + "supports_tools": True, + "supports_vision": False, + }, + "openai/gpt-5.3-codex": { + "context_window": 400_000, + "max_output_tokens": 128_000, + "supports_tools": True, + "supports_vision": False, + }, + "openai/gpt-5.4": { + "context_window": 400_000, + "max_output_tokens": 128_000, + "supports_tools": True, + "supports_vision": True, + }, + "openai/gpt-5.4-nano": { + "context_window": 1_050_000, + "max_output_tokens": 32_768, + "supports_tools": True, + "supports_vision": False, + }, + "openai/gpt-5.5": { + "context_window": 1_050_000, + "max_output_tokens": 128_000, + "supports_tools": True, + "supports_vision": True, + }, + "openai/gpt-5.6-terra": { + "context_window": 1_050_000, + "max_output_tokens": 128_000, + "supports_tools": True, + "supports_vision": True, + }, + "openai/o3": { + "context_window": 200_000, + "max_output_tokens": 100_000, + "supports_tools": True, + "supports_vision": False, + }, + "openai/o4-mini": { + "context_window": 128_000, + "max_output_tokens": 65_536, + "supports_tools": True, + "supports_vision": False, + }, + "qwen/qwen3.7-max": { + "context_window": 1_000_000, + "max_output_tokens": 65_536, + "supports_tools": True, + "supports_vision": False, + }, + "xai/grok-3-mini": { + "context_window": 131_072, + "max_output_tokens": 16_384, + "supports_tools": True, + "supports_vision": False, + }, + "xai/grok-4-0709": { + "context_window": 131_072, + "max_output_tokens": 16_384, + "supports_tools": True, + "supports_vision": False, + }, + "xai/grok-4-1-fast-non-reasoning": { + "context_window": 131_072, + "max_output_tokens": 16_384, + "supports_tools": True, + "supports_vision": False, + }, + "xai/grok-4-1-fast-reasoning": { + "context_window": 131_072, + "max_output_tokens": 16_384, + "supports_tools": True, + "supports_vision": False, + }, + "xai/grok-4-fast-non-reasoning": { + "context_window": 131_072, + "max_output_tokens": 16_384, + "supports_tools": True, + "supports_vision": False, + }, + "xai/grok-4-fast-reasoning": { + "context_window": 131_072, + "max_output_tokens": 16_384, + "supports_tools": True, + "supports_vision": False, + }, + "xai/grok-4.5": { + "context_window": 500_000, + "max_output_tokens": 16_384, + "supports_tools": True, + "supports_vision": True, + }, + "zai/glm-5.1": { + "context_window": 200_000, + "max_output_tokens": 128_000, + "supports_tools": True, + "supports_vision": False, + }, + "zai/glm-5.2": { + "context_window": 1_000_000, + "max_output_tokens": 262_144, + "supports_tools": True, + "supports_vision": False, + }, + } +) diff --git a/blockrun_llm/router_core/model_profiles.generated.json b/blockrun_llm/router_core/model_profiles.generated.json new file mode 100644 index 0000000..d099b6d --- /dev/null +++ b/blockrun_llm/router_core/model_profiles.generated.json @@ -0,0 +1,242 @@ +{ + "openai/gpt-5.5": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 6243.1, + "p95LatencyMs": 9865, + "outputTokensPerSecond": 12.53, + "errorRate": 0, + "samples": 3 + }, + "openai/gpt-5.4-pro": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 13015.5, + "p95LatencyMs": 23976.4, + "outputTokensPerSecond": 6.42, + "errorRate": 0, + "samples": 3 + }, + "openai/gpt-5.4-mini": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 5550, + "p95LatencyMs": 6595.7, + "outputTokensPerSecond": 11.96, + "errorRate": 0.3333, + "samples": 3 + }, + "openai/gpt-5.3-codex": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 4617.1, + "p95LatencyMs": 5800.7, + "outputTokensPerSecond": 12.48, + "errorRate": 0, + "samples": 3 + }, + "anthropic/claude-opus-4.8": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 3915.1, + "p95LatencyMs": 6130.8, + "outputTokensPerSecond": 16.33, + "errorRate": 0, + "samples": 3 + }, + "anthropic/claude-opus-4.6": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 3765.5, + "p95LatencyMs": 4257.2, + "outputTokensPerSecond": 14.18, + "errorRate": 0, + "samples": 3 + }, + "anthropic/claude-sonnet-4.6": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 3860.6, + "p95LatencyMs": 5093.5, + "outputTokensPerSecond": 13.85, + "errorRate": 0, + "samples": 3 + }, + "anthropic/claude-haiku-4.5": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 2734.9, + "p95LatencyMs": 3181.6, + "outputTokensPerSecond": 19.58, + "errorRate": 0, + "samples": 3 + }, + "google/gemini-3.1-pro": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 13935.7, + "p95LatencyMs": 26675.3, + "outputTokensPerSecond": 77.47, + "errorRate": 0, + "samples": 3 + }, + "google/gemini-3.5-flash": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 4608.7, + "p95LatencyMs": 8420.9, + "outputTokensPerSecond": 57.88, + "errorRate": 0, + "samples": 3 + }, + "google/gemini-3.1-flash-lite": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 4619.7, + "p95LatencyMs": 9927.1, + "outputTokensPerSecond": 42.01, + "errorRate": 0, + "samples": 3 + }, + "google/gemini-2.5-flash": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 5506.9, + "p95LatencyMs": 11462.5, + "outputTokensPerSecond": 65.19, + "errorRate": 0, + "samples": 3 + }, + "deepseek/deepseek-v4-pro": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 6044.8, + "p95LatencyMs": 10782.3, + "outputTokensPerSecond": 22.47, + "errorRate": 0, + "samples": 3 + }, + "deepseek/deepseek-reasoner": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 4111.9, + "p95LatencyMs": 5305.7, + "outputTokensPerSecond": 16.46, + "errorRate": 0, + "samples": 3 + }, + "deepseek/deepseek-chat": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 2648.6, + "p95LatencyMs": 3524.1, + "outputTokensPerSecond": 16.73, + "errorRate": 0, + "samples": 3 + }, + "moonshot/kimi-k2.7": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 4295.4, + "p95LatencyMs": 6153.8, + "outputTokensPerSecond": 18.54, + "errorRate": 0, + "samples": 3 + }, + "qwen/qwen3.7-max": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 30729.4, + "p95LatencyMs": 39622, + "outputTokensPerSecond": 36.89, + "errorRate": 0.3333, + "samples": 3 + }, + "xai/grok-4.3": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 6946.1, + "p95LatencyMs": 9495.4, + "outputTokensPerSecond": 65.3, + "errorRate": 0, + "samples": 3 + }, + "xai/grok-4.20-reasoning": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 3472.4, + "p95LatencyMs": 5332.4, + "outputTokensPerSecond": 13.27, + "errorRate": 0, + "samples": 3 + }, + "xai/grok-4.20-non-reasoning": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 5174.4, + "p95LatencyMs": 6081.7, + "outputTokensPerSecond": 10.21, + "errorRate": 0.3333, + "samples": 3 + }, + "xai/grok-4-1-fast-reasoning": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 13148.2, + "p95LatencyMs": 19104.2, + "outputTokensPerSecond": 4.28, + "errorRate": 0, + "samples": 3 + }, + "minimax/minimax-m3": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 3385, + "p95LatencyMs": 4247.2, + "outputTokensPerSecond": 15.16, + "errorRate": 0, + "samples": 3 + }, + "minimax/minimax-m2.7": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 4596.7, + "p95LatencyMs": 6884.6, + "outputTokensPerSecond": 17.03, + "errorRate": 0, + "samples": 3 + }, + "zai/glm-5.2": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 4406.3, + "p95LatencyMs": 6139.7, + "outputTokensPerSecond": 10.41, + "errorRate": 0, + "samples": 3 + }, + "zai/glm-5.1": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 7775.4, + "p95LatencyMs": 9182.1, + "outputTokensPerSecond": 6.08, + "errorRate": 0, + "samples": 3 + }, + "zai/glm-5": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 4159.4, + "p95LatencyMs": 4992.7, + "outputTokensPerSecond": 10.28, + "errorRate": 0, + "samples": 3 + }, + "free/qwen3-coder-480b": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 2063.9, + "p95LatencyMs": 3646.3, + "outputTokensPerSecond": 39.8, + "errorRate": 0, + "samples": 3 + }, + "free/mistral-large-3-675b": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 3147.5, + "p95LatencyMs": 5555.3, + "outputTokensPerSecond": 27.76, + "errorRate": 0, + "samples": 3 + }, + "free/nemotron-3-nano-omni-30b-a3b-reasoning": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 6508.4, + "p95LatencyMs": 14252.7, + "outputTokensPerSecond": 68.26, + "errorRate": 0, + "samples": 3 + }, + "free/glm-4.7": { + "measuredAt": "2026-07-21T10:21:31Z", + "latencyMs": 2014.8, + "p95LatencyMs": 3039.9, + "outputTokensPerSecond": 39.92, + "errorRate": 0, + "samples": 3 + } +} diff --git a/blockrun_llm/router_core/model_profiles.py b/blockrun_llm/router_core/model_profiles.py new file mode 100644 index 0000000..eca569f --- /dev/null +++ b/blockrun_llm/router_core/model_profiles.py @@ -0,0 +1,134 @@ +""" +Model-performance priors consumed by the portfolio router. + +Python port of ``@blockrun/router-core`` ``model-profiles.ts``. + +The entries below are a small, auditable seed extracted from the 2026-03-16 +BlockRun performance run. They are deliberately weak priors: live data injected +by the host should replace them through configuration before a release. +Historical numbers must never be presented as a current provider SLA or as +task-quality measurements. +""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from pathlib import Path +from types import MappingProxyType +from typing import Any + +from .types import ModelPerformanceProfile + +_GENERATED_PATH = Path(__file__).with_name("model_profiles.generated.json") + +#: camelCase (upstream JSON) -> snake_case (this port). +_FIELD_ALIASES = { + "measuredAt": "measured_at", + "latencyMs": "latency_ms", + "p95LatencyMs": "p95_latency_ms", + "outputTokensPerSecond": "output_tokens_per_second", + "intelligenceIndex": "intelligence_index", + "errorRate": "error_rate", + "samples": "samples", +} + + +def _normalize(raw: Mapping[str, Any]) -> ModelPerformanceProfile: + """Accept either the upstream camelCase JSON or already-ported keys.""" + profile: dict[str, Any] = {} + for key, value in raw.items(): + profile[_FIELD_ALIASES.get(key, key)] = value + return profile # type: ignore[return-value] + + +def _load_generated() -> Mapping[str, ModelPerformanceProfile]: + try: + with _GENERATED_PATH.open(encoding="utf-8") as handle: + payload: dict[str, dict[str, Any]] = json.load(handle) + except (OSError, ValueError): + # A missing or corrupt asset must not take routing down: these are + # weak priors, and the router already handles an absent observation. + return MappingProxyType({}) + return MappingProxyType({model: _normalize(raw) for model, raw in payload.items()}) + + +#: Generated from benchmark files that satisfy the uncached-inference +#: invariant. These are weak performance priors (speed/reliability), never +#: task-quality labels. +LIVE_MODEL_PROFILES: Mapping[str, ModelPerformanceProfile] = _load_generated() + +HISTORICAL_MODEL_PROFILES: Mapping[str, ModelPerformanceProfile] = MappingProxyType( + { + "anthropic/claude-haiku-4.5": { + "measured_at": "2026-03-16T13:50:48Z", + "latency_ms": 2305, + "output_tokens_per_second": 140.6, + }, + "anthropic/claude-opus-4.6": { + "measured_at": "2026-03-16T13:50:48Z", + "latency_ms": 2139, + "output_tokens_per_second": 119.7, + }, + "anthropic/claude-sonnet-4.6": { + "measured_at": "2026-03-16T13:50:48Z", + "latency_ms": 2110, + "output_tokens_per_second": 121.3, + }, + "deepseek/deepseek-chat": { + "measured_at": "2026-03-16T13:50:48Z", + "latency_ms": 1431, + "output_tokens_per_second": 179.2, + "intelligence_index": 32, + }, + "google/gemini-2.5-flash": { + "measured_at": "2026-03-16T13:50:48Z", + "latency_ms": 1238, + "output_tokens_per_second": 207.6, + "intelligence_index": 20, + }, + "google/gemini-2.5-flash-lite": { + "measured_at": "2026-03-16T13:50:48Z", + "latency_ms": 1353, + "output_tokens_per_second": 192.5, + "intelligence_index": 20, + }, + "google/gemini-2.5-pro": { + "measured_at": "2026-03-16T13:50:48Z", + "latency_ms": 1294, + "output_tokens_per_second": 197.8, + }, + "google/gemini-3.1-pro": { + "measured_at": "2026-03-16T13:50:48Z", + "latency_ms": 1609, + "output_tokens_per_second": 167.2, + }, + "moonshot/kimi-k2.5": { + "measured_at": "2026-03-16T13:50:48Z", + "latency_ms": 1646, + "output_tokens_per_second": 155.7, + }, + "openai/gpt-4o-mini": { + "measured_at": "2026-03-16T13:50:48Z", + "latency_ms": 2764, + "output_tokens_per_second": 92.8, + }, + "openai/gpt-5.3-codex": { + "measured_at": "2026-03-16T13:50:48Z", + "latency_ms": 7935, + "output_tokens_per_second": 32.3, + }, + "xai/grok-4-1-fast-non-reasoning": { + "measured_at": "2026-03-16T13:50:48Z", + "latency_ms": 1244, + "output_tokens_per_second": 205.8, + "intelligence_index": 41, + }, + "xai/grok-4-1-fast-reasoning": { + "measured_at": "2026-03-16T13:50:48Z", + "latency_ms": 1454, + "output_tokens_per_second": 176.2, + "intelligence_index": 41, + }, + } +) diff --git a/blockrun_llm/router_core/portfolio.py b/blockrun_llm/router_core/portfolio.py new file mode 100644 index 0000000..23ecf5c --- /dev/null +++ b/blockrun_llm/router_core/portfolio.py @@ -0,0 +1,1375 @@ +""" +V3 portfolio router. + +Python port of ``@blockrun/router-core`` ``portfolio.ts``. + +This is deliberately local and deterministic: feature extraction, eligibility +checks and scoring read only request data plus the in-process model registry. +It is therefore safe for the hot path and provides a stable baseline for the +RouterBench evaluation before health telemetry / an optional judge are added. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from datetime import datetime + +from ._js import as_utc, js_bool, js_regex, parse_date +from .model_capabilities import DEFAULT_MODEL_CAPABILITIES +from .model_profiles import HISTORICAL_MODEL_PROFILES, LIVE_MODEL_PROFILES +from .selector import get_fallback_chain, select_model +from .strategy import RulesStrategy, sample_prompt, scan_limit_for +from .tool_intent import infer_tool_requirement +from .types import ( + CandidateScore, + ModelPerformanceProfile, + PortfolioBandWeights, + PortfolioConfig, + RouterOptions, + RoutingDecision, + TaskType, + Tier, + TierConfig, +) + +DEFAULT_PORTFOLIO_WEIGHTS: PortfolioConfig = { + "auto": { + "quality": 0.47, + "capability": 0.2, + "cost": 0.18, + "speed": 0.07, + "reliability": 0.03, + "legacy": 0.05, + }, + "eco": { + "quality": 0.36, + "capability": 0.2, + "cost": 0.28, + "speed": 0.1, + "reliability": 0.04, + "legacy": 0.02, + }, + "premium": { + "quality": 0.58, + "capability": 0.2, + "cost": 0.08, + "speed": 0.06, + "reliability": 0.06, + "legacy": 0.02, + }, + "high_stakes_boost": {"quality": 0.08, "reliability": 0.05}, + "latency_sensitive_speed_boost": 0.08, + "affinity_floor_gap": {"auto": 0.1, "eco": 0.22, "premium": 0.05}, +} + + +@dataclass(frozen=True) +class TaskFeatures: + task_type: TaskType + estimated_input_tokens: int + has_code: bool + needs_tools: bool + tools_available: bool + needs_vision: bool + needs_structured_output: bool + latency_sensitive: bool + high_stakes: bool + language: str # "zh" | "other" + likely_parallel_tool_calls: bool + complex_multi_tool_plan: bool + agent_domain: str # "airline" | "retail" | "web_research" | "other" + deep_web_research: bool + #: "standard" | "high" | "complex_high" | "policy_exception_simple" | "policy_exception" + agent_risk: str + terminal_tool_signal: bool + terminal_safety_sensitive: bool + implicit_terminal_code: bool + + +# ─── Compiled request features (ported 1:1 from the TypeScript regexes) ─── + +_EXPLICIT_REPEAT = js_regex( + r"\b(?:in parallel|simultaneously|concurrently|for each|each of|every one|both" + r"|(?:two|three|multiple|several)\s+(?:cities|locations|items|tasks|orders|users|files))\b" + r"|并行|同时|分别|每个|各自|(?:两个|三个|多个)(?:城市|地点|项目|任务|订单|用户|文件)" + r"|cada uno|para cada|simult[aá]neamente", + ignorecase=True, +) +_SENTENCE_SPLIT = js_regex(r"[.!?。!?]+") +_ADDITIONALLY = js_regex(r"\b(?:also|additionally|furthermore)\b|另外|此外|그리고", ignorecase=True) +_AND_ALSO = js_regex(r"\band\s+(?:also|for the)\b", ignorecase=True) +_PAIRED_QUANTITY = js_regex( + r"\b\d+(?:\.\d+)?\s+(?:and|or)\s+\d+(?:\.\d+)?\s*(?:gb|mb|tb|kg|g|ml|oz|cups?|cores?|cpus?)\b", + ignorecase=True, +) +_TOOL_NAME_SPLIT = js_regex(r"[^a-z0-9\u3400-\u9fff]+") +_LINE_SPLIT = js_regex(r"\r?\n") +_QUANTITY_MENTION = js_regex( + r"\b(?:\d+(?:\.\d+)?|one|two|three|four|five|six|seven|eight|nine|ten)\s*" + r"(?:oz|ounce|ounces|g|gram|grams|kg|ml|cups?|pieces?|tablespoons?)\b", + ignorecase=True, +) +_REPEATED_LOOKUP = js_regex( + r"\b(?:weather|climate|clima|tiempo|temperature|snow|news|report)\b" + r"|天气|气象|温度|降雪|新闻|报告", + ignorecase=True, +) +_MULTI_LOCATION_CONNECTOR = js_regex(r"\b(?:and also|both|y|e)\b|还有|以及|和|、", ignorecase=True) +_COMMA = js_regex(r"[,,]") +_ASCII_COMMA = js_regex(r",") +_DISTINCT_ORDER_PARTS = js_regex( + r"\b(?:food|meal)\b[\s\S]*\bdrink\b|\bdrink\b[\s\S]*\b(?:food|meal)\b", ignorecase=True +) +_KOREAN_CLAUSES = js_regex(r"하고|그리고") + +_OPERATION_TOKENS = frozenset( + { + "add", + "delete", + "remove", + "cancel", + "return", + "exchange", + "modify", + "book", + "transfer", + "send", + "upload", + "download", + "create", + "close", + } +) + +_EXPLICIT_CODE_SIGNAL = js_regex( + r"```|\b(?:typescript|javascript|python|rust|java|sql|stack trace|traceback|exception)\b" + r"|\.(?:ts|tsx|js|py|go|rs)\b", + ignorecase=True, +) +_CODE_CONSTRUCT_SIGNAL = js_regex( + r"\b(?:implement|refactor|debug|write|edit|modify|create|define|review|fix)\b[\s\S]{0,48}" + r"\b(?:api|function|class|method)\b" + r"|\b(?:api|function|class|method)\b[\s\S]{0,48}" + r"\b(?:code|implementation|typescript|javascript|python|rust|java)\b", + ignorecase=True, +) +_NATIVE_CODE_SIGNAL = js_regex( + r"\b(?:programmed|written|implemented?|code)\s+(?:in|using)\s+(?:c\+\+|c|rust|go)\b", + ignorecase=True, +) +_AIRLINE_TOOL = js_regex(r"(?:flight|reservation|airport|baggage|passenger)") +_RETAIL_TOOL = js_regex(r"(?:order|product|item|return|exchange|address)") +_WEB_RESEARCH_TOOL = js_regex(r"^(?:web_?search|web_?fetch)$") +_CLUE_CONNECTORS = js_regex( + r"\b(?:after|before|while|where|whose|which|in \d{4}|as of|over \d+|another|also|furthermore)\b" + r"|(?:之后|之前|其中|截至|超过|另一个|此外)", + ignorecase=True, +) +_ENTITY_RESOLUTION = js_regex( + r"\b(?:identify|who (?:is|was)|what (?:is|was) the name" + r"|which (?:person|player|company|country|city)|find the (?:person|player|name|entity))\b" + r"|(?:找出|识别|是谁|哪位|名称是什么)", + ignorecase=True, +) +_EXACT_ANSWER = js_regex( + r"\b(?:exact answer|single best-supported answer|following clues|multiple public sources)\b" + r"|(?:精确答案|根据.*线索|多个公开来源)", + ignorecase=True, +) +_GLOBAL_OPTIMIZATION = js_regex( + r"\b(?:cheapest|lowest[- ]price|least expensive|most expensive|highest(?:[- ]priced)?" + r"|largest|smallest|maximum|minimum|best available|closest|not (?:cost|exceed))\b" + r"|最便宜|最低价|最贵|最高价|最大|最小", + ignorecase=True, +) +_GLOBAL_SCOPE = js_regex( + r"\b(?:everything|all (?:(?:my|your|their|the) )?(?:future |upcoming )?" + r"(?:items|orders|passengers|flights|reservations|bookings)" + r"|every (?:item|order|passenger|flight|reservation|booking))\b" + r"|全部|所有|每个", + ignorecase=True, +) +_CROSS_RECORD = js_regex( + r"\b(?:another|other|different|previous)\s+(?:order|reservation|booking|account|address)\b" + r"|另一(?:个)?(?:订单|预订|账户|地址)|其他(?:订单|预订|账户|地址)", + ignorecase=True, +) +_RESERVATION_ID = js_regex(r"\b[A-Z0-9]{6}\b") +_CROSS_RESERVATION_BATCH = js_regex( + r"\b(?:two|three|multiple|several)(?:\s+of\s+(?:my|our|the))?\s+(?:upcoming\s+)?" + r"(?:reservations?|bookings?)\b" + r"|\b(?:a\s+)?(?:second|third)\s+(?:reservation|booking)\b", + ignorecase=True, +) +_CONDITIONAL_GLOBAL_TERMS = js_regex( + r"\b(?:if|that (?:contain|have)|longer than|shorter than|under|over|at (?:most|least)" + r"|wherever possible)\b" + r"|如果|超过|少于|不超过|尽可能", + ignorecase=True, +) +_CONDITIONAL_GLOBAL_ACTIONS = js_regex( + r"\b(?:cancel|change|upgrade|move|book)\b[\s\S]*\b(?:cancel|change|upgrade|move|book)\b" + r"|取消[\s\S]*(?:升级|更改)|升级[\s\S]*(?:取消|更改)", + ignorecase=True, +) +_RETURN_INTENT = js_regex( + r"\b(?:return|refund|send back|get (?:my |the )?money back)\b|退货|退款|退回", ignorecase=True +) +_CARD_INTENT = js_regex( + r"\b(?:amex|american express|visa|mastercard|credit card|debit card|different card" + r"|another card|other card)\b" + r"|信用卡|借记卡|其他卡|另一张卡", + ignorecase=True, +) +_SINGLE_SELECTED_RETURN = js_regex( + r"\b(?:return|refund|send back)\b[^.!?。!?]{0,96}" + r"\b(?:the )?(?:pricier|cheaper|more expensive|less expensive|costlier|one)\b", + ignorecase=True, +) +_NEGOTIATED_WORKFLOW = js_regex(r"\b(?:return|exchange)\b|退货|退回|换货|交换", ignorecase=True) +_NUMBERED_STEP = js_regex(r"(?:^|\s)\d+(?:\.\d+)*[.)]\s+") +_LATENCY_SENSITIVE = js_regex( + r"\b(?:urgent|asap|fast|quick|low latency|real[- ]time)\b|尽快|马上|快速|低延迟", + ignorecase=True, +) +_HIGH_STAKES = js_regex( + r"\b(?:production|security|payment|legal|medical|financial|audit)\b" + r"|生产|安全|支付|法律|医疗|财务|审计", + ignorecase=True, +) +_TERMINAL_TOOL = js_regex(r"^(?:terminalexec|terminalinspect|terminalsendkeys)$") +_SIMPLE_TERMINAL_ARTIFACT = js_regex( + r"\b(?:create|write|convert|generate|build|implement|run|fix|repair|debug|make)\b" + r"[\s\S]{0,120}\b(?:file|script|csv|parquet|json|txt|server|endpoint)\b", + ignorecase=True, +) +_TERMINAL_COMPLEX_REPAIR = js_regex( + r"\b(?:multiple|several)\s+(?:scripts?|files?|components?)\b" + r"|\b(?:pipeline|dependencies)\b[\s\S]{0,100}\b(?:fail|issue|fix|repair|run|execute)\b" + r"|\b(?:identify|find|fix|repair)\s+(?:and\s+)?(?:fix\s+)?all\s+(?:the\s+)?issues\b", + ignorecase=True, +) +_TERMINAL_RUNTIME = js_regex( + r"\b(?:gcc|clang|rustc|javac|go\s+build|node|python)\b", ignorecase=True +) +_POLYGLOT = js_regex(r"\bpolyglot\b", ignorecase=True) +_BOTH_TOOLCHAINS = js_regex( + r"\b(?:both|each)\b[\s\S]{0,120}\b(?:compilers?|runtimes?|toolchains?)\b", ignorecase=True +) +_COMPILE_VERB = js_regex(r"\b(?:compile|build|run|execute)\b", ignorecase=True) +_FRAMEWORK_ARTIFACT = js_regex( + r"\b(?:pytorch|tensorflow|jax|onnx|state[_ -]?dict|checkpoint|safetensors?)\b" + r"|\.(?:pth|pt|onnx)\b", + ignorecase=True, +) +_NATIVE_TARGET = js_regex( + r"\b(?:pure|native|programmed|written|implemented?)\s+(?:in|using)\s+(?:c\+\+|c|rust|go)\b" + r"|\b(?:c\+\+|c|rust|go)\s+(?:program|binary|executable|cli|tool|implementation)\b", + ignorecase=True, +) +_INFERENCE_VERB = js_regex( + r"\b(?:inference|model|weights?|tensor|export|convert|load)\b", ignorecase=True +) +_COMPLEX_TERMINAL_OPERATION = js_regex( + r"\b(?:git|ssh|nginx|https|certificate|authentication|credential|deploy|production|encrypt" + r"|gpg|shred|securely delete|decommission|benchmark|evaluate|embedding|chess|image" + r"|search the web|schema|statistical|statistics|aggregate|join|multiple inputs?)\b", + ignorecase=True, +) +_TERMINAL_CREDENTIAL = js_regex( + r"\b(?:ssh|nginx|certificate|authentication|credentials?|passwords?|api keys?|deploy" + r"|production|encrypt|gpg|shred|securely delete|decommission)\b", + ignorecase=True, +) +_TERMINAL_TOKEN_CREDENTIAL = js_regex( + r"\b(?:access|auth|authentication|bearer|secret|api)\s+tokens?\b" + r"|\btokens?\s+(?:secret|credential|authentication)\b", + ignorecase=True, +) +_HAN = js_regex(r"[\u3400-\u9fff]") +_MULTIPLE_CHOICE = js_regex(r"(?:^|\n)\s*[A-D][.)]\s+", ignorecase=True, multiline=True) +_NUMERIC = js_regex(r"-?\d+(?:[.,]\d+)?") +_MATH_MARKERS = js_regex( + r"[+×÷=%$€£¥]|\b(?:total|each|per|times|half|twice|percent|how many|how much|calculate)\b", + ignorecase=True, +) +_TRAILING_QUESTION = js_regex(r"[??]\s*\Z") +_DEBUG_TASK = js_regex( + r"\b(?:bug|debug|error|failure|failing|regression|crash|修复|报错|错误|调试)\b", ignorecase=True +) +_CODE_EDIT_TASK = js_regex( + r"\b(?:refactor|implement|patch|edit|rewrite|重构|实现|修改)\b", ignorecase=True +) +_EXTRACTION_TASK = js_regex(r"\b(?:extract|json|schema|csv|字段|提取)\b", ignorecase=True) +_REASONING_TASK = js_regex( + r"\b(?:prove|derive|theorem|formal|mathematical|reasoning|证明|推导|定理|数学)\b", + ignorecase=True, +) + + +def _likely_needs_parallel_tool_calls( + prompt: str, + needs_tools: bool, + tool_count: int | None, + tool_names: list[str] | None, +) -> bool: + """Detect turns that probably need several tool calls. + + A deliberately conservative request-side feature: it uses only the prompt + and the visible tool count, never benchmark categories or expected answers. + """ + if not needs_tools or tool_count is None or tool_count < 1: + return False + text = prompt.strip() + if _EXPLICIT_REPEAT.search(text): + return True + + sentence_clauses = [ + part.strip() for part in _SENTENCE_SPLIT.split(text) if len(part.strip()) >= 8 + ] + if (_ADDITIONALLY.search(text) and len(sentence_clauses) >= 2) or _AND_ALSO.search(text): + return True + + if _PAIRED_QUANTITY.search(text): + return True + + # Distinctive tokens from two visible tool names are a strong local signal + # for a multi-operation turn (for example add_task + delete_task). + lowered = text.lower() + matched_operation_tokens = { + token + for name in (tool_names or []) + for token in _TOOL_NAME_SPLIT.split(name.lower()) + if token in _OPERATION_TOKENS and token in lowered + } + # A single workflow naturally mentions domain nouns like order/item plus one + # action. Upgrade only when two different visible operation verbs are + # requested (for example cancel + book or add + delete). + if len(matched_operation_tokens) >= 2: + return True + + # Repeated food/logging entries are commonly expressed as several lines, + # each with its own quantity rather than an explicit "for each" phrase. + non_empty_lines = [line.strip() for line in _LINE_SPLIT.split(text) if line.strip()] + quantity_mentions = _QUANTITY_MENTION.findall(text) + if len(non_empty_lines) >= 2 and len(quantity_mentions) >= 2: + return True + + # Weather prompts provide a useful language-independent high-confidence + # pattern: a single lookup tool plus multiple locations joined in one turn. + repeated_lookup = bool(_REPEATED_LOOKUP.search(text)) + multi_location_connector = bool(_MULTI_LOCATION_CONNECTOR.search(text)) + comma_separated_locations = len(_COMMA.findall(text)) >= 2 + if repeated_lookup and (multi_location_connector or comma_separated_locations): + return True + + distinct_order_parts = bool(_DISTINCT_ORDER_PARTS.search(text)) + korean_parallel_clauses = len(_ASCII_COMMA.findall(text)) >= 3 and bool( + _KOREAN_CLAUSES.search(text) + ) + return distinct_order_parts or korean_parallel_clauses + + +def classify_task(prompt: str, system_prompt: str | None, options: RouterOptions) -> TaskFeatures: + """Extract the request-side features the portfolio scorer ranks against.""" + full_text = f"{system_prompt or ''} {prompt}" + estimated_input_tokens = math.ceil(len(full_text) / 4) + # Feature regexes need request shape and intent, not the entire document. + # Sample both ends so a long pasted artifact keeps the task instruction at + # either boundary, while the full length still drives capacity decisions. + scan_limit = scan_limit_for(options) + scanned_prompt = sample_prompt(prompt, scan_limit) + scanned_system_prompt = sample_prompt(system_prompt or "", scan_limit) + scanned_full_text = f"{scanned_system_prompt} {scanned_prompt}" + text = scanned_prompt.lower() + + explicit_code_signal = bool(_EXPLICIT_CODE_SIGNAL.search(scanned_prompt)) + # `class` is common in non-code Agent domains (for example airline cabin + # class). Treat code constructs as code only when the prompt also contains + # an implementation/editing cue, instead of letting a single ambiguous noun + # redirect an entire tool session to the code-agent portfolio. + code_construct_signal = bool(_CODE_CONSTRUCT_SIGNAL.search(scanned_prompt)) + native_code_signal = bool(_NATIVE_CODE_SIGNAL.search(scanned_prompt)) + has_code = explicit_code_signal or code_construct_signal or native_code_signal + + tools_available = options.get("has_tools", False) + requires_tools = options.get("requires_tools") + needs_tools = ( + requires_tools + if requires_tools is not None + else bool(tools_available and infer_tool_requirement(scanned_prompt, scanned_system_prompt)) + ) + tool_names = list(options.get("tool_names") or []) + likely_parallel_tool_calls = _likely_needs_parallel_tool_calls( + scanned_prompt, needs_tools, options.get("tool_count"), tool_names + ) + normalized_tool_names = [name.lower() for name in tool_names] + airline_tool_signal = any(_AIRLINE_TOOL.search(name) for name in normalized_tool_names) + retail_tool_signal = any(_RETAIL_TOOL.search(name) for name in normalized_tool_names) + web_research_tool_signal = any( + _WEB_RESEARCH_TOOL.search(name) for name in normalized_tool_names + ) + if airline_tool_signal and not retail_tool_signal: + agent_domain = "airline" + elif retail_tool_signal and not airline_tool_signal: + agent_domain = "retail" + elif web_research_tool_signal: + agent_domain = "web_research" + else: + agent_domain = "other" + + # Distinguish a cheap lookup from a BrowseComp-like investigation. These + # prompts require joining several clues, resolving an entity, and ending in + # one exact answer; complete agent trajectories show that treating them as + # ordinary search causes long, costly loops. This is request/tool-surface + # evidence only and does not depend on a benchmark id or hidden answer. + clue_connectors = _CLUE_CONNECTORS.findall(scanned_full_text) + entity_resolution_signal = bool(_ENTITY_RESOLUTION.search(scanned_full_text)) + exact_answer_signal = bool(_EXACT_ANSWER.search(scanned_full_text)) + deep_web_research = agent_domain == "web_research" and ( + exact_answer_signal + or (entity_resolution_signal and (len(clue_connectors) >= 3 or len(prompt) >= 320)) + ) + + global_optimization_signal = bool(_GLOBAL_OPTIMIZATION.search(scanned_prompt)) + global_scope_signal = bool(_GLOBAL_SCOPE.search(scanned_prompt)) + global_choice_signal = global_optimization_signal or global_scope_signal + cross_record_signal = bool(_CROSS_RECORD.search(scanned_prompt)) + reservation_ids = _RESERVATION_ID.findall(scanned_prompt) + cross_reservation_batch_signal = agent_domain == "airline" and ( + bool(_CROSS_RESERVATION_BATCH.search(scanned_prompt)) or len(set(reservation_ids)) >= 2 + ) + conditional_global_workflow_signal = ( + agent_domain == "airline" + and global_scope_signal + and bool(_CONDITIONAL_GLOBAL_TERMS.search(scanned_prompt)) + and bool(_CONDITIONAL_GLOBAL_ACTIONS.search(scanned_prompt)) + ) + # A refund explicitly targeted at a named/non-original card can conflict + # with account state and require escalation rather than a substitute action. + # This narrow feature is visible on the first turn and avoids sending every + # ordinary return workflow to the expensive policy specialist. + policy_exception_signal = ( + agent_domain == "retail" + and bool(_RETURN_INTENT.search(scanned_prompt)) + and bool(_CARD_INTENT.search(scanned_prompt)) + ) + # A comparative selector can mention two products while requesting only one + # write (for example "send back the pricier one"). Three-repeat tau2 + # calibration found no quality gain from the policy specialist on these + # single-write cases, so keep them in a distinct, lower-cost risk band. + single_selected_policy_exception = policy_exception_signal and bool( + _SINGLE_SELECTED_RETURN.search(scanned_prompt) + ) + # Returns and exchanges often pivot after confirmation (return -> rethink -> + # exchange -> choose a variant). That future state is not visible to a + # task-start router, so treat the observable workflow verb as the risk cue. + # Simpler cancellation and one-field order edits stay on the standard path. + negotiated_workflow_signal = agent_domain == "retail" and bool( + _NEGOTIATED_WORKFLOW.search(scanned_prompt) + ) + numbered_steps = len(_NUMBERED_STEP.findall(scanned_prompt)) + complex_multi_tool_plan = likely_parallel_tool_calls and ( + (options.get("tool_count") or 0) >= 6 or numbered_steps >= 3 or len(prompt) > 1_200 + ) + + if needs_tools and single_selected_policy_exception: + agent_risk = "policy_exception_simple" + elif needs_tools and policy_exception_signal: + agent_risk = "policy_exception" + # Airline prompts that require a global optimum (for example the cheapest + # itinerary across several candidates) are materially harder than applying + # one change to every passenger in a known reservation. Full-session + # evidence supports Sonnet for the former, while upgrading the latter merely + # because it says "all passengers" caused a large cost increase without a + # quality gain. + elif ( + needs_tools + and agent_domain == "airline" + and (global_optimization_signal or conditional_global_workflow_signal) + ): + agent_risk = "complex_high" + elif needs_tools and ( + likely_parallel_tool_calls + or global_choice_signal + or cross_record_signal + or cross_reservation_batch_signal + or negotiated_workflow_signal + ): + agent_risk = "high" + else: + agent_risk = "standard" + + needs_vision = options.get("has_vision", False) + needs_structured_output = options.get("requires_structured_output", False) + latency_sensitive = bool(_LATENCY_SENSITIVE.search(scanned_full_text)) + high_stakes = bool(_HIGH_STAKES.search(scanned_full_text)) + + # Terminal tasks often describe the desired artifact rather than naming a + # programming language. Treat only small, deterministic local build/file + # work as implicit code. Operational deployment, credentials, destructive + # work, evaluation, vision, and broad search stay on the stronger generic + # tool-agent path. This is a request-side feature, not a benchmark ID list. + terminal_tool_signal = any(_TERMINAL_TOOL.search(name) for name in normalized_tool_names) + simple_terminal_artifact = bool(_SIMPLE_TERMINAL_ARTIFACT.search(scanned_prompt)) + # Multi-file repair is qualitatively different from fixing one known local + # script. The agent must preserve state across inspections, infer ordering + # and dependencies, edit several artifacts, and close the loop with tests. + terminal_complex_repair = terminal_tool_signal and bool( + _TERMINAL_COMPLEX_REPAIR.search(scanned_prompt) + ) + # One artifact that must be accepted by multiple compilers/runtimes is not a + # routine file-writing task. It requires reasoning across incompatible + # grammars and validating every execution path. + mentioned_terminal_runtimes = { + " ".join(name.lower().split()) for name in _TERMINAL_RUNTIME.findall(scanned_prompt) + } + terminal_cross_runtime_artifact = terminal_tool_signal and ( + bool(_POLYGLOT.search(scanned_prompt)) + or bool(_BOTH_TOOLCHAINS.search(scanned_prompt)) + or (len(mentioned_terminal_runtimes) >= 2 and bool(_COMPILE_VERB.search(scanned_prompt))) + ) + # Framework-to-native ports combine binary checkpoint inspection, weight + # export, tensor-layout reasoning, image/data decoding, and a separately + # compiled runtime. + terminal_framework_to_native_artifact = ( + terminal_tool_signal + and bool(_FRAMEWORK_ARTIFACT.search(scanned_prompt)) + and bool(_NATIVE_TARGET.search(scanned_prompt)) + and bool(_INFERENCE_VERB.search(scanned_prompt)) + ) + if ( + needs_tools + and ( + terminal_complex_repair + or terminal_cross_runtime_artifact + or terminal_framework_to_native_artifact + ) + and agent_risk in ("standard", "high") + ): + agent_risk = "complex_high" + + complex_terminal_operation = bool(_COMPLEX_TERMINAL_OPERATION.search(scanned_prompt)) + # A bare "token" is not a credential signal: blockchain, tokenizer, and LLM + # tasks use that word routinely (for example "token transfers"). Only treat + # it as sensitive when the prompt gives it an authentication/secret + # qualifier. API keys remain an unambiguous high-risk signal on their own. + terminal_credential_signal = bool(_TERMINAL_CREDENTIAL.search(scanned_prompt)) or bool( + _TERMINAL_TOKEN_CREDENTIAL.search(scanned_prompt) + ) + terminal_safety_sensitive = terminal_tool_signal and (high_stakes or terminal_credential_signal) + implicit_terminal_code = bool( + needs_tools + and terminal_tool_signal + and agent_risk == "standard" + and not high_stakes + and not complex_terminal_operation + and numbered_steps < 3 + and len(prompt) <= 1_000 + and simple_terminal_artifact + ) + language = "zh" if _HAN.search(scanned_full_text) else "other" + multiple_choice_signals = len(_MULTIPLE_CHOICE.findall(scanned_prompt)) + numeric_signals = len(_NUMERIC.findall(scanned_prompt)) + compact_math_problem = ( + not has_code + and len(prompt) < 2_500 + and numeric_signals >= 2 + and ( + bool(_MATH_MARKERS.search(scanned_prompt)) + or bool(_TRAILING_QUESTION.search(scanned_prompt.strip())) + or numeric_signals >= 3 + ) + ) + + task_type: TaskType = "chat" + if needs_vision: + task_type = "vision" + elif estimated_input_tokens > 80_000: + task_type = "long_context" + elif needs_tools and (has_code or implicit_terminal_code): + task_type = "code_agent" + elif needs_tools and likely_parallel_tool_calls and not complex_multi_tool_plan: + task_type = "tool_agent_parallel" + elif needs_tools: + task_type = "tool_agent" + elif multiple_choice_signals >= 3: + task_type = "reasoning_mcq" + elif compact_math_problem: + task_type = "reasoning_math" + elif _DEBUG_TASK.search(text): + task_type = "debug" + elif has_code or _CODE_EDIT_TASK.search(text): + task_type = "code_edit" + elif needs_structured_output or _EXTRACTION_TASK.search(text): + task_type = "extraction" + elif _REASONING_TASK.search(text): + task_type = "reasoning" + + return TaskFeatures( + task_type=task_type, + estimated_input_tokens=estimated_input_tokens, + has_code=has_code, + needs_tools=bool(needs_tools), + tools_available=bool(tools_available), + needs_vision=bool(needs_vision), + needs_structured_output=bool(needs_structured_output), + latency_sensitive=latency_sensitive, + high_stakes=high_stakes, + language=language, + likely_parallel_tool_calls=likely_parallel_tool_calls, + complex_multi_tool_plan=bool(complex_multi_tool_plan), + agent_domain=agent_domain, + deep_web_research=bool(deep_web_research), + agent_risk=agent_risk, + terminal_tool_signal=terminal_tool_signal, + terminal_safety_sensitive=terminal_safety_sensitive, + implicit_terminal_code=implicit_terminal_code, + ) + + +_AFFINITY_BASE = 0.68 + + +def affinity( + model_id: str, + task: TaskType, + language: str = "other", + agent_domain: str = "other", + deep_web_research: bool = False, + agent_risk: str = "standard", + terminal_tool_signal: bool = False, + terminal_safety_sensitive: bool = False, +) -> float: + """Task affinity for a model, on the same evidence bands as upstream. + + Model family names are intentionally similar (for example + ``gemini-2.5-flash`` vs ``gemini-2.5-flash-lite``). A substring match would + let a smaller sibling inherit a capability claim measured only for the + flagship, so these assignments are model-exact; a sibling can be added only + with its own evidence. + """ + model_id_lower = model_id.lower() + model_name = model_id_lower[model_id_lower.find("/") + 1 :] + + def match(values: list[str], score: float) -> float: + return score if model_name in values else 0.0 + + base = _AFFINITY_BASE + + if task == "code_agent": + if terminal_tool_signal and agent_risk == "complex_high": + # Strong native tool loop until the Responses function-output fix is + # deployed on both gateways; keep Codex available below the floor. + return max( + base, + match(["claude-sonnet-5"], 1), + match(["gpt-5.3-codex"], 0.87), + match(["gpt-5-mini"], 0.78), + match(["gemini-3.5-flash"], 0.76), + ) + # Seven valid full agent + official Terminal-Bench trajectories + # (2026-07-28) gave GPT-5 Mini 4/7 resolved tasks versus 1/7 for the + # prior dynamic code-agent choice. Its token-normalized total cost was + # higher in this small calibration, so keep Codex and Sonnet's quality + # priors above it. DeepSeek V4 Pro is kept below the primary band after + # two consecutive mid-trajectory provider timeouts. + return max( + base, + match(["gpt-5.3-codex"], 1), + match(["claude-sonnet-5"], 0.98), + match(["gpt-5-mini"], 0.96), + match(["gemini-3.5-flash"], 0.92), + match(["kimi-k3"], 0.9), + match(["deepseek-v4-pro", "glm-5.2"], 0.88), + ) + + if task == "tool_agent": + if terminal_tool_signal and agent_risk == "complex_high": + # Keep the Responses-API Codex path outside auto's affinity floor + # until the gateway fix that preserves function_call_output is live + # on both chains. Sonnet has a verified native multi-turn tool loop. + return max( + base, + match(["claude-sonnet-5"], 1), + match(["gpt-5.3-codex"], 0.87), + match(["gpt-5-mini"], 0.78), + match(["gemini-3.5-flash"], 0.76), + ) + if terminal_tool_signal and not terminal_safety_sensitive: + # Seven official Terminal-Bench calibration trajectories favoured + # GPT-5 Mini over the prior dynamic choice. Admit Codex/Sonnet as + # close fallbacks, but let actual request cost break the tie. + return max( + base, + match(["gpt-5-mini"], 1), + match(["gpt-5.3-codex"], 0.98), + match(["claude-sonnet-5"], 0.9), + match(["gemini-3.5-flash"], 0.89), + ) + if terminal_tool_signal and terminal_safety_sensitive: + # Two complete agent observations on the public Terminal-Bench + # new-encrypt-command task ended in Codex repeating the same + # TerminalExec input until the loop guard fired. + return max( + base, + match(["claude-sonnet-5"], 1), + match(["claude-opus-4.8"], 0.9), + match(["gpt-5.3-codex"], 0.84), + ) + if agent_domain == "web_research": + # Complete-session BrowseComp calibration supersedes the earlier + # single-case Opus promotion: strict deduplicated evidence has + # Sonnet 5 at 2/9 versus Opus 5 at 0/3, while Opus also costs more + # and has a much longer tail. + if deep_web_research: + return max( + base, + match(["claude-sonnet-5"], 1), + match(["gpt-5-mini"], 0.88), + match(["gemini-3.5-flash"], 0.84), + match(["claude-opus-5"], 0.8), + match(["claude-opus-4.8"], 0.78), + ) + return max( + base, + match(["claude-sonnet-5"], 1), + match(["gpt-5-mini"], 0.88), + match(["gemini-3.5-flash"], 0.86), + match(["claude-opus-5"], 0.84), + match(["claude-opus-4.8"], 0.82), + ) + # Full-trajectory tau2 calibration (2026-07-28, official gpt-4.1 + # simulator): Sonnet 5 completed both an airline policy task and a + # retail multi-write task with reward 1.0. Gemini 3.5 Flash emitted + # function calls as plain text after the first structured calls. + if agent_domain == "retail": + # Full-session calibration: GPT-5 Mini completed two local/single + # retail workflows at a fraction of Sonnet's token cost. It remains + # ineligible for promotion when the prompt asks for multiple + # actions, cross-record discovery, or a global optimum. DeepSeek V4 + # Pro completed all three high-risk retail calibration trajectories. + if agent_risk == "standard": + return max( + base, + match(["gpt-5-mini"], 1), + match(["claude-sonnet-5"], 0.88), + match(["gemini-3.5-flash"], 0.82), + match(["gpt-5.3-codex"], 0.81), + match(["kimi-k3"], 0.78), + match(["deepseek-v4-pro"], 0.76), + ) + if agent_risk == "policy_exception": + return max( + base, + match(["gpt-4.1"], 1), + match(["claude-sonnet-5"], 0.9), + match(["deepseek-v4-pro"], 0.82), + match(["gpt-5-mini"], 0.8), + match(["gpt-4o-mini"], 0.76), + ) + if agent_risk == "policy_exception_simple": + return max( + base, + match(["gpt-5-mini"], 1), + match(["gpt-4.1"], 0.86), + match(["deepseek-v4-pro"], 0.82), + match(["gpt-4o-mini"], 0.8), + ) + return max( + base, + match(["deepseek-v4-pro"], 1), + match(["claude-sonnet-5"], 0.88), + match(["gemini-3.5-flash"], 0.82), + match(["gpt-5.3-codex"], 0.81), + match(["kimi-k3"], 0.78), + match(["gpt-5-mini"], 0.76), + ) + # Standard airline workflows stay on GPT-5 Mini: six full-session + # development trajectories gave it the same 5/6 success as Sonnet at + # roughly one order of magnitude lower normalized token cost. Promote + # only global optimization / conditional-global work. + if agent_domain == "airline": + if agent_risk == "complex_high": + return max( + base, + match(["claude-sonnet-5"], 1), + match(["gpt-5-mini"], 0.78), + match(["gemini-3.5-flash"], 0.76), + match(["deepseek-v4-pro"], 0.74), + ) + return max( + base, + match(["gpt-5-mini"], 1), + match(["claude-sonnet-5"], 0.9), + match(["gemini-3.5-flash"], 0.8), + match(["deepseek-v4-pro"], 0.76), + ) + return max( + base, + match(["claude-sonnet-5"], 1), + match(["gemini-3.5-flash"], 0.88), + match(["gpt-5.3-codex"], 0.87), + match(["gpt-5-mini"], 0.84), + match(["kimi-k3"], 0.85), + match(["deepseek-v4-pro"], 0.82), + ) + + if task == "tool_agent_parallel": + if terminal_tool_signal: + # Multi-file Terminal work is not equivalent to a one-turn parallel + # function-call benchmark. Sonnet is the strongest trajectory-tested + # cost-controlled default; Opus remains a close safety fallback. + if terminal_safety_sensitive: + return max( + base, + match(["claude-sonnet-5"], 1), + match(["claude-opus-4.8"], 0.9), + match(["gpt-5.3-codex"], 0.86), + ) + return max( + base, + match(["gpt-5-mini"], 1), + match(["gpt-5.3-codex"], 0.98), + match(["claude-sonnet-5"], 0.92), + match(["gemini-3.5-flash"], 0.88), + ) + if agent_domain == "web_research": + if deep_web_research: + return max( + base, + match(["claude-sonnet-5"], 1), + match(["gpt-5-mini"], 0.88), + match(["gemini-3.5-flash"], 0.84), + match(["claude-opus-5"], 0.8), + match(["claude-opus-4.8"], 0.78), + ) + return max( + base, + match(["claude-sonnet-5"], 1), + match(["gpt-5-mini"], 0.88), + match(["gemini-3.5-flash"], 0.86), + match(["claude-opus-5"], 0.84), + match(["claude-opus-4.8"], 0.82), + ) + if agent_domain == "retail": + if agent_risk == "policy_exception": + return max( + base, + match(["gpt-4.1"], 1), + match(["claude-sonnet-5"], 0.9), + match(["deepseek-v4-pro"], 0.82), + match(["gpt-5-mini"], 0.8), + match(["gpt-4o-mini"], 0.76), + ) + if agent_risk == "policy_exception_simple": + return max( + base, + match(["gpt-5-mini"], 1), + match(["gpt-4.1"], 0.86), + match(["deepseek-v4-pro"], 0.82), + match(["gpt-4o-mini"], 0.8), + ) + return max( + base, + match(["deepseek-v4-pro"], 1), + match(["claude-sonnet-5"], 0.88), + match(["claude-opus-4.8"], 0.84), + match(["gpt-5-mini"], 0.78), + match(["gemini-3.5-flash"], 0.76), + ) + if agent_domain == "airline": + if agent_risk == "complex_high": + return max( + base, + match(["claude-sonnet-5"], 1), + match(["gpt-5-mini"], 0.78), + match(["claude-opus-4.8"], 0.76), + match(["gemini-3.5-flash"], 0.74), + ) + return max( + base, + match(["gpt-5-mini"], 1), + match(["claude-sonnet-5"], 0.9), + match(["gemini-3.5-flash"], 0.8), + ) + # RouterBench calibration, 2026-07-26: Opus 4.8 produced complete + # multi-call payloads on 2/3 multilingual BFCL parallel cases. Gemini + # 3.5 Flash, Sonnet 5, DeepSeek V4 Pro, and Grok 4.5 were 0/3. This + # narrow prior only applies after the conservative prompt feature above. + return max( + base, + match(["claude-opus-4.8"], 1), + match(["claude-sonnet-5"], 0.84), + match(["grok-4.5"], 0.82), + match(["gemini-3.5-flash"], 0.8), + match(["deepseek-v4-pro"], 0.78), + ) + + if task in ("code_edit", "debug"): + return max( + base, + match(["gpt-5.3-codex"], 1), + match(["claude-sonnet-4.6"], 0.94), + match(["glm-5.2"], 0.9), + match(["kimi-k2.7", "deepseek-v4-pro"], 0.86), + ) + + if task == "reasoning": + return max( + base, + match(["claude-sonnet-5", "claude-sonnet-4.6"], 0.98), + match(["deepseek-v4-pro"], 0.95), + match(["grok-4.5"], 0.94), + match(["gemini-3.1-pro", "gemini-3.5-flash"], 0.92), + ) + + if task == "reasoning_mcq": + # RouterBench calibration (2026-07-28, six stratified GPQA Diamond + # tasks, identical agent adapter and 512-token budget): Gemini 3 Flash + # Preview scored 5/6, Gemini 3.5 Flash 4/6, and Gemini 3.1 Pro 3/6 while + # costing ~170x more than Flash. Version recency alone is not a quality + # signal, and unused host tools must not change this model choice. + return max( + base, + match(["gemini-3-flash-preview"], 1), + match(["gemini-3.5-flash"], 0.91), + match(["grok-4.5"], 0.9), + match(["claude-sonnet-5"], 0.88), + match(["deepseek-v4-pro"], 0.84), + ) + + if task == "reasoning_math": + # Same calibration, five multilingual MGSM tasks: Gemini 3.5 Flash was + # 5/5 with the lowest cost and latency; four current flagships were 4/5 + # and Kimi K2.7 was 3/5. + return max( + base, + match(["gemini-3.5-flash"], 1), + match(["grok-4.5"], 0.93), + match(["claude-sonnet-5", "deepseek-v4-pro", "kimi-k3"], 0.9), + match(["kimi-k2.7"], 0.84), + ) + + if task == "vision": + return max( + base, + match(["gemini-3.1-pro"], 0.96), + match(["qwen3.7-max", "claude-sonnet-4.6", "kimi-k2.7", "grok-4.3"], 0.9), + ) + + if task == "long_context": + # Long-context eligibility is necessary but not sufficient: a provider + # can advertise a 1M window yet return an empty completion near that + # boundary. Keep the proven long-context flagship in the lead and put + # less-established alternatives in a separate affinity band so price + # alone cannot displace it. + return max( + base, + match(["gemini-3.1-pro"], 1), + match(["qwen3.7-max", "glm-5.2"], 0.89), + match(["gemini-3.5-flash"], 0.88), + match(["deepseek-v4-pro"], 0.85), + ) + + if task == "extraction": + # A structured extraction must preserve both the output contract and the + # source-language fields. For Mandarin input, keep the language-native + # Kimi candidate in a distinct affinity band. This is deliberately a + # candidate-pool decision (rather than a brittle post-hoc override): it + # still falls back normally if that model is unavailable or ineligible. + kimi_extraction_affinity = 1.0 if language == "zh" else 0.9 + return max( + base, + match(["gemini-3.5-flash", "gemini-2.5-flash", "gpt-4o-mini"], 0.9), + match(["claude-sonnet-5", "claude-sonnet-4.6"], 0.9), + match(["kimi-k3", "kimi-k2.7"], kimi_extraction_affinity), + ) + + return max(base, match(["gemini-3.5-flash", "gemini-2.5-flash", "kimi-k3", "kimi-k2.7"], 0.86)) + + +def evidence_candidates(task: TaskType) -> list[str]: + """Models with task-level calibration evidence, added to the tier chain.""" + if task == "code_agent": + return [ + "openai/gpt-5.3-codex", + "anthropic/claude-sonnet-5", + "openai/gpt-5-mini", + "google/gemini-3.5-flash", + "moonshot/kimi-k3", + "deepseek/deepseek-v4-pro", + ] + if task == "tool_agent": + return [ + "anthropic/claude-sonnet-5", + "anthropic/claude-opus-5", + "openai/gpt-5-mini", + "openai/gpt-4.1", + "openai/gpt-4o-mini", + "google/gemini-3.5-flash", + "openai/gpt-5.3-codex", + "moonshot/kimi-k3", + "deepseek/deepseek-v4-pro", + ] + if task == "tool_agent_parallel": + return [ + "anthropic/claude-opus-5", + "anthropic/claude-opus-4.8", + "anthropic/claude-sonnet-5", + "openai/gpt-5-mini", + "openai/gpt-4.1", + "openai/gpt-4o-mini", + "xai/grok-4.5", + "google/gemini-3.5-flash", + "deepseek/deepseek-v4-pro", + ] + if task == "long_context": + return [ + "google/gemini-3.1-pro", + "deepseek/deepseek-v4-pro", + "qwen/qwen3.7-max", + "zai/glm-5.2", + "google/gemini-3.5-flash", + ] + if task == "reasoning_mcq": + return [ + "google/gemini-3-flash-preview", + "google/gemini-3.5-flash", + "xai/grok-4.5", + "anthropic/claude-sonnet-5", + "deepseek/deepseek-v4-pro", + ] + if task == "reasoning_math": + return [ + "google/gemini-3.5-flash", + "xai/grok-4.5", + "anthropic/claude-sonnet-5", + "deepseek/deepseek-v4-pro", + "moonshot/kimi-k3", + ] + return [] + + +def is_eligible( + model_id: str, + features: TaskFeatures, + max_output_tokens: int, + options: RouterOptions, +) -> bool: + """Hard capability filter: capacity, tools, vision, structured output.""" + host_capabilities = options.get("model_capabilities") or {} + model = host_capabilities.get(model_id) or DEFAULT_MODEL_CAPABILITIES.get(model_id) + # Preserve compatibility for temporarily catalog-less fallback IDs. They are + # kept behind known-model candidates but are not silently dropped. + if not model: + return True + if features.needs_tools and not model["supports_tools"]: + return False + if features.needs_vision and not model["supports_vision"]: + return False + if features.needs_structured_output and not model["supports_tools"]: + return False + if model["max_output_tokens"] < max_output_tokens: + return False + return model["context_window"] >= (features.estimated_input_tokens + max_output_tokens) * 1.1 + + +def estimated_cost( + model_id: str, options: RouterOptions, input_tokens: int, output_tokens: int +) -> float: + price = options["model_pricing"].get(model_id) + if not price: + return math.inf + flat = price.get("flat_price") + if flat: + return float(flat) + return ( + input_tokens * price.get("input_price", 0) + output_tokens * price.get("output_price", 0) + ) / 1_000_000 + + +@dataclass(frozen=True) +class _ProfileScore: + quality: float | None + speed: float + tail_speed: float + reliability: float + freshness: float + + +def profile_score(model_id: str, options: RouterOptions, now: datetime) -> _ProfileScore | None: + """Weak speed/reliability priors, decayed by age and sample count.""" + host_performance = options.get("model_performance") or {} + profile: ModelPerformanceProfile | None = ( + host_performance.get(model_id) + or LIVE_MODEL_PROFILES.get(model_id) + or HISTORICAL_MODEL_PROFILES.get(model_id) + ) + if not profile: + return None + measured_at = parse_date(profile.get("measured_at", "")) + if measured_at is None: + return None + age_days = max(0.0, (now - measured_at).total_seconds() / 86_400) + # A 30-day half-life makes old data a tie-breaker only. Small probe runs are + # also weak evidence: three quick samples should not overturn a curated tier + # ordering merely because of a transient provider tail. Callers that inject + # an observation without a sample count retain the legacy full-confidence + # behaviour for compatibility. + samples = profile.get("samples") + sample_confidence = 1.0 if samples is None else min(1.0, max(0.0, samples) / 10) + freshness = math.pow(0.5, age_days / 30) * sample_confidence + intelligence_index = profile.get("intelligence_index") + quality = None if intelligence_index is None else min(1.0, intelligence_index / 50) + latency_ms = profile.get("latency_ms", 0) + speed = min( + 1.0, + (2_000 / max(500, latency_ms) + profile.get("output_tokens_per_second", 0) / 250) / 2, + ) + tail_speed = min(1.0, 3_000 / max(750, profile.get("p95_latency_ms", latency_ms))) + reliability = max(0.0, 1 - profile.get("error_rate", 0)) + return _ProfileScore( + quality=quality, + speed=speed, + tail_speed=tail_speed, + reliability=reliability, + freshness=freshness, + ) + + +_WEB_RESEARCH_FALLBACK_ORDER = [ + "anthropic/claude-sonnet-5", + "openai/gpt-5-mini", + "google/gemini-3.5-flash", + "anthropic/claude-opus-5", + "anthropic/claude-opus-4.8", + "openai/gpt-5.3-codex", +] + + +class PortfolioStrategy: + """Candidate router used for Auto. + + Rules still set the capability tier; V3 ranks within it. + """ + + name = "portfolio" + + def route( + self, + prompt: str, + system_prompt: str | None, + max_output_tokens: int, + options: RouterOptions, + ) -> RoutingDecision: + features = classify_task(prompt, system_prompt, options) + rules_options: RouterOptions = dict(options) # type: ignore[assignment] + rules_options["requires_tools"] = features.needs_tools + base = RulesStrategy().route(prompt, system_prompt, max_output_tokens, rules_options) + tier_configs = base.get("tier_configs") + if not tier_configs: + return base + + target_tier: Tier = ( + "REASONING" + if features.task_type in ("reasoning_mcq", "reasoning_math") + and base["tier"] in ("SIMPLE", "MEDIUM") + else base["tier"] + ) + tier_config = tier_configs.get(target_tier) + configured_candidates = get_fallback_chain(target_tier, tier_configs) if tier_config else [] + chain = [ + model + for model in dict.fromkeys( + [*configured_candidates, *evidence_candidates(features.task_type)] + ) + if isinstance(model, str) and model + ] + eligible = [ + model for model in chain if is_eligible(model, features, max_output_tokens, options) + ] + eligible_candidates = eligible if eligible else chain + if not eligible_candidates: + return base + + routing_profile = options.get("routing_profile") + portfolio = options["config"].get("portfolio") or DEFAULT_PORTFOLIO_WEIGHTS + profile_weights: PortfolioBandWeights + if routing_profile == "eco": + profile_weights = portfolio["eco"] + base_floor_gap = portfolio["affinity_floor_gap"]["eco"] + elif routing_profile == "premium": + profile_weights = portfolio["premium"] + base_floor_gap = portfolio["affinity_floor_gap"]["premium"] + else: + profile_weights = portfolio["auto"] + base_floor_gap = portfolio["affinity_floor_gap"]["auto"] + + affinities = { + model: affinity( + model, + features.task_type, + features.language, + features.agent_domain, + features.deep_web_research, + features.agent_risk, + features.terminal_tool_signal, + features.terminal_safety_sensitive, + ) + for model in eligible_candidates + } + best_affinity = max(affinities.values()) + specific_affinity = [ + model for model in eligible_candidates if affinities[model] > _AFFINITY_BASE + ] + # A tier's fallback list is primarily an availability/recovery chain, not + # a set of equally validated substitutes. Re-ranking every fallback lets + # a cheap generic model displace the curated primary merely because it + # has a favourable short performance probe. Only promote models with + # explicit task affinity; otherwise retain the first eligible tier model. + affinity_pool = specific_affinity if specific_affinity else [eligible_candidates[0]] + # Generic Terminal work has much wider trajectory variance than a + # BFCL-like one-turn parallel call. Keep the strong-model safety band, + # but admit the next capable tier so Auto's cost/reliability score can + # reject an Opus primary that is materially more expensive without + # measured benefit. + affinity_floor_gap = ( + max(base_floor_gap, 0.15 if features.terminal_safety_sensitive else 0.12) + if features.terminal_tool_signal + else base_floor_gap + ) + candidates = [ + model + for model in affinity_pool + if affinities[model] >= best_affinity - affinity_floor_gap + ] + costs = [ + estimated_cost(model, options, features.estimated_input_tokens, max_output_tokens) + for model in candidates + ] + finite_costs = [cost for cost in costs if math.isfinite(cost)] + min_cost = min(finite_costs) if finite_costs else 0.0 + max_cost = max(finite_costs) if finite_costs else 1.0 + + now = as_utc(options.get("now")) + ranked_entries: list[CandidateScore] = [] + for index, model in enumerate(candidates): + cost = estimated_cost( + model, options, features.estimated_input_tokens, max_output_tokens + ) + cost_score = ( + 1 - (cost - min_cost) / (max_cost - min_cost) + if math.isfinite(cost) and max_cost > min_cost + else 0.5 + ) + capability_score = ( + 1.0 if is_eligible(model, features, max_output_tokens, options) else 0.0 + ) + profile = profile_score(model, options, now) + # Fresh observations can refine affinity. Historical observations + # fade quickly and never replace task-level RouterBench evidence. + model_affinity = affinities[model] + if profile is None or profile.quality is None: + observed_quality = model_affinity + else: + observed_quality = ( + model_affinity * (1 - profile.freshness) + profile.quality * profile.freshness + ) + observed_speed = profile.speed * profile.freshness if profile else 0.5 + observed_tail_speed = profile.tail_speed * profile.freshness if profile else 0.5 + observed_reliability = ( + profile.reliability * profile.freshness + (1 - profile.freshness) + if profile + else 1.0 + ) + # Preserve a small amount of the hand-curated fallback order while + # V3's task affinity and real request constraints do the main work. + legacy_score = 1 - index / max(1, len(candidates) - 1) + quality_weight = profile_weights["quality"] + ( + portfolio["high_stakes_boost"]["quality"] if features.high_stakes else 0 + ) + speed_score = observed_tail_speed if features.latency_sensitive else observed_speed + speed_weight = profile_weights["speed"] + ( + portfolio["latency_sensitive_speed_boost"] if features.latency_sensitive else 0 + ) + reliability_weight = profile_weights["reliability"] + ( + portfolio["high_stakes_boost"]["reliability"] if features.high_stakes else 0 + ) + score = ( + observed_quality * quality_weight + + capability_score * profile_weights["capability"] + + cost_score * profile_weights["cost"] + + speed_score * speed_weight + + observed_reliability * reliability_weight + + legacy_score * profile_weights["legacy"] + ) + ranked_entries.append( + { + "model": model, + "score": score, + "quality": observed_quality, + "cost": cost_score, + "speed": speed_score, + "reliability": observed_reliability, + } + ) + ranked_entries.sort(key=lambda entry: entry["score"], reverse=True) + scored_models = [entry["model"] for entry in ranked_entries] + # The affinity floor controls which models may compete for the primary; + # it must not erase availability fallbacks. Append all remaining eligible + # models in their curated chain order after the scored primary pool. + if features.agent_domain == "web_research": + ranked = [ + *scored_models, + *[ + model + for model in _WEB_RESEARCH_FALLBACK_ORDER + if model in eligible_candidates and model not in scored_models + ], + *[ + model + for model in eligible_candidates + if model not in scored_models and model not in _WEB_RESEARCH_FALLBACK_ORDER + ], + ] + else: + ranked = [ + *scored_models, + *[model for model in eligible_candidates if model not in scored_models], + ] + + model = ranked[0] if ranked else base["model"] + selected_tier_configs: dict[str, TierConfig] = { + **tier_configs, + target_tier: {"primary": model, "fallback": ranked[1:]}, + } + # select_model only reads the selected tier; retain the complete tier map + # for host fallback. + decision = select_model( + target_tier, + base["confidence"], + "portfolio", + f"{base['reasoning']} | v3 task={features.task_type}" + f" agentRisk={features.agent_risk}" + f" deepWebResearch={js_bool(features.deep_web_research)}" + f" terminalCode={js_bool(features.implicit_terminal_code)}" + f" terminalSafety={js_bool(features.terminal_safety_sensitive)}" + f" candidates={len(ranked)}", + selected_tier_configs, + options["model_pricing"], + features.estimated_input_tokens, + max_output_tokens, + routing_profile, + base.get("agentic_score"), + ) + decision["tier_configs"] = selected_tier_configs + profile_value = base.get("profile") + if profile_value is not None: + decision["profile"] = profile_value + decision["candidates"] = ranked + decision["candidate_scores"] = ranked_entries + decision["task_type"] = features.task_type + decision["router_version"] = "v3-portfolio" + return decision diff --git a/blockrun_llm/router_core/rules.py b/blockrun_llm/router_core/rules.py new file mode 100644 index 0000000..baebe18 --- /dev/null +++ b/blockrun_llm/router_core/rules.py @@ -0,0 +1,326 @@ +""" +Rule-Based Classifier (v2 — Weighted Scoring) + +Python port of ``@blockrun/router-core`` ``rules.ts``. + +Scores a request across 15 weighted dimensions and maps the aggregate score to +a tier using configurable boundaries. Confidence is calibrated via sigmoid — +low confidence triggers the fallback classifier. + +Handles 70-80% of requests in < 1ms with zero cost. +""" + +from __future__ import annotations + +import math + +from ._js import js_regex +from .types import DimensionScore, ScoringConfig, ScoringResult, Tier, TokenCountThresholds + +_MULTI_STEP_PATTERNS = [ + js_regex(r"first.*then", ignorecase=True), + js_regex(r"step \d", ignorecase=True), + js_regex(r"\d\.\s"), +] +_QUESTION_MARK = js_regex(r"\?") + + +# ─── Dimension Scorers ─── +# Each returns a score in [-1, 1] and an optional signal string. + + +def _score_token_count( + estimated_tokens: int, + thresholds: TokenCountThresholds, +) -> DimensionScore: + if estimated_tokens < thresholds["simple"]: + return { + "name": "tokenCount", + "score": -1.0, + "signal": f"short ({estimated_tokens} tokens)", + } + if estimated_tokens > thresholds["complex"]: + return {"name": "tokenCount", "score": 1.0, "signal": f"long ({estimated_tokens} tokens)"} + return {"name": "tokenCount", "score": 0, "signal": None} + + +def _score_keyword_match( + text: str, + keywords: list[str], + name: str, + signal_label: str, + thresholds: tuple[int, int], + scores: tuple[float, float, float], +) -> DimensionScore: + """``thresholds`` is ``(low, high)``; ``scores`` is ``(none, low, high)``.""" + low_threshold, high_threshold = thresholds + none_score, low_score, high_score = scores + matches = [keyword for keyword in keywords if keyword.lower() in text] + if len(matches) >= high_threshold: + return { + "name": name, + "score": high_score, + "signal": f"{signal_label} ({', '.join(matches[:3])})", + } + if len(matches) >= low_threshold: + return { + "name": name, + "score": low_score, + "signal": f"{signal_label} ({', '.join(matches[:3])})", + } + return {"name": name, "score": none_score, "signal": None} + + +def _score_multi_step(text: str) -> DimensionScore: + if any(pattern.search(text) for pattern in _MULTI_STEP_PATTERNS): + return {"name": "multiStepPatterns", "score": 0.5, "signal": "multi-step"} + return {"name": "multiStepPatterns", "score": 0, "signal": None} + + +def _score_question_complexity(prompt: str) -> DimensionScore: + count = len(_QUESTION_MARK.findall(prompt)) + if count > 3: + return {"name": "questionComplexity", "score": 0.5, "signal": f"{count} questions"} + return {"name": "questionComplexity", "score": 0, "signal": None} + + +def _score_agentic_task(text: str, keywords: list[str]) -> tuple[DimensionScore, float]: + """Score agentic task indicators. + + Returns ``(dimension, agentic_score)`` where the 0-1 agentic score is based + on keyword matches: 4+ matches = 1.0 (high agentic), 3 = 0.6 (moderate, + triggers auto-agentic mode), 1-2 = 0.2 (low). Thresholds were raised + because common keywords were pruned from the list. + """ + match_count = 0 + signals: list[str] = [] + + for keyword in keywords: + if keyword.lower() in text: + match_count += 1 + if len(signals) < 3: + signals.append(keyword) + + if match_count >= 4: + return ( + {"name": "agenticTask", "score": 1.0, "signal": f"agentic ({', '.join(signals)})"}, + 1.0, + ) + if match_count >= 3: + return ( + {"name": "agenticTask", "score": 0.6, "signal": f"agentic ({', '.join(signals)})"}, + 0.6, + ) + if match_count >= 1: + return ( + { + "name": "agenticTask", + "score": 0.2, + "signal": f"agentic-light ({', '.join(signals)})", + }, + 0.2, + ) + + return ({"name": "agenticTask", "score": 0, "signal": None}, 0.0) + + +# ─── Main Classifier ─── + + +def classify_by_rules( + prompt: str, + system_prompt: str | None, + estimated_tokens: int, + config: ScoringConfig, +) -> ScoringResult: + """Classify a request into a tier with calibrated confidence.""" + # Score against user prompt only — system prompts contain boilerplate + # keywords (tool definitions, skill descriptions, behavioral rules) that + # dominate scoring and make every request score identically. + user_text = prompt.lower() + + # Score the base dimensions against user text only; the agentic dimension is + # appended below, so the scored total is one more than this list. + dimensions: list[DimensionScore] = [ + # Token count uses total estimated tokens (system + user) — context size + # matters for model selection. + _score_token_count(estimated_tokens, config["token_count_thresholds"]), + _score_keyword_match( + user_text, config["code_keywords"], "codePresence", "code", (1, 2), (0, 0.5, 1.0) + ), + _score_keyword_match( + user_text, + config["reasoning_keywords"], + "reasoningMarkers", + "reasoning", + (1, 2), + (0, 0.7, 1.0), + ), + _score_keyword_match( + user_text, + config["technical_keywords"], + "technicalTerms", + "technical", + (2, 4), + (0, 0.5, 1.0), + ), + _score_keyword_match( + user_text, + config["creative_keywords"], + "creativeMarkers", + "creative", + (1, 2), + (0, 0.5, 0.7), + ), + _score_keyword_match( + user_text, + config["simple_keywords"], + "simpleIndicators", + "simple", + (1, 2), + (0, -1.0, -1.0), + ), + _score_multi_step(user_text), + _score_question_complexity(prompt), + # 6 new dimensions + _score_keyword_match( + user_text, + config["imperative_verbs"], + "imperativeVerbs", + "imperative", + (1, 2), + (0, 0.3, 0.5), + ), + _score_keyword_match( + user_text, + config["constraint_indicators"], + "constraintCount", + "constraints", + (1, 3), + (0, 0.3, 0.7), + ), + _score_keyword_match( + user_text, + config["output_format_keywords"], + "outputFormat", + "format", + (1, 2), + (0, 0.4, 0.7), + ), + _score_keyword_match( + user_text, + config["reference_keywords"], + "referenceComplexity", + "references", + (1, 2), + (0, 0.3, 0.5), + ), + _score_keyword_match( + user_text, + config["negation_keywords"], + "negationComplexity", + "negation", + (2, 3), + (0, 0.3, 0.5), + ), + _score_keyword_match( + user_text, + config["domain_specific_keywords"], + "domainSpecificity", + "domain-specific", + (1, 2), + (0, 0.5, 0.8), + ), + ] + + # Score agentic task indicators — user prompt only. The system prompt + # describes assistant behavior, not the user's intent: a coding assistant + # system prompt with "edit files" / "fix bugs" should NOT force every + # request into agentic mode. + agentic_dimension, agentic_score = _score_agentic_task( + user_text, config["agentic_task_keywords"] + ) + dimensions.append(agentic_dimension) + + signals = [dimension["signal"] for dimension in dimensions if dimension["signal"] is not None] + + weights = config["dimension_weights"] + weighted_score = sum( + dimension["score"] * weights.get(dimension["name"], 0) for dimension in dimensions + ) + + # Count reasoning markers for override — only the USER prompt, so a system + # prompt saying "step by step" cannot force REASONING for simple queries. + reasoning_matches = [ + keyword for keyword in config["reasoning_keywords"] if keyword.lower() in user_text + ] + + # Direct reasoning override: 2+ reasoning markers = high confidence REASONING + if len(reasoning_matches) >= 2: + confidence = _calibrate_confidence( + max(weighted_score, 0.3), # ensure positive for confidence calc + config["confidence_steepness"], + ) + return { + "score": weighted_score, + "tier": "REASONING", + "confidence": max(confidence, 0.85), + "signals": signals, + "agentic_score": agentic_score, + "dimensions": dimensions, + } + + # Map weighted score to tier using boundaries + boundaries = config["tier_boundaries"] + simple_medium = boundaries["simple_medium"] + medium_complex = boundaries["medium_complex"] + complex_reasoning = boundaries["complex_reasoning"] + tier: Tier + if weighted_score < simple_medium: + tier = "SIMPLE" + distance_from_boundary = simple_medium - weighted_score + elif weighted_score < medium_complex: + tier = "MEDIUM" + distance_from_boundary = min( + weighted_score - simple_medium, medium_complex - weighted_score + ) + elif weighted_score < complex_reasoning: + tier = "COMPLEX" + distance_from_boundary = min( + weighted_score - medium_complex, complex_reasoning - weighted_score + ) + else: + tier = "REASONING" + distance_from_boundary = weighted_score - complex_reasoning + + # Calibrate confidence via sigmoid of distance from nearest boundary + confidence = _calibrate_confidence(distance_from_boundary, config["confidence_steepness"]) + + # If confidence is below threshold → ambiguous + if confidence < config["confidence_threshold"]: + return { + "score": weighted_score, + "tier": None, + "confidence": confidence, + "signals": signals, + "agentic_score": agentic_score, + "dimensions": dimensions, + } + + return { + "score": weighted_score, + "tier": tier, + "confidence": confidence, + "signals": signals, + "agentic_score": agentic_score, + "dimensions": dimensions, + } + + +def _calibrate_confidence(distance: float, steepness: float) -> float: + """Sigmoid confidence calibration onto the [0.5, 1.0] range.""" + try: + return 1 / (1 + math.exp(-steepness * distance)) + except OverflowError: + # JS evaluates exp() to Infinity here and collapses to 0; Python raises. + return 0.0 diff --git a/blockrun_llm/router_core/selector.py b/blockrun_llm/router_core/selector.py new file mode 100644 index 0000000..7546465 --- /dev/null +++ b/blockrun_llm/router_core/selector.py @@ -0,0 +1,244 @@ +""" +Tier → Model Selection + +Python port of ``@blockrun/router-core`` ``selector.ts``. + +Maps a classification tier to the cheapest capable model and builds +RoutingDecision metadata with cost estimates and savings. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Mapping + +from .types import Capacity, Method, ModelPricing, RoutingDecision, Tier, TierConfig + +# The savings baseline is a price anchor, not "the current flagship" — it is +# deliberately NOT bumped every time a new Opus ships. Opus 4.7, 4.8 and 5 all +# bill $5/$25, so moving it would change no reported number while breaking +# comparability with historical journal entries. Only move it if the Opus tier +# itself is repriced. +BASELINE_MODEL_ID = "anthropic/claude-opus-4.7" + +# Hardcoded fallback: Claude Opus 4.7 pricing (per 1M tokens), used when the +# baseline model is absent from the dynamic pricing map. +BASELINE_INPUT_PRICE = 5.0 +BASELINE_OUTPUT_PRICE = 25.0 + +# Server-side margin applied to all x402 payments (must match the blockrun +# server's MARGIN_PERCENT). +SERVER_MARGIN_PERCENT = 5 +# Minimum payment enforced by the CDP Facilitator (must match the blockrun +# server's MIN_PAYMENT_USD). +MIN_PAYMENT_USD = 0.001 + + +def _flat_price(pricing: ModelPricing | None) -> float | None: + """Active promo flat price, or ``None`` for per-token billing. + + The catalog reports ``flat_price: 0`` for per-token models where the + TypeScript host omits the field, so a falsy value means "not flat". + """ + if not pricing: + return None + flat = pricing.get("flat_price") + return float(flat) if flat else None + + +def _baseline_cost( + model_pricing: Mapping[str, ModelPricing], + estimated_input_tokens: int, + max_output_tokens: int, +) -> float: + """What the premium reference model would cost for the same request.""" + opus_pricing = model_pricing.get(BASELINE_MODEL_ID) + opus_input_price = (opus_pricing or {}).get("input_price", BASELINE_INPUT_PRICE) + opus_output_price = (opus_pricing or {}).get("output_price", BASELINE_OUTPUT_PRICE) + baseline_input = (estimated_input_tokens / 1_000_000) * opus_input_price + baseline_output = (max_output_tokens / 1_000_000) * opus_output_price + return baseline_input + baseline_output + + +def _savings(cost_estimate: float, baseline_cost: float, routing_profile: str | None) -> float: + # Premium profile doesn't calculate savings (it's about quality, not cost). + if routing_profile == "premium": + return 0.0 + if baseline_cost > 0: + return max(0.0, (baseline_cost - cost_estimate) / baseline_cost) + return 0.0 + + +def select_model( + tier: Tier, + confidence: float, + method: Method, + reasoning: str, + tier_configs: Mapping[str, TierConfig], + model_pricing: Mapping[str, ModelPricing], + estimated_input_tokens: int, + max_output_tokens: int, + routing_profile: str | None = None, + agentic_score: float | None = None, +) -> RoutingDecision: + """Select the primary model for a tier and build the RoutingDecision.""" + tier_config = tier_configs[tier] + model = tier_config["primary"] + pricing = model_pricing.get(model) + + flat = _flat_price(pricing) + if flat is not None: + cost_estimate = flat + else: + input_price = (pricing or {}).get("input_price", 0) + output_price = (pricing or {}).get("output_price", 0) + cost_estimate = (estimated_input_tokens / 1_000_000) * input_price + ( + max_output_tokens / 1_000_000 + ) * output_price + + baseline_cost = _baseline_cost(model_pricing, estimated_input_tokens, max_output_tokens) + + decision: RoutingDecision = { + "model": model, + "tier": tier, + "confidence": confidence, + "method": method, + "reasoning": reasoning, + "cost_estimate": cost_estimate, + "baseline_cost": baseline_cost, + "savings": _savings(cost_estimate, baseline_cost, routing_profile), + } + if agentic_score is not None: + decision["agentic_score"] = agentic_score + return decision + + +def get_fallback_chain(tier: Tier, tier_configs: Mapping[str, TierConfig]) -> list[str]: + """Get the ordered fallback chain for a tier: ``[primary, *fallbacks]``.""" + config = tier_configs[tier] + return [config["primary"], *config["fallback"]] + + +def calculate_model_cost( + model: str, + model_pricing: Mapping[str, ModelPricing], + estimated_input_tokens: int, + max_output_tokens: int, + routing_profile: str | None = None, +) -> dict[str, float]: + """Calculate cost for a specific model (used when a fallback model is used). + + Includes the server margin and the facilitator minimum so the estimate + matches the actual x402 charge. + """ + pricing = model_pricing.get(model) + + flat = _flat_price(pricing) + if flat is not None: + # Active promo: fixed cost per request + cost_estimate = max(flat * (1 + SERVER_MARGIN_PERCENT / 100), MIN_PAYMENT_USD) + else: + # Defensive: guard against undefined price fields (not just absent pricing) + input_price = (pricing or {}).get("input_price", 0) + output_price = (pricing or {}).get("output_price", 0) + input_cost = (estimated_input_tokens / 1_000_000) * input_price + output_cost = (max_output_tokens / 1_000_000) * output_price + cost_estimate = max( + (input_cost + output_cost) * (1 + SERVER_MARGIN_PERCENT / 100), MIN_PAYMENT_USD + ) + + baseline_cost = _baseline_cost(model_pricing, estimated_input_tokens, max_output_tokens) + return { + "cost_estimate": cost_estimate, + "baseline_cost": baseline_cost, + "savings": _savings(cost_estimate, baseline_cost, routing_profile), + } + + +def filter_by_tool_calling( + models: list[str], + has_tools: bool, + supports_tool_calling: Callable[[str], bool], +) -> list[str]: + """Keep only models that support tool calling when the request has tools. + + When every model lacks tool calling the full list is returned unchanged — + better to let the API error than to produce an empty chain. + """ + if not has_tools: + return models + filtered = [model for model in models if supports_tool_calling(model)] + return filtered if filtered else models + + +def filter_by_vision( + models: list[str], + has_vision: bool, + supports_vision: Callable[[str], bool], +) -> list[str]: + """Keep only vision-capable models when the request carries images. + + Same empty-chain safety net as :func:`filter_by_tool_calling`. + """ + if not has_vision: + return models + filtered = [model for model in models if supports_vision(model)] + return filtered if filtered else models + + +def filter_by_exclude_list(models: list[str], exclude_list: Iterable[str]) -> list[str]: + """Remove user-excluded models, with the same empty-chain safety net.""" + excluded = set(exclude_list) + if not excluded: + return models + filtered = [model for model in models if model not in excluded] + return filtered if filtered else models + + +def get_fallback_chain_filtered( + tier: Tier, + tier_configs: Mapping[str, TierConfig], + estimated_total_tokens: int, + get_context_window: Callable[[str], int | None], +) -> list[str]: + """Get the tier's fallback chain filtered by context length. + + Models with an unknown context window are kept (let the API reject them), + and an entirely filtered-out chain falls back to the full chain. + """ + full_chain = get_fallback_chain(tier, tier_configs) + + filtered = [] + for model_id in full_chain: + context_window = get_context_window(model_id) + # Unknown model - include it (let API reject if needed) + # Add 10% buffer for safety + if context_window is None or context_window >= estimated_total_tokens * 1.1: + filtered.append(model_id) + + return filtered if filtered else full_chain + + +def filter_candidates_by_capacity( + models: list[str], + estimated_input_tokens: int, + requested_output_tokens: int, + get_capabilities: Callable[[str], Capacity | None], +) -> list[str]: + """Filter an already-ranked candidate list by context and output capacity. + + Unlike :func:`get_fallback_chain_filtered` this supports the V3 portfolio + order and returns an empty list when nothing fits. + """ + filtered = [] + for model_id in models: + capabilities = get_capabilities(model_id) + if not capabilities: + filtered.append(model_id) + continue + if ( + capabilities["context_window"] + >= (estimated_input_tokens + requested_output_tokens) * 1.1 + and capabilities["max_output"] >= requested_output_tokens + ): + filtered.append(model_id) + return filtered diff --git a/blockrun_llm/router_core/strategy.py b/blockrun_llm/router_core/strategy.py new file mode 100644 index 0000000..dc6a196 --- /dev/null +++ b/blockrun_llm/router_core/strategy.py @@ -0,0 +1,276 @@ +""" +Router Strategy Registry + +Python port of ``@blockrun/router-core`` ``strategy.ts``. + +Pluggable strategy system for request routing. +Default: RulesStrategy — identical to the original inline route() logic, <1ms. +""" + +from __future__ import annotations + +import copy +import math +from datetime import datetime +from typing import Protocol + +from ._js import as_utc, js_regex, parse_date, to_fixed +from .rules import classify_by_rules +from .selector import select_model +from .types import ( + TIER_RANK, + Profile, + Promotion, + RouterOptions, + RoutingDecision, + Tier, + TierConfig, +) + +_STRUCTURED_OUTPUT = js_regex(r"json|structured|schema", ignorecase=True) + + +class RouterStrategy(Protocol): + """Interface implemented by every routing strategy.""" + + name: str + + def route( + self, + prompt: str, + system_prompt: str | None, + max_output_tokens: int, + options: RouterOptions, + ) -> RoutingDecision: ... + + +def sample_prompt(value: str, scan_limit: int) -> str: + """Sample both ends of a long prompt, keeping instructions at either edge.""" + if len(value) <= scan_limit: + return value + prefix_length = math.ceil(scan_limit / 2) + suffix_length = scan_limit - prefix_length + suffix = value[-suffix_length:] if suffix_length else value + return f"{value[:prefix_length]}\n{suffix}" + + +def scan_limit_for(options: RouterOptions) -> int: + return max(1, min(8_000, options["config"]["classifier"]["prompt_truncation_chars"])) + + +def apply_promotions( + tier_configs: dict[str, TierConfig], + promotions: list[Promotion] | None, + profile: Profile, + now: datetime | None = None, +) -> dict[str, TierConfig]: + """Apply active time-windowed promotions to tier configs. + + Returns a new tier-config mapping with promotion overrides merged in. + Expired or not-yet-active promotions are ignored. + """ + if not promotions: + return tier_configs + + current = now if now is not None else as_utc(None) + result = tier_configs + for promo in promotions: + start = parse_date(promo.get("start_date", "")) + end = parse_date(promo.get("end_date", "")) + if start is None or end is None: + continue + if current < start or current >= end: + continue + + profiles = promo.get("profiles") + if profiles and profile not in profiles: + continue + + # Shallow-clone on first mutation + if result is tier_configs: + result = {tier: copy.copy(config) for tier, config in tier_configs.items()} + + for tier, override in promo.get("tier_overrides", {}).items(): + if tier not in result: + continue + primary = override.get("primary") + fallback = override.get("fallback") + if primary: + result[tier]["primary"] = primary + if fallback: + result[tier]["fallback"] = fallback + + return result + + +class RulesStrategy: + """Rules-based routing strategy. + + Attaches ``tier_configs`` and ``profile`` to the decision for downstream use. + """ + + name = "rules" + + def route( + self, + prompt: str, + system_prompt: str | None, + max_output_tokens: int, + options: RouterOptions, + ) -> RoutingDecision: + config = options["config"] + model_pricing = options["model_pricing"] + + # Estimate input tokens (~4 chars per token) + full_text = f"{system_prompt or ''} {prompt}" + estimated_tokens = math.ceil(len(full_text) / 4) + scan_limit = scan_limit_for(options) + scanned_prompt = sample_prompt(prompt, scan_limit) + scanned_system_prompt = sample_prompt(system_prompt, scan_limit) if system_prompt else None + + # --- Rule-based classification (runs first to get agentic_score) --- + rule_result = classify_by_rules( + scanned_prompt, scanned_system_prompt, estimated_tokens, config["scoring"] + ) + + # --- Select tier configs based on routing profile --- + routing_profile = options.get("routing_profile") + profile: Profile + if routing_profile == "eco": + # `eco_tiers: None` explicitly disables the special eco tier set + # while keeping eco routing semantics. Fall back to regular tiers + # instead of dropping into auto routing (which could select agentic + # tiers). + eco_tiers = config.get("eco_tiers") + tier_configs = eco_tiers if eco_tiers else config["tiers"] + profile_suffix = " | eco" if eco_tiers else " | eco (default tiers)" + profile = "eco" + elif routing_profile == "premium": + # `premium_tiers: None` disables the premium-specific tier set but + # the request is still a premium-profile request, so use regular + # tiers while preserving premium metadata/cost semantics. + premium_tiers = config.get("premium_tiers") + tier_configs = premium_tiers if premium_tiers else config["tiers"] + profile_suffix = " | premium" if premium_tiers else " | premium (default tiers)" + profile = "premium" + else: + # Auto profile (or unset): intelligent routing with agentic detection. + # + # `agentic_mode` semantics: + # - True -> force agentic tiers (ignore heuristics) + # - False -> disable agentic tiers entirely (even if tools present) + # - unset -> auto-detect via heuristics (tools present OR high + # agentic score) + agentic_score = rule_result.get("agentic_score", 0) or 0 + is_auto_agentic = agentic_score >= 0.5 + agentic_mode_setting = config["overrides"].get("agentic_mode") + requires_tools = options.get("requires_tools") + has_tools_in_request = ( + requires_tools if requires_tools is not None else options.get("has_tools", False) + ) + agentic_tiers = config.get("agentic_tiers") + if agentic_mode_setting is False: + # Explicitly disabled — never use agentic tiers + use_agentic_tiers = False + elif agentic_mode_setting is True: + # Explicitly enabled — use agentic tiers if available + use_agentic_tiers = agentic_tiers is not None + else: + use_agentic_tiers = bool( + (has_tools_in_request or is_auto_agentic) and agentic_tiers is not None + ) + if use_agentic_tiers and agentic_tiers is not None: + tier_configs = agentic_tiers + profile_suffix = f" | agentic{' (tools)' if has_tools_in_request else ''}" + profile = "agentic" + else: + tier_configs = config["tiers"] + profile_suffix = "" + profile = "auto" + + # Apply time-windowed promotions + now = as_utc(options.get("now")) + tier_configs = apply_promotions(tier_configs, config.get("promotions"), profile, now) + + agentic_score_value = rule_result.get("agentic_score") + + # --- Override: large context → force COMPLEX --- + force_complex_at = config["overrides"]["max_tokens_force_complex"] + if estimated_tokens > force_complex_at: + decision = select_model( + "COMPLEX", + 0.95, + "rules", + f"Input exceeds {force_complex_at} tokens{profile_suffix}", + tier_configs, + model_pricing, + estimated_tokens, + max_output_tokens, + routing_profile, + agentic_score_value, + ) + decision["tier_configs"] = tier_configs + decision["profile"] = profile + return decision + + # Structured output detection + has_structured_output = options.get("requires_structured_output") is True or ( + bool(_STRUCTURED_OUTPUT.search(scanned_system_prompt)) + if scanned_system_prompt + else False + ) + + tier: Tier + signals = ", ".join(rule_result.get("signals", [])) + reasoning = f"score={to_fixed(rule_result['score'], 2)} | {signals}" + + if rule_result.get("tier") is not None: + tier = rule_result["tier"] # type: ignore[assignment] + confidence = rule_result["confidence"] + else: + # Ambiguous — default to configurable tier (no external API call) + tier = config["overrides"]["ambiguous_default_tier"] + confidence = 0.5 + reasoning += f" | ambiguous -> default: {tier}" + + # Apply structured output minimum tier + if has_structured_output: + min_tier = config["overrides"]["structured_output_min_tier"] + if TIER_RANK[tier] < TIER_RANK[min_tier]: + reasoning += f" | upgraded to {min_tier} (structured output)" + tier = min_tier + + # Add routing profile suffix to reasoning + reasoning += profile_suffix + + decision = select_model( + tier, + confidence, + "rules", + reasoning, + tier_configs, + model_pricing, + estimated_tokens, + max_output_tokens, + routing_profile, + agentic_score_value, + ) + decision["tier_configs"] = tier_configs + decision["profile"] = profile + return decision + + +# --- Strategy Registry --- + +_registry: dict[str, RouterStrategy] = {"rules": RulesStrategy()} + + +def get_strategy(name: str) -> RouterStrategy: + strategy = _registry.get(name) + if strategy is None: + raise ValueError(f"Unknown routing strategy: {name}") + return strategy + + +def register_strategy(strategy: RouterStrategy) -> None: + _registry[strategy.name] = strategy diff --git a/blockrun_llm/router_core/tool_intent.py b/blockrun_llm/router_core/tool_intent.py new file mode 100644 index 0000000..5be2fb3 --- /dev/null +++ b/blockrun_llm/router_core/tool_intent.py @@ -0,0 +1,72 @@ +""" +Whether the request actually requires an external action/tool, as distinct +from merely being sent by a host that exposes tools on every turn. + +Python port of ``@blockrun/router-core`` ``tool-intent.ts``. + +The detector intentionally looks for action+target pairs. A generic factual or +multiple-choice question must stay false even when the host attaches a large +tool schema; otherwise every tool-enabled host turn is over-routed as an agent +task and models may browse or mutate state unnecessarily. +""" + +from __future__ import annotations + +from typing import Any + +from ._js import js_regex + +# System prompts commonly describe every tool a host exposes. They are not +# evidence that the user asked to perform an action on this turn. Explicit host +# requirements should use tool_choice / requires_tools instead. +_EXPLICIT_TOOL = js_regex( + r"\b(?:use|call|invoke)\s+(?:the\s+)?[\w.-]+\s+(?:tool|function|api)\b|\btool[_ -]?call\b" + r"|使用.{0,20}(?:工具|函数|接口)|调用.{0,20}(?:工具|函数|接口)", + ignorecase=True, +) +_CODE_ENVIRONMENT = js_regex( + r"\b(?:run|execute)\s+(?:the\s+)?(?:tests?|command|script|build|linter)" + r"|\b(?:edit|modify|patch|create|write|save|delete|rename|move|inspect|read)\b.{0,60}" + r"\b(?:file|repository|repo|codebase|directory|folder)\b" + r"|\b(?:terminal|shell|bash|zsh|pytest|npm test|pnpm test|git\s+(?:status|diff|commit)|docker)\b" + r"|(?:运行|执行).{0,20}(?:测试|命令|脚本|构建)" + r"|(?:修改|编辑|修复|创建|读取|检查|保存).{0,30}(?:文件|仓库|代码库|目录)", + ignorecase=True, +) +_WEB_ACTION = js_regex( + r"\b(?:browse|search|look up|fetch|open)\b.{0,80}" + r"\b(?:web|website|url|online|documentation|docs|news|weather|price)\b" + r"|(?:浏览|搜索|查询|打开).{0,30}(?:网页|网站|链接|文档|新闻|天气|价格)", + ignorecase=True, +) +_STATEFUL_ACTION = js_regex( + r"\b(?:refund|cancel|book|reserve|purchase|buy|return|exchange|transfer|update|change)\b.{0,80}" + r"\b(?:order|booking|reservation|account|address|payment|subscription|ticket|flight|item)\b" + r"|(?:退款|取消|预订|购买|退货|换货|转账|更新|修改).{0,30}" + r"(?:订单|预订|账户|地址|付款|订阅|票|航班|商品)", + ignorecase=True, +) + + +def infer_tool_requirement( + prompt: str, + system_prompt: str | None = None, + tool_choice: Any = None, +) -> bool: + """Return ``True`` when this turn actually asks for a tool action.""" + # OpenAI-compatible clients can state this requirement directly. Treat that + # protocol signal as authoritative instead of trying to infer it from prose. + if tool_choice == "none": + return False + if tool_choice == "required": + return True + if isinstance(tool_choice, dict) and tool_choice.get("type") == "function": + return True + + text = prompt + return bool( + _EXPLICIT_TOOL.search(text) + or _CODE_ENVIRONMENT.search(text) + or _WEB_ACTION.search(text) + or _STATEFUL_ACTION.search(text) + ) diff --git a/blockrun_llm/router_core/types.py b/blockrun_llm/router_core/types.py new file mode 100644 index 0000000..9b79ffd --- /dev/null +++ b/blockrun_llm/router_core/types.py @@ -0,0 +1,307 @@ +""" +Router Core types — Python port of ``@blockrun/router-core`` ``types.ts``. + +Four classification tiers — REASONING is distinct from COMPLEX because +reasoning tasks need different models (o3, gemini-pro) than general complex +tasks (gpt-4o, sonnet-4). + +Scoring uses weighted float dimensions with sigmoid confidence calibration. + +Field names are snake_case (the upstream TypeScript uses camelCase); the +mapping is 1:1 and mechanical, e.g. ``costEstimate`` -> ``cost_estimate``. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Literal, TypedDict + +Tier = Literal["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"] + +TaskType = Literal[ + "chat", + "extraction", + "code_edit", + "code_agent", + "tool_agent", + "tool_agent_parallel", + "debug", + "reasoning", + "reasoning_mcq", + "reasoning_math", + "long_context", + "vision", +] + +Profile = Literal["auto", "eco", "premium", "agentic"] + +RoutingProfile = Literal["eco", "auto", "premium"] + +Method = Literal["rules", "llm", "portfolio"] + +#: Ordering used by the structured-output minimum-tier override. +TIER_RANK: dict[str, int] = {"SIMPLE": 0, "MEDIUM": 1, "COMPLEX": 2, "REASONING": 3} + +TIERS: tuple[str, ...] = ("SIMPLE", "MEDIUM", "COMPLEX", "REASONING") + + +class ModelPricing(TypedDict, total=False): + """Catalog prices per 1M tokens. + + ``flat_price`` overrides token pricing when present and non-zero (the + BlockRun catalog reports ``0`` rather than omitting the field, so falsy + means "per-token billing" here, matching the TypeScript ``undefined``). + """ + + input_price: float + output_price: float + flat_price: float + + +class ModelCapabilities(TypedDict): + context_window: int + max_output_tokens: int + supports_tools: bool + supports_vision: bool + + +class Capacity(TypedDict): + """Narrow capability view used by :func:`filter_candidates_by_capacity`.""" + + context_window: int + max_output: int + + +class ModelPerformanceProfile(TypedDict, total=False): + measured_at: str + #: Gateway end-to-end latency for the benchmark workload. + latency_ms: float + #: Tail latency is more relevant than mean latency for urgent requests. + p95_latency_ms: float + output_tokens_per_second: float + #: External intelligence index when one was available; not task success. + intelligence_index: float + #: Failure fraction observed in the same benchmark run. + error_rate: float + #: Number of sampled calls behind the observation. + samples: int + + +class TierConfig(TypedDict): + primary: str + fallback: list[str] + + +class DimensionScore(TypedDict): + name: str + score: float + signal: str | None + + +class ScoringResult(TypedDict, total=False): + #: weighted float (roughly [-0.3, 0.4]) + score: float + #: ``None`` = ambiguous, needs fallback classifier + tier: Tier | None + #: sigmoid-calibrated [0, 1] + confidence: float + signals: list[str] + #: 0-1 agentic task score for auto-switching to agentic tiers + agentic_score: float + #: per-dimension breakdown for /debug + dimensions: list[DimensionScore] + + +class CandidateScore(TypedDict): + model: str + score: float + quality: float + cost: float + speed: float + reliability: float + + +class _RoutingDecisionRequired(TypedDict): + model: str + tier: Tier + confidence: float + method: Method + reasoning: str + cost_estimate: float + baseline_cost: float + savings: float # 0-1 percentage + + +class RoutingDecision(_RoutingDecisionRequired, total=False): + #: 0-1 agentic task score (present when tier routing used) + agentic_score: float + #: Which tier configs were used (auto/eco/premium/agentic) + tier_configs: dict[str, TierConfig] + #: Which routing profile was applied + profile: Profile + #: Ordered, capability-eligible candidates. The first entry is ``model``. + candidates: list[str] + #: Explainable request classification used by the portfolio router. + task_type: TaskType + #: Router implementation that made the selection. + router_version: Literal["v2-rules", "v3-portfolio"] + #: Explainable local portfolio score breakdown, ordered with ``candidates``. + candidate_scores: list[CandidateScore] + + +class TokenCountThresholds(TypedDict): + simple: int + complex: int + + +class TierBoundaries(TypedDict): + simple_medium: float + medium_complex: float + complex_reasoning: float + + +class ScoringConfig(TypedDict): + token_count_thresholds: TokenCountThresholds + code_keywords: list[str] + reasoning_keywords: list[str] + simple_keywords: list[str] + technical_keywords: list[str] + creative_keywords: list[str] + imperative_verbs: list[str] + constraint_indicators: list[str] + output_format_keywords: list[str] + reference_keywords: list[str] + negation_keywords: list[str] + domain_specific_keywords: list[str] + agentic_task_keywords: list[str] + dimension_weights: dict[str, float] + tier_boundaries: TierBoundaries + confidence_steepness: float + confidence_threshold: float + + +class ClassifierConfig(TypedDict): + llm_model: str + llm_max_tokens: int + llm_temperature: float + prompt_truncation_chars: int + cache_ttl_ms: int + + +class OverridesConfig(TypedDict, total=False): + max_tokens_force_complex: int + structured_output_min_tier: Tier + ambiguous_default_tier: Tier + #: ``True`` forces agentic tiers, ``False`` disables them, absent = auto-detect. + agentic_mode: bool | None + + +class PortfolioBandWeights(TypedDict): + quality: float + capability: float + cost: float + speed: float + reliability: float + legacy: float + + +class HighStakesBoost(TypedDict): + quality: float + reliability: float + + +class AffinityFloorGap(TypedDict): + auto: float + eco: float + premium: float + + +class PortfolioConfig(TypedDict): + auto: PortfolioBandWeights + eco: PortfolioBandWeights + premium: PortfolioBandWeights + high_stakes_boost: HighStakesBoost + latency_sensitive_speed_boost: float + #: A candidate materially below the best task affinity cannot win on cost alone. + affinity_floor_gap: AffinityFloorGap + + +class PromotionTierOverride(TypedDict, total=False): + primary: str + fallback: list[str] + + +class Promotion(TypedDict, total=False): + """Time-windowed promotion that temporarily overrides tier routing. + + Active promotions are auto-applied; expired ones are ignored at runtime. + """ + + #: Human-readable label (e.g. "GLM-5 Launch Promo") + name: str + #: ISO date string, promotion starts (inclusive). e.g. "2026-04-01" + start_date: str + #: ISO date string, promotion ends (exclusive). e.g. "2026-04-15" + end_date: str + #: Partial tier overrides merged into the active tier configs. + tier_overrides: dict[str, PromotionTierOverride] + #: Which profiles this applies to. Default: all profiles. + profiles: list[Profile] + + +class ShadowConfig(TypedDict, total=False): + strategy: Literal["rules", "portfolio"] + sample_rate: float + + +class _RoutingConfigRequired(TypedDict): + version: str + classifier: ClassifierConfig + scoring: ScoringConfig + tiers: dict[str, TierConfig] + overrides: OverridesConfig + + +class RoutingConfig(_RoutingConfigRequired, total=False): + #: Enables a one-line rollback to the established V2 rules selector. + strategy: Literal["rules", "portfolio"] + #: Locally recompute a comparison strategy without changing the served model. + shadow: ShadowConfig + #: Calibratable local portfolio scoring weights; relative, not probabilities. + portfolio: PortfolioConfig + #: Tier configs for agentic mode. ``None`` disables agentic tier selection. + agentic_tiers: dict[str, TierConfig] | None + #: Tier configs for eco profile. ``None`` falls back to ``tiers``. + eco_tiers: dict[str, TierConfig] | None + #: Tier configs for premium profile. ``None`` falls back to ``tiers``. + premium_tiers: dict[str, TierConfig] | None + #: Time-windowed promotions that temporarily override tier routing. + promotions: list[Promotion] + + +class _RouterOptionsRequired(TypedDict): + config: RoutingConfig + model_pricing: Mapping[str, ModelPricing] + + +class RouterOptions(_RouterOptionsRequired, total=False): + """Per-request routing inputs.""" + + #: Host-provided capability snapshot; overrides the core's built-in one. + model_capabilities: Mapping[str, ModelCapabilities] + routing_profile: RoutingProfile | None + has_tools: bool + #: Number of tool definitions visible to the model on this turn. + tool_count: int + #: Local tool identifiers, used only for request/tool intent matching. + tool_names: Sequence[str] + #: Tools are attached by the host and this turn needs to use them. + requires_tools: bool | None + has_vision: bool + #: ``response_format`` / JSON schema requires reliable structured output. + requires_structured_output: bool + #: Override current time for promotion window checks (for testing). Naive + #: values are read as UTC. ``datetime.datetime``. + now: object + #: Fresh gateway performance observations, injected off the hot path. + model_performance: Mapping[str, ModelPerformanceProfile] diff --git a/blockrun_llm/types.py b/blockrun_llm/types.py index 0dc0108..f6cd42e 100644 --- a/blockrun_llm/types.py +++ b/blockrun_llm/types.py @@ -659,9 +659,35 @@ def cost(self) -> float: return self.spending_report.cost_usd -# Smart routing types (ClawRouter integration) +# Smart routing types (Router Core integration) RoutingProfile = Literal["free", "eco", "auto", "premium"] RoutingTier = Literal["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"] +RoutingMethod = Literal["rules", "llm", "portfolio"] +RoutingTaskType = Literal[ + "chat", + "extraction", + "code_edit", + "code_agent", + "tool_agent", + "tool_agent_parallel", + "debug", + "reasoning", + "reasoning_mcq", + "reasoning_math", + "long_context", + "vision", +] + + +class CandidateScore(BaseModel): + """Per-candidate portfolio score breakdown, ordered with ``candidates``.""" + + model: str + score: float + quality: float + cost: float + speed: float + reliability: float class RoutingDecision(BaseModel): @@ -670,12 +696,21 @@ class RoutingDecision(BaseModel): model: str tier: RoutingTier confidence: float - method: Literal["rules"] + #: "portfolio" for the default V3 strategy, "rules" for the V2 rollback and + #: the free profile. + method: RoutingMethod reasoning: str cost_estimate: float baseline_cost: float savings: float # 0-1 percentage fallbacks: List[str] = [] # remaining models in tier order, for runtime fallback + # Router Core metadata — present when the portfolio strategy ran. + candidates: List[str] = [] # ordered, capability-eligible; candidates[0] == model + candidate_scores: List[CandidateScore] = [] + task_type: Optional[RoutingTaskType] = None + router_version: Optional[Literal["v2-rules", "v3-portfolio"]] = None + profile: Optional[Literal["auto", "eco", "premium", "agentic"]] = None + agentic_score: Optional[float] = None class SmartChatResponse(BaseModel): diff --git a/pyproject.toml b/pyproject.toml index 3635ac7..7748f0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "blockrun-llm" -version = "1.10.1" +version = "1.11.0" description = "BlockRun SDK - Pay-per-request AI (LLM, Image, Video, Music, Voice) via x402 on Base and Solana" readme = "README.md" license = "MIT" diff --git a/tests/unit/test_router_adapter.py b/tests/unit/test_router_adapter.py new file mode 100644 index 0000000..ed63757 --- /dev/null +++ b/tests/unit/test_router_adapter.py @@ -0,0 +1,242 @@ +""" +Tests for the BlockRun host glue around Router Core. + +These cover what ``router_adapter`` adds on top of the product-neutral core: +catalog id resolution, the x402 payment floor, capacity filtering against the +whole conversation, and the SDK-only ``free`` profile. +""" + +from __future__ import annotations + +import pytest + +from blockrun_llm.router import route +from blockrun_llm.router_adapter import ( + BASE_MINIMUM_PAYMENT_USD, + FREE_TIERS, + routing_profile_for_model, + routing_text, +) +from blockrun_llm.router_core import DEFAULT_ROUTING_CONFIG +from blockrun_llm.types import RoutingDecision + +FREE_MODELS = [ + "nvidia/step-3.7-flash", + "nvidia/mistral-nemotron", + "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", + "nvidia/nemotron-nano-9b-v2", + "nvidia/nemotron-nano-12b-v2-vl", +] + + +def _price(input_price: float, output_price: float, flat_price: float = 0) -> dict[str, float]: + return { + "input_price": input_price, + "output_price": output_price, + "flat_price": flat_price, + } + + +CATALOG = { + "google/gemini-2.5-flash": _price(0.15, 0.6), + "google/gemini-2.5-flash-lite": _price(0.1, 0.4), + "google/gemini-3.5-flash": _price(0.5, 3), + "google/gemini-3-flash-preview": _price(0.5, 3), + "google/gemini-3.1-flash-lite": _price(0.25, 1.5), + "google/gemini-3.1-pro": _price(1.25, 10), + "openai/gpt-5.4-nano": _price(0.2, 1.25), + "openai/gpt-5-mini": _price(0.25, 2), + "openai/gpt-5.3-codex": _price(1.75, 14), + "anthropic/claude-opus-4.7": _price(5, 25), + "anthropic/claude-sonnet-5": _price(3, 15), + "anthropic/claude-fable-5": _price(10, 50), + "deepseek/deepseek-chat": _price(0.2, 0.4), + "deepseek/deepseek-v4-pro": _price(0.435, 0.87), + "moonshot/kimi-k2.7": _price(0.95, 4), + "xai/grok-4-1-fast-reasoning": _price(0.2, 0.5), + "xai/grok-4-fast-non-reasoning": _price(0.2, 0.5), + **{model: _price(0, 0) for model in FREE_MODELS}, +} + + +class TestCatalogResolution: + def test_maps_the_routers_free_namespace_onto_gateway_nvidia_ids(self): + catalog = {**CATALOG, "nvidia/gpt-oss-120b": _price(0, 0)} + + decision = route("hi", None, 512, catalog, "eco") + + # eco's SIMPLE chain leads with free/gpt-oss-120b, which the gateway + # serves as nvidia/gpt-oss-120b. + assert "nvidia/gpt-oss-120b" in [decision["model"], *decision["fallbacks"]] + assert not any( + model.startswith("free/") for model in [decision["model"], *decision["fallbacks"]] + ) + + def test_drops_free_ids_the_catalog_cannot_price(self): + # No nvidia/gpt-oss-* rows here: those ids are hidden from /v1/models, + # and an unmapped free/* id would draw a hard, non-transient 400. + decision = route("hi", None, 512, CATALOG, "eco") + + assert not any( + model.startswith("free/") for model in [decision["model"], *decision["fallbacks"]] + ) + assert decision["model"] in CATALOG + + def test_candidates_lead_with_the_selected_model_and_fallbacks_follow(self): + decision = route("What is 2+2?", None, 512, CATALOG) + + assert decision["candidates"][0] == decision["model"] + assert decision["fallbacks"] == decision["candidates"][1:] + assert decision["model"] not in decision["fallbacks"] + + +class TestCostMetadata: + def test_applies_the_base_chain_payment_floor_to_paid_models(self): + decision = route("What is 2+2?", None, 16, CATALOG) + + assert decision["cost_estimate"] == pytest.approx(BASE_MINIMUM_PAYMENT_USD) + + def test_never_floors_a_free_model_up_to_the_paid_minimum(self): + decision = route("What is 2+2?", None, 512, CATALOG, "free") + + assert decision["cost_estimate"] == 0 + assert decision["savings"] == pytest.approx(1.0) + + def test_premium_profile_reports_no_savings(self): + decision = route("Design a distributed ledger", None, 1024, CATALOG, "premium") + + assert decision["savings"] == 0 + + +class TestCapacityFiltering: + def test_drops_candidates_that_cannot_hold_the_full_conversation(self): + # 8k output is above several small-output models' ceiling. + decision = route("Explain this architecture", None, 20_000, CATALOG) + + assert "xai/grok-4-fast-non-reasoning" not in decision["candidates"] + + def test_keeps_models_absent_from_the_capability_snapshot(self): + catalog = {**CATALOG, "acme/experimental-1": _price(0.1, 0.1)} + config = { + **DEFAULT_ROUTING_CONFIG, + "strategy": "rules", + "tiers": { + tier: {"primary": "acme/experimental-1", "fallback": []} + for tier in DEFAULT_ROUTING_CONFIG["tiers"] + }, + } + from blockrun_llm.router_adapter import route_with_catalog + + decision = route_with_catalog("hi", None, 512, catalog, config=config) + + assert decision["model"] == "acme/experimental-1" + + +class TestFreeProfile: + @pytest.mark.parametrize( + "prompt", + [ + "What is 2+2?", + "Prove the theorem step by step using mathematical induction", + "Refactor this TypeScript function and explain the tradeoffs", + "A" * 5_000, + ], + ) + def test_never_selects_a_billable_model(self, prompt): + decision = route(prompt, None, 512, CATALOG, "free") + + for model in [decision["model"], *decision["fallbacks"]]: + assert CATALOG[model]["input_price"] == 0 + assert CATALOG[model]["output_price"] == 0 + + def test_every_free_tier_entry_is_live_in_the_catalog(self): + # The previous hand-maintained table rotted silently when NVIDIA EOL'd + # its early free lineup; this asserts the replacement points at models + # the catalog still prices. + for tier in FREE_TIERS.values(): + for model in [tier["primary"], *tier["fallback"]]: + assert model in FREE_MODELS, model + + def test_uses_the_rules_strategy_so_paid_evidence_models_cannot_leak_in(self): + decision = route( + "Fix the TypeScript payment retry bug, run tests, and update the patch.", + None, + 4096, + CATALOG, + "free", + ) + + assert decision["method"] == "rules" + assert "openai/gpt-5.3-codex" not in decision["candidates"] + + +class TestSdkDecisionShape: + def test_the_decision_parses_into_the_public_pydantic_model(self): + decision = route( + "Which answer is correct?\nA. One\nB. Two\nC. Three\nD. Four", None, 512, CATALOG + ) + + parsed = RoutingDecision(**decision) + + assert parsed.model == decision["model"] + assert parsed.method == "portfolio" + assert parsed.router_version == "v3-portfolio" + assert parsed.task_type == "reasoning_mcq" + assert parsed.candidates[0] == parsed.model + assert parsed.candidate_scores + assert parsed.profile == "auto" + + def test_the_free_profile_decision_also_parses(self): + parsed = RoutingDecision(**route("hi", None, 512, CATALOG, "free")) + + assert parsed.method == "rules" + assert parsed.task_type is None + + +class TestRoutingText: + def test_reads_the_whole_transcript_for_capacity_and_the_last_user_turn(self): + view = routing_text( + [ + {"role": "system", "content": "You are terse."}, + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + {"role": "user", "content": "and now?"}, + ] + ) + + assert view["prompt"] == "and now?" + assert view["system_prompt"] == "You are terse." + assert view["conversation_chars"] == len("You are terse.") + len("hello") + 2 + len( + "and now?" + ) + assert view["has_vision"] is False + + def test_detects_image_parts(self): + view = routing_text( + [ + { + "role": "user", + "content": [ + {"type": "text", "text": "what is this?"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAA"}}, + ], + } + ] + ) + + assert view["has_vision"] is True + assert view["conversation_chars"] == len("what is this?") + + +class TestVirtualModelIds: + @pytest.mark.parametrize( + ("model", "expected"), + [ + ("blockrun/auto", "auto"), + ("BlockRun/Eco", "eco"), + ("blockrun/premium", "premium"), + ("google/gemini-3.5-flash", None), + ], + ) + def test_maps_virtual_ids_to_profiles(self, model, expected): + assert routing_profile_for_model(model) == expected diff --git a/tests/unit/test_router_core.py b/tests/unit/test_router_core.py new file mode 100644 index 0000000..191a3f1 --- /dev/null +++ b/tests/unit/test_router_core.py @@ -0,0 +1,1237 @@ +""" +Parity tests for the Router Core port. + +Every case here is a 1:1 port of an upstream ``@blockrun/router-core`` vitest +case (``portfolio.test.ts``, ``selector.test.ts``, ``strategy.test.ts``, +``tool-intent.test.ts`` at commit ``18bf4ab``). They are the regression guard +that the Python port keeps choosing the same models as the TypeScript SDK — +when upstream is re-synced, re-port these alongside the source. +""" + +from __future__ import annotations + +import math +from datetime import datetime, timezone + +import pytest + +from blockrun_llm.router_core import ( + DEFAULT_ROUTING_CONFIG, + RulesStrategy, + calculate_model_cost, + filter_by_exclude_list, + filter_by_tool_calling, + filter_candidates_by_capacity, + get_strategy, + infer_tool_requirement, + register_strategy, + route, +) +from blockrun_llm.router_core.selector import select_model + + +def _price(input_price: float, output_price: float) -> dict[str, float]: + return {"input_price": input_price, "output_price": output_price} + + +PORTFOLIO_PRICING = { + "anthropic/claude-sonnet-4.6": _price(3, 15), + "anthropic/claude-sonnet-5": _price(3, 15), + "anthropic/claude-opus-5": _price(5, 25), + "anthropic/claude-opus-4.8": _price(5, 25), + "openai/gpt-5.3-codex": _price(1.75, 14), + "openai/gpt-5-mini": _price(0.25, 2), + "openai/gpt-4.1": _price(2, 8), + "google/gemini-3.5-flash": _price(0.5, 3), + "google/gemini-3-flash-preview": _price(0.5, 3), + "google/gemini-3.1-pro": _price(2, 12), + "moonshot/kimi-k3": _price(3, 15), + "deepseek/deepseek-v4-pro": _price(0.435, 0.87), + "xai/grok-4.5": _price(2, 10), + "qwen/qwen3.7-max": _price(1.475, 4.425), + "zai/glm-5.2": _price(1.4, 4.4), + "moonshot/kimi-k2.7": _price(0.95, 4), + "moonshot/kimi-k2.6": _price(0.95, 4), + "moonshot/kimi-k2.5": _price(0.6, 3), + "xai/grok-4-1-fast-non-reasoning": _price(0.2, 0.5), + "openai/gpt-4o-mini": _price(0.15, 0.6), + "deepseek/deepseek-chat": _price(0.2, 0.4), + "free/seed-oss-36b": _price(0, 0), +} + +STRATEGY_PRICING = { + "moonshot/kimi-k2.5": _price(0.5, 2.4), + "moonshot/kimi-k2.6": _price(0.95, 4.0), + "anthropic/claude-opus-4.6": _price(5, 25), + "anthropic/claude-opus-4.7": _price(5, 25), + "anthropic/claude-opus-4.8": _price(5, 25), + "google/gemini-2.5-flash": _price(0.15, 0.6), + "google/gemini-2.5-flash-lite": _price(0.1, 0.4), + "deepseek/deepseek-chat": _price(0.14, 0.28), + "anthropic/claude-sonnet-4.6": _price(3, 15), + "google/gemini-3.1-pro": _price(1.25, 10), + "google/gemini-3.5-flash": _price(0.5, 3), + "xai/grok-4.5": _price(2.5, 9), + "anthropic/claude-sonnet-5": _price(3, 15), + "deepseek/deepseek-v4-pro": _price(0.435, 0.87), + "moonshot/kimi-k3": _price(3, 15), + "xai/grok-4-1-fast-reasoning": _price(0.2, 0.5), + "nvidia/gpt-oss-120b": _price(0, 0), + "nvidia/gpt-oss-20b": _price(0, 0), + "nvidia/deepseek-v3.2": _price(0, 0), + "nvidia/deepseek-v4-pro": _price(0, 0), + "nvidia/deepseek-v4-flash": _price(0, 0), + "nvidia/qwen3-coder-480b": _price(0, 0), + "nvidia/glm-4.7": _price(0, 0), + "nvidia/llama-4-maverick": _price(0, 0), + "nvidia/qwen3-next-80b-a3b-thinking": _price(0, 0), + "nvidia/mistral-small-4-119b": _price(0, 0), + "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning": _price(0, 0), + "nvidia/qwen3-next-80b-a3b-instruct": _price(0, 0), + "nvidia/seed-oss-36b": _price(0, 0), + "nvidia/mistral-nemotron": _price(0, 0), + "nvidia/step-3.7-flash": _price(0, 0), + "nvidia/nemotron-nano-9b-v2": _price(0, 0), + "nvidia/nemotron-nano-12b-v2-vl": _price(0, 0), +} + +BASE_OPTIONS = {"config": DEFAULT_ROUTING_CONFIG, "model_pricing": STRATEGY_PRICING} + +TERMINAL_TOOLS = ["TerminalExec", "TerminalInspect", "TerminalSendKeys"] + +AIRLINE_TOOLS = [ + "get_user_details", + "get_reservation_details", + "search_direct_flight", + "update_reservation_flights", + "cancel_reservation", + "book_reservation", + "update_reservation_baggages", +] + +RETURN_TOOLS = [ + "get_order_details", + "return_delivered_order_items", + "transfer_to_human_agents", +] + +KIMI_MODELS = ("moonshot/kimi-k2.7", "moonshot/kimi-k2.6", "moonshot/kimi-k2.5") + + +def _portfolio(prompt: str, max_output_tokens: int, **options): + return route( + prompt, + None, + max_output_tokens, + {"config": DEFAULT_ROUTING_CONFIG, "model_pricing": PORTFOLIO_PRICING, **options}, + ) + + +def _terminal(prompt: str, max_output_tokens: int = 4096): + return _portfolio( + prompt, + max_output_tokens, + routing_profile="auto", + has_tools=True, + requires_tools=True, + tool_count=3, + tool_names=TERMINAL_TOOLS, + ) + + +def _scored_models(decision) -> list[str]: + return [row["model"] for row in decision.get("candidate_scores", [])] + + +# ─── portfolio.test.ts ─── + + +class TestPortfolioStrategy: + def test_keeps_only_tool_capable_models_for_a_coding_agent_request(self): + decision = _portfolio( + "Fix the TypeScript payment retry bug, run tests, and update the patch.", + 4096, + has_tools=True, + ) + + assert decision["method"] == "portfolio" + assert decision["task_type"] == "code_agent" + assert decision["model"] == "openai/gpt-5-mini" + assert decision["model"] in decision["candidates"] + assert "openai/gpt-5.3-codex" in decision["candidates"] + assert decision["model"] not in KIMI_MODELS + assert "google/gemini-3.1-pro" not in decision["candidates"] + + def test_classifies_a_non_code_function_call_as_a_tool_agent(self): + decision = _portfolio("Use the lookup_order tool for order B-42.", 256, has_tools=True) + + assert decision["task_type"] in ("tool_agent", "tool_agent_parallel") + assert decision["model"] == "anthropic/claude-sonnet-5" + assert decision["model"] in decision["candidates"] + assert "google/gemini-3.5-flash" in decision["candidates"] + assert decision["model"] not in KIMI_MODELS + + @pytest.mark.parametrize( + "prompt", + [ + "请问北京的当前天气状况如何?还有,上海的天气情况是怎样的?", + ( + "For breakfast I had a 12 ounce iced coffee and a banana.\n\n" + "For lunch I had a quesadilla.\n\n" + "Breakfast four ounces of asparagus and two eggs." + ), + "¿Cuáles son las condiciones del clima en Cancún, Playa del Carmen y Tulum?", + "Could you tell me the current temperature in Boston, MA and San Francisco, please?", + "What's the snow like in the two cities of Paris and Bordeaux?", + "What's cost of 2 and 4 gb ram machine on aws ec2 with one CPU?", + "能帮我查一下中国广州市和北京市现在的天气状况吗?请使用公制单位。", + ( + "Could you provide the latest news for Paris, France, and also for " + "Letterkenny, Ireland?" + ), + "I'd like to change my food order to a salad, and for the drink, update it to coffee.", + ], + ) + def test_routes_repeated_single_tool_requests_to_the_parallel_specialist(self, prompt): + decision = _portfolio( + prompt, + 600, + routing_profile="auto", + has_tools=True, + requires_tools=True, + tool_count=1, + ) + + assert decision["task_type"] == "tool_agent_parallel" + assert decision["model"] == "anthropic/claude-opus-4.8" + + def test_keeps_an_ordinary_single_lookup_on_the_standard_tool_agent_path(self): + decision = _portfolio( + "Use lookup_order for order B-42.", + 256, + routing_profile="auto", + has_tools=True, + requires_tools=True, + tool_count=1, + ) + + assert decision["task_type"] == "tool_agent" + assert decision["model"] == "anthropic/claude-sonnet-5" + assert "google/gemini-3.5-flash" in decision["candidates"] + + def test_keeps_deep_multi_clue_web_research_on_sonnet_5(self): + decision = _portfolio( + "Research the following clues across multiple public sources and identify the country.", + 2048, + routing_profile="auto", + has_tools=True, + requires_tools=True, + tool_count=2, + tool_names=["web_search", "web_fetch"], + ) + + assert decision["task_type"] in ("tool_agent", "tool_agent_parallel") + assert decision["model"] == "anthropic/claude-sonnet-5" + assert "deepWebResearch=true" in decision["reasoning"] + assert decision["candidates"][:3] == [ + "anthropic/claude-sonnet-5", + "openai/gpt-5-mini", + "google/gemini-3.5-flash", + ] + assert "candidates=" in decision["reasoning"] + + def test_keeps_a_routine_web_lookup_on_sonnet_5(self): + decision = _portfolio( + "Search the official documentation for the current API timeout setting.", + 1024, + routing_profile="auto", + has_tools=True, + requires_tools=True, + tool_count=2, + tool_names=["web_search", "web_fetch"], + ) + + assert decision["model"] == "anthropic/claude-sonnet-5" + assert "deepWebResearch=false" in decision["reasoning"] + + def test_keeps_a_known_cross_reservation_batch_on_the_cost_controlled_model(self): + decision = _portfolio( + "Hi! I’d like to make some changes to my bookings. I need to cancel two of my " + "upcoming reservations and upgrade another one to business class. " + "Can you help me with that?", + 4096, + routing_profile="auto", + has_tools=True, + requires_tools=True, + tool_count=7, + tool_names=AIRLINE_TOOLS, + ) + + assert decision["task_type"] in ("tool_agent", "tool_agent_parallel") + assert "agentRisk=high" in decision["reasoning"] + assert decision["model"] == "openai/gpt-5-mini" + + def test_promotes_conditional_global_airline_work_to_the_complex_band(self): + decision = _portfolio( + "Cancel all your future reservations that contain flights longer than 4 hours. " + "For flights under 3 hours, upgrade to business wherever possible.", + 4096, + routing_profile="auto", + has_tools=True, + requires_tools=True, + tool_count=7, + tool_names=AIRLINE_TOOLS, + ) + + assert "agentRisk=complex_high" in decision["reasoning"] + assert decision["model"] == "anthropic/claude-sonnet-5" + + @pytest.mark.parametrize( + "prompt", + [ + ( + "Create a file called hello.txt in the current directory. " + "Write Hello, world! to it and end with a newline." + ), + "Convert the file /app/data.csv into a Parquet file named /app/data.parquet.", + ( + "Create and run a server on port 3000 with a single GET endpoint /fib " + "that returns JSON." + ), + ( + "A script called 'process_data.sh' in the current directory won't run. " + "Figure out what's wrong and fix it so the script can run successfully." + ), + ], + ) + def test_uses_the_low_cost_code_agent_for_deterministic_local_terminal_work(self, prompt): + decision = _terminal(prompt) + + assert decision["task_type"] == "code_agent" + assert decision["model"] == "openai/gpt-5-mini" + assert "terminalCode=true" in decision["reasoning"] + + def test_promotes_a_multi_script_dependency_repair_to_the_strong_band(self): + decision = _terminal( + "There's a data processing pipeline in the current directory consisting of " + "multiple scripts that need to run in sequence. The main script 'run_pipeline.sh' " + "is failing to execute properly. Identify and fix all issues with the script files " + "and dependencies to make the pipeline run successfully." + ) + + assert decision["task_type"] == "tool_agent" + assert "agentRisk=complex_high" in decision["reasoning"] + assert decision["model"] == "anthropic/claude-sonnet-5" + + def test_promotes_a_cross_runtime_polyglot_artifact_to_the_strong_band(self): + decision = _terminal( + "Write one /app/main.c.rs polyglot file that must compile and run with both " + "rustc main.c.rs and gcc main.c.rs -o cmain." + ) + + assert decision["task_type"] == "code_agent" + assert "agentRisk=complex_high" in decision["reasoning"] + assert decision["model"] == "anthropic/claude-sonnet-5" + + def test_promotes_a_framework_checkpoint_port_to_the_strong_band(self): + decision = _terminal( + "Implement a command line tool programmed in C that runs inference using a " + "pre-trained PyTorch state_dict called simple_mnist.pth. The final output must be " + "a native cli_tool binary plus weights.json." + ) + + assert decision["task_type"] == "code_agent" + assert "agentRisk=complex_high" in decision["reasoning"] + assert decision["model"] == "anthropic/claude-sonnet-5" + + @pytest.mark.parametrize( + "prompt", + [ + ( + "Configure a git server over SSH and deploy two branches through Nginx HTTPS " + "with password authentication." + ), + ( + "Securely decommission the service: encrypt the archive with GPG, shred the " + "sensitive files, then delete them." + ), + ( + "Evaluate an embedding model with the MTEB benchmark and write the official " + "result file." + ), + "Inspect the chess board image and write the best move to a file.", + ( + "Create a JSON processor from three CSV inputs. Requirements: 1. Follow " + "schema.json. 2. Join departments and employees. 3. Calculate statistics." + ), + ], + ) + def test_keeps_complex_or_risky_terminal_operations_on_the_generic_agent_path(self, prompt): + decision = _terminal(prompt) + + assert decision["task_type"] != "code_agent" + assert "terminalCode=false" in decision["reasoning"] + + def test_keeps_codex_below_the_primary_band_for_security_sensitive_file_ops(self): + decision = _terminal( + "Please help me encrypt all the files I have in the data/ folder using rencrypt. " + "Use the most secure encryption and write the outputs to encrypted_data/ with the " + "same basenames." + ) + + assert decision["task_type"] == "tool_agent" + assert "terminalSafety=true" in decision["reasoning"] + assert decision["model"] == "anthropic/claude-sonnet-5" + assert "openai/gpt-5.3-codex" in decision["candidates"] + assert "openai/gpt-5.3-codex" not in _scored_models(decision) + + def test_admits_a_cost_controlled_strong_model_for_sensitive_multi_file_work(self): + decision = _terminal( + "Sanitize this git repository by replacing all AWS, GitHub, and Hugging Face API " + "keys with consistent placeholders across every affected file. Also, do not make " + "any other unnecessary changes to files without sensitive information." + ) + + assert decision["task_type"] == "tool_agent_parallel" + assert decision["model"] == "anthropic/claude-sonnet-5" + assert "anthropic/claude-sonnet-5" in decision["candidates"] + + @pytest.mark.parametrize( + "prompt", + [ + ( + "Reverse engineer the mystery binary, then write and compile image.c so it " + "produces the requested path-traced image." + ), + ( + "Create a local JSON server for Solana devnet with status, block, account, " + "transaction, and paginated program-account endpoints." + ), + ( + "Create a Solana devnet API whose transaction endpoint returns token transfers " + "with account, mint, and amount fields." + ), + ], + ) + def test_cost_controls_complex_terminal_work_that_is_not_safety_sensitive(self, prompt): + decision = _terminal(prompt) + + assert decision["task_type"] in ("tool_agent", "tool_agent_parallel", "code_agent") + assert decision["model"] == "openai/gpt-5-mini" + assert "terminalSafety=false" in decision["reasoning"] + + @pytest.mark.parametrize( + "prompt", + [ + ( + "Rotate the expired authentication token and update the bearer token used by " + "the production service." + ), + ( + "Replace every leaked API key and password in this repository without changing " + "unrelated files." + ), + ], + ) + def test_keeps_credential_bearing_terminal_work_safety_sensitive(self, prompt): + decision = _terminal(prompt) + + assert "terminalSafety=true" in decision["reasoning"] + assert decision["model"] != "openai/gpt-5-mini" + + def test_uses_the_high_risk_model_for_retail_order_tools(self): + decision = _portfolio( + "Exchange both items after I confirm the price difference.", + 512, + routing_profile="auto", + has_tools=True, + requires_tools=True, + tool_count=4, + tool_names=[ + "get_order_details", + "get_product_details", + "exchange_delivered_order_items", + "modify_pending_order_address", + ], + ) + + assert decision["task_type"] in ("tool_agent", "tool_agent_parallel") + assert decision["model"] == "deepseek/deepseek-v4-pro" + assert "openai/gpt-5-mini" in decision["candidates"] + + def test_uses_the_low_cost_model_for_one_local_retail_operation(self): + decision = _portfolio( + "Change the blue earbuds in order W5061109 to red after I confirm.", + 4096, + routing_profile="auto", + has_tools=True, + requires_tools=True, + tool_count=6, + tool_names=[ + "find_user_id_by_name_zip", + "get_order_details", + "get_product_details", + "modify_pending_order_items", + ], + ) + + assert decision["task_type"] == "tool_agent" + assert decision["model"] == "openai/gpt-5-mini" + + def test_keeps_global_retail_choices_on_the_high_risk_model(self): + decision = _portfolio( + "Exchange my tablet for the cheapest available variant in another order.", + 4096, + routing_profile="auto", + has_tools=True, + requires_tools=True, + tool_count=6, + tool_names=[ + "get_order_details", + "get_product_details", + "exchange_delivered_order_items", + ], + ) + + assert decision["model"] == "deepseek/deepseek-v4-pro" + + def test_uses_the_policy_specialist_for_a_refund_to_another_card(self): + decision = _portfolio( + "Return everything except the pet bed and refund it to my Amex card.", + 4096, + routing_profile="auto", + has_tools=True, + requires_tools=True, + tool_count=6, + tool_names=RETURN_TOOLS, + ) + + assert decision["task_type"] in ("tool_agent", "tool_agent_parallel") + assert decision["model"] == "openai/gpt-4.1" + assert "agentRisk=policy_exception" in decision["reasoning"] + + def test_keeps_a_single_comparative_send_back_on_the_low_cost_model(self): + decision = _portfolio( + "Send back the pricier one and get my money back on my credit card.", + 4096, + routing_profile="auto", + has_tools=True, + requires_tools=True, + tool_count=6, + tool_names=RETURN_TOOLS, + ) + + assert decision["model"] == "openai/gpt-5-mini" + assert "agentRisk=policy_exception_simple" in decision["reasoning"] + + def test_uses_the_policy_specialist_when_a_named_card_refund_covers_two_objects(self): + decision = _portfolio( + "Return these two skateboards and refund them to my credit card.", + 4096, + routing_profile="auto", + has_tools=True, + requires_tools=True, + tool_count=6, + tool_names=RETURN_TOOLS, + ) + + assert decision["model"] == "openai/gpt-4.1" + assert "agentRisk=policy_exception" in decision["reasoning"] + + def test_treats_a_simple_looking_retail_return_as_a_negotiated_high_risk_workflow(self): + decision = _portfolio( + "I want to return an office chair that arrived broken.", + 4096, + routing_profile="auto", + has_tools=True, + requires_tools=True, + tool_count=6, + tool_names=[ + "get_order_details", + "get_product_details", + "return_delivered_order_items", + "exchange_delivered_order_items", + ], + ) + + assert decision["task_type"] == "tool_agent" + assert decision["model"] == "deepseek/deepseek-v4-pro" + assert "agentRisk=high" in decision["reasoning"] + + def test_uses_the_cost_efficient_model_for_airline_tools(self): + decision = _portfolio( + "Change my flight after checking the reservation.", + 512, + routing_profile="auto", + has_tools=True, + requires_tools=True, + tool_count=3, + tool_names=[ + "get_reservation_details", + "search_direct_flight", + "update_reservation_flights", + ], + ) + + assert decision["task_type"] in ("tool_agent", "tool_agent_parallel") + assert decision["model"] == "openai/gpt-5-mini" + assert "anthropic/claude-sonnet-5" in decision["candidates"] + + def test_does_not_mistake_airline_cabin_class_for_a_code_agent_task(self): + decision = _portfolio( + "Move my flight to May 24 and upgrade all passengers to business class.", + 4096, + routing_profile="auto", + has_tools=True, + requires_tools=True, + tool_count=8, + tool_names=[ + "get_reservation_details", + "search_direct_flight", + "update_reservation_flights", + ], + ) + + assert decision["task_type"] != "code_agent" + assert decision["model"] == "openai/gpt-5-mini" + assert "agentRisk=high" in decision["reasoning"] + + def test_reserves_the_airline_specialist_for_global_itinerary_optimization(self): + decision = _portfolio( + "Show my gift card and certificate balances, then change my reservation to the " + "cheapest business round trip without changing the dates.", + 4096, + routing_profile="auto", + has_tools=True, + requires_tools=True, + tool_count=8, + tool_names=[ + "get_user_details", + "get_reservation_details", + "search_onestop_flight", + "cancel_reservation", + "book_reservation", + ], + ) + + assert decision["task_type"] != "code_agent" + assert decision["model"] == "anthropic/claude-sonnet-5" + assert "agentRisk=complex_high" in decision["reasoning"] + + def test_does_not_mistake_a_lookup_plus_explanation_for_parallel_tool_use(self): + decision = _portfolio( + "Get the weather for London and explain whether I need an umbrella.", + 256, + routing_profile="auto", + has_tools=True, + requires_tools=True, + tool_count=1, + tool_names=["get_current_weather"], + ) + + assert decision["task_type"] == "tool_agent" + + def test_uses_two_distinctive_visible_tool_names_as_a_multi_operation_signal(self): + decision = _portfolio( + "Add task draft release notes, then delete task obsolete draft.", + 256, + routing_profile="auto", + has_tools=True, + requires_tools=True, + tool_count=2, + tool_names=["add_task", "delete_task"], + ) + + assert decision["task_type"] == "tool_agent_parallel" + + def test_does_not_spend_upgrade_a_large_numbered_multi_tool_plan(self): + decision = _portfolio( + "Do all the following:\n1. Clone the repository.\n2. Analyze it.\n" + "3. Create Docker and Kubernetes files.\n4. Commit and push.", + 600, + routing_profile="auto", + has_tools=True, + requires_tools=True, + tool_count=7, + tool_names=[ + "clone_repo", + "analyze_repo", + "create_docker_file", + "create_kubernetes_yaml", + "commit_changes", + "push_changes", + "read_file", + ], + ) + + assert decision["task_type"] != "tool_agent_parallel" + assert decision["model"] != "anthropic/claude-opus-4.8" + + def test_detects_an_explicit_multi_object_request_with_a_distractor_tool(self): + decision = _portfolio( + "What's the weather like in the two cities of Boston and San Francisco?", + 600, + routing_profile="auto", + has_tools=True, + requires_tools=True, + tool_count=2, + ) + + assert decision["task_type"] == "tool_agent_parallel" + assert decision["model"] == "anthropic/claude-opus-4.8" + + def test_does_not_classify_ordinary_qa_as_a_tool_task(self): + decision = _portfolio( + "Which answer is correct?\nA. One\nB. Two\nC. Three\nD. Four", + 256, + has_tools=True, + requires_tools=False, + ) + + assert decision["task_type"] == "reasoning_mcq" + assert decision["profile"] == "auto" + assert decision["model"] == "google/gemini-3-flash-preview" + + def test_adds_current_long_context_models_instead_of_a_legacy_tier_chain(self): + decision = _portfolio("A" * 340_000, 1_024) + + assert decision["task_type"] == "long_context" + assert "deepseek/deepseek-v4-pro" not in _scored_models(decision) + assert "deepseek/deepseek-v4-pro" in decision["candidates"] + assert decision["model"] == "google/gemini-3.1-pro" + assert decision["model"] in decision["candidates"] + + def test_keeps_mandarin_extraction_in_the_source_language_affinity_band(self): + decision = _portfolio( + "只输出 JSON:从订单 A-17,数量 3,状态已发货中提取 orderId、quantity、status 三个字段。", + 256, + ) + + assert decision["task_type"] == "extraction" + assert decision["model"] == "moonshot/kimi-k2.7" + assert decision["candidates"][0] == "moonshot/kimi-k2.7" + + def test_does_not_promote_a_generic_recovery_fallback_without_task_affinity(self): + decision = _portfolio("Patch this API secret validation error.", 256) + + # DeepSeek Chat is a valid availability fallback in the SIMPLE tier, but + # is not an explicitly profiled code-edit specialist. It must not win the + # Auto ranking simply because it is inexpensive. + assert "deepseek/deepseek-chat" not in _scored_models(decision) + assert "deepseek/deepseek-chat" in decision["candidates"] + + def test_does_not_let_a_flash_lite_sibling_inherit_flash_task_affinity(self): + exact_name_config = { + **DEFAULT_ROUTING_CONFIG, + "tiers": { + tier: { + "primary": "google/gemini-2.5-flash", + "fallback": ["google/gemini-2.5-flash-lite"], + } + for tier in DEFAULT_ROUTING_CONFIG["tiers"] + }, + } + decision = route( + "Explain the deployment status.", + None, + 256, + { + "config": exact_name_config, + "model_pricing": { + "google/gemini-2.5-flash": _price(1, 1), + "google/gemini-2.5-flash-lite": _price(0.1, 0.1), + }, + }, + ) + + assert decision["candidates"][0] == "google/gemini-2.5-flash" + assert "google/gemini-2.5-flash-lite" in decision["candidates"] + assert "google/gemini-2.5-flash-lite" not in _scored_models(decision) + + def test_filters_models_that_cannot_satisfy_the_requested_output_length(self): + decision = _portfolio("Explain this architecture", 20_000) + + assert "xai/grok-4-fast-non-reasoning" not in decision["candidates"] + + def test_only_lets_fresh_performance_observations_influence_candidate_order(self): + two_candidate_config = { + **DEFAULT_ROUTING_CONFIG, + "tiers": { + tier: { + "primary": "xai/grok-4-1-fast-non-reasoning", + "fallback": ["openai/gpt-4o-mini"], + } + for tier in DEFAULT_ROUTING_CONFIG["tiers"] + }, + } + decision = route( + "Extract the fields as JSON", + None, + 512, + { + "config": two_candidate_config, + "model_pricing": { + "xai/grok-4-1-fast-non-reasoning": _price(1, 1), + "openai/gpt-4o-mini": _price(1, 1), + }, + "now": datetime(2026, 7, 21, tzinfo=timezone.utc), + "model_performance": { + "openai/gpt-4o-mini": { + "measured_at": "2026-07-21T00:00:00Z", + "latency_ms": 600, + "output_tokens_per_second": 250, + "intelligence_index": 50, + } + }, + }, + ) + + assert decision["task_type"] == "extraction" + assert decision["candidates"][0] == "openai/gpt-4o-mini" + + def test_treats_a_small_performance_probe_as_a_tie_breaker(self): + two_candidate_config = { + **DEFAULT_ROUTING_CONFIG, + "tiers": { + tier: { + "primary": "xai/grok-4-1-fast-non-reasoning", + "fallback": ["openai/gpt-4o-mini"], + } + for tier in DEFAULT_ROUTING_CONFIG["tiers"] + }, + } + decision = route( + "Explain the deployment status.", + None, + 512, + { + "config": two_candidate_config, + "model_pricing": { + "xai/grok-4-1-fast-non-reasoning": _price(1, 1), + "openai/gpt-4o-mini": _price(1, 1), + }, + "now": datetime(2026, 7, 21, tzinfo=timezone.utc), + "model_performance": { + "openai/gpt-4o-mini": { + "measured_at": "2026-07-21T00:00:00Z", + "latency_ms": 600, + "output_tokens_per_second": 250, + "intelligence_index": 50, + "samples": 1, + } + }, + }, + ) + + assert decision["candidates"][0] == "xai/grok-4-1-fast-non-reasoning" + + def test_ignores_a_malformed_performance_timestamp(self): + decision = _portfolio( + "Extract the fields as JSON", + 512, + now=datetime(2026, 7, 21, tzinfo=timezone.utc), + model_performance={ + "openai/gpt-4o-mini": { + "measured_at": "not-a-timestamp", + "latency_ms": 1, + "output_tokens_per_second": 10_000, + "intelligence_index": 50, + } + }, + ) + + assert all(math.isfinite(row["score"]) for row in decision.get("candidate_scores", [])) + + def test_falls_back_to_the_rules_decision_when_a_tier_has_no_usable_candidate(self): + empty_tiers = { + tier: {"primary": "", "fallback": []} for tier in DEFAULT_ROUTING_CONFIG["tiers"] + } + decision = route( + "hello", + None, + 128, + { + "config": {**DEFAULT_ROUTING_CONFIG, "tiers": empty_tiers}, + "model_pricing": PORTFOLIO_PRICING, + }, + ) + + assert decision["method"] == "rules" + assert decision["model"] == "" + + def test_lets_a_host_capability_snapshot_override_the_built_in_catalog(self): + decision = _portfolio( + "Use the lookup_order tool for order B-42.", + 256, + has_tools=True, + requires_tools=True, + model_capabilities={ + "anthropic/claude-sonnet-5": { + "context_window": 1_000_000, + "max_output_tokens": 128_000, + "supports_tools": False, + "supports_vision": True, + } + }, + ) + + assert "anthropic/claude-sonnet-5" not in decision["candidates"] + + +# ─── selector.test.ts ─── + +SELECTOR_TIER_CONFIGS = { + tier: {"primary": "moonshot/kimi-k2.5", "fallback": []} + for tier in ("SIMPLE", "MEDIUM", "COMPLEX", "REASONING") +} +SELECTOR_PRICING = { + "moonshot/kimi-k2.5": _price(0.5, 2.4), + "anthropic/claude-opus-4.7": _price(5, 25), + "anthropic/claude-opus-4.8": _price(5, 25), +} + + +def _supports_tool_calling(model: str) -> bool: + return model not in ("minimax/minimax-m2.5", "nvidia/gpt-oss-120b") + + +class TestSelector: + def test_select_model_uses_opus_4_7_as_the_savings_baseline(self): + decision = select_model( + "SIMPLE", + 0.95, + "rules", + "test", + SELECTOR_TIER_CONFIGS, + SELECTOR_PRICING, + 1000, + 1000, + ) + + assert decision["baseline_cost"] > 0 + assert decision["savings"] > 0 + + def test_calculate_model_cost_uses_opus_4_7_as_the_baseline(self): + costs = calculate_model_cost("moonshot/kimi-k2.5", SELECTOR_PRICING, 1000, 1000) + + assert costs["baseline_cost"] > 0 + assert costs["savings"] > 0 + + def test_filter_by_tool_calling_removes_models_without_tool_support(self): + models = ["moonshot/kimi-k2.5", "minimax/minimax-m2.5", "deepseek/deepseek-chat"] + + assert filter_by_tool_calling(models, True, _supports_tool_calling) == [ + "moonshot/kimi-k2.5", + "deepseek/deepseek-chat", + ] + + def test_filter_by_tool_calling_keeps_every_model_when_the_request_has_no_tools(self): + models = ["moonshot/kimi-k2.5", "minimax/minimax-m2.5", "nvidia/gpt-oss-120b"] + + assert filter_by_tool_calling(models, False, _supports_tool_calling) == models + + def test_filter_by_tool_calling_never_returns_an_empty_chain(self): + unsupported = ["minimax/minimax-m2.5", "nvidia/gpt-oss-120b"] + + assert filter_by_tool_calling(unsupported, True, _supports_tool_calling) == unsupported + + def test_filter_by_exclude_list(self): + chain = ["moonshot/kimi-k2.5", "deepseek/deepseek-chat", "anthropic/claude-sonnet-4.6"] + + assert filter_by_exclude_list(chain, {"deepseek/deepseek-chat"}) == [ + "moonshot/kimi-k2.5", + "anthropic/claude-sonnet-4.6", + ] + assert filter_by_exclude_list(chain, set(chain)) == chain + assert filter_by_exclude_list(chain, set()) == chain + + def test_filter_candidates_by_capacity(self): + capabilities = { + "small": {"context_window": 8_000, "max_output": 2_000}, + "large": {"context_window": 128_000, "max_output": 32_000}, + } + + assert filter_candidates_by_capacity( + ["small", "large"], 10_000, 4_000, capabilities.get + ) == ["large"] + assert filter_candidates_by_capacity(["small"], 100_000, 40_000, capabilities.get) == [] + + +# ─── strategy.test.ts ─── + + +class TestRulesStrategy: + def test_returns_tier_configs_in_the_decision(self): + decision = RulesStrategy().route("hello", None, 100, BASE_OPTIONS) + + assert decision["tier_configs"] is not None + for tier in ("SIMPLE", "MEDIUM", "COMPLEX", "REASONING"): + assert tier in decision["tier_configs"] + + def test_returns_profile_in_the_decision(self): + decision = RulesStrategy().route("hello", None, 100, BASE_OPTIONS) + + assert decision["profile"] in ("auto", "eco", "premium", "agentic") + + def test_honors_the_protocol_structured_output_requirement(self): + decision = RulesStrategy().route( + "hello", None, 100, {**BASE_OPTIONS, "requires_structured_output": True} + ) + + assert decision["tier"] == "MEDIUM" + assert "structured output" in decision["reasoning"] + + def test_sets_eco_profile_when_routing_profile_is_eco(self): + decision = RulesStrategy().route( + "hello", None, 100, {**BASE_OPTIONS, "routing_profile": "eco"} + ) + + assert decision["profile"] == "eco" + assert decision["tier_configs"] == DEFAULT_ROUTING_CONFIG["eco_tiers"] + + def test_sets_premium_profile_when_routing_profile_is_premium(self): + decision = RulesStrategy().route( + "hello", None, 100, {**BASE_OPTIONS, "routing_profile": "premium"} + ) + + assert decision["profile"] == "premium" + assert decision["tier_configs"] == DEFAULT_ROUTING_CONFIG["premium_tiers"] + + def test_eco_tiers_none_falls_back_to_regular_tiers_without_dropping_into_auto(self): + decision = RulesStrategy().route( + "hello", + None, + 100, + { + **BASE_OPTIONS, + "config": {**DEFAULT_ROUTING_CONFIG, "eco_tiers": None}, + "routing_profile": "eco", + "has_tools": True, + "now": datetime(2025, 1, 1, tzinfo=timezone.utc), + }, + ) + + assert decision["profile"] == "eco" + assert decision["tier_configs"] == DEFAULT_ROUTING_CONFIG["tiers"] + + def test_premium_tiers_none_falls_back_to_regular_tiers(self): + decision = RulesStrategy().route( + "hello", + None, + 100, + { + **BASE_OPTIONS, + "config": {**DEFAULT_ROUTING_CONFIG, "premium_tiers": None}, + "routing_profile": "premium", + "has_tools": True, + "now": datetime(2025, 1, 1, tzinfo=timezone.utc), + }, + ) + + assert decision["profile"] == "premium" + assert decision["tier_configs"] == DEFAULT_ROUTING_CONFIG["tiers"] + + def test_sets_agentic_profile_when_tools_are_present(self): + decision = RulesStrategy().route("hello", None, 100, {**BASE_OPTIONS, "has_tools": True}) + + assert decision["profile"] == "agentic" + assert decision["tier_configs"] == DEFAULT_ROUTING_CONFIG["agentic_tiers"] + + def test_sets_auto_profile_for_default_requests(self): + decision = RulesStrategy().route( + "what is the capital of France", + None, + 100, + {**BASE_OPTIONS, "now": datetime(2025, 1, 1, tzinfo=timezone.utc)}, + ) + + assert decision["profile"] == "auto" + assert decision["tier_configs"] == DEFAULT_ROUTING_CONFIG["tiers"] + + def test_agentic_mode_false_disables_agentic_tiers_even_with_tools(self): + config = { + **DEFAULT_ROUTING_CONFIG, + "overrides": {**DEFAULT_ROUTING_CONFIG["overrides"], "agentic_mode": False}, + } + decision = RulesStrategy().route( + "hello", + None, + 100, + { + **BASE_OPTIONS, + "config": config, + "has_tools": True, + "now": datetime(2025, 1, 1, tzinfo=timezone.utc), + }, + ) + + assert decision["profile"] == "auto" + assert decision["tier_configs"] == DEFAULT_ROUTING_CONFIG["tiers"] + + def test_agentic_mode_true_forces_agentic_tiers_even_without_tools(self): + config = { + **DEFAULT_ROUTING_CONFIG, + "overrides": {**DEFAULT_ROUTING_CONFIG["overrides"], "agentic_mode": True}, + } + decision = RulesStrategy().route( + "hello", + None, + 100, + { + **BASE_OPTIONS, + "config": config, + "has_tools": False, + "now": datetime(2025, 1, 1, tzinfo=timezone.utc), + }, + ) + + assert decision["profile"] == "agentic" + assert decision["tier_configs"] == DEFAULT_ROUTING_CONFIG["agentic_tiers"] + + +class TestStrategyRegistry: + def test_retrieves_the_default_rules_strategy(self): + strategy = get_strategy("rules") + + assert isinstance(strategy, RulesStrategy) + assert strategy.name == "rules" + + def test_raises_for_an_unknown_strategy(self): + with pytest.raises(ValueError, match="Unknown routing strategy: nonexistent"): + get_strategy("nonexistent") + + def test_registers_and_retrieves_a_custom_strategy(self): + class CustomStrategy: + name = "custom-test" + + def route(self, prompt, system_prompt, max_output_tokens, options): + return { + "model": "test/model", + "tier": "SIMPLE", + "confidence": 1, + "method": "rules", + "reasoning": "custom strategy", + "cost_estimate": 0, + "baseline_cost": 0, + "savings": 0, + "tier_configs": options["config"]["tiers"], + "profile": "auto", + } + + register_strategy(CustomStrategy()) + retrieved = get_strategy("custom-test") + + assert retrieved.name == "custom-test" + decision = retrieved.route("test", None, 100, BASE_OPTIONS) + assert decision["model"] == "test/model" + assert decision["reasoning"] == "custom strategy" + + +class TestPortfolioDefault: + def test_route_uses_the_v3_portfolio_while_retaining_rule_tiers(self): + simple = route("hello", None, 100, BASE_OPTIONS) + + assert simple["tier"] == "SIMPLE" + assert simple["method"] == "portfolio" + assert simple["model"] + assert simple["candidates"][0] == simple["model"] + assert simple["router_version"] == "v3-portfolio" + + reasoning = route( + "prove the theorem step by step using mathematical induction", None, 4096, BASE_OPTIONS + ) + + assert reasoning["tier"] == "REASONING" + assert reasoning["method"] == "portfolio" + assert simple["tier_configs"] is not None + assert simple["profile"] is not None + assert reasoning["tier_configs"] is not None + assert reasoning["profile"] is not None + + def test_supports_a_config_only_rollback_to_the_v2_rules_strategy(self): + decision = route( + "hello", + None, + 100, + {**BASE_OPTIONS, "config": {**DEFAULT_ROUTING_CONFIG, "strategy": "rules"}}, + ) + + assert decision["method"] == "rules" + + def test_recognizes_multiple_choice_reasoning(self): + decision = route( + "Which statement is correct?\n\nA. First\nB. Second\nC. Third\nD. Fourth\n\n" + "Return the final answer choice.", + None, + 512, + BASE_OPTIONS, + ) + + assert decision["task_type"] == "reasoning_mcq" + assert decision["tier"] == "REASONING" + assert decision["model"] == "google/gemini-3-flash-preview" + assert "xai/grok-4.5" in decision["candidates"] + assert decision["tier_configs"]["REASONING"]["primary"] == decision["model"] + + def test_recognizes_compact_multilingual_arithmetic(self): + decision = route( + "Una caja tiene 12 libros. Hay 4 cajas. ¿Cuántos libros hay en total?", + None, + 512, + BASE_OPTIONS, + ) + + assert decision["task_type"] == "reasoning_math" + assert decision["tier"] == "REASONING" + assert decision["model"] == "google/gemini-3.5-flash" + + def test_recognizes_math_word_problems_without_question_marks(self): + decision = route( + "เรือแล่นได้เร็ว 10 ไมล์ต่อชั่วโมง ตั้งแต่ 13.00 น. ถึง 16.00 น. " "และกลับด้วยความเร็ว 6 ไมล์ต่อชั่วโมง", + None, + 512, + BASE_OPTIONS, + ) + + assert decision["task_type"] == "reasoning_math" + + +# ─── tool-intent.test.ts ─── + + +class TestInferToolRequirement: + def test_does_not_confuse_available_tools_with_a_tool_requirement(self): + assert not infer_tool_requirement( + "Which option best explains the observation?\nA. One\nB. Two\nC. Three\nD. Four" + ) + assert not infer_tool_requirement("What is 17 times 9?") + + def test_recognizes_explicit_tool_repository_web_and_stateful_actions(self): + assert infer_tool_requirement("Use the lookup_order tool for order B-42.") + assert infer_tool_requirement("Patch the repository and run the tests.") + assert infer_tool_requirement( + "Calculate the average and save it in a file called result.txt." + ) + assert infer_tool_requirement("Search the web for today's weather in Shanghai.") + assert infer_tool_requirement("Cancel my flight booking and refund the ticket.") + assert infer_tool_requirement("修改仓库里的文件,然后运行测试。") + + def test_honors_the_openai_tool_choice_contract(self): + assert infer_tool_requirement("Retrieve the account details.", None, "required") + assert infer_tool_requirement( + "Retrieve the account details.", + None, + {"type": "function", "function": {"name": "get_account"}}, + ) + assert not infer_tool_requirement("What is 17 times 9?", None, "auto") + assert not infer_tool_requirement( + "Cancel my flight booking and refund the ticket.", None, "none" + ) + + def test_does_not_treat_host_tool_descriptions_as_a_per_turn_requirement(self): + system_prompt = ( + "You can use web_search to look up documentation, run tests, " + "and update account records." + ) + + assert not infer_tool_requirement("What is 17 times 9?", system_prompt)