spanlight 0.1.1__tar.gz → 0.1.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 (49) hide show
  1. {spanlight-0.1.1 → spanlight-0.1.2}/PKG-INFO +16 -13
  2. {spanlight-0.1.1 → spanlight-0.1.2}/README.md +15 -12
  3. {spanlight-0.1.1 → spanlight-0.1.2}/pyproject.toml +1 -1
  4. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_span.py +18 -2
  5. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_version.py +1 -1
  6. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/integrations/anthropic.py +24 -5
  7. {spanlight-0.1.1 → spanlight-0.1.2}/tests/provider_mocks.py +40 -0
  8. {spanlight-0.1.1 → spanlight-0.1.2}/tests/test_anthropic.py +57 -4
  9. {spanlight-0.1.1 → spanlight-0.1.2}/tests/test_spans_and_context.py +30 -0
  10. {spanlight-0.1.1 → spanlight-0.1.2}/uv.lock +1 -1
  11. {spanlight-0.1.1 → spanlight-0.1.2}/.gitignore +0 -0
  12. {spanlight-0.1.1 → spanlight-0.1.2}/LICENSE +0 -0
  13. {spanlight-0.1.1 → spanlight-0.1.2}/examples/support_bot.py +0 -0
  14. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/__init__.py +0 -0
  15. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_client.py +0 -0
  16. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_config.py +0 -0
  17. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_context.py +0 -0
  18. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_decorator.py +0 -0
  19. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_experiments.py +0 -0
  20. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_experiments_api.py +0 -0
  21. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_experiments_parse.py +0 -0
  22. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_experiments_report.py +0 -0
  23. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_experiments_runs.py +0 -0
  24. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_experiments_setup.py +0 -0
  25. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_exporter.py +0 -0
  26. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_exporter_delivery.py +0 -0
  27. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_globals.py +0 -0
  28. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_ids.py +0 -0
  29. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_prompt_client.py +0 -0
  30. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_prompts.py +0 -0
  31. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_request_hash.py +0 -0
  32. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_scores.py +0 -0
  33. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_serialize.py +0 -0
  34. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_templates.py +0 -0
  35. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/integrations/__init__.py +0 -0
  36. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/integrations/_common.py +0 -0
  37. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/integrations/openai.py +0 -0
  38. {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/py.typed +0 -0
  39. {spanlight-0.1.1 → spanlight-0.1.2}/tests/conftest.py +0 -0
  40. {spanlight-0.1.1 → spanlight-0.1.2}/tests/fixtures/template_cases.json +0 -0
  41. {spanlight-0.1.1 → spanlight-0.1.2}/tests/support.py +0 -0
  42. {spanlight-0.1.1 → spanlight-0.1.2}/tests/test_decorator.py +0 -0
  43. {spanlight-0.1.1 → spanlight-0.1.2}/tests/test_docs_snippets.py +0 -0
  44. {spanlight-0.1.1 → spanlight-0.1.2}/tests/test_experiments_report.py +0 -0
  45. {spanlight-0.1.1 → spanlight-0.1.2}/tests/test_exporter.py +0 -0
  46. {spanlight-0.1.1 → spanlight-0.1.2}/tests/test_exporter_retry_after.py +0 -0
  47. {spanlight-0.1.1 → spanlight-0.1.2}/tests/test_openai.py +0 -0
  48. {spanlight-0.1.1 → spanlight-0.1.2}/tests/test_package.py +0 -0
  49. {spanlight-0.1.1 → spanlight-0.1.2}/tests/test_templates_surrogates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: spanlight
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Python SDK for Spanlight, open-source observability for LLM applications.
5
5
  Author: Dheeraj
6
6
  License-Expression: MIT
@@ -294,17 +294,19 @@ run makes real provider calls. In a GitHub workflow use the
294
294
 
295
295
  For every LLM call:
296
296
 
297
- | Field | Source |
298
- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
299
- | `provider` | `openai` / `anthropic` |
300
- | `model` | Model from the response (for example `gpt-4o-mini-2024-07-18`). Falls back to the requested model. |
301
- | `usage.input_tokens` | Total prompt tokens, **including** cached tokens. For Anthropic this is input + cache reads + cache writes. |
302
- | `usage.output_tokens` | Completion tokens |
303
- | `usage.cached_tokens` | Prompt tokens served from the provider's prompt cache |
304
- | `time_to_first_token_ms` | Streaming calls only |
305
- | `input` | Request parameters such as `messages`, `system` and `temperature`. API keys, headers, `extra_*` and `timeout` are never recorded. |
306
- | `output` | The assistant message, including tool calls |
307
- | `status` / `status_message` | `error` plus the provider's message when the call fails |
297
+ | Field | Source |
298
+ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
299
+ | `provider` | `openai` / `anthropic` |
300
+ | `model` | Model from the response (for example `gpt-4o-mini-2024-07-18`). Falls back to the requested model. |
301
+ | `usage.input_tokens` | Total prompt tokens, **including** cached tokens. For Anthropic this is input + cache reads + cache writes. |
302
+ | `usage.output_tokens` | Completion tokens |
303
+ | `usage.cached_tokens` | Prompt tokens served from the provider's prompt cache |
304
+ | `usage.cache_write_tokens` | Prompt tokens written to the prompt cache (Anthropic `cache_creation_input_tokens`). Sent only when the provider reports it. |
305
+ | `usage.cache_write_1h_tokens` | The part of `cache_write_tokens` written with Anthropic's 1-hour TTL (`cache_creation.ephemeral_1h_input_tokens`); the rest are 5-minute writes. Sent only when reported. |
306
+ | `time_to_first_token_ms` | Streaming calls only |
307
+ | `input` | Request parameters such as `messages`, `system` and `temperature`. API keys, headers, `extra_*` and `timeout` are never recorded. |
308
+ | `output` | The assistant message, including tool calls |
309
+ | `status` / `status_message` | `error` plus the provider's message when the call fails |
308
310
 
309
311
  The Spanlight server also redacts common secret patterns from
310
312
  payloads on ingestion. Set `capture_payloads = false` on a project to drop
@@ -362,7 +364,8 @@ with spanlight.span("rerank", kind="retrieval") as span:
362
364
 
363
365
  `Span` methods: `set_input`, `set_output`, `set_attribute`, `set_attributes`,
364
366
  `set_model(model, provider=...)`, `set_usage(input_tokens=, output_tokens=,
365
- cached_tokens=)`, `set_finish_reason(reason)`, `set_request_hash(hash)`,
367
+ cached_tokens=, cache_write_tokens=, cache_write_1h_tokens=)`,
368
+ `set_finish_reason(reason)`, `set_request_hash(hash)`,
366
369
  `mark_first_token()` and `record_error(exc_or_message)`. An
367
370
  exception that escapes the block marks the span as failed and is re-raised
368
371
  unchanged.
@@ -267,17 +267,19 @@ run makes real provider calls. In a GitHub workflow use the
267
267
 
268
268
  For every LLM call:
269
269
 
270
- | Field | Source |
271
- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
272
- | `provider` | `openai` / `anthropic` |
273
- | `model` | Model from the response (for example `gpt-4o-mini-2024-07-18`). Falls back to the requested model. |
274
- | `usage.input_tokens` | Total prompt tokens, **including** cached tokens. For Anthropic this is input + cache reads + cache writes. |
275
- | `usage.output_tokens` | Completion tokens |
276
- | `usage.cached_tokens` | Prompt tokens served from the provider's prompt cache |
277
- | `time_to_first_token_ms` | Streaming calls only |
278
- | `input` | Request parameters such as `messages`, `system` and `temperature`. API keys, headers, `extra_*` and `timeout` are never recorded. |
279
- | `output` | The assistant message, including tool calls |
280
- | `status` / `status_message` | `error` plus the provider's message when the call fails |
270
+ | Field | Source |
271
+ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
272
+ | `provider` | `openai` / `anthropic` |
273
+ | `model` | Model from the response (for example `gpt-4o-mini-2024-07-18`). Falls back to the requested model. |
274
+ | `usage.input_tokens` | Total prompt tokens, **including** cached tokens. For Anthropic this is input + cache reads + cache writes. |
275
+ | `usage.output_tokens` | Completion tokens |
276
+ | `usage.cached_tokens` | Prompt tokens served from the provider's prompt cache |
277
+ | `usage.cache_write_tokens` | Prompt tokens written to the prompt cache (Anthropic `cache_creation_input_tokens`). Sent only when the provider reports it. |
278
+ | `usage.cache_write_1h_tokens` | The part of `cache_write_tokens` written with Anthropic's 1-hour TTL (`cache_creation.ephemeral_1h_input_tokens`); the rest are 5-minute writes. Sent only when reported. |
279
+ | `time_to_first_token_ms` | Streaming calls only |
280
+ | `input` | Request parameters such as `messages`, `system` and `temperature`. API keys, headers, `extra_*` and `timeout` are never recorded. |
281
+ | `output` | The assistant message, including tool calls |
282
+ | `status` / `status_message` | `error` plus the provider's message when the call fails |
281
283
 
282
284
  The Spanlight server also redacts common secret patterns from
283
285
  payloads on ingestion. Set `capture_payloads = false` on a project to drop
@@ -335,7 +337,8 @@ with spanlight.span("rerank", kind="retrieval") as span:
335
337
 
336
338
  `Span` methods: `set_input`, `set_output`, `set_attribute`, `set_attributes`,
337
339
  `set_model(model, provider=...)`, `set_usage(input_tokens=, output_tokens=,
338
- cached_tokens=)`, `set_finish_reason(reason)`, `set_request_hash(hash)`,
340
+ cached_tokens=, cache_write_tokens=, cache_write_1h_tokens=)`,
341
+ `set_finish_reason(reason)`, `set_request_hash(hash)`,
339
342
  `mark_first_token()` and `record_error(exc_or_message)`. An
