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,562 @@
1
+ """The setup wizard: providers and keys, the models they serve, and which
2
+ model answers each task. Opened from the palette ("Setup…"), f2, or by the
3
+ app itself when nothing is configured.
4
+
5
+ Three tabs, in the order a fresh install needs them:
6
+
7
+ 1 · Providers one row per vendor (the four built-ins plus every custom
8
+ endpoint), a masked key column, and a live status once
9
+ probed. A password Input to add or replace a key; a small
10
+ form to add a named OpenAI-compatible endpoint.
11
+ 2 · Models every model the configured providers list, with the
12
+ capabilities otto believes it has.
13
+ 3 · Mapping one row per Task: what resolves today, what auto-map
14
+ proposes and why, and a Select of the models eligible for
15
+ that seat. Save writes pins to ~/.otto/routes.json.
16
+
17
+ Nothing here touches the environment, a file or the registry: every effect
18
+ goes through `SetupBackend`, a bag of callables the app fills from its own
19
+ module globals (agent/cli/tui.py `_setup_backend`) -- which is what lets the
20
+ tests drive this screen against fakes and no network.
21
+
22
+ The screen never displays a secret. A pasted key lives in one local for the
23
+ length of the handler that saves it, the Input is cleared before anything
24
+ else happens, and every message written to the status line is built from
25
+ names and masks (`********abcd`), never from the value.
26
+
27
+ Two Textual 8.2.8 facts shape the code (checked, not assumed): a thread
28
+ worker's default is `exit_on_error=True`, so an exception escaping one exits
29
+ the WHOLE app -- every worker here is `exit_on_error=False` and routes its
30
+ failures to the status line; and `DataTable.update_cell` keys columns by the
31
+ KEY given to `add_columns((label, key))`, not by the label.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ from dataclasses import dataclass
36
+ from typing import Any, Callable, Mapping
37
+
38
+ from rich.text import Text
39
+ from textual import work
40
+ from textual.app import ComposeResult
41
+ from textual.containers import Horizontal, Vertical, VerticalScroll
42
+ from textual.css.query import NoMatches
43
+ from textual.screen import ModalScreen
44
+ from textual.widgets import Button, DataTable, Input, Label, Select, Static, TabbedContent, TabPane
45
+ from textual.widgets._select import InvalidSelectValueError
46
+
47
+ from agent.cli import art
48
+ from agent.cli.art import animations_enabled
49
+ from agent.router.llm_provider.base import ModelInfo, ProviderStatus
50
+ from agent.router.mapping import Candidate, Task
51
+ from agent.router.overrides import PROVIDER_ONLY
52
+
53
+ __all__ = ["SetupBackend", "SetupScreen", "MappingRow", "pin_options", "NO_PIN"]
54
+
55
+ NO_PIN = ("(no pin — default route)", "")
56
+ CUSTOM = "__custom__"
57
+
58
+ #: Status word -> primitive Rich style. agent/cli/doctor.py's map, without
59
+ #: THEME names, because a DataTable never sees the app's pushed theme.
60
+ _STATUS_STYLE = {
61
+ ProviderStatus.OK: "bold green",
62
+ ProviderStatus.NO_KEY: "dim",
63
+ ProviderStatus.AUTH_FAILED: "bold red",
64
+ ProviderStatus.UNREACHABLE: "yellow",
65
+ ProviderStatus.ERROR: "bold red",
66
+ }
67
+
68
+
69
+ @dataclass
70
+ class SetupBackend:
71
+ """Everything the screen may call. Names match agent/router/setup.py and
72
+ agent/router/overrides.py; see tui.py `_setup_backend` for the wiring."""
73
+
74
+ vendor_rows: Callable[[], list]
75
+ probe: Callable[[str], Any] # -> setup.ProbeResult
76
+ detected_pool: Callable[[], list[ModelInfo]]
77
+ resolve: Callable[[Task], Any] # -> RoutingDecision, may raise
78
+ routes: Callable[[], Mapping[Task, tuple[Candidate, ...]]]
79
+ propose: Callable[[list[ModelInfo]], dict] # -> {Task: automap.Proposal}
80
+ pins: Callable[[], dict[Task, str]]
81
+ set_pin: Callable[[Task, str], None]
82
+ clear_pin: Callable[[Task], None]
83
+ set_key: Callable[[str, str], Any]
84
+ set_base_url: Callable[[str, str], None]
85
+ add_endpoint: Callable[[str, str], None]
86
+ reload: Callable[[], Any]
87
+
88
+
89
+ def pin_options(task: Task, models: list[ModelInfo],
90
+ routes: Mapping[Task, tuple[Candidate, ...]]) -> list[tuple[str, str]]:
91
+ """The Select options for one task: no pin, then every model the seat
92
+ could use -- the head candidate's `requires`, narrowed to the provider a
93
+ task is bound to (agent/router/overrides.py PROVIDER_ONLY)."""
94
+ head = routes[task][0]
95
+ bound = PROVIDER_ONLY.get(task)
96
+ eligible = [
97
+ m for m in models
98
+ if head.requires <= m.capabilities and (bound is None or m.provider == bound[0])
99
+ ]
100
+ eligible.sort(key=lambda m: (m.provider, m.id))
101
+ return [NO_PIN] + [(m.spec, m.spec) for m in eligible]
102
+
103
+
104
+ class MappingRow(Horizontal):
105
+ """One task: its name, what resolves now, the proposal, and the pick."""
106
+
107
+ DEFAULT_CSS = """
108
+ MappingRow { height: 3; }
109
+ MappingRow > Label { width: 14; padding-top: 1; }
110
+ MappingRow > .current { width: 1fr; padding-top: 1; }
111
+ MappingRow > .proposal { width: 1fr; padding-top: 1; color: $text-muted; }
112
+ MappingRow > Select { width: 44; }
113
+ """
114
+
115
+ def __init__(self, task: Task) -> None:
116
+ super().__init__(id=f"row-{task.value}")
117
+ # `seat`, not `task`: Widget already has a `task` property (its message-pump task).
118
+ self.seat = task
119
+
120
+ def compose(self) -> ComposeResult:
121
+ yield Label(self.seat.value)
122
+ yield Static(Text("not probed yet", style="dim"), classes="current")
123
+ yield Static(Text(""), classes="proposal")
124
+ yield Select([NO_PIN], allow_blank=False, id=f"pin-{self.seat.value}")
125
+
126
+ @property
127
+ def select(self) -> Select:
128
+ return self.query_one(Select)
129
+
130
+
131
+ class SetupScreen(ModalScreen[dict | None]):
132
+ DEFAULT_CSS = """
133
+ SetupScreen { align: center middle; }
134
+ SetupScreen > Vertical { width: 96%; max-width: 120; height: 92%; border: round $accent; padding: 0 1; }
135
+ SetupScreen #banner { height: auto; margin-bottom: 1; }
136
+ SetupScreen TabbedContent { height: 1fr; }
137
+ SetupScreen DataTable { height: 1fr; }
138
+ SetupScreen .row { height: auto; }
139
+ SetupScreen .row > Select { width: 28; }
140
+ SetupScreen .row > Input { width: 1fr; }
141
+ SetupScreen #custom { height: auto; display: none; }
142
+ SetupScreen #custom.shown { display: block; }
143
+ SetupScreen .buttons { height: auto; margin-top: 1; }
144
+ SetupScreen .buttons Button { margin-right: 1; min-width: 8; }
145
+ SetupScreen #mapping-rows { height: 1fr; }
146
+ SetupScreen #setup-status { height: 1; color: $text-muted; }
147
+ """
148
+
149
+ def __init__(self, backend: SetupBackend) -> None:
150
+ super().__init__()
151
+ self.backend = backend
152
+ self._rows: dict[str, Any] = {} # name -> VendorRow
153
+ self._probing: set[str] = set()
154
+ self._ok: set[str] = set()
155
+ self._models: list[ModelInfo] = []
156
+ self._pending: dict[Task, str] = {}
157
+ #: What each row's Select was last SET to by code. `Select.Changed`
158
+ #: is a message, delivered after the setter returns, so a flag held
159
+ #: around the assignment is already down by the time the echo lands
160
+ #: -- the echo is told apart by its value instead.
161
+ self._shown: dict[Task, str] = {}
162
+ self._applying = False
163
+ self._warned = False
164
+ self._spin_timer = None
165
+ self._spin = 0
166
+ self._note = ""
167
+
168
+ # ---- widgets ----------------------------------------------------------
169
+
170
+ def compose(self) -> ComposeResult:
171
+ with Vertical():
172
+ banner = art.art_text(art.WORDMARK_SMALL, art.WORDMARK_STYLE)
173
+ banner.append(" setup", style=art.TAGLINE_STYLE)
174
+ yield Static(banner, id="banner")
175
+ with TabbedContent(id="tabs"):
176
+ with TabPane("1 · Providers", id="providers"):
177
+ yield DataTable(id="provider-table")
178
+ with Horizontal(classes="row"):
179
+ yield Select([("custom endpoint…", CUSTOM)], allow_blank=False, id="key-provider")
180
+ yield Input(password=True, placeholder="paste an API key, Enter saves it to .env", id="key-input")
181
+ with Vertical(id="custom"):
182
+ yield Input(placeholder="name (becomes NAME_API_KEY / NAME_BASE_URL), e.g. ollama",
183
+ restrict=r"[A-Za-z0-9_-]*", id="custom-name")
184
+ yield Input(placeholder="base URL, e.g. http://localhost:11434/v1", id="custom-url")
185
+ yield Input(password=True, placeholder="API key (any non-empty value for a server that ignores keys)",
186
+ id="custom-key")
187
+ with Horizontal(classes="buttons"):
188
+ yield Button("Probe providers", id="probe", variant="primary")
189
+ yield Button("Next: models ›", id="to-models")
190
+ with TabPane("2 · Models", id="models"):
191
+ yield DataTable(id="model-table", cursor_type="row", zebra_stripes=True)
192
+ with Horizontal(classes="buttons"):
193
+ yield Button("Refresh", id="refresh-models")
194
+ yield Button("Next: mapping ›", id="to-mapping")
195
+ with TabPane("3 · Mapping", id="mapping"):
196
+ with VerticalScroll(id="mapping-rows"):
197
+ for task in Task:
198
+ yield MappingRow(task)
199
+ with Horizontal(classes="buttons"):
200
+ yield Button("Apply auto-map", id="automap")
201
+ yield Button("Clear pin", id="clear-pin")
202
+ yield Button("Save", id="save", variant="success")
203
+ yield Button("Close", id="close")
204
+ yield Static("", id="setup-status")
205
+
206
+ def on_mount(self) -> None:
207
+ table = self.query_one("#provider-table", DataTable)
208
+ table.add_columns(("provider", "provider"), ("key", "key"), ("status", "status"),
209
+ ("models", "models"), ("detail", "detail"))
210
+ models = self.query_one("#model-table", DataTable)
211
+ models.add_columns(("provider", "provider"), ("model", "model"), ("context", "context"),
212
+ ("max out", "maxout"), ("capabilities", "caps"))
213
+ self._fill_rows()
214
+ self._status("probe to check keys, or paste one")
215
+ self.query_one("#key-input", Input).focus()
216
+
217
+ # ---- helpers ----------------------------------------------------------
218
+
219
+ def _status(self, text: str, style: str = "dim") -> None:
220
+ try:
221
+ self.query_one("#setup-status", Static).update(Text(text, style=style))
222
+ except NoMatches:
223
+ pass
224
+
225
+ def _ui(self, fn: Callable, *args) -> None:
226
+ """From a worker: run `fn(*args)` on the UI thread, tolerating a
227
+ screen that was dismissed while the worker was still going."""
228
+ def safely() -> None:
229
+ try:
230
+ fn(*args)
231
+ except NoMatches:
232
+ pass
233
+ except Exception as exc: # never let a UI update kill the worker
234
+ self._status(f"{type(exc).__name__}: {exc}", "red")
235
+ self.app.call_from_thread(safely)
236
+
237
+ def _fill_rows(self) -> None:
238
+ """Provider rows from the environment -- no network."""
239
+ try:
240
+ rows = self.backend.vendor_rows()
241
+ except Exception as exc:
242
+ self._status(f"could not list providers: {exc}", "red")
243
+ return
244
+ table = self.query_one("#provider-table", DataTable)
245
+ select = self.query_one("#key-provider", Select)
246
+ previous = select.value if select.value is not Select.BLANK else None
247
+ for row in rows:
248
+ self._rows[row.name] = row
249
+ key_cell = Text(row.masked_key, style="dim" if not row.key_present else "")
250
+ if row.custom and not row.url_present:
251
+ key_cell = Text(f"{row.masked_key} · {row.url_var} not set", style="yellow")
252
+ if row.name in table.rows:
253
+ table.update_cell(row.name, "key", key_cell)
254
+ else:
255
+ table.add_row(Text(row.label), key_cell, Text("not probed", style="dim"),
256
+ Text(""), Text(""), key=row.name)
257
+ options = [(r.label, r.name) for r in rows] + [("custom endpoint…", CUSTOM)]
258
+ self._applying = True
259
+ try:
260
+ select.set_options(options)
261
+ # Keep what the person had chosen; on the first fill the only
262
+ # option was the custom placeholder, so start on the first vendor.
263
+ if previous in {v for _, v in options} and previous != CUSTOM:
264
+ select.value = previous
265
+ elif rows:
266
+ select.value = rows[0].name
267
+ finally:
268
+ self._applying = False
269
+ self.query_one("#custom").set_class(select.value == CUSTOM, "shown")
270
+
271
+ def _set_report(self, name: str, result: Any) -> None:
272
+ self._probing.discard(name)
273
+ report = result.report
274
+ table = self.query_one("#provider-table", DataTable)
275
+ if name not in table.rows:
276
+ self._fill_rows()
277
+ if name not in table.rows:
278
+ return
279
+ table.update_cell(name, "status", Text(report.status.value, style=_STATUS_STYLE.get(report.status, "")))
280
+ table.update_cell(name, "models", Text(str(len(result.models) or report.model_count or "")))
281
+ table.update_cell(name, "detail", Text(report.detail or "", style="dim"))
282
+ if report.ok:
283
+ self._ok.add(name)
284
+ else:
285
+ self._ok.discard(name)
286
+
287
+ def _set_models(self, models: list[ModelInfo]) -> None:
288
+ self._models = list(models)
289
+ table = self.query_one("#model-table", DataTable)
290
+ table.clear()
291
+ for m in models:
292
+ table.add_row(
293
+ m.provider, m.id,
294
+ f"{m.context_window:,}" if m.context_window else "—",
295
+ f"{m.max_output_tokens:,}" if m.max_output_tokens else "—",
296
+ " ".join(sorted(c.value for c in m.capabilities)),
297
+ key=m.spec,
298
+ )
299
+
300
+ def _set_mapping(self, current: dict[Task, str], proposals: dict, models: list[ModelInfo]) -> None:
301
+ routes = self.backend.routes()
302
+ try:
303
+ pins = self.backend.pins()
304
+ except Exception:
305
+ pins = {}
306
+ self._applying = True
307
+ try:
308
+ for row in self.query(MappingRow):
309
+ task = row.seat
310
+ row.query_one(".current", Static).update(Text(current.get(task, "none")))
311
+ proposal = proposals.get(task)
312
+ if proposal is None:
313
+ note = Text("")
314
+ elif proposal.model is None:
315
+ note = Text(proposal.reason, style="yellow")
316
+ else:
317
+ note = Text(f"{proposal.spec} — {proposal.reason}", style="dim")
318
+ row.query_one(".proposal", Static).update(note)
319
+ options = pin_options(task, models, routes)
320
+ row.select.set_options(options)
321
+ wanted = self._pending.get(task, pins.get(task, ""))
322
+ try:
323
+ row.select.value = wanted
324
+ except InvalidSelectValueError:
325
+ row.select.value = ""
326
+ if wanted:
327
+ row.query_one(".proposal", Static).update(
328
+ Text(f"pinned {wanted} is not in the catalogue", style="yellow"))
329
+ self._shown[task] = "" if row.select.value is Select.BLANK else str(row.select.value)
330
+ finally:
331
+ self._applying = False
332
+ self._proposals = proposals
333
+
334
+ def _start_spinner(self) -> None:
335
+ if self._spin_timer is None and animations_enabled(self.app):
336
+ self._spin_timer = self.set_interval(0.1, self._advance_spinner)
337
+
338
+ def _advance_spinner(self) -> None:
339
+ if not self._probing:
340
+ return
341
+ self._spin += 1
342
+ glyph = art.SPINNER[self._spin % len(art.SPINNER)]
343
+ table = self.query_one("#provider-table", DataTable)
344
+ for name in list(self._probing):
345
+ if name in table.rows:
346
+ table.update_cell(name, "status", Text(glyph, style="dim"))
347
+
348
+ def _probe_done(self) -> None:
349
+ self._probing.clear()
350
+ note = f"{self._note} · " if self._note else ""
351
+ self._status(f"{note}probe finished — see Models and Mapping")
352
+
353
+ # ---- the probe: everything that touches the network ------------------
354
+
355
+ def _begin_probe(self, names: list[str], note: str = "") -> None:
356
+ """`note` is what just happened ("saved OPENAI_API_KEY to .env"); it
357
+ stays on the status line through the probe it triggered."""
358
+ self._note = note
359
+ table = self.query_one("#provider-table", DataTable)
360
+ for name in names:
361
+ self._probing.add(name)
362
+ if name in table.rows:
363
+ table.update_cell(name, "status", Text("probing…" if not animations_enabled(self.app)
364
+ else art.SPINNER[0], style="dim"))
365
+ self._status((f"{note} · " if note else "") + f"probing {', '.join(names)}…")
366
+ self._start_spinner()
367
+ self._probe(names)
368
+
369
+ @work(thread=True, exit_on_error=False)
370
+ def _probe(self, names: list[str]) -> None:
371
+ for name in names:
372
+ try:
373
+ result = self.backend.probe(name)
374
+ except Exception as exc:
375
+ self._ui(self._status, f"probe of {name} failed: {type(exc).__name__}: {exc}", "red")
376
+ self._probing.discard(name)
377
+ continue
378
+ self._ui(self._set_report, name, result)
379
+ try:
380
+ models = self.backend.detected_pool()
381
+ except Exception as exc:
382
+ self._ui(self._status, f"could not list models: {type(exc).__name__}: {exc}", "red")
383
+ models = []
384
+ self._ui(self._set_models, models)
385
+
386
+ current: dict[Task, str] = {}
387
+ for task in Task:
388
+ try:
389
+ d = self.backend.resolve(task)
390
+ current[task] = f"{d.provider}:{d.model.id}" + (" (degraded)" if d.fell_back else "")
391
+ except Exception:
392
+ current[task] = "none"
393
+ try:
394
+ proposals = self.backend.propose(models)
395
+ except Exception as exc:
396
+ self._ui(self._status, f"auto-map failed: {type(exc).__name__}: {exc}", "red")
397
+ proposals = {}
398
+ self._ui(self._set_mapping, current, proposals, models)
399
+ self._ui(self._probe_done)
400
+
401
+ # ---- keys and endpoints ------------------------------------------------
402
+
403
+ def on_input_submitted(self, event: Input.Submitted) -> None:
404
+ # FIRST -- or the App starts a pipeline turn on the key (modals.py).
405
+ event.stop()
406
+ iid = event.input.id
407
+ if iid == "key-input":
408
+ value = event.value
409
+ event.input.value = ""
410
+ self._save_key(value)
411
+ elif iid in ("custom-name", "custom-url"):
412
+ self.query_one("#custom-key", Input).focus()
413
+ elif iid == "custom-key":
414
+ self._save_custom()
415
+
416
+ def _save_key(self, value: str) -> None:
417
+ name = self.query_one("#key-provider", Select).value
418
+ if name is Select.BLANK or name is None:
419
+ self._status("choose a provider first", "yellow")
420
+ return
421
+ if name == CUSTOM:
422
+ self._status("fill in the custom endpoint form below", "yellow")
423
+ self.query_one("#custom-name", Input).focus()
424
+ return
425
+ if not value.strip():
426
+ self._status("nothing pasted", "yellow")
427
+ return
428
+ try:
429
+ self.backend.set_key(name, value)
430
+ except Exception as exc:
431
+ self._status(f"could not save the key: {type(exc).__name__}: {exc}", "red")
432
+ return
433
+ self._fill_rows()
434
+ var = self._rows[name].key_var if name in self._rows else f"{name.upper()}_API_KEY"
435
+ self._begin_probe([name], note=f"saved {var} to .env")
436
+
437
+ def _save_custom(self) -> None:
438
+ name = self.query_one("#custom-name", Input).value.strip().lower()
439
+ url = self.query_one("#custom-url", Input).value.strip()
440
+ key_input = self.query_one("#custom-key", Input)
441
+ key = key_input.value
442
+ key_input.value = ""
443
+ if not name or not url or not key.strip():
444
+ self._status("a custom endpoint needs a name, a base URL and a key", "yellow")
445
+ return
446
+ try:
447
+ self.backend.add_endpoint(name, name)
448
+ self.backend.set_base_url(name, url)
449
+ self.backend.set_key(name, key)
450
+ except Exception as exc:
451
+ self._status(f"could not add {name}: {type(exc).__name__}: {exc}", "red")
452
+ return
453
+ self._fill_rows()
454
+ self._begin_probe([name], note=f"added {name}: {name.upper().replace('-', '_')}_API_KEY and _BASE_URL saved to .env")
455
+
456
+ def on_select_changed(self, event: Select.Changed) -> None:
457
+ event.stop()
458
+ sid = event.select.id or ""
459
+ if sid == "key-provider":
460
+ self.query_one("#custom").set_class(event.value == CUSTOM, "shown")
461
+ if event.value == CUSTOM:
462
+ self.query_one("#custom-name", Input).focus()
463
+ return
464
+ if sid.startswith("pin-"):
465
+ task = Task(sid[len("pin-"):])
466
+ value = "" if event.value is Select.BLANK else str(event.value)
467
+ if self._applying or value == self._shown.get(task, ""):
468
+ return # the echo of a programmatic set, not a pick
469
+ self._shown[task] = value
470
+ self._pending[task] = value
471
+ self._warned = False
472
+
473
+ # ---- buttons ----------------------------------------------------------
474
+
475
+ def on_button_pressed(self, event: Button.Pressed) -> None:
476
+ event.stop()
477
+ bid = event.button.id
478
+ tabs = self.query_one("#tabs", TabbedContent)
479
+ if bid in ("probe", "refresh-models"):
480
+ self._begin_probe(list(self._rows) or [r.name for r in self.backend.vendor_rows()])
481
+ elif bid == "to-models":
482
+ tabs.active = "models"
483
+ elif bid == "to-mapping":
484
+ tabs.active = "mapping"
485
+ elif bid == "automap":
486
+ self._apply_automap()
487
+ elif bid == "clear-pin":
488
+ self._clear_focused_pin()
489
+ elif bid == "save":
490
+ self._save()
491
+ elif bid == "close":
492
+ self.action_close()
493
+
494
+ def _apply_automap(self) -> None:
495
+ proposals = getattr(self, "_proposals", None) or {}
496
+ if not proposals:
497
+ self._status("probe first — auto-map needs the catalogue", "yellow")
498
+ return
499
+ applied = 0
500
+ self._applying = True
501
+ try:
502
+ for row in self.query(MappingRow):
503
+ proposal = proposals.get(row.seat)
504
+ if proposal is None or proposal.model is None or not proposal.needs_pin:
505
+ continue
506
+ spec = proposal.spec
507
+ if spec not in {v for _, v in row.select._options}:
508
+ continue
509
+ row.select.value = spec
510
+ self._shown[row.seat] = spec
511
+ self._pending[row.seat] = spec
512
+ applied += 1
513
+ finally:
514
+ self._applying = False
515
+ self._status(f"auto-map set {applied} pin(s); Save to keep them")
516
+
517
+ def _clear_focused_pin(self) -> None:
518
+ node = self.focused
519
+ while node is not None and not isinstance(node, MappingRow):
520
+ node = node.parent
521
+ if node is None:
522
+ self._status("focus a task row first", "yellow")
523
+ return
524
+ self._applying = True
525
+ try:
526
+ node.select.value = ""
527
+ finally:
528
+ self._applying = False
529
+ self._shown[node.seat] = ""
530
+ self._pending[node.seat] = ""
531
+ self._status(f"{node.seat.value}: pin cleared (Save to keep)")
532
+
533
+ def _save(self) -> None:
534
+ pinned: dict[str, str] = {}
535
+ cleared: list[str] = []
536
+ problems: list[str] = []
537
+ for task, spec in self._pending.items():
538
+ try:
539
+ if spec:
540
+ self.backend.set_pin(task, spec)
541
+ pinned[task.value] = spec
542
+ else:
543
+ self.backend.clear_pin(task)
544
+ cleared.append(task.value)
545
+ except Exception as exc:
546
+ problems.append(f"{task.value}: {exc}")
547
+ try:
548
+ problems.extend(list(self.backend.reload() or []))
549
+ except Exception as exc:
550
+ problems.append(f"reload: {type(exc).__name__}: {exc}")
551
+ self.dismiss({"pinned": pinned, "cleared": cleared,
552
+ "providers_ok": sorted(self._ok), "problems": problems})
553
+
554
+ def action_close(self) -> None:
555
+ if self._pending and not self._warned:
556
+ self._warned = True
557
+ self._status("unsaved pin changes — Esc again to discard, or Save", "yellow")
558
+ return
559
+ self.dismiss(None)
560
+
561
+ def key_escape(self) -> None:
562
+ self.action_close()