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/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)
|