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,158 @@
1
+ """Deterministic model-bundle packing and safe unpacking for push/pull.
2
+
3
+ A pushed bundle is byte-compatible with the bundles the platform's own build
4
+ pipeline produces: a gzipped tarball of the model directory with
5
+ archive-relative member paths. Packing is an include-list — the model's
6
+ `config.toml`, `metrics.json`, `card.json`, `prompts/`, and `index/` — so
7
+ local `runs/` cost records and raw `traces/` (customer data) never leave the
8
+ machine.
9
+
10
+ Bundles can reach the platform's 1GB cap, so packing and unpacking are
11
+ file-based: bytes stream between disk and the network without ever being held
12
+ in memory whole.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import json
19
+ import shutil
20
+ import tarfile
21
+ import tomllib
22
+ import uuid
23
+ from pathlib import Path
24
+
25
+ from pydantic import BaseModel
26
+
27
+ from wmo.config.config import HarnessConfig
28
+ from wmo.core.types import JsonValue
29
+
30
+ _INCLUDED_FILES = ("config.toml", "metrics.json", "card.json")
31
+ _INCLUDED_DIRS = ("prompts", "index")
32
+
33
+ _HASH_CHUNK_BYTES = 1024 * 1024
34
+
35
+
36
+ class PackedModelBundle(BaseModel):
37
+ """A packed model bundle on disk, ready for upload."""
38
+
39
+ path: Path
40
+ sha256: str
41
+ byte_size: int
42
+
43
+
44
+ class BundleFormatError(ValueError):
45
+ """The directory or bytes are not a valid world-model bundle."""
46
+
47
+
48
+ def sha256_file(path: Path) -> str:
49
+ """Digest a file's contents without loading it whole."""
50
+ digest = hashlib.sha256()
51
+ with path.open("rb") as fh:
52
+ while chunk := fh.read(_HASH_CHUNK_BYTES):
53
+ digest.update(chunk)
54
+ return digest.hexdigest()
55
+
56
+
57
+ def pack_model_dir(directory: Path, dest: Path) -> PackedModelBundle:
58
+ """Pack a model directory into the platform's bundle format at ``dest``.
59
+
60
+ Args:
61
+ directory: A built model directory (must contain `config.toml`).
62
+ dest: Where to write the gzipped tarball (parent must exist).
63
+
64
+ Returns:
65
+ The bundle file plus integrity metadata; member order is sorted so
66
+ identical inputs produce identical archives.
67
+
68
+ Raises:
69
+ BundleFormatError: If the directory is missing or has no config.toml.
70
+ """
71
+ if not directory.is_dir():
72
+ msg = f"model directory does not exist: {directory}"
73
+ raise BundleFormatError(msg)
74
+ if not (directory / "config.toml").is_file():
75
+ msg = f"{directory} has no config.toml; only built world models can be pushed"
76
+ raise BundleFormatError(msg)
77
+
78
+ members: list[Path] = []
79
+ for name in _INCLUDED_FILES:
80
+ path = directory / name
81
+ if path.is_file():
82
+ members.append(path)
83
+ for name in _INCLUDED_DIRS:
84
+ root = directory / name
85
+ if root.is_dir():
86
+ members.extend(sorted(path for path in root.rglob("*")))
87
+ members.append(root)
88
+
89
+ with tarfile.open(dest, mode="w:gz") as tar:
90
+ for path in sorted(set(members)):
91
+ tar.add(path, arcname=str(path.relative_to(directory)), recursive=False)
92
+ return PackedModelBundle(
93
+ path=dest,
94
+ sha256=sha256_file(dest),
95
+ byte_size=dest.stat().st_size,
96
+ )
97
+
98
+
99
+ def unpack_model_bundle(source: Path, dest_dir: Path, *, force: bool = False) -> None:
100
+ """Unpack a pulled bundle file into a local model directory.
101
+
102
+ Extraction happens in a temporary sibling renamed into place, so a crashed
103
+ unpack never leaves a half-written model that later loads as real.
104
+
105
+ Args:
106
+ source: Downloaded gzipped tarball.
107
+ dest_dir: Target model directory (`.wmo/models/<name>`).
108
+ force: Replace an existing directory instead of refusing.
109
+
110
+ Raises:
111
+ BundleFormatError: If the file is not a readable bundle or a member
112
+ would escape the destination.
113
+ FileExistsError: If ``dest_dir`` exists and ``force`` is not set.
114
+ """
115
+ if dest_dir.exists() and not force:
116
+ msg = f"{dest_dir} already exists; pass --force to replace it"
117
+ raise FileExistsError(msg)
118
+ staging_dir = dest_dir.with_name(f"{dest_dir.name}.pull-{uuid.uuid4().hex}")
119
+ staging_dir.mkdir(parents=True)
120
+ try:
121
+ with tarfile.open(source, mode="r:gz") as tar:
122
+ # The "data" filter rejects absolute paths, traversal, and special
123
+ # members instead of writing them.
124
+ tar.extractall(staging_dir, filter="data")
125
+ except (tarfile.TarError, OSError) as error:
126
+ shutil.rmtree(staging_dir, ignore_errors=True)
127
+ msg = f"bundle could not be unpacked: {error}"
128
+ raise BundleFormatError(msg) from error
129
+ if dest_dir.exists():
130
+ shutil.rmtree(dest_dir, ignore_errors=True)
131
+ staging_dir.rename(dest_dir)
132
+
133
+
134
+ def extract_push_meta(directory: Path) -> dict[str, JsonValue]:
135
+ """Derive the push metadata the platform stores alongside a bundle.
136
+
137
+ Parses the model's own `config.toml` (and `metrics.json` when present)
138
+ through wmo's typed config so the platform never reads files out of the
139
+ tarball.
140
+ """
141
+ config = HarnessConfig.model_validate(
142
+ tomllib.loads((directory / "config.toml").read_text(encoding="utf-8"))
143
+ )
144
+ meta: dict[str, JsonValue] = {
145
+ "serve_provider": config.serve_provider.value,
146
+ "embed_provider": config.embed_provider.value,
147
+ "embed_dim": config.embed_dim,
148
+ "gepa_budget": config.gepa_budget,
149
+ }
150
+ try:
151
+ meta["serve_model"] = config.serve_provider_config().model
152
+ except ValueError:
153
+ # No provider block for the serve provider; the platform column stays unset.
154
+ pass
155
+ metrics_path = directory / "metrics.json"
156
+ if metrics_path.is_file():
157
+ meta["metrics"] = json.loads(metrics_path.read_text(encoding="utf-8"))
158
+ return meta
@@ -0,0 +1,40 @@
1
+ """Unified LLM provider layer.
2
+
3
+ One interface (`Provider`), multiple backends, one entry point (`get_provider` — or
4
+ `provider_or_chain`, which upgrades to the local `.wmo/fallback.toml` failover chain when present).
5
+ All can be verified on startup with a cheap ping. Built fresh for this repo; no external client
6
+ framework.
7
+ """
8
+
9
+ from wmo.providers.base import (
10
+ DEFAULT_MAX_TOKENS,
11
+ Completion,
12
+ EmbedderKind,
13
+ Message,
14
+ Provider,
15
+ ProviderConfig,
16
+ ProviderKind,
17
+ VerifyResult,
18
+ )
19
+ from wmo.providers.models import ProviderModel, model_types_for_provider, resolve_provider_model
20
+ from wmo.providers.registry import get_provider, verify_all, verify_embedder
21
+ from wmo.providers.waterfall import WaterfallProvider, provider_or_chain
22
+
23
+ __all__ = [
24
+ "Provider",
25
+ "ProviderConfig",
26
+ "ProviderKind",
27
+ "ProviderModel",
28
+ "EmbedderKind",
29
+ "DEFAULT_MAX_TOKENS",
30
+ "Completion",
31
+ "Message",
32
+ "VerifyResult",
33
+ "get_provider",
34
+ "provider_or_chain",
35
+ "WaterfallProvider",
36
+ "verify_all",
37
+ "verify_embedder",
38
+ "model_types_for_provider",
39
+ "resolve_provider_model",
40
+ ]
@@ -0,0 +1,155 @@
1
+ """Structured tool-calling translation for Bedrock Converse providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import cast
7
+
8
+ from llm_waterfall import ChatRequest, ChatResponse
9
+ from pydantic import JsonValue
10
+
11
+
12
+ def converse_request(request: ChatRequest, model: str) -> dict[str, object]:
13
+ """Translate the provider-neutral structured contract to Bedrock Converse."""
14
+ system: list[dict[str, str]] = []
15
+ messages: list[dict[str, object]] = []
16
+
17
+ def push(role: str, content: list[dict[str, object]]) -> None:
18
+ if messages and messages[-1]["role"] == role:
19
+ existing = cast("list[dict[str, object]]", messages[-1]["content"])
20
+ existing.extend(content)
21
+ else:
22
+ messages.append({"role": role, "content": content})
23
+
24
+ for message in request.messages:
25
+ if message.role in ("system", "developer"):
26
+ text = _chat_text(message.content)
27
+ if text:
28
+ system.append({"text": text})
29
+ continue
30
+ if message.role == "tool":
31
+ push(
32
+ "user",
33
+ [
34
+ {
35
+ "toolResult": {
36
+ "toolUseId": message.tool_call_id or "",
37
+ "content": [{"text": _chat_text(message.content)}],
38
+ }
39
+ }
40
+ ],
41
+ )
42
+ continue
43
+ blocks: list[dict[str, object]] = []
44
+ text = _chat_text(message.content)
45
+ if text:
46
+ blocks.append({"text": text})
47
+ for tool_call in message.tool_calls or []:
48
+ try:
49
+ arguments = json.loads(tool_call.function.arguments)
50
+ except ValueError:
51
+ arguments = {}
52
+ blocks.append(
53
+ {
54
+ "toolUse": {
55
+ "toolUseId": tool_call.id,
56
+ "name": tool_call.function.name,
57
+ "input": arguments,
58
+ }
59
+ }
60
+ )
61
+ if blocks:
62
+ push("assistant" if message.role == "assistant" else "user", blocks)
63
+
64
+ max_tokens = request.max_tokens or request.max_completion_tokens or 4096
65
+ inference: dict[str, float | int] = {"maxTokens": max_tokens}
66
+ if request.temperature is not None:
67
+ inference["temperature"] = request.temperature
68
+ result: dict[str, object] = {
69
+ "modelId": model,
70
+ "messages": messages,
71
+ "inferenceConfig": inference,
72
+ }
73
+ if system:
74
+ result["system"] = system
75
+ if request.tools:
76
+ tools = [
77
+ {
78
+ "toolSpec": {
79
+ "name": tool.function.name,
80
+ "description": tool.function.description,
81
+ "inputSchema": {"json": tool.function.parameters},
82
+ }
83
+ }
84
+ for tool in request.tools
85
+ ]
86
+ tool_config: dict[str, object] = {"tools": tools}
87
+ choice = request.tool_choice
88
+ if choice == "required":
89
+ tool_config["toolChoice"] = {"any": {}}
90
+ elif isinstance(choice, dict):
91
+ function = choice.get("function")
92
+ if isinstance(function, dict) and isinstance(function.get("name"), str):
93
+ tool_config["toolChoice"] = {"tool": {"name": function["name"]}}
94
+ if choice != "none":
95
+ result["toolConfig"] = tool_config
96
+ return result
97
+
98
+
99
+ def converse_response(raw: object, model: str) -> ChatResponse:
100
+ """Translate a Bedrock Converse response to the structured provider contract."""
101
+ response = cast("dict[str, object]", raw)
102
+ output = cast("dict[str, object]", response["output"])
103
+ message_data = cast("dict[str, object]", output["message"])
104
+ blocks = cast("list[dict[str, object]]", message_data["content"])
105
+ text = "".join(str(block["text"]) for block in blocks if "text" in block)
106
+ tool_calls: list[dict[str, object]] = []
107
+ for block in blocks:
108
+ use = block.get("toolUse")
109
+ if not isinstance(use, dict):
110
+ continue
111
+ tool_calls.append(
112
+ {
113
+ "id": str(use.get("toolUseId", "")),
114
+ "type": "function",
115
+ "function": {
116
+ "name": str(use.get("name", "")),
117
+ "arguments": json.dumps(use.get("input", {})),
118
+ },
119
+ }
120
+ )
121
+ stop_reason = str(response.get("stopReason", "end_turn"))
122
+ finish_reason = {
123
+ "tool_use": "tool_calls",
124
+ "max_tokens": "length",
125
+ "content_filtered": "content_filter",
126
+ "guardrail_intervened": "content_filter",
127
+ }.get(stop_reason, "stop")
128
+ message: dict[str, object] = {"role": "assistant", "content": text}
129
+ if tool_calls:
130
+ message["tool_calls"] = tool_calls
131
+ usage = response.get("usage")
132
+ usage_data = usage if isinstance(usage, dict) else {}
133
+ return ChatResponse.model_validate(
134
+ {
135
+ "model": model,
136
+ "choices": [{"index": 0, "message": message, "finish_reason": finish_reason}],
137
+ "usage": {
138
+ "prompt_tokens": usage_data.get("inputTokens", 0),
139
+ "completion_tokens": usage_data.get("outputTokens", 0),
140
+ },
141
+ }
142
+ )
143
+
144
+
145
+ def _chat_text(content: JsonValue) -> str:
146
+ """Flatten the text-bearing forms used by OpenAI-compatible chat messages."""
147
+ if isinstance(content, str):
148
+ return content
149
+ if isinstance(content, list):
150
+ parts: list[str] = []
151
+ for item in content:
152
+ if isinstance(item, dict) and isinstance(item.get("text"), str):
153
+ parts.append(item["text"])
154
+ return "".join(parts)
155
+ return "" if content is None else str(content)
@@ -0,0 +1,182 @@
1
+ """Shared request mapping / response parsing for the two OpenAI-shaped backends.
2
+
3
+ `OpenAIProvider` and `AzureOpenAIProvider` differ only in how their client is constructed; the
4
+ chat-completion and embedding wire formats are identical, so that logic lives here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Any, Protocol, cast
10
+
11
+ from llm_waterfall import ChatMaxTokensField, ChatRequest, ChatResponse
12
+ from openai import BadRequestError
13
+
14
+ from wmo.providers.base import Completion, Message, StreamChunk, TokenUsage
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Iterator
18
+
19
+ from openai.types import CreateEmbeddingResponse
20
+ from openai.types.chat import ChatCompletion, ChatCompletionMessageParam
21
+
22
+
23
+ class _ChatCompletions(Protocol):
24
+ def create(
25
+ self,
26
+ *,
27
+ model: str,
28
+ messages: list[ChatCompletionMessageParam],
29
+ max_completion_tokens: int,
30
+ temperature: float = ...,
31
+ ) -> ChatCompletion: ...
32
+
33
+
34
+ class _Embeddings(Protocol):
35
+ def create(
36
+ self, *, model: str, input: list[str], dimensions: int = ...
37
+ ) -> CreateEmbeddingResponse: ...
38
+
39
+
40
+ def to_messages(system: str, messages: list[Message]) -> list[ChatCompletionMessageParam]:
41
+ """Fold the system prompt into the message list as OpenAI's leading `system` turn."""
42
+ out: list[dict[str, str]] = []
43
+ if system:
44
+ out.append({"role": "system", "content": system})
45
+ out.extend({"role": m.role, "content": m.content} for m in messages)
46
+ return cast("list[ChatCompletionMessageParam]", out)
47
+
48
+
49
+ def complete(
50
+ chat_completions: _ChatCompletions,
51
+ model: str,
52
+ system: str,
53
+ messages: list[Message],
54
+ max_tokens: int,
55
+ temperature: float | None = None,
56
+ max_tokens_field: str = "max_completion_tokens",
57
+ ) -> Completion:
58
+ """Run one chat completion and map it onto our `Completion`.
59
+
60
+ `max_tokens_field` names the output-budget parameter the deployment accepts: GPT-5.x wants
61
+ `max_completion_tokens`, while Azure MaaS open models (DeepSeek, Kimi) still take the
62
+ classic `max_tokens` (see `ProviderConfig.resolved_chat_max_tokens_field`). `temperature`
63
+ is sent ONLY when given: GPT 5.5's reasoning models reject non-default sampling params
64
+ (callers pass None), while OpenAI-compatible servers (vLLM policies) need it.
65
+ """
66
+ # The output-budget param name is dynamic, so this call crosses the SDK boundary through
67
+ # the same one-line cast `stream` uses below.
68
+ resource = cast("Any", chat_completions)
69
+ base_kwargs: dict[str, Any] = {
70
+ "model": model,
71
+ "messages": to_messages(system, messages),
72
+ max_tokens_field: max_tokens,
73
+ }
74
+ if temperature is None:
75
+ response: ChatCompletion = resource.create(**base_kwargs)
76
+ else:
77
+ try:
78
+ response = resource.create(**base_kwargs, temperature=temperature)
79
+ except BadRequestError as exc:
80
+ # Reasoning-model deployments (GPT-5.x behind Azure/custom endpoints) reject any
81
+ # non-default temperature with a 400 unsupported_value. The caller can't know which
82
+ # models sample; degrade to the model's default rather than failing the request.
83
+ if "temperature" not in str(exc):
84
+ raise
85
+ response = resource.create(**base_kwargs)
86
+ if not response.choices:
87
+ # Content filtering (and some error modes) can return zero choices; surface it clearly
88
+ # rather than letting choices[0] raise a bare IndexError.
89
+ raise ValueError(f"{model} returned no choices")
90
+ text = response.choices[0].message.content or ""
91
+ usage = response.usage
92
+ token_usage = _chat_usage(usage) if usage is not None else TokenUsage()
93
+ return Completion(text=text, usage=token_usage)
94
+
95
+
96
+ def stream(
97
+ chat_completions: object,
98
+ model: str,
99
+ system: str,
100
+ messages: list[Message],
101
+ max_tokens: int,
102
+ temperature: float | None = None,
103
+ max_tokens_field: str = "max_completion_tokens",
104
+ ) -> Iterator[StreamChunk]:
105
+ """Stream one chat completion as `StreamChunk`s (deltas, then a terminal chunk with usage).
106
+
107
+ `stream_options.include_usage` makes the wire stream end with a usage-bearing chunk, so the
108
+ terminal `StreamChunk` carries real token counts instead of estimates. `temperature` and
109
+ `max_tokens_field` follow the same rules as `complete`.
110
+ """
111
+ resource = cast("Any", chat_completions)
112
+ kwargs: dict[str, Any] = {
113
+ "model": model,
114
+ "messages": to_messages(system, messages),
115
+ max_tokens_field: max_tokens,
116
+ "stream": True,
117
+ "stream_options": {"include_usage": True},
118
+ }
119
+ if temperature is not None:
120
+ kwargs["temperature"] = temperature
121
+ usage = TokenUsage()
122
+ upstream = resource.create(**kwargs)
123
+ try:
124
+ for chunk in upstream:
125
+ choices = getattr(chunk, "choices", None) or []
126
+ if choices:
127
+ delta = getattr(choices[0], "delta", None)
128
+ text = getattr(delta, "content", None) if delta is not None else None
129
+ if text:
130
+ yield StreamChunk(delta=text)
131
+ chunk_usage = getattr(chunk, "usage", None)
132
+ if chunk_usage is not None:
133
+ usage = _chat_usage(chunk_usage)
134
+ finally:
135
+ # The SDK Stream holds an httpx response; without an explicit close an abandoned
136
+ # stream releases the connection only when the object is garbage collected.
137
+ close = getattr(upstream, "close", None)
138
+ if callable(close):
139
+ close()
140
+ yield StreamChunk(done=True, usage=usage)
141
+
142
+
143
+ def _chat_usage(usage: object) -> TokenUsage:
144
+ """Chat-completions usage -> TokenUsage, including the cached-prompt split when reported."""
145
+ details = getattr(usage, "prompt_tokens_details", None)
146
+ return TokenUsage(
147
+ input_tokens=getattr(usage, "prompt_tokens", 0) or 0,
148
+ output_tokens=getattr(usage, "completion_tokens", 0) or 0,
149
+ cached_input_tokens=(getattr(details, "cached_tokens", None) or 0) if details else 0,
150
+ )
151
+
152
+
153
+ def complete_chat(
154
+ chat_completions: object,
155
+ model: str,
156
+ request: ChatRequest,
157
+ *,
158
+ max_tokens_field: ChatMaxTokensField,
159
+ ) -> ChatResponse:
160
+ """Run a validated structured request against an OpenAI-compatible SDK resource."""
161
+ # ChatRequest validates the stable tool-calling core before this SDK boundary. The OpenAI
162
+ # package models its evolving request surface as a large TypedDict union, so the narrow cast
163
+ # preserves forward-compatible extra fields without leaking Any into the public contract.
164
+ resource = cast("Any", chat_completions)
165
+ response = resource.create(**request.provider_payload(model, max_tokens_field=max_tokens_field))
166
+ return ChatResponse.model_validate(response.model_dump(mode="json"))
167
+
168
+
169
+ def embed(
170
+ embeddings: _Embeddings, model: str, texts: list[str], dim: int | None = None
171
+ ) -> list[list[float]]:
172
+ """Embed `texts` against `model` (an OpenAI model id, or an Azure embedding deployment).
173
+
174
+ `dim`, when set, requests a specific output dimension via the `dimensions` param (supported by
175
+ text-embedding-3-* and their Azure deployments) so the index and query vectors match.
176
+ """
177
+ response = (
178
+ embeddings.create(model=model, input=texts, dimensions=dim)
179
+ if dim is not None
180
+ else embeddings.create(model=model, input=texts)
181
+ )
182
+ return [item.embedding for item in response.data]