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.
Files changed (90) hide show
  1. pi_durable/__init__.py +320 -0
  2. pi_durable/context.py +328 -0
  3. pi_durable/delta.py +269 -0
  4. pi_durable/documents.py +366 -0
  5. pi_durable/entries.py +66 -0
  6. pi_durable/env/__init__.py +408 -0
  7. pi_durable/env/decode.py +48 -0
  8. pi_durable/env/line_scan.py +190 -0
  9. pi_durable/env/posix.py +1547 -0
  10. pi_durable/errors.py +89 -0
  11. pi_durable/harness/__init__.py +1 -0
  12. pi_durable/harness/agent.py +344 -0
  13. pi_durable/harness/compaction.py +673 -0
  14. pi_durable/harness/context.py +515 -0
  15. pi_durable/harness/define.py +111 -0
  16. pi_durable/harness/events.py +766 -0
  17. pi_durable/harness/generation.py +1079 -0
  18. pi_durable/harness/harness.py +570 -0
  19. pi_durable/harness/inbox.py +255 -0
  20. pi_durable/harness/live.py +374 -0
  21. pi_durable/harness/output.py +467 -0
  22. pi_durable/harness/prompt.py +288 -0
  23. pi_durable/harness/provider.py +78 -0
  24. pi_durable/harness/registry.py +136 -0
  25. pi_durable/harness/scheduler.py +1977 -0
  26. pi_durable/harness/submissions.py +357 -0
  27. pi_durable/harness/task_graph.py +334 -0
  28. pi_durable/harness/tool.py +1365 -0
  29. pi_durable/harness/types.py +772 -0
  30. pi_durable/harness/usage.py +88 -0
  31. pi_durable/harness/util.py +204 -0
  32. pi_durable/harness/validation.py +460 -0
  33. pi_durable/harness/view.py +324 -0
  34. pi_durable/ids.py +63 -0
  35. pi_durable/json.py +186 -0
  36. pi_durable/providers/__init__.py +362 -0
  37. pi_durable/providers/anthropic.py +603 -0
  38. pi_durable/providers/base.py +97 -0
  39. pi_durable/providers/json_parse.py +117 -0
  40. pi_durable/providers/openai.py +574 -0
  41. pi_durable/providers/request.py +35 -0
  42. pi_durable/providers/retry.py +127 -0
  43. pi_durable/providers/stream.py +183 -0
  44. pi_durable/providers/transcript.py +224 -0
  45. pi_durable/providers/usage.py +58 -0
  46. pi_durable/py.typed +0 -0
  47. pi_durable/search.py +115 -0
  48. pi_durable/session/__init__.py +5 -0
  49. pi_durable/session/drafts.py +762 -0
  50. pi_durable/session/forks.py +109 -0
  51. pi_durable/session/observation.py +374 -0
  52. pi_durable/session/session.py +1193 -0
  53. pi_durable/session/transaction.py +1088 -0
  54. pi_durable/storage/__init__.py +5 -0
  55. pi_durable/storage/jsonl/__init__.py +15 -0
  56. pi_durable/storage/jsonl/storage.py +958 -0
  57. pi_durable/storage/memory.py +889 -0
  58. pi_durable/storage/scan.py +57 -0
  59. pi_durable/storage/sqlite/__init__.py +26 -0
  60. pi_durable/storage/sqlite/database.py +62 -0
  61. pi_durable/storage/sqlite/migrations.py +191 -0
  62. pi_durable/storage/sqlite/posix.py +314 -0
  63. pi_durable/storage/sqlite/schema.py +97 -0
  64. pi_durable/storage/sqlite/storage.py +1130 -0
  65. pi_durable/tasks.py +89 -0
  66. pi_durable/testing/__init__.py +41 -0
  67. pi_durable/testing/assertions.py +44 -0
  68. pi_durable/testing/env_conformance.py +828 -0
  69. pi_durable/testing/runner.py +64 -0
  70. pi_durable/testing/storage_conformance.py +2008 -0
  71. pi_durable/testing/types.py +99 -0
  72. pi_durable/tool_search/__init__.py +159 -0
  73. pi_durable/tools/__init__.py +81 -0
  74. pi_durable/tools/_values.py +43 -0
  75. pi_durable/tools/bash.py +250 -0
  76. pi_durable/tools/edit.py +214 -0
  77. pi_durable/tools/edit_diff.py +452 -0
  78. pi_durable/tools/env.py +19 -0
  79. pi_durable/tools/file_mutation_queue.py +68 -0
  80. pi_durable/tools/image.py +248 -0
  81. pi_durable/tools/image_processor.py +77 -0
  82. pi_durable/tools/path_utils.py +111 -0
  83. pi_durable/tools/read.py +506 -0
  84. pi_durable/tools/write.py +71 -0
  85. pi_durable/truncate.py +185 -0
  86. pi_durable/types.py +1297 -0
  87. pi_durable_python-1.3.0.dist-info/METADATA +276 -0
  88. pi_durable_python-1.3.0.dist-info/RECORD +90 -0
  89. pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
  90. 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"]