runapprentice 0.1.0__py3-none-any.whl
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.
- runapprentice/__init__.py +26 -0
- runapprentice/_capture.py +117 -0
- runapprentice/client.py +391 -0
- runapprentice/langchain.py +403 -0
- runapprentice/models.py +62 -0
- runapprentice/openai.py +126 -0
- runapprentice/py.typed +0 -0
- runapprentice-0.1.0.dist-info/METADATA +219 -0
- runapprentice-0.1.0.dist-info/RECORD +11 -0
- runapprentice-0.1.0.dist-info/WHEEL +4 -0
- runapprentice-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""LangChain integration: zero-code-change capture via callbacks.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
from runapprentice import Apprentice
|
|
5
|
+
from runapprentice.langchain import ApprenticeCallback
|
|
6
|
+
|
|
7
|
+
client = Apprentice(base_url=..., api_key=...)
|
|
8
|
+
model = init_chat_model("gpt-5.5", callbacks=[ApprenticeCallback("content_gap", client)])
|
|
9
|
+
|
|
10
|
+
Every LLM call then logs input/output/latency to the task's dataset (raw tier).
|
|
11
|
+
|
|
12
|
+
FAIL-OPEN CONTRACT: nothing in this module may ever raise into the customer's
|
|
13
|
+
call path. All capture work is wrapped; worst case is a missing trace.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import time
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from typing import Any, Callable
|
|
22
|
+
from uuid import UUID
|
|
23
|
+
|
|
24
|
+
from runapprentice.client import Apprentice
|
|
25
|
+
from runapprentice.models import TraceRecord
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
29
|
+
except ImportError: # pragma: no cover - exercised only without the optional extra
|
|
30
|
+
BaseCallbackHandler = object # type: ignore[assignment, misc]
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("runapprentice.langchain")
|
|
33
|
+
|
|
34
|
+
RedactFn = Callable[[str], str]
|
|
35
|
+
|
|
36
|
+
_SUPPORTED_FORMAT = "f-string"
|
|
37
|
+
_ROLE_BY_CLASS = {
|
|
38
|
+
"SystemMessagePromptTemplate": "system",
|
|
39
|
+
"HumanMessagePromptTemplate": "human",
|
|
40
|
+
"AIMessagePromptTemplate": "ai",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ApprenticeCallback(BaseCallbackHandler):
|
|
45
|
+
"""Captures chat-model calls for one task. Streaming/async safe: LangChain
|
|
46
|
+
invokes `on_llm_end` after the final chunk for streamed runs."""
|
|
47
|
+
|
|
48
|
+
# never let a callback error abort the customer's chain
|
|
49
|
+
raise_error = False
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
task: str,
|
|
54
|
+
client: Apprentice,
|
|
55
|
+
redact: RedactFn | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
_require_langchain()
|
|
58
|
+
self.task = task
|
|
59
|
+
self._client = client
|
|
60
|
+
self._redact = redact
|
|
61
|
+
self._starts: dict[UUID, tuple[str, float, str | None]] = {}
|
|
62
|
+
self._parents: dict[UUID, UUID | None] = {}
|
|
63
|
+
self._questions: dict[UUID, str] = {}
|
|
64
|
+
self._contexts: dict[UUID, str] = {}
|
|
65
|
+
|
|
66
|
+
# -- chain / retriever lifecycle -------------------------------------------
|
|
67
|
+
|
|
68
|
+
def on_chain_start(
|
|
69
|
+
self,
|
|
70
|
+
serialized: dict[str, Any],
|
|
71
|
+
inputs: dict[str, Any] | Any,
|
|
72
|
+
*,
|
|
73
|
+
run_id: UUID,
|
|
74
|
+
parent_run_id: UUID | None = None,
|
|
75
|
+
**kwargs: Any,
|
|
76
|
+
) -> None:
|
|
77
|
+
try:
|
|
78
|
+
self._parents[run_id] = parent_run_id
|
|
79
|
+
question = _question_text(inputs)
|
|
80
|
+
if question is not None:
|
|
81
|
+
self._questions[run_id] = question
|
|
82
|
+
except Exception as exc: # noqa: BLE001 — fail-open contract
|
|
83
|
+
logger.debug("chain capture start failed: %s", exc)
|
|
84
|
+
|
|
85
|
+
def on_chain_end(
|
|
86
|
+
self,
|
|
87
|
+
outputs: dict[str, Any] | Any,
|
|
88
|
+
*,
|
|
89
|
+
run_id: UUID,
|
|
90
|
+
**kwargs: Any,
|
|
91
|
+
) -> None:
|
|
92
|
+
try:
|
|
93
|
+
self._questions.pop(run_id, None)
|
|
94
|
+
self._contexts.pop(run_id, None)
|
|
95
|
+
self._parents.pop(run_id, None)
|
|
96
|
+
except Exception as exc: # noqa: BLE001
|
|
97
|
+
logger.debug("chain capture end failed: %s", exc)
|
|
98
|
+
|
|
99
|
+
def on_retriever_start(
|
|
100
|
+
self,
|
|
101
|
+
serialized: dict[str, Any],
|
|
102
|
+
query: str,
|
|
103
|
+
*,
|
|
104
|
+
run_id: UUID,
|
|
105
|
+
parent_run_id: UUID | None = None,
|
|
106
|
+
**kwargs: Any,
|
|
107
|
+
) -> None:
|
|
108
|
+
try:
|
|
109
|
+
self._parents[run_id] = parent_run_id
|
|
110
|
+
if parent_run_id is None:
|
|
111
|
+
self._questions[run_id] = query
|
|
112
|
+
except Exception as exc: # noqa: BLE001
|
|
113
|
+
logger.debug("retriever capture start failed: %s", exc)
|
|
114
|
+
|
|
115
|
+
def on_retriever_end(
|
|
116
|
+
self,
|
|
117
|
+
documents: Sequence[Any],
|
|
118
|
+
*,
|
|
119
|
+
run_id: UUID,
|
|
120
|
+
parent_run_id: UUID | None = None,
|
|
121
|
+
**kwargs: Any,
|
|
122
|
+
) -> None:
|
|
123
|
+
try:
|
|
124
|
+
context = _documents_context(documents)
|
|
125
|
+
if context is None:
|
|
126
|
+
return
|
|
127
|
+
# Store context on the lineage ROOT, not the immediate parent: in the
|
|
128
|
+
# canonical LCEL shape ({"context": retriever | format, "question": ...}
|
|
129
|
+
# | prompt | llm) the retriever sits under a RunnableParallel sibling, so
|
|
130
|
+
# only the shared root run is also an ancestor of the model call. The
|
|
131
|
+
# model's on_llm_end walks its lineage to that root to pair context with
|
|
132
|
+
# question. ponytail: one retriever per simple RAG chain; use explicit
|
|
133
|
+
# capture() when custom formatting or multiple retrievers matter.
|
|
134
|
+
root = _lineage(run_id, self._parents)[-1]
|
|
135
|
+
self._contexts[root] = context
|
|
136
|
+
# The retriever run gets no on_chain_end, so drop its own transient
|
|
137
|
+
# entries here — otherwise they leak for the life of the process.
|
|
138
|
+
self._parents.pop(run_id, None)
|
|
139
|
+
if root != run_id:
|
|
140
|
+
self._questions.pop(run_id, None)
|
|
141
|
+
except Exception as exc: # noqa: BLE001
|
|
142
|
+
logger.debug("retriever capture end failed: %s", exc)
|
|
143
|
+
|
|
144
|
+
# -- chat model lifecycle ---------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def on_chat_model_start(
|
|
147
|
+
self,
|
|
148
|
+
serialized: dict[str, Any],
|
|
149
|
+
messages: list[list[Any]],
|
|
150
|
+
*,
|
|
151
|
+
run_id: UUID,
|
|
152
|
+
parent_run_id: UUID | None = None,
|
|
153
|
+
**kwargs: Any,
|
|
154
|
+
) -> None:
|
|
155
|
+
try:
|
|
156
|
+
self._parents[run_id] = parent_run_id
|
|
157
|
+
text = "\n".join(f"{m.type}: {_message_text(m)}" for batch in messages for m in batch)
|
|
158
|
+
model_name = _model_name(serialized, kwargs)
|
|
159
|
+
self._starts[run_id] = (text, time.monotonic(), model_name)
|
|
160
|
+
except Exception as exc: # noqa: BLE001 — fail-open contract
|
|
161
|
+
logger.debug("capture start failed: %s", exc)
|
|
162
|
+
|
|
163
|
+
def on_llm_start(
|
|
164
|
+
self,
|
|
165
|
+
serialized: dict[str, Any],
|
|
166
|
+
prompts: list[str],
|
|
167
|
+
*,
|
|
168
|
+
run_id: UUID,
|
|
169
|
+
parent_run_id: UUID | None = None,
|
|
170
|
+
**kwargs: Any,
|
|
171
|
+
) -> None:
|
|
172
|
+
try:
|
|
173
|
+
self._parents[run_id] = parent_run_id
|
|
174
|
+
self._starts[run_id] = (
|
|
175
|
+
"\n".join(prompts),
|
|
176
|
+
time.monotonic(),
|
|
177
|
+
_model_name(serialized, kwargs),
|
|
178
|
+
)
|
|
179
|
+
except Exception as exc: # noqa: BLE001
|
|
180
|
+
logger.debug("capture start failed: %s", exc)
|
|
181
|
+
|
|
182
|
+
def on_llm_end(self, response: Any, *, run_id: UUID, **kwargs: Any) -> None:
|
|
183
|
+
try:
|
|
184
|
+
start = self._starts.pop(run_id, None)
|
|
185
|
+
input_text, t0, model_name = start if start else ("", None, None)
|
|
186
|
+
output_text = "\n".join(gen.text for batch in response.generations for gen in batch)
|
|
187
|
+
usage = (response.llm_output or {}).get("token_usage", {}) or {}
|
|
188
|
+
if self._redact is not None:
|
|
189
|
+
input_text = self._redact(input_text)
|
|
190
|
+
output_text = self._redact(output_text)
|
|
191
|
+
inputs = self._structured_inputs(run_id)
|
|
192
|
+
if inputs is not None and self._redact is not None:
|
|
193
|
+
inputs = {key: self._redact(value) for key, value in inputs.items()}
|
|
194
|
+
record = TraceRecord(
|
|
195
|
+
task=self.task,
|
|
196
|
+
input=input_text,
|
|
197
|
+
inputs=inputs,
|
|
198
|
+
output=output_text,
|
|
199
|
+
model=model_name,
|
|
200
|
+
latency_ms=(time.monotonic() - t0) * 1000 if t0 is not None else None,
|
|
201
|
+
prompt_tokens=usage.get("prompt_tokens"),
|
|
202
|
+
completion_tokens=usage.get("completion_tokens"),
|
|
203
|
+
metadata={"structured_capture": inputs is not None},
|
|
204
|
+
)
|
|
205
|
+
self._client.post_trace_failopen(record)
|
|
206
|
+
self._parents.pop(run_id, None)
|
|
207
|
+
except Exception as exc: # noqa: BLE001 — fail-open contract
|
|
208
|
+
logger.debug("capture end failed: %s", exc)
|
|
209
|
+
|
|
210
|
+
def on_llm_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
|
|
211
|
+
try:
|
|
212
|
+
start = self._starts.pop(run_id, None)
|
|
213
|
+
input_text, t0, model_name = start if start else ("", None, None)
|
|
214
|
+
if self._redact is not None:
|
|
215
|
+
input_text = self._redact(input_text)
|
|
216
|
+
self._client.post_trace_failopen(
|
|
217
|
+
TraceRecord(
|
|
218
|
+
task=self.task,
|
|
219
|
+
input=input_text,
|
|
220
|
+
output="",
|
|
221
|
+
model=model_name,
|
|
222
|
+
latency_ms=(time.monotonic() - t0) * 1000 if t0 is not None else None,
|
|
223
|
+
error=str(error)[:2000],
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
self._parents.pop(run_id, None)
|
|
227
|
+
except Exception as exc: # noqa: BLE001
|
|
228
|
+
logger.debug("capture error-path failed: %s", exc)
|
|
229
|
+
|
|
230
|
+
def _structured_inputs(self, run_id: UUID) -> dict[str, str] | None:
|
|
231
|
+
question: str | None = None
|
|
232
|
+
context: str | None = None
|
|
233
|
+
for current in _lineage(run_id, self._parents):
|
|
234
|
+
question = question or self._questions.get(current)
|
|
235
|
+
context = context or self._contexts.get(current)
|
|
236
|
+
if question is not None and context is not None:
|
|
237
|
+
return {"question": question, "context": context}
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _message_text(m: Any) -> str:
|
|
242
|
+
"""Message text across LangChain versions: `.text` is a property in current
|
|
243
|
+
releases (was a method before; deprecated). Prefer the property, fall back
|
|
244
|
+
to the callable form, then to `.content`."""
|
|
245
|
+
text_attr = getattr(type(m), "text", None)
|
|
246
|
+
if isinstance(text_attr, property):
|
|
247
|
+
text = text_attr.__get__(m, type(m))
|
|
248
|
+
if text is not None:
|
|
249
|
+
return str(text)
|
|
250
|
+
text = getattr(m, "text", None)
|
|
251
|
+
if callable(text): # pre-property LangChain
|
|
252
|
+
try:
|
|
253
|
+
return str(text())
|
|
254
|
+
except Exception:
|
|
255
|
+
return str(getattr(m, "content", ""))
|
|
256
|
+
if text is not None:
|
|
257
|
+
return str(text)
|
|
258
|
+
return str(getattr(m, "content", ""))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _question_text(inputs: Any) -> str | None:
|
|
262
|
+
if isinstance(inputs, str):
|
|
263
|
+
return inputs
|
|
264
|
+
if not isinstance(inputs, dict):
|
|
265
|
+
return None
|
|
266
|
+
for key in ("question", "input", "query"):
|
|
267
|
+
value = inputs.get(key)
|
|
268
|
+
if isinstance(value, str):
|
|
269
|
+
return value
|
|
270
|
+
strings = [value for value in inputs.values() if isinstance(value, str)]
|
|
271
|
+
return strings[0] if len(strings) == 1 else None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _documents_context(documents: Sequence[Any]) -> str | None:
|
|
275
|
+
parts = [str(content) for doc in documents if (content := getattr(doc, "page_content", None))]
|
|
276
|
+
return "\n\n".join(parts) if parts else None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _lineage(run_id: UUID, parents: dict[UUID, UUID | None]) -> list[UUID]:
|
|
280
|
+
lineage = [run_id]
|
|
281
|
+
current = run_id
|
|
282
|
+
seen = {run_id}
|
|
283
|
+
while (parent := parents.get(current)) is not None and parent not in seen:
|
|
284
|
+
lineage.append(parent)
|
|
285
|
+
seen.add(parent)
|
|
286
|
+
current = parent
|
|
287
|
+
return lineage
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _model_name(serialized: dict[str, Any], kwargs: dict[str, Any]) -> str | None:
|
|
291
|
+
invocation = kwargs.get("invocation_params") or {}
|
|
292
|
+
name = invocation.get("model") or invocation.get("model_name")
|
|
293
|
+
if name:
|
|
294
|
+
return str(name)
|
|
295
|
+
ids = serialized.get("id")
|
|
296
|
+
return str(ids[-1]) if isinstance(ids, list) and ids else None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def serialize_prompt(template: Any) -> dict[str, Any]:
|
|
300
|
+
"""Serialize LangChain prompt templates to Apprentice Contract 2.
|
|
301
|
+
|
|
302
|
+
Plain `PromptTemplate` becomes one human message because it has no chat role.
|
|
303
|
+
"""
|
|
304
|
+
ChatPromptTemplate, PromptTemplate, MessagesPlaceholder = _prompt_classes()
|
|
305
|
+
if isinstance(template, ChatPromptTemplate):
|
|
306
|
+
messages = [_serialize_message(m, MessagesPlaceholder) for m in template.messages]
|
|
307
|
+
formats = {_message_format(m, MessagesPlaceholder) for m in template.messages}
|
|
308
|
+
template_format = formats.pop() if formats else _SUPPORTED_FORMAT
|
|
309
|
+
if formats:
|
|
310
|
+
raise ValueError("mixed template formats are not supported; use one f-string format")
|
|
311
|
+
_reject_format(template_format, messages)
|
|
312
|
+
return {
|
|
313
|
+
"format": template_format,
|
|
314
|
+
"messages": messages,
|
|
315
|
+
"input_variables": list(template.input_variables),
|
|
316
|
+
"partial_variables": dict(getattr(template, "partial_variables", {}) or {}),
|
|
317
|
+
}
|
|
318
|
+
if isinstance(template, PromptTemplate):
|
|
319
|
+
template_format = str(getattr(template, "template_format", _SUPPORTED_FORMAT))
|
|
320
|
+
messages = [{"role": "human", "template": str(template.template)}]
|
|
321
|
+
_reject_format(template_format, messages)
|
|
322
|
+
return {
|
|
323
|
+
"format": template_format,
|
|
324
|
+
"messages": messages,
|
|
325
|
+
"input_variables": list(template.input_variables),
|
|
326
|
+
"partial_variables": dict(getattr(template, "partial_variables", {}) or {}),
|
|
327
|
+
}
|
|
328
|
+
raise ValueError("unsupported LangChain prompt type; use ChatPromptTemplate or PromptTemplate")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def artifact_to_langchain(artifact: dict[str, Any]) -> Any:
|
|
332
|
+
ChatPromptTemplate, _, _ = _prompt_classes()
|
|
333
|
+
template_format = str(artifact.get("format", _SUPPORTED_FORMAT))
|
|
334
|
+
messages = artifact.get("messages")
|
|
335
|
+
if not isinstance(messages, list):
|
|
336
|
+
raise ValueError("template artifact must include messages list")
|
|
337
|
+
_reject_format(template_format, messages)
|
|
338
|
+
lc_messages = []
|
|
339
|
+
for message in messages:
|
|
340
|
+
if not isinstance(message, dict):
|
|
341
|
+
raise ValueError("template artifact messages must be objects")
|
|
342
|
+
role = str(message.get("role", ""))
|
|
343
|
+
if role not in {"system", "human", "ai"}:
|
|
344
|
+
raise ValueError(f"unsupported message role {role!r}; use system, human, or ai")
|
|
345
|
+
lc_messages.append((role, str(message.get("template", ""))))
|
|
346
|
+
return ChatPromptTemplate.from_messages(lc_messages, template_format=template_format)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _serialize_message(message: Any, MessagesPlaceholder: type[Any]) -> dict[str, str]:
|
|
350
|
+
if isinstance(message, MessagesPlaceholder):
|
|
351
|
+
raise ValueError(
|
|
352
|
+
"MessagesPlaceholder is not supported yet; upload flattened scalar inputs instead"
|
|
353
|
+
)
|
|
354
|
+
prompt = getattr(message, "prompt", None)
|
|
355
|
+
template = getattr(prompt, "template", None)
|
|
356
|
+
if template is None:
|
|
357
|
+
raise ValueError("only message prompt templates are supported; use tuple/string templates")
|
|
358
|
+
role = _ROLE_BY_CLASS.get(type(message).__name__)
|
|
359
|
+
if role is None:
|
|
360
|
+
raise ValueError("unsupported chat message role; use system, human, or ai message templates")
|
|
361
|
+
return {"role": role, "template": str(template)}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _message_format(message: Any, MessagesPlaceholder: type[Any]) -> str:
|
|
365
|
+
if isinstance(message, MessagesPlaceholder):
|
|
366
|
+
return _SUPPORTED_FORMAT
|
|
367
|
+
prompt = getattr(message, "prompt", None)
|
|
368
|
+
return str(getattr(prompt, "template_format", _SUPPORTED_FORMAT))
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _reject_format(template_format: str, messages: list[dict[str, Any]]) -> None:
|
|
372
|
+
if any(_has_template_loop(template_format, str(m.get("template", ""))) for m in messages):
|
|
373
|
+
raise ValueError(
|
|
374
|
+
"jinja2/mustache loops are not supported yet; use flat f-string variables"
|
|
375
|
+
)
|
|
376
|
+
if template_format != _SUPPORTED_FORMAT:
|
|
377
|
+
raise ValueError(
|
|
378
|
+
f"template_format={template_format!r} is not supported yet; "
|
|
379
|
+
"use f-string templates for Apprentice dynamic prompts"
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _has_template_loop(template_format: str, template: str) -> bool:
|
|
384
|
+
if template_format == "jinja2":
|
|
385
|
+
return "{% for " in template or "{% endfor" in template
|
|
386
|
+
if template_format == "mustache":
|
|
387
|
+
return "{{#" in template or "{{/" in template
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _prompt_classes() -> tuple[type[Any], type[Any], type[Any]]:
|
|
392
|
+
try:
|
|
393
|
+
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder, PromptTemplate
|
|
394
|
+
except ImportError as exc:
|
|
395
|
+
raise ImportError(
|
|
396
|
+
"LangChain support requires the optional extra: "
|
|
397
|
+
"`uv add 'runapprentice[langchain]'` or install `langchain-core>=0.3`."
|
|
398
|
+
) from exc
|
|
399
|
+
return ChatPromptTemplate, PromptTemplate, MessagesPlaceholder
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _require_langchain() -> None:
|
|
403
|
+
_prompt_classes()
|
runapprentice/models.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Pydantic models for the Apprentice SDK. Mirrors the control-plane API contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DatasetRow(BaseModel):
|
|
12
|
+
input: str
|
|
13
|
+
output: str
|
|
14
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DatasetStatus(BaseModel):
|
|
18
|
+
task: str
|
|
19
|
+
gold: int = 0
|
|
20
|
+
silver: int = 0
|
|
21
|
+
raw: int = 0
|
|
22
|
+
ready_for_optimization: bool = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TraceRecord(BaseModel):
|
|
26
|
+
task: str
|
|
27
|
+
input: str
|
|
28
|
+
inputs: dict[str, str] | None = None
|
|
29
|
+
output: str
|
|
30
|
+
model: str | None = None
|
|
31
|
+
latency_ms: float | None = None
|
|
32
|
+
prompt_tokens: int | None = None
|
|
33
|
+
completion_tokens: int | None = None
|
|
34
|
+
error: str | None = None
|
|
35
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class OptimizationReport(BaseModel):
|
|
39
|
+
task: str
|
|
40
|
+
baseline_score: float | None = None
|
|
41
|
+
optimized_score: float | None = None
|
|
42
|
+
examples_used: int | None = None
|
|
43
|
+
optimized_prompt: str | None = None
|
|
44
|
+
optimized_template: dict[str, Any] | None = None
|
|
45
|
+
detail: dict[str, Any] = Field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class JobInfo(BaseModel):
|
|
49
|
+
job_id: str
|
|
50
|
+
task: str
|
|
51
|
+
kind: str
|
|
52
|
+
status: str # queued | running | succeeded | failed
|
|
53
|
+
created_at: datetime | None = None
|
|
54
|
+
error: str | None = None
|
|
55
|
+
report: OptimizationReport | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PromptVersion(BaseModel):
|
|
59
|
+
task: str
|
|
60
|
+
version: int
|
|
61
|
+
text: str
|
|
62
|
+
score: float | None = None
|
runapprentice/openai.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""OpenAI SDK integration: zero-config capture by wrapping the client.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
from openai import OpenAI # or AsyncOpenAI
|
|
5
|
+
from runapprentice import Apprentice
|
|
6
|
+
from runapprentice.openai import wrap
|
|
7
|
+
|
|
8
|
+
client = wrap(OpenAI(), Apprentice(api_key="..."), task="ticket-triage")
|
|
9
|
+
# every client.chat.completions.create(...) call is captured, unchanged.
|
|
10
|
+
|
|
11
|
+
Works with both the sync `OpenAI` and async `AsyncOpenAI` clients (the wrapper
|
|
12
|
+
matches whichever `create` it finds).
|
|
13
|
+
|
|
14
|
+
This captures traces (it does NOT proxy or route your calls — your request goes
|
|
15
|
+
straight to OpenAI as before). Routing/replacement is a later, opt-in stage.
|
|
16
|
+
|
|
17
|
+
FAIL-OPEN CONTRACT: capture never raises into the customer's call path, and never
|
|
18
|
+
blocks it (delivery is buffered off-thread). The real OpenAI call happens first and
|
|
19
|
+
its result is always returned; worst case is a missing trace. Real OpenAI errors
|
|
20
|
+
propagate unchanged.
|
|
21
|
+
|
|
22
|
+
STREAMING: when `stream=True`, the response is an iterator that must not be consumed
|
|
23
|
+
by the capture layer (that would break the caller's stream). Such calls pass through
|
|
24
|
+
uncaptured — breaking streaming is never worth a trace.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import functools
|
|
30
|
+
import inspect
|
|
31
|
+
import logging
|
|
32
|
+
import time
|
|
33
|
+
from typing import Any, Callable
|
|
34
|
+
|
|
35
|
+
from runapprentice.client import Apprentice
|
|
36
|
+
from runapprentice.models import TraceRecord
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger("runapprentice.openai")
|
|
39
|
+
|
|
40
|
+
RedactFn = Callable[[str], str]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def wrap(openai_client: Any, client: Apprentice, task: str, redact: RedactFn | None = None) -> Any:
|
|
44
|
+
"""Wrap an OpenAI client so chat.completions.create calls are captured.
|
|
45
|
+
|
|
46
|
+
Returns the same client (mutated in place) for convenient one-line use.
|
|
47
|
+
Idempotent: wrapping an already-wrapped client is a no-op. Supports both sync
|
|
48
|
+
(`OpenAI`) and async (`AsyncOpenAI`) clients.
|
|
49
|
+
"""
|
|
50
|
+
completions = openai_client.chat.completions
|
|
51
|
+
if getattr(completions, "_apprentice_wrapped", False):
|
|
52
|
+
return openai_client
|
|
53
|
+
original = completions.create
|
|
54
|
+
|
|
55
|
+
def _capture(response: Any, kwargs: dict[str, Any], args: tuple[Any, ...], t0: float) -> None:
|
|
56
|
+
try:
|
|
57
|
+
input_text = _messages_to_text(kwargs.get("messages") or (args[0] if args else []))
|
|
58
|
+
output_text = _response_to_text(response)
|
|
59
|
+
if redact is not None:
|
|
60
|
+
input_text = redact(input_text)
|
|
61
|
+
output_text = redact(output_text)
|
|
62
|
+
usage = getattr(response, "usage", None)
|
|
63
|
+
client.post_trace_failopen(
|
|
64
|
+
TraceRecord(
|
|
65
|
+
task=task,
|
|
66
|
+
input=input_text,
|
|
67
|
+
output=output_text,
|
|
68
|
+
model=getattr(response, "model", None) or kwargs.get("model"),
|
|
69
|
+
latency_ms=(time.monotonic() - t0) * 1000,
|
|
70
|
+
prompt_tokens=getattr(usage, "prompt_tokens", None),
|
|
71
|
+
completion_tokens=getattr(usage, "completion_tokens", None),
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
except Exception as exc: # noqa: BLE001 — fail-open contract
|
|
75
|
+
logger.debug("capture failed: %s", exc)
|
|
76
|
+
|
|
77
|
+
if inspect.iscoroutinefunction(original):
|
|
78
|
+
|
|
79
|
+
@functools.wraps(original)
|
|
80
|
+
async def wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
81
|
+
t0 = time.monotonic()
|
|
82
|
+
response = await original(*args, **kwargs) # real call — never intercepted
|
|
83
|
+
if not kwargs.get("stream"):
|
|
84
|
+
_capture(response, kwargs, args, t0)
|
|
85
|
+
return response
|
|
86
|
+
|
|
87
|
+
else:
|
|
88
|
+
|
|
89
|
+
@functools.wraps(original)
|
|
90
|
+
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
91
|
+
t0 = time.monotonic()
|
|
92
|
+
response = original(*args, **kwargs) # real call — never intercepted
|
|
93
|
+
if not kwargs.get("stream"):
|
|
94
|
+
_capture(response, kwargs, args, t0)
|
|
95
|
+
return response
|
|
96
|
+
|
|
97
|
+
wrapped._apprentice_wrapped = True # type: ignore[attr-defined]
|
|
98
|
+
completions.create = wrapped
|
|
99
|
+
completions._apprentice_wrapped = True
|
|
100
|
+
return openai_client
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _messages_to_text(messages: Any) -> str:
|
|
104
|
+
if isinstance(messages, str):
|
|
105
|
+
return messages
|
|
106
|
+
parts = []
|
|
107
|
+
for m in messages or []:
|
|
108
|
+
role = m.get("role", "") if isinstance(m, dict) else getattr(m, "role", "")
|
|
109
|
+
content = m.get("content", "") if isinstance(m, dict) else getattr(m, "content", "")
|
|
110
|
+
if isinstance(content, list): # multimodal: keep the text parts
|
|
111
|
+
content = "\n".join(
|
|
112
|
+
p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"
|
|
113
|
+
)
|
|
114
|
+
parts.append(f"{role}: {content}".strip(": ").strip() if role else str(content))
|
|
115
|
+
return "\n".join(parts)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _response_to_text(response: Any) -> str:
|
|
119
|
+
choices = getattr(response, "choices", None) or []
|
|
120
|
+
out = []
|
|
121
|
+
for c in choices:
|
|
122
|
+
msg = getattr(c, "message", None)
|
|
123
|
+
text = getattr(msg, "content", None) if msg is not None else getattr(c, "text", None)
|
|
124
|
+
if text:
|
|
125
|
+
out.append(text)
|
|
126
|
+
return "\n".join(out)
|
runapprentice/py.typed
ADDED
|
File without changes
|