pi-durable-python 1.3.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.
- pi_durable/__init__.py +320 -0
- pi_durable/context.py +328 -0
- pi_durable/delta.py +269 -0
- pi_durable/documents.py +366 -0
- pi_durable/entries.py +66 -0
- pi_durable/env/__init__.py +408 -0
- pi_durable/env/decode.py +48 -0
- pi_durable/env/line_scan.py +190 -0
- pi_durable/env/posix.py +1547 -0
- pi_durable/errors.py +89 -0
- pi_durable/harness/__init__.py +1 -0
- pi_durable/harness/agent.py +344 -0
- pi_durable/harness/compaction.py +673 -0
- pi_durable/harness/context.py +515 -0
- pi_durable/harness/define.py +111 -0
- pi_durable/harness/events.py +766 -0
- pi_durable/harness/generation.py +1079 -0
- pi_durable/harness/harness.py +570 -0
- pi_durable/harness/inbox.py +255 -0
- pi_durable/harness/live.py +374 -0
- pi_durable/harness/output.py +467 -0
- pi_durable/harness/prompt.py +288 -0
- pi_durable/harness/provider.py +78 -0
- pi_durable/harness/registry.py +136 -0
- pi_durable/harness/scheduler.py +1977 -0
- pi_durable/harness/submissions.py +357 -0
- pi_durable/harness/task_graph.py +334 -0
- pi_durable/harness/tool.py +1365 -0
- pi_durable/harness/types.py +772 -0
- pi_durable/harness/usage.py +88 -0
- pi_durable/harness/util.py +204 -0
- pi_durable/harness/validation.py +460 -0
- pi_durable/harness/view.py +324 -0
- pi_durable/ids.py +63 -0
- pi_durable/json.py +186 -0
- pi_durable/providers/__init__.py +362 -0
- pi_durable/providers/anthropic.py +603 -0
- pi_durable/providers/base.py +97 -0
- pi_durable/providers/json_parse.py +117 -0
- pi_durable/providers/openai.py +574 -0
- pi_durable/providers/request.py +35 -0
- pi_durable/providers/retry.py +127 -0
- pi_durable/providers/stream.py +183 -0
- pi_durable/providers/transcript.py +224 -0
- pi_durable/providers/usage.py +58 -0
- pi_durable/py.typed +0 -0
- pi_durable/search.py +115 -0
- pi_durable/session/__init__.py +5 -0
- pi_durable/session/drafts.py +762 -0
- pi_durable/session/forks.py +109 -0
- pi_durable/session/observation.py +374 -0
- pi_durable/session/session.py +1193 -0
- pi_durable/session/transaction.py +1088 -0
- pi_durable/storage/__init__.py +5 -0
- pi_durable/storage/jsonl/__init__.py +15 -0
- pi_durable/storage/jsonl/storage.py +958 -0
- pi_durable/storage/memory.py +889 -0
- pi_durable/storage/scan.py +57 -0
- pi_durable/storage/sqlite/__init__.py +26 -0
- pi_durable/storage/sqlite/database.py +62 -0
- pi_durable/storage/sqlite/migrations.py +191 -0
- pi_durable/storage/sqlite/posix.py +314 -0
- pi_durable/storage/sqlite/schema.py +97 -0
- pi_durable/storage/sqlite/storage.py +1130 -0
- pi_durable/tasks.py +89 -0
- pi_durable/testing/__init__.py +41 -0
- pi_durable/testing/assertions.py +44 -0
- pi_durable/testing/env_conformance.py +828 -0
- pi_durable/testing/runner.py +64 -0
- pi_durable/testing/storage_conformance.py +2008 -0
- pi_durable/testing/types.py +99 -0
- pi_durable/tool_search/__init__.py +159 -0
- pi_durable/tools/__init__.py +81 -0
- pi_durable/tools/_values.py +43 -0
- pi_durable/tools/bash.py +250 -0
- pi_durable/tools/edit.py +214 -0
- pi_durable/tools/edit_diff.py +452 -0
- pi_durable/tools/env.py +19 -0
- pi_durable/tools/file_mutation_queue.py +68 -0
- pi_durable/tools/image.py +248 -0
- pi_durable/tools/image_processor.py +77 -0
- pi_durable/tools/path_utils.py +111 -0
- pi_durable/tools/read.py +506 -0
- pi_durable/tools/write.py +71 -0
- pi_durable/truncate.py +185 -0
- pi_durable/types.py +1297 -0
- pi_durable_python-1.3.0.dist-info/METADATA +276 -0
- pi_durable_python-1.3.0.dist-info/RECORD +90 -0
- pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
- pi_durable_python-1.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
"""OpenAI Chat Completions model adapter (optional ``openai`` extra).
|
|
2
|
+
|
|
3
|
+
The adapter implements the port's :class:`~pi_durable.providers.Models` protocol over the official
|
|
4
|
+
``AsyncOpenAI`` client. It builds the Chat Completions request from a pi-ai shaped transcript,
|
|
5
|
+
translates streamed chunks into the port's assistant-message events (text, thinking, and tool-call
|
|
6
|
+
deltas), and returns the terminal message generation and compaction classify.
|
|
7
|
+
|
|
8
|
+
The model catalog belongs to the caller: pass a sequence of :class:`~pi_durable.providers.Model`
|
|
9
|
+
values and the adapter resolves ``(provider, model_id)`` through it, so context windows, max-token
|
|
10
|
+
ceilings, input capabilities, and cost rates travel with each model.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
from collections.abc import Mapping, Sequence
|
|
19
|
+
from typing import Any, cast
|
|
20
|
+
|
|
21
|
+
from . import Model
|
|
22
|
+
from .base import ProviderModels
|
|
23
|
+
from .json_parse import parse_streaming_json, parse_tool_call_arguments
|
|
24
|
+
from .request import optional_int, request_options
|
|
25
|
+
from .retry import retry_provider_request
|
|
26
|
+
from .stream import ProviderStream, content_index, now_ms, provider_field
|
|
27
|
+
from .transcript import (
|
|
28
|
+
collapse_system_messages,
|
|
29
|
+
has_tool_history,
|
|
30
|
+
system_message_text,
|
|
31
|
+
transform_messages,
|
|
32
|
+
)
|
|
33
|
+
from .usage import calculate_cost, zero_usage
|
|
34
|
+
|
|
35
|
+
OPENAI_API = "openai-completions"
|
|
36
|
+
DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
|
37
|
+
PROMPT_CACHE_KEY_MAX_LENGTH = 64
|
|
38
|
+
|
|
39
|
+
_REASONING_FIELDS = ("reasoning_content", "reasoning", "reasoning_text")
|
|
40
|
+
_REASONING_EFFORT = {
|
|
41
|
+
"minimal": "minimal",
|
|
42
|
+
"low": "low",
|
|
43
|
+
"medium": "medium",
|
|
44
|
+
"high": "high",
|
|
45
|
+
"xhigh": "high",
|
|
46
|
+
"max": "high",
|
|
47
|
+
}
|
|
48
|
+
_STOP_REASONS = {
|
|
49
|
+
"stop": "stop",
|
|
50
|
+
"end": "stop",
|
|
51
|
+
"length": "length",
|
|
52
|
+
"function_call": "toolUse",
|
|
53
|
+
"tool_calls": "toolUse",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class OpenAIModels(ProviderModels):
|
|
58
|
+
"""The ``Models`` protocol over OpenAI Chat Completions.
|
|
59
|
+
|
|
60
|
+
``client`` injects an ``AsyncOpenAI``-shaped client (tests use a fake); otherwise a client is
|
|
61
|
+
built lazily from ``api_key``, ``base_url``, and ``headers``. Without an injected client an API
|
|
62
|
+
key is required in the same call, ``OPENAI_API_KEY``, or ``options["apiKey"]``.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
api = OPENAI_API
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
models: Sequence[Model] = (),
|
|
70
|
+
*,
|
|
71
|
+
client: Any | None = None,
|
|
72
|
+
api_key: str | None = None,
|
|
73
|
+
base_url: str | None = None,
|
|
74
|
+
headers: Mapping[str, str] | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
super().__init__(models)
|
|
77
|
+
self._client = client
|
|
78
|
+
self._api_key = api_key
|
|
79
|
+
self._base_url = base_url
|
|
80
|
+
self._headers = dict(headers or {})
|
|
81
|
+
|
|
82
|
+
# ── Models protocol ───────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
def stream_simple(
|
|
85
|
+
self,
|
|
86
|
+
model: Model,
|
|
87
|
+
context: Mapping[str, Any],
|
|
88
|
+
options: Mapping[str, Any] | None = None,
|
|
89
|
+
) -> ProviderStream:
|
|
90
|
+
return self._stream(model, context, options or {}, simple=True)
|
|
91
|
+
|
|
92
|
+
def stream(
|
|
93
|
+
self,
|
|
94
|
+
model: Model,
|
|
95
|
+
context: Mapping[str, Any],
|
|
96
|
+
options: Mapping[str, Any] | None = None,
|
|
97
|
+
) -> ProviderStream:
|
|
98
|
+
return self._stream(model, context, options or {}, simple=False)
|
|
99
|
+
|
|
100
|
+
# ── Request assembly ──────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
def _stream(
|
|
103
|
+
self,
|
|
104
|
+
model: Model,
|
|
105
|
+
context: Mapping[str, Any],
|
|
106
|
+
options: Mapping[str, Any],
|
|
107
|
+
*,
|
|
108
|
+
simple: bool,
|
|
109
|
+
) -> ProviderStream:
|
|
110
|
+
client = self._resolve_client(model, options)
|
|
111
|
+
output = _output_message(model)
|
|
112
|
+
return _OpenAIStream(client, model, context, options, output, simple=simple)
|
|
113
|
+
|
|
114
|
+
def _resolve_client(self, model: Model, options: Mapping[str, Any]) -> Any:
|
|
115
|
+
if self._client is not None:
|
|
116
|
+
return self._client
|
|
117
|
+
api_key = (
|
|
118
|
+
self._api_key
|
|
119
|
+
or cast(str | None, options.get("apiKey"))
|
|
120
|
+
or os.environ.get("OPENAI_API_KEY")
|
|
121
|
+
)
|
|
122
|
+
if not api_key:
|
|
123
|
+
raise ValueError(f"No API key for provider: {model.provider}")
|
|
124
|
+
try:
|
|
125
|
+
from openai import AsyncOpenAI
|
|
126
|
+
except ImportError as error: # pragma: no cover - covered by the extras test
|
|
127
|
+
raise ImportError(
|
|
128
|
+
"The OpenAI adapter requires the 'openai' extra: "
|
|
129
|
+
"pip install pi-durable-python[openai]"
|
|
130
|
+
) from error
|
|
131
|
+
self._client = AsyncOpenAI(
|
|
132
|
+
api_key=api_key,
|
|
133
|
+
base_url=self._base_url,
|
|
134
|
+
default_headers=dict(self._headers) or None,
|
|
135
|
+
max_retries=0,
|
|
136
|
+
)
|
|
137
|
+
return self._client
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _output_message(model: Model) -> dict[str, Any]:
|
|
141
|
+
return {
|
|
142
|
+
"role": "assistant",
|
|
143
|
+
"content": [],
|
|
144
|
+
"api": OPENAI_API,
|
|
145
|
+
"provider": model.provider,
|
|
146
|
+
"model": model.model_id,
|
|
147
|
+
"usage": zero_usage(),
|
|
148
|
+
"stopReason": "pending",
|
|
149
|
+
"timestamp": now_ms(),
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class _OpenAIStream(ProviderStream):
|
|
154
|
+
"""One Chat Completions request translated into assistant-message events."""
|
|
155
|
+
|
|
156
|
+
def __init__(
|
|
157
|
+
self,
|
|
158
|
+
client: Any,
|
|
159
|
+
model: Model,
|
|
160
|
+
context: Mapping[str, Any],
|
|
161
|
+
options: Mapping[str, Any],
|
|
162
|
+
output: dict[str, Any],
|
|
163
|
+
*,
|
|
164
|
+
simple: bool,
|
|
165
|
+
) -> None:
|
|
166
|
+
self._client = client
|
|
167
|
+
self._model = model
|
|
168
|
+
self._context = context
|
|
169
|
+
self._options = options
|
|
170
|
+
self._simple = simple
|
|
171
|
+
self._text_block: dict[str, Any] | None = None
|
|
172
|
+
self._thinking_block: dict[str, Any] | None = None
|
|
173
|
+
self._tool_blocks: dict[int, dict[str, Any]] = {}
|
|
174
|
+
self._tool_text: dict[int, str] = {}
|
|
175
|
+
self._tool_block_index: dict[int, int] = {}
|
|
176
|
+
self._has_finish_reason = False
|
|
177
|
+
super().__init__(output, signal=cast(Any, options.get("signal")))
|
|
178
|
+
|
|
179
|
+
async def _produce(self) -> None:
|
|
180
|
+
params = build_params(
|
|
181
|
+
self._model,
|
|
182
|
+
self._context,
|
|
183
|
+
self._options,
|
|
184
|
+
simple=self._simple,
|
|
185
|
+
base_url=self._base_url(),
|
|
186
|
+
)
|
|
187
|
+
sdk_options = request_options(self._options)
|
|
188
|
+
create = self._client.chat.completions.create
|
|
189
|
+
|
|
190
|
+
async def call() -> Any:
|
|
191
|
+
return await create(**params, **sdk_options)
|
|
192
|
+
|
|
193
|
+
stream = await retry_provider_request(
|
|
194
|
+
call,
|
|
195
|
+
max_retries=int(self._options.get("maxRetries") or 0),
|
|
196
|
+
max_retry_delay_ms=optional_int(self._options.get("maxRetryDelayMs")),
|
|
197
|
+
signal=cast(Any, self._options.get("signal")),
|
|
198
|
+
)
|
|
199
|
+
self.emit("start")
|
|
200
|
+
async for chunk in stream:
|
|
201
|
+
self._consume(chunk)
|
|
202
|
+
self._finish_blocks()
|
|
203
|
+
if self._signal is not None and self._signal.aborted:
|
|
204
|
+
raise asyncio.CancelledError
|
|
205
|
+
if not self._has_finish_reason:
|
|
206
|
+
raise RuntimeError("Stream ended without finish_reason")
|
|
207
|
+
if self.output["stopReason"] == "error":
|
|
208
|
+
raise RuntimeError(cast(str, self.output.get("errorMessage") or "Provider returned an error"))
|
|
209
|
+
self.emit("done", reason=self.output["stopReason"], message=self.output)
|
|
210
|
+
|
|
211
|
+
def _base_url(self) -> str:
|
|
212
|
+
configured = getattr(self._client, "base_url", None)
|
|
213
|
+
return DEFAULT_BASE_URL if configured is None else str(configured)
|
|
214
|
+
|
|
215
|
+
# ── Chunk translation ────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
def _consume(self, chunk: Any) -> None:
|
|
218
|
+
response_id = provider_field(chunk, "id")
|
|
219
|
+
if response_id and not self.output.get("responseId"):
|
|
220
|
+
self.output["responseId"] = response_id
|
|
221
|
+
response_model = provider_field(chunk, "model")
|
|
222
|
+
if (
|
|
223
|
+
isinstance(response_model, str)
|
|
224
|
+
and response_model
|
|
225
|
+
and response_model != self._model.model_id
|
|
226
|
+
and not self.output.get("responseModel")
|
|
227
|
+
):
|
|
228
|
+
self.output["responseModel"] = response_model
|
|
229
|
+
usage = provider_field(chunk, "usage")
|
|
230
|
+
if usage is not None:
|
|
231
|
+
self.output["usage"] = parse_usage(usage, self._model)
|
|
232
|
+
|
|
233
|
+
choices = provider_field(chunk, "choices") or ()
|
|
234
|
+
choice = choices[0] if choices else None
|
|
235
|
+
if choice is None:
|
|
236
|
+
return
|
|
237
|
+
finish_reason = provider_field(choice, "finish_reason")
|
|
238
|
+
if finish_reason is not None:
|
|
239
|
+
self.output["rawStopReason"] = finish_reason
|
|
240
|
+
stop_reason, error_message = map_stop_reason(finish_reason)
|
|
241
|
+
self.output["stopReason"] = stop_reason
|
|
242
|
+
if error_message is not None:
|
|
243
|
+
self.output["errorMessage"] = error_message
|
|
244
|
+
self._has_finish_reason = True
|
|
245
|
+
delta = provider_field(choice, "delta")
|
|
246
|
+
if delta is None:
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
for name in _REASONING_FIELDS:
|
|
250
|
+
reasoning = provider_field(delta, name)
|
|
251
|
+
if isinstance(reasoning, str) and reasoning:
|
|
252
|
+
self._append_thinking(reasoning, name)
|
|
253
|
+
break
|
|
254
|
+
content = provider_field(delta, "content")
|
|
255
|
+
if isinstance(content, str) and content:
|
|
256
|
+
self._append_text(content)
|
|
257
|
+
for call in provider_field(delta, "tool_calls") or ():
|
|
258
|
+
self._append_tool_call(call)
|
|
259
|
+
|
|
260
|
+
def _append_text(self, text: str) -> None:
|
|
261
|
+
block = self._text_block
|
|
262
|
+
if block is None:
|
|
263
|
+
block = {"type": "text", "text": ""}
|
|
264
|
+
self.output["content"].append(block)
|
|
265
|
+
self._text_block = block
|
|
266
|
+
self.emit("text_start", content_index=len(self.output["content"]) - 1)
|
|
267
|
+
block["text"] += text
|
|
268
|
+
self.emit("text_delta", content_index=len(self.output["content"]) - 1, delta=text)
|
|
269
|
+
|
|
270
|
+
def _append_thinking(self, text: str, signature: str) -> None:
|
|
271
|
+
block = self._thinking_block
|
|
272
|
+
if block is None:
|
|
273
|
+
block = {"type": "thinking", "thinking": "", "thinkingSignature": signature}
|
|
274
|
+
self.output["content"].append(block)
|
|
275
|
+
self._thinking_block = block
|
|
276
|
+
self.emit("thinking_start", content_index=len(self.output["content"]) - 1)
|
|
277
|
+
block["thinking"] += text
|
|
278
|
+
self.emit("thinking_delta", content_index=len(self.output["content"]) - 1, delta=text)
|
|
279
|
+
|
|
280
|
+
def _append_tool_call(self, call: Any) -> None:
|
|
281
|
+
provider_index = provider_field(call, "index")
|
|
282
|
+
if not isinstance(provider_index, int):
|
|
283
|
+
call_id = provider_field(call, "id")
|
|
284
|
+
provider_index = next(
|
|
285
|
+
(
|
|
286
|
+
index
|
|
287
|
+
for index, block in self._tool_blocks.items()
|
|
288
|
+
if call_id and block["id"] == call_id
|
|
289
|
+
),
|
|
290
|
+
max(self._tool_blocks, default=-1) + 1,
|
|
291
|
+
)
|
|
292
|
+
block = self._tool_blocks.get(provider_index)
|
|
293
|
+
if block is None:
|
|
294
|
+
block = {"type": "toolCall", "id": "", "name": "", "arguments": {}}
|
|
295
|
+
self._tool_blocks[provider_index] = block
|
|
296
|
+
self._tool_text[provider_index] = ""
|
|
297
|
+
self.output["content"].append(block)
|
|
298
|
+
self._tool_block_index[id(block)] = provider_index
|
|
299
|
+
self.emit("toolcall_start", content_index=len(self.output["content"]) - 1)
|
|
300
|
+
call_id = provider_field(call, "id")
|
|
301
|
+
if call_id and not block["id"]:
|
|
302
|
+
block["id"] = call_id
|
|
303
|
+
function = provider_field(call, "function")
|
|
304
|
+
name = provider_field(function, "name")
|
|
305
|
+
if name and not block["name"]:
|
|
306
|
+
block["name"] = name
|
|
307
|
+
arguments = provider_field(function, "arguments")
|
|
308
|
+
if isinstance(arguments, str) and arguments:
|
|
309
|
+
self._tool_text[provider_index] += arguments
|
|
310
|
+
block["arguments"] = parse_streaming_json(self._tool_text[provider_index])
|
|
311
|
+
self.emit(
|
|
312
|
+
"toolcall_delta",
|
|
313
|
+
content_index=content_index(self.output["content"], block),
|
|
314
|
+
delta=arguments,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
def _finish_blocks(self) -> None:
|
|
318
|
+
for block_position, block in enumerate(self.output["content"]):
|
|
319
|
+
kind = block.get("type")
|
|
320
|
+
if kind == "text":
|
|
321
|
+
self.emit("text_end", content_index=block_position, content=block["text"])
|
|
322
|
+
elif kind == "thinking":
|
|
323
|
+
self.emit("thinking_end", content_index=block_position, content=block["thinking"])
|
|
324
|
+
elif kind == "toolCall":
|
|
325
|
+
provider_index = self._tool_block_index.get(id(block))
|
|
326
|
+
text = self._tool_text.get(provider_index, "") if provider_index is not None else ""
|
|
327
|
+
arguments, error = parse_tool_call_arguments(text)
|
|
328
|
+
block["arguments"] = arguments
|
|
329
|
+
if error is not None:
|
|
330
|
+
block["argumentsError"] = error
|
|
331
|
+
self.emit("toolcall_end", content_index=block_position, toolCall=block)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def build_params(
|
|
335
|
+
model: Model,
|
|
336
|
+
context: Mapping[str, Any],
|
|
337
|
+
options: Mapping[str, Any],
|
|
338
|
+
*,
|
|
339
|
+
simple: bool,
|
|
340
|
+
base_url: str,
|
|
341
|
+
) -> dict[str, Any]:
|
|
342
|
+
"""The Chat Completions request body for one transcript."""
|
|
343
|
+
|
|
344
|
+
head, rest = collapse_system_messages(cast(Sequence[Mapping[str, Any]], context.get("messages") or ()))
|
|
345
|
+
transformed = transform_messages(rest, model, api=OPENAI_API)
|
|
346
|
+
messages = convert_messages(transformed, model)
|
|
347
|
+
if head is not None:
|
|
348
|
+
text = system_message_text(head)
|
|
349
|
+
if text:
|
|
350
|
+
messages.insert(0, {"role": _instruction_role(model), "content": text})
|
|
351
|
+
|
|
352
|
+
params: dict[str, Any] = {
|
|
353
|
+
"model": model.model_id,
|
|
354
|
+
"messages": messages,
|
|
355
|
+
"stream": True,
|
|
356
|
+
"stream_options": {"include_usage": True},
|
|
357
|
+
"store": False,
|
|
358
|
+
}
|
|
359
|
+
declared = (head.get("toolsAdded") or ()) if head is not None else ()
|
|
360
|
+
tools = convert_tools(cast(Sequence[Mapping[str, Any]], declared))
|
|
361
|
+
if tools:
|
|
362
|
+
params["tools"] = tools
|
|
363
|
+
elif has_tool_history(rest):
|
|
364
|
+
params["tools"] = []
|
|
365
|
+
|
|
366
|
+
max_tokens = optional_int(options.get("maxTokens")) or model.max_tokens
|
|
367
|
+
if max_tokens and max_tokens > 0:
|
|
368
|
+
params["max_completion_tokens"] = max_tokens
|
|
369
|
+
reasoning = options.get("reasoning")
|
|
370
|
+
if not simple:
|
|
371
|
+
reasoning = options.get("reasoningEffort", reasoning)
|
|
372
|
+
if model.reasoning and isinstance(reasoning, str) and reasoning != "off":
|
|
373
|
+
effort = _REASONING_EFFORT.get(reasoning)
|
|
374
|
+
if effort is not None:
|
|
375
|
+
params["reasoning_effort"] = effort
|
|
376
|
+
if "temperature" in options and options["temperature"] is not None:
|
|
377
|
+
params["temperature"] = options["temperature"]
|
|
378
|
+
if "toolChoice" in options and options["toolChoice"] is not None:
|
|
379
|
+
params["tool_choice"] = options["toolChoice"]
|
|
380
|
+
|
|
381
|
+
retention = str(options.get("cacheRetention") or "short")
|
|
382
|
+
session_id = options.get("sessionId")
|
|
383
|
+
if (
|
|
384
|
+
retention != "none"
|
|
385
|
+
and isinstance(session_id, str)
|
|
386
|
+
and session_id
|
|
387
|
+
and ("api.openai.com" in base_url or retention == "long")
|
|
388
|
+
):
|
|
389
|
+
params["prompt_cache_key"] = session_id[:PROMPT_CACHE_KEY_MAX_LENGTH]
|
|
390
|
+
if retention == "long":
|
|
391
|
+
params["prompt_cache_retention"] = "24h"
|
|
392
|
+
metadata = options.get("metadata")
|
|
393
|
+
if isinstance(metadata, Mapping) and metadata:
|
|
394
|
+
params["metadata"] = dict(metadata)
|
|
395
|
+
return params
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _instruction_role(model: Model) -> str:
|
|
399
|
+
return "developer" if model.reasoning else "system"
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def convert_messages(
|
|
403
|
+
messages: Sequence[Mapping[str, Any]], model: Model
|
|
404
|
+
) -> list[dict[str, Any]]:
|
|
405
|
+
"""Convert transformed pi-ai messages to Chat Completions messages."""
|
|
406
|
+
|
|
407
|
+
params: list[dict[str, Any]] = []
|
|
408
|
+
index = 0
|
|
409
|
+
while index < len(messages):
|
|
410
|
+
message = messages[index]
|
|
411
|
+
role = message.get("role")
|
|
412
|
+
if role == "user":
|
|
413
|
+
converted = _user_message(message)
|
|
414
|
+
if converted is not None:
|
|
415
|
+
params.append(converted)
|
|
416
|
+
elif role == "assistant":
|
|
417
|
+
converted = _assistant_message(message)
|
|
418
|
+
if converted is not None:
|
|
419
|
+
params.append(converted)
|
|
420
|
+
elif role == "toolResult":
|
|
421
|
+
images: list[dict[str, Any]] = []
|
|
422
|
+
while index < len(messages) and messages[index].get("role") == "toolResult":
|
|
423
|
+
result = messages[index]
|
|
424
|
+
params.append(_tool_message(result))
|
|
425
|
+
if "image" in model.input:
|
|
426
|
+
images.extend(_image_parts(result.get("content")))
|
|
427
|
+
index += 1
|
|
428
|
+
if images:
|
|
429
|
+
params.append(
|
|
430
|
+
{
|
|
431
|
+
"role": "user",
|
|
432
|
+
"content": [
|
|
433
|
+
{"type": "text", "text": "Attached image(s) from tool result:"},
|
|
434
|
+
*images,
|
|
435
|
+
],
|
|
436
|
+
}
|
|
437
|
+
)
|
|
438
|
+
continue
|
|
439
|
+
index += 1
|
|
440
|
+
return params
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _user_message(message: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
444
|
+
content = message.get("content")
|
|
445
|
+
if isinstance(content, str):
|
|
446
|
+
return {"role": "user", "content": content}
|
|
447
|
+
parts: list[dict[str, Any]] = []
|
|
448
|
+
for block in cast(Sequence[Mapping[str, Any]], content or ()):
|
|
449
|
+
if block.get("type") == "text":
|
|
450
|
+
text = cast(str, block.get("text") or "")
|
|
451
|
+
if text:
|
|
452
|
+
parts.append({"type": "text", "text": text})
|
|
453
|
+
elif block.get("type") == "image":
|
|
454
|
+
parts.append(_image_part(block))
|
|
455
|
+
return {"role": "user", "content": parts} if parts else None
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _assistant_message(message: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
459
|
+
content = cast(Sequence[Mapping[str, Any]], message.get("content") or ())
|
|
460
|
+
text = "".join(
|
|
461
|
+
cast(str, block.get("text") or "") for block in content if block.get("type") == "text"
|
|
462
|
+
)
|
|
463
|
+
calls = [block for block in content if block.get("type") == "toolCall"]
|
|
464
|
+
converted: dict[str, Any] = {"role": "assistant", "content": text or None}
|
|
465
|
+
if calls:
|
|
466
|
+
converted["tool_calls"] = [
|
|
467
|
+
{
|
|
468
|
+
"id": cast(str, call.get("id") or ""),
|
|
469
|
+
"type": "function",
|
|
470
|
+
"function": {
|
|
471
|
+
"name": cast(str, call.get("name") or ""),
|
|
472
|
+
"arguments": _json_arguments(call.get("arguments")),
|
|
473
|
+
},
|
|
474
|
+
}
|
|
475
|
+
for call in calls
|
|
476
|
+
]
|
|
477
|
+
if not text and not calls:
|
|
478
|
+
return None
|
|
479
|
+
return converted
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _tool_message(message: Mapping[str, Any]) -> dict[str, Any]:
|
|
483
|
+
content = message.get("content")
|
|
484
|
+
text = "\n".join(
|
|
485
|
+
cast(str, block.get("text") or "")
|
|
486
|
+
for block in cast(Sequence[Mapping[str, Any]], content or ())
|
|
487
|
+
if block.get("type") == "text"
|
|
488
|
+
)
|
|
489
|
+
has_images = any(
|
|
490
|
+
block.get("type") == "image"
|
|
491
|
+
for block in cast(Sequence[Mapping[str, Any]], content or ())
|
|
492
|
+
)
|
|
493
|
+
if not text:
|
|
494
|
+
text = "(see attached image)" if has_images else "(no tool output)"
|
|
495
|
+
return {
|
|
496
|
+
"role": "tool",
|
|
497
|
+
"content": text,
|
|
498
|
+
"tool_call_id": cast(str, message.get("toolCallId") or ""),
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _image_parts(content: Any) -> list[dict[str, Any]]:
|
|
503
|
+
return [
|
|
504
|
+
_image_part(block)
|
|
505
|
+
for block in cast(Sequence[Mapping[str, Any]], content or ())
|
|
506
|
+
if block.get("type") == "image"
|
|
507
|
+
]
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _image_part(block: Mapping[str, Any]) -> dict[str, Any]:
|
|
511
|
+
return {
|
|
512
|
+
"type": "image_url",
|
|
513
|
+
"image_url": {
|
|
514
|
+
"url": f"data:{block.get('mimeType')};base64,{block.get('data')}",
|
|
515
|
+
},
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _json_arguments(arguments: Any) -> str:
|
|
520
|
+
return json.dumps(arguments if arguments is not None else {}, ensure_ascii=False, separators=(",", ":"))
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def convert_tools(tools: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
|
524
|
+
"""Convert stored tool declarations to Chat Completions function tools."""
|
|
525
|
+
|
|
526
|
+
return [
|
|
527
|
+
{
|
|
528
|
+
"type": "function",
|
|
529
|
+
"function": {
|
|
530
|
+
"name": cast(str, tool.get("name") or ""),
|
|
531
|
+
"description": cast(str, tool.get("description") or ""),
|
|
532
|
+
"parameters": tool.get("parameters") or {"type": "object", "properties": {}},
|
|
533
|
+
},
|
|
534
|
+
}
|
|
535
|
+
for tool in tools
|
|
536
|
+
]
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def parse_usage(raw: Any, model: Model) -> dict[str, Any]:
|
|
540
|
+
"""Map an OpenAI usage object onto the pi-ai usage shape and price it."""
|
|
541
|
+
|
|
542
|
+
prompt_tokens = int(provider_field(raw, "prompt_tokens") or 0)
|
|
543
|
+
details = provider_field(raw, "prompt_tokens_details") or {}
|
|
544
|
+
cached = provider_field(details, "cached_tokens")
|
|
545
|
+
if cached is None:
|
|
546
|
+
cached = provider_field(raw, "prompt_cache_hit_tokens")
|
|
547
|
+
if cached is None:
|
|
548
|
+
cached = provider_field(raw, "cached_tokens")
|
|
549
|
+
cache_write = int(provider_field(details, "cache_write_tokens") or 0)
|
|
550
|
+
cache_read = int(cached or 0)
|
|
551
|
+
input_tokens = max(0, prompt_tokens - cache_read - cache_write)
|
|
552
|
+
output_tokens = int(provider_field(raw, "completion_tokens") or 0)
|
|
553
|
+
completion_details = provider_field(raw, "completion_tokens_details") or {}
|
|
554
|
+
reasoning = int(provider_field(completion_details, "reasoning_tokens") or 0)
|
|
555
|
+
usage = zero_usage()
|
|
556
|
+
usage["input"] = input_tokens
|
|
557
|
+
usage["output"] = output_tokens
|
|
558
|
+
usage["cacheRead"] = cache_read
|
|
559
|
+
usage["cacheWrite"] = cache_write
|
|
560
|
+
usage["reasoning"] = reasoning
|
|
561
|
+
usage["totalTokens"] = input_tokens + output_tokens + cache_read + cache_write
|
|
562
|
+
calculate_cost(model, usage)
|
|
563
|
+
return usage
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def map_stop_reason(reason: str) -> tuple[str, str | None]:
|
|
567
|
+
"""Map an OpenAI ``finish_reason`` to a stop reason, with error text when it is a failure."""
|
|
568
|
+
|
|
569
|
+
if reason in _STOP_REASONS:
|
|
570
|
+
return _STOP_REASONS[reason], None
|
|
571
|
+
return "error", f"Provider finish_reason: {reason}"
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
__all__ = ["OPENAI_API", "OpenAIModels"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Per-request SDK options shared by the model adapters.
|
|
2
|
+
|
|
3
|
+
Both official SDKs accept ``timeout`` in seconds and ``extra_headers`` per request. Provider-level
|
|
4
|
+
retries are disabled at the client and run through :mod:`pi_durable.providers.retry` instead, so no
|
|
5
|
+
per-request retry field is sent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def request_options(options: Mapping[str, Any]) -> dict[str, Any]:
|
|
15
|
+
"""Map the port's request options onto the SDK's per-request keyword arguments."""
|
|
16
|
+
|
|
17
|
+
values: dict[str, Any] = {}
|
|
18
|
+
timeout_ms = optional_int(options.get("timeoutMs"))
|
|
19
|
+
if timeout_ms is not None:
|
|
20
|
+
values["timeout"] = timeout_ms / 1000.0
|
|
21
|
+
headers = options.get("headers")
|
|
22
|
+
if isinstance(headers, Mapping) and headers:
|
|
23
|
+
values["extra_headers"] = dict(headers)
|
|
24
|
+
return values
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def optional_int(value: Any) -> int | None:
|
|
28
|
+
"""Coerce a present value to ``int``, preserving ``None``."""
|
|
29
|
+
|
|
30
|
+
if value is None:
|
|
31
|
+
return None
|
|
32
|
+
return int(value)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
__all__ = ["optional_int", "request_options"]
|