Files

1 line
250 KiB
JSON

{"repo": "amanverasia/janus", "files": {"src/janus/providers/mimo_free.py": {"8477b132cc16c8258e0d33dcfd6f376d34840f81": {"t": 1785595160.1550522, "n": 7347, "h": "745bb27cd3ee5fe2", "c": "from __future__ import annotations\n\nimport hashlib\nimport os\nimport random\nimport secrets\nimport string\nimport time\nfrom collections.abc import AsyncIterator\nfrom typing import Any\n\nimport httpx\n\nfrom .base import RawResult, parse_error_body, parse_retry_after\n\n_DEFAULT_LIMITS = httpx.Limits(max_connections=100, max_keepalive_connections=20)\n_DEFAULT_TIMEOUT = httpx.Timeout(connect=10.0, read=300.0, write=10.0, pool=5.0)\n\nBOOTSTRAP_URL = \"https://api.xiaomimimo.com/api/free-ai/bootstrap\"\nCHAT_URL = \"https://api.xiaomimimo.com/api/free-ai/openai/chat\"\nSESSION_AFFINITY_PREFIX = \"ses_\"\nSESSION_ID_LENGTH = 24\nJWT_FALLBACK_TTL_SEC = 3000\nJWT_EXPIRY_BUFFER_MS = 300_000\n\nMIMO_SYSTEM_MARKER = (\n \"You are MiMoCode, an interactive CLI tool that helps users with software engineering tasks.\"\n)\n\n_USER_AGENTS = (\n \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \"\n \"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36\",\n \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \"\n \"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36\",\n \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 \"\n \"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36\",\n)\n\n_SESSION_CHARS = string.ascii_lowercase + string.digits\n\n\ndef generate_fingerprint() -> str:\n try:\n username = os.getlogin()\n except OSError:\n username = \"unknown-user\"\n seed = f\"{os.uname().nodename}|{os.name}|{os.uname().machine}|{username}\"\n return hashlib.sha256(seed.encode()).hexdigest()\n\n\ndef generate_session_id() -> str:\n body = \"\".join(secrets.choice(_SESSION_CHARS) for _ in range(SESSION_ID_LENGTH))\n return f\"{SESSION_AFFINITY_PREFIX}{body}\"\n\n\ndef parse_jwt_exp_ms(jwt: str) -> int:\n try:\n import base64\n import json\n\n payload_b64 = jwt.split(\".\")[1]\n pad = \"=\" * (-len(payload_b64) % 4)\n payload = json.loads(base64.urlsafe_b64decode(payload_b64 + pad))\n exp = payload.get(\"exp\")\n if isinstance(exp, (int, float)):\n return int(exp * 1000)\n except Exception:\n pass\n return int(time.time() * 1000) + JWT_FALLBACK_TTL_SEC * 1000\n\n\ndef inject_system_marker(payload: dict[str, Any]) -> dict[str, Any]:\n messages = payload.get(\"messages\")\n if not isinstance(messages, list):\n return payload\n for msg in messages:\n if (\n isinstance(msg, dict)\n and msg.get(\"role\") == \"system\"\n and isinstance(msg.get(\"content\"), str)\n and MIMO_SYSTEM_MARKER in msg[\"content\"]\n ):\n return payload\n return {\n **payload,\n \"messages\": [{\"role\": \"system\", \"content\": MIMO_SYSTEM_MARKER}, *messages],\n }\n\n\nclass MimoFreeProvider:\n \"\"\"Xiaomi MiMo Code Free — bootstrap JWT + anti-abuse headers (9router parity).\"\"\"\n\n name = \"mimo_free\"\n\n def __init__(self) -> None:\n self.base_url = CHAT_URL\n self.api_key: str | None = None\n self.session_id = generate_session_id()\n self._client = httpx.AsyncClient(limits=_DEFAULT_LIMITS, timeout=_DEFAULT_TIMEOUT)\n self._jwt: str | None = None\n self._jwt_expires_at_ms: int = 0\n self._fingerprint = generate_fingerprint()\n\n def _reset_jwt(self) -> None:\n self._jwt = None\n self._jwt_expires_at_ms = 0\n\n async def _bootstrap_jwt(self) -> str:\n now_ms = int(time.time() * 1000)\n if self._jwt and now_ms < self._jwt_expires_at_ms - JWT_EXPIRY_BUFFER_MS:\n return self._jwt\n r = await self._client.post(\n BOOTSTRAP_URL,\n json={\"client\": self._fingerprint},\n headers={\n \"Content-Type\": \"application/json\",\n \"User-Agent\": random.choice(_USER_AGENTS),\n },\n )\n if r.status_code >= 400:\n raise RuntimeError(f\"MiMo bootstrap failed: {r.status_code}\")\n data = r.json()\n jwt = data.get(\"jwt\") if isinstance(data, dict) else None\n if not isinstance(jwt, str) or not jwt:\n raise RuntimeError(\"MiMo bootstrap returned no JWT\")\n self._jwt = jwt\n self._jwt_expires_at_ms = parse_jwt_exp_ms(jwt)\n return jwt\n\n def _headers(self, jwt: str, *, stream: bool) -> dict[str, str]:\n return {\n \"Content-Type\": \"application/json\",\n \"Authorization\": f\"Bearer {jwt}\",\n \"X-Mimo-Source\": \"mimocode-cli-free\",\n \"User-Agent\": random.choice(_USER_AGENTS),\n \"x-session-affinity\": self.session_id,\n \"Accept\": \"text/event-stream\" if stream else \"application/json\",\n }\n\n async def call(self, payload: dict[str, Any], stream: bool = False) -> RawResult:\n body = inject_system_marker(payload)\n try:\n jwt = await self._bootstrap_jwt()\n except RuntimeError as exc:\n return RawResult(status_code=502, json_data={\"error\": {\"message\": str(exc)}})\n\n if stream:\n return await self._call_stream(body, jwt)\n\n r = await self._client.post(CHAT_URL, json=body, headers=self._headers(jwt, stream=False))\n if r.status_code in (401, 403):\n self._reset_jwt()\n try:\n jwt = await self._bootstrap_jwt()\n except RuntimeError as exc:\n return RawResult(status_code=502, json_data={\"error\": {\"message\": str(exc)}})\n r = await self._client.post(\n CHAT_URL, json=body, headers=self._headers(jwt, stream=False)\n )\n if r.status_code >= 400:\n return RawResult(\n status_code=r.status_code,\n json_data=r.json() if r.content else {\"error\": {\"message\": r.text}},\n retry_after=parse_retry_after(r.headers),\n )\n return RawResult(status_code=r.status_code, json_data=r.json())\n\n async def _call_stream(self, payload: dict[str, Any], jwt: str) -> RawResult:\n payload = {**payload, \"stream\": True}\n headers = self._headers(jwt, stream=True)\n cm = self._client.stream(\"POST\", CHAT_URL, json=payload, headers=headers)\n r = await cm.__aenter__()\n if r.status_code in (401, 403):\n body = await r.aread()\n await cm.__aexit__(None, None, None)\n self._reset_jwt()\n try:\n jwt = await self._bootstrap_jwt()\n except RuntimeError as exc:\n return RawResult(status_code=502, json_data={\"error\": {\"message\": str(exc)}})\n headers = self._headers(jwt, stream=True)\n cm = self._client.stream(\"POST\", CHAT_URL, json=payload, headers=headers)\n r = await cm.__aenter__()\n if r.status_code >= 400:\n body = await r.aread()\n await cm.__aexit__(None, None, None)\n return RawResult(\n status_code=r.status_code,\n json_data=parse_error_body(body),\n retry_after=parse_retry_after(r.headers),\n )\n\n async def line_iter() -> AsyncIterator[str]:\n try:\n async for raw_line in r.aiter_lines():\n yield raw_line\n finally:\n await cm.__aexit__(None, None, None)\n\n return RawResult(status_code=r.status_code, lines=line_iter())\n\n async def close(self) -> None:\n await self._client.aclose()\n"}}, "src/janus/inventory/url_guard.py": {"8477b132cc16c8258e0d33dcfd6f376d34840f81": {"t": 1785674012.4476867, "n": 7967, "h": "0ca12a49d72a51b9", "c": "from __future__ import annotations\n\nimport asyncio\nimport ipaddress\nimport json\nimport os\nimport re\nimport socket\nfrom urllib.parse import urlparse\n\nimport httpx\n\nMAX_REDIRECTS = 3\n\n_BLOCKED_V4: list[tuple[str, int]] = [\n (\"0.0.0.0\", 8),\n (\"10.0.0.0\", 8),\n (\"100.64.0.0\", 10),\n (\"127.0.0.0\", 8),\n (\"169.254.0.0\", 16),\n (\"172.16.0.0\", 12),\n (\"192.0.0.0\", 24),\n (\"192.168.0.0\", 16),\n (\"198.18.0.0\", 15),\n (\"224.0.0.0\", 4),\n (\"240.0.0.0\", 4),\n (\"255.255.255.255\", 32),\n]\n\n\nclass BlockedUrlError(ValueError):\n pass\n\n\ndef _allow_private() -> bool:\n return os.environ.get(\"ALLOW_PRIVATE_BASE_URLS\", \"\").lower() == \"true\"\n\n\ndef _ipv4_to_int(ip: str) -> int | None:\n parts = ip.split(\".\")\n if len(parts) != 4:\n return None\n value = 0\n for part in parts:\n if not part.isdigit():\n return None\n octet = int(part)\n if octet < 0 or octet > 255:\n return None\n if len(part) > 1 and part[0] == \"0\":\n return None\n value = value * 256 + octet\n return value\n\n\ndef _in_v4_cidr(ip_int: int, base: str, bits: int) -> bool:\n base_int = _ipv4_to_int(base)\n if base_int is None:\n return False\n if bits == 0:\n return True\n mask = (0xFFFFFFFF << (32 - bits)) & 0xFFFFFFFF\n return (ip_int & mask) == (base_int & mask)\n\n\ndef _extract_embedded_v4(addr: str) -> str | None:\n last_colon = addr.rfind(\":\")\n if last_colon != -1:\n tail = addr[last_colon + 1 :]\n if \".\" in tail:\n try:\n ipaddress.IPv4Address(tail)\n except ValueError:\n pass\n else:\n return tail\n\n if addr.startswith(\"::ffff:\") or addr.startswith(\"64:ff9b:\"):\n groups = [group for group in addr.split(\":\") if group]\n if len(groups) >= 2:\n try:\n hi = int(groups[-2], 16)\n lo = int(groups[-1], 16)\n except ValueError:\n return None\n if hi <= 0xFFFF and lo <= 0xFFFF:\n return f\"{(hi >> 8) & 0xFF}.{hi & 0xFF}.{(lo >> 8) & 0xFF}.{lo & 0xFF}\"\n return None\n\n\ndef _is_blocked_v4(ip: str) -> bool:\n ip_int = _ipv4_to_int(ip)\n if ip_int is None:\n return True\n return any(_in_v4_cidr(ip_int, base, bits) for base, bits in _BLOCKED_V4)\n\n\ndef _is_blocked_v6(ip: str) -> bool:\n addr = ip.lower()\n percent = addr.find(\"%\")\n if percent != -1:\n addr = addr[:percent]\n\n embedded = _extract_embedded_v4(addr)\n if embedded is not None:\n return _is_blocked_v4(embedded)\n\n if addr in {\"::1\", \"::\"}:\n return True\n if addr.startswith((\"fe8\", \"fe9\", \"fea\", \"feb\")):\n return True\n if addr.startswith((\"fc\", \"fd\")):\n return True\n if addr.startswith(\"ff\"):\n return True\n return False\n\n\ndef _is_blocked_ip(ip: str) -> bool:\n try:\n parsed = ipaddress.ip_address(ip)\n except ValueError:\n return True\n if isinstance(parsed, ipaddress.IPv4Address):\n return _is_blocked_v4(str(parsed))\n return _is_blocked_v6(str(parsed))\n\n\ndef is_http_url(value: str) -> bool:\n try:\n parsed = urlparse(value)\n except ValueError:\n return False\n return parsed.scheme in {\"http\", \"https\"}\n\n\nasync def assert_public_url(raw_url: str) -> None:\n try:\n parsed = httpx.URL(raw_url)\n except Exception as exc:\n raise BlockedUrlError(f\"Invalid URL: {raw_url}\") from exc\n\n if parsed.scheme not in {\"http\", \"https\"}:\n raise BlockedUrlError(f\"Blocked URL scheme: {parsed.scheme}\")\n if parsed.username or parsed.password:\n raise BlockedUrlError(\"Credentials in URL are not allowed\")\n\n if _allow_private():\n return\n\n host = parsed.host\n if host is None:\n raise BlockedUrlError(f\"Invalid URL: {raw_url}\")\n\n host = host.strip(\"[]\")\n\n try:\n ipaddress.ip_address(host)\n except ValueError:\n pass\n else:\n if _is_blocked_ip(host):\n raise BlockedUrlError(f\"Blocked address: {host}\")\n return\n\n try:\n addresses = await asyncio.to_thread(\n socket.getaddrinfo,\n host,\n None,\n type=socket.SOCK_STREAM,\n )\n except socket.gaierror as exc:\n raise BlockedUrlError(f\"DNS resolution failed for {host}: {exc}\") from exc\n\n if not addresses:\n raise BlockedUrlError(f\"No addresses for {host}\")\n\n for info in addresses:\n sockaddr = info[4]\n if sockaddr is None:\n continue\n address = str(sockaddr[0])\n if _is_blocked_ip(address):\n raise BlockedUrlError(f\"Host {host} resolves to blocked address {address}\")\n\n\ndef _resolve_redirect(current_url: str, location: str) -> str:\n return str(httpx.URL(location, base=current_url))\n\n\nasync def safe_fetch(\n url: str,\n *,\n method: str = \"GET\",\n headers: dict[str, str] | None = None,\n content: bytes | None = None,\n timeout: float = 30.0,\n) -> httpx.Response:\n current_url = url\n current_method = method.upper()\n current_content = content\n\n async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:\n for hop in range(MAX_REDIRECTS + 1):\n await assert_public_url(current_url)\n response = await client.request(\n current_method,\n current_url,\n headers=headers,\n content=current_content,\n )\n\n if response.status_code not in {301, 302, 303, 307, 308}:\n return response\n\n location = response.headers.get(\"location\")\n if not location:\n return response\n\n if hop >= MAX_REDIRECTS:\n raise BlockedUrlError(f\"Too many redirects from {url}\")\n\n current_url = _resolve_redirect(current_url, location)\n if response.status_code in {301, 302, 303}:\n current_method = \"GET\"\n current_content = None\n\n raise BlockedUrlError(f\"Too many redirects from {url}\")\n\n\ndef mask_key(key: str) -> str:\n text = key.strip()\n if text.startswith(\"{\"):\n try:\n data = json.loads(text)\n if isinstance(data, dict):\n for field in (\"access_token\", \"accessToken\", \"refresh_token\", \"refreshToken\"):\n val = data.get(field)\n if isinstance(val, str) and val:\n return mask_key(val)\n except json.JSONDecodeError:\n pass\n if len(key) <= 8:\n return \"****\"\n return f\"{key[:4]}****{key[-6:]}\"\n\n\n_FAL_KEY_RE = re.compile(\n r\"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9a-f]+$\",\n re.IGNORECASE,\n)\n_ZHIPU_KEY_RE = re.compile(r\"^[0-9a-f]{32}\\.[A-Za-z0-9]{16}$\")\n\n\ndef detect_provider_from_key(key: str) -> str | None:\n if key.startswith(\"sk-or-v1-\"):\n return \"openrouter\"\n if key.startswith(\"sk-ant-\"):\n return \"anthropic\"\n if key.startswith(\"nvapi-\"):\n return \"nvidia\"\n if key.startswith(\"sk-proj-\"):\n return \"openai\"\n if key.startswith(\"gsk_\"):\n return \"groq\"\n if key.startswith(\"xai-\"):\n return \"xai\"\n if key.startswith(\"hf_\"):\n return \"huggingface\"\n if key.startswith(\"r8_\"):\n return \"replicate\"\n if key.startswith(\"pplx-\"):\n return \"perplexity\"\n if key.startswith(\"fw_\"):\n return \"fireworks\"\n if key.startswith(\"sk-cp-\"):\n return \"minimax\"\n if key.startswith(\"tp-\"):\n return \"xiaomi_tokenplan\"\n if key.startswith(\"tvly-\"):\n return \"tavily\"\n if key.startswith(\"fc-\"):\n return \"firecrawl\"\n if key.startswith(\"BSA\"):\n return \"brave-search\"\n if _FAL_KEY_RE.match(key):\n return \"fal\"\n if _ZHIPU_KEY_RE.match(key):\n return \"zhipu\"\n if key.startswith(\"sk-\") and not key.startswith((\"sk-or\", \"sk-ant\")):\n return \"openai\"\n return None\n"}}, "README.md": {"8477b132cc16c8258e0d33dcfd6f376d34840f81": {"t": 1785674611.8336422, "n": 11799, "h": "7fd7df1879cec1ff", "c": "# Janus\n\n> The two-faced gateway for AI coding tools. Janus sits at the threshold of every\n> AI call — facing the developer on one side and every provider on the other.\n\nJanus is a local-first, single-user AI routing gateway. It exposes\nOpenAI/Anthropic/Gemini-compatible HTTP endpoints that your coding tools (Claude Code,\nCodex, Cursor, Cline, ...) talk to, then translates and routes each request to\nany of 29 built-in AI providers — or any OpenAI-compatible endpoint — without\neither side needing to know the other exists.\n\n## First-time setup\n\nJanus needs Python **3.11+**. Everything lives under `~/.janus/` — a seed\n`config.yaml` and a SQLite database (`janus.db`) that becomes the source of truth\nafter the first startup.\n\n### 1. Install\n\n**From PyPI (recommended):**\n\n```bash\npip install janus-ai\n```\n\n**From source (development):**\n\n```bash\ngit clone https://github.com/amanverasia/Janus.git\ncd Janus\npython -m venv .venv\nsource .venv/bin/activate # Windows: .venv\\Scripts\\activate\npip install -e \".[dev]\"\n```\n\n### 2. Create config\n\n```bash\njanus config-init\n```\n\nThis writes `~/.janus/config.yaml`. Open it and add at least one provider with\nyour API keys. Environment variables in `${VAR}` form are resolved at startup:\n\n```bash\nexport OPENAI_API_KEY=sk-...\nexport ANTHROPIC_API_KEY=sk-ant-...\n```\n\nExample provider block:\n\n```yaml\nproviders:\n - id: openai\n prefix: openai\n api_type: openai_compat\n base_url: https://api.openai.com/v1\n api_key: ${OPENAI_API_KEY}\n models: [gpt-4o, gpt-4o-mini]\n```\n\nYou can also add providers later from the dashboard — no restart required.\n\n### 3. Start the server\n\n```bash\njanus serve --port 20128\n```\n\nFor access from other machines on your LAN or Tailscale:\n\n```bash\njanus serve --host 0.0.0.0 --port 20128\n```\n\nJanus serves **plain HTTP** only. Use `http://`, not `https://`, unless you put\na reverse proxy with TLS in front.\n\n### 4. Verify\n\n```bash\ncurl http://localhost:20128/v1/health\n# {\"status\":\"ok\"}\n```\n\nOpen the dashboard at [http://localhost:20128/dashboard](http://localhost:20128/dashboard).\nThe root URL `/` redirects there.\n\n### 5. Configure via dashboard\n\nOn first startup, Janus imports `providers`, `combos`, `token_savers`, and\n`pricing` from YAML into SQLite. **After that, the database is authoritative** —\nediting YAML and restarting will not re-apply changes. Use the dashboard instead.\n\n| Step | Where | What |\n|---|---|---|\n| Add providers | **Providers** | Pick from the catalog or add custom; fetch models, test connection |\n| Create a client key | **API Keys** | `sk-janus-...` shown once — save it |\n| Enable auth | **Settings** | Toggle **Require API key** (recommended for remote access) |\n| Set dashboard login | **Settings → Dashboard Login** | Username + password for remote browser sign-in |\n| Connect your tools | **Tool Setup** | Copy-paste env vars for Claude Code, Codex, Cursor, Cline |\n\n**Dashboard access rules:**\n\n- **localhost** — no sign-in required\n- **Remote** (LAN, Tailscale, Docker on `0.0.0.0`) — sign in at `/dashboard/login`\n with your dashboard username/password or a Janus API key\n\nCreate a key from the CLI instead:\n\n```bash\njanus keys create --name \"my-laptop\"\n```\n\n### 6. Send a test request\n\n```bash\ncurl http://localhost:20128/v1/chat/completions \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"model\": \"openai/gpt-4o\",\n \"messages\": [{\"role\": \"user\", \"content\": \"Hello!\"}],\n \"max_tokens\": 50\n }'\n```\n\nList registered models:\n\n```bash\ncurl http://localhost:20128/v1/models\n```\n\n### 7. Point your coding tool at Janus\n\n**Claude Code / Anthropic tools:**\n\n```bash\nexport ANTHROPIC_BASE_URL=http://localhost:20128/v1\nexport ANTHROPIC_API_KEY=sk-janus-yourkey # if require_api_key is on\n```\n\n**Cursor / OpenAI Chat Completions tools:**\n\n```bash\nexport OPENAI_BASE_URL=http://localhost:20128/v1\nexport OPENAI_API_KEY=sk-janus-yourkey # if require_api_key is on\n```\n\n**Codex CLI** uses `POST /v1/responses` — configure a provider in\n`~/.codex/config.toml` with `wire_api = \"responses\"` (see\n[Client Setup](https://amanverasia.github.io/Janus/client-setup/)).\n\nUse `prefix/model` in requests (e.g. `openai/gpt-4o`,\n`anthropic/claude-sonnet-4-20250514`) or a combo name like `best-effort`.\n\n**📚 [Documentation](https://amanverasia.github.io/Janus/) · [Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md)**\n\n## Docker\n\n```bash\nmkdir -p janus-data\njanus config-init --path janus-data/config.yaml\n# Edit janus-data/config.yaml — add providers and ${ENV_VAR} keys\n\n# Optional: pass API keys via .env in the repo root\necho 'OPENAI_API_KEY=sk-...' >> .env\n\ndocker compose up -d\n```\n\nThe image binds to `0.0.0.0:20128`. SQLite and config persist in `./janus-data/`.\nAfter first startup, manage providers and settings from the dashboard — not by\nediting YAML alone.\n\n**Remote dashboard:** enable **Require API key** in Settings, create a Janus API\nkey, and set a dashboard username/password under **Dashboard Login**.\n\n```bash\ncurl http://localhost:20128/v1/health\nopen http://localhost:20128/dashboard # macOS; or visit in your browser\n```\n\n## Configuration\n\nJanus reads YAML from `~/.janus/config.yaml` (or `--config`) with `${ENV_VAR}`\ntoken resolution. Generate a template with `janus config-init`.\n\nOn **first startup only**, YAML seeds the SQLite database. Subsequent changes\nshould be made via the **dashboard** or **Export Config** / **Reset to Defaults**\non the Settings page.\n\n```yaml\nserver:\n port: 20128\n host: 127.0.0.1\n require_api_key: false\n\nproviders:\n - id: openai\n prefix: openai\n api_type: openai_compat\n base_url: https://api.openai.com/v1\n api_key: ${OPENAI_API_KEY}\n models: [gpt-4o, gpt-4o-mini, o3, o4-mini]\n\n - id: anthropic\n prefix: anthropic\n api_type: anthropic\n base_url: https://api.anthropic.com\n api_key: ${ANTHROPIC_API_KEY}\n models: [claude-sonnet-4-20250514, claude-opus-4-20250514]\n\ncombos:\n - name: best-effort\n models: [anthropic/claude-sonnet-4-20250514, openai/gpt-4o]\n```\n\n### Supported Provider Types\n\n| `api_type` | Use For |\n|---|---|\n| `openai_compat` | Any OpenAI-compatible API (OpenAI, Groq, Together, DeepSeek, OpenRouter, Mistral, Fireworks, Perplexity, xAI, ...) |\n| `anthropic` | Direct Anthropic API |\n| `gemini` | Direct Google Gemini API |\n| `github_copilot` | GitHub Copilot (device-code OAuth from the dashboard) |\n| `opencode_free` | OpenCode Zen free tier |\n\n### Known Provider Base URLs\n\n| Provider | `base_url` |\n|---|---|\n| OpenAI | `https://api.openai.com/v1` |\n| Groq | `https://api.groq.com/openai/v1` |\n| Together AI | `https://api.together.xyz/v1` |\n| DeepSeek | `https://api.deepseek.com/v1` |\n| OpenRouter | `https://openrouter.ai/api/v1` |\n| Mistral | `https://api.mistral.ai/v1` |\n| Fireworks | `https://api.fireworks.ai/inference/v1` |\n| Perplexity | `https://api.perplexity.ai` |\n| xAI (Grok) | `https://api.x.ai/v1` |\n| Qwen/DashScope | `https://dashscope.aliyuncs.com/compatible-mode/v1` |\n\n## Client Setup\n\nSee step 7 in [First-time setup](#first-time-setup) for the basics. Full guides:\n[Client Setup](https://amanverasia.github.io/Janus/client-setup/). The dashboard\n**Tool Setup** page (`/dashboard/tools`) generates copy-paste env vars for your\nexact server URL and auth settings.\n\n**Claude Code / Anthropic tools:**\n```bash\nexport ANTHROPIC_BASE_URL=http://localhost:20128/v1\n```\n\n**Cursor / OpenAI Chat Completions tools:**\n```bash\nexport OPENAI_BASE_URL=http://localhost:20128/v1\nexport OPENAI_API_KEY=sk-janus-yourkey # if require_api_key is on\n```\n\n**Codex CLI** speaks the Responses API (`POST /v1/responses`). Prefer a\n`~/.codex/config.toml` provider with `wire_api = \"responses\"` and\n`base_url = \"http://localhost:20128/v1\"` — see the docs link above.\n\n**Ollama-only tools** use `OLLAMA_HOST=http://localhost:20128` (`/api/chat`,\n`/api/generate`, `/api/show`, `/api/tags`). **Gemini-native tools** use\n`GOOGLE_GEMINI_BASE_URL=http://localhost:20128`.\n\n## Features\n\n- **Multi-format inbound** — OpenAI Chat Completions, OpenAI Responses (`/v1/responses` for Codex CLI), Anthropic Messages, Gemini GenerateContent, and Ollama (`/api/chat`, `/api/generate`, `/api/show`, `/api/tags`)\n- **Fallback routing** — multi-account rotation with cooldowns (429→60s, 5xx→30s, auth→300s, network→15s)\n- **Rate-limit-aware rotation** — accounts at their per-minute or per-day request quota are tried last\n- **Subscription quotas** — per-provider 5h / daily / weekly / monthly windows; near-limit banners and soft deprioritization in routing\n- **Combos** — named ordered model sequences (e.g., `\"model\": \"best-effort\"`)\n- **Token savers** — RTK compression (default ON), Caveman, Ponytail, and optional Headroom compression proxy\n- **GitHub Copilot OAuth** — device-code connect from the dashboard; session tokens refreshed automatically\n- **API key scopes** — API-only keys (`can_login`), model allowlists (`prefix/*`), optional daily budgets\n- **Budgets** — daily spending limits per API key or global, with warn/block thresholds\n- **Request logging** — opt-in debug capture of request/response bodies (Settings → Request Logs)\n- **Analytics** — cost tracking, spend trends, success rates, per-model/provider/key breakdowns\n- **Pricing** — builtin model prices, YAML/DB overrides, cache token rates\n- **Dashboard** — HTMX UI at `/dashboard` with charts, budgets, usage, routing overview, and remote login\n- **Upstream key inventory** — validate, monitor, and route through a multi-key pool for 29 providers (`/dashboard/inventory`)\n\n## Upstream Key Inventory\n\nBuilt-in dashboard for upstream provider API keys: health checks, credit tracking, and automatic routing through the best available key.\n\n**Dashboard:** `http://127.0.0.1:20128/dashboard/inventory`\n\n- Overview stats, paginated/sortable keys table, key detail modal, best-keys widget\n- Add keys, bulk submit, import from Dashboard export JSON, re-identify misclassified keys\n- Encryption at rest; routable keys wired into gateway fallback rotation\n- Detected rate limits (RPM/RPD) deprioritize near-quota keys during routing\n- Background recheck scheduler (twice daily by default)\n\n| Variable | Purpose |\n|---|---|\n| `INVENTORY_ENCRYPTION_KEY` | Fernet key for encrypting upstream keys at rest |\n| `INVENTORY_PUSH_TOKEN` | Auth token for `POST /dashboard/api/inventory/push` |\n| `INVENTORY_SCHEDULER_ENABLED` | Set to `false` to disable background rechecks (default: `true`) |\n\n```bash\njanus inventory generate-encryption-key # create Fernet key\njanus inventory migrate export.json --verify # import Dashboard export + summary\njanus inventory verify # cutover verification summary\njanus inventory encrypt-keys # encrypt plaintext keys in DB\n```\n\n## CLI Reference\n\n| Command | Description |\n|---|---|\n| `janus serve` | Start the gateway server |\n| `janus config-init` | Generate default config YAML |\n| `janus config-path` | Print config file path |\n| `janus keys create/list/update/revoke` | Manage API keys (scopes: `--no-login`, `--models`, `--daily-budget`) |\n| `janus usage stats/cost/by-key` | Usage and cost reports |\n| `janus budgets list/set/delete` | Manage spending budgets |\n| `janus pricing list/show` | View model pricing |\n| `janus inventory migrate/verify/encrypt-keys/generate-encryption-key` | Upstream key inventory and cutover |\n\n## Development\n\n```bash\ngit clone https://github.com/amanverasia/Janus.git\ncd Janus\npython -m venv .venv\npip install -e \".[dev]\"\n\n# Run tests\n.venv/bin/python -m pytest\n\n# Lint + typecheck\n.venv/bin/ruff check src/janus/ tests/\n.venv/bin/mypy src/janus/\n\n# Start dev server\n.venv/bin/janus serve --port 20128 --reload\n```\n\n## Tech Stack\n\nPython 3.11+ / FastAPI / httpx / Pydantic v2 / aiosqlite / Jinja2 / HTMX / Chart.js\n\n## License\n\n[GPL-3.0](LICENSE) © 2026 Aman Verasia\n"}}, "docker-compose.yml": {"8477b132cc16c8258e0d33dcfd6f376d34840f81": {"t": 1785745267.9429824, "n": 382, "h": "c4cae7d4bc73148c", "c": "services:\n janus:\n build: .\n ports:\n - \"20128:20128\"\n volumes:\n - ./janus-data:/home/janus/.janus\n environment:\n - OPENAI_API_KEY=${OPENAI_API_KEY:-}\n - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}\n - GEMINI_API_KEY=${GEMINI_API_KEY:-}\n - GLM_API_KEY=${GLM_API_KEY:-}\n - DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY:-}\n restart: unless-stopped\n"}, "925081c0b48d0949a12c555244b7f745480503b4": {"t": 1785749420.480447, "n": 382, "h": "c4cae7d4bc73148c", "c": "services:\n janus:\n build: .\n ports:\n - \"20128:20128\"\n volumes:\n - ./janus-data:/home/janus/.janus\n environment:\n - OPENAI_API_KEY=${OPENAI_API_KEY:-}\n - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}\n - GEMINI_API_KEY=${GEMINI_API_KEY:-}\n - GLM_API_KEY=${GLM_API_KEY:-}\n - DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY:-}\n restart: unless-stopped\n"}}, "docs/superpowers/plans/2026-06-25-phase6-quota-usage-analytics.md": {"8477b132cc16c8258e0d33dcfd6f376d34840f81": {"t": 1785746533.4780679, "n": 107634, "h": "d84480c6409996c3", "c": "# Phase 6: Quota & Usage Analytics Implementation Plan\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** Add cost estimation per request, rich analytics with charts, and daily budget enforcement with warn/block thresholds.\n\n**Architecture:** A new `pricing/` package provides builtin model pricing tables overridable via YAML. Cost is computed at recording time and stored alongside raw token counts (hybrid approach). A new `budgets` table and enforcement layer in `_handle()` rejects requests when daily spend limits are hit. The dashboard gains Chart.js-powered analytics and budget management pages.\n\n**Tech Stack:** Python 3.11, FastAPI, aiosqlite, Pydantic v2, Jinja2, HTMX, Chart.js (CDN), typer, pytest, respx\n\n---\n\n## Task 1: Pricing Models\n\n**Files:**\n- Create: `src/janus/pricing/__init__.py`\n- Create: `src/janus/pricing/models.py`\n\n- [ ] **Step 1: Create the pricing package init**\n\nCreate `src/janus/pricing/__init__.py` as an empty file.\n\n- [ ] **Step 2: Create the pricing model**\n\nCreate `src/janus/pricing/models.py`:\n\n```python\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\n\n\n@dataclass(frozen=True)\nclass ModelPricing:\n input_per_mtok: float\n output_per_mtok: float\n cache_creation_per_mtok: float\n cache_read_per_mtok: float\n```\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add src/janus/pricing/\ngit commit -m \"feat: add pricing models package\"\n```\n\n---\n\n## Task 2: Builtin Pricing Table\n\n**Files:**\n- Create: `src/janus/pricing/builtin.py`\n- Test: `tests/unit/pricing/__init__.py`\n- Test: `tests/unit/pricing/test_builtin.py`\n\n- [ ] **Step 1: Create test package init**\n\nCreate `tests/unit/pricing/__init__.py` as an empty file.\n\n- [ ] **Step 2: Write the failing test**\n\nCreate `tests/unit/pricing/test_builtin.py`:\n\n```python\nfrom janus.pricing.builtin import BUILTIN_PRICING\nfrom janus.pricing.models import ModelPricing\n\n\ndef test_builtin_has_popular_models():\n expected = [\n \"claude-sonnet-4-20250514\",\n \"claude-opus-4-20250514\",\n \"gpt-4o\",\n \"gpt-4o-mini\",\n \"gemini-2.0-flash\",\n ]\n for model in expected:\n assert model in BUILTIN_PRICING, f\"Missing builtin pricing for {model}\"\n\n\ndef test_all_pricing_rates_non_negative():\n for model, pricing in BUILTIN_PRICING.items():\n assert pricing.input_per_mtok >= 0, f\"{model}: negative input rate\"\n assert pricing.output_per_mtok >= 0, f\"{model}: negative output rate\"\n assert pricing.cache_creation_per_mtok >= 0, f\"{model}: negative cache_creation rate\"\n assert pricing.cache_read_per_mtok >= 0, f\"{model}: negative cache_read rate\"\n\n\ndef test_pricing_is_model_pricing_instances():\n for model, pricing in BUILTIN_PRICING.items():\n assert isinstance(pricing, ModelPricing), f\"{model}: not a ModelPricing instance\"\n```\n\n- [ ] **Step 3: Run test to verify it fails**\n\nRun: `.venv/bin/python -m pytest tests/unit/pricing/test_builtin.py -v`\nExpected: FAIL with `ModuleNotFoundError: No module named 'janus.pricing.builtin'`\n\n- [ ] **Step 4: Write builtin pricing data**\n\nCreate `src/janus/pricing/builtin.py`:\n\n```python\nfrom __future__ import annotations\n\nfrom .models import ModelPricing\n\nBUILTIN_PRICING: dict[str, ModelPricing] = {\n # Anthropic\n \"claude-opus-4-20250514\": ModelPricing(15.0, 75.0, 18.75, 1.5),\n \"claude-sonnet-4-20250514\": ModelPricing(3.0, 15.0, 3.75, 0.3),\n \"claude-3.7-sonnet-20250219\": ModelPricing(3.0, 15.0, 3.75, 0.3),\n \"claude-3-5-sonnet-20241022\": ModelPricing(3.0, 15.0, 3.75, 0.3),\n \"claude-3-5-haiku-20241022\": ModelPricing(0.8, 4.0, 1.0, 0.08),\n \"claude-3-opus-20240229\": ModelPricing(15.0, 75.0, 18.75, 1.5),\n # OpenAI\n \"gpt-4o\": ModelPricing(2.5, 10.0, 0.0, 1.25),\n \"gpt-4o-mini\": ModelPricing(0.15, 0.6, 0.0, 0.075),\n \"o3\": ModelPricing(10.0, 40.0, 0.0, 0.0),\n \"o4-mini\": ModelPricing(1.1, 4.4, 0.0, 0.0),\n \"gpt-4.1\": ModelPricing(2.0, 8.0, 0.0, 0.5),\n \"gpt-4.1-mini\": ModelPricing(0.4, 1.6, 0.0, 0.1),\n \"gpt-4.1-nano\": ModelPricing(0.1, 0.4, 0.0, 0.025),\n # Google\n \"gemini-2.5-pro\": ModelPricing(1.25, 10.0, 0.0, 0.31),\n \"gemini-2.0-flash\": ModelPricing(0.1, 0.4, 0.0, 0.025),\n \"gemini-2.0-flash-lite\": ModelPricing(0.075, 0.3, 0.0, 0.01875),\n \"gemini-1.5-pro\": ModelPricing(1.25, 5.0, 0.0, 0.3125),\n \"gemini-1.5-flash\": ModelPricing(0.075, 0.3, 0.0, 0.01875),\n \"gemini-1.5-flash-8b\": ModelPricing(0.0375, 0.15, 0.0, 0.009375),\n # DeepSeek\n \"deepseek-chat\": ModelPricing(0.27, 1.1, 0.0, 0.07),\n \"deepseek-reasoner\": ModelPricing(0.55, 2.19, 0.0, 0.14),\n # Meta / others\n \"llama-3.3-70b-instruct\": ModelPricing(0.6, 0.6, 0.0, 0.0),\n \"llama-3.1-405b-instruct\": ModelPricing(3.0, 3.0, 0.0, 0.0),\n \"mistral-large-2411\": ModelPricing(2.0, 6.0, 0.0, 0.5),\n \"qwen-max\": ModelPricing(1.6, 6.4, 0.0, 0.4),\n \"qwen-plus\": ModelPricing(0.4, 1.2, 0.0, 0.1),\n \"qwen-turbo\": ModelPricing(0.05, 0.2, 0.0, 0.0125),\n \"glm-4.7\": ModelPricing(0.6, 2.2, 0.0, 0.0),\n}\n```\n\n- [ ] **Step 5: Run test to verify it passes**\n\nRun: `.venv/bin/python -m pytest tests/unit/pricing/test_builtin.py -v`\nExpected: PASS (3 tests)\n\n- [ ] **Step 6: Commit**\n\n```bash\ngit add src/janus/pricing/builtin.py tests/unit/pricing/\ngit commit -m \"feat: add builtin pricing table for popular models\"\n```\n\n---\n\n## Task 3: Pricing Registry\n\n**Files:**\n- Create: `src/janus/pricing/registry.py`\n- Test: `tests/unit/pricing/test_registry.py`\n\n- [ ] **Step 1: Write the failing test**\n\nCreate `tests/unit/pricing/test_registry.py`:\n\n```python\nfrom janus.pricing.models import ModelPricing\nfrom janus.pricing.registry import PricingRegistry\n\n\ndef test_exact_match():\n overrides: dict[str, dict[str, float]] = {}\n reg = PricingRegistry(overrides)\n p = reg.get(\"gpt-4o\")\n assert p is not None\n assert p.input_per_mtok == 2.5\n assert p.output_per_mtok == 10.0\n\n\ndef test_user_override_replaces_builtin():\n overrides = {\n \"gpt-4o\": {\n \"input_per_mtok\": 5.0,\n \"output_per_mtok\": 20.0,\n \"cache_creation_per_mtok\": 0.0,\n \"cache_read_per_mtok\": 2.5,\n }\n }\n reg = PricingRegistry(overrides)\n p = reg.get(\"gpt-4o\")\n assert p is not None\n assert p.input_per_mtok == 5.0\n assert p.output_per_mtok == 20.0\n\n\ndef test_user_override_adds_new_model():\n overrides = {\n \"my-custom-model\": {\n \"input_per_mtok\": 1.0,\n \"output_per_mtok\": 2.0,\n \"cache_creation_per_mtok\": 0.0,\n \"cache_read_per_mtok\": 0.0,\n }\n }\n reg = PricingRegistry(overrides)\n p = reg.get(\"my-custom-model\")\n assert p is not None\n assert p.input_per_mtok == 1.0\n\n\ndef test_unknown_model_returns_none():\n reg = PricingRegistry({})\n assert reg.get(\"does-not-exist\") is None\n\n\ndef test_prefix_match_strips_date_suffix():\n reg = PricingRegistry({})\n p = reg.get(\"claude-sonnet-4-20250514-some-alias\")\n assert p is not None\n assert p.input_per_mtok == 3.0\n\n\ndef test_prefix_match_strips_latest():\n reg = PricingRegistry({})\n p = reg.get(\"gpt-4o-latest\")\n assert p is not None\n assert p.output_per_mtok == 10.0\n\n\ndef test_get_all_returns_merged_table():\n overrides = {\n \"custom\": {\n \"input_per_mtok\": 1.0,\n \"output_per_mtok\": 2.0,\n \"cache_creation_per_mtok\": 0.0,\n \"cache_read_per_mtok\": 0.0,\n }\n }\n reg = PricingRegistry(overrides)\n all_pricing = reg.get_all()\n assert \"gpt-4o\" in all_pricing\n assert \"custom\" in all_pricing\n assert isinstance(all_pricing[\"custom\"], ModelPricing)\n```\n\n- [ ] **Step 2: Run test to verify it fails**\n\nRun: `.venv/bin/python -m pytest tests/unit/pricing/test_registry.py -v`\nExpected: FAIL with `ModuleNotFoundError: No module named 'janus.pricing.registry'`\n\n- [ ] **Step 3: Write the registry implementation**\n\nCreate `src/janus/pricing/registry.py`:\n\n```python\nfrom __future__ import annotations\n\nfrom .builtin import BUILTIN_PRICING\nfrom .models import ModelPricing\n\n\nclass PricingRegistry:\n def __init__(self, user_overrides: dict[str, dict[str, float]]) -> None:\n self._table: dict[str, ModelPricing] = {**BUILTIN_PRICING}\n for model, rates in user_overrides.items():\n self._table[model] = ModelPricing(**rates)\n\n def get(self, model: str) -> ModelPricing | None:\n if model in self._table:\n return self._table[model]\n parts = model.split(\"-\")\n for i in range(len(parts) - 1, 0, -1):\n candidate = \"-\".join(parts[:i])\n if candidate in self._table:\n return self._table[candidate]\n return None\n\n def get_all(self) -> dict[str, ModelPricing]:\n return dict(self._table)\n```\n\n- [ ] **Step 4: Run test to verify it passes**\n\nRun: `.venv/bin/python -m pytest tests/unit/pricing/test_registry.py -v`\nExpected: PASS (7 tests)\n\n- [ ] **Step 5: Commit**\n\n```bash\ngit add src/janus/pricing/registry.py tests/unit/pricing/test_registry.py\ngit commit -m \"feat: add pricing registry with builtin + override merge\"\n```\n\n---\n\n## Task 4: Cost Calculator\n\n**Files:**\n- Create: `src/janus/pricing/calculator.py`\n- Test: `tests/unit/pricing/test_calculator.py`\n\n- [ ] **Step 1: Write the failing test**\n\nCreate `tests/unit/pricing/test_calculator.py`:\n\n```python\nfrom janus.canonical.models import Usage\nfrom janus.pricing.calculator import compute_cost\nfrom janus.pricing.registry import PricingRegistry\n\n\ndef test_basic_cost():\n overrides = {\n \"test-model\": {\n \"input_per_mtok\": 3.0,\n \"output_per_mtok\": 15.0,\n \"cache_creation_per_mtok\": 3.75,\n \"cache_read_per_mtok\": 0.3,\n }\n }\n reg = PricingRegistry(overrides)\n usage = Usage(input_tokens=1_000_000, output_tokens=500_000)\n cost = compute_cost(usage, \"test-model\", reg)\n assert cost == 3.0 + 7.5\n\n\ndef test_zero_tokens():\n reg = PricingRegistry({})\n usage = Usage()\n cost = compute_cost(usage, \"gpt-4o\", reg)\n assert cost == 0.0\n\n\ndef test_unknown_model():\n reg = PricingRegistry({})\n usage = Usage(input_tokens=1000, output_tokens=1000)\n cost = compute_cost(usage, \"totally-unknown\", reg)\n assert cost == 0.0\n\n\ndef test_cache_token_cost():\n overrides = {\n \"test-model\": {\n \"input_per_mtok\": 3.0,\n \"output_per_mtok\": 15.0,\n \"cache_creation_per_mtok\": 3.75,\n \"cache_read_per_mtok\": 0.3,\n }\n }\n reg = PricingRegistry(overrides)\n usage = Usage(\n input_tokens=1_000_000,\n output_tokens=0,\n cache_creation_input_tokens=500_000,\n cache_read_input_tokens=2_000_000,\n )\n cost = compute_cost(usage, \"test-model\", reg)\n assert abs(cost - (3.0 + 1.875 + 0.6)) < 0.0001\n\n\ndef test_partial_cache_only():\n overrides = {\n \"test-model\": {\n \"input_per_mtok\": 1.0,\n \"output_per_mtok\": 1.0,\n \"cache_creation_per_mtok\": 1.0,\n \"cache_read_per_mtok\": 1.0,\n }\n }\n reg = PricingRegistry(overrides)\n usage = Usage(input_tokens=100, cache_read_input_tokens=200)\n cost = compute_cost(usage, \"test-model\", reg)\n expected = (100 / 1_000_000 * 1.0) + (200 / 1_000_000 * 1.0)\n assert abs(cost - expected) < 0.0001\n```\n\n- [ ] **Step 2: Run test to verify it fails**\n\nRun: `.venv/bin/python -m pytest tests/unit/pricing/test_calculator.py -v`\nExpected: FAIL with `ModuleNotFoundError: No module named 'janus.pricing.calculator'`\n\n- [ ] **Step 3: Write the calculator implementation**\n\nCreate `src/janus/pricing/calculator.py`:\n\n```python\nfrom __future__ import annotations\n\nfrom janus.canonical.models import Usage\n\nfrom .registry import PricingRegistry\n\n\ndef compute_cost(usage: Usage, model: str, registry: PricingRegistry) -> float:\n pricing = registry.get(model)\n if pricing is None:\n return 0.0\n return (\n usage.input_tokens / 1_000_000 * pricing.input_per_mtok\n + usage.output_tokens / 1_000_000 * pricing.output_per_mtok\n + usage.cache_creation_input_tokens / 1_000_000 * pricing.cache_creation_per_mtok\n + usage.cache_read_input_tokens / 1_000_000 * pricing.cache_read_per_mtok\n )\n```\n\n- [ ] **Step 4: Run test to verify it passes**\n\nRun: `.venv/bin/python -m pytest tests/unit/pricing/test_calculator.py -v`\nExpected: PASS (5 tests)\n\n- [ ] **Step 5: Commit**\n\n```bash\ngit add src/janus/pricing/calculator.py tests/unit/pricing/test_calculator.py\ngit commit -m \"feat: add cost calculator with cache token pricing\"\n```\n\n---\n\n## Task 5: Config Schema — Add Pricing Field\n\n**Files:**\n- Modify: `src/janus/config/schema.py:40-45`\n\n- [ ] **Step 1: Write the failing test**\n\nAdd to a new file `tests/unit/config/test_pricing_config.py`:\n\n```python\nfrom janus.config.schema import JanusConfig\n\n\ndef test_pricing_defaults_empty():\n cfg = JanusConfig()\n assert cfg.pricing == {}\n\n\ndef test_pricing_accepts_overrides():\n cfg = JanusConfig(\n pricing={\n \"custom-model\": {\n \"input_per_mtok\": 1.0,\n \"output_per_mtok\": 2.0,\n \"cache_creation_per_mtok\": 0.5,\n \"cache_read_per_mtok\": 0.1,\n }\n }\n )\n assert \"custom-model\" in cfg.pricing\n assert cfg.pricing[\"custom-model\"][\"input_per_mtok\"] == 1.0\n```\n\n- [ ] **Step 2: Run test to verify it fails**\n\nRun: `.venv/bin/python -m pytest tests/unit/config/test_pricing_config.py -v`\nExpected: FAIL with `AttributeError: ... object has no attribute 'pricing'` or similar\n\n- [ ] **Step 3: Add pricing field to JanusConfig**\n\nIn `src/janus/config/schema.py`, add the `pricing` field to the `JanusConfig` class:\n\n```python\nclass JanusConfig(BaseModel):\n server: ServerSettings = Field(default_factory=ServerSettings)\n providers: list[ProviderConfig] = Field(default_factory=list)\n combos: list[ComboConfig] = Field(default_factory=list)\n api_keys: list[str] = Field(default_factory=list)\n token_savers: TokenSaverConfig = Field(default_factory=TokenSaverConfig)\n pricing: dict[str, dict[str, float]] = Field(default_factory=dict)\n```\n\n- [ ] **Step 4: Run test to verify it passes**\n\nRun: `.venv/bin/python -m pytest tests/unit/config/test_pricing_config.py -v`\nExpected: PASS (2 tests)\n\n- [ ] **Step 5: Commit**\n\n```bash\ngit add src/janus/config/schema.py tests/unit/config/test_pricing_config.py\ngit commit -m \"feat: add pricing config field\"\n```\n\n---\n\n## Task 6: DB Schema Migration — New Columns + Budgets Table\n\n**Files:**\n- Modify: `src/janus/storage/database.py`\n- Test: `tests/unit/storage/test_migration.py`\n\n- [ ] **Step 1: Write the failing test**\n\nCreate `tests/unit/storage/test_migration.py`:\n\n```python\nimport aiosqlite\nimport pytest\n\nfrom janus.storage.database import init_db\n\n\n@pytest.mark.asyncio\nasync def test_usage_table_has_new_columns(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n async with aiosqlite.connect(str(db_path)) as db:\n async with db.execute(\"PRAGMA table_info(usage)\") as cur:\n rows = await cur.fetchall()\n columns = {row[1] for row in rows}\n assert \"cost\" in columns\n assert \"cache_creation_tokens\" in columns\n assert \"cache_read_tokens\" in columns\n assert \"client_key_id\" in columns\n\n\n@pytest.mark.asyncio\nasync def test_budgets_table_exists(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n async with aiosqlite.connect(str(db_path)) as db:\n async with db.execute(\"PRAGMA table_info(budgets)\") as cur:\n rows = await cur.fetchall()\n columns = {row[1] for row in rows}\n assert \"id\" in columns\n assert \"key_id\" in columns\n assert \"daily_limit\" in columns\n assert \"warn_pct\" in columns\n assert \"is_active\" in columns\n assert \"created_at\" in columns\n\n\n@pytest.mark.asyncio\nasync def test_migration_is_idempotent(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await init_db(db_path)\n async with aiosqlite.connect(str(db_path)) as db:\n async with db.execute(\"PRAGMA table_info(usage)\") as cur:\n rows = await cur.fetchall()\n column_names = [row[1] for row in rows]\n assert column_names.count(\"cost\") == 1\n assert column_names.count(\"cache_creation_tokens\") == 1\n```\n\n- [ ] **Step 2: Run test to verify it fails**\n\nRun: `.venv/bin/python -m pytest tests/unit/storage/test_migration.py -v`\nExpected: FAIL (columns not found, budgets table not found)\n\n- [ ] **Step 3: Update database.py with migration**\n\nReplace the entire contents of `src/janus/storage/database.py`:\n\n```python\nfrom __future__ import annotations\n\nfrom collections.abc import AsyncIterator\nfrom contextlib import asynccontextmanager\nfrom pathlib import Path\n\nimport aiosqlite\n\n_SCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS api_keys (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n name TEXT NOT NULL,\n key_hash TEXT NOT NULL UNIQUE,\n prefix TEXT NOT NULL,\n is_active INTEGER NOT NULL DEFAULT 1,\n created_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\nCREATE TABLE IF NOT EXISTS usage (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n timestamp TEXT NOT NULL DEFAULT (datetime('now')),\n provider_id TEXT,\n model TEXT,\n account_id TEXT,\n input_tokens INTEGER DEFAULT 0,\n output_tokens INTEGER DEFAULT 0,\n status INTEGER\n);\n\nCREATE TABLE IF NOT EXISTS budgets (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n key_id INTEGER,\n daily_limit REAL NOT NULL,\n warn_pct REAL DEFAULT 80,\n is_active INTEGER NOT NULL DEFAULT 1,\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n FOREIGN KEY (key_id) REFERENCES api_keys(id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_usage_model ON usage(model);\nCREATE INDEX IF NOT EXISTS idx_usage_ts ON usage(timestamp);\nCREATE INDEX IF NOT EXISTS idx_usage_cost_key ON usage(client_key_id, date(timestamp));\nCREATE INDEX IF NOT EXISTS idx_usage_provider ON usage(provider_id);\n\"\"\"\n\n_NEW_USAGE_COLUMNS = [\n (\"cost\", \"REAL DEFAULT 0.0\"),\n (\"cache_creation_tokens\", \"INTEGER DEFAULT 0\"),\n (\"cache_read_tokens\", \"INTEGER DEFAULT 0\"),\n (\"client_key_id\", \"INTEGER\"),\n]\n\n\nasync def _migrate_usage_columns(db: aiosqlite.Connection) -> None:\n cursor = await db.execute(\"PRAGMA table_info(usage)\")\n rows = await cursor.fetchall()\n existing = {row[1] for row in rows}\n for col, col_type in _NEW_USAGE_COLUMNS:\n if col not in existing:\n await db.execute(f\"ALTER TABLE usage ADD COLUMN {col} {col_type}\")\n\n\nasync def init_db(db_path: str | Path) -> None:\n db_path = Path(db_path)\n db_path.parent.mkdir(parents=True, exist_ok=True)\n async with aiosqlite.connect(str(db_path)) as db:\n await db.executescript(_SCHEMA)\n await _migrate_usage_columns(db)\n await db.commit()\n\n\n@asynccontextmanager\nasync def get_connection(db_path: str | Path) -> AsyncIterator[aiosqlite.Connection]:\n async with aiosqlite.connect(str(db_path)) as db:\n db.row_factory = aiosqlite.Row\n yield db\n```\n\n- [ ] **Step 4: Run test to verify it passes**\n\nRun: `.venv/bin/python -m pytest tests/unit/storage/test_migration.py -v`\nExpected: PASS (3 tests)\n\n- [ ] **Step 5: Run existing storage tests to ensure no regression**\n\nRun: `.venv/bin/python -m pytest tests/unit/storage/ -v`\nExpected: All pass\n\n- [ ] **Step 6: Commit**\n\n```bash\ngit add src/janus/storage/database.py tests/unit/storage/test_migration.py\ngit commit -m \"feat: add DB migration for cost, cache tokens, client_key_id, budgets table\"\n```\n\n---\n\n## Task 7: Update record_usage() with New Parameters\n\n**Files:**\n- Modify: `src/janus/storage/usage.py`\n- Modify: `tests/unit/storage/test_usage.py`\n\n- [ ] **Step 1: Update existing tests and add new ones**\n\nReplace `tests/unit/storage/test_usage.py`:\n\n```python\nimport pytest\n\nfrom janus.storage.database import init_db\nfrom janus.storage.usage import get_usage_stats, record_usage\n\n\n@pytest.mark.asyncio\nasync def test_record_usage(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await record_usage(\n db_path,\n provider_id=\"glm\",\n model=\"glm-4.7\",\n input_tokens=100,\n output_tokens=50,\n status=200,\n )\n stats = await get_usage_stats(db_path)\n assert stats[\"total_requests\"] == 1\n assert stats[\"total_input_tokens\"] == 100\n assert stats[\"total_output_tokens\"] == 50\n\n\n@pytest.mark.asyncio\nasync def test_record_multiple_usage(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await record_usage(\n db_path,\n provider_id=\"glm\",\n model=\"glm-4.7\",\n input_tokens=100,\n output_tokens=50,\n status=200,\n )\n await record_usage(\n db_path,\n provider_id=\"an\",\n model=\"claude\",\n input_tokens=200,\n output_tokens=100,\n status=200,\n )\n await record_usage(\n db_path,\n provider_id=\"glm\",\n model=\"glm-4.7\",\n input_tokens=50,\n output_tokens=25,\n status=429,\n )\n stats = await get_usage_stats(db_path)\n assert stats[\"total_requests\"] == 3\n assert stats[\"total_input_tokens\"] == 350\n assert stats[\"total_output_tokens\"] == 175\n\n\n@pytest.mark.asyncio\nasync def test_usage_stats_by_model(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await record_usage(\n db_path,\n provider_id=\"glm\",\n model=\"glm-4.7\",\n input_tokens=100,\n output_tokens=50,\n status=200,\n )\n await record_usage(\n db_path,\n provider_id=\"glm\",\n model=\"glm-4.7\",\n input_tokens=200,\n output_tokens=100,\n status=200,\n )\n await record_usage(\n db_path,\n provider_id=\"an\",\n model=\"claude\",\n input_tokens=50,\n output_tokens=25,\n status=200,\n )\n stats = await get_usage_stats(db_path)\n by_model = {m[\"model\"]: m for m in stats[\"by_model\"]}\n assert by_model[\"glm-4.7\"][\"requests\"] == 2\n assert by_model[\"glm-4.7\"][\"input_tokens\"] == 300\n assert by_model[\"claude\"][\"requests\"] == 1\n\n\n@pytest.mark.asyncio\nasync def test_record_usage_with_cost_and_cache(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await record_usage(\n db_path,\n provider_id=\"an\",\n model=\"claude-sonnet-4-20250514\",\n account_id=\"an-0\",\n input_tokens=1000,\n output_tokens=500,\n cache_creation_tokens=200,\n cache_read_tokens=800,\n status=200,\n client_key_id=1,\n cost=0.015,\n )\n stats = await get_usage_stats(db_path)\n assert stats[\"total_requests\"] == 1\n\n\n@pytest.mark.asyncio\nasync def test_record_usage_defaults_backward_compatible(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await record_usage(\n db_path,\n provider_id=\"glm\",\n model=\"glm-4.7\",\n input_tokens=100,\n output_tokens=50,\n status=200,\n )\n stats = await get_usage_stats(db_path)\n assert stats[\"total_requests\"] == 1\n```\n\n- [ ] **Step 2: Run tests to verify new tests fail**\n\nRun: `.venv/bin/python -m pytest tests/unit/storage/test_usage.py -v`\nExpected: FAIL (cost/cache params not accepted)\n\n- [ ] **Step 3: Update record_usage()**\n\nReplace `src/janus/storage/usage.py`:\n\n```python\nfrom __future__ import annotations\n\nimport logging\nfrom pathlib import Path\nfrom typing import Any\n\nfrom .database import get_connection\n\nlogger = logging.getLogger(__name__)\n\n\nasync def record_usage(\n db_path: str | Path,\n *,\n provider_id: str | None = None,\n model: str | None = None,\n account_id: str | None = None,\n input_tokens: int = 0,\n output_tokens: int = 0,\n cache_creation_tokens: int = 0,\n cache_read_tokens: int = 0,\n status: int = 0,\n client_key_id: int | None = None,\n cost: float = 0.0,\n) -> None:\n try:\n async with get_connection(db_path) as db:\n await db.execute(\n \"\"\"INSERT INTO usage\n (provider_id, model, account_id, input_tokens, output_tokens,\n cache_creation_tokens, cache_read_tokens, status, client_key_id, cost)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\"\"\",\n (\n provider_id,\n model,\n account_id,\n input_tokens,\n output_tokens,\n cache_creation_tokens,\n cache_read_tokens,\n status,\n client_key_id,\n cost,\n ),\n )\n await db.commit()\n except Exception as e:\n logger.warning(\"Failed to record usage: %s\", e)\n\n\nasync def get_usage_stats(db_path: str | Path) -> dict[str, Any]:\n async with get_connection(db_path) as db:\n async with db.execute(\n \"SELECT COUNT(*) as cnt, COALESCE(SUM(input_tokens),0) as inp,\"\n \"COALESCE(SUM(output_tokens),0) as outp FROM usage\"\n ) as cur:\n row = await cur.fetchone()\n assert row is not None\n total_requests = row[\"cnt\"]\n total_input = row[\"inp\"]\n total_output = row[\"outp\"]\n\n async with db.execute(\n \"\"\"SELECT model, COUNT(*) as requests,\n COALESCE(SUM(input_tokens),0) as input_tokens,\n COALESCE(SUM(output_tokens),0) as output_tokens\n FROM usage GROUP BY model ORDER BY requests DESC\"\"\"\n ) as cur:\n model_rows = await cur.fetchall()\n\n return {\n \"total_requests\": total_requests,\n \"total_input_tokens\": total_input,\n \"total_output_tokens\": total_output,\n \"by_model\": [dict(r) for r in model_rows],\n }\n```\n\n- [ ] **Step 4: Run tests to verify they pass**\n\nRun: `.venv/bin/python -m pytest tests/unit/storage/test_usage.py -v`\nExpected: PASS (5 tests)\n\n- [ ] **Step 5: Commit**\n\n```bash\ngit add src/janus/storage/usage.py tests/unit/storage/test_usage.py\ngit commit -m \"feat: update record_usage with cost, cache tokens, client_key_id\"\n```\n\n---\n\n## Task 8: Update verify_key() to Return key_id\n\n**Files:**\n- Modify: `src/janus/storage/api_keys.py:30-40`\n- Test: `tests/unit/storage/test_api_keys.py`\n\n- [ ] **Step 1: Write the failing test**\n\nAdd to `tests/unit/storage/test_api_keys.py` (create if it does not exist, or append):\n\n```python\nimport pytest\n\nfrom janus.storage.api_keys import create_key, verify_key\nfrom janus.storage.database import init_db\n\n\n@pytest.mark.asyncio\nasync def test_verify_key_returns_id(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n raw_key, record = await create_key(db_path, name=\"test\")\n result = await verify_key(db_path, raw_key)\n assert result == record[\"id\"]\n\n\n@pytest.mark.asyncio\nasync def test_verify_key_returns_none_for_invalid(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n result = await verify_key(db_path, \"sk-janus-deadbeef\")\n assert result is None\n\n\n@pytest.mark.asyncio\nasync def test_verify_revoked_key_returns_none(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n raw_key, record = await create_key(db_path, name=\"test\")\n from janus.storage.api_keys import revoke_key\n await revoke_key(db_path, record[\"id\"])\n result = await verify_key(db_path, raw_key)\n assert result is None\n```\n\n- [ ] **Step 2: Run test to verify it fails**\n\nRun: `.venv/bin/python -m pytest tests/unit/storage/test_api_keys.py -v`\nExpected: FAIL (verify_key returns bool, not int)\n\n- [ ] **Step 3: Update verify_key()**\n\nIn `src/janus/storage/api_keys.py`, replace the `verify_key` function:\n\n```python\nasync def verify_key(db_path: str | Path, key: str) -> int | None:\n if not key.startswith(\"sk-janus-\"):\n return None\n key_hash = _hash_key(key)\n async with get_connection(db_path) as db:\n async with db.execute(\n \"SELECT id FROM api_keys WHERE key_hash = ? AND is_active = 1\",\n (key_hash,),\n ) as cur:\n row = await cur.fetchone()\n if row is None:\n return None\n return row[\"id\"]\n```\n\n- [ ] **Step 4: Run test to verify it passes**\n\nRun: `.venv/bin/python -m pytest tests/unit/storage/test_api_keys.py -v`\nExpected: PASS\n\n- [ ] **Step 5: Commit**\n\n```bash\ngit add src/janus/storage/api_keys.py tests/unit/storage/test_api_keys.py\ngit commit -m \"feat: verify_key returns key_id instead of bool\"\n```\n\n---\n\n## Task 9: Update Auth Dependency to Expose key_id\n\n**Files:**\n- Modify: `src/janus/api/deps.py`\n\n- [ ] **Step 1: Write the failing integration test**\n\nCreate `tests/integration/test_key_id_passthrough.py`:\n\n```python\nimport pytest\nfrom httpx import ASGITransport, AsyncClient\n\nfrom janus.app import create_app\nfrom janus.config.schema import JanusConfig, ProviderConfig, ServerSettings\nfrom janus.providers.registry import ProviderRegistry\nfrom janus.storage.api_keys import create_key\nfrom janus.storage.database import init_db\n\n\n@pytest.mark.asyncio\nasync def test_request_with_db_key_stores_key_id(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n\n registry = ProviderRegistry()\n config = JanusConfig(\n server=ServerSettings(require_api_key=True, data_dir=tmp_path),\n )\n app = create_app(registry=registry, config=config)\n app.state.db_path = db_path\n\n raw_key, record = await create_key(db_path, name=\"test\")\n\n transport = ASGITransport(app=app)\n async with AsyncClient(transport=transport, base_url=\"http://test\") as client:\n await client.post(\n \"/v1/chat/completions\",\n json={\"model\": \"nonexistent\", \"messages\": [{\"role\": \"user\", \"content\": \"hi\"}]},\n headers={\"Authorization\": f\"Bearer {raw_key}\"},\n )\n\n import aiosqlite\n async with aiosqlite.connect(str(db_path)) as db:\n db.row_factory = aiosqlite.Row\n async with db.execute(\"SELECT client_key_id FROM usage\") as cur:\n rows = await cur.fetchall()\n if rows:\n assert rows[0][\"client_key_id\"] == record[\"id\"]\n```\n\n- [ ] **Step 2: Run test to verify it fails or has no key_id stored**\n\nRun: `.venv/bin/python -m pytest tests/integration/test_key_id_passthrough.py -v`\nExpected: FAIL or client_key_id is NULL (auth dependency doesn't pass key_id)\n\n- [ ] **Step 3: Update deps.py to expose key_id**\n\nReplace `src/janus/api/deps.py`:\n\n```python\nfrom __future__ import annotations\n\nfrom fastapi import Header, HTTPException, Request\n\n\nasync def require_api_key(request: Request, authorization: str = Header(default=\"\")) -> None:\n config = request.app.state.config\n if not config.server.require_api_key:\n return\n if authorization.startswith(\"Bearer \"):\n key = authorization[7:]\n if key in config.api_keys:\n return\n from janus.storage.api_keys import verify_key\n\n db_path = request.app.state.db_path\n key_id = await verify_key(db_path, key)\n if key_id is not None:\n request.state.client_key_id = key_id\n return\n raise HTTPException(status_code=401, detail=\"Invalid API key\")\n```\n\n- [ ] **Step 4: Commit (will wire this into routes in Task 12)**\n\n```bash\ngit add src/janus/api/deps.py tests/integration/test_key_id_passthrough.py\ngit commit -m \"feat: auth dependency exposes resolved key_id on request state\"\n```\n\n---\n\n## Task 10: Analytics Query Layer\n\n**Files:**\n- Create: `src/janus/storage/analytics.py`\n- Create: `tests/unit/storage/test_analytics.py`\n- Create: `tests/fixtures/__init__.py`\n- Create: `tests/fixtures/usage_seed.py`\n\n- [ ] **Step 1: Create fixtures package**\n\nCreate `tests/fixtures/__init__.py` as an empty file.\n\n- [ ] **Step 2: Create usage seed helper**\n\nCreate `tests/fixtures/usage_seed.py`:\n\n```python\nfrom __future__ import annotations\n\nimport datetime\nfrom pathlib import Path\n\nfrom janus.storage.database import get_connection\n\n\nasync def seed_usage(\n db_path: str | Path,\n rows: list[dict],\n) -> None:\n for row in rows:\n ts = row.get(\"timestamp\", datetime.datetime.now().isoformat())\n async with get_connection(db_path) as db:\n await db.execute(\n \"\"\"INSERT INTO usage\n (timestamp, provider_id, model, account_id,\n input_tokens, output_tokens, cache_creation_tokens,\n cache_read_tokens, status, client_key_id, cost)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\"\"\",\n (\n ts,\n row.get(\"provider_id\"),\n row.get(\"model\"),\n row.get(\"account_id\"),\n row.get(\"input_tokens\", 0),\n row.get(\"output_tokens\", 0),\n row.get(\"cache_creation_tokens\", 0),\n row.get(\"cache_read_tokens\", 0),\n row.get(\"status\", 200),\n row.get(\"client_key_id\"),\n row.get(\"cost\", 0.0),\n ),\n )\n await db.commit()\n```\n\n- [ ] **Step 3: Write the failing tests**\n\nCreate `tests/unit/storage/test_analytics.py`:\n\n```python\nimport datetime\n\nimport pytest\n\nfrom janus.storage.analytics import (\n get_breakdown,\n get_spend_summary,\n get_success_rate,\n)\nfrom janus.storage.database import init_db\nfrom tests.fixtures.usage_seed import seed_usage\n\n\ndef _ts(days_ago: int) -> str:\n return (datetime.datetime.now() - datetime.timedelta(days=days_ago)).isoformat()\n\n\n@pytest.mark.asyncio\nasync def test_get_spend_summary_empty(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n result = await get_spend_summary(db_path, days=30)\n assert result[\"total_cost\"] == 0.0\n assert result[\"total_requests\"] == 0\n assert result[\"daily\"] == []\n\n\n@pytest.mark.asyncio\nasync def test_get_spend_summary_with_data(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await seed_usage(\n db_path,\n [\n {\"timestamp\": _ts(0), \"model\": \"gpt-4o\", \"input_tokens\": 1000, \"output_tokens\": 500, \"cost\": 0.01, \"status\": 200},\n {\"timestamp\": _ts(1), \"model\": \"gpt-4o\", \"input_tokens\": 2000, \"output_tokens\": 1000, \"cost\": 0.02, \"status\": 200},\n {\"timestamp\": _ts(0), \"model\": \"claude-sonnet-4-20250514\", \"input_tokens\": 500, \"output_tokens\": 250, \"cost\": 0.005, \"status\": 500},\n ],\n )\n result = await get_spend_summary(db_path, days=30)\n assert result[\"total_requests\"] == 3\n assert abs(result[\"total_cost\"] - 0.035) < 0.0001\n assert result[\"total_input_tokens\"] == 3500\n assert result[\"total_output_tokens\"] == 1750\n assert len(result[\"daily\"]) >= 1\n\n\n@pytest.mark.asyncio\nasync def test_get_breakdown_by_model(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await seed_usage(\n db_path,\n [\n {\"timestamp\": _ts(0), \"model\": \"gpt-4o\", \"input_tokens\": 1000, \"output_tokens\": 500, \"cost\": 0.01, \"status\": 200},\n {\"timestamp\": _ts(0), \"model\": \"gpt-4o\", \"input_tokens\": 500, \"output_tokens\": 250, \"cost\": 0.005, \"status\": 200},\n {\"timestamp\": _ts(0), \"model\": \"claude\", \"input_tokens\": 300, \"output_tokens\": 100, \"cost\": 0.003, \"status\": 200},\n ],\n )\n result = await get_breakdown(db_path, dimension=\"model\", days=30)\n assert len(result) == 2\n gpt = [r for r in result if r[\"model\"] == \"gpt-4o\"][0]\n assert gpt[\"requests\"] == 2\n assert abs(gpt[\"cost\"] - 0.015) < 0.0001\n\n\n@pytest.mark.asyncio\nasync def test_get_breakdown_by_provider(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await seed_usage(\n db_path,\n [\n {\"timestamp\": _ts(0), \"provider_id\": \"openai\", \"model\": \"gpt-4o\", \"cost\": 0.01, \"status\": 200},\n {\"timestamp\": _ts(0), \"provider_id\": \"anthropic\", \"model\": \"claude\", \"cost\": 0.02, \"status\": 200},\n ],\n )\n result = await get_breakdown(db_path, dimension=\"provider\", days=30)\n assert len(result) == 2\n\n\n@pytest.mark.asyncio\nasync def test_get_success_rate(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await seed_usage(\n db_path,\n [\n {\"timestamp\": _ts(0), \"model\": \"gpt-4o\", \"status\": 200},\n {\"timestamp\": _ts(0), \"model\": \"gpt-4o\", \"status\": 200},\n {\"timestamp\": _ts(0), \"model\": \"gpt-4o\", \"status\": 500},\n {\"timestamp\": _ts(0), \"model\": \"gpt-4o\", \"status\": 429},\n ],\n )\n result = await get_success_rate(db_path, days=30)\n assert result[\"success_2xx\"] == 2\n assert result[\"client_4xx\"] == 1\n assert result[\"server_5xx\"] == 1\n assert result[\"total\"] == 4\n```\n\n- [ ] **Step 4: Run tests to verify they fail**\n\nRun: `.venv/bin/python -m pytest tests/unit/storage/test_analytics.py -v`\nExpected: FAIL (ModuleNotFoundError)\n\n- [ ] **Step 5: Write the analytics implementation**\n\nCreate `src/janus/storage/analytics.py`:\n\n```python\nfrom __future__ import annotations\n\nfrom pathlib import Path\nfrom typing import Any, Literal\n\nfrom .database import get_connection\n\nDimension = Literal[\"model\", \"provider\", \"account\", \"client_key\"]\n\n_DIMENSION_COLUMN = {\n \"model\": \"model\",\n \"provider\": \"provider_id\",\n \"account\": \"account_id\",\n \"client_key\": \"client_key_id\",\n}\n\n\nasync def get_spend_summary(db_path: str | Path, *, days: int = 30) -> dict[str, Any]:\n async with get_connection(db_path) as db:\n async with db.execute(\n \"\"\"SELECT COUNT(*) as cnt,\n COALESCE(SUM(input_tokens), 0) as inp,\n COALESCE(SUM(output_tokens), 0) as outp,\n COALESCE(SUM(cache_creation_tokens), 0) as cc,\n COALESCE(SUM(cache_read_tokens), 0) as cr,\n COALESCE(SUM(cost), 0.0) as cost\n FROM usage\n WHERE timestamp >= datetime('now', ?)\"\"\",\n (f\"-{days} days\",),\n ) as cur:\n row = await cur.fetchone()\n assert row is not None\n\n async with db.execute(\n \"\"\"SELECT date(timestamp) as date,\n COUNT(*) as requests,\n COALESCE(SUM(cost), 0.0) as cost\n FROM usage\n WHERE timestamp >= datetime('now', ?)\n GROUP BY date(timestamp)\n ORDER BY date(timestamp)\"\"\",\n (f\"-{days} days\",),\n ) as cur:\n daily_rows = await cur.fetchall()\n\n return {\n \"total_cost\": row[\"cost\"],\n \"total_requests\": row[\"cnt\"],\n \"total_input_tokens\": row[\"inp\"],\n \"total_output_tokens\": row[\"outp\"],\n \"total_cache_creation_tokens\": row[\"cc\"],\n \"total_cache_read_tokens\": row[\"cr\"],\n \"daily\": [dict(r) for r in daily_rows],\n }\n\n\nasync def get_breakdown(\n db_path: str | Path, *, dimension: Dimension, days: int = 30\n) -> list[dict[str, Any]]:\n col = _DIMENSION_COLUMN[dimension]\n async with get_connection(db_path) as db:\n async with db.execute(\n f\"\"\"SELECT {col} as {dimension},\n COUNT(*) as requests,\n COALESCE(SUM(input_tokens), 0) as input_tokens,\n COALESCE(SUM(output_tokens), 0) as output_tokens,\n COALESCE(SUM(cache_creation_tokens), 0) as cache_creation_tokens,\n COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens,\n COALESCE(SUM(cost), 0.0) as cost\n FROM usage\n WHERE timestamp >= datetime('now', ?)\n GROUP BY {col}\n ORDER BY cost DESC\"\"\",\n (f\"-{days} days\",),\n ) as cur:\n rows = await cur.fetchall()\n return [dict(r) for r in rows]\n\n\nasync def get_success_rate(db_path: str | Path, *, days: int = 30) -> dict[str, Any]:\n async with get_connection(db_path) as db:\n async with db.execute(\n \"\"\"SELECT\n SUM(CASE WHEN status >= 200 AND status < 300 THEN 1 ELSE 0 END) as s2xx,\n SUM(CASE WHEN status >= 400 AND status < 500 THEN 1 ELSE 0 END) as s4xx,\n SUM(CASE WHEN status >= 500 THEN 1 ELSE 0 END) as s5xx,\n COUNT(*) as total\n FROM usage\n WHERE timestamp >= datetime('now', ?)\"\"\",\n (f\"-{days} days\",),\n ) as cur:\n row = await cur.fetchone()\n assert row is not None\n return {\n \"success_2xx\": row[\"s2xx\"] or 0,\n \"client_4xx\": row[\"s4xx\"] or 0,\n \"server_5xx\": row[\"s5xx\"] or 0,\n \"total\": row[\"total\"] or 0,\n }\n```\n\n- [ ] **Step 6: Run tests to verify they pass**\n\nRun: `.venv/bin/python -m pytest tests/unit/storage/test_analytics.py -v`\nExpected: PASS (5 tests)\n\n- [ ] **Step 7: Commit**\n\n```bash\ngit add src/janus/storage/analytics.py tests/unit/storage/test_analytics.py tests/fixtures/\ngit commit -m \"feat: add analytics query layer (spend summary, breakdown, success rate)\"\n```\n\n---\n\n## Task 11: Budget CRUD + Status\n\n**Files:**\n- Create: `src/janus/storage/budgets.py`\n- Create: `tests/unit/storage/test_budgets.py`\n\n- [ ] **Step 1: Write the failing tests**\n\nCreate `tests/unit/storage/test_budgets.py`:\n\n```python\nimport datetime\n\nimport pytest\n\nfrom janus.storage.budgets import (\n create_or_update_budget,\n delete_budget,\n get_budget_status,\n get_budgets,\n)\nfrom janus.storage.database import init_db\nfrom tests.fixtures.usage_seed import seed_usage\n\n\ndef _ts(days_ago: int) -> str:\n return (datetime.datetime.now() - datetime.timedelta(days=days_ago)).isoformat()\n\n\n@pytest.mark.asyncio\nasync def test_create_budget(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n budget_id = await create_or_update_budget(db_path, key_id=None, daily_limit=5.0, warn_pct=80)\n budgets = await get_budgets(db_path)\n assert len(budgets) == 1\n assert budgets[0][\"daily_limit\"] == 5.0\n assert budgets[0][\"id\"] == budget_id\n\n\n@pytest.mark.asyncio\nasync def test_update_budget_replaces(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await create_or_update_budget(db_path, key_id=None, daily_limit=5.0, warn_pct=80)\n await create_or_update_budget(db_path, key_id=None, daily_limit=10.0, warn_pct=90)\n budgets = await get_budgets(db_path)\n assert len(budgets) == 1\n assert budgets[0][\"daily_limit\"] == 10.0\n assert budgets[0][\"warn_pct\"] == 90\n\n\n@pytest.mark.asyncio\nasync def test_budget_status_no_budget(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n status = await get_budget_status(db_path, key_id=None)\n assert status is None\n\n\n@pytest.mark.asyncio\nasync def test_budget_status_ok(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await create_or_update_budget(db_path, key_id=None, daily_limit=10.0, warn_pct=80)\n await seed_usage(db_path, [{\"timestamp\": _ts(0), \"cost\": 3.0, \"status\": 200}])\n status = await get_budget_status(db_path, key_id=None)\n assert status is not None\n assert status[\"status\"] == \"ok\"\n assert abs(status[\"today_spend\"] - 3.0) < 0.0001\n assert abs(status[\"daily_limit\"] - 10.0) < 0.0001\n\n\n@pytest.mark.asyncio\nasync def test_budget_status_warning(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await create_or_update_budget(db_path, key_id=None, daily_limit=10.0, warn_pct=50)\n await seed_usage(db_path, [{\"timestamp\": _ts(0), \"cost\": 6.0, \"status\": 200}])\n status = await get_budget_status(db_path, key_id=None)\n assert status[\"status\"] == \"warning\"\n\n\n@pytest.mark.asyncio\nasync def test_budget_status_exceeded(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await create_or_update_budget(db_path, key_id=None, daily_limit=5.0, warn_pct=80)\n await seed_usage(db_path, [{\"timestamp\": _ts(0), \"cost\": 6.0, \"status\": 200}])\n status = await get_budget_status(db_path, key_id=None)\n assert status[\"status\"] == \"exceeded\"\n\n\n@pytest.mark.asyncio\nasync def test_per_key_budget(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n from janus.storage.api_keys import create_key\n _, key_record = await create_key(db_path, name=\"test-key\")\n key_id = key_record[\"id\"]\n await create_or_update_budget(db_path, key_id=key_id, daily_limit=2.0, warn_pct=80)\n await seed_usage(db_path, [{\"timestamp\": _ts(0), \"cost\": 1.0, \"status\": 200, \"client_key_id\": key_id}])\n status = await get_budget_status(db_path, key_id=key_id)\n assert status is not None\n assert status[\"status\"] == \"ok\"\n assert abs(status[\"today_spend\"] - 1.0) < 0.0001\n\n\n@pytest.mark.asyncio\nasync def test_only_today_counts(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await create_or_update_budget(db_path, key_id=None, daily_limit=5.0, warn_pct=80)\n await seed_usage(db_path, [{\"timestamp\": _ts(2), \"cost\": 10.0, \"status\": 200}])\n status = await get_budget_status(db_path, key_id=None)\n assert status[\"status\"] == \"ok\"\n assert abs(status[\"today_spend\"] - 0.0) < 0.0001\n\n\n@pytest.mark.asyncio\nasync def test_delete_budget(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n budget_id = await create_or_update_budget(db_path, key_id=None, daily_limit=5.0, warn_pct=80)\n deleted = await delete_budget(db_path, budget_id)\n assert deleted is True\n budgets = await get_budgets(db_path)\n assert len(budgets) == 0\n```\n\n- [ ] **Step 2: Run tests to verify they fail**\n\nRun: `.venv/bin/python -m pytest tests/unit/storage/test_budgets.py -v`\nExpected: FAIL (ModuleNotFoundError)\n\n- [ ] **Step 3: Write the budgets implementation**\n\nCreate `src/janus/storage/budgets.py`:\n\n```python\nfrom __future__ import annotations\n\nfrom pathlib import Path\nfrom typing import Any\n\nfrom .database import get_connection\n\n\nasync def create_or_update_budget(\n db_path: str | Path,\n *,\n key_id: int | None,\n daily_limit: float,\n warn_pct: float = 80,\n) -> int:\n async with get_connection(db_path) as db:\n if key_id is not None:\n async with db.execute(\n \"SELECT id FROM budgets WHERE key_id = ? AND is_active = 1\",\n (key_id,),\n ) as cur:\n row = await cur.fetchone()\n else:\n async with db.execute(\n \"SELECT id FROM budgets WHERE key_id IS NULL AND is_active = 1\"\n ) as cur:\n row = await cur.fetchone()\n if row is not None:\n await db.execute(\n \"UPDATE budgets SET daily_limit = ?, warn_pct = ? WHERE id = ?\",\n (daily_limit, warn_pct, row[\"id\"]),\n )\n await db.commit()\n return row[\"id\"]\n cursor = await db.execute(\n \"INSERT INTO budgets (key_id, daily_limit, warn_pct) VALUES (?, ?, ?)\",\n (key_id, daily_limit, warn_pct),\n )\n await db.commit()\n return cursor.lastrowid\n\n\nasync def get_budgets(db_path: str | Path) -> list[dict[str, Any]]:\n async with get_connection(db_path) as db:\n async with db.execute(\n \"SELECT id, key_id, daily_limit, warn_pct, is_active, created_at \"\n \"FROM budgets WHERE is_active = 1 ORDER BY id\"\n ) as cur:\n rows = await cur.fetchall()\n return [dict(r) for r in rows]\n\n\nasync def delete_budget(db_path: str | Path, budget_id: int) -> bool:\n async with get_connection(db_path) as db:\n cursor = await db.execute(\n \"DELETE FROM budgets WHERE id = ?\", (budget_id,)\n )\n await db.commit()\n return cursor.rowcount > 0\n\n\nasync def get_budget_status(\n db_path: str | Path, *, key_id: int | None = None\n) -> dict[str, Any] | None:\n async with get_connection(db_path) as db:\n if key_id is not None:\n async with db.execute(\n \"SELECT daily_limit, warn_pct FROM budgets WHERE key_id = ? AND is_active = 1\",\n (key_id,),\n ) as cur:\n row = await cur.fetchone()\n if row is None:\n return None\n async with db.execute(\n \"SELECT COALESCE(SUM(cost), 0.0) as spent FROM usage \"\n \"WHERE client_key_id = ? AND date(timestamp) = date('now', 'localtime')\",\n (key_id,),\n ) as cur:\n spent_row = await cur.fetchone()\n else:\n async with db.execute(\n \"SELECT daily_limit, warn_pct FROM budgets WHERE key_id IS NULL AND is_active = 1\"\n ) as cur:\n row = await cur.fetchone()\n if row is None:\n return None\n async with db.execute(\n \"SELECT COALESCE(SUM(cost), 0.0) as spent FROM usage \"\n \"WHERE date(timestamp) = date('now', 'localtime')\"\n ) as cur:\n spent_row = await cur.fetchone()\n\n assert row is not None\n assert spent_row is not None\n daily_limit = row[\"daily_limit\"]\n today_spend = spent_row[\"spent\"]\n pct_used = (today_spend / daily_limit * 100) if daily_limit > 0 else 100.0\n if pct_used >= 100:\n status = \"exceeded\"\n elif pct_used >= row[\"warn_pct\"]:\n status = \"warning\"\n else:\n status = \"ok\"\n return {\n \"daily_limit\": daily_limit,\n \"today_spend\": today_spend,\n \"remaining\": max(0.0, daily_limit - today_spend),\n \"pct_used\": pct_used,\n \"status\": status,\n \"warn_pct\": row[\"warn_pct\"],\n }\n```\n\n- [ ] **Step 4: Run tests to verify they pass**\n\nRun: `.venv/bin/python -m pytest tests/unit/storage/test_budgets.py -v`\nExpected: PASS (9 tests)\n\n- [ ] **Step 5: Commit**\n\n```bash\ngit add src/janus/storage/budgets.py tests/unit/storage/test_budgets.py\ngit commit -m \"feat: add budget CRUD and status queries\"\n```\n\n---\n\n## Task 12: Wire PricingRegistry into App State\n\n**Files:**\n- Modify: `src/janus/app.py`\n\n- [ ] **Step 1: Add PricingRegistry to app factory**\n\nIn `src/janus/app.py`, add import and wire it:\n\nAdd to imports at top:\n```python\nfrom janus.pricing.registry import PricingRegistry\n```\n\nAfter the `app.state.saver_pipeline = SaverPipeline(savers)` line (line 53), add:\n```python\n app.state.pricing_registry = PricingRegistry(config.pricing)\n```\n\n- [ ] **Step 2: Verify app still imports**\n\nRun: `.venv/bin/python -c \"from janus.app import create_app; create_app()\"`\nExpected: No error\n\n- [ ] **Step 3: Run full test suite to verify no regressions**\n\nRun: `.venv/bin/python -m pytest -x -q`\nExpected: All existing tests pass\n\n- [ ] **Step 4: Commit**\n\n```bash\ngit add src/janus/app.py\ngit commit -m \"feat: wire PricingRegistry into app state\"\n```\n\n---\n\n## Task 13: Budget Enforcement + Cost Recording in _handle()\n\n**Files:**\n- Modify: `src/janus/api/routes.py`\n\n- [ ] **Step 1: Write the failing integration test for budget enforcement**\n\nCreate `tests/integration/test_budget_enforcement.py`:\n\n```python\nimport datetime\n\nimport pytest\nfrom httpx import ASGITransport, AsyncClient\n\nfrom janus.app import create_app\nfrom janus.config.schema import JanusConfig, ProviderConfig, ServerSettings\nfrom janus.providers.registry import ProviderRegistry\nfrom janus.routing.fallback import FallbackHandler\nfrom janus.storage.api_keys import create_key\nfrom janus.storage.budgets import create_or_update_budget\nfrom janus.storage.database import init_db, get_connection\n\n\ndef _ts(days_ago: int) -> str:\n return (datetime.datetime.now() - datetime.timedelta(days=days_ago)).isoformat()\n\n\nasync def _seed_cost(db_path, cost: float, key_id=None) -> None:\n async with get_connection(db_path) as db:\n await db.execute(\n \"INSERT INTO usage (timestamp, model, cost, status, client_key_id) \"\n \"VALUES (?, 'test', ?, 200, ?)\",\n (_ts(0), cost, key_id),\n )\n await db.commit()\n\n\n@pytest.mark.asyncio\nasync def test_request_blocked_when_budget_exceeded(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n\n registry = ProviderRegistry()\n config = JanusConfig(\n server=ServerSettings(require_api_key=True, data_dir=tmp_path),\n )\n app = create_app(registry=registry, config=config)\n app.state.db_path = db_path\n\n raw_key, record = await create_key(db_path, name=\"test\")\n await create_or_update_budget(db_path, key_id=record[\"id\"], daily_limit=1.0, warn_pct=80)\n await _seed_cost(db_path, 1.5, record[\"id\"])\n\n transport = ASGITransport(app=app)\n async with AsyncClient(transport=transport, base_url=\"http://test\") as client:\n resp = await client.post(\n \"/v1/chat/completions\",\n json={\"model\": \"nonexistent\", \"messages\": [{\"role\": \"user\", \"content\": \"hi\"}]},\n headers={\"Authorization\": f\"Bearer {raw_key}\"},\n )\n assert resp.status_code == 429\n body = resp.json()\n assert \"budget\" in body[\"error\"][\"message\"].lower()\n\n\n@pytest.mark.asyncio\nasync def test_request_passes_when_no_budget(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n\n registry = ProviderRegistry()\n config = JanusConfig(\n server=ServerSettings(require_api_key=True, data_dir=tmp_path),\n )\n app = create_app(registry=registry, config=config)\n app.state.db_path = db_path\n\n raw_key, _ = await create_key(db_path, name=\"test\")\n\n transport = ASGITransport(app=app)\n async with AsyncClient(transport=transport, base_url=\"http://test\") as client:\n resp = await client.post(\n \"/v1/chat/completions\",\n json={\"model\": \"nonexistent\", \"messages\": [{\"role\": \"user\", \"content\": \"hi\"}]},\n headers={\"Authorization\": f\"Bearer {raw_key}\"},\n )\n assert resp.status_code != 429\n\n\n@pytest.mark.asyncio\nasync def test_budget_block_response_has_retry_after(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n\n registry = ProviderRegistry()\n config = JanusConfig(\n server=ServerSettings(require_api_key=True, data_dir=tmp_path),\n )\n app = create_app(registry=registry, config=config)\n app.state.db_path = db_path\n\n raw_key, record = await create_key(db_path, name=\"test\")\n await create_or_update_budget(db_path, key_id=record[\"id\"], daily_limit=0.5, warn_pct=50)\n await _seed_cost(db_path, 1.0, record[\"id\"])\n\n transport = ASGITransport(app=app)\n async with AsyncClient(transport=transport, base_url=\"http://test\") as client:\n resp = await client.post(\n \"/v1/chat/completions\",\n json={\"model\": \"nonexistent\", \"messages\": [{\"role\": \"user\", \"content\": \"hi\"}]},\n headers={\"Authorization\": f\"Bearer {raw_key}\"},\n )\n assert resp.status_code == 429\n assert \"retry-after\" in {k.lower() for k in resp.headers.keys()}\n```\n\n- [ ] **Step 2: Run test to verify it fails**\n\nRun: `.venv/bin/python -m pytest tests/integration/test_budget_enforcement.py -v`\nExpected: FAIL (no budget enforcement in _handle)\n\n- [ ] **Step 3: Implement budget enforcement + cost recording in _handle()**\n\nReplace the `_handle` function in `src/janus/api/routes.py`:\n\n```python\nasync def _handle(\n client_format: str,\n body: dict[str, Any],\n request: Request,\n) -> Response:\n handler: FallbackHandler = request.app.state.fallback_handler\n db_path = request.app.state.db_path\n pricing_registry = request.app.state.pricing_registry\n\n client_key_id = getattr(request.state, \"client_key_id\", None)\n\n blocked_response = await _check_budgets(db_path, client_key_id)\n if blocked_response is not None:\n return blocked_response\n\n client_adapter = FORMATS[client_format]\n canonical_req = client_adapter.parse_request(body)\n\n saver_pipeline: SaverPipeline = request.app.state.saver_pipeline\n canonical_req = saver_pipeline.apply(canonical_req)\n\n try:\n attempts = handler.resolve_attempts(canonical_req.model)\n except ValueError as e:\n raise HTTPException(status_code=400, detail=str(e))\n\n last_error = \"Unknown error\"\n for target in attempts:\n provider_adapter = _resolve_format(target.native_format)\n upstream_payload = provider_adapter.build_upstream_request(canonical_req, target.model)\n provider = _build_provider(target.provider_config)\n\n try:\n if canonical_req.stream:\n result = await provider.call(upstream_payload, stream=True)\n if result.status_code >= 400:\n if is_fallback_eligible(result.status_code):\n handler.mark_cooldown(\n target.account_id,\n classify_error(result.status_code).value,\n )\n last_error = f\"{target.account_id}: {result.status_code}\"\n continue\n raise HTTPException(\n status_code=result.status_code,\n detail=(str(result.json_data) if result.json_data else \"Upstream error\"),\n )\n lines = result.lines\n if lines is None:\n raise HTTPException(status_code=502, detail=\"No stream from upstream\")\n parser = provider_adapter.stream_parser()\n emitter = client_adapter.stream_emitter()\n generator = translate_stream(lines, parser, emitter)\n return StreamingResponse(generator, media_type=\"text/event-stream\")\n\n result = await provider.call(upstream_payload, stream=False)\n if result.status_code >= 400:\n if is_fallback_eligible(result.status_code):\n handler.mark_cooldown(\n target.account_id,\n classify_error(result.status_code).value,\n )\n last_error = f\"{target.account_id}: {result.status_code}\"\n continue\n raise HTTPException(\n status_code=result.status_code,\n detail=(str(result.json_data) if result.json_data else \"Upstream error\"),\n )\n if result.json_data is None:\n raise HTTPException(status_code=502, detail=\"Empty upstream response\")\n canonical_resp = provider_adapter.parse_upstream_response(result.json_data)\n client_payload = client_adapter.emit_response(canonical_resp)\n\n from janus.pricing.calculator import compute_cost\n from janus.storage.usage import record_usage\n\n cost = compute_cost(canonical_resp.usage, target.model, pricing_registry)\n await record_usage(\n db_path,\n provider_id=target.provider_config.id,\n model=target.model,\n account_id=target.account_id,\n input_tokens=canonical_resp.usage.input_tokens,\n output_tokens=canonical_resp.usage.output_tokens,\n cache_creation_tokens=canonical_resp.usage.cache_creation_input_tokens,\n cache_read_tokens=canonical_resp.usage.cache_read_input_tokens,\n status=result.status_code,\n client_key_id=client_key_id,\n cost=cost,\n )\n\n return JSONResponse(content=client_payload)\n\n except (httpx.TimeoutException, httpx.ConnectError) as e:\n handler.mark_cooldown(target.account_id, \"network\")\n last_error = f\"{target.account_id}: {type(e).__name__}\"\n continue\n\n raise HTTPException(status_code=503, detail=f\"All providers exhausted: {last_error}\")\n```\n\nAdd this helper function above `_handle`:\n\n```python\nimport datetime\n\nfrom janus.storage.budgets import get_budget_status\n\n\nasync def _check_budgets(\n db_path: str | Path,\n client_key_id: int | None,\n) -> Response | None:\n try:\n statuses: list[dict[str, Any]] = []\n key_status = await get_budget_status(db_path, key_id=client_key_id)\n if key_status is not None:\n statuses.append(key_status)\n global_status = await get_budget_status(db_path, key_id=None)\n if global_status is not None:\n statuses.append(global_status)\n for s in statuses:\n if s[\"status\"] == \"exceeded\":\n now = datetime.datetime.now()\n midnight = now.replace(hour=23, minute=59, second=59, microsecond=0)\n retry_after = int((midnight - now).total_seconds()) + 1\n error_body: dict[str, Any] = {\n \"error\": {\n \"message\": (\n f\"Daily budget exceeded. \"\n f\"Spent ${s['today_spend']:.2f} of ${s['daily_limit']:.2f} limit. \"\n f\"Resets at midnight.\"\n ),\n \"type\": \"budget_exceeded\",\n \"today_spend\": round(s[\"today_spend\"], 4),\n \"daily_limit\": s[\"daily_limit\"],\n }\n }\n return JSONResponse(\n content=error_body,\n status_code=429,\n headers={\"Retry-After\": str(max(retry_after, 1))},\n )\n except Exception:\n pass\n return None\n```\n\nAdd `from pathlib import Path` and `import datetime` to the imports at the top of `routes.py`.\n\n- [ ] **Step 4: Run budget enforcement tests**\n\nRun: `.venv/bin/python -m pytest tests/integration/test_budget_enforcement.py -v`\nExpected: PASS (3 tests)\n\n- [ ] **Step 5: Run full test suite to verify no regressions**\n\nRun: `.venv/bin/python -m pytest -x -q`\nExpected: All tests pass\n\n- [ ] **Step 6: Lint and typecheck**\n\nRun: `.venv/bin/ruff check src/janus/ tests/` and `.venv/bin/mypy src/janus/`\nExpected: No errors\n\n- [ ] **Step 7: Commit**\n\n```bash\ngit add src/janus/api/routes.py tests/integration/test_budget_enforcement.py\ngit commit -m \"feat: budget enforcement + cost recording in request handler\"\n```\n\n---\n\n## Task 14: Cost Recording Integration Test\n\n**Files:**\n- Create: `tests/integration/test_cost_recording.py`\n\n- [ ] **Step 1: Write the test**\n\nCreate `tests/integration/test_cost_recording.py`:\n\n```python\nimport aiosqlite\nimport pytest\nfrom httpx import ASGITransport, AsyncClient\n\nimport respx\nfrom janus.app import create_app\nfrom janus.config.schema import JanusConfig, ProviderConfig, ServerSettings\nfrom janus.providers.registry import ProviderRegistry\nfrom janus.routing.fallback import FallbackHandler\nfrom janus.storage.database import init_db\n\n\n@pytest.mark.asyncio\nasync def test_cost_recorded_for_non_streaming(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n\n registry = ProviderRegistry()\n provider = ProviderConfig(\n id=\"test-openai\",\n prefix=\"test\",\n api_type=\"openai_compat\",\n base_url=\"https://api.test.com/v1\",\n api_key=\"sk-test\",\n models=[\"gpt-4o\"],\n )\n registry.register(provider)\n config = JanusConfig(\n server=ServerSettings(data_dir=tmp_path),\n providers=[provider],\n )\n app = create_app(registry=registry, config=config)\n app.state.db_path = db_path\n\n mock_response = {\n \"id\": \"chatcmpl-123\",\n \"object\": \"chat.completion\",\n \"model\": \"gpt-4o\",\n \"choices\": [\n {\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"Hello!\"}, \"finish_reason\": \"stop\"}\n ],\n \"usage\": {\"prompt_tokens\": 100, \"completion_tokens\": 50, \"total_tokens\": 150},\n }\n\n with respx.mock(base_url=\"https://api.test.com/v1\") as mock:\n mock.post(\"/chat/completions\").respond(200, json=mock_response)\n transport = ASGITransport(app=app)\n async with AsyncClient(transport=transport, base_url=\"http://test\") as client:\n resp = await client.post(\n \"/v1/chat/completions\",\n json={\"model\": \"test/gpt-4o\", \"messages\": [{\"role\": \"user\", \"content\": \"hi\"}]},\n )\n assert resp.status_code == 200\n\n async with aiosqlite.connect(str(db_path)) as db:\n db.row_factory = aiosqlite.Row\n async with db.execute(\"SELECT cost, input_tokens, output_tokens FROM usage WHERE model = 'gpt-4o'\") as cur:\n rows = await cur.fetchall()\n assert len(rows) == 1\n assert rows[0][\"cost\"] > 0\n assert rows[0][\"input_tokens\"] == 100\n assert rows[0][\"output_tokens\"] == 50\n```\n\n- [ ] **Step 2: Run test**\n\nRun: `.venv/bin/python -m pytest tests/integration/test_cost_recording.py -v`\nExpected: PASS\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add tests/integration/test_cost_recording.py\ngit commit -m \"test: cost recording integration test\"\n```\n\n---\n\n## Task 15: Dashboard — Chart.js + Nav Updates\n\n**Files:**\n- Modify: `src/janus/dashboard/templates/base.html`\n\n- [ ] **Step 1: Add Chart.js CDN and nav items**\n\nIn `src/janus/dashboard/templates/base.html`, add Chart.js CDN after the HTMX script tag (line 8):\n\n```html\n <script src=\"https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js\"></script>\n```\n\nAdd \"Analytics\" and \"Budgets\" nav items between the Usage and bottom section (after line 43, after the Usage `</a>`):\n\n```html\n <a href=\"/dashboard/analytics\" class=\"flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:bg-gray-800 hover:text-white transition-colors {% block analytics_active %}{% endblock %}\">\n <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z\"/><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z\"/></svg>\n Analytics\n </a>\n <a href=\"/dashboard/budgets\" class=\"flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:bg-gray-800 hover:text-white transition-colors {% block budgets_active %}{% endblock %}\">\n <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1\"/></svg>\n Budgets\n </a>\n```\n\n- [ ] **Step 2: Commit**\n\n```bash\ngit add src/janus/dashboard/templates/base.html\ngit commit -m \"feat: add Chart.js CDN and analytics/budgets nav items\"\n```\n\n---\n\n## Task 16: Dashboard — Analytics Page\n\n**Files:**\n- Create: `src/janus/dashboard/templates/analytics.html`\n- Modify: `src/janus/dashboard/routes.py`\n- Test: `tests/integration/test_dashboard_analytics.py`\n\n- [ ] **Step 1: Write the failing test**\n\nCreate `tests/integration/test_dashboard_analytics.py`:\n\n```python\nimport datetime\n\nimport pytest\nfrom httpx import ASGITransport, AsyncClient\n\nfrom janus.app import create_app\nfrom janus.config.schema import JanusConfig, ServerSettings\nfrom janus.providers.registry import ProviderRegistry\nfrom janus.storage.database import init_db, get_connection\n\n\ndef _ts(days_ago: int) -> str:\n return (datetime.datetime.now() - datetime.timedelta(days=days_ago)).isoformat()\n\n\nasync def _seed(db_path, rows):\n for row in rows:\n async with get_connection(db_path) as db:\n await db.execute(\n \"INSERT INTO usage (timestamp, model, provider_id, input_tokens, output_tokens, cost, status) \"\n \"VALUES (?, ?, ?, ?, ?, ?, ?)\",\n (\n row.get(\"ts\", _ts(0)),\n row.get(\"model\", \"gpt-4o\"),\n row.get(\"provider_id\", \"openai\"),\n row.get(\"input_tokens\", 1000),\n row.get(\"output_tokens\", 500),\n row.get(\"cost\", 0.01),\n row.get(\"status\", 200),\n ),\n )\n await db.commit()\n\n\n@pytest.mark.asyncio\nasync def test_analytics_page_renders(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await _seed(db_path, [{\"ts\": _ts(0), \"cost\": 0.05, \"model\": \"gpt-4o\"}, {\"ts\": _ts(1), \"cost\": 0.03, \"model\": \"claude\"}])\n\n registry = ProviderRegistry()\n config = JanusConfig(server=ServerSettings(data_dir=tmp_path))\n app = create_app(registry=registry, config=config)\n app.state.db_path = db_path\n\n transport = ASGITransport(app=app)\n async with AsyncClient(transport=transport, base_url=\"http://test\") as client:\n resp = await client.get(\"/dashboard/analytics\")\n assert resp.status_code == 200\n assert \"Analytics\" in resp.text\n assert \"0.08\" in resp.text or \"0.0\" in resp.text\n\n\n@pytest.mark.asyncio\nasync def test_analytics_breakdown_by_provider(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await _seed(db_path, [\n {\"ts\": _ts(0), \"provider_id\": \"openai\", \"cost\": 0.05},\n {\"ts\": _ts(0), \"provider_id\": \"anthropic\", \"cost\": 0.03},\n ])\n\n registry = ProviderRegistry()\n config = JanusConfig(server=ServerSettings(data_dir=tmp_path))\n app = create_app(registry=registry, config=config)\n app.state.db_path = db_path\n\n transport = ASGITransport(app=app)\n async with AsyncClient(transport=transport, base_url=\"http://test\") as client:\n resp = await client.get(\"/dashboard/analytics?dimension=provider&days=30\")\n assert resp.status_code == 200\n assert \"openai\" in resp.text\n assert \"anthropic\" in resp.text\n```\n\n- [ ] **Step 2: Run test to verify it fails**\n\nRun: `.venv/bin/python -m pytest tests/integration/test_dashboard_analytics.py -v`\nExpected: FAIL (no analytics route)\n\n- [ ] **Step 3: Add analytics route to dashboard**\n\nIn `src/janus/dashboard/routes.py`, add imports at the top (after existing imports):\n\n```python\nfrom janus.storage.analytics import get_breakdown, get_spend_summary, get_success_rate\n```\n\nAdd the analytics route after the usage route:\n\n```python\n@router.get(\"/analytics\", response_class=HTMLResponse)\nasync def analytics_page(\n request: Request,\n days: int = 30,\n dimension: str = \"model\",\n) -> HTMLResponse:\n db_path = await _ensure_db(request)\n try:\n summary = await get_spend_summary(db_path, days=days)\n breakdown = await get_breakdown(db_path, dimension=dimension, days=days)\n success = await get_success_rate(db_path, days=days)\n except Exception:\n summary = {\"total_cost\": 0, \"total_requests\": 0, \"daily\": []}\n breakdown = []\n success = {\"success_2xx\": 0, \"client_4xx\": 0, \"server_5xx\": 0, \"total\": 0}\n context: dict[str, Any] = {\n \"request\": request,\n \"summary\": summary,\n \"breakdown\": breakdown,\n \"success\": success,\n \"days\": days,\n \"dimension\": dimension,\n }\n return _templates.TemplateResponse(request, \"analytics.html\", context)\n```\n\n- [ ] **Step 4: Create the analytics template**\n\nCreate `src/janus/dashboard/templates/analytics.html`:\n\n```html\n{% extends \"base.html\" %}\n{% block title %}Analytics - Janus{% endblock %}\n{% block analytics_active %}bg-gray-700 text-white{% endblock %}\n\n{% block content %}\n<h1 class=\"text-2xl font-bold text-white mb-6\">Analytics</h1>\n\n<!-- Date Range Selector -->\n<div class=\"flex space-x-2 mb-6\">\n {% for d in [7, 30, 90] %}\n <a href=\"/dashboard/analytics?days={{ d }}&dimension={{ dimension }}\"\n class=\"px-4 py-2 rounded-lg text-sm font-medium {% if days == d %}bg-blue-600 text-white{% else %}bg-gray-800 text-gray-400 hover:text-white{% endif %} transition-colors\">\n {{ d }}d\n </a>\n {% endfor %}\n</div>\n\n<!-- Stats Cards -->\n<div class=\"grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-8\">\n <div class=\"bg-gray-800 rounded-xl p-5 border border-gray-700\">\n <p class=\"text-gray-400 text-sm font-medium mb-1\">Total Cost</p>\n <p class=\"text-3xl font-bold text-white\">${{ \"%.4f\"|format(summary.total_cost) }}</p>\n </div>\n <div class=\"bg-gray-800 rounded-xl p-5 border border-gray-700\">\n <p class=\"text-gray-400 text-sm font-medium mb-1\">Total Requests</p>\n <p class=\"text-3xl font-bold text-white\">{{ summary.total_requests }}</p>\n </div>\n <div class=\"bg-gray-800 rounded-xl p-5 border border-gray-700\">\n <p class=\"text-gray-400 text-sm font-medium mb-1\">Input Tokens</p>\n <p class=\"text-3xl font-bold text-white\">{{ \"{:,}\".format(summary.total_input_tokens) }}</p>\n </div>\n <div class=\"bg-gray-800 rounded-xl p-5 border border-gray-700\">\n <p class=\"text-gray-400 text-sm font-medium mb-1\">Output Tokens</p>\n <p class=\"text-3xl font-bold text-white\">{{ \"{:,}\".format(summary.total_output_tokens) }}</p>\n </div>\n</div>\n\n<!-- Spend Chart -->\n<div class=\"bg-gray-800 rounded-xl p-6 border border-gray-700 mb-8\">\n <h2 class=\"text-lg font-semibold text-white mb-4\">Spend Over Time</h2>\n <canvas id=\"spendChart\" height=\"80\"></canvas>\n</div>\n\n<!-- Breakdown -->\n<div class=\"bg-gray-800 rounded-xl border border-gray-700 overflow-hidden mb-8\">\n <div class=\"px-6 py-4 border-b border-gray-700 flex items-center justify-between\">\n <h2 class=\"text-lg font-semibold text-white\">Breakdown</h2>\n <div class=\"flex space-x-1\">\n {% for dim, label in [(\"model\", \"Model\"), (\"provider\", \"Provider\"), (\"account\", \"Account\"), (\"client_key\", \"Key\")] %}\n <a href=\"/dashboard/analytics?days={{ days }}&dimension={{ dim }}\"\n class=\"px-3 py-1 rounded-md text-xs font-medium {% if dimension == dim %}bg-blue-600 text-white{% else %}bg-gray-700 text-gray-400 hover:text-white{% endif %} transition-colors\">\n {{ label }}\n </a>\n {% endfor %}\n </div>\n </div>\n {% if breakdown %}\n <table class=\"w-full\">\n <thead>\n <tr class=\"bg-gray-900 border-b border-gray-700\">\n <th class=\"px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider\">{{ dimension }}</th>\n <th class=\"px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider\">Requests</th>\n <th class=\"px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider\">Input</th>\n <th class=\"px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider\">Output</th>\n <th class=\"px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider\">Cost</th>\n </tr>\n </thead>\n <tbody class=\"divide-y divide-gray-700\">\n {% for row in breakdown %}\n <tr class=\"hover:bg-gray-750\">\n <td class=\"px-4 py-3 text-sm text-white font-mono\">{{ row[dimension] or '—' }}</td>\n <td class=\"px-4 py-3 text-sm text-gray-300 text-right\">{{ row.requests }}</td>\n <td class=\"px-4 py-3 text-sm text-gray-300 text-right\">{{ \"{:,}\".format(row.input_tokens) }}</td>\n <td class=\"px-4 py-3 text-sm text-gray-300 text-right\">{{ \"{:,}\".format(row.output_tokens) }}</td>\n <td class=\"px-4 py-3 text-sm text-green-400 text-right\">${{ \"%.4f\"|format(row.cost) }}</td>\n </tr>\n {% endfor %}\n </tbody>\n </table>\n {% else %}\n <div class=\"px-6 py-12 text-center\"><p class=\"text-gray-400\">No data for this range.</p></div>\n {% endif %}\n</div>\n\n<!-- Success Rate -->\n<div class=\"grid grid-cols-1 md:grid-cols-2 gap-4 mb-8\">\n <div class=\"bg-gray-800 rounded-xl p-6 border border-gray-700\">\n <h2 class=\"text-lg font-semibold text-white mb-4\">Success Rate</h2>\n <canvas id=\"successChart\" height=\"120\"></canvas>\n </div>\n <div class=\"bg-gray-800 rounded-xl p-6 border border-gray-700\">\n <h2 class=\"text-lg font-semibold text-white mb-4\">Status Breakdown</h2>\n <div class=\"space-y-3\">\n <div class=\"flex items-center justify-between\">\n <span class=\"text-green-400 text-sm\">2xx Success</span>\n <span class=\"text-white font-bold\">{{ success.success_2xx }}</span>\n </div>\n <div class=\"flex items-center justify-between\">\n <span class=\"text-yellow-400 text-sm\">4xx Client Error</span>\n <span class=\"text-white font-bold\">{{ success.client_4xx }}</span>\n </div>\n <div class=\"flex items-center justify-between\">\n <span class=\"text-red-400 text-sm\">5xx Server Error</span>\n <span class=\"text-white font-bold\">{{ success.server_5xx }}</span>\n </div>\n </div>\n </div>\n</div>\n\n<script>\n// Spend chart\nconst dailyData = {{ summary.daily | tojson }};\nconst ctxSpend = document.getElementById('spendChart');\nnew Chart(ctxSpend, {\n type: 'line',\n data: {\n labels: dailyData.map(d => d.date),\n datasets: [{\n label: 'Daily Cost ($)',\n data: dailyData.map(d => d.cost),\n borderColor: '#3b82f6',\n backgroundColor: 'rgba(59, 130, 246, 0.1)',\n fill: true,\n tension: 0.3,\n }]\n },\n options: {\n responsive: true,\n plugins: { legend: { labels: { color: '#9ca3af' } } },\n scales: {\n x: { ticks: { color: '#9ca3af' }, grid: { color: '#374151' } },\n y: { ticks: { color: '#9ca3af' }, grid: { color: '#374151' } }\n }\n }\n});\n\n// Success donut\nconst successData = {{ success | tojson }};\nconst ctxSuccess = document.getElementById('successChart');\nnew Chart(ctxSuccess, {\n type: 'doughnut',\n data: {\n labels: ['2xx Success', '4xx Client Error', '5xx Server Error'],\n datasets: [{\n data: [successData.success_2xx, successData.client_4xx, successData.server_5xx],\n backgroundColor: ['#10b981', '#f59e0b', '#ef4444'],\n }]\n },\n options: {\n responsive: true,\n plugins: { legend: { labels: { color: '#9ca3af' } } }\n }\n});\n</script>\n{% endblock %}\n```\n\n- [ ] **Step 5: Run tests**\n\nRun: `.venv/bin/python -m pytest tests/integration/test_dashboard_analytics.py -v`\nExpected: PASS (2 tests)\n\n- [ ] **Step 6: Commit**\n\n```bash\ngit add src/janus/dashboard/templates/analytics.html src/janus/dashboard/routes.py tests/integration/test_dashboard_analytics.py\ngit commit -m \"feat: dashboard analytics page with charts and breakdowns\"\n```\n\n---\n\n## Task 17: Dashboard — Budgets Page\n\n**Files:**\n- Create: `src/janus/dashboard/templates/budgets.html`\n- Create: `src/janus/dashboard/templates/budgets_partial.html`\n- Modify: `src/janus/dashboard/routes.py`\n\n- [ ] **Step 1: Add budgets routes to dashboard**\n\nIn `src/janus/dashboard/routes.py`, add imports at the top:\n\n```python\nfrom janus.storage.budgets import (\n create_or_update_budget,\n delete_budget,\n get_budget_status,\n get_budgets,\n)\nfrom janus.storage.api_keys import list_keys\n```\n\nAdd the routes after the analytics route:\n\n```python\n@router.get(\"/budgets\", response_class=HTMLResponse)\nasync def budgets_page(request: Request) -> HTMLResponse:\n db_path = await _ensure_db(request)\n try:\n budgets = await get_budgets(db_path)\n keys = await list_keys(db_path)\n budget_statuses: list[dict[str, Any]] = []\n for b in budgets:\n status = await get_budget_status(db_path, key_id=b[\"key_id\"])\n key_name = \"Global\"\n if b[\"key_id\"] is not None:\n key_name = next((k[\"name\"] for k in keys if k[\"id\"] == b[\"key_id\"]), f\"Key #{b['key_id']}\")\n budget_statuses.append({**b, \"status\": status, \"key_name\": key_name})\n except Exception:\n budget_statuses = []\n keys = []\n context: dict[str, Any] = {\n \"request\": request,\n \"budgets\": budget_statuses,\n \"keys\": keys,\n }\n return _templates.TemplateResponse(request, \"budgets.html\", context)\n\n\n@router.post(\"/api/budgets\", response_class=HTMLResponse)\nasync def create_budget(\n request: Request,\n key_select: str = Form(...),\n daily_limit: float = Form(...),\n warn_pct: float = Form(80),\n) -> HTMLResponse:\n db_path = await _ensure_db(request)\n key_id: int | None = None\n if key_select != \"global\":\n key_id = int(key_select)\n await create_or_update_budget(db_path, key_id=key_id, daily_limit=daily_limit, warn_pct=warn_pct)\n return await _budgets_partial(request, db_path)\n\n\n@router.delete(\"/api/budgets/{budget_id}\", response_class=HTMLResponse)\nasync def delete_budget_endpoint(request: Request, budget_id: int) -> HTMLResponse:\n db_path = await _ensure_db(request)\n await delete_budget(db_path, budget_id)\n return await _budgets_partial(request, db_path)\n\n\nasync def _budgets_partial(request: Request, db_path: Path) -> HTMLResponse:\n budgets = await get_budgets(db_path)\n keys = await list_keys(db_path)\n budget_statuses: list[dict[str, Any]] = []\n for b in budgets:\n status = await get_budget_status(db_path, key_id=b[\"key_id\"])\n key_name = \"Global\"\n if b[\"key_id\"] is not None:\n key_name = next((k[\"name\"] for k in keys if k[\"id\"] == b[\"key_id\"]), f\"Key #{b['key_id']}\")\n budget_statuses.append({**b, \"status\": status, \"key_name\": key_name})\n context: dict[str, Any] = {\n \"request\": request,\n \"budgets\": budget_statuses,\n \"keys\": keys,\n }\n return _templates.TemplateResponse(request, \"budgets_partial.html\", context)\n```\n\n- [ ] **Step 2: Create the budgets template**\n\nCreate `src/janus/dashboard/templates/budgets.html`:\n\n```html\n{% extends \"base.html\" %}\n{% block title %}Budgets - Janus{% endblock %}\n{% block budgets_active %}bg-gray-700 text-white{% endblock %}\n\n{% block content %}\n<h1 class=\"text-2xl font-bold text-white mb-6\">Budgets</h1>\n\n<!-- Create Budget Form -->\n<div class=\"bg-gray-800 rounded-xl p-6 border border-gray-700 mb-8\">\n <h2 class=\"text-lg font-semibold text-white mb-4\">Create / Update Budget</h2>\n <form hx-post=\"/dashboard/api/budgets\" hx-target=\"#budgets-table\" class=\"flex flex-wrap items-end gap-4\">\n <div>\n <label class=\"block text-gray-400 text-sm mb-1\">Scope</label>\n <select name=\"key_select\" class=\"bg-gray-900 text-white rounded-lg px-3 py-2 border border-gray-700 text-sm\">\n <option value=\"global\">Global (all keys)</option>\n {% for key in keys %}\n <option value=\"{{ key.id }}\">{{ key.name }}</option>\n {% endfor %}\n </select>\n </div>\n <div>\n <label class=\"block text-gray-400 text-sm mb-1\">Daily Limit ($)</label>\n <input type=\"number\" name=\"daily_limit\" step=\"0.01\" min=\"0\" value=\"5.00\"\n class=\"bg-gray-900 text-white rounded-lg px-3 py-2 border border-gray-700 text-sm w-32\">\n </div>\n <div>\n <label class=\"block text-gray-400 text-sm mb-1\">Warn at (%)</label>\n <input type=\"number\" name=\"warn_pct\" step=\"1\" min=\"1\" max=\"100\" value=\"80\"\n class=\"bg-gray-900 text-white rounded-lg px-3 py-2 border border-gray-700 text-sm w-24\">\n </div>\n <button type=\"submit\" class=\"bg-blue-600 hover:bg-blue-700 text-white rounded-lg px-5 py-2 text-sm font-medium transition-colors\">\n Set Budget\n </button>\n </form>\n</div>\n\n<!-- Budgets Table -->\n<div id=\"budgets-table\">\n {% include \"budgets_partial.html\" %}\n</div>\n{% endblock %}\n```\n\n- [ ] **Step 3: Create the budgets partial template**\n\nCreate `src/janus/dashboard/templates/budgets_partial.html`:\n\n```html\n<div class=\"bg-gray-800 rounded-xl border border-gray-700 overflow-hidden\">\n {% if budgets %}\n <table class=\"w-full\">\n <thead>\n <tr class=\"bg-gray-900 border-b border-gray-700\">\n <th class=\"px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider\">Scope</th>\n <th class=\"px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider\">Daily Limit</th>\n <th class=\"px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider\">Today's Spend</th>\n <th class=\"px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider\">Usage</th>\n <th class=\"px-4 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider\">Status</th>\n <th class=\"px-4 py-3\"></th>\n </tr>\n </thead>\n <tbody class=\"divide-y divide-gray-700\">\n {% for b in budgets %}\n <tr class=\"hover:bg-gray-750\">\n <td class=\"px-4 py-3 text-sm text-white font-medium\">{{ b.key_name }}</td>\n <td class=\"px-4 py-3 text-sm text-gray-300 text-right\">${{ \"%.2f\"|format(b.daily_limit) }}</td>\n <td class=\"px-4 py-3 text-sm text-gray-300 text-right\">\n {% if b.status %}${{ \"%.4f\"|format(b.status.today_spend) }}{% else %}—{% endif %}\n </td>\n <td class=\"px-4 py-3 text-right\">\n {% if b.status %}\n <div class=\"flex items-center justify-end\">\n <div class=\"w-24 bg-gray-700 rounded-full h-2 mr-2\">\n <div class=\"h-2 rounded-full {% if b.status.status == 'exceeded' %}bg-red-500{% elif b.status.status == 'warning' %}bg-yellow-500{% else %}bg-green-500{% endif %}\"\n style=\"width: {{ [b.status.pct_used, 100] | min }}%\"></div>\n </div>\n <span class=\"text-sm text-gray-400\">{{ \"%.0f\"|format(b.status.pct_used) }}%</span>\n </div>\n {% else %}\n <span class=\"text-sm text-gray-500\">—</span>\n {% endif %}\n </td>\n <td class=\"px-4 py-3 text-center\">\n {% if b.status %}\n {% if b.status.status == 'exceeded' %}\n <span class=\"inline-flex px-2 py-1 rounded-full text-xs font-medium bg-red-900 text-red-200\">Exceeded</span>\n {% elif b.status.status == 'warning' %}\n <span class=\"inline-flex px-2 py-1 rounded-full text-xs font-medium bg-yellow-900 text-yellow-200\">Warning</span>\n {% else %}\n <span class=\"inline-flex px-2 py-1 rounded-full text-xs font-medium bg-green-900 text-green-200\">OK</span>\n {% endif %}\n {% else %}\n <span class=\"text-sm text-gray-500\">—</span>\n {% endif %}\n </td>\n <td class=\"px-4 py-3 text-right\">\n <button hx-delete=\"/dashboard/api/budgets/{{ b.id }}\" hx-target=\"#budgets-table\"\n class=\"text-red-400 hover:text-red-300 text-sm\">Delete</button>\n </td>\n </tr>\n {% endfor %}\n </tbody>\n </table>\n {% else %}\n <div class=\"px-6 py-12 text-center\">\n <p class=\"text-gray-400\">No budgets configured.</p>\n </div>\n {% endif %}\n</div>\n```\n\n- [ ] **Step 4: Run full test suite**\n\nRun: `.venv/bin/python -m pytest -x -q`\nExpected: All tests pass\n\n- [ ] **Step 5: Commit**\n\n```bash\ngit add src/janus/dashboard/templates/budgets.html src/janus/dashboard/templates/budgets_partial.html src/janus/dashboard/routes.py\ngit commit -m \"feat: dashboard budgets page with HTMX create/delete\"\n```\n\n---\n\n## Task 18: Dashboard — Enhanced Overview + Usage Pages\n\n**Files:**\n- Modify: `src/janus/dashboard/routes.py`\n- Modify: `src/janus/dashboard/templates/overview.html`\n- Modify: `src/janus/dashboard/templates/usage.html`\n\n- [ ] **Step 1: Enhance overview route to include spend + budget data**\n\nIn `src/janus/dashboard/routes.py`, update the `overview` function. Replace the existing function with:\n\n```python\n@router.get(\"\", response_class=HTMLResponse)\nasync def overview(request: Request) -> HTMLResponse:\n db_path = await _ensure_db(request)\n stats = await _get_usage_stats_safe(db_path)\n registry = request.app.state.registry\n provider_count = len(registry.providers)\n today_cost = 0.0\n global_budget = None\n try:\n summary = await get_spend_summary(db_path, days=1)\n today_cost = summary[\"total_cost\"]\n global_budget = await get_budget_status(db_path, key_id=None)\n except Exception:\n pass\n context: dict[str, Any] = {\n \"request\": request,\n \"stats\": stats,\n \"provider_count\": provider_count,\n \"combos\": registry.combos,\n \"today_cost\": today_cost,\n \"global_budget\": global_budget,\n }\n return _templates.TemplateResponse(request, \"overview.html\", context)\n```\n\n- [ ] **Step 2: Update overview template to show today's spend + budget bar**\n\nReplace `src/janus/dashboard/templates/overview.html`:\n\n```html\n{% extends \"base.html\" %}\n{% block title %}Overview - Janus{% endblock %}\n{% block overview_active %}bg-gray-700 text-white{% endblock %}\n\n{% block content %}\n<h1 class=\"text-2xl font-bold text-white mb-6\">Overview</h1>\n\n<!-- Stats Cards -->\n<div class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8\">\n <div class=\"bg-gray-800 rounded-xl p-5 border border-gray-700\">\n <p class=\"text-gray-400 text-sm font-medium mb-1\">Total Requests</p>\n <p class=\"text-3xl font-bold text-white\">{{ stats.total_requests }}</p>\n </div>\n <div class=\"bg-gray-800 rounded-xl p-5 border border-gray-700\">\n <p class=\"text-gray-400 text-sm font-medium mb-1\">Input Tokens</p>\n <p class=\"text-3xl font-bold text-white\">{{ \"{:,}\".format(stats.total_input_tokens) }}</p>\n </div>\n <div class=\"bg-gray-800 rounded-xl p-5 border border-gray-700\">\n <p class=\"text-gray-400 text-sm font-medium mb-1\">Output Tokens</p>\n <p class=\"text-3xl font-bold text-white\">{{ \"{:,}\".format(stats.total_output_tokens) }}</p>\n </div>\n <div class=\"bg-gray-800 rounded-xl p-5 border border-gray-700\">\n <p class=\"text-gray-400 text-sm font-medium mb-1\">Active Providers</p>\n <p class=\"text-3xl font-bold text-white\">{{ provider_count }}</p>\n </div>\n</div>\n\n<!-- Today's Spend + Budget -->\n<div class=\"bg-gray-800 rounded-xl p-6 border border-gray-700 mb-8\">\n <div class=\"flex items-center justify-between mb-3\">\n <h2 class=\"text-lg font-semibold text-white\">Today's Spend</h2>\n <span class=\"text-2xl font-bold text-green-400\">${{ \"%.4f\"|format(today_cost) }}</span>\n </div>\n {% if global_budget %}\n <div class=\"flex items-center gap-3\">\n <div class=\"flex-1 bg-gray-700 rounded-full h-3\">\n <div class=\"h-3 rounded-full {% if global_budget.status == 'exceeded' %}bg-red-500{% elif global_budget.status == 'warning' %}bg-yellow-500{% else %}bg-green-500{% endif %}\"\n style=\"width: {{ [global_budget.pct_used, 100] | min }}%\"></div>\n </div>\n <span class=\"text-sm text-gray-400 whitespace-nowrap\">\n ${{ \"%.2f\"|format(global_budget.today_spend) }} / ${{ \"%.2f\"|format(global_budget.daily_limit) }} ({{ \"%.0f\"|format(global_budget.pct_used) }}%)\n </span>\n </div>\n {% else %}\n <p class=\"text-gray-500 text-sm\">No global budget set. <a href=\"/dashboard/budgets\" class=\"text-blue-400 hover:underline\">Create one</a></p>\n {% endif %}\n</div>\n\n<!-- Active Combos -->\n<div class=\"bg-gray-800 rounded-xl p-6 border border-gray-700\">\n <h2 class=\"text-lg font-semibold text-white mb-4\">Active Combos</h2>\n {% if combos %}\n <div class=\"space-y-3\">\n {% for name, models in combos.items() %}\n <div class=\"bg-gray-900 rounded-lg p-4 border border-gray-700\">\n <div class=\"flex items-center justify-between mb-2\">\n <span class=\"text-white font-medium\">{{ name }}</span>\n <span class=\"text-gray-500 text-sm\">{{ models|length }} models</span>\n </div>\n <div class=\"flex flex-wrap items-center gap-2\">\n {% for model in models %}\n <span class=\"inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-blue-900 text-blue-200\">{{ model }}</span>\n {% if not loop.last %}\n <span class=\"text-gray-500 text-sm\">→</span>\n {% endif %}\n {% endfor %}\n </div>\n </div>\n {% endfor %}\n </div>\n {% else %}\n <p class=\"text-gray-500 text-sm\">No combos configured.</p>\n {% endif %}\n</div>\n{% endblock %}\n```\n\n- [ ] **Step 3: Update usage template to add cost column**\n\nReplace `src/janus/dashboard/templates/usage.html`:\n\n```html\n{% extends \"base.html\" %}\n{% block title %}Usage - Janus{% endblock %}\n{% block usage_active %}bg-gray-700 text-white{% endblock %}\n\n{% block content %}\n<h1 class=\"text-2xl font-bold text-white mb-6\">Usage</h1>\n\n<!-- Stats Cards -->\n<div class=\"grid grid-cols-1 md:grid-cols-3 gap-4 mb-8\">\n <div class=\"bg-gray-800 rounded-xl p-5 border border-gray-700\">\n <p class=\"text-gray-400 text-sm font-medium mb-1\">Total Requests</p>\n <p class=\"text-3xl font-bold text-white\">{{ stats.total_requests }}</p>\n </div>\n <div class=\"bg-gray-800 rounded-xl p-5 border border-gray-700\">\n <p class=\"text-gray-400 text-sm font-medium mb-1\">Input Tokens</p>\n <p class=\"text-3xl font-bold text-white\">{{ \"{:,}\".format(stats.total_input_tokens) }}</p>\n </div>\n <div class=\"bg-gray-800 rounded-xl p-5 border border-gray-700\">\n <p class=\"text-gray-400 text-sm font-medium mb-1\">Output Tokens</p>\n <p class=\"text-3xl font-bold text-white\">{{ \"{:,}\".format(stats.total_output_tokens) }}</p>\n </div>\n</div>\n\n<!-- Usage by Model -->\n<div class=\"bg-gray-800 rounded-xl border border-gray-700 overflow-hidden\">\n <div class=\"px-6 py-4 border-b border-gray-700\">\n <h2 class=\"text-lg font-semibold text-white\">Usage by Model</h2>\n </div>\n {% if stats.by_model %}\n <table class=\"w-full\">\n <thead>\n <tr class=\"bg-gray-900 border-b border-gray-700\">\n <th class=\"px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider\">Model</th>\n <th class=\"px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider\">Requests</th>\n <th class=\"px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider\">Input Tokens</th>\n <th class=\"px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider\">Output Tokens</th>\n </tr>\n </thead>\n <tbody class=\"divide-y divide-gray-700\">\n {% for row in stats.by_model %}\n <tr class=\"hover:bg-gray-750\">\n <td class=\"px-4 py-3 text-sm text-white font-mono\">{{ row.model or '—' }}</td>\n <td class=\"px-4 py-3 text-sm text-gray-300 text-right\">{{ row.requests }}</td>\n <td class=\"px-4 py-3 text-sm text-gray-300 text-right\">{{ \"{:,}\".format(row.input_tokens) }}</td>\n <td class=\"px-4 py-3 text-sm text-gray-300 text-right\">{{ \"{:,}\".format(row.output_tokens) }}</td>\n </tr>\n {% endfor %}\n </tbody>\n </table>\n {% else %}\n <div class=\"px-6 py-12 text-center\">\n <p class=\"text-gray-400\">No usage data yet.</p>\n </div>\n {% endif %}\n</div>\n<p class=\"text-gray-500 text-xs mt-4\">Cost analytics available on the <a href=\"/dashboard/analytics\" class=\"text-blue-400 hover:underline\">Analytics page</a>.</p>\n{% endblock %}\n```\n\n- [ ] **Step 4: Run full test suite**\n\nRun: `.venv/bin/python -m pytest -x -q`\nExpected: All tests pass\n\n- [ ] **Step 5: Commit**\n\n```bash\ngit add src/janus/dashboard/routes.py src/janus/dashboard/templates/overview.html src/janus/dashboard/templates/usage.html\ngit commit -m \"feat: enhanced overview with today's spend + budget bar, usage page link to analytics\"\n```\n\n---\n\n## Task 19: CLI — Budgets Sub-App\n\n**Files:**\n- Modify: `src/janus/cli.py`\n\n- [ ] **Step 1: Add budgets sub-app to CLI**\n\nIn `src/janus/cli.py`, after the `usage_app` definition and registration (after line 68), add:\n\n```python\nbudgets_app = typer.Typer(help=\"Manage spending budgets\")\npricing_app = typer.Typer(help=\"View model pricing\")\napp.add_typer(budgets_app, name=\"budgets\")\napp.add_typer(pricing_app, name=\"pricing\")\n```\n\nAfter the `usage_stats` function (end of file), add:\n\n```python\n@budgets_app.command(\"list\")\ndef budgets_list(\n config: str = typer.Option(\"~/.janus/config.yaml\", \"--config\", \"-c\"),\n) -> None:\n \"\"\"List all active budgets.\"\"\"\n import asyncio\n\n from janus.storage.budgets import get_budget_status, get_budgets\n from janus.storage.database import init_db\n\n db_path = _get_db_path(config)\n asyncio.run(init_db(db_path))\n budgets = asyncio.run(get_budgets(db_path))\n if not budgets:\n typer.echo(\"No budgets found.\")\n return\n for b in budgets:\n status = asyncio.run(get_budget_status(db_path, key_id=b[\"key_id\"]))\n scope = f\"Key #{b['key_id']}\" if b[\"key_id\"] else \"Global\"\n spend_str = f\"${status['today_spend']:.2f}\" if status else \"—\"\n limit_str = f\"${b['daily_limit']:.2f}\"\n pct_str = f\"{status['pct_used']:.0f}%\" if status else \"—\"\n st = status[\"status\"] if status else \"—\"\n typer.echo(\n f\" {b['id']:>3} {scope:<15} {limit_str:>10} \"\n f\"spent: {spend_str:>10} {pct_str:>6} {st}\"\n )\n\n\n@budgets_app.command(\"set\")\ndef budgets_set(\n daily: float = typer.Option(..., \"--daily\", \"-d\", help=\"Daily limit in USD\"),\n key: str = typer.Option(\"global\", \"--key\", \"-k\", help=\"Key name or 'global'\"),\n warn: float = typer.Option(80, \"--warn\", \"-w\", help=\"Warn threshold percentage\"),\n config: str = typer.Option(\"~/.janus/config.yaml\", \"--config\", \"-c\"),\n) -> None:\n \"\"\"Create or update a budget.\"\"\"\n import asyncio\n\n from janus.storage.api_keys import list_keys\n from janus.storage.budgets import create_or_update_budget\n from janus.storage.database import init_db\n\n db_path = _get_db_path(config)\n asyncio.run(init_db(db_path))\n\n key_id: int | None = None\n if key != \"global\":\n keys = asyncio.run(list_keys(db_path))\n match = next((k for k in keys if k[\"name\"] == key), None)\n if match is None:\n typer.echo(f\"Key '{key}' not found.\")\n raise typer.Exit(1)\n key_id = match[\"id\"]\n\n budget_id = asyncio.run(\n create_or_update_budget(db_path, key_id=key_id, daily_limit=daily, warn_pct=warn)\n )\n scope = key if key == \"global\" else f\"key '{key}'\"\n typer.echo(f\"Budget {budget_id} set: {scope} daily limit = ${daily:.2f}, warn at {warn:.0f}%\")\n\n\n@budgets_app.command(\"delete\")\ndef budgets_delete(\n budget_id: int = typer.Argument(..., help=\"Budget ID to delete\"),\n config: str = typer.Option(\"~/.janus/config.yaml\", \"--config\", \"-c\"),\n) -> None:\n \"\"\"Delete a budget.\"\"\"\n import asyncio\n\n from janus.storage.budgets import delete_budget\n from janus.storage.database import init_db\n\n db_path = _get_db_path(config)\n asyncio.run(init_db(db_path))\n deleted = asyncio.run(delete_budget(db_path, budget_id))\n if deleted:\n typer.echo(f\"Deleted budget {budget_id}\")\n else:\n typer.echo(f\"Budget {budget_id} not found\")\n raise typer.Exit(1)\n```\n\n- [ ] **Step 2: Verify CLI loads**\n\nRun: `.venv/bin/janus budgets --help`\nExpected: Shows `list`, `set`, `delete` commands\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add src/janus/cli.py\ngit commit -m \"feat: CLI budgets sub-app (list/set/delete)\"\n```\n\n---\n\n## Task 20: CLI — Pricing Sub-App\n\n**Files:**\n- Modify: `src/janus/cli.py`\n\n- [ ] **Step 1: Add pricing sub-app commands**\n\nAfter the `budgets_delete` function added in Task 19, add:\n\n```python\n@pricing_app.command(\"list\")\ndef pricing_list(\n config: str = typer.Option(\"~/.janus/config.yaml\", \"--config\", \"-c\"),\n) -> None:\n \"\"\"List all known model pricing.\"\"\"\n from janus.config.loader import load_config\n from janus.pricing.registry import PricingRegistry\n\n cfg = load_config(Path(config).expanduser())\n reg = PricingRegistry(cfg.pricing)\n all_pricing = reg.get_all()\n for model in sorted(all_pricing.keys()):\n p = all_pricing[model]\n typer.echo(\n f\" {model:<40} \"\n f\"in: ${p.input_per_mtok:<6} \"\n f\"out: ${p.output_per_mtok:<6} \"\n f\"cc: ${p.cache_creation_per_mtok:<6} \"\n f\"cr: ${p.cache_read_per_mtok:<6}\"\n )\n\n\n@pricing_app.command(\"show\")\ndef pricing_show(\n model: str = typer.Argument(..., help=\"Model name\"),\n config: str = typer.Option(\"~/.janus/config.yaml\", \"--config\", \"-c\"),\n) -> None:\n \"\"\"Show pricing for a specific model.\"\"\"\n from janus.config.loader import load_config\n from janus.pricing.registry import PricingRegistry\n\n cfg = load_config(Path(config).expanduser())\n reg = PricingRegistry(cfg.pricing)\n p = reg.get(model)\n if p is None:\n typer.echo(f\"No pricing found for '{model}'\")\n raise typer.Exit(1)\n typer.echo(f\"Model: {model}\")\n typer.echo(f\" Input: ${p.input_per_mtok} / Mtok\")\n typer.echo(f\" Output: ${p.output_per_mtok} / Mtok\")\n typer.echo(f\" Cache creation: ${p.cache_creation_per_mtok} / Mtok\")\n typer.echo(f\" Cache read: ${p.cache_read_per_mtok} / Mtok\")\n```\n\n- [ ] **Step 2: Verify CLI loads**\n\nRun: `.venv/bin/janus pricing --help`\nExpected: Shows `list`, `show` commands\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add src/janus/cli.py\ngit commit -m \"feat: CLI pricing sub-app (list/show)\"\n```\n\n---\n\n## Task 21: CLI — Enhanced Usage Commands\n\n**Files:**\n- Modify: `src/janus/cli.py`\n\n- [ ] **Step 1: Add cost and by-key commands to usage sub-app**\n\nAfter the existing `usage_stats` function, add:\n\n```python\n@usage_app.command(\"cost\")\ndef usage_cost(\n days: int = typer.Option(30, \"--days\", \"-d\", help=\"Number of days to show\"),\n config: str = typer.Option(\"~/.janus/config.yaml\", \"--config\", \"-c\"),\n) -> None:\n \"\"\"Show cost breakdown by model.\"\"\"\n import asyncio\n\n from janus.storage.analytics import get_breakdown\n from janus.storage.database import init_db\n\n db_path = _get_db_path(config)\n asyncio.run(init_db(db_path))\n rows = asyncio.run(get_breakdown(db_path, dimension=\"model\", days=days))\n if not rows:\n typer.echo(\"No usage data.\")\n return\n total_cost = sum(r[\"cost\"] for r in rows)\n typer.echo(f\"Cost breakdown (last {days} days):\")\n typer.echo(f\"{'Model':<35} {'Requests':>8} {'Cost':>12}\")\n typer.echo(\"-\" * 58)\n for r in rows:\n typer.echo(f\" {r['model'] or '—':<33} {r['requests']:>8} ${r['cost']:>10.4f}\")\n typer.echo(\"-\" * 58)\n typer.echo(f\" {'Total':<33} {'':>8} ${total_cost:>10.4f}\")\n\n\n@usage_app.command(\"by-key\")\ndef usage_by_key(\n days: int = typer.Option(30, \"--days\", \"-d\", help=\"Number of days to show\"),\n config: str = typer.Option(\"~/.janus/config.yaml\", \"--config\", \"-c\"),\n) -> None:\n \"\"\"Show spending per client API key.\"\"\"\n import asyncio\n\n from janus.storage.analytics import get_breakdown\n from janus.storage.api_keys import list_keys\n from janus.storage.database import init_db\n\n db_path = _get_db_path(config)\n asyncio.run(init_db(db_path))\n rows = asyncio.run(get_breakdown(db_path, dimension=\"client_key\", days=days))\n keys = asyncio.run(list_keys(db_path))\n key_map = {k[\"id\"]: k[\"name\"] for k in keys}\n if not rows:\n typer.echo(\"No per-key usage data.\")\n return\n typer.echo(f\"Spending per key (last {days} days):\")\n typer.echo(f\"{'Key':<25} {'Requests':>8} {'Cost':>12}\")\n typer.echo(\"-\" * 48)\n for r in rows:\n name = key_map.get(r[\"client_key_id\"], f\"Key #{r['client_key_id']}\") if r[\"client_key_id\"] else \"No key\"\n typer.echo(f\" {name:<23} {r['requests']:>8} ${r['cost']:>10.4f}\")\n```\n\n- [ ] **Step 2: Verify CLI loads**\n\nRun: `.venv/bin/janus usage --help`\nExpected: Shows `stats`, `cost`, `by-key` commands\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add src/janus/cli.py\ngit commit -m \"feat: CLI usage cost and by-key commands\"\n```\n\n---\n\n## Task 22: Lint, Typecheck, Full Test Suite\n\n**Files:**\n- All files\n\n- [ ] **Step 1: Run ruff lint**\n\nRun: `.venv/bin/ruff check src/janus/ tests/`\nExpected: No errors. Fix any issues found.\n\n- [ ] **Step 2: Run ruff format check**\n\nRun: `.venv/bin/ruff format --check src/janus/ tests/`\nExpected: No errors. Fix any issues found.\n\n- [ ] **Step 3: Run mypy strict**\n\nRun: `.venv/bin/mypy src/janus/`\nExpected: No errors. Fix any type issues.\n\n- [ ] **Step 4: Run full test suite**\n\nRun: `.venv/bin/python -m pytest -v`\nExpected: All tests pass\n\n- [ ] **Step 5: Commit any fixes**\n\n```bash\ngit add -A\ngit commit -m \"fix: lint and typecheck cleanup for Phase 6\"\n```\n\n---\n\n## Task 23: Update AGENTS.md\n\n**Files:**\n- Modify: `AGENTS.md`\n\n- [ ] **Step 1: Add Phase 6 sections to AGENTS.md**\n\nAdd the following sections to `AGENTS.md` (before the Config section):\n\n```markdown\n## Pricing & cost tracking\n\nThe `pricing/` package provides per-model cost estimation. `PricingRegistry` merges builtin defaults (~30 popular models) with YAML overrides from the `pricing:` config section. Cost is computed at recording time via `compute_cost(usage, model, registry)` and stored in the `usage.cost` column. Unknown models cost $0.0 (not an error).\n\n- `pricing/builtin.py` — hardcoded `dict[str, ModelPricing]` seed data ($ per million tokens: input, output, cache_creation, cache_read).\n- `pricing/registry.py` — `PricingRegistry(overrides)`, does exact match then progressively shorter prefix matching for model variants.\n- `pricing/calculator.py` — `compute_cost(usage, model, registry) -> float`, pure function.\n- Config: `pricing:` section in YAML, same dict structure as builtin.\n\n## Budget enforcement\n\nBudgets are daily spending limits stored in the `budgets` SQLite table. Each budget targets either a specific API key (`key_id`) or is global (`key_id = NULL`). Enforcement happens in `_handle()` before routing:\n\n- **Warn threshold** (default 80%): request proceeds, dashboard shows amber.\n- **Hard threshold** (100%): request rejected with `429` + `Retry-After` header.\n- Both per-key and global budgets are checked; most restrictive wins.\n- Fail-safe: DB errors don't block requests.\n- `storage/budgets.py` — CRUD + `get_budget_status(key_id)`.\n- CLI: `janus budgets list/set/delete`.\n\n## Analytics\n\n`storage/analytics.py` provides aggregated queries: `get_spend_summary(days)`, `get_breakdown(dimension, days)`, `get_success_rate(days)`. The dashboard `/dashboard/analytics` page uses Chart.js (via CDN) for spend trends and success-rate donut charts. Breakdowns available by model, provider, account, or client key.\n```\n\n- [ ] **Step 2: Commit**\n\n```bash\ngit add AGENTS.md\ngit commit -m \"docs: update AGENTS.md for Phase 6 (pricing, budgets, analytics)\"\n```\n\n---\n\n## Task 24: Final Integration Test Run + Push\n\n- [ ] **Step 1: Run the complete test suite one final time**\n\nRun: `.venv/bin/python -m pytest -v`\nExpected: All tests pass, no skips on new tests\n\n- [ ] **Step 2: Run lint + typecheck one final time**\n\nRun: `.venv/bin/ruff check src/janus/ tests/ && .venv/bin/mypy src/janus/`\nExpected: Clean\n\n- [ ] **Step 3: Verify CLI commands work end-to-end**\n\nRun:\n```bash\n.venv/bin/janus pricing list\n.venv/bin/janus pricing show gpt-4o\n.venv/bin/janus budgets --help\n.venv/bin/janus usage --help\n```\nExpected: All commands produce output without errors\n\n- [ ] **Step 4: Push to main**\n\n```bash\ngit push origin main\n```\n"}, "a7a4df69b9460a613dcf325dae1aa7c3c9a72782": {"t": 1785749421.1298852, "n": 107634, "h": "d84480c6409996c3", "c": "# Phase 6: Quota & Usage Analytics Implementation Plan\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** Add cost estimation per request, rich analytics with charts, and daily budget enforcement with warn/block thresholds.\n\n**Architecture:** A new `pricing/` package provides builtin model pricing tables overridable via YAML. Cost is computed at recording time and stored alongside raw token counts (hybrid approach). A new `budgets` table and enforcement layer in `_handle()` rejects requests when daily spend limits are hit. The dashboard gains Chart.js-powered analytics and budget management pages.\n\n**Tech Stack:** Python 3.11, FastAPI, aiosqlite, Pydantic v2, Jinja2, HTMX, Chart.js (CDN), typer, pytest, respx\n\n---\n\n## Task 1: Pricing Models\n\n**Files:**\n- Create: `src/janus/pricing/__init__.py`\n- Create: `src/janus/pricing/models.py`\n\n- [ ] **Step 1: Create the pricing package init**\n\nCreate `src/janus/pricing/__init__.py` as an empty file.\n\n- [ ] **Step 2: Create the pricing model**\n\nCreate `src/janus/pricing/models.py`:\n\n```python\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\n\n\n@dataclass(frozen=True)\nclass ModelPricing:\n input_per_mtok: float\n output_per_mtok: float\n cache_creation_per_mtok: float\n cache_read_per_mtok: float\n```\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add src/janus/pricing/\ngit commit -m \"feat: add pricing models package\"\n```\n\n---\n\n## Task 2: Builtin Pricing Table\n\n**Files:**\n- Create: `src/janus/pricing/builtin.py`\n- Test: `tests/unit/pricing/__init__.py`\n- Test: `tests/unit/pricing/test_builtin.py`\n\n- [ ] **Step 1: Create test package init**\n\nCreate `tests/unit/pricing/__init__.py` as an empty file.\n\n- [ ] **Step 2: Write the failing test**\n\nCreate `tests/unit/pricing/test_builtin.py`:\n\n```python\nfrom janus.pricing.builtin import BUILTIN_PRICING\nfrom janus.pricing.models import ModelPricing\n\n\ndef test_builtin_has_popular_models():\n expected = [\n \"claude-sonnet-4-20250514\",\n \"claude-opus-4-20250514\",\n \"gpt-4o\",\n \"gpt-4o-mini\",\n \"gemini-2.0-flash\",\n ]\n for model in expected:\n assert model in BUILTIN_PRICING, f\"Missing builtin pricing for {model}\"\n\n\ndef test_all_pricing_rates_non_negative():\n for model, pricing in BUILTIN_PRICING.items():\n assert pricing.input_per_mtok >= 0, f\"{model}: negative input rate\"\n assert pricing.output_per_mtok >= 0, f\"{model}: negative output rate\"\n assert pricing.cache_creation_per_mtok >= 0, f\"{model}: negative cache_creation rate\"\n assert pricing.cache_read_per_mtok >= 0, f\"{model}: negative cache_read rate\"\n\n\ndef test_pricing_is_model_pricing_instances():\n for model, pricing in BUILTIN_PRICING.items():\n assert isinstance(pricing, ModelPricing), f\"{model}: not a ModelPricing instance\"\n```\n\n- [ ] **Step 3: Run test to verify it fails**\n\nRun: `.venv/bin/python -m pytest tests/unit/pricing/test_builtin.py -v`\nExpected: FAIL with `ModuleNotFoundError: No module named 'janus.pricing.builtin'`\n\n- [ ] **Step 4: Write builtin pricing data**\n\nCreate `src/janus/pricing/builtin.py`:\n\n```python\nfrom __future__ import annotations\n\nfrom .models import ModelPricing\n\nBUILTIN_PRICING: dict[str, ModelPricing] = {\n # Anthropic\n \"claude-opus-4-20250514\": ModelPricing(15.0, 75.0, 18.75, 1.5),\n \"claude-sonnet-4-20250514\": ModelPricing(3.0, 15.0, 3.75, 0.3),\n \"claude-3.7-sonnet-20250219\": ModelPricing(3.0, 15.0, 3.75, 0.3),\n \"claude-3-5-sonnet-20241022\": ModelPricing(3.0, 15.0, 3.75, 0.3),\n \"claude-3-5-haiku-20241022\": ModelPricing(0.8, 4.0, 1.0, 0.08),\n \"claude-3-opus-20240229\": ModelPricing(15.0, 75.0, 18.75, 1.5),\n # OpenAI\n \"gpt-4o\": ModelPricing(2.5, 10.0, 0.0, 1.25),\n \"gpt-4o-mini\": ModelPricing(0.15, 0.6, 0.0, 0.075),\n \"o3\": ModelPricing(10.0, 40.0, 0.0, 0.0),\n \"o4-mini\": ModelPricing(1.1, 4.4, 0.0, 0.0),\n \"gpt-4.1\": ModelPricing(2.0, 8.0, 0.0, 0.5),\n \"gpt-4.1-mini\": ModelPricing(0.4, 1.6, 0.0, 0.1),\n \"gpt-4.1-nano\": ModelPricing(0.1, 0.4, 0.0, 0.025),\n # Google\n \"gemini-2.5-pro\": ModelPricing(1.25, 10.0, 0.0, 0.31),\n \"gemini-2.0-flash\": ModelPricing(0.1, 0.4, 0.0, 0.025),\n \"gemini-2.0-flash-lite\": ModelPricing(0.075, 0.3, 0.0, 0.01875),\n \"gemini-1.5-pro\": ModelPricing(1.25, 5.0, 0.0, 0.3125),\n \"gemini-1.5-flash\": ModelPricing(0.075, 0.3, 0.0, 0.01875),\n \"gemini-1.5-flash-8b\": ModelPricing(0.0375, 0.15, 0.0, 0.009375),\n # DeepSeek\n \"deepseek-chat\": ModelPricing(0.27, 1.1, 0.0, 0.07),\n \"deepseek-reasoner\": ModelPricing(0.55, 2.19, 0.0, 0.14),\n # Meta / others\n \"llama-3.3-70b-instruct\": ModelPricing(0.6, 0.6, 0.0, 0.0),\n \"llama-3.1-405b-instruct\": ModelPricing(3.0, 3.0, 0.0, 0.0),\n \"mistral-large-2411\": ModelPricing(2.0, 6.0, 0.0, 0.5),\n \"qwen-max\": ModelPricing(1.6, 6.4, 0.0, 0.4),\n \"qwen-plus\": ModelPricing(0.4, 1.2, 0.0, 0.1),\n \"qwen-turbo\": ModelPricing(0.05, 0.2, 0.0, 0.0125),\n \"glm-4.7\": ModelPricing(0.6, 2.2, 0.0, 0.0),\n}\n```\n\n- [ ] **Step 5: Run test to verify it passes**\n\nRun: `.venv/bin/python -m pytest tests/unit/pricing/test_builtin.py -v`\nExpected: PASS (3 tests)\n\n- [ ] **Step 6: Commit**\n\n```bash\ngit add src/janus/pricing/builtin.py tests/unit/pricing/\ngit commit -m \"feat: add builtin pricing table for popular models\"\n```\n\n---\n\n## Task 3: Pricing Registry\n\n**Files:**\n- Create: `src/janus/pricing/registry.py`\n- Test: `tests/unit/pricing/test_registry.py`\n\n- [ ] **Step 1: Write the failing test**\n\nCreate `tests/unit/pricing/test_registry.py`:\n\n```python\nfrom janus.pricing.models import ModelPricing\nfrom janus.pricing.registry import PricingRegistry\n\n\ndef test_exact_match():\n overrides: dict[str, dict[str, float]] = {}\n reg = PricingRegistry(overrides)\n p = reg.get(\"gpt-4o\")\n assert p is not None\n assert p.input_per_mtok == 2.5\n assert p.output_per_mtok == 10.0\n\n\ndef test_user_override_replaces_builtin():\n overrides = {\n \"gpt-4o\": {\n \"input_per_mtok\": 5.0,\n \"output_per_mtok\": 20.0,\n \"cache_creation_per_mtok\": 0.0,\n \"cache_read_per_mtok\": 2.5,\n }\n }\n reg = PricingRegistry(overrides)\n p = reg.get(\"gpt-4o\")\n assert p is not None\n assert p.input_per_mtok == 5.0\n assert p.output_per_mtok == 20.0\n\n\ndef test_user_override_adds_new_model():\n overrides = {\n \"my-custom-model\": {\n \"input_per_mtok\": 1.0,\n \"output_per_mtok\": 2.0,\n \"cache_creation_per_mtok\": 0.0,\n \"cache_read_per_mtok\": 0.0,\n }\n }\n reg = PricingRegistry(overrides)\n p = reg.get(\"my-custom-model\")\n assert p is not None\n assert p.input_per_mtok == 1.0\n\n\ndef test_unknown_model_returns_none():\n reg = PricingRegistry({})\n assert reg.get(\"does-not-exist\") is None\n\n\ndef test_prefix_match_strips_date_suffix():\n reg = PricingRegistry({})\n p = reg.get(\"claude-sonnet-4-20250514-some-alias\")\n assert p is not None\n assert p.input_per_mtok == 3.0\n\n\ndef test_prefix_match_strips_latest():\n reg = PricingRegistry({})\n p = reg.get(\"gpt-4o-latest\")\n assert p is not None\n assert p.output_per_mtok == 10.0\n\n\ndef test_get_all_returns_merged_table():\n overrides = {\n \"custom\": {\n \"input_per_mtok\": 1.0,\n \"output_per_mtok\": 2.0,\n \"cache_creation_per_mtok\": 0.0,\n \"cache_read_per_mtok\": 0.0,\n }\n }\n reg = PricingRegistry(overrides)\n all_pricing = reg.get_all()\n assert \"gpt-4o\" in all_pricing\n assert \"custom\" in all_pricing\n assert isinstance(all_pricing[\"custom\"], ModelPricing)\n```\n\n- [ ] **Step 2: Run test to verify it fails**\n\nRun: `.venv/bin/python -m pytest tests/unit/pricing/test_registry.py -v`\nExpected: FAIL with `ModuleNotFoundError: No module named 'janus.pricing.registry'`\n\n- [ ] **Step 3: Write the registry implementation**\n\nCreate `src/janus/pricing/registry.py`:\n\n```python\nfrom __future__ import annotations\n\nfrom .builtin import BUILTIN_PRICING\nfrom .models import ModelPricing\n\n\nclass PricingRegistry:\n def __init__(self, user_overrides: dict[str, dict[str, float]]) -> None:\n self._table: dict[str, ModelPricing] = {**BUILTIN_PRICING}\n for model, rates in user_overrides.items():\n self._table[model] = ModelPricing(**rates)\n\n def get(self, model: str) -> ModelPricing | None:\n if model in self._table:\n return self._table[model]\n parts = model.split(\"-\")\n for i in range(len(parts) - 1, 0, -1):\n candidate = \"-\".join(parts[:i])\n if candidate in self._table:\n return self._table[candidate]\n return None\n\n def get_all(self) -> dict[str, ModelPricing]:\n return dict(self._table)\n```\n\n- [ ] **Step 4: Run test to verify it passes**\n\nRun: `.venv/bin/python -m pytest tests/unit/pricing/test_registry.py -v`\nExpected: PASS (7 tests)\n\n- [ ] **Step 5: Commit**\n\n```bash\ngit add src/janus/pricing/registry.py tests/unit/pricing/test_registry.py\ngit commit -m \"feat: add pricing registry with builtin + override merge\"\n```\n\n---\n\n## Task 4: Cost Calculator\n\n**Files:**\n- Create: `src/janus/pricing/calculator.py`\n- Test: `tests/unit/pricing/test_calculator.py`\n\n- [ ] **Step 1: Write the failing test**\n\nCreate `tests/unit/pricing/test_calculator.py`:\n\n```python\nfrom janus.canonical.models import Usage\nfrom janus.pricing.calculator import compute_cost\nfrom janus.pricing.registry import PricingRegistry\n\n\ndef test_basic_cost():\n overrides = {\n \"test-model\": {\n \"input_per_mtok\": 3.0,\n \"output_per_mtok\": 15.0,\n \"cache_creation_per_mtok\": 3.75,\n \"cache_read_per_mtok\": 0.3,\n }\n }\n reg = PricingRegistry(overrides)\n usage = Usage(input_tokens=1_000_000, output_tokens=500_000)\n cost = compute_cost(usage, \"test-model\", reg)\n assert cost == 3.0 + 7.5\n\n\ndef test_zero_tokens():\n reg = PricingRegistry({})\n usage = Usage()\n cost = compute_cost(usage, \"gpt-4o\", reg)\n assert cost == 0.0\n\n\ndef test_unknown_model():\n reg = PricingRegistry({})\n usage = Usage(input_tokens=1000, output_tokens=1000)\n cost = compute_cost(usage, \"totally-unknown\", reg)\n assert cost == 0.0\n\n\ndef test_cache_token_cost():\n overrides = {\n \"test-model\": {\n \"input_per_mtok\": 3.0,\n \"output_per_mtok\": 15.0,\n \"cache_creation_per_mtok\": 3.75,\n \"cache_read_per_mtok\": 0.3,\n }\n }\n reg = PricingRegistry(overrides)\n usage = Usage(\n input_tokens=1_000_000,\n output_tokens=0,\n cache_creation_input_tokens=500_000,\n cache_read_input_tokens=2_000_000,\n )\n cost = compute_cost(usage, \"test-model\", reg)\n assert abs(cost - (3.0 + 1.875 + 0.6)) < 0.0001\n\n\ndef test_partial_cache_only():\n overrides = {\n \"test-model\": {\n \"input_per_mtok\": 1.0,\n \"output_per_mtok\": 1.0,\n \"cache_creation_per_mtok\": 1.0,\n \"cache_read_per_mtok\": 1.0,\n }\n }\n reg = PricingRegistry(overrides)\n usage = Usage(input_tokens=100, cache_read_input_tokens=200)\n cost = compute_cost(usage, \"test-model\", reg)\n expected = (100 / 1_000_000 * 1.0) + (200 / 1_000_000 * 1.0)\n assert abs(cost - expected) < 0.0001\n```\n\n- [ ] **Step 2: Run test to verify it fails**\n\nRun: `.venv/bin/python -m pytest tests/unit/pricing/test_calculator.py -v`\nExpected: FAIL with `ModuleNotFoundError: No module named 'janus.pricing.calculator'`\n\n- [ ] **Step 3: Write the calculator implementation**\n\nCreate `src/janus/pricing/calculator.py`:\n\n```python\nfrom __future__ import annotations\n\nfrom janus.canonical.models import Usage\n\nfrom .registry import PricingRegistry\n\n\ndef compute_cost(usage: Usage, model: str, registry: PricingRegistry) -> float:\n pricing = registry.get(model)\n if pricing is None:\n return 0.0\n return (\n usage.input_tokens / 1_000_000 * pricing.input_per_mtok\n + usage.output_tokens / 1_000_000 * pricing.output_per_mtok\n + usage.cache_creation_input_tokens / 1_000_000 * pricing.cache_creation_per_mtok\n + usage.cache_read_input_tokens / 1_000_000 * pricing.cache_read_per_mtok\n )\n```\n\n- [ ] **Step 4: Run test to verify it passes**\n\nRun: `.venv/bin/python -m pytest tests/unit/pricing/test_calculator.py -v`\nExpected: PASS (5 tests)\n\n- [ ] **Step 5: Commit**\n\n```bash\ngit add src/janus/pricing/calculator.py tests/unit/pricing/test_calculator.py\ngit commit -m \"feat: add cost calculator with cache token pricing\"\n```\n\n---\n\n## Task 5: Config Schema — Add Pricing Field\n\n**Files:**\n- Modify: `src/janus/config/schema.py:40-45`\n\n- [ ] **Step 1: Write the failing test**\n\nAdd to a new file `tests/unit/config/test_pricing_config.py`:\n\n```python\nfrom janus.config.schema import JanusConfig\n\n\ndef test_pricing_defaults_empty():\n cfg = JanusConfig()\n assert cfg.pricing == {}\n\n\ndef test_pricing_accepts_overrides():\n cfg = JanusConfig(\n pricing={\n \"custom-model\": {\n \"input_per_mtok\": 1.0,\n \"output_per_mtok\": 2.0,\n \"cache_creation_per_mtok\": 0.5,\n \"cache_read_per_mtok\": 0.1,\n }\n }\n )\n assert \"custom-model\" in cfg.pricing\n assert cfg.pricing[\"custom-model\"][\"input_per_mtok\"] == 1.0\n```\n\n- [ ] **Step 2: Run test to verify it fails**\n\nRun: `.venv/bin/python -m pytest tests/unit/config/test_pricing_config.py -v`\nExpected: FAIL with `AttributeError: ... object has no attribute 'pricing'` or similar\n\n- [ ] **Step 3: Add pricing field to JanusConfig**\n\nIn `src/janus/config/schema.py`, add the `pricing` field to the `JanusConfig` class:\n\n```python\nclass JanusConfig(BaseModel):\n server: ServerSettings = Field(default_factory=ServerSettings)\n providers: list[ProviderConfig] = Field(default_factory=list)\n combos: list[ComboConfig] = Field(default_factory=list)\n api_keys: list[str] = Field(default_factory=list)\n token_savers: TokenSaverConfig = Field(default_factory=TokenSaverConfig)\n pricing: dict[str, dict[str, float]] = Field(default_factory=dict)\n```\n\n- [ ] **Step 4: Run test to verify it passes**\n\nRun: `.venv/bin/python -m pytest tests/unit/config/test_pricing_config.py -v`\nExpected: PASS (2 tests)\n\n- [ ] **Step 5: Commit**\n\n```bash\ngit add src/janus/config/schema.py tests/unit/config/test_pricing_config.py\ngit commit -m \"feat: add pricing config field\"\n```\n\n---\n\n## Task 6: DB Schema Migration — New Columns + Budgets Table\n\n**Files:**\n- Modify: `src/janus/storage/database.py`\n- Test: `tests/unit/storage/test_migration.py`\n\n- [ ] **Step 1: Write the failing test**\n\nCreate `tests/unit/storage/test_migration.py`:\n\n```python\nimport aiosqlite\nimport pytest\n\nfrom janus.storage.database import init_db\n\n\n@pytest.mark.asyncio\nasync def test_usage_table_has_new_columns(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n async with aiosqlite.connect(str(db_path)) as db:\n async with db.execute(\"PRAGMA table_info(usage)\") as cur:\n rows = await cur.fetchall()\n columns = {row[1] for row in rows}\n assert \"cost\" in columns\n assert \"cache_creation_tokens\" in columns\n assert \"cache_read_tokens\" in columns\n assert \"client_key_id\" in columns\n\n\n@pytest.mark.asyncio\nasync def test_budgets_table_exists(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n async with aiosqlite.connect(str(db_path)) as db:\n async with db.execute(\"PRAGMA table_info(budgets)\") as cur:\n rows = await cur.fetchall()\n columns = {row[1] for row in rows}\n assert \"id\" in columns\n assert \"key_id\" in columns\n assert \"daily_limit\" in columns\n assert \"warn_pct\" in columns\n assert \"is_active\" in columns\n assert \"created_at\" in columns\n\n\n@pytest.mark.asyncio\nasync def test_migration_is_idempotent(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await init_db(db_path)\n async with aiosqlite.connect(str(db_path)) as db:\n async with db.execute(\"PRAGMA table_info(usage)\") as cur:\n rows = await cur.fetchall()\n column_names = [row[1] for row in rows]\n assert column_names.count(\"cost\") == 1\n assert column_names.count(\"cache_creation_tokens\") == 1\n```\n\n- [ ] **Step 2: Run test to verify it fails**\n\nRun: `.venv/bin/python -m pytest tests/unit/storage/test_migration.py -v`\nExpected: FAIL (columns not found, budgets table not found)\n\n- [ ] **Step 3: Update database.py with migration**\n\nReplace the entire contents of `src/janus/storage/database.py`:\n\n```python\nfrom __future__ import annotations\n\nfrom collections.abc import AsyncIterator\nfrom contextlib import asynccontextmanager\nfrom pathlib import Path\n\nimport aiosqlite\n\n_SCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS api_keys (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n name TEXT NOT NULL,\n key_hash TEXT NOT NULL UNIQUE,\n prefix TEXT NOT NULL,\n is_active INTEGER NOT NULL DEFAULT 1,\n created_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\nCREATE TABLE IF NOT EXISTS usage (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n timestamp TEXT NOT NULL DEFAULT (datetime('now')),\n provider_id TEXT,\n model TEXT,\n account_id TEXT,\n input_tokens INTEGER DEFAULT 0,\n output_tokens INTEGER DEFAULT 0,\n status INTEGER\n);\n\nCREATE TABLE IF NOT EXISTS budgets (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n key_id INTEGER,\n daily_limit REAL NOT NULL,\n warn_pct REAL DEFAULT 80,\n is_active INTEGER NOT NULL DEFAULT 1,\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n FOREIGN KEY (key_id) REFERENCES api_keys(id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_usage_model ON usage(model);\nCREATE INDEX IF NOT EXISTS idx_usage_ts ON usage(timestamp);\nCREATE INDEX IF NOT EXISTS idx_usage_cost_key ON usage(client_key_id, date(timestamp));\nCREATE INDEX IF NOT EXISTS idx_usage_provider ON usage(provider_id);\n\"\"\"\n\n_NEW_USAGE_COLUMNS = [\n (\"cost\", \"REAL DEFAULT 0.0\"),\n (\"cache_creation_tokens\", \"INTEGER DEFAULT 0\"),\n (\"cache_read_tokens\", \"INTEGER DEFAULT 0\"),\n (\"client_key_id\", \"INTEGER\"),\n]\n\n\nasync def _migrate_usage_columns(db: aiosqlite.Connection) -> None:\n cursor = await db.execute(\"PRAGMA table_info(usage)\")\n rows = await cursor.fetchall()\n existing = {row[1] for row in rows}\n for col, col_type in _NEW_USAGE_COLUMNS:\n if col not in existing:\n await db.execute(f\"ALTER TABLE usage ADD COLUMN {col} {col_type}\")\n\n\nasync def init_db(db_path: str | Path) -> None:\n db_path = Path(db_path)\n db_path.parent.mkdir(parents=True, exist_ok=True)\n async with aiosqlite.connect(str(db_path)) as db:\n await db.executescript(_SCHEMA)\n await _migrate_usage_columns(db)\n await db.commit()\n\n\n@asynccontextmanager\nasync def get_connection(db_path: str | Path) -> AsyncIterator[aiosqlite.Connection]:\n async with aiosqlite.connect(str(db_path)) as db:\n db.row_factory = aiosqlite.Row\n yield db\n```\n\n- [ ] **Step 4: Run test to verify it passes**\n\nRun: `.venv/bin/python -m pytest tests/unit/storage/test_migration.py -v`\nExpected: PASS (3 tests)\n\n- [ ] **Step 5: Run existing storage tests to ensure no regression**\n\nRun: `.venv/bin/python -m pytest tests/unit/storage/ -v`\nExpected: All pass\n\n- [ ] **Step 6: Commit**\n\n```bash\ngit add src/janus/storage/database.py tests/unit/storage/test_migration.py\ngit commit -m \"feat: add DB migration for cost, cache tokens, client_key_id, budgets table\"\n```\n\n---\n\n## Task 7: Update record_usage() with New Parameters\n\n**Files:**\n- Modify: `src/janus/storage/usage.py`\n- Modify: `tests/unit/storage/test_usage.py`\n\n- [ ] **Step 1: Update existing tests and add new ones**\n\nReplace `tests/unit/storage/test_usage.py`:\n\n```python\nimport pytest\n\nfrom janus.storage.database import init_db\nfrom janus.storage.usage import get_usage_stats, record_usage\n\n\n@pytest.mark.asyncio\nasync def test_record_usage(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await record_usage(\n db_path,\n provider_id=\"glm\",\n model=\"glm-4.7\",\n input_tokens=100,\n output_tokens=50,\n status=200,\n )\n stats = await get_usage_stats(db_path)\n assert stats[\"total_requests\"] == 1\n assert stats[\"total_input_tokens\"] == 100\n assert stats[\"total_output_tokens\"] == 50\n\n\n@pytest.mark.asyncio\nasync def test_record_multiple_usage(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await record_usage(\n db_path,\n provider_id=\"glm\",\n model=\"glm-4.7\",\n input_tokens=100,\n output_tokens=50,\n status=200,\n )\n await record_usage(\n db_path,\n provider_id=\"an\",\n model=\"claude\",\n input_tokens=200,\n output_tokens=100,\n status=200,\n )\n await record_usage(\n db_path,\n provider_id=\"glm\",\n model=\"glm-4.7\",\n input_tokens=50,\n output_tokens=25,\n status=429,\n )\n stats = await get_usage_stats(db_path)\n assert stats[\"total_requests\"] == 3\n assert stats[\"total_input_tokens\"] == 350\n assert stats[\"total_output_tokens\"] == 175\n\n\n@pytest.mark.asyncio\nasync def test_usage_stats_by_model(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await record_usage(\n db_path,\n provider_id=\"glm\",\n model=\"glm-4.7\",\n input_tokens=100,\n output_tokens=50,\n status=200,\n )\n await record_usage(\n db_path,\n provider_id=\"glm\",\n model=\"glm-4.7\",\n input_tokens=200,\n output_tokens=100,\n status=200,\n )\n await record_usage(\n db_path,\n provider_id=\"an\",\n model=\"claude\",\n input_tokens=50,\n output_tokens=25,\n status=200,\n )\n stats = await get_usage_stats(db_path)\n by_model = {m[\"model\"]: m for m in stats[\"by_model\"]}\n assert by_model[\"glm-4.7\"][\"requests\"] == 2\n assert by_model[\"glm-4.7\"][\"input_tokens\"] == 300\n assert by_model[\"claude\"][\"requests\"] == 1\n\n\n@pytest.mark.asyncio\nasync def test_record_usage_with_cost_and_cache(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await record_usage(\n db_path,\n provider_id=\"an\",\n model=\"claude-sonnet-4-20250514\",\n account_id=\"an-0\",\n input_tokens=1000,\n output_tokens=500,\n cache_creation_tokens=200,\n cache_read_tokens=800,\n status=200,\n client_key_id=1,\n cost=0.015,\n )\n stats = await get_usage_stats(db_path)\n assert stats[\"total_requests\"] == 1\n\n\n@pytest.mark.asyncio\nasync def test_record_usage_defaults_backward_compatible(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await record_usage(\n db_path,\n provider_id=\"glm\",\n model=\"glm-4.7\",\n input_tokens=100,\n output_tokens=50,\n status=200,\n )\n stats = await get_usage_stats(db_path)\n assert stats[\"total_requests\"] == 1\n```\n\n- [ ] **Step 2: Run tests to verify new tests fail**\n\nRun: `.venv/bin/python -m pytest tests/unit/storage/test_usage.py -v`\nExpected: FAIL (cost/cache params not accepted)\n\n- [ ] **Step 3: Update record_usage()**\n\nReplace `src/janus/storage/usage.py`:\n\n```python\nfrom __future__ import annotations\n\nimport logging\nfrom pathlib import Path\nfrom typing import Any\n\nfrom .database import get_connection\n\nlogger = logging.getLogger(__name__)\n\n\nasync def record_usage(\n db_path: str | Path,\n *,\n provider_id: str | None = None,\n model: str | None = None,\n account_id: str | None = None,\n input_tokens: int = 0,\n output_tokens: int = 0,\n cache_creation_tokens: int = 0,\n cache_read_tokens: int = 0,\n status: int = 0,\n client_key_id: int | None = None,\n cost: float = 0.0,\n) -> None:\n try:\n async with get_connection(db_path) as db:\n await db.execute(\n \"\"\"INSERT INTO usage\n (provider_id, model, account_id, input_tokens, output_tokens,\n cache_creation_tokens, cache_read_tokens, status, client_key_id, cost)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\"\"\",\n (\n provider_id,\n model,\n account_id,\n input_tokens,\n output_tokens,\n cache_creation_tokens,\n cache_read_tokens,\n status,\n client_key_id,\n cost,\n ),\n )\n await db.commit()\n except Exception as e:\n logger.warning(\"Failed to record usage: %s\", e)\n\n\nasync def get_usage_stats(db_path: str | Path) -> dict[str, Any]:\n async with get_connection(db_path) as db:\n async with db.execute(\n \"SELECT COUNT(*) as cnt, COALESCE(SUM(input_tokens),0) as inp,\"\n \"COALESCE(SUM(output_tokens),0) as outp FROM usage\"\n ) as cur:\n row = await cur.fetchone()\n assert row is not None\n total_requests = row[\"cnt\"]\n total_input = row[\"inp\"]\n total_output = row[\"outp\"]\n\n async with db.execute(\n \"\"\"SELECT model, COUNT(*) as requests,\n COALESCE(SUM(input_tokens),0) as input_tokens,\n COALESCE(SUM(output_tokens),0) as output_tokens\n FROM usage GROUP BY model ORDER BY requests DESC\"\"\"\n ) as cur:\n model_rows = await cur.fetchall()\n\n return {\n \"total_requests\": total_requests,\n \"total_input_tokens\": total_input,\n \"total_output_tokens\": total_output,\n \"by_model\": [dict(r) for r in model_rows],\n }\n```\n\n- [ ] **Step 4: Run tests to verify they pass**\n\nRun: `.venv/bin/python -m pytest tests/unit/storage/test_usage.py -v`\nExpected: PASS (5 tests)\n\n- [ ] **Step 5: Commit**\n\n```bash\ngit add src/janus/storage/usage.py tests/unit/storage/test_usage.py\ngit commit -m \"feat: update record_usage with cost, cache tokens, client_key_id\"\n```\n\n---\n\n## Task 8: Update verify_key() to Return key_id\n\n**Files:**\n- Modify: `src/janus/storage/api_keys.py:30-40`\n- Test: `tests/unit/storage/test_api_keys.py`\n\n- [ ] **Step 1: Write the failing test**\n\nAdd to `tests/unit/storage/test_api_keys.py` (create if it does not exist, or append):\n\n```python\nimport pytest\n\nfrom janus.storage.api_keys import create_key, verify_key\nfrom janus.storage.database import init_db\n\n\n@pytest.mark.asyncio\nasync def test_verify_key_returns_id(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n raw_key, record = await create_key(db_path, name=\"test\")\n result = await verify_key(db_path, raw_key)\n assert result == record[\"id\"]\n\n\n@pytest.mark.asyncio\nasync def test_verify_key_returns_none_for_invalid(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n result = await verify_key(db_path, \"sk-janus-deadbeef\")\n assert result is None\n\n\n@pytest.mark.asyncio\nasync def test_verify_revoked_key_returns_none(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n raw_key, record = await create_key(db_path, name=\"test\")\n from janus.storage.api_keys import revoke_key\n await revoke_key(db_path, record[\"id\"])\n result = await verify_key(db_path, raw_key)\n assert result is None\n```\n\n- [ ] **Step 2: Run test to verify it fails**\n\nRun: `.venv/bin/python -m pytest tests/unit/storage/test_api_keys.py -v`\nExpected: FAIL (verify_key returns bool, not int)\n\n- [ ] **Step 3: Update verify_key()**\n\nIn `src/janus/storage/api_keys.py`, replace the `verify_key` function:\n\n```python\nasync def verify_key(db_path: str | Path, key: str) -> int | None:\n if not key.startswith(\"sk-janus-\"):\n return None\n key_hash = _hash_key(key)\n async with get_connection(db_path) as db:\n async with db.execute(\n \"SELECT id FROM api_keys WHERE key_hash = ? AND is_active = 1\",\n (key_hash,),\n ) as cur:\n row = await cur.fetchone()\n if row is None:\n return None\n return row[\"id\"]\n```\n\n- [ ] **Step 4: Run test to verify it passes**\n\nRun: `.venv/bin/python -m pytest tests/unit/storage/test_api_keys.py -v`\nExpected: PASS\n\n- [ ] **Step 5: Commit**\n\n```bash\ngit add src/janus/storage/api_keys.py tests/unit/storage/test_api_keys.py\ngit commit -m \"feat: verify_key returns key_id instead of bool\"\n```\n\n---\n\n## Task 9: Update Auth Dependency to Expose key_id\n\n**Files:**\n- Modify: `src/janus/api/deps.py`\n\n- [ ] **Step 1: Write the failing integration test**\n\nCreate `tests/integration/test_key_id_passthrough.py`:\n\n```python\nimport pytest\nfrom httpx import ASGITransport, AsyncClient\n\nfrom janus.app import create_app\nfrom janus.config.schema import JanusConfig, ProviderConfig, ServerSettings\nfrom janus.providers.registry import ProviderRegistry\nfrom janus.storage.api_keys import create_key\nfrom janus.storage.database import init_db\n\n\n@pytest.mark.asyncio\nasync def test_request_with_db_key_stores_key_id(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n\n registry = ProviderRegistry()\n config = JanusConfig(\n server=ServerSettings(require_api_key=True, data_dir=tmp_path),\n )\n app = create_app(registry=registry, config=config)\n app.state.db_path = db_path\n\n raw_key, record = await create_key(db_path, name=\"test\")\n\n transport = ASGITransport(app=app)\n async with AsyncClient(transport=transport, base_url=\"http://test\") as client:\n await client.post(\n \"/v1/chat/completions\",\n json={\"model\": \"nonexistent\", \"messages\": [{\"role\": \"user\", \"content\": \"hi\"}]},\n headers={\"Authorization\": f\"Bearer {raw_key}\"},\n )\n\n import aiosqlite\n async with aiosqlite.connect(str(db_path)) as db:\n db.row_factory = aiosqlite.Row\n async with db.execute(\"SELECT client_key_id FROM usage\") as cur:\n rows = await cur.fetchall()\n if rows:\n assert rows[0][\"client_key_id\"] == record[\"id\"]\n```\n\n- [ ] **Step 2: Run test to verify it fails or has no key_id stored**\n\nRun: `.venv/bin/python -m pytest tests/integration/test_key_id_passthrough.py -v`\nExpected: FAIL or client_key_id is NULL (auth dependency doesn't pass key_id)\n\n- [ ] **Step 3: Update deps.py to expose key_id**\n\nReplace `src/janus/api/deps.py`:\n\n```python\nfrom __future__ import annotations\n\nfrom fastapi import Header, HTTPException, Request\n\n\nasync def require_api_key(request: Request, authorization: str = Header(default=\"\")) -> None:\n config = request.app.state.config\n if not config.server.require_api_key:\n return\n if authorization.startswith(\"Bearer \"):\n key = authorization[7:]\n if key in config.api_keys:\n return\n from janus.storage.api_keys import verify_key\n\n db_path = request.app.state.db_path\n key_id = await verify_key(db_path, key)\n if key_id is not None:\n request.state.client_key_id = key_id\n return\n raise HTTPException(status_code=401, detail=\"Invalid API key\")\n```\n\n- [ ] **Step 4: Commit (will wire this into routes in Task 12)**\n\n```bash\ngit add src/janus/api/deps.py tests/integration/test_key_id_passthrough.py\ngit commit -m \"feat: auth dependency exposes resolved key_id on request state\"\n```\n\n---\n\n## Task 10: Analytics Query Layer\n\n**Files:**\n- Create: `src/janus/storage/analytics.py`\n- Create: `tests/unit/storage/test_analytics.py`\n- Create: `tests/fixtures/__init__.py`\n- Create: `tests/fixtures/usage_seed.py`\n\n- [ ] **Step 1: Create fixtures package**\n\nCreate `tests/fixtures/__init__.py` as an empty file.\n\n- [ ] **Step 2: Create usage seed helper**\n\nCreate `tests/fixtures/usage_seed.py`:\n\n```python\nfrom __future__ import annotations\n\nimport datetime\nfrom pathlib import Path\n\nfrom janus.storage.database import get_connection\n\n\nasync def seed_usage(\n db_path: str | Path,\n rows: list[dict],\n) -> None:\n for row in rows:\n ts = row.get(\"timestamp\", datetime.datetime.now().isoformat())\n async with get_connection(db_path) as db:\n await db.execute(\n \"\"\"INSERT INTO usage\n (timestamp, provider_id, model, account_id,\n input_tokens, output_tokens, cache_creation_tokens,\n cache_read_tokens, status, client_key_id, cost)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\"\"\",\n (\n ts,\n row.get(\"provider_id\"),\n row.get(\"model\"),\n row.get(\"account_id\"),\n row.get(\"input_tokens\", 0),\n row.get(\"output_tokens\", 0),\n row.get(\"cache_creation_tokens\", 0),\n row.get(\"cache_read_tokens\", 0),\n row.get(\"status\", 200),\n row.get(\"client_key_id\"),\n row.get(\"cost\", 0.0),\n ),\n )\n await db.commit()\n```\n\n- [ ] **Step 3: Write the failing tests**\n\nCreate `tests/unit/storage/test_analytics.py`:\n\n```python\nimport datetime\n\nimport pytest\n\nfrom janus.storage.analytics import (\n get_breakdown,\n get_spend_summary,\n get_success_rate,\n)\nfrom janus.storage.database import init_db\nfrom tests.fixtures.usage_seed import seed_usage\n\n\ndef _ts(days_ago: int) -> str:\n return (datetime.datetime.now() - datetime.timedelta(days=days_ago)).isoformat()\n\n\n@pytest.mark.asyncio\nasync def test_get_spend_summary_empty(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n result = await get_spend_summary(db_path, days=30)\n assert result[\"total_cost\"] == 0.0\n assert result[\"total_requests\"] == 0\n assert result[\"daily\"] == []\n\n\n@pytest.mark.asyncio\nasync def test_get_spend_summary_with_data(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await seed_usage(\n db_path,\n [\n {\"timestamp\": _ts(0), \"model\": \"gpt-4o\", \"input_tokens\": 1000, \"output_tokens\": 500, \"cost\": 0.01, \"status\": 200},\n {\"timestamp\": _ts(1), \"model\": \"gpt-4o\", \"input_tokens\": 2000, \"output_tokens\": 1000, \"cost\": 0.02, \"status\": 200},\n {\"timestamp\": _ts(0), \"model\": \"claude-sonnet-4-20250514\", \"input_tokens\": 500, \"output_tokens\": 250, \"cost\": 0.005, \"status\": 500},\n ],\n )\n result = await get_spend_summary(db_path, days=30)\n assert result[\"total_requests\"] == 3\n assert abs(result[\"total_cost\"] - 0.035) < 0.0001\n assert result[\"total_input_tokens\"] == 3500\n assert result[\"total_output_tokens\"] == 1750\n assert len(result[\"daily\"]) >= 1\n\n\n@pytest.mark.asyncio\nasync def test_get_breakdown_by_model(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await seed_usage(\n db_path,\n [\n {\"timestamp\": _ts(0), \"model\": \"gpt-4o\", \"input_tokens\": 1000, \"output_tokens\": 500, \"cost\": 0.01, \"status\": 200},\n {\"timestamp\": _ts(0), \"model\": \"gpt-4o\", \"input_tokens\": 500, \"output_tokens\": 250, \"cost\": 0.005, \"status\": 200},\n {\"timestamp\": _ts(0), \"model\": \"claude\", \"input_tokens\": 300, \"output_tokens\": 100, \"cost\": 0.003, \"status\": 200},\n ],\n )\n result = await get_breakdown(db_path, dimension=\"model\", days=30)\n assert len(result) == 2\n gpt = [r for r in result if r[\"model\"] == \"gpt-4o\"][0]\n assert gpt[\"requests\"] == 2\n assert abs(gpt[\"cost\"] - 0.015) < 0.0001\n\n\n@pytest.mark.asyncio\nasync def test_get_breakdown_by_provider(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await seed_usage(\n db_path,\n [\n {\"timestamp\": _ts(0), \"provider_id\": \"openai\", \"model\": \"gpt-4o\", \"cost\": 0.01, \"status\": 200},\n {\"timestamp\": _ts(0), \"provider_id\": \"anthropic\", \"model\": \"claude\", \"cost\": 0.02, \"status\": 200},\n ],\n )\n result = await get_breakdown(db_path, dimension=\"provider\", days=30)\n assert len(result) == 2\n\n\n@pytest.mark.asyncio\nasync def test_get_success_rate(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await seed_usage(\n db_path,\n [\n {\"timestamp\": _ts(0), \"model\": \"gpt-4o\", \"status\": 200},\n {\"timestamp\": _ts(0), \"model\": \"gpt-4o\", \"status\": 200},\n {\"timestamp\": _ts(0), \"model\": \"gpt-4o\", \"status\": 500},\n {\"timestamp\": _ts(0), \"model\": \"gpt-4o\", \"status\": 429},\n ],\n )\n result = await get_success_rate(db_path, days=30)\n assert result[\"success_2xx\"] == 2\n assert result[\"client_4xx\"] == 1\n assert result[\"server_5xx\"] == 1\n assert result[\"total\"] == 4\n```\n\n- [ ] **Step 4: Run tests to verify they fail**\n\nRun: `.venv/bin/python -m pytest tests/unit/storage/test_analytics.py -v`\nExpected: FAIL (ModuleNotFoundError)\n\n- [ ] **Step 5: Write the analytics implementation**\n\nCreate `src/janus/storage/analytics.py`:\n\n```python\nfrom __future__ import annotations\n\nfrom pathlib import Path\nfrom typing import Any, Literal\n\nfrom .database import get_connection\n\nDimension = Literal[\"model\", \"provider\", \"account\", \"client_key\"]\n\n_DIMENSION_COLUMN = {\n \"model\": \"model\",\n \"provider\": \"provider_id\",\n \"account\": \"account_id\",\n \"client_key\": \"client_key_id\",\n}\n\n\nasync def get_spend_summary(db_path: str | Path, *, days: int = 30) -> dict[str, Any]:\n async with get_connection(db_path) as db:\n async with db.execute(\n \"\"\"SELECT COUNT(*) as cnt,\n COALESCE(SUM(input_tokens), 0) as inp,\n COALESCE(SUM(output_tokens), 0) as outp,\n COALESCE(SUM(cache_creation_tokens), 0) as cc,\n COALESCE(SUM(cache_read_tokens), 0) as cr,\n COALESCE(SUM(cost), 0.0) as cost\n FROM usage\n WHERE timestamp >= datetime('now', ?)\"\"\",\n (f\"-{days} days\",),\n ) as cur:\n row = await cur.fetchone()\n assert row is not None\n\n async with db.execute(\n \"\"\"SELECT date(timestamp) as date,\n COUNT(*) as requests,\n COALESCE(SUM(cost), 0.0) as cost\n FROM usage\n WHERE timestamp >= datetime('now', ?)\n GROUP BY date(timestamp)\n ORDER BY date(timestamp)\"\"\",\n (f\"-{days} days\",),\n ) as cur:\n daily_rows = await cur.fetchall()\n\n return {\n \"total_cost\": row[\"cost\"],\n \"total_requests\": row[\"cnt\"],\n \"total_input_tokens\": row[\"inp\"],\n \"total_output_tokens\": row[\"outp\"],\n \"total_cache_creation_tokens\": row[\"cc\"],\n \"total_cache_read_tokens\": row[\"cr\"],\n \"daily\": [dict(r) for r in daily_rows],\n }\n\n\nasync def get_breakdown(\n db_path: str | Path, *, dimension: Dimension, days: int = 30\n) -> list[dict[str, Any]]:\n col = _DIMENSION_COLUMN[dimension]\n async with get_connection(db_path) as db:\n async with db.execute(\n f\"\"\"SELECT {col} as {dimension},\n COUNT(*) as requests,\n COALESCE(SUM(input_tokens), 0) as input_tokens,\n COALESCE(SUM(output_tokens), 0) as output_tokens,\n COALESCE(SUM(cache_creation_tokens), 0) as cache_creation_tokens,\n COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens,\n COALESCE(SUM(cost), 0.0) as cost\n FROM usage\n WHERE timestamp >= datetime('now', ?)\n GROUP BY {col}\n ORDER BY cost DESC\"\"\",\n (f\"-{days} days\",),\n ) as cur:\n rows = await cur.fetchall()\n return [dict(r) for r in rows]\n\n\nasync def get_success_rate(db_path: str | Path, *, days: int = 30) -> dict[str, Any]:\n async with get_connection(db_path) as db:\n async with db.execute(\n \"\"\"SELECT\n SUM(CASE WHEN status >= 200 AND status < 300 THEN 1 ELSE 0 END) as s2xx,\n SUM(CASE WHEN status >= 400 AND status < 500 THEN 1 ELSE 0 END) as s4xx,\n SUM(CASE WHEN status >= 500 THEN 1 ELSE 0 END) as s5xx,\n COUNT(*) as total\n FROM usage\n WHERE timestamp >= datetime('now', ?)\"\"\",\n (f\"-{days} days\",),\n ) as cur:\n row = await cur.fetchone()\n assert row is not None\n return {\n \"success_2xx\": row[\"s2xx\"] or 0,\n \"client_4xx\": row[\"s4xx\"] or 0,\n \"server_5xx\": row[\"s5xx\"] or 0,\n \"total\": row[\"total\"] or 0,\n }\n```\n\n- [ ] **Step 6: Run tests to verify they pass**\n\nRun: `.venv/bin/python -m pytest tests/unit/storage/test_analytics.py -v`\nExpected: PASS (5 tests)\n\n- [ ] **Step 7: Commit**\n\n```bash\ngit add src/janus/storage/analytics.py tests/unit/storage/test_analytics.py tests/fixtures/\ngit commit -m \"feat: add analytics query layer (spend summary, breakdown, success rate)\"\n```\n\n---\n\n## Task 11: Budget CRUD + Status\n\n**Files:**\n- Create: `src/janus/storage/budgets.py`\n- Create: `tests/unit/storage/test_budgets.py`\n\n- [ ] **Step 1: Write the failing tests**\n\nCreate `tests/unit/storage/test_budgets.py`:\n\n```python\nimport datetime\n\nimport pytest\n\nfrom janus.storage.budgets import (\n create_or_update_budget,\n delete_budget,\n get_budget_status,\n get_budgets,\n)\nfrom janus.storage.database import init_db\nfrom tests.fixtures.usage_seed import seed_usage\n\n\ndef _ts(days_ago: int) -> str:\n return (datetime.datetime.now() - datetime.timedelta(days=days_ago)).isoformat()\n\n\n@pytest.mark.asyncio\nasync def test_create_budget(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n budget_id = await create_or_update_budget(db_path, key_id=None, daily_limit=5.0, warn_pct=80)\n budgets = await get_budgets(db_path)\n assert len(budgets) == 1\n assert budgets[0][\"daily_limit\"] == 5.0\n assert budgets[0][\"id\"] == budget_id\n\n\n@pytest.mark.asyncio\nasync def test_update_budget_replaces(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await create_or_update_budget(db_path, key_id=None, daily_limit=5.0, warn_pct=80)\n await create_or_update_budget(db_path, key_id=None, daily_limit=10.0, warn_pct=90)\n budgets = await get_budgets(db_path)\n assert len(budgets) == 1\n assert budgets[0][\"daily_limit\"] == 10.0\n assert budgets[0][\"warn_pct\"] == 90\n\n\n@pytest.mark.asyncio\nasync def test_budget_status_no_budget(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n status = await get_budget_status(db_path, key_id=None)\n assert status is None\n\n\n@pytest.mark.asyncio\nasync def test_budget_status_ok(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await create_or_update_budget(db_path, key_id=None, daily_limit=10.0, warn_pct=80)\n await seed_usage(db_path, [{\"timestamp\": _ts(0), \"cost\": 3.0, \"status\": 200}])\n status = await get_budget_status(db_path, key_id=None)\n assert status is not None\n assert status[\"status\"] == \"ok\"\n assert abs(status[\"today_spend\"] - 3.0) < 0.0001\n assert abs(status[\"daily_limit\"] - 10.0) < 0.0001\n\n\n@pytest.mark.asyncio\nasync def test_budget_status_warning(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await create_or_update_budget(db_path, key_id=None, daily_limit=10.0, warn_pct=50)\n await seed_usage(db_path, [{\"timestamp\": _ts(0), \"cost\": 6.0, \"status\": 200}])\n status = await get_budget_status(db_path, key_id=None)\n assert status[\"status\"] == \"warning\"\n\n\n@pytest.mark.asyncio\nasync def test_budget_status_exceeded(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await create_or_update_budget(db_path, key_id=None, daily_limit=5.0, warn_pct=80)\n await seed_usage(db_path, [{\"timestamp\": _ts(0), \"cost\": 6.0, \"status\": 200}])\n status = await get_budget_status(db_path, key_id=None)\n assert status[\"status\"] == \"exceeded\"\n\n\n@pytest.mark.asyncio\nasync def test_per_key_budget(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n from janus.storage.api_keys import create_key\n _, key_record = await create_key(db_path, name=\"test-key\")\n key_id = key_record[\"id\"]\n await create_or_update_budget(db_path, key_id=key_id, daily_limit=2.0, warn_pct=80)\n await seed_usage(db_path, [{\"timestamp\": _ts(0), \"cost\": 1.0, \"status\": 200, \"client_key_id\": key_id}])\n status = await get_budget_status(db_path, key_id=key_id)\n assert status is not None\n assert status[\"status\"] == \"ok\"\n assert abs(status[\"today_spend\"] - 1.0) < 0.0001\n\n\n@pytest.mark.asyncio\nasync def test_only_today_counts(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await create_or_update_budget(db_path, key_id=None, daily_limit=5.0, warn_pct=80)\n await seed_usage(db_path, [{\"timestamp\": _ts(2), \"cost\": 10.0, \"status\": 200}])\n status = await get_budget_status(db_path, key_id=None)\n assert status[\"status\"] == \"ok\"\n assert abs(status[\"today_spend\"] - 0.0) < 0.0001\n\n\n@pytest.mark.asyncio\nasync def test_delete_budget(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n budget_id = await create_or_update_budget(db_path, key_id=None, daily_limit=5.0, warn_pct=80)\n deleted = await delete_budget(db_path, budget_id)\n assert deleted is True\n budgets = await get_budgets(db_path)\n assert len(budgets) == 0\n```\n\n- [ ] **Step 2: Run tests to verify they fail**\n\nRun: `.venv/bin/python -m pytest tests/unit/storage/test_budgets.py -v`\nExpected: FAIL (ModuleNotFoundError)\n\n- [ ] **Step 3: Write the budgets implementation**\n\nCreate `src/janus/storage/budgets.py`:\n\n```python\nfrom __future__ import annotations\n\nfrom pathlib import Path\nfrom typing import Any\n\nfrom .database import get_connection\n\n\nasync def create_or_update_budget(\n db_path: str | Path,\n *,\n key_id: int | None,\n daily_limit: float,\n warn_pct: float = 80,\n) -> int:\n async with get_connection(db_path) as db:\n if key_id is not None:\n async with db.execute(\n \"SELECT id FROM budgets WHERE key_id = ? AND is_active = 1\",\n (key_id,),\n ) as cur:\n row = await cur.fetchone()\n else:\n async with db.execute(\n \"SELECT id FROM budgets WHERE key_id IS NULL AND is_active = 1\"\n ) as cur:\n row = await cur.fetchone()\n if row is not None:\n await db.execute(\n \"UPDATE budgets SET daily_limit = ?, warn_pct = ? WHERE id = ?\",\n (daily_limit, warn_pct, row[\"id\"]),\n )\n await db.commit()\n return row[\"id\"]\n cursor = await db.execute(\n \"INSERT INTO budgets (key_id, daily_limit, warn_pct) VALUES (?, ?, ?)\",\n (key_id, daily_limit, warn_pct),\n )\n await db.commit()\n return cursor.lastrowid\n\n\nasync def get_budgets(db_path: str | Path) -> list[dict[str, Any]]:\n async with get_connection(db_path) as db:\n async with db.execute(\n \"SELECT id, key_id, daily_limit, warn_pct, is_active, created_at \"\n \"FROM budgets WHERE is_active = 1 ORDER BY id\"\n ) as cur:\n rows = await cur.fetchall()\n return [dict(r) for r in rows]\n\n\nasync def delete_budget(db_path: str | Path, budget_id: int) -> bool:\n async with get_connection(db_path) as db:\n cursor = await db.execute(\n \"DELETE FROM budgets WHERE id = ?\", (budget_id,)\n )\n await db.commit()\n return cursor.rowcount > 0\n\n\nasync def get_budget_status(\n db_path: str | Path, *, key_id: int | None = None\n) -> dict[str, Any] | None:\n async with get_connection(db_path) as db:\n if key_id is not None:\n async with db.execute(\n \"SELECT daily_limit, warn_pct FROM budgets WHERE key_id = ? AND is_active = 1\",\n (key_id,),\n ) as cur:\n row = await cur.fetchone()\n if row is None:\n return None\n async with db.execute(\n \"SELECT COALESCE(SUM(cost), 0.0) as spent FROM usage \"\n \"WHERE client_key_id = ? AND date(timestamp) = date('now', 'localtime')\",\n (key_id,),\n ) as cur:\n spent_row = await cur.fetchone()\n else:\n async with db.execute(\n \"SELECT daily_limit, warn_pct FROM budgets WHERE key_id IS NULL AND is_active = 1\"\n ) as cur:\n row = await cur.fetchone()\n if row is None:\n return None\n async with db.execute(\n \"SELECT COALESCE(SUM(cost), 0.0) as spent FROM usage \"\n \"WHERE date(timestamp) = date('now', 'localtime')\"\n ) as cur:\n spent_row = await cur.fetchone()\n\n assert row is not None\n assert spent_row is not None\n daily_limit = row[\"daily_limit\"]\n today_spend = spent_row[\"spent\"]\n pct_used = (today_spend / daily_limit * 100) if daily_limit > 0 else 100.0\n if pct_used >= 100:\n status = \"exceeded\"\n elif pct_used >= row[\"warn_pct\"]:\n status = \"warning\"\n else:\n status = \"ok\"\n return {\n \"daily_limit\": daily_limit,\n \"today_spend\": today_spend,\n \"remaining\": max(0.0, daily_limit - today_spend),\n \"pct_used\": pct_used,\n \"status\": status,\n \"warn_pct\": row[\"warn_pct\"],\n }\n```\n\n- [ ] **Step 4: Run tests to verify they pass**\n\nRun: `.venv/bin/python -m pytest tests/unit/storage/test_budgets.py -v`\nExpected: PASS (9 tests)\n\n- [ ] **Step 5: Commit**\n\n```bash\ngit add src/janus/storage/budgets.py tests/unit/storage/test_budgets.py\ngit commit -m \"feat: add budget CRUD and status queries\"\n```\n\n---\n\n## Task 12: Wire PricingRegistry into App State\n\n**Files:**\n- Modify: `src/janus/app.py`\n\n- [ ] **Step 1: Add PricingRegistry to app factory**\n\nIn `src/janus/app.py`, add import and wire it:\n\nAdd to imports at top:\n```python\nfrom janus.pricing.registry import PricingRegistry\n```\n\nAfter the `app.state.saver_pipeline = SaverPipeline(savers)` line (line 53), add:\n```python\n app.state.pricing_registry = PricingRegistry(config.pricing)\n```\n\n- [ ] **Step 2: Verify app still imports**\n\nRun: `.venv/bin/python -c \"from janus.app import create_app; create_app()\"`\nExpected: No error\n\n- [ ] **Step 3: Run full test suite to verify no regressions**\n\nRun: `.venv/bin/python -m pytest -x -q`\nExpected: All existing tests pass\n\n- [ ] **Step 4: Commit**\n\n```bash\ngit add src/janus/app.py\ngit commit -m \"feat: wire PricingRegistry into app state\"\n```\n\n---\n\n## Task 13: Budget Enforcement + Cost Recording in _handle()\n\n**Files:**\n- Modify: `src/janus/api/routes.py`\n\n- [ ] **Step 1: Write the failing integration test for budget enforcement**\n\nCreate `tests/integration/test_budget_enforcement.py`:\n\n```python\nimport datetime\n\nimport pytest\nfrom httpx import ASGITransport, AsyncClient\n\nfrom janus.app import create_app\nfrom janus.config.schema import JanusConfig, ProviderConfig, ServerSettings\nfrom janus.providers.registry import ProviderRegistry\nfrom janus.routing.fallback import FallbackHandler\nfrom janus.storage.api_keys import create_key\nfrom janus.storage.budgets import create_or_update_budget\nfrom janus.storage.database import init_db, get_connection\n\n\ndef _ts(days_ago: int) -> str:\n return (datetime.datetime.now() - datetime.timedelta(days=days_ago)).isoformat()\n\n\nasync def _seed_cost(db_path, cost: float, key_id=None) -> None:\n async with get_connection(db_path) as db:\n await db.execute(\n \"INSERT INTO usage (timestamp, model, cost, status, client_key_id) \"\n \"VALUES (?, 'test', ?, 200, ?)\",\n (_ts(0), cost, key_id),\n )\n await db.commit()\n\n\n@pytest.mark.asyncio\nasync def test_request_blocked_when_budget_exceeded(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n\n registry = ProviderRegistry()\n config = JanusConfig(\n server=ServerSettings(require_api_key=True, data_dir=tmp_path),\n )\n app = create_app(registry=registry, config=config)\n app.state.db_path = db_path\n\n raw_key, record = await create_key(db_path, name=\"test\")\n await create_or_update_budget(db_path, key_id=record[\"id\"], daily_limit=1.0, warn_pct=80)\n await _seed_cost(db_path, 1.5, record[\"id\"])\n\n transport = ASGITransport(app=app)\n async with AsyncClient(transport=transport, base_url=\"http://test\") as client:\n resp = await client.post(\n \"/v1/chat/completions\",\n json={\"model\": \"nonexistent\", \"messages\": [{\"role\": \"user\", \"content\": \"hi\"}]},\n headers={\"Authorization\": f\"Bearer {raw_key}\"},\n )\n assert resp.status_code == 429\n body = resp.json()\n assert \"budget\" in body[\"error\"][\"message\"].lower()\n\n\n@pytest.mark.asyncio\nasync def test_request_passes_when_no_budget(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n\n registry = ProviderRegistry()\n config = JanusConfig(\n server=ServerSettings(require_api_key=True, data_dir=tmp_path),\n )\n app = create_app(registry=registry, config=config)\n app.state.db_path = db_path\n\n raw_key, _ = await create_key(db_path, name=\"test\")\n\n transport = ASGITransport(app=app)\n async with AsyncClient(transport=transport, base_url=\"http://test\") as client:\n resp = await client.post(\n \"/v1/chat/completions\",\n json={\"model\": \"nonexistent\", \"messages\": [{\"role\": \"user\", \"content\": \"hi\"}]},\n headers={\"Authorization\": f\"Bearer {raw_key}\"},\n )\n assert resp.status_code != 429\n\n\n@pytest.mark.asyncio\nasync def test_budget_block_response_has_retry_after(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n\n registry = ProviderRegistry()\n config = JanusConfig(\n server=ServerSettings(require_api_key=True, data_dir=tmp_path),\n )\n app = create_app(registry=registry, config=config)\n app.state.db_path = db_path\n\n raw_key, record = await create_key(db_path, name=\"test\")\n await create_or_update_budget(db_path, key_id=record[\"id\"], daily_limit=0.5, warn_pct=50)\n await _seed_cost(db_path, 1.0, record[\"id\"])\n\n transport = ASGITransport(app=app)\n async with AsyncClient(transport=transport, base_url=\"http://test\") as client:\n resp = await client.post(\n \"/v1/chat/completions\",\n json={\"model\": \"nonexistent\", \"messages\": [{\"role\": \"user\", \"content\": \"hi\"}]},\n headers={\"Authorization\": f\"Bearer {raw_key}\"},\n )\n assert resp.status_code == 429\n assert \"retry-after\" in {k.lower() for k in resp.headers.keys()}\n```\n\n- [ ] **Step 2: Run test to verify it fails**\n\nRun: `.venv/bin/python -m pytest tests/integration/test_budget_enforcement.py -v`\nExpected: FAIL (no budget enforcement in _handle)\n\n- [ ] **Step 3: Implement budget enforcement + cost recording in _handle()**\n\nReplace the `_handle` function in `src/janus/api/routes.py`:\n\n```python\nasync def _handle(\n client_format: str,\n body: dict[str, Any],\n request: Request,\n) -> Response:\n handler: FallbackHandler = request.app.state.fallback_handler\n db_path = request.app.state.db_path\n pricing_registry = request.app.state.pricing_registry\n\n client_key_id = getattr(request.state, \"client_key_id\", None)\n\n blocked_response = await _check_budgets(db_path, client_key_id)\n if blocked_response is not None:\n return blocked_response\n\n client_adapter = FORMATS[client_format]\n canonical_req = client_adapter.parse_request(body)\n\n saver_pipeline: SaverPipeline = request.app.state.saver_pipeline\n canonical_req = saver_pipeline.apply(canonical_req)\n\n try:\n attempts = handler.resolve_attempts(canonical_req.model)\n except ValueError as e:\n raise HTTPException(status_code=400, detail=str(e))\n\n last_error = \"Unknown error\"\n for target in attempts:\n provider_adapter = _resolve_format(target.native_format)\n upstream_payload = provider_adapter.build_upstream_request(canonical_req, target.model)\n provider = _build_provider(target.provider_config)\n\n try:\n if canonical_req.stream:\n result = await provider.call(upstream_payload, stream=True)\n if result.status_code >= 400:\n if is_fallback_eligible(result.status_code):\n handler.mark_cooldown(\n target.account_id,\n classify_error(result.status_code).value,\n )\n last_error = f\"{target.account_id}: {result.status_code}\"\n continue\n raise HTTPException(\n status_code=result.status_code,\n detail=(str(result.json_data) if result.json_data else \"Upstream error\"),\n )\n lines = result.lines\n if lines is None:\n raise HTTPException(status_code=502, detail=\"No stream from upstream\")\n parser = provider_adapter.stream_parser()\n emitter = client_adapter.stream_emitter()\n generator = translate_stream(lines, parser, emitter)\n return StreamingResponse(generator, media_type=\"text/event-stream\")\n\n result = await provider.call(upstream_payload, stream=False)\n if result.status_code >= 400:\n if is_fallback_eligible(result.status_code):\n handler.mark_cooldown(\n target.account_id,\n classify_error(result.status_code).value,\n )\n last_error = f\"{target.account_id}: {result.status_code}\"\n continue\n raise HTTPException(\n status_code=result.status_code,\n detail=(str(result.json_data) if result.json_data else \"Upstream error\"),\n )\n if result.json_data is None:\n raise HTTPException(status_code=502, detail=\"Empty upstream response\")\n canonical_resp = provider_adapter.parse_upstream_response(result.json_data)\n client_payload = client_adapter.emit_response(canonical_resp)\n\n from janus.pricing.calculator import compute_cost\n from janus.storage.usage import record_usage\n\n cost = compute_cost(canonical_resp.usage, target.model, pricing_registry)\n await record_usage(\n db_path,\n provider_id=target.provider_config.id,\n model=target.model,\n account_id=target.account_id,\n input_tokens=canonical_resp.usage.input_tokens,\n output_tokens=canonical_resp.usage.output_tokens,\n cache_creation_tokens=canonical_resp.usage.cache_creation_input_tokens,\n cache_read_tokens=canonical_resp.usage.cache_read_input_tokens,\n status=result.status_code,\n client_key_id=client_key_id,\n cost=cost,\n )\n\n return JSONResponse(content=client_payload)\n\n except (httpx.TimeoutException, httpx.ConnectError) as e:\n handler.mark_cooldown(target.account_id, \"network\")\n last_error = f\"{target.account_id}: {type(e).__name__}\"\n continue\n\n raise HTTPException(status_code=503, detail=f\"All providers exhausted: {last_error}\")\n```\n\nAdd this helper function above `_handle`:\n\n```python\nimport datetime\n\nfrom janus.storage.budgets import get_budget_status\n\n\nasync def _check_budgets(\n db_path: str | Path,\n client_key_id: int | None,\n) -> Response | None:\n try:\n statuses: list[dict[str, Any]] = []\n key_status = await get_budget_status(db_path, key_id=client_key_id)\n if key_status is not None:\n statuses.append(key_status)\n global_status = await get_budget_status(db_path, key_id=None)\n if global_status is not None:\n statuses.append(global_status)\n for s in statuses:\n if s[\"status\"] == \"exceeded\":\n now = datetime.datetime.now()\n midnight = now.replace(hour=23, minute=59, second=59, microsecond=0)\n retry_after = int((midnight - now).total_seconds()) + 1\n error_body: dict[str, Any] = {\n \"error\": {\n \"message\": (\n f\"Daily budget exceeded. \"\n f\"Spent ${s['today_spend']:.2f} of ${s['daily_limit']:.2f} limit. \"\n f\"Resets at midnight.\"\n ),\n \"type\": \"budget_exceeded\",\n \"today_spend\": round(s[\"today_spend\"], 4),\n \"daily_limit\": s[\"daily_limit\"],\n }\n }\n return JSONResponse(\n content=error_body,\n status_code=429,\n headers={\"Retry-After\": str(max(retry_after, 1))},\n )\n except Exception:\n pass\n return None\n```\n\nAdd `from pathlib import Path` and `import datetime` to the imports at the top of `routes.py`.\n\n- [ ] **Step 4: Run budget enforcement tests**\n\nRun: `.venv/bin/python -m pytest tests/integration/test_budget_enforcement.py -v`\nExpected: PASS (3 tests)\n\n- [ ] **Step 5: Run full test suite to verify no regressions**\n\nRun: `.venv/bin/python -m pytest -x -q`\nExpected: All tests pass\n\n- [ ] **Step 6: Lint and typecheck**\n\nRun: `.venv/bin/ruff check src/janus/ tests/` and `.venv/bin/mypy src/janus/`\nExpected: No errors\n\n- [ ] **Step 7: Commit**\n\n```bash\ngit add src/janus/api/routes.py tests/integration/test_budget_enforcement.py\ngit commit -m \"feat: budget enforcement + cost recording in request handler\"\n```\n\n---\n\n## Task 14: Cost Recording Integration Test\n\n**Files:**\n- Create: `tests/integration/test_cost_recording.py`\n\n- [ ] **Step 1: Write the test**\n\nCreate `tests/integration/test_cost_recording.py`:\n\n```python\nimport aiosqlite\nimport pytest\nfrom httpx import ASGITransport, AsyncClient\n\nimport respx\nfrom janus.app import create_app\nfrom janus.config.schema import JanusConfig, ProviderConfig, ServerSettings\nfrom janus.providers.registry import ProviderRegistry\nfrom janus.routing.fallback import FallbackHandler\nfrom janus.storage.database import init_db\n\n\n@pytest.mark.asyncio\nasync def test_cost_recorded_for_non_streaming(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n\n registry = ProviderRegistry()\n provider = ProviderConfig(\n id=\"test-openai\",\n prefix=\"test\",\n api_type=\"openai_compat\",\n base_url=\"https://api.test.com/v1\",\n api_key=\"sk-test\",\n models=[\"gpt-4o\"],\n )\n registry.register(provider)\n config = JanusConfig(\n server=ServerSettings(data_dir=tmp_path),\n providers=[provider],\n )\n app = create_app(registry=registry, config=config)\n app.state.db_path = db_path\n\n mock_response = {\n \"id\": \"chatcmpl-123\",\n \"object\": \"chat.completion\",\n \"model\": \"gpt-4o\",\n \"choices\": [\n {\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"Hello!\"}, \"finish_reason\": \"stop\"}\n ],\n \"usage\": {\"prompt_tokens\": 100, \"completion_tokens\": 50, \"total_tokens\": 150},\n }\n\n with respx.mock(base_url=\"https://api.test.com/v1\") as mock:\n mock.post(\"/chat/completions\").respond(200, json=mock_response)\n transport = ASGITransport(app=app)\n async with AsyncClient(transport=transport, base_url=\"http://test\") as client:\n resp = await client.post(\n \"/v1/chat/completions\",\n json={\"model\": \"test/gpt-4o\", \"messages\": [{\"role\": \"user\", \"content\": \"hi\"}]},\n )\n assert resp.status_code == 200\n\n async with aiosqlite.connect(str(db_path)) as db:\n db.row_factory = aiosqlite.Row\n async with db.execute(\"SELECT cost, input_tokens, output_tokens FROM usage WHERE model = 'gpt-4o'\") as cur:\n rows = await cur.fetchall()\n assert len(rows) == 1\n assert rows[0][\"cost\"] > 0\n assert rows[0][\"input_tokens\"] == 100\n assert rows[0][\"output_tokens\"] == 50\n```\n\n- [ ] **Step 2: Run test**\n\nRun: `.venv/bin/python -m pytest tests/integration/test_cost_recording.py -v`\nExpected: PASS\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add tests/integration/test_cost_recording.py\ngit commit -m \"test: cost recording integration test\"\n```\n\n---\n\n## Task 15: Dashboard — Chart.js + Nav Updates\n\n**Files:**\n- Modify: `src/janus/dashboard/templates/base.html`\n\n- [ ] **Step 1: Add Chart.js CDN and nav items**\n\nIn `src/janus/dashboard/templates/base.html`, add Chart.js CDN after the HTMX script tag (line 8):\n\n```html\n <script src=\"https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js\"></script>\n```\n\nAdd \"Analytics\" and \"Budgets\" nav items between the Usage and bottom section (after line 43, after the Usage `</a>`):\n\n```html\n <a href=\"/dashboard/analytics\" class=\"flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:bg-gray-800 hover:text-white transition-colors {% block analytics_active %}{% endblock %}\">\n <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z\"/><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z\"/></svg>\n Analytics\n </a>\n <a href=\"/dashboard/budgets\" class=\"flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:bg-gray-800 hover:text-white transition-colors {% block budgets_active %}{% endblock %}\">\n <svg class=\"w-5 h-5 mr-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1\"/></svg>\n Budgets\n </a>\n```\n\n- [ ] **Step 2: Commit**\n\n```bash\ngit add src/janus/dashboard/templates/base.html\ngit commit -m \"feat: add Chart.js CDN and analytics/budgets nav items\"\n```\n\n---\n\n## Task 16: Dashboard — Analytics Page\n\n**Files:**\n- Create: `src/janus/dashboard/templates/analytics.html`\n- Modify: `src/janus/dashboard/routes.py`\n- Test: `tests/integration/test_dashboard_analytics.py`\n\n- [ ] **Step 1: Write the failing test**\n\nCreate `tests/integration/test_dashboard_analytics.py`:\n\n```python\nimport datetime\n\nimport pytest\nfrom httpx import ASGITransport, AsyncClient\n\nfrom janus.app import create_app\nfrom janus.config.schema import JanusConfig, ServerSettings\nfrom janus.providers.registry import ProviderRegistry\nfrom janus.storage.database import init_db, get_connection\n\n\ndef _ts(days_ago: int) -> str:\n return (datetime.datetime.now() - datetime.timedelta(days=days_ago)).isoformat()\n\n\nasync def _seed(db_path, rows):\n for row in rows:\n async with get_connection(db_path) as db:\n await db.execute(\n \"INSERT INTO usage (timestamp, model, provider_id, input_tokens, output_tokens, cost, status) \"\n \"VALUES (?, ?, ?, ?, ?, ?, ?)\",\n (\n row.get(\"ts\", _ts(0)),\n row.get(\"model\", \"gpt-4o\"),\n row.get(\"provider_id\", \"openai\"),\n row.get(\"input_tokens\", 1000),\n row.get(\"output_tokens\", 500),\n row.get(\"cost\", 0.01),\n row.get(\"status\", 200),\n ),\n )\n await db.commit()\n\n\n@pytest.mark.asyncio\nasync def test_analytics_page_renders(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await _seed(db_path, [{\"ts\": _ts(0), \"cost\": 0.05, \"model\": \"gpt-4o\"}, {\"ts\": _ts(1), \"cost\": 0.03, \"model\": \"claude\"}])\n\n registry = ProviderRegistry()\n config = JanusConfig(server=ServerSettings(data_dir=tmp_path))\n app = create_app(registry=registry, config=config)\n app.state.db_path = db_path\n\n transport = ASGITransport(app=app)\n async with AsyncClient(transport=transport, base_url=\"http://test\") as client:\n resp = await client.get(\"/dashboard/analytics\")\n assert resp.status_code == 200\n assert \"Analytics\" in resp.text\n assert \"0.08\" in resp.text or \"0.0\" in resp.text\n\n\n@pytest.mark.asyncio\nasync def test_analytics_breakdown_by_provider(tmp_path):\n db_path = tmp_path / \"test.db\"\n await init_db(db_path)\n await _seed(db_path, [\n {\"ts\": _ts(0), \"provider_id\": \"openai\", \"cost\": 0.05},\n {\"ts\": _ts(0), \"provider_id\": \"anthropic\", \"cost\": 0.03},\n ])\n\n registry = ProviderRegistry()\n config = JanusConfig(server=ServerSettings(data_dir=tmp_path))\n app = create_app(registry=registry, config=config)\n app.state.db_path = db_path\n\n transport = ASGITransport(app=app)\n async with AsyncClient(transport=transport, base_url=\"http://test\") as client:\n resp = await client.get(\"/dashboard/analytics?dimension=provider&days=30\")\n assert resp.status_code == 200\n assert \"openai\" in resp.text\n assert \"anthropic\" in resp.text\n```\n\n- [ ] **Step 2: Run test to verify it fails**\n\nRun: `.venv/bin/python -m pytest tests/integration/test_dashboard_analytics.py -v`\nExpected: FAIL (no analytics route)\n\n- [ ] **Step 3: Add analytics route to dashboard**\n\nIn `src/janus/dashboard/routes.py`, add imports at the top (after existing imports):\n\n```python\nfrom janus.storage.analytics import get_breakdown, get_spend_summary, get_success_rate\n```\n\nAdd the analytics route after the usage route:\n\n```python\n@router.get(\"/analytics\", response_class=HTMLResponse)\nasync def analytics_page(\n request: Request,\n days: int = 30,\n dimension: str = \"model\",\n) -> HTMLResponse:\n db_path = await _ensure_db(request)\n try:\n summary = await get_spend_summary(db_path, days=days)\n breakdown = await get_breakdown(db_path, dimension=dimension, days=days)\n success = await get_success_rate(db_path, days=days)\n except Exception:\n summary = {\"total_cost\": 0, \"total_requests\": 0, \"daily\": []}\n breakdown = []\n success = {\"success_2xx\": 0, \"client_4xx\": 0, \"server_5xx\": 0, \"total\": 0}\n context: dict[str, Any] = {\n \"request\": request,\n \"summary\": summary,\n \"breakdown\": breakdown,\n \"success\": success,\n \"days\": days,\n \"dimension\": dimension,\n }\n return _templates.TemplateResponse(request, \"analytics.html\", context)\n```\n\n- [ ] **Step 4: Create the analytics template**\n\nCreate `src/janus/dashboard/templates/analytics.html`:\n\n```html\n{% extends \"base.html\" %}\n{% block title %}Analytics - Janus{% endblock %}\n{% block analytics_active %}bg-gray-700 text-white{% endblock %}\n\n{% block content %}\n<h1 class=\"text-2xl font-bold text-white mb-6\">Analytics</h1>\n\n<!-- Date Range Selector -->\n<div class=\"flex space-x-2 mb-6\">\n {% for d in [7, 30, 90] %}\n <a href=\"/dashboard/analytics?days={{ d }}&dimension={{ dimension }}\"\n class=\"px-4 py-2 rounded-lg text-sm font-medium {% if days == d %}bg-blue-600 text-white{% else %}bg-gray-800 text-gray-400 hover:text-white{% endif %} transition-colors\">\n {{ d }}d\n </a>\n {% endfor %}\n</div>\n\n<!-- Stats Cards -->\n<div class=\"grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-8\">\n <div class=\"bg-gray-800 rounded-xl p-5 border border-gray-700\">\n <p class=\"text-gray-400 text-sm font-medium mb-1\">Total Cost</p>\n <p class=\"text-3xl font-bold text-white\">${{ \"%.4f\"|format(summary.total_cost) }}</p>\n </div>\n <div class=\"bg-gray-800 rounded-xl p-5 border border-gray-700\">\n <p class=\"text-gray-400 text-sm font-medium mb-1\">Total Requests</p>\n <p class=\"text-3xl font-bold text-white\">{{ summary.total_requests }}</p>\n </div>\n <div class=\"bg-gray-800 rounded-xl p-5 border border-gray-700\">\n <p class=\"text-gray-400 text-sm font-medium mb-1\">Input Tokens</p>\n <p class=\"text-3xl font-bold text-white\">{{ \"{:,}\".format(summary.total_input_tokens) }}</p>\n </div>\n <div class=\"bg-gray-800 rounded-xl p-5 border border-gray-700\">\n <p class=\"text-gray-400 text-sm font-medium mb-1\">Output Tokens</p>\n <p class=\"text-3xl font-bold text-white\">{{ \"{:,}\".format(summary.total_output_tokens) }}</p>\n </div>\n</div>\n\n<!-- Spend Chart -->\n<div class=\"bg-gray-800 rounded-xl p-6 border border-gray-700 mb-8\">\n <h2 class=\"text-lg font-semibold text-white mb-4\">Spend Over Time</h2>\n <canvas id=\"spendChart\" height=\"80\"></canvas>\n</div>\n\n<!-- Breakdown -->\n<div class=\"bg-gray-800 rounded-xl border border-gray-700 overflow-hidden mb-8\">\n <div class=\"px-6 py-4 border-b border-gray-700 flex items-center justify-between\">\n <h2 class=\"text-lg font-semibold text-white\">Breakdown</h2>\n <div class=\"flex space-x-1\">\n {% for dim, label in [(\"model\", \"Model\"), (\"provider\", \"Provider\"), (\"account\", \"Account\"), (\"client_key\", \"Key\")] %}\n <a href=\"/dashboard/analytics?days={{ days }}&dimension={{ dim }}\"\n class=\"px-3 py-1 rounded-md text-xs font-medium {% if dimension == dim %}bg-blue-600 text-white{% else %}bg-gray-700 text-gray-400 hover:text-white{% endif %} transition-colors\">\n {{ label }}\n </a>\n {% endfor %}\n </div>\n </div>\n {% if breakdown %}\n <table class=\"w-full\">\n <thead>\n <tr class=\"bg-gray-900 border-b border-gray-700\">\n <th class=\"px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider\">{{ dimension }}</th>\n <th class=\"px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider\">Requests</th>\n <th class=\"px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider\">Input</th>\n <th class=\"px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider\">Output</th>\n <th class=\"px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider\">Cost</th>\n </tr>\n </thead>\n <tbody class=\"divide-y divide-gray-700\">\n {% for row in breakdown %}\n <tr class=\"hover:bg-gray-750\">\n <td class=\"px-4 py-3 text-sm text-white font-mono\">{{ row[dimension] or '—' }}</td>\n <td class=\"px-4 py-3 text-sm text-gray-300 text-right\">{{ row.requests }}</td>\n <td class=\"px-4 py-3 text-sm text-gray-300 text-right\">{{ \"{:,}\".format(row.input_tokens) }}</td>\n <td class=\"px-4 py-3 text-sm text-gray-300 text-right\">{{ \"{:,}\".format(row.output_tokens) }}</td>\n <td class=\"px-4 py-3 text-sm text-green-400 text-right\">${{ \"%.4f\"|format(row.cost) }}</td>\n </tr>\n {% endfor %}\n </tbody>\n </table>\n {% else %}\n <div class=\"px-6 py-12 text-center\"><p class=\"text-gray-400\">No data for this range.</p></div>\n {% endif %}\n</div>\n\n<!-- Success Rate -->\n<div class=\"grid grid-cols-1 md:grid-cols-2 gap-4 mb-8\">\n <div class=\"bg-gray-800 rounded-xl p-6 border border-gray-700\">\n <h2 class=\"text-lg font-semibold text-white mb-4\">Success Rate</h2>\n <canvas id=\"successChart\" height=\"120\"></canvas>\n </div>\n <div class=\"bg-gray-800 rounded-xl p-6 border border-gray-700\">\n <h2 class=\"text-lg font-semibold text-white mb-4\">Status Breakdown</h2>\n <div class=\"space-y-3\">\n <div class=\"flex items-center justify-between\">\n <span class=\"text-green-400 text-sm\">2xx Success</span>\n <span class=\"text-white font-bold\">{{ success.success_2xx }}</span>\n </div>\n <div class=\"flex items-center justify-between\">\n <span class=\"text-yellow-400 text-sm\">4xx Client Error</span>\n <span class=\"text-white font-bold\">{{ success.client_4xx }}</span>\n </div>\n <div class=\"flex items-center justify-between\">\n <span class=\"text-red-400 text-sm\">5xx Server Error</span>\n <span class=\"text-white font-bold\">{{ success.server_5xx }}</span>\n </div>\n </div>\n </div>\n</div>\n\n<script>\n// Spend chart\nconst dailyData = {{ summary.daily | tojson }};\nconst ctxSpend = document.getElementById('spendChart');\nnew Chart(ctxSpend, {\n type: 'line',\n data: {\n labels: dailyData.map(d => d.date),\n datasets: [{\n label: 'Daily Cost ($)',\n data: dailyData.map(d => d.cost),\n borderColor: '#3b82f6',\n backgroundColor: 'rgba(59, 130, 246, 0.1)',\n fill: true,\n tension: 0.3,\n }]\n },\n options: {\n responsive: true,\n plugins: { legend: { labels: { color: '#9ca3af' } } },\n scales: {\n x: { ticks: { color: '#9ca3af' }, grid: { color: '#374151' } },\n y: { ticks: { color: '#9ca3af' }, grid: { color: '#374151' } }\n }\n }\n});\n\n// Success donut\nconst successData = {{ success | tojson }};\nconst ctxSuccess = document.getElementById('successChart');\nnew Chart(ctxSuccess, {\n type: 'doughnut',\n data: {\n labels: ['2xx Success', '4xx Client Error', '5xx Server Error'],\n datasets: [{\n data: [successData.success_2xx, successData.client_4xx, successData.server_5xx],\n backgroundColor: ['#10b981', '#f59e0b', '#ef4444'],\n }]\n },\n options: {\n responsive: true,\n plugins: { legend: { labels: { color: '#9ca3af' } } }\n }\n});\n</script>\n{% endblock %}\n```\n\n- [ ] **Step 5: Run tests**\n\nRun: `.venv/bin/python -m pytest tests/integration/test_dashboard_analytics.py -v`\nExpected: PASS (2 tests)\n\n- [ ] **Step 6: Commit**\n\n```bash\ngit add src/janus/dashboard/templates/analytics.html src/janus/dashboard/routes.py tests/integration/test_dashboard_analytics.py\ngit commit -m \"feat: dashboard analytics page with charts and breakdowns\"\n```\n\n---\n\n## Task 17: Dashboard — Budgets Page\n\n**Files:**\n- Create: `src/janus/dashboard/templates/budgets.html`\n- Create: `src/janus/dashboard/templates/budgets_partial.html`\n- Modify: `src/janus/dashboard/routes.py`\n\n- [ ] **Step 1: Add budgets routes to dashboard**\n\nIn `src/janus/dashboard/routes.py`, add imports at the top:\n\n```python\nfrom janus.storage.budgets import (\n create_or_update_budget,\n delete_budget,\n get_budget_status,\n get_budgets,\n)\nfrom janus.storage.api_keys import list_keys\n```\n\nAdd the routes after the analytics route:\n\n```python\n@router.get(\"/budgets\", response_class=HTMLResponse)\nasync def budgets_page(request: Request) -> HTMLResponse:\n db_path = await _ensure_db(request)\n try:\n budgets = await get_budgets(db_path)\n keys = await list_keys(db_path)\n budget_statuses: list[dict[str, Any]] = []\n for b in budgets:\n status = await get_budget_status(db_path, key_id=b[\"key_id\"])\n key_name = \"Global\"\n if b[\"key_id\"] is not None:\n key_name = next((k[\"name\"] for k in keys if k[\"id\"] == b[\"key_id\"]), f\"Key #{b['key_id']}\")\n budget_statuses.append({**b, \"status\": status, \"key_name\": key_name})\n except Exception:\n budget_statuses = []\n keys = []\n context: dict[str, Any] = {\n \"request\": request,\n \"budgets\": budget_statuses,\n \"keys\": keys,\n }\n return _templates.TemplateResponse(request, \"budgets.html\", context)\n\n\n@router.post(\"/api/budgets\", response_class=HTMLResponse)\nasync def create_budget(\n request: Request,\n key_select: str = Form(...),\n daily_limit: float = Form(...),\n warn_pct: float = Form(80),\n) -> HTMLResponse:\n db_path = await _ensure_db(request)\n key_id: int | None = None\n if key_select != \"global\":\n key_id = int(key_select)\n await create_or_update_budget(db_path, key_id=key_id, daily_limit=daily_limit, warn_pct=warn_pct)\n return await _budgets_partial(request, db_path)\n\n\n@router.delete(\"/api/budgets/{budget_id}\", response_class=HTMLResponse)\nasync def delete_budget_endpoint(request: Request, budget_id: int) -> HTMLResponse:\n db_path = await _ensure_db(request)\n await delete_budget(db_path, budget_id)\n return await _budgets_partial(request, db_path)\n\n\nasync def _budgets_partial(request: Request, db_path: Path) -> HTMLResponse:\n budgets = await get_budgets(db_path)\n keys = await list_keys(db_path)\n budget_statuses: list[dict[str, Any]] = []\n for b in budgets:\n status = await get_budget_status(db_path, key_id=b[\"key_id\"])\n key_name = \"Global\"\n if b[\"key_id\"] is not None:\n key_name = next((k[\"name\"] for k in keys if k[\"id\"] == b[\"key_id\"]), f\"Key #{b['key_id']}\")\n budget_statuses.append({**b, \"status\": status, \"key_name\": key_name})\n context: dict[str, Any] = {\n \"request\": request,\n \"budgets\": budget_statuses,\n \"keys\": keys,\n }\n return _templates.TemplateResponse(request, \"budgets_partial.html\", context)\n```\n\n- [ ] **Step 2: Create the budgets template**\n\nCreate `src/janus/dashboard/templates/budgets.html`:\n\n```html\n{% extends \"base.html\" %}\n{% block title %}Budgets - Janus{% endblock %}\n{% block budgets_active %}bg-gray-700 text-white{% endblock %}\n\n{% block content %}\n<h1 class=\"text-2xl font-bold text-white mb-6\">Budgets</h1>\n\n<!-- Create Budget Form -->\n<div class=\"bg-gray-800 rounded-xl p-6 border border-gray-700 mb-8\">\n <h2 class=\"text-lg font-semibold text-white mb-4\">Create / Update Budget</h2>\n <form hx-post=\"/dashboard/api/budgets\" hx-target=\"#budgets-table\" class=\"flex flex-wrap items-end gap-4\">\n <div>\n <label class=\"block text-gray-400 text-sm mb-1\">Scope</label>\n <select name=\"key_select\" class=\"bg-gray-900 text-white rounded-lg px-3 py-2 border border-gray-700 text-sm\">\n <option value=\"global\">Global (all keys)</option>\n {% for key in keys %}\n <option value=\"{{ key.id }}\">{{ key.name }}</option>\n {% endfor %}\n </select>\n </div>\n <div>\n <label class=\"block text-gray-400 text-sm mb-1\">Daily Limit ($)</label>\n <input type=\"number\" name=\"daily_limit\" step=\"0.01\" min=\"0\" value=\"5.00\"\n class=\"bg-gray-900 text-white rounded-lg px-3 py-2 border border-gray-700 text-sm w-32\">\n </div>\n <div>\n <label class=\"block text-gray-400 text-sm mb-1\">Warn at (%)</label>\n <input type=\"number\" name=\"warn_pct\" step=\"1\" min=\"1\" max=\"100\" value=\"80\"\n class=\"bg-gray-900 text-white rounded-lg px-3 py-2 border border-gray-700 text-sm w-24\">\n </div>\n <button type=\"submit\" class=\"bg-blue-600 hover:bg-blue-700 text-white rounded-lg px-5 py-2 text-sm font-medium transition-colors\">\n Set Budget\n </button>\n </form>\n</div>\n\n<!-- Budgets Table -->\n<div id=\"budgets-table\">\n {% include \"budgets_partial.html\" %}\n</div>\n{% endblock %}\n```\n\n- [ ] **Step 3: Create the budgets partial template**\n\nCreate `src/janus/dashboard/templates/budgets_partial.html`:\n\n```html\n<div class=\"bg-gray-800 rounded-xl border border-gray-700 overflow-hidden\">\n {% if budgets %}\n <table class=\"w-full\">\n <thead>\n <tr class=\"bg-gray-900 border-b border-gray-700\">\n <th class=\"px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider\">Scope</th>\n <th class=\"px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider\">Daily Limit</th>\n <th class=\"px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider\">Today's Spend</th>\n <th class=\"px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider\">Usage</th>\n <th class=\"px-4 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider\">Status</th>\n <th class=\"px-4 py-3\"></th>\n </tr>\n </thead>\n <tbody class=\"divide-y divide-gray-700\">\n {% for b in budgets %}\n <tr class=\"hover:bg-gray-750\">\n <td class=\"px-4 py-3 text-sm text-white font-medium\">{{ b.key_name }}</td>\n <td class=\"px-4 py-3 text-sm text-gray-300 text-right\">${{ \"%.2f\"|format(b.daily_limit) }}</td>\n <td class=\"px-4 py-3 text-sm text-gray-300 text-right\">\n {% if b.status %}${{ \"%.4f\"|format(b.status.today_spend) }}{% else %}—{% endif %}\n </td>\n <td class=\"px-4 py-3 text-right\">\n {% if b.status %}\n <div class=\"flex items-center justify-end\">\n <div class=\"w-24 bg-gray-700 rounded-full h-2 mr-2\">\n <div class=\"h-2 rounded-full {% if b.status.status == 'exceeded' %}bg-red-500{% elif b.status.status == 'warning' %}bg-yellow-500{% else %}bg-green-500{% endif %}\"\n style=\"width: {{ [b.status.pct_used, 100] | min }}%\"></div>\n </div>\n <span class=\"text-sm text-gray-400\">{{ \"%.0f\"|format(b.status.pct_used) }}%</span>\n </div>\n {% else %}\n <span class=\"text-sm text-gray-500\">—</span>\n {% endif %}\n </td>\n <td class=\"px-4 py-3 text-center\">\n {% if b.status %}\n {% if b.status.status == 'exceeded' %}\n <span class=\"inline-flex px-2 py-1 rounded-full text-xs font-medium bg-red-900 text-red-200\">Exceeded</span>\n {% elif b.status.status == 'warning' %}\n <span class=\"inline-flex px-2 py-1 rounded-full text-xs font-medium bg-yellow-900 text-yellow-200\">Warning</span>\n {% else %}\n <span class=\"inline-flex px-2 py-1 rounded-full text-xs font-medium bg-green-900 text-green-200\">OK</span>\n {% endif %}\n {% else %}\n <span class=\"text-sm text-gray-500\">—</span>\n {% endif %}\n </td>\n <td class=\"px-4 py-3 text-right\">\n <button hx-delete=\"/dashboard/api/budgets/{{ b.id }}\" hx-target=\"#budgets-table\"\n class=\"text-red-400 hover:text-red-300 text-sm\">Delete</button>\n </td>\n </tr>\n {% endfor %}\n </tbody>\n </table>\n {% else %}\n <div class=\"px-6 py-12 text-center\">\n <p class=\"text-gray-400\">No budgets configured.</p>\n </div>\n {% endif %}\n</div>\n```\n\n- [ ] **Step 4: Run full test suite**\n\nRun: `.venv/bin/python -m pytest -x -q`\nExpected: All tests pass\n\n- [ ] **Step 5: Commit**\n\n```bash\ngit add src/janus/dashboard/templates/budgets.html src/janus/dashboard/templates/budgets_partial.html src/janus/dashboard/routes.py\ngit commit -m \"feat: dashboard budgets page with HTMX create/delete\"\n```\n\n---\n\n## Task 18: Dashboard — Enhanced Overview + Usage Pages\n\n**Files:**\n- Modify: `src/janus/dashboard/routes.py`\n- Modify: `src/janus/dashboard/templates/overview.html`\n- Modify: `src/janus/dashboard/templates/usage.html`\n\n- [ ] **Step 1: Enhance overview route to include spend + budget data**\n\nIn `src/janus/dashboard/routes.py`, update the `overview` function. Replace the existing function with:\n\n```python\n@router.get(\"\", response_class=HTMLResponse)\nasync def overview(request: Request) -> HTMLResponse:\n db_path = await _ensure_db(request)\n stats = await _get_usage_stats_safe(db_path)\n registry = request.app.state.registry\n provider_count = len(registry.providers)\n today_cost = 0.0\n global_budget = None\n try:\n summary = await get_spend_summary(db_path, days=1)\n today_cost = summary[\"total_cost\"]\n global_budget = await get_budget_status(db_path, key_id=None)\n except Exception:\n pass\n context: dict[str, Any] = {\n \"request\": request,\n \"stats\": stats,\n \"provider_count\": provider_count,\n \"combos\": registry.combos,\n \"today_cost\": today_cost,\n \"global_budget\": global_budget,\n }\n return _templates.TemplateResponse(request, \"overview.html\", context)\n```\n\n- [ ] **Step 2: Update overview template to show today's spend + budget bar**\n\nReplace `src/janus/dashboard/templates/overview.html`:\n\n```html\n{% extends \"base.html\" %}\n{% block title %}Overview - Janus{% endblock %}\n{% block overview_active %}bg-gray-700 text-white{% endblock %}\n\n{% block content %}\n<h1 class=\"text-2xl font-bold text-white mb-6\">Overview</h1>\n\n<!-- Stats Cards -->\n<div class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8\">\n <div class=\"bg-gray-800 rounded-xl p-5 border border-gray-700\">\n <p class=\"text-gray-400 text-sm font-medium mb-1\">Total Requests</p>\n <p class=\"text-3xl font-bold text-white\">{{ stats.total_requests }}</p>\n </div>\n <div class=\"bg-gray-800 rounded-xl p-5 border border-gray-700\">\n <p class=\"text-gray-400 text-sm font-medium mb-1\">Input Tokens</p>\n <p class=\"text-3xl font-bold text-white\">{{ \"{:,}\".format(stats.total_input_tokens) }}</p>\n </div>\n <div class=\"bg-gray-800 rounded-xl p-5 border border-gray-700\">\n <p class=\"text-gray-400 text-sm font-medium mb-1\">Output Tokens</p>\n <p class=\"text-3xl font-bold text-white\">{{ \"{:,}\".format(stats.total_output_tokens) }}</p>\n </div>\n <div class=\"bg-gray-800 rounded-xl p-5 border border-gray-700\">\n <p class=\"text-gray-400 text-sm font-medium mb-1\">Active Providers</p>\n <p class=\"text-3xl font-bold text-white\">{{ provider_count }}</p>\n </div>\n</div>\n\n<!-- Today's Spend + Budget -->\n<div class=\"bg-gray-800 rounded-xl p-6 border border-gray-700 mb-8\">\n <div class=\"flex items-center justify-between mb-3\">\n <h2 class=\"text-lg font-semibold text-white\">Today's Spend</h2>\n <span class=\"text-2xl font-bold text-green-400\">${{ \"%.4f\"|format(today_cost) }}</span>\n </div>\n {% if global_budget %}\n <div class=\"flex items-center gap-3\">\n <div class=\"flex-1 bg-gray-700 rounded-full h-3\">\n <div class=\"h-3 rounded-full {% if global_budget.status == 'exceeded' %}bg-red-500{% elif global_budget.status == 'warning' %}bg-yellow-500{% else %}bg-green-500{% endif %}\"\n style=\"width: {{ [global_budget.pct_used, 100] | min }}%\"></div>\n </div>\n <span class=\"text-sm text-gray-400 whitespace-nowrap\">\n ${{ \"%.2f\"|format(global_budget.today_spend) }} / ${{ \"%.2f\"|format(global_budget.daily_limit) }} ({{ \"%.0f\"|format(global_budget.pct_used) }}%)\n </span>\n </div>\n {% else %}\n <p class=\"text-gray-500 text-sm\">No global budget set. <a href=\"/dashboard/budgets\" class=\"text-blue-400 hover:underline\">Create one</a></p>\n {% endif %}\n</div>\n\n<!-- Active Combos -->\n<div class=\"bg-gray-800 rounded-xl p-6 border border-gray-700\">\n <h2 class=\"text-lg font-semibold text-white mb-4\">Active Combos</h2>\n {% if combos %}\n <div class=\"space-y-3\">\n {% for name, models in combos.items() %}\n <div class=\"bg-gray-900 rounded-lg p-4 border border-gray-700\">\n <div class=\"flex items-center justify-between mb-2\">\n <span class=\"text-white font-medium\">{{ name }}</span>\n <span class=\"text-gray-500 text-sm\">{{ models|length }} models</span>\n </div>\n <div class=\"flex flex-wrap items-center gap-2\">\n {% for model in models %}\n <span class=\"inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-blue-900 text-blue-200\">{{ model }}</span>\n {% if not loop.last %}\n <span class=\"text-gray-500 text-sm\">→</span>\n {% endif %}\n {% endfor %}\n </div>\n </div>\n {% endfor %}\n </div>\n {% else %}\n <p class=\"text-gray-500 text-sm\">No combos configured.</p>\n {% endif %}\n</div>\n{% endblock %}\n```\n\n- [ ] **Step 3: Update usage template to add cost column**\n\nReplace `src/janus/dashboard/templates/usage.html`:\n\n```html\n{% extends \"base.html\" %}\n{% block title %}Usage - Janus{% endblock %}\n{% block usage_active %}bg-gray-700 text-white{% endblock %}\n\n{% block content %}\n<h1 class=\"text-2xl font-bold text-white mb-6\">Usage</h1>\n\n<!-- Stats Cards -->\n<div class=\"grid grid-cols-1 md:grid-cols-3 gap-4 mb-8\">\n <div class=\"bg-gray-800 rounded-xl p-5 border border-gray-700\">\n <p class=\"text-gray-400 text-sm font-medium mb-1\">Total Requests</p>\n <p class=\"text-3xl font-bold text-white\">{{ stats.total_requests }}</p>\n </div>\n <div class=\"bg-gray-800 rounded-xl p-5 border border-gray-700\">\n <p class=\"text-gray-400 text-sm font-medium mb-1\">Input Tokens</p>\n <p class=\"text-3xl font-bold text-white\">{{ \"{:,}\".format(stats.total_input_tokens) }}</p>\n </div>\n <div class=\"bg-gray-800 rounded-xl p-5 border border-gray-700\">\n <p class=\"text-gray-400 text-sm font-medium mb-1\">Output Tokens</p>\n <p class=\"text-3xl font-bold text-white\">{{ \"{:,}\".format(stats.total_output_tokens) }}</p>\n </div>\n</div>\n\n<!-- Usage by Model -->\n<div class=\"bg-gray-800 rounded-xl border border-gray-700 overflow-hidden\">\n <div class=\"px-6 py-4 border-b border-gray-700\">\n <h2 class=\"text-lg font-semibold text-white\">Usage by Model</h2>\n </div>\n {% if stats.by_model %}\n <table class=\"w-full\">\n <thead>\n <tr class=\"bg-gray-900 border-b border-gray-700\">\n <th class=\"px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider\">Model</th>\n <th class=\"px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider\">Requests</th>\n <th class=\"px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider\">Input Tokens</th>\n <th class=\"px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider\">Output Tokens</th>\n </tr>\n </thead>\n <tbody class=\"divide-y divide-gray-700\">\n {% for row in stats.by_model %}\n <tr class=\"hover:bg-gray-750\">\n <td class=\"px-4 py-3 text-sm text-white font-mono\">{{ row.model or '—' }}</td>\n <td class=\"px-4 py-3 text-sm text-gray-300 text-right\">{{ row.requests }}</td>\n <td class=\"px-4 py-3 text-sm text-gray-300 text-right\">{{ \"{:,}\".format(row.input_tokens) }}</td>\n <td class=\"px-4 py-3 text-sm text-gray-300 text-right\">{{ \"{:,}\".format(row.output_tokens) }}</td>\n </tr>\n {% endfor %}\n </tbody>\n </table>\n {% else %}\n <div class=\"px-6 py-12 text-center\">\n <p class=\"text-gray-400\">No usage data yet.</p>\n </div>\n {% endif %}\n</div>\n<p class=\"text-gray-500 text-xs mt-4\">Cost analytics available on the <a href=\"/dashboard/analytics\" class=\"text-blue-400 hover:underline\">Analytics page</a>.</p>\n{% endblock %}\n```\n\n- [ ] **Step 4: Run full test suite**\n\nRun: `.venv/bin/python -m pytest -x -q`\nExpected: All tests pass\n\n- [ ] **Step 5: Commit**\n\n```bash\ngit add src/janus/dashboard/routes.py src/janus/dashboard/templates/overview.html src/janus/dashboard/templates/usage.html\ngit commit -m \"feat: enhanced overview with today's spend + budget bar, usage page link to analytics\"\n```\n\n---\n\n## Task 19: CLI — Budgets Sub-App\n\n**Files:**\n- Modify: `src/janus/cli.py`\n\n- [ ] **Step 1: Add budgets sub-app to CLI**\n\nIn `src/janus/cli.py`, after the `usage_app` definition and registration (after line 68), add:\n\n```python\nbudgets_app = typer.Typer(help=\"Manage spending budgets\")\npricing_app = typer.Typer(help=\"View model pricing\")\napp.add_typer(budgets_app, name=\"budgets\")\napp.add_typer(pricing_app, name=\"pricing\")\n```\n\nAfter the `usage_stats` function (end of file), add:\n\n```python\n@budgets_app.command(\"list\")\ndef budgets_list(\n config: str = typer.Option(\"~/.janus/config.yaml\", \"--config\", \"-c\"),\n) -> None:\n \"\"\"List all active budgets.\"\"\"\n import asyncio\n\n from janus.storage.budgets import get_budget_status, get_budgets\n from janus.storage.database import init_db\n\n db_path = _get_db_path(config)\n asyncio.run(init_db(db_path))\n budgets = asyncio.run(get_budgets(db_path))\n if not budgets:\n typer.echo(\"No budgets found.\")\n return\n for b in budgets:\n status = asyncio.run(get_budget_status(db_path, key_id=b[\"key_id\"]))\n scope = f\"Key #{b['key_id']}\" if b[\"key_id\"] else \"Global\"\n spend_str = f\"${status['today_spend']:.2f}\" if status else \"—\"\n limit_str = f\"${b['daily_limit']:.2f}\"\n pct_str = f\"{status['pct_used']:.0f}%\" if status else \"—\"\n st = status[\"status\"] if status else \"—\"\n typer.echo(\n f\" {b['id']:>3} {scope:<15} {limit_str:>10} \"\n f\"spent: {spend_str:>10} {pct_str:>6} {st}\"\n )\n\n\n@budgets_app.command(\"set\")\ndef budgets_set(\n daily: float = typer.Option(..., \"--daily\", \"-d\", help=\"Daily limit in USD\"),\n key: str = typer.Option(\"global\", \"--key\", \"-k\", help=\"Key name or 'global'\"),\n warn: float = typer.Option(80, \"--warn\", \"-w\", help=\"Warn threshold percentage\"),\n config: str = typer.Option(\"~/.janus/config.yaml\", \"--config\", \"-c\"),\n) -> None:\n \"\"\"Create or update a budget.\"\"\"\n import asyncio\n\n from janus.storage.api_keys import list_keys\n from janus.storage.budgets import create_or_update_budget\n from janus.storage.database import init_db\n\n db_path = _get_db_path(config)\n asyncio.run(init_db(db_path))\n\n key_id: int | None = None\n if key != \"global\":\n keys = asyncio.run(list_keys(db_path))\n match = next((k for k in keys if k[\"name\"] == key), None)\n if match is None:\n typer.echo(f\"Key '{key}' not found.\")\n raise typer.Exit(1)\n key_id = match[\"id\"]\n\n budget_id = asyncio.run(\n create_or_update_budget(db_path, key_id=key_id, daily_limit=daily, warn_pct=warn)\n )\n scope = key if key == \"global\" else f\"key '{key}'\"\n typer.echo(f\"Budget {budget_id} set: {scope} daily limit = ${daily:.2f}, warn at {warn:.0f}%\")\n\n\n@budgets_app.command(\"delete\")\ndef budgets_delete(\n budget_id: int = typer.Argument(..., help=\"Budget ID to delete\"),\n config: str = typer.Option(\"~/.janus/config.yaml\", \"--config\", \"-c\"),\n) -> None:\n \"\"\"Delete a budget.\"\"\"\n import asyncio\n\n from janus.storage.budgets import delete_budget\n from janus.storage.database import init_db\n\n db_path = _get_db_path(config)\n asyncio.run(init_db(db_path))\n deleted = asyncio.run(delete_budget(db_path, budget_id))\n if deleted:\n typer.echo(f\"Deleted budget {budget_id}\")\n else:\n typer.echo(f\"Budget {budget_id} not found\")\n raise typer.Exit(1)\n```\n\n- [ ] **Step 2: Verify CLI loads**\n\nRun: `.venv/bin/janus budgets --help`\nExpected: Shows `list`, `set`, `delete` commands\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add src/janus/cli.py\ngit commit -m \"feat: CLI budgets sub-app (list/set/delete)\"\n```\n\n---\n\n## Task 20: CLI — Pricing Sub-App\n\n**Files:**\n- Modify: `src/janus/cli.py`\n\n- [ ] **Step 1: Add pricing sub-app commands**\n\nAfter the `budgets_delete` function added in Task 19, add:\n\n```python\n@pricing_app.command(\"list\")\ndef pricing_list(\n config: str = typer.Option(\"~/.janus/config.yaml\", \"--config\", \"-c\"),\n) -> None:\n \"\"\"List all known model pricing.\"\"\"\n from janus.config.loader import load_config\n from janus.pricing.registry import PricingRegistry\n\n cfg = load_config(Path(config).expanduser())\n reg = PricingRegistry(cfg.pricing)\n all_pricing = reg.get_all()\n for model in sorted(all_pricing.keys()):\n p = all_pricing[model]\n typer.echo(\n f\" {model:<40} \"\n f\"in: ${p.input_per_mtok:<6} \"\n f\"out: ${p.output_per_mtok:<6} \"\n f\"cc: ${p.cache_creation_per_mtok:<6} \"\n f\"cr: ${p.cache_read_per_mtok:<6}\"\n )\n\n\n@pricing_app.command(\"show\")\ndef pricing_show(\n model: str = typer.Argument(..., help=\"Model name\"),\n config: str = typer.Option(\"~/.janus/config.yaml\", \"--config\", \"-c\"),\n) -> None:\n \"\"\"Show pricing for a specific model.\"\"\"\n from janus.config.loader import load_config\n from janus.pricing.registry import PricingRegistry\n\n cfg = load_config(Path(config).expanduser())\n reg = PricingRegistry(cfg.pricing)\n p = reg.get(model)\n if p is None:\n typer.echo(f\"No pricing found for '{model}'\")\n raise typer.Exit(1)\n typer.echo(f\"Model: {model}\")\n typer.echo(f\" Input: ${p.input_per_mtok} / Mtok\")\n typer.echo(f\" Output: ${p.output_per_mtok} / Mtok\")\n typer.echo(f\" Cache creation: ${p.cache_creation_per_mtok} / Mtok\")\n typer.echo(f\" Cache read: ${p.cache_read_per_mtok} / Mtok\")\n```\n\n- [ ] **Step 2: Verify CLI loads**\n\nRun: `.venv/bin/janus pricing --help`\nExpected: Shows `list`, `show` commands\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add src/janus/cli.py\ngit commit -m \"feat: CLI pricing sub-app (list/show)\"\n```\n\n---\n\n## Task 21: CLI — Enhanced Usage Commands\n\n**Files:**\n- Modify: `src/janus/cli.py`\n\n- [ ] **Step 1: Add cost and by-key commands to usage sub-app**\n\nAfter the existing `usage_stats` function, add:\n\n```python\n@usage_app.command(\"cost\")\ndef usage_cost(\n days: int = typer.Option(30, \"--days\", \"-d\", help=\"Number of days to show\"),\n config: str = typer.Option(\"~/.janus/config.yaml\", \"--config\", \"-c\"),\n) -> None:\n \"\"\"Show cost breakdown by model.\"\"\"\n import asyncio\n\n from janus.storage.analytics import get_breakdown\n from janus.storage.database import init_db\n\n db_path = _get_db_path(config)\n asyncio.run(init_db(db_path))\n rows = asyncio.run(get_breakdown(db_path, dimension=\"model\", days=days))\n if not rows:\n typer.echo(\"No usage data.\")\n return\n total_cost = sum(r[\"cost\"] for r in rows)\n typer.echo(f\"Cost breakdown (last {days} days):\")\n typer.echo(f\"{'Model':<35} {'Requests':>8} {'Cost':>12}\")\n typer.echo(\"-\" * 58)\n for r in rows:\n typer.echo(f\" {r['model'] or '—':<33} {r['requests']:>8} ${r['cost']:>10.4f}\")\n typer.echo(\"-\" * 58)\n typer.echo(f\" {'Total':<33} {'':>8} ${total_cost:>10.4f}\")\n\n\n@usage_app.command(\"by-key\")\ndef usage_by_key(\n days: int = typer.Option(30, \"--days\", \"-d\", help=\"Number of days to show\"),\n config: str = typer.Option(\"~/.janus/config.yaml\", \"--config\", \"-c\"),\n) -> None:\n \"\"\"Show spending per client API key.\"\"\"\n import asyncio\n\n from janus.storage.analytics import get_breakdown\n from janus.storage.api_keys import list_keys\n from janus.storage.database import init_db\n\n db_path = _get_db_path(config)\n asyncio.run(init_db(db_path))\n rows = asyncio.run(get_breakdown(db_path, dimension=\"client_key\", days=days))\n keys = asyncio.run(list_keys(db_path))\n key_map = {k[\"id\"]: k[\"name\"] for k in keys}\n if not rows:\n typer.echo(\"No per-key usage data.\")\n return\n typer.echo(f\"Spending per key (last {days} days):\")\n typer.echo(f\"{'Key':<25} {'Requests':>8} {'Cost':>12}\")\n typer.echo(\"-\" * 48)\n for r in rows:\n name = key_map.get(r[\"client_key_id\"], f\"Key #{r['client_key_id']}\") if r[\"client_key_id\"] else \"No key\"\n typer.echo(f\" {name:<23} {r['requests']:>8} ${r['cost']:>10.4f}\")\n```\n\n- [ ] **Step 2: Verify CLI loads**\n\nRun: `.venv/bin/janus usage --help`\nExpected: Shows `stats`, `cost`, `by-key` commands\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add src/janus/cli.py\ngit commit -m \"feat: CLI usage cost and by-key commands\"\n```\n\n---\n\n## Task 22: Lint, Typecheck, Full Test Suite\n\n**Files:**\n- All files\n\n- [ ] **Step 1: Run ruff lint**\n\nRun: `.venv/bin/ruff check src/janus/ tests/`\nExpected: No errors. Fix any issues found.\n\n- [ ] **Step 2: Run ruff format check**\n\nRun: `.venv/bin/ruff format --check src/janus/ tests/`\nExpected: No errors. Fix any issues found.\n\n- [ ] **Step 3: Run mypy strict**\n\nRun: `.venv/bin/mypy src/janus/`\nExpected: No errors. Fix any type issues.\n\n- [ ] **Step 4: Run full test suite**\n\nRun: `.venv/bin/python -m pytest -v`\nExpected: All tests pass\n\n- [ ] **Step 5: Commit any fixes**\n\n```bash\ngit add -A\ngit commit -m \"fix: lint and typecheck cleanup for Phase 6\"\n```\n\n---\n\n## Task 23: Update AGENTS.md\n\n**Files:**\n- Modify: `AGENTS.md`\n\n- [ ] **Step 1: Add Phase 6 sections to AGENTS.md**\n\nAdd the following sections to `AGENTS.md` (before the Config section):\n\n```markdown\n## Pricing & cost tracking\n\nThe `pricing/` package provides per-model cost estimation. `PricingRegistry` merges builtin defaults (~30 popular models) with YAML overrides from the `pricing:` config section. Cost is computed at recording time via `compute_cost(usage, model, registry)` and stored in the `usage.cost` column. Unknown models cost $0.0 (not an error).\n\n- `pricing/builtin.py` — hardcoded `dict[str, ModelPricing]` seed data ($ per million tokens: input, output, cache_creation, cache_read).\n- `pricing/registry.py` — `PricingRegistry(overrides)`, does exact match then progressively shorter prefix matching for model variants.\n- `pricing/calculator.py` — `compute_cost(usage, model, registry) -> float`, pure function.\n- Config: `pricing:` section in YAML, same dict structure as builtin.\n\n## Budget enforcement\n\nBudgets are daily spending limits stored in the `budgets` SQLite table. Each budget targets either a specific API key (`key_id`) or is global (`key_id = NULL`). Enforcement happens in `_handle()` before routing:\n\n- **Warn threshold** (default 80%): request proceeds, dashboard shows amber.\n- **Hard threshold** (100%): request rejected with `429` + `Retry-After` header.\n- Both per-key and global budgets are checked; most restrictive wins.\n- Fail-safe: DB errors don't block requests.\n- `storage/budgets.py` — CRUD + `get_budget_status(key_id)`.\n- CLI: `janus budgets list/set/delete`.\n\n## Analytics\n\n`storage/analytics.py` provides aggregated queries: `get_spend_summary(days)`, `get_breakdown(dimension, days)`, `get_success_rate(days)`. The dashboard `/dashboard/analytics` page uses Chart.js (via CDN) for spend trends and success-rate donut charts. Breakdowns available by model, provider, account, or client key.\n```\n\n- [ ] **Step 2: Commit**\n\n```bash\ngit add AGENTS.md\ngit commit -m \"docs: update AGENTS.md for Phase 6 (pricing, budgets, analytics)\"\n```\n\n---\n\n## Task 24: Final Integration Test Run + Push\n\n- [ ] **Step 1: Run the complete test suite one final time**\n\nRun: `.venv/bin/python -m pytest -v`\nExpected: All tests pass, no skips on new tests\n\n- [ ] **Step 2: Run lint + typecheck one final time**\n\nRun: `.venv/bin/ruff check src/janus/ tests/ && .venv/bin/mypy src/janus/`\nExpected: Clean\n\n- [ ] **Step 3: Verify CLI commands work end-to-end**\n\nRun:\n```bash\n.venv/bin/janus pricing list\n.venv/bin/janus pricing show gpt-4o\n.venv/bin/janus budgets --help\n.venv/bin/janus usage --help\n```\nExpected: All commands produce output without errors\n\n- [ ] **Step 4: Push to main**\n\n```bash\ngit push origin main\n```\n"}}}}