telemetry-dev 0.2.11__tar.gz → 0.2.13__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 (21) hide show
  1. {telemetry_dev-0.2.11 → telemetry_dev-0.2.13}/PKG-INFO +76 -2
  2. {telemetry_dev-0.2.11 → telemetry_dev-0.2.13}/README.md +75 -1
  3. {telemetry_dev-0.2.11 → telemetry_dev-0.2.13}/pyproject.toml +1 -1
  4. {telemetry_dev-0.2.11 → telemetry_dev-0.2.13}/pyproject.toml.orig +1 -1
  5. {telemetry_dev-0.2.11 → telemetry_dev-0.2.13}/src/telemetry_dev/__init__.py +18 -0
  6. telemetry_dev-0.2.13/src/telemetry_dev/_prompts.py +740 -0
  7. {telemetry_dev-0.2.11 → telemetry_dev-0.2.13}/src/telemetry_dev/_semconv.py +5 -0
  8. {telemetry_dev-0.2.11 → telemetry_dev-0.2.13}/src/telemetry_dev/_spans.py +6 -0
  9. {telemetry_dev-0.2.11 → telemetry_dev-0.2.13}/LICENSE +0 -0
  10. {telemetry_dev-0.2.11 → telemetry_dev-0.2.13}/src/telemetry_dev/_capture.py +0 -0
  11. {telemetry_dev-0.2.11 → telemetry_dev-0.2.13}/src/telemetry_dev/_client.py +0 -0
  12. {telemetry_dev-0.2.11 → telemetry_dev-0.2.13}/src/telemetry_dev/_config.py +0 -0
  13. {telemetry_dev-0.2.11 → telemetry_dev-0.2.13}/src/telemetry_dev/_context.py +0 -0
  14. {telemetry_dev-0.2.11 → telemetry_dev-0.2.13}/src/telemetry_dev/_export.py +0 -0
  15. {telemetry_dev-0.2.11 → telemetry_dev-0.2.13}/src/telemetry_dev/_logs.py +0 -0
  16. {telemetry_dev-0.2.11 → telemetry_dev-0.2.13}/src/telemetry_dev/_metrics.py +0 -0
  17. {telemetry_dev-0.2.11 → telemetry_dev-0.2.13}/src/telemetry_dev/_observe.py +0 -0
  18. {telemetry_dev-0.2.11 → telemetry_dev-0.2.13}/src/telemetry_dev/_processor.py +0 -0
  19. {telemetry_dev-0.2.11 → telemetry_dev-0.2.13}/src/telemetry_dev/_serialize.py +0 -0
  20. {telemetry_dev-0.2.11 → telemetry_dev-0.2.13}/src/telemetry_dev/otel.py +0 -0
  21. {telemetry_dev-0.2.11 → telemetry_dev-0.2.13}/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.2.11
3
+ Version: 0.2.13
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://ingest.telemetry.dev` | Ingest base URL (trailing slashes stripped). |
84
+ | `TELEMETRY_DEV_BASE_URL` | `https://ingest.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. |
@@ -108,6 +108,8 @@ later `init()` generates a new ID. It is not persisted across processes.
108
108
  | `propagate_attributes(*, user_id=, session_id=, metadata=)` | Context manager stamping `user.id` / `gen_ai.conversation.id` / `td.metadata.*` on every span and log record started inside (threads/asyncio included via contextvars). |
109
109
  | `log(message, *, level="info", event_name=None, attributes=None)` | Emit an OTLP log record to `/v1/logs`, correlated with the current trace. Levels: `debug`/`info`/`warn`/`error` (`"warning"` is accepted as an alias of `warn`). |
110
110
  | `get_traceparent() -> str \| None` | W3C traceparent of the current context. |
111
+ | `get_prompt(name, *, label=, version=, type="chat", cache_ttl_seconds=60, fallback=, api_key=, base_url=)` / `get_prompt_async(...)` | Fetch a prompt by label or version; see [Prompts](#prompts). |
112
+ | `with_prompt(prompt)` | Context manager recording the prompt on each model call started inside. |
111
113
  | `flush(timeout_s=10.0)` / `shutdown(timeout_s=10.0)` | Force-flush / tear down traces + logs + metrics. Shutdown also runs atexit unless `disable_atexit=True`. |
112
114
  | `TelemetrySpanProcessor` / `telemetry_dev.otel.create_telemetry_span_exporter` | Bring-your-own-OTel helpers (below). |
113
115
  | `MaskContext`, `Usage`, `SpanHandle`, `Client`, `NOT_GIVEN` | Supporting types. |
@@ -201,6 +203,78 @@ For example, `init(sampler=ParentBased(TraceIdRatioBased(0.1)))` samples session
201
203
  These classes come from `opentelemetry.sdk.trace.sampling`.
202
204
  The built-in Python and TypeScript ratio samplers can select different sessions because the OTel algorithms differ.
203
205
 
206
+ ## Prompts
207
+
208
+ `get_prompt` fetches a prompt that you manage in telemetry.dev. Fetch it by its label, so that you
209
+ deploy a version when you move the label, without a change to the code:
210
+
211
+ ```python
212
+ import telemetry_dev
213
+ from openai import OpenAI
214
+ from telemetry_dev_openai import wrap_openai
215
+
216
+ telemetry_dev.init()
217
+ client = wrap_openai(OpenAI())
218
+
219
+ prompt = telemetry_dev.get_prompt("support/reply", label="production")
220
+ messages = prompt.compile(customer_message=customer_message, tone=tone)
221
+
222
+ with telemetry_dev.with_prompt(prompt):
223
+ response = client.chat.completions.create(
224
+ model=prompt.model["name"] if prompt.model else "gpt-4o", messages=messages
225
+ )
226
+
227
+ # Pin one version, as in a test:
228
+ pinned = telemetry_dev.get_prompt("support/reply", version=13)
229
+ ```
230
+
231
+ - Without `label` and `version`, `get_prompt` fetches the `production` label. A text prompt needs
232
+ `type="text"`; `get_prompt` raises for a prompt of the other type.
233
+ - `compile(variables)` or `compile(**variables)` puts the values in place of `{{name}}`. A missing
234
+ value or `None` becomes an empty text, and a value that is not a string becomes JSON. In a chat
235
+ prompt, a placeholder message becomes the list of messages in its variable. A text prompt
236
+ compiles to a string.
237
+ - The prompt also has `model`, `parameters`, `tools`, and `response_format` of its version. The
238
+ keys inside them are the API's (`maxTokens`, `topP`, ...).
239
+ - `get_prompt` keeps each prompt. When the copy is older than `cache_ttl_seconds` (default 60), a
240
+ call returns it at once and fetches a new copy in a background thread. Only a call with no copy
241
+ waits for a fetch, for 10 seconds at most, also when the network stops. Calls for the same
242
+ prompt share one fetch. `get_prompt_async` runs the same function in a worker thread.
243
+ - If a fetch fails, `get_prompt` returns the last copy that it fetched and reports the error
244
+ through `on_error`. With no copy, it returns the `fallback` (`version` is `None` and
245
+ `is_fallback` is `True`). With no fallback, it raises `PromptError` whose `code` tells why, for
246
+ example `prompt_not_found`, `label_not_found`, `insufficient_scope`, `missing_api_key`, or
247
+ `timeout`. A response with a different status and no API error gives `http_<status>`, for
248
+ example `http_302` for a redirect. `get_prompt` does not send the request to the location of a
249
+ 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. After a 429 or 503
251
+ response with a `Retry-After` header, `get_prompt` does not fetch that prompt again before that
252
+ time (one hour at most). This is the one SDK function that can raise: your app cannot run
253
+ without its prompt.
254
+ - `get_prompt` uses the API key and base URL of an enabled `init()`, or `TELEMETRY_DEV_API_KEY`
255
+ and `TELEMETRY_DEV_BASE_URL`, or its own `api_key` and `base_url`. It reads the same host that
256
+ telemetry goes to, `https://ingest.telemetry.dev` by default. The key needs the read scope for
257
+ prompts.
258
+ - When a label sends part of the traffic to a second version, each session keeps one version: the
259
+ choice uses the session id of `propagate_attributes` (or of `session_mode="process"`). Without a
260
+ session id, each call chooses at random.
261
+
262
+ `with_prompt(prompt)` records the prompt on each model call that starts inside it:
263
+ `td.prompt.name`, `td.prompt.version`, and `td.prompt.label` (only for a prompt fetched by label).
264
+ When input capture is on, `td.prompt.variables` holds the JSON of the variables of the last
265
+ `compile()` of that prompt object before `with_prompt` starts, or of the last `compile()` inside
266
+ it, after `mask` and truncation. If threads or tasks that run at the same time share one prompt
267
+ object, call `compile()` in `with_prompt`, or call `get_prompt` in each thread or task.
268
+ `get_prompt` returns a new object from the cache. If you do not, a thread can record the variables
269
+ of a different thread. Inside one `with_prompt`, an asyncio task records the last `compile()` that
270
+ ran in it, or before it started in the code that started it; with none, it records the last
271
+ `compile()` in the block. A `compile()` in a worker thread, as with `asyncio.to_thread`, counts only
272
+ for code that has not compiled in its own task. Model calls are the spans of type `generation`
273
+ (operations `chat`, `text_completion`, and `generate_content`), including the spans of the provider
274
+ integrations: OpenAI, Anthropic, Amazon Bedrock, Google GenAI, LiteLLM, and OpenRouter. Spans from
275
+ other OpenTelemetry tracers do not get the attributes. Messages from `compile()` that you pass to a
276
+ model call outside `with_prompt` are not recorded.
277
+
204
278
  ## Auto-metrics
