"""Configure the SDK environment for the run's provider route: set ANTHROPIC/OPENAI/GOOGLE auth env vars (direct key, OpenSwarm Pro proxy, OpenRouter, or 9Router) and pin subagent models, ensuring 9Router is up where the route needs it.""" import os from typing import Dict, Optional from typeguard import typechecked from backend.apps.agents.core.models import AgentSession from backend.apps.settings.models import AppSettings from backend.auth import get_auth_token logger = __import__("logging").getLogger(__name__) @typechecked async def router_available(global_settings: AppSettings) -> bool: """True when 9Router is up, reviving it first if it died. A dead router must never masquerade as "no provider configured": detection now shares the dispatch path's lazy-start, so a crashed or orphaned router self-heals on the very next send instead of erroring the turn. Revival is gated on EVIDENCE of a provider (a settings key, proxy mode, or an active connection in the router's on-disk db) so a zero-config user keeps the clean no-provider message instead of us booting a router with nothing to route.""" from backend.apps.nine_router import ensure_running as p_ensure, is_running as p_running from backend.apps.nine_router.process import has_persisted_connections if p_running(): return True p_evidence = any([ getattr(global_settings, "anthropic_api_key", None), getattr(global_settings, "openai_api_key", None), getattr(global_settings, "google_api_key", None), getattr(global_settings, "openrouter_api_key", None), getattr(global_settings, "connection_mode", "own_key") in ("openswarm-pro", "free-trial"), bool(getattr(global_settings, "custom_providers", None) or []), has_persisted_connections(), ]) if not p_evidence: return False logger.info("[MCP-DEBUG] 9Router down at provider detection; reviving before concluding") await p_ensure() return p_running() @typechecked async def configure_provider_env( options_kwargs: Dict, session: AgentSession, resolved_model: object, api_type: Optional[str], global_settings: AppSettings, ) -> None: from backend.apps.nine_router import is_running as nine_router_running from backend.apps.agents.providers.registry import NINEROUTER_MODEL_PREFIXES as NINEROUTER_MODEL_PREFIXES resolved_is_9router = isinstance(resolved_model, str) and resolved_model.startswith(NINEROUTER_MODEL_PREFIXES) from backend.apps.agents.providers.registry import find_builtin_model as find_builtin_model model_entry = find_builtin_model(session.model) is_pinned_api_route = ( model_entry is not None and model_entry.get("route") == "api" ) api_route_provider = (model_entry or {}).get("api") if is_pinned_api_route else None if is_pinned_api_route and api_route_provider == "anthropic" and getattr(global_settings, "anthropic_api_key", None): from backend.apps.settings.credentials import own_key_cli_env as p_own_key_cli_env options_kwargs["env"] = { **p_own_key_cli_env(global_settings), "ANTHROPIC_BASE_URL": "https://api.anthropic.com", # Pin subagents so they don't drift back to the proxy. "CLAUDE_CODE_SUBAGENT_MODEL": "claude-sonnet-4-6", "ANTHROPIC_SMALL_FAST_MODEL": "claude-haiku-4-5", "ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4-5", } logger.info(f"[MCP-DEBUG] Using direct Anthropic API key (route=api) for {session.model}") elif is_pinned_api_route and api_route_provider == "openai" and getattr(global_settings, "openai_api_key", None): # openai-passthrough renames max_tokens->max_completion_tokens before relaying (GPT-5 400s on max_tokens; 9Router 0.3.60, pinned for WebSearch, emits the legacy field). passthrough_url = f"http://127.0.0.1:{os.environ.get('OPENSWARM_PORT', '8324')}/api/openai-passthrough/v1" options_kwargs["env"] = { "OPENAI_API_KEY": global_settings.openai_api_key, "OPENAI_BASE_URL": passthrough_url, "ANTHROPIC_API_KEY": get_auth_token() or "9router", "ANTHROPIC_BASE_URL": "http://localhost:20128", } logger.info(f"[MCP-DEBUG] Using direct OpenAI API key (route=api) for {session.model} via openai-passthrough") elif is_pinned_api_route and api_route_provider == "custom": # User OpenAI-compatible endpoint (Ollama/Together/LM Studio) via 9Router's synced provider node. from backend.apps.nine_router import ensure_running as p_9r_ensure_c if not nine_router_running(): logger.info(f"[MCP-DEBUG] custom provider selected but 9Router not running; waiting for startup") await p_9r_ensure_c() if not nine_router_running(): raise ValueError( "9Router could not start. Custom OpenAI-compatible " "providers need 9Router to translate the Anthropic " "protocol, install Node.js and restart the app." ) from backend.apps.agents.providers.registry import find_custom_provider_for_value as find_custom_provider_for_value cp = find_custom_provider_for_value(global_settings, session.model) env = { "ANTHROPIC_API_KEY": "9router", "ANTHROPIC_BASE_URL": "http://localhost:20128", "ENABLE_TOOL_SEARCH": "auto", } if cp: # Local servers often run auth-disabled; placeholder key since the OpenAI SDK requires non-empty. env["OPENAI_API_KEY"] = (cp.api_key or "").strip() or "no-auth-required" from backend.apps.nine_router import normalize_openai_compat_base_url as norm_cp_url env["OPENAI_BASE_URL"] = norm_cp_url(cp.base_url or "") # Pin subagents or CLI's default Haiku 4.5 404s on the custom provider. if global_settings.anthropic_api_key: env["CLAUDE_CODE_SUBAGENT_MODEL"] = "claude-sonnet-4-6" env["ANTHROPIC_SMALL_FAST_MODEL"] = "claude-haiku-4-5-20251001" env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = "claude-haiku-4-5-20251001" else: # No Anthropic key: pin subagents to the custom model so they stay on the user's endpoint. env["CLAUDE_CODE_SUBAGENT_MODEL"] = resolved_model env["ANTHROPIC_SMALL_FAST_MODEL"] = resolved_model env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = resolved_model options_kwargs["env"] = env logger.info(f"[MCP-DEBUG] Using custom provider for {session.model} → {resolved_model}") elif is_pinned_api_route and api_route_provider == "gemini" and getattr(global_settings, "google_api_key", None): # Local anthropic-proxy scrubs JSON-Schema fields Gemini rejects ($schema, additionalProperties, propertyNames, exclusiveMinimum, nested const) that 9Router 0.3.60 misses. proxy_url = f"http://127.0.0.1:{os.environ.get('OPENSWARM_PORT', '8324')}/api/anthropic-proxy" options_kwargs["env"] = { "GEMINI_API_KEY": global_settings.google_api_key, "GOOGLE_API_KEY": global_settings.google_api_key, "ANTHROPIC_API_KEY": get_auth_token() or "9router", "ANTHROPIC_BASE_URL": proxy_url, } logger.info(f"[MCP-DEBUG] Using direct Google API key (route=api) for {session.model} via local proxy") elif api_type == "openrouter" and getattr(global_settings, "openrouter_api_key", None): # OpenRouter via 9Router; with no Anthropic key/sub, fall back to OR's resold Claude for subagents (incl. WebSearch delegation) so they stay on the same OR billing. if not nine_router_running(): from backend.apps.nine_router import ensure_running as nine_router_ensure logger.info(f"[MCP-DEBUG] OpenRouter selected but 9Router not running; waiting for startup") await nine_router_ensure() if not nine_router_running(): raise ValueError( "9Router could not start. OpenRouter routing requires " "Node.js, install it and restart the app, or pick a " "model that uses a direct API key (Anthropic, OpenAI, " "or Google AI Studio)." ) env = { "ANTHROPIC_API_KEY": "9router", "ANTHROPIC_BASE_URL": "http://localhost:20128", } if global_settings.anthropic_api_key: env["CLAUDE_CODE_SUBAGENT_MODEL"] = "claude-sonnet-4-6" env["ANTHROPIC_SMALL_FAST_MODEL"] = "claude-haiku-4-5-20251001" env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = "claude-haiku-4-5-20251001" else: env["CLAUDE_CODE_SUBAGENT_MODEL"] = "openrouter/anthropic/claude-sonnet-4.5" env["ANTHROPIC_SMALL_FAST_MODEL"] = "openrouter/anthropic/claude-haiku-4.5" env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = "openrouter/anthropic/claude-haiku-4.5" env["ENABLE_TOOL_SEARCH"] = "auto" options_kwargs["env"] = env logger.info(f"[MCP-DEBUG] Using OpenRouter for {session.model}") elif api_type == "anthropic" and not resolved_is_9router and getattr(global_settings, "connection_mode", "own_key") in ("openswarm-pro", "free-trial"): from backend.apps.settings.credentials import proxy_auth bearer, proxy_url = proxy_auth(global_settings) bearer = bearer or "" options_kwargs["env"] = { "ANTHROPIC_AUTH_TOKEN": bearer, "ANTHROPIC_BASE_URL": proxy_url, # Pin subagents; Pro rejects CLI's default haiku as "No credentials for provider: anthropic". "CLAUDE_CODE_SUBAGENT_MODEL": "claude-sonnet-4-6", "ANTHROPIC_SMALL_FAST_MODEL": "claude-haiku-4-5-20251001", "ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4-5-20251001", # auto: tengu_defer_all_bn4 defers every tool, which collides with cache_control and 400s the first tool-laden request. "ENABLE_TOOL_SEARCH": "auto", } # Tag every call (subagents inherit env) + aux calls with the task id so a query plus its title-gen meter as ONE free run; base goes straight to cloud (no 9Router) so the header rides. if getattr(global_settings, "connection_mode", "own_key") == "free-trial": options_kwargs["env"]["ANTHROPIC_CUSTOM_HEADERS"] = f"X-Openswarm-Task-Id: {session.id}" # Cloud serves every free run as Haiku; keep the subagent on Haiku too (sonnet adds `effort`, Haiku 400s). options_kwargs["env"]["CLAUDE_CODE_SUBAGENT_MODEL"] = "claude-haiku-4-5-20251001" logger.info(f"[MCP-DEBUG] Using OpenSwarm cloud proxy at {proxy_url}") elif api_type == "anthropic" and not resolved_is_9router and global_settings.anthropic_api_key: from backend.apps.settings.credentials import own_key_cli_env as p_own_key_cli_env_fallback options_kwargs["env"] = p_own_key_cli_env_fallback(global_settings) logger.info("[MCP-DEBUG] Using direct Anthropic API key") elif await router_available(global_settings): # Gemini-bound ids go through the local proxy for schema scrubbing; everything else hits 9Router directly. is_gemini_bound = ( isinstance(resolved_model, str) and resolved_model.startswith(("gemini/", "gc/", "ag/")) ) if is_gemini_bound: base_url = f"http://127.0.0.1:{os.environ.get('OPENSWARM_PORT', '8324')}/api/anthropic-proxy" env = { "ANTHROPIC_API_KEY": get_auth_token() or "9router", "ANTHROPIC_BASE_URL": base_url, } else: env = { "ANTHROPIC_API_KEY": "9router", "ANTHROPIC_BASE_URL": "http://localhost:20128", } # Pin subagents to whichever lane the user has, else the CLI's default Haiku 4.5 hits 9Router with no Claude route and every sub-agent 401s while the parent turn works. Fetched live here (fail-open []) so no caller can starve the pin with a stale list again, the run/ split did exactly that and silently killed sub-agents on router routes. from backend.apps.nine_router import get_providers as p_get_providers sub_conns = await p_get_providers() active = {c.get("provider") for c in sub_conns if isinstance(c, dict) and c.get("isActive")} sub_model = None small_model = None if global_settings.anthropic_api_key: sub_model = "claude-sonnet-4-6" small_model = "claude-haiku-4-5-20251001" elif "claude" in active or "anthropic" in active: sub_model = "cc/claude-sonnet-4-6" small_model = "cc/claude-haiku-4-5-20251001" elif "antigravity" in active: sub_model = "ag/gemini-3-flash" small_model = "ag/gemini-3-flash" elif "gemini-cli" in active: sub_model = "gc/gemini-2.5-flash" small_model = "gc/gemini-2.5-flash" elif "codex" in active: sub_model = "cx/gpt-5.4-mini" small_model = "cx/gpt-5.4-mini" if sub_model: env["CLAUDE_CODE_SUBAGENT_MODEL"] = sub_model if small_model: env["ANTHROPIC_SMALL_FAST_MODEL"] = small_model env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = small_model logger.info( f"[MCP-DEBUG] 9Router direct, subagent_model={sub_model}, small_fast={small_model}" ) # auto eagerly loads tools when the schema budget fits; without it tengu_defer_all_bn4 defers 16 tools unloadable off Anthropic networks. Don't use --bare (strips system prompt). env["ENABLE_TOOL_SEARCH"] = "auto" options_kwargs["env"] = env logger.info(f"[MCP-DEBUG] Using 9Router (api_type={api_type})") else: # router_available() above already attempted a revival; reaching here means it truly can't start. from backend.apps.agents.core import flight_recorder flight_recorder.crumb(session.id, "router-unavailable", model=session.model, api=api_type) if api_type != "anthropic" or resolved_is_9router: raise ValueError( f"9Router is not running; cannot use {session.model}. " "Install Node.js and restart the app, or switch to a model " "with a direct API key." ) raise ValueError("No AI provider configured. Set an API key or connect a subscription.") # ENABLE_TOOL_SEARCH belongs to EVERY lane, so it is set after the branch chain rather than # inside it. The CLI's tengu_defer_all_bn4 defers every tool, which collides with cache_control # and 400s the next tool-laden request ("cannot have both defer_loading=true and cache_control # set", ENG-394). That collision is CLI-side and has nothing to do with which credential pays, # yet the flag was set in 4 of 8 branches; the direct-Anthropic-key lanes were among the misses, # which is exactly where it was hit on opus-5. A default applied inside a branch protects only # that branch, and the next branch added forgets it again. p_env = options_kwargs.get("env") if isinstance(p_env, dict): p_env.setdefault("ENABLE_TOOL_SEARCH", "auto") # The CLI is Node and ignores the OS trust store, so on a machine whose endpoint tool or # corporate proxy installed a root, sign-in succeeds (ENG-407 armed truststore for THIS # process) and the first model call still dies on TLS. Additive and fail-open: empty dict # when nothing can be exported, which is every build before this (ENG-408). try: from backend.config.node_trust import node_ca_env from backend.config.paths import DATA_ROOT for k, v in node_ca_env(os.path.join(DATA_ROOT, "node-ca-roots.pem")).items(): p_env.setdefault(k, v) except Exception as e: logger.debug("node trust: skipped for the CLI (%s)", e) # Drill only: shrink the CLI's autocompact window so the thrash class is reproducible in # seconds instead of needing a real 180K-token session. The SAME number scales our own # compaction trigger (context_budget.effective_window), so the drill preserves the # production race between the two rather than inventing one (ENG-418). from backend.apps.agents.core.fault_injection import armed as p_fault_armed from backend.apps.agents.core.fault_injection import squeezed_context_window if p_fault_armed("cli_context_squeeze"): p_squeeze = squeezed_context_window() p_env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = str(p_squeeze) logger.warning("[cli-context] DRILL: CLI autocompact window forced to %d", p_squeeze) # Fault-injection seam: lets a QA harness front the provider with a local proxy (mid-run 401 drills, ENG-302 family). Absent in prod, so every branch's real base URL stands. p_base_override = os.environ.get("OPENSWARM_ANTHROPIC_BASE_OVERRIDE") if p_base_override and isinstance(options_kwargs.get("env"), dict): # Set, not just replace: the direct-api-key branch ships only ANTHROPIC_API_KEY and leans on # the CLI's built-in default host, so a replace-only seam silently measured nothing. options_kwargs["env"]["ANTHROPIC_BASE_URL"] = p_base_override logger.info(f"[QA] ANTHROPIC_BASE_URL overridden to {p_base_override}")