telemetry-dev 0.2.12__tar.gz → 0.3.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.
- {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/PKG-INFO +21 -12
- {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/README.md +20 -11
- {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/pyproject.toml +1 -1
- {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/pyproject.toml.orig +1 -1
- {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_config.py +1 -1
- {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_prompts.py +76 -9
- {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/LICENSE +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/__init__.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_capture.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_client.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_context.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_export.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_logs.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_metrics.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_observe.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_processor.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_semconv.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_serialize.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_spans.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/otel.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: telemetry-dev
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: telemetry.dev SDK for Python — OpenTelemetry-native GenAI tracing, logs, and metrics
|
|
5
5
|
Keywords: telemetry,opentelemetry,llm,genai,tracing,observability
|
|
6
6
|
Author: telemetry.dev
|
|
@@ -81,7 +81,7 @@ routed to the `on_error` hook / `telemetry_dev` logger — never raised into you
|
|
|
81
81
|
| Variable | Default | Purpose |
|
|
82
82
|
| --- | --- | --- |
|
|
83
83
|
| `TELEMETRY_DEV_API_KEY` | — | Ingest key (`td_live_...`). Absent = SDK is a no-op. |
|
|
84
|
-
| `TELEMETRY_DEV_BASE_URL` | `https://
|
|
84
|
+
| `TELEMETRY_DEV_BASE_URL` | `https://api.telemetry.dev` | Base URL for telemetry export and `get_prompt` (trailing slashes stripped). |
|
|
85
85
|
| `TELEMETRY_DEV_ENVIRONMENT` | `production` | Deployment environment label. |
|
|
86
86
|
| `OTEL_SERVICE_NAME` | `unknown_service` | Service name on every trace. |
|
|
87
87
|
| `OTEL_TRACES_SAMPLER` | `parentbased_always_on` | OTel trace sampling policy, which includes session roots. |
|
|
@@ -89,6 +89,10 @@ routed to the `on_error` hook / `telemetry_dev` logger — never raised into you
|
|
|
89
89
|
|
|
90
90
|
Explicit `init()` arguments take precedence over environment variables.
|
|
91
91
|
|
|
92
|
+
Earlier versions sent data to `https://ingest.telemetry.dev` by default. That hostname reaches the
|
|
93
|
+
same service and stays supported: if a firewall allows only it, set `TELEMETRY_DEV_BASE_URL` or
|
|
94
|
+
`base_url` to it.
|
|
95
|
+
|
|
92
96
|
Root spans remain independent by default. `telemetry_dev.init(session_mode="process")` groups
|
|
93
97
|
otherwise uncorrelated spans and logs using a fresh opaque UUID for each enabled SDK
|
|
94
98
|
initialization. Explicit and propagated session IDs take precedence, and valid parent trace
|
|
@@ -247,11 +251,13 @@ pinned = telemetry_dev.get_prompt("support/reply", version=13)
|
|
|
247
251
|
`timeout`. A response with a different status and no API error gives `http_<status>`, for
|
|
248
252
|
example `http_302` for a redirect. `get_prompt` does not send the request to the location of a
|
|
249
253
|
redirect, thus your key does not go to a different host. With no copy, the calls of the next 10
|
|
250
|
-
seconds (or the TTL, if it is shorter) get the same result without a fetch.
|
|
251
|
-
|
|
254
|
+
seconds (or the TTL, if it is shorter) get the same result without a fetch. After a 429 or 503
|
|
255
|
+
response with a `Retry-After` header, `get_prompt` does not fetch that prompt again before that
|
|
256
|
+
time (one hour at most). This is the one SDK function that can raise: your app cannot run
|
|
257
|
+
without its prompt.
|
|
252
258
|
- `get_prompt` uses the API key and base URL of an enabled `init()`, or `TELEMETRY_DEV_API_KEY`
|
|
253
259
|
and `TELEMETRY_DEV_BASE_URL`, or its own `api_key` and `base_url`. It reads the same host that
|
|
254
|
-
telemetry goes to, `https://
|
|
260
|
+
telemetry goes to, `https://api.telemetry.dev` by default. The key needs the read scope for
|
|
255
261
|
prompts.
|
|
256
262
|
- When a label sends part of the traffic to a second version, each session keeps one version: the
|
|
257
263
|
choice uses the session id of `propagate_attributes` (or of `session_mode="process"`). Without a
|
|
@@ -264,10 +270,13 @@ When input capture is on, `td.prompt.variables` holds the JSON of the variables
|
|
|
264
270
|
it, after `mask` and truncation. If threads or tasks that run at the same time share one prompt
|
|
265
271
|
object, call `compile()` in `with_prompt`, or call `get_prompt` in each thread or task.
|
|
266
272
|
`get_prompt` returns a new object from the cache. If you do not, a thread can record the variables
|
|
267
|
-
of a different thread.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
273
|
+
of a different thread. Inside one `with_prompt`, an asyncio task records the last `compile()` that
|
|
274
|
+
ran in it, or before it started in the code that started it; with none, it records the last
|
|
275
|
+
`compile()` in the block. A `compile()` in a worker thread, as with `asyncio.to_thread`, counts only
|
|
276
|
+
for code that has not compiled in its own task. Model calls are the spans of type `generation`
|
|
277
|
+
(operations `chat`, `text_completion`, and `generate_content`), including the spans of the provider
|
|
278
|
+
integrations: OpenAI, Anthropic, Amazon Bedrock, Google GenAI, LiteLLM, and OpenRouter. Spans from
|
|
279
|
+
other OpenTelemetry tracers do not get the attributes. Messages from `compile()` that you pass to a
|
|
271
280
|
model call outside `with_prompt` are not recorded.
|
|
272
281
|
|
|
273
282
|
## Auto-metrics
|
|
@@ -326,7 +335,7 @@ from telemetry_dev import TelemetrySpanProcessor
|
|
|
326
335
|
| Option | Default | Purpose |
|
|
327
336
|
| --- | --- | --- |
|
|
328
337
|
| `api_key` | `TELEMETRY_DEV_API_KEY` | Ingest key. Absent with no `span_exporter` = inert no-op. |
|
|
329
|
-
| `base_url` | `TELEMETRY_DEV_BASE_URL` or `https://
|
|
338
|
+
| `base_url` | `TELEMETRY_DEV_BASE_URL` or `https://api.telemetry.dev` | Base URL; trailing slashes are stripped. |
|
|
330
339
|
| `export_mode` | `"batched"` | `"batched"` or `"immediate"` span export. |
|
|
331
340
|
| `max_export_batch_size` | `64` | BatchSpanProcessor export batch size. |
|
|
332
341
|
| `schedule_delay_millis` | `1000` | BatchSpanProcessor schedule delay. |
|
|
@@ -394,7 +403,7 @@ from opentelemetry.sdk.trace import TracerProvider
|
|
|
394
403
|
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
395
404
|
|
|
396
405
|
exporter = OTLPSpanExporter(
|
|
397
|
-
endpoint="https://
|
|
406
|
+
endpoint="https://api.telemetry.dev/v1/traces",
|
|
398
407
|
headers={"Authorization": f"Bearer {os.environ['TELEMETRY_DEV_API_KEY']}"},
|
|
399
408
|
)
|
|
400
409
|
provider = TracerProvider()
|
|
@@ -402,7 +411,7 @@ provider.add_span_processor(BatchSpanProcessor(exporter))
|
|
|
402
411
|
trace.set_tracer_provider(provider)
|
|
403
412
|
```
|
|
404
413
|
|
|
405
|
-
The Claude API can also send server-side spans to telemetry.dev through a Console destination. Set the endpoint to `https://
|
|
414
|
+
The Claude API can also send server-side spans to telemetry.dev through a Console destination. Set the endpoint to `https://api.telemetry.dev`. Set the `Authorization` header to `Bearer <telemetry.dev API key>`. For more information, refer to [Claude Platform](https://telemetry.dev/docs/integrations/claude-platform).
|
|
406
415
|
|
|
407
416
|
When the SDK and the Claude API send the spans of a request to the same telemetry.dev project, telemetry.dev counts the tokens of the request one time, from the API span. The SDK span stays in the trace with no tokens. telemetry.dev matches the two spans by the request ID or the response ID, so the API span does not have to be a child of the SDK span. If the Console destination uses an API key that is scoped to one environment, its API spans take over only the SDK spans of that environment.
|
|
408
417
|
|
|
@@ -56,7 +56,7 @@ routed to the `on_error` hook / `telemetry_dev` logger — never raised into you
|
|
|
56
56
|
| Variable | Default | Purpose |
|
|
57
57
|
| --- | --- | --- |
|
|
58
58
|
| `TELEMETRY_DEV_API_KEY` | — | Ingest key (`td_live_...`). Absent = SDK is a no-op. |
|
|
59
|
-
| `TELEMETRY_DEV_BASE_URL` | `https://
|
|
59
|
+
| `TELEMETRY_DEV_BASE_URL` | `https://api.telemetry.dev` | Base URL for telemetry export and `get_prompt` (trailing slashes stripped). |
|
|
60
60
|
| `TELEMETRY_DEV_ENVIRONMENT` | `production` | Deployment environment label. |
|
|
61
61
|
| `OTEL_SERVICE_NAME` | `unknown_service` | Service name on every trace. |
|
|
62
62
|
| `OTEL_TRACES_SAMPLER` | `parentbased_always_on` | OTel trace sampling policy, which includes session roots. |
|
|
@@ -64,6 +64,10 @@ routed to the `on_error` hook / `telemetry_dev` logger — never raised into you
|
|
|
64
64
|
|
|
65
65
|
Explicit `init()` arguments take precedence over environment variables.
|
|
66
66
|
|
|
67
|
+
Earlier versions sent data to `https://ingest.telemetry.dev` by default. That hostname reaches the
|
|
68
|
+
same service and stays supported: if a firewall allows only it, set `TELEMETRY_DEV_BASE_URL` or
|
|
69
|
+
`base_url` to it.
|
|
70
|
+
|
|
67
71
|
Root spans remain independent by default. `telemetry_dev.init(session_mode="process")` groups
|
|
68
72
|
otherwise uncorrelated spans and logs using a fresh opaque UUID for each enabled SDK
|
|
69
73
|
initialization. Explicit and propagated session IDs take precedence, and valid parent trace
|
|
@@ -222,11 +226,13 @@ pinned = telemetry_dev.get_prompt("support/reply", version=13)
|
|
|
222
226
|
`timeout`. A response with a different status and no API error gives `http_<status>`, for
|
|
223
227
|
example `http_302` for a redirect. `get_prompt` does not send the request to the location of a
|
|
224
228
|
redirect, thus your key does not go to a different host. With no copy, the calls of the next 10
|
|
225
|
-
seconds (or the TTL, if it is shorter) get the same result without a fetch.
|
|
226
|
-
|
|
229
|
+
seconds (or the TTL, if it is shorter) get the same result without a fetch. After a 429 or 503
|
|
230
|
+
response with a `Retry-After` header, `get_prompt` does not fetch that prompt again before that
|
|
231
|
+
time (one hour at most). This is the one SDK function that can raise: your app cannot run
|
|
232
|
+
without its prompt.
|
|
227
233
|
- `get_prompt` uses the API key and base URL of an enabled `init()`, or `TELEMETRY_DEV_API_KEY`
|
|
228
234
|
and `TELEMETRY_DEV_BASE_URL`, or its own `api_key` and `base_url`. It reads the same host that
|
|
229
|
-
telemetry goes to, `https://
|
|
235
|
+
telemetry goes to, `https://api.telemetry.dev` by default. The key needs the read scope for
|
|
230
236
|
prompts.
|
|
231
237
|
- When a label sends part of the traffic to a second version, each session keeps one version: the
|
|
232
238
|
choice uses the session id of `propagate_attributes` (or of `session_mode="process"`). Without a
|
|
@@ -239,10 +245,13 @@ When input capture is on, `td.prompt.variables` holds the JSON of the variables
|
|
|
239
245
|
it, after `mask` and truncation. If threads or tasks that run at the same time share one prompt
|
|
240
246
|
object, call `compile()` in `with_prompt`, or call `get_prompt` in each thread or task.
|
|
241
247
|
`get_prompt` returns a new object from the cache. If you do not, a thread can record the variables
|
|
242
|
-
of a different thread.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
248
|
+
of a different thread. Inside one `with_prompt`, an asyncio task records the last `compile()` that
|
|
249
|
+
ran in it, or before it started in the code that started it; with none, it records the last
|
|
250
|
+
`compile()` in the block. A `compile()` in a worker thread, as with `asyncio.to_thread`, counts only
|
|
251
|
+
for code that has not compiled in its own task. Model calls are the spans of type `generation`
|
|
252
|
+
(operations `chat`, `text_completion`, and `generate_content`), including the spans of the provider
|
|
253
|
+
integrations: OpenAI, Anthropic, Amazon Bedrock, Google GenAI, LiteLLM, and OpenRouter. Spans from
|
|
254
|
+
other OpenTelemetry tracers do not get the attributes. Messages from `compile()` that you pass to a
|
|
246
255
|
model call outside `with_prompt` are not recorded.
|
|
247
256
|
|
|
248
257
|
## Auto-metrics
|
|
@@ -301,7 +310,7 @@ from telemetry_dev import TelemetrySpanProcessor
|
|
|
301
310
|
| Option | Default | Purpose |
|
|
302
311
|
| --- | --- | --- |
|
|
303
312
|
| `api_key` | `TELEMETRY_DEV_API_KEY` | Ingest key. Absent with no `span_exporter` = inert no-op. |
|
|
304
|
-
| `base_url` | `TELEMETRY_DEV_BASE_URL` or `https://
|
|
313
|
+
| `base_url` | `TELEMETRY_DEV_BASE_URL` or `https://api.telemetry.dev` | Base URL; trailing slashes are stripped. |
|
|
305
314
|
| `export_mode` | `"batched"` | `"batched"` or `"immediate"` span export. |
|
|
306
315
|
| `max_export_batch_size` | `64` | BatchSpanProcessor export batch size. |
|
|
307
316
|
| `schedule_delay_millis` | `1000` | BatchSpanProcessor schedule delay. |
|
|
@@ -369,7 +378,7 @@ from opentelemetry.sdk.trace import TracerProvider
|
|
|
369
378
|
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
370
379
|
|
|
371
380
|
exporter = OTLPSpanExporter(
|
|
372
|
-
endpoint="https://
|
|
381
|
+
endpoint="https://api.telemetry.dev/v1/traces",
|
|
373
382
|
headers={"Authorization": f"Bearer {os.environ['TELEMETRY_DEV_API_KEY']}"},
|
|
374
383
|
)
|
|
375
384
|
provider = TracerProvider()
|
|
@@ -377,7 +386,7 @@ provider.add_span_processor(BatchSpanProcessor(exporter))
|
|
|
377
386
|
trace.set_tracer_provider(provider)
|
|
378
387
|
```
|
|
379
388
|
|
|
380
|
-
The Claude API can also send server-side spans to telemetry.dev through a Console destination. Set the endpoint to `https://
|
|
389
|
+
The Claude API can also send server-side spans to telemetry.dev through a Console destination. Set the endpoint to `https://api.telemetry.dev`. Set the `Authorization` header to `Bearer <telemetry.dev API key>`. For more information, refer to [Claude Platform](https://telemetry.dev/docs/integrations/claude-platform).
|
|
381
390
|
|
|
382
391
|
When the SDK and the Claude API send the spans of a request to the same telemetry.dev project, telemetry.dev counts the tokens of the request one time, from the API span. The SDK span stays in the trace with no tokens. telemetry.dev matches the two spans by the request ID or the response ID, so the API span does not have to be a child of the SDK span. If the Console destination uses an API key that is scoped to one environment, its API spans take over only the SDK spans of that environment.
|
|
383
392
|
|
|
@@ -13,8 +13,11 @@ import urllib.request
|
|
|
13
13
|
from collections import OrderedDict
|
|
14
14
|
from collections.abc import Callable, Generator, Mapping
|
|
15
15
|
from contextlib import contextmanager
|
|
16
|
+
from contextvars import ContextVar
|
|
16
17
|
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime, timezone
|
|
17
19
|
from email.message import Message
|
|
20
|
+
from email.utils import parsedate_to_datetime
|
|
18
21
|
from time import monotonic
|
|
19
22
|
from typing import IO, Any, Literal, TypeAlias, TypedDict, cast, overload
|
|
20
23
|
from weakref import WeakKeyDictionary
|
|
@@ -23,7 +26,7 @@ from opentelemetry import context as otel_context
|
|
|
23
26
|
from opentelemetry.context import Context
|
|
24
27
|
|
|
25
28
|
from ._client import Client, get_client
|
|
26
|
-
from ._config import report_error, resolve_config
|
|
29
|
+
from ._config import SDK_VERSION, report_error, resolve_config
|
|
27
30
|
from ._context import propagated_attributes
|
|
28
31
|
from ._semconv import (
|
|
29
32
|
ATTR_PROMPT_LABEL,
|
|
@@ -37,7 +40,11 @@ from ._serialize import AttributeValue, json_dumps
|
|
|
37
40
|
_FETCH_TIMEOUT_S = 10.0
|
|
38
41
|
# How long a failed fetch with no copy answers calls, so that they do not each fetch again.
|
|
39
42
|
_ERROR_TTL_S = 10.0
|
|
43
|
+
# The longest Retry-After that get_prompt honors, so that a bad header cannot stop fetches.
|
|
44
|
+
_MAX_RETRY_AFTER_S = 3600.0
|
|
40
45
|
_MAX_CACHE_ENTRIES = 1000
|
|
46
|
+
# IMF-fixdate, the HTTP date that servers send: Sun, 06 Nov 1994 08:49:37 GMT.
|
|
47
|
+
_HTTP_DATE = re.compile(r"[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT", re.ASCII)
|
|
41
48
|
_VARIABLE = re.compile(r"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}")
|
|
42
49
|
_MODEL_CALL_OPERATIONS = frozenset({"chat", "text_completion", "generate_content"})
|
|
43
50
|
_PROMPT_KEY = otel_context.create_key("telemetry_dev.prompt")
|
|
@@ -61,6 +68,8 @@ class PromptError(Exception):
|
|
|
61
68
|
self.code = code
|
|
62
69
|
self.message = message
|
|
63
70
|
self.status = status
|
|
71
|
+
# The Retry-After of a 429 or 503 response, in seconds.
|
|
72
|
+
self._retry_after: float | None = None
|
|
64
73
|
|
|
65
74
|
|
|
66
75
|
@dataclass(frozen=True, eq=False)
|
|
@@ -243,12 +252,21 @@ def with_prompt(prompt: Prompt) -> Generator[None]:
|
|
|
243
252
|
``td.prompt.name``, ``td.prompt.version``, ``td.prompt.label``, and, when input capture is on,
|
|
244
253
|
``td.prompt.variables``. The variables are those of the last ``compile()`` of this prompt
|
|
245
254
|
object before the context starts, or of the last ``compile()`` inside it, so a compile in
|
|
246
|
-
another thread or task does not change them once the context runs.
|
|
255
|
+
another thread or task does not change them once the context runs. When concurrent tasks in
|
|
256
|
+
the context each compile, a span records the last compile of its own task; a compile in a
|
|
257
|
+
worker thread (``asyncio.to_thread``) counts only for a task that has not compiled."""
|
|
247
258
|
scope = _PromptScope(prompt, _compiled_variables.get(prompt))
|
|
248
259
|
token = otel_context.attach(otel_context.set_value(_PROMPT_KEY, scope))
|
|
260
|
+
task_token = _task_variables.set(_task_variables.get())
|
|
249
261
|
try:
|
|
250
262
|
yield
|
|
251
263
|
finally:
|
|
264
|
+
try:
|
|
265
|
+
_task_variables.reset(task_token)
|
|
266
|
+
except ValueError:
|
|
267
|
+
# The block ended in another Context, as a generator closed elsewhere does. Keep
|
|
268
|
+
# going like otel_context.detach does, so that the detach below still runs.
|
|
269
|
+
pass
|
|
252
270
|
otel_context.detach(token)
|
|
253
271
|
|
|
254
272
|
|
|
@@ -267,8 +285,10 @@ def prompt_attributes(
|
|
|
267
285
|
attrs[ATTR_PROMPT_VERSION] = prompt.version
|
|
268
286
|
if prompt.label is not None:
|
|
269
287
|
attrs[ATTR_PROMPT_LABEL] = prompt.label
|
|
270
|
-
|
|
271
|
-
|
|
288
|
+
task = _task_variables.get()
|
|
289
|
+
variables = task[1] if task is not None and task[0] is scope else scope.variables
|
|
290
|
+
if variables is not None and capture_input:
|
|
291
|
+
serialized = client.serialize(variables, ATTR_PROMPT_VARIABLES)
|
|
272
292
|
if serialized is not None:
|
|
273
293
|
attrs[ATTR_PROMPT_VARIABLES] = serialized
|
|
274
294
|
return attrs
|
|
@@ -313,7 +333,13 @@ def _get_prompt(
|
|
|
313
333
|
cache_ttl_seconds,
|
|
314
334
|
lambda: _fetch(
|
|
315
335
|
f"{url}/v1/prompts?{urllib.parse.urlencode(query)}",
|
|
316
|
-
{
|
|
336
|
+
{
|
|
337
|
+
"Authorization": f"Bearer {key}",
|
|
338
|
+
"X-Telemetry-Dev-SDK": "telemetry-dev",
|
|
339
|
+
# urllib's default User-Agent, Python-urllib/3.x, is refused by bot filters
|
|
340
|
+
# such as Cloudflare's Browser Integrity Check.
|
|
341
|
+
"User-Agent": f"telemetry-dev-python/{SDK_VERSION}",
|
|
342
|
+
},
|
|
317
343
|
),
|
|
318
344
|
on_error,
|
|
319
345
|
)
|
|
@@ -347,14 +373,22 @@ _compiled_variables: WeakKeyDictionary[ChatPrompt | TextPrompt, dict[str, Any]]
|
|
|
347
373
|
)
|
|
348
374
|
|
|
349
375
|
|
|
350
|
-
@dataclass
|
|
376
|
+
@dataclass(eq=False)
|
|
351
377
|
class _PromptScope:
|
|
352
|
-
"""The prompt of a ``with_prompt`` context and the variables
|
|
378
|
+
"""The prompt of a ``with_prompt`` context and the variables of the last ``compile()`` in it.
|
|
379
|
+
Concurrent tasks in the context share the scope; see ``_task_variables``."""
|
|
353
380
|
|
|
354
381
|
prompt: Prompt
|
|
355
382
|
variables: dict[str, Any] | None
|
|
356
383
|
|
|
357
384
|
|
|
385
|
+
# The variables of the last compile() of a scope in the current task. A new asyncio task or a
|
|
386
|
+
# copied context gets a copy, so its compile does not change the value of other tasks.
|
|
387
|
+
_task_variables: ContextVar[tuple[_PromptScope, dict[str, Any]] | None] = ContextVar(
|
|
388
|
+
"telemetry_dev_prompt_variables", default=None
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
|
|
358
392
|
def _record(
|
|
359
393
|
prompt: Prompt, variables: Mapping[str, Any] | None, kwargs: Mapping[str, Any]
|
|
360
394
|
) -> dict[str, Any]:
|
|
@@ -363,6 +397,7 @@ def _record(
|
|
|
363
397
|
scope = otel_context.get_value(_PROMPT_KEY)
|
|
364
398
|
if isinstance(scope, _PromptScope) and scope.prompt is prompt:
|
|
365
399
|
scope.variables = values
|
|
400
|
+
_task_variables.set((scope, values))
|
|
366
401
|
return values
|
|
367
402
|
|
|
368
403
|
|
|
@@ -413,6 +448,8 @@ class _Entry:
|
|
|
413
448
|
error: Exception | None = None
|
|
414
449
|
# When the last fetch ended.
|
|
415
450
|
checked_at: float = 0.0
|
|
451
|
+
# No fetch before this time: the Retry-After of the last 429 or 503 response.
|
|
452
|
+
retry_at: float = 0.0
|
|
416
453
|
# Set when the running fetch ends.
|
|
417
454
|
pending: threading.Event | None = None
|
|
418
455
|
# When calls stop waiting for the running fetch.
|
|
@@ -442,7 +479,7 @@ def _load(
|
|
|
442
479
|
entry = _cache[key] = _Entry()
|
|
443
480
|
now = monotonic()
|
|
444
481
|
ttl = ttl_seconds if entry.response is not None else min(ttl_seconds, _ERROR_TTL_S)
|
|
445
|
-
if now >= entry.checked_at + ttl and entry.pending is None:
|
|
482
|
+
if now >= max(entry.checked_at + ttl, entry.retry_at) and entry.pending is None:
|
|
446
483
|
entry.pending = threading.Event()
|
|
447
484
|
entry.deadline = now + _FETCH_TIMEOUT_S
|
|
448
485
|
threading.Thread(
|
|
@@ -478,6 +515,12 @@ def _refresh(
|
|
|
478
515
|
error = exc
|
|
479
516
|
with _cache_lock:
|
|
480
517
|
stale = entry.response
|
|
518
|
+
delay = (
|
|
519
|
+
error._retry_after # pyright: ignore[reportPrivateUsage]
|
|
520
|
+
if isinstance(error, PromptError)
|
|
521
|
+
else None
|
|
522
|
+
)
|
|
523
|
+
entry.retry_at = 0.0 if delay is None else monotonic() + delay
|
|
481
524
|
if response is not None:
|
|
482
525
|
entry.response = response
|
|
483
526
|
elif stale is None:
|
|
@@ -515,7 +558,11 @@ def _fetch(url: str, headers: dict[str, str]) -> dict[str, Any]:
|
|
|
515
558
|
body: bytes = response.read()
|
|
516
559
|
except urllib.error.HTTPError as exc:
|
|
517
560
|
with exc:
|
|
518
|
-
|
|
561
|
+
error = _api_error(exc.code, exc.read())
|
|
562
|
+
if exc.code in (429, 503):
|
|
563
|
+
retry_after = _retry_after(exc.headers.get("Retry-After"))
|
|
564
|
+
error._retry_after = retry_after # pyright: ignore[reportPrivateUsage]
|
|
565
|
+
raise error from None
|
|
519
566
|
except TimeoutError as exc:
|
|
520
567
|
raise PromptError(
|
|
521
568
|
"timeout", f"Fetching the prompt took more than {_FETCH_TIMEOUT_S:g} s"
|
|
@@ -550,6 +597,25 @@ def _api_error(status: int, body: bytes) -> PromptError:
|
|
|
550
597
|
)
|
|
551
598
|
|
|
552
599
|
|
|
600
|
+
def _retry_after(value: str | None) -> float | None:
|
|
601
|
+
"""Seconds to wait from a Retry-After header of seconds or an IMF-fixdate HTTP date
|
|
602
|
+
(``Sun, 06 Nov 1994 08:49:37 GMT``), capped."""
|
|
603
|
+
value = (value or "").strip()
|
|
604
|
+
if not value:
|
|
605
|
+
return None
|
|
606
|
+
if value.isascii() and value.isdigit():
|
|
607
|
+
seconds = float(value)
|
|
608
|
+
elif _HTTP_DATE.fullmatch(value):
|
|
609
|
+
try:
|
|
610
|
+
date = parsedate_to_datetime(value)
|
|
611
|
+
except (TypeError, ValueError):
|
|
612
|
+
return None
|
|
613
|
+
seconds = (date - datetime.now(timezone.utc)).total_seconds()
|
|
614
|
+
else:
|
|
615
|
+
return None
|
|
616
|
+
return min(seconds, _MAX_RETRY_AFTER_S) if seconds > 0 else None
|
|
617
|
+
|
|
618
|
+
|
|
553
619
|
def _parse_response(data: object) -> dict[str, Any] | None:
|
|
554
620
|
if not isinstance(data, dict):
|
|
555
621
|
return None
|
|
@@ -559,6 +625,7 @@ def _parse_response(data: object) -> dict[str, Any] | None:
|
|
|
559
625
|
if (
|
|
560
626
|
not isinstance(body.get("name"), str)
|
|
561
627
|
or prompt_type not in ("chat", "text")
|
|
628
|
+
or "label" not in body
|
|
562
629
|
or not (label is None or isinstance(label, str))
|
|
563
630
|
or not _is_version(body.get("version"))
|
|
564
631
|
or not _valid_content(body, prompt_type)
|
|
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
|