Skip to content

Commit 2d85a68

Browse files
chore(llmobs): restore and deprecate managed prompt label (#19399)
## Description - Restored `ManagedPrompt.label` removed in #19047 - Direct access emits debtcollector warning - Internal reads do not warn ## Testing <!-- Describe your testing strategy or note what tests are included --> ## Risks <!-- Note any risks associated with this change, or "None" if no risks --> ## Additional Notes <!-- Any other information that would be helpful for reviewers --> Co-authored-by: brettlangdon <brett.langdon@datadoghq.com>
1 parent 51f9740 commit 2d85a68

4 files changed

Lines changed: 50 additions & 16 deletions

File tree

ddtrace/llmobs/_prompts/manager.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ def _fetch_http(self, req: _PromptRequest, timeout: float) -> tuple[Optional[Man
451451

452452
if status == 200:
453453
source: Literal["registry", "resolve"] = "resolve" if req.use_resolve else "registry"
454-
prompt = self._parse_prompt(body, source=source)
454+
prompt = self._parse_prompt(body, source=source, label=req.label)
455455
return prompt, False, ""
456456

457457
not_found = status == 404
@@ -484,6 +484,7 @@ def _build_path(self, prompt_id: str, label: Optional[str], version: Optional[in
484484
def _parse_prompt(
485485
raw: Union[str, dict[str, Any]],
486486
source: Literal["registry", "ff", "resolve"],
487+
label: Optional[str] = None,
487488
) -> Optional[ManagedPrompt]:
488489
try:
489490
if isinstance(raw, str):
@@ -504,6 +505,7 @@ def _parse_prompt(
504505
return ManagedPrompt(
505506
id=prompt_id,
506507
version=str(version),
508+
label=data.get("label", label),
507509
source=source,
508510
template=extract_template(data, default=[]),
509511
_uuid=data.get("prompt_uuid"),

ddtrace/llmobs/_prompts/prompt.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
from typing import Optional
66
from typing import Union
77

8+
from ddtrace.internal.utils.deprecations import DDTraceDeprecationWarning
89
from ddtrace.llmobs._prompts.utils import extract_template
910
from ddtrace.llmobs._prompts.utils import render_chat
1011
from ddtrace.llmobs._prompts.utils import safe_substitute
1112
from ddtrace.llmobs._utils import attach_prompt
1213
from ddtrace.llmobs.types import Message
1314
from ddtrace.llmobs.types import Prompt
1415
from ddtrace.llmobs.types import PromptFallback
16+
from ddtrace.vendor.debtcollector import deprecate
1517

1618

1719
@dataclass(frozen=True)
@@ -24,16 +26,25 @@ class ManagedPrompt:
2426
- to_annotation_dict(**vars) -> dict[str, Any]
2527
2628
INTERNAL (may change):
27-
- All fields (id, version, source, template)
29+
- All fields (id, version, label, source, template)
2830
"""
2931

3032
id: str
3133
version: str
34+
label: Optional[str]
3235
source: Literal["registry", "cache", "fallback", "ff", "resolve"]
3336
template: Union[str, list[Message]]
3437
_uuid: Optional[str] = None
3538
_version_uuid: Optional[str] = None
3639

40+
def __getattribute__(self, name: str) -> Any:
41+
if name == "label":
42+
deprecate( # type: ignore[no-untyped-call]
43+
prefix="The 'label' property of ManagedPrompt is deprecated",
44+
category=DDTraceDeprecationWarning,
45+
)
46+
return object.__getattribute__(self, name)
47+
3748
def format(self, **variables: str) -> Union[str, list[Message]]:
3849
"""
3950
Render the template with variables.
@@ -73,6 +84,9 @@ def to_annotation_dict(self, **variables: Any) -> Prompt:
7384
}
7485
if variables:
7586
result["variables"] = variables
87+
label = object.__getattribute__(self, "label")
88+
if label:
89+
result["label"] = label
7690
if self._uuid:
7791
result["prompt_uuid"] = self._uuid
7892
if self._version_uuid:
@@ -86,13 +100,15 @@ def to_annotation_dict(self, **variables: Any) -> Prompt:
86100
return result
87101

88102
def __repr__(self) -> str:
89-
return f"ManagedPrompt(id={self.id!r}, version={self.version!r}, source={self.source!r})"
103+
label = object.__getattribute__(self, "label")
104+
return f"ManagedPrompt(id={self.id!r}, version={self.version!r}, label={label!r}, source={self.source!r})"
90105

91106
def _serialize(self) -> dict[str, Any]:
92107
"""Serialize to a JSON-compatible dict for cache storage."""
93108
return {
94109
"id": self.id,
95110
"version": self.version,
111+
"label": object.__getattribute__(self, "label"),
96112
"source": self.source,
97113
"template": self.template,
98114
"_uuid": self._uuid,
@@ -105,6 +121,7 @@ def _deserialize(cls, data: dict[str, Any]) -> "ManagedPrompt":
105121
return cls(
106122
id=data["id"],
107123
version=data["version"],
124+
label=data["label"],
108125
source=data.get("source", "cache"),
109126
template=data["template"],
110127
_uuid=data.get("_uuid"),
@@ -115,7 +132,7 @@ def _with_source(self, source: Literal["registry", "cache", "fallback"]) -> "Man
115132
"""Create a copy with a different source. Used internally for caching."""
116133
if self.source == source:
117134
return self
118-
return replace(self, source=source)
135+
return replace(self, source=source, label=object.__getattribute__(self, "label"))
119136

120137
@classmethod
121138
def from_fallback(
@@ -130,7 +147,7 @@ def from_fallback(
130147
fallback: A string, message list, Prompt dict, or callable returning any of those.
131148
132149
Returns:
133-
A ManagedPrompt with source="fallback".
150+
A ManagedPrompt with source="fallback" and label=None.
134151
"""
135152
template: Union[str, list[Message]] = ""
136153
version = "fallback"
@@ -146,6 +163,7 @@ def from_fallback(
146163
return cls(
147164
id=prompt_id,
148165
version=version,
166+
label=None,
149167
source="fallback",
150168
template=template,
151169
)
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
---
22
deprecations:
33
- |
4-
LLM Observability: Managed prompts no longer expose deprecated deployment label metadata. #19047
4+
LLM Observability: The ``ManagedPrompt.label`` property is deprecated. #19399

tests/llmobs/test_prompts.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -206,16 +206,29 @@ def test_label_parameter(self):
206206
with pytest.warns(DDTraceDeprecationWarning):
207207
prod_prompt = LLMObs.get_prompt("greeting", label="production")
208208
assert prod_prompt.version == "v1"
209-
assert all(not hasattr(prod_prompt, field) for field in ("label", "labels"))
209+
with pytest.warns(DDTraceDeprecationWarning):
210+
assert prod_prompt.label == "production"
210211

211212
LLMObs.clear_prompt_cache(hot=True, warm=True)
212213

213214
with mock_api(200, DEV_PROMPT_RESPONSE):
214215
with pytest.warns(DDTraceDeprecationWarning):
215216
dev_prompt = LLMObs.get_prompt("greeting", label="development")
216217
assert dev_prompt.version == "dev-v1"
218+
with pytest.warns(DDTraceDeprecationWarning):
219+
assert dev_prompt.label == "development"
217220
assert "DEBUG" in dev_prompt.format(name="Test")
218221

222+
def test_internal_label_reads_do_not_warn(self):
223+
prompt = ManagedPrompt(id="greeting", version="v1", label="production", source="registry", template="Hello!")
224+
225+
with warnings.catch_warnings():
226+
warnings.simplefilter("error", DDTraceDeprecationWarning)
227+
assert prompt.to_annotation_dict()["label"] == "production"
228+
assert "label='production'" in repr(prompt)
229+
assert prompt._serialize()["label"] == "production"
230+
assert prompt._with_source("cache").source == "cache"
231+
219232
def test_string_fallback_on_error(self):
220233
"""String fallback used when API returns 500."""
221234
with mock_api(500, "Internal Server Error"):
@@ -498,7 +511,7 @@ def test_route_env_agent_to_ff(self):
498511

499512
def test_route_env_agentless_to_http_resolve(self):
500513
manager = _make_manager(agentless=True)
501-
sentinel = ManagedPrompt(id="greeting", version="v1", source="resolve", template="Hi")
514+
sentinel = ManagedPrompt(id="greeting", version="v1", label="production", source="resolve", template="Hi")
502515
with patch.object(manager, "_fetch_from_ff") as ff_mock:
503516
with patch.object(manager, "_get_prompt_http", return_value=sentinel) as http_mock:
504517
with patch("ddtrace.llmobs._prompts.manager.config") as cfg:
@@ -541,6 +554,7 @@ def test_route_targeting_key_to_ff(self):
541554
ff_prompt = ManagedPrompt(
542555
id="greeting",
543556
version="ff-v1",
557+
label=None,
544558
source="ff",
545559
template="Hello!",
546560
)
@@ -564,7 +578,7 @@ def test_route_label_targeting_conflict_warns(self):
564578
# which resolves the same env-scoped variant server-side.
565579
def test_route_not_ready_to_http_resolve(self):
566580
manager = _make_manager()
567-
sentinel = ManagedPrompt(id="greeting", version="v1", source="resolve", template="Hi")
581+
sentinel = ManagedPrompt(id="greeting", version="v1", label=None, source="resolve", template="Hi")
568582
with _ffe_enabled():
569583
with patch.object(manager, "_get_prompt_http", return_value=sentinel) as http_mock:
570584
prompt = manager.get_prompt("greeting")
@@ -575,7 +589,7 @@ def test_route_not_ready_to_http_resolve(self):
575589

576590
def test_route_no_flag_to_http_resolve(self):
577591
manager = _make_manager()
578-
sentinel = ManagedPrompt(id="greeting", version="v1", source="resolve", template="Hi")
592+
sentinel = ManagedPrompt(id="greeting", version="v1", label=None, source="resolve", template="Hi")
579593
with _ffe_enabled():
580594
_deliver_prompt_flag("other-prompt", {"prompt_id": "other-prompt", "version": "1", "template": "x"})
581595
with patch.object(manager, "_get_prompt_http", return_value=sentinel) as http_mock:
@@ -762,7 +776,7 @@ def test_delete_prompt_evicts_cache(self):
762776
manager = _make_manager()
763777
manager._hot_cache.set(
764778
"my-prompt:production",
765-
ManagedPrompt(id="my-prompt", version="v1", source="registry", template=[]),
779+
ManagedPrompt(id="my-prompt", version="v1", label="production", source="registry", template=[]),
766780
)
767781
assert len(manager._hot_cache) == 1
768782

@@ -860,11 +874,11 @@ def test_hot_evict_does_not_over_evict_colon_prefixed_ids(self):
860874
manager = _make_manager()
861875
manager._hot_cache.set(
862876
"foo:production",
863-
ManagedPrompt(id="foo", version="v1", source="registry", template=[]),
877+
ManagedPrompt(id="foo", version="v1", label="production", source="registry", template=[]),
864878
)
865879
manager._hot_cache.set(
866880
"foo:bar:production",
867-
ManagedPrompt(id="foo:bar", version="v1", source="registry", template=[]),
881+
ManagedPrompt(id="foo:bar", version="v1", label="production", source="registry", template=[]),
868882
)
869883
assert len(manager._hot_cache) == 2
870884

@@ -876,8 +890,8 @@ def test_hot_evict_does_not_over_evict_colon_prefixed_ids(self):
876890
def test_warm_cache_distinct_ids_do_not_collide_on_path(self, tmp_path):
877891
"""Regression: 'a/b' and 'a_b' must not share a cache file (lossy sanitization served wrong prompts)."""
878892
cache = WarmCache(cache_dir=str(tmp_path), ttl_seconds=60)
879-
cache.set("a/b:", ManagedPrompt(id="a/b", version="v1", source="registry", template=[]))
880-
cache.set("a_b:", ManagedPrompt(id="a_b", version="v2", source="registry", template=[]))
893+
cache.set("a/b:", ManagedPrompt(id="a/b", version="v1", label=None, source="registry", template=[]))
894+
cache.set("a_b:", ManagedPrompt(id="a_b", version="v2", label=None, source="registry", template=[]))
881895

882896
assert cache.get("a/b:")[0].id == "a/b"
883897
assert cache.get("a_b:")[0].id == "a_b"
@@ -909,7 +923,7 @@ def test_hot_cache_lru_eviction():
909923
cache = HotCache(ttl_seconds=60, maxsize=2)
910924

911925
def mk(v):
912-
return ManagedPrompt(id=v, version="1", source="resolve", template="x")
926+
return ManagedPrompt(id=v, version="1", label=None, source="resolve", template="x")
913927

914928
cache.set("a", mk("a"))
915929
cache.set("b", mk("b"))

0 commit comments

Comments
 (0)