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.
- {scootcli-0.2.0/src/scootcli.egg-info → scootcli-0.3.0}/PKG-INFO +35 -2
- {scootcli-0.2.0 → scootcli-0.3.0}/README.md +34 -1
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/__init__.py +1 -1
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/agent.py +22 -5
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/auth.py +8 -3
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/cli.py +15 -4
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/__init__.py +1 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/auth.py +4 -2
- scootcli-0.3.0/src/scootcli/commands/route.py +21 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/status.py +7 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/providers/base.py +17 -1
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/providers/registry.py +58 -2
- scootcli-0.3.0/src/scootcli/providers/router.py +231 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/repl.py +18 -5
- {scootcli-0.2.0 → scootcli-0.3.0/src/scootcli.egg-info}/PKG-INFO +35 -2
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli.egg-info/SOURCES.txt +4 -0
- scootcli-0.3.0/tests/test_readiness.py +134 -0
- scootcli-0.3.0/tests/test_router.py +155 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/LICENSE +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/pyproject.toml +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/setup.cfg +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/__main__.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/activity.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/approvals.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/clipboard.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/approve.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/base.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/compact.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/copy.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/exit.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/forget.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/help.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/init.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/logo.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/model.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/panel.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/reset.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/resume.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/save.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/sessions.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/verbosity.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/worktree.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/commands/yolo.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/config.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/context.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/credentials.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/errors.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/images.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/keys.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/lineeditor.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/logo.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/models.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/panel.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/preferences.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/presets.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/project.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/prompts.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/providers/__init__.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/providers/anthropic.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/providers/openai_chat.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/providers/openai_responses.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/rendering.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/sessions.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/status.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/tools/__init__.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/tools/base.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/tools/edit_file.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/tools/list_dir.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/tools/open_editor.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/tools/read_file.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/tools/run_shell.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/tools/search.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/tools/update_plan.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/tools/write_file.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/transport.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/vision.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/workspace.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli/worktree.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli.egg-info/entry_points.txt +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/src/scootcli.egg-info/top_level.txt +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_agent.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_anthropic.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_approvals.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_auth.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_clipboard.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_config.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_images.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_lineeditor.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_logo.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_models.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_panel.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_preferences.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_providers.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_repl.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_resilience.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_sessions.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_smoke.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_streaming.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_tools.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_transport_native.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_vision.py +0 -0
- {scootcli-0.2.0 → scootcli-0.3.0}/tests/test_workspace.py +0 -0
- {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.
|
|
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
|
|
|
@@ -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
|
-
|
|
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.
|
|
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 .
|
|
196
|
+
from .providers.router import Router, compute_hints
|
|
194
197
|
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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()
|
|
@@ -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("
|
|
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
|