Files
openswarm/backend/apps/agents/providers/registry.py
T
Arnav NavalandCursor 64dfd50d12 [arnav] remove dead code identified by audit
Each removal verified by checking actual production callers (frontend,
electron, internal HTTP, MCP-server subprocesses) — not just test
references. Symbols whose only callers were tests are removed along
with those tests.

Production removals (~390 LOC):

- backend/main.py
  - websocket_session: drop `agent:edit_message` WS branch. Frontend
    only ever uses HTTP `POST /api/agents/sessions/{id}/edit_message`
    (frontend/src/shared/state/agentsSlice.ts); nothing on the wire
    sends a WS message of this type.

- backend/apps/agents/agent_manager.py
  - AgentManager._build_connected_tools_context (~80 LOC): zero call
    sites in production; the connected-tools system-prompt context is
    built inline in _compose_system_prompt now.
  - AgentManager._approx_tokens / _summarize_message_block: pure
    helpers whose only callers were tests. The compaction path uses
    LLM-driven _maybe_compact instead.

- backend/apps/agents/browser_agent.py
  - clear_browser_history: only used by tests. _browser_history is
    pruned via the size cap inline.
  - MODEL_MAP constant: never read.

- backend/apps/agents/mcp_preflight.py
  - DISCOVERY_SCAFFOLDING (~25-line system-prompt block): defined but
    never appended anywhere. The header comment described an intended
    use that the codebase no longer has.

- backend/apps/agents/providers/registry.py
  - thinking_params_for, _is_9router_available, OPENROUTER_BASE_URL,
    get_context_window: zero callers in production. Thinking-params
    routing is done by the provider classes directly; 9Router presence
    is detected at request time; context-window numbers are stamped
    onto sessions from BUILTIN_MODELS at launch.

- backend/apps/agents/tools/{base,web}.py
  - BaseTool.get_schema (abstract) + WebSearchTool/WebFetchTool
    overrides: production code in backend/apps/web/web.py instantiates
    these tools and only calls .execute(); the JSON-schema lives in
    the HTTP wrapper, not on the tool class.

- backend/apps/outputs/outputs.py
  - _resolve_model + MODEL_MAP: tests-only.
  - load_output: docstring claimed it was a public helper for "other
    modules" but no module imported it.

- backend/apps/service/client.py
  - set_user_id, the _user_id module global, and the dead cache short-
    circuit in _get_user_id: setter was tests-only. _get_user_id now
    reads user_email directly from settings on every call.

- backend/apps/settings/credentials.py
  - get_provider_credentials: zero callers. The sibling get_agent_sdk_env
    is kept (it has the explicit "Legacy helpers" keep-comment).

Test updates:

- test_agent_manager_unit.py: drop _approx_tokens / _summarize_message_block
  cases (5 tests), update module docstring index.
- test_browser_agent_unit.py: drop clear_browser_history cases (2 tests)
  and the unused _Boom helper class in the repr-fallback test.
- test_outputs_unit.py: drop _resolve_model / load_output cases
  (4 tests), update docstring + import list.
- test_v2_invariants.py: drop get_context_window tests + get_schema
  assertions on web tools (kept name + BaseTool inheritance checks).
- test_service.py: rewrite the 4 set_user_id-driven tests to drive
  user_id through settings.user_email instead, so _get_user_id's live
  envelope-stamping path stays covered.

Verification:
- ruff --select F401,F811,F841 backend/  →  clean.
- pytest backend/tests/ → 1167 passed, 1 deselected (pre-existing
  sandbox git test, unrelated). No tests dropped silently — every
  deletion is paired with the corresponding test removal/rewrite.
