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.
- pi_agent/__init__.py +127 -0
- pi_agent/agent.py +617 -0
- pi_agent/agent_loop.py +820 -0
- pi_agent/proxy.py +269 -0
- pi_agent/py.typed +0 -0
- pi_agent/stream_fn.py +27 -0
- pi_agent/tools.py +327 -0
- pi_agent/types.py +394 -0
- pi_ai/__init__.py +263 -0
- pi_ai/abort.py +315 -0
- pi_ai/api/__init__.py +1 -0
- pi_ai/api/anthropic_messages.py +1782 -0
- pi_ai/api/azure_openai_config.py +138 -0
- pi_ai/api/azure_openai_responses.py +245 -0
- pi_ai/api/bedrock_converse_stream.py +1598 -0
- pi_ai/api/classifier_shared.py +211 -0
- pi_ai/api/cloudflare.py +36 -0
- pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
- pi_ai/api/constrained_sampling.py +279 -0
- pi_ai/api/github_copilot_headers.py +30 -0
- pi_ai/api/google_generative_ai.py +138 -0
- pi_ai/api/google_shared.py +927 -0
- pi_ai/api/google_vertex.py +234 -0
- pi_ai/api/lazy.py +156 -0
- pi_ai/api/llama_cpp_classify.py +449 -0
- pi_ai/api/mistral_conversations.py +756 -0
- pi_ai/api/openai_codex_responses.py +1545 -0
- pi_ai/api/openai_completions.py +1594 -0
- pi_ai/api/openai_decisions.py +210 -0
- pi_ai/api/openai_prompt_cache.py +19 -0
- pi_ai/api/openai_responses.py +418 -0
- pi_ai/api/openai_responses_shared.py +840 -0
- pi_ai/api/openai_sdk.py +204 -0
- pi_ai/api/openrouter_images.py +153 -0
- pi_ai/api/pi_messages.py +449 -0
- pi_ai/api/registry.py +103 -0
- pi_ai/api/simple_options.py +119 -0
- pi_ai/api/system_one_shared.py +148 -0
- pi_ai/api/transform_messages.py +211 -0
- pi_ai/api/typesafe_system_one.py +43 -0
- pi_ai/auth/__init__.py +1 -0
- pi_ai/auth/context.py +47 -0
- pi_ai/auth/credential_store.py +208 -0
- pi_ai/auth/helpers.py +123 -0
- pi_ai/auth/oauth/__init__.py +5 -0
- pi_ai/auth/oauth/_common.py +454 -0
- pi_ai/auth/oauth/anthropic.py +283 -0
- pi_ai/auth/oauth/callback_server.py +339 -0
- pi_ai/auth/oauth/device_code.py +127 -0
- pi_ai/auth/oauth/github_copilot.py +508 -0
- pi_ai/auth/oauth/kimi_coding.py +283 -0
- pi_ai/auth/oauth/load.py +64 -0
- pi_ai/auth/oauth/meta.py +238 -0
- pi_ai/auth/oauth/openai_chatgpt.py +321 -0
- pi_ai/auth/oauth/openai_codex.py +391 -0
- pi_ai/auth/oauth/openrouter.py +173 -0
- pi_ai/auth/oauth/pkce.py +23 -0
- pi_ai/auth/oauth/radius.py +311 -0
- pi_ai/auth/oauth/xai.py +220 -0
- pi_ai/auth/resolve.py +190 -0
- pi_ai/auth/types.py +292 -0
- pi_ai/cli.py +265 -0
- pi_ai/compat.py +651 -0
- pi_ai/env_api_keys.py +137 -0
- pi_ai/event_stream.py +329 -0
- pi_ai/models.py +1191 -0
- pi_ai/models_store.py +121 -0
- pi_ai/oauth.py +90 -0
- pi_ai/providers/__init__.py +1 -0
- pi_ai/providers/all.py +154 -0
- pi_ai/providers/amazon_bedrock.py +116 -0
- pi_ai/providers/ant_ling.py +23 -0
- pi_ai/providers/anthropic.py +105 -0
- pi_ai/providers/azure.py +103 -0
- pi_ai/providers/baseten.py +23 -0
- pi_ai/providers/catalog.py +167 -0
- pi_ai/providers/cerebras.py +23 -0
- pi_ai/providers/cloudflare_ai_gateway.py +30 -0
- pi_ai/providers/cloudflare_auth.py +112 -0
- pi_ai/providers/cloudflare_stream.py +84 -0
- pi_ai/providers/cloudflare_workers_ai.py +26 -0
- pi_ai/providers/data/amazon-bedrock.json +1 -0
- pi_ai/providers/data/ant-ling.json +1 -0
- pi_ai/providers/data/anthropic.json +1 -0
- pi_ai/providers/data/azure.json +1 -0
- pi_ai/providers/data/baseten.json +1 -0
- pi_ai/providers/data/cerebras.json +1 -0
- pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
- pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
- pi_ai/providers/data/deepseek.json +1 -0
- pi_ai/providers/data/fireworks.json +1 -0
- pi_ai/providers/data/github-copilot.json +1 -0
- pi_ai/providers/data/google-vertex.json +1 -0
- pi_ai/providers/data/google.json +1 -0
- pi_ai/providers/data/groq.json +1 -0
- pi_ai/providers/data/huggingface.json +1 -0
- pi_ai/providers/data/kimi-coding.json +1 -0
- pi_ai/providers/data/manifest.json +1 -0
- pi_ai/providers/data/meta.json +1 -0
- pi_ai/providers/data/minimax-cn.json +1 -0
- pi_ai/providers/data/minimax.json +1 -0
- pi_ai/providers/data/mistral.json +1 -0
- pi_ai/providers/data/moonshotai-cn.json +1 -0
- pi_ai/providers/data/moonshotai.json +1 -0
- pi_ai/providers/data/nvidia.json +1 -0
- pi_ai/providers/data/openai-codex.json +1 -0
- pi_ai/providers/data/openai.json +1 -0
- pi_ai/providers/data/opencode-go.json +1 -0
- pi_ai/providers/data/opencode.json +1 -0
- pi_ai/providers/data/openrouter.json +1 -0
- pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
- pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
- pi_ai/providers/data/qwen-token-plan.json +1 -0
- pi_ai/providers/data/radius.json +1 -0
- pi_ai/providers/data/together.json +1 -0
- pi_ai/providers/data/typesafe.json +1 -0
- pi_ai/providers/data/vercel-ai-gateway.json +1 -0
- pi_ai/providers/data/xai.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
- pi_ai/providers/data/xiaomi.json +1 -0
- pi_ai/providers/data/zai-coding-cn.json +1 -0
- pi_ai/providers/data/zai.json +1 -0
- pi_ai/providers/deepseek.py +23 -0
- pi_ai/providers/faux.py +680 -0
- pi_ai/providers/fireworks.py +26 -0
- pi_ai/providers/github_copilot.py +46 -0
- pi_ai/providers/google.py +23 -0
- pi_ai/providers/google_vertex.py +131 -0
- pi_ai/providers/groq.py +23 -0
- pi_ai/providers/huggingface.py +23 -0
- pi_ai/providers/kimi_coding.py +32 -0
- pi_ai/providers/meta.py +32 -0
- pi_ai/providers/minimax.py +23 -0
- pi_ai/providers/minimax_cn.py +23 -0
- pi_ai/providers/mistral.py +23 -0
- pi_ai/providers/moonshotai.py +23 -0
- pi_ai/providers/moonshotai_cn.py +23 -0
- pi_ai/providers/nvidia.py +23 -0
- pi_ai/providers/openai.py +44 -0
- pi_ai/providers/openai_codex.py +26 -0
- pi_ai/providers/opencode.py +36 -0
- pi_ai/providers/opencode_go.py +33 -0
- pi_ai/providers/opencode_headers.py +60 -0
- pi_ai/providers/openrouter.py +145 -0
- pi_ai/providers/openrouter_api.py +122 -0
- pi_ai/providers/qwen_token_plan.py +23 -0
- pi_ai/providers/qwen_token_plan_cn.py +23 -0
- pi_ai/providers/qwen_token_plan_individual.py +23 -0
- pi_ai/providers/radius.py +146 -0
- pi_ai/providers/radius_config.py +136 -0
- pi_ai/providers/together.py +23 -0
- pi_ai/providers/typesafe.py +22 -0
- pi_ai/providers/vercel_ai_gateway.py +25 -0
- pi_ai/providers/xai.py +32 -0
- pi_ai/providers/xiaomi.py +23 -0
- pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
- pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
- pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
- pi_ai/providers/zai.py +23 -0
- pi_ai/providers/zai_coding_cn.py +23 -0
- pi_ai/py.typed +0 -0
- pi_ai/serialization.py +477 -0
- pi_ai/session_resources.py +33 -0
- pi_ai/types.py +1003 -0
- pi_ai/utils/__init__.py +1 -0
- pi_ai/utils/callbacks.py +15 -0
- pi_ai/utils/diagnostics.py +70 -0
- pi_ai/utils/error_body.py +89 -0
- pi_ai/utils/estimate.py +125 -0
- pi_ai/utils/fastjson.py +63 -0
- pi_ai/utils/http.py +409 -0
- pi_ai/utils/json_parse.py +424 -0
- pi_ai/utils/misc.py +193 -0
- pi_ai/utils/model_operations.py +73 -0
- pi_ai/utils/models_error.py +35 -0
- pi_ai/utils/oauth_page.py +121 -0
- pi_ai/utils/overflow.py +87 -0
- pi_ai/utils/retry.py +193 -0
- pi_ai/utils/schema.py +283 -0
- pi_ai/utils/text.py +65 -0
- pi_ai/utils/transcript.py +271 -0
- pi_ai/utils/validation.py +813 -0
- pi_ai_python-1.3.0.dist-info/METADATA +167 -0
- pi_ai_python-1.3.0.dist-info/RECORD +189 -0
- pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
- pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
- pi_ai_python-1.3.0.dist-info/licenses/LICENSE +22 -0
pi_ai/api/openai_sdk.py
ADDED
|
@@ -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
|
+
)
|