otto-cli-agent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. agent/README.md +22 -0
  2. agent/__init__.py +0 -0
  3. agent/cli/README.md +77 -0
  4. agent/cli/__init__.py +0 -0
  5. agent/cli/art.py +371 -0
  6. agent/cli/chat.py +309 -0
  7. agent/cli/clipboard.py +106 -0
  8. agent/cli/context.py +56 -0
  9. agent/cli/doctor.py +60 -0
  10. agent/cli/errors.py +58 -0
  11. agent/cli/eval.py +130 -0
  12. agent/cli/eval_claw.py +414 -0
  13. agent/cli/eval_compaction.py +106 -0
  14. agent/cli/eval_hle.py +92 -0
  15. agent/cli/eval_memory.py +253 -0
  16. agent/cli/eval_swe.py +172 -0
  17. agent/cli/lessons.py +97 -0
  18. agent/cli/main.py +67 -0
  19. agent/cli/modals.py +570 -0
  20. agent/cli/models.py +64 -0
  21. agent/cli/output.py +54 -0
  22. agent/cli/route.py +78 -0
  23. agent/cli/sessions.py +125 -0
  24. agent/cli/setup_screen.py +562 -0
  25. agent/cli/shell.py +548 -0
  26. agent/cli/tui.py +1807 -0
  27. agent/cli/ui.py +14 -0
  28. agent/cli/usage_panel.py +159 -0
  29. agent/config/README.md +7 -0
  30. agent/config/__init__.py +0 -0
  31. agent/config/envfile.py +76 -0
  32. agent/eval/README.md +76 -0
  33. agent/eval/__init__.py +0 -0
  34. agent/eval/claw_bench.py +1031 -0
  35. agent/eval/compaction_bench.py +229 -0
  36. agent/eval/data/README.md +10 -0
  37. agent/eval/data/claw/README.md +108 -0
  38. agent/eval/data/claw/llm_judge-gemini.patch +57 -0
  39. agent/eval/data/claw/otto.yaml +35 -0
  40. agent/eval/failures.py +276 -0
  41. agent/eval/golden/README.md +33 -0
  42. agent/eval/golden/code_01.json +6 -0
  43. agent/eval/golden/code_02.json +6 -0
  44. agent/eval/golden/code_03.json +6 -0
  45. agent/eval/golden/code_04.json +6 -0
  46. agent/eval/golden/code_05.json +6 -0
  47. agent/eval/golden/code_06.json +6 -0
  48. agent/eval/golden/math_01.json +6 -0
  49. agent/eval/golden/math_02.json +6 -0
  50. agent/eval/golden/math_03.json +6 -0
  51. agent/eval/golden/math_04.json +6 -0
  52. agent/eval/golden/math_05.json +6 -0
  53. agent/eval/golden/math_06.json +6 -0
  54. agent/eval/golden/nphard_gcp_01.json +6 -0
  55. agent/eval/golden/nphard_ksp_01.json +6 -0
  56. agent/eval/golden/nphard_math_binpacking_01.json +6 -0
  57. agent/eval/golden/nphard_math_clique_01.json +6 -0
  58. agent/eval/golden/nphard_math_setcover_01.json +6 -0
  59. agent/eval/golden/nphard_math_subsetsum_01.json +6 -0
  60. agent/eval/golden/nphard_tsp_01.json +6 -0
  61. agent/eval/golden/nphard_tsp_02.json +6 -0
  62. agent/eval/hle_bench.py +273 -0
  63. agent/eval/langfuse_sync.py +172 -0
  64. agent/eval/memory_bench.py +538 -0
  65. agent/eval/runner.py +174 -0
  66. agent/eval/single_agent.py +120 -0
  67. agent/eval/swe_bench.py +604 -0
  68. agent/eval/terminal_bench.py +345 -0
  69. agent/memory/README.md +102 -0
  70. agent/memory/__init__.py +42 -0
  71. agent/memory/embeddings.py +302 -0
  72. agent/memory/hashing.py +15 -0
  73. agent/memory/lessons.py +483 -0
  74. agent/memory/queue.py +531 -0
  75. agent/memory/retrieval.py +493 -0
  76. agent/memory/session.py +60 -0
  77. agent/memory/sessions.py +436 -0
  78. agent/memory/store.py +429 -0
  79. agent/memory/tokens.py +60 -0
  80. agent/memory/wiring.py +146 -0
  81. agent/pipeline/README.md +135 -0
  82. agent/pipeline/__init__.py +0 -0
  83. agent/pipeline/browsing.py +609 -0
  84. agent/pipeline/budget.py +403 -0
  85. agent/pipeline/codemap.py +254 -0
  86. agent/pipeline/evidence.py +325 -0
  87. agent/pipeline/execution.py +67 -0
  88. agent/pipeline/modes.py +137 -0
  89. agent/pipeline/native.py +1137 -0
  90. agent/pipeline/nodes.py +3644 -0
  91. agent/pipeline/pricing.py +209 -0
  92. agent/pipeline/progress.py +139 -0
  93. agent/pipeline/rag.py +139 -0
  94. agent/pipeline/research.py +1325 -0
  95. agent/pipeline/run.py +528 -0
  96. agent/pipeline/screen.py +77 -0
  97. agent/pipeline/state.py +220 -0
  98. agent/pipeline/toolkit.py +328 -0
  99. agent/pipeline/tools.py +1990 -0
  100. agent/pipeline/tracing.py +147 -0
  101. agent/pipeline/usage.py +251 -0
  102. agent/pipeline/vision.py +84 -0
  103. agent/pipeline/walkthrough.py +735 -0
  104. agent/pipeline/workspace.py +229 -0
  105. agent/router/README.md +60 -0
  106. agent/router/__init__.py +0 -0
  107. agent/router/automap.py +114 -0
  108. agent/router/health.py +229 -0
  109. agent/router/llm_provider/README.md +38 -0
  110. agent/router/llm_provider/__init__.py +202 -0
  111. agent/router/llm_provider/anthropic_provider.py +128 -0
  112. agent/router/llm_provider/base.py +507 -0
  113. agent/router/llm_provider/custom.py +152 -0
  114. agent/router/llm_provider/gemini_provider.py +122 -0
  115. agent/router/llm_provider/inception_provider.py +687 -0
  116. agent/router/llm_provider/openai_provider.py +151 -0
  117. agent/router/llm_provider/retired.py +145 -0
  118. agent/router/llm_provider/temperature.py +371 -0
  119. agent/router/mapping.py +579 -0
  120. agent/router/outcomes.py +363 -0
  121. agent/router/overrides.py +389 -0
  122. agent/router/reload.py +28 -0
  123. agent/router/router.py +413 -0
  124. agent/router/setup.py +123 -0
  125. otto_cli_agent-0.1.0.dist-info/METADATA +115 -0
  126. otto_cli_agent-0.1.0.dist-info/RECORD +129 -0
  127. otto_cli_agent-0.1.0.dist-info/WHEEL +4 -0
  128. otto_cli_agent-0.1.0.dist-info/entry_points.txt +2 -0
  129. otto_cli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,579 @@