340
343
  exception that escapes the block marks the span as failed and is re-raised
341
344
  unchanged.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spanlight"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "Python SDK for Spanlight, open-source observability for LLM applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -143,6 +143,8 @@ class Span:
143
143
  self.input_tokens: int | None = None
144
144
  self.output_tokens: int | None = None
145
145
  self.cached_tokens: int | None = None
146
+ self.cache_write_tokens: int | None = None
147
+ self.cache_write_1h_tokens: int | None = None
146
148
  self.time_to_first_token_ms: float | None = None
147
149
  self.request_hash: str | None = None
148
150
  self.finish_reason: str | None = None
@@ -206,11 +208,17 @@ class Span:
206
208
  input_tokens: int | None = None,
207
209
  output_tokens: int | None = None,
208
210
  cached_tokens: int | None = None,
211
+ cache_write_tokens: int | None = None,
212
+ cache_write_1h_tokens: int | None = None,
209
213
  ) -> None:
210
214
  """Record token usage. ``None`` leaves the existing value unchanged.
211
215
 
212
- ``input_tokens`` is the *total* prompt size, including any cached
213
- tokens; ``cached_tokens`` is the subset served from the prompt cache.
216
+ ``input_tokens`` is the *total* prompt size: uncached tokens plus
217
+ prompt-cache reads plus prompt-cache writes. ``cached_tokens`` is the
218
+ part served from the cache (reads); ``cache_write_tokens`` is the part
219
+ written to it, and ``cache_write_1h_tokens`` the subset of those writes
220
+ made with a 1-hour TTL (the rest are 5-minute writes). Leave the write
221
+ fields unset when the provider reports none.
214
222
  """
