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