world-model-optimizer 0.2.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.
- llm_waterfall/LICENSE +21 -0
- llm_waterfall/__init__.py +53 -0
- llm_waterfall/adapters/__init__.py +36 -0
- llm_waterfall/adapters/anthropic.py +105 -0
- llm_waterfall/adapters/aws_mantle.py +47 -0
- llm_waterfall/adapters/azure_openai.py +71 -0
- llm_waterfall/adapters/base.py +51 -0
- llm_waterfall/adapters/bedrock.py +309 -0
- llm_waterfall/adapters/openai.py +130 -0
- llm_waterfall/classify.py +184 -0
- llm_waterfall/pricing.py +110 -0
- llm_waterfall/py.typed +0 -0
- llm_waterfall/types.py +295 -0
- llm_waterfall/waterfall.py +255 -0
- wmo/__init__.py +38 -0
- wmo/agents/__init__.py +7 -0
- wmo/agents/default.py +29 -0
- wmo/agents/meta.py +55 -0
- wmo/agents/optimizer.py +55 -0
- wmo/agents/project.py +928 -0
- wmo/cli/__init__.py +5 -0
- wmo/cli/agent_session.py +1123 -0
- wmo/cli/app.py +2489 -0
- wmo/cli/e2b_cmds.py +212 -0
- wmo/cli/eval_closed_loop.py +207 -0
- wmo/cli/harness_app.py +1147 -0
- wmo/cli/harness_distill.py +659 -0
- wmo/cli/hosted_session.py +880 -0
- wmo/cli/ingest_cmd.py +165 -0
- wmo/cli/model_roles.py +82 -0
- wmo/cli/platform_cmds.py +372 -0
- wmo/cli/route_app.py +274 -0
- wmo/cli/session_state.py +243 -0
- wmo/cli/ui.py +1107 -0
- wmo/cli/workspace_sync.py +504 -0
- wmo/config/__init__.py +60 -0
- wmo/config/card.py +129 -0
- wmo/config/config.py +367 -0
- wmo/config/dotenv.py +67 -0
- wmo/config/settings.py +128 -0
- wmo/config/store.py +177 -0
- wmo/conftest.py +19 -0
- wmo/connect/__init__.py +88 -0
- wmo/connect/apps.py +78 -0
- wmo/connect/brave.py +284 -0
- wmo/connect/connector.py +79 -0
- wmo/connect/credentials.py +164 -0
- wmo/connect/github.py +321 -0
- wmo/connect/google.py +627 -0
- wmo/connect/notion.py +790 -0
- wmo/connect/oauth.py +461 -0
- wmo/connect/slack.py +555 -0
- wmo/connect/store.py +199 -0
- wmo/connect/types.py +156 -0
- wmo/core/__init__.py +21 -0
- wmo/core/parsing.py +281 -0
- wmo/core/render.py +271 -0
- wmo/core/text.py +40 -0
- wmo/core/types.py +116 -0
- wmo/distill/__init__.py +14 -0
- wmo/distill/agents.py +140 -0
- wmo/distill/config.py +1006 -0
- wmo/distill/cost.py +437 -0
- wmo/distill/data.py +921 -0
- wmo/distill/deadlines.py +254 -0
- wmo/distill/fake_tinker.py +734 -0
- wmo/distill/gate.py +122 -0
- wmo/distill/loop.py +3499 -0
- wmo/distill/renderers.py +399 -0
- wmo/distill/rendering.py +620 -0
- wmo/distill/rollouts.py +726 -0
- wmo/distill/samples.py +195 -0
- wmo/distill/store.py +829 -0
- wmo/distill/teacher.py +714 -0
- wmo/distill/tokens.py +535 -0
- wmo/distill/tracking.py +552 -0
- wmo/distill/tripwire.py +411 -0
- wmo/distill/xtoken/byte_offsets.py +152 -0
- wmo/distill/xtoken/chunks.py +457 -0
- wmo/distill/xtoken/prompt_logprobs.py +475 -0
- wmo/distill/xtoken/teacher_render.py +346 -0
- wmo/engine/__init__.py +28 -0
- wmo/engine/autoconfig.py +367 -0
- wmo/engine/build.py +346 -0
- wmo/engine/demo.py +77 -0
- wmo/engine/eval_suites.py +245 -0
- wmo/engine/grounding.py +491 -0
- wmo/engine/knowledge.py +291 -0
- wmo/engine/loader.py +36 -0
- wmo/engine/play.py +92 -0
- wmo/engine/prompts.py +99 -0
- wmo/engine/replay.py +443 -0
- wmo/engine/reporting.py +58 -0
- wmo/engine/workspace.py +468 -0
- wmo/engine/world_model.py +568 -0
- wmo/env/__init__.py +22 -0
- wmo/env/base.py +121 -0
- wmo/env/closed_loop.py +229 -0
- wmo/env/episode.py +107 -0
- wmo/env/llm_agent.py +93 -0
- wmo/env/scenarios.py +73 -0
- wmo/evals/__init__.py +52 -0
- wmo/evals/agreement.py +110 -0
- wmo/evals/base.py +45 -0
- wmo/evals/closed_loop.py +480 -0
- wmo/evals/failover.py +96 -0
- wmo/evals/gold.py +127 -0
- wmo/evals/grid.py +394 -0
- wmo/evals/grid_plot.py +205 -0
- wmo/evals/harbor/__init__.py +27 -0
- wmo/evals/harbor/agent.py +573 -0
- wmo/evals/harbor/ctrf.py +171 -0
- wmo/evals/harbor/e2b_environment.py +587 -0
- wmo/evals/harbor/e2b_template_policy.py +144 -0
- wmo/evals/harbor/scorer.py +875 -0
- wmo/evals/harbor/tasks.py +140 -0
- wmo/evals/open_loop.py +194 -0
- wmo/evals/tasks.py +53 -0
- wmo/harness/__init__.py +51 -0
- wmo/harness/code_runtime.py +288 -0
- wmo/harness/create.py +1191 -0
- wmo/harness/delta.py +220 -0
- wmo/harness/doc.py +556 -0
- wmo/harness/e2b_ledger.py +342 -0
- wmo/harness/e2b_reap.py +476 -0
- wmo/harness/e2b_sandbox.py +350 -0
- wmo/harness/environment.py +35 -0
- wmo/harness/live_session.py +543 -0
- wmo/harness/mutate.py +343 -0
- wmo/harness/pi_e2b.py +1710 -0
- wmo/harness/pi_entry/entry.ts +268 -0
- wmo/harness/pi_entry/runner_frames.ts +92 -0
- wmo/harness/pi_entry/runner_live.ts +587 -0
- wmo/harness/pi_entry/runner_service.ts +270 -0
- wmo/harness/pi_entry/runner_stdio.ts +374 -0
- wmo/harness/pi_entry/runner_termination.ts +142 -0
- wmo/harness/pi_local.py +262 -0
- wmo/harness/pi_runtime.py +495 -0
- wmo/harness/pi_vendor.py +65 -0
- wmo/harness/population.py +509 -0
- wmo/harness/project_proposer.py +569 -0
- wmo/harness/proposer.py +977 -0
- wmo/harness/runner_link.py +619 -0
- wmo/harness/runtime.py +389 -0
- wmo/harness/scoring.py +247 -0
- wmo/harness/skills.py +116 -0
- wmo/harness/source_tree.py +319 -0
- wmo/harness/store.py +176 -0
- wmo/harness/tools.py +105 -0
- wmo/harness/vendor/manifest.sha256 +58 -0
- wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
- wmo/harness/vendor/pi-agent/LICENSE +21 -0
- wmo/harness/vendor/pi-agent/README.md +488 -0
- wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
- wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
- wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
- wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
- wmo/harness/vendor/pi-agent/docs/models.md +966 -0
- wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
- wmo/harness/vendor/pi-agent/package.json +60 -0
- wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
- wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
- wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
- wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
- wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
- wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
- wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
- wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
- wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
- wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
- wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
- wmo/harness/vendor/pi-agent/src/index.ts +44 -0
- wmo/harness/vendor/pi-agent/src/node.ts +2 -0
- wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
- wmo/harness/vendor/pi-agent/src/types.ts +428 -0
- wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
- wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
- wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
- wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
- wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
- wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
- wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
- wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
- wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
- wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
- wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
- wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
- wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
- wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
- wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
- wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
- wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
- wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
- wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
- wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
- wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
- wmo/harness/vendor/vendor_pi.sh +59 -0
- wmo/harness/workspace_patch.py +270 -0
- wmo/ingest/__init__.py +47 -0
- wmo/ingest/adapter.py +72 -0
- wmo/ingest/base.py +114 -0
- wmo/ingest/braintrust.py +339 -0
- wmo/ingest/detect.py +126 -0
- wmo/ingest/langfuse.py +291 -0
- wmo/ingest/langsmith.py +444 -0
- wmo/ingest/mastra.py +330 -0
- wmo/ingest/messages.py +170 -0
- wmo/ingest/normalize.py +679 -0
- wmo/ingest/otel_genai.py +69 -0
- wmo/ingest/otel_writer.py +100 -0
- wmo/ingest/phoenix.py +150 -0
- wmo/ingest/postgres.py +246 -0
- wmo/ingest/posthog.py +320 -0
- wmo/ingest/quality.py +28 -0
- wmo/ingest/stream.py +209 -0
- wmo/ingest/testdata/sample_otlp.json +60 -0
- wmo/ingest/testdata/sample_spans.jsonl +3 -0
- wmo/optimize/__init__.py +25 -0
- wmo/optimize/base.py +143 -0
- wmo/optimize/gepa.py +806 -0
- wmo/optimize/judge.py +262 -0
- wmo/optimize/judge_quality.py +359 -0
- wmo/optimize/knn.py +468 -0
- wmo/optimize/numeric.py +152 -0
- wmo/optimize/outcomes.py +103 -0
- wmo/optimize/policy.py +669 -0
- wmo/optimize/report.py +231 -0
- wmo/optimize/reward.py +129 -0
- wmo/optimize/routing.py +373 -0
- wmo/platform/__init__.py +6 -0
- wmo/platform/auth.py +115 -0
- wmo/platform/client.py +551 -0
- wmo/platform/credentials.py +126 -0
- wmo/platform/transfer.py +158 -0
- wmo/providers/__init__.py +40 -0
- wmo/providers/_bedrock_chat.py +155 -0
- wmo/providers/_openai_common.py +182 -0
- wmo/providers/_responses_common.py +472 -0
- wmo/providers/anthropic.py +134 -0
- wmo/providers/azure_openai.py +296 -0
- wmo/providers/base.py +300 -0
- wmo/providers/bedrock.py +312 -0
- wmo/providers/models.py +205 -0
- wmo/providers/openai.py +143 -0
- wmo/providers/openai_responses.py +240 -0
- wmo/providers/pool.py +170 -0
- wmo/providers/registry.py +73 -0
- wmo/providers/retry.py +151 -0
- wmo/providers/tinker.py +936 -0
- wmo/providers/waterfall.py +336 -0
- wmo/research/__init__.py +81 -0
- wmo/research/ablation.py +133 -0
- wmo/research/concurrency_plot.py +523 -0
- wmo/research/concurrency_run.py +240 -0
- wmo/research/concurrency_scaling.py +270 -0
- wmo/research/gepa_scaling.py +274 -0
- wmo/research/pipeline.py +198 -0
- wmo/research/scaling_split.py +82 -0
- wmo/research/scenario_fidelity.py +198 -0
- wmo/research/scenario_recovery.py +92 -0
- wmo/research/seed_stability.py +90 -0
- wmo/research/trace_scaling.py +348 -0
- wmo/retrieval/__init__.py +6 -0
- wmo/retrieval/embedders.py +105 -0
- wmo/retrieval/leakfree.py +52 -0
- wmo/retrieval/retriever.py +173 -0
- wmo/scenarios/__init__.py +58 -0
- wmo/scenarios/builder.py +152 -0
- wmo/scenarios/mining/__init__.py +27 -0
- wmo/scenarios/mining/clustering.py +171 -0
- wmo/scenarios/mining/facets.py +226 -0
- wmo/scenarios/mining/selection.py +220 -0
- wmo/scenarios/synthesis/__init__.py +6 -0
- wmo/scenarios/synthesis/scenario_set.py +63 -0
- wmo/scenarios/synthesis/synthesizer.py +85 -0
- wmo/scenarios/verification/__init__.py +17 -0
- wmo/scenarios/verification/judge.py +97 -0
- wmo/scenarios/verification/verify.py +135 -0
- wmo/serving/__init__.py +5 -0
- wmo/serving/builds.py +451 -0
- wmo/serving/chat.py +878 -0
- wmo/serving/endpoint_config.py +64 -0
- wmo/serving/savings.py +250 -0
- wmo/serving/server.py +553 -0
- wmo/serving/traces_source.py +206 -0
- wmo/telemetry.py +213 -0
- wmo/tracking/__init__.py +36 -0
- wmo/tracking/clock.py +24 -0
- wmo/tracking/metered.py +125 -0
- wmo/tracking/pricing.py +99 -0
- wmo/tracking/store.py +31 -0
- wmo/tracking/tracker.py +149 -0
- world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
- world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
- world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
- world_model_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""AWS Bedrock adapter (Anthropic Messages schema via InvokeModel, Titan embeddings).
|
|
2
|
+
|
|
3
|
+
Credentials come from the boto3 chain, or a named AWS profile when `Backend.profile` is set —
|
|
4
|
+
`boto3.Session(profile_name=...)` — so one waterfall chain can span multiple AWS accounts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import threading
|
|
11
|
+
from typing import TYPE_CHECKING, TypedDict, cast
|
|
12
|
+
|
|
13
|
+
from pydantic import JsonValue
|
|
14
|
+
|
|
15
|
+
from llm_waterfall.adapters.base import missing_sdk_error
|
|
16
|
+
from llm_waterfall.types import (
|
|
17
|
+
Backend,
|
|
18
|
+
ChatRequest,
|
|
19
|
+
ChatResponse,
|
|
20
|
+
Message,
|
|
21
|
+
TokenUsage,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from botocore.client import BaseClient
|
|
26
|
+
|
|
27
|
+
# Bedrock speaks the same Anthropic Messages schema as the direct API, pinned by this version tag.
|
|
28
|
+
_ANTHROPIC_BEDROCK_VERSION = "bedrock-2023-05-31"
|
|
29
|
+
|
|
30
|
+
# Default Titan text-embeddings model (v2 supports `dimensions` 256/512/1024).
|
|
31
|
+
_DEFAULT_EMBED_MODEL = "amazon.titan-embed-text-v2:0"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _ContentBlock(TypedDict):
|
|
35
|
+
type: str
|
|
36
|
+
text: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Usage(TypedDict):
|
|
40
|
+
input_tokens: int
|
|
41
|
+
output_tokens: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _MessagesResponse(TypedDict):
|
|
45
|
+
content: list[_ContentBlock]
|
|
46
|
+
usage: _Usage
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _TitanEmbedResponse(TypedDict, total=False):
|
|
50
|
+
embedding: list[float]
|
|
51
|
+
inputTextTokenCount: int
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _ConverseContentBlock(TypedDict, total=False):
|
|
55
|
+
text: str
|
|
56
|
+
toolUse: dict[str, object]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _ConverseMessage(TypedDict):
|
|
60
|
+
role: str
|
|
61
|
+
content: list[_ConverseContentBlock]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _ConverseOutput(TypedDict):
|
|
65
|
+
message: _ConverseMessage
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _ConverseUsage(TypedDict):
|
|
69
|
+
inputTokens: int
|
|
70
|
+
outputTokens: int
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _ConverseResponse(TypedDict):
|
|
74
|
+
output: _ConverseOutput
|
|
75
|
+
stopReason: str
|
|
76
|
+
usage: _ConverseUsage
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class BedrockAdapter:
|
|
80
|
+
"""Claude (and Titan embeddings) via the Bedrock Runtime."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, backend: Backend) -> None:
|
|
83
|
+
self.backend = backend
|
|
84
|
+
self._client: BaseClient | None = None
|
|
85
|
+
self._lock = threading.Lock()
|
|
86
|
+
|
|
87
|
+
def _get_client(self) -> BaseClient:
|
|
88
|
+
# Lazy + lock-guarded: boto3 is an optional extra, and boto3.Session construction is not
|
|
89
|
+
# thread-safe (the resulting client is — one Waterfall is shared across thread pools).
|
|
90
|
+
if self._client is None:
|
|
91
|
+
with self._lock:
|
|
92
|
+
if self._client is None:
|
|
93
|
+
try:
|
|
94
|
+
import boto3
|
|
95
|
+
from botocore.config import Config
|
|
96
|
+
except ModuleNotFoundError as exc:
|
|
97
|
+
raise missing_sdk_error("boto3", "bedrock") from exc
|
|
98
|
+
|
|
99
|
+
# Bound each request so a stalled connection RAISES instead of blocking
|
|
100
|
+
# forever — the waterfall can only fail over on a raised error. read_timeout
|
|
101
|
+
# is generous because reasoning models can legitimately generate for minutes;
|
|
102
|
+
# a mid-generation cutoff wastes the whole call and silently substitutes a
|
|
103
|
+
# different model into an eval.
|
|
104
|
+
#
|
|
105
|
+
# total_max_attempts=1 disables botocore's OWN retries on purpose (it counts
|
|
106
|
+
# the initial request; botocore's `max_attempts` counts retries AFTER it, so
|
|
107
|
+
# `max_attempts: 1` would still allow one hidden retry). Throttling/5xx/
|
|
108
|
+
# timeouts must surface IMMEDIATELY to the waterfall, which owns retry policy
|
|
109
|
+
# — SDK retries stack multiplicatively under the failover chain.
|
|
110
|
+
config = Config(
|
|
111
|
+
connect_timeout=self.backend.connect_timeout_s,
|
|
112
|
+
read_timeout=self.backend.read_timeout_s,
|
|
113
|
+
retries={"total_max_attempts": 1},
|
|
114
|
+
)
|
|
115
|
+
session = boto3.Session(
|
|
116
|
+
profile_name=self.backend.profile, region_name=self.backend.region
|
|
117
|
+
)
|
|
118
|
+
self._client = session.client("bedrock-runtime", config=config)
|
|
119
|
+
return self._client
|
|
120
|
+
|
|
121
|
+
def complete(
|
|
122
|
+
self,
|
|
123
|
+
system: str,
|
|
124
|
+
messages: list[Message],
|
|
125
|
+
*,
|
|
126
|
+
temperature: float | None,
|
|
127
|
+
max_tokens: int,
|
|
128
|
+
) -> tuple[str, TokenUsage]:
|
|
129
|
+
"""One InvokeModel call with the Anthropic Messages body."""
|
|
130
|
+
body: dict[str, object] = {
|
|
131
|
+
"anthropic_version": _ANTHROPIC_BEDROCK_VERSION,
|
|
132
|
+
"max_tokens": max_tokens,
|
|
133
|
+
"system": system,
|
|
134
|
+
"messages": [{"role": m.role, "content": m.content} for m in messages],
|
|
135
|
+
}
|
|
136
|
+
# Claude 4.7+ rejects sampling params; only forward temperature when explicitly set.
|
|
137
|
+
if temperature is not None:
|
|
138
|
+
body["temperature"] = temperature
|
|
139
|
+
raw = self._get_client().invoke_model(modelId=self.backend.model, body=json.dumps(body))
|
|
140
|
+
data = cast("_MessagesResponse", json.loads(raw["body"].read()))
|
|
141
|
+
text = "".join(block["text"] for block in data["content"] if block["type"] == "text")
|
|
142
|
+
usage = TokenUsage(
|
|
143
|
+
input_tokens=data["usage"]["input_tokens"],
|
|
144
|
+
output_tokens=data["usage"]["output_tokens"],
|
|
145
|
+
)
|
|
146
|
+
return text, usage
|
|
147
|
+
|
|
148
|
+
def complete_chat(self, request: ChatRequest) -> ChatResponse:
|
|
149
|
+
"""Run a structured tool-calling request through Bedrock Converse."""
|
|
150
|
+
converse_request = _converse_request(request, self.backend.model)
|
|
151
|
+
response = cast("_ConverseResponse", self._get_client().converse(**converse_request))
|
|
152
|
+
blocks = response["output"]["message"]["content"]
|
|
153
|
+
text = "".join(block["text"] for block in blocks if "text" in block)
|
|
154
|
+
tool_calls: list[dict[str, object]] = []
|
|
155
|
+
for block in blocks:
|
|
156
|
+
use = block.get("toolUse")
|
|
157
|
+
if use is None:
|
|
158
|
+
continue
|
|
159
|
+
tool_calls.append(
|
|
160
|
+
{
|
|
161
|
+
"id": str(use.get("toolUseId", "")),
|
|
162
|
+
"type": "function",
|
|
163
|
+
"function": {
|
|
164
|
+
"name": str(use.get("name", "")),
|
|
165
|
+
"arguments": json.dumps(use.get("input", {})),
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
stop_reason = response["stopReason"]
|
|
170
|
+
finish_reason = {
|
|
171
|
+
"tool_use": "tool_calls",
|
|
172
|
+
"max_tokens": "length",
|
|
173
|
+
"content_filtered": "content_filter",
|
|
174
|
+
"guardrail_intervened": "content_filter",
|
|
175
|
+
}.get(stop_reason, "stop")
|
|
176
|
+
message: dict[str, object] = {"role": "assistant", "content": text}
|
|
177
|
+
if tool_calls:
|
|
178
|
+
message["tool_calls"] = tool_calls
|
|
179
|
+
return ChatResponse.model_validate(
|
|
180
|
+
{
|
|
181
|
+
"model": self.backend.model,
|
|
182
|
+
"choices": [{"index": 0, "message": message, "finish_reason": finish_reason}],
|
|
183
|
+
"usage": {
|
|
184
|
+
"prompt_tokens": response["usage"]["inputTokens"],
|
|
185
|
+
"completion_tokens": response["usage"]["outputTokens"],
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def embed_model_id(self) -> str | None:
|
|
191
|
+
"""The model embed() resolves to — the single source of truth for embed attribution."""
|
|
192
|
+
return self.backend.embed_model or _DEFAULT_EMBED_MODEL
|
|
193
|
+
|
|
194
|
+
def embed(self, texts: list[str]) -> tuple[list[list[float]], TokenUsage]:
|
|
195
|
+
"""Embed via Amazon Titan (one InvokeModel per text — Titan has no batch input)."""
|
|
196
|
+
model = self.backend.embed_model or _DEFAULT_EMBED_MODEL
|
|
197
|
+
client = self._get_client()
|
|
198
|
+
vectors: list[list[float]] = []
|
|
199
|
+
input_tokens = 0
|
|
200
|
+
for text in texts:
|
|
201
|
+
body: dict[str, object] = {"inputText": text}
|
|
202
|
+
if self.backend.embed_dim is not None:
|
|
203
|
+
body["dimensions"] = self.backend.embed_dim
|
|
204
|
+
body["normalize"] = True
|
|
205
|
+
raw = client.invoke_model(modelId=model, body=json.dumps(body))
|
|
206
|
+
data = cast("_TitanEmbedResponse", json.loads(raw["body"].read()))
|
|
207
|
+
vectors.append(data["embedding"])
|
|
208
|
+
input_tokens += data.get("inputTextTokenCount", 0)
|
|
209
|
+
return vectors, TokenUsage(input_tokens=input_tokens)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _converse_request(request: ChatRequest, model: str) -> dict[str, object]:
|
|
213
|
+
"""Translate the structured OpenAI-compatible contract to Bedrock Converse."""
|
|
214
|
+
system: list[dict[str, str]] = []
|
|
215
|
+
messages: list[dict[str, object]] = []
|
|
216
|
+
|
|
217
|
+
def push(role: str, content: list[dict[str, object]]) -> None:
|
|
218
|
+
if messages and messages[-1]["role"] == role:
|
|
219
|
+
existing = cast("list[dict[str, object]]", messages[-1]["content"])
|
|
220
|
+
existing.extend(content)
|
|
221
|
+
else:
|
|
222
|
+
messages.append({"role": role, "content": content})
|
|
223
|
+
|
|
224
|
+
for message in request.messages:
|
|
225
|
+
if message.role in ("system", "developer"):
|
|
226
|
+
text = _chat_text(message.content)
|
|
227
|
+
if text:
|
|
228
|
+
system.append({"text": text})
|
|
229
|
+
continue
|
|
230
|
+
if message.role == "tool":
|
|
231
|
+
push(
|
|
232
|
+
"user",
|
|
233
|
+
[
|
|
234
|
+
{
|
|
235
|
+
"toolResult": {
|
|
236
|
+
"toolUseId": message.tool_call_id or "",
|
|
237
|
+
"content": [{"text": _chat_text(message.content)}],
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
],
|
|
241
|
+
)
|
|
242
|
+
continue
|
|
243
|
+
blocks: list[dict[str, object]] = []
|
|
244
|
+
text = _chat_text(message.content)
|
|
245
|
+
if text:
|
|
246
|
+
blocks.append({"text": text})
|
|
247
|
+
for tool_call in message.tool_calls or []:
|
|
248
|
+
try:
|
|
249
|
+
arguments = json.loads(tool_call.function.arguments)
|
|
250
|
+
except ValueError:
|
|
251
|
+
arguments = {}
|
|
252
|
+
blocks.append(
|
|
253
|
+
{
|
|
254
|
+
"toolUse": {
|
|
255
|
+
"toolUseId": tool_call.id,
|
|
256
|
+
"name": tool_call.function.name,
|
|
257
|
+
"input": arguments,
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
if blocks:
|
|
262
|
+
push("assistant" if message.role == "assistant" else "user", blocks)
|
|
263
|
+
|
|
264
|
+
max_tokens = request.max_tokens or request.max_completion_tokens or 4096
|
|
265
|
+
inference: dict[str, float | int] = {"maxTokens": max_tokens}
|
|
266
|
+
if request.temperature is not None:
|
|
267
|
+
inference["temperature"] = request.temperature
|
|
268
|
+
result: dict[str, object] = {
|
|
269
|
+
"modelId": model,
|
|
270
|
+
"messages": messages,
|
|
271
|
+
"inferenceConfig": inference,
|
|
272
|
+
}
|
|
273
|
+
if system:
|
|
274
|
+
result["system"] = system
|
|
275
|
+
if request.tools:
|
|
276
|
+
tools = [
|
|
277
|
+
{
|
|
278
|
+
"toolSpec": {
|
|
279
|
+
"name": tool.function.name,
|
|
280
|
+
"description": tool.function.description,
|
|
281
|
+
"inputSchema": {"json": tool.function.parameters},
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
for tool in request.tools
|
|
285
|
+
]
|
|
286
|
+
tool_config: dict[str, object] = {"tools": tools}
|
|
287
|
+
choice = request.tool_choice
|
|
288
|
+
if choice == "required":
|
|
289
|
+
tool_config["toolChoice"] = {"any": {}}
|
|
290
|
+
elif isinstance(choice, dict):
|
|
291
|
+
function = choice.get("function")
|
|
292
|
+
if isinstance(function, dict) and isinstance(function.get("name"), str):
|
|
293
|
+
tool_config["toolChoice"] = {"tool": {"name": function["name"]}}
|
|
294
|
+
if choice != "none":
|
|
295
|
+
result["toolConfig"] = tool_config
|
|
296
|
+
return result
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _chat_text(content: JsonValue) -> str:
|
|
300
|
+
"""Flatten the text-bearing forms used by OpenAI-compatible chat messages."""
|
|
301
|
+
if isinstance(content, str):
|
|
302
|
+
return content
|
|
303
|
+
if isinstance(content, list):
|
|
304
|
+
parts: list[str] = []
|
|
305
|
+
for item in content:
|
|
306
|
+
if isinstance(item, dict) and isinstance(item.get("text"), str):
|
|
307
|
+
parts.append(item["text"])
|
|
308
|
+
return "".join(parts)
|
|
309
|
+
return "" if content is None else str(content)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""OpenAI adapter (chat completions + embeddings). Reads OPENAI_API_KEY from the environment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
7
|
+
|
|
8
|
+
from llm_waterfall.adapters.base import missing_sdk_error
|
|
9
|
+
from llm_waterfall.types import (
|
|
10
|
+
Backend,
|
|
11
|
+
ChatRequest,
|
|
12
|
+
ChatResponse,
|
|
13
|
+
EmbeddingsUnsupported,
|
|
14
|
+
Message,
|
|
15
|
+
TokenUsage,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from openai import OpenAI
|
|
20
|
+
from openai.types.chat import ChatCompletionMessageParam
|
|
21
|
+
|
|
22
|
+
_DEFAULT_EMBED_MODEL = "text-embedding-3-small"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OpenAIAdapter:
|
|
26
|
+
"""GPT-5.x via chat completions; text-embedding-3-* for embeddings."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, backend: Backend) -> None:
|
|
29
|
+
self.backend = backend
|
|
30
|
+
self._client: OpenAI | None = None
|
|
31
|
+
self._lock = threading.Lock()
|
|
32
|
+
|
|
33
|
+
def _get_client(self) -> OpenAI:
|
|
34
|
+
if self._client is None:
|
|
35
|
+
with self._lock:
|
|
36
|
+
if self._client is None:
|
|
37
|
+
try:
|
|
38
|
+
import httpx
|
|
39
|
+
from openai import OpenAI
|
|
40
|
+
except ModuleNotFoundError as exc:
|
|
41
|
+
raise missing_sdk_error("openai", "openai") from exc
|
|
42
|
+
|
|
43
|
+
# max_retries=0: the waterfall owns retry policy — SDK retries would stack
|
|
44
|
+
# multiplicatively under the failover chain. Granular httpx.Timeout so a
|
|
45
|
+
# dead endpoint fails over after connect_timeout_s, not read_timeout_s.
|
|
46
|
+
self._client = OpenAI(
|
|
47
|
+
base_url=self.backend.endpoint,
|
|
48
|
+
max_retries=0,
|
|
49
|
+
timeout=httpx.Timeout(
|
|
50
|
+
self.backend.read_timeout_s,
|
|
51
|
+
connect=self.backend.connect_timeout_s,
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
return self._client
|
|
55
|
+
|
|
56
|
+
def complete(
|
|
57
|
+
self,
|
|
58
|
+
system: str,
|
|
59
|
+
messages: list[Message],
|
|
60
|
+
*,
|
|
61
|
+
temperature: float | None,
|
|
62
|
+
max_tokens: int,
|
|
63
|
+
) -> tuple[str, TokenUsage]:
|
|
64
|
+
"""One chat completion.
|
|
65
|
+
|
|
66
|
+
The backend contract selects the output-token field. Omitting a default temperature keeps
|
|
67
|
+
this compatible with GPT-5.x reasoning models, which reject non-default sampling params.
|
|
68
|
+
"""
|
|
69
|
+
wire: list[dict[str, str]] = []
|
|
70
|
+
if system:
|
|
71
|
+
wire.append({"role": "system", "content": system})
|
|
72
|
+
wire.extend({"role": m.role, "content": m.content} for m in messages)
|
|
73
|
+
api_messages = cast("list[ChatCompletionMessageParam]", wire)
|
|
74
|
+
chat = self._get_client().chat.completions
|
|
75
|
+
model = self._request_model()
|
|
76
|
+
payload: dict[str, object] = {
|
|
77
|
+
"model": model,
|
|
78
|
+
"messages": api_messages,
|
|
79
|
+
self.backend.chat_max_tokens_field: max_tokens,
|
|
80
|
+
}
|
|
81
|
+
if temperature is not None:
|
|
82
|
+
payload["temperature"] = temperature
|
|
83
|
+
response = chat.create(**cast("Any", payload))
|
|
84
|
+
if not response.choices:
|
|
85
|
+
# Content filtering (and some error modes) can return zero choices; surface it
|
|
86
|
+
# clearly rather than letting choices[0] raise a bare IndexError.
|
|
87
|
+
raise ValueError(f"{model} returned no choices")
|
|
88
|
+
text = response.choices[0].message.content or ""
|
|
89
|
+
usage = response.usage
|
|
90
|
+
token_usage = (
|
|
91
|
+
TokenUsage(input_tokens=usage.prompt_tokens, output_tokens=usage.completion_tokens)
|
|
92
|
+
if usage is not None
|
|
93
|
+
else TokenUsage()
|
|
94
|
+
)
|
|
95
|
+
return text, token_usage
|
|
96
|
+
|
|
97
|
+
def complete_chat(self, request: ChatRequest) -> ChatResponse:
|
|
98
|
+
"""Run a full OpenAI-compatible tool-calling request through this backend."""
|
|
99
|
+
payload = request.provider_payload(
|
|
100
|
+
self._request_model(), max_tokens_field=self.backend.chat_max_tokens_field
|
|
101
|
+
)
|
|
102
|
+
# The OpenAI SDK's input TypedDict is intentionally not our public contract. This one
|
|
103
|
+
# narrow cast sits at the SDK boundary after ChatRequest validated the structured core;
|
|
104
|
+
# provider_payload preserves forward-compatible extra fields emitted by agent SDKs.
|
|
105
|
+
response = self._get_client().chat.completions.create(**cast("Any", payload))
|
|
106
|
+
return ChatResponse.model_validate(response.model_dump(mode="json"))
|
|
107
|
+
|
|
108
|
+
def _request_model(self) -> str:
|
|
109
|
+
"""The id sent as `model` on the wire (Azure overrides this with the deployment)."""
|
|
110
|
+
return self.backend.model
|
|
111
|
+
|
|
112
|
+
def embed_model_id(self) -> str | None:
|
|
113
|
+
"""The model embed() resolves to — the single source of truth for embed attribution."""
|
|
114
|
+
return self.backend.embed_model or _DEFAULT_EMBED_MODEL
|
|
115
|
+
|
|
116
|
+
def embed(self, texts: list[str]) -> tuple[list[list[float]], TokenUsage]:
|
|
117
|
+
"""Embed against `embed_model_id()` (OpenAI default: text-embedding-3-small)."""
|
|
118
|
+
model = self.embed_model_id()
|
|
119
|
+
if model is None: # pragma: no cover - only reachable via subclasses
|
|
120
|
+
raise EmbeddingsUnsupported("no embedding deployment configured for this backend")
|
|
121
|
+
embeddings = self._get_client().embeddings
|
|
122
|
+
if self.backend.embed_dim is None:
|
|
123
|
+
response = embeddings.create(model=model, input=texts)
|
|
124
|
+
else:
|
|
125
|
+
response = embeddings.create(
|
|
126
|
+
model=model, input=texts, dimensions=self.backend.embed_dim
|
|
127
|
+
)
|
|
128
|
+
usage = getattr(response, "usage", None)
|
|
129
|
+
input_tokens = getattr(usage, "prompt_tokens", 0) if usage is not None else 0
|
|
130
|
+
return [item.embedding for item in response.data], TokenUsage(input_tokens=input_tokens)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Capacity-vs-client error classification — the contract that decides failover vs propagate.
|
|
2
|
+
|
|
3
|
+
Capacity errors (throttling, transient 5xx, model-not-ready, transport timeouts) mean "this backend
|
|
4
|
+
can't serve right now; the next one might" — the waterfall spills. Client errors (bad request,
|
|
5
|
+
auth, validation) mean "this request is wrong" — failing over would just mask a real bug behind a
|
|
6
|
+
different model's answer, so they propagate immediately.
|
|
7
|
+
|
|
8
|
+
Classification is pure duck-typing with zero SDK imports, so the package works with any subset of
|
|
9
|
+
provider SDKs installed and the logic is testable with fake exceptions. Signals are checked in
|
|
10
|
+
fidelity order: a structured error code always wins over anything derived from the message.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
from llm_waterfall.types import WaterfallExhausted
|
|
18
|
+
|
|
19
|
+
Outcome = Literal["capacity_error", "client_error"]
|
|
20
|
+
|
|
21
|
+
# Botocore error CODES that mean "this model is capacity-constrained right now" — the reliable
|
|
22
|
+
# signal (from `exc.response["Error"]["Code"]`), preferred over everything else.
|
|
23
|
+
_CAPACITY_ERROR_CODES = frozenset(
|
|
24
|
+
{
|
|
25
|
+
"ThrottlingException",
|
|
26
|
+
"TooManyRequestsException",
|
|
27
|
+
"ServiceUnavailableException",
|
|
28
|
+
"ServiceQuotaExceededException",
|
|
29
|
+
"ModelNotReadyException",
|
|
30
|
+
"ModelTimeoutException",
|
|
31
|
+
"InternalServerException", # transient 5xx, safe to fail over
|
|
32
|
+
"InternalFailure", # botocore's generic 5xx code (killed a live full-slice eval run)
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Exception class NAMES from the OpenAI/Anthropic SDKs (and their httpx/httpcore transport) that
|
|
37
|
+
# mean capacity/transport failure. Matched by name + module gate rather than isinstance so the
|
|
38
|
+
# SDKs never need to be importable.
|
|
39
|
+
_SDK_CAPACITY_TYPE_NAMES = frozenset(
|
|
40
|
+
{
|
|
41
|
+
"RateLimitError",
|
|
42
|
+
"APITimeoutError",
|
|
43
|
+
"APIConnectionError",
|
|
44
|
+
"InternalServerError",
|
|
45
|
+
"OverloadedError", # anthropic 529
|
|
46
|
+
# httpx/httpcore transient transport failures (no status code on any of them):
|
|
47
|
+
"ConnectTimeout",
|
|
48
|
+
"ReadTimeout",
|
|
49
|
+
"WriteTimeout",
|
|
50
|
+
"PoolTimeout",
|
|
51
|
+
"ConnectError",
|
|
52
|
+
"ReadError",
|
|
53
|
+
"WriteError",
|
|
54
|
+
"RemoteProtocolError", # server disconnected mid-response
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Top-level modules whose exceptions we trust for name/status-code classification. An exception
|
|
59
|
+
# named `RateLimitError` from arbitrary application code proves nothing. tinker's exceptions
|
|
60
|
+
# mirror openai's shape (same capacity type names; `status_code` on its APIStatusError family).
|
|
61
|
+
_TRUSTED_SDK_MODULES = frozenset({"openai", "anthropic", "httpx", "httpcore", "tinker"})
|
|
62
|
+
|
|
63
|
+
# HTTP statuses on a trusted SDK error that mean capacity/transient failure. 529 is Anthropic's
|
|
64
|
+
# "overloaded". Everything else (400/401/403/404/422/...) is a client error.
|
|
65
|
+
_CAPACITY_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504, 529})
|
|
66
|
+
|
|
67
|
+
# Last-resort substrings for structureless transport errors (e.g. botocore's Read/ConnectTimeout,
|
|
68
|
+
# raw ConnectionError), which carry no code, no trusted type, no status. Kept conservative: only
|
|
69
|
+
# phrases that unambiguously mean capacity/transport failure, NOT generic tokens like "429"/"503"/
|
|
70
|
+
# "capacity" that can appear inside a bad-request message and turn a real error into a silent
|
|
71
|
+
# failover. (That was a real bug once: `ValidationException: request timeout too large`.)
|
|
72
|
+
_TRANSPORT_MARKERS = (
|
|
73
|
+
"throttl",
|
|
74
|
+
"read timeout",
|
|
75
|
+
"connect timeout",
|
|
76
|
+
"connection reset",
|
|
77
|
+
"connection aborted",
|
|
78
|
+
"connection was closed", # botocore ConnectionClosedError (dropped mid-response)
|
|
79
|
+
"could not connect to the endpoint", # botocore EndpointConnectionError (DNS/unreachable)
|
|
80
|
+
"timed out",
|
|
81
|
+
"service unavailable",
|
|
82
|
+
"model not ready",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _from_trusted_sdk(exc: Exception) -> bool:
|
|
87
|
+
"""Whether `exc` was defined by a provider SDK or its HTTP transport."""
|
|
88
|
+
module = type(exc).__module__ or ""
|
|
89
|
+
return module.split(".")[0] in _TRUSTED_SDK_MODULES
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Botocore transport-failure base classes, matched anywhere in the exception's MRO by
|
|
93
|
+
# (module, name) so subclasses we have never seen still classify without importing botocore.
|
|
94
|
+
# Message matching alone lost this game twice: ConnectionClosedError and EndpointConnectionError
|
|
95
|
+
# each killed a multi-dollar run before their phrasing joined _TRANSPORT_MARKERS.
|
|
96
|
+
_BOTOCORE_TRANSPORT_BASES = frozenset({"ConnectionError", "HTTPClientError"})
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _is_botocore_transport_error(exc: Exception) -> bool:
|
|
100
|
+
"""Whether `exc` is any member of botocore's ConnectionError/HTTPClientError family."""
|
|
101
|
+
for klass in type(exc).__mro__:
|
|
102
|
+
module = getattr(klass, "__module__", "") or ""
|
|
103
|
+
if module.split(".")[0] == "botocore" and klass.__name__ in _BOTOCORE_TRANSPORT_BASES:
|
|
104
|
+
return True
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# tinker's sidecar family (the SDK's local transport subprocess dying, failing to start, or
|
|
109
|
+
# losing IPC mid-request), matched anywhere in the MRO by (module, name) like the botocore
|
|
110
|
+
# transport bases above: these carry no status_code, no openai-shaped capacity name, and no
|
|
111
|
+
# reliable message phrasing, but a fresh attempt (which respawns the sidecar) can succeed.
|
|
112
|
+
_TINKER_TRANSPORT_BASES = frozenset({"SidecarError"})
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _is_tinker_transport_error(exc: Exception) -> bool:
|
|
116
|
+
"""Whether `exc` is any member of tinker's SidecarError family."""
|
|
117
|
+
for klass in type(exc).__mro__:
|
|
118
|
+
module = getattr(klass, "__module__", "") or ""
|
|
119
|
+
if module.split(".")[0] == "tinker" and klass.__name__ in _TINKER_TRANSPORT_BASES:
|
|
120
|
+
return True
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def outcome_for(exc: Exception) -> Outcome:
|
|
125
|
+
"""Classify an exception raised by a backend attempt.
|
|
126
|
+
|
|
127
|
+
Checks, in fidelity order:
|
|
128
|
+
1. Our own `WaterfallExhausted` — a nested chain that exhausted was capacity-constrained
|
|
129
|
+
by definition (outer waterfalls/retry loops must treat it as transient).
|
|
130
|
+
2. Structured botocore error code — authoritative; a non-capacity code stops here so a
|
|
131
|
+
message substring can never overrule it.
|
|
132
|
+
3. Botocore's ConnectionError/HTTPClientError family, matched by MRO (module, name) —
|
|
133
|
+
transport failures whose subclasses carry no code and keep inventing new phrasings.
|
|
134
|
+
4. Tinker's non-HTTP failure families, same MRO matching: the SidecarError family is
|
|
135
|
+
transport-shaped (retry can respawn the sidecar), and RequestFailedError classifies
|
|
136
|
+
by its `category` field (a "user" request is wrong; "server"/"unknown" are transient).
|
|
137
|
+
5. SDK exception type name, gated on the defining module.
|
|
138
|
+
6. HTTP status, from a `status_code` attribute on the exception or on its `.response`
|
|
139
|
+
(httpx.HTTPStatusError carries it there), same module gate.
|
|
140
|
+
7. Conservative transport-phrase substrings for structureless errors.
|
|
141
|
+
"""
|
|
142
|
+
if isinstance(exc, WaterfallExhausted):
|
|
143
|
+
return "capacity_error"
|
|
144
|
+
|
|
145
|
+
response = getattr(exc, "response", None)
|
|
146
|
+
if isinstance(response, dict):
|
|
147
|
+
error = response.get("Error")
|
|
148
|
+
# Guard the shape: a duck-typed `.response` dict may carry a non-dict "Error" value;
|
|
149
|
+
# classification must never raise over the original exception.
|
|
150
|
+
code = error.get("Code") if isinstance(error, dict) else None
|
|
151
|
+
if code is not None:
|
|
152
|
+
return "capacity_error" if code in _CAPACITY_ERROR_CODES else "client_error"
|
|
153
|
+
|
|
154
|
+
if _is_botocore_transport_error(exc):
|
|
155
|
+
return "capacity_error"
|
|
156
|
+
|
|
157
|
+
if _is_tinker_transport_error(exc):
|
|
158
|
+
return "capacity_error"
|
|
159
|
+
|
|
160
|
+
if _from_trusted_sdk(exc):
|
|
161
|
+
if type(exc).__name__ in _SDK_CAPACITY_TYPE_NAMES:
|
|
162
|
+
return "capacity_error"
|
|
163
|
+
if type(exc).__name__ == "RequestFailedError":
|
|
164
|
+
# tinker's async-request failure carries a StrEnum `category` instead of a
|
|
165
|
+
# status code: "user" means this request is wrong (propagate); "server" and
|
|
166
|
+
# "unknown" mean the service failed it and a fresh attempt can succeed.
|
|
167
|
+
category = str(getattr(exc, "category", "")).lower()
|
|
168
|
+
return "client_error" if category == "user" else "capacity_error"
|
|
169
|
+
status = getattr(exc, "status_code", None)
|
|
170
|
+
if not isinstance(status, int):
|
|
171
|
+
# httpx.HTTPStatusError keeps the status on the response object instead.
|
|
172
|
+
status = getattr(response, "status_code", None)
|
|
173
|
+
if isinstance(status, int):
|
|
174
|
+
return "capacity_error" if status in _CAPACITY_STATUS_CODES else "client_error"
|
|
175
|
+
|
|
176
|
+
message = str(exc).lower()
|
|
177
|
+
if any(marker in message for marker in _TRANSPORT_MARKERS):
|
|
178
|
+
return "capacity_error"
|
|
179
|
+
return "client_error"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def is_capacity_error(exc: Exception) -> bool:
|
|
183
|
+
"""True when the waterfall should spill to the next backend instead of raising."""
|
|
184
|
+
return outcome_for(exc) == "capacity_error"
|