zettcode-app 0.4.2__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.
- zettcode/app/__init__.py +63 -0
- zettcode/app/agent/__init__.py +47 -0
- zettcode/app/agent/agent/__init__.py +84 -0
- zettcode/app/agent/agent/commands.py +269 -0
- zettcode/app/agent/agent/settings.py +132 -0
- zettcode/app/agent/conversation/__init__.py +7 -0
- zettcode/app/agent/conversation/changes.py +64 -0
- zettcode/app/agent/conversation/effort_gauge.py +126 -0
- zettcode/app/agent/conversation/projection.py +265 -0
- zettcode/app/agent/conversation/rendering.py +837 -0
- zettcode/app/agent/conversation/replay.py +198 -0
- zettcode/app/agent/conversation/shimmer.py +256 -0
- zettcode/app/agent/conversation/stops.py +57 -0
- zettcode/app/agent/conversation/tips.py +86 -0
- zettcode/app/agent/conversation/transcript/__init__.py +80 -0
- zettcode/app/agent/conversation/transcript/fold.py +57 -0
- zettcode/app/agent/conversation/transcript/log.py +182 -0
- zettcode/app/agent/conversation/transcript/oneoff.py +55 -0
- zettcode/app/agent/conversation/transcript/stream.py +187 -0
- zettcode/app/agent/conversation/transcript/turn.py +323 -0
- zettcode/app/commandline/__init__.py +47 -0
- zettcode/app/commandline/arguments.py +169 -0
- zettcode/app/commandline/completion.py +262 -0
- zettcode/app/commandline/history.py +305 -0
- zettcode/app/commandline/runner.py +365 -0
- zettcode/app/errors.py +12 -0
- zettcode/app/plugins/__init__.py +110 -0
- zettcode/app/plugins/builtins.py +33 -0
- zettcode/app/plugins/groups.py +503 -0
- zettcode/app/plugins/loader.py +268 -0
- zettcode/app/py.typed +0 -0
- zettcode/app/ui/__init__.py +63 -0
- zettcode/app/ui/app.py +1507 -0
- zettcode/app/ui/bars.py +290 -0
- zettcode/app/ui/boot.py +131 -0
- zettcode/app/ui/clipboard.py +340 -0
- zettcode/app/ui/commands.py +323 -0
- zettcode/app/ui/config_form.py +343 -0
- zettcode/app/ui/demo.py +180 -0
- zettcode/app/ui/followups.py +430 -0
- zettcode/app/ui/gc_policy.py +147 -0
- zettcode/app/ui/keys.py +462 -0
- zettcode/app/ui/messaging.py +260 -0
- zettcode/app/ui/notices.py +195 -0
- zettcode/app/ui/providers.py +626 -0
- zettcode/app/ui/sessions.py +422 -0
- zettcode/app/ui/settings.py +161 -0
- zettcode/app/ui/shell.py +361 -0
- zettcode/app/ui/turns.py +115 -0
- zettcode/app/ui/updating.py +204 -0
- zettcode/app/ui/widgets/__init__.py +161 -0
- zettcode/app/ui/widgets/approval_page.py +284 -0
- zettcode/app/ui/widgets/ask_page.py +386 -0
- zettcode/app/ui/widgets/bash_page.py +90 -0
- zettcode/app/ui/widgets/completer.py +100 -0
- zettcode/app/ui/widgets/composer.py +529 -0
- zettcode/app/ui/widgets/document_page.py +159 -0
- zettcode/app/ui/widgets/effort_page.py +294 -0
- zettcode/app/ui/widgets/follow_up.py +168 -0
- zettcode/app/ui/widgets/form_page.py +380 -0
- zettcode/app/ui/widgets/input_page.py +175 -0
- zettcode/app/ui/widgets/model_page.py +63 -0
- zettcode/app/ui/widgets/model_settings_page.py +653 -0
- zettcode/app/ui/widgets/pet.py +247 -0
- zettcode/app/ui/widgets/pet_page.py +347 -0
- zettcode/app/ui/widgets/plugins_page.py +442 -0
- zettcode/app/ui/widgets/root.py +68 -0
- zettcode/app/ui/widgets/self_compact_page.py +363 -0
- zettcode/app/ui/widgets/send_kind_page.py +193 -0
- zettcode/app/ui/widgets/sessions_page.py +511 -0
- zettcode/app/ui/widgets/statusline_page.py +427 -0
- zettcode/app/ui/widgets/steering.py +126 -0
- zettcode/app/ui/widgets/transcript.py +287 -0
- zettcode/app/ui/widgets/update_page.py +65 -0
- zettcode/app/ui/widgets/welcome.py +24 -0
- zettcode/collector.py +54 -0
- zettcode/update.py +519 -0
- zettcode_app-0.4.2.dist-info/METADATA +26 -0
- zettcode_app-0.4.2.dist-info/RECORD +81 -0
- zettcode_app-0.4.2.dist-info/WHEEL +4 -0
- zettcode_app-0.4.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
"""``/config``: add, change and remove the models in the config file from inside the shell.
|
|
2
|
+
|
|
3
|
+
The pages are the shell's usual ones — a list to choose from, a form to fill in — stacked one after another; this
|
|
4
|
+
module is the order they come in. Each step replaces the page before it, Escape on any step abandons the whole thing,
|
|
5
|
+
and nothing is written until the last answer is in. What is written is decided by :mod:`.config_form` and
|
|
6
|
+
:mod:`zettcode.config_edit`; what this module adds is only the sequence and the sentence the reader is left with.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
from typing import TYPE_CHECKING, cast
|
|
13
|
+
|
|
14
|
+
from zettcode.presenters.text import compact_path
|
|
15
|
+
from zettcode.providers import API_FLAG_KEYS, CUSTOM, Api, check_url
|
|
16
|
+
from zettcode.sdk.errors import ConfigurationError
|
|
17
|
+
from zettcode.tui import SEPARATOR, ListItem, ListPage, Screen, Widget
|
|
18
|
+
|
|
19
|
+
from .config_form import (
|
|
20
|
+
Draft,
|
|
21
|
+
describe,
|
|
22
|
+
entry_for,
|
|
23
|
+
limits_text,
|
|
24
|
+
parse_add,
|
|
25
|
+
parse_percent,
|
|
26
|
+
parse_temperature,
|
|
27
|
+
parse_tokens,
|
|
28
|
+
whole_if_whole,
|
|
29
|
+
)
|
|
30
|
+
from .shell import PAGE_SCREEN, ShellState
|
|
31
|
+
from .widgets import Field, FormPage, ModelSettingsPage, bottom_panel, form_rows, model_settings_rows
|
|
32
|
+
from .widgets.model_settings_page import Action, Answer, Setting
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only
|
|
35
|
+
from zettcode.providers import Provider
|
|
36
|
+
from zettcode.sdk.types import ModelInfo
|
|
37
|
+
|
|
38
|
+
#: Rows the list pages take: the title, a blank, up to eight rows, the keys.
|
|
39
|
+
LIST_ROWS = 13
|
|
40
|
+
|
|
41
|
+
#: Columns the file's path may take under the title; a longer one keeps its end, where the name is.
|
|
42
|
+
SUBTITLE_COLUMNS = 70
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _Refused(Exception):
|
|
46
|
+
"""A change the file would not take, and the row of the panel that has to be corrected for it to be."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, key: str, reason: str) -> None:
|
|
49
|
+
"""Hold the key of the row (empty when it is none) and the sentence that says what is wrong."""
|
|
50
|
+
super().__init__(reason)
|
|
51
|
+
self.key = key
|
|
52
|
+
self.reason = reason
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ConfigMixin(ShellState):
|
|
56
|
+
"""Walk the reader through changing the models, and apply the result to the running session."""
|
|
57
|
+
|
|
58
|
+
# -- showing a page ------------------------------------------------------------------
|
|
59
|
+
def _config_show(self, page: Widget, *, rows: int, replace: bool) -> None:
|
|
60
|
+
"""Put one step of the flow on screen, in place of the one before it when there is one."""
|
|
61
|
+
if replace:
|
|
62
|
+
self.app.pop_screen()
|
|
63
|
+
self.app.push_screen(Screen(bottom_panel(page, rows=rows), name=PAGE_SCREEN, modal=True))
|
|
64
|
+
self.app.request_layout()
|
|
65
|
+
|
|
66
|
+
def _config_end(self, message: str | None = None, *, error: bool = False) -> None:
|
|
67
|
+
"""Close the flow and say how it ended."""
|
|
68
|
+
self.close_page()
|
|
69
|
+
if message:
|
|
70
|
+
(self.transcript.error if error else self.transcript.notice)(message)
|
|
71
|
+
self.app.invalidate()
|
|
72
|
+
|
|
73
|
+
def _config_failed(self, error: Exception) -> None:
|
|
74
|
+
"""Close the flow on a refusal; the file was not touched."""
|
|
75
|
+
self._config_end(f"/config: {error}", error=True)
|
|
76
|
+
|
|
77
|
+
# -- the first page ------------------------------------------------------------------
|
|
78
|
+
def config_page(self) -> Widget:
|
|
79
|
+
"""Return the models in the file, and a row to add another.
|
|
80
|
+
|
|
81
|
+
The list is read from the file rather than from the session: it is what an edit will act
|
|
82
|
+
on, and a reader who changed the file by hand since the start sees that here.
|
|
83
|
+
"""
|
|
84
|
+
entries = self.client.model_entries()
|
|
85
|
+
digits = len(str(len(entries)))
|
|
86
|
+
rows = [
|
|
87
|
+
ListItem(
|
|
88
|
+
index,
|
|
89
|
+
f"{index + 1:>{digits}}. {model.name}{' (current)' if model.active else ''}",
|
|
90
|
+
f"{model.model} {SEPARATOR} {model.api} {SEPARATOR} {model.base_url or 'default address'}",
|
|
91
|
+
)
|
|
92
|
+
for index, (model, _) in enumerate(entries)
|
|
93
|
+
]
|
|
94
|
+
rows.append(ListItem(None, "+ Add a model", "from a provider, or any endpoint"))
|
|
95
|
+
page = ListPage(
|
|
96
|
+
rows,
|
|
97
|
+
title="Models",
|
|
98
|
+
subtitle=compact_path(self.client.config_file, limit=SUBTITLE_COLUMNS),
|
|
99
|
+
on_select=self._config_chosen,
|
|
100
|
+
on_cancel=self.close_page,
|
|
101
|
+
)
|
|
102
|
+
return bottom_panel(page, rows=LIST_ROWS)
|
|
103
|
+
|
|
104
|
+
def _config_chosen(self, item: ListItem) -> None:
|
|
105
|
+
"""A row of the model list: a model to change, or the row that adds one."""
|
|
106
|
+
if item.value is None:
|
|
107
|
+
self._config_pick_provider(replace=True)
|
|
108
|
+
else:
|
|
109
|
+
self._config_actions(int(item.value), replace=True) # type: ignore[call-overload]
|
|
110
|
+
|
|
111
|
+
# -- adding --------------------------------------------------------------------------
|
|
112
|
+
def _config_pick_provider(self, *, replace: bool) -> None:
|
|
113
|
+
"""Ask which host the model is at."""
|
|
114
|
+
rows = [
|
|
115
|
+
ListItem(provider, provider.label, provider.note or provider.api.description)
|
|
116
|
+
for provider in self.agent.providers
|
|
117
|
+
]
|
|
118
|
+
rows.append(ListItem(CUSTOM, "Custom", "any endpoint; you give the address and the protocol"))
|
|
119
|
+
page = ListPage(
|
|
120
|
+
rows,
|
|
121
|
+
title="Add a model \u2014 which provider?",
|
|
122
|
+
on_select=lambda item: self._config_begin(None if item.value == CUSTOM else cast("Provider", item.value)),
|
|
123
|
+
on_cancel=self._config_back,
|
|
124
|
+
)
|
|
125
|
+
self._config_show(page, rows=LIST_ROWS, replace=replace)
|
|
126
|
+
|
|
127
|
+
def _config_back(self) -> None:
|
|
128
|
+
"""Go back to the model list."""
|
|
129
|
+
try:
|
|
130
|
+
page = self.config_page()
|
|
131
|
+
except ConfigurationError as error:
|
|
132
|
+
self._config_failed(error)
|
|
133
|
+
return
|
|
134
|
+
self.app.pop_screen()
|
|
135
|
+
self.app.push_screen(Screen(page, name=PAGE_SCREEN, modal=True))
|
|
136
|
+
self.app.request_layout()
|
|
137
|
+
|
|
138
|
+
def _config_begin(self, provider: Provider | None) -> None:
|
|
139
|
+
"""Start asking what the provider does not already say."""
|
|
140
|
+
draft = Draft.for_provider(provider)
|
|
141
|
+
draft.from_panel = True
|
|
142
|
+
self._config_ask(draft)
|
|
143
|
+
|
|
144
|
+
def _config_step_back(self, draft: Draft) -> None:
|
|
145
|
+
"""Escape in the form: take back the last choice made before it and ask it again.
|
|
146
|
+
|
|
147
|
+
That choice is the protocol of a custom endpoint, the one thing asked on a page of its own. With none behind
|
|
148
|
+
it, Escape goes to the provider list when the flow came from there, and closes when it was started from a
|
|
149
|
+
typed line, which has nothing behind it.
|
|
150
|
+
"""
|
|
151
|
+
previous = draft.undo()
|
|
152
|
+
if previous is not None:
|
|
153
|
+
self._config_ask(draft)
|
|
154
|
+
elif draft.from_panel:
|
|
155
|
+
self._config_pick_provider(replace=True)
|
|
156
|
+
else:
|
|
157
|
+
self._config_end()
|
|
158
|
+
|
|
159
|
+
def _config_ask(self, draft: Draft, *, replace: bool = True) -> None:
|
|
160
|
+
"""Ask for what a draft is missing, or write it when nothing is.
|
|
161
|
+
|
|
162
|
+
A custom endpoint is asked which protocol it speaks, on a list of its own, because that is a choice among three
|
|
163
|
+
words and not text. Everything else is one form: the model id, the token, the address and a name, with the cursor
|
|
164
|
+
moving between them by Tab and Enter saving all of it.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
draft: What is known so far.
|
|
168
|
+
replace: Whether a page of this flow is on screen to be replaced. The form of ``/config add`` typed with
|
|
169
|
+
something left out has none.
|
|
170
|
+
"""
|
|
171
|
+
wanted = draft.missing()
|
|
172
|
+
if not wanted:
|
|
173
|
+
self._config_write(draft)
|
|
174
|
+
elif wanted[0] == "api":
|
|
175
|
+
self._config_ask_api(draft, replace=replace)
|
|
176
|
+
else:
|
|
177
|
+
self._config_add_form(draft, replace=replace)
|
|
178
|
+
|
|
179
|
+
def _config_add_form(self, draft: Draft, *, replace: bool) -> None:
|
|
180
|
+
"""Show the form that adds a model: only the model id has to be filled in."""
|
|
181
|
+
host = draft.provider.label if draft.provider is not None else "a custom endpoint"
|
|
182
|
+
suggestions = draft.provider.models if draft.provider is not None else ()
|
|
183
|
+
token_note = (
|
|
184
|
+
f"Your key; the vendor's own tools read it from ${draft.provider.token_env}. Leave it empty for a local server."
|
|
185
|
+
if draft.provider is not None and draft.provider.token_env
|
|
186
|
+
else "Your key for this endpoint. Leave it empty for a local server, or to use the environment's."
|
|
187
|
+
)
|
|
188
|
+
fields = [
|
|
189
|
+
Field(
|
|
190
|
+
"model",
|
|
191
|
+
"Model id",
|
|
192
|
+
initial=draft.model,
|
|
193
|
+
placeholder=suggestions[0] if suggestions else "",
|
|
194
|
+
hint="The id the provider calls the model, exactly as its documentation writes it."
|
|
195
|
+
+ (f" For example: {', '.join(suggestions[:3])}" if suggestions else ""),
|
|
196
|
+
required=True,
|
|
197
|
+
),
|
|
198
|
+
Field(
|
|
199
|
+
"token",
|
|
200
|
+
"Token",
|
|
201
|
+
initial=draft.token,
|
|
202
|
+
placeholder="optional",
|
|
203
|
+
hint=f"{token_note} It is written to {self.client.config_file}.",
|
|
204
|
+
secret=True,
|
|
205
|
+
),
|
|
206
|
+
]
|
|
207
|
+
if draft.api is not Api.ANTHROPIC:
|
|
208
|
+
fields.append(
|
|
209
|
+
Field(
|
|
210
|
+
"url",
|
|
211
|
+
"Address",
|
|
212
|
+
initial=draft.url or "",
|
|
213
|
+
placeholder="default address" if not draft.custom else "https://api.example.com/v1",
|
|
214
|
+
hint="The address the client appends its route to, such as https://api.example.com/v1.",
|
|
215
|
+
required=draft.custom,
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
fields.append(
|
|
219
|
+
Field(
|
|
220
|
+
"name",
|
|
221
|
+
"Shown name",
|
|
222
|
+
initial=draft.name,
|
|
223
|
+
placeholder="the model id",
|
|
224
|
+
hint="What the header and /model show for it. Leave it empty to show the model id.",
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
fields.append(
|
|
228
|
+
Field(
|
|
229
|
+
"temperature",
|
|
230
|
+
"Temperature",
|
|
231
|
+
initial="" if draft.temperature is None else f"{whole_if_whole(draft.temperature)}",
|
|
232
|
+
placeholder="the provider's default",
|
|
233
|
+
hint="How random the answers are, from 0 (the most deterministic) to "
|
|
234
|
+
+ ("1." if draft.api is Api.ANTHROPIC else "2.")
|
|
235
|
+
+ " Leave it empty to send none.",
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
page = FormPage(
|
|
239
|
+
f"Add a model at {host}",
|
|
240
|
+
fields,
|
|
241
|
+
on_submit=lambda values: self._config_added(draft, values),
|
|
242
|
+
on_cancel=lambda: self._config_step_back(draft),
|
|
243
|
+
cancel_label="esc back" if draft.trail or draft.from_panel else "esc cancel",
|
|
244
|
+
)
|
|
245
|
+
self._config_show(page, rows=form_rows(len(fields)), replace=replace)
|
|
246
|
+
|
|
247
|
+
def _config_added(self, draft: Draft, values: dict[str, str]) -> tuple[str, str] | None:
|
|
248
|
+
"""Take the form's answers into the draft and write it; answer which line is wrong when one is."""
|
|
249
|
+
address = values.get("url", "")
|
|
250
|
+
if address:
|
|
251
|
+
try:
|
|
252
|
+
checked = check_url(address, what="the address")
|
|
253
|
+
except ValueError as error:
|
|
254
|
+
return ("url", str(error))
|
|
255
|
+
draft.url = checked
|
|
256
|
+
elif draft.api is not Api.ANTHROPIC:
|
|
257
|
+
draft.url = None
|
|
258
|
+
draft.given.add("url")
|
|
259
|
+
draft.model = values["model"]
|
|
260
|
+
draft.token = values["token"]
|
|
261
|
+
draft.name = values["name"]
|
|
262
|
+
if values["temperature"]:
|
|
263
|
+
try:
|
|
264
|
+
draft.temperature = parse_temperature(values["temperature"], draft.api)
|
|
265
|
+
except ValueError as error:
|
|
266
|
+
return ("temperature", str(error))
|
|
267
|
+
else:
|
|
268
|
+
draft.temperature = None
|
|
269
|
+
draft.given.add("token")
|
|
270
|
+
self._config_write(draft)
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
def _config_ask_api(self, draft: Draft, *, replace: bool = True) -> None:
|
|
274
|
+
"""Ask which protocol a custom endpoint speaks."""
|
|
275
|
+
rows = [ListItem(api, api.value, api.description) for api in Api]
|
|
276
|
+
page = ListPage(
|
|
277
|
+
rows,
|
|
278
|
+
title="Which API does the endpoint speak?",
|
|
279
|
+
on_select=lambda item: self._config_api_chosen(draft, cast(Api, item.value)),
|
|
280
|
+
on_cancel=lambda: self._config_step_back(draft),
|
|
281
|
+
)
|
|
282
|
+
self._config_show(page, rows=LIST_ROWS, replace=replace)
|
|
283
|
+
|
|
284
|
+
def _config_api_chosen(self, draft: Draft, api: Api) -> None:
|
|
285
|
+
"""Take the protocol picked from the list and ask the rest on one form."""
|
|
286
|
+
draft.api = Api.parse(api)
|
|
287
|
+
draft.chat = draft.api is Api.CHAT
|
|
288
|
+
draft.given.add("api")
|
|
289
|
+
draft.trail.append(("api", api.value))
|
|
290
|
+
self._config_ask(draft)
|
|
291
|
+
|
|
292
|
+
def _config_write(self, draft: Draft) -> None:
|
|
293
|
+
"""Write the finished draft to the file, and announce it.
|
|
294
|
+
|
|
295
|
+
The first model a machine ever has is the one requests go to from then on — there was nothing for it to
|
|
296
|
+
be added beside — so that one is announced as being in use rather than as one more to switch to.
|
|
297
|
+
"""
|
|
298
|
+
first = self.model is None
|
|
299
|
+
try:
|
|
300
|
+
entry = entry_for(draft)
|
|
301
|
+
model = self.client.add_model(entry)
|
|
302
|
+
except ValueError as error: # ConfigurationError is one
|
|
303
|
+
self._config_failed(error)
|
|
304
|
+
return
|
|
305
|
+
if first:
|
|
306
|
+
self._warm_runtime()
|
|
307
|
+
self._config_end(f"added {describe(entry)} \u2014 this is the model requests now use")
|
|
308
|
+
elif model.name != self.client.model.name:
|
|
309
|
+
self._config_end(f"added {describe(entry)} \u2014 /model switches to it")
|
|
310
|
+
else:
|
|
311
|
+
self._config_end(f"added {describe(entry)}")
|
|
312
|
+
|
|
313
|
+
# -- changing one --------------------------------------------------------------------
|
|
314
|
+
def _config_actions(self, index: int, *, replace: bool) -> None:
|
|
315
|
+
"""Show every setting of one model on one page, with what can be done to it under them.
|
|
316
|
+
|
|
317
|
+
The names are on the left and the values on the right: what is typed is an editor, whether images are accepted
|
|
318
|
+
and whether the provider searches the web are switches, the API is a choice among its three. Enter saves all of
|
|
319
|
+
them at once; the two actions at the bottom are switching to the model and removing it.
|
|
320
|
+
"""
|
|
321
|
+
try:
|
|
322
|
+
model, entry = self.client.model_entries()[index]
|
|
323
|
+
except (ConfigurationError, IndexError) as error:
|
|
324
|
+
self._config_failed(error)
|
|
325
|
+
return
|
|
326
|
+
settings = [
|
|
327
|
+
Setting(
|
|
328
|
+
"token",
|
|
329
|
+
"Token",
|
|
330
|
+
"secret",
|
|
331
|
+
placeholder="unchanged",
|
|
332
|
+
hint="Your key. The current one is never shown; leave this empty to keep it.",
|
|
333
|
+
),
|
|
334
|
+
Setting(
|
|
335
|
+
"api",
|
|
336
|
+
"API",
|
|
337
|
+
"choice",
|
|
338
|
+
words=tuple(api.value for api in Api),
|
|
339
|
+
value=list(Api).index(Api.parse(model.api)),
|
|
340
|
+
hint="The protocol the endpoint speaks. The address stays as it is; change it too if this host uses another.",
|
|
341
|
+
),
|
|
342
|
+
Setting(
|
|
343
|
+
"base_url",
|
|
344
|
+
"Address",
|
|
345
|
+
initial=str(entry.get("base_url", "")),
|
|
346
|
+
placeholder="default address",
|
|
347
|
+
hint="Where requests go, such as https://api.example.com/v1. Leave it empty for the API's own.",
|
|
348
|
+
),
|
|
349
|
+
Setting(
|
|
350
|
+
"model",
|
|
351
|
+
"Model id",
|
|
352
|
+
initial=str(entry.get("model", "")),
|
|
353
|
+
hint="The id the provider calls the model, exactly as its documentation writes it.",
|
|
354
|
+
required=True,
|
|
355
|
+
),
|
|
356
|
+
Setting(
|
|
357
|
+
"display_model",
|
|
358
|
+
"Shown name",
|
|
359
|
+
initial=str(entry.get("display_model", "")),
|
|
360
|
+
placeholder="the model id",
|
|
361
|
+
hint="What the header and /model show for it. Leave it empty to show the model id.",
|
|
362
|
+
),
|
|
363
|
+
Setting(
|
|
364
|
+
"multimodal",
|
|
365
|
+
"Images",
|
|
366
|
+
"switch",
|
|
367
|
+
words=("text only", "accepted"),
|
|
368
|
+
value=int(model.multimodal),
|
|
369
|
+
hint="Only the model's documentation knows; a model that cannot read a picture fails the request.",
|
|
370
|
+
),
|
|
371
|
+
Setting(
|
|
372
|
+
"web_search",
|
|
373
|
+
"Web search",
|
|
374
|
+
"switch",
|
|
375
|
+
words=("off", "on"),
|
|
376
|
+
value=int(model.web_search),
|
|
377
|
+
hint="The endpoint runs the search; ZettCode fetches nothing."
|
|
378
|
+
+ ("" if model.can_search else " DeepSeek searches only over the Anthropic API."),
|
|
379
|
+
),
|
|
380
|
+
Setting(
|
|
381
|
+
"web_search_name",
|
|
382
|
+
"Search tool",
|
|
383
|
+
initial=str(entry.get("web_search_name", "")),
|
|
384
|
+
placeholder=f"the default: {model.search_name}",
|
|
385
|
+
hint="The exact tool type the endpoint expects. Leave it empty to send the default for the API.",
|
|
386
|
+
),
|
|
387
|
+
Setting(
|
|
388
|
+
"temperature",
|
|
389
|
+
"Temperature",
|
|
390
|
+
initial=str(entry.get("temperature", "")),
|
|
391
|
+
placeholder="the provider's default",
|
|
392
|
+
hint=f"From 0 (the most deterministic) to {1 if model.api == 'anthropic' else 2}. Leave it empty to send none.",
|
|
393
|
+
),
|
|
394
|
+
Setting(
|
|
395
|
+
"context_window",
|
|
396
|
+
"Context window",
|
|
397
|
+
initial=str(entry.get("context_window", "")),
|
|
398
|
+
placeholder="128,000 tokens",
|
|
399
|
+
hint="How many tokens the model can carry: 128000, 128k or 1m. Leave it empty to use 128,000.",
|
|
400
|
+
),
|
|
401
|
+
Setting(
|
|
402
|
+
"compact_percent",
|
|
403
|
+
"Compress at",
|
|
404
|
+
initial=str(entry.get("compact_percent", "")),
|
|
405
|
+
placeholder="80 (% of the window)",
|
|
406
|
+
hint="When the conversation fills this share of the window it is summarized first: more than 0, at most 100.",
|
|
407
|
+
),
|
|
408
|
+
]
|
|
409
|
+
actions = [
|
|
410
|
+
Action("use", "Use this model", "save any change, then switch to it now", saves=True),
|
|
411
|
+
Action("remove", "Remove", "delete it from the file"),
|
|
412
|
+
]
|
|
413
|
+
page = ModelSettingsPage(
|
|
414
|
+
f"{model.name}{' (current)' if model.active else ''}",
|
|
415
|
+
settings,
|
|
416
|
+
actions,
|
|
417
|
+
on_save=lambda values: self._config_saved(index, values),
|
|
418
|
+
on_action=lambda action, values: self._config_acted(index, action, values),
|
|
419
|
+
on_cancel=self._config_back,
|
|
420
|
+
)
|
|
421
|
+
self._config_show(page, rows=model_settings_rows(len(settings), len(actions)), replace=replace)
|
|
422
|
+
|
|
423
|
+
def _config_saved(self, index: int, values: dict[str, Answer]) -> tuple[str, str] | None:
|
|
424
|
+
"""Write what the page holds, and close it; answer which row is wrong when one is."""
|
|
425
|
+
try:
|
|
426
|
+
model, entry, before = self._config_update(index, values)
|
|
427
|
+
except _Refused as refused:
|
|
428
|
+
return (refused.key, refused.reason)
|
|
429
|
+
except (ConfigurationError, IndexError) as error:
|
|
430
|
+
self._config_failed(error)
|
|
431
|
+
return None
|
|
432
|
+
if entry == before:
|
|
433
|
+
self._config_end(f"{model.name}: nothing changed")
|
|
434
|
+
else:
|
|
435
|
+
limits = any(entry.get(key) != before.get(key) for key in ("context_window", "compact_percent"))
|
|
436
|
+
self._config_end(f"updated {describe(entry)}" + (f" {SEPARATOR} {limits_text(model)}" if limits else ""))
|
|
437
|
+
self.app.request_layout()
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
def _config_acted(self, index: int, action: Action, values: dict[str, Answer]) -> tuple[str, str] | None:
|
|
441
|
+
"""Do what one of the page's actions says: switch to the model, after saving it, or ask about removing it."""
|
|
442
|
+
try:
|
|
443
|
+
if action.key == "remove":
|
|
444
|
+
model, _ = self.client.model_entries()[index]
|
|
445
|
+
self._config_confirm_remove(index, model)
|
|
446
|
+
return None
|
|
447
|
+
model, _, _ = self._config_update(index, values)
|
|
448
|
+
except _Refused as refused:
|
|
449
|
+
return (refused.key, refused.reason)
|
|
450
|
+
except (ConfigurationError, IndexError) as error:
|
|
451
|
+
self._config_failed(error)
|
|
452
|
+
return None
|
|
453
|
+
self._config_end()
|
|
454
|
+
self.change_model(model)
|
|
455
|
+
return None
|
|
456
|
+
|
|
457
|
+
def _config_update(
|
|
458
|
+
self, index: int, values: dict[str, Answer]
|
|
459
|
+
) -> tuple[ModelInfo, dict[str, object], Mapping[str, object]]:
|
|
460
|
+
"""Rewrite the entry the page was opened on with every answer it holds, in the file and in this session.
|
|
461
|
+
|
|
462
|
+
Only what differs from the entry is touched: a key the reader never changed is not written, and an empty row
|
|
463
|
+
removes its key so the default applies. The token is the exception to "empty clears": the current one is never
|
|
464
|
+
shown, so an empty row means it was left alone.
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
The model as it is now, the entry as written, and the entry as it was.
|
|
468
|
+
|
|
469
|
+
Raises:
|
|
470
|
+
_Refused: With the row to correct, when an answer is not one the model can take.
|
|
471
|
+
"""
|
|
472
|
+
model, current = self.client.model_entries()[index]
|
|
473
|
+
entry = dict(current)
|
|
474
|
+
api = Api.parse(str(values["api"]))
|
|
475
|
+
if api != Api.parse(model.api):
|
|
476
|
+
for key in API_FLAG_KEYS:
|
|
477
|
+
entry.pop(key, None)
|
|
478
|
+
entry.update(api.keys)
|
|
479
|
+
if api is Api.CHAT:
|
|
480
|
+
entry["chat"] = True
|
|
481
|
+
readers: dict[str, Callable[[str], object]] = {
|
|
482
|
+
"base_url": lambda text: check_url(text, what="the address"),
|
|
483
|
+
"temperature": lambda text: whole_if_whole(parse_temperature(text, api)),
|
|
484
|
+
"context_window": parse_tokens,
|
|
485
|
+
"compact_percent": lambda text: whole_if_whole(parse_percent(text)),
|
|
486
|
+
}
|
|
487
|
+
for key in (
|
|
488
|
+
"base_url",
|
|
489
|
+
"model",
|
|
490
|
+
"display_model",
|
|
491
|
+
"web_search_name",
|
|
492
|
+
"temperature",
|
|
493
|
+
"context_window",
|
|
494
|
+
"compact_percent",
|
|
495
|
+
):
|
|
496
|
+
text = str(values[key])
|
|
497
|
+
if text == str(current.get(key, "")):
|
|
498
|
+
continue # a row the reader did not touch is not rewritten, however the file happens to spell it
|
|
499
|
+
try:
|
|
500
|
+
self._set(entry, key, readers.get(key, str)(text) if text else None)
|
|
501
|
+
except ValueError as error:
|
|
502
|
+
raise _Refused(key, str(error)) from None
|
|
503
|
+
if values["token"]:
|
|
504
|
+
entry["token"] = str(values["token"])
|
|
505
|
+
self._set(entry, "multimodal", True if values["multimodal"] else None)
|
|
506
|
+
self._set(entry, "web_search", True if values["web_search"] else None)
|
|
507
|
+
if entry == current:
|
|
508
|
+
return model, entry, current
|
|
509
|
+
try:
|
|
510
|
+
# The page holds the whole entry it wants; the SDK takes what to change, and a key the entry no longer
|
|
511
|
+
# has is one to remove.
|
|
512
|
+
changes = {**entry, **{key: None for key in current if key not in entry}}
|
|
513
|
+
return self.client.update_model(index, changes), entry, current
|
|
514
|
+
except ValueError as error:
|
|
515
|
+
sentence = str(error).lower()
|
|
516
|
+
raise _Refused(
|
|
517
|
+
next((key for key in ("temperature", "search") if key in sentence), ""), str(error)
|
|
518
|
+
) from None
|
|
519
|
+
|
|
520
|
+
@staticmethod
|
|
521
|
+
def _set(entry: dict[str, object], key: str, value: object | None) -> None:
|
|
522
|
+
"""Set a key, or take it out of the entry when there is nothing to say."""
|
|
523
|
+
if value is None:
|
|
524
|
+
entry.pop(key, None)
|
|
525
|
+
else:
|
|
526
|
+
entry[key] = value
|
|
527
|
+
|
|
528
|
+
def _config_confirm_remove(self, index: int, model: ModelInfo) -> None:
|
|
529
|
+
"""Ask once before deleting, with the safe answer first."""
|
|
530
|
+
rows = [
|
|
531
|
+
ListItem(False, "Keep it", ""),
|
|
532
|
+
ListItem(True, f"Remove {model.name}", "this edits the config file"),
|
|
533
|
+
]
|
|
534
|
+
page = ListPage(
|
|
535
|
+
rows,
|
|
536
|
+
title="Remove this model?",
|
|
537
|
+
on_select=lambda item: (
|
|
538
|
+
self._config_remove(index, model) if item.value else self._config_actions(index, replace=True)
|
|
539
|
+
),
|
|
540
|
+
on_cancel=lambda: self._config_actions(index, replace=True),
|
|
541
|
+
)
|
|
542
|
+
self._config_show(page, rows=LIST_ROWS, replace=True)
|
|
543
|
+
|
|
544
|
+
def _config_remove(self, index: int, model: ModelInfo) -> None:
|
|
545
|
+
"""Remove the entry and tell the reader what is in use afterwards."""
|
|
546
|
+
previous = self.model
|
|
547
|
+
try:
|
|
548
|
+
now = self.client.remove_model(index)
|
|
549
|
+
except ValueError as error:
|
|
550
|
+
self._config_failed(error)
|
|
551
|
+
return
|
|
552
|
+
if previous is not None and previous.name == model.name and now.name != model.name:
|
|
553
|
+
self.transcript.model_changed(previous.name, now.name)
|
|
554
|
+
self._config_end(f"removed {model.name}")
|
|
555
|
+
self.app.request_layout()
|
|
556
|
+
|
|
557
|
+
# -- the command line form -----------------------------------------------------------
|
|
558
|
+
def config_add_from_line(self, argument: str) -> str | None:
|
|
559
|
+
"""Add a model from ``/config add ...``, asking only for what the line left out.
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
A sentence when the model was added outright; ``None`` when a question was opened.
|
|
563
|
+
|
|
564
|
+
Raises:
|
|
565
|
+
ValueError: With the reason, when the line cannot be understood or the entry is refused.
|
|
566
|
+
"""
|
|
567
|
+
draft = parse_add(argument, self.agent.providers)
|
|
568
|
+
if draft.missing():
|
|
569
|
+
self._config_ask(draft, replace=False) # nothing of the flow is on screen yet
|
|
570
|
+
return None
|
|
571
|
+
first = self.model is None
|
|
572
|
+
entry = entry_for(draft)
|
|
573
|
+
self.client.add_model(entry)
|
|
574
|
+
if first:
|
|
575
|
+
self._warm_runtime()
|
|
576
|
+
return f"added {describe(entry)} \u2014 this is the model requests now use"
|
|
577
|
+
return f"added {describe(entry)} \u2014 /model switches to it"
|
|
578
|
+
|
|
579
|
+
def config_remove_from_line(self, argument: str) -> str:
|
|
580
|
+
"""Remove the model a number or a name picks out, for ``/config remove``."""
|
|
581
|
+
entries = self.client.model_entries()
|
|
582
|
+
wanted = argument.strip()
|
|
583
|
+
if not wanted:
|
|
584
|
+
raise ValueError("Usage: /config remove <number|name>")
|
|
585
|
+
if wanted.isdigit():
|
|
586
|
+
index = int(wanted) - 1
|
|
587
|
+
if not 0 <= index < len(entries):
|
|
588
|
+
raise ValueError(f"There is no model number {wanted}; /config list shows them")
|
|
589
|
+
else:
|
|
590
|
+
matches = [i for i, (model, _) in enumerate(entries) if wanted in (model.name, model.model)]
|
|
591
|
+
if not matches:
|
|
592
|
+
raise ValueError(f"No model called {wanted!r}; /config list shows them")
|
|
593
|
+
if len(matches) > 1:
|
|
594
|
+
raise ValueError(f"{wanted!r} names more than one model; use its number from /config list")
|
|
595
|
+
index = matches[0]
|
|
596
|
+
model = entries[index][0]
|
|
597
|
+
previous = self.model
|
|
598
|
+
now = self.client.remove_model(index)
|
|
599
|
+
if previous is not None and previous.name == model.name and now.name != model.name:
|
|
600
|
+
self.transcript.model_changed(previous.name, now.name)
|
|
601
|
+
self.app.request_layout()
|
|
602
|
+
return f"removed {model.name}"
|
|
603
|
+
|
|
604
|
+
def config_listing(self) -> str:
|
|
605
|
+
"""Return the models in the file as Markdown, with no token in it."""
|
|
606
|
+
lines = [
|
|
607
|
+
"| # | name | model | api | address | images | context window | compress at |",
|
|
608
|
+
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
609
|
+
]
|
|
610
|
+
for index, (model, _) in enumerate(self.client.model_entries(), start=1):
|
|
611
|
+
mark = " (current)" if model.active else ""
|
|
612
|
+
lines.append(
|
|
613
|
+
f"| {index} | {model.name}{mark} | `{model.model}` | {model.api} | {model.base_url or 'default'} "
|
|
614
|
+
f"| {'yes' if model.multimodal else 'no'} | {model.context_window:,} "
|
|
615
|
+
f"| {whole_if_whole(model.compact_percent)}% |"
|
|
616
|
+
)
|
|
617
|
+
return "\n".join(lines)
|
|
618
|
+
|
|
619
|
+
def config_providers_listing(self) -> str:
|
|
620
|
+
"""Return the providers `/config add` accepts, as Markdown."""
|
|
621
|
+
lines = ["| name | api | address | note |", "| --- | --- | --- | --- |"]
|
|
622
|
+
for provider in self.agent.providers:
|
|
623
|
+
note = provider.note or provider.api.description
|
|
624
|
+
lines.append(f"| `{provider.name}` | {provider.api} | {provider.base_url or 'default'} | {note} |")
|
|
625
|
+
lines.append(f"| `{CUSTOM}` | you choose | you give it | any endpoint |")
|
|
626
|
+
return "\n".join(lines)
|