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
|
@@ -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()
|