205
279
 
206
280
  Ended spans automatically record histograms (DELTA temporality, exported every 60s):
@@ -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://ingest.telemetry.dev` | Ingest base URL (trailing slashes stripped). |
59
+ | `TELEMETRY_DEV_BASE_URL` | `https://ingest.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. |
@@ -83,6 +83,8 @@ later `init()` generates a new ID. It is not persisted across processes.
83
83
  | `propagate_attributes(*, user_id=, session_id=, metadata=)` | Context manager stamping `user.id` / `gen_ai.conversation.id` / `td.metadata.*` on every span and log record started inside (threads/asyncio included via contextvars). |
84
84
  | `log(message, *, level="info", event_name=None, attributes=None)` | Emit an OTLP log record to `/v1/logs`, correlated with the current trace. Levels: `debug`/`info`/`warn`/`error` (`"warning"` is accepted as an alias of `warn`). |
85
85
  | `get_traceparent() -> str \| None` | W3C traceparent of the current context. |
86
+ | `get_prompt(name, *, label=, version=, type="chat", cache_ttl_seconds=60, fallback=, api_key=, base_url=)` / `get_prompt_async(...)` | Fetch a prompt by label or version; see [Prompts](#prompts). |
87
+ | `with_prompt(prompt)` | Context manager recording the prompt on each model call started inside. |
86
88
  | `flush(timeout_s=10.0)` / `shutdown(timeout_s=10.0)` | Force-flush / tear down traces + logs + metrics. Shutdown also runs atexit unless `disable_atexit=True`. |
87
89
  | `TelemetrySpanProcessor` / `telemetry_dev.otel.create_telemetry_span_exporter` | Bring-your-own-OTel helpers (below). |
88
90
  | `MaskContext`, `Usage`, `SpanHandle`, `Client`, `NOT_GIVEN` | Supporting types. |
@@ -176,6 +178,78 @@ For example, `init(sampler=ParentBased(TraceIdRatioBased(0.1)))` samples session
176
178
  These classes come from `opentelemetry.sdk.trace.sampling`.
177
179
  The built-in Python and TypeScript ratio samplers can select different sessions because the OTel algorithms differ.
178
180
 
181
+ ## Prompts
182
+
183
+ `get_prompt` fetches a prompt that you manage in telemetry.dev. Fetch it by its label, so that you
184
+ deploy a version when you move the label, without a change to the code:
185
+
186
+ ```python
187
+ import telemetry_dev
188
+ from openai import OpenAI
189
+ from telemetry_dev_openai import wrap_openai
190
+
191
+ telemetry_dev.init()
192
+ client = wrap_openai(OpenAI())
193
+
194
+ prompt = telemetry_dev.get_prompt("support/reply", label="production")
195
+ messages = prompt.compile(customer_message=customer_message, tone=tone)
196
+
197
+ with telemetry_dev.with_prompt(prompt):
198
+ response = client.chat.completions.create(
199
+ model=prompt.model["name"] if prompt.model else "gpt-4o", messages=messages
200
+ )
201
+
202
+ # Pin one version, as in a test:
203
+ pinned = telemetry_dev.get_prompt("support/reply", version=13)
204
+ ```
205
+
206
+ - Without `label` and `version`, `get_prompt` fetches the `production` label. A text prompt needs
207
+ `type="text"`; `get_prompt` raises for a prompt of the other type.
208
+ - `compile(variables)` or `compile(**variables)` puts the values in place of `{{name}}`. A missing
209
+ value or `None` becomes an empty text, and a value that is not a string becomes JSON. In a chat
210
+ prompt, a placeholder message becomes the list of messages in its variable. A text prompt
211
+ compiles to a string.
212
+ - The prompt also has `model`, `parameters`, `tools`, and `response_format` of its version. The
213
+ keys inside them are the API's (`maxTokens`, `topP`, ...).
214
+ - `get_prompt` keeps each prompt. When the copy is older than `cache_ttl_seconds` (default 60), a
215
+ call returns it at once and fetches a new copy in a background thread. Only a call with no copy
216
+ waits for a fetch, for 10 seconds at most, also when the network stops. Calls for the same
217
+ prompt share one fetch. `get_prompt_async` runs the same function in a worker thread.
218
+ - If a fetch fails, `get_prompt` returns the last copy that it fetched and reports the error
219
+ through `on_error`. With no copy, it returns the `fallback` (`version` is `None` and
220
+ `is_fallback` is `True`). With no fallback, it raises `PromptError` whose `code` tells why, for
221
+ example `prompt_not_found`, `label_not_found`, `insufficient_scope`, `missing_api_key`, or
222
+ `timeout`. A response with a different status and no API error gives `http_<status>`, for
223
+ example `http_302` for a redirect. `get_prompt` does not send the request to the location of a
224
+ 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. After a 429 or 503
226
+ response with a `Retry-After` header, `get_prompt` does not fetch that prompt again before that
227
+ time (one hour at most). This is the one SDK function that can raise: your app cannot run
228
+ without its prompt.
229
+ - `get_prompt` uses the API key and base URL of an enabled `init()`, or `TELEMETRY_DEV_API_KEY`
230
+ and `TELEMETRY_DEV_BASE_URL`, or its own `api_key` and `base_url`. It reads the same host that
231
+ telemetry goes to, `https://ingest.telemetry.dev` by default. The key needs the read scope for
232
+ prompts.
233
+ - When a label sends part of the traffic to a second version, each session keeps one version: the
234
+ choice uses the session id of `propagate_attributes` (or of `session_mode="process"`). Without a
235
+ session id, each call chooses at random.
236
+
237
+ `with_prompt(prompt)` records the prompt on each model call that starts inside it:
238
+ `td.prompt.name`, `td.prompt.version`, and `td.prompt.label` (only for a prompt fetched by label).
239
+ When input capture is on, `td.prompt.variables` holds the JSON of the variables of the last
240
+ `compile()` of that prompt object before `with_prompt` starts, or of the last `compile()` inside
241
+ it, after `mask` and truncation. If threads or tasks that run at the same time share one prompt
242
+ object, call `compile()` in `with_prompt`, or call `get_prompt` in each thread or task.
243
+ `get_prompt` returns a new object from the cache. If you do not, a thread can record the variables
244
+ of a different thread. Inside one `with_prompt`, an asyncio task records the last `compile()` that
245
+ ran in it, or before it started in the code that started it; with none, it records the last
246
+ `compile()` in the block. A `compile()` in a worker thread, as with `asyncio.to_thread`, counts only
247
+ for code that has not compiled in its own task. Model calls are the spans of type `generation`
248
+ (operations `chat`, `text_completion`, and `generate_content`), including the spans of the provider
249
+ integrations: OpenAI, Anthropic, Amazon Bedrock, Google GenAI, LiteLLM, and OpenRouter. Spans from
250
+ other OpenTelemetry tracers do not get the attributes. Messages from `compile()` that you pass to a
251
+ model call outside `with_prompt` are not recorded.
252
+
179
253
  ## Auto-metrics
