thwip-cli 1.16.2__tar.gz → 1.17.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.
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/.github/workflows/ci.yml +20 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/PKG-INFO +24 -1
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/README.md +23 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/pyproject.toml +1 -1
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_compaction.py +40 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_native_agents.py +15 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_native_print.py +15 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_parity_commands.py +50 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/__init__.py +1 -1
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/__init__.py +9 -1
- thwip_cli-1.17.0/thwip/agents/custom_agent.py +129 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/native_agent.py +7 -3
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/native_print.py +28 -2
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/openai_agent.py +4 -2
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/cli.py +91 -2
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/compaction.py +61 -8
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/config.py +25 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/data/thwip.1 +4 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/uv.lock +1 -1
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/.github/workflows/publish.yml +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/.gitignore +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/ARCHITECTURE.md +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/LICENSE +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/docs/benchmark/2026-10-04-final.html +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/docs/benchmark/2026-10-04-final.json +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/docs/benchmark/2026-10-04-home-wifi.json +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/docs/benchmark/2026-10-04-hotspot.html +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/docs/benchmark/2026-10-04-hotspot.json +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/docs/benchmark/2026-10-06-final.html +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/docs/benchmark/2026-10-06-final.json +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/docs/handoff-research.md +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/docs/verification.md +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/install.sh +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/scripts/drive_repl.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/__init__.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/fake_providers.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_agents.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_ai_layers.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_audit_regressions.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_catalog.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_cli.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_compatible_streaming.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_config.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_detector.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_direct_providers_e2e.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_evals.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_handoff.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_memory.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_native_launcher.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_native_rpc.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_prompt.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_repair_verification.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_retrieval.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_session.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_tools.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_utils.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/__main__.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/base.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/catalog.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/chat_messages.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/claude_agent.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/deepseek_agent.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/google_agent.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/groq_agent.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/native_common.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/native_rpc.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/ollama_agent.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/openrouter_agent.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/data/context_template.md +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/detector.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/endpoints.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/evals/__init__.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/evals/__main__.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/evals/fake_agent.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/evals/runner.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/evals/tasks.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/guardrails.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/handoff.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/limits.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/memory.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/recall.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/retrieval.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/session.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/shortcuts.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/structured.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/theme.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/tools/__init__.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/tools/code_runner.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/tools/file_editor.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/tools/git_ops.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/tools/terminal.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/tracing.py +0 -0
- {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/utils.py +0 -0
|
@@ -26,3 +26,23 @@ jobs:
|
|
|
26
26
|
with:
|
|
27
27
|
name: evals-fake-${{ matrix.python }}
|
|
28
28
|
path: evals-fake.json
|
|
29
|
+
|
|
30
|
+
windows:
|
|
31
|
+
runs-on: windows-latest
|
|
32
|
+
steps:
|
|
33
|
+
- uses: actions/checkout@v6
|
|
34
|
+
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
35
|
+
- run: uv python install 3.13
|
|
36
|
+
- run: uv sync --all-extras --python 3.13
|
|
37
|
+
- name: Lint
|
|
38
|
+
run: uv run ruff check .
|
|
39
|
+
- name: Unit and integration tests (offline)
|
|
40
|
+
run: uv run pytest -q
|
|
41
|
+
- name: Evaluation harness (offline fake adapter)
|
|
42
|
+
run: uv run python -m thwip.evals --provider fake --out evals-fake-windows.json
|
|
43
|
+
- name: Build wheel and sdist
|
|
44
|
+
run: uv build
|
|
45
|
+
- uses: actions/upload-artifact@v4
|
|
46
|
+
with:
|
|
47
|
+
name: windows-build
|
|
48
|
+
path: dist/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: thwip-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.17.0
|
|
4
4
|
Summary: Universal coding agent multiplexer: detect, switch, and route between AI coding agents seamlessly
|
|
5
5
|
Project-URL: Homepage, https://github.com/tanmayhutt/thwip-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/tanmayhutt/thwip-cli
|
|
@@ -164,6 +164,29 @@ needs one is declined by the CLI itself and thwip shows its explanation; its own
|
|
|
164
164
|
flag is the only alternative. `/permissions` prints this summary. The evaluation harness approves the
|
|
165
165
|
CLIs' write questions for the file-edit task so all three are measured on a real edit.
|
|
166
166
|
|
|
167
|
+
**Your own models.** `/providers add` walks you through adding any server that speaks the OpenAI
|
|
168
|
+
chat protocol: llama.cpp, LM Studio, vLLM, Ollama's `/v1` endpoint, text-generation-webui, a company
|
|
169
|
+
gateway, or a hosted API. You give it a name, the base URL ending in `/v1`, optionally the name of an
|
|
170
|
+
environment variable holding a key, and optionally model IDs; otherwise thwip lists the models from
|
|
171
|
+
the server. The provider then appears in `/switch`, in the fallback chain, and as a compaction worker
|
|
172
|
+
like any other. Saved under `[providers.<name>]` in the config:
|
|
173
|
+
|
|
174
|
+
```toml
|
|
175
|
+
[providers.local]
|
|
176
|
+
base_url = "http://localhost:1234/v1"
|
|
177
|
+
models = ["qwen2.5-coder", "llama3.3"]
|
|
178
|
+
default_model = "qwen2.5-coder"
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Images.** `@shot.png` in a message sends the image to the CLI's own image input instead of reading
|
|
182
|
+
it as text: Codex receives the local path, Claude Code receives the image inline. Antigravity's
|
|
183
|
+
headless input is text only, so it receives the path and may open it with its own tools. Direct API
|
|
184
|
+
providers do not take images through thwip yet.
|
|
185
|
+
|
|
186
|
+
**Windows.** The package is pure Python. CI runs the full test suite, the offline harness and the
|
|
187
|
+
build on a Windows runner as well as Linux, so a wheel built anywhere runs on macOS, Linux and
|
|
188
|
+
Windows. Native CLIs must themselves support the platform.
|
|
189
|
+
|
|
167
190
|
**Before a switch.** A switch sends the live conversation to the new CLI. When that transfer would
|
|
168
191
|
fill a large share of the new CLI's window, thwip says how many tokens it is and offers to compact
|
|
169
192
|
first, so the handoff is small. `/handoff` shows the same numbers without switching.
|
|
@@ -127,6 +127,29 @@ needs one is declined by the CLI itself and thwip shows its explanation; its own
|
|
|
127
127
|
flag is the only alternative. `/permissions` prints this summary. The evaluation harness approves the
|
|
128
128
|
CLIs' write questions for the file-edit task so all three are measured on a real edit.
|
|
129
129
|
|
|
130
|
+
**Your own models.** `/providers add` walks you through adding any server that speaks the OpenAI
|
|
131
|
+
chat protocol: llama.cpp, LM Studio, vLLM, Ollama's `/v1` endpoint, text-generation-webui, a company
|
|
132
|
+
gateway, or a hosted API. You give it a name, the base URL ending in `/v1`, optionally the name of an
|
|
133
|
+
environment variable holding a key, and optionally model IDs; otherwise thwip lists the models from
|
|
134
|
+
the server. The provider then appears in `/switch`, in the fallback chain, and as a compaction worker
|
|
135
|
+
like any other. Saved under `[providers.<name>]` in the config:
|
|
136
|
+
|
|
137
|
+
```toml
|
|
138
|
+
[providers.local]
|
|
139
|
+
base_url = "http://localhost:1234/v1"
|
|
140
|
+
models = ["qwen2.5-coder", "llama3.3"]
|
|
141
|
+
default_model = "qwen2.5-coder"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
**Images.** `@shot.png` in a message sends the image to the CLI's own image input instead of reading
|
|
145
|
+
it as text: Codex receives the local path, Claude Code receives the image inline. Antigravity's
|
|
146
|
+
headless input is text only, so it receives the path and may open it with its own tools. Direct API
|
|
147
|
+
providers do not take images through thwip yet.
|
|
148
|
+
|
|
149
|
+
**Windows.** The package is pure Python. CI runs the full test suite, the offline harness and the
|
|
150
|
+
build on a Windows runner as well as Linux, so a wheel built anywhere runs on macOS, Linux and
|
|
151
|
+
Windows. Native CLIs must themselves support the platform.
|
|
152
|
+
|
|
130
153
|
**Before a switch.** A switch sends the live conversation to the new CLI. When that transfer would
|
|
131
154
|
fill a large share of the new CLI's window, thwip says how many tokens it is and offers to compact
|
|
132
155
|
first, so the handoff is small. `/handoff` shows the same numbers without switching.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "thwip-cli"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.17.0"
|
|
8
8
|
description = "Universal coding agent multiplexer: detect, switch, and route between AI coding agents seamlessly"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -148,3 +148,43 @@ async def test_cmd_compact_falls_back_to_plain_text_when_worker_cannot_do_json(c
|
|
|
148
148
|
assert await cli.cmd_compact() is True
|
|
149
149
|
assert other.calls == 4, "two repairs after the first JSON attempt, then one plain-text request"
|
|
150
150
|
assert "plain prose summary" in cli.session.messages[0].content
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class SequenceAgent(Agent):
|
|
154
|
+
"""Replies in order; used to prove the coverage check sends missing exact items back."""
|
|
155
|
+
|
|
156
|
+
def __init__(self, name, replies):
|
|
157
|
+
super().__init__(name)
|
|
158
|
+
self.replies = list(replies)
|
|
159
|
+
self.prompts = []
|
|
160
|
+
|
|
161
|
+
async def chat(self, messages=None, **kwargs):
|
|
162
|
+
self.calls += 1
|
|
163
|
+
self.prompts.append(messages[-1]["content"])
|
|
164
|
+
yield TextDelta(content=self.replies.pop(0) if self.replies else self.replies_last)
|
|
165
|
+
yield AgentDone()
|
|
166
|
+
|
|
167
|
+
replies_last = ""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@pytest.mark.asyncio
|
|
171
|
+
async def test_structured_summary_sends_back_missing_exact_items():
|
|
172
|
+
from thwip.compaction import extract_anchors, missing_anchors, summarize_structured
|
|
173
|
+
|
|
174
|
+
history = [{"role": "user", "content": "Edit thwip/cli.py; the code word is PLUM-7 and the docs are at https://x.y/z"},
|
|
175
|
+
{"role": "assistant", "content": "Done, see `uv lock` output; released v1.16.2."}]
|
|
176
|
+
anchors = extract_anchors(transcript(history))
|
|
177
|
+
assert {"thwip/cli.py", "PLUM-7", "https://x.y/z", "uv lock", "1.16.2"} <= set(anchors)
|
|
178
|
+
incomplete = '{"context": ["edited the CLI file"], "decisions": [], "open_tasks": []}'
|
|
179
|
+
complete = ('{"context": ["Edit thwip/cli.py; code word PLUM-7; docs https://x.y/z"], '
|
|
180
|
+
'"decisions": ["ran `uv lock`; released v1.16.2"], "open_tasks": []}')
|
|
181
|
+
worker = SequenceAgent("w", [incomplete, complete])
|
|
182
|
+
summary, repairs = await summarize_structured(worker, history)
|
|
183
|
+
assert worker.calls == 2 and "left out these exact items" in worker.prompts[1] and "PLUM-7" in worker.prompts[1]
|
|
184
|
+
assert not missing_anchors(summary, anchors) and repairs == 1
|
|
185
|
+
|
|
186
|
+
# When the worker keeps leaving items out, they are appended verbatim rather than lost.
|
|
187
|
+
stubborn = SequenceAgent("s", [incomplete, incomplete, incomplete])
|
|
188
|
+
stubborn.replies_last = incomplete
|
|
189
|
+
summary, _ = await summarize_structured(stubborn, history)
|
|
190
|
+
assert "Exact items from earlier turns" in summary and "PLUM-7" in summary and "https://x.y/z" in summary
|
|
@@ -287,3 +287,18 @@ async def test_codex_thread_is_reused_across_turns(monkeypatch):
|
|
|
287
287
|
[e async for e in agent.chat([{'role': 'user', 'content': 'b'}], model='future-model', resume={'id': thread_id, 'synced': 0})]
|
|
288
288
|
methods = [m for m, _ in rpc.requests]
|
|
289
289
|
assert 'thread/start' not in methods and 'thread/resume' not in methods, 'the open thread is reused without re-opening'
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@pytest.mark.asyncio
|
|
293
|
+
async def test_codex_turn_carries_local_images(monkeypatch, tmp_path):
|
|
294
|
+
agent = NativeAgent('openai', '.')
|
|
295
|
+
rpc = FakeRPC('openai')
|
|
296
|
+
async def connect():
|
|
297
|
+
return rpc
|
|
298
|
+
monkeypatch.setattr(agent, '_connect', connect)
|
|
299
|
+
shot = tmp_path / 'shot.png'
|
|
300
|
+
shot.write_bytes(b'png')
|
|
301
|
+
[e async for e in agent.chat([{'role': 'user', 'content': 'what is this'}], model='future-model', images=[str(shot)])]
|
|
302
|
+
turn = next(p for m, p in rpc.requests if m == 'turn/start')
|
|
303
|
+
assert turn['input'][0] == {'type': 'text', 'text': 'what is this'}
|
|
304
|
+
assert turn['input'][1] == {'type': 'localImage', 'path': str(shot)}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Print-mode native adapters, exercised with fake CLI processes only."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import base64
|
|
4
5
|
import json
|
|
5
6
|
|
|
6
7
|
import pytest
|
|
@@ -390,3 +391,17 @@ async def test_thwip_adds_no_restrictions_of_its_own(monkeypatch):
|
|
|
390
391
|
await collect(agy, model="gemini-3.8-flash-high")
|
|
391
392
|
assert "--mode" not in calls[0][0] and "--sandbox" not in calls[0][0]
|
|
392
393
|
await agy.close()
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@pytest.mark.asyncio
|
|
397
|
+
async def test_claude_turn_carries_images_as_blocks(monkeypatch, tmp_path):
|
|
398
|
+
agent = PrintAgent("claude", ".")
|
|
399
|
+
shot = tmp_path / "shot.png"
|
|
400
|
+
shot.write_bytes(b"png-bytes")
|
|
401
|
+
calls = install_fake(monkeypatch, agent, [{"type": "result", "subtype": "success", "result": "a cat", "usage": {}}])
|
|
402
|
+
events = [e async for e in agent.chat([{"role": "user", "content": "what is this"}], model="fable", images=[str(shot)])]
|
|
403
|
+
assert isinstance(events[-1], AgentDone)
|
|
404
|
+
content = calls[0][1].user_messages()[0]
|
|
405
|
+
assert content[0] == {"type": "text", "text": "what is this"}
|
|
406
|
+
assert content[1]["type"] == "image" and content[1]["source"]["media_type"] == "image/png"
|
|
407
|
+
assert content[1]["source"]["data"] == base64.b64encode(b"png-bytes").decode()
|
|
@@ -308,3 +308,53 @@ def test_current_folder_wins_over_the_remembered_project(tmp_path, monkeypatch):
|
|
|
308
308
|
monkeypatch.chdir(Path.home())
|
|
309
309
|
assert Path(ThwipCLI().config.project) == remembered.resolve(), "from home, the remembered project is the default"
|
|
310
310
|
assert Path(ThwipCLI(project=str(here)).config.project) == here.resolve(), "--project always wins"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_custom_provider_config_round_trip_and_registry(tmp_path, monkeypatch):
|
|
314
|
+
import tomllib
|
|
315
|
+
|
|
316
|
+
from thwip.agents.custom_agent import CustomAgent
|
|
317
|
+
|
|
318
|
+
monkeypatch.setenv("THWIP_CONFIG_DIR", str(tmp_path / "config"))
|
|
319
|
+
config = ThwipConfig()
|
|
320
|
+
config._apply_toml(tomllib.loads(
|
|
321
|
+
'[providers.local]\nbase_url = "http://localhost:1234/v1/"\nmodels = ["qwen", "llama"]\ndefault_model = "llama"\n'
|
|
322
|
+
'[providers.bad]\nmodels = ["x"]\n'))
|
|
323
|
+
assert set(config.providers) == {"local"}, "an entry without base_url is ignored"
|
|
324
|
+
assert config.providers["local"]["base_url"] == "http://localhost:1234/v1"
|
|
325
|
+
config.save()
|
|
326
|
+
reloaded = ThwipConfig.load()
|
|
327
|
+
assert reloaded.providers["local"]["models"] == ["qwen", "llama"]
|
|
328
|
+
registry = AgentRegistry(reloaded)
|
|
329
|
+
agent = registry.get_agent("local")
|
|
330
|
+
assert isinstance(agent, CustomAgent) and agent.is_configured() and agent.get_default_model() == "llama"
|
|
331
|
+
assert agent.get_model_info("anything-else") is not None, "unknown IDs are left to the server"
|
|
332
|
+
assert agent._get_api_key() == "local" and not agent.responses_api
|
|
333
|
+
assert agent in registry.get_ready_agents()
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@pytest.mark.asyncio
|
|
337
|
+
async def test_custom_provider_lists_models_from_the_server(monkeypatch):
|
|
338
|
+
import httpx
|
|
339
|
+
|
|
340
|
+
from thwip.agents.custom_agent import CustomAgent
|
|
341
|
+
|
|
342
|
+
def handler(request):
|
|
343
|
+
assert request.url.path.endswith("/models") and request.headers.get("authorization") == "Bearer k"
|
|
344
|
+
return httpx.Response(200, json={"data": [{"id": "served-a"}, {"id": "served-b"}]})
|
|
345
|
+
transport = httpx.MockTransport(handler)
|
|
346
|
+
real = httpx.AsyncClient
|
|
347
|
+
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: real(transport=transport, **kw))
|
|
348
|
+
monkeypatch.setenv("MY_KEY", "k")
|
|
349
|
+
agent = CustomAgent("srv", {"base_url": "http://srv/v1", "api_key_env": "MY_KEY", "models": ["mine"]})
|
|
350
|
+
await agent.refresh_models()
|
|
351
|
+
assert [m.id for m in agent.available_models] == ["mine", "served-a", "served-b"] and agent.get_default_model() == "mine"
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@pytest.mark.asyncio
|
|
355
|
+
async def test_image_mentions_go_to_the_cli_image_input(cli, tmp_path, monkeypatch):
|
|
356
|
+
(tmp_path / "shot.png").write_bytes(b"\x89PNG fake")
|
|
357
|
+
(tmp_path / "notes.txt").write_text("plain text")
|
|
358
|
+
text = cli._expand_mentions("look at @shot.png and @notes.txt")
|
|
359
|
+
assert cli._pending_images == [str((tmp_path / "shot.png").resolve())]
|
|
360
|
+
assert "Attached file: notes.txt" in text and "PNG" not in text, "images are never read as text"
|
|
@@ -54,6 +54,11 @@ class AgentRegistry:
|
|
|
54
54
|
inst = agent_cls(api_key=key)
|
|
55
55
|
|
|
56
56
|
self._agents[name] = inst
|
|
57
|
+
# Your own OpenAI-compatible providers from [providers.<name>].
|
|
58
|
+
from thwip.agents.custom_agent import CustomAgent
|
|
59
|
+
for name, spec in (getattr(self.config, "providers", None) or {}).items():
|
|
60
|
+
if name not in self._agents:
|
|
61
|
+
self._agents[name] = CustomAgent(name, spec)
|
|
57
62
|
|
|
58
63
|
def get_agent(self, name: str) -> BaseAgent | None:
|
|
59
64
|
"""Get agent by name or alias."""
|
|
@@ -75,7 +80,10 @@ class AgentRegistry:
|
|
|
75
80
|
"local": "ollama",
|
|
76
81
|
"openrouter": "openrouter",
|
|
77
82
|
}
|
|
78
|
-
|
|
83
|
+
wanted = name.lower().strip()
|
|
84
|
+
if wanted in self._agents:
|
|
85
|
+
return self._agents[wanted] # a provider you named yourself wins over built-in aliases
|
|
86
|
+
normalized = alias_map.get(wanted, wanted)
|
|
79
87
|
return self._agents.get(normalized)
|
|
80
88
|
|
|
81
89
|
async def connect_native_agents(self, project: str) -> None:
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Your own providers: any OpenAI-compatible server, local or hosted.
|
|
2
|
+
|
|
3
|
+
llama.cpp, LM Studio, vLLM, Ollama's /v1 endpoint, text-generation-webui, a company gateway: anything
|
|
4
|
+
that speaks the OpenAI chat-completions protocol. Configured under [providers.<name>] in config.toml
|
|
5
|
+
or with /providers add. Models are listed live from the server's /models endpoint when it has one,
|
|
6
|
+
or taken from the config; any model ID is accepted and the server validates it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from thwip.agents.base import Capability, LimitStatus, ModelInfo, SubscriptionInfo
|
|
17
|
+
from thwip.agents.openai_agent import OpenAIAgent
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CustomAgent(OpenAIAgent):
|
|
21
|
+
"""An OpenAI-compatible endpoint under a name you chose."""
|
|
22
|
+
|
|
23
|
+
responses_api = False
|
|
24
|
+
capabilities = {Capability.CHAT, Capability.FILE_READ, Capability.FILE_EDIT, Capability.CODE_RUN,
|
|
25
|
+
Capability.TERMINAL, Capability.GIT}
|
|
26
|
+
|
|
27
|
+
def __init__(self, name: str, spec: dict[str, Any]) -> None:
|
|
28
|
+
super().__init__(api_key=spec.get("api_key"))
|
|
29
|
+
self.name = name
|
|
30
|
+
self.display_name = spec.get("display_name") or name
|
|
31
|
+
self.company = spec.get("company") or "Custom"
|
|
32
|
+
self.base_url = str(spec["base_url"]).rstrip("/")
|
|
33
|
+
self.api_key_env = spec.get("api_key_env", "")
|
|
34
|
+
self.available_models = [ModelInfo(id=m, name=m, is_default=(i == 0)) for i, m in enumerate(spec.get("models", []))]
|
|
35
|
+
default = spec.get("default_model")
|
|
36
|
+
if default:
|
|
37
|
+
for model in self.available_models:
|
|
38
|
+
model.is_default = model.id == default
|
|
39
|
+
if default not in {m.id for m in self.available_models}:
|
|
40
|
+
self.available_models.insert(0, ModelInfo(id=default, name=default, is_default=True))
|
|
41
|
+
self.discovery_error = ""
|
|
42
|
+
|
|
43
|
+
# --- Keys and client ---
|
|
44
|
+
|
|
45
|
+
def _get_api_key(self) -> str | None:
|
|
46
|
+
if self._api_key:
|
|
47
|
+
return self._api_key
|
|
48
|
+
if self.api_key_env:
|
|
49
|
+
value = os.environ.get(self.api_key_env, "").strip()
|
|
50
|
+
if value:
|
|
51
|
+
return value
|
|
52
|
+
return "local" # local servers ignore the key; the OpenAI client requires a non-empty one
|
|
53
|
+
|
|
54
|
+
def _ensure_client(self) -> Any:
|
|
55
|
+
if self._client is None:
|
|
56
|
+
from openai import AsyncOpenAI
|
|
57
|
+
self._client = AsyncOpenAI(api_key=self._get_api_key(), base_url=self.base_url)
|
|
58
|
+
return self._client
|
|
59
|
+
|
|
60
|
+
# --- Detection ---
|
|
61
|
+
|
|
62
|
+
def is_installed(self) -> bool:
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
def is_configured(self) -> bool:
|
|
66
|
+
return bool(self.base_url)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def auth_method(self) -> str:
|
|
70
|
+
return "custom_endpoint"
|
|
71
|
+
|
|
72
|
+
def get_status_display(self):
|
|
73
|
+
if self.discovery_error:
|
|
74
|
+
return (f"Configured; {self.discovery_error}", "status.limited")
|
|
75
|
+
return (f"Ready ({self.base_url})", "status.ready")
|
|
76
|
+
|
|
77
|
+
def get_install_info(self) -> dict[str, str]:
|
|
78
|
+
return {"method": "OpenAI-compatible endpoint", "path": self.base_url, "version": ""}
|
|
79
|
+
|
|
80
|
+
def get_subscription_info(self) -> SubscriptionInfo:
|
|
81
|
+
return SubscriptionInfo(is_active=True, message=f"Your own endpoint: {self.base_url}")
|
|
82
|
+
|
|
83
|
+
def check_limits(self):
|
|
84
|
+
return LimitStatus.OK
|
|
85
|
+
|
|
86
|
+
def get_model_info(self, model_id):
|
|
87
|
+
known = super().get_model_info(model_id)
|
|
88
|
+
if known or not isinstance(model_id, str) or not model_id or any(c.isspace() for c in model_id):
|
|
89
|
+
return known
|
|
90
|
+
return ModelInfo(id=model_id, name=model_id) # the server validates unknown IDs
|
|
91
|
+
|
|
92
|
+
def get_default_model(self) -> str:
|
|
93
|
+
for model in self.available_models:
|
|
94
|
+
if model.is_default:
|
|
95
|
+
return model.id
|
|
96
|
+
return self.available_models[0].id if self.available_models else ""
|
|
97
|
+
|
|
98
|
+
# --- Live model list ---
|
|
99
|
+
|
|
100
|
+
async def refresh_models(self) -> None:
|
|
101
|
+
"""Merge the server's /models list over the configured one; keep the configured list on failure."""
|
|
102
|
+
headers = {}
|
|
103
|
+
key = self._get_api_key()
|
|
104
|
+
if key and key != "local":
|
|
105
|
+
headers["Authorization"] = f"Bearer {key}"
|
|
106
|
+
try:
|
|
107
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
108
|
+
response = await client.get(f"{self.base_url}/models", headers=headers)
|
|
109
|
+
response.raise_for_status()
|
|
110
|
+
payload = response.json()
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
self.discovery_error = f"model list unavailable ({type(exc).__name__}); using the configured list"
|
|
113
|
+
return
|
|
114
|
+
items = payload.get("data", payload) if isinstance(payload, dict) else payload
|
|
115
|
+
live = []
|
|
116
|
+
for item in items if isinstance(items, list) else []:
|
|
117
|
+
model_id = item.get("id") if isinstance(item, dict) else item
|
|
118
|
+
if isinstance(model_id, str) and model_id:
|
|
119
|
+
live.append(model_id)
|
|
120
|
+
if not live:
|
|
121
|
+
self.discovery_error = "the server listed no models; using the configured list"
|
|
122
|
+
return
|
|
123
|
+
self.discovery_error = ""
|
|
124
|
+
default = self.get_default_model()
|
|
125
|
+
configured = {m.id for m in self.available_models}
|
|
126
|
+
merged = list(self.available_models) + [ModelInfo(id=m, name=m) for m in live if m not in configured]
|
|
127
|
+
for model in merged:
|
|
128
|
+
model.is_default = model.id == (default or live[0])
|
|
129
|
+
self.available_models = merged
|
|
@@ -187,7 +187,10 @@ class NativeAgent(BaseAgent):
|
|
|
187
187
|
self._thread_id = session["thread"]["id"]
|
|
188
188
|
return self._thread_id, False
|
|
189
189
|
|
|
190
|
-
|
|
190
|
+
supports_images = True # Codex takes local image paths as turn input
|
|
191
|
+
|
|
192
|
+
async def chat(self, messages, model=None, system_prompt=None, tools=None, stream=True, resume=None, memory_note="",
|
|
193
|
+
images=None):
|
|
191
194
|
rpc = await self._live_rpc()
|
|
192
195
|
event_task = None
|
|
193
196
|
chosen = model or self.get_default_model()
|
|
@@ -205,8 +208,9 @@ class NativeAgent(BaseAgent):
|
|
|
205
208
|
stale = rpc.events.get_nowait()
|
|
206
209
|
if stale.get("method") == "_closed":
|
|
207
210
|
raise RuntimeError("Native CLI exited before the turn started.")
|
|
208
|
-
|
|
209
|
-
|
|
211
|
+
turn_input = [{"type": "text", "text": prompt}]
|
|
212
|
+
turn_input += [{"type": "localImage", "path": str(path)} for path in (images or [])]
|
|
213
|
+
await rpc.request("turn/start", {"threadId": thread_id, "model": chosen, "input": turn_input})
|
|
210
214
|
usage = TokenUsage()
|
|
211
215
|
started_items = {}
|
|
212
216
|
text_items = set()
|
|
@@ -16,9 +16,11 @@ tool that needs one is declined by the CLI itself, and thwip shows its explanati
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
18
|
import asyncio
|
|
19
|
+
import base64
|
|
19
20
|
import collections
|
|
20
21
|
import contextlib
|
|
21
22
|
import json
|
|
23
|
+
import mimetypes
|
|
22
24
|
import os
|
|
23
25
|
import shutil
|
|
24
26
|
import uuid
|
|
@@ -272,7 +274,25 @@ class PrintAgent(BaseAgent):
|
|
|
272
274
|
|
|
273
275
|
# --- Chat ---
|
|
274
276
|
|
|
275
|
-
|
|
277
|
+
supports_images = True # passed through to the CLI's own image input
|
|
278
|
+
|
|
279
|
+
@staticmethod
|
|
280
|
+
def _image_blocks(images) -> list[dict]:
|
|
281
|
+
"""Anthropic-style image blocks (base64) for Claude Code's stream-json input."""
|
|
282
|
+
blocks = []
|
|
283
|
+
for path in images or []:
|
|
284
|
+
path = Path(path)
|
|
285
|
+
media = mimetypes.guess_type(path.name)[0] or "image/png"
|
|
286
|
+
try:
|
|
287
|
+
data = base64.b64encode(path.read_bytes()).decode()
|
|
288
|
+
except OSError:
|
|
289
|
+
continue
|
|
290
|
+
blocks.append({"type": "image", "source": {"type": "base64", "media_type": media, "data": data}})
|
|
291
|
+
return blocks
|
|
292
|
+
|
|
293
|
+
async def chat(self, messages, model=None, system_prompt=None, tools=None, stream=True, resume=None, memory_note="",
|
|
294
|
+
images=None):
|
|
295
|
+
self._images = list(images or [])
|
|
276
296
|
chosen = model or self.get_default_model()
|
|
277
297
|
resume_id = resume.get("id") if isinstance(resume, dict) else None
|
|
278
298
|
synced = resume.get("synced", 0) if isinstance(resume, dict) else 0
|
|
@@ -313,7 +333,9 @@ class PrintAgent(BaseAgent):
|
|
|
313
333
|
# The initialize handshake is what makes Claude Code route can_use_tool questions over the stream.
|
|
314
334
|
await self._send(process, {"type": "control_request", "request_id": "thwip-init",
|
|
315
335
|
"request": {"subtype": "initialize"}})
|
|
316
|
-
|
|
336
|
+
blocks = self._image_blocks(getattr(self, "_images", []))
|
|
337
|
+
content = [{"type": "text", "text": prompt}, *blocks] if blocks else prompt
|
|
338
|
+
await self._send(process, {"type": "user", "message": {"role": "user", "content": content}})
|
|
317
339
|
async for event in self._iterate(process):
|
|
318
340
|
kind = event.get("type")
|
|
319
341
|
if kind == "system" and event.get("session_id"):
|
|
@@ -386,6 +408,10 @@ class PrintAgent(BaseAgent):
|
|
|
386
408
|
streamed_text = produced_text = False
|
|
387
409
|
session_id = kept.conversation_id
|
|
388
410
|
try:
|
|
411
|
+
images = [str(Path(p)) for p in getattr(self, "_images", [])]
|
|
412
|
+
if images:
|
|
413
|
+
# Antigravity's headless input takes text only; the paths are named so its own tools can open them.
|
|
414
|
+
prompt = prompt + "\n\nAttached image files (open them with your own tools): " + ", ".join(images)
|
|
389
415
|
await self._send(process, {"event": "user", "message": {"role": "user", "content": prompt}})
|
|
390
416
|
async for event in self._iterate(process):
|
|
391
417
|
kind = event.get("event") or event.get("type")
|
|
@@ -101,6 +101,8 @@ class OpenAIAgent(BaseAgent):
|
|
|
101
101
|
),
|
|
102
102
|
]
|
|
103
103
|
|
|
104
|
+
responses_api = True # OpenAI itself; compatible servers (custom providers) use chat completions only
|
|
105
|
+
|
|
104
106
|
def __init__(self, api_key: str | None = None) -> None:
|
|
105
107
|
self._api_key = api_key
|
|
106
108
|
self._client = None
|
|
@@ -297,8 +299,8 @@ class OpenAIAgent(BaseAgent):
|
|
|
297
299
|
|
|
298
300
|
client = self._ensure_client()
|
|
299
301
|
model = model or self.get_default_model()
|
|
300
|
-
if tools:
|
|
301
|
-
stream = False
|
|
302
|
+
if tools and self.responses_api:
|
|
303
|
+
stream = False # tool rounds go through the Responses API
|
|
302
304
|
|
|
303
305
|
# Build messages with system prompt
|
|
304
306
|
api_messages: list[dict[str, Any]] = []
|
|
@@ -78,6 +78,9 @@ from thwip.utils import estimate_cost
|
|
|
78
78
|
os.environ.setdefault("PROMPT_TOOLKIT_NO_CPR", "1")
|
|
79
79
|
|
|
80
80
|
|
|
81
|
+
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
|
|
82
|
+
|
|
83
|
+
|
|
81
84
|
class QuietTerminal:
|
|
82
85
|
"""Turn off keyboard echo while a turn runs and drop stray input before the next prompt."""
|
|
83
86
|
|
|
@@ -232,8 +235,8 @@ class ThwipCLI:
|
|
|
232
235
|
print_info("Connecting installed agents using their existing sign-ins...")
|
|
233
236
|
await self.registry.connect_native_agents(str(Path(self.session.project_path).resolve()))
|
|
234
237
|
self.current_agent = self._resolve_initial_agent()
|
|
235
|
-
if getattr(self.current_agent, "native_tools", False):
|
|
236
|
-
self.session.current_model = self.current_agent.get_default_model()
|
|
238
|
+
if getattr(self.current_agent, "native_tools", False) or self.current_agent.name in (getattr(self.config, "providers", None) or {}):
|
|
239
|
+
self.session.current_model = self.current_agent.get_default_model() # the global default model is not theirs
|
|
237
240
|
# 1. Detect agents on system
|
|
238
241
|
self.detector.scan_all()
|
|
239
242
|
all_agents = self.registry.list_agents()
|
|
@@ -463,6 +466,9 @@ class ThwipCLI:
|
|
|
463
466
|
elif cmd == "/export":
|
|
464
467
|
self.cmd_export(cmd_line.partition(" ")[2].strip())
|
|
465
468
|
|
|
469
|
+
elif cmd == "/providers":
|
|
470
|
+
await self.cmd_providers(arg1.lower(), arg2)
|
|
471
|
+
|
|
466
472
|
elif cmd in ("/permissions", "/approvals"): # Codex calls it /approvals
|
|
467
473
|
self.cmd_permissions()
|
|
468
474
|
|
|
@@ -819,6 +825,7 @@ class ThwipCLI:
|
|
|
819
825
|
def _expand_mentions(self, text: str) -> str:
|
|
820
826
|
"""Attach files referenced as @path (inside the project) to the message, like @ mentions in Codex and Claude Code."""
|
|
821
827
|
mentions = re.findall(r"(?<!\S)@([\w./\-]+)", text)
|
|
828
|
+
self._pending_images = []
|
|
822
829
|
if not mentions:
|
|
823
830
|
return text
|
|
824
831
|
attachments = []
|
|
@@ -827,6 +834,11 @@ class ThwipCLI:
|
|
|
827
834
|
candidate = (project / mention).resolve()
|
|
828
835
|
if not candidate.is_file() or project not in candidate.parents:
|
|
829
836
|
continue
|
|
837
|
+
if candidate.suffix.lower() in IMAGE_SUFFIXES:
|
|
838
|
+
# Images go to the CLI's own image input (Codex, Claude Code), never read as text.
|
|
839
|
+
self._pending_images.append(str(candidate))
|
|
840
|
+
print_info(f"Attached image {mention}")
|
|
841
|
+
continue
|
|
830
842
|
content = self.tool_manager.execute_tool("read_file", {"file_path": mention, "max_lines": 400})
|
|
831
843
|
attachments.append(f"[Attached file: {mention}]\n{content}")
|
|
832
844
|
print_info(f"Attached {mention}")
|
|
@@ -1149,6 +1161,73 @@ class ThwipCLI:
|
|
|
1149
1161
|
return "No earlier conversation matched that query." if self.session.archive else "Nothing has been compacted yet; the full conversation is already in context."
|
|
1150
1162
|
return "\n\n".join(chunk.text for chunk in found)
|
|
1151
1163
|
|
|
1164
|
+
async def cmd_providers(self, sub: str = "", rest: str = "") -> None:
|
|
1165
|
+
"""/providers [add|remove <name>|list]: your own OpenAI-compatible models, local or hosted."""
|
|
1166
|
+
providers = getattr(self.config, "providers", None)
|
|
1167
|
+
if providers is None:
|
|
1168
|
+
self.config.providers = providers = {}
|
|
1169
|
+
if sub == "add":
|
|
1170
|
+
console.print(Text(
|
|
1171
|
+
"Add any server that speaks the OpenAI chat protocol: llama.cpp, LM Studio, vLLM, Ollama (/v1), "
|
|
1172
|
+
"text-generation-webui, a company gateway, or a hosted API.\n"
|
|
1173
|
+
"You need its base URL ending in /v1, and a key only if the server asks for one.", style="dim"))
|
|
1174
|
+
name = (await self._ask_text("Name for this provider (e.g. local, lmstudio, work):")).strip().lower().replace(" ", "-")
|
|
1175
|
+
if not name:
|
|
1176
|
+
print_info("Nothing added.")
|
|
1177
|
+
return
|
|
1178
|
+
base_url = (await self._ask_text("Base URL (e.g. http://localhost:1234/v1):")).strip().rstrip("/")
|
|
1179
|
+
if not base_url.startswith(("http://", "https://")):
|
|
1180
|
+
print_error("The base URL must start with http:// or https://.")
|
|
1181
|
+
return
|
|
1182
|
+
key_env = (await self._ask_text("Environment variable holding the API key (Enter if none):")).strip()
|
|
1183
|
+
models_text = (await self._ask_text("Model IDs, comma separated (Enter to list them from the server):")).strip()
|
|
1184
|
+
spec = {"base_url": base_url}
|
|
1185
|
+
if key_env:
|
|
1186
|
+
spec["api_key_env"] = key_env
|
|
1187
|
+
if models_text:
|
|
1188
|
+
spec["models"] = [m.strip() for m in models_text.split(",") if m.strip()]
|
|
1189
|
+
from thwip.agents.custom_agent import CustomAgent
|
|
1190
|
+
agent = CustomAgent(name, spec)
|
|
1191
|
+
with console.status("[dim]Asking the server for its model list...[/dim]"):
|
|
1192
|
+
await agent.refresh_models()
|
|
1193
|
+
if not agent.available_models:
|
|
1194
|
+
print_error("The server listed no models and none were given. Check the URL or enter model IDs.")
|
|
1195
|
+
return
|
|
1196
|
+
if agent.discovery_error:
|
|
1197
|
+
print_warning(agent.discovery_error)
|
|
1198
|
+
providers[name] = spec
|
|
1199
|
+
self.registry._agents[name] = agent
|
|
1200
|
+
with contextlib.suppress(Exception):
|
|
1201
|
+
self.config.save()
|
|
1202
|
+
print_success(f"Added {name}: {len(agent.available_models)} model(s), default {agent.get_default_model()}. "
|
|
1203
|
+
f"Use /switch {name}. Saved under [providers.{name}] in config.toml.")
|
|
1204
|
+
return
|
|
1205
|
+
if sub == "remove":
|
|
1206
|
+
name = rest.strip().lower()
|
|
1207
|
+
if name in providers:
|
|
1208
|
+
providers.pop(name)
|
|
1209
|
+
self.registry._agents.pop(name, None)
|
|
1210
|
+
with contextlib.suppress(Exception):
|
|
1211
|
+
self.config.save()
|
|
1212
|
+
print_success(f"Removed provider {name}.")
|
|
1213
|
+
else:
|
|
1214
|
+
print_error(f"No custom provider named '{name}'.")
|
|
1215
|
+
return
|
|
1216
|
+
if not providers:
|
|
1217
|
+
console.print(Text("No custom providers yet. /providers add walks you through adding any OpenAI-compatible "
|
|
1218
|
+
"server, local (llama.cpp, LM Studio, vLLM, Ollama) or hosted.", style="dim"))
|
|
1219
|
+
return
|
|
1220
|
+
table = Table(title="Your providers", box=box.ROUNDED)
|
|
1221
|
+
table.add_column("Name", style="bold white")
|
|
1222
|
+
table.add_column("Base URL", style="white")
|
|
1223
|
+
table.add_column("Models", style="dim")
|
|
1224
|
+
for name, spec in providers.items():
|
|
1225
|
+
agent = self.registry.get_agent(name)
|
|
1226
|
+
models = ", ".join(m.id for m in (agent.available_models if agent else [])) or ", ".join(spec.get("models", []))
|
|
1227
|
+
table.add_row(name, spec.get("base_url", ""), models[:80])
|
|
1228
|
+
console.print(table)
|
|
1229
|
+
console.print(Text("/providers add, /providers remove <name>, /switch <name>", style="dim"))
|
|
1230
|
+
|
|
1152
1231
|
def cmd_permissions(self) -> None:
|
|
1153
1232
|
"""/permissions: how each native CLI's own permission questions reach you."""
|
|
1154
1233
|
lines = [
|
|
@@ -1292,6 +1371,8 @@ class ThwipCLI:
|
|
|
1292
1371
|
("@path in a message", "Attach a project file's content to your message"),
|
|
1293
1372
|
("/prompt [show|set <text>|reset|save]", "Your standing instructions for every assistant; thwip itself adds only a neutral note"),
|
|
1294
1373
|
("/permissions", "How each native CLI's own permission questions reach you"),
|
|
1374
|
+
("/providers [add|remove <name>]", "Your own models: any OpenAI-compatible server, local or hosted"),
|
|
1375
|
+
("@image.png in a message", "Send an image to the CLI's own image input (Codex, Claude Code)"),
|
|
1295
1376
|
("/recall <words>", "Search earlier turns that compaction summarized away"),
|
|
1296
1377
|
("/trace [n]", "Recent request traces: latency, tokens, cost, tool calls, errors per provider"),
|
|
1297
1378
|
("/memory [show|init|edit|update|sync|vault|link]", "Project memory file shared by every CLI, filed into your second-brain vault; /init is a shortcut for /memory init"),
|
|
@@ -2006,6 +2087,14 @@ class ThwipCLI:
|
|
|
2006
2087
|
chat_kwargs["resume"] = self.session.native_session(self.current_agent.name)
|
|
2007
2088
|
if memory_note:
|
|
2008
2089
|
chat_kwargs["memory_note"] = memory_note
|
|
2090
|
+
pending_images = getattr(self, "_pending_images", [])
|
|
2091
|
+
if pending_images:
|
|
2092
|
+
if getattr(self.current_agent, "supports_images", False):
|
|
2093
|
+
chat_kwargs["images"] = list(pending_images)
|
|
2094
|
+
else:
|
|
2095
|
+
print_warning(f"{self.current_agent.display_name} takes no image input through thwip; "
|
|
2096
|
+
"the image was not sent.")
|
|
2097
|
+
self._pending_images = []
|
|
2009
2098
|
response_stream = self.current_agent.chat(**chat_kwargs)
|
|
2010
2099
|
# Close the adapter generator deterministically on interruption so child processes stop.
|
|
2011
2100
|
closer = contextlib.aclosing(response_stream) if hasattr(response_stream, "aclose") else contextlib.nullcontext(response_stream)
|
|
@@ -11,6 +11,7 @@ Vocabulary:
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import contextlib
|
|
14
|
+
import re
|
|
14
15
|
from collections.abc import Sequence
|
|
15
16
|
|
|
16
17
|
from thwip.agents.base import AgentDone, LimitHit, TextDelta
|
|
@@ -20,14 +21,47 @@ class CompactionUnavailable(RuntimeError):
|
|
|
20
21
|
"""The worker could not produce a summary (limit hit or empty answer)."""
|
|
21
22
|
|
|
22
23
|
|
|
23
|
-
SUMMARY_SYSTEM_PROMPT = "You write faithful, compact summaries for another assistant that will continue the work."
|
|
24
|
-
|
|
24
|
+
SUMMARY_SYSTEM_PROMPT = ("You write faithful, compact summaries for another assistant that will continue the work. "
|
|
25
|
+
"When unsure whether a detail matters, keep it.")
|
|
25
26
|
SUMMARY_REQUEST = (
|
|
26
|
-
"Summarize the conversation below for another assistant that will continue it
|
|
27
|
-
"
|
|
28
|
-
"
|
|
27
|
+
"Summarize the conversation below for another assistant that will continue it with no other memory of it.\n"
|
|
28
|
+
"Rules:\n"
|
|
29
|
+
"- Copy every file path, command, error message, code word, identifier, URL, number and version exactly as written; "
|
|
30
|
+
"never paraphrase these.\n"
|
|
31
|
+
"- Keep every decision with its reason, every requirement and constraint the user stated, and every preference "
|
|
32
|
+
"about how the user wants to work.\n"
|
|
33
|
+
"- Keep every open task, question, and anything tried that failed, so it is not tried again.\n"
|
|
34
|
+
"- Drop only greetings, repetition, and reasoning that led nowhere.\n"
|
|
35
|
+
"- Short bullet points under the headings Context, Decisions, Open tasks. No commentary.\n\n"
|
|
29
36
|
)
|
|
30
37
|
|
|
38
|
+
# Things a summary must carry word for word. They are extracted deterministically from the old turns and checked
|
|
39
|
+
# against the summary; a summary missing any of them is sent back with the exact list.
|
|
40
|
+
_ANCHOR_PATTERNS = [
|
|
41
|
+
re.compile(r"`([^`\n]{2,120})`"), # anything the conversation put in code marks
|
|
42
|
+
re.compile(r"(?<![\w/.])((?:~|\.{1,2})?/?[\w.\-]+(?:/[\w.\-]+)+)"), # paths with at least one slash
|
|
43
|
+
re.compile(r"\b([\w.\-]+\.(?:py|js|ts|tsx|md|toml|json|yml|yaml|txt|ini|cfg|sh|html|css|rs|go|java|c|h|cpp))\b"),
|
|
44
|
+
re.compile(r"\b(https?://[^\s)\]]+)"), # URLs
|
|
45
|
+
re.compile(r"\b([A-Z][A-Z0-9_-]{3,})\b"), # CODE-WORDS and CONSTANTS
|
|
46
|
+
re.compile(r"\bv?(\d+\.\d+(?:\.\d+)+)\b"), # versions
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def extract_anchors(text: str) -> list[str]:
|
|
51
|
+
"""Exact strings the summary must contain, in order of first appearance, without duplicates."""
|
|
52
|
+
found: list[str] = []
|
|
53
|
+
for pattern in _ANCHOR_PATTERNS:
|
|
54
|
+
for match in pattern.findall(text):
|
|
55
|
+
item = match.strip()
|
|
56
|
+
if len(item) >= 2 and item not in found:
|
|
57
|
+
found.append(item)
|
|
58
|
+
return found
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def missing_anchors(summary: str, anchors: Sequence[str]) -> list[str]:
|
|
62
|
+
lowered = summary.lower()
|
|
63
|
+
return [a for a in anchors if a.lower() not in lowered]
|
|
64
|
+
|
|
31
65
|
|
|
32
66
|
def choose_worker(active, ready: Sequence, chain: Sequence[str] = ()):
|
|
33
67
|
"""Pick the provider that writes the summary: never the active one when any alternative is ready."""
|
|
@@ -54,13 +88,32 @@ async def summarize_structured(worker, messages: Sequence[dict], model: str | No
|
|
|
54
88
|
Falls back to the plain-text summary when the worker cannot produce valid JSON after repairs.
|
|
55
89
|
"""
|
|
56
90
|
from thwip.structured import StructuredOutputError, render_summary, request_json, summary_schema
|
|
57
|
-
|
|
58
|
-
request = SUMMARY_REQUEST +
|
|
91
|
+
text = transcript(messages)
|
|
92
|
+
request = SUMMARY_REQUEST + text
|
|
93
|
+
anchors = extract_anchors(text)
|
|
59
94
|
try:
|
|
60
95
|
data, repairs = await request_json(worker, request, summary_schema(), model=model)
|
|
61
|
-
return render_summary(data), repairs
|
|
62
96
|
except StructuredOutputError:
|
|
63
97
|
return await summarize(worker, messages, model), -1
|
|
98
|
+
summary = render_summary(data)
|
|
99
|
+
# Coverage check: every exact item from the old turns must appear in the summary. Two rounds, then whatever is
|
|
100
|
+
# still missing is appended verbatim so nothing is silently dropped.
|
|
101
|
+
for _round in range(2):
|
|
102
|
+
missing = missing_anchors(summary, anchors)
|
|
103
|
+
if not missing:
|
|
104
|
+
break
|
|
105
|
+
fix = (request + "\n\nYour previous summary left out these exact items; include each one verbatim where it "
|
|
106
|
+
"belongs:\n- " + "\n- ".join(missing[:40]) + "\n\nPrevious summary:\n" + summary)
|
|
107
|
+
try:
|
|
108
|
+
data, more = await request_json(worker, fix, summary_schema(), model=model)
|
|
109
|
+
except StructuredOutputError:
|
|
110
|
+
break
|
|
111
|
+
repairs += more + 1
|
|
112
|
+
summary = render_summary(data)
|
|
113
|
+
missing = missing_anchors(summary, anchors)
|
|
114
|
+
if missing:
|
|
115
|
+
summary += "\nExact items from earlier turns\n" + "\n".join(f"- {m}" for m in missing[:40])
|
|
116
|
+
return summary, repairs
|
|
64
117
|
|
|
65
118
|
|
|
66
119
|
async def summarize(worker, messages: Sequence[dict], model: str | None = None) -> str:
|
|
@@ -249,6 +249,9 @@ class ThwipConfig:
|
|
|
249
249
|
|
|
250
250
|
# Optional per-provider base URL overrides ([endpoints] table)
|
|
251
251
|
endpoints: dict[str, str] = field(default_factory=dict)
|
|
252
|
+
# Your own providers: any OpenAI-compatible server, local or hosted ([providers.<name>] tables).
|
|
253
|
+
# Keys: base_url (required), api_key or api_key_env, models (list), default_model, display_name, company.
|
|
254
|
+
providers: dict[str, dict] = field(default_factory=dict)
|
|
252
255
|
|
|
253
256
|
# Sub-configs
|
|
254
257
|
fallback: FallbackConfig = field(default_factory=FallbackConfig)
|
|
@@ -307,6 +310,12 @@ class ThwipConfig:
|
|
|
307
310
|
# Endpoint overrides are free-form provider -> URL pairs.
|
|
308
311
|
raw_endpoints = data.get("endpoints", {})
|
|
309
312
|
clean["endpoints"] = {str(k): v for k, v in raw_endpoints.items() if isinstance(v, str)} if isinstance(raw_endpoints, dict) else {}
|
|
313
|
+
raw_providers = data.get("providers", {})
|
|
314
|
+
clean["providers"] = {}
|
|
315
|
+
if isinstance(raw_providers, dict):
|
|
316
|
+
for name, entry in raw_providers.items():
|
|
317
|
+
if isinstance(entry, dict) and isinstance(entry.get("base_url"), str) and entry["base_url"].strip():
|
|
318
|
+
clean["providers"][str(name)] = entry
|
|
310
319
|
raw_keys = data.get("keys", {})
|
|
311
320
|
clean["keys"] = {key: value for key, value in raw_keys.items()
|
|
312
321
|
if isinstance(value, str) and value.strip()} if isinstance(raw_keys, dict) else {}
|
|
@@ -375,6 +384,21 @@ class ThwipConfig:
|
|
|
375
384
|
from thwip import endpoints as endpoint_overrides
|
|
376
385
|
endpoint_overrides.configure(self.endpoints)
|
|
377
386
|
|
|
387
|
+
# Custom providers
|
|
388
|
+
providers = data.get("providers", {})
|
|
389
|
+
if isinstance(providers, dict):
|
|
390
|
+
self.providers = {}
|
|
391
|
+
for name, entry in providers.items():
|
|
392
|
+
slug = str(name).strip().lower().replace(" ", "-")
|
|
393
|
+
if not slug or not isinstance(entry, dict):
|
|
394
|
+
continue
|
|
395
|
+
clean_entry = {"base_url": str(entry["base_url"]).strip().rstrip("/")}
|
|
396
|
+
for key in ("api_key", "api_key_env", "default_model", "display_name", "company"):
|
|
397
|
+
if isinstance(entry.get(key), str) and entry[key].strip():
|
|
398
|
+
clean_entry[key] = entry[key].strip()
|
|
399
|
+
if isinstance(entry.get("models"), list):
|
|
400
|
+
clean_entry["models"] = [str(m).strip() for m in entry["models"] if isinstance(m, str) and m.strip()]
|
|
401
|
+
self.providers[slug] = clean_entry
|
|
378
402
|
# Fallback
|
|
379
403
|
fallback = data.get("fallback", {})
|
|
380
404
|
if "enabled" in fallback:
|
|
@@ -447,6 +471,7 @@ class ThwipConfig:
|
|
|
447
471
|
"max_width": self.display.max_width,
|
|
448
472
|
"dynamic_ui": self.display.dynamic_ui,
|
|
449
473
|
},
|
|
474
|
+
"providers": self.providers,
|
|
450
475
|
"limits": {
|
|
451
476
|
"warn_at_percent": self.limits.warn_at_percent,
|
|
452
477
|
"quota_warn_percent": self.limits.quota_warn_percent,
|
|
@@ -89,6 +89,10 @@ neutral note and the relevant project memory.
|
|
|
89
89
|
Pick a known project, create one, or switch to a path. Sessions remember their project, so thwip can
|
|
90
90
|
run from the home folder; started there, it asks which project to work on.
|
|
91
91
|
.TP
|
|
92
|
+
.B /providers [add|remove <name>]
|
|
93
|
+
Your own models: any OpenAI-compatible server, local or hosted. /providers add asks for a name, the
|
|
94
|
+
base URL, an optional key variable and optional model IDs, then lists the server's models.
|
|
95
|
+
.TP
|
|
92
96
|
.B /permissions
|
|
93
97
|
How each native CLI's own permission questions reach you. Codex and Claude Code ask through thwip;
|
|
94
98
|
Antigravity cannot ask in headless mode and declines such tools itself.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|