- Dead-code scan re-run: dead WS events 1→0, Tier-2 high-confidence
  14→11 (residue is SDK-callback `context` params + Pydantic `cls`
  validators — both false positives vulture can't see through),
  vulture total 165→145.

Total diff: -565 / +34 LOC across 15 files.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 18:49:30 -05:00

464 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Provider registry and model catalog.
Live agent path goes through claude_agent_sdk via agent_manager._run_agent_loop.
Non-Anthropic models route through 9Router's /v1/messages endpoint with
prefixed ids (cx/gpt-5.4, gc/gemini-3-pro-preview).
"""
from __future__ import annotations
import logging
from typing import Any, TYPE_CHECKING
if TYPE_CHECKING:
from backend.apps.settings.models import AppSettings
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tier 1: Built-in models (curated, we know their quirks)
# ---------------------------------------------------------------------------
#
# Fields:
# value — short internal name stored on AgentSession.model
# label — display name in the model picker
# context_window — tokens
# model_id — bare model string for direct API calls (Anthropic key path)
# router_model_id — prefixed string for 9Router routing (cc/, cx/, gc/)
# api — "anthropic" | "codex" | "gemini-cli"
# subscription_only— True means hidden from picker unless 9Router has that
# provider actively connected
# reasoning — True for models that emit Anthropic `thinking` content
# blocks via 9Router's translator. OpenSwarm's stream
# handler at agent_manager.py:1141-1165 does not yet
# render these blocks — final text still appears but
# the reasoning trace is silently dropped. Tracked as
# a follow-up; add a `thinking` case to the handler
# to surface the trace.
#
# Model IDs match 9Router's internal routing catalog at
# 9router/src/shared/constants/pricing.js. Each provider has a distinct
# model-name convention:
# - cc/ (Claude Code subscription) uses dash-notation: claude-sonnet-4-6
# - cx/ (OpenAI Codex subscription) uses dot-notation with -codex suffix.
# Note: `gpt-5.4` is NOT available on this path — it's API-key-only.
# The Codex subscription's flagship is gpt-5.3-codex.
# - gc/ (Gemini CLI subscription) uses gemini-3-pro-preview / 3-flash-preview
# (Gemini 3 family — thinking-capable). 2.5 models removed.
# Gemini 3 thought signatures handled via skip_thought_signature_validator.
BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
# Anthropic: Sonnet 4.6 (Feb 17 2026), Opus 4.6 (Feb 5 2026),
# Haiku 4.5 (Oct 2025). Opus 4.7 was briefly exposed but pulled —
# the Claude Code SDK currently elides plaintext thinking deltas
# for 4.7 (encrypted/redacted blocks only), which broke the
# "Thought for Ns" pill UX. Re-add once Anthropic ships the
# plaintext summarizer for 4.7.
"Anthropic": [
# Adaptive entries: route is chosen at call time based on
# settings.connection_mode (openswarm-pro → proxy; api_key → direct;
# else → 9Router cc/).
{"value": "sonnet", "label": "Claude Sonnet 4.6", "context_window": 1_000_000,
"model_id": "claude-sonnet-4-6", "router_model_id": "cc/claude-sonnet-4-6", "api": "anthropic", "reasoning": True},
{"value": "opus", "label": "Claude Opus 4.6", "context_window": 1_000_000,
"model_id": "claude-opus-4-6", "router_model_id": "cc/claude-opus-4-6", "api": "anthropic", "reasoning": True},
{"value": "haiku", "label": "Claude Haiku 4.5", "context_window": 200_000,
"model_id": "claude-haiku-4-5", "router_model_id": "cc/claude-haiku-4-5-20251001", "api": "anthropic", "reasoning": True},
# Pinned-subscription entries: always route via 9Router's `cc/` prefix
# (the user's personal Claude Pro/Max subscription), regardless of
# connection_mode. Surfaced in list_models only when the user has
# BOTH openswarm-pro active AND the 9Router `claude` subscription
# connected — so the model picker can offer a per-call choice between
# the managed OpenSwarm proxy and their own Claude subscription.
{"value": "sonnet-cc", "label": "Claude Sonnet 4.6", "context_window": 1_000_000,
"model_id": "claude-sonnet-4-6", "router_model_id": "cc/claude-sonnet-4-6", "api": "anthropic", "reasoning": True, "route": "cc"},
{"value": "opus-cc", "label": "Claude Opus 4.6", "context_window": 1_000_000,
"model_id": "claude-opus-4-6", "router_model_id": "cc/claude-opus-4-6", "api": "anthropic", "reasoning": True, "route": "cc"},
{"value": "haiku-cc", "label": "Claude Haiku 4.5", "context_window": 200_000,
"model_id": "claude-haiku-4-5", "router_model_id": "cc/claude-haiku-4-5-20251001", "api": "anthropic", "reasoning": True, "route": "cc"},
{"value": "sonnet-api", "label": "Claude Sonnet 4.6 (API key)", "context_window": 1_000_000,
"model_id": "claude-sonnet-4-6", "router_model_id": "claude-sonnet-4-6", "api": "anthropic", "reasoning": True, "route": "api"},
{"value": "opus-api", "label": "Claude Opus 4.6 (API key)", "context_window": 1_000_000,
"model_id": "claude-opus-4-6", "router_model_id": "claude-opus-4-6", "api": "anthropic", "reasoning": True, "route": "api"},
{"value": "haiku-api", "label": "Claude Haiku 4.5 (API key)", "context_window": 200_000,
"model_id": "claude-haiku-4-5", "router_model_id": "claude-haiku-4-5", "api": "anthropic", "reasoning": True, "route": "api"},
],
"OpenAI": [
# GPT-5.5 — newest ChatGPT flagship (May 2026). Available via
# the Codex subscription path on 9Router 0.4.x catalogs; on
# 0.3.60 (our pin) the cx/ catalog stops at gpt-5.4, so the
# subscription-routed entry will 404 until we bump. The
# API-key entry below works today against api.openai.com.
{"value": "gpt-5.5", "label": "GPT-5.5",
"context_window": 1_000_000, "router_model_id": "cx/gpt-5.5",
"api": "codex", "subscription_only": True, "reasoning": True},
{"value": "gpt-5.4", "label": "GPT-5.4",
"context_window": 1_000_000, "router_model_id": "cx/gpt-5.4",
"api": "codex", "subscription_only": True, "reasoning": True},
{"value": "gpt-5.4-mini", "label": "GPT-5.4 Mini",
"context_window": 400_000, "router_model_id": "cx/gpt-5.4-mini",
"api": "codex", "subscription_only": True, "reasoning": True},
# GPT-5.3 Codex variants. The bare `gpt-5.3-codex` adapts reasoning
# effort from session.thinking_level. The -high / -xhigh suffixes
# are distinct codex tunes from OpenAI optimized for longer-horizon
# coding (xhigh = max-quality, slowest). Both are surfaced for users
# who want to pin effort independently of the global thinking knob.
{"value": "gpt-5.3-codex", "label": "GPT-5.3 Codex",
"context_window": 400_000, "router_model_id": "cx/gpt-5.3-codex",
"api": "codex", "subscription_only": True, "reasoning": True},
{"value": "gpt-5.3-codex-high", "label": "GPT-5.3 Codex High",
"context_window": 400_000, "router_model_id": "cx/gpt-5.3-codex-high",
"api": "codex", "subscription_only": True, "reasoning": True},
{"value": "gpt-5.3-codex-xhigh", "label": "GPT-5.3 Codex Extra High",
"context_window": 400_000, "router_model_id": "cx/gpt-5.3-codex-xhigh",
"api": "codex", "subscription_only": True, "reasoning": True},
# Pinned-API-key entries: bypass 9Router and call api.openai.com
# directly with openai_api_key. Model ids match what OpenAI's API
# accepts (no cx/ prefix). Surfaced when openai_api_key is set —
# gives a metered alternative to the ChatGPT-Plus subscription
# route. Same -api suffix convention as the Anthropic mirrors.
{"value": "gpt-5.5-api", "label": "GPT-5.5 (API key)",
"context_window": 1_000_000, "router_model_id": "gpt-5.5", "model_id": "gpt-5.5",
"api": "openai", "reasoning": True, "route": "api"},
{"value": "gpt-5.4-api", "label": "GPT-5.4 (API key)",
"context_window": 1_000_000, "router_model_id": "gpt-5.4", "model_id": "gpt-5.4",
"api": "openai", "reasoning": True, "route": "api"},
{"value": "gpt-5.4-mini-api", "label": "GPT-5.4 Mini (API key)",
"context_window": 400_000, "router_model_id": "gpt-5.4-mini", "model_id": "gpt-5.4-mini",
"api": "openai", "reasoning": True, "route": "api"},
{"value": "gpt-5.3-codex-api", "label": "GPT-5.3 Codex (API key)",
"context_window": 400_000, "router_model_id": "gpt-5.3-codex", "model_id": "gpt-5.3-codex",
"api": "openai", "reasoning": True, "route": "api"},
{"value": "gpt-5.3-codex-high-api", "label": "GPT-5.3 Codex High (API key)",
"context_window": 400_000, "router_model_id": "gpt-5.3-codex-high", "model_id": "gpt-5.3-codex-high",
"api": "openai", "reasoning": True, "route": "api"},
{"value": "gpt-5.3-codex-xhigh-api", "label": "GPT-5.3 Codex Extra High (API key)",
"context_window": 400_000, "router_model_id": "gpt-5.3-codex-xhigh", "model_id": "gpt-5.3-codex-xhigh",
"api": "openai", "reasoning": True, "route": "api"},
],
# Google: Gemini via Gemini CLI subscription. Both 3.x (thinking-
# capable) and 2.5 (stable) are offered. Gemini 3 models have
# always-on thinking with per-session thought signatures that are
# lost during the format translation round-trip. We use Google's
# official workaround: `skip_thought_signature_validator` on all
# historical function call and thinking parts (see 9router
# openai-to-gemini.js). This bypasses signature validation at the
# cost of the model not being able to build on prior reasoning
# across turns — but all tools work and thinking is visible.
"Google": [
# Gemini 3.1 Pro — newest flagship (Apr 2026), routes to
# `gc/gemini-3.1-pro-preview` for the subscription path. Same
# thoughtSignature caveat applies; resolve_model_id_for_sdk's
# Antigravity map handles the multi-step routing.
{"value": "gemini-3.1-pro", "label": "Gemini 3.1 Pro",
"context_window": 1_000_000, "router_model_id": "gc/gemini-3.1-pro-preview",
"api": "gemini-cli", "subscription_only": True, "reasoning": True},
{"value": "gemini-3.1-flash-lite", "label": "Gemini 3.1 Flash Lite",
"context_window": 1_000_000, "router_model_id": "gc/gemini-3.1-flash-lite-preview",
"api": "gemini-cli", "subscription_only": True, "reasoning": True},
{"value": "gemini-3-pro", "label": "Gemini 3 Pro",
"context_window": 1_000_000, "router_model_id": "gc/gemini-3-pro-preview",
"api": "gemini-cli", "subscription_only": True, "reasoning": True},
{"value": "gemini-3-flash", "label": "Gemini 3 Flash",
"context_window": 1_000_000, "router_model_id": "gc/gemini-3-flash-preview",
"api": "gemini-cli", "subscription_only": True, "reasoning": True},
# Pinned-API-key entries for Google AI Studio (api="gemini"). Bypass
# both 9Router (which routes via Gemini CLI/Antigravity OAuth) and
# any subscription path; call generativelanguage.googleapis.com
# directly with google_api_key. Free-tier quota is generous (~1K
# requests/day) and lives separately from the OAuth lanes.
{"value": "gemini-3.1-pro-api", "label": "Gemini 3.1 Pro (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-3.1-pro-preview", "model_id": "gemini-3.1-pro-preview",
"api": "gemini", "reasoning": True, "route": "api"},
{"value": "gemini-3.1-flash-lite-api", "label": "Gemini 3.1 Flash Lite (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-3.1-flash-lite-preview", "model_id": "gemini-3.1-flash-lite-preview",
"api": "gemini", "reasoning": True, "route": "api"},
{"value": "gemini-3-pro-api", "label": "Gemini 3 Pro (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-3-pro-preview", "model_id": "gemini-3-pro-preview",
"api": "gemini", "reasoning": True, "route": "api"},
{"value": "gemini-3-flash-api", "label": "Gemini 3 Flash (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-3-flash-preview", "model_id": "gemini-3-flash-preview",
"api": "gemini", "reasoning": True, "route": "api"},
],
}
# ---------------------------------------------------------------------------
# Model resolution (used by the live claude_agent_sdk path)
# ---------------------------------------------------------------------------
def _find_builtin_model(short_name: str) -> dict | None:
"""Look up a model entry by its short `value`."""
for models in BUILTIN_MODELS.values():
for m in models:
if m.get("value") == short_name:
return m
return None
def get_api_type(short_name: str) -> str:
"""Return the api type for a short model name.
Returns one of: "anthropic", "codex", "gemini-cli".
Defaults to "anthropic" for unknown names so existing behavior is preserved.
"""
entry = _find_builtin_model(short_name)
return (entry or {}).get("api", "anthropic")
def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str:
"""Resolve a short model name into the id string passed to ClaudeAgentOptions.
Priority:
- Anthropic model + openswarm-pro mode → bare `model_id` (our cloud proxy)
- Anthropic model with an API key set → bare `model_id` (real Anthropic API)
- Everything else → `router_model_id` (9Router with cc/ cx/ gc/ prefix)
- Unknown names pass through unchanged
"""
entry = _find_builtin_model(short_name)
if entry is None:
return short_name
# Pinned-route entries (e.g. "sonnet-cc") always use their router_model_id,
# bypassing connection_mode. This is what lets the picker offer a
# distinct "Anthropic" group pointing at the user's 9Router Claude
# subscription even while openswarm-pro is the default Claude route.
if entry.get("route") == "cc":
return entry.get("router_model_id", entry.get("model_id", short_name))
# route="api" is the analogue for the user's direct Anthropic API key:
# bare model_id, and agent_manager will force the spawn env to point at
# api.anthropic.com with the api_key (skipping both the Pro proxy AND
# 9Router). This is what makes "use my API key" reachable even when
# connection_mode is openswarm-pro.
if entry.get("route") == "api":
return entry.get("model_id", short_name)
if entry.get("api") == "anthropic":
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
return entry.get("model_id", short_name)
if getattr(settings, "anthropic_api_key", None):
return entry.get("model_id", short_name)
# Gemini: prefer lanes with higher quota in order —
# 1. AI Studio apikey (free 1K/day, separate from any OAuth limit)
# 2. Antigravity OAuth (preview, 5-10× the Gemini CLI free tier).
# CRITICAL: Antigravity's wrapper around Google's API doesn't
# enforce the strict thoughtSignature continuity check that
# breaks multi-step tool turns through Gemini CLI. Without
# this lane, agent turns that combine thinking + tool use get
# "Thought signature is not valid" 400s on every follow-up
# request because the claude_agent_sdk has no hook to round-
# trip Gemini-specific signatures.
# 3. Gemini CLI OAuth (free tier, ~5 RPM — last resort, breaks
# on multi-step agent turns).
#
# Antigravity exposes differently-named Gemini models than Gemini CLI:
# gc/gemini-3-pro-preview → ag/gemini-3.1-pro-high (DISABLED —
# Google returns 404 not_found_error on this even with an
# active Antigravity connection; tier-side access gate.)
# gc/gemini-3-flash-preview → ag/gemini-3-flash (works)
# Models Antigravity doesn't have (or that 404) fall through to gc/.
_ANTIGRAVITY_MAP = {
# Disabled until 9Router exposes per-model availability so we
# can verify pro-high is actually serviceable before routing.
# "gemini-3-pro-preview": "gemini-3.1-pro-high",
"gemini-3-flash-preview": "gemini-3-flash",
# Gemini 3.1 family — same thoughtSignature problem as 3.0:
# gc/ enforces continuity, the Anthropic SDK has no hook to
# round-trip the signature, every multi-step tool turn 400s
# with "Thought signature is not valid". Routing through
# ag/ (Antigravity wrapper around Google's API) sidesteps
# the validator. AG is flagged deprecated upstream — we keep
# using it on 9router 0.3.60 (our pin) as the only working
# multi-step Gemini path; will revisit once the SDK gets
# signature passthrough or 9router lands a Gemini-CLI fix.
"gemini-3.1-pro-preview": "gemini-3.1-pro-high",
"gemini-3.1-flash-lite-preview": "gemini-3-flash",
}
if entry.get("api") == "gemini-cli":
rid = entry.get("router_model_id", "")
if isinstance(rid, str) and rid.startswith("gc/"):
suffix = rid[len("gc/"):]
if getattr(settings, "google_api_key", None):
return "gemini/" + suffix
ag_suffix = _ANTIGRAVITY_MAP.get(suffix)
if ag_suffix:
# Check 9Router for an Antigravity connection.
try:
import httpx as _httpx
r = _httpx.get("http://localhost:20128/api/providers", timeout=2.0)
if r.status_code == 200:
data = r.json()
conns = data.get("connections", []) if isinstance(data, dict) else (data if isinstance(data, list) else [])
has_ag = any(
isinstance(c, dict)
and c.get("provider") == "antigravity"
and c.get("isActive")
for c in conns
)
if has_ag:
return "ag/" + ag_suffix
except Exception:
pass
return entry.get("router_model_id", entry.get("model_id", short_name))
async def resolve_aux_model(
settings: AppSettings,
preferred_tier: str = "haiku",
primary_api: str | None = None,
) -> tuple[str, str | None]:
"""Pick the cheapest/most-available model for auxiliary LLM calls.
Used by title generation, group meta, dashboard naming, outputs/view
builder, and browser_agent — wherever we need a quick one-shot LLM call
that is NOT the user's selected chat model.
Args:
primary_api: when set, prefer this provider family ("anthropic" |
"codex" | "gemini-cli") over the default Anthropic-first cascade.
Lets a Codex-only or Gemini-only session keep aux work on the
same family it's already paying for, instead of leaking to
Anthropic Haiku just because the user *also* has Anthropic
connected. Caller passes `get_api_type(session.model)`.
Returns (model_id, base_url).
- If base_url is None, caller should use the default Anthropic client.
- If base_url is set, caller should route through that endpoint.
Priority (when primary_api is None, classic cascade):
1. OpenSwarm Pro mode → bare haiku/sonnet via proxy
2. Anthropic API key set → bare haiku/sonnet on real Anthropic API
3. 9Router + Claude subscription connected → cc/<model>
4. 9Router + Codex connected → cx/gpt-5.4-mini
5. 9Router + Gemini connected → gc/gemini-3.1-flash-lite-preview
6. Nothing available → raise ValueError
When primary_api is provided, the resolver tries that family first
(subscription path then API key) and only falls through to other
providers if the primary family isn't reachable.
"""
haiku_bare = "claude-haiku-4-5-20251001"
sonnet_bare = "claude-sonnet-4-20250514"
bare = haiku_bare if preferred_tier == "haiku" else sonnet_bare
# Probe 9Router once up front so the primary_api branch and the
# default cascade share the same connection set.
from backend.apps.nine_router import is_running as _9r_running, get_providers as _9r_providers
base_url = "http://localhost:20128"
connected: set[str] = set()
if _9r_running():
try:
connections = await _9r_providers()
connected = {c.get("provider") for c in connections if c.get("isActive")}
except Exception:
connected = set()
# Match primary_api first when supplied. Each branch checks both the
# subscription path (preferred — usually free) and the direct-API path
# before giving up on this family.
if primary_api == "codex":
if "codex" in connected:
return ("cx/gpt-5.4-mini", base_url)
if getattr(settings, "openai_api_key", None):
return ("gpt-5.4-mini", "https://api.openai.com/v1")
# primary is Codex but it's not reachable — fall through to default
elif primary_api == "gemini-cli" or primary_api == "gemini":
if "gemini-cli" in connected:
return ("gc/gemini-3.1-flash-lite-preview", base_url)
if getattr(settings, "google_api_key", None):
return ("gemini-3.1-flash-lite-preview", "https://generativelanguage.googleapis.com/v1beta")
# fall through to default
# primary_api == "anthropic" naturally falls into the Anthropic-first
# cascade below — no special branch needed.
# OpenSwarm Pro — route through our cloud proxy
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
proxy_url = getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.com"
return (bare, proxy_url)
# Direct API key wins
if getattr(settings, "anthropic_api_key", None):
return (bare, None)
if not _9r_running():
raise ValueError(
"No AI provider configured for auxiliary LLM call. "
"Set an Anthropic API key or connect a subscription."
)
if "claude" in connected:
return (f"cc/{haiku_bare}" if preferred_tier == "haiku" else f"cc/{sonnet_bare}", base_url)
if "codex" in connected:
return ("cx/gpt-5.4-mini", base_url)
if "gemini-cli" in connected:
return ("gc/gemini-3.1-flash-lite-preview", base_url)
raise ValueError(
"No AI provider connected for auxiliary LLM call. "
"Connect at least one subscription in Settings."
)
# ---------------------------------------------------------------------------
# Cost tracking
# ---------------------------------------------------------------------------
COST_PER_1M_TOKENS: dict[tuple[str, str], tuple[float, float]] = {
# (provider, model): (input_cost_per_1M, output_cost_per_1M)
# NOTE: `calculate_cost` is currently unused in the live path — real
# cost numbers come from 9Router's usage stats. These entries are kept
# so the table matches BUILTIN_MODELS and can
# be used by any future native-loop path. Subscription-routed models
# are zero-cost to the user, but API rates are recorded here for
# reference where they exist.
# Anthropic (direct API rates).
("Anthropic", "sonnet"): (3.0, 15.0),
("Anthropic", "opus"): (5.0, 25.0),
("Anthropic", "haiku"): (1.0, 5.0),
# OpenAI — Codex subscription path, user pays nothing per token
("OpenAI", "gpt-5.5"): (0.0, 0.0),
("OpenAI", "gpt-5.4"): (0.0, 0.0),
("OpenAI", "gpt-5.4-mini"): (0.0, 0.0),
("OpenAI", "gpt-5.3-codex"): (0.0, 0.0),
("OpenAI", "gpt-5.3-codex-high"): (0.0, 0.0),
("OpenAI", "gpt-5.3-codex-xhigh"): (0.0, 0.0),
# Google — Gemini CLI subscription path, user pays nothing per token
("Google", "gemini-3.1-pro"): (0.0, 0.0),
("Google", "gemini-3.1-flash-lite"): (0.0, 0.0),
("Google", "gemini-3-pro"): (0.0, 0.0),
("Google", "gemini-3-flash"): (0.0, 0.0),
("Google", "gemini-2.5-pro"): (0.0, 0.0),
("Google", "gemini-2.5-flash"): (0.0, 0.0),
# OpenRouter-backed (approximate)
("xAI", "x-ai/grok-4-0214"): (3.0, 15.0),
("Meta", "meta-llama/llama-4-maverick"): (0.50, 0.70),
("Meta", "meta-llama/llama-4-scout"): (0.15, 0.40),
("DeepSeek", "deepseek/deepseek-chat-v3-0324"): (0.30, 0.90),
("DeepSeek", "deepseek/deepseek-r1"): (0.80, 2.40),
("Mistral", "mistralai/mistral-large-2501"): (2.0, 6.0),
("Mistral", "mistralai/mistral-small-3.1-24b-instruct"): (0.10, 0.30),
("Qwen", "qwen/qwen3-coder"): (0.0, 0.0),
("Qwen", "qwen/qwen3-235b-a22b"): (0.20, 0.70),
("Cohere", "cohere/command-a-03-2025"): (2.50, 10.0),
}
def calculate_cost(
provider: str, model: str,
input_tokens: int, output_tokens: int,
) -> float:
"""Calculate cost in USD from token counts."""
# Direct lookup first
rates = COST_PER_1M_TOKENS.get((provider, model))
if not rates:
# Case-insensitive provider lookup
lower = provider.lower()
for (p, m), r in COST_PER_1M_TOKENS.items():
if p.lower() == lower and m == model:
rates = r
break
if not rates:
return 0.0
input_rate, output_rate = rates
return (input_tokens * input_rate + output_tokens * output_rate) / 1_000_000