Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/captainphantasy_casper.json
T

1 line
37 KiB
JSON

{"repo": "captainphantasy/casper", "files": {"core/services/llm.py": {"09b988bb32a3db679b5118527209e3ee04eb79b1": {"t": 1785596348.5175917, "n": 26796, "h": "b40a8727c3b7f4b7", "c": "\"\"\"\nLLM Service wrapper for CASPER Prime.\nUses the configured provider, defaulting to MiniMax M2.7 Highspeed.\nFalls back gracefully when no key is configured.\nIncludes comprehensive error recovery and retry logic.\n\"\"\"\n\nimport os\nimport re\nimport asyncio\nimport logging\nimport time\nfrom typing import Optional, Union, Dict, Any, List\nfrom dataclasses import dataclass\nfrom enum import Enum\n\nfrom core.services.user_config import user_config\nimport httpx\n\ntry:\n from anthropic import AsyncAnthropic\n from anthropic import APIConnectionError, RateLimitError, APIStatusError\nexcept Exception: # pragma: no cover - optional import\n AsyncAnthropic = None # type: ignore\n APIConnectionError = Exception # type: ignore\n RateLimitError = Exception # type: ignore\n APIStatusError = Exception # type: ignore\n\ntry:\n import openai\n from openai import OpenAI, AsyncOpenAI\nexcept Exception: # pragma: no cover - optional import\n openai = None # type: ignore\n OpenAI = None # type: ignore\n AsyncOpenAI = None # type: ignore\n\nlogger = logging.getLogger(__name__)\n\nDEFAULT_SYSTEM_PROMPT = (\n \"You are a frontier coding and code-review agent. Report only facts verified \"\n \"by direct evidence. Never claim completion without a passing check. Mark every \"\n \"unverified item INCOMPLETE. For completed work report action, evidence, \"\n \"verification PASS/FAIL, and status.\"\n)\n\n\n# ---------------------------------------------------------------------------\n# Dynamic model resolution\n# ---------------------------------------------------------------------------\n# CASPER no longer hardcodes specific model ids. At runtime we query each\n# provider's /models endpoint, filter out deprecated/legacy families, and rank\n# what's actually available to the account. These constants are last-resort\n# fallbacks used ONLY when live discovery fails — and they are deliberately\n# current-generation, never the deprecated claude-3 / gpt-3 families.\n# Tiers/classes: primary+worker are the legacy two-tier names; frontier/standard/\n# cheap/tiny are the task-aware model classes used by the router (Feature 4).\n_ANTHROPIC_FALLBACK = {\n \"primary\": \"claude-sonnet-4-6\",\n \"worker\": \"claude-haiku-4-5-20251001\",\n \"frontier\": \"claude-opus-4-8\",\n \"standard\": \"claude-sonnet-4-6\",\n \"cheap\": \"claude-haiku-4-5-20251001\",\n \"tiny\": \"claude-haiku-4-5-20251001\",\n}\n_OPENAI_FALLBACK = {\n \"primary\": \"gpt-4o\",\n \"worker\": \"gpt-4o-mini\",\n \"frontier\": \"gpt-4o\",\n \"standard\": \"gpt-4o\",\n \"cheap\": \"gpt-4o-mini\",\n \"tiny\": \"gpt-4o-mini\",\n}\n_MINIMAX_FALLBACK = {\n tier: \"MiniMax-M2.7-highspeed\"\n for tier in (\"primary\", \"worker\", \"frontier\", \"standard\", \"cheap\", \"tiny\")\n}\n_OPENCODE_DEFAULTS = {\n \"opencode-go\": \"minimax-m2.7\",\n \"opencode-zen\": \"minimax-m2.7\",\n}\n\n# Matches deprecated / legacy model ids that should be auto-upgraded if a\n# caller still passes one (e.g. older hardcoded \"claude-3-5-sonnet-...\").\n_LEGACY_MODEL_RE = re.compile(\n r\"claude-(?:instant|1|2|3)[._-]|^claude-instant|^gpt-3|^text-|davinci|babbage\",\n re.IGNORECASE,\n)\n\n\ndef _is_legacy_model(model_id: Optional[str]) -> bool:\n return bool(model_id) and bool(_LEGACY_MODEL_RE.search(model_id))\n\n\ndef _anthropic_rank(model_id: str):\n \"\"\"Sortable key (tier, version_tuple) for an Anthropic model id.\n Higher = more capable / newer. No specific ids are hardcoded — ranking is\n derived from the family name and embedded version numbers.\"\"\"\n mid = model_id.lower()\n if \"mythos\" in mid or \"fable\" in mid:\n tier = 5 # Mythos-class — above Opus\n elif \"opus\" in mid:\n tier = 4\n elif \"sonnet\" in mid:\n tier = 3\n elif \"haiku\" in mid:\n tier = 2\n else:\n tier = 1\n version = tuple(int(n) for n in re.findall(r\"\\d+\", mid))\n return (tier, version)\n\n\ndef _select_anthropic(ids: List[str], tier: str) -> Optional[str]:\n ids = [i for i in ids if not _is_legacy_model(i)]\n if not ids:\n return None\n low = str.lower\n t = (tier or \"primary\").lower()\n if t in (\"worker\", \"cheap\", \"tiny\"):\n pool = [i for i in ids if \"haiku\" in low(i)] or ids\n elif t == \"standard\":\n pool = [i for i in ids if \"sonnet\" in low(i)] or [i for i in ids if \"opus\" in low(i)] or ids\n elif t == \"frontier\":\n pool = [i for i in ids if \"opus\" in low(i)] or [i for i in ids if \"sonnet\" in low(i)] or ids\n else: # primary — most capable; prefer Opus/Sonnet, avoid the fast tier\n pool = [i for i in ids if \"opus\" in low(i) or \"sonnet\" in low(i)] or ids\n return max(pool, key=_anthropic_rank)\n\n\ndef _select_openai(ids: List[str], tier: str) -> Optional[str]:\n ids = [i for i in ids if not _is_legacy_model(i)]\n if not ids:\n return None\n t = (tier or \"primary\").lower()\n if t in (\"worker\", \"cheap\", \"tiny\"):\n prefs = [\"gpt-4o-mini\", \"gpt-4.1-mini\", \"o4-mini\", \"gpt-4o\"]\n else: # primary / standard / frontier\n prefs = [\"gpt-4o\", \"gpt-4.1\", \"o4\", \"gpt-4-turbo\", \"gpt-4\"]\n for p in prefs:\n for i in ids:\n if i == p or i.startswith(p):\n return i\n return ids[0]\n\n\ndef _select_minimax(ids: List[str], tier: str) -> Optional[str]:\n \"\"\"Prefer the verified same-performance highspeed M2.7 endpoint.\"\"\"\n for preferred in (\"MiniMax-M2.7-highspeed\", \"MiniMax-M2.7\"):\n for model_id in ids:\n if model_id.lower() == preferred.lower():\n return model_id\n return ids[0] if ids else None\n\n\nclass LLMProvider(Enum):\n MINIMAX = \"minimax\"\n ANTHROPIC = \"anthropic\"\n OPENAI = \"openai\"\n\n\n@dataclass\nclass RetryConfig:\n max_retries: int = 3\n base_delay: float = 1.0\n max_delay: float = 60.0\n exponential_base: float = 2.0\n jitter: bool = True\n\n\n@dataclass\nclass CircuitBreakerConfig:\n failure_threshold: int = 5\n recovery_timeout: int = 60\n half_open_max_calls: int = 3\n\n\nclass CircuitBreakerState(Enum):\n CLOSED = \"closed\" # Normal operation\n OPEN = \"open\" # Failing, reject calls\n HALF_OPEN = \"half_open\" # Testing recovery\n\n\nclass CircuitBreaker:\n \"\"\"Circuit breaker pattern implementation for LLM providers.\"\"\"\n\n def __init__(self, config: CircuitBreakerConfig):\n self.config = config\n self.state = CircuitBreakerState.CLOSED\n self.failure_count = 0\n self.last_failure_time = 0\n self.half_open_calls = 0\n\n def can_call(self) -> bool:\n \"\"\"Check if calls are allowed based on circuit breaker state.\"\"\"\n now = time.time()\n\n if self.state == CircuitBreakerState.CLOSED:\n return True\n elif self.state == CircuitBreakerState.OPEN:\n if now - self.last_failure_time > self.config.recovery_timeout:\n self.state = CircuitBreakerState.HALF_OPEN\n self.half_open_calls = 0\n return True\n return False\n elif self.state == CircuitBreakerState.HALF_OPEN:\n return self.half_open_calls < self.config.half_open_max_calls\n\n return False\n\n def record_success(self):\n \"\"\"Record successful call.\"\"\"\n if self.state == CircuitBreakerState.HALF_OPEN:\n self.state = CircuitBreakerState.CLOSED\n self.failure_count = 0\n self.half_open_calls = 0\n\n def record_failure(self):\n \"\"\"Record failed call.\"\"\"\n self.failure_count += 1\n self.last_failure_time = time.time()\n\n if self.state == CircuitBreakerState.HALF_OPEN:\n self.state = CircuitBreakerState.OPEN\n elif self.failure_count >= self.config.failure_threshold:\n self.state = CircuitBreakerState.OPEN\n\n if self.state == CircuitBreakerState.HALF_OPEN:\n self.half_open_calls += 1\n\n\nclass LLMService:\n def __init__(self):\n # Get API keys. Environment variables take precedence over stored keys so\n # a freshly-rotated key in the environment/.env always overrides a stale\n # value in encrypted local storage.\n self.anthropic_key = os.environ.get(\"ANTHROPIC_API_KEY\") or user_config.get_api_key(\"anthropic\")\n self.openai_key = os.environ.get(\"OPENAI_API_KEY\") or user_config.get_api_key(\"openai\")\n self.minimax_key = os.environ.get(\"MINIMAX_API_KEY\") or user_config.get_api_key(\"minimax\")\n shared_opencode_key = os.environ.get(\"OPENCODE_API_KEY\")\n self.opencode_go_key = shared_opencode_key or user_config.get_api_key(\"opencode-go\")\n self.opencode_zen_key = shared_opencode_key or user_config.get_api_key(\"opencode-zen\")\n self._anthropic: Optional[AsyncAnthropic] = None\n self._openai: Optional[AsyncOpenAI] = None\n self._minimax: Optional[AsyncOpenAI] = None\n\n # Initialize clients\n if self.anthropic_key and AsyncAnthropic is not None:\n try:\n self._anthropic = AsyncAnthropic(api_key=self.anthropic_key)\n except Exception as e:\n logger.warning(f\"Failed to initialize Anthropic client: {e}\")\n self._anthropic = None\n\n if self.openai_key and AsyncOpenAI is not None:\n try:\n self._openai = AsyncOpenAI(api_key=self.openai_key)\n except Exception as e:\n logger.warning(f\"Failed to initialize OpenAI client: {e}\")\n self._openai = None\n\n if self.minimax_key and AsyncOpenAI is not None:\n try:\n self._minimax = AsyncOpenAI(\n api_key=self.minimax_key,\n base_url=\"https://api.minimax.io/v1\",\n )\n except Exception as e:\n logger.warning(f\"Failed to initialize MiniMax client: {e}\")\n self._minimax = None\n\n # Circuit breakers for each provider\n self.anthropic_breaker = CircuitBreaker(CircuitBreakerConfig())\n self.openai_breaker = CircuitBreaker(CircuitBreakerConfig())\n self.minimax_breaker = CircuitBreaker(CircuitBreakerConfig())\n self.opencode_go_breaker = CircuitBreaker(CircuitBreakerConfig())\n self.opencode_zen_breaker = CircuitBreaker(CircuitBreakerConfig())\n\n # Retry configuration\n self.retry_config = RetryConfig()\n\n # Resolved-model cache: {provider: {\"primary\": id, \"worker\": id}}.\n # Populated lazily from each provider's live /models listing.\n self._resolved_models: Dict[str, Dict[str, str]] = {}\n self._resolve_lock = asyncio.Lock()\n\n async def _discover_models(self, provider: str) -> Dict[str, str]:\n \"\"\"Query a provider's live model list and pick current (non-deprecated)\n primary + worker models. Falls back to current-gen constants on error.\"\"\"\n try:\n tiers = (\"primary\", \"worker\", \"frontier\", \"standard\", \"cheap\", \"tiny\")\n if provider == \"anthropic\" and self._anthropic is not None:\n page = await self._anthropic.models.list()\n ids = [getattr(m, \"id\", None) for m in getattr(page, \"data\", [])]\n ids = [i for i in ids if i]\n chosen = {t: (_select_anthropic(ids, t) or _ANTHROPIC_FALLBACK[t]) for t in tiers}\n logger.info(f\"Resolved Anthropic models from {len(ids)} available: {chosen}\")\n return chosen\n if provider == \"openai\" and self._openai is not None:\n page = await self._openai.models.list()\n ids = [getattr(m, \"id\", None) for m in getattr(page, \"data\", [])]\n ids = [i for i in ids if i]\n chosen = {t: (_select_openai(ids, t) or _OPENAI_FALLBACK[t]) for t in tiers}\n logger.info(f\"Resolved OpenAI models from {len(ids)} available: {chosen}\")\n return chosen\n if provider == \"minimax\" and self._minimax is not None:\n page = await self._minimax.models.list()\n ids = [getattr(m, \"id\", None) for m in getattr(page, \"data\", [])]\n ids = [i for i in ids if i]\n chosen = {t: (_select_minimax(ids, t) or _MINIMAX_FALLBACK[t]) for t in tiers}\n logger.info(f\"Resolved MiniMax models from {len(ids)} available: {chosen}\")\n return chosen\n except Exception as e:\n logger.warning(f\"Live model discovery failed for {provider}: {e}; using current-gen fallback\")\n fallbacks = {\n \"anthropic\": _ANTHROPIC_FALLBACK,\n \"openai\": _OPENAI_FALLBACK,\n \"minimax\": _MINIMAX_FALLBACK,\n }\n return dict(fallbacks.get(provider, _OPENAI_FALLBACK))\n\n async def resolve_model(self, provider: str, tier: str = \"primary\") -> Optional[str]:\n \"\"\"Return a current model id for the given provider + tier\n ('primary' or 'worker'), discovering and caching on first use.\"\"\"\n async with self._resolve_lock:\n if provider not in self._resolved_models:\n self._resolved_models[provider] = await self._discover_models(provider)\n return self._resolved_models[provider].get(tier)\n\n async def list_available_models(self) -> Dict[str, Dict[str, str]]:\n \"\"\"Expose the resolved model selection for both providers (for /api/status, diagnostics).\"\"\"\n out: Dict[str, Dict[str, str]] = {}\n if self._anthropic is not None:\n out[\"anthropic\"] = {**await self._discover_models(\"anthropic\")}\n if self._openai is not None:\n out[\"openai\"] = {**await self._discover_models(\"openai\")}\n if self._minimax is not None:\n out[\"minimax\"] = {**await self._discover_models(\"minimax\")}\n return out\n\n def available(self) -> bool:\n return any((\n self._minimax is not None,\n self._anthropic is not None,\n self._openai is not None,\n bool(self.opencode_go_key),\n bool(self.opencode_zen_key),\n ))\n\n def _calculate_delay(self, attempt: int) -> float:\n \"\"\"Calculate delay with exponential backoff and jitter.\"\"\"\n delay = min(\n self.retry_config.base_delay * (self.retry_config.exponential_base ** attempt),\n self.retry_config.max_delay\n )\n\n if self.retry_config.jitter:\n # Add random jitter ±25%\n jitter_range = delay * 0.25\n delay += (asyncio.get_event_loop().time() % 1 - 0.5) * 2 * jitter_range\n\n return max(0, delay)\n\n def _is_retryable_error(self, error: Exception) -> bool:\n \"\"\"Determine if an error should be retried.\"\"\"\n if isinstance(error, (APIConnectionError, RateLimitError)):\n return True\n if isinstance(error, APIStatusError) and 500 <= error.status_code < 600:\n return True\n if hasattr(error, 'status_code') and error.status_code in [429, 502, 503, 504]:\n return True\n if isinstance(error, httpx.HTTPStatusError) and error.response.status_code in [429, 502, 503, 504]:\n return True\n if isinstance(error, (httpx.ConnectError, httpx.TimeoutException)):\n return True\n return False\n\n async def _call_anthropic_with_retry(self, prompt: str, system: str, model: str, max_tokens: int) -> Optional[str]:\n \"\"\"Call Anthropic API with retry logic and circuit breaker.\"\"\"\n if not self._anthropic or not self.anthropic_breaker.can_call():\n return None\n\n for attempt in range(self.retry_config.max_retries + 1):\n try:\n resp = await self._anthropic.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system or DEFAULT_SYSTEM_PROMPT,\n messages=[{\"role\": \"user\", \"content\": prompt}],\n )\n\n # Successful call\n self.anthropic_breaker.record_success()\n\n # Extract text content\n parts = []\n for block in resp.content:\n if hasattr(block, 'text') and block.text:\n parts.append(block.text)\n elif isinstance(block, dict) and block.get('type') == 'text':\n parts.append(block.get('text', ''))\n\n result = \"\\n\".join([p for p in parts if p])\n logger.debug(f\"Anthropic API success on attempt {attempt + 1}\")\n return result\n\n except Exception as e:\n logger.warning(f\"Anthropic API attempt {attempt + 1} failed: {e}\")\n self.anthropic_breaker.record_failure()\n\n if not self._is_retryable_error(e) or attempt == self.retry_config.max_retries:\n break\n\n delay = self._calculate_delay(attempt)\n await asyncio.sleep(delay)\n\n return None\n\n async def _call_openai_with_retry(self, prompt: str, system: str, model: str, max_tokens: int) -> Optional[str]:\n \"\"\"Call OpenAI API with retry logic and circuit breaker.\"\"\"\n if not self._openai or not self.openai_breaker.can_call():\n return None\n\n # `model` here is already a resolved OpenAI model id (see complete()).\n openai_model = model\n\n for attempt in range(self.retry_config.max_retries + 1):\n try:\n messages = []\n if system:\n messages.append({\"role\": \"system\", \"content\": system})\n messages.append({\"role\": \"user\", \"content\": prompt})\n\n resp = await self._openai.chat.completions.create(\n model=openai_model,\n messages=messages,\n max_tokens=max_tokens,\n )\n\n # Successful call\n self.openai_breaker.record_success()\n\n result = resp.choices[0].message.content or \"\"\n logger.debug(f\"OpenAI API success on attempt {attempt + 1}\")\n return result\n\n except Exception as e:\n logger.warning(f\"OpenAI API attempt {attempt + 1} failed: {e}\")\n self.openai_breaker.record_failure()\n\n if not self._is_retryable_error(e) or attempt == self.retry_config.max_retries:\n break\n\n delay = self._calculate_delay(attempt)\n await asyncio.sleep(delay)\n\n return None\n\n async def _call_minimax_with_retry(self, prompt: str, system: str, model: str, max_tokens: int) -> Optional[str]:\n \"\"\"Call MiniMax through its officially supported OpenAI-compatible endpoint.\"\"\"\n if not self._minimax or not self.minimax_breaker.can_call():\n return None\n\n for attempt in range(self.retry_config.max_retries + 1):\n try:\n response = await self._minimax.chat.completions.create(\n model=model,\n messages=[\n {\"role\": \"system\", \"content\": system or DEFAULT_SYSTEM_PROMPT},\n {\"role\": \"user\", \"content\": prompt},\n ],\n max_tokens=max_tokens,\n )\n self.minimax_breaker.record_success()\n return response.choices[0].message.content or \"\"\n except Exception as error:\n logger.warning(f\"MiniMax API attempt {attempt + 1} failed: {error}\")\n self.minimax_breaker.record_failure()\n if not self._is_retryable_error(error) or attempt == self.retry_config.max_retries:\n break\n await asyncio.sleep(self._calculate_delay(attempt))\n return None\n\n async def _call_opencode_with_retry(\n self,\n provider: str,\n api_key: str,\n prompt: str,\n system: str,\n model: str,\n max_tokens: int,\n ) -> Optional[str]:\n \"\"\"Call Zen/Go without a provider SDK, translating the model's dialect.\"\"\"\n from core.routers.gateway import detect_dialect, resolve_upstream_url\n\n breaker = self.opencode_go_breaker if provider == \"opencode-go\" else self.opencode_zen_breaker\n if not breaker.can_call():\n return None\n base_url = \"https://opencode.ai/zen/go/v1\" if provider == \"opencode-go\" else \"https://opencode.ai/zen/v1\"\n dialect = detect_dialect(provider, model, base_url)\n endpoint = resolve_upstream_url(base_url, dialect)\n system_prompt = system or DEFAULT_SYSTEM_PROMPT\n\n if dialect == \"anthropic\":\n headers = {\n \"x-api-key\": api_key,\n \"anthropic-version\": \"2023-06-01\",\n \"content-type\": \"application/json\",\n }\n payload = {\n \"model\": model,\n \"system\": system_prompt,\n \"messages\": [{\"role\": \"user\", \"content\": prompt}],\n \"max_tokens\": max_tokens,\n \"stream\": False,\n }\n elif dialect == \"responses\":\n headers = {\"authorization\": f\"Bearer {api_key}\", \"content-type\": \"application/json\"}\n payload = {\n \"model\": model,\n \"instructions\": system_prompt,\n \"input\": prompt,\n \"max_output_tokens\": max_tokens,\n \"stream\": False,\n }\n else:\n headers = {\"authorization\": f\"Bearer {api_key}\", \"content-type\": \"application/json\"}\n payload = {\n \"model\": model,\n \"messages\": [\n {\"role\": \"system\", \"content\": system_prompt},\n {\"role\": \"user\", \"content\": prompt},\n ],\n \"max_tokens\": max_tokens,\n \"stream\": False,\n }\n\n for attempt in range(self.retry_config.max_retries + 1):\n try:\n async with httpx.AsyncClient(timeout=60.0, follow_redirects=False) as client:\n response = await client.post(endpoint, headers=headers, json=payload)\n response.raise_for_status()\n data = response.json()\n breaker.record_success()\n if dialect == \"anthropic\":\n return \"\\n\".join(block.get(\"text\", \"\") for block in data.get(\"content\", []) if block.get(\"type\") == \"text\")\n if dialect == \"responses\":\n if data.get(\"output_text\"):\n return str(data[\"output_text\"])\n return \"\\n\".join(\n part.get(\"text\", \"\")\n for item in data.get(\"output\", [])\n for part in item.get(\"content\", [])\n if part.get(\"type\") in {\"output_text\", \"text\"}\n )\n return ((data.get(\"choices\") or [{}])[0].get(\"message\") or {}).get(\"content\", \"\")\n except (httpx.HTTPError, ValueError, KeyError, TypeError) as error:\n logger.warning(f\"{provider} API attempt {attempt + 1} failed: {error}\")\n breaker.record_failure()\n if not self._is_retryable_error(error) or attempt == self.retry_config.max_retries:\n break\n await asyncio.sleep(self._calculate_delay(attempt))\n return None\n\n async def complete(\n self,\n prompt: str,\n system: str = \"\",\n model: Optional[str] = None,\n max_tokens: int = 1000,\n tier: str = \"primary\",\n ) -> str:\n \"\"\"\n Generate text completion with comprehensive error recovery.\n Tries the configured provider first, then configured fallbacks.\n\n Model selection is dynamic: if `model` is None or a deprecated/legacy id,\n CASPER resolves a current model from the provider's live model list for\n the requested `tier` (\"primary\" or \"worker\"). No model ids are hardcoded\n in the hot path.\n \"\"\"\n if not self.available():\n logger.warning(\"No LLM providers available\")\n return \"\"\n\n requested = model\n preferred = user_config.get_default_provider()\n if requested and preferred not in _OPENCODE_DEFAULTS:\n lowered = requested.lower()\n if \"minimax\" in lowered:\n preferred = \"minimax\"\n elif \"claude\" in lowered:\n preferred = \"anthropic\"\n elif lowered.startswith((\"gpt-\", \"o1\", \"o3\", \"o4\")):\n preferred = \"openai\"\n\n provider_order = list(dict.fromkeys([\n preferred,\n \"minimax\",\n \"opencode-go\",\n \"opencode-zen\",\n \"anthropic\",\n \"openai\",\n ]))\n for provider in provider_order:\n if provider == \"minimax\" and self._minimax:\n selected = requested if requested and \"minimax\" in requested.lower() else None\n selected = selected or user_config.get_default_model(_MINIMAX_FALLBACK[tier], \"minimax\")\n result = await self._call_minimax_with_retry(prompt, system, selected, max_tokens)\n elif provider == \"anthropic\" and self._anthropic:\n selected = requested\n if not selected or _is_legacy_model(selected) or \"claude\" not in selected.lower():\n selected = await self.resolve_model(\"anthropic\", tier) or _ANTHROPIC_FALLBACK[tier]\n result = await self._call_anthropic_with_retry(prompt, system, selected, max_tokens)\n elif provider == \"openai\" and self._openai:\n selected = requested\n if not selected or _is_legacy_model(selected) or not selected.lower().startswith((\"gpt-\", \"o1\", \"o3\", \"o4\")):\n selected = await self.resolve_model(\"openai\", tier) or _OPENAI_FALLBACK[tier]\n result = await self._call_openai_with_retry(prompt, system or DEFAULT_SYSTEM_PROMPT, selected, max_tokens)\n elif provider == \"opencode-go\" and self.opencode_go_key:\n selected = requested or user_config.get_default_model(_OPENCODE_DEFAULTS[provider], provider)\n result = await self._call_opencode_with_retry(\n provider, self.opencode_go_key, prompt, system, selected, max_tokens\n )\n elif provider == \"opencode-zen\" and self.opencode_zen_key:\n selected = requested or user_config.get_default_model(_OPENCODE_DEFAULTS[provider], provider)\n result = await self._call_opencode_with_retry(\n provider, self.opencode_zen_key, prompt, system, selected, max_tokens\n )\n else:\n continue\n if result is not None:\n return result\n\n logger.error(\"All LLM providers failed or circuit breakers are open\")\n return \"\" # Return empty string if all providers fail\n\n\nllm_service = LLMService()\n"}}, "tests/test_model_gateway.py": {"09b988bb32a3db679b5118527209e3ee04eb79b1": {"t": 1785596361.402904, "n": 8537, "h": "46663be638069f40", "c": "import json\nfrom pathlib import Path\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\nfrom starlette.requests import Request\n\nfrom core.routers import gateway as gateway_module\nfrom core.routers.gateway import (\n GatewayRequest,\n PROVIDERS,\n build_upstream_headers,\n build_upstream_payload,\n detect_dialect,\n gateway,\n normalize_sse_event,\n resolve_upstream_url,\n _stream_response,\n _watch_disconnect,\n)\nfrom core.services.llm import DEFAULT_SYSTEM_PROMPT, LLMService\nfrom core.services.setup import AIProviders\nfrom core.services.slash_commands import SlashCommandRegistry\nfrom core.services.user_config import user_config\nfrom core.server import app\n\n\ndef request_with_headers(**headers):\n raw = [(name.replace(\"_\", \"-\").encode(), value.encode()) for name, value in headers.items()]\n return Request({\"type\": \"http\", \"method\": \"POST\", \"path\": \"/gateway\", \"headers\": raw})\n\n\ndef gateway_request(**overrides):\n data = {\n \"provider\": \"opencode-go\",\n \"model\": \"minimax-m2.7\",\n \"messages\": [\n {\"role\": \"system\", \"content\": \"Be exact.\"},\n {\"role\": \"user\", \"content\": \"Review this.\"},\n ],\n }\n data.update(overrides)\n return GatewayRequest(**data)\n\n\ndef test_provider_endpoints_and_minimax_default_are_exact():\n assert PROVIDERS[\"opencode-zen\"].base_url == \"https://opencode.ai/zen/v1\"\n assert PROVIDERS[\"opencode-go\"].base_url == \"https://opencode.ai/zen/go/v1\"\n assert AIProviders.PROVIDERS[\"minimax\"].default_model == \"MiniMax-M2.7-highspeed\"\n\n\ndef test_dialect_detection_and_endpoint_resolution():\n assert detect_dialect(\"opencode-go\", \"minimax-m2.7\", \"https://opencode.ai/zen/go/v1\") == \"anthropic\"\n assert detect_dialect(\"minimax\", \"MiniMax-M2.7-highspeed\", \"https://api.minimax.io/v1\") == \"openai\"\n assert detect_dialect(\"opencode-go\", \"glm-5.2\", \"https://opencode.ai/zen/go/v1\") == \"openai\"\n assert detect_dialect(\"opencode-zen\", \"gpt-5.5\", \"https://opencode.ai/zen/v1\") == \"responses\"\n assert resolve_upstream_url(\"https://opencode.ai/zen/go/v1\", \"anthropic\").endswith(\"/v1/messages\")\n assert resolve_upstream_url(\"https://example.com/v1/chat/completions\", \"anthropic\") == \"https://example.com/v1/messages\"\n\n\ndef test_anthropic_translation_moves_system_and_auth_headers():\n body = gateway_request(api_key=\"secret\")\n payload = build_upstream_payload(body, \"anthropic\")\n assert payload[\"system\"] == \"Be exact.\"\n assert [message[\"role\"] for message in payload[\"messages\"]] == [\"user\"]\n\n headers = build_upstream_headers(request_with_headers(authorization=\"Bearer original\"), body, \"anthropic\")\n assert headers[\"x-api-key\"] == \"original\"\n assert headers[\"anthropic-version\"] == \"2023-06-01\"\n assert \"authorization\" not in headers\n\n\ndef test_openai_headers_are_forwarded_without_rewriting():\n body = gateway_request(model=\"glm-5.2\")\n headers = build_upstream_headers(request_with_headers(authorization=\"Bearer exact-token\"), body, \"openai\")\n assert headers[\"authorization\"] == \"Bearer exact-token\"\n\n\ndef test_stream_events_normalize_across_dialects():\n openai = normalize_sse_event(\"openai\", json.dumps({\"choices\": [{\"delta\": {\"content\": \"A\"}}]}))\n anthropic = normalize_sse_event(\"anthropic\", json.dumps({\"type\": \"content_block_delta\", \"delta\": {\"text\": \"B\"}}))\n responses = normalize_sse_event(\"responses\", json.dumps({\"type\": \"response.output_text.delta\", \"delta\": \"C\"}))\n assert [events[0][1][\"text\"] for events in (openai, anthropic, responses)] == [\"A\", \"B\", \"C\"]\n\n\n@pytest.mark.asyncio\nasync def test_gateway_preserves_upstream_error_status_and_payload(monkeypatch):\n class FakeResponse:\n status_code = 429\n headers = {\"content-type\": \"application/json\"}\n\n async def aread(self):\n return b'{\"error\":{\"message\":\"vendor rate limit\"}}'\n\n async def aclose(self):\n return None\n\n class FakeClient:\n def build_request(self, *args, **kwargs):\n return object()\n\n async def send(self, request, stream=False):\n return FakeResponse()\n\n async def aclose(self):\n return None\n\n monkeypatch.setattr(gateway_module, \"_new_client\", FakeClient)\n response = await gateway(request_with_headers(authorization=\"Bearer key\"), gateway_request(model=\"glm-5.2\"))\n assert response.status_code == 429\n assert response.body == b'{\"error\":{\"message\":\"vendor rate limit\"}}'\n\n\n@pytest.mark.asyncio\nasync def test_disconnect_watcher_immediately_closes_upstream_resources():\n incoming = MagicMock()\n incoming.is_disconnected = AsyncMock(return_value=True)\n upstream = MagicMock()\n upstream.aclose = AsyncMock()\n client = MagicMock()\n client.aclose = AsyncMock()\n\n await _watch_disconnect(incoming, upstream, client)\n upstream.aclose.assert_awaited_once()\n client.aclose.assert_awaited_once()\n\n\n@pytest.mark.asyncio\nasync def test_stream_normalization_closes_resources_and_emits_one_done():\n incoming = MagicMock()\n incoming.is_disconnected = AsyncMock(return_value=False)\n\n class Upstream:\n def __init__(self):\n self.closed = False\n\n async def aiter_lines(self):\n yield 'data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}'\n yield 'data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}'\n yield \"data: [DONE]\"\n\n async def aclose(self):\n self.closed = True\n\n class Client:\n def __init__(self):\n self.closed = False\n\n async def aclose(self):\n self.closed = True\n\n upstream = Upstream()\n client = Client()\n frames = [frame async for frame in _stream_response(incoming, upstream, client, \"openai\")]\n assert sum(frame.startswith(b\"event: done\") for frame in frames) == 1\n assert b'\"text\":\"ok\"' in b\"\".join(frames)\n assert upstream.closed is True\n assert client.closed is True\n\n\n@pytest.mark.asyncio\nasync def test_minimax_uses_compact_verified_default_prompt():\n service = LLMService()\n response = MagicMock()\n response.choices = [MagicMock(message=MagicMock(content=\"ok\"))]\n client = MagicMock()\n client.chat.completions.create = AsyncMock(return_value=response)\n service._minimax = client\n\n result = await service._call_minimax_with_retry(\"task\", \"\", \"MiniMax-M2.7-highspeed\", 50)\n assert result == \"ok\"\n sent = client.chat.completions.create.await_args.kwargs\n assert sent[\"messages\"][0] == {\"role\": \"system\", \"content\": DEFAULT_SYSTEM_PROMPT}\n assert \"INCOMPLETE\" in DEFAULT_SYSTEM_PROMPT\n assert len(DEFAULT_SYSTEM_PROMPT) < 300\n\n\n@pytest.mark.asyncio\nasync def test_selected_opencode_provider_keeps_its_minimax_model_on_go():\n service = LLMService()\n service.opencode_go_key = \"test-key\"\n service._minimax = MagicMock()\n with patch.object(user_config, \"get_default_provider\", return_value=\"opencode-go\"), \\\n patch.object(service, \"_call_opencode_with_retry\", new_callable=AsyncMock, return_value=\"go response\") as go_call, \\\n patch.object(service, \"_call_minimax_with_retry\", new_callable=AsyncMock) as native_call:\n result = await service.complete(\"task\", model=\"minimax-m2.7\")\n assert result == \"go response\"\n assert go_call.await_args.args[0] == \"opencode-go\"\n native_call.assert_not_awaited()\n\n\ndef test_familiar_agent_commands_are_registered():\n registry = SlashCommandRegistry()\n for name in (\"goal\", \"model\", \"plan\", \"diff\", \"pwd\", \"review\", \"status\", \"init\"):\n assert registry.get_command(name) is not None\n\n\ndef test_gateway_is_mounted_on_the_casper_server():\n assert \"/gateway\" in app.openapi()[\"paths\"]\n\n\n@pytest.mark.asyncio\nasync def test_complete_terminal_routes_new_agent_commands():\n from casper_terminal_complete import CasperTerminalComplete\n\n terminal = CasperTerminalComplete()\n terminal.slash_commands = MagicMock()\n terminal.slash_commands.execute = AsyncMock(return_value=True)\n should_exit = await terminal.handle_command(\"/goal\", \"status\")\n assert should_exit is False\n terminal.slash_commands.execute.assert_awaited_once_with(\"/goal status\")\n\n\n@pytest.mark.asyncio\nasync def test_global_cli_without_subcommand_launches_interactive_terminal(monkeypatch):\n from core import cli as cli_module\n\n run = AsyncMock()\n monkeypatch.setattr(\"sys.argv\", [\"casper\"])\n with patch(\"core.terminal.modern_cli.run_interactive\", run):\n await cli_module.main_async()\n run.assert_awaited_once_with(Path.cwd(), permission_mode=\"accept_edits\")\n"}}}}