pi-ai-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 (189) hide show
  1. pi_agent/__init__.py +127 -0
  2. pi_agent/agent.py +617 -0
  3. pi_agent/agent_loop.py +820 -0
  4. pi_agent/proxy.py +269 -0
  5. pi_agent/py.typed +0 -0
  6. pi_agent/stream_fn.py +27 -0
  7. pi_agent/tools.py +327 -0
  8. pi_agent/types.py +394 -0
  9. pi_ai/__init__.py +263 -0
  10. pi_ai/abort.py +315 -0
  11. pi_ai/api/__init__.py +1 -0
  12. pi_ai/api/anthropic_messages.py +1782 -0
  13. pi_ai/api/azure_openai_config.py +138 -0
  14. pi_ai/api/azure_openai_responses.py +245 -0
  15. pi_ai/api/bedrock_converse_stream.py +1598 -0
  16. pi_ai/api/classifier_shared.py +211 -0
  17. pi_ai/api/cloudflare.py +36 -0
  18. pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
  19. pi_ai/api/constrained_sampling.py +279 -0
  20. pi_ai/api/github_copilot_headers.py +30 -0
  21. pi_ai/api/google_generative_ai.py +138 -0
  22. pi_ai/api/google_shared.py +927 -0
  23. pi_ai/api/google_vertex.py +234 -0
  24. pi_ai/api/lazy.py +156 -0
  25. pi_ai/api/llama_cpp_classify.py +449 -0
  26. pi_ai/api/mistral_conversations.py +756 -0
  27. pi_ai/api/openai_codex_responses.py +1545 -0
  28. pi_ai/api/openai_completions.py +1594 -0
  29. pi_ai/api/openai_decisions.py +210 -0
  30. pi_ai/api/openai_prompt_cache.py +19 -0
  31. pi_ai/api/openai_responses.py +418 -0
  32. pi_ai/api/openai_responses_shared.py +840 -0
  33. pi_ai/api/openai_sdk.py +204 -0
  34. pi_ai/api/openrouter_images.py +153 -0
  35. pi_ai/api/pi_messages.py +449 -0
  36. pi_ai/api/registry.py +103 -0
  37. pi_ai/api/simple_options.py +119 -0
  38. pi_ai/api/system_one_shared.py +148 -0
  39. pi_ai/api/transform_messages.py +211 -0
  40. pi_ai/api/typesafe_system_one.py +43 -0
  41. pi_ai/auth/__init__.py +1 -0
  42. pi_ai/auth/context.py +47 -0
  43. pi_ai/auth/credential_store.py +208 -0
  44. pi_ai/auth/helpers.py +123 -0
  45. pi_ai/auth/oauth/__init__.py +5 -0
  46. pi_ai/auth/oauth/_common.py +454 -0
  47. pi_ai/auth/oauth/anthropic.py +283 -0
  48. pi_ai/auth/oauth/callback_server.py +339 -0
  49. pi_ai/auth/oauth/device_code.py +127 -0
  50. pi_ai/auth/oauth/github_copilot.py +508 -0
  51. pi_ai/auth/oauth/kimi_coding.py +283 -0
  52. pi_ai/auth/oauth/load.py +64 -0
  53. pi_ai/auth/oauth/meta.py +238 -0
  54. pi_ai/auth/oauth/openai_chatgpt.py +321 -0
  55. pi_ai/auth/oauth/openai_codex.py +391 -0
  56. pi_ai/auth/oauth/openrouter.py +173 -0
  57. pi_ai/auth/oauth/pkce.py +23 -0
  58. pi_ai/auth/oauth/radius.py +311 -0
  59. pi_ai/auth/oauth/xai.py +220 -0
  60. pi_ai/auth/resolve.py +190 -0
  61. pi_ai/auth/types.py +292 -0
  62. pi_ai/cli.py +265 -0
  63. pi_ai/compat.py +651 -0
  64. pi_ai/env_api_keys.py +137 -0
  65. pi_ai/event_stream.py +329 -0
  66. pi_ai/models.py +1191 -0
  67. pi_ai/models_store.py +121 -0
  68. pi_ai/oauth.py +90 -0
  69. pi_ai/providers/__init__.py +1 -0
  70. pi_ai/providers/all.py +154 -0
  71. pi_ai/providers/amazon_bedrock.py +116 -0
  72. pi_ai/providers/ant_ling.py +23 -0
  73. pi_ai/providers/anthropic.py +105 -0
  74. pi_ai/providers/azure.py +103 -0
  75. pi_ai/providers/baseten.py +23 -0
  76. pi_ai/providers/catalog.py +167 -0
  77. pi_ai/providers/cerebras.py +23 -0
  78. pi_ai/providers/cloudflare_ai_gateway.py +30 -0
  79. pi_ai/providers/cloudflare_auth.py +112 -0
  80. pi_ai/providers/cloudflare_stream.py +84 -0
  81. pi_ai/providers/cloudflare_workers_ai.py +26 -0
  82. pi_ai/providers/data/amazon-bedrock.json +1 -0
  83. pi_ai/providers/data/ant-ling.json +1 -0
  84. pi_ai/providers/data/anthropic.json +1 -0
  85. pi_ai/providers/data/azure.json +1 -0
  86. pi_ai/providers/data/baseten.json +1 -0
  87. pi_ai/providers/data/cerebras.json +1 -0
  88. pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
  89. pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
  90. pi_ai/providers/data/deepseek.json +1 -0
  91. pi_ai/providers/data/fireworks.json +1 -0
  92. pi_ai/providers/data/github-copilot.json +1 -0
  93. pi_ai/providers/data/google-vertex.json +1 -0
  94. pi_ai/providers/data/google.json +1 -0
  95. pi_ai/providers/data/groq.json +1 -0
  96. pi_ai/providers/data/huggingface.json +1 -0
  97. pi_ai/providers/data/kimi-coding.json +1 -0
  98. pi_ai/providers/data/manifest.json +1 -0
  99. pi_ai/providers/data/meta.json +1 -0
  100. pi_ai/providers/data/minimax-cn.json +1 -0
  101. pi_ai/providers/data/minimax.json +1 -0
  102. pi_ai/providers/data/mistral.json +1 -0
  103. pi_ai/providers/data/moonshotai-cn.json +1 -0
  104. pi_ai/providers/data/moonshotai.json +1 -0
  105. pi_ai/providers/data/nvidia.json +1 -0
  106. pi_ai/providers/data/openai-codex.json +1 -0
  107. pi_ai/providers/data/openai.json +1 -0
  108. pi_ai/providers/data/opencode-go.json +1 -0
  109. pi_ai/providers/data/opencode.json +1 -0
  110. pi_ai/providers/data/openrouter.json +1 -0
  111. pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
  112. pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
  113. pi_ai/providers/data/qwen-token-plan.json +1 -0
  114. pi_ai/providers/data/radius.json +1 -0
  115. pi_ai/providers/data/together.json +1 -0
  116. pi_ai/providers/data/typesafe.json +1 -0
  117. pi_ai/providers/data/vercel-ai-gateway.json +1 -0
  118. pi_ai/providers/data/xai.json +1 -0
  119. pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
  120. pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
  121. pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
  122. pi_ai/providers/data/xiaomi.json +1 -0
  123. pi_ai/providers/data/zai-coding-cn.json +1 -0
  124. pi_ai/providers/data/zai.json +1 -0
  125. pi_ai/providers/deepseek.py +23 -0
  126. pi_ai/providers/faux.py +680 -0
  127. pi_ai/providers/fireworks.py +26 -0
  128. pi_ai/providers/github_copilot.py +46 -0
  129. pi_ai/providers/google.py +23 -0
  130. pi_ai/providers/google_vertex.py +131 -0
  131. pi_ai/providers/groq.py +23 -0
  132. pi_ai/providers/huggingface.py +23 -0
  133. pi_ai/providers/kimi_coding.py +32 -0
  134. pi_ai/providers/meta.py +32 -0
  135. pi_ai/providers/minimax.py +23 -0
  136. pi_ai/providers/minimax_cn.py +23 -0
  137. pi_ai/providers/mistral.py +23 -0
  138. pi_ai/providers/moonshotai.py +23 -0
  139. pi_ai/providers/moonshotai_cn.py +23 -0
  140. pi_ai/providers/nvidia.py +23 -0
  141. pi_ai/providers/openai.py +44 -0
  142. pi_ai/providers/openai_codex.py +26 -0
  143. pi_ai/providers/opencode.py +36 -0
  144. pi_ai/providers/opencode_go.py +33 -0
  145. pi_ai/providers/opencode_headers.py +60 -0
  146. pi_ai/providers/openrouter.py +145 -0
  147. pi_ai/providers/openrouter_api.py +122 -0
  148. pi_ai/providers/qwen_token_plan.py +23 -0
  149. pi_ai/providers/qwen_token_plan_cn.py +23 -0
  150. pi_ai/providers/qwen_token_plan_individual.py +23 -0
  151. pi_ai/providers/radius.py +146 -0
  152. pi_ai/providers/radius_config.py +136 -0
  153. pi_ai/providers/together.py +23 -0
  154. pi_ai/providers/typesafe.py +22 -0
  155. pi_ai/providers/vercel_ai_gateway.py +25 -0
  156. pi_ai/providers/xai.py +32 -0
  157. pi_ai/providers/xiaomi.py +23 -0
  158. pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
  159. pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
  160. pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
  161. pi_ai/providers/zai.py +23 -0
  162. pi_ai/providers/zai_coding_cn.py +23 -0
  163. pi_ai/py.typed +0 -0
  164. pi_ai/serialization.py +477 -0
  165. pi_ai/session_resources.py +33 -0
  166. pi_ai/types.py +1003 -0
  167. pi_ai/utils/__init__.py +1 -0
  168. pi_ai/utils/callbacks.py +15 -0
  169. pi_ai/utils/diagnostics.py +70 -0
  170. pi_ai/utils/error_body.py +89 -0
  171. pi_ai/utils/estimate.py +125 -0
  172. pi_ai/utils/fastjson.py +63 -0
  173. pi_ai/utils/http.py +409 -0
  174. pi_ai/utils/json_parse.py +424 -0
  175. pi_ai/utils/misc.py +193 -0
  176. pi_ai/utils/model_operations.py +73 -0
  177. pi_ai/utils/models_error.py +35 -0
  178. pi_ai/utils/oauth_page.py +121 -0
  179. pi_ai/utils/overflow.py +87 -0
  180. pi_ai/utils/retry.py +193 -0
  181. pi_ai/utils/schema.py +283 -0
  182. pi_ai/utils/text.py +65 -0
  183. pi_ai/utils/transcript.py +271 -0
  184. pi_ai/utils/validation.py +813 -0
  185. pi_ai_python-1.3.0.dist-info/METADATA +167 -0
  186. pi_ai_python-1.3.0.dist-info/RECORD +189 -0
  187. pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
  188. pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
  189. pi_ai_python-1.3.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,204 @@
