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.
- {spanlight-0.1.1 → spanlight-0.1.2}/PKG-INFO +16 -13
- {spanlight-0.1.1 → spanlight-0.1.2}/README.md +15 -12
- {spanlight-0.1.1 → spanlight-0.1.2}/pyproject.toml +1 -1
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_span.py +18 -2
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_version.py +1 -1
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/integrations/anthropic.py +24 -5
- {spanlight-0.1.1 → spanlight-0.1.2}/tests/provider_mocks.py +40 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/tests/test_anthropic.py +57 -4
- {spanlight-0.1.1 → spanlight-0.1.2}/tests/test_spans_and_context.py +30 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/uv.lock +1 -1
- {spanlight-0.1.1 → spanlight-0.1.2}/.gitignore +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/LICENSE +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/examples/support_bot.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/__init__.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_client.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_config.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_context.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_decorator.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_experiments.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_experiments_api.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_experiments_parse.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_experiments_report.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_experiments_runs.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_experiments_setup.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_exporter.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_exporter_delivery.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_globals.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_ids.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_prompt_client.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_prompts.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_request_hash.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_scores.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_serialize.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/_templates.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/integrations/__init__.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/integrations/_common.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/integrations/openai.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/src/spanlight/py.typed +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/tests/conftest.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/tests/fixtures/template_cases.json +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/tests/support.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/tests/test_decorator.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/tests/test_docs_snippets.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/tests/test_experiments_report.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/tests/test_exporter.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/tests/test_exporter_retry_after.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/tests/test_openai.py +0 -0
- {spanlight-0.1.1 → spanlight-0.1.2}/tests/test_package.py +0 -0
- {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.
|
|
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
|
|
298
|
-
|
|
|
299
|
-
| `provider`
|
|
300
|
-
| `model`
|
|
301
|
-
| `usage.input_tokens`
|
|
302
|
-
| `usage.output_tokens`
|
|
303
|
-
| `usage.cached_tokens`
|
|
304
|
-
| `
|
|
305
|
-
| `
|
|
306
|
-
| `
|
|
307
|
-
| `
|
|
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
|
|
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
|
|
271
|
-
|
|
|
272
|
-
| `provider`
|
|
273
|
-
| `model`
|
|
274
|
-
| `usage.input_tokens`
|
|
275
|
-
| `usage.output_tokens`
|
|
276
|
-
| `usage.cached_tokens`
|
|
277
|
-
| `
|
|
278
|
-
| `
|
|
279
|
-
| `
|
|
280
|
-
| `
|
|
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
|
|
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.
|
|
@@ -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
|
|
213
|
-
|
|
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}
|
|
@@ -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)
|
|
11
|
-
|
|
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
|
-
|
|
65
|
-
assert span["
|
|
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"] == {
|
|
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"] == {
|
|
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
|
|
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
|
|
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
|