zexl-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ dist/
9
+ *.egg-info/
10
+
11
+ # Node
12
+ node_modules/
13
+ .next/
14
+ out/
15
+ *.tsbuildinfo
16
+ next-env.d.ts
17
+
18
+ # Secrets and local config
19
+ .env
20
+ .env.*
21
+ !.env.example
22
+
23
+ # OS / editor
24
+ .DS_Store
25
+ .idea/
26
+ .vscode/
27
+
28
+ # Database backups (scripts/backup-restore-check.sh)
29
+ backups/
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.5
2
+ Name: zexl-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for Zexl: ask your AI assistant about AI spend and waste, and connect a project in one go.
5
+ Project-URL: Homepage, https://tryzexl.xyz/docs/mcp
6
+ Author: Zexl
7
+ License-Expression: Apache-2.0
8
+ Keywords: ai,cost,llm,mcp,zexl
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: mcp<2,>=1.12
11
+ Description-Content-Type: text/markdown
12
+
13
+ # zexl-mcp
14
+
15
+ An MCP server for [Zexl](https://tryzexl.xyz). Ask your AI assistant about your AI spend ("what did we waste last week, and why?"), and let a coding agent add Zexl to a project in one go.
16
+
17
+ It only reads. It cannot send data to Zexl, change budgets, keys or members, and it never sees prompt text, because Zexl does not have any.
18
+
19
+ ## Add it to your AI tool
20
+
21
+ Claude Code:
22
+
23
+ ```bash
24
+ claude mcp add zexl -- uvx zexl-mcp
25
+ ```
26
+
27
+ Claude Desktop, Cursor and other clients that take a JSON config:
28
+
29
+ ```json
30
+ {
31
+ "mcpServers": {
32
+ "zexl": { "command": "uvx", "args": ["zexl-mcp"] }
33
+ }
34
+ }
35
+ ```
36
+
37
+ `uvx` comes with [uv](https://docs.astral.sh/uv/). With pip instead: `pip install zexl-mcp`, then use `zexl-mcp` as the command.
38
+
39
+ ## Connect
40
+
41
+ Two ways:
42
+
43
+ 1. **Let the agent do it.** Ask: "Add Zexl to this project." The agent calls `zexl_connect_start` and shows you a link and a code. Open the link, sign up or sign in (email or Google), check the code matches, pick a workspace and an environment, and approve. The agent then calls `zexl_connect_finish`, which:
44
+ - writes `ZEXL_API_KEY` into the project's `.env` file itself, so the key never appears in the chat;
45
+ - makes the salt (the part after the dot) on your machine; Zexl never receives it. If the project already had a Zexl key, its salt is kept so earlier calls still match;
46
+ - adds `.env` to `.gitignore` if git would otherwise commit it;
47
+ - saves a read key for the other tools in `~/.config/zexl/credentials.json`, readable only by you.
48
+
49
+ The agent then follows `zexl_setup_guide` to add the SDK and checks the first call with `zexl_check_connection`.
50
+
51
+ 2. **Use a read key you made.** In the Zexl dashboard, Settings > API keys, create a key with "Read figures (MCP server)". Then:
52
+
53
+ ```bash
54
+ claude mcp add zexl -e ZEXL_READ_KEY=zx_... -- uvx zexl-mcp
55
+ ```
56
+
57
+ ## Tools
58
+
59
+ | Tool | What it answers |
60
+ |---|---|
61
+ | `zexl_status` | Is it connected, to which workspace, and when did the last call arrive? |
62
+ | `zexl_connect_start`, `zexl_connect_finish` | Connect this project (see above) |
63
+ | `zexl_setup_guide` | Exact steps to add the SDK to a Python app, or to track Claude Code |
64
+ | `zexl_check_connection` | Did the first call arrive, and what was recorded? |
65
+ | `zexl_spend_summary` | Spend, waste by type, useful share, budget headroom, invoice coverage |
66
+ | `zexl_forecast` | Where this month will end, the likely range, and when it passes the budget |
67
+ | `zexl_top_waste` | The biggest waste findings, each with a fix |
68
+ | `zexl_breakdown` | Spend and waste per feature, model, customer or actor |
69
+ | `zexl_explain_spike` | Why one day cost more: the waste that day, and the features and models behind it |
70
+ | `zexl_pattern_runs` | The runs behind a finding, worst first |
71
+ | `zexl_run_detail` | One run step by step, with repeats, retries and cut-off answers marked |
72
+
73
+ There is also a prompt, `integrate_zexl`, that walks the agent through connecting, setup and the first check.
74
+
75
+ Every reading tool takes `days` (default 30) and `environment` (`production` by default; `staging`, `development` or `all`). A read key made for one environment always reads that environment.
76
+
77
+ ## Settings
78
+
79
+ | Variable | Meaning | Default |
80
+ |---|---|---|
81
+ | `ZEXL_READ_KEY` | A read key. Overrides the saved credentials | the saved credentials |
82
+ | `ZEXL_API_URL` | The Zexl API | `https://zexl-api.fly.dev` |
83
+ | `ZEXL_CONFIG_DIR` | Where credentials are saved | `~/.config/zexl` |
84
+
85
+ To disconnect, revoke the read key in Settings > API keys and delete `~/.config/zexl/credentials.json`.
86
+
87
+ ## Development
88
+
89
+ ```bash
90
+ uv sync
91
+ ```
92
+
93
+ ```bash
94
+ uv run ruff check . && uv run ruff format --check . && uv run mypy && uv run pytest -q
95
+ ```
@@ -0,0 +1,83 @@
1
+ # zexl-mcp
2
+
3
+ An MCP server for [Zexl](https://tryzexl.xyz). Ask your AI assistant about your AI spend ("what did we waste last week, and why?"), and let a coding agent add Zexl to a project in one go.
4
+
5
+ It only reads. It cannot send data to Zexl, change budgets, keys or members, and it never sees prompt text, because Zexl does not have any.
6
+
7
+ ## Add it to your AI tool
8
+
9
+ Claude Code:
10
+
11
+ ```bash
12
+ claude mcp add zexl -- uvx zexl-mcp
13
+ ```
14
+
15
+ Claude Desktop, Cursor and other clients that take a JSON config:
16
+
17
+ ```json
18
+ {
19
+ "mcpServers": {
20
+ "zexl": { "command": "uvx", "args": ["zexl-mcp"] }
21
+ }
22
+ }
23
+ ```
24
+
25
+ `uvx` comes with [uv](https://docs.astral.sh/uv/). With pip instead: `pip install zexl-mcp`, then use `zexl-mcp` as the command.
26
+
27
+ ## Connect
28
+
29
+ Two ways:
30
+
31
+ 1. **Let the agent do it.** Ask: "Add Zexl to this project." The agent calls `zexl_connect_start` and shows you a link and a code. Open the link, sign up or sign in (email or Google), check the code matches, pick a workspace and an environment, and approve. The agent then calls `zexl_connect_finish`, which:
32
+ - writes `ZEXL_API_KEY` into the project's `.env` file itself, so the key never appears in the chat;
33
+ - makes the salt (the part after the dot) on your machine; Zexl never receives it. If the project already had a Zexl key, its salt is kept so earlier calls still match;
34
+ - adds `.env` to `.gitignore` if git would otherwise commit it;
35
+ - saves a read key for the other tools in `~/.config/zexl/credentials.json`, readable only by you.
36
+
37
+ The agent then follows `zexl_setup_guide` to add the SDK and checks the first call with `zexl_check_connection`.
38
+
39
+ 2. **Use a read key you made.** In the Zexl dashboard, Settings > API keys, create a key with "Read figures (MCP server)". Then:
40
+
41
+ ```bash
42
+ claude mcp add zexl -e ZEXL_READ_KEY=zx_... -- uvx zexl-mcp
43
+ ```
44
+
45
+ ## Tools
46
+
47
+ | Tool | What it answers |
48
+ |---|---|
49
+ | `zexl_status` | Is it connected, to which workspace, and when did the last call arrive? |
50
+ | `zexl_connect_start`, `zexl_connect_finish` | Connect this project (see above) |
51
+ | `zexl_setup_guide` | Exact steps to add the SDK to a Python app, or to track Claude Code |
52
+ | `zexl_check_connection` | Did the first call arrive, and what was recorded? |
53
+ | `zexl_spend_summary` | Spend, waste by type, useful share, budget headroom, invoice coverage |
54
+ | `zexl_forecast` | Where this month will end, the likely range, and when it passes the budget |
55
+ | `zexl_top_waste` | The biggest waste findings, each with a fix |
56
+ | `zexl_breakdown` | Spend and waste per feature, model, customer or actor |
57
+ | `zexl_explain_spike` | Why one day cost more: the waste that day, and the features and models behind it |
58
+ | `zexl_pattern_runs` | The runs behind a finding, worst first |
59
+ | `zexl_run_detail` | One run step by step, with repeats, retries and cut-off answers marked |
60
+
61
+ There is also a prompt, `integrate_zexl`, that walks the agent through connecting, setup and the first check.
62
+
63
+ Every reading tool takes `days` (default 30) and `environment` (`production` by default; `staging`, `development` or `all`). A read key made for one environment always reads that environment.
64
+
65
+ ## Settings
66
+
67
+ | Variable | Meaning | Default |
68
+ |---|---|---|
69
+ | `ZEXL_READ_KEY` | A read key. Overrides the saved credentials | the saved credentials |
70
+ | `ZEXL_API_URL` | The Zexl API | `https://zexl-api.fly.dev` |
71
+ | `ZEXL_CONFIG_DIR` | Where credentials are saved | `~/.config/zexl` |
72
+
73
+ To disconnect, revoke the read key in Settings > API keys and delete `~/.config/zexl/credentials.json`.
74
+
75
+ ## Development
76
+
77
+ ```bash
78
+ uv sync
79
+ ```
80
+
81
+ ```bash
82
+ uv run ruff check . && uv run ruff format --check . && uv run mypy && uv run pytest -q
83
+ ```
@@ -0,0 +1,52 @@
1
+ [project]
2
+ name = "zexl-mcp"
3
+ version = "0.1.0"
4
+ description = "MCP server for Zexl: ask your AI assistant about AI spend and waste, and connect a project in one go."
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ authors = [{ name = "Zexl" }]
8
+ keywords = ["mcp", "llm", "ai", "cost", "zexl"]
9
+ requires-python = ">=3.10"
10
+ dependencies = ["mcp>=1.12,<2"]
11
+
12
+ [project.scripts]
13
+ zexl-mcp = "zexl_mcp.server:main"
14
+
15
+ [project.urls]
16
+ Homepage = "https://tryzexl.xyz/docs/mcp"
17
+
18
+ [dependency-groups]
19
+ dev = ["pytest>=8", "pytest-asyncio>=0.24", "ruff>=0.8", "mypy>=1.13"]
20
+
21
+ [build-system]
22
+ requires = ["hatchling"]
23
+ build-backend = "hatchling.build"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/zexl_mcp"]
27
+
28
+ [tool.hatch.build.targets.sdist]
29
+ include = ["src", "README.md"]
30
+
31
+ [tool.ruff]
32
+ line-length = 100
33
+ target-version = "py310"
34
+
35
+ [tool.ruff.lint]
36
+ select = ["E", "F", "I", "B", "UP", "S"]
37
+
38
+ [tool.ruff.lint.per-file-ignores]
39
+ "tests/*" = ["S101", "S105", "S106", "S603", "S607", "E501"]
40
+
41
+ [tool.mypy]
42
+ strict = true
43
+ files = ["src"]
44
+
45
+ [[tool.mypy.overrides]]
46
+ module = ["mcp.*"]
47
+ ignore_missing_imports = true
48
+
49
+ [tool.pytest.ini_options]
50
+ testpaths = ["tests"]
51
+ asyncio_mode = "auto"
52
+ asyncio_default_fixture_loop_scope = "function"
@@ -0,0 +1,3 @@
1
+ """Zexl MCP server. See server.py and design section 14."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,67 @@
1
+ """A small client for the Zexl query API. Read keys only; nothing here can change a workspace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from zexl_mcp import config
10
+
11
+ USER_AGENT = "zexl-mcp/0.1.0"
12
+
13
+
14
+ class ZexlError(Exception):
15
+ """A failure the person or the agent can act on. The message says what to do."""
16
+
17
+
18
+ class Api:
19
+ def __init__(self, base: str | None = None, transport: httpx.AsyncBaseTransport | None = None):
20
+ self._base = (base or config.api_url()).rstrip("/")
21
+ self._transport = transport
22
+
23
+ def _client(self) -> httpx.AsyncClient:
24
+ return httpx.AsyncClient(
25
+ base_url=self._base,
26
+ timeout=20.0,
27
+ transport=self._transport,
28
+ headers={"User-Agent": USER_AGENT},
29
+ )
30
+
31
+ async def _send(
32
+ self, method: str, path: str, key: str | None = None, body: Any = None
33
+ ) -> httpx.Response:
34
+ headers = {"Authorization": f"Bearer {key}"} if key else {}
35
+ try:
36
+ async with self._client() as c:
37
+ return await c.request(method, path, headers=headers, json=body)
38
+ except httpx.HTTPError as e:
39
+ raise ZexlError(f"Could not reach Zexl at {self._base} ({type(e).__name__}).") from e
40
+
41
+ async def get(self, path: str, key: str) -> dict[str, Any]:
42
+ r = await self._send("GET", path, key)
43
+ return _json(r)
44
+
45
+ async def post(self, path: str, body: Any) -> tuple[int, dict[str, Any]]:
46
+ r = await self._send("POST", path, body=body)
47
+ if r.status_code in (200, 201, 202):
48
+ return r.status_code, r.json()
49
+ _json(r)
50
+ raise AssertionError("unreachable")
51
+
52
+
53
+ def _json(r: httpx.Response) -> dict[str, Any]:
54
+ if r.status_code < 300:
55
+ data: dict[str, Any] = r.json()
56
+ return data
57
+ try:
58
+ detail = r.json().get("detail")
59
+ except ValueError:
60
+ detail = None
61
+ if r.status_code == 401:
62
+ raise ZexlError(
63
+ "Zexl did not accept the read key. Run zexl_connect_start to connect again, or set "
64
+ "ZEXL_READ_KEY to a read key from Settings > API keys."
65
+ )
66
+ message = detail if isinstance(detail, str) else f"Zexl answered {r.status_code}."
67
+ raise ZexlError(message)
@@ -0,0 +1,79 @@
1
+ """Where the MCP server finds its settings and its read key. Design section 14.
2
+
3
+ Order for the read key: `ZEXL_READ_KEY`, then the credentials file written by the connect flow
4
+ (`~/.config/zexl/credentials.json`, or `$ZEXL_CONFIG_DIR/credentials.json`).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import stat
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ DEFAULT_API = "https://zexl-api.fly.dev"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Credentials:
20
+ read_key: str
21
+ workspace_id: str = ""
22
+ workspace_name: str = ""
23
+ environment: str = ""
24
+
25
+
26
+ def api_url() -> str:
27
+ return os.environ.get("ZEXL_API_URL", DEFAULT_API).rstrip("/")
28
+
29
+
30
+ def config_dir() -> Path:
31
+ base = os.environ.get("ZEXL_CONFIG_DIR")
32
+ if base:
33
+ return Path(base)
34
+ xdg = os.environ.get("XDG_CONFIG_HOME")
35
+ return (Path(xdg) if xdg else Path.home() / ".config") / "zexl"
36
+
37
+
38
+ def credentials_path() -> Path:
39
+ return config_dir() / "credentials.json"
40
+
41
+
42
+ def load() -> Credentials | None:
43
+ env = os.environ.get("ZEXL_READ_KEY", "").strip()
44
+ if env:
45
+ return Credentials(env)
46
+ path = credentials_path()
47
+ try:
48
+ data = json.loads(path.read_text())
49
+ except (OSError, ValueError):
50
+ return None
51
+ key = data.get("read_key")
52
+ if not isinstance(key, str) or not key.startswith("zx_"):
53
+ return None
54
+ return Credentials(
55
+ key,
56
+ str(data.get("workspace_id", "")),
57
+ str(data.get("workspace_name", "")),
58
+ str(data.get("environment") or ""),
59
+ )
60
+
61
+
62
+ def save(creds: Credentials) -> Path:
63
+ """Writes the credentials file readable by this user only."""
64
+ path = credentials_path()
65
+ path.parent.mkdir(parents=True, exist_ok=True)
66
+ body = json.dumps(
67
+ {
68
+ "read_key": creds.read_key,
69
+ "workspace_id": creds.workspace_id,
70
+ "workspace_name": creds.workspace_name,
71
+ "environment": creds.environment,
72
+ },
73
+ indent=2,
74
+ )
75
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, stat.S_IRUSR | stat.S_IWUSR)
76
+ with os.fdopen(fd, "w") as f:
77
+ f.write(body + "\n")
78
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
79
+ return path
@@ -0,0 +1,139 @@
1
+ """Puts the send key into a project's .env file without the key passing through the model.
2
+
3
+ The salt (the part after the dot) is made here, on the person's machine, and never sent to
4
+ Zexl. When the project already has a Zexl key, its salt is reused so that prompt hashes keep
5
+ matching earlier calls (design sections 3.3 and 14.3).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ import secrets
13
+ import shutil
14
+ import subprocess # noqa: S404 - only `git check-ignore`, with fixed arguments
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ NAME = "ZEXL_API_KEY"
19
+ _LINE = re.compile(rf"^\s*(?:export\s+)?{NAME}\s*=\s*(.*)$")
20
+ _SALT = re.compile(r"^[A-Za-z0-9_-]{22,128}$")
21
+
22
+
23
+ def mask(key: str) -> str:
24
+ """`zx_ab12…` : enough to recognise a key, not enough to use it."""
25
+ return key[:7] + "…" if len(key) > 7 else "…"
26
+
27
+
28
+ def _unquote(value: str) -> str:
29
+ value = value.strip()
30
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
31
+ return value[1:-1]
32
+ return value.split(" #", 1)[0].strip()
33
+
34
+
35
+ def existing_key(path: Path) -> str | None:
36
+ """A Zexl key already in the shell or in the .env file."""
37
+ try:
38
+ for line in path.read_text().splitlines():
39
+ m = _LINE.match(line)
40
+ if m:
41
+ value = _unquote(m.group(1))
42
+ if value.startswith("zx_"):
43
+ return value
44
+ except OSError:
45
+ pass
46
+ env = os.environ.get(NAME, "").strip()
47
+ return env if env.startswith("zx_") else None
48
+
49
+
50
+ def salt_of(key: str | None) -> str | None:
51
+ if not key or "." not in key:
52
+ return None
53
+ salt = key.rsplit(".", 1)[1]
54
+ return salt if _SALT.match(salt) else None
55
+
56
+
57
+ def new_salt() -> str:
58
+ return secrets.token_urlsafe(32)
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Written:
63
+ path: Path
64
+ replaced: bool
65
+ reused_salt: bool
66
+ gitignore: str # "added", "already", "not a git repository" or "could not check"
67
+ key: str
68
+
69
+
70
+ def write_key(path: Path, token: str) -> Written:
71
+ """Adds or replaces ZEXL_API_KEY in `path`, creating it if needed. `token` is
72
+ `zx_<id>_<secret>` from the API; the salt is added here."""
73
+ previous = existing_key(path)
74
+ salt = salt_of(previous)
75
+ key = f"{token}.{salt or new_salt()}"
76
+ lines: list[str] = []
77
+ replaced = False
78
+ try:
79
+ lines = path.read_text().splitlines()
80
+ except FileNotFoundError:
81
+ pass
82
+ out = []
83
+ for line in lines:
84
+ if _LINE.match(line):
85
+ if not replaced:
86
+ out.append(f"{NAME}={key}")
87
+ replaced = True
88
+ continue
89
+ out.append(line)
90
+ if not replaced:
91
+ if out and out[-1].strip():
92
+ out.append("")
93
+ out.append("# Zexl: sends call metadata only, never prompts. https://tryzexl.xyz/docs")
94
+ out.append(f"{NAME}={key}")
95
+ path.parent.mkdir(parents=True, exist_ok=True)
96
+ path.write_text("\n".join(out) + "\n")
97
+ try:
98
+ os.chmod(path, 0o600)
99
+ except OSError:
100
+ pass
101
+ return Written(path, replaced, salt is not None, ensure_ignored(path), key)
102
+
103
+
104
+ def ensure_ignored(path: Path) -> str:
105
+ """Makes sure git will not commit the .env file."""
106
+ folder = path.parent.resolve()
107
+ git = shutil.which("git")
108
+ if git is None:
109
+ return "could not check"
110
+ try:
111
+ top = subprocess.run( # noqa: S603
112
+ [git, "rev-parse", "--show-toplevel"],
113
+ cwd=folder,
114
+ capture_output=True,
115
+ text=True,
116
+ timeout=5,
117
+ )
118
+ except (OSError, subprocess.SubprocessError):
119
+ return "could not check"
120
+ if top.returncode != 0:
121
+ return "not a git repository"
122
+ root = Path(top.stdout.strip())
123
+ try:
124
+ check = subprocess.run( # noqa: S603
125
+ [git, "check-ignore", "-q", str(path.resolve())],
126
+ cwd=root,
127
+ capture_output=True,
128
+ timeout=5,
129
+ )
130
+ except (OSError, subprocess.SubprocessError):
131
+ return "could not check"
132
+ if check.returncode == 0:
133
+ return "already"
134
+ rel = path.resolve().relative_to(root).as_posix()
135
+ gi = root / ".gitignore"
136
+ text = gi.read_text() if gi.exists() else ""
137
+ sep = "" if not text or text.endswith("\n") else "\n"
138
+ gi.write_text(f"{text}{sep}{rel}\n")
139
+ return "added"
@@ -0,0 +1,82 @@
1
+ """Setup steps the agent follows to add Zexl to a project. Kept in the package so the tool works
2
+ without fetching anything; the same steps are on https://tryzexl.xyz/docs."""
3
+
4
+ from __future__ import annotations
5
+
6
+ PYTHON = """\
7
+ # Add Zexl to a Python app
8
+
9
+ Zexl records metadata about each OpenAI and Anthropic call (model, tokens, timing, outcome and
10
+ salted hashes) and never sends prompt or response text.
11
+
12
+ 1. Install the SDK. Add `zexl` to the project's dependencies the way the project already does it:
13
+ - requirements.txt: add a line `zexl`
14
+ - pyproject.toml (uv, poetry, pdm, hatch): add `"zexl"` to `dependencies`, or run `uv add zexl`
15
+ - otherwise: `pip install zexl`
16
+
17
+ 2. Load the key from the environment. `ZEXL_API_KEY` is already in the project's `.env` file
18
+ (written by zexl_connect_finish). Do not copy the key into code or chat.
19
+ - If the app already loads `.env` (python-dotenv, pydantic-settings, Django-environ, the
20
+ framework's own loader), nothing to do: make sure the loader runs before step 3.
21
+ - Otherwise set it in the process environment where the app is deployed. For local runs,
22
+ `python-dotenv` with `load_dotenv()` at the top of the entry point works.
23
+
24
+ 3. Turn capture on. Add this one import once, at the entry point, before the OpenAI or Anthropic
25
+ client is created:
26
+
27
+ import zexl.auto # records OpenAI and Anthropic calls; prompts never leave the process
28
+
29
+ Typical entry points: `main.py` or `app.py` (FastAPI, Flask), `manage.py` and `wsgi.py`/`asgi.py`
30
+ (Django), the worker's start module (Celery, RQ), the handler module (AWS Lambda), the first
31
+ cell (notebooks). If the key is loaded from .env in code, put the import after `load_dotenv()`.
32
+
33
+ 4. Label the spend (optional, recommended). Labels make the dashboard useful:
34
+
35
+ import zexl
36
+
37
+ with zexl.feature("support-reply"): # which product feature made the calls
38
+ ...
39
+ with zexl.run("research", feature="report-builder"): # one unit of agent work
40
+ ...
41
+ zexl.set_customer(account.id) # your customer's id, never an email
42
+
43
+ Without labels, the feature is the `module.function` that made the call, and agent loops are
44
+ grouped into runs automatically when each request continues the previous answer.
45
+
46
+ 5. Short-lived processes (serverless, scripts, CLI tools): call `zexl.flush()` before exit.
47
+
48
+ 6. Environment. The key decides the environment (production, staging or development) on the
49
+ server, so `ZEXL_ENV` is only needed with older keys.
50
+
51
+ 7. Check it works: run the app so it makes one model call, then call zexl_check_connection.
52
+ `zexl.status()` in the app shows whether capture is on and how many records were sent.
53
+
54
+ Do not: put the key in source files, log it, or commit `.env`. Do not wrap or replace the
55
+ OpenAI/Anthropic clients; the import hooks them in place. Calls are never blocked or changed, and
56
+ if Zexl is down the app carries on.
57
+ """
58
+
59
+ CLAUDE_CODE = """\
60
+ # Track Claude Code usage with Zexl
61
+
62
+ Claude Code exports usage over OpenTelemetry. No SDK, no code change. It sends model, tokens,
63
+ cost estimate, timing and errors per request; prompts and code are not sent unless the user turns
64
+ that on, and Zexl drops them if they arrive.
65
+
66
+ Use a send key (zx_<id>_<secret>, the part before the dot is enough). Put these in the shell
67
+ profile (~/.zshrc or ~/.bashrc), or in Claude Code's settings.json under "env":
68
+
69
+ export CLAUDE_CODE_ENABLE_TELEMETRY=1
70
+ export OTEL_LOGS_EXPORTER=otlp
71
+ export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
72
+ export OTEL_EXPORTER_OTLP_ENDPOINT=https://zexl-intake.fly.dev
73
+ export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer zx_<id>_<secret>"
74
+
75
+ Then start a new `claude` session. On a Pro or Max plan the cost shown is what the same usage
76
+ would cost on the API; you are not billed per token.
77
+
78
+ The key is in the project's .env as ZEXL_API_KEY: the header takes the part before the dot. Ask
79
+ the person to paste it into their profile themselves rather than printing it in chat.
80
+ """
81
+
82
+ TOPICS = {"python": PYTHON, "claude-code": CLAUDE_CODE}
File without changes
@@ -0,0 +1,462 @@
1
+ """Zexl MCP server (stdio). Design section 14.
2
+
3
+ Run with `uvx zexl-mcp`. Tools read a workspace through the query API with a read key, and the
4
+ connect tools set a project up in one go. Nothing here can change a workspace, and no tool ever
5
+ sees prompt text: Zexl does not have any.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import statistics
13
+ import time
14
+ from datetime import date, datetime, timedelta, timezone
15
+ from pathlib import Path
16
+ from typing import Any, Literal
17
+
18
+ from mcp.server.fastmcp import FastMCP
19
+ from mcp.types import CallToolResult, TextContent, ToolAnnotations
20
+
21
+ from zexl_mcp import config, envfile, guide, text
22
+ from zexl_mcp.client import Api, ZexlError
23
+
24
+ READ = ToolAnnotations(readOnlyHint=True, openWorldHint=True)
25
+ Env = Literal["production", "staging", "development", "all"]
26
+ Category = Literal["retry", "loop", "abandoned", "truncate_redo", "cache_miss", "context_growth"]
27
+
28
+ INSTRUCTIONS = """\
29
+ Zexl shows how much AI model spend did useful work and what was wasted (retries, loops,
30
+ abandoned responses, answers cut off and redone, prompt caching not used). It only ever has call
31
+ metadata, never prompts.
32
+
33
+ To add Zexl to the user's project: zexl_connect_start, show the user the link and code, then
34
+ zexl_connect_finish (it writes ZEXL_API_KEY to .env itself; never ask the user to paste a key into
35
+ chat), then follow zexl_setup_guide, then zexl_check_connection after the app makes a call.
36
+
37
+ To answer questions about spend: start with zexl_spend_summary or zexl_top_waste, then drill in
38
+ with zexl_pattern_runs and zexl_run_detail. Default environment is production. Quote dollar
39
+ figures as given and say when they are estimates.
40
+ """
41
+
42
+
43
+ def _result(message: str, data: dict[str, Any] | None = None) -> CallToolResult:
44
+ return CallToolResult(
45
+ content=[TextContent(type="text", text=message)], structuredContent=data or None
46
+ )
47
+
48
+
49
+ def _error(e: Exception) -> CallToolResult:
50
+ return CallToolResult(content=[TextContent(type="text", text=str(e))], isError=True)
51
+
52
+
53
+ def _range(days: int) -> tuple[int, str]:
54
+ days = max(1, min(int(days), 365))
55
+ end = datetime.now(timezone.utc).date() # noqa: UP017 - Python 3.10
56
+ start = end - timedelta(days=days - 1)
57
+ return days, f"from={start.isoformat()}&to={end.isoformat()}"
58
+
59
+
60
+ class Zexl:
61
+ """The tools' logic, separate from MCP so it can be tested with a fake API."""
62
+
63
+ def __init__(self, api: Api | None = None, sleep: Any = asyncio.sleep) -> None:
64
+ self.api = api or Api()
65
+ self._sleep = sleep
66
+ self._pending: dict[str, Any] | None = None
67
+ self._connected_at: float | None = None
68
+
69
+ # --- credentials ---
70
+
71
+ def _creds(self) -> config.Credentials:
72
+ creds = config.load()
73
+ if creds is None:
74
+ raise ZexlError(
75
+ "Zexl is not connected yet. Run zexl_connect_start to connect this project, or set "
76
+ "ZEXL_READ_KEY to a read key from Settings > API keys in the Zexl dashboard."
77
+ )
78
+ return creds
79
+
80
+ async def _workspace(self) -> tuple[config.Credentials, dict[str, Any]]:
81
+ creds = self._creds()
82
+ listed = await self.api.get("/v1/workspaces", creds.read_key)
83
+ workspaces = listed.get("workspaces") or []
84
+ if not workspaces:
85
+ raise ZexlError("The read key has no workspace. Connect again with zexl_connect_start.")
86
+ return creds, workspaces[0]
87
+
88
+ async def _get(self, path: str) -> tuple[dict[str, Any], dict[str, Any]]:
89
+ creds, ws = await self._workspace()
90
+ return ws, await self.api.get(f"/v1/workspaces/{ws['id']}{path}", creds.read_key)
91
+
92
+ @staticmethod
93
+ def _env(ws: dict[str, Any], environment: str) -> str:
94
+ return str(ws.get("environment") or environment)
95
+
96
+ # --- connect ---
97
+
98
+ async def connect_start(self, project: str, client: str) -> CallToolResult:
99
+ code, body = await self.api.post(
100
+ "/v1/connect", {"client": client[:40], "project": project[:80]}
101
+ )
102
+ self._pending = body
103
+ pending = config.config_dir() / "pending.json"
104
+ try:
105
+ pending.parent.mkdir(parents=True, exist_ok=True)
106
+ pending.write_text(json.dumps(body))
107
+ pending.chmod(0o600)
108
+ except OSError:
109
+ pass
110
+ msg = (
111
+ f"Ask the user to open this link and approve: {body['url']}\n"
112
+ f"The page shows the code {body['user_code']}; it must match. They can sign up there "
113
+ "(email or Google) if they have no account. The link works for 15 minutes.\n"
114
+ "Then call zexl_connect_finish with the path of the project's .env file. Do not ask "
115
+ "the user to paste any key into chat."
116
+ )
117
+ return _result(msg, {"url": body["url"], "user_code": body["user_code"]})
118
+
119
+ def _load_pending(self) -> dict[str, Any] | None:
120
+ if self._pending:
121
+ return self._pending
122
+ try:
123
+ data: dict[str, Any] = json.loads((config.config_dir() / "pending.json").read_text())
124
+ return data
125
+ except (OSError, ValueError):
126
+ return None
127
+
128
+ async def connect_finish(self, env_file: str, wait_seconds: int) -> CallToolResult:
129
+ pending = self._load_pending()
130
+ if not pending:
131
+ raise ZexlError("No connect request is open. Call zexl_connect_start first.")
132
+ deadline = time.monotonic() + max(0, min(int(wait_seconds), 600))
133
+ poll = {"poll_token": pending["poll_token"]}
134
+ while True:
135
+ try:
136
+ status, body = await self.api.post(
137
+ f"/v1/connect/{pending['connect_id']}/token", poll
138
+ )
139
+ except ZexlError:
140
+ self._clear_pending()
141
+ raise
142
+ if status == 200:
143
+ break
144
+ if time.monotonic() >= deadline:
145
+ return _result(
146
+ f"Still waiting for the user to approve at {pending['url']} (code "
147
+ f"{pending['user_code']}). Call zexl_connect_finish again once they have.",
148
+ {"status": "pending"},
149
+ )
150
+ await self._sleep(float(pending.get("interval_s", 3)))
151
+ self._clear_pending()
152
+ path = Path(env_file).expanduser()
153
+ written = envfile.write_key(path, body["ingest_token"])
154
+ saved = config.save(
155
+ config.Credentials(
156
+ body["read_token"],
157
+ body["workspace_id"],
158
+ body["workspace_name"],
159
+ body.get("environment") or "",
160
+ )
161
+ )
162
+ self._connected_at = time.time()
163
+ git = {
164
+ "added": "Added it to .gitignore.",
165
+ "already": "It is already in .gitignore.",
166
+ "not a git repository": "The folder is not a git repository.",
167
+ "could not check": "Could not check .gitignore; make sure .env is not committed.",
168
+ }[written.gitignore]
169
+ salt = (
170
+ "Reused the salt of the key that was there, so prompt hashes keep matching."
171
+ if written.reused_salt
172
+ else "Made a new salt on this machine; it is never sent to Zexl."
173
+ )
174
+ msg = (
175
+ f"Connected to workspace {body['workspace_name']} ({body['environment']}). "
176
+ f"Wrote ZEXL_API_KEY={envfile.mask(written.key)} to {written.path} "
177
+ f"({'replaced the old value' if written.replaced else 'added'}). {git} {salt} "
178
+ f"Saved a read key for these tools in {saved}.\n"
179
+ "Next: call zexl_setup_guide and follow it to add the SDK, then run the app and call "
180
+ "zexl_check_connection."
181
+ )
182
+ return _result(
183
+ msg,
184
+ {
185
+ "workspace": body["workspace_name"],
186
+ "environment": body["environment"],
187
+ "env_file": str(written.path),
188
+ "gitignore": written.gitignore,
189
+ },
190
+ )
191
+
192
+ def _clear_pending(self) -> None:
193
+ self._pending = None
194
+ try:
195
+ (config.config_dir() / "pending.json").unlink()
196
+ except OSError:
197
+ pass
198
+
199
+ async def check_connection(self, wait_seconds: int) -> CallToolResult:
200
+ deadline = time.monotonic() + max(0, min(int(wait_seconds), 300))
201
+ while True:
202
+ ws, live = await self._get("/live")
203
+ recent = live.get("recent") or []
204
+ if recent:
205
+ last = recent[0]
206
+ msg = (
207
+ f"Receiving: {live['calls']:,} calls in workspace {ws['name']} so far. Latest: "
208
+ f"{last['model'] or 'unknown model'} at {last['ts']} ({last['environment']}, "
209
+ f"{last['source']}), {last['input_tokens']:,} in / {last['output_tokens']:,} "
210
+ f"out, {text.money(last['cost_usd'])}. Waste analysis follows within about 3 "
211
+ "minutes."
212
+ )
213
+ return _result(msg, {"calls": live["calls"], "latest": last})
214
+ if time.monotonic() >= deadline:
215
+ return _result(
216
+ "No calls yet. Check that the app ran a model call, that `import zexl.auto` "
217
+ "runs before the client is created, and that ZEXL_API_KEY is set in that "
218
+ "process "
219
+ "(`zexl.status()` shows what the SDK sees). Then call this again.",
220
+ {"calls": 0},
221
+ )
222
+ await self._sleep(3.0)
223
+
224
+ # --- reading ---
225
+
226
+ async def status(self) -> CallToolResult:
227
+ creds = config.load()
228
+ if creds is None:
229
+ return _result(
230
+ "Not connected. Use zexl_connect_start to connect this project, or set "
231
+ "ZEXL_READ_KEY.",
232
+ {"connected": False},
233
+ )
234
+ ws, live = await self._get("/live")
235
+ recent = live.get("recent") or []
236
+ env = ws.get("environment") or "all environments"
237
+ last = f" Latest call {recent[0]['ts']}." if recent else " No calls received yet."
238
+ return _result(
239
+ f"Connected to workspace {ws['name']} (reads {env}). {live['calls']:,} calls stored."
240
+ + last,
241
+ {"connected": True, "workspace": ws["name"], "calls": live["calls"]},
242
+ )
243
+
244
+ async def spend_summary(self, days: int, environment: str) -> CallToolResult:
245
+ days, q = _range(days)
246
+ ws, s = await self._get(f"/summary?{q}&environment={environment}")
247
+ env = self._env(ws, environment)
248
+ return _result(text.summary(s, days, env), s)
249
+
250
+ async def forecast(self, environment: str) -> CallToolResult:
251
+ _, f = await self._get(f"/forecast?environment={environment}")
252
+ data = {k: v for k, v in f.items() if k != "days"}
253
+ return _result(text.forecast(f), data)
254
+
255
+ async def top_waste(self, days: int, environment: str, limit: int) -> CallToolResult:
256
+ days, q = _range(days)
257
+ ws, p = await self._get(f"/patterns?{q}&environment={environment}")
258
+ env = self._env(ws, environment)
259
+ cards = p.get("cards", [])
260
+ return _result(text.cards(cards, max(1, min(limit, 20)), days, env), {"cards": cards})
261
+
262
+ async def breakdown(self, by: str, days: int, environment: str) -> CallToolResult:
263
+ days, q = _range(days)
264
+ ws, b = await self._get(f"/breakdown?by={by}&{q}&environment={environment}")
265
+ env = self._env(ws, environment)
266
+ return _result(text.breakdown(b["rows"], by, days, env), b)
267
+
268
+ async def pattern_runs(
269
+ self, category: str, feature: str | None, days: int, environment: str
270
+ ) -> CallToolResult:
271
+ days, q = _range(days)
272
+ extra = f"&feature={_quote(feature)}" if feature is not None else ""
273
+ _, r = await self._get(f"/patterns/{category}/runs?{q}&environment={environment}{extra}")
274
+ return _result(text.runs(r["runs"], category, feature), r)
275
+
276
+ async def run_detail(self, run_id: str) -> CallToolResult:
277
+ _, r = await self._get(f"/runs/{_quote(run_id)}")
278
+ return _result(text.run_detail(r), r)
279
+
280
+ async def explain_spike(self, day: str | None, days: int, environment: str) -> CallToolResult:
281
+ days, q = _range(max(days, 7))
282
+ ws, series = await self._get(f"/timeseries?{q}&environment={environment}")
283
+ points = series["days"]
284
+ if not any(p["tracked_usd"] > 0 for p in points):
285
+ return _result("No spend in this period.")
286
+ if day:
287
+ match = [p for p in points if p["day"] == day]
288
+ if not match:
289
+ raise ZexlError(f"{day} is not in the last {days} days. Pass days to widen it.")
290
+ peak = match[0]
291
+ else:
292
+ peak = max(points, key=lambda p: p["tracked_usd"])
293
+ others = [p["tracked_usd"] for p in points if p["day"] != peak["day"]]
294
+ median = statistics.median(others) if others else 0.0
295
+ d = date.fromisoformat(peak["day"])
296
+ one = f"from={d.isoformat()}&to={d.isoformat()}&environment={environment}"
297
+ whole = f"{q}&environment={environment}"
298
+ drivers: list[tuple[str, str, float, float]] = []
299
+ for by in ("feature", "model"):
300
+ _, on_day = await self._get(f"/breakdown?by={by}&{one}")
301
+ _, over = await self._get(f"/breakdown?by={by}&{whole}")
302
+ usual = {r["value"]: r["tracked_usd"] for r in over["rows"]}
303
+ n = max(len(points) - 1, 1)
304
+ for r in on_day["rows"]:
305
+ base = (usual.get(r["value"], 0.0) - r["tracked_usd"]) / n
306
+ if r["tracked_usd"] - base > 0:
307
+ drivers.append((by, r["value"], r["tracked_usd"], max(base, 0.0)))
308
+ # Only differences that explain a real part of the extra spend.
309
+ floor = max(0.1 * (peak["tracked_usd"] - median), 0.01)
310
+ drivers = [t for t in drivers if t[2] - t[3] >= floor]
311
+ drivers.sort(key=lambda t: -(t[2] - t[3]))
312
+ top = drivers[:6]
313
+ return _result(
314
+ text.spike(peak, median, top),
315
+ {"day": peak, "median_usd": median, "drivers": [list(t) for t in top]},
316
+ )
317
+
318
+
319
+ def _quote(value: str) -> str:
320
+ from urllib.parse import quote
321
+
322
+ return quote(value, safe="")
323
+
324
+
325
+ def build(zexl: Zexl | None = None) -> FastMCP:
326
+ z = zexl or Zexl()
327
+ mcp = FastMCP("zexl", instructions=INSTRUCTIONS)
328
+
329
+ async def safe(coro: Any) -> CallToolResult:
330
+ try:
331
+ result: CallToolResult = await coro
332
+ return result
333
+ except ZexlError as e:
334
+ return _error(e)
335
+
336
+ @mcp.tool(annotations=READ)
337
+ async def zexl_status() -> CallToolResult:
338
+ """Whether this server is connected to a Zexl workspace, which one, and when the last
339
+ call arrived. Call this first if unsure."""
340
+ return await safe(z.status())
341
+
342
+ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=False, openWorldHint=True))
343
+ async def zexl_connect_start(project: str = "", client: str = "claude-code") -> CallToolResult:
344
+ """Start connecting this project to Zexl. Returns a link and a code to show the user;
345
+ they open it, sign up or sign in, and approve. Then call zexl_connect_finish.
346
+
347
+ project: a short name for the project, such as the repository name.
348
+ client: the AI tool you are, e.g. claude-code, claude-desktop or cursor."""
349
+ return await safe(z.connect_start(project, client))
350
+
351
+ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False))
352
+ async def zexl_connect_finish(env_file: str = ".env", wait_seconds: int = 90) -> CallToolResult:
353
+ """Wait for the user to approve the connect request, then write ZEXL_API_KEY into the
354
+ project's .env file and save a read key for these tools. The key is never returned to
355
+ you; code should read ZEXL_API_KEY from the environment.
356
+
357
+ env_file: path to the project's .env file; prefer an absolute path. Created if missing.
358
+ wait_seconds: how long to wait for approval in this call (up to 600)."""
359
+ return await safe(z.connect_finish(env_file, wait_seconds))
360
+
361
+ @mcp.tool(annotations=READ)
362
+ async def zexl_setup_guide(
363
+ target: Literal["python", "claude-code"] = "python",
364
+ ) -> CallToolResult:
365
+ """Exact steps to add Zexl to a project: where `import zexl.auto` goes, labels for
366
+ features, runs and customers, environments, and how to check it works.
367
+
368
+ target: python for an app using the OpenAI or Anthropic Python clients; claude-code to
369
+ track Claude Code itself."""
370
+ return _result(guide.TOPICS[target])
371
+
372
+ @mcp.tool(annotations=READ)
373
+ async def zexl_check_connection(wait_seconds: int = 60) -> CallToolResult:
374
+ """After the app has made a model call, wait for it to arrive in Zexl and report what
375
+ was recorded (model, tokens, cost). wait_seconds: up to 300."""
376
+ return await safe(z.check_connection(wait_seconds))
377
+
378
+ @mcp.tool(annotations=READ)
379
+ async def zexl_spend_summary(days: int = 30, environment: Env = "production") -> CallToolResult:
380
+ """Tracked spend, how much was wasted and on what, the useful-spend share, budget
381
+ headroom and invoice coverage.
382
+
383
+ days: the period, ending today (1 to 365).
384
+ environment: production, staging, development, or all."""
385
+ return await safe(z.spend_summary(days, environment))
386
+
387
+ @mcp.tool(annotations=READ)
388
+ async def zexl_forecast(environment: Env = "all") -> CallToolResult:
389
+ """Where this month's spend is likely to end, with a likely range, and the day it passes
390
+ the monthly budget if it does. Defaults to all environments, like the budget."""
391
+ return await safe(z.forecast(environment))
392
+
393
+ @mcp.tool(annotations=READ)
394
+ async def zexl_top_waste(
395
+ days: int = 30, environment: Env = "production", limit: int = 5
396
+ ) -> CallToolResult:
397
+ """The biggest waste findings (pattern and feature), each with its dollar size, a
398
+ plain explanation and how to fix it."""
399
+ return await safe(z.top_waste(days, environment, limit))
400
+
401
+ @mcp.tool(annotations=READ)
402
+ async def zexl_breakdown(
403
+ by: Literal["feature", "model", "customer", "actor"] = "feature",
404
+ days: int = 30,
405
+ environment: Env = "production",
406
+ ) -> CallToolResult:
407
+ """Spend, waste and useful share per feature, model, customer or actor (person or
408
+ service), biggest first."""
409
+ return await safe(z.breakdown(by, days, environment))
410
+
411
+ @mcp.tool(annotations=READ)
412
+ async def zexl_explain_spike(
413
+ day: str | None = None, days: int = 30, environment: Env = "production"
414
+ ) -> CallToolResult:
415
+ """Explain a day of unusually high spend: how far above a usual day it was, the waste
416
+ that day, and which features and models made the difference.
417
+
418
+ day: YYYY-MM-DD; omitted means the highest day in the period."""
419
+ return await safe(z.explain_spike(day, days, environment))
420
+
421
+ @mcp.tool(annotations=READ)
422
+ async def zexl_pattern_runs(
423
+ category: Category,
424
+ feature: str | None = None,
425
+ days: int = 30,
426
+ environment: Env = "production",
427
+ ) -> CallToolResult:
428
+ """The runs behind one waste finding, worst first. Use the category and feature from
429
+ zexl_top_waste."""
430
+ return await safe(z.pattern_runs(category, feature, days, environment))
431
+
432
+ @mcp.tool(annotations=READ)
433
+ async def zexl_run_detail(run_id: str) -> CallToolResult:
434
+ """One run step by step: model, tokens and cost per step, and which steps repeated an
435
+ earlier request, input or tool call, failed, or were cut off."""
436
+ return await safe(z.run_detail(run_id))
437
+
438
+ @mcp.prompt()
439
+ def integrate_zexl(project: str = "") -> str:
440
+ """Add Zexl to this project, from connecting to the first recorded call."""
441
+ name = f" (project: {project})" if project else ""
442
+ return (
443
+ f"Add Zexl to this project{name}.\n"
444
+ "1. Call zexl_connect_start with the project name, and show me the link and the code.\n"
445
+ "2. When I say I have approved, call zexl_connect_finish with the absolute path of "
446
+ "the project's .env file.\n"
447
+ "3. Call zexl_setup_guide and make the code changes it describes: add the dependency, "
448
+ "add `import zexl.auto` at the entry point, and add feature labels where the app calls "
449
+ "a model. Show me the diff.\n"
450
+ "4. Tell me how to run the app once, then call zexl_check_connection.\n"
451
+ "Never print the key or ask me to paste it."
452
+ )
453
+
454
+ return mcp
455
+
456
+
457
+ def main() -> None:
458
+ build().run("stdio")
459
+
460
+
461
+ if __name__ == "__main__":
462
+ main()
@@ -0,0 +1,220 @@
1
+ """Turns API answers into short sentences, the dollar figure first. Pure functions; tested."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ TITLES = {
8
+ "retry": "Retries",
9
+ "loop": "Loops",
10
+ "abandoned": "Abandoned responses",
11
+ "truncate_redo": "Cut off and redone",
12
+ "cache_miss": "Prompt caching not used",
13
+ "context_growth": "Growing context",
14
+ }
15
+
16
+ FIXES = {
17
+ "retry": "Find why the calls fail (timeouts, rate limits, bad requests). Raise the client "
18
+ "timeout for long answers, back off on 429s, and stop retrying errors that will fail again.",
19
+ "loop": "Give the agent a step limit and stop when a tool returns the same result twice. "
20
+ "Open a run with zexl_run_detail to see which step repeats.",
21
+ "abandoned": "A stream was started and never read to the end, or the client gave up. Cancel "
22
+ "requests you no longer need early, or do not start them; check client timeouts.",
23
+ "truncate_redo": "Answers hit the output limit and were asked again. Raise max_tokens for "
24
+ "this feature, or ask for a shorter answer, so the first attempt is enough.",
25
+ "cache_miss": "Turn on prompt caching for the long fixed part of the prompt: put the system "
26
+ "prompt and tool definitions first and unchanged. Anthropic needs cache_control on them; "
27
+ "OpenAI caches prompts over 1,024 tokens automatically when the start is identical.",
28
+ "context_growth": "The input grows every step. Summarise or trim the history, or pass tool "
29
+ "results by reference. Not counted as waste.",
30
+ }
31
+
32
+
33
+ def money(usd: float | None) -> str:
34
+ if usd is None:
35
+ return "–"
36
+ if usd >= 100:
37
+ return f"${usd:,.0f}"
38
+ if usd >= 0.995:
39
+ return f"${usd:,.2f}"
40
+ if usd >= 0.01:
41
+ return f"${usd:.2f}"
42
+ if usd > 0:
43
+ return f"${usd:.4f}".rstrip("0")
44
+ return "$0"
45
+
46
+
47
+ def pct(share: float | None) -> str:
48
+ return "–" if share is None else f"{100 * share:.0f}%"
49
+
50
+
51
+ def scope(days: int, environment: str) -> str:
52
+ env = "all environments" if environment == "all" else environment
53
+ return f"last {days} days, {env}"
54
+
55
+
56
+ def summary(s: dict[str, Any], days: int, environment: str) -> str:
57
+ if not s.get("calls"):
58
+ return f"No calls in the {scope(days, environment)}."
59
+ est = "About " if s.get("is_estimate") else ""
60
+ lines = [
61
+ f"{money(s['tracked_usd'])} tracked spend ({scope(days, environment)}), "
62
+ f"{s['calls']:,} calls. {est}{money(s['waste_usd'])} wasted; "
63
+ f"{pct(s.get('useful_share'))} did useful work."
64
+ ]
65
+ waste = sorted(s.get("waste", []), key=lambda w: -w["usd"])
66
+ for w in waste:
67
+ if w["usd"] > 0:
68
+ mark = " (estimate)" if w.get("is_estimate") else ""
69
+ lines.append(
70
+ f"- {TITLES.get(w['category'], w['category'])}: {money(w['usd'])}{mark}, "
71
+ f"{w['calls']:,} calls"
72
+ )
73
+ growth = s.get("context_growth") or {}
74
+ if growth.get("runs"):
75
+ lines.append(
76
+ f"- Growing context in {growth['runs']:,} runs cost {money(growth['exposure_usd'])} "
77
+ "extra (not counted as waste)."
78
+ )
79
+ b = s.get("budget") or {}
80
+ if b.get("monthly_usd") is not None:
81
+ lines.append(
82
+ f"Budget: {money(b['month_spend_usd'])} of {money(b['monthly_usd'])} spent this "
83
+ f"month (all environments), {money(max(b['headroom_usd'], 0))} left."
84
+ )
85
+ c = s.get("coverage")
86
+ if c:
87
+ lines.append(
88
+ f"Coverage: Zexl tracked {pct(c['share'])} of the {money(c['invoiced_usd'])} on "
89
+ "entered provider invoices."
90
+ )
91
+ unpriced = s.get("unpriced_models") or []
92
+ if unpriced:
93
+ names = ", ".join(m["model"] for m in unpriced)
94
+ lines.append(f"No price known for {names}; those calls count as $0.")
95
+ return "\n".join(lines)
96
+
97
+
98
+ def forecast(f: dict[str, Any]) -> str:
99
+ if f["method"] == "none":
100
+ return "No calls yet this month, so there is nothing to forecast."
101
+ env = "all environments" if f["environment"] == "all" else f["environment"]
102
+ out = [
103
+ f"About {money(f['projected_usd'])} by the end of {f['month']} ({env}); likely between "
104
+ f"{money(f['low_usd'])} and {money(f['high_usd'])}. Spent so far: "
105
+ f"{money(f['spent_usd'])}. Last 7 days averaged {money(f['last7_daily_usd'])} a day."
106
+ ]
107
+ if f.get("budget_usd") is not None:
108
+ if f.get("crosses_budget_on"):
109
+ out.append(
110
+ f"At this trend it passes the {money(f['budget_usd'])} budget around "
111
+ f"{f['crosses_budget_on']}."
112
+ )
113
+ else:
114
+ out.append(f"At this trend it stays within the {money(f['budget_usd'])} budget.")
115
+ how = (
116
+ "pace so far (under 3 full days of data)"
117
+ if f["method"] == "pace"
118
+ else f"weighted trend of the last {f['days_used']} full days"
119
+ )
120
+ out.append(f"Method: {how}.")
121
+ return " ".join(out)
122
+
123
+
124
+ def cards(rows: list[dict[str, Any]], limit: int, days: int, environment: str) -> str:
125
+ waste = [r for r in rows if r.get("kind") != "exposure" and r["usd"] > 0]
126
+ growth = [r for r in rows if r.get("kind") == "exposure"]
127
+ if not waste and not growth:
128
+ return f"No waste found in the {scope(days, environment)}."
129
+ out = [f"Biggest waste, {scope(days, environment)}:"]
130
+ for i, r in enumerate(sorted(waste, key=lambda r: -r["usd"])[:limit], 1):
131
+ out.append(
132
+ f"{i}. {money(r['usd'])} {r['title']} in {r['feature'] or 'unlabelled calls'}. "
133
+ f"{r['sentence']}\n Fix: {FIXES.get(r['category'], '')}\n"
134
+ f" Runs: zexl_pattern_runs(category='{r['category']}', feature='{r['feature']}')"
135
+ )
136
+ for r in growth[:3]:
137
+ out.append(f"Also: {r['sentence']}")
138
+ return "\n".join(out)
139
+
140
+
141
+ def breakdown(rows: list[dict[str, Any]], by: str, days: int, environment: str) -> str:
142
+ if not rows:
143
+ return f"No calls in the {scope(days, environment)}."
144
+ out = [f"Spend by {by}, {scope(days, environment)}:"]
145
+ for r in rows[:25]:
146
+ est = "≈" if r.get("is_estimate") else ""
147
+ out.append(
148
+ f"- {r['value'] or '(none)'}: {money(r['tracked_usd'])}, {est}{money(r['waste_usd'])} "
149
+ f"wasted, {pct(r.get('useful_share'))} useful, {r['calls']:,} calls"
150
+ )
151
+ if len(rows) > 25:
152
+ out.append(f"…and {len(rows) - 25} more.")
153
+ return "\n".join(out)
154
+
155
+
156
+ def runs(rows: list[dict[str, Any]], category: str, feature: str | None) -> str:
157
+ title = TITLES.get(category, category)
158
+ where = f" in {feature}" if feature else ""
159
+ if not rows:
160
+ return f"No runs with {title.lower()}{where} in this period."
161
+ out = [f"Runs with {title.lower()}{where}, worst first:"]
162
+ for r in rows[:20]:
163
+ steps = f", {r['run_steps']} steps" if r.get("run_steps") else ""
164
+ total = f" of {money(r['run_cost_usd'])}" if r.get("run_cost_usd") is not None else ""
165
+ out.append(
166
+ f"- {r['run_id']}: {money(r['usd'])} wasted{total}{steps}, started {r['started_at']}"
167
+ )
168
+ out.append("Open one with zexl_run_detail(run_id=...).")
169
+ return "\n".join(out)
170
+
171
+
172
+ def run_detail(r: dict[str, Any]) -> str:
173
+ flags = f" Flags: {', '.join(r['flags'])}." if r.get("flags") else ""
174
+ out = [
175
+ f"Run {r['run_id']} ({r['feature'] or 'unlabelled'}, {r['environment']}): "
176
+ f"{money(r['cost_usd'])} over {r['steps']} steps, {money(r['waste_usd'])} wasted.{flags}"
177
+ ]
178
+ for s in r.get("timeline", [])[:60]:
179
+ notes = []
180
+ same = s.get("same_as") or {}
181
+ if same.get("request"):
182
+ notes.append(f"same request as step {same['request']}")
183
+ elif same.get("input"):
184
+ notes.append(f"same input as step {same['input']}")
185
+ elif same.get("tools"):
186
+ notes.append(f"same tool calls as step {same['tools']}")
187
+ if s.get("finish_reason") in ("length", "max_tokens"):
188
+ notes.append("cut off at the output limit")
189
+ if s.get("error_type") or (s.get("status_code") or 200) >= 400:
190
+ notes.append(f"error {s.get('error_type') or s.get('status_code')}")
191
+ if s.get("streamed") and not s.get("stream_completed"):
192
+ notes.append("stream not finished")
193
+ tools = f" tools: {', '.join(s['tool_calls'])}" if s.get("tool_calls") else ""
194
+ cat = "" if s["category"] == "useful" else f" [{s['category']} {money(s['waste_usd'])}]"
195
+ note = f" — {'; '.join(notes)}" if notes else ""
196
+ out.append(
197
+ f"{s['position']}. {s['model']} {s['input_tokens']:,} in / {s['output_tokens']:,} out, "
198
+ f"{money(s['cost_usd'])}{cat}{tools}{note}"
199
+ )
200
+ return "\n".join(out)
201
+
202
+
203
+ def spike(day: dict[str, Any], median: float, drivers: list[tuple[str, str, float, float]]) -> str:
204
+ extra = day["tracked_usd"] - median
205
+ out = [
206
+ f"{day['day']}: {money(day['tracked_usd'])}, {money(extra)} above the median day "
207
+ f"({money(median)})."
208
+ ]
209
+ waste = sum(day.get("waste", {}).values())
210
+ if waste > 0:
211
+ top = max(day["waste"].items(), key=lambda kv: kv[1])
212
+ out.append(
213
+ f"Waste that day: {money(waste)}, mostly {TITLES.get(top[0], top[0]).lower()} "
214
+ f"({money(top[1])})."
215
+ )
216
+ if drivers:
217
+ out.append("What changed against a usual day:")
218
+ for kind, value, usd, usual in drivers:
219
+ out.append(f"- {kind} {value or '(none)'}: {money(usd)} vs {money(usual)} usually")
220
+ return "\n".join(out)