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.
Files changed (93) hide show
  1. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/.github/workflows/ci.yml +20 -0
  2. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/PKG-INFO +24 -1
  3. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/README.md +23 -0
  4. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/pyproject.toml +1 -1
  5. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_compaction.py +40 -0
  6. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_native_agents.py +15 -0
  7. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_native_print.py +15 -0
  8. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_parity_commands.py +50 -0
  9. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/__init__.py +1 -1
  10. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/__init__.py +9 -1
  11. thwip_cli-1.17.0/thwip/agents/custom_agent.py +129 -0
  12. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/native_agent.py +7 -3
  13. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/native_print.py +28 -2
  14. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/openai_agent.py +4 -2
  15. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/cli.py +91 -2
  16. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/compaction.py +61 -8
  17. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/config.py +25 -0
  18. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/data/thwip.1 +4 -0
  19. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/uv.lock +1 -1
  20. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/.github/workflows/publish.yml +0 -0
  21. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/.gitignore +0 -0
  22. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/ARCHITECTURE.md +0 -0
  23. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/LICENSE +0 -0
  24. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/docs/benchmark/2026-10-04-final.html +0 -0
  25. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/docs/benchmark/2026-10-04-final.json +0 -0
  26. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/docs/benchmark/2026-10-04-home-wifi.json +0 -0
  27. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/docs/benchmark/2026-10-04-hotspot.html +0 -0
  28. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/docs/benchmark/2026-10-04-hotspot.json +0 -0
  29. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/docs/benchmark/2026-10-06-final.html +0 -0
  30. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/docs/benchmark/2026-10-06-final.json +0 -0
  31. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/docs/handoff-research.md +0 -0
  32. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/docs/verification.md +0 -0
  33. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/install.sh +0 -0
  34. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/scripts/drive_repl.py +0 -0
  35. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/__init__.py +0 -0
  36. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/fake_providers.py +0 -0
  37. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_agents.py +0 -0
  38. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_ai_layers.py +0 -0
  39. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_audit_regressions.py +0 -0
  40. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_catalog.py +0 -0
  41. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_cli.py +0 -0
  42. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_compatible_streaming.py +0 -0
  43. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_config.py +0 -0
  44. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_detector.py +0 -0
  45. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_direct_providers_e2e.py +0 -0
  46. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_evals.py +0 -0
  47. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_handoff.py +0 -0
  48. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_memory.py +0 -0
  49. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_native_launcher.py +0 -0
  50. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_native_rpc.py +0 -0
  51. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_prompt.py +0 -0
  52. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_repair_verification.py +0 -0
  53. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_retrieval.py +0 -0
  54. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_session.py +0 -0
  55. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_tools.py +0 -0
  56. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/tests/test_utils.py +0 -0
  57. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/__main__.py +0 -0
  58. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/base.py +0 -0
  59. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/catalog.py +0 -0
  60. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/chat_messages.py +0 -0
  61. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/claude_agent.py +0 -0
  62. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/deepseek_agent.py +0 -0
  63. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/google_agent.py +0 -0
  64. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/groq_agent.py +0 -0
  65. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/native_common.py +0 -0
  66. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/native_rpc.py +0 -0
  67. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/ollama_agent.py +0 -0
  68. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/agents/openrouter_agent.py +0 -0
  69. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/data/context_template.md +0 -0
  70. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/detector.py +0 -0
  71. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/endpoints.py +0 -0
  72. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/evals/__init__.py +0 -0
  73. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/evals/__main__.py +0 -0
  74. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/evals/fake_agent.py +0 -0
  75. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/evals/runner.py +0 -0
  76. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/evals/tasks.py +0 -0
  77. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/guardrails.py +0 -0
  78. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/handoff.py +0 -0
  79. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/limits.py +0 -0
  80. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/memory.py +0 -0
  81. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/recall.py +0 -0
  82. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/retrieval.py +0 -0
  83. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/session.py +0 -0
  84. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/shortcuts.py +0 -0
  85. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/structured.py +0 -0
  86. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/theme.py +0 -0
  87. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/tools/__init__.py +0 -0
  88. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/tools/code_runner.py +0 -0
  89. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/tools/file_editor.py +0 -0
  90. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/tools/git_ops.py +0 -0
  91. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/tools/terminal.py +0 -0
  92. {thwip_cli-1.16.2 → thwip_cli-1.17.0}/thwip/tracing.py +0 -0
  93. {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.16.2
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.16.2"
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"
@@ -1,4 +1,4 @@
1
1
  """thwip: Universal Coding Agent Multiplexer."""
2
2
 
3
- __version__ = "1.16.2"
3
+ __version__ = "1.17.0"
4
4
  __app_name__ = "thwip"
@@ -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
- normalized = alias_map.get(name.lower().strip(), name.lower().strip())
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
- async def chat(self, messages, model=None, system_prompt=None, tools=None, stream=True, resume=None, memory_note=""):
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
- await rpc.request("turn/start", {"threadId": thread_id, "model": chosen,
209
- "input": [{"type": "text", "text": prompt}]})
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
- async def chat(self, messages, model=None, system_prompt=None, tools=None, stream=True, resume=None, memory_note=""):
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
- await self._send(process, {"type": "user", "message": {"role": "user", "content": prompt}})
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. Keep every decision, "
27
- "requirement, file path, command, error, code word, URL, and open task exactly as stated. Use short bullet points "
28
- "under the headings Context, Decisions, Open tasks. Do not add commentary.\n\n"
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 + transcript(messages)
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.
@@ -1022,7 +1022,7 @@ wheels = [
1022
1022
 
1023
1023
  [[package]]
1024
1024
  name = "thwip-cli"
1025
- version = "1.16.2"
1025
+ version = "1.17.0"
1026
1026
  source = { editable = "." }
1027
1027
  dependencies = [
1028
1028
  { name = "anthropic" },
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