spanlight 0.1.2__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {spanlight-0.1.2 → spanlight-0.2.0}/PKG-INFO +121 -5
- {spanlight-0.1.2 → spanlight-0.2.0}/README.md +120 -4
- {spanlight-0.1.2 → spanlight-0.2.0}/pyproject.toml +1 -1
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/__init__.py +2 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/_client.py +15 -5
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/_decorator.py +25 -4
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/_experiments_report.py +47 -1
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/_exporter.py +27 -2
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/_exporter_delivery.py +27 -10
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/_globals.py +1 -1
- spanlight-0.2.0/src/spanlight/_metadata.py +110 -0
- spanlight-0.2.0/src/spanlight/_ratelimit_headers.py +101 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/_serialize.py +26 -16
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/_span.py +18 -116
- spanlight-0.2.0/src/spanlight/_tiers.py +94 -0
- spanlight-0.2.0/src/spanlight/_trace_state.py +83 -0
- spanlight-0.2.0/src/spanlight/_usage.py +123 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/_version.py +1 -1
- spanlight-0.2.0/src/spanlight/integrations/_anthropic_batch.py +135 -0
- spanlight-0.2.0/src/spanlight/integrations/_anthropic_message.py +66 -0
- spanlight-0.1.2/src/spanlight/integrations/anthropic.py → spanlight-0.2.0/src/spanlight/integrations/_anthropic_stream.py +46 -161
- spanlight-0.2.0/src/spanlight/integrations/_batch.py +167 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/integrations/_common.py +35 -210
- spanlight-0.2.0/src/spanlight/integrations/_modifiers.py +64 -0
- spanlight-0.2.0/src/spanlight/integrations/_openai_batch.py +181 -0
- spanlight-0.2.0/src/spanlight/integrations/_openai_response.py +108 -0
- spanlight-0.2.0/src/spanlight/integrations/_openai_stream.py +190 -0
- spanlight-0.2.0/src/spanlight/integrations/_provider_errors.py +202 -0
- spanlight-0.2.0/src/spanlight/integrations/_streams.py +188 -0
- spanlight-0.2.0/src/spanlight/integrations/_wrappers.py +91 -0
- spanlight-0.2.0/src/spanlight/integrations/anthropic.py +122 -0
- spanlight-0.2.0/src/spanlight/integrations/openai.py +189 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/tests/provider_mocks.py +127 -11
- {spanlight-0.1.2 → spanlight-0.2.0}/tests/test_anthropic.py +166 -0
- spanlight-0.2.0/tests/test_anthropic_batch_results.py +235 -0
- spanlight-0.2.0/tests/test_anthropic_cut_stream.py +214 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/tests/test_exporter.py +21 -7
- spanlight-0.2.0/tests/test_exporter_counters.py +163 -0
- spanlight-0.2.0/tests/test_gateway_refusal.py +193 -0
- spanlight-0.2.0/tests/test_metadata.py +86 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/tests/test_openai.py +70 -5
- spanlight-0.2.0/tests/test_openai_batch_output.py +250 -0
- spanlight-0.2.0/tests/test_openai_tier_and_search.py +257 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/tests/test_package.py +5 -0
- spanlight-0.2.0/tests/test_provider_errors.py +350 -0
- spanlight-0.2.0/tests/test_ratelimit_allowlist.py +103 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/tests/test_spans_and_context.py +106 -0
- spanlight-0.2.0/tests/test_tiers.py +62 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/uv.lock +1 -1
- spanlight-0.1.2/src/spanlight/integrations/openai.py +0 -356
- {spanlight-0.1.2 → spanlight-0.2.0}/.gitignore +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/LICENSE +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/examples/support_bot.py +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/_config.py +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/_context.py +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/_experiments.py +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/_experiments_api.py +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/_experiments_parse.py +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/_experiments_runs.py +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/_experiments_setup.py +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/_ids.py +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/_prompt_client.py +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/_prompts.py +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/_request_hash.py +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/_scores.py +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/_templates.py +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/integrations/__init__.py +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/src/spanlight/py.typed +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/tests/conftest.py +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/tests/fixtures/template_cases.json +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/tests/support.py +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/tests/test_decorator.py +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/tests/test_docs_snippets.py +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/tests/test_experiments_report.py +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/tests/test_exporter_retry_after.py +0 -0
- {spanlight-0.1.2 → spanlight-0.2.0}/tests/test_templates_surrogates.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: spanlight
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python SDK for Spanlight, open-source observability for LLM applications.
|
|
5
5
|
Author: Dheeraj
|
|
6
6
|
License-Expression: MIT
|
|
@@ -124,7 +124,8 @@ def answer(question: str) -> str:
|
|
|
124
124
|
|
|
125
125
|
`wrap_openai` instruments `chat.completions.create` and `responses.create` on
|
|
126
126
|
the client it is given. It works with `OpenAI`, `AsyncOpenAI` and the Azure
|
|
127
|
-
variants
|
|
127
|
+
variants; an Azure client is recorded with the provider `azure_openai`, which the
|
|
128
|
+
server does not price at OpenAI's rates (add a price override for it). The SDK's own `.stream()` helpers are traced as well, because they
|
|
128
129
|
call `create` internally.
|
|
129
130
|
|
|
130
131
|
Streaming works without any changes to your code. Usage is recorded when
|
|
@@ -145,6 +146,56 @@ The span ends when the stream is exhausted or closed, and records
|
|
|
145
146
|
time-to-first-token. If you point the OpenAI SDK at a compatible API, set the
|
|
146
147
|
provider name explicitly: `wrap_openai(client, provider="openrouter")`.
|
|
147
148
|
|
|
149
|
+
When a stream carries no usage, the span's output token count is left unknown
|
|
150
|
+
(never guessed) and the `usage.unreported_reason` attribute says why:
|
|
151
|
+
`include_usage_not_set` when a Chat Completions stream ended without a usage
|
|
152
|
+
chunk, `no_final_usage` when a Responses stream ended without its terminal
|
|
153
|
+
event, `client_disconnected` when your code closed it early, and
|
|
154
|
+
`stream_error` when it failed. Such a call's cost shows as unknown.
|
|
155
|
+
|
|
156
|
+
### Service tier and web search
|
|
157
|
+
|
|
158
|
+
- The `service_tier` OpenAI answers with (on the response, or on the last
|
|
159
|
+
stream chunk or terminal event) is recorded as `standard`, `flex`, `priority`
|
|
160
|
+
or `batch`; `default`, `auto` and `scale` are `standard`. A value the SDK does
|
|
161
|
+
not know is kept in the `usage.service_tier_raw` attribute and the call is
|
|
162
|
+
priced at the standard rate.
|
|
163
|
+
- For the Responses API, every completed `web_search_call` output item counts
|
|
164
|
+
as one billed web search; searches still in progress or failed are not
|
|
165
|
+
counted.
|
|
166
|
+
- Chat Completions with `web_search_options` report sources as message
|
|
167
|
+
annotations, which are not a count of searches. No count is recorded and the
|
|
168
|
+
span gets `usage.fee_unknown = "openai_chat_web_search"`, so the search fee
|
|
169
|
+
shows as unknown rather than zero.
|
|
170
|
+
- Requests that set `inference_geo` (other than `global`) or `speed: "fast"`
|
|
171
|
+
are priced at the standard rate and tagged with
|
|
172
|
+
`usage.pricing_modifier_unknown`, because those modifiers are not modelled.
|
|
173
|
+
|
|
174
|
+
### Batch output
|
|
175
|
+
|
|
176
|
+
OpenAI Batch results are a file you download, so record them explicitly once
|
|
177
|
+
the batch has completed:
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
batch = client.batches.retrieve("batch_67ccd2bed1ec8190b14f964abc054267")
|
|
181
|
+
content = client.files.content(batch.output_file_id)
|
|
182
|
+
recorded = spanlight.record_openai_batch_output(content.iter_lines(), batch_id=batch.id)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Pass the error file (`batch.error_file_id`) the same way to record failed
|
|
186
|
+
requests. Each line becomes one `llm` span with the shape described under
|
|
187
|
+
[Anthropic batch results](#batch-results): `service_tier` is `batch` (the tier
|
|
188
|
+
the body reported is kept in `usage.service_tier_raw`), cached tokens come from
|
|
189
|
+
the usage details, and a line whose `status_code` is not 200 or that carries an
|
|
190
|
+
`error` becomes an error span without usage. A Responses body with
|
|
191
|
+
`status: "failed"` becomes an error span that keeps the usage OpenAI reports
|
|
192
|
+
(it is billed), and `status: "incomplete"` a normal span whose finish reason is
|
|
193
|
+
`incomplete_details.reason`. Chat Completions and Responses
|
|
194
|
+
bodies are both read. The function accepts any iterable of `str` or `bytes`
|
|
195
|
+
lines (or the whole file as one string), returns the number of spans recorded,
|
|
196
|
+
and never raises: unreadable lines are skipped. Use `provider=` for a
|
|
197
|
+
compatible API and `spanlight=` for a client other than the global one.
|
|
198
|
+
|
|
148
199
|
## Anthropic
|
|
149
200
|
|
|
150
201
|
```python
|
|
@@ -173,6 +224,33 @@ with client.messages.stream(
|
|
|
173
224
|
`wrap_anthropic` instruments `messages.create` (including `stream=True`) and
|
|
174
225
|
`messages.stream` on both `Anthropic` and `AsyncAnthropic`.
|
|
175
226
|
|
|
227
|
+
### Batch results
|
|
228
|
+
|
|
229
|
+
Results read through `messages.batches.results` are recorded as you iterate
|
|
230
|
+
them, on both `Anthropic` and `AsyncAnthropic`:
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
for item in client.messages.batches.results("msgbatch_01HkcTjaV5uDC8jWR4ZsDV8d"):
|
|
234
|
+
handle(item) # each item is yielded unchanged
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Each result becomes one `llm` span named `batch <model>`, in a trace named
|
|
238
|
+
`batch <batch_id>` and tagged `spanlight:batch`:
|
|
239
|
+
|
|
240
|
+
- A `succeeded` result carries the model, token usage, finish reason and the
|
|
241
|
+
response content, with `service_tier` set to `batch` (the Batches API bills
|
|
242
|
+
every request at the batch rate). The tier the response reported is kept in
|
|
243
|
+
the `usage.service_tier_raw` attribute.
|
|
244
|
+
- `errored`, `canceled` and `expired` results are error spans without usage,
|
|
245
|
+
because they are not billed.
|
|
246
|
+
- The request is not part of a result, so the span has no input
|
|
247
|
+
(`batch.request_available` is `false`). Its start and end are both the
|
|
248
|
+
moment the result was read (`timing.synthetic` is `true`).
|
|
249
|
+
- The attributes `batch.id`, `batch.custom_id` and `batch.result_type` link
|
|
250
|
+
the span back to the batch.
|
|
251
|
+
- Trace and span ids are derived from the batch id and `custom_id`, so reading
|
|
252
|
+
the same batch again updates the same spans instead of adding new ones.
|
|
253
|
+
|
|
176
254
|
## Prompts
|
|
177
255
|
|
|
178
256
|
Keep prompts in Spanlight and fetch them by label, so a new version ships
|
|
@@ -290,6 +368,16 @@ host must be `https://` unless it is localhost. Each
|
|
|
290
368
|
run makes real provider calls. In a GitHub workflow use the
|
|
291
369
|
`.github/actions/eval-gate` Action instead of calling the module yourself.
|
|
292
370
|
|
|
371
|
+
The run prints a Markdown report (also appended to the GitHub step summary): the
|
|
372
|
+
run's items, errors, cost and latency, then one row per score with its mean, the
|
|
373
|
+
95% confidence interval on the pass rate of a boolean score (for example
|
|
374
|
+
`64–88%`; `—` for numeric and categorical scores, and from servers that predate
|
|
375
|
+
intervals), the number of items, the threshold and the result. When a threshold
|
|
376
|
+
lies inside a boolean score's interval, the report adds "Interval includes the
|
|
377
|
+
threshold; the result is not conclusive at this sample size." The gate itself
|
|
378
|
+
still compares the mean with the threshold, so the exit codes do not depend on
|
|
379
|
+
the interval.
|
|
380
|
+
|
|
293
381
|
## What gets recorded
|
|
294
382
|
|
|
295
383
|
For every LLM call:
|
|
@@ -308,6 +396,22 @@ For every LLM call:
|
|
|
308
396
|
| `output` | The assistant message, including tool calls |
|
|
309
397
|
| `status` / `status_message` | `error` plus the provider's message when the call fails |
|
|
310
398
|
|
|
399
|
+
When a provider call fails with an HTTP error, the span also carries the
|
|
400
|
+
provider's error type and code (`provider.error.type`, `provider.error.code`),
|
|
401
|
+
the wait it asked for in seconds (`provider.retry_after_s`, from `retry-after-ms`
|
|
402
|
+
or a numeric `retry-after`), and `provider.ratelimit`: the response's rate-limit
|
|
403
|
+
headers, filtered through a fixed allowlist of 29 names. Only `retry-after`,
|
|
404
|
+
`retry-after-ms`, OpenAI's `x-ratelimit-*` headers and Anthropic's
|
|
405
|
+
`anthropic-ratelimit-*` and `anthropic-priority-*` headers are kept, each value
|
|
406
|
+
at most 64 printable ASCII characters. Every other response header, including
|
|
407
|
+
`authorization`, `x-api-key`, `cookie`, `set-cookie` and Anthropic's
|
|
408
|
+
organization and workspace ids, is dropped before the span leaves your process.
|
|
409
|
+
Connection errors and timeouts add none of these fields.
|
|
410
|
+
When the Spanlight gateway refused the call itself (its `X-Spanlight-Code`
|
|
411
|
+
header names one of its own codes, such as `RATE_LIMITED` for your gateway key's
|
|
412
|
+
limit), the span carries that code as `spanlight.gateway.error_code` instead, and
|
|
413
|
+
none of the `provider.*` fields above.
|
|
414
|
+
|
|
311
415
|
The Spanlight server also redacts common secret patterns from
|
|
312
416
|
payloads on ingestion. Set `capture_payloads = false` on a project to drop
|
|
313
417
|
inputs and outputs entirely. To keep payloads out of the SDK side for a
|
|
@@ -337,7 +441,7 @@ options. Use `tracer.observe`, `tracer.span` and
|
|
|
337
441
|
| `flush_interval` | | `2.0` | Seconds a span may wait before it is sent |
|
|
338
442
|
| `batch_size` | | `100` | Spans per request (max 1000) |
|
|
339
443
|
| `max_queue` | | `10000` | Buffered spans. When the buffer is full, the oldest spans are dropped. |
|
|
340
|
-
| `gzip` | | `
|
|
444
|
+
| `gzip` | | `True` | Gzip-compress request bodies |
|
|
341
445
|
| `timeout` | | `10.0` | HTTP timeout per request, in seconds |
|
|
342
446
|
| `max_retries` | | `5` | Retries for network errors, 429 and 5xx |
|
|
343
447
|
| `shutdown_timeout` | | `5.0` | Time budget for the flush at interpreter exit |
|
|
@@ -370,10 +474,14 @@ cached_tokens=, cache_write_tokens=, cache_write_1h_tokens=)`,
|
|
|
370
474
|
exception that escapes the block marks the span as failed and is re-raised
|
|
371
475
|
unchanged.
|
|
372
476
|
|
|
373
|
-
### `update_trace(name=None, user_id=None, session_id=None, tags=None)`
|
|
477
|
+
### `update_trace(name=None, user_id=None, session_id=None, tags=None, metadata=None)`
|
|
374
478
|
|
|
375
479
|
Sets trace-level fields on the trace of the current span. You can call it
|
|
376
|
-
anywhere inside the trace. Tags accumulate across calls.
|
|
480
|
+
anywhere inside the trace. Tags accumulate across calls. Metadata keys
|
|
481
|
+
accumulate too, and a later value replaces a key's earlier one:
|
|
482
|
+
`spanlight.update_trace(metadata={"customer_id": "acme", "feature": "search"})`.
|
|
483
|
+
Up to 32 keys matching `^[a-z0-9_.-]{1,64}$`, values of 1 to 256 characters
|
|
484
|
+
(numbers and booleans become text); an invalid entry is dropped with a debug log.
|
|
377
485
|
|
|
378
486
|
### `flush(timeout=None) -> bool` and `shutdown(timeout=None)`
|
|
379
487
|
|
|
@@ -407,6 +515,14 @@ logged to the `spanlight` logger, as are any spans the server rejects. If the
|
|
|
407
515
|
queue fills up, the oldest spans are dropped and counted
|
|
408
516
|
(`spanlight.get_client().exporter.dropped_count`).
|
|
409
517
|
|
|
518
|
+
Since 0.2.0, every batch of spans also carries a small `client` object with the
|
|
519
|
+
SDK name and version, a random `exporter_id` (new in each process, including a
|
|
520
|
+
forked child) and two running totals for that exporter: `dropped_total` (spans
|
|
521
|
+
and scores dropped because the queue was full) and `failed_total` (spans and
|
|
522
|
+
scores lost after retries or rejected by the server). The totals are cumulative,
|
|
523
|
+
so a batch delivered twice does not count a loss twice. It holds no span
|
|
524
|
+
content, and older Spanlight servers ignore it.
|
|
525
|
+
|
|
410
526
|
To see what the SDK is doing:
|
|
411
527
|
|
|
412
528
|
```python
|
|
@@ -97,7 +97,8 @@ def answer(question: str) -> str:
|
|
|
97
97
|
|
|
98
98
|
`wrap_openai` instruments `chat.completions.create` and `responses.create` on
|
|
99
99
|
the client it is given. It works with `OpenAI`, `AsyncOpenAI` and the Azure
|
|
100
|
-
variants
|
|
100
|
+
variants; an Azure client is recorded with the provider `azure_openai`, which the
|
|
101
|
+
server does not price at OpenAI's rates (add a price override for it). The SDK's own `.stream()` helpers are traced as well, because they
|
|
101
102
|
call `create` internally.
|
|
102
103
|
|
|
103
104
|
Streaming works without any changes to your code. Usage is recorded when
|
|
@@ -118,6 +119,56 @@ The span ends when the stream is exhausted or closed, and records
|
|
|
118
119
|
time-to-first-token. If you point the OpenAI SDK at a compatible API, set the
|
|
119
120
|
provider name explicitly: `wrap_openai(client, provider="openrouter")`.
|
|
120
121
|
|
|
122
|
+
When a stream carries no usage, the span's output token count is left unknown
|
|
123
|
+
(never guessed) and the `usage.unreported_reason` attribute says why:
|
|
124
|
+
`include_usage_not_set` when a Chat Completions stream ended without a usage
|
|
125
|
+
chunk, `no_final_usage` when a Responses stream ended without its terminal
|
|
126
|
+
event, `client_disconnected` when your code closed it early, and
|
|
127
|
+
`stream_error` when it failed. Such a call's cost shows as unknown.
|
|
128
|
+
|
|
129
|
+
### Service tier and web search
|
|
130
|
+
|
|
131
|
+
- The `service_tier` OpenAI answers with (on the response, or on the last
|
|
132
|
+
stream chunk or terminal event) is recorded as `standard`, `flex`, `priority`
|
|
133
|
+
or `batch`; `default`, `auto` and `scale` are `standard`. A value the SDK does
|
|
134
|
+
not know is kept in the `usage.service_tier_raw` attribute and the call is
|
|
135
|
+
priced at the standard rate.
|
|
136
|
+
- For the Responses API, every completed `web_search_call` output item counts
|
|
137
|
+
as one billed web search; searches still in progress or failed are not
|
|
138
|
+
counted.
|
|
139
|
+
- Chat Completions with `web_search_options` report sources as message
|
|
140
|
+
annotations, which are not a count of searches. No count is recorded and the
|
|
141
|
+
span gets `usage.fee_unknown = "openai_chat_web_search"`, so the search fee
|
|
142
|
+
shows as unknown rather than zero.
|
|
143
|
+
- Requests that set `inference_geo` (other than `global`) or `speed: "fast"`
|
|
144
|
+
are priced at the standard rate and tagged with
|
|
145
|
+
`usage.pricing_modifier_unknown`, because those modifiers are not modelled.
|
|
146
|
+
|
|
147
|
+
### Batch output
|
|
148
|
+
|
|
149
|
+
OpenAI Batch results are a file you download, so record them explicitly once
|
|
150
|
+
the batch has completed:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
batch = client.batches.retrieve("batch_67ccd2bed1ec8190b14f964abc054267")
|
|
154
|
+
content = client.files.content(batch.output_file_id)
|
|
155
|
+
recorded = spanlight.record_openai_batch_output(content.iter_lines(), batch_id=batch.id)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Pass the error file (`batch.error_file_id`) the same way to record failed
|
|
159
|
+
requests. Each line becomes one `llm` span with the shape described under
|
|
160
|
+
[Anthropic batch results](#batch-results): `service_tier` is `batch` (the tier
|
|
161
|
+
the body reported is kept in `usage.service_tier_raw`), cached tokens come from
|
|
162
|
+
the usage details, and a line whose `status_code` is not 200 or that carries an
|
|
163
|
+
`error` becomes an error span without usage. A Responses body with
|
|
164
|
+
`status: "failed"` becomes an error span that keeps the usage OpenAI reports
|
|
165
|
+
(it is billed), and `status: "incomplete"` a normal span whose finish reason is
|
|
166
|
+
`incomplete_details.reason`. Chat Completions and Responses
|
|
167
|
+
bodies are both read. The function accepts any iterable of `str` or `bytes`
|
|
168
|
+
lines (or the whole file as one string), returns the number of spans recorded,
|
|
169
|
+
and never raises: unreadable lines are skipped. Use `provider=` for a
|
|
170
|
+
compatible API and `spanlight=` for a client other than the global one.
|
|
171
|
+
|
|
121
172
|
## Anthropic
|
|
122
173
|
|
|
123
174
|
```python
|
|
@@ -146,6 +197,33 @@ with client.messages.stream(
|
|
|
146
197
|
`wrap_anthropic` instruments `messages.create` (including `stream=True`) and
|
|
147
198
|
`messages.stream` on both `Anthropic` and `AsyncAnthropic`.
|
|
148
199
|
|
|
200
|
+
### Batch results
|
|
201
|
+
|
|
202
|
+
Results read through `messages.batches.results` are recorded as you iterate
|
|
203
|
+
them, on both `Anthropic` and `AsyncAnthropic`:
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
for item in client.messages.batches.results("msgbatch_01HkcTjaV5uDC8jWR4ZsDV8d"):
|
|
207
|
+
handle(item) # each item is yielded unchanged
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Each result becomes one `llm` span named `batch <model>`, in a trace named
|
|
211
|
+
`batch <batch_id>` and tagged `spanlight:batch`:
|
|
212
|
+
|
|
213
|
+
- A `succeeded` result carries the model, token usage, finish reason and the
|
|
214
|
+
response content, with `service_tier` set to `batch` (the Batches API bills
|
|
215
|
+
every request at the batch rate). The tier the response reported is kept in
|
|
216
|
+
the `usage.service_tier_raw` attribute.
|
|
217
|
+
- `errored`, `canceled` and `expired` results are error spans without usage,
|
|
218
|
+
because they are not billed.
|
|
219
|
+
- The request is not part of a result, so the span has no input
|
|
220
|
+
(`batch.request_available` is `false`). Its start and end are both the
|
|
221
|
+
moment the result was read (`timing.synthetic` is `true`).
|
|
222
|
+
- The attributes `batch.id`, `batch.custom_id` and `batch.result_type` link
|
|
223
|
+
the span back to the batch.
|
|
224
|
+
- Trace and span ids are derived from the batch id and `custom_id`, so reading
|
|
225
|
+
the same batch again updates the same spans instead of adding new ones.
|
|
226
|
+
|
|
149
227
|
## Prompts
|
|
150
228
|
|
|
151
229
|
Keep prompts in Spanlight and fetch them by label, so a new version ships
|
|
@@ -263,6 +341,16 @@ host must be `https://` unless it is localhost. Each
|
|
|
263
341
|
run makes real provider calls. In a GitHub workflow use the
|
|
264
342
|
`.github/actions/eval-gate` Action instead of calling the module yourself.
|
|
265
343
|
|
|
344
|
+
The run prints a Markdown report (also appended to the GitHub step summary): the
|
|
345
|
+
run's items, errors, cost and latency, then one row per score with its mean, the
|
|
346
|
+
95% confidence interval on the pass rate of a boolean score (for example
|
|
347
|
+
`64–88%`; `—` for numeric and categorical scores, and from servers that predate
|
|
348
|
+
intervals), the number of items, the threshold and the result. When a threshold
|
|
349
|
+
lies inside a boolean score's interval, the report adds "Interval includes the
|
|
350
|
+
threshold; the result is not conclusive at this sample size." The gate itself
|
|
351
|
+
still compares the mean with the threshold, so the exit codes do not depend on
|
|
352
|
+
the interval.
|
|
353
|
+
|
|
266
354
|
## What gets recorded
|
|
267
355
|
|
|
268
356
|
For every LLM call:
|
|
@@ -281,6 +369,22 @@ For every LLM call:
|
|
|
281
369
|
| `output` | The assistant message, including tool calls |
|
|
282
370
|
| `status` / `status_message` | `error` plus the provider's message when the call fails |
|
|
283
371
|
|
|
372
|
+
When a provider call fails with an HTTP error, the span also carries the
|
|
373
|
+
provider's error type and code (`provider.error.type`, `provider.error.code`),
|
|
374
|
+
the wait it asked for in seconds (`provider.retry_after_s`, from `retry-after-ms`
|
|
375
|
+
or a numeric `retry-after`), and `provider.ratelimit`: the response's rate-limit
|
|
376
|
+
headers, filtered through a fixed allowlist of 29 names. Only `retry-after`,
|
|
377
|
+
`retry-after-ms`, OpenAI's `x-ratelimit-*` headers and Anthropic's
|
|
378
|
+
`anthropic-ratelimit-*` and `anthropic-priority-*` headers are kept, each value
|
|
379
|
+
at most 64 printable ASCII characters. Every other response header, including
|
|
380
|
+
`authorization`, `x-api-key`, `cookie`, `set-cookie` and Anthropic's
|
|
381
|
+
organization and workspace ids, is dropped before the span leaves your process.
|
|
382
|
+
Connection errors and timeouts add none of these fields.
|
|
383
|
+
When the Spanlight gateway refused the call itself (its `X-Spanlight-Code`
|
|
384
|
+
header names one of its own codes, such as `RATE_LIMITED` for your gateway key's
|
|
385
|
+
limit), the span carries that code as `spanlight.gateway.error_code` instead, and
|
|
386
|
+
none of the `provider.*` fields above.
|
|
387
|
+
|
|
284
388
|
The Spanlight server also redacts common secret patterns from
|
|
285
389
|
payloads on ingestion. Set `capture_payloads = false` on a project to drop
|
|
286
390
|
inputs and outputs entirely. To keep payloads out of the SDK side for a
|
|
@@ -310,7 +414,7 @@ options. Use `tracer.observe`, `tracer.span` and
|
|
|
310
414
|
| `flush_interval` | | `2.0` | Seconds a span may wait before it is sent |
|
|
311
415
|
| `batch_size` | | `100` | Spans per request (max 1000) |
|
|
312
416
|
| `max_queue` | | `10000` | Buffered spans. When the buffer is full, the oldest spans are dropped. |
|
|
313
|
-
| `gzip` | | `
|
|
417
|
+
| `gzip` | | `True` | Gzip-compress request bodies |
|
|
314
418
|
| `timeout` | | `10.0` | HTTP timeout per request, in seconds |
|
|
315
419
|
| `max_retries` | | `5` | Retries for network errors, 429 and 5xx |
|
|
316
420
|
| `shutdown_timeout` | | `5.0` | Time budget for the flush at interpreter exit |
|
|
@@ -343,10 +447,14 @@ cached_tokens=, cache_write_tokens=, cache_write_1h_tokens=)`,
|
|
|
343
447
|
exception that escapes the block marks the span as failed and is re-raised
|
|
344
448
|
unchanged.
|
|
345
449
|
|
|
346
|
-
### `update_trace(name=None, user_id=None, session_id=None, tags=None)`
|
|
450
|
+
### `update_trace(name=None, user_id=None, session_id=None, tags=None, metadata=None)`
|
|
347
451
|
|
|
348
452
|
Sets trace-level fields on the trace of the current span. You can call it
|
|
349
|
-
anywhere inside the trace. Tags accumulate across calls.
|
|
453
|
+
anywhere inside the trace. Tags accumulate across calls. Metadata keys
|
|
454
|
+
accumulate too, and a later value replaces a key's earlier one:
|
|
455
|
+
`spanlight.update_trace(metadata={"customer_id": "acme", "feature": "search"})`.
|
|
456
|
+
Up to 32 keys matching `^[a-z0-9_.-]{1,64}$`, values of 1 to 256 characters
|
|
457
|
+
(numbers and booleans become text); an invalid entry is dropped with a debug log.
|
|
350
458
|
|
|
351
459
|
### `flush(timeout=None) -> bool` and `shutdown(timeout=None)`
|
|
352
460
|
|
|
@@ -380,6 +488,14 @@ logged to the `spanlight` logger, as are any spans the server rejects. If the
|
|
|
380
488
|
queue fills up, the oldest spans are dropped and counted
|
|
381
489
|
(`spanlight.get_client().exporter.dropped_count`).
|
|
382
490
|
|
|
491
|
+
Since 0.2.0, every batch of spans also carries a small `client` object with the
|
|
492
|
+
SDK name and version, a random `exporter_id` (new in each process, including a
|
|
493
|
+
forked child) and two running totals for that exporter: `dropped_total` (spans
|
|
494
|
+
and scores dropped because the queue was full) and `failed_total` (spans and
|
|
495
|
+
scores lost after retries or rejected by the server). The totals are cumulative,
|
|
496
|
+
so a batch delivered twice does not count a loss twice. It holds no span
|
|
497
|
+
content, and older Spanlight servers ignore it.
|
|
498
|
+
|
|
383
499
|
To see what the SDK is doing:
|
|
384
500
|
|
|
385
501
|
```python
|
|
@@ -25,6 +25,7 @@ from ._prompts import CompiledPrompt, Prompt, PromptUnavailableError, no_prompt
|
|
|
25
25
|
from ._span import Span, SpanKind, SpanStatus
|
|
26
26
|
from ._templates import InvalidTemplateError, MissingVariablesError, TemplateTooLargeError
|
|
27
27
|
from ._version import __version__
|
|
28
|
+
from .integrations._openai_batch import record_openai_batch_output
|
|
28
29
|
from .integrations.anthropic import wrap_anthropic
|
|
29
30
|
from .integrations.openai import wrap_openai
|
|
30
31
|
|
|
@@ -50,6 +51,7 @@ __all__ = [
|
|
|
50
51
|
"no_prompt",
|
|
51
52
|
"observe",
|
|
52
53
|
"prompts",
|
|
54
|
+
"record_openai_batch_output",
|
|
53
55
|
"score",
|
|
54
56
|
"shutdown",
|
|
55
57
|
"span",
|
|
@@ -21,7 +21,8 @@ from ._context import (
|
|
|
21
21
|
from ._exporter import BatchExporter
|
|
22
22
|
from ._prompt_client import PromptClient
|
|
23
23
|
from ._scores import ScoreValue, build_score
|
|
24
|
-
from ._span import Span, SpanKind,
|
|
24
|
+
from ._span import Span, SpanKind, normalize_kind
|
|
25
|
+
from ._trace_state import TraceState
|
|
25
26
|
|
|
26
27
|
logger = logging.getLogger("spanlight")
|
|
27
28
|
|
|
@@ -60,7 +61,8 @@ class Spanlight:
|
|
|
60
61
|
flush_interval: Maximum seconds a span waits before it is sent.
|
|
61
62
|
batch_size: Maximum spans per request (capped at 1000).
|
|
62
63
|
max_queue: Maximum spans buffered in memory; oldest dropped first.
|
|
63
|
-
gzip:
|
|
64
|
+
gzip: Gzip-compress request bodies (default). Turn it off only for a proxy that
|
|
65
|
+
cannot forward a compressed body.
|
|
64
66
|
timeout: HTTP timeout per request, in seconds.
|
|
65
67
|
max_retries: Retries per batch for network errors, 429 and 5xx.
|
|
66
68
|
shutdown_timeout: Seconds allowed for the final flush at exit.
|
|
@@ -79,7 +81,7 @@ class Spanlight:
|
|
|
79
81
|
flush_interval: float = 2.0,
|
|
80
82
|
batch_size: int = 100,
|
|
81
83
|
max_queue: int = 10_000,
|
|
82
|
-
gzip: bool =
|
|
84
|
+
gzip: bool = True,
|
|
83
85
|
timeout: float = 10.0,
|
|
84
86
|
max_retries: int = 5,
|
|
85
87
|
shutdown_timeout: float = 5.0,
|
|
@@ -275,9 +277,12 @@ class Spanlight:
|
|
|
275
277
|
user_id: str | None = None,
|
|
276
278
|
session_id: str | None = None,
|
|
277
279
|
tags: Iterable[str] | None = None,
|
|
280
|
+
metadata: Mapping[str, object] | None = None,
|
|
278
281
|
) -> None:
|
|
279
282
|
"""Set fields on the current trace. See :func:`spanlight.update_trace`."""
|
|
280
|
-
update_trace(
|
|
283
|
+
update_trace(
|
|
284
|
+
name=name, user_id=user_id, session_id=session_id, tags=tags, metadata=metadata
|
|
285
|
+
)
|
|
281
286
|
|
|
282
287
|
# ------------------------------------------------------------------ #
|
|
283
288
|
# Scores
|
|
@@ -426,6 +431,7 @@ def update_trace(
|
|
|
426
431
|
user_id: str | None = None,
|
|
427
432
|
session_id: str | None = None,
|
|
428
433
|
tags: Iterable[str] | None = None,
|
|
434
|
+
metadata: Mapping[str, object] | None = None,
|
|
429
435
|
) -> None:
|
|
430
436
|
"""Attach trace-level fields to the trace of the current span.
|
|
431
437
|
|
|
@@ -437,13 +443,17 @@ def update_trace(
|
|
|
437
443
|
user_id: Your application's user identifier.
|
|
438
444
|
session_id: Groups traces into a conversation/session.
|
|
439
445
|
tags: Free-form labels for filtering.
|
|
446
|
+
metadata: Key-value pairs such as ``{"customer_id": "acme"}``; a later value
|
|
447
|
+
replaces a key's earlier one. Invalid entries are dropped with a debug log.
|
|
440
448
|
"""
|
|
441
449
|
span = get_current_span()
|
|
442
450
|
if span is None:
|
|
443
451
|
logger.debug("update_trace() called outside of a span; ignoring")
|
|
444
452
|
return
|
|
445
453
|
try:
|
|
446
|
-
span.trace.update(
|
|
454
|
+
span.trace.update(
|
|
455
|
+
name=name, user_id=user_id, session_id=session_id, tags=tags, metadata=metadata
|
|
456
|
+
)
|
|
447
457
|
except Exception:
|
|
448
458
|
logger.debug("update_trace() failed", exc_info=True)
|
|
449
459
|
|
|
@@ -196,7 +196,10 @@ def _traced_generator(
|
|
|
196
196
|
item = step()
|
|
197
197
|
except StopIteration as stop:
|
|
198
198
|
_finish(
|
|
199
|
-
span,
|
|
199
|
+
span,
|
|
200
|
+
output=collected.result(stop.value),
|
|
201
|
+
capture_output=options.capture_output,
|
|
202
|
+
output_cut=collected.truncated,
|
|
200
203
|
)
|
|
201
204
|
return stop.value
|
|
202
205
|
except BaseException as exc:
|
|
@@ -212,7 +215,12 @@ def _traced_generator(
|
|
|
212
215
|
try:
|
|
213
216
|
generator.close()
|
|
214
217
|
finally:
|
|
215
|
-
_finish(
|
|
218
|
+
_finish(
|
|
219
|
+
span,
|
|
220
|
+
output=collected.result(None),
|
|
221
|
+
capture_output=options.capture_output,
|
|
222
|
+
output_cut=collected.truncated,
|
|
223
|
+
)
|
|
216
224
|
raise
|
|
217
225
|
except BaseException as exc:
|
|
218
226
|
step = functools.partial(generator.throw, exc)
|
|
@@ -250,7 +258,12 @@ async def _traced_async_generator(
|
|
|
250
258
|
try:
|
|
251
259
|
item = await step()
|
|
252
260
|
except StopAsyncIteration:
|
|
253
|
-
_finish(
|
|
261
|
+
_finish(
|
|
262
|
+
span,
|
|
263
|
+
output=collected.result(None),
|
|
264
|
+
capture_output=options.capture_output,
|
|
265
|
+
output_cut=collected.truncated,
|
|
266
|
+
)
|
|
254
267
|
return
|
|
255
268
|
except BaseException as exc:
|
|
256
269
|
_finish(span, error=exc)
|
|
@@ -265,7 +278,12 @@ async def _traced_async_generator(
|
|
|
265
278
|
try:
|
|
266
279
|
await generator.aclose()
|
|
267
280
|
finally:
|
|
268
|
-
_finish(
|
|
281
|
+
_finish(
|
|
282
|
+
span,
|
|
283
|
+
output=collected.result(None),
|
|
284
|
+
capture_output=options.capture_output,
|
|
285
|
+
output_cut=collected.truncated,
|
|
286
|
+
)
|
|
269
287
|
raise
|
|
270
288
|
except BaseException as exc:
|
|
271
289
|
step = functools.partial(generator.athrow, exc)
|
|
@@ -305,6 +323,7 @@ def _finish(
|
|
|
305
323
|
*,
|
|
306
324
|
output: Any = None,
|
|
307
325
|
capture_output: bool = False,
|
|
326
|
+
output_cut: bool = False,
|
|
308
327
|
error: BaseException | None = None,
|
|
309
328
|
) -> None:
|
|
310
329
|
"""End ``span`` recording either its output or the error. Never raises."""
|
|
@@ -314,6 +333,8 @@ def _finish(
|
|
|
314
333
|
span.record_error(error)
|
|
315
334
|
elif capture_output:
|
|
316
335
|
span.set_output(output)
|
|
336
|
+
# The collector kept only the first MAX_ITEMS yielded items.
|
|
337
|
+
span._output_cut = span._output_cut or output_cut
|
|
317
338
|
span.end()
|
|
318
339
|
except Exception:
|
|
319
340
|
logger.debug("observe: failed to finish span", exc_info=True)
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import math
|
|
5
6
|
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
8
9
|
_PLACES = Decimal("0.000001")
|
|
9
10
|
_UNKNOWN = "—"
|
|
11
|
+
_INCONCLUSIVE = "Interval includes the threshold; the result is not conclusive at this sample size."
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
def fmt(value: Decimal) -> str:
|
|
@@ -22,6 +24,30 @@ def _mean(stat: Any) -> Decimal | None:
|
|
|
22
24
|
return Decimal(str(mean))
|
|
23
25
|
|
|
24
26
|
|
|
27
|
+
def _interval(stat: Any) -> tuple[Decimal, Decimal] | None:
|
|
28
|
+
"""The 95 % interval on a boolean score's pass rate.
|
|
29
|
+
|
|
30
|
+
``None`` for a numeric or categorical score, or from a server that predates intervals.
|
|
31
|
+
"""
|
|
32
|
+
if not isinstance(stat, dict):
|
|
33
|
+
return None
|
|
34
|
+
try:
|
|
35
|
+
low, high = Decimal(str(stat["ci_low"])), Decimal(str(stat["ci_high"]))
|
|
36
|
+
except (KeyError, InvalidOperation): # a missing key, or null
|
|
37
|
+
return None
|
|
38
|
+
if not (low.is_finite() and high.is_finite()): # "NaN" or "Infinity" would raise in floor
|
|
39
|
+
return None
|
|
40
|
+
return low, high
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _percents(interval: tuple[Decimal, Decimal] | None) -> str:
|
|
44
|
+
"""``64\u201388%``: the low bound floored, the high bound ceiled, so the range contains it."""
|
|
45
|
+
if interval is None:
|
|
46
|
+
return _UNKNOWN
|
|
47
|
+
low, high = interval
|
|
48
|
+
return f"{math.floor(low * 100)}\u2013{math.ceil(high * 100)}%"
|
|
49
|
+
|
|
50
|
+
|
|
25
51
|
def check_thresholds(totals: dict[str, Any], fail_below: dict[str, Decimal]) -> list[str]:
|
|
26
52
|
"""One line per score whose mean is below its threshold or that the run did not produce.
|
|
27
53
|
|
|
@@ -41,7 +67,10 @@ def check_thresholds(totals: dict[str, Any], fail_below: dict[str, Decimal]) ->
|
|
|
41
67
|
|
|
42
68
|
def _score_rows(totals: dict[str, Any], fail_below: dict[str, Decimal]) -> list[str]:
|
|
43
69
|
scores = totals.get("scores") or {}
|
|
44
|
-
rows = [
|
|
70
|
+
rows = [
|
|
71
|
+
"| Score | Mean | 95% CI | Items | Threshold | Result |",
|
|
72
|
+
"| --- | --- | --- | --- | --- | --- |",
|
|
73
|
+
]
|
|
45
74
|
for name in sorted({*scores, *fail_below}):
|
|
46
75
|
stat = scores.get(name)
|
|
47
76
|
mean = _mean(stat)
|
|
@@ -55,12 +84,27 @@ def _score_rows(totals: dict[str, Any], fail_below: dict[str, Decimal]) -> list[
|
|
|
55
84
|
result = "FAIL"
|
|
56
85
|
rows.append(
|
|
57
86
|
f"| {name} | {fmt(mean) if mean is not None else _UNKNOWN} "
|
|
87
|
+
f"| {_percents(_interval(stat))} "
|
|
58
88
|
f"| {count if count is not None else _UNKNOWN} "
|
|
59
89
|
f"| {'>= ' + fmt(threshold) if threshold is not None else _UNKNOWN} | {result} |"
|
|
60
90
|
)
|
|
61
91
|
return rows
|
|
62
92
|
|
|
63
93
|
|
|
94
|
+
def _inconclusive(totals: dict[str, Any], fail_below: dict[str, Decimal]) -> list[str]:
|
|
95
|
+
"""One line per gated boolean score whose threshold lies inside its interval.
|
|
96
|
+
|
|
97
|
+
The gate still compares the mean; this only says the verdict could flip with more items.
|
|
98
|
+
"""
|
|
99
|
+
scores = totals.get("scores") or {}
|
|
100
|
+
lines: list[str] = []
|
|
101
|
+
for name, threshold in sorted(fail_below.items()):
|
|
102
|
+
interval = _interval(scores.get(name))
|
|
103
|
+
if interval is not None and interval[0] <= threshold <= interval[1]:
|
|
104
|
+
lines.append(f"{name}: {_INCONCLUSIVE}")
|
|
105
|
+
return lines
|
|
106
|
+
|
|
107
|
+
|
|
64
108
|
def _number(value: Any) -> str:
|
|
65
109
|
return _UNKNOWN if value is None else str(value)
|
|
66
110
|
|
|
@@ -107,6 +151,8 @@ def render_markdown(
|
|
|
107
151
|
*_score_rows(totals, fail_below),
|
|
108
152
|
"",
|
|
109
153
|
]
|
|
154
|
+
if inconclusive := _inconclusive(totals, fail_below):
|
|
155
|
+
lines += [*inconclusive, ""]
|
|
110
156
|
if failures:
|
|
111
157
|
lines += ["Below threshold:", "", *[f"- {line}" for line in failures], ""]
|
|
112
158
|
return "\n".join(lines)
|