1
+ """Static routing intent.
2
+
3
+ Imports only `Capability` from the provider base -- no registry, no vendor SDKs.
4
+ That is what lets routing be tested with an empty .env and no network.
5
+ """
6
+
7
+ from typing import Any, Mapping
8
+
9
+ from agent.router.llm_provider.base import Capability
10
+ from enum import StrEnum
11
+ from dataclasses import dataclass, field
12
+
13
+
14
+ class Task(StrEnum):
15
+ """What a swarm node is doing. Named by intent, never by model or vendor."""
16
+
17
+ CHAT_FAST = "chat_fast"
18
+ REASON = "reason"
19
+ #: Judging an attempt, as opposed to producing one. Split off REASON on
20
+ #: 2026-09-11 for one reason: the evaluator and the solver shared it, and
21
+ #: they now want different vendors. A task keyed by intent cannot express
22
+ #: "same intent, different seat", so the seats became separate intents.
23
+ EVALUATE = "evaluate"
24
+ PLAN = "plan"
25
+ SUMMARIZE = "summarize"
26
+ #: Looking at an image. Reached only by the `view_image` tool, never by a
27
+ #: role node -- the graph itself stays text-only (see agent/pipeline/
28
+ #: vision.py for why that is a deliberate ceiling rather than an oversight).
29
+ VISION = "vision"
30
+ #: Searching the live web. Reached only by the `web_search` tool, which
31
+ #: uses the vendor's own server-side search rather than a search API key
32
+ #: Otto would have to hold.
33
+ WEB = "web"
34
+ CODE_COMPLETE = "code_complete"
35
+ CODE_EDIT = "code_edit"
36
+
37
+
38
+ class Endpoint(StrEnum):
39
+ """Which provider method a candidate is calling.
40
+
41
+ Not derivable from `requires`: a capability says what a model can do, an
42
+ endpoint says which function the router invokes. They differ in signature
43
+ and in accepted parameters, so the router has to be told, not left to infer.
44
+
45
+ CHAT -> provider.chat_model(model_id, **params) -> BaseChatModel
46
+ FIM -> provider.fim(model_id, prefix, suffix, **params) -> str
47
+ EDIT -> provider.code_edit(model_id, code_to_edit, **params) -> str
48
+ """
49
+
50
+ CHAT = "chat"
51
+ FIM = "fim"
52
+ EDIT = "edit"
53
+
54
+
55
+ class Preference(StrEnum):
56
+ """How to choose when a query matches several models.
57
+
58
+ Nothing in today's `TASK_ROUTES` exercises this -- every candidate is now
59
+ a pinned Inception spec, and a pin resolves to its exact model id
60
+ (`Router._select`), never a tiebreak. Kept for the day a query candidate
61
+ (e.g. "whichever Mercury variant is smallest") earns its place again --
62
+ removing the mechanism to save a few lines now would just mean rebuilding
63
+ it later under time pressure.
64
+ """
65
+
66
+ #: Biggest window that qualifies -- proxy for "most capable".
67
+ LARGEST_CONTEXT = "largest_context"
68
+ #: Smallest window that still clears `min_context` -- proxy for "cheap tier".
69
+ SMALLEST_CONTEXT = "smallest_context"
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class Candidate:
74
+ """One proposal in a chain. Three forms, in decreasing determinism:
75
+
76
+ pinned spec="inception:mercury-2.5" exact model
77
+ provider query provider="inception" best match within one vendor
78
+ open query neither best match anywhere
79
+
80
+ Pin when you need reproducibility. Query when the vendor churns model ids
81
+ faster than you want to edit this file -- capabilities outlive ids. Every
82
+ route in `TASK_ROUTES` today is a pin: Inception is the only vendor, and
83
+ its two chat-capable generations (mercury-2, mercury-2.5) are different
84
+ enough in quality that "whichever one exists" is never what a route wants.
85
+ """
86
+
87
+ spec: str | None = None
88
+ #: Scopes a query to one vendor. Mutually exclusive with `spec`.
89
+ provider: str | None = None
90
+ requires: frozenset[Capability] = frozenset()
91
+ endpoint: Endpoint = Endpoint.CHAT
92
+ min_context: int | None = None
93
+ #: Substring the model id must contain, matched case-insensitively.
94
+ #: Ignored on a pinned candidate -- see `Router._select`.
95
+ name_contains: str | None = None
96
+ #: Tiebreak for queries. Ignored for a pin, which matches exactly one model.
97
+ prefer: Preference = Preference.SMALLEST_CONTEXT
98
+ params: Mapping[str, Any] = field(default_factory=dict)
99
+
100
+ @property
101
+ def provider_name(self) -> str | None:
102
+ """Vendor this candidate resolves against, or None for an open query."""
103
+ if self.spec is not None:
104
+ head, sep, _ = self.spec.partition(":")
105
+ return head if sep else None
106
+ return self.provider
107
+
108
+ @property
109
+ def is_query(self) -> bool:
110
+ return self.spec is None
111
+
112
+
113
+ # Chains are ordered: first viable candidate wins, the rest are the fallback
114
+ # path. Must be tuples -- a set literal both scrambles that order and fails to
115
+ # build, since `params` makes a frozen Candidate unhashable.
116
+ #
117
+ # Otto is Inception-only (2026-09-09: "going all in with Mercury" -- the
118
+ # multi-vendor fallback chains from Phases 2-7 are gone, along with the
119
+ # anthropic/openai/gemini provider modules themselves). Every chain below is
120
+ # a single pinned candidate; there is no secondary vendor left to fall back
121
+ # to, so `NoViableRoute` now means exactly one thing: Inception itself is
122
+ # unreachable or the pinned id has gone stale.
123
+ #
124
+ # `params` are bound per route and must be valid for that candidate's endpoint.
125
+ # The three Inception endpoints accept disjoint parameter sets:
126
+ #
127
+ # chat constructor kwargs for ChatInception -- temperature, max_tokens,
128
+ # diffusing, realtime, reasoning_effort, reasoning_summary
129
+ # fim call kwargs -- max_tokens, stop, top_p, top_k, frequency_penalty,
130
+ # presence_penalty, repetition_penalty (no temperature)
131
+ # edit call kwargs -- max_tokens, temperature, top_p, presence_penalty
132
+ #
133
+ # Note the asymmetry: CHAT params reach a constructor, FIM/EDIT params reach a
134
+ # call. `stop` and `tools` are chat *invoke*-time options, not constructor
135
+ # fields, so they do not belong in a CHAT route's params.
136
+ #
137
+ # `diffusing=True` streams the *denoising effect*: each chunk is a full
138
+ # snapshot of the whole answer, progressively less noisy, NOT the next slice
139
+ # of text. A consumer that appends chunks concatenates every refinement step
140
+ # and produces garbage -- the renderer must REPLACE the previous frame.
141
+ # `chat.py` selects its renderer on this flag.
142
+
143
+ TASK_ROUTES: dict[Task, tuple[Candidate, ...]] = {
144
+ # `reasoning_effort` is the dial every chat route tunes, cheapest to most
145
+ # thoughtful: "instant" for a swarm node's fast turnaround (classify,
146
+ # review), "medium"/"high" for the calls that actually have to think
147
+ # (reconcile, plan) -- one model, four settings, not four models.
148
+ Task.CHAT_FAST: (
149
+ Candidate(
150
+ spec="inception:mercury-2.5",
151
+ requires=frozenset({Capability.CHAT}),
152
+ params={"temperature": 0.2, "reasoning_effort": "instant", "diffusing": True},
153
+ ),
154
+ ),
155
+
156
+ Task.REASON: (
157
+ # The solver seat. It is the most turn-hungry role in the graph, so it
158
+ # is also where model choice costs the most -- cheap tier first, and
159
+ # upgrade only what measurement says is losing points.
160
+ Candidate(
161
+ spec="openai:gpt-5-mini",
162
+ requires=frozenset({Capability.CHAT, Capability.REASONING}),
163
+ params={"temperature": 0.0, "max_tokens": 8192},
164
+ ),
165
+ Candidate(
166
+ spec="inception:mercury-2.5",
167
+ requires=frozenset({Capability.CHAT}),
168
+ # max_tokens set explicitly, generous: this route authors whole
169
+ # code files and reconciles (stitches) them, and an unset
170
+ # max_tokens fell back to whatever Inception defaults to
171
+ # unspecified -- too low for a real script, and a diffusing call
172
+ # cut off at length is not a clean prefix, it's an unconverged
173
+ # snapshot (code_nodes.py's _call() retries on that finish_reason,
174
+ # this just makes hitting it in the first place rarer).
175
+ #
176
+ # 2026-09-09 flipped diffusing False and bumped reasoning_effort
177
+ # to "high" while chasing malformed tool-calling replies -- a bare
178
+ # fragment with neither "ACTION:" nor "FINAL:" in it -- and said
179
+ # explicitly: "Revert if it doesn't measurably help."
180
+ #
181
+ # 2026-09-10, measured: "high" is the cause, not the cure. Six
182
+ # trials per setting on a real solver-shaped conversation,
183
+ # counting replies with any content at all, and replies that were
184
+ # well-formed:
185
+ #
186
+ # diffusing effort non-empty well-formed avg
187
+ # False high 2/6 2/6 13.8s
188
+ # True high 1/6 1/6 10.5s
189
+ # False medium 6/6 6/6 6.3s
190
+ # True medium 6/6 6/6 9.7s
191
+ # False (unset) 6/6 6/6 10.0s
192
+ # True (unset) 6/6 6/6 7.2s
193
+ #
194
+ # At "high" the stream yields two chunks, no content and no
195
+ # finish_reason. That empty reply IS the malformed fragment the
196
+ # experiment was chasing -- _parse_worker_reply can only call it
197
+ # unparseable -- so asking for more deliberation made the very bug
198
+ # it was aimed at four times worse. It also wrecked the agent's
199
+ # ratio of thinking to doing: on a hard task the solver spent 39
200
+ # of its 40 tool-loop turns on empty replies and issued 2 real
201
+ # commands in six minutes.
202
+ #
203
+ # "medium" it is: every reply usable, and the fastest of the
204
+ # working settings. `diffusing` is left where the experiment put
205
+ # it -- nothing here shows it doing harm, and changing two things
206
+ # at once is how this became hard to attribute in the first place.
207
+ params={"temperature": 0.7, "reasoning_effort": "medium",
208
+ "diffusing": False, "max_tokens": 8192},
209
+ ),
210
+ ),
211
+
212
+ Task.EVALUATE: (
213
+ # Judging is where a weak model cost the most this session: the
214
+ # evaluator approved work it had never checked, repeatedly. It gets a
215
+ # real reasoning model, at the cheap end of one -- upgrade only if
216
+ # measurement says this tier is what is losing points.
217
+ Candidate(
218
+ spec="anthropic:claude-haiku-4-5-20251001",
219
+ requires=frozenset({Capability.CHAT, Capability.REASONING}),
220
+ params={"temperature": 0.0, "max_tokens": 4096},
221
+ ),
222
+ Candidate(
223
+ spec="inception:mercury-2.5",
224
+ requires=frozenset({Capability.CHAT}),
225
+ params={"temperature": 0.5, "reasoning_effort": "medium",
226
+ "diffusing": False, "max_tokens": 8192},
227
+ ),
228
+ ),
229
+
230
+ Task.PLAN: (
231
+ Candidate(
232
+ spec="anthropic:claude-haiku-4-5-20251001",
233
+ requires=frozenset({Capability.CHAT, Capability.REASONING}),
234
+ params={"temperature": 0.0, "max_tokens": 4096},
235
+ ),
236
+ Candidate(
237
+ spec="inception:mercury-2.5",
238
+ requires=frozenset({Capability.CHAT}),
239
+ # "medium", not "high", for the reason spelled out on Task.REASON
240
+ # above: at "high" this model returns an empty stream most of the
241
+ # time. Measured on REASON's prompt shape rather than this one,
242
+ # but it is the same model and endpoint, and 1-2 usable replies
243
+ # out of 6 is not a risk worth carrying here either.
244
+ params={"temperature": 0.4, "reasoning_effort": "medium",
245
+ "diffusing": True, "max_tokens": 4096},
246
+ ),
247
+ ),
248
+
249
+ Task.SUMMARIZE: (
250
+ Candidate(
251
+ spec="gemini:gemini-flash-lite-latest",
252
+ requires=frozenset({Capability.CHAT}),
253
+ params={"temperature": 0.0, "max_tokens": 4096},
254
+ ),
255
+ Candidate(
256
+ spec="inception:mercury-2.5",
257
+ requires=frozenset({Capability.CHAT}),
258
+ params={"temperature": 0.5, "reasoning_effort": "instant", "diffusing": True},
259
+ ),
260
+ ),
261
+
262
+ Task.VISION: (
263
+ # Pinned to an id that was verified to actually SERVE a request, not
264
+ # merely to appear in the catalogue: Gemini's model list still returns
265
+ # gemini-2.5-flash and gemini-2.5-flash-lite, both of which answer
266
+ # generateContent with 404 "no longer available". Router._select can
267
+ # only check that a pin exists in the catalogue, so a retired id
268
+ # resolves cleanly and then fails at call time.
269
+ #
270
+ # Deliberately NO Inception fallback. Mercury has no vision, and a
271
+ # chain that quietly fell through to a text model would answer
272
+ # confidently about an image it never saw. NoViableRoute -> a failing
273
+ # ToolResult is the correct outcome when no Gemini key is configured.
274
+ Candidate(
275
+ spec="gemini:gemini-3-flash-preview",
276
+ requires=frozenset({Capability.CHAT, Capability.VISION}),
277
+ params={"temperature": 0.0, "max_tokens": 4096},
278
+ ),
279
+ # Capability query, not a pin -- the first use of the machinery this
280
+ # module has carried unused since it was written. A pin is right for
281
+ # the head of a chain because cost is a property of the exact id, but
282
+ # it is brittle against exactly what retired.py now filters: if the
283
+ # pinned model is withdrawn, VISION has no route at all and every
284
+ # image task fails. This says "any Gemini model that can still see",
285
+ # preferring the smallest context that qualifies, so the capability
286
+ # survives a retirement even though the cost guarantee does not.
287
+ Candidate(
288
+ provider="gemini",
289
+ requires=frozenset({Capability.CHAT, Capability.VISION}),
290
+ # Narrowed to the flash family as well as the capability. The
291
+ # unusable-model filter removes what is obviously not a chat model,
292
+ # but capability tags alone are too generous to pick a replacement
293
+ # blind -- this keeps the fallback inside the family the pin was
294
+ # chosen from, so a withdrawal changes the model without changing
295
+ # the class of model.
296
+ name_contains="flash",
297
+ params={"temperature": 0.0, "max_tokens": 4096},
298
+ ),
299
+ ),
300
+
301
+ Task.WEB: (
302
+ # No Inception fallback either, for the same shape of reason: Mercury
303
+ # has no web access, so falling through would answer from memory while
304
+ # looking like a search.
305
+ Candidate(
306
+ spec="anthropic:claude-haiku-4-5-20251001",
307
+ requires=frozenset({Capability.CHAT, Capability.TOOLS}),
308
+ params={"temperature": 0.0, "max_tokens": 4096},
309
+ ),
310
+ ),
311
+
312
+ # No fallback exists for either of these -- no other vendor implements the
313
+ # endpoints. `validate()` enforces that, so nobody can add one that looks
314
+ # like coverage. Unchanged by the Mercury 2.5 switch: Inception's docs
315
+ # list no mercury-edit-2.5, only mercury-edit-2, for FIM/edit.
316
+ Task.CODE_COMPLETE: (
317
+ Candidate(
318
+ spec="inception:mercury-edit-2",
319
+ requires=frozenset({Capability.FIM}),
320
+ endpoint=Endpoint.FIM,
321
+ params={"max_tokens": 256, "presence_penalty": 1.5},
322
+ ),
323
+ ),
324
+ Task.CODE_EDIT: (
325
+ Candidate(
326
+ spec="inception:mercury-edit-2",
327
+ requires=frozenset({Capability.EDIT}),
328
+ endpoint=Endpoint.EDIT,
329
+ params={"temperature": 0.4, "max_tokens": 1024},
330
+ ),
331
+ ),
332
+ }
333
+
334
+ KNOWN_PROVIDERS = frozenset({"inception", "anthropic", "openai", "gemini"})
335
+
336
+ #: Provider names added at runtime -- the custom OpenAI-compatible endpoints
337
+ #: agent/router/overrides.py registers from ~/.otto/routes.json. A set this
338
+ #: module owns rather than an import of the registry, so mapping.py stays
339
+ #: importable with an empty environment and no I/O (its docstring's promise).
340
+ _EXTRA_PROVIDERS: set[str] = set()
341
+
342
+
343
+ def known_providers() -> frozenset[str]:
344
+ """Built-in vendors plus every custom endpoint registered so far."""
345
+ return KNOWN_PROVIDERS | frozenset(_EXTRA_PROVIDERS)
346
+
347
+
348
+ def register_provider_name(name: str, *, params_like: str | None = "openai") -> None:
349
+ """Let `validate()` accept routes naming `name`. `params_like` copies
350
+ that vendor's accepted-parameter table, because a custom endpoint speaks
351
+ the OpenAI dialect and should be checked as strictly as OpenAI is."""
352
+ _EXTRA_PROVIDERS.add(name)
353
+ if params_like is not None and params_like in PARAMS_BY_PROVIDER:
354
+ PARAMS_BY_PROVIDER.setdefault(name, PARAMS_BY_PROVIDER[params_like])
355
+
356
+
357
+ def unregister_provider_name(name: str) -> None:
358
+ _EXTRA_PROVIDERS.discard(name)
359
+ if name not in KNOWN_PROVIDERS:
360
+ PARAMS_BY_PROVIDER.pop(name, None)
361
+ INCEPTION_ONLY_ENDPOINTS = frozenset({Endpoint.FIM, Endpoint.EDIT})
362
+ ENDPOINT_CAPABILITY: dict[Endpoint, Capability] = {
363
+ Endpoint.CHAT: Capability.CHAT,
364
+ Endpoint.FIM: Capability.FIM,
365
+ Endpoint.EDIT: Capability.EDIT,
366
+ }
367
+
368
+ #: Deliberately named for the vendor it describes. These are Inception's
369
+ #: parameter sets, not a general truth -- see PARAMS_BY_PROVIDER below, which
370
+ #: is the table that grew when the second vendor actually arrived.
371
+ #:
372
+ #: `model`, `prompt`, `suffix` and `messages` are absent on purpose: the provider
373
+ #: wrapper supplies them positionally, so a route listing one would pass it twice
374
+ #: and raise "got multiple values for argument".
375
+ INCEPTION_PARAMS_BY_ENDPOINT: dict[Endpoint, frozenset[str]] = {
376
+ Endpoint.CHAT: frozenset({
377
+ "temperature", "max_tokens", "diffusing", "realtime",
378
+ "reasoning_effort", "reasoning_summary", "model_kwargs",
379
+ }),
380
+ Endpoint.FIM: frozenset({
381
+ "max_tokens", "stop", "top_p", "top_k",
382
+ "frequency_penalty", "presence_penalty", "repetition_penalty",
383
+ }),
384
+ Endpoint.EDIT: frozenset({
385
+ "max_tokens", "temperature", "top_p", "presence_penalty",
386
+ }),
387
+ }
388
+
389
+
390
+ #: What each vendor's chat model actually accepts, so a route carrying the
391
+ #: wrong kwarg fails at import rather than on the first live call.
392
+ #:
393
+ #: This matters more than it looks. Every CHAT route here used to carry
394
+ #: `reasoning_effort` and `diffusing`, which are `ChatInception` constructor
395
+ #: arguments -- handing either to `ChatOpenAI` or `ChatAnthropic` raises. The
396
+ #: old validation ran only `if provider == "inception"`, so a non-Inception
397
+ #: route's params were completely unchecked and the mistake would surface as a
398
+ #: TypeError deep inside a node, mid-run.
399
+ #:
400
+ #: A provider absent from this table is not validated, which is the honest
401
+ #: default for a vendor whose parameter set nobody here has written down yet.
402
+ PARAMS_BY_PROVIDER: dict[str, dict[Endpoint, frozenset[str]]] = {
403
+ "inception": INCEPTION_PARAMS_BY_ENDPOINT,
404
+ # LangChain's ChatAnthropic / ChatOpenAI / ChatGoogleGenerativeAI share
405
+ # this much; anything vendor-specific goes through `model_kwargs`.
406
+ "anthropic": {Endpoint.CHAT: frozenset({
407
+ "temperature", "max_tokens", "top_p", "top_k", "timeout",
408
+ "stop", "model_kwargs", "thinking",
409
+ })},
410
+ "openai": {Endpoint.CHAT: frozenset({
411
+ "temperature", "max_tokens", "max_completion_tokens", "top_p",
412
+ "timeout", "stop", "model_kwargs", "reasoning_effort",
413
+ })},
414
+ "gemini": {Endpoint.CHAT: frozenset({
415
+ "temperature", "max_tokens", "max_output_tokens", "top_p", "top_k",
416
+ "timeout", "model_kwargs",
417
+ })},
418
+ }
419
+
420
+
421
+ class MappingError(Exception): ...
422
+
423
+
424
+ def validate(routes: Mapping[Task, tuple[Candidate, ...]] = TASK_ROUTES) -> None:
425
+ """Check the routing table's shape. Collects every problem, then raises once.
426
+
427
+ Shape only. Whether a model *exists* needs a live catalogue, so that check
428
+ belongs to the Router -- same split as `is_configured()` vs `check()`.
429
+ """
430
+ problems: list[str] = []
431
+
432
+ for task in Task:
433
+ chain = routes.get(task)
434
+ if chain is None:
435
+ problems.append(f"{task!r}: no route defined")
436
+ continue
437
+ if not isinstance(chain, tuple):
438
+ problems.append(f"{task!r}: chain is {type(chain).__name__}, not tuple")
439
+ continue
440
+ if not chain:
441
+ problems.append(f"{task!r}: chain is empty")
442
+ continue
443
+
444
+ for i, c in enumerate(chain):
445
+ where = f"{task!r}[{i}]"
446
+
447
+ if not isinstance(c, Candidate):
448
+ problems.append(f"{where}: {type(c).__name__}, not a Candidate")
449
+ continue
450
+
451
+ if c.name_contains is not None and (
452
+ not isinstance(c.name_contains, str) or not c.name_contains.strip()
453
+ ):
454
+ problems.append(
455
+ f"{where}: name_contains must be a non-empty string, "
456
+ f"got {c.name_contains!r}"
457
+ )
458
+
459
+ if not isinstance(c.prefer, Preference):
460
+ problems.append(f"{where}: prefer {c.prefer!r} is not a Preference")
461
+
462
+ if not isinstance(c.endpoint, Endpoint):
463
+ problems.append(f"{where}: endpoint {c.endpoint!r} is not an Endpoint")
464
+ continue
465
+
466
+ # --- spec / provider: pinned, provider-scoped query, or open query ---
467
+ provider: str | None = None
468
+ if c.spec is not None and c.provider is not None:
469
+ problems.append(
470
+ f"{where}: set spec or provider, not both "
471
+ f"(spec={c.spec!r}, provider={c.provider!r})"
472
+ )
473
+ if c.spec is None:
474
+ if not c.requires:
475
+ problems.append(
476
+ f"{where}: query candidate must set requires, "
477
+ f"or it matches every model"
478
+ )
479
+ if c.provider is not None:
480
+ if c.provider in known_providers():
481
+ provider = c.provider
482
+ else:
483
+ problems.append(
484
+ f"{where}: unknown provider {c.provider!r}; "
485
+ f"known: {sorted(known_providers())}"
486
+ )
487
+ else:
488
+ # Split on the FIRST colon only. A model id may itself carry
489
+ # one -- Ollama tags every id ("llama3.2:latest") -- and every
490
+ # other reader here (Candidate.provider_name, Router._select,
491
+ # parse_spec, outcomes.spec_id) already partitions the same way.
492
+ provider, sep, model = c.spec.partition(":")
493
+ if not sep:
494
+ problems.append(f"{where}: spec {c.spec!r} is not 'provider:model'")
495
+ provider = None
496
+ elif provider not in known_providers():
497
+ problems.append(
498
+ f"{where}: unknown provider {provider!r}; "
499
+ f"known: {sorted(known_providers())}"
500
+ )
501
+ provider = None
502
+ elif not model:
503
+ problems.append(f"{where}: spec {c.spec!r} has an empty model id")
504
+
505
+ # --- requires ---
506
+ if not isinstance(c.requires, frozenset):
507
+ problems.append(
508
+ f"{where}: requires is {type(c.requires).__name__}, not frozenset"
509
+ )
510
+ else:
511
+ loose = sorted(r for r in c.requires if not isinstance(r, Capability))
512
+ if loose:
513
+ problems.append(f"{where}: requires holds non-Capability values {loose}")
514
+
515
+ need = ENDPOINT_CAPABILITY[c.endpoint]
516
+ if need not in c.requires:
517
+ problems.append(
518
+ f"{where}: endpoint {c.endpoint.value!r} needs "
519
+ f"{need.value!r} in requires"
520
+ )
521
+
522
+ # --- endpoints only Inception implements ---
523
+ if c.endpoint in INCEPTION_ONLY_ENDPOINTS:
524
+ if c.spec is None and c.provider is None:
525
+ problems.append(
526
+ f"{where}: endpoint {c.endpoint.value!r} cannot be an open "
527
+ f"query -- only inception implements it"
528
+ )
529
+ elif provider is not None and provider != "inception":
530
+ problems.append(
531
+ f"{where}: endpoint {c.endpoint.value!r} is inception-only, "
532
+ f"got provider {provider!r}"
533
+ )
534
+
535
+ # --- min_context ---
536
+ if c.min_context is not None:
537
+ # bool is a subclass of int, so isinstance alone lets True through.
538
+ if not isinstance(c.min_context, int) or isinstance(c.min_context, bool):
539
+ problems.append(
540
+ f"{where}: min_context must be an int, got {c.min_context!r}"
541
+ )
542
+ elif c.min_context <= 0:
543
+ problems.append(
544
+ f"{where}: min_context must be positive, got {c.min_context}"
545
+ )
546
+
547
+ # --- params, checked only where we actually know the parameter set ---
548
+ known = PARAMS_BY_PROVIDER.get(provider or "", {}).get(c.endpoint)
549
+ if known is not None:
550
+ illegal = sorted(set(c.params) - known)
551
+ if illegal:
552
+ problems.append(
553
+ f"{where}: params {illegal} not accepted by {provider} "
554
+ f"{c.endpoint.value!r}"
555
+ )
556
+
557
+ # --- chain level: a query candidate shadows every pin after it ---
558
+ # Only an *open* query shadows what follows -- it can match any vendor.
559
+ # A provider-scoped query only ever competes within its own vendor, so
560
+ # several of them in one chain is the normal, intended shape.
561
+ open_queries = [
562
+ i for i, c in enumerate(chain)
563
+ if isinstance(c, Candidate) and c.spec is None and c.provider is None
564
+ ]
565
+ if len(open_queries) > 1:
566
+ problems.append(
567
+ f"{task!r}: {len(open_queries)} open query candidates; at most one allowed"
568
+ )
569
+ elif open_queries and open_queries[0] != len(chain) - 1:
570
+ problems.append(
571
+ f"{task!r}: open query at index {open_queries[0]} shadows every "
572
+ f"candidate after it; it must be last"
573
+ )
574
+
575
+ if problems:
576
+ raise MappingError("invalid TASK_ROUTES:\n " + "\n ".join(problems))
577
+
578
+
579
+ validate()