|
1 | | -"""Langfuse-backed PromptBackend (text prompts). |
| 1 | +"""Langfuse-backed PromptBackend (text + chat prompts). |
2 | 2 |
|
3 | 3 | Fetches prompts from Langfuse's prompt registry through OA's |
4 | 4 | ``PromptManager``. Gated behind the ``[langfuse]`` extra; import this |
5 | 5 | module only when ``langfuse`` is installed (``backends/__init__`` does |
6 | 6 | not import it, so the base package stays langfuse-free). |
7 | 7 |
|
8 | | -v1 supports Langfuse TEXT prompts. A Langfuse CHAT prompt raises |
9 | | -``PromptNotFound`` because OA's render produces a single user message |
10 | | -today; multi-message (chat) prompt support is tracked for a later |
11 | | -release. |
| 8 | +Per proposal 0046 (v0.38.0): both Langfuse TEXT and CHAT prompts are |
| 9 | +supported. Text prompts return a :class:`TextPrompt`; chat prompts |
| 10 | +return a :class:`ChatPrompt` with one :class:`ContentSegment` per |
| 11 | +Langfuse chat message. Langfuse chat placeholders map to |
| 12 | +:class:`PlaceholderSegment` entries. |
12 | 13 | """ |
13 | 14 |
|
14 | 15 | from __future__ import annotations |
15 | 16 |
|
16 | 17 | import asyncio |
| 18 | +import json |
| 19 | +from collections.abc import Iterable |
17 | 20 | from datetime import UTC, datetime |
18 | | -from typing import Any, Protocol |
| 21 | +from typing import Any, Protocol, cast |
19 | 22 |
|
20 | 23 | import httpx |
21 | 24 | from langfuse.api import NotFoundError, ServiceUnavailableError |
22 | 25 | from langfuse.model import ChatPromptClient, TextPromptClient |
23 | 26 |
|
24 | 27 | from ..errors import PromptNotFound, PromptStoreUnavailable |
25 | 28 | from ..hashing import compute_template_hash |
26 | | -from ..prompt import Prompt, SamplingConfig |
| 29 | +from ..prompt import ( |
| 30 | + ChatPrompt, |
| 31 | + ChatSegment, |
| 32 | + ContentSegment, |
| 33 | + PlaceholderSegment, |
| 34 | + Prompt, |
| 35 | + SamplingConfig, |
| 36 | + TextPrompt, |
| 37 | +) |
27 | 38 |
|
28 | 39 |
|
29 | 40 | class LangfusePromptClient(Protocol): |
@@ -83,18 +94,34 @@ async def fetch(self, name: str, label: str = "production") -> Prompt: |
83 | 94 | result = await asyncio.to_thread(self._get_prompt, name, label) |
84 | 95 |
|
85 | 96 | if isinstance(result, ChatPromptClient): |
86 | | - raise PromptNotFound( |
87 | | - f"prompt ({name!r}, {label!r}) is a Langfuse chat prompt; " |
88 | | - "the Langfuse backend supports text prompts only in this " |
89 | | - "release (multi-message prompt support is planned)", |
| 97 | + normalized = _normalized_langfuse_entries(result.prompt, name=name, label=label) |
| 98 | + chat_template = list(_chat_segments_from_normalized(normalized)) |
| 99 | + template_hash = compute_template_hash(json.dumps(normalized, sort_keys=True)) |
| 100 | + # ``ChatPrompt.model_construct`` is required (not the |
| 101 | + # plain constructor): pydantic re-runs validators on |
| 102 | + # nested field values when validating the outer model, |
| 103 | + # so a placeholder name we bypassed at the |
| 104 | + # ``PlaceholderSegment`` level would still trip the |
| 105 | + # regex check during ChatPrompt construction. Bypass |
| 106 | + # the outer validators too so the malformed input |
| 107 | + # reaches render-time (the spec-normative §11 error |
| 108 | + # trigger). |
| 109 | + return ChatPrompt.model_construct( |
| 110 | + kind="chat", |
90 | 111 | name=name, |
| 112 | + version=str(result.version), |
91 | 113 | label=label, |
92 | | - backend="langfuse", |
| 114 | + chat_template=chat_template, |
| 115 | + template_hash=template_hash, |
| 116 | + fetched_at=datetime.now(UTC), |
| 117 | + sampling=_sampling_from_config(result.config), |
| 118 | + observability_entities={"langfuse_prompt": result}, |
| 119 | + metadata=_metadata_from(result), |
93 | 120 | ) |
94 | 121 |
|
95 | 122 | template = result.prompt |
96 | 123 | template_hash = compute_template_hash(template) |
97 | | - return Prompt( |
| 124 | + return TextPrompt( |
98 | 125 | name=name, |
99 | 126 | version=str(result.version), |
100 | 127 | label=label, |
@@ -138,7 +165,83 @@ def _sampling_from_config(config: dict[str, Any] | None) -> SamplingConfig | Non |
138 | 165 | return SamplingConfig(**declared) |
139 | 166 |
|
140 | 167 |
|
141 | | -def _metadata_from(result: TextPromptClient) -> dict[str, Any]: |
| 168 | +def _normalized_langfuse_entries(raw: Iterable[Any], *, name: str, label: str) -> list[dict[str, Any]]: |
| 169 | + """Normalize a Langfuse ``ChatPromptClient.prompt`` list to OA |
| 170 | + canonical entry dicts. Each output entry is either a content |
| 171 | + message ``{"role": ..., "content": ...}`` or a placeholder |
| 172 | + marker ``{"type": "placeholder", "name": ...}``. |
| 173 | +
|
| 174 | + Fails closed on any entry whose shape this mapper doesn't |
| 175 | + recognize. Silent skipping is the wrong posture for a fetch- |
| 176 | + side mapper: a Langfuse SDK extension (or a malformed entry) |
| 177 | + would otherwise produce a degraded rendered prompt with zero |
| 178 | + signal to the caller — exactly the kind of bug that changes |
| 179 | + model behavior invisibly. ``PromptNotFound`` is the canonical |
| 180 | + "we got the prompt but couldn't fully deserialize it" signal, |
| 181 | + matching how the backend handles other fetch-side failures. |
| 182 | +
|
| 183 | + ``name`` and ``label`` are threaded through purely for error |
| 184 | + context on the ``PromptNotFound`` carriers. |
| 185 | + """ |
| 186 | + out: list[dict[str, Any]] = [] |
| 187 | + for raw_entry in raw: |
| 188 | + if not isinstance(raw_entry, dict): |
| 189 | + raise PromptNotFound( |
| 190 | + f"Langfuse chat-prompt entry has unsupported shape: " |
| 191 | + f"expected dict, got {type(raw_entry).__name__}", |
| 192 | + name=name, |
| 193 | + label=label, |
| 194 | + backend="langfuse", |
| 195 | + ) |
| 196 | + entry = cast("dict[str, Any]", raw_entry) |
| 197 | + entry_type = entry.get("type") |
| 198 | + if entry_type == "placeholder": |
| 199 | + placeholder_name = entry.get("name") |
| 200 | + if not isinstance(placeholder_name, str): |
| 201 | + raise PromptNotFound( |
| 202 | + f"Langfuse placeholder entry missing or invalid 'name': {entry!r}", |
| 203 | + name=name, |
| 204 | + label=label, |
| 205 | + backend="langfuse", |
| 206 | + ) |
| 207 | + out.append({"type": "placeholder", "name": placeholder_name}) |
| 208 | + continue |
| 209 | + role = entry.get("role") |
| 210 | + content = entry.get("content") |
| 211 | + if role in {"system", "user", "assistant"} and isinstance(content, str): |
| 212 | + out.append({"role": role, "content": content}) |
| 213 | + continue |
| 214 | + raise PromptNotFound( |
| 215 | + f"Langfuse chat-prompt entry has unsupported role/content shape: {entry!r}", |
| 216 | + name=name, |
| 217 | + label=label, |
| 218 | + backend="langfuse", |
| 219 | + ) |
| 220 | + return out |
| 221 | + |
| 222 | + |
| 223 | +def _chat_segments_from_normalized( |
| 224 | + entries: Iterable[dict[str, Any]], |
| 225 | +) -> Iterable[ChatSegment]: |
| 226 | + """Map a normalized canonical entry list to OA |
| 227 | + :class:`ChatSegment` entries. Placeholder segments use |
| 228 | + ``model_construct`` so a Langfuse-stored prompt with a |
| 229 | + malformed placeholder name (e.g., leading-digit) reaches the |
| 230 | + render path before raising — the spec-normative §11 error |
| 231 | + trigger. Content segments go through the normal pydantic |
| 232 | + constructor since their fields don't carry spec-§11 constraints |
| 233 | + that hand-built callers would benefit from catching earlier.""" |
| 234 | + for entry in entries: |
| 235 | + if entry.get("type") == "placeholder": |
| 236 | + yield PlaceholderSegment.model_construct( |
| 237 | + type="placeholder", |
| 238 | + placeholder=entry["name"], |
| 239 | + ) |
| 240 | + else: |
| 241 | + yield ContentSegment(role=entry["role"], content=entry["content"]) |
| 242 | + |
| 243 | + |
| 244 | +def _metadata_from(result: TextPromptClient | ChatPromptClient) -> dict[str, Any]: |
142 | 245 | # Preserve Langfuse-side attribution. `config` is kept whole here |
143 | 246 | # even though sampling fields are also lifted to `Prompt.sampling`, |
144 | 247 | # so non-sampling config keys aren't dropped. |
|
0 commit comments