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,35 @@
1
+ """The error type raised by the model runtime (auth, provider lookup, catalogs)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal, TypeAlias
6
+
7
+ from .diagnostics import format_thrown_value
8
+
9
+ __all__ = ["ModelsError", "ModelsErrorCode"]
10
+
11
+ ModelsErrorCode: TypeAlias = Literal["model_source", "model_validation", "provider", "stream", "auth", "oauth"]
12
+
13
+
14
+ class ModelsError(Exception):
15
+ """Runtime error with a machine-readable ``code``.
16
+
17
+ The message includes the cause's detail, because callers usually surface only
18
+ ``str(error)``.
19
+ """
20
+
21
+ def __init__(self, code: ModelsErrorCode, message: str, *, cause: Any = None) -> None:
22
+ super().__init__(_with_cause_detail(message, cause))
23
+ self.code: ModelsErrorCode = code
24
+ if isinstance(cause, BaseException):
25
+ self.__cause__ = cause
26
+ self.cause = cause
27
+
28
+
29
+ def _with_cause_detail(message: str, cause: Any) -> str:
30
+ if cause is None:
31
+ return message
32
+ detail = format_thrown_value(cause).strip()
33
+ if not detail or detail in message:
34
+ return message
35
+ return f"{message}: {detail}"
@@ -0,0 +1,121 @@
1
+ """HTML pages shown by the local OAuth callback servers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from string import Template
6
+
7
+ __all__ = ["oauth_success_html", "oauth_error_html"]
8
+
9
+ _LOGO_SVG = (
10
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" aria-hidden="true">'
11
+ '<path fill="#F09082" d="M165.29 165.29H517.36V400H400V282.65H165.29Z"/>'
12
+ '<path fill="#4D9ABF" d="M165.29 282.65H282.65V400H400V517.36H282.65V634.72H165.29Z"/>'
13
+ '<path fill="#F1BE58" d="M517.36 400H634.72V634.72H517.36Z"/></svg>'
14
+ )
15
+
16
+ _PAGE = Template(
17
+ """<!doctype html>
18
+ <html lang="en">
19
+ <head>
20
+ <meta charset="utf-8" />
21
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
22
+ <title>$title</title>
23
+ <style>
24
+ :root {
25
+ --text: #fafafa;
26
+ --text-dim: #a1a1aa;
27
+ --page-bg: #09090b;
28
+ --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
29
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
30
+ }
31
+ * { box-sizing: border-box; }
32
+ html { color-scheme: dark; }
33
+ body {
34
+ margin: 0;
35
+ min-height: 100vh;
36
+ display: flex;
37
+ align-items: center;
38
+ justify-content: center;
39
+ padding: 24px;
40
+ background: var(--page-bg);
41
+ color: var(--text);
42
+ font-family: var(--font-sans);
43
+ text-align: center;
44
+ }
45
+ main {
46
+ width: 100%;
47
+ max-width: 560px;
48
+ display: flex;
49
+ flex-direction: column;
50
+ align-items: center;
51
+ justify-content: center;
52
+ }
53
+ .logo {
54
+ width: 72px;
55
+ height: 72px;
56
+ display: block;
57
+ margin-bottom: 24px;
58
+ }
59
+ h1 {
60
+ margin: 0 0 10px;
61
+ font-size: 28px;
62
+ line-height: 1.15;
63
+ font-weight: 650;
64
+ color: var(--text);
65
+ }
66
+ p {
67
+ margin: 0;
68
+ line-height: 1.7;
69
+ color: var(--text-dim);
70
+ font-size: 15px;
71
+ }
72
+ .details {
73
+ margin-top: 16px;
74
+ font-family: var(--font-mono);
75
+ font-size: 13px;
76
+ color: var(--text-dim);
77
+ white-space: pre-wrap;
78
+ word-break: break-word;
79
+ }
80
+ </style>
81
+ </head>
82
+ <body>
83
+ <main>
84
+ <div class="logo">$logo</div>
85
+ <h1>$heading</h1>
86
+ <p>$message</p>
87
+ $details
88
+ </main>
89
+ </body>
90
+ </html>"""
91
+ )
92
+
93
+
94
+ def _escape_html(value: str) -> str:
95
+ return (
96
+ value.replace("&", "&amp;")
97
+ .replace("<", "&lt;")
98
+ .replace(">", "&gt;")
99
+ .replace('"', "&quot;")
100
+ .replace("'", "&#39;")
101
+ )
102
+
103
+
104
+ def _render_page(*, title: str, heading: str, message: str, details: str | None = None) -> str:
105
+ return _PAGE.substitute(
106
+ title=_escape_html(title),
107
+ heading=_escape_html(heading),
108
+ message=_escape_html(message),
109
+ logo=_LOGO_SVG,
110
+ details=f'<div class="details">{_escape_html(details)}</div>' if details else "",
111
+ )
112
+
113
+
114
+ def oauth_success_html(message: str) -> str:
115
+ return _render_page(title="Authentication successful", heading="Authentication successful", message=message)
116
+
117
+
118
+ def oauth_error_html(message: str, details: str | None = None) -> str:
119
+ return _render_page(
120
+ title="Authentication failed", heading="Authentication failed", message=message, details=details
121
+ )
@@ -0,0 +1,87 @@
1
+ """Context overflow detection across providers.
2
+
3
+ See the TypeScript source for the catalogue of provider error messages each
4
+ pattern matches; the patterns are kept identical.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from ..types import AssistantMessage
12
+
13
+ __all__ = ["is_context_overflow", "is_recoverable_length", "get_overflow_patterns"]
14
+
15
+ _OVERFLOW_PATTERNS: list[re.Pattern[str]] = [
16
+ re.compile(pattern, re.IGNORECASE)
17
+ for pattern in (
18
+ r"prompt (?:is )?too long", # Anthropic and z.ai token overflow
19
+ r"prompt exceeds max length", # z.ai CN endpoint token overflow
20
+ r"request_too_large", # Anthropic request byte-size overflow (HTTP 413)
21
+ r"input is too long for requested model", # Amazon Bedrock
22
+ r"exceeds the context window", # OpenAI (Completions & Responses API)
23
+ r"exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))", # LiteLLM
24
+ r"input token count.*exceeds the maximum", # Google (Gemini)
25
+ r"maximum prompt length is \d+", # xAI (Grok)
26
+ r"reduce the length of the messages", # Groq
27
+ r"maximum context length is \d+ tokens", # OpenRouter (most backends)
28
+ r"exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?", # OpenRouter/Poolside
29
+ r"input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)", # Together AI
30
+ r"exceeds the limit of \d+", # GitHub Copilot
31
+ r"exceeds the available context size", # llama.cpp server
32
+ r"greater than the context length", # LM Studio
33
+ r"context window exceeds limit", # MiniMax
34
+ r"exceeded model token limit", # Kimi For Coding
35
+ r"too large for model with \d+ maximum context length", # Mistral
36
+ r"prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?", # DS4 server
37
+ r"model_context_window_exceeded", # z.ai non-standard finish_reason
38
+ r"prompt too long; exceeded (?:max )?context length", # Ollama
39
+ r"range of input length should be", # DashScope / Qwen Token Plan
40
+ r"context[_ ]length[_ ]exceeded", # Generic fallback
41
+ r"too many tokens", # Generic fallback
42
+ r"token limit exceeded", # Generic fallback
43
+ )
44
+ ]
45
+
46
+ _CEREBRAS_BODYLESS_OVERFLOW = re.compile(r"^4(?:00|13)\s*(?:status code)?\s*\(no body\)", re.IGNORECASE)
47
+
48
+ _NON_OVERFLOW_PATTERNS: list[re.Pattern[str]] = [
49
+ re.compile(r"^(Throttling error|Service unavailable):", re.IGNORECASE), # AWS Bedrock non-overflow errors
50
+ re.compile(r"rate limit", re.IGNORECASE),
51
+ re.compile(r"too many requests", re.IGNORECASE),
52
+ ]
53
+
54
+
55
+ def is_context_overflow(message: AssistantMessage, context_window: int | None = None) -> bool:
56
+ """Whether an assistant message represents a context overflow.
57
+
58
+ Handles error-message overflow (most providers), silent overflow where
59
+ ``usage.input`` exceeds the context window (z.ai), and length-stop overflow with
60
+ zero output (Xiaomi MiMo). Pass ``context_window`` for the latter two.
61
+ """
62
+ error_message = message.error_message
63
+ if message.stop_reason == "error" and error_message:
64
+ if not any(pattern.search(error_message) for pattern in _NON_OVERFLOW_PATTERNS):
65
+ if any(pattern.search(error_message) for pattern in _OVERFLOW_PATTERNS):
66
+ return True
67
+ if message.provider == "cerebras" and _CEREBRAS_BODYLESS_OVERFLOW.search(error_message):
68
+ return True
69
+
70
+ if context_window and message.stop_reason == "stop":
71
+ if message.usage.input + message.usage.cache_read > context_window:
72
+ return True
73
+
74
+ if context_window and message.stop_reason == "length" and message.usage.output == 0:
75
+ if message.usage.input + message.usage.cache_read >= context_window * 0.99:
76
+ return True
77
+
78
+ return False
79
+
80
+
81
+ def is_recoverable_length(message: AssistantMessage, desired_max_output: int) -> bool:
82
+ """Whether a length stop ended below the intended output limit (context pressure)."""
83
+ return message.stop_reason == "length" and desired_max_output > 0 and message.usage.output < desired_max_output
84
+
85
+
86
+ def get_overflow_patterns() -> list[re.Pattern[str]]:
87
+ return list(_OVERFLOW_PATTERNS)
pi_ai/utils/retry.py ADDED
@@ -0,0 +1,193 @@
1
+ """Retry classification and a policy-driven retry loop for assistant calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import re
7
+ from collections.abc import Awaitable, Callable
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ from ..abort import AbortError, AbortSignal, sleep
12
+ from ..types import AssistantMessage
13
+ from .callbacks import maybe_await
14
+
15
+ __all__ = [
16
+ "RetryPolicy",
17
+ "RetryCallbacks",
18
+ "DEFAULT_MAX_AGENT_RETRY_DELAY_MS",
19
+ "retry_delay_ms",
20
+ "retry_assistant_call",
21
+ "is_retryable_assistant_error",
22
+ ]
23
+
24
+
25
+ def _pattern(parts: list[str]) -> re.Pattern[str]:
26
+ return re.compile("|".join(parts), re.IGNORECASE)
27
+
28
+
29
+ _NON_RETRYABLE = _pattern(
30
+ [
31
+ "GoUsageLimitError",
32
+ "FreeUsageLimitError",
33
+ "Monthly usage limit reached",
34
+ "available balance",
35
+ "insufficient_quota",
36
+ "out of budget",
37
+ "quota exceeded",
38
+ "billing",
39
+ "subscription_sharing_usage_limit_exceeded",
40
+ ]
41
+ )
42
+
43
+ _RETRYABLE = _pattern(
44
+ [
45
+ "overloaded",
46
+ "server_busy",
47
+ "servers are currently busy",
48
+ "currently experiencing high demand",
49
+ "model is at capacity",
50
+ "rate.?limit",
51
+ "too many requests",
52
+ "429",
53
+ "500",
54
+ "502",
55
+ "503",
56
+ "504",
57
+ "520",
58
+ "524",
59
+ r"service.?(?:is.?)?(?:temporarily.?)?unavailable",
60
+ "server.?error",
61
+ "internal.?error",
62
+ r"provider.?returned.?(?:an.?)?error",
63
+ "exceeded request buffer limit while retrying upstream",
64
+ "unable to forward upstream",
65
+ "upstream.?request.?failed",
66
+ "upstream_stream_read_error",
67
+ "stream was interrupted",
68
+ "http/?2 stream failed",
69
+ "network.?error",
70
+ "connection.?error",
71
+ "connection.?refused",
72
+ "connection.?lost",
73
+ "other side closed",
74
+ "fetch failed",
75
+ r"request to \S+ failed, reason:",
76
+ "getaddrinfo",
77
+ "ENOTFOUND",
78
+ "EAI_AGAIN",
79
+ "upstream.?connect",
80
+ "reset before headers",
81
+ "socket hang up",
82
+ "socket connection was closed",
83
+ "timed? out",
84
+ "timeout",
85
+ "terminated",
86
+ "websocket.?closed",
87
+ "websocket.?error",
88
+ "ended without",
89
+ "stream ended before message_stop",
90
+ "stream ended before a terminal response event",
91
+ "http2 request did not get a response",
92
+ "pending stream has been canceled",
93
+ "retry delay",
94
+ "you can retry your request",
95
+ "try your request again",
96
+ "please retry your request",
97
+ "ResourceExhausted",
98
+ "subscription_sharing_usage_unavailable",
99
+ "subscription_sharing_user_unavailable",
100
+ # Python transport errors (httpx/httpcore wording)
101
+ "remote ?protocol ?error",
102
+ "server disconnected",
103
+ "connection reset",
104
+ "read ?error",
105
+ "connect ?error",
106
+ ]
107
+ )
108
+
109
+ DEFAULT_MAX_AGENT_RETRY_DELAY_MS = 60_000
110
+
111
+
112
+ @dataclass(slots=True)
113
+ class RetryPolicy:
114
+ """Bounded attempts with exponential backoff (``base_delay_ms * 2^(attempt-1)``)."""
115
+
116
+ enabled: bool = True
117
+ #: Max retry attempts (0 = no retries). The initial call never counts as a retry.
118
+ max_retries: int = 3
119
+ base_delay_ms: float = 2000
120
+ #: Cap for each computed delay. Defaults to 60 seconds.
121
+ max_agent_delay_ms: float | None = None
122
+
123
+
124
+ def retry_delay_ms(policy: RetryPolicy, attempt: int) -> float:
125
+ delay = policy.base_delay_ms * 2 ** max(0, attempt - 1)
126
+ cap = policy.max_agent_delay_ms if policy.max_agent_delay_ms is not None else DEFAULT_MAX_AGENT_RETRY_DELAY_MS
127
+ return min(delay, cap)
128
+
129
+
130
+ @dataclass(slots=True)
131
+ class RetryCallbacks:
132
+ #: Before the backoff sleep of each retry attempt (1-indexed): (attempt, max_attempts, delay_ms, error_message).
133
+ on_retry_scheduled: Callable[[int, int, float, str], Any] | None = None
134
+ #: After the backoff sleep, immediately before the retried call starts.
135
+ on_retry_attempt_start: Callable[[], Any] | None = None
136
+ #: Once when the loop ends: (success, attempt, final_error).
137
+ on_retry_finished: Callable[[bool, int, str | None], Any] | None = None
138
+
139
+
140
+ async def retry_assistant_call(
141
+ produce: Callable[[], Awaitable[AssistantMessage]],
142
+ policy: RetryPolicy | None,
143
+ signal: AbortSignal | None = None,
144
+ callbacks: RetryCallbacks | None = None,
145
+ ) -> AssistantMessage:
146
+ """Run an assistant-producing call with bounded retry on transient errors.
147
+
148
+ Aborts are terminal and never retried; aborts during the backoff sleep are
149
+ normalized to an aborted message. Non-retryable errors (quota, billing) return
150
+ immediately.
151
+ """
152
+ max_attempts = policy.max_retries if policy is not None and policy.enabled else 0
153
+ callbacks = callbacks or RetryCallbacks()
154
+ attempt = 0
155
+ last_retry: tuple[int, str] | None = None
156
+ while True:
157
+ response = await produce()
158
+ if response.stop_reason == "aborted":
159
+ if last_retry and callbacks.on_retry_finished:
160
+ await maybe_await(callbacks.on_retry_finished(False, last_retry[0], None))
161
+ return response
162
+ if response.stop_reason != "error":
163
+ if last_retry and callbacks.on_retry_finished:
164
+ await maybe_await(callbacks.on_retry_finished(True, last_retry[0], None))
165
+ return response
166
+ if attempt >= max_attempts or not is_retryable_assistant_error(response):
167
+ if last_retry and callbacks.on_retry_finished:
168
+ await maybe_await(callbacks.on_retry_finished(False, last_retry[0], response.error_message))
169
+ return response
170
+
171
+ attempt += 1
172
+ last_retry = (attempt, response.error_message or "Unknown error")
173
+ assert policy is not None
174
+ delay = retry_delay_ms(policy, attempt)
175
+ if callbacks.on_retry_scheduled:
176
+ await maybe_await(callbacks.on_retry_scheduled(attempt, max_attempts, delay, last_retry[1]))
177
+ try:
178
+ await sleep(delay / 1000, signal)
179
+ except AbortError:
180
+ if callbacks.on_retry_finished:
181
+ await maybe_await(callbacks.on_retry_finished(False, attempt, last_retry[1]))
182
+ return dataclasses.replace(response, stop_reason="aborted", error_message=None)
183
+ if callbacks.on_retry_attempt_start:
184
+ await maybe_await(callbacks.on_retry_attempt_start())
185
+
186
+
187
+ def is_retryable_assistant_error(message: AssistantMessage) -> bool:
188
+ """Whether a failed assistant message looks like a transient provider/transport error."""
189
+ if message.stop_reason != "error" or not message.error_message:
190
+ return False
191
+ if _NON_RETRYABLE.search(message.error_message):
192
+ return False
193
+ return _RETRYABLE.search(message.error_message) is not None