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
|
@@ -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("&", "&")
|
|
97
|
+
.replace("<", "<")
|
|
98
|
+
.replace(">", ">")
|
|
99
|
+
.replace('"', """)
|
|
100
|
+
.replace("'", "'")
|
|
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
|
+
)
|
pi_ai/utils/overflow.py
ADDED
|
@@ -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
|