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
pi_ai/utils/http.py ADDED
@@ -0,0 +1,409 @@
1
+ """Shared HTTP transport for provider adapters.
2
+
3
+ Adapters built on vendor SDKs (anthropic, openai, google-genai) hand the pooled
4
+ client from :func:`get_http_client` to the SDK. Adapters for APIs that pi calls
5
+ without an SDK (Mistral, OpenAI Codex, pi-messages, classifiers, OAuth) send
6
+ requests with :func:`send_request`, which provides header suppression with
7
+ ``None`` values, provider-scoped proxy settings, SDK-compatible retries and
8
+ error messages, and prompt cancellation through :class:`pi_ai.AbortSignal`.
9
+
10
+ Connection pools are shared per event loop (and per proxy configuration), so
11
+ consecutive requests reuse TLS connections.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import email.utils
18
+ import json
19
+ import random
20
+ import time
21
+ import weakref
22
+ from collections.abc import AsyncIterator, Mapping, Sequence
23
+ from dataclasses import dataclass
24
+ from typing import Any, Literal
25
+
26
+ import httpx
27
+
28
+ from ..abort import AbortError, AbortSignal, sleep
29
+ from . import fastjson
30
+ from .misc import get_provider_env_value
31
+
32
+ __all__ = [
33
+ "ProviderHttpError",
34
+ "SSEEvent",
35
+ "DEFAULT_TIMEOUT_MS",
36
+ "get_http_client",
37
+ "aclose_http_clients",
38
+ "build_headers",
39
+ "make_status_error",
40
+ "send_request",
41
+ "retry_provider_request",
42
+ "iter_sse",
43
+ "iter_json_lines",
44
+ ]
45
+
46
+ DEFAULT_TIMEOUT_MS = 600_000
47
+ DEFAULT_MAX_RETRY_DELAY_MS = 60_000
48
+
49
+ ErrorStyle = Literal["openai", "anthropic", "raw"]
50
+
51
+
52
+ class ProviderHttpError(Exception):
53
+ """A non-2xx provider response (or a transport failure when ``status`` is ``None``).
54
+
55
+ Attributes follow the shape of the vendor SDK errors that pi normalizes:
56
+ ``status``, ``headers``, the raw ``body`` text, and the parsed ``error`` object.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ message: str,
62
+ *,
63
+ status: int | None = None,
64
+ headers: httpx.Headers | None = None,
65
+ body: str | None = None,
66
+ error: Any = None,
67
+ include_body: bool = False,
68
+ ) -> None:
69
+ super().__init__(message)
70
+ self.status = status
71
+ self.headers = headers
72
+ #: Raw body text. Only exposed for display when ``include_body`` (Mistral-style errors).
73
+ self.body = body if include_body else None
74
+ self.raw_body = body
75
+ #: Parsed error payload, like ``error.error`` on OpenAI SDK errors.
76
+ self.error = error
77
+
78
+
79
+ def make_status_error(
80
+ status: int, body_text: str, headers: httpx.Headers | None = None, style: ErrorStyle = "openai"
81
+ ) -> ProviderHttpError:
82
+ """Build an error whose message matches the vendor SDK wording.
83
+
84
+ * ``openai``: ``"<status> <error.message>"`` using the body's ``error`` field.
85
+ * ``anthropic``: ``"<status> <json body>"`` (the whole body when it has no ``message``).
86
+ * ``raw``: ``"<status> <body text>"``.
87
+ """
88
+ parsed: Any = None
89
+ if body_text:
90
+ try:
91
+ parsed = json.loads(body_text)
92
+ except ValueError:
93
+ parsed = None
94
+ error_obj: Any
95
+ if style == "openai":
96
+ error_obj = parsed.get("error") if isinstance(parsed, dict) else None
97
+ message: Any
98
+ if isinstance(error_obj, dict) and error_obj.get("message"):
99
+ raw = error_obj["message"]
100
+ message = raw if isinstance(raw, str) else json.dumps(raw)
101
+ elif error_obj:
102
+ message = json.dumps(error_obj)
103
+ else:
104
+ message = None if parsed is not None else (body_text or None)
105
+ include_body = False
106
+ elif style == "anthropic":
107
+ error_obj = parsed
108
+ if isinstance(parsed, dict) and isinstance(parsed.get("message"), str):
109
+ message = parsed["message"]
110
+ elif parsed is not None:
111
+ message = json.dumps(parsed, separators=(",", ":"))
112
+ else:
113
+ message = body_text or None
114
+ include_body = False
115
+ else:
116
+ error_obj = parsed
117
+ message = body_text or None
118
+ include_body = True
119
+ text = f"{status} {message}" if message else f"{status} status code (no body)"
120
+ return ProviderHttpError(
121
+ text, status=status, headers=headers, body=body_text, error=error_obj, include_body=include_body
122
+ )
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Client pool
127
+ # ---------------------------------------------------------------------------
128
+
129
+ _clients: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict[str | None, httpx.AsyncClient]] = (
130
+ weakref.WeakKeyDictionary()
131
+ )
132
+
133
+ _LIMITS = httpx.Limits(max_connections=200, max_keepalive_connections=40, keepalive_expiry=60)
134
+
135
+
136
+ def _proxy_for(url: str, env: Mapping[str, str] | None) -> str | None:
137
+ """Proxy URL from provider-scoped env overrides only (``os.environ`` is honored by httpx itself)."""
138
+ if not env:
139
+ return None
140
+ scheme = "https" if url.startswith("https:") else "http"
141
+ names = ("HTTPS_PROXY", "https_proxy") if scheme == "https" else ("HTTP_PROXY", "http_proxy")
142
+ for name in (*names, "ALL_PROXY", "all_proxy"):
143
+ value = env.get(name)
144
+ if value:
145
+ no_proxy = env.get("NO_PROXY") or env.get("no_proxy") or get_provider_env_value("NO_PROXY")
146
+ if no_proxy and _host_matches_no_proxy(url, no_proxy):
147
+ return None
148
+ return value
149
+ return None
150
+
151
+
152
+ def _host_matches_no_proxy(url: str, no_proxy: str) -> bool:
153
+ host = httpx.URL(url).host
154
+ for entry in (part.strip() for part in no_proxy.split(",")):
155
+ if not entry:
156
+ continue
157
+ if entry == "*":
158
+ return True
159
+ entry = entry.lstrip(".")
160
+ if host == entry or host.endswith("." + entry):
161
+ return True
162
+ return False
163
+
164
+
165
+ def get_http_client(url: str = "", options: Mapping[str, Any] | None = None) -> httpx.AsyncClient:
166
+ """The client for a request: ``options["http_client"]`` or a pooled per-loop client."""
167
+ if options:
168
+ custom = options.get("http_client")
169
+ if custom is not None:
170
+ return custom
171
+ proxy = _proxy_for(url, options.get("env") if options else None)
172
+ loop = asyncio.get_running_loop()
173
+ pool = _clients.get(loop)
174
+ if pool is None:
175
+ pool = {}
176
+ _clients[loop] = pool
177
+ client = pool.get(proxy)
178
+ if client is None or client.is_closed:
179
+ client = httpx.AsyncClient(
180
+ proxy=proxy,
181
+ limits=_LIMITS,
182
+ timeout=httpx.Timeout(DEFAULT_TIMEOUT_MS / 1000),
183
+ follow_redirects=True,
184
+ trust_env=proxy is None,
185
+ )
186
+ pool[proxy] = client
187
+ return client
188
+
189
+
190
+ async def aclose_http_clients() -> None:
191
+ """Close the pooled clients of the running event loop."""
192
+ loop = asyncio.get_running_loop()
193
+ pool = _clients.pop(loop, None)
194
+ for client in (pool or {}).values():
195
+ await client.aclose()
196
+
197
+
198
+ def build_headers(*sources: Mapping[str, str | None] | None) -> dict[str, str]:
199
+ """Merge header sources case-insensitively; later sources win, ``None`` removes a header."""
200
+ merged: dict[str, tuple[str, str]] = {}
201
+ for source in sources:
202
+ if not source:
203
+ continue
204
+ for name, value in source.items():
205
+ lower = name.lower()
206
+ if value is None:
207
+ merged.pop(lower, None)
208
+ else:
209
+ merged[lower] = (name, value)
210
+ return {name: value for name, value in merged.values()}
211
+
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # Requests and retries
215
+ # ---------------------------------------------------------------------------
216
+
217
+
218
+ def _timeout(options: Mapping[str, Any] | None) -> httpx.Timeout:
219
+ timeout_ms = options.get("timeout_ms") if options else None
220
+ seconds = (timeout_ms if timeout_ms is not None else DEFAULT_TIMEOUT_MS) / 1000
221
+ # ``read`` is the idle gap between chunks, so an active stream is never cut off.
222
+ return httpx.Timeout(seconds)
223
+
224
+
225
+ async def send_request(
226
+ method: str,
227
+ url: str,
228
+ *,
229
+ headers: Mapping[str, str],
230
+ body: Any = None,
231
+ content: bytes | str | None = None,
232
+ options: Mapping[str, Any] | None = None,
233
+ stream: bool = True,
234
+ error_style: ErrorStyle = "openai",
235
+ params: Mapping[str, Any] | None = None,
236
+ no_retry_statuses: Sequence[int] = (),
237
+ ) -> httpx.Response:
238
+ """Send a provider request with SDK-compatible retries.
239
+
240
+ ``body`` is JSON-encoded (``content`` sends raw bytes). Non-2xx responses raise
241
+ :class:`ProviderHttpError` after reading the error body. Successful streaming
242
+ responses are returned open; the caller iterates and closes them.
243
+ """
244
+ client = get_http_client(url, options)
245
+ request_headers = dict(headers)
246
+ if body is not None:
247
+ content = fastjson.dumps_bytes(body)
248
+ if not any(key.lower() == "content-type" for key in request_headers):
249
+ request_headers["Content-Type"] = "application/json"
250
+ timeout = _timeout(options)
251
+
252
+ async def attempt() -> httpx.Response:
253
+ request = client.build_request(
254
+ method, url, headers=request_headers, content=content, params=params, timeout=timeout
255
+ )
256
+ try:
257
+ response = await client.send(request, stream=stream)
258
+ except httpx.TimeoutException as error:
259
+ raise ProviderHttpError(f"Request timed out: {error}") from error
260
+ except httpx.TransportError as error:
261
+ raise ProviderHttpError(f"Connection error: {error or type(error).__name__}") from error
262
+ if response.status_code >= 400:
263
+ try:
264
+ raw = await response.aread()
265
+ finally:
266
+ await response.aclose()
267
+ text = raw.decode("utf-8", "replace")
268
+ raise make_status_error(response.status_code, text, response.headers, error_style)
269
+ return response
270
+
271
+ signal = options.get("signal") if options else None
272
+ return await retry_provider_request(
273
+ attempt,
274
+ max_retries=(options.get("max_retries") if options else None) or 0,
275
+ max_retry_delay_ms=options.get("max_retry_delay_ms") if options else None,
276
+ signal=signal,
277
+ no_retry_statuses=no_retry_statuses,
278
+ )
279
+
280
+
281
+ def _is_retryable(error: ProviderHttpError) -> bool:
282
+ headers = error.headers
283
+ should_retry = headers.get("x-should-retry") if headers is not None else None
284
+ if should_retry == "true":
285
+ return True
286
+ if should_retry == "false":
287
+ return False
288
+ status = error.status
289
+ if status is None:
290
+ return True
291
+ return status in (408, 409, 429) or status >= 500
292
+
293
+
294
+ def _validate_server_delay(delay_ms: float, max_retry_delay_ms: float | None, message: str) -> float:
295
+ max_delay = max_retry_delay_ms if max_retry_delay_ms is not None else DEFAULT_MAX_RETRY_DELAY_MS
296
+ if max_delay > 0 and delay_ms > max_delay:
297
+ raise ProviderHttpError(
298
+ f"Server requested {int(-(-delay_ms // 1000))}s retry delay (max: {int(-(-max_delay // 1000))}s). {message}"
299
+ )
300
+ return delay_ms
301
+
302
+
303
+ def _retry_delay_ms(error: ProviderHttpError, retry_index: int, max_retry_delay_ms: float | None) -> float:
304
+ headers = error.headers
305
+ if headers is not None:
306
+ retry_after_ms = headers.get("retry-after-ms")
307
+ if retry_after_ms:
308
+ try:
309
+ return _validate_server_delay(float(retry_after_ms), max_retry_delay_ms, str(error))
310
+ except ValueError:
311
+ pass
312
+ retry_after = headers.get("retry-after")
313
+ if retry_after:
314
+ try:
315
+ delay_ms = float(retry_after) * 1000
316
+ except ValueError:
317
+ parsed = email.utils.parsedate_to_datetime(retry_after) if retry_after else None
318
+ delay_ms = (parsed.timestamp() - time.time()) * 1000 if parsed else float("nan")
319
+ if delay_ms == delay_ms: # not NaN
320
+ return _validate_server_delay(delay_ms, max_retry_delay_ms, str(error))
321
+ exponential = min(0.5 * 2**retry_index, 8) * 1000
322
+ return exponential * (1 - random.random() * 0.25)
323
+
324
+
325
+ async def retry_provider_request(
326
+ request: Any,
327
+ *,
328
+ max_retries: int = 0,
329
+ max_retry_delay_ms: float | None = None,
330
+ signal: AbortSignal | None = None,
331
+ no_retry_statuses: Sequence[int] = (),
332
+ ) -> Any:
333
+ """Retry ``request()`` like the OpenAI/Anthropic SDKs, with an interruptible backoff.
334
+
335
+ Provider-requested delays above ``max_retry_delay_ms`` (60s by default, 0 disables)
336
+ fail immediately so outer retry logic can surface them.
337
+ """
338
+ retries_remaining = max_retries
339
+ while True:
340
+ try:
341
+ return await request()
342
+ except ProviderHttpError as error:
343
+ if signal is not None and signal.aborted:
344
+ raise AbortError(signal.reason) from error
345
+ if retries_remaining <= 0 or not _is_retryable(error):
346
+ raise
347
+ if error.status is not None and error.status in no_retry_statuses:
348
+ raise
349
+ retry_index = max_retries - retries_remaining
350
+ retries_remaining -= 1
351
+ await sleep(_retry_delay_ms(error, retry_index, max_retry_delay_ms) / 1000, signal)
352
+
353
+
354
+ # ---------------------------------------------------------------------------
355
+ # Server-sent events
356
+ # ---------------------------------------------------------------------------
357
+
358
+
359
+ @dataclass(slots=True)
360
+ class SSEEvent:
361
+ data: str
362
+ event: str | None = None
363
+ id: str | None = None
364
+ retry: int | None = None
365
+
366
+ def json(self) -> Any:
367
+ return fastjson.loads(self.data)
368
+
369
+
370
+ async def iter_sse(response: httpx.Response) -> AsyncIterator[SSEEvent]:
371
+ """Decode a ``text/event-stream`` response into :class:`SSEEvent` objects."""
372
+ data_lines: list[str] = []
373
+ event_name: str | None = None
374
+ event_id: str | None = None
375
+ retry: int | None = None
376
+ async for line in response.aiter_lines():
377
+ if not line:
378
+ if data_lines or event_name is not None:
379
+ yield SSEEvent("\n".join(data_lines), event_name, event_id, retry)
380
+ data_lines = []
381
+ event_name = None
382
+ retry = None
383
+ continue
384
+ if line[0] == ":":
385
+ continue
386
+ name, sep, value = line.partition(":")
387
+ if sep and value.startswith(" "):
388
+ value = value[1:]
389
+ if name == "data":
390
+ data_lines.append(value)
391
+ elif name == "event":
392
+ event_name = value
393
+ elif name == "id":
394
+ event_id = value
395
+ elif name == "retry":
396
+ try:
397
+ retry = int(value)
398
+ except ValueError:
399
+ pass
400
+ if data_lines or event_name is not None:
401
+ yield SSEEvent("\n".join(data_lines), event_name, event_id, retry)
402
+
403
+
404
+ async def iter_json_lines(response: httpx.Response) -> AsyncIterator[Any]:
405
+ """Decode a newline-delimited JSON response."""
406
+ async for line in response.aiter_lines():
407
+ line = line.strip()
408
+ if line:
409
+ yield fastjson.loads(line)