zettcode-app 0.4.2__tar.gz → 0.4.4__tar.gz
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-0.4.2 → zettcode_app-0.4.4}/PKG-INFO +1 -1
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/agent/commands.py +0 -6
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/app.py +7 -3
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/commands.py +6 -10
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/config_form.py +23 -44
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/keys.py +4 -3
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/providers.py +113 -66
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/settings.py +3 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/shell.py +17 -4
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/form_page.py +186 -24
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/model_page.py +16 -6
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/.gitignore +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/pyproject.toml +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/__init__.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/__init__.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/agent/__init__.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/agent/settings.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/__init__.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/changes.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/effort_gauge.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/projection.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/rendering.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/replay.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/shimmer.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/stops.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/tips.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/transcript/__init__.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/transcript/fold.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/transcript/log.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/transcript/oneoff.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/transcript/stream.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/transcript/turn.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/commandline/__init__.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/commandline/arguments.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/commandline/completion.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/commandline/history.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/commandline/runner.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/errors.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/plugins/__init__.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/plugins/builtins.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/plugins/groups.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/plugins/loader.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/py.typed +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/__init__.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/bars.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/boot.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/clipboard.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/demo.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/followups.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/gc_policy.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/messaging.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/notices.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/sessions.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/turns.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/updating.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/__init__.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/approval_page.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/ask_page.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/bash_page.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/completer.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/composer.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/document_page.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/effort_page.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/follow_up.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/input_page.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/model_settings_page.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/pet.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/pet_page.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/plugins_page.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/root.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/self_compact_page.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/send_kind_page.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/sessions_page.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/statusline_page.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/steering.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/transcript.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/update_page.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/welcome.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/collector.py +0 -0
- {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/update.py +0 -0
|
@@ -18,7 +18,6 @@ from zettcode.plugin_api.plugin import TuiPlugins
|
|
|
18
18
|
from zettcode.plugin_api.presenters import Presenters
|
|
19
19
|
from zettcode.plugin_api.state import TurnSegment, UiRow
|
|
20
20
|
from zettcode.presenters.statusline import NAMES, unknown
|
|
21
|
-
from zettcode.providers import BUILTIN_PROVIDERS, Provider, merge_providers
|
|
22
21
|
from zettcode.tui import ThemeCatalog
|
|
23
22
|
|
|
24
23
|
from .settings import AppearanceMixin
|
|
@@ -71,11 +70,6 @@ class CommandsMixin(AppearanceMixin, ExportsMixin):
|
|
|
71
70
|
"""Return the commands the program's own plugins registered: ``/resume``, ``/model``, ``/usage`` and the rest."""
|
|
72
71
|
return self.terminal_plugins.builtin_commands
|
|
73
72
|
|
|
74
|
-
@property
|
|
75
|
-
def providers(self) -> tuple[Provider, ...]:
|
|
76
|
-
"""Return the hosts ``/config`` can offer: the built-in ones, then the plugins' own."""
|
|
77
|
-
return merge_providers(BUILTIN_PROVIDERS, self.runtime.plugins.providers)
|
|
78
|
-
|
|
79
73
|
@property
|
|
80
74
|
def themes(self) -> ThemeCatalog:
|
|
81
75
|
"""Return every palette that can be asked for by name: the plugins', then the reader's own files.
|
|
@@ -99,6 +99,9 @@ from .shell import (
|
|
|
99
99
|
EMPTY_REPLY,
|
|
100
100
|
FLASH_SECONDS,
|
|
101
101
|
NO_MODEL_ASKED,
|
|
102
|
+
NO_MODEL_CHOSEN_ASKED,
|
|
103
|
+
NO_MODEL_CHOSEN_QUESTION,
|
|
104
|
+
NO_MODEL_CHOSEN_START,
|
|
102
105
|
NO_MODEL_QUESTION,
|
|
103
106
|
NO_MODEL_START,
|
|
104
107
|
PAGE_SCREEN,
|
|
@@ -210,7 +213,8 @@ class ZettCodeApp(
|
|
|
210
213
|
if agent.runtime.active_model is None:
|
|
211
214
|
# The first thing the conversation says on a machine that has never been set up, under the banner and
|
|
212
215
|
# above anything the reader does next: the shell runs, and this is the one command it is waiting for.
|
|
213
|
-
self.
|
|
216
|
+
chosen = NO_MODEL_CHOSEN_START if self.client.models else NO_MODEL_START
|
|
217
|
+
self.transcript.notice(chosen)
|
|
214
218
|
for failure in agent.plugin_failures:
|
|
215
219
|
self.transcript.error(f"plugin: {failure}")
|
|
216
220
|
self.projector = TranscriptProjector(
|
|
@@ -750,7 +754,7 @@ class ZettCodeApp(
|
|
|
750
754
|
# Nothing to ask, and no turn is opened for it: one sentence, where /compact gives it, instead of a turn
|
|
751
755
|
# that the stream then fails and a "btw failed" line under it. Its own sentence, not the prompt's: a
|
|
752
756
|
# command clears the composer before its handler runs, so "your words are still in the box" would lie.
|
|
753
|
-
self.transcript.error(NO_MODEL_QUESTION)
|
|
757
|
+
self.transcript.error(NO_MODEL_CHOSEN_QUESTION if self.client.models else NO_MODEL_QUESTION)
|
|
754
758
|
self.app.invalidate()
|
|
755
759
|
return
|
|
756
760
|
self.projector.begin_turn(question, side=True)
|
|
@@ -1094,7 +1098,7 @@ class ZettCodeApp(
|
|
|
1094
1098
|
# the draft away: the words stay in the box, so setting the model up is all that is left to do. This is
|
|
1095
1099
|
# checked before the parts are taken, because nothing downstream of here has a model to ask about.
|
|
1096
1100
|
if self.model is None:
|
|
1097
|
-
self.transcript.error(NO_MODEL_ASKED)
|
|
1101
|
+
self.transcript.error(NO_MODEL_CHOSEN_ASKED if self.client.models else NO_MODEL_ASKED)
|
|
1098
1102
|
self.app.invalidate()
|
|
1099
1103
|
return False
|
|
1100
1104
|
# The parts are taken before the composer is cleared, and keep the
|
|
@@ -44,10 +44,9 @@ THEME_NOTES = {
|
|
|
44
44
|
|
|
45
45
|
#: What ``/config`` takes first, as its description lists them.
|
|
46
46
|
CONFIG_WORDS = (
|
|
47
|
-
Subcommand("add", "add a model:
|
|
47
|
+
Subcommand("add", "add a model: its protocol, then what it needs", needs_more=True),
|
|
48
48
|
Subcommand("remove", "remove a model by number or name", needs_more=True),
|
|
49
49
|
Subcommand("list", "show the models in the file"),
|
|
50
|
-
Subcommand("providers", "show the providers and where to get a key"),
|
|
51
50
|
)
|
|
52
51
|
|
|
53
52
|
|
|
@@ -97,7 +96,7 @@ class ShellCommands(CommandProvider):
|
|
|
97
96
|
# /resume, /fork, plus the agent's /new, /compact and /export.
|
|
98
97
|
Command(
|
|
99
98
|
"/config",
|
|
100
|
-
"add, change or remove models: /config [add|remove|list
|
|
99
|
+
"add, change or remove models: /config [add|remove|list]",
|
|
101
100
|
"app",
|
|
102
101
|
self.config,
|
|
103
102
|
subcommands=CONFIG_WORDS,
|
|
@@ -147,10 +146,9 @@ class ShellCommands(CommandProvider):
|
|
|
147
146
|
|
|
148
147
|
With nothing after it the command opens the panel, which asks one question at a time and
|
|
149
148
|
is the way to type a token without it being echoed. The words after it do the same job
|
|
150
|
-
on one line: ``add`` takes
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
nothing is written.
|
|
149
|
+
on one line: ``add`` takes whatever of a model's settings the line gives, and asks for the
|
|
150
|
+
rest; ``remove`` takes a number or a name; ``list`` only shows. A refusal \u2014 a bad address, a
|
|
151
|
+
file that cannot be edited \u2014 is the command's error and nothing is written.
|
|
154
152
|
"""
|
|
155
153
|
word, _, rest = context.argument.partition(" ")
|
|
156
154
|
shell = self.shell
|
|
@@ -158,14 +156,12 @@ class ShellCommands(CommandProvider):
|
|
|
158
156
|
return CommandResult(widget=shell.config_page())
|
|
159
157
|
if word == "list":
|
|
160
158
|
return CommandResult(message=shell.config_listing())
|
|
161
|
-
if word == "providers":
|
|
162
|
-
return CommandResult(message=shell.config_providers_listing())
|
|
163
159
|
if word == "add":
|
|
164
160
|
done = shell.config_add_from_line(rest)
|
|
165
161
|
return CommandResult(notification=done, relayout=True) if done else CommandResult()
|
|
166
162
|
if word == "remove":
|
|
167
163
|
return CommandResult(notification=shell.config_remove_from_line(rest), relayout=True)
|
|
168
|
-
raise ValueError("Usage: /config [add
|
|
164
|
+
raise ValueError("Usage: /config [add [--model ID] ... | remove <number|name> | list]")
|
|
169
165
|
|
|
170
166
|
async def theme(self, context: CommandContext) -> CommandResult:
|
|
171
167
|
"""Switch straight to a named palette and remember it, or open the menu of them.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""What ``/config add`` means: turn a
|
|
1
|
+
"""What ``/config add`` means: turn a few answers into one model entry.
|
|
2
2
|
|
|
3
3
|
The panel asks one question at a time and ``/config add`` takes everything on one line; both end up
|
|
4
4
|
here, so a model added either way is the same model. This module is plain data in and data out: it
|
|
@@ -13,7 +13,7 @@ from collections.abc import Callable, Mapping, Sequence
|
|
|
13
13
|
from dataclasses import dataclass, field
|
|
14
14
|
from typing import TYPE_CHECKING, TypeVar
|
|
15
15
|
|
|
16
|
-
from zettcode.providers import
|
|
16
|
+
from zettcode.providers import Api, check_url
|
|
17
17
|
|
|
18
18
|
if TYPE_CHECKING: # pragma: no cover - annotations only
|
|
19
19
|
from zettcode.sdk.types import ModelInfo
|
|
@@ -21,7 +21,7 @@ if TYPE_CHECKING: # pragma: no cover - annotations only
|
|
|
21
21
|
T = TypeVar("T")
|
|
22
22
|
|
|
23
23
|
USAGE = (
|
|
24
|
-
"/config add
|
|
24
|
+
"/config add [--model ID] [--token KEY] [--url URL] [--api chat|responses|anthropic] "
|
|
25
25
|
"[--name SHOWN] [--multimodal] [--context TOKENS] [--compact PERCENT] [--temperature T] "
|
|
26
26
|
"[--web-search] [--search-tool TYPE]"
|
|
27
27
|
)
|
|
@@ -51,8 +51,7 @@ class Draft:
|
|
|
51
51
|
"""What is known about a model that is being added, filled in as it is learned.
|
|
52
52
|
|
|
53
53
|
Attributes:
|
|
54
|
-
|
|
55
|
-
api: Which protocol it speaks. Starts as the provider's, and a reader may change it.
|
|
54
|
+
api: Which protocol it speaks: chat completions unless the reader says otherwise.
|
|
56
55
|
url: The address; ``None`` is the SDK's own default.
|
|
57
56
|
model: The model id sent to the endpoint.
|
|
58
57
|
token: The credential. May stay empty: a local server wants none, and the environment can supply one.
|
|
@@ -65,7 +64,6 @@ class Draft:
|
|
|
65
64
|
search_tool: The tool type that search is sent as; empty takes the default for the API.
|
|
66
65
|
"""
|
|
67
66
|
|
|
68
|
-
provider: Provider | None = None
|
|
69
67
|
api: Api = Api.CHAT
|
|
70
68
|
url: str | None = None
|
|
71
69
|
model: str = ""
|
|
@@ -85,8 +83,8 @@ class Draft:
|
|
|
85
83
|
#: The questions answered in the panel, newest last, each with what was typed, so that
|
|
86
84
|
#: Escape can take back the last one and show it again as it was answered.
|
|
87
85
|
trail: list[tuple[str, str]] = field(default_factory=list)
|
|
88
|
-
#: Whether the flow was entered from the
|
|
89
|
-
#:
|
|
86
|
+
#: Whether the flow was entered from the model list, which is where Escape from the first question
|
|
87
|
+
#: goes back to; from a typed line there is nothing behind it.
|
|
90
88
|
from_panel: bool = False
|
|
91
89
|
|
|
92
90
|
def undo(self) -> str | None:
|
|
@@ -109,34 +107,16 @@ class Draft:
|
|
|
109
107
|
self.given.discard(name)
|
|
110
108
|
return previous
|
|
111
109
|
|
|
112
|
-
@classmethod
|
|
113
|
-
def for_provider(cls, provider: Provider | None) -> Draft:
|
|
114
|
-
"""Start a draft with what the provider already says."""
|
|
115
|
-
if provider is None:
|
|
116
|
-
return cls()
|
|
117
|
-
return cls(
|
|
118
|
-
provider=provider,
|
|
119
|
-
api=provider.api,
|
|
120
|
-
url=provider.base_url,
|
|
121
|
-
multimodal=provider.multimodal,
|
|
122
|
-
)
|
|
123
|
-
|
|
124
|
-
@property
|
|
125
|
-
def custom(self) -> bool:
|
|
126
|
-
"""Return whether the host is one nobody described, so its address has to be asked."""
|
|
127
|
-
return self.provider is None
|
|
128
|
-
|
|
129
110
|
def missing(self) -> list[str]:
|
|
130
111
|
"""Return what still has to be asked, in the order to ask it.
|
|
131
112
|
|
|
132
|
-
The
|
|
133
|
-
|
|
113
|
+
The protocol comes first, because it decides what the rest of the form offers. The token is asked about,
|
|
114
|
+
not required: a draft that was not told one (``--token``) is shown to the reader with the line empty, and
|
|
115
|
+
an empty line is a valid answer.
|
|
134
116
|
"""
|
|
135
117
|
wanted: list[str] = []
|
|
136
|
-
if
|
|
118
|
+
if "api" not in self.given:
|
|
137
119
|
wanted.append("api")
|
|
138
|
-
if (self.custom or self.url is None) and "url" not in self.given and self.api is not Api.ANTHROPIC:
|
|
139
|
-
wanted.append("url")
|
|
140
120
|
if not self.model:
|
|
141
121
|
wanted.append("model")
|
|
142
122
|
if "token" not in self.given:
|
|
@@ -161,8 +141,6 @@ def entry_for(draft: Draft) -> dict[str, object]:
|
|
|
161
141
|
entry["display_model"] = draft.name.strip()
|
|
162
142
|
if draft.url:
|
|
163
143
|
entry["base_url"] = check_url(draft.url)
|
|
164
|
-
elif draft.custom and draft.api is not Api.ANTHROPIC:
|
|
165
|
-
raise ValueError("A custom provider needs an address (--url)")
|
|
166
144
|
entry.update(draft.api.keys)
|
|
167
145
|
if draft.chat and draft.api is Api.CHAT:
|
|
168
146
|
entry["chat"] = True
|
|
@@ -181,12 +159,11 @@ def entry_for(draft: Draft) -> dict[str, object]:
|
|
|
181
159
|
return entry
|
|
182
160
|
|
|
183
161
|
|
|
184
|
-
def parse_add(argument: str
|
|
162
|
+
def parse_add(argument: str) -> Draft:
|
|
185
163
|
"""Read what follows ``/config add`` into a draft, which may still be missing answers.
|
|
186
164
|
|
|
187
165
|
Args:
|
|
188
|
-
argument: The text after ``add
|
|
189
|
-
providers: The hosts that can be named.
|
|
166
|
+
argument: The text after ``add``, which is flags and their values.
|
|
190
167
|
|
|
191
168
|
Raises:
|
|
192
169
|
ValueError: With a sentence saying what is wrong and how to write it.
|
|
@@ -195,15 +172,17 @@ def parse_add(argument: str, providers: Sequence[Provider]) -> Draft:
|
|
|
195
172
|
words = shlex.split(argument)
|
|
196
173
|
except ValueError as error:
|
|
197
174
|
raise ValueError(f"cannot read that: {error}") from error
|
|
198
|
-
if not words
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
175
|
+
if not words:
|
|
176
|
+
# `/config add` on its own opens the panel, which asks the protocol first.
|
|
177
|
+
return Draft()
|
|
178
|
+
if not words[0].startswith("--"):
|
|
179
|
+
raise ValueError(f"Put the model after --model, not on its own\nUsage: {USAGE}")
|
|
180
|
+
flags = _flags(words)
|
|
181
|
+
draft = Draft()
|
|
182
|
+
if "--api" not in flags:
|
|
183
|
+
# A line that names settings and no protocol means chat completions, the way an entry with no flag
|
|
184
|
+
# does: a reader typing everything on one line is not asked a question they did not raise.
|
|
185
|
+
draft.given.add("api")
|
|
207
186
|
if "--api" in flags:
|
|
208
187
|
try:
|
|
209
188
|
draft.api = Api.parse(flags["--api"])
|
|
@@ -14,7 +14,7 @@ from zettcode.tui import AnyEvent, Host
|
|
|
14
14
|
from zettcode.tui.clipboard import copy_route
|
|
15
15
|
|
|
16
16
|
from .clipboard import read_image
|
|
17
|
-
from .shell import ASK_SCREEN, PAGE_SCREEN, ShellState
|
|
17
|
+
from .shell import ASK_SCREEN, NO_MODEL_CHOSEN_IMAGE, NO_MODEL_IMAGE, PAGE_SCREEN, ShellState
|
|
18
18
|
from .widgets.completer import COMMAND_PREFIX
|
|
19
19
|
|
|
20
20
|
if TYPE_CHECKING: # pragma: no cover - annotations only
|
|
@@ -297,8 +297,9 @@ class KeysMixin(ShellState):
|
|
|
297
297
|
if image is None:
|
|
298
298
|
self.transcript.notice("no image on the clipboard")
|
|
299
299
|
elif self.model is None:
|
|
300
|
-
# Nothing would take the picture: say what to set up rather than reporting the model's limits.
|
|
301
|
-
|
|
300
|
+
# Nothing would take the picture: say what to set up rather than reporting the model's limits. A
|
|
301
|
+
# machine whose plugin offers models has something to pick, so the picker is the instruction.
|
|
302
|
+
self.transcript.error(NO_MODEL_CHOSEN_IMAGE if self.client.models else NO_MODEL_IMAGE)
|
|
302
303
|
elif not self.model.multimodal:
|
|
303
304
|
self.transcript.error(f"{self.model.name} does not take images")
|
|
304
305
|
else:
|
|
@@ -11,8 +11,9 @@ from __future__ import annotations
|
|
|
11
11
|
from collections.abc import Callable, Mapping
|
|
12
12
|
from typing import TYPE_CHECKING, cast
|
|
13
13
|
|
|
14
|
+
from zettcode.config import DEFAULT_COMPACT_PERCENT, DEFAULT_CONTEXT_WINDOW, model_from_entry
|
|
14
15
|
from zettcode.presenters.text import compact_path
|
|
15
|
-
from zettcode.providers import API_FLAG_KEYS,
|
|
16
|
+
from zettcode.providers import API_FLAG_KEYS, Api, check_url
|
|
16
17
|
from zettcode.sdk.errors import ConfigurationError
|
|
17
18
|
from zettcode.tui import SEPARATOR, ListItem, ListPage, Screen, Widget
|
|
18
19
|
|
|
@@ -32,7 +33,6 @@ from .widgets import Field, FormPage, ModelSettingsPage, bottom_panel, form_rows
|
|
|
32
33
|
from .widgets.model_settings_page import Action, Answer, Setting
|
|
33
34
|
|
|
34
35
|
if TYPE_CHECKING: # pragma: no cover - annotations only
|
|
35
|
-
from zettcode.providers import Provider
|
|
36
36
|
from zettcode.sdk.types import ModelInfo
|
|
37
37
|
|
|
38
38
|
#: Rows the list pages take: the title, a blank, up to eight rows, the keys.
|
|
@@ -91,7 +91,7 @@ class ConfigMixin(ShellState):
|
|
|
91
91
|
)
|
|
92
92
|
for index, (model, _) in enumerate(entries)
|
|
93
93
|
]
|
|
94
|
-
rows.append(ListItem(None, "+ Add a model", "
|
|
94
|
+
rows.append(ListItem(None, "+ Add a model", "choose the API, then the model"))
|
|
95
95
|
page = ListPage(
|
|
96
96
|
rows,
|
|
97
97
|
title="Models",
|
|
@@ -104,26 +104,11 @@ class ConfigMixin(ShellState):
|
|
|
104
104
|
def _config_chosen(self, item: ListItem) -> None:
|
|
105
105
|
"""A row of the model list: a model to change, or the row that adds one."""
|
|
106
106
|
if item.value is None:
|
|
107
|
-
self.
|
|
107
|
+
self._config_begin(replace=True)
|
|
108
108
|
else:
|
|
109
109
|
self._config_actions(int(item.value), replace=True) # type: ignore[call-overload]
|
|
110
110
|
|
|
111
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
112
|
def _config_back(self) -> None:
|
|
128
113
|
"""Go back to the model list."""
|
|
129
114
|
try:
|
|
@@ -135,33 +120,33 @@ class ConfigMixin(ShellState):
|
|
|
135
120
|
self.app.push_screen(Screen(page, name=PAGE_SCREEN, modal=True))
|
|
136
121
|
self.app.request_layout()
|
|
137
122
|
|
|
138
|
-
def _config_begin(self,
|
|
139
|
-
"""Start
|
|
140
|
-
draft = Draft
|
|
123
|
+
def _config_begin(self, *, replace: bool) -> None:
|
|
124
|
+
"""Start a new model: the protocol is the first question, and the rest follows in one form."""
|
|
125
|
+
draft = Draft()
|
|
141
126
|
draft.from_panel = True
|
|
142
|
-
self._config_ask(draft)
|
|
127
|
+
self._config_ask(draft, replace=replace)
|
|
143
128
|
|
|
144
129
|
def _config_step_back(self, draft: Draft) -> None:
|
|
145
130
|
"""Escape in the form: take back the last choice made before it and ask it again.
|
|
146
131
|
|
|
147
|
-
That choice is the protocol
|
|
148
|
-
|
|
149
|
-
|
|
132
|
+
That choice is the protocol, the one thing asked on a page of its own. With none behind it, Escape goes back
|
|
133
|
+
to the model list when the flow came from there, and closes when it was started from a typed line, which has
|
|
134
|
+
nothing behind it.
|
|
150
135
|
"""
|
|
151
136
|
previous = draft.undo()
|
|
152
137
|
if previous is not None:
|
|
153
138
|
self._config_ask(draft)
|
|
154
139
|
elif draft.from_panel:
|
|
155
|
-
self.
|
|
140
|
+
self._config_back()
|
|
156
141
|
else:
|
|
157
142
|
self._config_end()
|
|
158
143
|
|
|
159
144
|
def _config_ask(self, draft: Draft, *, replace: bool = True) -> None:
|
|
160
145
|
"""Ask for what a draft is missing, or write it when nothing is.
|
|
161
146
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
147
|
+
The protocol is asked on a list of its own, because that is a choice among three words and not text.
|
|
148
|
+
Everything else is one form: the model id, the token, the address and a name, with the cursor moving
|
|
149
|
+
between them by Tab and Enter saving all of it.
|
|
165
150
|
|
|
166
151
|
Args:
|
|
167
152
|
draft: What is known so far.
|
|
@@ -178,21 +163,12 @@ class ConfigMixin(ShellState):
|
|
|
178
163
|
|
|
179
164
|
def _config_add_form(self, draft: Draft, *, replace: bool) -> None:
|
|
180
165
|
"""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
166
|
fields = [
|
|
189
167
|
Field(
|
|
190
168
|
"model",
|
|
191
169
|
"Model id",
|
|
192
170
|
initial=draft.model,
|
|
193
|
-
|
|
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 ""),
|
|
171
|
+
hint="The id the endpoint calls the model, exactly as its documentation writes it.",
|
|
196
172
|
required=True,
|
|
197
173
|
),
|
|
198
174
|
Field(
|
|
@@ -200,21 +176,19 @@ class ConfigMixin(ShellState):
|
|
|
200
176
|
"Token",
|
|
201
177
|
initial=draft.token,
|
|
202
178
|
placeholder="optional",
|
|
203
|
-
hint=f"
|
|
179
|
+
hint=f"Your key for this endpoint. Leave it empty for a local server, or to use the environment's. "
|
|
180
|
+
f"It is written to {self.client.config_file}.",
|
|
204
181
|
secret=True,
|
|
205
182
|
),
|
|
183
|
+
Field(
|
|
184
|
+
"url",
|
|
185
|
+
"Address",
|
|
186
|
+
initial=draft.url or "",
|
|
187
|
+
placeholder="default address",
|
|
188
|
+
hint="The address the client appends its route to, such as https://api.example.com/v1. "
|
|
189
|
+
"Leave it empty for the API's own.",
|
|
190
|
+
),
|
|
206
191
|
]
|
|
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
192
|
fields.append(
|
|
219
193
|
Field(
|
|
220
194
|
"name",
|
|
@@ -235,8 +209,44 @@ class ConfigMixin(ShellState):
|
|
|
235
209
|
+ " Leave it empty to send none.",
|
|
236
210
|
)
|
|
237
211
|
)
|
|
212
|
+
fields.append(
|
|
213
|
+
Field(
|
|
214
|
+
"multimodal",
|
|
215
|
+
"Images",
|
|
216
|
+
switch=("text only", "accepted"),
|
|
217
|
+
value=1 if draft.multimodal else 0,
|
|
218
|
+
hint="Left and Right turn it over; text only is the default.",
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
fields.append(
|
|
222
|
+
Field(
|
|
223
|
+
"web_search",
|
|
224
|
+
"Web search",
|
|
225
|
+
switch=("off", "on"),
|
|
226
|
+
value=1 if draft.web_search else 0,
|
|
227
|
+
hint="Left and Right turn it over; off is the default.",
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
fields.append(
|
|
231
|
+
Field(
|
|
232
|
+
"context_window",
|
|
233
|
+
"Context window",
|
|
234
|
+
initial="" if draft.context_window is None else f"{draft.context_window:,}",
|
|
235
|
+
placeholder=f"{DEFAULT_CONTEXT_WINDOW:,} tokens",
|
|
236
|
+
hint="Tokens the model can carry: 128000, 128k or 1m. Leave it empty for 128,000.",
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
fields.append(
|
|
240
|
+
Field(
|
|
241
|
+
"compact_percent",
|
|
242
|
+
"Compress at",
|
|
243
|
+
initial="" if draft.compact_percent is None else f"{whole_if_whole(draft.compact_percent)}%",
|
|
244
|
+
placeholder=f"{whole_if_whole(DEFAULT_COMPACT_PERCENT)}%",
|
|
245
|
+
hint="Share of the window at which a conversation is summarized: 80 or 80%. Leave it empty for 80.",
|
|
246
|
+
)
|
|
247
|
+
)
|
|
238
248
|
page = FormPage(
|
|
239
|
-
f"Add a model
|
|
249
|
+
f"Add a model \u2014 {draft.api.label}",
|
|
240
250
|
fields,
|
|
241
251
|
on_submit=lambda values: self._config_added(draft, values),
|
|
242
252
|
on_cancel=lambda: self._config_step_back(draft),
|
|
@@ -253,7 +263,7 @@ class ConfigMixin(ShellState):
|
|
|
253
263
|
except ValueError as error:
|
|
254
264
|
return ("url", str(error))
|
|
255
265
|
draft.url = checked
|
|
256
|
-
|
|
266
|
+
else:
|
|
257
267
|
draft.url = None
|
|
258
268
|
draft.given.add("url")
|
|
259
269
|
draft.model = values["model"]
|
|
@@ -266,13 +276,59 @@ class ConfigMixin(ShellState):
|
|
|
266
276
|
return ("temperature", str(error))
|
|
267
277
|
else:
|
|
268
278
|
draft.temperature = None
|
|
279
|
+
draft.multimodal = values["multimodal"] == "yes"
|
|
280
|
+
draft.web_search = values["web_search"] == "yes"
|
|
281
|
+
if values["context_window"].strip():
|
|
282
|
+
try:
|
|
283
|
+
draft.context_window = parse_tokens(values["context_window"])
|
|
284
|
+
except ValueError as error:
|
|
285
|
+
return ("context_window", str(error))
|
|
286
|
+
else:
|
|
287
|
+
draft.context_window = None
|
|
288
|
+
if values["compact_percent"].strip():
|
|
289
|
+
try:
|
|
290
|
+
draft.compact_percent = parse_percent(values["compact_percent"])
|
|
291
|
+
except ValueError as error:
|
|
292
|
+
return ("compact_percent", str(error))
|
|
293
|
+
else:
|
|
294
|
+
draft.compact_percent = None
|
|
295
|
+
shown = draft.name.strip() or draft.model.strip()
|
|
296
|
+
if shown and any(model.name == shown for model, _ in self.client.model_entries()):
|
|
297
|
+
return ("name", f"a model is already shown as {shown!r}; choose another shown name")
|
|
298
|
+
try:
|
|
299
|
+
entry = entry_for(draft)
|
|
300
|
+
model_from_entry(entry)
|
|
301
|
+
except ValueError as error:
|
|
302
|
+
# The rules the loader will read (a DeepSeek chat endpoint cannot search, say) are asked before
|
|
303
|
+
# anything is written, so the row that caused it keeps the form and everything typed into it.
|
|
304
|
+
return (self._refusal_key(draft, error), str(error))
|
|
269
305
|
draft.given.add("token")
|
|
270
306
|
self._config_write(draft)
|
|
271
307
|
return None
|
|
272
308
|
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _refusal_key(draft: Draft, error: ValueError) -> str:
|
|
311
|
+
"""Return the form row a loader refusal belongs to, by the rule it names."""
|
|
312
|
+
if (
|
|
313
|
+
draft.web_search
|
|
314
|
+
and draft.api is not Api.ANTHROPIC
|
|
315
|
+
and ("deepseek" in draft.model.lower() or "deepseek" in (draft.url or "").lower())
|
|
316
|
+
):
|
|
317
|
+
return "web_search"
|
|
318
|
+
# The three parsers above refuse these values before the loader sees them; these are a floor for a
|
|
319
|
+
# rule the form does not word yet.
|
|
320
|
+
message = str(error).lower()
|
|
321
|
+
if "context_window" in message or "context window" in message:
|
|
322
|
+
return "context_window"
|
|
323
|
+
if "compact_percent" in message or "compact percent" in message:
|
|
324
|
+
return "compact_percent"
|
|
325
|
+
if "temperature" in message:
|
|
326
|
+
return "temperature"
|
|
327
|
+
return ""
|
|
328
|
+
|
|
273
329
|
def _config_ask_api(self, draft: Draft, *, replace: bool = True) -> None:
|
|
274
|
-
"""Ask which protocol
|
|
275
|
-
rows = [ListItem(api, api.
|
|
330
|
+
"""Ask which protocol the endpoint speaks."""
|
|
331
|
+
rows = [ListItem(api, api.label, api.description) for api in Api]
|
|
276
332
|
page = ListPage(
|
|
277
333
|
rows,
|
|
278
334
|
title="Which API does the endpoint speak?",
|
|
@@ -564,7 +620,7 @@ class ConfigMixin(ShellState):
|
|
|
564
620
|
Raises:
|
|
565
621
|
ValueError: With the reason, when the line cannot be understood or the entry is refused.
|
|
566
622
|
"""
|
|
567
|
-
draft = parse_add(argument
|
|
623
|
+
draft = parse_add(argument)
|
|
568
624
|
if draft.missing():
|
|
569
625
|
self._config_ask(draft, replace=False) # nothing of the flow is on screen yet
|
|
570
626
|
return None
|
|
@@ -615,12 +671,3 @@ class ConfigMixin(ShellState):
|
|
|
615
671
|
f"| {whole_if_whole(model.compact_percent)}% |"
|
|
616
672
|
)
|
|
617
673
|
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)
|
|
@@ -44,6 +44,9 @@ class SettingsMixin(ShellState):
|
|
|
44
44
|
selected = self.client.use_model(name)
|
|
45
45
|
if previous is None or selected.name != previous.name or selected.model != previous.model:
|
|
46
46
|
self.transcript.model_changed(previous.name if previous is not None else "", selected.name)
|
|
47
|
+
# A machine whose first model is one a plugin offered had nothing to warm at launch; the choice is the
|
|
48
|
+
# moment the runtime has something to build, exactly as the first `/config` entry is.
|
|
49
|
+
self._warm_runtime()
|
|
47
50
|
self.app.request_layout()
|
|
48
51
|
return selected
|
|
49
52
|
|
|
@@ -29,19 +29,32 @@ EMPTY_REPLY = "The model sent back an empty reply. Try again, or check the model
|
|
|
29
29
|
#: What a machine with no ``[[models]]`` entry says for itself. The shell starts anyway — a reader can look through
|
|
30
30
|
#: sessions, change the theme and set a model up — so the line that opens the conversation names the one command
|
|
31
31
|
#: that turns it into a working agent, rather than a start that exits with a message about a file.
|
|
32
|
-
NO_MODEL_START =
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
NO_MODEL_START = "no model is configured yet \u2014 /config adds one; nothing else here waits for it"
|
|
33
|
+
|
|
34
|
+
#: What that machine says when a plugin offers models but none has been chosen: ``/config`` edits only the file, so
|
|
35
|
+
#: the picker is the command that makes it work.
|
|
36
|
+
NO_MODEL_CHOSEN_START = "no model is chosen yet \u2014 /model picks one; nothing else here waits for it"
|
|
36
37
|
|
|
37
38
|
#: What a prompt typed on such a machine is answered with. It is not an error in the program but in the setup, so
|
|
38
39
|
#: the sentence says how to finish the setup, and the draft is kept so nothing has to be typed again.
|
|
39
40
|
NO_MODEL_ASKED = "no model is configured yet \u2014 /config adds one, then ask again; your words are still in the box"
|
|
40
41
|
|
|
42
|
+
#: What a prompt is answered with when the models exist but none is chosen.
|
|
43
|
+
NO_MODEL_CHOSEN_ASKED = (
|
|
44
|
+
"no model is chosen yet \u2014 /model picks one, then ask again; your words are still in the box"
|
|
45
|
+
)
|
|
46
|
+
|
|
41
47
|
#: What ``/btw`` says instead, and the difference is the box: a command is submitted and the composer cleared
|
|
42
48
|
#: before its handler runs, so the prompt's "your words are still in the box" would be false there.
|
|
43
49
|
NO_MODEL_QUESTION = "no model is configured yet \u2014 /config adds one; /btw asks it once there is one"
|
|
44
50
|
|
|
51
|
+
#: What ``/btw`` says when the models exist but none is chosen.
|
|
52
|
+
NO_MODEL_CHOSEN_QUESTION = "no model is chosen yet \u2014 /model picks one; /btw asks it once there is one"
|
|
53
|
+
|
|
54
|
+
#: What a pasted image is told when nothing could take it, by the two states above.
|
|
55
|
+
NO_MODEL_IMAGE = "no model is configured yet \u2014 /config adds one, then the image can go with it"
|
|
56
|
+
NO_MODEL_CHOSEN_IMAGE = "no model is chosen yet \u2014 /model picks one, then the image can go with it"
|
|
57
|
+
|
|
45
58
|
#: Screen name used for a page a command presented; the shell checks it to know
|
|
46
59
|
#: that Ctrl-C and Ctrl-D belong to the page rather than the composer.
|
|
47
60
|
PAGE_SCREEN = "page"
|