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.
- zexl_mcp-0.1.0/.gitignore +29 -0
- zexl_mcp-0.1.0/PKG-INFO +95 -0
- zexl_mcp-0.1.0/README.md +83 -0
- zexl_mcp-0.1.0/pyproject.toml +52 -0
- zexl_mcp-0.1.0/src/zexl_mcp/__init__.py +3 -0
- zexl_mcp-0.1.0/src/zexl_mcp/client.py +67 -0
- zexl_mcp-0.1.0/src/zexl_mcp/config.py +79 -0
- zexl_mcp-0.1.0/src/zexl_mcp/envfile.py +139 -0
- zexl_mcp-0.1.0/src/zexl_mcp/guide.py +82 -0
- zexl_mcp-0.1.0/src/zexl_mcp/py.typed +0 -0
- zexl_mcp-0.1.0/src/zexl_mcp/server.py +462 -0
- zexl_mcp-0.1.0/src/zexl_mcp/text.py +220 -0
|
@@ -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/
|
zexl_mcp-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
zexl_mcp-0.1.0/README.md
ADDED
|
@@ -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,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)
|