215
223
  if not self._mutable:
216
224
  return
@@ -220,6 +228,10 @@ class Span:
220
228
  self.output_tokens = int(output_tokens)
221
229
  if cached_tokens is not None:
222
230
  self.cached_tokens = int(cached_tokens)
231
+ if cache_write_tokens is not None:
232
+ self.cache_write_tokens = int(cache_write_tokens)
233
+ if cache_write_1h_tokens is not None:
234
+ self.cache_write_1h_tokens = int(cache_write_1h_tokens)
223
235
 
224
236
  def set_request_hash(self, value: str | None) -> None:
225
237
  """Record the request hash used to group identical calls.
@@ -301,6 +313,10 @@ class Span:
301
313
  "input_tokens": self.input_tokens,
302
314
  "output_tokens": self.output_tokens,
303
315
  "cached_tokens": self.cached_tokens,
316
+ # Sent only when set, so payloads for servers that predate cache-write
317
+ # pricing stay unchanged.
318
+ "cache_write_tokens": self.cache_write_tokens,
319
+ "cache_write_1h_tokens": self.cache_write_1h_tokens,
304
320
  }
305
321
  if any(count is not None for count in token_counts.values()):
306
322
  usage = {key: count for key, count in token_counts.items() if count is not None}
@@ -1,3 +1,3 @@
1
1
  """Package version, kept in one place."""
2
2
 
3
- __version__ = "0.1.1"
3
+ __version__ = "0.1.2"
@@ -7,8 +7,10 @@ instance, sync or async.
7
7
  Token accounting: Anthropic reports ``input_tokens`` *excluding* prompt-cache
8
8
  reads and writes. To keep one meaning across providers, spans record
9
9
  ``input_tokens`` as the total prompt size (uncached + cache reads + cache
10
- writes) and ``cached_tokens`` as the cache reads. The raw cache-write count is
11
- kept in the ``usage.cache_creation_input_tokens`` attribute.
10
+ writes), ``cached_tokens`` as the cache reads, ``cache_write_tokens`` as the
11
+ cache writes (``cache_creation_input_tokens``) and ``cache_write_1h_tokens``
12
+ as the writes made with the 1-hour TTL
13
+ (``cache_creation.ephemeral_1h_input_tokens``), which the server prices apart.
12
14
  """
