aight 0.2.0__tar.gz → 0.2.2__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.
- aight-0.2.2/PKG-INFO +322 -0
- {aight-0.2.0 → aight-0.2.2}/README.md +42 -8
- {aight-0.2.0 → aight-0.2.2}/aight/AGENTS.md +4 -0
- {aight-0.2.0 → aight-0.2.2}/aight/__init__.py +1 -1
- {aight-0.2.0 → aight-0.2.2}/aight/collect/base.py +105 -3
- {aight-0.2.0 → aight-0.2.2}/aight/collect/proxy.py +1 -1
- {aight-0.2.0 → aight-0.2.2}/aight/remote.py +5 -5
- aight-0.2.2/aight.egg-info/PKG-INFO +322 -0
- {aight-0.2.0 → aight-0.2.2}/pyproject.toml +12 -1
- {aight-0.2.0 → aight-0.2.2}/tests/test_collect_claude_code.py +38 -1
- aight-0.2.0/PKG-INFO +0 -21
- aight-0.2.0/aight.egg-info/PKG-INFO +0 -21
- {aight-0.2.0 → aight-0.2.2}/LICENSE +0 -0
- {aight-0.2.0 → aight-0.2.2}/aight/auto.py +0 -0
- {aight-0.2.0 → aight-0.2.2}/aight/collect/__init__.py +0 -0
- {aight-0.2.0 → aight-0.2.2}/aight/collect/aider.py +0 -0
- {aight-0.2.0 → aight-0.2.2}/aight/collect/claude_code.py +0 -0
- {aight-0.2.0 → aight-0.2.2}/aight/collect/codex.py +0 -0
- {aight-0.2.0 → aight-0.2.2}/aight/collect/gemini.py +0 -0
- {aight-0.2.0 → aight-0.2.2}/aight/tracing.py +0 -0
- {aight-0.2.0 → aight-0.2.2}/aight.egg-info/SOURCES.txt +0 -0
- {aight-0.2.0 → aight-0.2.2}/aight.egg-info/dependency_links.txt +0 -0
- {aight-0.2.0 → aight-0.2.2}/aight.egg-info/entry_points.txt +0 -0
- {aight-0.2.0 → aight-0.2.2}/aight.egg-info/requires.txt +0 -0
- {aight-0.2.0 → aight-0.2.2}/aight.egg-info/top_level.txt +0 -0
- {aight-0.2.0 → aight-0.2.2}/setup.cfg +0 -0
- {aight-0.2.0 → aight-0.2.2}/tests/test_auto_instrument.py +0 -0
- {aight-0.2.0 → aight-0.2.2}/tests/test_collect_aider.py +0 -0
- {aight-0.2.0 → aight-0.2.2}/tests/test_collect_codex.py +0 -0
- {aight-0.2.0 → aight-0.2.2}/tests/test_collect_gemini.py +0 -0
- {aight-0.2.0 → aight-0.2.2}/tests/test_collect_proxy.py +0 -0
- {aight-0.2.0 → aight-0.2.2}/tests/test_remote.py +0 -0
- {aight-0.2.0 → aight-0.2.2}/tests/test_tracing.py +0 -0
aight-0.2.2/PKG-INFO
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aight
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Attribute LLM spend to the exact source line, and push it to the AIght Workspace at aight.studio.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://aight.studio
|
|
7
|
+
Project-URL: Documentation, https://aight.studio/docs
|
|
8
|
+
Project-URL: Repository, https://github.com/theunknownport/aight-clients
|
|
9
|
+
Keywords: llm,llm-cost,ai-agents,observability,llmops,profiler,claude-code,langchain,langgraph,openai,anthropic,finops
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
14
|
+
Classifier: Topic :: System :: Monitoring
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: opentelemetry-api>=1.27
|
|
19
|
+
Requires-Dist: opentelemetry-sdk>=1.27
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest; extra == "dev"
|
|
22
|
+
Requires-Dist: ruff; extra == "dev"
|
|
23
|
+
Provides-Extra: examples
|
|
24
|
+
Requires-Dist: langgraph>=0.2; extra == "examples"
|
|
25
|
+
Provides-Extra: openai
|
|
26
|
+
Requires-Dist: opentelemetry-instrumentation-openai>=0.30.0; extra == "openai"
|
|
27
|
+
Provides-Extra: anthropic
|
|
28
|
+
Requires-Dist: opentelemetry-instrumentation-anthropic>=0.30.0; extra == "anthropic"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# aight (Python SDK)
|
|
32
|
+
|
|
33
|
+
Attributes LLM spend to the exact source line that issued it, and pushes it
|
|
34
|
+
to your [aight.studio](https://aight.studio) AIght Workspace.
|
|
35
|
+
|
|
36
|
+
No price table ships in this SDK, by design. The SDK sends the model and the
|
|
37
|
+
token counts; **the platform prices them** — it recomputes `cost_usd` for
|
|
38
|
+
every row at receive time — so the dollar figures in the AIght Workspace are
|
|
39
|
+
always the platform's current prices, never a snapshot frozen at install
|
|
40
|
+
time. `traced_llm_call` therefore returns `None`, not a cost.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install aight
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
That is the whole install, and it also puts the collectors on your path —
|
|
49
|
+
`aight-collect` for Claude Code, three more for other agents, and `aight-proxy`
|
|
50
|
+
for anything none of them can read. They are a separate thing from the SDK and
|
|
51
|
+
are described under [External agents](#external-agents) below.
|
|
52
|
+
|
|
53
|
+
## Deploy with your AI coding agent
|
|
54
|
+
|
|
55
|
+
Paste this into Claude Code, Cursor, or any other coding agent, in whatever
|
|
56
|
+
project you want traced:
|
|
57
|
+
|
|
58
|
+
> Run `pip install "aight[openai,anthropic]"`, then read the `AGENTS.md` file
|
|
59
|
+
> next to the installed `aight` package and follow it to wire up cost
|
|
60
|
+
> attribution for this project.
|
|
61
|
+
|
|
62
|
+
`AGENTS.md` ships inside the package (`python -c "import aight, pathlib; print(pathlib.Path(aight.__file__).parent / 'AGENTS.md')"`)
|
|
63
|
+
so the agent has the full integration recipe locally — no extra fetch, and
|
|
64
|
+
nothing it has to be able to reach over the network.
|
|
65
|
+
|
|
66
|
+
## Use
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from aight.tracing import traced_llm_call, COST_PROCESSOR
|
|
70
|
+
from aight.remote import push
|
|
71
|
+
|
|
72
|
+
traced_llm_call("gpt-4o-mini", input_tokens=180, output_tokens=60)
|
|
73
|
+
# ...wrap every LLM call your agent makes the same way...
|
|
74
|
+
|
|
75
|
+
push(COST_PROCESSOR.buckets) # reads AIGHT_API_KEY from the environment
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Get an API key from Project settings in your AIght Workspace, then:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
export AIGHT_API_KEY="aight_..."
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`traced_llm_call` walks the call stack past known framework packages
|
|
85
|
+
(LangGraph, LangChain) to attribute the span to your code, not theirs.
|
|
86
|
+
|
|
87
|
+
### Latency is yours to measure
|
|
88
|
+
|
|
89
|
+
This SDK does not time the LLM call. It is called *after* your call returns,
|
|
90
|
+
not around it, so a duration taken here would measure the cost of recording
|
|
91
|
+
rather than the cost of the model. Time the call yourself and pass it:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
import time
|
|
95
|
+
from aight.tracing import traced_llm_call
|
|
96
|
+
|
|
97
|
+
started = time.perf_counter()
|
|
98
|
+
response = client.chat.completions.create(...)
|
|
99
|
+
elapsed_ms = (time.perf_counter() - started) * 1000
|
|
100
|
+
|
|
101
|
+
traced_llm_call(model, input_tokens, output_tokens, latency_ms=elapsed_ms)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Leave `latency_ms` out and no latency is claimed — the Workspace shows "—" on
|
|
105
|
+
that agent's Speed axis rather than an impossibly fast number. The
|
|
106
|
+
auto-instrumented path cannot measure it either, so rows it records carry
|
|
107
|
+
none: if you want Speed for a call, wrap that call by hand.
|
|
108
|
+
|
|
109
|
+
## Auto-instrument (no manual wrapping)
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
pip install "aight[openai]" # or aight[anthropic]
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from aight.auto import auto_instrument
|
|
117
|
+
from aight.remote import push
|
|
118
|
+
from aight.tracing import COST_PROCESSOR
|
|
119
|
+
|
|
120
|
+
auto_instrument()
|
|
121
|
+
# ...call the OpenAI/Anthropic client exactly as you normally would...
|
|
122
|
+
|
|
123
|
+
push(COST_PROCESSOR.buckets)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`auto_instrument()` silently skips any provider whose instrumentor package
|
|
127
|
+
isn't installed, so it's safe to call unconditionally. It patches the provider
|
|
128
|
+
client, not the call, so the rows it records carry no latency — see above.
|
|
129
|
+
|
|
130
|
+
## Spent vs earned
|
|
131
|
+
|
|
132
|
+
Report the value your agent generates alongside its spend, to power the
|
|
133
|
+
AIght Workspace's spent-vs-earned view:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from aight.tracing import report_value, VALUE_BY_FILE
|
|
137
|
+
from aight.remote import push_value
|
|
138
|
+
|
|
139
|
+
report_value(49.0) # a closed deal, a resolved ticket — whatever you count
|
|
140
|
+
push_value(VALUE_BY_FILE)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`push_value` sends what has accumulated and clears it once the server has
|
|
144
|
+
acknowledged — so calling it every flush sends each earning once, and a failed
|
|
145
|
+
push keeps the total for the retry instead of losing it.
|
|
146
|
+
|
|
147
|
+
It takes that map as an argument rather than reading a global, which makes it
|
|
148
|
+
also the way to report value for an agent you did not write — pass the agent id
|
|
149
|
+
the collector uses as the filepath. See [External
|
|
150
|
+
agents](#external-agents) below.
|
|
151
|
+
|
|
152
|
+
## Local-only mode
|
|
153
|
+
|
|
154
|
+
Skip `push()` and call `COST_PROCESSOR.report()` instead — a plain-text
|
|
155
|
+
calls-and-tokens-by-line report with no network calls, useful for local
|
|
156
|
+
debugging or fully offline use. It has no dollar figure: pricing is the
|
|
157
|
+
platform's, so spend only appears in the AIght Workspace once you push.
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
examples/snippet_check.py:19 (plan_step) [gpt-4o-mini] = 3 call(s), 540 in / 180 out
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## External agents
|
|
164
|
+
|
|
165
|
+
Everything above is for an agent *you wrote*: the SDK walks your call stack to
|
|
166
|
+
find the line that issued the call. An agent you run but did not write has no
|
|
167
|
+
line of yours on the stack, so there is nothing to instrument and no library to
|
|
168
|
+
import. AIght traces those by reading the agent's own transcripts instead.
|
|
169
|
+
|
|
170
|
+
`pip install aight` also installs `aight-collect`. To see what your Claude Code
|
|
171
|
+
history would cost at API list prices, with no account and nothing sent:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
pip install aight && aight-collect --report --all-projects
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
That prints a breakdown by token type, step and model. Only the model names go
|
|
178
|
+
out, to fetch their rates from AIght's price table; drop `--all-projects` for
|
|
179
|
+
just this directory, add `--since 2026-10-01` to narrow it. To push to your
|
|
180
|
+
Workspace instead:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
export AIGHT_API_KEY="aight_..."
|
|
184
|
+
|
|
185
|
+
aight-collect --dry-run # print the rows instead of sending them
|
|
186
|
+
aight-collect # push them
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
It reads Claude Code's transcripts under `~/.claude/projects` and pushes one
|
|
190
|
+
row per step the agent took, under an external agent called `claude-code`. Cost
|
|
191
|
+
lands on the step — the tool it called and the file it touched — rather than on
|
|
192
|
+
a source line, because no source line of yours is involved.
|
|
193
|
+
|
|
194
|
+
That is the spend side. **Earnings you report yourself** — a collector reads
|
|
195
|
+
token counts, not what the work was worth, and no SDK can infer it. Push them
|
|
196
|
+
against the agent's id:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
from aight.remote import push_value
|
|
200
|
+
|
|
201
|
+
push_value({"claude-code": 49.0}) # the id the collector roots its rows at
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The Workspace shows that on `claude-code` exactly as it shows an agent of your
|
|
205
|
+
own: the earned total, the net, the ratio, and the earning-or-burning status
|
|
206
|
+
that goes with them. What an external agent never gets is a **matched** figure
|
|
207
|
+
— the matching engine's time-window fallback excludes external rows outright,
|
|
208
|
+
because a collector pushes no timestamp and an external row would always be the
|
|
209
|
+
newest thing in the window and win the guess by default. Reported, yes; guessed,
|
|
210
|
+
no.
|
|
211
|
+
|
|
212
|
+
Leave it running and the spend appears as you work, rather than whenever you
|
|
213
|
+
next remember to run it:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
aight-collect --watch # re-scan every 30s, until Ctrl-C
|
|
217
|
+
aight-collect --watch --interval 10
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
`--watch` cannot be combined with `--since` or `--all-time`. Those name a scope
|
|
221
|
+
to re-send, and a loop would re-send it every interval — which the API *adds* to
|
|
222
|
+
what it already holds, so the second tick would double the first. Run them once
|
|
223
|
+
to catch up, then start the watcher.
|
|
224
|
+
|
|
225
|
+
Three things are worth knowing before the first run:
|
|
226
|
+
|
|
227
|
+
- **A plain run resumes.** It records the newest call it pushed for each
|
|
228
|
+
transcript file in `~/.aight/`, so a second run sends only what is new.
|
|
229
|
+
Running it repeatedly is the intended use.
|
|
230
|
+
- **`--all-time` does not resume.** It re-sends the whole history, and the API
|
|
231
|
+
*adds* what it receives to what it already holds — so pushing the same calls
|
|
232
|
+
twice doubles the recorded spend permanently. Cost is frozen at receive time
|
|
233
|
+
and there is no repair path. Reach for it only on a transcript that has never
|
|
234
|
+
been pushed.
|
|
235
|
+
- **The key alone decides the destination.** A run prints the project its rows
|
|
236
|
+
landed in and stops rather than resuming when that differs from the project
|
|
237
|
+
its earlier pushes went to. A key copied out of another project's settings
|
|
238
|
+
otherwise reads exactly like success, and fills a workspace nobody is
|
|
239
|
+
looking at.
|
|
240
|
+
|
|
241
|
+
Without installing anything:
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
uvx --from aight aight-collect --dry-run
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
The same machinery covers three more agents, each as its own command:
|
|
248
|
+
|
|
249
|
+
| Command | Reads | Needs |
|
|
250
|
+
|---|---|---|
|
|
251
|
+
| `aight-collect` | Claude Code — `~/.claude/projects` | nothing |
|
|
252
|
+
| `aight-collect-codex` | Codex CLI — `~/.codex/sessions` (`CODEX_HOME` respected) | nothing |
|
|
253
|
+
| `aight-collect-gemini` | Gemini CLI — `~/.gemini/tmp` | nothing |
|
|
254
|
+
| `aight-collect-aider` | Aider's `--analytics-log` file | **you must pass the path** |
|
|
255
|
+
|
|
256
|
+
Everything above applies to all four — the resume marker, `--since`/`--all-time`,
|
|
257
|
+
`--watch`, and the destination check — because they share one implementation of
|
|
258
|
+
it rather than one copy each.
|
|
259
|
+
|
|
260
|
+
Aider is the odd one and the command says so when it has nothing to read: it
|
|
261
|
+
writes a per-call log **only** if it was started with `--analytics-log FILE`,
|
|
262
|
+
and there is no default location. A session that already ran without that flag
|
|
263
|
+
cannot be collected, because Aider recorded nothing for it. Start Aider with
|
|
264
|
+
the flag, then point this at the same file.
|
|
265
|
+
|
|
266
|
+
The proxy below is the fallback for an agent none of these cover — including
|
|
267
|
+
the ones whose records are not readable at all.
|
|
268
|
+
|
|
269
|
+
## Any other agent (the proxy)
|
|
270
|
+
|
|
271
|
+
The collector above reads Claude Code's own transcripts, which means one parser
|
|
272
|
+
per agent — and several agents keep no readable per-call record at all. Cursor
|
|
273
|
+
keeps no token counts on disk; Amp emits no model name anywhere. So for those
|
|
274
|
+
there is nothing to parse, and the answer is not another parser.
|
|
275
|
+
|
|
276
|
+
`aight-proxy` stands between the agent and the API instead. It sees the request
|
|
277
|
+
and the response, so it does not need to know what the agent is:
|
|
278
|
+
|
|
279
|
+
```bash
|
|
280
|
+
aight-proxy # 127.0.0.1:8787
|
|
281
|
+
export OPENAI_BASE_URL=http://127.0.0.1:8787/openai
|
|
282
|
+
export ANTHROPIC_BASE_URL=http://127.0.0.1:8787/anthropic
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
The path prefix picks the upstream, because both APIs live under `/v1/` and
|
|
286
|
+
`/v1/messages` would otherwise be ambiguous. Everything after the prefix is
|
|
287
|
+
forwarded unchanged, including your `Authorization` header — the proxy holds no
|
|
288
|
+
credentials of its own and stores nothing from the request or the response.
|
|
289
|
+
|
|
290
|
+
It works for anything that lets you point its base URL somewhere else, which is
|
|
291
|
+
most agents. Name them so several through one proxy stay distinct:
|
|
292
|
+
|
|
293
|
+
```bash
|
|
294
|
+
AIGHT_PROXY_AGENT=codex aight-proxy
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Four things worth knowing:
|
|
298
|
+
|
|
299
|
+
- **It measures latency**, which the SDKs cannot. They record after the call
|
|
300
|
+
returns, so a duration taken there would time the recording; the proxy sits
|
|
301
|
+
around the call, so `latency_ms` is real.
|
|
302
|
+
- **OpenAI's cached tokens are subtracted.** `prompt_tokens` counts the cached
|
|
303
|
+
prefix and AIght's `input_tokens` does not, so sending the inclusive number
|
|
304
|
+
would bill those tokens twice — permanently, since cost is frozen at receive
|
|
305
|
+
time. Anthropic reports the split itself and nothing is subtracted there.
|
|
306
|
+
- **Streaming OpenAI requests get `stream_options.include_usage` added.** That
|
|
307
|
+
is a change to your request, not a passive read: without it a streaming call
|
|
308
|
+
carries no usage at all, and most calls stream. Anthropic needs no equivalent.
|
|
309
|
+
- **Nothing is priced here.** Rows go up with `cost_usd` 0.0 and the platform
|
|
310
|
+
prices them from its own table at receive time.
|
|
311
|
+
|
|
312
|
+
## Example
|
|
313
|
+
|
|
314
|
+
`examples/langgraph_spike.py` traces a 3-node LangGraph agent and prints the
|
|
315
|
+
attribution report (also pushes it, if `AIGHT_API_KEY` is set). It ships in the
|
|
316
|
+
repo rather than in the wheel, so run it from a clone:
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
cd /path/to/aight-clients/python
|
|
320
|
+
pip install ".[examples]" # langgraph; ".[dev,examples]" adds pytest and ruff
|
|
321
|
+
python -m examples.langgraph_spike
|
|
322
|
+
```
|
|
@@ -15,9 +15,10 @@ time. `traced_llm_call` therefore returns `None`, not a cost.
|
|
|
15
15
|
pip install aight
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
That is the whole install, and it also puts
|
|
19
|
-
Claude Code
|
|
20
|
-
|
|
18
|
+
That is the whole install, and it also puts the collectors on your path —
|
|
19
|
+
`aight-collect` for Claude Code, three more for other agents, and `aight-proxy`
|
|
20
|
+
for anything none of them can read. They are a separate thing from the SDK and
|
|
21
|
+
are described under [External agents](#external-agents) below.
|
|
21
22
|
|
|
22
23
|
## Deploy with your AI coding agent
|
|
23
24
|
|
|
@@ -44,7 +45,7 @@ traced_llm_call("gpt-4o-mini", input_tokens=180, output_tokens=60)
|
|
|
44
45
|
push(COST_PROCESSOR.buckets) # reads AIGHT_API_KEY from the environment
|
|
45
46
|
```
|
|
46
47
|
|
|
47
|
-
Get an API key from
|
|
48
|
+
Get an API key from Project settings in your AIght Workspace, then:
|
|
48
49
|
|
|
49
50
|
```bash
|
|
50
51
|
export AIGHT_API_KEY="aight_..."
|
|
@@ -113,6 +114,11 @@ push_value(VALUE_BY_FILE)
|
|
|
113
114
|
acknowledged — so calling it every flush sends each earning once, and a failed
|
|
114
115
|
push keeps the total for the retry instead of losing it.
|
|
115
116
|
|
|
117
|
+
It takes that map as an argument rather than reading a global, which makes it
|
|
118
|
+
also the way to report value for an agent you did not write — pass the agent id
|
|
119
|
+
the collector uses as the filepath. See [External
|
|
120
|
+
agents](#external-agents) below.
|
|
121
|
+
|
|
116
122
|
## Local-only mode
|
|
117
123
|
|
|
118
124
|
Skip `push()` and call `COST_PROCESSOR.report()` instead — a plain-text
|
|
@@ -124,14 +130,24 @@ platform's, so spend only appears in the AIght Workspace once you push.
|
|
|
124
130
|
examples/snippet_check.py:19 (plan_step) [gpt-4o-mini] = 3 call(s), 540 in / 180 out
|
|
125
131
|
```
|
|
126
132
|
|
|
127
|
-
## External agents
|
|
133
|
+
## External agents
|
|
128
134
|
|
|
129
135
|
Everything above is for an agent *you wrote*: the SDK walks your call stack to
|
|
130
136
|
find the line that issued the call. An agent you run but did not write has no
|
|
131
137
|
line of yours on the stack, so there is nothing to instrument and no library to
|
|
132
138
|
import. AIght traces those by reading the agent's own transcripts instead.
|
|
133
139
|
|
|
134
|
-
`pip install aight` also installs `aight-collect
|
|
140
|
+
`pip install aight` also installs `aight-collect`. To see what your Claude Code
|
|
141
|
+
history would cost at API list prices, with no account and nothing sent:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
pip install aight && aight-collect --report --all-projects
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
That prints a breakdown by token type, step and model. Only the model names go
|
|
148
|
+
out, to fetch their rates from AIght's price table; drop `--all-projects` for
|
|
149
|
+
just this directory, add `--since 2026-10-01` to narrow it. To push to your
|
|
150
|
+
Workspace instead:
|
|
135
151
|
|
|
136
152
|
```bash
|
|
137
153
|
export AIGHT_API_KEY="aight_..."
|
|
@@ -145,6 +161,24 @@ row per step the agent took, under an external agent called `claude-code`. Cost
|
|
|
145
161
|
lands on the step — the tool it called and the file it touched — rather than on
|
|
146
162
|
a source line, because no source line of yours is involved.
|
|
147
163
|
|
|
164
|
+
That is the spend side. **Earnings you report yourself** — a collector reads
|
|
165
|
+
token counts, not what the work was worth, and no SDK can infer it. Push them
|
|
166
|
+
against the agent's id:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
from aight.remote import push_value
|
|
170
|
+
|
|
171
|
+
push_value({"claude-code": 49.0}) # the id the collector roots its rows at
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The Workspace shows that on `claude-code` exactly as it shows an agent of your
|
|
175
|
+
own: the earned total, the net, the ratio, and the earning-or-burning status
|
|
176
|
+
that goes with them. What an external agent never gets is a **matched** figure
|
|
177
|
+
— the matching engine's time-window fallback excludes external rows outright,
|
|
178
|
+
because a collector pushes no timestamp and an external row would always be the
|
|
179
|
+
newest thing in the window and win the guess by default. Reported, yes; guessed,
|
|
180
|
+
no.
|
|
181
|
+
|
|
148
182
|
Leave it running and the spend appears as you work, rather than whenever you
|
|
149
183
|
next remember to run it:
|
|
150
184
|
|
|
@@ -170,8 +204,8 @@ Three things are worth knowing before the first run:
|
|
|
170
204
|
been pushed.
|
|
171
205
|
- **The key alone decides the destination.** A run prints the project its rows
|
|
172
206
|
landed in and stops rather than resuming when that differs from the project
|
|
173
|
-
its earlier pushes went to. A key copied out of another project's
|
|
174
|
-
|
|
207
|
+
its earlier pushes went to. A key copied out of another project's settings
|
|
208
|
+
otherwise reads exactly like success, and fills a workspace nobody is
|
|
175
209
|
looking at.
|
|
176
210
|
|
|
177
211
|
Without installing anything:
|
|
@@ -32,6 +32,10 @@ from aight.tracing import report_value, VALUE_BY_FILE
|
|
|
32
32
|
from aight.remote import push_value
|
|
33
33
|
report_value(49.0)
|
|
34
34
|
push_value(VALUE_BY_FILE)
|
|
35
|
+
For an agent the user runs but this project does not contain — Claude Code, Gemini, Codex, traced by aight's collectors — earnings go in by agent id, because there is no calling file of theirs to resolve. push_value takes the map, so pass one directly, on the same rule:
|
|
36
|
+
from aight.remote import push_value
|
|
37
|
+
push_value({"claude-code": 49.0}) # the agent id the collector roots its rows at
|
|
38
|
+
That agent then shows a reported figure in the Workspace like any other. It never shows a matched one: the matching engine excludes external rows from its time-window fallback, so do not try to give it one through push_event.
|
|
35
39
|
7. Optional: Agnostic business matching, Trace IDs & Labels
|
|
36
40
|
To link LLM costs explicitly to business events (like a Stripe checkout, a conversion, or custom tiers) without hardcoding any specific business model, use TraceContext or pass parameters directly:
|
|
37
41
|
Option A — Using TraceContext for an execution scope (recommended for web requests / checkouts):
|
|
@@ -36,6 +36,7 @@ import subprocess
|
|
|
36
36
|
import sys
|
|
37
37
|
import time
|
|
38
38
|
import urllib.error
|
|
39
|
+
import urllib.parse
|
|
39
40
|
import urllib.request
|
|
40
41
|
from collections.abc import Callable
|
|
41
42
|
from dataclasses import dataclass, field
|
|
@@ -284,7 +285,7 @@ def _check_destination(config: Collector, api_key: str, url: str) -> tuple[bool,
|
|
|
284
285
|
An ingest key is otherwise write-only: every read endpoint wants the Auth0
|
|
285
286
|
JWT, so a collector had no way to ask this before pushing. The marker
|
|
286
287
|
records that calls were *sent*, never where, so a key minted in another
|
|
287
|
-
project's
|
|
288
|
+
project's settings would resume this machine's coverage, send only
|
|
288
289
|
what is newer than it into the wrong workspace, and leave the project that
|
|
289
290
|
was meant permanently missing that window — silently, because every one of
|
|
290
291
|
those pushes succeeds.
|
|
@@ -581,6 +582,94 @@ def watch(
|
|
|
581
582
|
return 0
|
|
582
583
|
|
|
583
584
|
|
|
585
|
+
def fetch_prices(rows: list[dict], ingest_url: str) -> dict | None:
|
|
586
|
+
"""The server's per-1K rates for the models in `rows`, or None offline.
|
|
587
|
+
|
|
588
|
+
Only model names go up. The SDK ships no price table on purpose (copies
|
|
589
|
+
drifted from the server's and priced new models at zero), so a report
|
|
590
|
+
asks the one table there is, and falls back to tokens when it can't."""
|
|
591
|
+
models = sorted({r["model"] for r in rows if r.get("model")})
|
|
592
|
+
if not models:
|
|
593
|
+
return {}
|
|
594
|
+
base = ingest_url.split("/api/", 1)[0]
|
|
595
|
+
query = urllib.parse.urlencode({"models": ",".join(models)})
|
|
596
|
+
try:
|
|
597
|
+
with urllib.request.urlopen(f"{base}/api/pricing?{query}", timeout=10) as resp:
|
|
598
|
+
return json.load(resp).get("prices") or {}
|
|
599
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
600
|
+
return None
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
TOKEN_KINDS = (
|
|
604
|
+
("cache read", "cache_read_tokens", "cache_read"),
|
|
605
|
+
("cache write", "cache_creation_tokens", "cache_creation"),
|
|
606
|
+
("output", "output_tokens", "output"),
|
|
607
|
+
("fresh input", "input_tokens", "input"),
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def report(rows: list[dict], agent: str, prices: dict | None, top: int = 8) -> str:
|
|
612
|
+
"""A breakdown worth a screenshot: what the calls in `rows` would cost at
|
|
613
|
+
list price, by token type, by step and by model. With `prices` None (the
|
|
614
|
+
server was unreachable) it reports tokens instead of dollars."""
|
|
615
|
+
priced = prices is not None
|
|
616
|
+
by_kind: dict[str, float] = {}
|
|
617
|
+
by_step: dict[str, list[float]] = {}
|
|
618
|
+
by_model: dict[str, float] = {}
|
|
619
|
+
unpriced: set[str] = set()
|
|
620
|
+
calls = 0
|
|
621
|
+
for r in rows:
|
|
622
|
+
rate = (prices or {}).get(r["model"]) if priced else None
|
|
623
|
+
if priced and rate is None:
|
|
624
|
+
unpriced.add(r["model"])
|
|
625
|
+
# A coding agent's step is the tool, whatever file it touched (and the
|
|
626
|
+
# path would put the user's directories in a shared screenshot); your
|
|
627
|
+
# own agent's step is the line, named by file, not full path.
|
|
628
|
+
frame = r["chain"][-1]
|
|
629
|
+
if r.get("kind") == "external" or not frame.get("filepath"):
|
|
630
|
+
step = frame.get("function") or "?"
|
|
631
|
+
else:
|
|
632
|
+
step = f"{Path(frame['filepath']).name}:{frame['lineno']}"
|
|
633
|
+
row_total = 0.0
|
|
634
|
+
for label, key, rate_key in TOKEN_KINDS:
|
|
635
|
+
n = r.get(key, 0) or 0
|
|
636
|
+
v = (n / 1000) * (rate or {}).get(rate_key, 0.0) if priced else n
|
|
637
|
+
by_kind[label] = by_kind.get(label, 0.0) + v
|
|
638
|
+
row_total += v
|
|
639
|
+
s = by_step.setdefault(step, [0.0, 0])
|
|
640
|
+
s[0] += row_total
|
|
641
|
+
s[1] += r.get("calls", 0)
|
|
642
|
+
by_model[r["model"]] = by_model.get(r["model"], 0.0) + row_total
|
|
643
|
+
calls += r.get("calls", 0)
|
|
644
|
+
|
|
645
|
+
total = sum(by_kind.values())
|
|
646
|
+
|
|
647
|
+
def amount(v: float) -> str:
|
|
648
|
+
return f"${v:,.2f}" if priced else f"{v:,.0f} tok"
|
|
649
|
+
|
|
650
|
+
def line(name: str, v: float, extra: str = "") -> str:
|
|
651
|
+
share = v / total if total else 0
|
|
652
|
+
bar = "█" * round(share * 24)
|
|
653
|
+
name = name if len(name) <= 28 else name[:27] + "…"
|
|
654
|
+
return f" {name:<28} {amount(v):>14} {share:>5.0%} {bar}{extra}"
|
|
655
|
+
|
|
656
|
+
out = [f"{agent} · {calls:,} calls · " + (
|
|
657
|
+
"priced at API list rates" if priced else "tokens only (pricing unreachable)"),
|
|
658
|
+
"", f" {'TOTAL':<28} {amount(total):>14}", "", "By token type"]
|
|
659
|
+
out += [line(k, v) for k, v in sorted(by_kind.items(), key=lambda kv: -kv[1])]
|
|
660
|
+
out += ["", "By step"]
|
|
661
|
+
steps = sorted(by_step.items(), key=lambda kv: -kv[1][0])
|
|
662
|
+
out += [line(k, v[0], f" {int(v[1]):,} calls") for k, v in steps[:top]]
|
|
663
|
+
if len(steps) > top:
|
|
664
|
+
out.append(f" … {len(steps) - top} more steps")
|
|
665
|
+
out += ["", "By model"]
|
|
666
|
+
out += [line(k, v) for k, v in sorted(by_model.items(), key=lambda kv: -kv[1])[:top]]
|
|
667
|
+
if unpriced:
|
|
668
|
+
out += ["", "No list price yet for: " + ", ".join(sorted(unpriced)) + " (counted as $0)"]
|
|
669
|
+
out += ["", "Nothing was sent. Per line of your own agent's code: https://aight.studio"]
|
|
670
|
+
return "\n".join(out)
|
|
671
|
+
|
|
672
|
+
|
|
584
673
|
def main(config: Collector, argv: list[str] | None = None) -> int:
|
|
585
674
|
# Not description=__doc__: a module docstring is fifty lines of rationale
|
|
586
675
|
# for whoever maintains the parser, and `--help` is the first thing a
|
|
@@ -603,6 +692,9 @@ def main(config: Collector, argv: list[str] | None = None) -> int:
|
|
|
603
692
|
"so running it again over an already-pushed directory doubles it")
|
|
604
693
|
parser.add_argument("--dry-run", action="store_true",
|
|
605
694
|
help="print the rows instead of pushing them")
|
|
695
|
+
parser.add_argument("--report", action="store_true",
|
|
696
|
+
help="print a priced breakdown (token type, step, model) and push "
|
|
697
|
+
"nothing; every call unless --since narrows it")
|
|
606
698
|
parser.add_argument("--watch", action="store_true",
|
|
607
699
|
help="keep running: re-scan and push on an interval, until interrupted")
|
|
608
700
|
parser.add_argument("--interval", type=float, default=DEFAULT_INTERVAL_SECONDS,
|
|
@@ -611,6 +703,12 @@ def main(config: Collector, argv: list[str] | None = None) -> int:
|
|
|
611
703
|
f"(default: {DEFAULT_INTERVAL_SECONDS})")
|
|
612
704
|
args = parser.parse_args(argv)
|
|
613
705
|
|
|
706
|
+
if args.report and args.watch:
|
|
707
|
+
parser.error("--report prints once; it can't be combined with --watch.")
|
|
708
|
+
# A report pushes nothing, so re-reading all history can't double anything.
|
|
709
|
+
if args.report and not args.since:
|
|
710
|
+
args.all_time = True
|
|
711
|
+
|
|
614
712
|
# --since/--all-time scope every file by hand; a plain run resumes.
|
|
615
713
|
manual = args.all_time or bool(args.since)
|
|
616
714
|
|
|
@@ -689,7 +787,7 @@ def main(config: Collector, argv: list[str] | None = None) -> int:
|
|
|
689
787
|
if args.watch:
|
|
690
788
|
api_key = os.environ.get("AIGHT_API_KEY")
|
|
691
789
|
if not api_key:
|
|
692
|
-
print("Set AIGHT_API_KEY (
|
|
790
|
+
print("Set AIGHT_API_KEY (Project settings in your AIght Workspace)", file=sys.stderr)
|
|
693
791
|
return 1
|
|
694
792
|
url = os.environ.get("AIGHT_INGEST_URL", DEFAULT_INGEST_URL)
|
|
695
793
|
ok, destination = _check_destination(config, api_key, url)
|
|
@@ -718,6 +816,10 @@ def main(config: Collector, argv: list[str] | None = None) -> int:
|
|
|
718
816
|
rows = config.rows_for(calls)
|
|
719
817
|
print(f"{len(paths)} transcript(s), {len(calls)} unique calls, {len(rows)} rows")
|
|
720
818
|
|
|
819
|
+
if args.report:
|
|
820
|
+
url = os.environ.get("AIGHT_INGEST_URL", DEFAULT_INGEST_URL)
|
|
821
|
+
print(report(rows, config.agent_id, fetch_prices(rows, url)))
|
|
822
|
+
return 0
|
|
721
823
|
if args.dry_run:
|
|
722
824
|
print(json.dumps(rows, indent=2))
|
|
723
825
|
return 0
|
|
@@ -729,7 +831,7 @@ def main(config: Collector, argv: list[str] | None = None) -> int:
|
|
|
729
831
|
|
|
730
832
|
api_key = os.environ.get("AIGHT_API_KEY")
|
|
731
833
|
if not api_key:
|
|
732
|
-
print("Set AIGHT_API_KEY (
|
|
834
|
+
print("Set AIGHT_API_KEY (Project settings in your AIght Workspace)", file=sys.stderr)
|
|
733
835
|
return 1
|
|
734
836
|
url = os.environ.get("AIGHT_INGEST_URL", DEFAULT_INGEST_URL)
|
|
735
837
|
|
|
@@ -434,7 +434,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
434
434
|
|
|
435
435
|
api_key = os.environ.get("AIGHT_API_KEY")
|
|
436
436
|
if not api_key:
|
|
437
|
-
print("Set AIGHT_API_KEY (
|
|
437
|
+
print("Set AIGHT_API_KEY (Project settings in your AIght Workspace)", file=sys.stderr)
|
|
438
438
|
return 1
|
|
439
439
|
ingest_url = os.environ.get("AIGHT_INGEST_URL", DEFAULT_INGEST_URL)
|
|
440
440
|
return serve(args.host, args.port, api_key, ingest_url, args.agent)
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Push locally-traced spend data to your hosted AIght Workspace.
|
|
2
2
|
|
|
3
3
|
Stdlib only (urllib) — reporting cost data home shouldn't require pulling in
|
|
4
|
-
`requests` on top of the tracing deps. Get an API key from
|
|
5
|
-
|
|
4
|
+
`requests` on top of the tracing deps. Get an API key from Project settings
|
|
5
|
+
in your AIght Workspace, then after a traced run:
|
|
6
6
|
|
|
7
7
|
from aight.remote import push
|
|
8
8
|
from aight.tracing import COST_PROCESSOR
|
|
@@ -68,7 +68,7 @@ def push(buckets, api_key: str | None = None, url: str | None = None) -> None:
|
|
|
68
68
|
if not api_key:
|
|
69
69
|
raise RuntimeError(
|
|
70
70
|
"No API key. Pass api_key=..., or set AIGHT_API_KEY (get one from "
|
|
71
|
-
"
|
|
71
|
+
"Project settings in your AIght Workspace at aight.studio)."
|
|
72
72
|
)
|
|
73
73
|
url = url or os.environ.get("AIGHT_INGEST_URL", DEFAULT_INGEST_URL)
|
|
74
74
|
rows = [
|
|
@@ -141,7 +141,7 @@ def push_event(
|
|
|
141
141
|
if not api_key:
|
|
142
142
|
raise RuntimeError(
|
|
143
143
|
"No API key. Pass api_key=..., or set AIGHT_API_KEY (get one from "
|
|
144
|
-
"
|
|
144
|
+
"Project settings in your AIght Workspace at aight.studio)."
|
|
145
145
|
)
|
|
146
146
|
url = url or os.environ.get("AIGHT_EVENTS_INGEST_URL", DEFAULT_INGEST_URL.replace("/spans", "/events"))
|
|
147
147
|
event = {
|
|
@@ -173,7 +173,7 @@ def push_value(value_by_file, api_key: str | None = None, url: str | None = None
|
|
|
173
173
|
if not api_key:
|
|
174
174
|
raise RuntimeError(
|
|
175
175
|
"No API key. Pass api_key=..., or set AIGHT_API_KEY (get one from "
|
|
176
|
-
"
|
|
176
|
+
"Project settings in your AIght Workspace at aight.studio)."
|
|
177
177
|
)
|
|
178
178
|
url = url or os.environ.get("AIGHT_VALUE_INGEST_URL", DEFAULT_INGEST_URL.replace("/spans", "/value"))
|
|
179
179
|
rows = [{"filepath": filepath, "value_usd": value} for filepath, value in value_by_file.items()]
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aight
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Attribute LLM spend to the exact source line, and push it to the AIght Workspace at aight.studio.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://aight.studio
|
|
7
|
+
Project-URL: Documentation, https://aight.studio/docs
|
|
8
|
+
Project-URL: Repository, https://github.com/theunknownport/aight-clients
|
|
9
|
+
Keywords: llm,llm-cost,ai-agents,observability,llmops,profiler,claude-code,langchain,langgraph,openai,anthropic,finops
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
14
|
+
Classifier: Topic :: System :: Monitoring
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: opentelemetry-api>=1.27
|
|
19
|
+
Requires-Dist: opentelemetry-sdk>=1.27
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest; extra == "dev"
|
|
22
|
+
Requires-Dist: ruff; extra == "dev"
|
|
23
|
+
Provides-Extra: examples
|
|
24
|
+
Requires-Dist: langgraph>=0.2; extra == "examples"
|
|
25
|
+
Provides-Extra: openai
|
|
26
|
+
Requires-Dist: opentelemetry-instrumentation-openai>=0.30.0; extra == "openai"
|
|
27
|
+
Provides-Extra: anthropic
|
|
28
|
+
Requires-Dist: opentelemetry-instrumentation-anthropic>=0.30.0; extra == "anthropic"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# aight (Python SDK)
|
|
32
|
+
|
|
33
|
+
Attributes LLM spend to the exact source line that issued it, and pushes it
|
|
34
|
+
to your [aight.studio](https://aight.studio) AIght Workspace.
|
|
35
|
+
|
|
36
|
+
No price table ships in this SDK, by design. The SDK sends the model and the
|
|
37
|
+
token counts; **the platform prices them** — it recomputes `cost_usd` for
|
|
38
|
+
every row at receive time — so the dollar figures in the AIght Workspace are
|
|
39
|
+
always the platform's current prices, never a snapshot frozen at install
|
|
40
|
+
time. `traced_llm_call` therefore returns `None`, not a cost.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install aight
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
That is the whole install, and it also puts the collectors on your path —
|
|
49
|
+
`aight-collect` for Claude Code, three more for other agents, and `aight-proxy`
|
|
50
|
+
for anything none of them can read. They are a separate thing from the SDK and
|
|
51
|
+
are described under [External agents](#external-agents) below.
|
|
52
|
+
|
|
53
|
+
## Deploy with your AI coding agent
|
|
54
|
+
|
|
55
|
+
Paste this into Claude Code, Cursor, or any other coding agent, in whatever
|
|
56
|
+
project you want traced:
|
|
57
|
+
|
|
58
|
+
> Run `pip install "aight[openai,anthropic]"`, then read the `AGENTS.md` file
|
|
59
|
+
> next to the installed `aight` package and follow it to wire up cost
|
|
60
|
+
> attribution for this project.
|
|
61
|
+
|
|
62
|
+
`AGENTS.md` ships inside the package (`python -c "import aight, pathlib; print(pathlib.Path(aight.__file__).parent / 'AGENTS.md')"`)
|
|
63
|
+
so the agent has the full integration recipe locally — no extra fetch, and
|
|
64
|
+
nothing it has to be able to reach over the network.
|
|
65
|
+
|
|
66
|
+
## Use
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from aight.tracing import traced_llm_call, COST_PROCESSOR
|
|
70
|
+
from aight.remote import push
|
|
71
|
+
|
|
72
|
+
traced_llm_call("gpt-4o-mini", input_tokens=180, output_tokens=60)
|
|
73
|
+
# ...wrap every LLM call your agent makes the same way...
|
|
74
|
+
|
|
75
|
+
push(COST_PROCESSOR.buckets) # reads AIGHT_API_KEY from the environment
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Get an API key from Project settings in your AIght Workspace, then:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
export AIGHT_API_KEY="aight_..."
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`traced_llm_call` walks the call stack past known framework packages
|
|
85
|
+
(LangGraph, LangChain) to attribute the span to your code, not theirs.
|
|
86
|
+
|
|
87
|
+
### Latency is yours to measure
|
|
88
|
+
|
|
89
|
+
This SDK does not time the LLM call. It is called *after* your call returns,
|
|
90
|
+
not around it, so a duration taken here would measure the cost of recording
|
|
91
|
+
rather than the cost of the model. Time the call yourself and pass it:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
import time
|
|
95
|
+
from aight.tracing import traced_llm_call
|
|
96
|
+
|
|
97
|
+
started = time.perf_counter()
|
|
98
|
+
response = client.chat.completions.create(...)
|
|
99
|
+
elapsed_ms = (time.perf_counter() - started) * 1000
|
|
100
|
+
|
|
101
|
+
traced_llm_call(model, input_tokens, output_tokens, latency_ms=elapsed_ms)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Leave `latency_ms` out and no latency is claimed — the Workspace shows "—" on
|
|
105
|
+
that agent's Speed axis rather than an impossibly fast number. The
|
|
106
|
+
auto-instrumented path cannot measure it either, so rows it records carry
|
|
107
|
+
none: if you want Speed for a call, wrap that call by hand.
|
|
108
|
+
|
|
109
|
+
## Auto-instrument (no manual wrapping)
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
pip install "aight[openai]" # or aight[anthropic]
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from aight.auto import auto_instrument
|
|
117
|
+
from aight.remote import push
|
|
118
|
+
from aight.tracing import COST_PROCESSOR
|
|
119
|
+
|
|
120
|
+
auto_instrument()
|
|
121
|
+
# ...call the OpenAI/Anthropic client exactly as you normally would...
|
|
122
|
+
|
|
123
|
+
push(COST_PROCESSOR.buckets)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`auto_instrument()` silently skips any provider whose instrumentor package
|
|
127
|
+
isn't installed, so it's safe to call unconditionally. It patches the provider
|
|
128
|
+
client, not the call, so the rows it records carry no latency — see above.
|
|
129
|
+
|
|
130
|
+
## Spent vs earned
|
|
131
|
+
|
|
132
|
+
Report the value your agent generates alongside its spend, to power the
|
|
133
|
+
AIght Workspace's spent-vs-earned view:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from aight.tracing import report_value, VALUE_BY_FILE
|
|
137
|
+
from aight.remote import push_value
|
|
138
|
+
|
|
139
|
+
report_value(49.0) # a closed deal, a resolved ticket — whatever you count
|
|
140
|
+
push_value(VALUE_BY_FILE)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`push_value` sends what has accumulated and clears it once the server has
|
|
144
|
+
acknowledged — so calling it every flush sends each earning once, and a failed
|
|
145
|
+
push keeps the total for the retry instead of losing it.
|
|
146
|
+
|
|
147
|
+
It takes that map as an argument rather than reading a global, which makes it
|
|
148
|
+
also the way to report value for an agent you did not write — pass the agent id
|
|
149
|
+
the collector uses as the filepath. See [External
|
|
150
|
+
agents](#external-agents) below.
|
|
151
|
+
|
|
152
|
+
## Local-only mode
|
|
153
|
+
|
|
154
|
+
Skip `push()` and call `COST_PROCESSOR.report()` instead — a plain-text
|
|
155
|
+
calls-and-tokens-by-line report with no network calls, useful for local
|
|
156
|
+
debugging or fully offline use. It has no dollar figure: pricing is the
|
|
157
|
+
platform's, so spend only appears in the AIght Workspace once you push.
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
examples/snippet_check.py:19 (plan_step) [gpt-4o-mini] = 3 call(s), 540 in / 180 out
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## External agents
|
|
164
|
+
|
|
165
|
+
Everything above is for an agent *you wrote*: the SDK walks your call stack to
|
|
166
|
+
find the line that issued the call. An agent you run but did not write has no
|
|
167
|
+
line of yours on the stack, so there is nothing to instrument and no library to
|
|
168
|
+
import. AIght traces those by reading the agent's own transcripts instead.
|
|
169
|
+
|
|
170
|
+
`pip install aight` also installs `aight-collect`. To see what your Claude Code
|
|
171
|
+
history would cost at API list prices, with no account and nothing sent:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
pip install aight && aight-collect --report --all-projects
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
That prints a breakdown by token type, step and model. Only the model names go
|
|
178
|
+
out, to fetch their rates from AIght's price table; drop `--all-projects` for
|
|
179
|
+
just this directory, add `--since 2026-10-01` to narrow it. To push to your
|
|
180
|
+
Workspace instead:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
export AIGHT_API_KEY="aight_..."
|
|
184
|
+
|
|
185
|
+
aight-collect --dry-run # print the rows instead of sending them
|
|
186
|
+
aight-collect # push them
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
It reads Claude Code's transcripts under `~/.claude/projects` and pushes one
|
|
190
|
+
row per step the agent took, under an external agent called `claude-code`. Cost
|
|
191
|
+
lands on the step — the tool it called and the file it touched — rather than on
|
|
192
|
+
a source line, because no source line of yours is involved.
|
|
193
|
+
|
|
194
|
+
That is the spend side. **Earnings you report yourself** — a collector reads
|
|
195
|
+
token counts, not what the work was worth, and no SDK can infer it. Push them
|
|
196
|
+
against the agent's id:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
from aight.remote import push_value
|
|
200
|
+
|
|
201
|
+
push_value({"claude-code": 49.0}) # the id the collector roots its rows at
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The Workspace shows that on `claude-code` exactly as it shows an agent of your
|
|
205
|
+
own: the earned total, the net, the ratio, and the earning-or-burning status
|
|
206
|
+
that goes with them. What an external agent never gets is a **matched** figure
|
|
207
|
+
— the matching engine's time-window fallback excludes external rows outright,
|
|
208
|
+
because a collector pushes no timestamp and an external row would always be the
|
|
209
|
+
newest thing in the window and win the guess by default. Reported, yes; guessed,
|
|
210
|
+
no.
|
|
211
|
+
|
|
212
|
+
Leave it running and the spend appears as you work, rather than whenever you
|
|
213
|
+
next remember to run it:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
aight-collect --watch # re-scan every 30s, until Ctrl-C
|
|
217
|
+
aight-collect --watch --interval 10
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
`--watch` cannot be combined with `--since` or `--all-time`. Those name a scope
|
|
221
|
+
to re-send, and a loop would re-send it every interval — which the API *adds* to
|
|
222
|
+
what it already holds, so the second tick would double the first. Run them once
|
|
223
|
+
to catch up, then start the watcher.
|
|
224
|
+
|
|
225
|
+
Three things are worth knowing before the first run:
|
|
226
|
+
|
|
227
|
+
- **A plain run resumes.** It records the newest call it pushed for each
|
|
228
|
+
transcript file in `~/.aight/`, so a second run sends only what is new.
|
|
229
|
+
Running it repeatedly is the intended use.
|
|
230
|
+
- **`--all-time` does not resume.** It re-sends the whole history, and the API
|
|
231
|
+
*adds* what it receives to what it already holds — so pushing the same calls
|
|
232
|
+
twice doubles the recorded spend permanently. Cost is frozen at receive time
|
|
233
|
+
and there is no repair path. Reach for it only on a transcript that has never
|
|
234
|
+
been pushed.
|
|
235
|
+
- **The key alone decides the destination.** A run prints the project its rows
|
|
236
|
+
landed in and stops rather than resuming when that differs from the project
|
|
237
|
+
its earlier pushes went to. A key copied out of another project's settings
|
|
238
|
+
otherwise reads exactly like success, and fills a workspace nobody is
|
|
239
|
+
looking at.
|
|
240
|
+
|
|
241
|
+
Without installing anything:
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
uvx --from aight aight-collect --dry-run
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
The same machinery covers three more agents, each as its own command:
|
|
248
|
+
|
|
249
|
+
| Command | Reads | Needs |
|
|
250
|
+
|---|---|---|
|
|
251
|
+
| `aight-collect` | Claude Code — `~/.claude/projects` | nothing |
|
|
252
|
+
| `aight-collect-codex` | Codex CLI — `~/.codex/sessions` (`CODEX_HOME` respected) | nothing |
|
|
253
|
+
| `aight-collect-gemini` | Gemini CLI — `~/.gemini/tmp` | nothing |
|
|
254
|
+
| `aight-collect-aider` | Aider's `--analytics-log` file | **you must pass the path** |
|
|
255
|
+
|
|
256
|
+
Everything above applies to all four — the resume marker, `--since`/`--all-time`,
|
|
257
|
+
`--watch`, and the destination check — because they share one implementation of
|
|
258
|
+
it rather than one copy each.
|
|
259
|
+
|
|
260
|
+
Aider is the odd one and the command says so when it has nothing to read: it
|
|
261
|
+
writes a per-call log **only** if it was started with `--analytics-log FILE`,
|
|
262
|
+
and there is no default location. A session that already ran without that flag
|
|
263
|
+
cannot be collected, because Aider recorded nothing for it. Start Aider with
|
|
264
|
+
the flag, then point this at the same file.
|
|
265
|
+
|
|
266
|
+
The proxy below is the fallback for an agent none of these cover — including
|
|
267
|
+
the ones whose records are not readable at all.
|
|
268
|
+
|
|
269
|
+
## Any other agent (the proxy)
|
|
270
|
+
|
|
271
|
+
The collector above reads Claude Code's own transcripts, which means one parser
|
|
272
|
+
per agent — and several agents keep no readable per-call record at all. Cursor
|
|
273
|
+
keeps no token counts on disk; Amp emits no model name anywhere. So for those
|
|
274
|
+
there is nothing to parse, and the answer is not another parser.
|
|
275
|
+
|
|
276
|
+
`aight-proxy` stands between the agent and the API instead. It sees the request
|
|
277
|
+
and the response, so it does not need to know what the agent is:
|
|
278
|
+
|
|
279
|
+
```bash
|
|
280
|
+
aight-proxy # 127.0.0.1:8787
|
|
281
|
+
export OPENAI_BASE_URL=http://127.0.0.1:8787/openai
|
|
282
|
+
export ANTHROPIC_BASE_URL=http://127.0.0.1:8787/anthropic
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
The path prefix picks the upstream, because both APIs live under `/v1/` and
|
|
286
|
+
`/v1/messages` would otherwise be ambiguous. Everything after the prefix is
|
|
287
|
+
forwarded unchanged, including your `Authorization` header — the proxy holds no
|
|
288
|
+
credentials of its own and stores nothing from the request or the response.
|
|
289
|
+
|
|
290
|
+
It works for anything that lets you point its base URL somewhere else, which is
|
|
291
|
+
most agents. Name them so several through one proxy stay distinct:
|
|
292
|
+
|
|
293
|
+
```bash
|
|
294
|
+
AIGHT_PROXY_AGENT=codex aight-proxy
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Four things worth knowing:
|
|
298
|
+
|
|
299
|
+
- **It measures latency**, which the SDKs cannot. They record after the call
|
|
300
|
+
returns, so a duration taken there would time the recording; the proxy sits
|
|
301
|
+
around the call, so `latency_ms` is real.
|
|
302
|
+
- **OpenAI's cached tokens are subtracted.** `prompt_tokens` counts the cached
|
|
303
|
+
prefix and AIght's `input_tokens` does not, so sending the inclusive number
|
|
304
|
+
would bill those tokens twice — permanently, since cost is frozen at receive
|
|
305
|
+
time. Anthropic reports the split itself and nothing is subtracted there.
|
|
306
|
+
- **Streaming OpenAI requests get `stream_options.include_usage` added.** That
|
|
307
|
+
is a change to your request, not a passive read: without it a streaming call
|
|
308
|
+
carries no usage at all, and most calls stream. Anthropic needs no equivalent.
|
|
309
|
+
- **Nothing is priced here.** Rows go up with `cost_usd` 0.0 and the platform
|
|
310
|
+
prices them from its own table at receive time.
|
|
311
|
+
|
|
312
|
+
## Example
|
|
313
|
+
|
|
314
|
+
`examples/langgraph_spike.py` traces a 3-node LangGraph agent and prints the
|
|
315
|
+
attribution report (also pushes it, if `AIGHT_API_KEY` is set). It ships in the
|
|
316
|
+
repo rather than in the wheel, so run it from a clone:
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
cd /path/to/aight-clients/python
|
|
320
|
+
pip install ".[examples]" # langgraph; ".[dev,examples]" adds pytest and ruff
|
|
321
|
+
python -m examples.langgraph_spike
|
|
322
|
+
```
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "aight"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.2"
|
|
4
4
|
description = "Attribute LLM spend to the exact source line, and push it to the AIght Workspace at aight.studio."
|
|
5
5
|
# SPDX identifier; the text it refers to is the LICENSE file in this directory.
|
|
6
6
|
# The bare string is PEP 639, which is why the build floor below is setuptools
|
|
7
7
|
# 77 — older versions want the deprecated {text = "Apache-2.0"} table instead.
|
|
8
8
|
license = "Apache-2.0"
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
keywords = ["llm", "llm-cost", "ai-agents", "observability", "llmops", "profiler",
|
|
12
|
+
"claude-code", "langchain", "langgraph", "openai", "anthropic", "finops"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: Software Development :: Libraries",
|
|
18
|
+
"Topic :: System :: Monitoring",
|
|
19
|
+
]
|
|
10
20
|
dependencies = [
|
|
11
21
|
"opentelemetry-api>=1.27",
|
|
12
22
|
"opentelemetry-sdk>=1.27",
|
|
@@ -30,6 +40,7 @@ aight-proxy = "aight.collect.proxy:cli"
|
|
|
30
40
|
|
|
31
41
|
[project.urls]
|
|
32
42
|
Homepage = "https://aight.studio"
|
|
43
|
+
Documentation = "https://aight.studio/docs"
|
|
33
44
|
Repository = "https://github.com/theunknownport/aight-clients"
|
|
34
45
|
|
|
35
46
|
[build-system]
|
|
@@ -466,7 +466,7 @@ def test_a_key_for_another_project_refuses_instead_of_resuming(tmp_path, monkeyp
|
|
|
466
466
|
"""The one mistake the marker cannot catch by itself.
|
|
467
467
|
|
|
468
468
|
A marker records that calls were *sent*, never where. So a key minted in a
|
|
469
|
-
different project's
|
|
469
|
+
different project's settings resumes this machine's coverage, pushes
|
|
470
470
|
only what is newer than it into the wrong workspace, and leaves the project
|
|
471
471
|
that was meant permanently missing that window — silently, because every
|
|
472
472
|
one of those pushes succeeds. Refusing is the only safe direction.
|
|
@@ -737,3 +737,40 @@ def test_push_builds_a_request_that_reports_the_installed_version(monkeypatch):
|
|
|
737
737
|
assert seen["headers"]["x-aight-sdk-version"] == base.SDK_VERSION
|
|
738
738
|
assert seen["headers"]["x-aight-sdk-language"] == "a-collector"
|
|
739
739
|
assert seen["headers"]["authorization"] == "Bearer key"
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _row(step, model, calls, cache_read=0, output=0, filepath="", kind="external"):
|
|
743
|
+
return {
|
|
744
|
+
"chain": [{"filepath": "claude-code", "lineno": 0, "function": "claude-code"},
|
|
745
|
+
{"filepath": filepath, "lineno": 0, "function": step}],
|
|
746
|
+
"kind": kind, "model": model, "calls": calls, "cost_usd": 0.0,
|
|
747
|
+
"input_tokens": 0, "output_tokens": output,
|
|
748
|
+
"cache_read_tokens": cache_read, "cache_creation_tokens": 0, "trace_id": "",
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def test_report_prices_with_the_server_rates_and_groups_tools_not_paths():
|
|
753
|
+
rows = [_row("reply", "m", 10, cache_read=1_000_000),
|
|
754
|
+
_row("Read", "m", 2, output=2000, filepath="/Users/someone/secret/notes.md"),
|
|
755
|
+
_row("Read", "unknown", 1, output=1000)]
|
|
756
|
+
out = collector.report(rows, "claude-code", {"m": {"cache_read": 0.0003, "output": 0.015}, "unknown": None})
|
|
757
|
+
assert "$0.30" in out.split("By step")[0] # 1M cache read at 0.0003/1K
|
|
758
|
+
assert "$0.33" in out # + 2K output at 0.015/1K; the unknown model is $0
|
|
759
|
+
assert "/Users/someone" not in out
|
|
760
|
+
assert out.count("Read") == 1
|
|
761
|
+
assert "No list price yet for: unknown" in out
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def test_report_falls_back_to_tokens_when_pricing_is_unreachable():
|
|
765
|
+
out = collector.report([_row("reply", "m", 1, cache_read=5000)], "claude-code", None)
|
|
766
|
+
assert "tokens only" in out and "5,000 tok" in out
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def test_report_pushes_nothing_and_reads_all_history(tmp_path, monkeypatch, capsys):
|
|
770
|
+
pushed = _recording_push(monkeypatch)
|
|
771
|
+
_recent(tmp_path / "base")
|
|
772
|
+
monkeypatch.setattr(collector, "fetch_prices", lambda rows, url: {})
|
|
773
|
+
assert main(["--root", str(tmp_path / "base"), "--report"]) == 0
|
|
774
|
+
assert pushed == []
|
|
775
|
+
out = capsys.readouterr().out
|
|
776
|
+
assert "Nothing was sent" in out and "go up" not in out
|
aight-0.2.0/PKG-INFO
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: aight
|
|
3
|
-
Version: 0.2.0
|
|
4
|
-
Summary: Attribute LLM spend to the exact source line, and push it to the AIght Workspace at aight.studio.
|
|
5
|
-
License-Expression: Apache-2.0
|
|
6
|
-
Project-URL: Homepage, https://aight.studio
|
|
7
|
-
Project-URL: Repository, https://github.com/theunknownport/aight-clients
|
|
8
|
-
Requires-Python: >=3.11
|
|
9
|
-
License-File: LICENSE
|
|
10
|
-
Requires-Dist: opentelemetry-api>=1.27
|
|
11
|
-
Requires-Dist: opentelemetry-sdk>=1.27
|
|
12
|
-
Provides-Extra: dev
|
|
13
|
-
Requires-Dist: pytest; extra == "dev"
|
|
14
|
-
Requires-Dist: ruff; extra == "dev"
|
|
15
|
-
Provides-Extra: examples
|
|
16
|
-
Requires-Dist: langgraph>=0.2; extra == "examples"
|
|
17
|
-
Provides-Extra: openai
|
|
18
|
-
Requires-Dist: opentelemetry-instrumentation-openai>=0.30.0; extra == "openai"
|
|
19
|
-
Provides-Extra: anthropic
|
|
20
|
-
Requires-Dist: opentelemetry-instrumentation-anthropic>=0.30.0; extra == "anthropic"
|
|
21
|
-
Dynamic: license-file
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: aight
|
|
3
|
-
Version: 0.2.0
|
|
4
|
-
Summary: Attribute LLM spend to the exact source line, and push it to the AIght Workspace at aight.studio.
|
|
5
|
-
License-Expression: Apache-2.0
|
|
6
|
-
Project-URL: Homepage, https://aight.studio
|
|
7
|
-
Project-URL: Repository, https://github.com/theunknownport/aight-clients
|
|
8
|
-
Requires-Python: >=3.11
|
|
9
|
-
License-File: LICENSE
|
|
10
|
-
Requires-Dist: opentelemetry-api>=1.27
|
|
11
|
-
Requires-Dist: opentelemetry-sdk>=1.27
|
|
12
|
-
Provides-Extra: dev
|
|
13
|
-
Requires-Dist: pytest; extra == "dev"
|
|
14
|
-
Requires-Dist: ruff; extra == "dev"
|
|
15
|
-
Provides-Extra: examples
|
|
16
|
-
Requires-Dist: langgraph>=0.2; extra == "examples"
|
|
17
|
-
Provides-Extra: openai
|
|
18
|
-
Requires-Dist: opentelemetry-instrumentation-openai>=0.30.0; extra == "openai"
|
|
19
|
-
Provides-Extra: anthropic
|
|
20
|
-
Requires-Dist: opentelemetry-instrumentation-anthropic>=0.30.0; extra == "anthropic"
|
|
21
|
-
Dynamic: license-file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|