diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c19082..11c0207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to blockrun-llm will be documented in this file. +## Unreleased + +### Added — Router Core V3 across Base and Solana + +- Added a native Python adapter pinned to Router Core commit `d430804`, with + constraint-first task classification, portfolio ranking, live-catalog + filtering, and ordered transient fallbacks. +- Base and Solana, sync and async, now expose `route()`, `smart_chat()`, and + `smart_chat_completion()`. +- `blockrun/auto`, `blockrun/eco`, and `blockrun/premium` work anywhere a chat + model id is accepted, including tool/agent and streaming requests. Routing is + local and completes before the first x402 quote. + +### Fixed + +- Base balance lookup now uses the valid PublicNode Base RPC hostname and a + working public fallback. + ## 1.10.0 — 2026-07-28 ### Added diff --git a/README.md b/README.md index 8d0862f..80b06b8 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 V3) Let the SDK automatically pick the cheapest capable model for each request: @@ -139,6 +139,15 @@ print(f"Saved {result.routing.savings * 100:.0f}%") # 'Saved 94%' # Complex reasoning task -> routes to reasoning model result = client.smart_chat("Prove the Riemann hypothesis step by step") print(result.model) # 'deepseek/deepseek-reasoner' + +# Use Auto as a normal model id for an OpenAI-compatible agent/tool turn. +response = client.chat_completion( + "blockrun/auto", + messages, + tools=tools, + tool_choice="auto", +) +print(response.routing["task_type"]) ``` ### Routing Profiles @@ -161,7 +170,9 @@ print(result.model) # 'openai/gpt-5.4' ### How It Works -ClawRouter uses a 14-dimension rule-based classifier to analyze each request: +The bundled Router Core V3 adapter first applies hard capability constraints, +then ranks eligible models using task affinity, quality, token-normalized cost, +speed, and reliability. It uses request text and tool metadata only: - **Token count** - Short vs long prompts - **Code presence** - Programming keywords @@ -170,7 +181,9 @@ ClawRouter uses a 14-dimension rule-based classifier to analyze each request: - **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 is deterministic and 100% local: there is no classifier model call and +no additional x402 payment. The same engine is used by Base and Solana, sync +and async. It routes to one of four capability tiers: | Tier | Example Tasks | Auto Profile Model | |------|---------------|-------------------| @@ -400,8 +413,8 @@ model fails. Useful before a release or after router/catalog changes. `smart_chat()` and `chat()` accept an optional `fallback_models=[...]` list — on timeout / 5xx / network error the SDK transparently walks the chain -before raising. `smart_chat()` populates this from the tier's fallback list -automatically. +before raising. Router V3 populates this from its live-catalog-filtered +portfolio order automatically and never advances after a settled payment. ### Image Generation @@ -1675,8 +1688,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 V3 is the SDK's bundled, product-neutral model picker. It analyzes request and tool metadata, filters models that cannot satisfy hard requirements, and ranks the remaining portfolio locally. Use `smart_chat()`, `smart_chat_completion()`, or `blockrun/auto`; no second model call or separate package is required. ### 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/blockrun_llm/client.py b/blockrun_llm/client.py index 6e56a34..4031c9c 100644 --- a/blockrun_llm/client.py +++ b/blockrun_llm/client.py @@ -50,7 +50,8 @@ from dotenv import load_dotenv from eth_account import Account -from .router import route as route_request +from .router_v3 import message_routing_inputs, routing_profile_for_model +from .router_v3 import route as route_request from .tx_log import ( TransactionLogger, _resolve_log_dir, @@ -516,8 +517,9 @@ 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). + Routes requests locally with BlockRun Router Core V3. Hard capability + constraints run first, then eligible models are portfolio-ranked for + quality, task affinity, price, speed, and reliability. Args: prompt: User message @@ -556,6 +558,7 @@ def smart_chat( max_output_tokens=max_output_tokens, model_pricing=model_pricing, routing_profile=routing_profile, + minimum_payment_usd=0.002, ) # Make the chat request with selected model. Pass the tier's remaining @@ -576,6 +579,73 @@ def smart_chat( routing=RoutingDecision(**decision), ) + def route( + self, + prompt: str, + *, + system: str | None = None, + max_tokens: int | None = None, + routing_profile: RoutingProfile = "auto", + tools: list[dict[str, Any]] | None = None, + tool_choice: Any | None = None, + response_format: dict[str, Any] | None = None, + has_vision: bool = False, + ) -> RoutingDecision: + """Return a local Router V3 decision without spending or inference.""" + + decision = route_request( + prompt=prompt, + system_prompt=system, + max_output_tokens=max_tokens or self.DEFAULT_MAX_TOKENS, + model_pricing=self._get_model_pricing(), + routing_profile=routing_profile, + tools=tools, + tool_choice=tool_choice, + requires_structured_output=response_format is not None, + has_vision=has_vision, + minimum_payment_usd=0.002, + ) + return RoutingDecision(**decision) + + def smart_chat_completion( + self, + messages: list[dict[str, Any]], + *, + max_tokens: int | None = None, + routing_profile: RoutingProfile = "auto", + tools: list[dict[str, Any]] | None = None, + tool_choice: Any | None = None, + response_format: dict[str, Any] | None = None, + **kwargs: Any, + ) -> ChatResponse: + """Route and execute an OpenAI-compatible agent/tool turn.""" + + prompt, system, has_vision = message_routing_inputs(messages) + decision = self.route( + prompt, + system=system, + max_tokens=max_tokens, + routing_profile=routing_profile, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + has_vision=has_vision, + ) + response = self.chat_completion( + decision.model, + messages, + max_tokens=max_tokens, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + fallback_models=decision.fallbacks, + **kwargs, + ) + response.routing = ( + decision.model_dump() if hasattr(decision, "model_dump") else decision.dict() + ) + return response + def get_spending(self) -> dict[str, Any]: """ Get current session spending. @@ -744,6 +814,24 @@ def chat_completion( for tc in result.choices[0].message.tool_calls: print(f"Call: {tc.function.name}({tc.function.arguments})") """ + routing_decision: RoutingDecision | None = None + alias_profile = routing_profile_for_model(model) + if alias_profile is not None: + prompt, system, has_vision = message_routing_inputs(messages) + routing_decision = self.route( + prompt, + system=system, + max_tokens=max_tokens, + routing_profile=alias_profile, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + has_vision=has_vision, + ) + model = routing_decision.model + if fallback_models is None: + fallback_models = routing_decision.fallbacks + # Validate inputs validate_model(model) validate_max_tokens(max_tokens) @@ -795,7 +883,14 @@ def chat_completion( for i, attempt_model in enumerate(attempts): body["model"] = attempt_model try: - return self._request_with_payment("/v1/chat/completions", body) + response = self._request_with_payment("/v1/chat/completions", body) + if routing_decision is not None: + response.routing = ( + routing_decision.model_dump() + if hasattr(routing_decision, "model_dump") + else routing_decision.dict() + ) + return response except Exception as exc: if not _should_fallback(exc): raise @@ -869,6 +964,23 @@ def chat_completion_stream( mode by the BlockRun backend — the server will reject with 400. Codex / GPT-5.4 Pro also do not support streaming. """ + alias_profile = routing_profile_for_model(model) + if alias_profile is not None: + prompt, system, has_vision = message_routing_inputs(messages) + decision = self.route( + prompt, + system=system, + max_tokens=max_tokens, + routing_profile=alias_profile, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + has_vision=has_vision, + ) + model = decision.model + if fallback_models is None: + fallback_models = decision.fallbacks + validate_model(model) validate_max_tokens(max_tokens) validate_temperature(temperature) @@ -2362,9 +2474,9 @@ def get_balance(self) -> float: else: usdc_contract = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" rpcs = [ - "https://base.publicnode.com", + "https://base-rpc.publicnode.com", "https://mainnet.base.org", - "https://base.meowrpc.com", + "https://base.llamarpc.com", ] # balanceOf(address) function selector @@ -2493,6 +2605,7 @@ def __init__( limits=httpx.Limits(max_connections=200, max_keepalive_connections=50), ) self._last_call_cost: float = 0.0 + self._model_pricing_cache: dict[str, dict[str, float]] | None = None # This client tracks no session total (see chat_completion), so the # session limit has nothing to accumulate against; the per-call limit # still applies. Kept as an attribute so the shared check is uniform. @@ -2518,6 +2631,108 @@ def _capture_settlement(self, response: httpx.Response) -> dict[str, Any] | None self._last_settlement = settlement return settlement + async def _get_model_pricing(self) -> dict[str, dict[str, float]]: + if self._model_pricing_cache is not None: + return self._model_pricing_cache + response = await self._client.get(f"{self.api_url}/v1/models") + response.raise_for_status() + pricing: dict[str, dict[str, float]] = {} + for model in response.json().get("data", []): + block = model.get("pricing") or {} + model_id = model.get("id", "") + pricing[model_id] = { + "input_price": float(block.get("input", model.get("inputPrice", 0)) or 0), + "output_price": float(block.get("output", model.get("outputPrice", 0)) or 0), + "flat_price": float(block.get("flat", model.get("flatPrice", 0)) or 0), + } + self._model_pricing_cache = pricing + return pricing + + async def route( + self, + prompt: str, + *, + system: str | None = None, + max_tokens: int | None = None, + routing_profile: RoutingProfile = "auto", + tools: list[dict[str, Any]] | None = None, + tool_choice: Any | None = None, + response_format: dict[str, Any] | None = None, + has_vision: bool = False, + ) -> RoutingDecision: + decision = route_request( + prompt=prompt, + system_prompt=system, + max_output_tokens=max_tokens or self.DEFAULT_MAX_TOKENS, + model_pricing=await self._get_model_pricing(), + routing_profile=routing_profile, + tools=tools, + tool_choice=tool_choice, + requires_structured_output=response_format is not None, + has_vision=has_vision, + minimum_payment_usd=0.002, + ) + return RoutingDecision(**decision) + + async def smart_chat_completion( + self, + messages: list[dict[str, Any]], + *, + max_tokens: int | None = None, + routing_profile: RoutingProfile = "auto", + tools: list[dict[str, Any]] | None = None, + tool_choice: Any | None = None, + response_format: dict[str, Any] | None = None, + **kwargs: Any, + ) -> ChatResponse: + prompt, system, has_vision = message_routing_inputs(messages) + decision = await self.route( + prompt, + system=system, + max_tokens=max_tokens, + routing_profile=routing_profile, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + has_vision=has_vision, + ) + response = await self.chat_completion( + decision.model, + messages, + max_tokens=max_tokens, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + fallback_models=decision.fallbacks, + **kwargs, + ) + response.routing = ( + decision.model_dump() if hasattr(decision, "model_dump") else decision.dict() + ) + return response + + async def smart_chat( + self, + prompt: str, + *, + system: str | None = None, + max_tokens: int | None = None, + temperature: float | None = None, + routing_profile: RoutingProfile = "auto", + ) -> SmartChatResponse: + decision = await self.route( + prompt, system=system, max_tokens=max_tokens, routing_profile=routing_profile + ) + response = await self.chat( + decision.model, + prompt, + system=system, + max_tokens=max_tokens, + temperature=temperature, + fallback_models=decision.fallbacks, + ) + return SmartChatResponse(response=response, model=decision.model, routing=decision) + async def chat( self, model: str, @@ -2574,6 +2789,24 @@ async def chat_completion( **extra: Any, ) -> ChatResponse: """Async full chat completion interface with optional xAI Live Search and tool calling.""" + routing_decision: RoutingDecision | None = None + alias_profile = routing_profile_for_model(model) + if alias_profile is not None: + prompt, system, has_vision = message_routing_inputs(messages) + routing_decision = await self.route( + prompt, + system=system, + max_tokens=max_tokens, + routing_profile=alias_profile, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + has_vision=has_vision, + ) + model = routing_decision.model + if fallback_models is None: + fallback_models = routing_decision.fallbacks + # Validate inputs validate_model(model) validate_max_tokens(max_tokens) @@ -2622,7 +2855,14 @@ async def chat_completion( for i, attempt_model in enumerate(attempts): body["model"] = attempt_model try: - return await self._request_with_payment("/v1/chat/completions", body) + response = await self._request_with_payment("/v1/chat/completions", body) + if routing_decision is not None: + response.routing = ( + routing_decision.model_dump() + if hasattr(routing_decision, "model_dump") + else routing_decision.dict() + ) + return response except Exception as exc: if not _should_fallback(exc): raise @@ -2662,6 +2902,23 @@ async def chat_completion_stream( for protocol details and the ``fallback_models`` semantics — identical here, only the iteration protocol differs (``async for``). """ + alias_profile = routing_profile_for_model(model) + if alias_profile is not None: + prompt, system, has_vision = message_routing_inputs(messages) + decision = await self.route( + prompt, + system=system, + max_tokens=max_tokens, + routing_profile=alias_profile, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + has_vision=has_vision, + ) + model = decision.model + if fallback_models is None: + fallback_models = decision.fallbacks + validate_model(model) validate_max_tokens(max_tokens) validate_temperature(temperature) diff --git a/blockrun_llm/router_v3.py b/blockrun_llm/router_v3.py new file mode 100644 index 0000000..86522ef --- /dev/null +++ b/blockrun_llm/router_v3.py @@ -0,0 +1,1032 @@ +"""BlockRun Router Core V3 native Python adapter. + +This module mirrors the product-neutral Router Core portfolio contract pinned +at ``d4308049348e11e17ed08a254676a34949be80f9``. It deliberately contains no +network or payment code: callers provide the current gateway catalog and the +router returns one capability-eligible primary plus ordered fallbacks. +""" + +from __future__ import annotations + +import math +import re +from typing import Any, Literal, TypedDict + +from .router import classify_by_rules + +ROUTER_CORE_COMMIT = "d4308049348e11e17ed08a254676a34949be80f9" +ROUTER_VERSION = "v3-portfolio" + +Tier = Literal["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"] +RoutingProfile = Literal["free", "eco", "auto", "premium"] +ROUTER_ALIASES: dict[str, RoutingProfile] = { + "blockrun/auto": "auto", + "blockrun/eco": "eco", + "blockrun/premium": "premium", +} +TaskType = Literal[ + "chat", + "extraction", + "code_edit", + "code_agent", + "tool_agent", + "tool_agent_parallel", + "debug", + "reasoning", + "reasoning_mcq", + "reasoning_math", + "long_context", + "vision", +] + + +class RoutingDecision(TypedDict): + model: str + tier: Tier + confidence: float + method: Literal["portfolio"] + reasoning: str + cost_estimate: float + baseline_cost: float + savings: float + profile: str + task_type: TaskType + router_version: Literal["v3-portfolio"] + candidates: list[str] + candidate_scores: list[dict[str, float | str]] + fallbacks: list[str] + + +def routing_profile_for_model(model: str) -> RoutingProfile | None: + """Return the Router profile represented by a public model alias.""" + + return ROUTER_ALIASES.get(model.strip().lower()) + + +def message_routing_inputs(messages: list[dict[str, Any]]) -> tuple[str, str | None, bool]: + """Extract bounded text and vision signals from OpenAI-style messages. + + The latest user turn is the routing prompt. System/developer messages are + included as instructions, while assistant/tool history is intentionally not + reclassified as a new user task. This keeps routing local and deterministic + even for long agent transcripts. + """ + + user_parts: list[str] = [] + system_parts: list[str] = [] + has_vision = False + + def content_text(content: Any) -> str: + nonlocal has_vision + if isinstance(content, str): + return content + if not isinstance(content, list): + return "" + parts: list[str] = [] + for item in content: + if not isinstance(item, dict): + continue + kind = str(item.get("type", "")) + if kind in {"image_url", "input_image", "image"}: + has_vision = True + text = item.get("text") or item.get("input_text") + if isinstance(text, str): + parts.append(text) + return "\n".join(parts) + + for message in messages: + role = str(message.get("role", "")) + text = content_text(message.get("content")) + if role in {"system", "developer"} and text: + system_parts.append(text) + elif role == "user" and text: + user_parts.append(text) + + prompt = user_parts[-1] if user_parts else "" + return prompt, "\n".join(system_parts) or None, has_vision + + +TierConfig = dict[Tier, dict[str, Any]] + +AUTO_TIERS: TierConfig = { + "SIMPLE": { + "primary": "google/gemini-2.5-flash", + "fallback": [ + "google/gemini-3-flash-preview", + "deepseek/deepseek-chat", + "moonshot/kimi-k2.5", + "google/gemini-3.1-flash-lite", + "google/gemini-2.5-flash-lite", + "openai/gpt-5.4-nano", + "xai/grok-4-fast-non-reasoning", + "free/gpt-oss-120b", + ], + }, + "MEDIUM": { + "primary": "moonshot/kimi-k2.7", + "fallback": [ + "moonshot/kimi-k2.6", + "moonshot/kimi-k2.5", + "google/gemini-3-flash-preview", + "deepseek/deepseek-chat", + "google/gemini-2.5-flash", + "google/gemini-3.1-flash-lite", + "google/gemini-2.5-flash-lite", + "xai/grok-4-1-fast-non-reasoning", + "xai/grok-3-mini", + ], + }, + "COMPLEX": { + "primary": "google/gemini-3.1-pro", + "fallback": [ + "google/gemini-3-flash-preview", + "xai/grok-4-0709", + "google/gemini-2.5-pro", + "anthropic/claude-sonnet-5", + "anthropic/claude-sonnet-4.6", + "deepseek/deepseek-chat", + "google/gemini-2.5-flash", + "openai/gpt-5.6-terra", + "openai/gpt-5.5", + "openai/gpt-5.4", + ], + }, + "REASONING": { + "primary": "xai/grok-4-1-fast-reasoning", + "fallback": [ + "xai/grok-4-fast-reasoning", + "deepseek/deepseek-reasoner", + "deepseek/deepseek-v4-pro", + "openai/o4-mini", + "openai/o3", + ], + }, +} + +ECO_TIERS: TierConfig = { + "SIMPLE": { + "primary": "free/gpt-oss-120b", + "fallback": [ + "free/gpt-oss-20b", + "free/deepseek-v4-flash", + "google/gemini-3.1-flash-lite", + "openai/gpt-5.4-nano", + "google/gemini-2.5-flash-lite", + "xai/grok-4-fast-non-reasoning", + ], + }, + "MEDIUM": { + "primary": "google/gemini-3.1-flash-lite", + "fallback": [ + "openai/gpt-5.4-nano", + "google/gemini-2.5-flash-lite", + "xai/grok-4-fast-non-reasoning", + "google/gemini-2.5-flash", + ], + }, + "COMPLEX": { + "primary": "google/gemini-3.1-flash-lite", + "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", + "fallback": [ + "xai/grok-4-fast-reasoning", + "deepseek/deepseek-reasoner", + "deepseek/deepseek-v4-pro", + ], + }, +} + +PREMIUM_TIERS: TierConfig = { + "SIMPLE": { + "primary": "moonshot/kimi-k2.7", + "fallback": [ + "moonshot/kimi-k2.6", + "moonshot/kimi-k2.5", + "google/gemini-2.5-flash", + "anthropic/claude-haiku-4.5", + "google/gemini-2.5-flash-lite", + "deepseek/deepseek-chat", + ], + }, + "MEDIUM": { + "primary": "openai/gpt-5.3-codex", + "fallback": [ + "moonshot/kimi-k2.7", + "moonshot/kimi-k2.6", + "moonshot/kimi-k2.5", + "google/gemini-2.5-flash", + "google/gemini-2.5-pro", + "xai/grok-4-0709", + "anthropic/claude-sonnet-5", + "anthropic/claude-sonnet-4.6", + ], + }, + "COMPLEX": { + "primary": "anthropic/claude-fable-5", + "fallback": [ + "anthropic/claude-opus-5", + "anthropic/claude-opus-4.8", + "anthropic/claude-opus-4.7", + "anthropic/claude-opus-4.6", + "anthropic/claude-sonnet-5", + "anthropic/claude-sonnet-4.6", + "xai/grok-4.5", + "xai/grok-4-0709", + "moonshot/kimi-k2.7", + "moonshot/kimi-k2.6", + "moonshot/kimi-k2.5", + "openai/gpt-5.6-terra", + "openai/gpt-5.5", + "openai/gpt-5.4", + "openai/gpt-5.3-codex", + "deepseek/deepseek-chat", + "free/gpt-oss-120b", + ], + }, + "REASONING": { + "primary": "anthropic/claude-sonnet-4.6", + "fallback": [ + "anthropic/claude-sonnet-5", + "anthropic/claude-opus-5", + "anthropic/claude-opus-4.8", + "anthropic/claude-opus-4.7", + "anthropic/claude-opus-4.6", + "xai/grok-4-1-fast-reasoning", + "openai/o4-mini", + "openai/o3", + ], + }, +} + +AGENTIC_TIERS: TierConfig = { + "SIMPLE": { + "primary": "openai/gpt-4o-mini", + "fallback": [ + "moonshot/kimi-k2.5", + "anthropic/claude-haiku-4.5", + "xai/grok-4-1-fast-non-reasoning", + ], + }, + "MEDIUM": { + "primary": "moonshot/kimi-k2.7", + "fallback": [ + "moonshot/kimi-k2.6", + "moonshot/kimi-k2.5", + "xai/grok-4-1-fast-non-reasoning", + "openai/gpt-4o-mini", + "anthropic/claude-haiku-4.5", + "deepseek/deepseek-chat", + ], + }, + "COMPLEX": { + "primary": "anthropic/claude-sonnet-4.6", + "fallback": [ + "anthropic/claude-sonnet-5", + "anthropic/claude-opus-5", + "anthropic/claude-opus-4.8", + "anthropic/claude-opus-4.7", + "anthropic/claude-opus-4.6", + "xai/grok-4-0709", + "moonshot/kimi-k2.7", + "moonshot/kimi-k2.5", + "openai/gpt-5.6-terra", + "openai/gpt-5.5", + "openai/gpt-5.4", + "deepseek/deepseek-chat", + "free/gpt-oss-120b", + ], + }, + "REASONING": { + "primary": "anthropic/claude-sonnet-4.6", + "fallback": [ + "anthropic/claude-sonnet-5", + "anthropic/claude-opus-5", + "anthropic/claude-opus-4.8", + "anthropic/claude-opus-4.7", + "anthropic/claude-opus-4.6", + "xai/grok-4-1-fast-reasoning", + "deepseek/deepseek-reasoner", + ], + }, +} + +EVIDENCE_CANDIDATES: dict[TaskType, list[str]] = { + "code_agent": [ + "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", + ], + "tool_agent": [ + "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", + ], + "tool_agent_parallel": [ + "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", + ], + "long_context": [ + "google/gemini-3.1-pro", + "deepseek/deepseek-v4-pro", + "qwen/qwen3.7-max", + "zai/glm-5.2", + "google/gemini-3.5-flash", + ], + "reasoning_mcq": [ + "google/gemini-3-flash-preview", + "google/gemini-3.5-flash", + "xai/grok-4.5", + "anthropic/claude-sonnet-5", + "deepseek/deepseek-v4-pro", + ], + "reasoning_math": [ + "google/gemini-3.5-flash", + "xai/grok-4.5", + "anthropic/claude-sonnet-5", + "deepseek/deepseek-v4-pro", + "moonshot/kimi-k3", + ], +} + +NO_TOOL_MODELS = { + "free/deepseek-v4-flash", + "free/gpt-oss-120b", + "free/gpt-oss-20b", + "free/seed-oss-36b", + "google/gemini-3-flash-preview", +} + +VISION_MODELS = { + "anthropic/claude-fable-5", + "anthropic/claude-haiku-4.5", + "anthropic/claude-opus-4.6", + "anthropic/claude-opus-4.7", + "anthropic/claude-opus-4.8", + "anthropic/claude-opus-5", + "anthropic/claude-sonnet-4.6", + "anthropic/claude-sonnet-5", + "google/gemini-2.5-flash", + "google/gemini-2.5-pro", + "google/gemini-3-flash-preview", + "google/gemini-3.1-pro", + "google/gemini-3.5-flash", + "moonshot/kimi-k2.5", + "moonshot/kimi-k2.6", + "moonshot/kimi-k2.7", + "moonshot/kimi-k3", + "openai/gpt-4.1", + "openai/gpt-5.4", + "openai/gpt-5.5", + "openai/gpt-5.6-terra", + "xai/grok-4.5", +} + +OUTPUT_LIMITS = { + "anthropic/claude-haiku-4.5": 8_192, + "deepseek/deepseek-chat": 8_192, + "deepseek/deepseek-reasoner": 8_192, + "google/gemini-3.1-flash-lite": 8_192, + "xai/grok-3-mini": 16_384, + "xai/grok-4-0709": 16_384, + "xai/grok-4-1-fast-non-reasoning": 16_384, + "xai/grok-4-1-fast-reasoning": 16_384, + "xai/grok-4-fast-non-reasoning": 16_384, + "xai/grok-4-fast-reasoning": 16_384, + "xai/grok-4.5": 16_384, +} + +CONTEXT_LIMITS = { + "openai/gpt-4.1": 128_000, + "openai/gpt-4o-mini": 128_000, + "openai/gpt-5-mini": 200_000, + "openai/gpt-5.3-codex": 400_000, + "moonshot/kimi-k2.5": 262_144, + "moonshot/kimi-k2.6": 262_144, + "moonshot/kimi-k2.7": 262_144, + "xai/grok-3-mini": 131_072, + "xai/grok-4-0709": 131_072, + "xai/grok-4-1-fast-non-reasoning": 131_072, + "xai/grok-4-1-fast-reasoning": 131_072, + "xai/grok-4-fast-non-reasoning": 131_072, + "xai/grok-4-fast-reasoning": 131_072, +} + +PORTFOLIO_WEIGHTS = { + "auto": { + "quality": 0.47, + "capability": 0.20, + "cost": 0.18, + "speed": 0.07, + "reliability": 0.03, + "legacy": 0.05, + }, + "eco": { + "quality": 0.36, + "capability": 0.20, + "cost": 0.28, + "speed": 0.10, + "reliability": 0.04, + "legacy": 0.02, + }, + "premium": { + "quality": 0.58, + "capability": 0.20, + "cost": 0.08, + "speed": 0.06, + "reliability": 0.06, + "legacy": 0.02, + }, +} +AFFINITY_FLOOR = {"auto": 0.10, "eco": 0.22, "premium": 0.05} + + +def _sample(text: str, limit: int = 8_000) -> str: + if len(text) <= limit: + return text + first = math.ceil(limit / 2) + return f"{text[:first]}\n{text[-(limit - first) :]}" + + +def _infer_tool_requirement(prompt: str, system: str | None, tool_choice: Any) -> bool: + if tool_choice == "none": + return False + if tool_choice == "required" or isinstance(tool_choice, dict): + return True + # System prompts usually describe every tool a host exposes and are not + # evidence that the user requested an action on this turn. + del system + text = prompt + return bool( + re.search( + r"\b(?:get|fetch|search|look up|check|update|change|create|delete|cancel|book|send|run|execute|open|read|write|edit|deploy|install)\b|" + r"(?:查询|搜索|查看|获取|更新|修改|创建|删除|取消|预订|发送|执行|打开|读取|写入|部署|安装)", + text, + re.IGNORECASE, + ) + ) + + +def _parallel(prompt: str, needs_tools: bool, tool_names: list[str]) -> bool: + if not needs_tools or not tool_names: + return False + if re.search( + r"\b(?:in parallel|simultaneously|concurrently|for each|each of|every one|both|(?:two|three|multiple|several)\s+(?:cities|locations|items|tasks|orders|users|files))\b|" + r"并行|同时|分别|每个|各自|(?:两个|三个|多个)(?:城市|地点|项目|任务|订单|用户|文件)", + prompt, + re.IGNORECASE, + ): + return True + lookup = re.search( + r"\b(?:weather|climate|temperature|news|report)\b|天气|气象|温度|新闻|报告", + prompt, + re.IGNORECASE, + ) + return bool( + lookup + and ( + len(re.findall(r"[,,]", prompt)) >= 2 + or re.search(r"\band\b|以及|和|、", prompt, re.IGNORECASE) + ) + ) + + +def _task_features( + prompt: str, + system_prompt: str | None, + tools: list[dict[str, Any]], + tool_choice: Any, + requires_structured_output: bool, + has_vision: bool, +) -> dict[str, Any]: + scanned = _sample(prompt) + scanned_system = _sample(system_prompt or "") + full = f"{scanned_system} {scanned}" + lower = scanned.lower() + estimated = math.ceil(len(f"{system_prompt or ''} {prompt}") / 4) + names = [str(tool.get("function", {}).get("name", "")).lower() for tool in tools] + has_code = bool( + re.search( + r"```|\b(?:typescript|javascript|python|rust|java|sql|stack trace|traceback|exception)\b|\.(?:ts|tsx|js|py|go|rs)\b", + scanned, + re.IGNORECASE, + ) + or re.search( + r"\b(?:implement|refactor|debug|write|edit|modify|create|define|review|fix)\b.{0,48}\b(?:api|function|class|method)\b", + scanned, + re.IGNORECASE | re.DOTALL, + ) + ) + needs_tools = bool(tools) and _infer_tool_requirement(scanned, scanned_system, tool_choice) + likely_parallel = _parallel(scanned, needs_tools, names) + airline = any( + re.search(r"flight|reservation|airport|baggage|passenger", name) for name in names + ) + retail = any(re.search(r"order|product|item|return|exchange|address", name) for name in names) + web = any(re.fullmatch(r"web_?search|web_?fetch", name) for name in names) + domain = ( + "airline" + if airline and not retail + else "retail" + if retail and not airline + else "web_research" + if web + else "other" + ) + deep_research = domain == "web_research" and bool( + re.search( + r"exact answer|best-supported answer|following clues|multiple public sources|精确答案|多个公开来源", + full, + re.IGNORECASE, + ) + or ( + len(prompt) >= 320 + and re.search( + r"identify|who is|who was|find the person|找出|识别|是谁", full, re.IGNORECASE + ) + ) + ) + terminal = any( + re.fullmatch(r"terminalexec|terminalinspect|terminalsendkeys", name) for name in names + ) + high_stakes = bool( + re.search( + r"\b(?:production|security|payment|legal|medical|financial|audit)\b|生产|安全|支付|法律|医疗|财务|审计", + full, + re.IGNORECASE, + ) + ) + risk = "standard" + if needs_tools and re.search(r"\b(?:return|exchange)\b|退货|换货", scanned, re.IGNORECASE): + risk = "high" + if ( + needs_tools + and domain == "airline" + and re.search( + r"\b(?:cheapest|lowest price|all reservations|every passenger)\b|最便宜|所有", + scanned, + re.IGNORECASE, + ) + ): + risk = "complex_high" + if terminal and re.search( + r"\b(?:multiple|several)\s+(?:scripts?|files?)\b|fix all the issues|pipeline.*(?:fail|fix)", + scanned, + re.IGNORECASE | re.DOTALL, + ): + risk = "complex_high" + multiple_choice = len(re.findall(r"(?:^|\n)\s*[A-D][.)]\s+", scanned, re.IGNORECASE)) + numeric = len(re.findall(r"-?\d+(?:[.,]\d+)?", scanned)) + compact_math = ( + not has_code + and len(prompt) < 2_500 + and numeric >= 2 + and bool( + re.search( + r"[+×÷=%$€£¥]|\b(?:total|each|per|times|half|twice|percent|how many|how much|calculate)\b", + scanned, + re.IGNORECASE, + ) + or re.search(r"[??]\s*$", scanned) + or numeric >= 3 + ) + ) + task: TaskType = "chat" + if has_vision: + task = "vision" + elif estimated > 80_000: + task = "long_context" + elif needs_tools and ( + has_code + or (terminal and re.search(r"\b(?:file|script|server|endpoint)\b", scanned, re.IGNORECASE)) + ): + task = "code_agent" + elif needs_tools and likely_parallel: + task = "tool_agent_parallel" + elif needs_tools: + task = "tool_agent" + elif multiple_choice >= 3: + task = "reasoning_mcq" + elif compact_math: + task = "reasoning_math" + elif re.search( + r"\b(?:bug|debug|error|failure|failing|regression|crash|修复|报错|错误|调试)\b", + lower, + re.IGNORECASE, + ): + task = "debug" + elif has_code or re.search( + r"\b(?:refactor|implement|patch|edit|rewrite|重构|实现|修改)\b", lower, re.IGNORECASE + ): + task = "code_edit" + elif requires_structured_output or re.search( + r"\b(?:extract|json|schema|csv|字段|提取)\b", lower, re.IGNORECASE + ): + task = "extraction" + elif re.search( + r"\b(?:prove|derive|theorem|formal|mathematical|reasoning|证明|推导|定理|数学)\b", + lower, + re.IGNORECASE, + ): + task = "reasoning" + return { + "task_type": task, + "estimated_input_tokens": estimated, + "needs_tools": needs_tools, + "needs_vision": has_vision, + "needs_structured_output": requires_structured_output, + "language": "zh" if re.search(r"[\u3400-\u9fff]", full) else "other", + "domain": domain, + "deep_research": deep_research, + "risk": risk, + "terminal": terminal, + "terminal_safety": terminal and high_stakes, + } + + +def _affinity(model_id: str, features: dict[str, Any]) -> float: + name = model_id.split("/", 1)[-1].lower() + task = features["task_type"] + domain = features["domain"] + risk = features["risk"] + terminal = features["terminal"] + safety = features["terminal_safety"] + base = 0.68 + + def score(mapping: dict[str, float]) -> float: + return max(base, mapping.get(name, 0.0)) + + if task == "code_agent": + if terminal and risk == "complex_high": + return score( + { + "claude-sonnet-5": 1, + "gpt-5.3-codex": 0.87, + "gpt-5-mini": 0.78, + "gemini-3.5-flash": 0.76, + } + ) + return score( + { + "gpt-5.3-codex": 1, + "claude-sonnet-5": 0.98, + "gpt-5-mini": 0.96, + "gemini-3.5-flash": 0.92, + "kimi-k3": 0.9, + "deepseek-v4-pro": 0.88, + "glm-5.2": 0.88, + } + ) + if task == "tool_agent": + if terminal and risk == "complex_high": + return score( + { + "claude-sonnet-5": 1, + "gpt-5.3-codex": 0.87, + "gpt-5-mini": 0.78, + "gemini-3.5-flash": 0.76, + } + ) + if terminal and not safety: + return score( + { + "gpt-5-mini": 1, + "gpt-5.3-codex": 0.98, + "claude-sonnet-5": 0.9, + "gemini-3.5-flash": 0.89, + } + ) + if domain == "web_research": + return score( + { + "claude-sonnet-5": 1, + "gpt-5-mini": 0.88, + "gemini-3.5-flash": 0.84 if features["deep_research"] else 0.86, + "claude-opus-5": 0.8 if features["deep_research"] else 0.84, + "claude-opus-4.8": 0.78 if features["deep_research"] else 0.82, + } + ) + if domain in {"retail", "airline"} and risk == "standard": + return score({"gpt-5-mini": 1, "claude-sonnet-5": 0.9, "gemini-3.5-flash": 0.82}) + if domain == "retail" and risk != "standard": + return score( + { + "deepseek-v4-pro": 1, + "claude-sonnet-5": 0.88, + "gemini-3.5-flash": 0.82, + "gpt-5-mini": 0.76, + } + ) + if domain == "airline" and risk == "complex_high": + return score({"claude-sonnet-5": 1, "gpt-5-mini": 0.78, "gemini-3.5-flash": 0.76}) + return score( + { + "claude-sonnet-5": 1, + "gemini-3.5-flash": 0.88, + "gpt-5.3-codex": 0.87, + "kimi-k3": 0.85, + "gpt-5-mini": 0.84, + "deepseek-v4-pro": 0.82, + } + ) + if task == "tool_agent_parallel": + if terminal: + return score( + { + "gpt-5-mini": 1, + "gpt-5.3-codex": 0.98, + "claude-sonnet-5": 0.92, + "gemini-3.5-flash": 0.88, + } + ) + if domain in {"retail", "airline"}: + return score( + { + "deepseek-v4-pro": 1, + "claude-sonnet-5": 0.88, + "claude-opus-4.8": 0.84, + "gpt-5-mini": 0.78, + } + ) + return score( + { + "claude-opus-4.8": 1, + "claude-sonnet-5": 0.84, + "grok-4.5": 0.82, + "gemini-3.5-flash": 0.8, + "deepseek-v4-pro": 0.78, + } + ) + if task in {"code_edit", "debug"}: + return score( + { + "gpt-5.3-codex": 1, + "claude-sonnet-4.6": 0.94, + "glm-5.2": 0.9, + "kimi-k2.7": 0.86, + "deepseek-v4-pro": 0.86, + } + ) + if task == "reasoning": + return score( + { + "claude-sonnet-5": 0.98, + "claude-sonnet-4.6": 0.98, + "deepseek-v4-pro": 0.95, + "grok-4.5": 0.94, + "gemini-3.1-pro": 0.92, + "gemini-3.5-flash": 0.92, + } + ) + if task == "reasoning_mcq": + return score( + { + "gemini-3-flash-preview": 1, + "gemini-3.5-flash": 0.91, + "grok-4.5": 0.9, + "claude-sonnet-5": 0.88, + "deepseek-v4-pro": 0.84, + } + ) + if task == "reasoning_math": + return score( + { + "gemini-3.5-flash": 1, + "grok-4.5": 0.93, + "claude-sonnet-5": 0.9, + "deepseek-v4-pro": 0.9, + "kimi-k3": 0.9, + "kimi-k2.7": 0.84, + } + ) + if task == "vision": + return score( + { + "gemini-3.1-pro": 0.96, + "qwen3.7-max": 0.9, + "claude-sonnet-4.6": 0.9, + "kimi-k2.7": 0.9, + "grok-4.3": 0.9, + } + ) + if task == "long_context": + return score( + { + "gemini-3.1-pro": 1, + "qwen3.7-max": 0.89, + "glm-5.2": 0.89, + "gemini-3.5-flash": 0.88, + "deepseek-v4-pro": 0.85, + } + ) + if task == "extraction": + kimi = 1 if features["language"] == "zh" else 0.9 + return score( + { + "gemini-3.5-flash": 0.9, + "gemini-2.5-flash": 0.9, + "gpt-4o-mini": 0.9, + "claude-sonnet-5": 0.9, + "claude-sonnet-4.6": 0.9, + "kimi-k3": kimi, + "kimi-k2.7": kimi, + } + ) + return score( + {"gemini-3.5-flash": 0.86, "gemini-2.5-flash": 0.86, "kimi-k3": 0.86, "kimi-k2.7": 0.86} + ) + + +def _eligible(model: str, features: dict[str, Any], max_output_tokens: int) -> bool: + if features["needs_tools"] and model in NO_TOOL_MODELS: + return False + if features["needs_vision"] and model not in VISION_MODELS: + return False + if features["needs_structured_output"] and model in NO_TOOL_MODELS: + return False + if OUTPUT_LIMITS.get(model, 65_536) < max_output_tokens: + return False + context = CONTEXT_LIMITS.get(model, 1_000_000) + return bool(context >= (features["estimated_input_tokens"] + max_output_tokens) * 1.1) + + +def _cost( + model: str, pricing: dict[str, dict[str, float]], input_tokens: int, output_tokens: int +) -> float: + price = pricing.get(model) + if not price: + return math.inf + flat = float(price.get("flat_price", 0) or 0) + if flat: + return flat + return ( + input_tokens * float(price.get("input_price", 0) or 0) + + output_tokens * float(price.get("output_price", 0) or 0) + ) / 1_000_000 + + +def route( + prompt: str, + system_prompt: str | None, + max_output_tokens: int, + model_pricing: dict[str, dict[str, float]], + routing_profile: RoutingProfile = "auto", + *, + tools: list[dict[str, Any]] | None = None, + tool_choice: Any = None, + requires_structured_output: bool = False, + has_vision: bool = False, + minimum_payment_usd: float = 0.001, +) -> RoutingDecision: + """Return a deterministic V3 portfolio decision with no model call.""" + + tools = tools or [] + features = _task_features( + prompt, + system_prompt, + tools, + tool_choice, + requires_structured_output, + has_vision, + ) + estimated = features["estimated_input_tokens"] + rules = classify_by_rules(prompt, system_prompt, estimated) + tier: Tier = rules["tier"] or "MEDIUM" + if estimated > 100_000: + tier = "COMPLEX" + if features["task_type"] in {"reasoning_mcq", "reasoning_math"} and tier in { + "SIMPLE", + "MEDIUM", + }: + tier = "REASONING" + + normalized_profile = "eco" if routing_profile == "free" else routing_profile + profile_name = normalized_profile if normalized_profile in {"eco", "premium"} else "auto" + if profile_name == "eco": + tiers = ECO_TIERS + decision_profile = "eco" + elif profile_name == "premium": + tiers = PREMIUM_TIERS + decision_profile = "premium" + elif features["needs_tools"] or float(rules.get("agentic_score", 0)) >= 0.5: + tiers = AGENTIC_TIERS + decision_profile = "agentic" + else: + tiers = AUTO_TIERS + decision_profile = "auto" + + configured = [tiers[tier]["primary"], *tiers[tier]["fallback"]] + chain = list(dict.fromkeys([*configured, *EVIDENCE_CANDIDATES.get(features["task_type"], [])])) + chain = [model for model in chain if model in model_pricing] + eligible = [model for model in chain if _eligible(model, features, max_output_tokens)] + available = eligible or chain + if not available: + raise ValueError("Router found no model present in the current BlockRun catalog") + + affinities = {model: _affinity(model, features) for model in available} + best_affinity = max(affinities.values()) + specific = [model for model in available if affinities[model] > 0.68] + pool = specific or [available[0]] + gap = AFFINITY_FLOOR[profile_name] + if features["terminal"]: + gap = max(gap, 0.15 if features["terminal_safety"] else 0.12) + candidates = [model for model in pool if affinities[model] >= best_affinity - gap] + raw_costs = [_cost(model, model_pricing, estimated, max_output_tokens) for model in candidates] + finite = [cost for cost in raw_costs if math.isfinite(cost)] + min_cost = min(finite) if finite else 0 + max_cost = max(finite) if finite else 1 + weights = PORTFOLIO_WEIGHTS[profile_name] + ranked_entries: list[dict[str, float | str]] = [] + for index, model in enumerate(candidates): + raw = _cost(model, model_pricing, estimated, max_output_tokens) + cost_score = ( + 1 - (raw - min_cost) / (max_cost - min_cost) + if math.isfinite(raw) and max_cost > min_cost + else 0.5 + ) + legacy = 1 - index / max(1, len(candidates) - 1) + quality_weight = weights["quality"] + ( + 0.08 + if re.search( + r"production|security|payment|legal|medical|financial|audit|生产|安全|支付|法律|医疗|财务|审计", + f"{system_prompt or ''} {prompt}", + re.IGNORECASE, + ) + else 0 + ) + score_value = ( + affinities[model] * quality_weight + + weights["capability"] + + cost_score * weights["cost"] + + 0.5 * weights["speed"] + + 1.0 * weights["reliability"] + + legacy * weights["legacy"] + ) + ranked_entries.append( + { + "model": model, + "score": score_value, + "quality": affinities[model], + "cost": cost_score, + "speed": 0.5, + "reliability": 1.0, + } + ) + ranked_entries.sort(key=lambda entry: float(entry["score"]), reverse=True) + scored = [str(entry["model"]) for entry in ranked_entries] + ranked = [*scored, *[model for model in available if model not in scored]] + model = ranked[0] + + raw_selected = _cost(model, model_pricing, estimated, max_output_tokens) + selected_price = model_pricing[model] + if selected_price.get("flat_price"): + estimated_cost = max(float(selected_price["flat_price"]), minimum_payment_usd) + else: + estimated_cost = max(raw_selected * 1.05, minimum_payment_usd) + baseline = (estimated * 5.0 + max_output_tokens * 30.0) / 1_000_000 + savings = ( + 0.0 + if profile_name == "premium" or baseline <= 0 + else max(0.0, (baseline - estimated_cost) / baseline) + ) + confidence = ( + 0.95 if estimated > 100_000 else float(rules["confidence"] if rules["tier"] else 0.5) + ) + return { + "model": model, + "tier": tier, + "confidence": confidence, + "method": "portfolio", + "reasoning": f"score={rules['score']:.2f} | v3 task={features['task_type']} candidates={len(ranked)}", + "cost_estimate": estimated_cost, + "baseline_cost": baseline, + "savings": savings, + "profile": decision_profile, + "task_type": features["task_type"], + "router_version": "v3-portfolio", + "candidates": ranked, + "candidate_scores": ranked_entries, + "fallbacks": ranked[1:], + } diff --git a/blockrun_llm/solana_client.py b/blockrun_llm/solana_client.py index cdf750f..ddfec3c 100644 --- a/blockrun_llm/solana_client.py +++ b/blockrun_llm/solana_client.py @@ -33,9 +33,11 @@ # "already paid, do not retry on another model" tag has to mean the same thing # in both fallback chains. client.py does not import this module, so there is # no cycle. -from .client import _SETTLED_ATTR, _enforce_spend_limits, _mark_settled +from .client import _SETTLED_ATTR, _enforce_spend_limits, _mark_settled, _should_fallback from .price import Category, Market, Resolution, Session from .realface import _GROUP_ID_RE +from .router_v3 import message_routing_inputs, routing_profile_for_model +from .router_v3 import route as route_request from .solana_wallet import get_solana_public_key from .tx_log import ( TransactionLogger, @@ -60,8 +62,11 @@ RealFaceList, RealFaceStatus, RetiredEndpointError, + RoutingDecision, + RoutingProfile, RpcResponse, SearchResult, + SmartChatResponse, SpeechResponse, SymbolListResponse, VideoResponse, @@ -386,7 +391,7 @@ def _safe_path_segment(value: str, field: str) -> str: """Return ``value`` if it is a single safe URL path segment, else raise.""" if not value or not _SAFE_PATH_SEGMENT_RE.match(value): raise ValueError( - f"{field} must contain only letters, digits, '.', '_' or '-' " f"(got {value!r})" + f"{field} must contain only letters, digits, '.', '_' or '-' (got {value!r})" ) return value @@ -539,6 +544,7 @@ def __init__( self._max_session_cost = resolve_spend_limit(max_session_cost, "BLOCKRUN_MAX_SESSION_COST") self._session_calls = 0 self._last_call_cost: float = 0.0 + self._model_pricing_cache: dict[str, dict[str, float]] | None = None self._address: str | None = None log_dir = _resolve_log_dir(transaction_log) @@ -561,7 +567,7 @@ def __init__( # front: turn a malformed key (incl. one auto-loaded from disk) into # a clean error instead of a raw base58/solders exception. raise ValueError( - "Invalid Solana private key (expected a base58-encoded keypair " "or 32-byte seed)." + "Invalid Solana private key (expected a base58-encoded keypair or 32-byte seed)." ) from e _register_svm_with_headers(self._x402_client, signer, resolved_url, resolved_headers) # x402ClientSync is NOT thread-safe: concurrent payment signing on one @@ -666,6 +672,106 @@ def _log_transaction( except Exception: pass + def _get_model_pricing(self) -> dict[str, dict[str, float]]: + if self._model_pricing_cache is not None: + return self._model_pricing_cache + pricing: dict[str, dict[str, float]] = {} + for model in self.list_models(): + block = model.get("pricing") or {} + model_id = model.get("id", "") + pricing[model_id] = { + "input_price": float(block.get("input", model.get("inputPrice", 0)) or 0), + "output_price": float(block.get("output", model.get("outputPrice", 0)) or 0), + "flat_price": float(block.get("flat", model.get("flatPrice", 0)) or 0), + } + self._model_pricing_cache = pricing + return pricing + + def route( + self, + prompt: str, + *, + system: str | None = None, + max_tokens: int = DEFAULT_MAX_TOKENS, + routing_profile: RoutingProfile = "auto", + tools: list[dict[str, Any]] | None = None, + tool_choice: Any | None = None, + response_format: dict[str, Any] | None = None, + has_vision: bool = False, + ) -> RoutingDecision: + decision = route_request( + prompt=prompt, + system_prompt=system, + max_output_tokens=max_tokens, + model_pricing=self._get_model_pricing(), + routing_profile=routing_profile, + tools=tools, + tool_choice=tool_choice, + requires_structured_output=response_format is not None, + has_vision=has_vision, + minimum_payment_usd=0.001, + ) + return RoutingDecision(**decision) + + def smart_chat( + self, + prompt: str, + *, + system: str | None = None, + max_tokens: int = DEFAULT_MAX_TOKENS, + temperature: float | None = None, + routing_profile: RoutingProfile = "auto", + ) -> SmartChatResponse: + decision = self.route( + prompt, system=system, max_tokens=max_tokens, routing_profile=routing_profile + ) + response = self.chat( + decision.model, + prompt, + system=system, + max_tokens=max_tokens, + temperature=temperature, + fallback_models=decision.fallbacks, + ) + return SmartChatResponse(response=response, model=decision.model, routing=decision) + + def smart_chat_completion( + self, + messages: list[dict[str, Any]], + *, + max_tokens: int = DEFAULT_MAX_TOKENS, + routing_profile: RoutingProfile = "auto", + tools: list[dict[str, Any]] | None = None, + tool_choice: Any | None = None, + response_format: dict[str, Any] | None = None, + **kwargs: Any, + ) -> ChatResponse: + prompt, system, has_vision = message_routing_inputs(messages) + decision = self.route( + prompt, + system=system, + max_tokens=max_tokens, + routing_profile=routing_profile, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + has_vision=has_vision, + ) + response = self.chat_completion( + decision.model, + messages, + max_tokens=max_tokens, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + fallback_models=decision.fallbacks, + **kwargs, + ) + response.routing = ( + decision.model_dump() if hasattr(decision, "model_dump") else decision.dict() + ) + return response + def chat( self, model: str, @@ -677,6 +783,7 @@ def chat( timeout: float | None = None, response_format: dict[str, Any] | None = None, stop: str | list[str] | None = None, + fallback_models: list[str] | None = None, ) -> str: """Simple 1-line chat.""" messages: list[dict[str, str]] = [] @@ -692,6 +799,7 @@ def chat( timeout=timeout, response_format=response_format, stop=stop, + fallback_models=fallback_models, ) return result.choices[0].message.content or "" @@ -709,6 +817,7 @@ def chat_completion( timeout: float | None = None, response_format: dict[str, Any] | None = None, stop: str | list[str] | None = None, + fallback_models: list[str] | None = None, ) -> ChatResponse: """Full chat completion (OpenAI-compatible). @@ -721,6 +830,24 @@ def chat_completion( client's chat baseline, ``DEFAULT_CHAT_TIMEOUT``). Raise it for large ``max_tokens`` runs against slow models. """ + routing_decision: RoutingDecision | None = None + alias_profile = routing_profile_for_model(model) + if alias_profile is not None: + prompt, system, has_vision = message_routing_inputs(messages) + routing_decision = self.route( + prompt, + system=system, + max_tokens=max_tokens, + routing_profile=alias_profile, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + has_vision=has_vision, + ) + model = routing_decision.model + if fallback_models is None: + fallback_models = routing_decision.fallbacks + validate_max_tokens(max_tokens) body: dict[str, Any] = {"model": model, "messages": messages, "max_tokens": max_tokens} if temperature is not None: @@ -739,7 +866,25 @@ def chat_completion( body["response_format"] = response_format if stop is not None: body["stop"] = stop - return self._request_with_payment("/v1/chat/completions", body, timeout=timeout) + attempts = [model, *(fallback_models or [])] + last_exc: Exception | None = None + for attempt_model in attempts: + body["model"] = attempt_model + try: + response = self._request_with_payment("/v1/chat/completions", body, timeout=timeout) + if routing_decision is not None: + response.routing = ( + routing_decision.model_dump() + if hasattr(routing_decision, "model_dump") + else routing_decision.dict() + ) + return response + except Exception as exc: + if not _should_fallback(exc): + raise + last_exc = exc + assert last_exc is not None + raise last_exc def close(self) -> None: """Close the HTTP client.""" @@ -822,6 +967,23 @@ def chat_completion_stream( Note: ``search_parameters`` is rejected by the BlockRun gateway in stream mode (HTTP 400). Codex / GPT-5.4-Pro also can't stream. """ + alias_profile = routing_profile_for_model(model) + if alias_profile is not None: + prompt, system, has_vision = message_routing_inputs(messages) + decision = self.route( + prompt, + system=system, + max_tokens=max_tokens, + routing_profile=alias_profile, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + has_vision=has_vision, + ) + model = decision.model + if fallback_models is None: + fallback_models = decision.fallbacks + validate_max_tokens(max_tokens) body: dict[str, Any] = { "model": model, @@ -2926,6 +3088,7 @@ def __init__( self._max_session_cost = resolve_spend_limit(max_session_cost, "BLOCKRUN_MAX_SESSION_COST") self._session_calls = 0 self._last_call_cost: float = 0.0 + self._model_pricing_cache: dict[str, dict[str, float]] | None = None self._address: str | None = None log_dir = _resolve_log_dir(transaction_log) @@ -2950,7 +3113,7 @@ def __init__( # front: turn a malformed key (incl. one auto-loaded from disk) into # a clean error instead of a raw base58/solders exception. raise ValueError( - "Invalid Solana private key (expected a base58-encoded keypair " "or 32-byte seed)." + "Invalid Solana private key (expected a base58-encoded keypair or 32-byte seed)." ) from e _register_svm_with_headers(self._x402_client, signer, resolved_url, resolved_headers) # Lazily created on first sign (avoids binding asyncio.Lock to a loop at @@ -3052,6 +3215,107 @@ def _billing_meta(self) -> dict[str, str | None]: # Non-streaming chat # ------------------------------------------------------------------ + async def _get_model_pricing(self) -> dict[str, dict[str, float]]: + if self._model_pricing_cache is not None: + return self._model_pricing_cache + models = await self.list_models() + pricing: dict[str, dict[str, float]] = {} + for model in models: + block = model.get("pricing") or {} + model_id = model.get("id", "") + pricing[model_id] = { + "input_price": float(block.get("input", model.get("inputPrice", 0)) or 0), + "output_price": float(block.get("output", model.get("outputPrice", 0)) or 0), + "flat_price": float(block.get("flat", model.get("flatPrice", 0)) or 0), + } + self._model_pricing_cache = pricing + return pricing + + async def route( + self, + prompt: str, + *, + system: str | None = None, + max_tokens: int = DEFAULT_MAX_TOKENS, + routing_profile: RoutingProfile = "auto", + tools: list[dict[str, Any]] | None = None, + tool_choice: Any | None = None, + response_format: dict[str, Any] | None = None, + has_vision: bool = False, + ) -> RoutingDecision: + decision = route_request( + prompt=prompt, + system_prompt=system, + max_output_tokens=max_tokens, + model_pricing=await self._get_model_pricing(), + routing_profile=routing_profile, + tools=tools, + tool_choice=tool_choice, + requires_structured_output=response_format is not None, + has_vision=has_vision, + minimum_payment_usd=0.001, + ) + return RoutingDecision(**decision) + + async def smart_chat( + self, + prompt: str, + *, + system: str | None = None, + max_tokens: int = DEFAULT_MAX_TOKENS, + temperature: float | None = None, + routing_profile: RoutingProfile = "auto", + ) -> SmartChatResponse: + decision = await self.route( + prompt, system=system, max_tokens=max_tokens, routing_profile=routing_profile + ) + response = await self.chat( + decision.model, + prompt, + system=system, + max_tokens=max_tokens, + temperature=temperature, + fallback_models=decision.fallbacks, + ) + return SmartChatResponse(response=response, model=decision.model, routing=decision) + + async def smart_chat_completion( + self, + messages: list[dict[str, Any]], + *, + max_tokens: int = DEFAULT_MAX_TOKENS, + routing_profile: RoutingProfile = "auto", + tools: list[dict[str, Any]] | None = None, + tool_choice: Any | None = None, + response_format: dict[str, Any] | None = None, + **kwargs: Any, + ) -> ChatResponse: + prompt, system, has_vision = message_routing_inputs(messages) + decision = await self.route( + prompt, + system=system, + max_tokens=max_tokens, + routing_profile=routing_profile, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + has_vision=has_vision, + ) + response = await self.chat_completion( + decision.model, + messages, + max_tokens=max_tokens, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + fallback_models=decision.fallbacks, + **kwargs, + ) + response.routing = ( + decision.model_dump() if hasattr(decision, "model_dump") else decision.dict() + ) + return response + async def chat( self, model: str, @@ -3063,6 +3327,7 @@ async def chat( timeout: float | None = None, response_format: dict[str, Any] | None = None, stop: str | list[str] | None = None, + fallback_models: list[str] | None = None, ) -> str: messages: list[dict[str, str]] = [] if system: @@ -3077,6 +3342,7 @@ async def chat( timeout=timeout, response_format=response_format, stop=stop, + fallback_models=fallback_models, ) return result.choices[0].message.content or "" @@ -3094,7 +3360,26 @@ async def chat_completion( timeout: float | None = None, response_format: dict[str, Any] | None = None, stop: str | list[str] | None = None, + fallback_models: list[str] | None = None, ) -> ChatResponse: + routing_decision: RoutingDecision | None = None + alias_profile = routing_profile_for_model(model) + if alias_profile is not None: + prompt, system, has_vision = message_routing_inputs(messages) + routing_decision = await self.route( + prompt, + system=system, + max_tokens=max_tokens, + routing_profile=alias_profile, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + has_vision=has_vision, + ) + model = routing_decision.model + if fallback_models is None: + fallback_models = routing_decision.fallbacks + validate_max_tokens(max_tokens) body: dict[str, Any] = {"model": model, "messages": messages, "max_tokens": max_tokens} if temperature is not None: @@ -3113,7 +3398,27 @@ async def chat_completion( body["response_format"] = response_format if stop is not None: body["stop"] = stop - return await self._request_with_payment("/v1/chat/completions", body, timeout=timeout) + attempts = [model, *(fallback_models or [])] + last_exc: Exception | None = None + for attempt_model in attempts: + body["model"] = attempt_model + try: + response = await self._request_with_payment( + "/v1/chat/completions", body, timeout=timeout + ) + if routing_decision is not None: + response.routing = ( + routing_decision.model_dump() + if hasattr(routing_decision, "model_dump") + else routing_decision.dict() + ) + return response + except Exception as exc: + if not _should_fallback(exc): + raise + last_exc = exc + assert last_exc is not None + raise last_exc async def list_models(self) -> list[dict[str, Any]]: resp = await self._client.get(f"{self._api_url}/v1/models") @@ -3144,6 +3449,23 @@ async def chat_completion_stream( """Async streaming. Same protocol semantics as the sync :meth:`SolanaLLMClient.chat_completion_stream`; only the iteration protocol differs (``async for``).""" + alias_profile = routing_profile_for_model(model) + if alias_profile is not None: + prompt, system, has_vision = message_routing_inputs(messages) + decision = await self.route( + prompt, + system=system, + max_tokens=max_tokens, + routing_profile=alias_profile, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + has_vision=has_vision, + ) + model = decision.model + if fallback_models is None: + fallback_models = decision.fallbacks + validate_max_tokens(max_tokens) body: dict[str, Any] = { "model": model, diff --git a/blockrun_llm/types.py b/blockrun_llm/types.py index 0dc0108..a27e173 100644 --- a/blockrun_llm/types.py +++ b/blockrun_llm/types.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List, Literal, Optional, Union -from pydantic import BaseModel +from pydantic import BaseModel, Field # Tool calling types (OpenAI compatible) @@ -118,6 +118,7 @@ class ChatResponse(BaseModel): # returned an X-PAYMENT-RESPONSE header. cost_usd: Optional[float] = None settlement: Optional[Dict[str, Any]] = None + routing: Optional[Dict[str, Any]] = None class Config: extra = "allow" @@ -659,7 +660,7 @@ def cost(self) -> float: return self.spending_report.cost_usd -# Smart routing types (ClawRouter integration) +# Smart routing types (BlockRun Router Core V3 integration) RoutingProfile = Literal["free", "eco", "auto", "premium"] RoutingTier = Literal["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"] @@ -670,12 +671,17 @@ class RoutingDecision(BaseModel): model: str tier: RoutingTier confidence: float - method: Literal["rules"] + method: Literal["rules", "portfolio"] reasoning: str cost_estimate: float baseline_cost: float savings: float # 0-1 percentage - fallbacks: List[str] = [] # remaining models in tier order, for runtime fallback + profile: str = "auto" + task_type: str = "chat" + router_version: str = "legacy" + candidates: List[str] = Field(default_factory=list) + candidate_scores: List[Dict[str, Any]] = Field(default_factory=list) + fallbacks: List[str] = Field(default_factory=list) class SmartChatResponse(BaseModel): diff --git a/tests/unit/test_router_v3.py b/tests/unit/test_router_v3.py new file mode 100644 index 0000000..fe71e9f --- /dev/null +++ b/tests/unit/test_router_v3.py @@ -0,0 +1,253 @@ +"""Golden conformance and SDK plumbing for Router Core V3.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from blockrun_llm.client import AsyncLLMClient, LLMClient +from blockrun_llm.router_v3 import ROUTER_CORE_COMMIT, message_routing_inputs, route +from blockrun_llm.solana_client import AsyncSolanaLLMClient, SolanaLLMClient +from blockrun_llm.types import ChatResponse + + +def _price(input_price: float, output_price: float) -> dict[str, float]: + return {"input_price": input_price, "output_price": output_price, "flat_price": 0} + + +# Current public catalog subset used by the official Router Core golden cases. +PRICING = { + "google/gemini-2.5-flash": _price(0.3, 2.5), + "google/gemini-3-flash-preview": _price(0.5, 3), + "google/gemini-3.5-flash": _price(1.5, 9), + "google/gemini-3.1-pro": _price(2, 12), + "google/gemini-3.1-flash-lite": _price(0.25, 1.5), + "google/gemini-2.5-flash-lite": _price(0.1, 0.4), + "deepseek/deepseek-chat": _price(0.2, 0.4), + "deepseek/deepseek-reasoner": _price(0.2, 0.4), + "deepseek/deepseek-v4-pro": _price(0.435, 0.87), + "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), + "openai/gpt-4o-mini": _price(0.15, 0.6), + "openai/gpt-4.1": _price(2, 8), + "openai/o4-mini": _price(1.1, 4.4), + "openai/o3": _price(2, 8), + "anthropic/claude-haiku-4.5": _price(1, 5), + "anthropic/claude-sonnet-5": _price(3, 15), + "anthropic/claude-sonnet-4.6": _price(3, 15), + "anthropic/claude-opus-4.8": _price(5, 25), + "anthropic/claude-opus-5": _price(5, 25), + "xai/grok-4.5": _price(2.5, 9), + "xai/grok-4.3": _price(1.5, 4), + "moonshot/kimi-k3": _price(3, 15), + "zai/glm-5.2": _price(1.4, 4.4), +} + + +TOOLS = { + "terminal": [ + {"type": "function", "function": {"name": "terminalExec"}}, + {"type": "function", "function": {"name": "terminalInspect"}}, + {"type": "function", "function": {"name": "terminalSendKeys"}}, + ], + "order": [ + {"type": "function", "function": {"name": "get_order"}}, + {"type": "function", "function": {"name": "update_address"}}, + ], + "weather": [{"type": "function", "function": {"name": "get_weather"}}], + "web": [ + {"type": "function", "function": {"name": "web_search"}}, + {"type": "function", "function": {"name": "web_fetch"}}, + ], +} + + +@pytest.mark.parametrize( + ("prompt", "kwargs", "expected"), + [ + ( + "Explain why the sky is blue in two sentences.", + {}, + ("google/gemini-2.5-flash", "SIMPLE", "chat"), + ), + ( + "从这段文字中提取姓名和公司,并返回 JSON:Ada works at BlockRun。", + {"requires_structured_output": True}, + ("google/gemini-2.5-flash", "MEDIUM", "extraction"), + ), + ( + "Refactor this TypeScript function to avoid the race condition and return a patch.", + {}, + ("google/gemini-3-flash-preview", "MEDIUM", "code_edit"), + ), + ( + "Debug the failing Python tests, identify the regression, edit the files, and verify the fix.", + {}, + ("openai/gpt-4o-mini", "MEDIUM", "debug"), + ), + ( + "Prove the theorem formally and derive every step.", + {}, + ("deepseek/deepseek-v4-pro", "REASONING", "reasoning"), + ), + ( + "Which option is correct?\nA. Mercury\nB. Venus\nC. Earth\nD. Mars", + {}, + ("google/gemini-3-flash-preview", "REASONING", "reasoning_mcq"), + ), + ( + "A shop sells 3 books at $12 each with a 25% discount. How much is the total?", + {}, + ("deepseek/deepseek-v4-pro", "REASONING", "reasoning_math"), + ), + ( + "Inspect the repository, edit the TypeScript files, run tests, and fix the bug.", + {"tools": TOOLS["terminal"], "tool_choice": "required"}, + ("openai/gpt-5-mini", "SIMPLE", "code_agent"), + ), + ( + "Check my order status and update its delivery address.", + {"tools": TOOLS["order"], "tool_choice": "required"}, + ("openai/gpt-5-mini", "SIMPLE", "tool_agent"), + ), + ( + "Get the weather for Tokyo, Paris, and London simultaneously.", + {"tools": TOOLS["weather"], "tool_choice": "required"}, + ("anthropic/claude-opus-4.8", "SIMPLE", "tool_agent_parallel"), + ), + ( + "Using multiple public sources, identify the person described by the following clues and return one exact best-supported answer: they founded a company after 2010, later joined another lab, and published work in 2024.", + {"tools": TOOLS["web"], "tool_choice": "required"}, + ("anthropic/claude-sonnet-5", "MEDIUM", "tool_agent"), + ), + ( + "Read this screenshot and explain the error.", + {"has_vision": True}, + ("google/gemini-2.5-flash", "SIMPLE", "vision"), + ), + ( + "Review this production payment implementation for security vulnerabilities.", + {"routing_profile": "premium"}, + ("google/gemini-2.5-flash", "SIMPLE", "chat"), + ), + ( + "Summarize this note in one sentence.", + {"routing_profile": "eco"}, + ("google/gemini-3.1-flash-lite", "SIMPLE", "chat"), + ), + ], +) +def test_router_v3_matches_core_golden_after_catalog_filter( + prompt: str, kwargs: dict[str, Any], expected: tuple[str, str, str] +) -> None: + decision = route(prompt, None, 512, PRICING, **kwargs) + assert (decision["model"], decision["tier"], decision["task_type"]) == expected + assert decision["router_version"] == "v3-portfolio" + assert ROUTER_CORE_COMMIT == "d4308049348e11e17ed08a254676a34949be80f9" + + +def _response(model: str) -> ChatResponse: + return ChatResponse( + id="chatcmpl-test", + created=1, + model=model, + choices=[{"index": 0, "message": {"role": "assistant", "content": "ok"}}], + ) + + +def test_base_auto_alias_routes_full_agent_turn(monkeypatch: pytest.MonkeyPatch) -> None: + client = object.__new__(LLMClient) + client._model_pricing_cache = PRICING + seen: dict[str, Any] = {} + + def fake_request(_endpoint: str, body: dict[str, Any]) -> ChatResponse: + seen.update(body) + return _response(body["model"]) + + monkeypatch.setattr(client, "_request_with_payment", fake_request) + result = client.chat_completion( + "blockrun/auto", + [{"role": "user", "content": "Check my order status and update its delivery address."}], + tools=TOOLS["order"], + tool_choice="required", + max_tokens=512, + ) + assert seen["model"] == "openai/gpt-5-mini" + assert seen["tools"] == TOOLS["order"] + assert result.routing and result.routing["task_type"] == "tool_agent" + + +def test_solana_auto_alias_uses_solana_payment_floor(monkeypatch: pytest.MonkeyPatch) -> None: + client = object.__new__(SolanaLLMClient) + client._model_pricing_cache = PRICING + seen: dict[str, Any] = {} + + def fake_request( + _endpoint: str, body: dict[str, Any], timeout: float | None = None + ) -> ChatResponse: + del timeout + seen.update(body) + return _response(body["model"]) + + monkeypatch.setattr(client, "_request_with_payment", fake_request) + result = client.chat_completion( + "blockrun/auto", + [ + { + "role": "user", + "content": "Get the weather for Tokyo, Paris, and London simultaneously.", + } + ], + tools=TOOLS["weather"], + tool_choice="required", + max_tokens=512, + ) + assert seen["model"] == "anthropic/claude-opus-4.8" + assert result.routing and result.routing["cost_estimate"] >= 0.001 + + +@pytest.mark.parametrize("client_type", [AsyncLLMClient, AsyncSolanaLLMClient]) +async def test_async_auto_alias_routes_before_payment( + monkeypatch: pytest.MonkeyPatch, client_type: type[AsyncLLMClient | AsyncSolanaLLMClient] +) -> None: + client = object.__new__(client_type) + + async def fake_pricing() -> dict[str, dict[str, float]]: + return PRICING + + async def fake_request(_endpoint: str, body: dict[str, Any], **_kwargs: Any) -> ChatResponse: + return _response(body["model"]) + + monkeypatch.setattr(client, "_get_model_pricing", fake_pricing) + monkeypatch.setattr(client, "_request_with_payment", fake_request) + result = await client.chat_completion( + "blockrun/auto", + [{"role": "user", "content": "Check my order status and update its delivery address."}], + tools=TOOLS["order"], + tool_choice="required", + max_tokens=512, + ) + assert result.model == "openai/gpt-5-mini" + assert result.routing and result.routing["task_type"] == "tool_agent" + + +def test_message_extraction_uses_latest_user_and_preserves_vision() -> None: + prompt, system, vision = message_routing_inputs( + [ + {"role": "system", "content": "Be safe"}, + {"role": "user", "content": "old task"}, + {"role": "assistant", "content": "done"}, + { + "role": "user", + "content": [ + {"type": "text", "text": "read this"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,x"}}, + ], + }, + ] + ) + assert prompt == "read this" + assert system == "Be safe" + assert vision is True