13
15
 
14
16
  from __future__ import annotations
@@ -128,16 +130,26 @@ def _record_usage(span: Span, usage: Any) -> None:
128
130
  uncached = as_int(get_field(usage, "input_tokens"))
129
131
  cache_read = as_int(get_field(usage, "cache_read_input_tokens"))
130
132
  cache_write = as_int(get_field(usage, "cache_creation_input_tokens"))
133
+ cache_write_1h = as_int(get_field(usage, "cache_creation", "ephemeral_1h_input_tokens"))
131
134
  total_input = None
132
135
  if uncached is not None:
133
136
  total_input = uncached + (cache_read or 0) + (cache_write or 0)
137
+ if cache_write_1h is not None and (cache_write is None or cache_write_1h > cache_write):
138
+ # The server rejects the whole span for an inconsistent split; keep the
139
+ # total and drop the part rather than lose the span.
140
+ logger.debug(
141
+ "Ignoring Anthropic 1h cache-write count %r exceeding total %r",
142
+ cache_write_1h,
143
+ cache_write,
144
+ )
145
+ cache_write_1h = None
134
146
  span.set_usage(
135
147
  input_tokens=total_input,
136
148
  output_tokens=as_int(get_field(usage, "output_tokens")),
137
149
  cached_tokens=cache_read,
150
+ cache_write_tokens=cache_write,
151
+ cache_write_1h_tokens=cache_write_1h,
138
152
  )
139
- if cache_write:
140
- span.set_attribute("usage.cache_creation_input_tokens", cache_write)
141
153
 
142
154
 
143
155
  def _record_message_metadata(span: Span, message_id: Any, stop_reason: Any) -> None:
@@ -184,7 +196,11 @@ class _MessagesStreamAccumulator(StreamAccumulator):
184
196
  self._merge_usage(get_field(message, "usage"))
185
197
 
186
198
  def _merge_usage(self, usage: Any) -> None:
