scootcli 0.1.0__tar.gz → 0.2.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.1.0 → scootcli-0.2.0}/LICENSE +1 -1
- {scootcli-0.1.0/src/scootcli.egg-info → scootcli-0.2.0}/PKG-INFO +14 -5
- {scootcli-0.1.0 → scootcli-0.2.0}/README.md +13 -4
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/__init__.py +1 -1
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/config.py +2 -0
- scootcli-0.2.0/src/scootcli/providers/anthropic.py +354 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/providers/base.py +15 -6
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/providers/registry.py +12 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/status.py +14 -1
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/tools/__init__.py +1 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/tools/base.py +28 -1
- scootcli-0.2.0/src/scootcli/tools/open_editor.py +123 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/tools/run_shell.py +3 -1
- {scootcli-0.1.0 → scootcli-0.2.0/src/scootcli.egg-info}/PKG-INFO +14 -5
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli.egg-info/SOURCES.txt +3 -0
- scootcli-0.2.0/tests/test_anthropic.py +286 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_tools.py +64 -2
- {scootcli-0.1.0 → scootcli-0.2.0}/pyproject.toml +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/setup.cfg +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/__main__.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/activity.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/agent.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/approvals.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/auth.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/cli.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/clipboard.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/__init__.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/approve.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/auth.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/base.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/compact.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/copy.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/exit.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/forget.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/help.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/init.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/logo.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/model.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/panel.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/reset.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/resume.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/save.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/sessions.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/status.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/verbosity.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/worktree.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/yolo.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/context.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/credentials.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/errors.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/images.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/keys.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/lineeditor.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/logo.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/models.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/panel.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/preferences.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/presets.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/project.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/prompts.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/providers/__init__.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/providers/openai_chat.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/providers/openai_responses.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/rendering.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/repl.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/sessions.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/tools/edit_file.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/tools/list_dir.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/tools/read_file.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/tools/search.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/tools/update_plan.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/tools/write_file.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/transport.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/vision.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/workspace.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/worktree.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli.egg-info/entry_points.txt +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli.egg-info/top_level.txt +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_agent.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_approvals.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_auth.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_clipboard.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_config.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_images.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_lineeditor.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_logo.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_models.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_panel.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_preferences.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_providers.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_repl.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_resilience.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_sessions.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_smoke.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_streaming.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_transport_native.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_vision.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_workspace.py +0 -0
- {scootcli-0.1.0 → scootcli-0.2.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.2.0
|
|
4
4
|
Summary: A tiny coding agent that goes where you point it
|
|
5
5
|
Author: Sergey Nes
|
|
6
6
|
License-Expression: MIT
|
|
@@ -25,6 +25,14 @@ Dynamic: license-file
|
|
|
25
25
|
|
|
26
26
|
# scoot
|
|
27
27
|
|
|
28
|
+
[](https://pypi.org/project/scootcli/)
|
|
29
|
+
[](https://github.com/sergenes/scootcli/actions/workflows/tests.yml)
|
|
30
|
+
[](https://www.python.org/downloads/)
|
|
31
|
+
[](#install-options)
|
|
32
|
+
[](#install-options)
|
|
33
|
+
[](#providers-and-models)
|
|
34
|
+
[](./LICENSE)
|
|
35
|
+
|
|
28
36
|
```
|
|
29
37
|
╭───╮ scoot: a tiny coding agent that goes where you point it.
|
|
30
38
|
│o o│
|
|
@@ -35,7 +43,7 @@ Dynamic: license-file
|
|
|
35
43
|
|
|
36
44
|
`scoot` is a terminal coding agent in plain Python.
|
|
37
45
|
You type what you want in natural language; it reads, searches, edits, and runs things in your repo, asking before anything risky.
|
|
38
|
-
It talks to official model APIs (OpenAI
|
|
46
|
+
It talks to official model APIs (OpenAI, Anthropic, and a local Ollama for free) and has **zero third-party dependencies**: the whole tool is the Python standard library, and it ships as a single-file zipapp as well as a wheel.
|
|
39
47
|
|
|
40
48
|
## Where this comes from
|
|
41
49
|
|
|
@@ -88,7 +96,7 @@ A bare name means the default provider, which is the first provider that has a k
|
|
|
88
96
|
|---|---|---|---|
|
|
89
97
|
| `openai` | OpenAI Responses API | `OPENAI_API_KEY` | `gpt-5.3-codex` |
|
|
90
98
|
| `ollama` | local Ollama, Responses API | none | `llama3.2` |
|
|
91
|
-
| `anthropic` | Messages API | `ANTHROPIC_API_KEY` |
|
|
99
|
+
| `anthropic` | Anthropic Messages API | `ANTHROPIC_API_KEY` | `claude-opus-5` |
|
|
92
100
|
|
|
93
101
|
```bash
|
|
94
102
|
scoot models # every configured provider, grouped
|
|
@@ -98,7 +106,8 @@ scoot --model auto "..." # pick a model per prompt from the live lis
|
|
|
98
106
|
```
|
|
99
107
|
|
|
100
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.
|
|
101
|
-
`SCOOT_EFFORT` (`low` | `medium` | `high` | `xhigh`, default `medium`) sets the reasoning effort for models that take it.
|
|
109
|
+
`SCOOT_EFFORT` (`low` | `medium` | `high` | `xhigh`, default `medium`) sets the reasoning effort for models that take it, on both OpenAI and Anthropic.
|
|
110
|
+
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.
|
|
102
111
|
|
|
103
112
|
A note on how this is built: scoot speaks the OpenAI chat format internally and translates at the edge.
|
|
104
113
|
Adding a provider that speaks that format is one registry row; a different wire format is one small adapter (`src/scootcli/providers/`).
|
|
@@ -173,7 +182,7 @@ Without a TTY, scoot falls back to a plain prompt with no bar and no dock.
|
|
|
173
182
|
|
|
174
183
|
### Tools and approvals
|
|
175
184
|
|
|
176
|
-
The agent has
|
|
185
|
+
The agent has eight tools: `read_file`, `list_dir`, `search`, `write_file`, `edit_file`, `run_shell`, `open_editor` (hands a file to IntelliJ IDEA's `idea -e` or to VS Code, `SCOOT_EDITOR` picks), and `update_plan` (a progress checklist for multi-step work).
|
|
177
186
|
Every tool is sandboxed to the workspace root.
|
|
178
187
|
|
|
179
188
|
When a call needs approval you can approve once `[a]`, trust that tool for the session `[t]`, approve everything this session `[A]`, edit the arguments `[e]`, skip `[s]`, or quit `[q]`.
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# scoot
|
|
2
2
|
|
|
3
|
+
[](https://pypi.org/project/scootcli/)
|
|
4
|
+
[](https://github.com/sergenes/scootcli/actions/workflows/tests.yml)
|
|
5
|
+
[](https://www.python.org/downloads/)
|
|
6
|
+
[](#install-options)
|
|
7
|
+
[](#install-options)
|
|
8
|
+
[](#providers-and-models)
|
|
9
|
+
[](./LICENSE)
|
|
10
|
+
|
|
3
11
|
```
|
|
4
12
|
╭───╮ scoot: a tiny coding agent that goes where you point it.
|
|
5
13
|
│o o│
|
|
@@ -10,7 +18,7 @@
|
|
|
10
18
|
|
|
11
19
|
`scoot` is a terminal coding agent in plain Python.
|
|
12
20
|
You type what you want in natural language; it reads, searches, edits, and runs things in your repo, asking before anything risky.
|
|
13
|
-
It talks to official model APIs (OpenAI
|
|
21
|
+
It talks to official model APIs (OpenAI, Anthropic, and a local Ollama for free) and has **zero third-party dependencies**: the whole tool is the Python standard library, and it ships as a single-file zipapp as well as a wheel.
|
|
14
22
|
|
|
15
23
|
## Where this comes from
|
|
16
24
|
|
|
@@ -63,7 +71,7 @@ A bare name means the default provider, which is the first provider that has a k
|
|
|
63
71
|
|---|---|---|---|
|
|
64
72
|
| `openai` | OpenAI Responses API | `OPENAI_API_KEY` | `gpt-5.3-codex` |
|
|
65
73
|
| `ollama` | local Ollama, Responses API | none | `llama3.2` |
|
|
66
|
-
| `anthropic` | Messages API | `ANTHROPIC_API_KEY` |
|
|
74
|
+
| `anthropic` | Anthropic Messages API | `ANTHROPIC_API_KEY` | `claude-opus-5` |
|
|
67
75
|
|
|
68
76
|
```bash
|
|
69
77
|
scoot models # every configured provider, grouped
|
|
@@ -73,7 +81,8 @@ scoot --model auto "..." # pick a model per prompt from the live lis
|
|
|
73
81
|
```
|
|
74
82
|
|
|
75
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.
|
|
76
|
-
`SCOOT_EFFORT` (`low` | `medium` | `high` | `xhigh`, default `medium`) sets the reasoning effort for models that take it.
|
|
84
|
+
`SCOOT_EFFORT` (`low` | `medium` | `high` | `xhigh`, default `medium`) sets the reasoning effort for models that take it, on both OpenAI and Anthropic.
|
|
85
|
+
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.
|
|
77
86
|
|
|
78
87
|
A note on how this is built: scoot speaks the OpenAI chat format internally and translates at the edge.
|
|
79
88
|
Adding a provider that speaks that format is one registry row; a different wire format is one small adapter (`src/scootcli/providers/`).
|
|
@@ -148,7 +157,7 @@ Without a TTY, scoot falls back to a plain prompt with no bar and no dock.
|
|
|
148
157
|
|
|
149
158
|
### Tools and approvals
|
|
150
159
|
|
|
151
|
-
The agent has
|
|
160
|
+
The agent has eight tools: `read_file`, `list_dir`, `search`, `write_file`, `edit_file`, `run_shell`, `open_editor` (hands a file to IntelliJ IDEA's `idea -e` or to VS Code, `SCOOT_EDITOR` picks), and `update_plan` (a progress checklist for multi-step work).
|
|
152
161
|
Every tool is sandboxed to the workspace root.
|
|
153
162
|
|
|
154
163
|
When a call needs approval you can approve once `[a]`, trust that tool for the session `[t]`, approve everything this session `[A]`, edit the arguments `[e]`, skip `[s]`, or quit `[q]`.
|
|
@@ -178,6 +178,7 @@ class Config:
|
|
|
178
178
|
images: bool = True # detect dropped image paths + describe them with a vision model (M22)
|
|
179
179
|
vision_model: str = "auto" # provider/model for image descriptions, or "auto" → pick a capable one
|
|
180
180
|
image_max_bytes: int = 4 * 1024 * 1024 # per-image cap (no downscale without a 3rd-party lib)
|
|
181
|
+
editor: str = "idea" # external editor for the open_editor tool: idea | vscode
|
|
181
182
|
env_files: Tuple[str, ...] = () # the .env files that were read, in load order (shown by /status)
|
|
182
183
|
logo: bool = True # mascot in the launch banner + status-bar face (--no-logo / SCOOT_LOGO / /logo)
|
|
183
184
|
emoji: bool = True # 🛴 transcript label; off → ⏺ for terminals without an emoji font (--no-emoji)
|
|
@@ -214,6 +215,7 @@ class Config:
|
|
|
214
215
|
images=_as_bool(get("SCOOT_IMAGES", "true")),
|
|
215
216
|
vision_model=get("SCOOT_VISION_MODEL", "auto"),
|
|
216
217
|
image_max_bytes=int(get("SCOOT_IMAGE_MAX_BYTES", str(4 * 1024 * 1024))),
|
|
218
|
+
editor=get("SCOOT_EDITOR", "idea").strip().lower(),
|
|
217
219
|
logo=_resolve_logo_setting(get("SCOOT_LOGO")),
|
|
218
220
|
emoji=_as_bool(get("SCOOT_EMOJI", "true")),
|
|
219
221
|
)
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""Anthropic Messages API adapter (``POST {base}/messages``).
|
|
2
|
+
|
|
3
|
+
Anthropic has one chat endpoint and it is the same kind of API as OpenAI's Responses: stateless, a
|
|
4
|
+
turn is a list of typed content blocks, opaque reasoning (``thinking`` blocks) is echoed back to the
|
|
5
|
+
producing model. Translation from scoot's neutral format:
|
|
6
|
+
|
|
7
|
+
* ``system`` messages → the top-level ``system`` string
|
|
8
|
+
* ``user`` messages → ``text`` / ``image`` (base64) blocks
|
|
9
|
+
* ``assistant`` messages → ``text`` + ``tool_use`` blocks, or the verbatim ``provider_items`` blocks
|
|
10
|
+
(thinking included) when this very model produced them
|
|
11
|
+
* consecutive ``tool`` messages → one ``user`` message of ``tool_result`` blocks, never split
|
|
12
|
+
* tool schemas → ``{"name", "description", "input_schema"}``
|
|
13
|
+
|
|
14
|
+
Model-specific request rules (from the Claude API reference, 2026-06):
|
|
15
|
+
* ``max_tokens`` is required; we default it generously because we stream.
|
|
16
|
+
* Thinking: on by default on Claude Opus 5, Sonnet 5, and Fable, so no parameter is sent there;
|
|
17
|
+
Opus 4.6/4.7/4.8 and Sonnet 4.6 need ``{"type": "adaptive"}`` explicitly; Haiku gets nothing.
|
|
18
|
+
* ``output_config.effort`` carries ``SCOOT_EFFORT`` (``xhigh`` is downgraded to ``high`` on 4.6).
|
|
19
|
+
* Sampling parameters (``temperature``) are rejected on 4.6+, so none are sent.
|
|
20
|
+
* ``cache_control: {"type": "ephemeral"}`` at the top level caches the stable prefix (system prompt
|
|
21
|
+
and tool list) automatically.
|
|
22
|
+
* On Claude Opus 5 and Fable the server-side refusal fallback is requested by default
|
|
23
|
+
(``fallbacks: "default"`` with its beta header); ``SCOOT_ANTHROPIC_FALLBACKS=0`` turns it off.
|
|
24
|
+
* ``stop_reason: "refusal"`` becomes a clear error instead of an empty answer.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
from typing import Callable, List, Optional, Tuple
|
|
32
|
+
|
|
33
|
+
from ..errors import ApiError
|
|
34
|
+
from .base import (
|
|
35
|
+
BaseProvider,
|
|
36
|
+
ChatRequest,
|
|
37
|
+
ChatResult,
|
|
38
|
+
decode_json,
|
|
39
|
+
finish_stream,
|
|
40
|
+
iter_sse_json,
|
|
41
|
+
normalize_usage,
|
|
42
|
+
raise_for_status,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
API_VERSION = "2023-06-01"
|
|
46
|
+
FALLBACK_BETA = "server-side-fallback-2026-07-01"
|
|
47
|
+
DEFAULT_MAX_TOKENS = 32000
|
|
48
|
+
|
|
49
|
+
# Models where omitting ``thinking`` means *no* thinking: send adaptive explicitly.
|
|
50
|
+
_EXPLICIT_ADAPTIVE = ("claude-opus-4-6", "claude-opus-4-7", "claude-opus-4-8", "claude-sonnet-4-6")
|
|
51
|
+
# Models that get the server-side refusal fallback by default.
|
|
52
|
+
_FALLBACK_MODELS = ("claude-opus-5", "claude-fable", "claude-mythos")
|
|
53
|
+
# 4.6-generation models cap effort at ``high``.
|
|
54
|
+
_EFFORT_HIGH_MAX = ("claude-opus-4-6", "claude-sonnet-4-6")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def thinking_param(model: str) -> Optional[dict]:
|
|
58
|
+
m = (model or "").lower()
|
|
59
|
+
if any(m.startswith(p) for p in _EXPLICIT_ADAPTIVE):
|
|
60
|
+
return {"type": "adaptive"}
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def effort_param(model: str, effort: str) -> Optional[str]:
|
|
65
|
+
m = (model or "").lower()
|
|
66
|
+
e = (effort or "").strip().lower()
|
|
67
|
+
if not e or "haiku" in m:
|
|
68
|
+
return None
|
|
69
|
+
if e == "xhigh" and any(m.startswith(p) for p in _EFFORT_HIGH_MAX):
|
|
70
|
+
return "high"
|
|
71
|
+
return e if e in ("low", "medium", "high", "xhigh", "max") else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def fallbacks_enabled(model: str) -> bool:
|
|
75
|
+
m = (model or "").lower()
|
|
76
|
+
if not any(m.startswith(p) for p in _FALLBACK_MODELS):
|
|
77
|
+
return False
|
|
78
|
+
return (os.environ.get("SCOOT_ANTHROPIC_FALLBACKS", "1").strip().lower()
|
|
79
|
+
not in ("0", "false", "no", "off"))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ── request translation ─────────────────────────────────────────────────────────
|
|
83
|
+
def _image_block(url: str) -> dict:
|
|
84
|
+
if url.startswith("data:"):
|
|
85
|
+
head, _, data = url.partition(",")
|
|
86
|
+
media = head[len("data:"):].split(";")[0] or "image/png"
|
|
87
|
+
return {"type": "image", "source": {"type": "base64", "media_type": media, "data": data}}
|
|
88
|
+
return {"type": "image", "source": {"type": "url", "url": url}}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _user_blocks(content) -> object:
|
|
92
|
+
if isinstance(content, str) or content is None:
|
|
93
|
+
return content or ""
|
|
94
|
+
blocks = []
|
|
95
|
+
for part in content:
|
|
96
|
+
if not isinstance(part, dict):
|
|
97
|
+
continue
|
|
98
|
+
ptype = part.get("type")
|
|
99
|
+
if ptype == "text":
|
|
100
|
+
blocks.append({"type": "text", "text": part.get("text", "")})
|
|
101
|
+
elif ptype == "image_url":
|
|
102
|
+
img = part.get("image_url") or {}
|
|
103
|
+
url = img.get("url") if isinstance(img, dict) else img
|
|
104
|
+
if url:
|
|
105
|
+
blocks.append(_image_block(url))
|
|
106
|
+
elif ptype in ("image", "document", "tool_result"):
|
|
107
|
+
blocks.append(part)
|
|
108
|
+
return blocks
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _arguments_dict(raw) -> dict:
|
|
112
|
+
if isinstance(raw, dict):
|
|
113
|
+
return raw
|
|
114
|
+
if not raw:
|
|
115
|
+
return {}
|
|
116
|
+
try:
|
|
117
|
+
parsed = json.loads(raw)
|
|
118
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
119
|
+
except (json.JSONDecodeError, TypeError):
|
|
120
|
+
return {}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def translate_messages(messages: List[dict], qualified_model: str) -> Tuple[str, List[dict]]:
|
|
124
|
+
"""Return ``(system, messages)`` for the Messages request."""
|
|
125
|
+
system: List[str] = []
|
|
126
|
+
out: List[dict] = []
|
|
127
|
+
pending_results: List[dict] = []
|
|
128
|
+
|
|
129
|
+
def flush_results() -> None:
|
|
130
|
+
if pending_results:
|
|
131
|
+
out.append({"role": "user", "content": list(pending_results)})
|
|
132
|
+
pending_results.clear()
|
|
133
|
+
|
|
134
|
+
for m in messages:
|
|
135
|
+
role = m.get("role")
|
|
136
|
+
if role in ("system", "developer"):
|
|
137
|
+
text = m.get("content")
|
|
138
|
+
if isinstance(text, list):
|
|
139
|
+
text = "\n".join(p.get("text", "") for p in text if isinstance(p, dict))
|
|
140
|
+
if text:
|
|
141
|
+
system.append(str(text))
|
|
142
|
+
continue
|
|
143
|
+
if role == "tool":
|
|
144
|
+
content = m.get("content")
|
|
145
|
+
if not isinstance(content, str):
|
|
146
|
+
content = json.dumps(content) if content is not None else ""
|
|
147
|
+
pending_results.append({"type": "tool_result", "tool_use_id": m.get("tool_call_id", ""),
|
|
148
|
+
"content": content})
|
|
149
|
+
continue
|
|
150
|
+
flush_results()
|
|
151
|
+
if role == "user":
|
|
152
|
+
out.append({"role": "user", "content": _user_blocks(m.get("content"))})
|
|
153
|
+
elif role == "assistant":
|
|
154
|
+
replay = m.get("provider_items")
|
|
155
|
+
if isinstance(replay, dict) and replay.get("model") == qualified_model and replay.get("items"):
|
|
156
|
+
out.append({"role": "assistant", "content": list(replay["items"])})
|
|
157
|
+
continue
|
|
158
|
+
blocks: List[dict] = []
|
|
159
|
+
text = m.get("content")
|
|
160
|
+
if isinstance(text, list):
|
|
161
|
+
blocks.extend(b for b in _user_blocks(text) if b.get("type") == "text")
|
|
162
|
+
elif text:
|
|
163
|
+
blocks.append({"type": "text", "text": str(text)})
|
|
164
|
+
for tc in m.get("tool_calls") or []:
|
|
165
|
+
fn = tc.get("function") or {}
|
|
166
|
+
blocks.append({"type": "tool_use", "id": tc.get("id", ""), "name": fn.get("name", ""),
|
|
167
|
+
"input": _arguments_dict(fn.get("arguments"))})
|
|
168
|
+
if blocks:
|
|
169
|
+
out.append({"role": "assistant", "content": blocks})
|
|
170
|
+
flush_results()
|
|
171
|
+
return "\n\n".join(system), out
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def translate_tool(tool: dict) -> dict:
|
|
175
|
+
fn = tool.get("function") if isinstance(tool, dict) else None
|
|
176
|
+
if not isinstance(fn, dict):
|
|
177
|
+
return tool
|
|
178
|
+
return {
|
|
179
|
+
"name": fn.get("name", ""),
|
|
180
|
+
"description": fn.get("description", ""),
|
|
181
|
+
"input_schema": fn.get("parameters") or {"type": "object", "properties": {}},
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ── response parsing ────────────────────────────────────────────────────────────
|
|
186
|
+
_STOP = {"end_turn": "stop", "tool_use": "tool_calls", "max_tokens": "length", "stop_sequence": "stop",
|
|
187
|
+
"pause_turn": "stop", "refusal": "refusal"}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def total_input_usage(usage: Optional[dict]) -> dict:
|
|
191
|
+
"""Anthropic's ``input_tokens`` excludes cached input; scoot's ``prompt_tokens`` is the whole prompt.
|
|
192
|
+
|
|
193
|
+
The context gauge and the session totals need the full size, so fold ``cache_read_input_tokens``
|
|
194
|
+
and ``cache_creation_input_tokens`` back in. The raw fields stay for cost analysis.
|
|
195
|
+
"""
|
|
196
|
+
if not isinstance(usage, dict):
|
|
197
|
+
return {}
|
|
198
|
+
out = dict(usage)
|
|
199
|
+
total = (int(out.get("input_tokens") or 0) + int(out.get("cache_read_input_tokens") or 0)
|
|
200
|
+
+ int(out.get("cache_creation_input_tokens") or 0))
|
|
201
|
+
out["prompt_tokens"] = total
|
|
202
|
+
return out
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def build_result(blocks: List[dict], model: str, usage: Optional[dict], stop_reason: str,
|
|
206
|
+
stop_details: Optional[dict] = None, streamed_text: str = "") -> ChatResult:
|
|
207
|
+
if stop_reason == "refusal":
|
|
208
|
+
detail = (stop_details or {}).get("explanation") or (stop_details or {}).get("category") or ""
|
|
209
|
+
raise ApiError("the model declined this request" + (f": {detail}" if detail else ""),
|
|
210
|
+
code="refusal", hint="rephrase the request or switch models with /model")
|
|
211
|
+
texts = [b.get("text", "") for b in blocks if b.get("type") == "text"]
|
|
212
|
+
tool_calls = [{"id": b.get("id", ""), "type": "function",
|
|
213
|
+
"function": {"name": b.get("name", ""), "arguments": json.dumps(b.get("input") or {})}}
|
|
214
|
+
for b in blocks if b.get("type") == "tool_use"]
|
|
215
|
+
content = streamed_text or "".join(texts)
|
|
216
|
+
finish = "tool_calls" if tool_calls else _STOP.get(stop_reason, "stop")
|
|
217
|
+
message: dict = {"role": "assistant", "content": content}
|
|
218
|
+
if tool_calls:
|
|
219
|
+
message["tool_calls"] = tool_calls
|
|
220
|
+
if blocks:
|
|
221
|
+
message["provider_items"] = {"model": model, "items": blocks}
|
|
222
|
+
return ChatResult(content=content, model=model, tool_calls=tool_calls, finish_reason=finish,
|
|
223
|
+
usage=normalize_usage(total_input_usage(usage)), raw_message=message)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class _StreamState:
|
|
227
|
+
"""Accumulates Messages SSE events into content blocks + live text."""
|
|
228
|
+
|
|
229
|
+
def __init__(self) -> None:
|
|
230
|
+
self.blocks: dict = {} # index -> block dict
|
|
231
|
+
self._json: dict = {} # index -> partial tool input JSON
|
|
232
|
+
self.text: List[str] = []
|
|
233
|
+
self.usage: dict = {}
|
|
234
|
+
self.model = ""
|
|
235
|
+
self.stop_reason = ""
|
|
236
|
+
self.stop_details: Optional[dict] = None
|
|
237
|
+
self.error: Optional[dict] = None
|
|
238
|
+
|
|
239
|
+
def add(self, obj: dict) -> str:
|
|
240
|
+
etype = obj.get("type", "")
|
|
241
|
+
if etype == "message_start":
|
|
242
|
+
msg = obj.get("message") or {}
|
|
243
|
+
self.model = msg.get("model") or self.model
|
|
244
|
+
self.usage.update(msg.get("usage") or {})
|
|
245
|
+
elif etype == "content_block_start":
|
|
246
|
+
idx = obj.get("index", 0)
|
|
247
|
+
block = dict(obj.get("content_block") or {})
|
|
248
|
+
if block.get("type") == "tool_use":
|
|
249
|
+
self._json[idx] = ""
|
|
250
|
+
block.setdefault("input", {})
|
|
251
|
+
self.blocks[idx] = block
|
|
252
|
+
elif etype == "content_block_delta":
|
|
253
|
+
idx = obj.get("index", 0)
|
|
254
|
+
block = self.blocks.setdefault(idx, {"type": "text", "text": ""})
|
|
255
|
+
delta = obj.get("delta") or {}
|
|
256
|
+
dtype = delta.get("type")
|
|
257
|
+
if dtype == "text_delta":
|
|
258
|
+
text = delta.get("text") or ""
|
|
259
|
+
block["text"] = block.get("text", "") + text
|
|
260
|
+
self.text.append(text)
|
|
261
|
+
return text
|
|
262
|
+
if dtype == "input_json_delta":
|
|
263
|
+
self._json[idx] = self._json.get(idx, "") + (delta.get("partial_json") or "")
|
|
264
|
+
elif dtype == "thinking_delta":
|
|
265
|
+
block["thinking"] = block.get("thinking", "") + (delta.get("thinking") or "")
|
|
266
|
+
elif dtype == "signature_delta":
|
|
267
|
+
block["signature"] = delta.get("signature") or block.get("signature", "")
|
|
268
|
+
elif etype == "content_block_stop":
|
|
269
|
+
idx = obj.get("index", 0)
|
|
270
|
+
if idx in self._json:
|
|
271
|
+
raw = self._json.pop(idx)
|
|
272
|
+
try:
|
|
273
|
+
self.blocks[idx]["input"] = json.loads(raw) if raw.strip() else {}
|
|
274
|
+
except json.JSONDecodeError:
|
|
275
|
+
self.blocks[idx]["input"] = {}
|
|
276
|
+
elif etype == "message_delta":
|
|
277
|
+
delta = obj.get("delta") or {}
|
|
278
|
+
self.stop_reason = delta.get("stop_reason") or self.stop_reason
|
|
279
|
+
if delta.get("stop_details"):
|
|
280
|
+
self.stop_details = delta["stop_details"]
|
|
281
|
+
self.usage.update(obj.get("usage") or {})
|
|
282
|
+
elif etype == "error":
|
|
283
|
+
self.error = obj.get("error") or {"message": "stream error"}
|
|
284
|
+
return ""
|
|
285
|
+
|
|
286
|
+
def ordered_blocks(self) -> List[dict]:
|
|
287
|
+
return [self.blocks[i] for i in sorted(self.blocks)]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class AnthropicProvider(BaseProvider):
|
|
291
|
+
wire = "anthropic"
|
|
292
|
+
|
|
293
|
+
# ── auth + headers ────────────────────────────────────────────────────────────
|
|
294
|
+
def _auth_token(self) -> str:
|
|
295
|
+
self._auth() # raises the same "no API key" error as the other providers
|
|
296
|
+
return "" # the key travels in x-api-key, not Authorization
|
|
297
|
+
|
|
298
|
+
def _headers_for(self, payload: Optional[dict]) -> List[str]:
|
|
299
|
+
headers = [f"x-api-key: {self._auth()}", f"anthropic-version: {API_VERSION}"]
|
|
300
|
+
if payload and payload.get("fallbacks"):
|
|
301
|
+
headers.append(f"anthropic-beta: {FALLBACK_BETA}")
|
|
302
|
+
return headers + self._extra_headers()
|
|
303
|
+
|
|
304
|
+
# ── request ───────────────────────────────────────────────────────────────────
|
|
305
|
+
def _payload(self, req: ChatRequest, stream: bool) -> dict:
|
|
306
|
+
system, messages = translate_messages(req.messages, self.qualified(req.model))
|
|
307
|
+
payload: dict = {
|
|
308
|
+
"model": req.model,
|
|
309
|
+
"max_tokens": req.max_tokens or DEFAULT_MAX_TOKENS,
|
|
310
|
+
"messages": messages,
|
|
311
|
+
"cache_control": {"type": "ephemeral"},
|
|
312
|
+
}
|
|
313
|
+
if system:
|
|
314
|
+
payload["system"] = system
|
|
315
|
+
if stream:
|
|
316
|
+
payload["stream"] = True
|
|
317
|
+
if req.tools:
|
|
318
|
+
payload["tools"] = [translate_tool(t) for t in req.tools]
|
|
319
|
+
payload["tool_choice"] = {"type": "auto"}
|
|
320
|
+
thinking = thinking_param(req.model)
|
|
321
|
+
if thinking:
|
|
322
|
+
payload["thinking"] = thinking
|
|
323
|
+
effort = effort_param(req.model, getattr(self.config, "effort", ""))
|
|
324
|
+
if effort:
|
|
325
|
+
payload["output_config"] = {"effort": effort}
|
|
326
|
+
if fallbacks_enabled(req.model):
|
|
327
|
+
payload["fallbacks"] = "default"
|
|
328
|
+
return payload
|
|
329
|
+
|
|
330
|
+
def _complete(self, req: ChatRequest) -> ChatResult:
|
|
331
|
+
payload = self._payload(req, stream=False)
|
|
332
|
+
status, body = self._http("POST", "/messages", payload, req.cancel_event)
|
|
333
|
+
data = decode_json(status, body)
|
|
334
|
+
if data.get("type") == "error" or data.get("error"):
|
|
335
|
+
raise_for_status(400, json.dumps({"error": data.get("error")}), self.name)
|
|
336
|
+
return build_result(data.get("content") or [], self.qualified(data.get("model") or req.model),
|
|
337
|
+
data.get("usage"), data.get("stop_reason") or "", data.get("stop_details"))
|
|
338
|
+
|
|
339
|
+
def _stream(self, req: ChatRequest, on_delta: Callable[[str], None]) -> ChatResult:
|
|
340
|
+
payload = self._payload(req, stream=True)
|
|
341
|
+
state = _StreamState()
|
|
342
|
+
holder: dict = {}
|
|
343
|
+
leftovers: List[str] = []
|
|
344
|
+
for obj in iter_sse_json(self._http_stream("/messages", payload, req.cancel_event), holder,
|
|
345
|
+
leftovers):
|
|
346
|
+
text = state.add(obj)
|
|
347
|
+
if text:
|
|
348
|
+
on_delta(text)
|
|
349
|
+
finish_stream(holder, leftovers, self.name)
|
|
350
|
+
if state.error:
|
|
351
|
+
raise_for_status(400, json.dumps({"error": state.error}), self.name)
|
|
352
|
+
raise ApiError(str(state.error))
|
|
353
|
+
return build_result(state.ordered_blocks(), self.qualified(state.model or req.model), state.usage,
|
|
354
|
+
state.stop_reason, state.stop_details, streamed_text="".join(state.text))
|
|
@@ -154,11 +154,12 @@ def raise_for_status(status: int, body: str, provider: str = "") -> None:
|
|
|
154
154
|
hint=f"{who} issue; backing off and retrying")
|
|
155
155
|
if status >= 400 or msg:
|
|
156
156
|
low = (code + " " + msg).lower()
|
|
157
|
-
if "
|
|
157
|
+
if ("prompt is too long" in low or "too many tokens" in low
|
|
158
|
+
or ("context" in low and any(w in low for w in ("length", "exceed", "maximum", "token")))):
|
|
158
159
|
raise ContextLengthError(msg or "context length exceeded", status=status, code=code,
|
|
159
160
|
hint="run /compact to shrink context, then retry")
|
|
160
161
|
if "model" in low and any(w in low for w in
|
|
161
|
-
("not supported", "not accessible", "not found", "unsupported",
|
|
162
|
+
("not supported", "not accessible", "not found", "not_found", "unsupported",
|
|
162
163
|
"does not exist", "not exist")):
|
|
163
164
|
raise ModelUnavailableError(msg or "model unavailable", status=status, code=code,
|
|
164
165
|
hint="switching to a supported model")
|
|
@@ -295,19 +296,27 @@ class BaseProvider:
|
|
|
295
296
|
def _extra_headers(self) -> List[str]:
|
|
296
297
|
return [f"{k}: {v}" for k, v in self.spec.extra_headers]
|
|
297
298
|
|
|
299
|
+
# Two hooks let an adapter change how the key travels: by default it is a Bearer token in
|
|
300
|
+
# ``Authorization``; Anthropic overrides both to send ``x-api-key`` plus its version header.
|
|
301
|
+
def _auth_token(self) -> str:
|
|
302
|
+
return self._auth()
|
|
303
|
+
|
|
304
|
+
def _headers_for(self, payload: Optional[dict]) -> List[str]:
|
|
305
|
+
return self._extra_headers()
|
|
306
|
+
|
|
298
307
|
def _http(self, method: str, path: str, payload: Optional[dict], cancel_event) -> Tuple[int, str]:
|
|
299
308
|
"""One request, status-checked. Callers wrap it in :meth:`_with_retry` when appropriate."""
|
|
300
309
|
status, body = self.transport.request(
|
|
301
|
-
method, self._url(path), self.
|
|
302
|
-
cancel_event=cancel_event,
|
|
310
|
+
method, self._url(path), self._auth_token(), extra_headers=self._headers_for(payload),
|
|
311
|
+
body=payload, cancel_event=cancel_event,
|
|
303
312
|
)
|
|
304
313
|
raise_for_status(status, body, self.name)
|
|
305
314
|
return status, body
|
|
306
315
|
|
|
307
316
|
def _http_stream(self, path: str, payload: dict, cancel_event) -> Iterator[str]:
|
|
308
317
|
return self.transport.stream_request(
|
|
309
|
-
"POST", self._url(path), self.
|
|
310
|
-
cancel_event=cancel_event,
|
|
318
|
+
"POST", self._url(path), self._auth_token(), extra_headers=self._headers_for(payload),
|
|
319
|
+
body=payload, cancel_event=cancel_event,
|
|
311
320
|
)
|
|
312
321
|
|
|
313
322
|
# ── retry policy ─────────────────────────────────────────────────────────────
|
|
@@ -44,11 +44,13 @@ def load_builtins() -> None:
|
|
|
44
44
|
global _loaded
|
|
45
45
|
if _loaded:
|
|
46
46
|
return
|
|
47
|
+
from .anthropic import AnthropicProvider
|
|
47
48
|
from .openai_chat import OpenAIChatProvider
|
|
48
49
|
from .openai_responses import OpenAIResponsesProvider
|
|
49
50
|
|
|
50
51
|
_WIRES["openai_responses"] = OpenAIResponsesProvider
|
|
51
52
|
_WIRES["openai_chat"] = OpenAIChatProvider
|
|
53
|
+
_WIRES["anthropic"] = AnthropicProvider
|
|
52
54
|
register(ProviderSpec(
|
|
53
55
|
name="openai",
|
|
54
56
|
base_url="https://api.openai.com/v1",
|
|
@@ -69,6 +71,16 @@ def load_builtins() -> None:
|
|
|
69
71
|
vision_models=("qwen2.5vl", "llama3.2-vision", "llava", "gemma3", "minicpm-v"),
|
|
70
72
|
capabilities=frozenset({"tools", "streaming", "vision", "temperature"}),
|
|
71
73
|
))
|
|
74
|
+
register(ProviderSpec(
|
|
75
|
+
name="anthropic",
|
|
76
|
+
base_url="https://api.anthropic.com/v1",
|
|
77
|
+
wire="anthropic",
|
|
78
|
+
key_env=("ANTHROPIC_API_KEY",),
|
|
79
|
+
key_required=True,
|
|
80
|
+
preferred_models=("claude-opus-5", "claude-sonnet-5", "claude-opus-4-8", "claude-haiku-4-5"),
|
|
81
|
+
vision_models=("claude",),
|
|
82
|
+
capabilities=frozenset({"tools", "streaming", "vision", "reasoning"}),
|
|
83
|
+
))
|
|
72
84
|
_loaded = True
|
|
73
85
|
|
|
74
86
|
|
|
@@ -16,6 +16,13 @@ _FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
|
16
16
|
_CLEAR_LINE = "\r\033[K"
|
|
17
17
|
|
|
18
18
|
|
|
19
|
+
def _fmt_elapsed(seconds: int) -> str:
|
|
20
|
+
"""Compact elapsed-time label: ``8s`` or ``1m03s``."""
|
|
21
|
+
if seconds < 60:
|
|
22
|
+
return f"{seconds}s"
|
|
23
|
+
return f"{seconds // 60}m{seconds % 60:02d}s"
|
|
24
|
+
|
|
25
|
+
|
|
19
26
|
class Status:
|
|
20
27
|
"""A threaded spinner with an updatable message and a persistent step hint."""
|
|
21
28
|
|
|
@@ -23,6 +30,7 @@ class Status:
|
|
|
23
30
|
self.enabled = enabled and sys.stdout.isatty()
|
|
24
31
|
self._message = ""
|
|
25
32
|
self._hint = ""
|
|
33
|
+
self._started = 0.0
|
|
26
34
|
self._thread = None
|
|
27
35
|
self._stop = threading.Event()
|
|
28
36
|
self._lock = threading.Lock()
|
|
@@ -30,6 +38,7 @@ class Status:
|
|
|
30
38
|
def start(self, message: str = "thinking…", hint: str = "esc to stop") -> None:
|
|
31
39
|
self._message = message
|
|
32
40
|
self._hint = hint
|
|
41
|
+
self._started = time.time()
|
|
33
42
|
if not self.enabled:
|
|
34
43
|
return
|
|
35
44
|
self._stop.clear()
|
|
@@ -47,9 +56,13 @@ class Status:
|
|
|
47
56
|
while not self._stop.is_set():
|
|
48
57
|
with self._lock:
|
|
49
58
|
message, hint = self._message, self._hint
|
|
59
|
+
elapsed = int(time.time() - self._started) if self._started else 0
|
|
50
60
|
frame = color(_FRAMES[i % len(_FRAMES)], "cyan")
|
|
61
|
+
timer = color(f" ({_fmt_elapsed(elapsed)})", "gray") if elapsed >= 3 else ""
|
|
62
|
+
if elapsed >= 30:
|
|
63
|
+
hint = "still working · ctrl+c to force" + (f" — {hint}" if hint else "")
|
|
51
64
|
tail = color(f" · {hint}", "gray") if hint else ""
|
|
52
|
-
sys.stdout.write(f"{_CLEAR_LINE} {frame} {message}{tail}")
|
|
65
|
+
sys.stdout.write(f"{_CLEAR_LINE} {frame} {message}{timer}{tail}")
|
|
53
66
|
sys.stdout.flush()
|
|
54
67
|
i += 1
|
|
55
68
|
time.sleep(0.08)
|