180
254
 
181
255
  Ended spans automatically record histograms (DELTA temporality, exported every 60s):
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "telemetry-dev"
3
- version = "0.2.11"
3
+ version = "0.2.13"
4
4
  description = "telemetry.dev SDK for Python — OpenTelemetry-native GenAI tracing, logs, and metrics"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "telemetry-dev"
3
- version = "0.2.11"
3
+ version = "0.2.13"
4
4
  description = "telemetry.dev SDK for Python — OpenTelemetry-native GenAI tracing, logs, and metrics"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -19,6 +19,16 @@ from ._config import LogLevelOption
19
19
  from ._context import get_traceparent, propagate_attributes
20
20
  from ._logs import log
21
21
  from ._observe import observe
22
+ from ._prompts import (
23
+ ChatPrompt,
24
+ Prompt,
25
+ PromptError,
26
+ PromptModel,
27
+ TextPrompt,
28
+ get_prompt,
29
+ get_prompt_async,
30
+ with_prompt,
31
+ )
22
32
  from ._semconv import LogLevel, SpanType
23
33
  from ._serialize import AttributeValue, Mask, MaskContext
24
34
  from ._spans import (
@@ -34,18 +44,25 @@ __all__ = [
34
44
  "NOT_GIVEN",
35
45
  "AttributeValue",
36
46
  "CaptureBudget",
47
+ "ChatPrompt",
37
48
  "Client",
38
49
  "LogLevel",
39
50
  "LogLevelOption",
40
51
  "Mask",
41
52
  "MaskContext",
53
+ "Prompt",
54
+ "PromptError",
55
+ "PromptModel",
42
56
  "SpanHandle",
43
57
  "SpanType",
44
58
  "TelemetrySpanProcessor",
59
+ "TextPrompt",
45
60
  "Usage",
46
61
  "__version__",
47
62
  "flush",
48
63
  "get_client",
64
+ "get_prompt",
65
+ "get_prompt_async",
49
66
  "get_traceparent",
50
67
  "init",
51
68
  "log",
@@ -54,4 +71,5 @@ __all__ = [
54
71
  "shutdown",
55
72
  "start_span",
56
73
  "update_current_span",
74
+ "with_prompt",
57
75
  ]
@@ -0,0 +1,740 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import copy
5
+ import hashlib
6
+ import json
7
+ import random
8
+ import re
9
+ import threading
10
+ import urllib.error
11
+ import urllib.parse
12
+ import urllib.request
13
+ from collections import OrderedDict
14
+ from collections.abc import Callable, Generator, Mapping
15
+ from contextlib import contextmanager
16
+ from contextvars import ContextVar
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime, timezone
19
+ from email.message import Message
20
+ from email.utils import parsedate_to_datetime
21
+ from time import monotonic
22
+ from typing import IO, Any, Literal, TypeAlias, TypedDict, cast, overload
23
+ from weakref import WeakKeyDictionary
24
+
25
+ from opentelemetry import context as otel_context
26
+ from opentelemetry.context import Context
27
+
28
+ from ._client import Client, get_client
29
+ from ._config import report_error, resolve_config
30
+ from ._context import propagated_attributes
31
+ from ._semconv import (
32
+ ATTR_PROMPT_LABEL,
33
+ ATTR_PROMPT_NAME,
34
+ ATTR_PROMPT_VARIABLES,
35
+ ATTR_PROMPT_VERSION,
36
+ ATTR_SESSION_ID,
37
+ )
38
+ from ._serialize import AttributeValue, json_dumps
39
+
40
+ _FETCH_TIMEOUT_S = 10.0
41
+ # How long a failed fetch with no copy answers calls, so that they do not each fetch again.
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
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)
48
+ _VARIABLE = re.compile(r"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}")
49
+ _MODEL_CALL_OPERATIONS = frozenset({"chat", "text_completion", "generate_content"})
50
+ _PROMPT_KEY = otel_context.create_key("telemetry_dev.prompt")
51
+
52
+
53
+ class PromptModel(TypedDict):
54
+ provider: str
55
+ name: str
56
+
57
+
58
+ class PromptError(Exception):
59
+ """Why get_prompt failed. ``code`` is the code of the API error (``prompt_not_found``,
60
+ ``label_not_found``, ``insufficient_scope``, ``invalid_api_key``, ...), or
61
+ ``missing_api_key``, ``timeout``, ``network_error``, ``invalid_response``, or
62
+ ``type_mismatch``. A response with a different status and no API error gives
63
+ ``http_<status>``, for example ``http_302`` for a redirect: get_prompt does not go to the
64
+ location of a redirect. ``status`` is the HTTP status of the response."""
65
+
66
+ def __init__(self, code: str, message: str, status: int | None = None) -> None:
67
+ super().__init__(message)
68
+ self.code = code
69
+ self.message = message
70
+ self.status = status
71
+ # The Retry-After of a 429 or 503 response, in seconds.
72
+ self._retry_after: float | None = None
73
+
74
+
75
+ @dataclass(frozen=True, eq=False)
76
+ class ChatPrompt:
77
+ """A chat prompt version. ``compile()`` makes the messages to send to the model."""
78
+
79
+ name: str
80
+ version: int | None
81
+ """The version that the app got; None for the fallback."""
82
+ label: str | None
83
+ """The label that the prompt was fetched by; None when it was fetched by version."""
84
+ messages: list[dict[str, Any]]
85
+ model: PromptModel | None
86
+ parameters: dict[str, Any]
87
+ tools: list[dict[str, Any]]
88
+ response_format: dict[str, Any]
89
+ is_fallback: bool
90
+ """True when get_prompt could not fetch the prompt and returned the ``fallback``."""
91
+ type: Literal["chat"] = field(default="chat", init=False)
92
+ text: None = field(default=None, init=False)
93
+
94
+ def compile(
95
+ self, variables: Mapping[str, Any] | None = None, /, **kwargs: Any
96
+ ) -> list[dict[str, Any]]:
97
+ """Fill in the variables and replace each placeholder message with the list of messages
98
+ in its variable. ``with_prompt`` records the variables of the last call before it starts
99
+ or inside it."""
100
+ values = _record(self, variables, kwargs)
101
+ compiled: list[dict[str, Any]] = []
102
+ for message in self.messages:
103
+ if message.get("role") != "placeholder":
104
+ compiled.append(
105
+ {"role": message["role"], "content": _fill(str(message["content"]), values)}
106
+ )
107
+ continue
108
+ name = str(message["name"])
109
+ value = values.get(name)
110
+ if value is None:
111
+ continue
112
+ if not isinstance(value, list | tuple):
113
+ raise TypeError(f"The {name} variable must be a list of messages")
114
+ compiled.extend(cast("list[dict[str, Any]] | tuple[dict[str, Any], ...]", value))
115
+ return compiled
116
+
117
+
118
+ @dataclass(frozen=True, eq=False)
119
+ class TextPrompt:
120
+ """A text prompt version. ``compile()`` makes the text to send to the model."""
121
+
122
+ name: str
123
+ version: int | None
124
+ """The version that the app got; None for the fallback."""
125
+ label: str | None
126
+ """The label that the prompt was fetched by; None when it was fetched by version."""
127
+ text: str
128
+ model: PromptModel | None
129
+ parameters: dict[str, Any]
130
+ tools: list[dict[str, Any]]
131
+ response_format: dict[str, Any]
132
+ is_fallback: bool
133
+ """True when get_prompt could not fetch the prompt and returned the ``fallback``."""
134
+ type: Literal["text"] = field(default="text", init=False)
135
+
136
+ @property
137
+ def messages(self) -> list[dict[str, Any]]:
138
+ return []
139
+
140
+ def compile(self, variables: Mapping[str, Any] | None = None, /, **kwargs: Any) -> str:
141
+ """Fill in the variables. ``with_prompt`` records the variables of the last call before it
142
+ starts or inside it."""
143
+ return _fill(self.text, _record(self, variables, kwargs))
144
+
145
+
146
+ Prompt: TypeAlias = ChatPrompt | TextPrompt
147
+
148
+
149
+ @overload
150
+ def get_prompt(
151
+ name: str,
152
+ *,
153
+ type: Literal["chat"] = "chat",
154
+ label: str | None = None,
155
+ version: int | None = None,
156
+ cache_ttl_seconds: float = 60,
157
+ fallback: list[dict[str, Any]] | None = None,
158
+ api_key: str | None = None,
159
+ base_url: str | None = None,
160
+ ) -> ChatPrompt: ...
161
+
162
+
163
+ @overload
164
+ def get_prompt(
165
+ name: str,
166
+ *,
167
+ type: Literal["text"],
168
+ label: str | None = None,
169
+ version: int | None = None,
170
+ cache_ttl_seconds: float = 60,
171
+ fallback: str | None = None,
172
+ api_key: str | None = None,
173
+ base_url: str | None = None,
174
+ ) -> TextPrompt: ...
175
+
176
+
177
+ def get_prompt(
178
+ name: str,
179
+ *,
180
+ type: Literal["chat", "text"] = "chat",
181
+ label: str | None = None,
182
+ version: int | None = None,
183
+ cache_ttl_seconds: float = 60,
184
+ fallback: list[dict[str, Any]] | str | None = None,
185
+ api_key: str | None = None,
186
+ base_url: str | None = None,
187
+ ) -> Prompt:
188
+ """Fetch a prompt version from telemetry.dev and keep it.
189
+
190
+ Without ``label`` and ``version`` it fetches the ``production`` label. When the copy is older
191
+ than ``cache_ttl_seconds``, a call returns it at once and fetches a new one in the background.
192
+ Only a call with no copy waits for a fetch, at most 10 seconds. Calls for the same prompt share
193
+ one fetch. When a fetch fails, it returns the last copy that it kept; with no copy it returns
194
+ ``fallback``. With neither it raises ``PromptError``: unlike the telemetry functions,
195
+ get_prompt cannot fail open, because the app cannot run without the prompt. With no copy, the
196
+ calls of the next 10 seconds (or the TTL, if it is shorter) fail the same way without a fetch.
197
+
198
+ A label with a gradual rollout gives each session one version: the choice uses the session
199
+ id of ``propagate_attributes``, or a random choice per call without one.
200
+ """
201
+ return _get_prompt(name, type, label, version, cache_ttl_seconds, fallback, api_key, base_url)
202
+
203
+
204
+ @overload
205
+ async def get_prompt_async(
206
+ name: str,
207
+ *,
208
+ type: Literal["chat"] = "chat",
209
+ label: str | None = None,
210
+ version: int | None = None,
211
+ cache_ttl_seconds: float = 60,
212
+ fallback: list[dict[str, Any]] | None = None,
213
+ api_key: str | None = None,
214
+ base_url: str | None = None,
215
+ ) -> ChatPrompt: ...
216
+
217
+
218
+ @overload
219
+ async def get_prompt_async(
220
+ name: str,
221
+ *,
222
+ type: Literal["text"],
223
+ label: str | None = None,
224
+ version: int | None = None,
225
+ cache_ttl_seconds: float = 60,
226
+ fallback: str | None = None,
227
+ api_key: str | None = None,
228
+ base_url: str | None = None,
229
+ ) -> TextPrompt: ...
230
+
231
+
232
+ async def get_prompt_async(
233
+ name: str,
234
+ *,
235
+ type: Literal["chat", "text"] = "chat",
236
+ label: str | None = None,
237
+ version: int | None = None,
238
+ cache_ttl_seconds: float = 60,
239
+ fallback: list[dict[str, Any]] | str | None = None,
240
+ api_key: str | None = None,
241
+ base_url: str | None = None,
242
+ ) -> Prompt:
243
+ """``get_prompt`` in a worker thread, so a fetch does not block the event loop."""
244
+ return await asyncio.to_thread(
245
+ _get_prompt, name, type, label, version, cache_ttl_seconds, fallback, api_key, base_url
246
+ )
247
+
248
+
249
+ @contextmanager
250
+ def with_prompt(prompt: Prompt) -> Generator[None]:
251
+ """Record ``prompt`` on each model call (generation span) started inside this context:
252
+ ``td.prompt.name``, ``td.prompt.version``, ``td.prompt.label``, and, when input capture is on,
253
+ ``td.prompt.variables``. The variables are those of the last ``compile()`` of this prompt
254
+ object before the context starts, or of the last ``compile()`` inside it, so a compile in
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."""
258
+ scope = _PromptScope(prompt, _compiled_variables.get(prompt))
259
+ token = otel_context.attach(otel_context.set_value(_PROMPT_KEY, scope))
260
+ task_token = _task_variables.set(_task_variables.get())
261
+ try:
262
+ yield
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
270
+ otel_context.detach(token)
271
+
272
+
273
+ def prompt_attributes(
274
+ client: Client, context: Context | None, operation: object, capture_input: bool
275
+ ) -> dict[str, AttributeValue]:
276
+ """The td.prompt.* attributes for a span with this operation that starts in ``context``."""
277
+ if operation not in _MODEL_CALL_OPERATIONS:
278
+ return {}
279
+ scope = otel_context.get_value(_PROMPT_KEY, context) or otel_context.get_value(_PROMPT_KEY)
280
+ if not isinstance(scope, _PromptScope):
281
+ return {}
282
+ prompt = scope.prompt
283
+ attrs: dict[str, AttributeValue] = {ATTR_PROMPT_NAME: prompt.name}
284
+ if prompt.version is not None:
285
+ attrs[ATTR_PROMPT_VERSION] = prompt.version
286
+ if prompt.label is not None:
287
+ attrs[ATTR_PROMPT_LABEL] = prompt.label
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)
292
+ if serialized is not None:
293
+ attrs[ATTR_PROMPT_VARIABLES] = serialized
294
+ return attrs
295
+
296
+
297
+ def _get_prompt(
298
+ name: str,
299
+ type: Literal["chat", "text"],
300
+ label: str | None,
301
+ version: int | None,
302
+ cache_ttl_seconds: float,
303
+ fallback: list[dict[str, Any]] | str | None,
304
+ api_key: str | None,
305
+ base_url: str | None,
306
+ ) -> Prompt:
307
+ _check(name, type, label, version, cache_ttl_seconds, fallback)
308
+ client = get_client()
309
+ enabled = client if client is not None and client.enabled else None
310
+ config = enabled.config if enabled is not None else resolve_config()
311
+ key = api_key if api_key is not None else config.api_key
312
+ url = (base_url or config.base_url).rstrip("/")
313
+ asked_label = (label if label is not None else "production") if version is None else None
314
+ on_error = enabled.on_error if enabled is not None else None
315
+
316
+ try:
317
+ if not key:
318
+ raise PromptError(
319
+ "missing_api_key", "No API key: pass api_key or set TELEMETRY_DEV_API_KEY"
320
+ )
321
+ query = {"name": name}
322
+ if asked_label is None:
323
+ query["version"] = str(version)
324
+ else:
325
+ query["label"] = asked_label
326
+ response = _load(
327
+ (
328
+ url,
329
+ hashlib.sha256(key.encode()).hexdigest()[:32],
330
+ name,
331
+ f"version:{version}" if asked_label is None else f"label:{asked_label}",
332
+ ),
333
+ cache_ttl_seconds,
334
+ lambda: _fetch(
335
+ f"{url}/v1/prompts?{urllib.parse.urlencode(query)}",
336
+ {"Authorization": f"Bearer {key}", "X-Telemetry-Dev-SDK": "telemetry-dev"},
337
+ ),
338
+ on_error,
339
+ )
340
+ except Exception as exc:
341
+ if fallback is None:
342
+ raise
343
+ report_error(on_error, "fetching the prompt failed; using the fallback", exc)
344
+ content: dict[str, Any] = {
345
+ "messages" if type == "chat" else "text": fallback,
346
+ "model": None,
347
+ "parameters": {},
348
+ "tools": [],
349
+ "responseFormat": {"type": "text"},
350
+ }
351
+ return _make_prompt(name, type, asked_label, None, content, True)
352
+
353
+ if response["type"] != type:
354
+ raise PromptError(
355
+ "type_mismatch",
356
+ f'{name} is a {response["type"]} prompt; pass type="{response["type"]}"',
357
+ )
358
+ rollout = response.get("rollout")
359
+ chosen: Mapping[str, Any] = response
360
+ if rollout is not None and _in_rollout(response, rollout["percent"], enabled):
361
+ chosen = rollout
362
+ return _make_prompt(response["name"], type, response["label"], chosen["version"], chosen, False)
363
+
364
+
365
+ _compiled_variables: WeakKeyDictionary[ChatPrompt | TextPrompt, dict[str, Any]] = (
366
+ WeakKeyDictionary()
367
+ )
368
+
369
+
370
+ @dataclass(eq=False)
371
+ class _PromptScope:
372
+ """The prompt of a ``with_prompt`` context and the variables of the last ``compile()`` in it.
373
+ Concurrent tasks in the context share the scope; see ``_task_variables``."""
374
+
375
+ prompt: Prompt
376
+ variables: dict[str, Any] | None
377
+
378
+
379
+ # The variables of the last compile() of a scope in the current task. A new asyncio task or a
380
+ # copied context gets a copy, so its compile does not change the value of other tasks.
381
+ _task_variables: ContextVar[tuple[_PromptScope, dict[str, Any]] | None] = ContextVar(
382
+ "telemetry_dev_prompt_variables", default=None
383
+ )
384
+
385
+
386
+ def _record(
387
+ prompt: Prompt, variables: Mapping[str, Any] | None, kwargs: Mapping[str, Any]
388
+ ) -> dict[str, Any]:
389
+ values = {**(variables or {}), **kwargs}
390
+ _compiled_variables[prompt] = values
391
+ scope = otel_context.get_value(_PROMPT_KEY)
392
+ if isinstance(scope, _PromptScope) and scope.prompt is prompt:
393
+ scope.variables = values
394
+ _task_variables.set((scope, values))
395
+ return values
396
+
397
+
398
+ def _fill(text: str, values: Mapping[str, Any]) -> str:
399
+ def replace(match: re.Match[str]) -> str:
400
+ value = values.get(match.group(1))
401
+ if value is None:
402
+ return ""
403
+ return value if isinstance(value, str) else json_dumps(value)
404
+
405
+ return _VARIABLE.sub(replace, text)
406
+
407
+
408
+ def _check(
409
+ name: object,
410
+ type: object,
411
+ label: str | None,
412
+ version: object,
413
+ cache_ttl_seconds: object,
414
+ fallback: object,
415
+ ) -> None:
416
+ if not isinstance(name, str) or not name:
417
+ raise TypeError("name must be a non-empty string")
418
+ if type not in ("chat", "text"):
419
+ raise TypeError('type must be "chat" or "text"')
420
+ if label is not None and version is not None:
421
+ raise TypeError("Pass label or version, not both")
422
+ if version is not None and (
423
+ isinstance(version, bool) or not isinstance(version, int) or version < 1
424
+ ):
425
+ raise ValueError("version must be a positive integer")
426
+ if (
427
+ isinstance(cache_ttl_seconds, bool)
428
+ or not isinstance(cache_ttl_seconds, int | float)
429
+ or not 0 <= cache_ttl_seconds < float("inf")
430
+ ):
431
+ raise ValueError("cache_ttl_seconds must be a non-negative number")
432
+ if fallback is not None and not isinstance(fallback, list if type == "chat" else str):
433
+ raise TypeError(
434
+ "fallback must be a list of messages" if type == "chat" else "fallback must be a string"
435
+ )
436
+
437
+
438
+ @dataclass
439
+ class _Entry:
440
+ response: dict[str, Any] | None = None
441
+ # Why the last fetch failed, when there was no response to keep.
442
+ error: Exception | None = None
443
+ # When the last fetch ended.
444
+ checked_at: float = 0.0
445
+ # No fetch before this time: the Retry-After of the last 429 or 503 response.
446
+ retry_at: float = 0.0
447
+ # Set when the running fetch ends.
448
+ pending: threading.Event | None = None
449
+ # When calls stop waiting for the running fetch.
450
+ deadline: float = 0.0
451
+
452
+
453
+ _cache: OrderedDict[tuple[str, str, str, str], _Entry] = OrderedDict()
454
+ _cache_lock = threading.Lock()
455
+
456
+
457
+ def _load(
458
+ key: tuple[str, str, str, str],
459
+ ttl_seconds: float,
460
+ fetch: Callable[[], dict[str, Any]],
461
+ on_error: Callable[[BaseException], None] | None,
462
+ ) -> dict[str, Any]:
463
+ """Return the kept copy of a prompt, and fetch a new copy in a background thread when it is
464
+ older than the TTL. Only a call with no copy waits for a fetch, until the fetch's deadline: the
465
+ timeout of urllib applies to each socket operation, not to the whole fetch. A failed fetch with
466
+ no copy fails the calls of the next ``_ERROR_TTL_S`` (or the TTL, if it is shorter) without a
467
+ fetch."""
468
+ with _cache_lock:
469
+ entry = _cache.get(key)
470
+ if entry is None:
471
+ if len(_cache) >= _MAX_CACHE_ENTRIES:
472
+ _cache.popitem(last=False)
473
+ entry = _cache[key] = _Entry()
474
+ now = monotonic()
475
+ ttl = ttl_seconds if entry.response is not None else min(ttl_seconds, _ERROR_TTL_S)
476
+ if now >= max(entry.checked_at + ttl, entry.retry_at) and entry.pending is None:
477
+ entry.pending = threading.Event()
478
+ entry.deadline = now + _FETCH_TIMEOUT_S
479
+ threading.Thread(
480
+ target=_refresh, args=(entry, entry.pending, fetch, on_error), daemon=True
481
+ ).start()
482
+ if entry.response is not None:
483
+ return entry.response
484
+ pending, deadline = entry.pending, entry.deadline
485
+ if pending is not None and not pending.wait(deadline - monotonic()):
486
+ raise PromptError("timeout", f"Fetching the prompt took more than {_FETCH_TIMEOUT_S:g} s")
487
+ with _cache_lock:
488
+ response, error = entry.response, entry.error
489
+ if response is not None:
490
+ return response
491
+ assert error is not None # A fetch that ends with no copy keeps its error.
492
+ # Raise a new exception: each raise of one exception object adds to its traceback.
493
+ if isinstance(error, PromptError):
494
+ raise PromptError(error.code, error.message, error.status) from error
495
+ raise PromptError("network_error", "Could not reach telemetry.dev") from error
496
+
497
+
498
+ def _refresh(
499
+ entry: _Entry,
500
+ done: threading.Event,
501
+ fetch: Callable[[], dict[str, Any]],
502
+ on_error: Callable[[BaseException], None] | None,
503
+ ) -> None:
504
+ response: dict[str, Any] | None = None
505
+ error: Exception | None = None
506
+ try:
507
+ response = fetch()
508
+ except Exception as exc:
509
+ error = exc
510
+ with _cache_lock:
511
+ stale = entry.response
512
+ delay = (
513
+ error._retry_after # pyright: ignore[reportPrivateUsage]
514
+ if isinstance(error, PromptError)
515
+ else None
516
+ )
517
+ entry.retry_at = 0.0 if delay is None else monotonic() + delay
518
+ if response is not None:
519
+ entry.response = response
520
+ elif stale is None:
521
+ entry.error = error
522
+ # A failed fetch counts too, so that calls do not each start a fetch while it fails.
523
+ entry.checked_at = monotonic()
524
+ entry.pending = None
525
+ if error is not None and stale is not None:
526
+ report_error(on_error, "fetching the prompt failed; using the last copy", error)
527
+ done.set()
528
+
529
+
530
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
531
+ # A redirect to another host must not get the API key, so a 3xx fails like any status that is
532
+ # not 2xx.
533
+ def redirect_request(
534
+ self,
535
+ req: urllib.request.Request,
536
+ fp: IO[bytes],
537
+ code: int,
538
+ msg: str,
539
+ headers: Message,
540
+ newurl: str,
541
+ ) -> urllib.request.Request | None:
542
+ return None
543
+
544
+
545
+ _opener = urllib.request.build_opener(_NoRedirect)
546
+
547
+
548
+ def _fetch(url: str, headers: dict[str, str]) -> dict[str, Any]:
549
+ request = urllib.request.Request(url, headers=headers, method="GET")
550
+ try:
551
+ with _opener.open(request, timeout=_FETCH_TIMEOUT_S) as response:
552
+ body: bytes = response.read()
553
+ except urllib.error.HTTPError as exc:
554
+ with exc:
555
+ error = _api_error(exc.code, exc.read())
556
+ if exc.code in (429, 503):
557
+ retry_after = _retry_after(exc.headers.get("Retry-After"))
558
+ error._retry_after = retry_after # pyright: ignore[reportPrivateUsage]
559
+ raise error from None
560
+ except TimeoutError as exc:
561
+ raise PromptError(
562
+ "timeout", f"Fetching the prompt took more than {_FETCH_TIMEOUT_S:g} s"
563
+ ) from exc
564
+ except (urllib.error.URLError, OSError) as exc:
565
+ if isinstance(getattr(exc, "reason", None), TimeoutError):
566
+ raise PromptError(
567
+ "timeout", f"Fetching the prompt took more than {_FETCH_TIMEOUT_S:g} s"
568
+ ) from exc
569
+ raise PromptError("network_error", "Could not reach telemetry.dev") from exc
570
+ try:
571
+ parsed = _parse_response(json.loads(body))
572
+ except ValueError:
573
+ parsed = None
574
+ if parsed is None:
575
+ raise PromptError("invalid_response", "telemetry.dev returned a prompt it cannot read")
576
+ return parsed
577
+
578
+
579
+ def _api_error(status: int, body: bytes) -> PromptError:
580
+ try:
581
+ error = json.loads(body).get("error")
582
+ except (ValueError, AttributeError):
583
+ error = None
584
+ fields = cast("dict[str, Any]", error) if isinstance(error, dict) else {}
585
+ code = fields.get("code")
586
+ message = fields.get("message")
587
+ return PromptError(
588
+ code if isinstance(code, str) else f"http_{status}",
589
+ message if isinstance(message, str) else f"telemetry.dev returned {status}",
590
+ status,
591
+ )
592
+
593
+
594
+ def _retry_after(value: str | None) -> float | None:
595
+ """Seconds to wait from a Retry-After header of seconds or an IMF-fixdate HTTP date
596
+ (``Sun, 06 Nov 1994 08:49:37 GMT``), capped."""
597
+ value = (value or "").strip()
598
+ if not value:
599
+ return None
600
+ if value.isascii() and value.isdigit():
601
+ seconds = float(value)
602
+ elif _HTTP_DATE.fullmatch(value):
603
+ try:
604
+ date = parsedate_to_datetime(value)
605
+ except (TypeError, ValueError):
606
+ return None
607
+ seconds = (date - datetime.now(timezone.utc)).total_seconds()
608
+ else:
609
+ return None
610
+ return min(seconds, _MAX_RETRY_AFTER_S) if seconds > 0 else None
611
+
612
+
613
+ def _parse_response(data: object) -> dict[str, Any] | None:
614
+ if not isinstance(data, dict):
615
+ return None
616
+ body = cast("dict[str, Any]", data)
617
+ prompt_type = body.get("type")
618
+ label = body.get("label")
619
+ if (
620
+ not isinstance(body.get("name"), str)
621
+ or prompt_type not in ("chat", "text")
622
+ or "label" not in body
623
+ or not (label is None or isinstance(label, str))
624
+ or not _is_version(body.get("version"))
625
+ or not _valid_content(body, prompt_type)
626
+ ):
627
+ return None
628
+ rollout = body.get("rollout")
629
+ if rollout is None:
630
+ return body
631
+ if not isinstance(rollout, dict):
632
+ return None
633
+ rollout_body = cast("dict[str, Any]", rollout)
634
+ percent = rollout_body.get("percent")
635
+ if (
636
+ not _is_version(rollout_body.get("version"))
637
+ or isinstance(percent, bool)
638
+ or not isinstance(percent, int)
639
+ or not 0 <= percent <= 100
640
+ or not _valid_content(rollout_body, prompt_type)
641
+ ):
642
+ return None
643
+ return body
644
+
645
+
646
+ def _valid_content(body: Mapping[str, Any], prompt_type: str) -> bool:
647
+ messages = body.get("messages")
648
+ model = body.get("model")
649
+ tools = body.get("tools")
650
+ response_format = body.get("responseFormat")
651
+ valid_body = (
652
+ isinstance(messages, list) and all(_is_message(m) for m in cast("list[Any]", messages))
653
+ if prompt_type == "chat"
654
+ else isinstance(body.get("text"), str)
655
+ )
656
+ return (
657
+ valid_body
658
+ and isinstance(model, dict)
659
+ and isinstance(cast("dict[str, Any]", model).get("provider"), str)
660
+ and isinstance(cast("dict[str, Any]", model).get("name"), str)
661
+ and isinstance(body.get("parameters"), dict)
662
+ and isinstance(tools, list)
663
+ and all(
664
+ isinstance(tool, dict) and isinstance(cast("dict[str, Any]", tool).get("name"), str)
665
+ for tool in cast("list[Any]", tools)
666
+ )
667
+ and isinstance(response_format, dict)
668
+ and isinstance(cast("dict[str, Any]", response_format).get("type"), str)
669
+ )
670
+
671
+
672
+ def _is_message(value: object) -> bool:
673
+ if not isinstance(value, dict):
674
+ return False
675
+ message = cast("dict[str, Any]", value)
676
+ if message.get("role") == "placeholder":
677
+ return isinstance(message.get("name"), str)
678
+ return message.get("role") in ("system", "user", "assistant") and isinstance(
679
+ message.get("content"), str
680
+ )
681
+
682
+
683
+ def _is_version(value: object) -> bool:
684
+ return isinstance(value, int) and not isinstance(value, bool) and value >= 1
685
+
686
+
687
+ def _make_prompt(
688
+ name: str,
689
+ prompt_type: Literal["chat", "text"],
690
+ label: str | None,
691
+ version: int | None,
692
+ content: Mapping[str, Any],
693
+ is_fallback: bool,
694
+ ) -> Prompt:
695
+ # Each prompt gets its own copy, so an app cannot change the cached response through it.
696
+ model: PromptModel | None = copy.deepcopy(content["model"])
697
+ parameters: dict[str, Any] = copy.deepcopy(content["parameters"])
698
+ tools: list[dict[str, Any]] = copy.deepcopy(content["tools"])
699
+ response_format: dict[str, Any] = copy.deepcopy(content["responseFormat"])
700
+ if prompt_type == "chat":
701
+ return ChatPrompt(
702
+ name=name,
703
+ version=version,
704
+ label=label,
705
+ messages=copy.deepcopy(content["messages"]),
706
+ model=model,
707
+ parameters=parameters,
708
+ tools=tools,
709
+ response_format=response_format,
710
+ is_fallback=is_fallback,
711
+ )
712
+ return TextPrompt(
713
+ name=name,
714
+ version=version,
715
+ label=label,
716
+ text=content["text"],
717
+ model=model,
718
+ parameters=parameters,
719
+ tools=tools,
720
+ response_format=response_format,
721
+ is_fallback=is_fallback,
722
+ )
723
+
724
+
725
+ def _in_rollout(response: Mapping[str, Any], percent: int, client: Client | None) -> bool:
726
+ session_id = propagated_attributes().get(ATTR_SESSION_ID)
727
+ if not isinstance(session_id, str) and client is not None:
728
+ session_id = client._process_session_id # pyright: ignore[reportPrivateUsage]
729
+ if not isinstance(session_id, str):
730
+ return random.randrange(100) < percent
731
+ return _fnv1a(f"{response['name']}:{response['label']}:{session_id}") % 100 < percent
732
+
733
+
734
+ def _fnv1a(text: str) -> int:
735
+ """FNV-1a 32-bit over the UTF-8 bytes, as TextEncoder makes them in the TypeScript SDK."""
736
+ data = text.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "replace").encode()
737
+ value = 2166136261
738
+ for byte in data:
739
+ value = ((value ^ byte) * 16777619) & 0xFFFFFFFF
740
+ return value
@@ -84,6 +84,11 @@ ATTR_TIME_TO_FIRST_CHUNK = "gen_ai.response.time_to_first_chunk"
84
84
 