187
- """Merge usage counters; later events carry cumulative values."""
199
+ """Merge usage counters; later events carry cumulative values, never summed.
200
+
201
+ The TTL split (``cache_creation``) usually appears only in
202
+ ``message_start``; a later event without it leaves the split in place.
203
+ """
188
204
  for key in (
189
205
  "input_tokens",
190
206
  "output_tokens",
@@ -194,6 +210,9 @@ class _MessagesStreamAccumulator(StreamAccumulator):
194
210
  value = as_int(get_field(usage, key))
195
211
  if value is not None:
196
212
  self._usage[key] = value
213
+ cache_write_1h = as_int(get_field(usage, "cache_creation", "ephemeral_1h_input_tokens"))
214
+ if cache_write_1h is not None:
215
+ self._usage["cache_creation"] = {"ephemeral_1h_input_tokens": cache_write_1h}
197
216
 
198
217
  def _on_block_start(self, event: Any) -> None:
199
218
  index = as_int(get_field(event, "index")) or 0
@@ -300,6 +300,46 @@ ANTHROPIC_MESSAGE = {
300
300
  }
301
301
 
302
302
 
303
+ # Real usage from a 2026-10-14 pilot call: every write landed in the 1-hour cache.
304
+ ANTHROPIC_CACHE_WRITE_USAGE = {
305
+ "input_tokens": 19,
306
+ "output_tokens": 12,
307
+ "cache_read_input_tokens": 0,
308
+ "cache_creation_input_tokens": 6404,
309
+ "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 6404},
310
+ }
311
+
312
+ ANTHROPIC_CACHE_WRITE_MESSAGE = {**ANTHROPIC_MESSAGE, "usage": ANTHROPIC_CACHE_WRITE_USAGE}
313
+
314
+
315
+ def anthropic_cache_write_stream() -> list[tuple[str | None, dict[str, Any] | str]]:
316
+ """Text stream whose ``message_start`` carries the pilot's cache-write usage.
317
+
318
+ ``message_delta`` repeats the cumulative prompt counts without the TTL split,
319
+ as the API does; the accumulator must not sum them.
320
+ """
321
+ events = anthropic_text_stream()
322
+ name, start = events[0]
323
+ assert isinstance(start, dict)
324
+ message = {**start["message"], "usage": {**ANTHROPIC_CACHE_WRITE_USAGE, "output_tokens": 1}}
325
+ events[0] = (name, {**start, "message": message})
326
+ name, delta = events[-2]
327
+ assert isinstance(delta, dict)
328
+ events[-2] = (
329
+ name,
330
+ {
331
+ **delta,
332
+ "usage": {
333
+ "input_tokens": 19,
334
+ "cache_read_input_tokens": 0,
335
+ "cache_creation_input_tokens": 6404,
336
+ "output_tokens": 12,
337
+ },
338
+ },
339
+ )
340
+ return events
341
+
342
+
303
343
  def anthropic_text_stream() -> list[tuple[str | None, dict[str, Any] | str]]:
304
344
  return [
305
345
  (
@@ -7,9 +7,11 @@ import json
7
7
  import anthropic
8
8
  import pytest
9
9
  from provider_mocks import (
10
+ ANTHROPIC_CACHE_WRITE_MESSAGE,
10
11
  ANTHROPIC_INVALID_REQUEST,
11
12
  ANTHROPIC_MESSAGE,
12
13
  MockProvider,
14
+ anthropic_cache_write_stream,
13
15
  anthropic_stream_with_overload_error,
14
16
  anthropic_text_stream,
15
17
  anthropic_tool_use_stream,
@@ -61,8 +63,14 @@ def test_messages_create_span_fields(tracer, ingest):
61
63
  assert span["provider"] == "anthropic"
62
64
  assert span["model"] == "claude-haiku-4-5-20251001"
63
65
  # input_tokens is the full prompt: 12 uncached + 1500 cache reads + 3 cache writes.
64
- assert span["usage"] == {"input_tokens": 1515, "output_tokens": 9, "cached_tokens": 1500}
65
- assert span["attributes"]["usage.cache_creation_input_tokens"] == 3
66
+ # No TTL split was reported, so cache_write_1h_tokens is omitted (all writes 5-minute).
67
+ assert span["usage"] == {
68
+ "input_tokens": 1515,
69
+ "output_tokens": 9,
70
+ "cached_tokens": 1500,
71
+ "cache_write_tokens": 3,
72
+ }
73
+ assert "usage.cache_creation_input_tokens" not in span["attributes"]
66
74
  assert span["attributes"]["response.stop_reason"] == "end_turn"
67
75
  assert span["attributes"]["response.id"] == "msg_01XFDUDYJgAACzvnptvVoYEL"
68
76
  assert span["input"] == {
@@ -78,6 +86,41 @@ def test_messages_create_span_fields(tracer, ingest):
78
86
  assert API_KEY not in json.dumps(span)
79
87
 
80
88
 
89
+ # Real 2026-10-14 pilot usage: 19 uncached + 0 reads + 6404 writes, all 1-hour TTL.
90
+ CACHE_WRITE_USAGE = {
91
+ "input_tokens": 6423,
92
+ "output_tokens": 12,
93
+ "cached_tokens": 0,
94
+ "cache_write_tokens": 6404,
95
+ "cache_write_1h_tokens": 6404,
96
+ }
97
+
98
+
99
+ def test_create_records_cache_write_fields(tracer, ingest):
100
+ provider = MockProvider(json_response(ANTHROPIC_CACHE_WRITE_MESSAGE))
101
+ client = make_client(provider, tracer)
102
+
103
+ message = client.messages.create(model=MODEL, max_tokens=256, messages=MESSAGES)
104
+
105
+ assert message.usage.cache_creation.ephemeral_1h_input_tokens == 6404
106
+ span = flushed_spans(tracer, ingest)[0]
107
+ assert span["usage"] == CACHE_WRITE_USAGE
108
+ assert "usage.cache_creation_input_tokens" not in span["attributes"]
109
+
110
+
111
+ def test_stream_cache_write_fields(tracer, ingest):
112
+ provider = MockProvider(sse_response(anthropic_cache_write_stream()))
113
+ client = make_client(provider, tracer)
114
+
115
+ with client.messages.stream(model=MODEL, max_tokens=256, messages=MESSAGES) as stream:
116
+ streamed_text = "".join(stream.text_stream)
117
+
118
+ assert streamed_text == "Hi, how can I help?"
119
+ span = flushed_spans(tracer, ingest)[0]
120
+ assert span["usage"] == CACHE_WRITE_USAGE
121
+ assert "usage.cache_creation_input_tokens" not in span["attributes"]
122
+
123
+
81
124
  async def test_async_messages_create(tracer, ingest):
82
125
  provider = MockProvider(json_response(ANTHROPIC_MESSAGE))
83
126
  client = make_async_client(provider, tracer)
@@ -105,7 +148,12 @@ def test_create_with_stream_true(tracer, ingest):
105
148
  span = flushed_spans(tracer, ingest)[0]
106
149
  assert span["status"] == "ok"
107
150
  assert span["model"] == "claude-haiku-4-5-20251001"
108
- assert span["usage"] == {"input_tokens": 25, "output_tokens": 15, "cached_tokens": 0}
151
+ assert span["usage"] == {
152
+ "input_tokens": 25,
153
+ "output_tokens": 15,
154
+ "cached_tokens": 0,
155
+ "cache_write_tokens": 0,
156
+ }
109
157
  assert span["time_to_first_token_ms"] is not None
110
158
  assert span["output"] == {
111
159
  "role": "assistant",
@@ -142,7 +190,12 @@ def test_messages_stream_helper(tracer, ingest):
142
190
  assert final.usage.output_tokens == 15
143
191
  span = flushed_spans(tracer, ingest)[0]
144
192
  assert span["name"] == f"chat {MODEL}"
145
- assert span["usage"] == {"input_tokens": 25, "output_tokens": 15, "cached_tokens": 0}
193
+ assert span["usage"] == {
194
+ "input_tokens": 25,
195
+ "output_tokens": 15,
196
+ "cached_tokens": 0,
197
+ "cache_write_tokens": 0,
198
+ }
146
199
  assert span["time_to_first_token_ms"] is not None
147
200
  assert span["output"]["content"] == [{"type": "text", "text": "Hi, how can I help?"}]
148
201
  assert span["input"]["stream"] is True
@@ -38,6 +38,36 @@ def test_span_setters_are_exported(tracer, ingest):
38
38
  assert exported["attributes"] == {"temperature": 0.2, "attempt": 1}
39
39
 
40
40
 
41
+ def test_set_usage_omits_unset_write_fields(tracer, ingest):
42
+ with tracer.span("reads-only", kind="llm") as span:
43
+ span.set_usage(input_tokens=120, output_tokens=30, cached_tokens=100)
44
+ with tracer.span("writes-5m", kind="llm") as span:
45
+ span.set_usage(input_tokens=120, output_tokens=30, cache_write_tokens=20)
46
+ with tracer.span("writes-split", kind="llm") as span:
47
+ span.set_usage(
48
+ input_tokens=120,
49
+ output_tokens=30,
50
+ cached_tokens=50,
51
+ cache_write_tokens=20,
52
+ cache_write_1h_tokens=5,
53
+ )
54
+
55
+ by_name = {item["name"]: item["usage"] for item in flushed_spans(tracer, ingest)}
56
+ assert by_name["reads-only"] == {"input_tokens": 120, "output_tokens": 30, "cached_tokens": 100}
57
+ assert by_name["writes-5m"] == {
58
+ "input_tokens": 120,
59
+ "output_tokens": 30,
60
+ "cache_write_tokens": 20,
61
+ }
62
+ assert by_name["writes-split"] == {
63
+ "input_tokens": 120,
64
+ "output_tokens": 30,
65
+ "cached_tokens": 50,
66
+ "cache_write_tokens": 20,
67
+ "cache_write_1h_tokens": 5,
68
+ }
69
+
70
+
41
71
  def test_span_records_error_and_reraises(tracer, ingest):
42
72
  with pytest.raises(ZeroDivisionError), tracer.span("divide"):
43
73
  _ = 1 / 0
@@ -838,7 +838,7 @@ wheels = [
838
838
 
839
839
  [[package]]
840
840
  name = "spanlight"
841
- version = "0.1.1"
841
+ version = "0.1.2"
842
842
  source = { editable = "." }
843
843
  dependencies = [
844
844
  { name = "httpx" },
File without changes
File without changes
File without changes
File without changes