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.
Files changed (308) hide show
  1. llm_waterfall/LICENSE +21 -0
  2. llm_waterfall/__init__.py +53 -0
  3. llm_waterfall/adapters/__init__.py +36 -0
  4. llm_waterfall/adapters/anthropic.py +105 -0
  5. llm_waterfall/adapters/aws_mantle.py +47 -0
  6. llm_waterfall/adapters/azure_openai.py +71 -0
  7. llm_waterfall/adapters/base.py +51 -0
  8. llm_waterfall/adapters/bedrock.py +309 -0
  9. llm_waterfall/adapters/openai.py +130 -0
  10. llm_waterfall/classify.py +184 -0
  11. llm_waterfall/pricing.py +110 -0
  12. llm_waterfall/py.typed +0 -0
  13. llm_waterfall/types.py +295 -0
  14. llm_waterfall/waterfall.py +255 -0
  15. wmo/__init__.py +38 -0
  16. wmo/agents/__init__.py +7 -0
  17. wmo/agents/default.py +29 -0
  18. wmo/agents/meta.py +55 -0
  19. wmo/agents/optimizer.py +55 -0
  20. wmo/agents/project.py +928 -0
  21. wmo/cli/__init__.py +5 -0
  22. wmo/cli/agent_session.py +1123 -0
  23. wmo/cli/app.py +2489 -0
  24. wmo/cli/e2b_cmds.py +212 -0
  25. wmo/cli/eval_closed_loop.py +207 -0
  26. wmo/cli/harness_app.py +1147 -0
  27. wmo/cli/harness_distill.py +659 -0
  28. wmo/cli/hosted_session.py +880 -0
  29. wmo/cli/ingest_cmd.py +165 -0
  30. wmo/cli/model_roles.py +82 -0
  31. wmo/cli/platform_cmds.py +372 -0
  32. wmo/cli/route_app.py +274 -0
  33. wmo/cli/session_state.py +243 -0
  34. wmo/cli/ui.py +1107 -0
  35. wmo/cli/workspace_sync.py +504 -0
  36. wmo/config/__init__.py +60 -0
  37. wmo/config/card.py +129 -0
  38. wmo/config/config.py +367 -0
  39. wmo/config/dotenv.py +67 -0
  40. wmo/config/settings.py +128 -0
  41. wmo/config/store.py +177 -0
  42. wmo/conftest.py +19 -0
  43. wmo/connect/__init__.py +88 -0
  44. wmo/connect/apps.py +78 -0
  45. wmo/connect/brave.py +284 -0
  46. wmo/connect/connector.py +79 -0
  47. wmo/connect/credentials.py +164 -0
  48. wmo/connect/github.py +321 -0
  49. wmo/connect/google.py +627 -0
  50. wmo/connect/notion.py +790 -0
  51. wmo/connect/oauth.py +461 -0
  52. wmo/connect/slack.py +555 -0
  53. wmo/connect/store.py +199 -0
  54. wmo/connect/types.py +156 -0
  55. wmo/core/__init__.py +21 -0
  56. wmo/core/parsing.py +281 -0
  57. wmo/core/render.py +271 -0
  58. wmo/core/text.py +40 -0
  59. wmo/core/types.py +116 -0
  60. wmo/distill/__init__.py +14 -0
  61. wmo/distill/agents.py +140 -0
  62. wmo/distill/config.py +1006 -0
  63. wmo/distill/cost.py +437 -0
  64. wmo/distill/data.py +921 -0
  65. wmo/distill/deadlines.py +254 -0
  66. wmo/distill/fake_tinker.py +734 -0
  67. wmo/distill/gate.py +122 -0
  68. wmo/distill/loop.py +3499 -0
  69. wmo/distill/renderers.py +399 -0
  70. wmo/distill/rendering.py +620 -0
  71. wmo/distill/rollouts.py +726 -0
  72. wmo/distill/samples.py +195 -0
  73. wmo/distill/store.py +829 -0
  74. wmo/distill/teacher.py +714 -0
  75. wmo/distill/tokens.py +535 -0
  76. wmo/distill/tracking.py +552 -0
  77. wmo/distill/tripwire.py +411 -0
  78. wmo/distill/xtoken/byte_offsets.py +152 -0
  79. wmo/distill/xtoken/chunks.py +457 -0
  80. wmo/distill/xtoken/prompt_logprobs.py +475 -0
  81. wmo/distill/xtoken/teacher_render.py +346 -0
  82. wmo/engine/__init__.py +28 -0
  83. wmo/engine/autoconfig.py +367 -0
  84. wmo/engine/build.py +346 -0
  85. wmo/engine/demo.py +77 -0
  86. wmo/engine/eval_suites.py +245 -0
  87. wmo/engine/grounding.py +491 -0
  88. wmo/engine/knowledge.py +291 -0
  89. wmo/engine/loader.py +36 -0
  90. wmo/engine/play.py +92 -0
  91. wmo/engine/prompts.py +99 -0
  92. wmo/engine/replay.py +443 -0
  93. wmo/engine/reporting.py +58 -0
  94. wmo/engine/workspace.py +468 -0
  95. wmo/engine/world_model.py +568 -0
  96. wmo/env/__init__.py +22 -0
  97. wmo/env/base.py +121 -0
  98. wmo/env/closed_loop.py +229 -0
  99. wmo/env/episode.py +107 -0
  100. wmo/env/llm_agent.py +93 -0
  101. wmo/env/scenarios.py +73 -0
  102. wmo/evals/__init__.py +52 -0
  103. wmo/evals/agreement.py +110 -0
  104. wmo/evals/base.py +45 -0
  105. wmo/evals/closed_loop.py +480 -0
  106. wmo/evals/failover.py +96 -0
  107. wmo/evals/gold.py +127 -0
  108. wmo/evals/grid.py +394 -0
  109. wmo/evals/grid_plot.py +205 -0
  110. wmo/evals/harbor/__init__.py +27 -0
  111. wmo/evals/harbor/agent.py +573 -0
  112. wmo/evals/harbor/ctrf.py +171 -0
  113. wmo/evals/harbor/e2b_environment.py +587 -0
  114. wmo/evals/harbor/e2b_template_policy.py +144 -0
  115. wmo/evals/harbor/scorer.py +875 -0
  116. wmo/evals/harbor/tasks.py +140 -0
  117. wmo/evals/open_loop.py +194 -0
  118. wmo/evals/tasks.py +53 -0
  119. wmo/harness/__init__.py +51 -0
  120. wmo/harness/code_runtime.py +288 -0
  121. wmo/harness/create.py +1191 -0
  122. wmo/harness/delta.py +220 -0
  123. wmo/harness/doc.py +556 -0
  124. wmo/harness/e2b_ledger.py +342 -0
  125. wmo/harness/e2b_reap.py +476 -0
  126. wmo/harness/e2b_sandbox.py +350 -0
  127. wmo/harness/environment.py +35 -0
  128. wmo/harness/live_session.py +543 -0
  129. wmo/harness/mutate.py +343 -0
  130. wmo/harness/pi_e2b.py +1710 -0
  131. wmo/harness/pi_entry/entry.ts +268 -0
  132. wmo/harness/pi_entry/runner_frames.ts +92 -0
  133. wmo/harness/pi_entry/runner_live.ts +587 -0
  134. wmo/harness/pi_entry/runner_service.ts +270 -0
  135. wmo/harness/pi_entry/runner_stdio.ts +374 -0
  136. wmo/harness/pi_entry/runner_termination.ts +142 -0
  137. wmo/harness/pi_local.py +262 -0
  138. wmo/harness/pi_runtime.py +495 -0
  139. wmo/harness/pi_vendor.py +65 -0
  140. wmo/harness/population.py +509 -0
  141. wmo/harness/project_proposer.py +569 -0
  142. wmo/harness/proposer.py +977 -0
  143. wmo/harness/runner_link.py +619 -0
  144. wmo/harness/runtime.py +389 -0
  145. wmo/harness/scoring.py +247 -0
  146. wmo/harness/skills.py +116 -0
  147. wmo/harness/source_tree.py +319 -0
  148. wmo/harness/store.py +176 -0
  149. wmo/harness/tools.py +105 -0
  150. wmo/harness/vendor/manifest.sha256 +58 -0
  151. wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
  152. wmo/harness/vendor/pi-agent/LICENSE +21 -0
  153. wmo/harness/vendor/pi-agent/README.md +488 -0
  154. wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
  155. wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
  156. wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
  157. wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
  158. wmo/harness/vendor/pi-agent/docs/models.md +966 -0
  159. wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
  160. wmo/harness/vendor/pi-agent/package.json +60 -0
  161. wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
  162. wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
  163. wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
  164. wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
  165. wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
  166. wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
  167. wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
  168. wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
  169. wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
  170. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
  171. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
  172. wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
  173. wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
  174. wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
  175. wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
  176. wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
  177. wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
  178. wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
  179. wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
  180. wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
  181. wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
  182. wmo/harness/vendor/pi-agent/src/index.ts +44 -0
  183. wmo/harness/vendor/pi-agent/src/node.ts +2 -0
  184. wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
  185. wmo/harness/vendor/pi-agent/src/types.ts +428 -0
  186. wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
  187. wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
  188. wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
  189. wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
  190. wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
  191. wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
  192. wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
  193. wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
  194. wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
  195. wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
  196. wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
  197. wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
  198. wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
  199. wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
  200. wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
  201. wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
  202. wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
  203. wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
  204. wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
  205. wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
  206. wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
  207. wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
  208. wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
  209. wmo/harness/vendor/vendor_pi.sh +59 -0
  210. wmo/harness/workspace_patch.py +270 -0
  211. wmo/ingest/__init__.py +47 -0
  212. wmo/ingest/adapter.py +72 -0
  213. wmo/ingest/base.py +114 -0
  214. wmo/ingest/braintrust.py +339 -0
  215. wmo/ingest/detect.py +126 -0
  216. wmo/ingest/langfuse.py +291 -0
  217. wmo/ingest/langsmith.py +444 -0
  218. wmo/ingest/mastra.py +330 -0
  219. wmo/ingest/messages.py +170 -0
  220. wmo/ingest/normalize.py +679 -0
  221. wmo/ingest/otel_genai.py +69 -0
  222. wmo/ingest/otel_writer.py +100 -0
  223. wmo/ingest/phoenix.py +150 -0
  224. wmo/ingest/postgres.py +246 -0
  225. wmo/ingest/posthog.py +320 -0
  226. wmo/ingest/quality.py +28 -0
  227. wmo/ingest/stream.py +209 -0
  228. wmo/ingest/testdata/sample_otlp.json +60 -0
  229. wmo/ingest/testdata/sample_spans.jsonl +3 -0
  230. wmo/optimize/__init__.py +25 -0
  231. wmo/optimize/base.py +143 -0
  232. wmo/optimize/gepa.py +806 -0
  233. wmo/optimize/judge.py +262 -0
  234. wmo/optimize/judge_quality.py +359 -0
  235. wmo/optimize/knn.py +468 -0
  236. wmo/optimize/numeric.py +152 -0
  237. wmo/optimize/outcomes.py +103 -0
  238. wmo/optimize/policy.py +669 -0
  239. wmo/optimize/report.py +231 -0
  240. wmo/optimize/reward.py +129 -0
  241. wmo/optimize/routing.py +373 -0
  242. wmo/platform/__init__.py +6 -0
  243. wmo/platform/auth.py +115 -0
  244. wmo/platform/client.py +551 -0
  245. wmo/platform/credentials.py +126 -0
  246. wmo/platform/transfer.py +158 -0
  247. wmo/providers/__init__.py +40 -0
  248. wmo/providers/_bedrock_chat.py +155 -0
  249. wmo/providers/_openai_common.py +182 -0
  250. wmo/providers/_responses_common.py +472 -0
  251. wmo/providers/anthropic.py +134 -0
  252. wmo/providers/azure_openai.py +296 -0
  253. wmo/providers/base.py +300 -0
  254. wmo/providers/bedrock.py +312 -0
  255. wmo/providers/models.py +205 -0
  256. wmo/providers/openai.py +143 -0
  257. wmo/providers/openai_responses.py +240 -0
  258. wmo/providers/pool.py +170 -0
  259. wmo/providers/registry.py +73 -0
  260. wmo/providers/retry.py +151 -0
  261. wmo/providers/tinker.py +936 -0
  262. wmo/providers/waterfall.py +336 -0
  263. wmo/research/__init__.py +81 -0
  264. wmo/research/ablation.py +133 -0
  265. wmo/research/concurrency_plot.py +523 -0
  266. wmo/research/concurrency_run.py +240 -0
  267. wmo/research/concurrency_scaling.py +270 -0
  268. wmo/research/gepa_scaling.py +274 -0
  269. wmo/research/pipeline.py +198 -0
  270. wmo/research/scaling_split.py +82 -0
  271. wmo/research/scenario_fidelity.py +198 -0
  272. wmo/research/scenario_recovery.py +92 -0
  273. wmo/research/seed_stability.py +90 -0
  274. wmo/research/trace_scaling.py +348 -0
  275. wmo/retrieval/__init__.py +6 -0
  276. wmo/retrieval/embedders.py +105 -0
  277. wmo/retrieval/leakfree.py +52 -0
  278. wmo/retrieval/retriever.py +173 -0
  279. wmo/scenarios/__init__.py +58 -0
  280. wmo/scenarios/builder.py +152 -0
  281. wmo/scenarios/mining/__init__.py +27 -0
  282. wmo/scenarios/mining/clustering.py +171 -0
  283. wmo/scenarios/mining/facets.py +226 -0
  284. wmo/scenarios/mining/selection.py +220 -0
  285. wmo/scenarios/synthesis/__init__.py +6 -0
  286. wmo/scenarios/synthesis/scenario_set.py +63 -0
  287. wmo/scenarios/synthesis/synthesizer.py +85 -0
  288. wmo/scenarios/verification/__init__.py +17 -0
  289. wmo/scenarios/verification/judge.py +97 -0
  290. wmo/scenarios/verification/verify.py +135 -0
  291. wmo/serving/__init__.py +5 -0
  292. wmo/serving/builds.py +451 -0
  293. wmo/serving/chat.py +878 -0
  294. wmo/serving/endpoint_config.py +64 -0
  295. wmo/serving/savings.py +250 -0
  296. wmo/serving/server.py +553 -0
  297. wmo/serving/traces_source.py +206 -0
  298. wmo/telemetry.py +213 -0
  299. wmo/tracking/__init__.py +36 -0
  300. wmo/tracking/clock.py +24 -0
  301. wmo/tracking/metered.py +125 -0
  302. wmo/tracking/pricing.py +99 -0
  303. wmo/tracking/store.py +31 -0
  304. wmo/tracking/tracker.py +149 -0
  305. world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
  306. world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
  307. world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
  308. 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"