forkreduce 0.1.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.
- forkreduce/__init__.py +76 -0
- forkreduce/checks.py +274 -0
- forkreduce/context.py +157 -0
- forkreduce/contrib/__init__.py +5 -0
- forkreduce/contrib/temporal.py +349 -0
- forkreduce/coverage.py +187 -0
- forkreduce/documents.py +173 -0
- forkreduce/errors.py +39 -0
- forkreduce/executor.py +200 -0
- forkreduce/failures.py +239 -0
- forkreduce/followup.py +215 -0
- forkreduce/models.py +48 -0
- forkreduce/plan.py +355 -0
- forkreduce/policy.py +147 -0
- forkreduce/prefix.py +90 -0
- forkreduce/py.typed +0 -0
- forkreduce/query.py +77 -0
- forkreduce/reduce.py +625 -0
- forkreduce/schemas.py +30 -0
- forkreduce/session.py +447 -0
- forkreduce/stats.py +92 -0
- forkreduce/textutil.py +21 -0
- forkreduce-0.1.0.dist-info/METADATA +631 -0
- forkreduce-0.1.0.dist-info/RECORD +26 -0
- forkreduce-0.1.0.dist-info/WHEEL +4 -0
- forkreduce-0.1.0.dist-info/licenses/LICENSE +21 -0
forkreduce/documents.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Documents and blocks frozen into the shared prefix."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from forkreduce.errors import ForkReduceError
|
|
11
|
+
from forkreduce.textutil import strip_control_chars
|
|
12
|
+
|
|
13
|
+
END_MARKER = "===END_FIXED_DOCUMENTS==="
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Block(BaseModel):
|
|
17
|
+
"""One addressable span of a document.
|
|
18
|
+
|
|
19
|
+
Citation checks look up ``block_id`` and search for a quote inside ``text``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
block_id: str
|
|
23
|
+
text: str
|
|
24
|
+
meta: dict[str, str] = Field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Document(BaseModel):
|
|
28
|
+
"""A document made of blocks. ``doc_id`` is stable and appears in the rendered prefix."""
|
|
29
|
+
|
|
30
|
+
doc_id: str
|
|
31
|
+
title: str = ""
|
|
32
|
+
blocks: list[Block]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class DocumentIndex:
|
|
37
|
+
"""Cleaned documents plus the exact text that is frozen into the prefix."""
|
|
38
|
+
|
|
39
|
+
documents: list[Document]
|
|
40
|
+
blocks: dict[str, Block] = field(default_factory=dict)
|
|
41
|
+
rendered: str = ""
|
|
42
|
+
|
|
43
|
+
def block_text(self, block_id: str) -> str | None:
|
|
44
|
+
block = self.blocks.get(block_id)
|
|
45
|
+
if block is None:
|
|
46
|
+
return None
|
|
47
|
+
return block.text
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
DocumentsInput = str | Document | Block | Sequence[str | Document | Block]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def coerce_documents(documents: DocumentsInput) -> DocumentIndex:
|
|
54
|
+
"""Normalize caller input into cleaned blocks and a stable rendering.
|
|
55
|
+
|
|
56
|
+
A bare string becomes one document with a single block ``b1``. A sequence of
|
|
57
|
+
strings becomes blocks ``b1``, ``b2``, ... inside one document. Control
|
|
58
|
+
characters are stripped before the text is hashed or shown to the model, so a
|
|
59
|
+
null byte cannot break JSON tool arguments later.
|
|
60
|
+
"""
|
|
61
|
+
docs = _coerce_many(documents)
|
|
62
|
+
if not docs:
|
|
63
|
+
raise ForkReduceError("documents must contain at least one block of text")
|
|
64
|
+
blocks: dict[str, Block] = {}
|
|
65
|
+
for doc in docs:
|
|
66
|
+
if not doc.blocks:
|
|
67
|
+
raise ForkReduceError(f"document {doc.doc_id!r} has no blocks")
|
|
68
|
+
for block in doc.blocks:
|
|
69
|
+
if not block.block_id:
|
|
70
|
+
raise ForkReduceError("block_id must be a non-empty string")
|
|
71
|
+
if block.block_id in blocks:
|
|
72
|
+
raise ForkReduceError(f"duplicate block_id {block.block_id!r}")
|
|
73
|
+
if not block.text.strip():
|
|
74
|
+
raise ForkReduceError(f"block {block.block_id!r} is empty after cleaning")
|
|
75
|
+
blocks[block.block_id] = block
|
|
76
|
+
rendered = render_documents(docs)
|
|
77
|
+
return DocumentIndex(documents=docs, blocks=blocks, rendered=rendered)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def render_documents(documents: Sequence[Document]) -> str:
|
|
81
|
+
"""Render documents into the frozen prefix body, ending with :data:`END_MARKER`."""
|
|
82
|
+
lines: list[str] = []
|
|
83
|
+
for doc in documents:
|
|
84
|
+
title = f" title={doc.title}" if doc.title else ""
|
|
85
|
+
lines.append(f"[document id={doc.doc_id}{title}]")
|
|
86
|
+
for block in doc.blocks:
|
|
87
|
+
lines.append(f"[block {block.block_id}]")
|
|
88
|
+
lines.append(block.text)
|
|
89
|
+
lines.append("")
|
|
90
|
+
lines.append(END_MARKER)
|
|
91
|
+
return "\n".join(lines)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _coerce_many(documents: DocumentsInput) -> list[Document]:
|
|
95
|
+
if isinstance(documents, str):
|
|
96
|
+
return [_string_document("doc", [documents])]
|
|
97
|
+
if isinstance(documents, Document):
|
|
98
|
+
return [_clean_document(documents)]
|
|
99
|
+
if isinstance(documents, Block):
|
|
100
|
+
return [_blocks_document("doc", [documents])]
|
|
101
|
+
if isinstance(documents, Sequence):
|
|
102
|
+
# A homogeneous sequence keeps the historical single-document shape.
|
|
103
|
+
# A mixed sequence keeps the caller's order: consecutive loose blocks
|
|
104
|
+
# or strings become one generated document at that position.
|
|
105
|
+
if documents and all(isinstance(item, str) for item in documents):
|
|
106
|
+
return [_string_document("doc", [item for item in documents if isinstance(item, str)])]
|
|
107
|
+
if documents and all(isinstance(item, Block) for item in documents):
|
|
108
|
+
return [_blocks_document("doc", [item for item in documents if isinstance(item, Block)])]
|
|
109
|
+
docs: list[Document] = []
|
|
110
|
+
pending_blocks: list[Block] = []
|
|
111
|
+
pending_strings: list[str] = []
|
|
112
|
+
|
|
113
|
+
def flush() -> None:
|
|
114
|
+
nonlocal pending_blocks, pending_strings
|
|
115
|
+
if pending_blocks:
|
|
116
|
+
docs.append(_blocks_document(_generated_doc_id(docs), pending_blocks))
|
|
117
|
+
pending_blocks = []
|
|
118
|
+
if pending_strings:
|
|
119
|
+
docs.append(_string_document(_generated_doc_id(docs), pending_strings))
|
|
120
|
+
pending_strings = []
|
|
121
|
+
|
|
122
|
+
for item in documents:
|
|
123
|
+
if isinstance(item, str):
|
|
124
|
+
if pending_blocks:
|
|
125
|
+
flush()
|
|
126
|
+
pending_strings.append(item)
|
|
127
|
+
elif isinstance(item, Block):
|
|
128
|
+
if pending_strings:
|
|
129
|
+
flush()
|
|
130
|
+
pending_blocks.append(item)
|
|
131
|
+
elif isinstance(item, Document):
|
|
132
|
+
flush()
|
|
133
|
+
docs.append(_clean_document(item))
|
|
134
|
+
else:
|
|
135
|
+
raise ForkReduceError("documents must be a string, Block, Document, or a sequence of those")
|
|
136
|
+
flush()
|
|
137
|
+
return docs
|
|
138
|
+
raise ForkReduceError("documents must be a string, Block, Document, or a sequence of those")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _generated_doc_id(existing: list[Document]) -> str:
|
|
142
|
+
used = {doc.doc_id for doc in existing}
|
|
143
|
+
if "doc" not in used:
|
|
144
|
+
return "doc"
|
|
145
|
+
number = 2
|
|
146
|
+
while f"doc-{number}" in used:
|
|
147
|
+
number += 1
|
|
148
|
+
return f"doc-{number}"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _string_document(doc_id: str, parts: list[str]) -> Document:
|
|
152
|
+
blocks: list[Block] = []
|
|
153
|
+
for index, part in enumerate(parts, start=1):
|
|
154
|
+
blocks.append(Block(block_id=f"b{index}", text=strip_control_chars(part)))
|
|
155
|
+
return Document(doc_id=doc_id, blocks=blocks)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _blocks_document(doc_id: str, blocks: Sequence[Block]) -> Document:
|
|
159
|
+
cleaned = [
|
|
160
|
+
Block(block_id=block.block_id, text=strip_control_chars(block.text), meta=dict(block.meta)) for block in blocks
|
|
161
|
+
]
|
|
162
|
+
return Document(doc_id=doc_id, blocks=cleaned)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _clean_document(document: Document) -> Document:
|
|
166
|
+
return Document(
|
|
167
|
+
doc_id=document.doc_id,
|
|
168
|
+
title=strip_control_chars(document.title),
|
|
169
|
+
blocks=[
|
|
170
|
+
Block(block_id=block.block_id, text=strip_control_chars(block.text), meta=dict(block.meta))
|
|
171
|
+
for block in document.blocks
|
|
172
|
+
],
|
|
173
|
+
)
|
forkreduce/errors.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Errors raised for caller mistakes.
|
|
2
|
+
|
|
3
|
+
Model, transport, and budget failures are returned as :class:`forkreduce.failures.Failure`
|
|
4
|
+
on a result object. The exceptions in this module mean the caller asked for something the
|
|
5
|
+
session cannot do, and they are raised before a model call when that is possible.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ForkReduceError(Exception):
|
|
12
|
+
"""Base class for forkreduce caller errors.
|
|
13
|
+
|
|
14
|
+
``code`` is an optional machine-readable label. A provider context-length
|
|
15
|
+
error is a :class:`forkreduce.failures.Failure` with code ``context_overflow``,
|
|
16
|
+
not an exception from this class.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
code: str
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, *, code: str = "") -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.code = code
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UnregisteredOutputError(ForkReduceError):
|
|
27
|
+
"""A query or reduce asked for an output type that was not registered at build time.
|
|
28
|
+
|
|
29
|
+
Registering it later would change the tool set and invalidate the shared prefix cache,
|
|
30
|
+
so the call fails immediately instead of silently sending a different schema.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PrefixMismatchWarning(UserWarning):
|
|
35
|
+
"""The supplied message history does not start with this session's frozen prefix.
|
|
36
|
+
|
|
37
|
+
The call still runs. Provider prefix caches will miss, because the shared head of the
|
|
38
|
+
prompt is no longer identical to the other branches.
|
|
39
|
+
"""
|
forkreduce/executor.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Single-attempt execution and the default local executor.
|
|
2
|
+
|
|
3
|
+
:class:`LocalExecutor` is what :meth:`Session.ask` uses: a semaphore, bounded
|
|
4
|
+
retries, and backoff for transport errors. One attempt lives on
|
|
5
|
+
:meth:`Session.run_once` so a Temporal activity can do the same call and let
|
|
6
|
+
Temporal own the retry.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from collections.abc import AsyncIterator
|
|
13
|
+
from contextlib import asynccontextmanager
|
|
14
|
+
from dataclasses import dataclass, replace
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
16
|
+
|
|
17
|
+
from pydantic_ai.usage import RunUsage
|
|
18
|
+
|
|
19
|
+
from forkreduce.failures import Failure, budget_failure
|
|
20
|
+
from forkreduce.policy import Accept, BudgetReport, Fail, Policy
|
|
21
|
+
from forkreduce.query import Query, QueryResult
|
|
22
|
+
from forkreduce.stats import add_usage
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from forkreduce.session import Session
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Executor(Protocol):
|
|
29
|
+
"""Runs one query to a :class:`QueryResult`, including whatever retries it owns."""
|
|
30
|
+
|
|
31
|
+
async def run_query(self, session: Session, query: Query[Any]) -> QueryResult[Any]:
|
|
32
|
+
"""Execute ``query`` against ``session``."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class BudgetLedger:
|
|
38
|
+
policy: Policy
|
|
39
|
+
requests: int = 0
|
|
40
|
+
input_tokens: int = 0
|
|
41
|
+
output_tokens: int = 0
|
|
42
|
+
_lock: asyncio.Lock | None = None
|
|
43
|
+
_token_gate: asyncio.Lock | None = None
|
|
44
|
+
|
|
45
|
+
def _lock_for(self) -> asyncio.Lock:
|
|
46
|
+
if self._lock is None:
|
|
47
|
+
self._lock = asyncio.Lock()
|
|
48
|
+
return self._lock
|
|
49
|
+
|
|
50
|
+
async def try_reserve(self) -> BudgetReport | None:
|
|
51
|
+
budget = self.policy.budget
|
|
52
|
+
if budget is None:
|
|
53
|
+
return None
|
|
54
|
+
async with self._lock_for():
|
|
55
|
+
problem = self._problem()
|
|
56
|
+
if problem is not None:
|
|
57
|
+
return problem
|
|
58
|
+
self.requests += 1
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
async def force_reserve(self) -> None:
|
|
62
|
+
async with self._lock_for():
|
|
63
|
+
self.requests += 1
|
|
64
|
+
|
|
65
|
+
async def add_usage(self, usage: RunUsage) -> None:
|
|
66
|
+
async with self._lock_for():
|
|
67
|
+
self.input_tokens += usage.input_tokens
|
|
68
|
+
self.output_tokens += usage.output_tokens
|
|
69
|
+
|
|
70
|
+
def limits_tokens(self) -> bool:
|
|
71
|
+
"""Whether a token ceiling requires calls to run one at a time."""
|
|
72
|
+
budget = self.policy.budget
|
|
73
|
+
if budget is None:
|
|
74
|
+
return False
|
|
75
|
+
return any(
|
|
76
|
+
value is not None for value in (budget.max_input_tokens, budget.max_output_tokens, budget.max_total_tokens)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
@asynccontextmanager
|
|
80
|
+
async def token_turn(self) -> AsyncIterator[None]:
|
|
81
|
+
"""Hold the token gate for one call when a token ceiling is set.
|
|
82
|
+
|
|
83
|
+
Request-only budgets do not take this gate, so they stay concurrent.
|
|
84
|
+
"""
|
|
85
|
+
if not self.limits_tokens():
|
|
86
|
+
yield
|
|
87
|
+
return
|
|
88
|
+
async with self._lock_for():
|
|
89
|
+
if self._token_gate is None:
|
|
90
|
+
self._token_gate = asyncio.Lock()
|
|
91
|
+
gate = self._token_gate
|
|
92
|
+
await gate.acquire()
|
|
93
|
+
try:
|
|
94
|
+
yield
|
|
95
|
+
finally:
|
|
96
|
+
gate.release()
|
|
97
|
+
|
|
98
|
+
def _problem(self) -> BudgetReport | None:
|
|
99
|
+
budget = self.policy.budget
|
|
100
|
+
if budget is None:
|
|
101
|
+
return None
|
|
102
|
+
message: str | None = None
|
|
103
|
+
if budget.max_requests is not None and self.requests >= budget.max_requests:
|
|
104
|
+
message = f"request budget exhausted ({self.requests}/{budget.max_requests})"
|
|
105
|
+
elif budget.max_input_tokens is not None and self.input_tokens >= budget.max_input_tokens:
|
|
106
|
+
message = f"input token budget exhausted ({self.input_tokens}/{budget.max_input_tokens})"
|
|
107
|
+
elif budget.max_output_tokens is not None and self.output_tokens >= budget.max_output_tokens:
|
|
108
|
+
message = f"output token budget exhausted ({self.output_tokens}/{budget.max_output_tokens})"
|
|
109
|
+
elif budget.max_total_tokens is not None and (
|
|
110
|
+
self.input_tokens + self.output_tokens >= budget.max_total_tokens
|
|
111
|
+
):
|
|
112
|
+
total = self.input_tokens + self.output_tokens
|
|
113
|
+
message = f"total token budget exhausted ({total}/{budget.max_total_tokens})"
|
|
114
|
+
if message is None:
|
|
115
|
+
return None
|
|
116
|
+
return BudgetReport(
|
|
117
|
+
kind="budget",
|
|
118
|
+
message=message,
|
|
119
|
+
requests=self.requests,
|
|
120
|
+
input_tokens=self.input_tokens,
|
|
121
|
+
output_tokens=self.output_tokens,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class LocalExecutor:
|
|
126
|
+
"""asyncio executor with a concurrency semaphore and bounded retries.
|
|
127
|
+
|
|
128
|
+
Validation failures retry immediately. Transport and rate-limit failures
|
|
129
|
+
sleep with exponential backoff. Permanent failures and budget exhaustion
|
|
130
|
+
are returned on the result and are not retried.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def __init__(self, policy: Policy) -> None:
|
|
134
|
+
self.policy = policy
|
|
135
|
+
self._semaphore = asyncio.Semaphore(policy.concurrency)
|
|
136
|
+
|
|
137
|
+
async def run_query(self, session: Session, query: Query[Any]) -> QueryResult[Any]:
|
|
138
|
+
async with self._semaphore:
|
|
139
|
+
return await self._run_with_retries(session, query)
|
|
140
|
+
|
|
141
|
+
async def _run_with_retries(self, session: Session, query: Query[Any]) -> QueryResult[Any]:
|
|
142
|
+
usage = RunUsage()
|
|
143
|
+
cache = 0
|
|
144
|
+
elapsed = 0.0
|
|
145
|
+
last: QueryResult[object] | None = None
|
|
146
|
+
attempts = 0
|
|
147
|
+
for attempt in range(self.policy.retries + 1):
|
|
148
|
+
attempts = attempt + 1
|
|
149
|
+
result = await session.run_once(query)
|
|
150
|
+
usage = add_usage(usage, result.usage)
|
|
151
|
+
cache += result.cache_hit_tokens
|
|
152
|
+
elapsed += result.elapsed_ms
|
|
153
|
+
last = result
|
|
154
|
+
# A budget rejection happens before any model call.
|
|
155
|
+
if result.attempts == 0:
|
|
156
|
+
return self._folded(result, usage, cache, elapsed, 0)
|
|
157
|
+
if result.failure is None:
|
|
158
|
+
return self._folded(result, usage, cache, elapsed, attempts)
|
|
159
|
+
if not result.failure.retryable or attempt >= self.policy.retries:
|
|
160
|
+
return self._folded(result, usage, cache, elapsed, attempts)
|
|
161
|
+
if result.failure.kind.value == "transport":
|
|
162
|
+
await self._sleep(attempt + 1)
|
|
163
|
+
assert last is not None
|
|
164
|
+
return self._folded(last, usage, cache, elapsed, attempts)
|
|
165
|
+
|
|
166
|
+
def _folded(
|
|
167
|
+
self,
|
|
168
|
+
result: QueryResult[object],
|
|
169
|
+
usage: RunUsage,
|
|
170
|
+
cache: int,
|
|
171
|
+
elapsed: float,
|
|
172
|
+
attempts: int,
|
|
173
|
+
) -> QueryResult[object]:
|
|
174
|
+
failure = result.failure.with_attempts(attempts) if result.failure is not None else None
|
|
175
|
+
return replace(
|
|
176
|
+
result,
|
|
177
|
+
usage=usage,
|
|
178
|
+
cache_hit_tokens=cache,
|
|
179
|
+
elapsed_ms=elapsed,
|
|
180
|
+
attempts=attempts,
|
|
181
|
+
failure=failure,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
async def _sleep(self, attempt: int) -> None:
|
|
185
|
+
delay = min(self.policy.retry_backoff * (2 ** (attempt - 1)), self.policy.max_backoff)
|
|
186
|
+
if delay > 0:
|
|
187
|
+
await asyncio.sleep(delay)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def budget_action_failure(report: BudgetReport, policy: Policy) -> Failure | None:
|
|
191
|
+
"""Apply the budget policy. ``None`` means the call may proceed anyway."""
|
|
192
|
+
action: Accept | Fail
|
|
193
|
+
if policy.budget_policy is None:
|
|
194
|
+
action = Fail(report.message)
|
|
195
|
+
else:
|
|
196
|
+
decided = policy.budget_policy(report)
|
|
197
|
+
if isinstance(decided, Accept):
|
|
198
|
+
return None
|
|
199
|
+
action = decided if isinstance(decided, Fail) else Fail(report.message)
|
|
200
|
+
return budget_failure(action.reason)
|
forkreduce/failures.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Failure classification.
|
|
2
|
+
|
|
3
|
+
Every model call ends as either a value or a :class:`Failure`. Nothing is dropped
|
|
4
|
+
on the floor: transport and validation failures are retryable up to the policy
|
|
5
|
+
bound, and permanent failures are returned to the caller.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from pydantic import ValidationError
|
|
17
|
+
from pydantic_ai.exceptions import (
|
|
18
|
+
ModelAPIError,
|
|
19
|
+
ModelHTTPError,
|
|
20
|
+
UnexpectedModelBehavior,
|
|
21
|
+
UsageLimitExceeded,
|
|
22
|
+
UserError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
import httpx
|
|
27
|
+
except ImportError: # pragma: no cover - pydantic-ai always brings an http client
|
|
28
|
+
httpx = None # type: ignore[assignment]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FailureKind(StrEnum):
|
|
32
|
+
"""Coarse class used for retry decisions and Temporal non-retryable mapping."""
|
|
33
|
+
|
|
34
|
+
VALIDATION = "validation"
|
|
35
|
+
TRANSPORT = "transport"
|
|
36
|
+
PERMANENT = "permanent"
|
|
37
|
+
BUDGET = "budget"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# 408/429 and the usual gateway/unavailable statuses are worth another attempt.
|
|
41
|
+
# Other 4xx responses (auth, bad request, not found) will not succeed if repeated.
|
|
42
|
+
_RETRYABLE_STATUS = frozenset({408, 409, 425, 429, 500, 502, 503, 504})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Failure:
|
|
47
|
+
"""A classified failure attached to a query or a run.
|
|
48
|
+
|
|
49
|
+
``retryable`` is the executor's signal. Temporal maps it to
|
|
50
|
+
``ApplicationError(non_retryable=not retryable)``.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
kind: FailureKind
|
|
54
|
+
message: str
|
|
55
|
+
retryable: bool
|
|
56
|
+
code: str = ""
|
|
57
|
+
attempts: int = 1
|
|
58
|
+
detail: dict[str, Any] = field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
def with_attempts(self, attempts: int) -> Failure:
|
|
61
|
+
return Failure(
|
|
62
|
+
kind=self.kind,
|
|
63
|
+
message=self.message,
|
|
64
|
+
retryable=self.retryable,
|
|
65
|
+
code=self.code,
|
|
66
|
+
attempts=attempts,
|
|
67
|
+
detail=dict(self.detail),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def classify_exception(exc: BaseException) -> Failure:
|
|
72
|
+
"""Map an exception from a model call onto a :class:`Failure`."""
|
|
73
|
+
message = _short_message(exc)
|
|
74
|
+
if isinstance(exc, ModelHTTPError):
|
|
75
|
+
overflow = _context_overflow_from_provider(exc)
|
|
76
|
+
if overflow is not None:
|
|
77
|
+
return overflow
|
|
78
|
+
retryable = exc.status_code in _RETRYABLE_STATUS or exc.status_code >= 500
|
|
79
|
+
kind = FailureKind.TRANSPORT if retryable else FailureKind.PERMANENT
|
|
80
|
+
return Failure(
|
|
81
|
+
kind=kind,
|
|
82
|
+
message=message,
|
|
83
|
+
retryable=retryable,
|
|
84
|
+
code=f"http_{exc.status_code}",
|
|
85
|
+
detail={"status_code": exc.status_code},
|
|
86
|
+
)
|
|
87
|
+
if isinstance(exc, ModelAPIError):
|
|
88
|
+
return Failure(
|
|
89
|
+
kind=FailureKind.TRANSPORT,
|
|
90
|
+
message=message,
|
|
91
|
+
retryable=True,
|
|
92
|
+
code="transport",
|
|
93
|
+
)
|
|
94
|
+
if httpx is not None and isinstance(exc, (httpx.TimeoutException, httpx.TransportError)):
|
|
95
|
+
return Failure(
|
|
96
|
+
kind=FailureKind.TRANSPORT,
|
|
97
|
+
message=message,
|
|
98
|
+
retryable=True,
|
|
99
|
+
code="transport",
|
|
100
|
+
)
|
|
101
|
+
if isinstance(exc, (TimeoutError, asyncio.TimeoutError, ConnectionError)):
|
|
102
|
+
return Failure(
|
|
103
|
+
kind=FailureKind.TRANSPORT,
|
|
104
|
+
message=message,
|
|
105
|
+
retryable=True,
|
|
106
|
+
code="transport",
|
|
107
|
+
)
|
|
108
|
+
if isinstance(exc, (UnexpectedModelBehavior, ValidationError, UsageLimitExceeded)):
|
|
109
|
+
return Failure(
|
|
110
|
+
kind=FailureKind.VALIDATION,
|
|
111
|
+
message=message,
|
|
112
|
+
retryable=True,
|
|
113
|
+
code="validation",
|
|
114
|
+
)
|
|
115
|
+
if isinstance(exc, UserError):
|
|
116
|
+
return Failure(
|
|
117
|
+
kind=FailureKind.PERMANENT,
|
|
118
|
+
message=message,
|
|
119
|
+
retryable=False,
|
|
120
|
+
code="user_error",
|
|
121
|
+
)
|
|
122
|
+
return Failure(
|
|
123
|
+
kind=FailureKind.PERMANENT,
|
|
124
|
+
message=message,
|
|
125
|
+
retryable=False,
|
|
126
|
+
code="unexpected",
|
|
127
|
+
detail={"exception_type": type(exc).__name__},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def wrong_output_failure(expected: str, actual: str) -> Failure:
|
|
132
|
+
"""The model called a registered output tool other than the one this query asked for."""
|
|
133
|
+
return Failure(
|
|
134
|
+
kind=FailureKind.VALIDATION,
|
|
135
|
+
message=f"Expected output {expected}, got {actual}",
|
|
136
|
+
retryable=True,
|
|
137
|
+
code="wrong_output_type",
|
|
138
|
+
detail={"expected": expected, "actual": actual},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def context_overflow_failure(
|
|
143
|
+
*,
|
|
144
|
+
estimated_tokens: int | None,
|
|
145
|
+
limit: int | None,
|
|
146
|
+
where: str,
|
|
147
|
+
reported_tokens: int | None = None,
|
|
148
|
+
) -> Failure:
|
|
149
|
+
"""Permanent failure when the provider reports that one prompt does not fit.
|
|
150
|
+
|
|
151
|
+
The local character estimate never produces this failure. ``where`` is
|
|
152
|
+
``provider``. The library does not truncate or split the prompt.
|
|
153
|
+
"""
|
|
154
|
+
size = reported_tokens if reported_tokens is not None else estimated_tokens
|
|
155
|
+
size_label = "reported" if reported_tokens is not None else "estimated"
|
|
156
|
+
size_text = "unknown" if size is None else str(size)
|
|
157
|
+
limit_text = "unknown" if limit is None else str(limit)
|
|
158
|
+
detail: dict[str, Any] = {"where": where}
|
|
159
|
+
if estimated_tokens is not None:
|
|
160
|
+
detail["estimated_tokens"] = estimated_tokens
|
|
161
|
+
if reported_tokens is not None:
|
|
162
|
+
detail["reported_tokens"] = reported_tokens
|
|
163
|
+
if limit is not None:
|
|
164
|
+
detail["limit"] = limit
|
|
165
|
+
return Failure(
|
|
166
|
+
kind=FailureKind.PERMANENT,
|
|
167
|
+
message=(f"context overflow: {size_label} {size_text} tokens exceeds context limit {limit_text} ({where})"),
|
|
168
|
+
retryable=False,
|
|
169
|
+
code="context_overflow",
|
|
170
|
+
detail=detail,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def budget_failure(message: str) -> Failure:
|
|
175
|
+
return Failure(
|
|
176
|
+
kind=FailureKind.BUDGET,
|
|
177
|
+
message=message,
|
|
178
|
+
retryable=False,
|
|
179
|
+
code="budget_exceeded",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
_OVERFLOW_MARKERS = (
|
|
184
|
+
"context_length_exceeded",
|
|
185
|
+
"context_overflow",
|
|
186
|
+
"maximum context length",
|
|
187
|
+
"context window",
|
|
188
|
+
"too many tokens",
|
|
189
|
+
"prompt is too long",
|
|
190
|
+
"input is too long",
|
|
191
|
+
"exceeds the context",
|
|
192
|
+
"exceeds the model's maximum context",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _context_overflow_from_provider(exc: ModelHTTPError) -> Failure | None:
|
|
197
|
+
text = _provider_error_text(exc)
|
|
198
|
+
if not any(marker in text.lower() for marker in _OVERFLOW_MARKERS):
|
|
199
|
+
return None
|
|
200
|
+
reported, reported_limit = _reported_context_sizes(text)
|
|
201
|
+
failure = context_overflow_failure(
|
|
202
|
+
estimated_tokens=None,
|
|
203
|
+
limit=reported_limit,
|
|
204
|
+
where="provider",
|
|
205
|
+
reported_tokens=reported,
|
|
206
|
+
)
|
|
207
|
+
failure.detail["status_code"] = exc.status_code
|
|
208
|
+
return failure
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _provider_error_text(exc: ModelHTTPError) -> str:
|
|
212
|
+
body = exc.body
|
|
213
|
+
if isinstance(body, str):
|
|
214
|
+
rendered = body
|
|
215
|
+
elif body is None:
|
|
216
|
+
rendered = ""
|
|
217
|
+
else:
|
|
218
|
+
rendered = str(body)
|
|
219
|
+
return f"{exc} {rendered}"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _reported_context_sizes(text: str) -> tuple[int | None, int | None]:
|
|
223
|
+
"""Pull ``(requested tokens, window)`` out of a provider error, when present."""
|
|
224
|
+
match = re.search(
|
|
225
|
+
r"maximum context length is (\d+).*?requested (\d+)",
|
|
226
|
+
text,
|
|
227
|
+
flags=re.IGNORECASE | re.DOTALL,
|
|
228
|
+
)
|
|
229
|
+
if match is None:
|
|
230
|
+
return None, None
|
|
231
|
+
return int(match.group(2)), int(match.group(1))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _short_message(exc: BaseException) -> str:
|
|
235
|
+
text = str(exc).strip() or type(exc).__name__
|
|
236
|
+
text = " ".join(text.split())
|
|
237
|
+
if len(text) > 500:
|
|
238
|
+
text = text[:500] + "..."
|
|
239
|
+
return f"{type(exc).__name__}: {text}"
|