1
+ """Shared OpenAI SDK plumbing for the adapters built on the official ``openai`` package.
2
+
3
+ Upstream pi creates an ``OpenAI`` (or ``AzureOpenAI``) client per request, calls
4
+ ``create(params, { maxRetries: 0 }).withResponse()`` and wraps that call in
5
+ ``retryProviderRequest`` so the backoff sleep stays abortable. The helpers here do
6
+ the same with ``AsyncOpenAI`` / ``AsyncAzureOpenAI`` and translate SDK exceptions
7
+ into :class:`~pi_ai.utils.http.ProviderHttpError` with the JS SDK's message wording,
8
+ which pi's retry and context-overflow detection match against.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
15
+ from typing import Any, TypeVar, cast
16
+
17
+ import httpx
18
+ import openai
19
+ from openai import AsyncAzureOpenAI, AsyncOpenAI, AsyncStream, omit
20
+
21
+ from ..utils.http import (
22
+ DEFAULT_TIMEOUT_MS,
23
+ ProviderHttpError,
24
+ get_http_client,
25
+ make_status_error,
26
+ retry_provider_request,
27
+ )
28
+ from ..utils.misc import get_provider_env_value
29
+
30
+ __all__ = [
31
+ "create_openai_client",
32
+ "merge_sdk_headers",
33
+ "sdk_body_arguments",
34
+ "openai_request",
35
+ "open_sdk_stream",
36
+ "iter_sdk_stream",
37
+ "status_error_from_sdk",
38
+ "stream_error_from_sdk",
39
+ ]
40
+
41
+ _T = TypeVar("_T")
42
+
43
+ #: ``AsyncStream`` yielding the decoded JSON of each event. Like the JS SDK, which hands
44
+ #: back the parsed objects as-is; building pydantic models for every event would cost more
45
+ #: than pi's own event processing.
46
+ _RawStream = AsyncStream[object]
47
+
48
+
49
+ def merge_sdk_headers(*sources: Mapping[str, str | None] | None) -> dict[str, str | None]:
50
+ """Merge header sources case-insensitively; later sources win.
51
+
52
+ ``None`` values are kept: they remove the header, including the SDK's own defaults
53
+ (``Authorization``, ``User-Agent``, ...), like ``null`` in the JS SDK's ``defaultHeaders``.
54
+ """
55
+ merged: dict[str, tuple[str, str | None]] = {}
56
+ for source in sources:
57
+ if not source:
58
+ continue
59
+ for name, value in source.items():
60
+ lower = name.lower()
61
+ merged.pop(lower, None)
62
+ merged[lower] = (name, value)
63
+ return {name: value for name, value in merged.values()}
64
+
65
+
66
+ def create_openai_client(
67
+ *,
68
+ api_key: str,
69
+ base_url: str | None,
70
+ headers: Mapping[str, str | None],
71
+ options: Mapping[str, Any],
72
+ azure_api_version: str | None = None,
73
+ ) -> AsyncOpenAI:
74
+ """An ``AsyncOpenAI`` client (``AsyncAzureOpenAI`` with ``azure_api_version``) for one request.
75
+
76
+ ``None`` header values are left out here and removed per request (see
77
+ :func:`sdk_body_arguments`). Retries are left to :func:`openai_request`. The transport is ``options["http_client"]`` or
78
+ pi's pooled client for the running loop, so connections are reused across requests.
79
+ Construction is cheap (tens of microseconds), so clients are not cached.
80
+ """
81
+ timeout_ms = options.get("timeout_ms")
82
+ env = options.get("env")
83
+ default_headers = {name: value for name, value in headers.items() if value is not None}
84
+ http_client = cast(Any, get_http_client(base_url or "", options))
85
+ common: dict[str, Any] = {
86
+ "api_key": api_key,
87
+ "organization": get_provider_env_value("OPENAI_ORG_ID", env),
88
+ "project": get_provider_env_value("OPENAI_PROJECT_ID", env),
89
+ # Same overall budget as the JS SDK default (10 minutes); ``read`` is the idle gap
90
+ # between chunks, so an active stream is never cut off.
91
+ "timeout": (timeout_ms if timeout_ms is not None else DEFAULT_TIMEOUT_MS) / 1000,
92
+ "max_retries": 0,
93
+ "default_headers": default_headers,
94
+ "http_client": http_client,
95
+ }
96
+ if azure_api_version is not None:
97
+ return AsyncAzureOpenAI(api_version=azure_api_version, base_url=base_url or "", **common)
98
+ return AsyncOpenAI(base_url=base_url or None, **common)
99
+
100
+
101
+ def sdk_body_arguments(
102
+ params: Mapping[str, Any], *required: str, headers: Mapping[str, str | None] | None = None
103
+ ) -> dict[str, Any]:
104
+ """``create()`` keyword arguments that send ``params`` as the request body unchanged.
105
+
106
+ pi builds (and ``on_payload`` may replace) the body in wire format, including fields the
107
+ SDK does not declare, so it goes through ``extra_body``. ``required`` parameters are
108
+ passed as ``omit`` so the body keys come only from ``params``. This also skips the SDK's
109
+ parameter transform, which walks the whole transcript on every request.
110
+
111
+ Headers that ``headers`` (the client's header record) maps to ``None`` are omitted per
112
+ request: the Python SDK only accepts a removed ``Authorization`` header there.
113
+ """
114
+ arguments: dict[str, Any] = dict.fromkeys(required, omit)
115
+ arguments["extra_body"] = params
116
+ if headers:
117
+ removed = {name: omit for name, value in headers.items() if value is None}
118
+ if removed:
119
+ arguments["extra_headers"] = removed
120
+ return arguments
121
+
122
+
123
+ def _legacy_headers(headers: Any) -> httpx.Headers:
124
+ return headers if isinstance(headers, httpx.Headers) else httpx.Headers(list(headers.multi_items()))
125
+
126
+
127
+ def status_error_from_sdk(error: openai.APIStatusError) -> ProviderHttpError:
128
+ """``"<status> <error.message>"`` from the raw body, as the JS SDK formats ``APIError``."""
129
+ response = error.response
130
+ try:
131
+ text = response.text
132
+ except Exception: # body could not be read
133
+ text = ""
134
+ return make_status_error(error.status_code, text, _legacy_headers(response.headers), "openai")
135
+
136
+
137
+ def stream_error_from_sdk(error: openai.APIError, headers: Any) -> ProviderHttpError:
138
+ """The JS SDK's ``APIError`` for a stream event carrying an ``error`` field."""
139
+ body = error.body
140
+ message = body.get("message") if isinstance(body, dict) else None
141
+ if message:
142
+ text = message if isinstance(message, str) else _json_compact(message)
143
+ elif body:
144
+ text = _json_compact(body)
145
+ else:
146
+ text = "(no status code or body)"
147
+ return ProviderHttpError(text, headers=_legacy_headers(headers), error=body)
148
+
149
+
150
+ def _json_compact(value: Any) -> str:
151
+ try:
152
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
153
+ except (TypeError, ValueError):
154
+ return str(value)
155
+
156
+
157
+ async def openai_request(call: Callable[[], Awaitable[_T]], options: Mapping[str, Any]) -> _T:
158
+ """Run an SDK request with pi's abortable, SDK-compatible retry policy.
159
+
160
+ HTTP errors become :class:`ProviderHttpError` (status, headers, parsed ``error`` body) and
161
+ connection failures a status-less one (``"Connection error."``, ``"Request timed out."``).
162
+ """
163
+
164
+ async def attempt() -> _T:
165
+ try:
166
+ return await call()
167
+ except openai.APIStatusError as error:
168
+ raise status_error_from_sdk(error) from error
169
+ except openai.APIConnectionError as error:
170
+ raise ProviderHttpError(error.message) from error
171
+
172
+ return await retry_provider_request(
173
+ attempt,
174
+ max_retries=options.get("max_retries") or 0,
175
+ max_retry_delay_ms=options.get("max_retry_delay_ms"),
176
+ signal=options.get("signal"),
177
+ )
178
+
179
+
180
+ # Streams are decoded by the SDK, as upstream iterates the JS SDK's ``Stream``. The SDK's SSE
181
+ # decoder is the throughput limit of these adapters: about 70k events/s in
182
+ # ``benchmarks/bench_stream.py`` versus about 120k with pi's ``iter_sse`` + ``fastjson``.
183
+ # Two ways to recover that speed, neither taken:
184
+ # - overriding ``AsyncOpenAI._make_sse_decoder`` relies on a private SDK method that can change
185
+ # in any release;
186
+ # - parsing ``raw.http_response`` with pi's own SSE parser uses only public API (the Anthropic
187
+ # adapter does this), but stops using the SDK's stream decoding.
188
+ # Real model output arrives far slower than either rate, so per-event cost rarely matters.
189
+ def open_sdk_stream(raw: Any) -> AsyncStream[Any]:
190
+ """The event stream of a ``with_raw_response`` streaming call, yielding plain JSON."""
191
+ return raw.parse(to=_RawStream)
192
+
193
+
194
+ async def iter_sdk_stream(stream: AsyncStream[Any]) -> AsyncIterator[Any]:
195
+ """Iterate SDK stream events; ``error`` events raise like the JS SDK's ``Stream``."""
196
+ try:
197
+ async for event in stream:
198
+ yield event
199
+ except (openai.APIStatusError, openai.APIConnectionError):
200
+ raise
201
+ except openai.APIError as error:
202
+ raise stream_error_from_sdk(error, stream.response.headers) from error
203
+ finally:
204
+ await stream.close()
@@ -0,0 +1,153 @@
1
+ """Image generation over OpenRouter's chat completions endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ from ..abort import AbortError, abort_scope
10
+ from ..types import (
11
+ AssistantImages,
12
+ ImageContent,
13
+ ImageModel,
14
+ ImagesContext,
15
+ ProviderResponse,
16
+ TextContent,
17
+ Usage,
18
+ UsageCost,
19
+ now_ms,
20
+ )
21
+ from ..utils import fastjson
22
+ from ..utils.callbacks import maybe_await
23
+ from ..utils.error_body import format_provider_error, normalize_provider_error
24
+ from ..utils.misc import headers_to_record, provider_headers_to_record, sanitize_surrogates
25
+ from ..utils.model_operations import image_error_result
26
+ from .openai_sdk import create_openai_client, openai_request, sdk_body_arguments
27
+
28
+ __all__ = ["generate_images"]
29
+
30
+ _DATA_URL = re.compile(r"data:([^;]+);base64,(.+)")
31
+
32
+
33
+ async def generate_images(
34
+ model: ImageModel, context: ImagesContext, options: Mapping[str, Any] | None = None
35
+ ) -> AssistantImages:
36
+ """Generate images with an OpenRouter image model. Never raises."""
37
+ options = options or {}
38
+ output = AssistantImages(
39
+ api=model.api, provider=model.provider, model=model.id, output=[], stop_reason="stop", timestamp=now_ms()
40
+ )
41
+ signal = options.get("signal")
42
+ try:
43
+ api_key = options.get("api_key")
44
+ if not api_key:
45
+ raise ValueError(f"No API key for provider: {model.provider}")
46
+ client = create_openai_client(
47
+ api_key=api_key,
48
+ base_url=model.base_url,
49
+ headers=provider_headers_to_record(model.headers, options.get("headers")) or {},
50
+ options=options,
51
+ )
52
+ params: Any = _build_params(model, context)
53
+ on_payload = options.get("on_payload")
54
+ if on_payload is not None:
55
+ next_params = await maybe_await(on_payload(params, model))
56
+ if next_params is not None:
57
+ params = next_params
58
+ create = client.chat.completions.with_raw_response.create
59
+ body_arguments = sdk_body_arguments(params, "model", "messages")
60
+ try:
61
+ async with abort_scope(signal):
62
+ response = await openai_request(lambda: create(**body_arguments), options)
63
+ except AbortError as error:
64
+ raise AbortError("Request aborted") from error
65
+ on_response = options.get("on_response")
66
+ if on_response is not None:
67
+ await maybe_await(
68
+ on_response(ProviderResponse(response.status_code, headers_to_record(response.headers)), model)
69
+ )
70
+
71
+ body = fastjson.loads(response.content)
72
+ if not isinstance(body, Mapping):
73
+ raise ValueError("OpenRouter returned an unexpected image generation response")
74
+ output.response_id = body.get("id")
75
+ raw_usage = body.get("usage")
76
+ if isinstance(raw_usage, Mapping):
77
+ output.usage = _parse_usage(raw_usage, model)
78
+
79
+ choices = body.get("choices")
80
+ if not isinstance(choices, list):
81
+ raise ValueError("OpenRouter returned an image generation response without choices")
82
+ choice = choices[0] if choices else None
83
+ if isinstance(choice, Mapping):
84
+ message = choice.get("message") or {}
85
+ content = message.get("content")
86
+ if isinstance(content, str) and content:
87
+ output.output.append(TextContent(content))
88
+ for image in message.get("images") or ():
89
+ image_url = image.get("image_url") if isinstance(image, Mapping) else None
90
+ url = image_url.get("url") if isinstance(image_url, Mapping) else image_url
91
+ if not isinstance(url, str) or not url.startswith("data:"):
92
+ continue
93
+ matches = _DATA_URL.fullmatch(url)
94
+ if matches is None:
95
+ continue
96
+ output.output.append(ImageContent(data=matches.group(2), mime_type=matches.group(1)))
97
+ return output
98
+ except Exception as error:
99
+ failed = image_error_result(
100
+ model,
101
+ format_provider_error(normalize_provider_error(error)),
102
+ aborted=signal is not None and signal.aborted,
103
+ )
104
+ failed.response_id = output.response_id
105
+ failed.usage = output.usage
106
+ failed.timestamp = output.timestamp
107
+ return failed
108
+
109
+
110
+ def _build_params(model: ImageModel, context: ImagesContext) -> dict[str, Any]:
111
+ """The chat completions request body for an image generation."""
112
+ content: list[dict[str, Any]] = []
113
+ for item in context.input:
114
+ if item.type == "text":
115
+ content.append({"type": "text", "text": sanitize_surrogates(item.text)}) # type: ignore[union-attr]
116
+ else:
117
+ content.append(
118
+ {"type": "image_url", "image_url": {"url": f"data:{item.mime_type};base64,{item.data}"}} # type: ignore[union-attr]
119
+ )
120
+ return {
121
+ "model": model.id,
122
+ "messages": [{"role": "user", "content": content}],
123
+ "stream": False,
124
+ "modalities": ["image", "text"] if "text" in model.output else ["image"],
125
+ }
126
+
127
+
128
+ def _parse_usage(raw_usage: Mapping[str, Any], model: ImageModel) -> Usage:
129
+ """Usage from an OpenAI-style ``usage`` object, priced from the model's base rates."""
130
+ details = raw_usage.get("prompt_tokens_details") or {}
131
+ prompt_tokens = raw_usage.get("prompt_tokens") or 0
132
+ reported_cached_tokens = details.get("cached_tokens") or 0
133
+ cache_write_tokens = details.get("cache_write_tokens") or 0
134
+ cache_read_tokens = (
135
+ max(0, reported_cached_tokens - cache_write_tokens) if cache_write_tokens > 0 else reported_cached_tokens
136
+ )
137
+ input_tokens = max(0, prompt_tokens - cache_read_tokens - cache_write_tokens)
138
+ output_tokens = raw_usage.get("completion_tokens") or 0
139
+ cost = UsageCost(
140
+ input=(model.cost.input / 1000000) * input_tokens,
141
+ output=(model.cost.output / 1000000) * output_tokens,
142
+ cache_read=(model.cost.cache_read / 1000000) * cache_read_tokens,
143
+ cache_write=(model.cost.cache_write / 1000000) * cache_write_tokens,
144
+ )
145
+ cost.total = cost.input + cost.output + cost.cache_read + cost.cache_write
146
+ return Usage(
147
+ input_tokens,
148
+ output_tokens,
149
+ cache_read_tokens,
150
+ cache_write_tokens,
151
+ input_tokens + output_tokens + cache_read_tokens + cache_write_tokens,
152
+ cost,
153
+ )