85
85
  METADATA_PREFIX = "td.metadata."
86
86
 
87
+ ATTR_PROMPT_NAME = "td.prompt.name"
88
+ ATTR_PROMPT_VERSION = "td.prompt.version"
89
+ ATTR_PROMPT_LABEL = "td.prompt.label"
90
+ ATTR_PROMPT_VARIABLES = "td.prompt.variables"
91
+
87
92
  USAGE_ATTRS: dict[str, str] = {
88
93
  "input_tokens": "gen_ai.usage.input_tokens",
89
94
  "output_tokens": "gen_ai.usage.output_tokens",
@@ -29,6 +29,7 @@ from ._context import (
29
29
  traceparent_of,
30
30
  with_session_parent,
31
31
  )
32
+ from ._prompts import prompt_attributes
32
33
  from ._semconv import (
33
34
  ATTR_AGENT_ID,
34
35
  ATTR_AGENT_NAME,
@@ -743,6 +744,11 @@ def start_span(
743
744
  ctx, session_id if isinstance(session_id, str) else None, client.config.api_key
744
745
  )
745
746
  initial.update(propagated)
747
+ initial.update(
748
+ prompt_attributes(
749
+ client, ctx, attrs.get(ATTR_OPERATION, operation), state.capture_input
750
+ )
751
+ )
746
752
  initial.update(attrs)
747
753
  span = client.tracer.start_span(
748
754
  name,
File without changes