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.
- agent/README.md +22 -0
- agent/__init__.py +0 -0
- agent/cli/README.md +77 -0
- agent/cli/__init__.py +0 -0
- agent/cli/art.py +371 -0
- agent/cli/chat.py +309 -0
- agent/cli/clipboard.py +106 -0
- agent/cli/context.py +56 -0
- agent/cli/doctor.py +60 -0
- agent/cli/errors.py +58 -0
- agent/cli/eval.py +130 -0
- agent/cli/eval_claw.py +414 -0
- agent/cli/eval_compaction.py +106 -0
- agent/cli/eval_hle.py +92 -0
- agent/cli/eval_memory.py +253 -0
- agent/cli/eval_swe.py +172 -0
- agent/cli/lessons.py +97 -0
- agent/cli/main.py +67 -0
- agent/cli/modals.py +570 -0
- agent/cli/models.py +64 -0
- agent/cli/output.py +54 -0
- agent/cli/route.py +78 -0
- agent/cli/sessions.py +125 -0
- agent/cli/setup_screen.py +562 -0
- agent/cli/shell.py +548 -0
- agent/cli/tui.py +1807 -0
- agent/cli/ui.py +14 -0
- agent/cli/usage_panel.py +159 -0
- agent/config/README.md +7 -0
- agent/config/__init__.py +0 -0
- agent/config/envfile.py +76 -0
- agent/eval/README.md +76 -0
- agent/eval/__init__.py +0 -0
- agent/eval/claw_bench.py +1031 -0
- agent/eval/compaction_bench.py +229 -0
- agent/eval/data/README.md +10 -0
- agent/eval/data/claw/README.md +108 -0
- agent/eval/data/claw/llm_judge-gemini.patch +57 -0
- agent/eval/data/claw/otto.yaml +35 -0
- agent/eval/failures.py +276 -0
- agent/eval/golden/README.md +33 -0
- agent/eval/golden/code_01.json +6 -0
- agent/eval/golden/code_02.json +6 -0
- agent/eval/golden/code_03.json +6 -0
- agent/eval/golden/code_04.json +6 -0
- agent/eval/golden/code_05.json +6 -0
- agent/eval/golden/code_06.json +6 -0
- agent/eval/golden/math_01.json +6 -0
- agent/eval/golden/math_02.json +6 -0
- agent/eval/golden/math_03.json +6 -0
- agent/eval/golden/math_04.json +6 -0
- agent/eval/golden/math_05.json +6 -0
- agent/eval/golden/math_06.json +6 -0
- agent/eval/golden/nphard_gcp_01.json +6 -0
- agent/eval/golden/nphard_ksp_01.json +6 -0
- agent/eval/golden/nphard_math_binpacking_01.json +6 -0
- agent/eval/golden/nphard_math_clique_01.json +6 -0
- agent/eval/golden/nphard_math_setcover_01.json +6 -0
- agent/eval/golden/nphard_math_subsetsum_01.json +6 -0
- agent/eval/golden/nphard_tsp_01.json +6 -0
- agent/eval/golden/nphard_tsp_02.json +6 -0
- agent/eval/hle_bench.py +273 -0
- agent/eval/langfuse_sync.py +172 -0
- agent/eval/memory_bench.py +538 -0
- agent/eval/runner.py +174 -0
- agent/eval/single_agent.py +120 -0
- agent/eval/swe_bench.py +604 -0
- agent/eval/terminal_bench.py +345 -0
- agent/memory/README.md +102 -0
- agent/memory/__init__.py +42 -0
- agent/memory/embeddings.py +302 -0
- agent/memory/hashing.py +15 -0
- agent/memory/lessons.py +483 -0
- agent/memory/queue.py +531 -0
- agent/memory/retrieval.py +493 -0
- agent/memory/session.py +60 -0
- agent/memory/sessions.py +436 -0
- agent/memory/store.py +429 -0
- agent/memory/tokens.py +60 -0
- agent/memory/wiring.py +146 -0
- agent/pipeline/README.md +135 -0
- agent/pipeline/__init__.py +0 -0
- agent/pipeline/browsing.py +609 -0
- agent/pipeline/budget.py +403 -0
- agent/pipeline/codemap.py +254 -0
- agent/pipeline/evidence.py +325 -0
- agent/pipeline/execution.py +67 -0
- agent/pipeline/modes.py +137 -0
- agent/pipeline/native.py +1137 -0
- agent/pipeline/nodes.py +3644 -0
- agent/pipeline/pricing.py +209 -0
- agent/pipeline/progress.py +139 -0
- agent/pipeline/rag.py +139 -0
- agent/pipeline/research.py +1325 -0
- agent/pipeline/run.py +528 -0
- agent/pipeline/screen.py +77 -0
- agent/pipeline/state.py +220 -0
- agent/pipeline/toolkit.py +328 -0
- agent/pipeline/tools.py +1990 -0
- agent/pipeline/tracing.py +147 -0
- agent/pipeline/usage.py +251 -0
- agent/pipeline/vision.py +84 -0
- agent/pipeline/walkthrough.py +735 -0
- agent/pipeline/workspace.py +229 -0
- agent/router/README.md +60 -0
- agent/router/__init__.py +0 -0
- agent/router/automap.py +114 -0
- agent/router/health.py +229 -0
- agent/router/llm_provider/README.md +38 -0
- agent/router/llm_provider/__init__.py +202 -0
- agent/router/llm_provider/anthropic_provider.py +128 -0
- agent/router/llm_provider/base.py +507 -0
- agent/router/llm_provider/custom.py +152 -0
- agent/router/llm_provider/gemini_provider.py +122 -0
- agent/router/llm_provider/inception_provider.py +687 -0
- agent/router/llm_provider/openai_provider.py +151 -0
- agent/router/llm_provider/retired.py +145 -0
- agent/router/llm_provider/temperature.py +371 -0
- agent/router/mapping.py +579 -0
- agent/router/outcomes.py +363 -0
- agent/router/overrides.py +389 -0
- agent/router/reload.py +28 -0
- agent/router/router.py +413 -0
- agent/router/setup.py +123 -0
- otto_cli_agent-0.1.0.dist-info/METADATA +115 -0
- otto_cli_agent-0.1.0.dist-info/RECORD +129 -0
- otto_cli_agent-0.1.0.dist-info/WHEEL +4 -0
- otto_cli_agent-0.1.0.dist-info/entry_points.txt +2 -0
- otto_cli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
agent/router/mapping.py
ADDED
|
@@ -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()
|