quantred 2.4.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.
- quantred/__init__.py +24 -0
- quantred/alphas/__init__.py +53 -0
- quantred/alphas/base.py +221 -0
- quantred/alphas/combine.py +260 -0
- quantred/alphas/horizon.py +153 -0
- quantred/alphas/refine.py +300 -0
- quantred/alphas/scorers.py +87 -0
- quantred/analytics/attribution.py +354 -0
- quantred/analytics/bootstrap.py +353 -0
- quantred/analytics/charts.py +393 -0
- quantred/analytics/excursion.py +185 -0
- quantred/analytics/execution_quality.py +180 -0
- quantred/analytics/exposure.py +167 -0
- quantred/analytics/htmlreport.py +1208 -0
- quantred/analytics/information.py +197 -0
- quantred/analytics/metrics.py +502 -0
- quantred/analytics/performance.py +639 -0
- quantred/analytics/reporting.py +1672 -0
- quantred/analytics/screening.py +190 -0
- quantred/analytics/series_comparison.py +303 -0
- quantred/analytics/trade_analytics.py +353 -0
- quantred/brokers/alpaca/broker.py +415 -0
- quantred/brokers/alpaca/factory.py +33 -0
- quantred/brokers/alpaca/market_data.py +166 -0
- quantred/brokers/base.py +220 -0
- quantred/brokers/dryrun.py +164 -0
- quantred/brokers/errors.py +70 -0
- quantred/cli.py +8619 -0
- quantred/costs/__init__.py +11 -0
- quantred/costs/base.py +126 -0
- quantred/costs/parametric.py +131 -0
- quantred/data/__init__.py +29 -0
- quantred/data/compute.py +332 -0
- quantred/data/edges.py +81 -0
- quantred/data/features.py +120 -0
- quantred/data/panel.py +61 -0
- quantred/data/scan.py +85 -0
- quantred/data/store.py +269 -0
- quantred/demo/__init__.py +22 -0
- quantred/demo/scanners.py +104 -0
- quantred/demo/strategies.py +125 -0
- quantred/engine/backtest.py +1206 -0
- quantred/engine/barcheck.py +228 -0
- quantred/engine/live.py +539 -0
- quantred/execution/decision.py +192 -0
- quantred/execution/flatten.py +302 -0
- quantred/execution/halt.py +165 -0
- quantred/execution/ledger.py +758 -0
- quantred/execution/live_trader.py +587 -0
- quantred/execution/order_id.py +83 -0
- quantred/execution/sizing.py +114 -0
- quantred/indicators/indicators.py +158 -0
- quantred/marketdata/base.py +62 -0
- quantred/marketdata/client.py +59 -0
- quantred/marketdata/session.py +112 -0
- quantred/marketdata/synthetic.py +74 -0
- quantred/marketdata/timeframe.py +125 -0
- quantred/mcp/__init__.py +6 -0
- quantred/mcp/selftest.py +139 -0
- quantred/mcp/server.py +2419 -0
- quantred/optimization/causality.py +607 -0
- quantred/optimization/config_store.py +300 -0
- quantred/optimization/optimizer.py +431 -0
- quantred/optimization/parallel.py +345 -0
- quantred/optimization/param_space.py +309 -0
- quantred/optimization/walk_forward.py +1254 -0
- quantred/portfolio/allocator.py +135 -0
- quantred/portfolio/benchmark.py +109 -0
- quantred/portfolio/optimizer.py +786 -0
- quantred/portfolio/policy.py +335 -0
- quantred/portfolio/posterior.py +208 -0
- quantred/research/__init__.py +30 -0
- quantred/research/agent.py +868 -0
- quantred/research/demo_proposals.py +222 -0
- quantred/research/llm.py +311 -0
- quantred/research/proposer.py +234 -0
- quantred/research/provider_errors.py +242 -0
- quantred/research/sandbox.py +340 -0
- quantred/risk/__init__.py +60 -0
- quantred/risk/base.py +218 -0
- quantred/risk/conditional.py +474 -0
- quantred/risk/exposures.py +88 -0
- quantred/risk/factor.py +119 -0
- quantred/risk/sample.py +89 -0
- quantred/risk/streaming.py +214 -0
- quantred/scanners/base.py +141 -0
- quantred/scanners/symbol_scanner.py +148 -0
- quantred/services/__init__.py +51 -0
- quantred/services/analysis.py +6082 -0
- quantred/services/audit.py +574 -0
- quantred/services/campaign.py +381 -0
- quantred/services/configs.py +282 -0
- quantred/services/data.py +172 -0
- quantred/services/dryrun.py +206 -0
- quantred/services/glossary.py +130 -0
- quantred/services/maintenance.py +314 -0
- quantred/services/registry.py +351 -0
- quantred/services/setup.py +1023 -0
- quantred/services/sizing.py +150 -0
- quantred/services/smallreal.py +450 -0
- quantred/settings.py +571 -0
- quantred/store/__init__.py +0 -0
- quantred/store/bars.py +660 -0
- quantred/store/trials.py +1804 -0
- quantred/strategies/base.py +643 -0
- quantred/strategies/signals.py +28 -0
- quantred/utils/errors.py +48 -0
- quantred/utils/extras.py +330 -0
- quantred/utils/logging_config.py +33 -0
- quantred/utils/numeric.py +46 -0
- quantred/utils/streaming.py +474 -0
- quantred/utils/timeutils.py +148 -0
- quantred-2.4.0.dist-info/METADATA +632 -0
- quantred-2.4.0.dist-info/RECORD +117 -0
- quantred-2.4.0.dist-info/WHEEL +4 -0
- quantred-2.4.0.dist-info/entry_points.txt +8 -0
- quantred-2.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Proposers: where the *creativity* enters the research loop.
|
|
2
|
+
|
|
3
|
+
A :class:`Proposer` turns a goal + the history so far into the next
|
|
4
|
+
:class:`Proposal` - either a parameter config to try ("tune") or new strategy
|
|
5
|
+
*code* to author ("code"). The agent loop and all guardrails are proposer-agnostic,
|
|
6
|
+
so the LLM-backed :class:`LLMProposer` and a deterministic test double share one
|
|
7
|
+
contract.
|
|
8
|
+
|
|
9
|
+
Each proposal must carry a one-paragraph **hypothesis** (the economic/behavioral
|
|
10
|
+
rationale). No rationale -> the agent rejects it unevaluated (a research-hygiene
|
|
11
|
+
rule; see the research-agent guide in the engineering docs).
|
|
12
|
+
|
|
13
|
+
:class:`LLMProposer` is provider-agnostic - it drives any
|
|
14
|
+
:class:`~quantred.research.llm.LLMClient` (Claude, GPT, or a local Ollama model), so
|
|
15
|
+
the same loop runs against a hosted frontier model or one on your laptop.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from abc import ABC, abstractmethod
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
from quantred.research.llm import LLMClient, build_llm_client
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Proposal:
|
|
27
|
+
"""One experiment the agent should try next."""
|
|
28
|
+
|
|
29
|
+
hypothesis: str # WHY this should work (required by hygiene)
|
|
30
|
+
kind: str = "tune" # "tune" (config) | "code" (new subclass)
|
|
31
|
+
strategy: Optional[str] = None # registered strategy to tune (kind == "tune")
|
|
32
|
+
params: Dict[str, Any] = field(default_factory=dict) # config overrides (kind == "tune")
|
|
33
|
+
code: Optional[str] = None # source of a new Strategy subclass (kind == "code")
|
|
34
|
+
tuned_params: List[str] = field(default_factory=list) # which params this varies (hygiene cap)
|
|
35
|
+
parent_id: Optional[str] = None # lineage: the candidate this mutates, if any
|
|
36
|
+
tokens_used: int = 0 # reported LLM token cost, for the budget
|
|
37
|
+
#: Why the reply could not be read as a proposal at all (unparseable text, a field of
|
|
38
|
+
#: the wrong type). Set instead of returning ``None``, which means "nothing more to
|
|
39
|
+
#: try": a model that answered badly once still has ideas, and the round is spent.
|
|
40
|
+
malformed: Optional[str] = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ProposalContext:
|
|
45
|
+
"""What the proposer sees each round (never includes holdout data)."""
|
|
46
|
+
|
|
47
|
+
goal: str
|
|
48
|
+
strategy: str
|
|
49
|
+
param_ranges: Dict[str, Any]
|
|
50
|
+
history: List[Dict[str, Any]] # compact summary of prior trials + OOS scores
|
|
51
|
+
incumbent: Optional[Dict[str, Any]] # current best candidate, if any
|
|
52
|
+
round_index: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Proposer(ABC):
|
|
56
|
+
"""Turns the goal + history into the next proposal."""
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def propose(self, context: ProposalContext) -> Optional[Proposal]:
|
|
60
|
+
"""Return the next proposal, or ``None`` to signal "nothing more to try"."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class FixedProposer(Proposer):
|
|
64
|
+
"""Deterministic proposer that replays a fixed list - for tests and replays."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, proposals: List[Proposal]):
|
|
67
|
+
self._proposals = list(proposals)
|
|
68
|
+
|
|
69
|
+
def propose(self, context: ProposalContext) -> Optional[Proposal]:
|
|
70
|
+
if context.round_index >= len(self._proposals):
|
|
71
|
+
return None
|
|
72
|
+
return self._proposals[context.round_index]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class LLMProposer(Proposer):
|
|
76
|
+
"""Proposer backed by any :class:`~quantred.research.llm.LLMClient`.
|
|
77
|
+
|
|
78
|
+
Asks the model for a JSON proposal: a hypothesis plus either param overrides
|
|
79
|
+
within the supplied ``PARAM_RANGES`` (``kind="tune"``) or, when
|
|
80
|
+
``allow_code_gen`` is set, the source of a brand-new ``Strategy`` subclass
|
|
81
|
+
(``kind="code"``). The model id is pinned on the client and logged by the
|
|
82
|
+
agent's audit journal, so every proposal is replayable.
|
|
83
|
+
|
|
84
|
+
Code proposals are *untrusted text* until :mod:`quantred.research.sandbox` has
|
|
85
|
+
validated them - this class only transports them.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self, client: LLMClient, max_tokens: int = 1024, *, allow_code_gen: bool = False):
|
|
89
|
+
self.client = client
|
|
90
|
+
self.max_tokens = max_tokens
|
|
91
|
+
self.allow_code_gen = allow_code_gen
|
|
92
|
+
|
|
93
|
+
def propose(self, context: ProposalContext) -> Optional[Proposal]:
|
|
94
|
+
import json
|
|
95
|
+
|
|
96
|
+
system = _CODEGEN_SYSTEM_PROMPT if self.allow_code_gen else _SYSTEM_PROMPT
|
|
97
|
+
response = self.client.complete(system, _build_user_prompt(context), self.max_tokens)
|
|
98
|
+
try:
|
|
99
|
+
payload = json.loads(_extract_json(response.text))
|
|
100
|
+
except (ValueError, KeyError):
|
|
101
|
+
return self._malformed("unparseable reply", response.tokens, context)
|
|
102
|
+
if not isinstance(payload, dict):
|
|
103
|
+
return self._malformed("unparseable reply: expected a JSON object", response.tokens, context)
|
|
104
|
+
|
|
105
|
+
hypothesis = payload.get("hypothesis", "")
|
|
106
|
+
if not isinstance(hypothesis, str):
|
|
107
|
+
return self._malformed(
|
|
108
|
+
f"hypothesis must be text, got {_type_name(hypothesis)}", response.tokens, context
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if payload.get("kind") == "code":
|
|
112
|
+
code = payload.get("code", "")
|
|
113
|
+
if not isinstance(code, str):
|
|
114
|
+
return self._malformed(f"code must be text, got {_type_name(code)}", response.tokens, context)
|
|
115
|
+
return Proposal(
|
|
116
|
+
hypothesis=hypothesis,
|
|
117
|
+
kind="code",
|
|
118
|
+
strategy=context.strategy,
|
|
119
|
+
code=code,
|
|
120
|
+
tokens_used=response.tokens,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
params = payload.get("params", {})
|
|
124
|
+
if not isinstance(params, dict):
|
|
125
|
+
return self._malformed(
|
|
126
|
+
f"params must be an object of name: value, got {_type_name(params)}",
|
|
127
|
+
response.tokens,
|
|
128
|
+
context,
|
|
129
|
+
hypothesis,
|
|
130
|
+
)
|
|
131
|
+
return Proposal(
|
|
132
|
+
hypothesis=hypothesis,
|
|
133
|
+
kind="tune",
|
|
134
|
+
strategy=context.strategy,
|
|
135
|
+
params=params,
|
|
136
|
+
tuned_params=list(params),
|
|
137
|
+
tokens_used=response.tokens,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _malformed(reason: str, tokens: int, context: ProposalContext, hypothesis: str = "") -> Proposal:
|
|
142
|
+
return Proposal(
|
|
143
|
+
hypothesis=hypothesis,
|
|
144
|
+
kind="tune",
|
|
145
|
+
strategy=context.strategy,
|
|
146
|
+
tokens_used=tokens,
|
|
147
|
+
malformed=reason,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _type_name(value: Any) -> str:
|
|
152
|
+
"""A JSON-flavoured name for a decoded value, for a message a human reads."""
|
|
153
|
+
if value is None:
|
|
154
|
+
return "null"
|
|
155
|
+
if isinstance(value, bool):
|
|
156
|
+
return "a boolean"
|
|
157
|
+
if isinstance(value, (int, float)):
|
|
158
|
+
return "a number"
|
|
159
|
+
if isinstance(value, str):
|
|
160
|
+
return "text"
|
|
161
|
+
return "a list" if isinstance(value, list) else type(value).__name__
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def build_proposer(
|
|
165
|
+
provider: str = "anthropic",
|
|
166
|
+
model: Optional[str] = None,
|
|
167
|
+
*,
|
|
168
|
+
client: Optional[LLMClient] = None,
|
|
169
|
+
max_tokens: int = 1024,
|
|
170
|
+
allow_code_gen: bool = False,
|
|
171
|
+
**kwargs,
|
|
172
|
+
) -> LLMProposer:
|
|
173
|
+
"""Build an :class:`LLMProposer` for a provider (anthropic | openai | ollama)."""
|
|
174
|
+
llm = client or build_llm_client(provider, model, **kwargs)
|
|
175
|
+
return LLMProposer(llm, max_tokens=max_tokens, allow_code_gen=allow_code_gen)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
_SYSTEM_PROMPT = (
|
|
179
|
+
"You are a quantitative research assistant proposing the next backtest experiment. "
|
|
180
|
+
"Change at most 5 parameters. Give a one-paragraph hypothesis of WHY the change should "
|
|
181
|
+
"improve OUT-OF-SAMPLE performance - in-sample gains do not count. "
|
|
182
|
+
'Respond ONLY with JSON: {"hypothesis": "...", "params": {"name": value, ...}}.'
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
#: System prompt for sessions run with ``--allow-code-gen``. The model may either
|
|
186
|
+
#: tune the incumbent or author a new mechanism; the sandbox enforces the contract
|
|
187
|
+
#: described here, so a violation is a rejected proposal, not a broken run.
|
|
188
|
+
_CODEGEN_SYSTEM_PROMPT = (
|
|
189
|
+
"You are a quantitative research assistant proposing the next backtest experiment. "
|
|
190
|
+
"You may respond with EITHER of two proposal kinds.\n\n"
|
|
191
|
+
'1. Tune the existing strategy: {"kind": "tune", "hypothesis": "...", '
|
|
192
|
+
'"params": {"name": value, ...}} - at most 5 parameters, each within PARAM_RANGES.\n\n'
|
|
193
|
+
'2. Author a NEW strategy mechanism: {"kind": "code", "hypothesis": "...", "code": "<python source>"}.\n\n'
|
|
194
|
+
"Propose 'code' when the history suggests the parameter space is exhausted and a different "
|
|
195
|
+
"mechanism is needed; propose 'tune' when the incumbent looks refinable.\n\n"
|
|
196
|
+
"Generated code runs in a restricted sandbox and MUST satisfy this contract or it is rejected:\n"
|
|
197
|
+
"- Define exactly one concrete subclass of `Strategy` (from quantred.strategies.base).\n"
|
|
198
|
+
"- Imports are limited to: pandas, numpy, math, quantred.indicators.indicators, quantred.strategies.base. "
|
|
199
|
+
"No os, sys, io, requests, or file access of any kind.\n"
|
|
200
|
+
"- Give the class a docstring stating the hypothesis (required).\n"
|
|
201
|
+
"- Declare a PARAM_RANGES ClassVar dict with AT MOST 5 SEARCHABLE params. A param is "
|
|
202
|
+
"searchable when its spec has min, max, and step; a spec with only a default is fixed and "
|
|
203
|
+
"does NOT count against the cap. Each spec needs type ('int'|'float'), default, description.\n"
|
|
204
|
+
"- PARAM_RANGES MUST include risk_per_trade, stop_loss, and take_profit (fractional distances). "
|
|
205
|
+
"The execution path reads these on every bar; omitting them is rejected. Pin them with a "
|
|
206
|
+
"default only if you would rather spend the searchable budget on your mechanism.\n"
|
|
207
|
+
"- Set TIMEFRAME (e.g. '1Day') and call super().__init__(config) after setting config['timeframe'].\n"
|
|
208
|
+
"- Implement all four abstract hooks: initialize(), process_data(data) -> DataFrame, "
|
|
209
|
+
"calculate_scores(data) -> Series, calculate_required_lookback() -> int.\n"
|
|
210
|
+
"- calculate_scores returns ONE continuous conviction score per bar; the base class derives "
|
|
211
|
+
"BUY/SELL/HOLD from its sign. Do not emit discrete signals yourself.\n"
|
|
212
|
+
"- The class must construct from its own declared defaults.\n\n"
|
|
213
|
+
"Give a one-paragraph hypothesis of WHY this should improve OUT-OF-SAMPLE performance - "
|
|
214
|
+
"in-sample gains do not count. Respond ONLY with the JSON object."
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _build_user_prompt(context: ProposalContext) -> str:
|
|
219
|
+
import json
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
f"GOAL: {context.goal}\n"
|
|
223
|
+
f"STRATEGY: {context.strategy}\n"
|
|
224
|
+
f"TUNABLE PARAM_RANGES (stay within bounds):\n{json.dumps(context.param_ranges, default=str)}\n"
|
|
225
|
+
f"HISTORY (prior out-of-sample results):\n{json.dumps(context.history, default=str)}\n"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _extract_json(text: str) -> str:
|
|
230
|
+
"""Pull the first {...} block out of an LLM response."""
|
|
231
|
+
start, end = text.find("{"), text.rfind("}")
|
|
232
|
+
if start == -1 or end == -1 or end < start:
|
|
233
|
+
raise ValueError("no JSON object in response")
|
|
234
|
+
return text[start : end + 1]
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Naming what went wrong when a model provider refuses or cannot be reached.
|
|
2
|
+
|
|
3
|
+
One classification, used twice: by the opt-in readiness probe, and by the research
|
|
4
|
+
loop, because a key that passes a probe can still run out of credit twenty minutes
|
|
5
|
+
into a session. Both surfaces must name the same failure the same way, so the
|
|
6
|
+
mapping lives here and imports nothing heavy - the provider SDKs are never imported,
|
|
7
|
+
the exceptions are recognised by their shape (``status_code``, ``code``, ``type``,
|
|
8
|
+
class names).
|
|
9
|
+
|
|
10
|
+
**Structured fields decide; the message is the last resort.** Both SDKs carry a
|
|
11
|
+
machine-readable error code/type (``insufficient_quota`` against
|
|
12
|
+
``rate_limit_exceeded``; ``billing_error``, ``rate_limit_error``,
|
|
13
|
+
``overloaded_error``). A message is free text that can mention billing for any reason -
|
|
14
|
+
a rate-limit reply that links the billing page is still a rate limit - so wording is
|
|
15
|
+
consulted only when no structured field settles the question.
|
|
16
|
+
|
|
17
|
+
**Messages never carry the exception's own text.** A vendor error string can echo
|
|
18
|
+
request parameters, and the request carries the key. The detail is composed here from
|
|
19
|
+
the category, the HTTP status and the exception's type name.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import http.client
|
|
25
|
+
import socket
|
|
26
|
+
import urllib.error
|
|
27
|
+
from typing import Any, List, Literal, Optional, Tuple
|
|
28
|
+
|
|
29
|
+
ProbeCategory = Literal[
|
|
30
|
+
"ok",
|
|
31
|
+
"auth",
|
|
32
|
+
"quota",
|
|
33
|
+
"rate_limited",
|
|
34
|
+
"unavailable",
|
|
35
|
+
"entitlement",
|
|
36
|
+
"unreachable",
|
|
37
|
+
"timeout",
|
|
38
|
+
"model_not_found",
|
|
39
|
+
"unknown",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
#: Categories worth sending again: the condition is the provider's or the network's and
|
|
43
|
+
#: passes by itself. ``quota`` (an empty balance), ``auth``, ``entitlement`` and
|
|
44
|
+
#: ``model_not_found`` are not - repeating the request cannot change the answer, only
|
|
45
|
+
#: bill or hammer an account. ``timeout`` is left out too: a provider that did not answer
|
|
46
|
+
#: in the configured time is not helped by waiting that long again, three times.
|
|
47
|
+
RETRYABLE: Tuple[str, ...] = ("rate_limited", "unavailable", "unreachable")
|
|
48
|
+
|
|
49
|
+
#: Structured ``code`` / ``type`` values (OpenAI and Anthropic) and the category each settles.
|
|
50
|
+
_STRUCTURED = {
|
|
51
|
+
"insufficient_quota": "quota",
|
|
52
|
+
"billing_error": "quota",
|
|
53
|
+
"rate_limit_exceeded": "rate_limited",
|
|
54
|
+
"rate_limit_error": "rate_limited",
|
|
55
|
+
"overloaded_error": "unavailable",
|
|
56
|
+
"server_error": "unavailable",
|
|
57
|
+
"api_error": "unavailable",
|
|
58
|
+
"authentication_error": "auth",
|
|
59
|
+
"invalid_api_key": "auth",
|
|
60
|
+
"permission_error": "entitlement",
|
|
61
|
+
"not_found_error": "model_not_found",
|
|
62
|
+
"model_not_found": "model_not_found",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#: Wording that marks a 400/429 as "out of credit" - consulted only when no structured
|
|
66
|
+
#: field settled it. Anthropic reports an empty balance as a plain 400 with no code.
|
|
67
|
+
_CREDIT_MARKERS = (
|
|
68
|
+
"insufficient_quota",
|
|
69
|
+
"credit balance",
|
|
70
|
+
"exceeded your current quota",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
_HTTP_STATUS = {
|
|
74
|
+
401: ("auth", "the provider rejected the credentials (HTTP 401)"),
|
|
75
|
+
402: ("quota", "credit exhausted (HTTP 402) - the account has no usable balance"),
|
|
76
|
+
403: ("entitlement", "the credentials are valid but not permitted to use this model (HTTP 403)"),
|
|
77
|
+
404: ("model_not_found", "the provider has no such model (HTTP 404)"),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ModelProviderError(RuntimeError):
|
|
82
|
+
"""A provider call failed in a way the user can act on.
|
|
83
|
+
|
|
84
|
+
``category`` is one of :data:`ProbeCategory`; ``detail`` is safe to print.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
provider: str,
|
|
90
|
+
category: ProbeCategory,
|
|
91
|
+
detail: str,
|
|
92
|
+
*,
|
|
93
|
+
attempts: int = 1,
|
|
94
|
+
advised_wait: Optional[float] = None,
|
|
95
|
+
):
|
|
96
|
+
super().__init__(f"model provider {provider}: {category} - {detail}")
|
|
97
|
+
self.provider = provider
|
|
98
|
+
self.category = category
|
|
99
|
+
self.detail = detail
|
|
100
|
+
#: Requests actually sent before giving up (1 means no retry happened) and the
|
|
101
|
+
#: wait the provider asked for, if any. Carried so the message can say what
|
|
102
|
+
#: happened instead of what the policy usually does.
|
|
103
|
+
self.attempts = attempts
|
|
104
|
+
self.advised_wait = advised_wait
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _class_names(exc: BaseException) -> set:
|
|
108
|
+
return {cls.__name__ for cls in type(exc).__mro__}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _status_of(exc: BaseException) -> Optional[int]:
|
|
112
|
+
for attr in ("status_code", "code", "status"):
|
|
113
|
+
value = getattr(exc, attr, None)
|
|
114
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
115
|
+
return value
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _structured_values(exc: BaseException) -> List[str]:
|
|
120
|
+
"""The machine-readable code/type strings an error carries, lower-cased."""
|
|
121
|
+
values: List[Any] = [getattr(exc, "code", None), getattr(exc, "type", None)]
|
|
122
|
+
body = getattr(exc, "body", None)
|
|
123
|
+
for level in (body, body.get("error") if isinstance(body, dict) else None):
|
|
124
|
+
if isinstance(level, dict):
|
|
125
|
+
values.extend((level.get("code"), level.get("type")))
|
|
126
|
+
return [v.lower() for v in values if isinstance(v, str)]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _message_text(exc: BaseException) -> str:
|
|
130
|
+
"""Free text that might say "credit" - used only to decide, never to print."""
|
|
131
|
+
parts = [str(exc)]
|
|
132
|
+
body = getattr(exc, "body", None)
|
|
133
|
+
if body is not None:
|
|
134
|
+
parts.append(str(body))
|
|
135
|
+
return " ".join(parts).lower()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _structured_category(exc: BaseException) -> Optional[str]:
|
|
139
|
+
for value in _structured_values(exc):
|
|
140
|
+
if value in _STRUCTURED:
|
|
141
|
+
return _STRUCTURED[value]
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def is_provider_exception(exc: BaseException) -> bool:
|
|
146
|
+
"""Whether ``exc`` is a vendor/network failure rather than a bug in this code.
|
|
147
|
+
|
|
148
|
+
Recognised by shape, like the classifier: an HTTP status carried by the exception,
|
|
149
|
+
an SDK ``API*Error`` class, or a socket/urllib failure. Anything else - an
|
|
150
|
+
``AttributeError`` parsing a reply, a ``KeyError`` - is ours and keeps its traceback.
|
|
151
|
+
"""
|
|
152
|
+
if isinstance(
|
|
153
|
+
exc,
|
|
154
|
+
(
|
|
155
|
+
urllib.error.URLError,
|
|
156
|
+
TimeoutError,
|
|
157
|
+
socket.timeout,
|
|
158
|
+
socket.gaierror,
|
|
159
|
+
ConnectionError,
|
|
160
|
+
http.client.HTTPException,
|
|
161
|
+
),
|
|
162
|
+
):
|
|
163
|
+
return True
|
|
164
|
+
if _status_of(exc) is not None:
|
|
165
|
+
return True
|
|
166
|
+
return bool(_class_names(exc) & {"APIError", "APIStatusError", "APIConnectionError", "APITimeoutError"})
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def retry_after_seconds(exc: BaseException) -> Optional[float]:
|
|
170
|
+
"""The provider's own ``Retry-After`` (seconds), when the error carries one."""
|
|
171
|
+
headers = getattr(getattr(exc, "response", None), "headers", None) or getattr(exc, "headers", None)
|
|
172
|
+
if headers is None:
|
|
173
|
+
return None
|
|
174
|
+
try:
|
|
175
|
+
value = headers.get("retry-after")
|
|
176
|
+
except AttributeError:
|
|
177
|
+
return None
|
|
178
|
+
try:
|
|
179
|
+
return float(value) if value is not None else None
|
|
180
|
+
except (TypeError, ValueError):
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def classify_provider_error(exc: BaseException) -> Tuple[ProbeCategory, str]:
|
|
185
|
+
"""Map a provider exception to ``(category, safe detail)``.
|
|
186
|
+
|
|
187
|
+
Duck-typed so it needs no SDK. Order: transport failures by type, then the
|
|
188
|
+
structured code/type, then the HTTP status, and only for a 400/429 that nothing
|
|
189
|
+
structured explained, the message wording. ``quota`` means credit (a billing
|
|
190
|
+
action); ``rate_limited`` and ``unavailable`` mean wait.
|
|
191
|
+
"""
|
|
192
|
+
names = _class_names(exc)
|
|
193
|
+
|
|
194
|
+
if isinstance(exc, TimeoutError) or isinstance(exc, socket.timeout) or "APITimeoutError" in names:
|
|
195
|
+
return "timeout", "the provider did not answer in time"
|
|
196
|
+
if isinstance(exc, urllib.error.URLError) and not isinstance(exc, urllib.error.HTTPError):
|
|
197
|
+
reason = exc.reason
|
|
198
|
+
if isinstance(reason, (TimeoutError, socket.timeout)):
|
|
199
|
+
return "timeout", "the provider did not answer in time"
|
|
200
|
+
return "unreachable", "the connection failed before any response"
|
|
201
|
+
|
|
202
|
+
status = _status_of(exc)
|
|
203
|
+
suffix = f" (HTTP {status})" if status is not None else ""
|
|
204
|
+
structured = _structured_category(exc)
|
|
205
|
+
if structured == "quota":
|
|
206
|
+
return "quota", f"credit exhausted{suffix} - the account has no usable balance"
|
|
207
|
+
if structured == "rate_limited":
|
|
208
|
+
return "rate_limited", f"rate limited{suffix} - too many requests, not an empty balance; wait"
|
|
209
|
+
if structured == "unavailable":
|
|
210
|
+
return "unavailable", f"the provider is overloaded or failing{suffix}; try again shortly"
|
|
211
|
+
if structured in ("auth", "entitlement", "model_not_found") and status in (None, 400, 401, 403, 404):
|
|
212
|
+
return _HTTP_STATUS[{"auth": 401, "entitlement": 403, "model_not_found": 404}[structured]]
|
|
213
|
+
|
|
214
|
+
if status in _HTTP_STATUS:
|
|
215
|
+
return _HTTP_STATUS[status]
|
|
216
|
+
if status == 429 or status == 400:
|
|
217
|
+
# No structured field settled it: the message is all there is.
|
|
218
|
+
if any(marker in _message_text(exc) for marker in _CREDIT_MARKERS):
|
|
219
|
+
return "quota", f"credit exhausted{suffix} - the account has no usable balance"
|
|
220
|
+
if status == 429:
|
|
221
|
+
return "rate_limited", f"rate limited{suffix} - too many requests, not an empty balance; wait"
|
|
222
|
+
if status is not None and status >= 500:
|
|
223
|
+
return "unavailable", f"the provider is overloaded or failing{suffix}; try again shortly"
|
|
224
|
+
|
|
225
|
+
if "APIConnectionError" in names or isinstance(exc, (ConnectionError, OSError)):
|
|
226
|
+
return "unreachable", "the connection failed before any response"
|
|
227
|
+
|
|
228
|
+
return "unknown", f"unexpected {type(exc).__name__}{suffix}"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def as_provider_error(
|
|
232
|
+
provider: str, exc: BaseException, *, detail: Optional[Any] = None, attempts: int = 1
|
|
233
|
+
) -> ModelProviderError:
|
|
234
|
+
"""Wrap ``exc`` as a :class:`ModelProviderError`, optionally overriding the detail."""
|
|
235
|
+
category, classified = classify_provider_error(exc)
|
|
236
|
+
return ModelProviderError(
|
|
237
|
+
provider,
|
|
238
|
+
category,
|
|
239
|
+
str(detail) if detail else classified,
|
|
240
|
+
attempts=attempts,
|
|
241
|
+
advised_wait=retry_after_seconds(exc),
|
|
242
|
+
)
|