scootcli 0.2.0__tar.gz → 0.3.0__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 (104) hide show
  1. {scootcli-0.2.0/src/scootcli.egg-info → scootcli-0.3.0}/PKG-INFO +35 -2
  2. {scootcli-0.2.0 → scootcli-0.3.0}/README.md +34 -1
  3. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/__init__.py +1 -1
  4. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/agent.py +22 -5
  5. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/auth.py +8 -3
  6. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/cli.py +15 -4
  7. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/__init__.py +1 -0
  8. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/auth.py +4 -2
  9. scootcli-0.3.0/src/scootcli/commands/route.py +21 -0
  10. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/status.py +7 -0
  11. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/providers/base.py +17 -1
  12. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/providers/registry.py +58 -2
  13. scootcli-0.3.0/src/scootcli/providers/router.py +231 -0
  14. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/repl.py +18 -5
  15. {scootcli-0.2.0 → scootcli-0.3.0/src/scootcli.egg-info}/PKG-INFO +35 -2
  16. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli.egg-info/SOURCES.txt +4 -0
  17. scootcli-0.3.0/tests/test_readiness.py +134 -0
  18. scootcli-0.3.0/tests/test_router.py +155 -0
  19. {scootcli-0.2.0 → scootcli-0.3.0}/LICENSE +0 -0
  20. {scootcli-0.2.0 → scootcli-0.3.0}/pyproject.toml +0 -0
  21. {scootcli-0.2.0 → scootcli-0.3.0}/setup.cfg +0 -0
  22. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/__main__.py +0 -0
  23. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/activity.py +0 -0
  24. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/approvals.py +0 -0
  25. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/clipboard.py +0 -0
  26. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/approve.py +0 -0
  27. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/base.py +0 -0
  28. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/compact.py +0 -0
  29. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/copy.py +0 -0
  30. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/exit.py +0 -0
  31. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/forget.py +0 -0
  32. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/help.py +0 -0
  33. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/init.py +0 -0
  34. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/logo.py +0 -0
  35. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/model.py +0 -0
  36. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/panel.py +0 -0
  37. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/reset.py +0 -0
  38. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/resume.py +0 -0
  39. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/save.py +0 -0
  40. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/sessions.py +0 -0
  41. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/verbosity.py +0 -0
  42. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/worktree.py +0 -0
  43. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/yolo.py +0 -0
  44. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/config.py +0 -0
  45. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/context.py +0 -0
  46. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/credentials.py +0 -0
  47. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/errors.py +0 -0
  48. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/images.py +0 -0
  49. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/keys.py +0 -0
  50. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/lineeditor.py +0 -0
  51. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/logo.py +0 -0
  52. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/models.py +0 -0
  53. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/panel.py +0 -0
  54. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/preferences.py +0 -0
  55. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/presets.py +0 -0
  56. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/project.py +0 -0
  57. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/prompts.py +0 -0
  58. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/providers/__init__.py +0 -0
  59. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/providers/anthropic.py +0 -0
  60. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/providers/openai_chat.py +0 -0
  61. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/providers/openai_responses.py +0 -0
  62. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/rendering.py +0 -0
  63. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/sessions.py +0 -0
  64. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/status.py +0 -0
  65. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/tools/__init__.py +0 -0
  66. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/tools/base.py +0 -0
  67. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/tools/edit_file.py +0 -0
  68. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/tools/list_dir.py +0 -0
  69. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/tools/open_editor.py +0 -0
  70. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/tools/read_file.py +0 -0
  71. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/tools/run_shell.py +0 -0
  72. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/tools/search.py +0 -0
  73. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/tools/update_plan.py +0 -0
  74. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/tools/write_file.py +0 -0
  75. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/transport.py +0 -0
  76. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/vision.py +0 -0
  77. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/workspace.py +0 -0
  78. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/worktree.py +0 -0
  79. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
  80. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli.egg-info/entry_points.txt +0 -0
  81. {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli.egg-info/top_level.txt +0 -0
  82. {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_agent.py +0 -0
  83. {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_anthropic.py +0 -0
  84. {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_approvals.py +0 -0
  85. {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_auth.py +0 -0
  86. {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_clipboard.py +0 -0
  87. {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_config.py +0 -0
  88. {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_images.py +0 -0
  89. {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_lineeditor.py +0 -0
  90. {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_logo.py +0 -0
  91. {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_models.py +0 -0
  92. {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_panel.py +0 -0
  93. {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_preferences.py +0 -0
  94. {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_providers.py +0 -0
  95. {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_repl.py +0 -0
  96. {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_resilience.py +0 -0
  97. {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_sessions.py +0 -0
  98. {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_smoke.py +0 -0
  99. {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_streaming.py +0 -0
  100. {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_tools.py +0 -0
  101. {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_transport_native.py +0 -0
  102. {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_vision.py +0 -0
  103. {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_workspace.py +0 -0
  104. {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scootcli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A tiny coding agent that goes where you point it
5
5
  Author: Sergey Nes
6
6
  License-Expression: MIT
@@ -36,7 +36,7 @@ Dynamic: license-file
36
36
  ```
37
37
  ╭───╮ scoot: a tiny coding agent that goes where you point it.
38
38
  │o o│
39
- T──┤───┤ pipx install scootcli
39
+ T──┤───┤ pipx install scootcli (or: curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash)
40
40
  │ ╰┬─┬╯ scoot
41
41
  (o)═══╧═╧═(o)
42
42
  ```
@@ -106,6 +106,36 @@ scoot --model auto "..." # pick a model per prompt from the live lis
106
106
  ```
107
107
 
108
108
  Inside the REPL, `/model <provider/model>` switches and is remembered for the next launch; `/model default` goes back to the provider's preferred model.
109
+
110
+ ### Routing
111
+
112
+ `auto` is opt-in (`--model auto`, `SCOOT_MODEL=auto`, or `/model auto`) because choosing costs a decision per turn.
113
+ Without any configuration it uses a built-in heuristic over the models your providers actually list: a strong coding model for multi-step or editing prompts, a cheaper one for short questions, never a dated snapshot.
114
+ A turn is routed once, at its first model call, and stays on that model.
115
+
116
+ Write your own rules in `~/.config/scoot/router.json` (or the file named by `SCOOT_ROUTER`); first match wins, and a rule whose provider has no key is skipped:
117
+
118
+ ```json
119
+ {
120
+ "rules": [
121
+ {"when": {"has_images": true}, "use": "anthropic/claude-opus-5"},
122
+ {"when": {"complex": true}, "use": "openai/gpt-5.3-codex"},
123
+ {"when": {"est_tokens_over": 60000}, "use": "anthropic/claude-sonnet-5"},
124
+ {"when": {"prompt_matches": "(?i)translate|summari[sz]e"}, "use": "ollama/qwen3"}
125
+ ],
126
+ "default": "ollama/llama3.2",
127
+ "classifier": {
128
+ "model": "ollama/llama3.2",
129
+ "tiers": {"simple": "ollama/llama3.2", "coding": "openai/gpt-5.3-codex", "hard": "anthropic/claude-opus-5"}
130
+ }
131
+ }
132
+ ```
133
+
134
+ Conditions: `complex`, `has_images`, `needs_tools`, `est_tokens_over`, `prompt_matches`.
135
+ The optional `classifier` asks a small model one question per turn ("simple, coding, or hard?") and maps the answer to a tier; it adds a short call, and any failure falls through to the rules.
136
+ Expect it to be rough with a 3B local model: on a hand-labelled set of seven prompts, `llama3.2` and `qwen2.5` each got four right, mostly confusing "coding" with "hard".
137
+ The rules are deterministic, so put the decisions you care about there, and if you want a better judge, name a cheap hosted model as the classifier (`openai/gpt-5-mini`), which costs a few hundred tokens per turn.
138
+ `/route` shows the rules in force and why the current model was picked; `/status` shows tokens per model.
109
139
  `SCOOT_EFFORT` (`low` | `medium` | `high` | `xhigh`, default `medium`) sets the reasoning effort for models that take it, on both OpenAI and Anthropic.
110
140
  On Claude Opus 5 the server-side refusal fallback is requested by default, so a declined request is retried on another Claude model inside the same call; `SCOOT_ANTHROPIC_FALLBACKS=0` turns that off.
111
141
 
@@ -205,11 +235,14 @@ The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `>
205
235
 
206
236
  ```bash
207
237
  pipx install scootcli # recommended: isolated, `scoot` on PATH
238
+ curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # no pipx: puts the zipapp at ~/.local/bin/scoot
208
239
  pip install scootcli # anywhere
209
240
  curl -LO https://github.com/sergenes/scootcli/releases/latest/download/scoot.pyz && python3 scoot.pyz # single file, no install
210
241
  git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e . # from source
211
242
  ```
212
243
 
244
+ The install script needs only `curl` and Python 3.9+. `SCOOT_VERSION=v0.2.0` pins a release and `SCOOT_INSTALL_DIR` changes the target; read it before you run it, it is sixty lines.
245
+
213
246
  Requirements: Python 3.9 or newer on macOS or Linux.
214
247
  Nothing else: no compiler, no packages, no `curl`.
215
248
 
@@ -11,7 +11,7 @@
11
11
  ```
12
12
  ╭───╮ scoot: a tiny coding agent that goes where you point it.
13
13
  │o o│
14
- T──┤───┤ pipx install scootcli
14
+ T──┤───┤ pipx install scootcli (or: curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash)
15
15
  │ ╰┬─┬╯ scoot
16
16
  (o)═══╧═╧═(o)
17
17
  ```
@@ -81,6 +81,36 @@ scoot --model auto "..." # pick a model per prompt from the live lis
81
81
  ```
82
82
 
83
83
  Inside the REPL, `/model <provider/model>` switches and is remembered for the next launch; `/model default` goes back to the provider's preferred model.
84
+
85
+ ### Routing
86
+
87
+ `auto` is opt-in (`--model auto`, `SCOOT_MODEL=auto`, or `/model auto`) because choosing costs a decision per turn.
88
+ Without any configuration it uses a built-in heuristic over the models your providers actually list: a strong coding model for multi-step or editing prompts, a cheaper one for short questions, never a dated snapshot.
89
+ A turn is routed once, at its first model call, and stays on that model.
90
+
91
+ Write your own rules in `~/.config/scoot/router.json` (or the file named by `SCOOT_ROUTER`); first match wins, and a rule whose provider has no key is skipped:
92
+
93
+ ```json
94
+ {
95
+ "rules": [
96
+ {"when": {"has_images": true}, "use": "anthropic/claude-opus-5"},
97
+ {"when": {"complex": true}, "use": "openai/gpt-5.3-codex"},
98
+ {"when": {"est_tokens_over": 60000}, "use": "anthropic/claude-sonnet-5"},
99
+ {"when": {"prompt_matches": "(?i)translate|summari[sz]e"}, "use": "ollama/qwen3"}
100
+ ],
101
+ "default": "ollama/llama3.2",
102
+ "classifier": {
103
+ "model": "ollama/llama3.2",
104
+ "tiers": {"simple": "ollama/llama3.2", "coding": "openai/gpt-5.3-codex", "hard": "anthropic/claude-opus-5"}
105
+ }
106
+ }
107
+ ```
108
+
109
+ Conditions: `complex`, `has_images`, `needs_tools`, `est_tokens_over`, `prompt_matches`.
110
+ The optional `classifier` asks a small model one question per turn ("simple, coding, or hard?") and maps the answer to a tier; it adds a short call, and any failure falls through to the rules.
111
+ Expect it to be rough with a 3B local model: on a hand-labelled set of seven prompts, `llama3.2` and `qwen2.5` each got four right, mostly confusing "coding" with "hard".
112
+ The rules are deterministic, so put the decisions you care about there, and if you want a better judge, name a cheap hosted model as the classifier (`openai/gpt-5-mini`), which costs a few hundred tokens per turn.
113
+ `/route` shows the rules in force and why the current model was picked; `/status` shows tokens per model.
84
114
  `SCOOT_EFFORT` (`low` | `medium` | `high` | `xhigh`, default `medium`) sets the reasoning effort for models that take it, on both OpenAI and Anthropic.
85
115
  On Claude Opus 5 the server-side refusal fallback is requested by default, so a declined request is retried on another Claude model inside the same call; `SCOOT_ANTHROPIC_FALLBACKS=0` turns that off.
86
116
 
@@ -180,11 +210,14 @@ The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `>
180
210
 
181
211
  ```bash
182
212
  pipx install scootcli # recommended: isolated, `scoot` on PATH
213
+ curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # no pipx: puts the zipapp at ~/.local/bin/scoot
183
214
  pip install scootcli # anywhere
184
215
  curl -LO https://github.com/sergenes/scootcli/releases/latest/download/scoot.pyz && python3 scoot.pyz # single file, no install
185
216
  git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e . # from source
186
217
  ```
187
218
 
219
+ The install script needs only `curl` and Python 3.9+. `SCOOT_VERSION=v0.2.0` pins a release and `SCOOT_INSTALL_DIR` changes the target; read it before you run it, it is sixty lines.
220
+
188
221
  Requirements: Python 3.9 or newer on macOS or Linux.
189
222
  Nothing else: no compiler, no packages, no `curl`.
190
223
 
@@ -1,4 +1,4 @@
1
1
  """scoot: a tiny coding agent that goes where you point it."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
4
4
 
@@ -129,7 +129,10 @@ class Agent:
129
129
  except ScootError as exc:
130
130
  return AgentOutcome("error", error=_fmt_error(exc), steps=steps)
131
131
 
132
- session.account(result.usage)
132
+ try:
133
+ session.account(result.usage, model=result.model or session.active_model)
134
+ except TypeError: # older/fake sessions with a one-argument account()
135
+ session.account(result.usage)
133
136
  session.messages.append(self._assistant_message(result))
134
137
 
135
138
  if result.tool_calls:
@@ -187,19 +190,33 @@ class Agent:
187
190
 
188
191
  # ── message construction ─────────────────────────────────────────────────────
189
192
  def _pick_model(self, session) -> str:
190
- """Resolve the model for this turn. For ``auto``, use the heuristic over live models."""
193
+ """Resolve the model for this turn. ``auto`` routes once per turn (see ``providers.router``)."""
191
194
  if session.model.lower() != "auto":
192
195
  return session.resolved_model()
193
- from .models import resolve_auto
196
+ from .providers.router import Router, compute_hints
194
197
 
195
- last_user = self._last_user_text(session)
198
+ router = getattr(session, "router", None)
199
+ if router is None:
200
+ router = Router()
201
+ try:
202
+ session.router = router
203
+ except Exception:
204
+ pass
196
205
  try:
197
206
  available = session.available_models()
198
207
  except Exception:
199
208
  available = []
200
209
  bad = getattr(session, "bad_models", None) or set()
201
210
  available = [m for m in available if m not in bad] or available
202
- return resolve_auto(last_user, available, fallback=session.resolved_model())
211
+ hints = compute_hints(session)
212
+ return router.choose(session, hints, available, session.resolved_model(),
213
+ classify=self._classify_with_provider).model
214
+
215
+ def _classify_with_provider(self, model: str, prompt: str) -> str:
216
+ """One small, non-streaming call used by a configured router classifier."""
217
+ result = self.provider.chat([{"role": "user", "content": prompt}], model=model, max_tokens=5,
218
+ temperature=0, hints={"purpose": "route"})
219
+ return result.content or ""
203
220
 
204
221
  def _fallback_model(self, session) -> bool:
205
222
  """After a model-unavailable error, blacklist it and switch models. Returns False if stuck."""
@@ -43,14 +43,19 @@ def status_rows(config) -> List[dict]:
43
43
  default = registry.default_provider_name(config)
44
44
  rows = []
45
45
  for spec in registry.all_specs():
46
- rows.append({
46
+ url = registry.base_url_for(spec)
47
+ row = {
47
48
  "name": spec.name,
48
49
  "source": key_source(spec),
49
50
  "required": spec.key_required,
50
51
  "configured": is_configured(spec),
51
52
  "default": spec.name == default,
52
- "base_url": registry.base_url_for(spec),
53
- })
53
+ "base_url": url,
54
+ "reachable": None, # only probed for keyless local servers
55
+ }
56
+ if not spec.key_required and registry.is_local_url(url):
57
+ row["reachable"] = registry.reachable(url)
58
+ rows.append(row)
54
59
  return rows
55
60
 
56
61
 
@@ -141,6 +141,16 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
141
141
  from .agent import Agent
142
142
  from .repl import ReplSession, ReplUI
143
143
 
144
+ from .providers.registry import readiness
145
+
146
+ ready, message = readiness(config)
147
+ if not ready: # fail fast with guidance instead of a connection error after retries
148
+ if as_json:
149
+ print(_json.dumps({"status": "error", "model": "", "steps": 0, "content": "",
150
+ "error": message, "usage": {}}, indent=2))
151
+ else:
152
+ eprint(color(message, "yellow"))
153
+ return 1
144
154
  session = ReplSession(config, pool)
145
155
  if resume is not None:
146
156
  session.apply_record(resume)
@@ -194,7 +204,6 @@ def _cmd_auth(config: Config, pool: ProviderPool, words: List[str]) -> int:
194
204
 
195
205
  def _interactive(pool: ProviderPool, resume=None) -> int:
196
206
  """Launch the persistent REPL (banner, live status, ESC-interrupt, slash-commands)."""
197
- from .auth import is_configured, missing_key_hint
198
207
  from .repl import Repl
199
208
 
200
209
  config = pool.config
@@ -206,9 +215,11 @@ def _interactive(pool: ProviderPool, resume=None) -> int:
206
215
  resume = sessions.latest_for_root(str(config.root))
207
216
 
208
217
  try:
209
- spec = pool.spec
210
- if not is_configured(spec): # first-run onboarding: guide, but still open the REPL
211
- eprint(color(f"No API key for {spec.name}: {missing_key_hint(spec)}", "yellow"))
218
+ from .providers.registry import readiness
219
+
220
+ ready, message = readiness(config)
221
+ if not ready: # first-run onboarding: guide, but still open the REPL so /auth is usable
222
+ eprint(color(message, "yellow"))
212
223
  except ScootError as exc:
213
224
  eprint(color(f"⚠ {redact(str(exc))}", "yellow"))
214
225
  return Repl(config, pool, resume=resume).run()
@@ -51,6 +51,7 @@ def load_builtins() -> None:
51
51
  from . import verbosity as _verbosity # noqa: F401
52
52
  from . import copy as _copy # noqa: F401
53
53
  from . import logo as _logo # noqa: F401
54
+ from . import route as _route # noqa: F401
54
55
 
55
56
  _loaded = True
56
57
 
@@ -24,8 +24,10 @@ def _show(session) -> None:
24
24
  print(color("providers:", "bold"))
25
25
  for row in rows:
26
26
  name = row["name"] + (" (default)" if row["default"] else "")
27
- if not row["required"]:
28
- state = color("no key needed", "gray")
27
+ if not row["required"] and row.get("reachable") is False:
28
+ state = color("not running (ollama serve; install from https://ollama.com)", "yellow")
29
+ elif not row["required"]:
30
+ state = color("no key needed" + (", running" if row.get("reachable") else ""), "gray")
29
31
  elif row["source"]:
30
32
  state = color(f"key from {row['source']}", "green")
31
33
  else:
@@ -0,0 +1,21 @@
1
+ """/route — show how the ``auto`` model alias routes: rule source, rules, classifier, last decision."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..providers.router import Router, config_path, describe
6
+ from ..rendering import color
7
+ from . import register
8
+ from .base import SlashCommand
9
+
10
+
11
+ def _run(session, args: str):
12
+ router = getattr(session, "router", None) or Router()
13
+ print(color("routing (for /model auto):", "bold"))
14
+ for line in describe(router):
15
+ print(" " + line)
16
+ if session.model.lower() != "auto":
17
+ print(color(f" auto is off; the preference is '{session.model}'. Turn it on with /model auto.", "gray"))
18
+ print(color(f" edit rules in {config_path()} (see README: Routing)", "gray"))
19
+
20
+
21
+ register(SlashCommand("route", "show how 'auto' picks a model", _run))
@@ -42,6 +42,13 @@ def _run(session, args: str):
42
42
  print(color("status:", "bold"))
43
43
  for label, value in rows:
44
44
  print(f" {color(label + ':', 'gray'):<28} {value}")
45
+ by_model = getattr(session, "usage_by_model", None) or {}
46
+ if len(by_model) > 1 or (by_model and session.model.lower() == "auto"):
47
+ print(f" {color('tokens by model:', 'gray')}")
48
+ for name, u in sorted(by_model.items(), key=lambda kv: -(kv[1]['prompt'] + kv[1]['completion'])):
49
+ print(f" {color(name, 'cyan'):<40} prompt={u['prompt']} completion={u['completion']} calls={u['calls']}")
50
+ if session.model.lower() == "auto" and getattr(session, "route_reason", ""):
51
+ print(f" {color('routed by:', 'gray'):<28} {session.route_reason}")
45
52
  trusted = getattr(session, "trusted_tools", None)
46
53
  if trusted:
47
54
  print(f" {color('trusted tools:', 'gray'):<28} {', '.join(sorted(trusted))}")
@@ -21,6 +21,7 @@ from ..errors import (
21
21
  ContextLengthError,
22
22
  Interrupted,
23
23
  ModelUnavailableError,
24
+ NetworkError,
24
25
  QuotaError,
25
26
  RETRIABLE,
26
27
  RateLimitError,
@@ -321,12 +322,27 @@ class BaseProvider:
321
322
 
322
323
  # ── retry policy ─────────────────────────────────────────────────────────────
323
324
  def _with_retry(self, fn: Callable[[], ChatResult], cancel_event, emitted=None) -> ChatResult:
324
- """Retry transient failures with capped, jittered backoff; never after tokens were printed."""
325
+ """Retry transient failures with capped, jittered backoff; never after tokens were printed.
326
+
327
+ A connection failure to a *local* server is not transient (nothing is listening), so it fails
328
+ at once with a hint about starting the server instead of burning the retry budget.
329
+ """
330
+ from .registry import is_local_url
331
+
325
332
  attempt = 0
326
333
  while True:
327
334
  attempt += 1
328
335
  try:
329
336
  return fn()
337
+ except NetworkError as exc:
338
+ if is_local_url(self.spec.base_url):
339
+ exc.hint = (f"{self.name} is not running at {self.spec.base_url}: start it "
340
+ f"(`ollama serve`; install from https://ollama.com) or use another "
341
+ f"provider (`scoot auth set openai`)")
342
+ raise
343
+ if attempt >= _RETRY_ATTEMPTS or (emitted is not None and emitted[0]):
344
+ raise
345
+ self._backoff(attempt, cancel_event)
330
346
  except RETRIABLE:
331
347
  if attempt >= _RETRY_ATTEMPTS or (emitted is not None and emitted[0]):
332
348
  raise
@@ -7,8 +7,10 @@ selected by ``spec.wire``. Base URLs can be overridden per provider with ``SCOOT
7
7
  from __future__ import annotations
8
8
 
9
9
  import os
10
+ import socket
10
11
  from dataclasses import replace
11
- from typing import Dict, List, Optional
12
+ from typing import Dict, List, Optional, Tuple
13
+ from urllib.parse import urlsplit
12
14
 
13
15
  from ..errors import ConfigError, ScootError
14
16
  from .base import BaseProvider, ChatResult, ModelInfo, ProviderSpec, qualify, split_model_id
@@ -89,6 +91,56 @@ def base_url_for(spec: ProviderSpec) -> str:
89
91
  return override or spec.base_url
90
92
 
91
93
 
94
+ def is_local_url(url: str) -> bool:
95
+ host = (urlsplit(url).hostname or "").lower()
96
+ return host in ("localhost", "127.0.0.1", "::1", "0.0.0.0") or host.endswith(".localhost")
97
+
98
+
99
+ def reachable(url: str, timeout: float = 0.3) -> bool:
100
+ """One TCP connect to the URL's host and port. Cheap enough to run at startup for a local server."""
101
+ parts = urlsplit(url)
102
+ host = parts.hostname or ""
103
+ port = parts.port or (443 if parts.scheme == "https" else 80)
104
+ try:
105
+ with socket.create_connection((host, port), timeout=timeout):
106
+ return True
107
+ except OSError:
108
+ return False
109
+
110
+
111
+ SETUP_HELP = (
112
+ "No model provider is ready yet. Set one up:\n"
113
+ " scoot auth set openai hosted, needs an OpenAI API key\n"
114
+ " scoot auth set anthropic hosted, needs an Anthropic API key\n"
115
+ " ollama pull llama3.2 local and free (install from https://ollama.com), then run scoot again"
116
+ )
117
+
118
+
119
+ def readiness(config) -> Tuple[bool, str]:
120
+ """Whether the default provider can take a request now, and a message when it cannot.
121
+
122
+ Hosted providers are ready when a key is present (no network call). A keyless local provider is
123
+ ready when its server answers a TCP connect; when nothing is configured at all, the message lists
124
+ every way to get started.
125
+ """
126
+ from ..auth import is_configured
127
+
128
+ name = default_provider_name(config)
129
+ spec = get(name)
130
+ if spec is None:
131
+ return False, SETUP_HELP
132
+ if spec.key_required:
133
+ if is_configured(spec):
134
+ return True, ""
135
+ return False, SETUP_HELP
136
+ url = base_url_for(spec)
137
+ if reachable(url):
138
+ return True, ""
139
+ if any(s.key_required and is_configured(s) for s in all_specs()):
140
+ return False, f"{name} is not running at {url} (start it with `ollama serve`, or pick another provider with --provider)"
141
+ return False, SETUP_HELP
142
+
143
+
92
144
  def make_provider(name: str, config, transport=None) -> BaseProvider:
93
145
  """Instantiate the adapter for ``name`` with any base-URL override applied."""
94
146
  spec = get(name)
@@ -178,8 +230,12 @@ class ProviderPool:
178
230
  for spec in all_specs():
179
231
  if not is_configured(spec):
180
232
  continue
233
+ url = base_url_for(spec)
234
+ if not spec.key_required and is_local_url(url) and not reachable(url):
235
+ self.list_errors[spec.name] = f"not running at {url} (start it with `ollama serve`)"
236
+ continue
181
237
  try:
182
238
  out.extend(self.provider(spec.name).list_models(cancel_event=cancel_event))
183
239
  except ScootError as exc:
184
- self.list_errors[spec.name] = str(exc)
240
+ self.list_errors[spec.name] = str(exc) + (f" ({exc.hint})" if getattr(exc, "hint", "") else "")
185
241
  return out