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.
Files changed (33) hide show
  1. aight-0.2.2/PKG-INFO +322 -0
  2. {aight-0.2.0 → aight-0.2.2}/README.md +42 -8
  3. {aight-0.2.0 → aight-0.2.2}/aight/AGENTS.md +4 -0
  4. {aight-0.2.0 → aight-0.2.2}/aight/__init__.py +1 -1
  5. {aight-0.2.0 → aight-0.2.2}/aight/collect/base.py +105 -3
  6. {aight-0.2.0 → aight-0.2.2}/aight/collect/proxy.py +1 -1
  7. {aight-0.2.0 → aight-0.2.2}/aight/remote.py +5 -5
  8. aight-0.2.2/aight.egg-info/PKG-INFO +322 -0
  9. {aight-0.2.0 → aight-0.2.2}/pyproject.toml +12 -1
  10. {aight-0.2.0 → aight-0.2.2}/tests/test_collect_claude_code.py +38 -1
  11. aight-0.2.0/PKG-INFO +0 -21
  12. aight-0.2.0/aight.egg-info/PKG-INFO +0 -21
  13. {aight-0.2.0 → aight-0.2.2}/LICENSE +0 -0
  14. {aight-0.2.0 → aight-0.2.2}/aight/auto.py +0 -0
  15. {aight-0.2.0 → aight-0.2.2}/aight/collect/__init__.py +0 -0
  16. {aight-0.2.0 → aight-0.2.2}/aight/collect/aider.py +0 -0
  17. {aight-0.2.0 → aight-0.2.2}/aight/collect/claude_code.py +0 -0
  18. {aight-0.2.0 → aight-0.2.2}/aight/collect/codex.py +0 -0
  19. {aight-0.2.0 → aight-0.2.2}/aight/collect/gemini.py +0 -0
  20. {aight-0.2.0 → aight-0.2.2}/aight/tracing.py +0 -0
  21. {aight-0.2.0 → aight-0.2.2}/aight.egg-info/SOURCES.txt +0 -0
  22. {aight-0.2.0 → aight-0.2.2}/aight.egg-info/dependency_links.txt +0 -0
  23. {aight-0.2.0 → aight-0.2.2}/aight.egg-info/entry_points.txt +0 -0
  24. {aight-0.2.0 → aight-0.2.2}/aight.egg-info/requires.txt +0 -0
  25. {aight-0.2.0 → aight-0.2.2}/aight.egg-info/top_level.txt +0 -0
  26. {aight-0.2.0 → aight-0.2.2}/setup.cfg +0 -0
  27. {aight-0.2.0 → aight-0.2.2}/tests/test_auto_instrument.py +0 -0
  28. {aight-0.2.0 → aight-0.2.2}/tests/test_collect_aider.py +0 -0
  29. {aight-0.2.0 → aight-0.2.2}/tests/test_collect_codex.py +0 -0
  30. {aight-0.2.0 → aight-0.2.2}/tests/test_collect_gemini.py +0 -0
  31. {aight-0.2.0 → aight-0.2.2}/tests/test_collect_proxy.py +0 -0
  32. {aight-0.2.0 → aight-0.2.2}/tests/test_remote.py +0 -0
  33. {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 `aight-collect` on your path — the
19
- Claude Code collector, which is a separate thing from the SDK and is described
20
- under [External agents](#external-agents-claude-code) below.
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 the Integrate tab of your AIght Workspace, then:
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 (Claude Code)
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 Integrate
174
- tab otherwise reads exactly like success, and fills a workspace nobody is
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):
@@ -23,4 +23,4 @@ Or trace manually, call by call:
23
23
  push(COST_PROCESSOR.buckets)
24
24
  """
25
25
 
26
- __version__ = "0.2.0"
26
+ __version__ = "0.2.2"
@@ -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 Integrate tab would resume this machine's coverage, send only
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 (Integrate tab of your AIght Workspace)", file=sys.stderr)
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 (Integrate tab of your AIght Workspace)", file=sys.stderr)
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 (Integrate tab of your AIght Workspace)", file=sys.stderr)
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 the "Integrate"
5
- tab of your AIght Workspace, then after a traced run:
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
- "the Integrate tab of your aight.studio dashboard)."
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
- "the Integrate tab of your aight.studio dashboard)."
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
- "the Integrate tab of your aight.studio dashboard)."
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.0"
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 Integrate tab resumes this machine's coverage, pushes
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