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.
Files changed (80) hide show
  1. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/PKG-INFO +1 -1
  2. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/agent/commands.py +0 -6
  3. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/app.py +7 -3
  4. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/commands.py +6 -10
  5. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/config_form.py +23 -44
  6. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/keys.py +4 -3
  7. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/providers.py +113 -66
  8. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/settings.py +3 -0
  9. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/shell.py +17 -4
  10. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/form_page.py +186 -24
  11. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/model_page.py +16 -6
  12. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/.gitignore +0 -0
  13. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/pyproject.toml +0 -0
  14. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/__init__.py +0 -0
  15. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/__init__.py +0 -0
  16. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/agent/__init__.py +0 -0
  17. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/agent/settings.py +0 -0
  18. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/__init__.py +0 -0
  19. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/changes.py +0 -0
  20. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/effort_gauge.py +0 -0
  21. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/projection.py +0 -0
  22. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/rendering.py +0 -0
  23. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/replay.py +0 -0
  24. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/shimmer.py +0 -0
  25. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/stops.py +0 -0
  26. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/tips.py +0 -0
  27. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/transcript/__init__.py +0 -0
  28. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/transcript/fold.py +0 -0
  29. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/transcript/log.py +0 -0
  30. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/transcript/oneoff.py +0 -0
  31. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/transcript/stream.py +0 -0
  32. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/agent/conversation/transcript/turn.py +0 -0
  33. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/commandline/__init__.py +0 -0
  34. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/commandline/arguments.py +0 -0
  35. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/commandline/completion.py +0 -0
  36. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/commandline/history.py +0 -0
  37. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/commandline/runner.py +0 -0
  38. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/errors.py +0 -0
  39. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/plugins/__init__.py +0 -0
  40. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/plugins/builtins.py +0 -0
  41. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/plugins/groups.py +0 -0
  42. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/plugins/loader.py +0 -0
  43. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/py.typed +0 -0
  44. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/__init__.py +0 -0
  45. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/bars.py +0 -0
  46. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/boot.py +0 -0
  47. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/clipboard.py +0 -0
  48. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/demo.py +0 -0
  49. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/followups.py +0 -0
  50. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/gc_policy.py +0 -0
  51. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/messaging.py +0 -0
  52. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/notices.py +0 -0
  53. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/sessions.py +0 -0
  54. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/turns.py +0 -0
  55. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/updating.py +0 -0
  56. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/__init__.py +0 -0
  57. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/approval_page.py +0 -0
  58. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/ask_page.py +0 -0
  59. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/bash_page.py +0 -0
  60. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/completer.py +0 -0
  61. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/composer.py +0 -0
  62. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/document_page.py +0 -0
  63. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/effort_page.py +0 -0
  64. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/follow_up.py +0 -0
  65. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/input_page.py +0 -0
  66. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/model_settings_page.py +0 -0
  67. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/pet.py +0 -0
  68. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/pet_page.py +0 -0
  69. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/plugins_page.py +0 -0
  70. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/root.py +0 -0
  71. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/self_compact_page.py +0 -0
  72. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/send_kind_page.py +0 -0
  73. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/sessions_page.py +0 -0
  74. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/statusline_page.py +0 -0
  75. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/steering.py +0 -0
  76. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/transcript.py +0 -0
  77. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/update_page.py +0 -0
  78. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/app/ui/widgets/welcome.py +0 -0
  79. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/collector.py +0 -0
  80. {zettcode_app-0.4.2 → zettcode_app-0.4.4}/src/zettcode/update.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: zettcode-app
3
- Version: 0.4.2
3
+ Version: 0.4.4
4
4
  Summary: The terminal application of ZettCode: the conversation model, the shell and its views
5
5
  Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
6
6
  Author: Chang-LeHung
@@ -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.transcript.notice(NO_MODEL_START)
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: a provider, then what it needs", needs_more=True),
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|providers]",
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 a provider and whatever of its settings the line gives, and
151
- asks for the rest; ``remove`` takes a number or a name; ``list`` and ``providers`` only
152
- show. A refusal \u2014 a bad address, a file that cannot be edited \u2014 is the command's error and
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 <provider> ... | remove <number|name> | list | providers]")
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 choice of provider and a few answers into one model entry.
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 CUSTOM, Api, Provider, check_url, find_provider
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 <provider> [--model ID] [--token KEY] [--url URL] [--api chat|responses|anthropic] "
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
- provider: The described host the model is at, or ``None`` for a custom one.
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 panel's provider list, which is where Escape from
89
- #: the first question goes back to; from a typed line there is nothing behind it.
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 token is asked about, not required: a draft that was not told one (``--token``) is shown to the reader
133
- with the line empty, and an empty line is a valid answer.
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 self.custom and "api" not in self.given:
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, providers: Sequence[Provider]) -> Draft:
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``: a provider name (or ``custom``) and optional flags.
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 or words[0].startswith("--"):
199
- names = ", ".join([*(provider.name for provider in providers), CUSTOM])
200
- raise ValueError(f"Name the provider: one of {names}\nUsage: {USAGE}")
201
- name, flags = words[0].lower(), _flags(words[1:])
202
- provider = None if name == CUSTOM else find_provider(name, providers)
203
- if provider is None and name != CUSTOM:
204
- known = ", ".join([*(item.name for item in providers), CUSTOM])
205
- raise ValueError(f"Unknown provider {words[0]!r}; use one of {known}")
206
- draft = Draft.for_provider(provider)
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
- self.transcript.error("no model is configured yet \u2014 /config adds one, then the image can go with it")
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, CUSTOM, Api, check_url
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", "from a provider, or any endpoint"))
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._config_pick_provider(replace=True)
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, provider: Provider | None) -> None:
139
- """Start asking what the provider does not already say."""
140
- draft = Draft.for_provider(provider)
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 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.
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._config_pick_provider(replace=True)
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
- 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.
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
- 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 ""),
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"{token_note} It is written to {self.client.config_file}.",
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 at {host}",
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
- elif draft.api is not Api.ANTHROPIC:
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 a custom endpoint speaks."""
275
- rows = [ListItem(api, api.value, api.description) for api in 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, self.agent.providers)
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
- "no model is configured yet \u2014 /config adds one (a provider, a model id, and a token); "
34
- "nothing else here waits for it"
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"