thwip-cli 1.4.0__tar.gz → 1.5.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 (58) hide show
  1. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/PKG-INFO +35 -3
  2. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/README.md +34 -2
  3. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/docs/verification.md +7 -3
  4. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/pyproject.toml +1 -1
  5. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_audit_regressions.py +60 -1
  6. thwip_cli-1.5.0/tests/test_catalog.py +131 -0
  7. thwip_cli-1.5.0/tests/test_parity_commands.py +173 -0
  8. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/__init__.py +1 -1
  9. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/__init__.py +9 -4
  10. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/base.py +28 -0
  11. thwip_cli-1.5.0/thwip/agents/catalog.py +141 -0
  12. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/native_common.py +5 -3
  13. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/cli.py +370 -82
  14. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/shortcuts.py +7 -0
  15. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/theme.py +5 -0
  16. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/uv.lock +1 -1
  17. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/.github/workflows/publish.yml +0 -0
  18. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/.gitignore +0 -0
  19. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/LICENSE +0 -0
  20. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/docs/handoff-research.md +0 -0
  21. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/install.sh +0 -0
  22. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_agents.py +0 -0
  23. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_cli.py +0 -0
  24. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_compatible_streaming.py +0 -0
  25. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_config.py +0 -0
  26. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_detector.py +0 -0
  27. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_handoff.py +0 -0
  28. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_native_agents.py +0 -0
  29. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_native_launcher.py +0 -0
  30. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_native_print.py +0 -0
  31. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_native_rpc.py +0 -0
  32. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_repair_verification.py +0 -0
  33. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_session.py +0 -0
  34. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_tools.py +0 -0
  35. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_utils.py +0 -0
  36. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/__main__.py +0 -0
  37. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/chat_messages.py +0 -0
  38. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/claude_agent.py +0 -0
  39. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/deepseek_agent.py +0 -0
  40. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/google_agent.py +0 -0
  41. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/groq_agent.py +0 -0
  42. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/native_agent.py +0 -0
  43. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/native_print.py +0 -0
  44. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/native_rpc.py +0 -0
  45. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/ollama_agent.py +0 -0
  46. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/openai_agent.py +0 -0
  47. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/openrouter_agent.py +0 -0
  48. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/config.py +0 -0
  49. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/detector.py +0 -0
  50. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/handoff.py +0 -0
  51. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/limits.py +0 -0
  52. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/session.py +0 -0
  53. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/tools/__init__.py +0 -0
  54. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/tools/code_runner.py +0 -0
  55. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/tools/file_editor.py +0 -0
  56. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/tools/git_ops.py +0 -0
  57. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/tools/terminal.py +0 -0
  58. {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: thwip-cli
3
- Version: 1.4.0
3
+ Version: 1.5.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
@@ -54,7 +54,8 @@ Description-Content-Type: text/markdown
54
54
  - **Rate Limit Failover**: Detects HTTP 429 errors or quota exhaustion and prompts instant switching to ready fallback models.
55
55
  - **Tool Layer**: Shared file editing, code execution (Python, Node, Shell), and Git integration across connected models.
56
56
  - **Session Persistence**: Save and resume sessions across projects with `/session save` and `/session load`.
57
- - **Terminal Ergonomics**: Autocompletion (Tab), keyboard shortcuts (Ctrl+S, Ctrl+T, Ctrl+H), and streaming markdown rendering.
57
+ - **Familiar Flow**: The same everyday commands as Codex, Claude Code, and Antigravity (`/model`, `/new`, `/resume`, `/compact`, `/diff`, `/copy`, `!cmd`, `@file`), plus autocompletion, Ctrl+S / Ctrl+T shortcuts, and streaming Markdown.
58
+ - **Live Model Lists**: Model catalogs come from the connected CLI or the provider's list-models endpoint, never from a hardcoded list.
58
59
 
59
60
  ---
60
61
 
@@ -99,8 +100,17 @@ thwip --version
99
100
  | `/models [agent]` | List available models for current or target agent |
100
101
  | `/key [provider]` | Enter an API key securely without placing it in prompt history |
101
102
  | `/status` | Display current session, project, and token stats |
102
- | `/limits` | View token usage, quota, and spend metrics |
103
+ | `/limits`, `/usage` | View token usage, CLI account usage windows, and spend metrics |
103
104
  | `/detect` | Re-scan installed agents and reconnect CLI sign-ins |
105
+ | `/model [id]` | Pick a model for the current agent from a numbered list or by ID |
106
+ | `/new` | Start a fresh conversation; the current one is saved first |
107
+ | `/resume [name]` | Resume a saved session from a numbered list |
108
+ | `/compact` | Summarize the conversation with the current model to free context; the summary is plain text and travels across providers |
109
+ | `/diff [staged]` | Show the project's git diff |
110
+ | `/copy` | Copy the last response to the clipboard |
111
+ | `/export [path]` | Write the conversation to Markdown with per-message model attribution |
112
+ | `!<command>` | Run a shell command in the project without involving a model |
113
+ | `@path` in a message | Attach a project file's content to the message (files outside the project are ignored) |
104
114
  | `/session save [name]` | Save current chat session |
105
115
  | `/session load <name>` | Load a previously saved session |
106
116
  | `/session list` | List all saved sessions |
@@ -154,6 +164,28 @@ replaces Thwip with the Codex CLI itself in the selected project. Conversation
154
164
  history is not transferred by the launcher; use `/session load` after restarting
155
165
  Thwip to resume.
156
166
 
167
+ ## Live model lists
168
+
169
+ Model lists are not hardcoded. Each connected source supplies its own list:
170
+
171
+ - Installed CLIs report their models (Codex `model/list`, `agy models`, Claude Code aliases).
172
+ - Direct providers with a key are queried through their list-models endpoint (OpenAI,
173
+ Anthropic, Google, DeepSeek, Groq, OpenRouter) at startup, on `/models`, and right
174
+ after `/key`. Non-chat models (embeddings, speech, image, moderation) are filtered
175
+ out. Context sizes and prices are taken from the provider when it publishes them.
176
+ - A small bundled list per provider remains only as an offline fallback and is labelled
177
+ "bundled fallback" in `/models` until a key or connection is available.
178
+
179
+ ## Usage-limit failover
180
+
181
+ When the active provider reports an exhausted limit (Codex "You've hit your usage
182
+ limit", Claude Code "usage limit reached" or a rejected rate-limit event, Google
183
+ `RESOURCE_EXHAUSTED`, or HTTP 429 from a direct API), Thwip stops, shows the ready
184
+ alternatives with their default models, and offers to switch. Accepting switches the
185
+ provider, keeps the text conversation, and re-sends the unanswered message. Set
186
+ `[limits] auto_switch = true` to skip the question. The `[fallback] chain` order is
187
+ honored; a chain model the provider does not list falls back to that provider's default.
188
+
157
189
  ## Auditable handoffs
158
190
 
159
191
  ```text
@@ -17,7 +17,8 @@
17
17
  - **Rate Limit Failover**: Detects HTTP 429 errors or quota exhaustion and prompts instant switching to ready fallback models.
18
18
  - **Tool Layer**: Shared file editing, code execution (Python, Node, Shell), and Git integration across connected models.
19
19
  - **Session Persistence**: Save and resume sessions across projects with `/session save` and `/session load`.
20
- - **Terminal Ergonomics**: Autocompletion (Tab), keyboard shortcuts (Ctrl+S, Ctrl+T, Ctrl+H), and streaming markdown rendering.
20
+ - **Familiar Flow**: The same everyday commands as Codex, Claude Code, and Antigravity (`/model`, `/new`, `/resume`, `/compact`, `/diff`, `/copy`, `!cmd`, `@file`), plus autocompletion, Ctrl+S / Ctrl+T shortcuts, and streaming Markdown.
21
+ - **Live Model Lists**: Model catalogs come from the connected CLI or the provider's list-models endpoint, never from a hardcoded list.
21
22
 
22
23
  ---
23
24
 
@@ -62,8 +63,17 @@ thwip --version
62
63
  | `/models [agent]` | List available models for current or target agent |
63
64
  | `/key [provider]` | Enter an API key securely without placing it in prompt history |
64
65
  | `/status` | Display current session, project, and token stats |
65
- | `/limits` | View token usage, quota, and spend metrics |
66
+ | `/limits`, `/usage` | View token usage, CLI account usage windows, and spend metrics |
66
67
  | `/detect` | Re-scan installed agents and reconnect CLI sign-ins |
68
+ | `/model [id]` | Pick a model for the current agent from a numbered list or by ID |
69
+ | `/new` | Start a fresh conversation; the current one is saved first |
70
+ | `/resume [name]` | Resume a saved session from a numbered list |
71
+ | `/compact` | Summarize the conversation with the current model to free context; the summary is plain text and travels across providers |
72
+ | `/diff [staged]` | Show the project's git diff |
73
+ | `/copy` | Copy the last response to the clipboard |
74
+ | `/export [path]` | Write the conversation to Markdown with per-message model attribution |
75
+ | `!<command>` | Run a shell command in the project without involving a model |
76
+ | `@path` in a message | Attach a project file's content to the message (files outside the project are ignored) |
67
77
  | `/session save [name]` | Save current chat session |
68
78
  | `/session load <name>` | Load a previously saved session |
69
79
  | `/session list` | List all saved sessions |
@@ -117,6 +127,28 @@ replaces Thwip with the Codex CLI itself in the selected project. Conversation
117
127
  history is not transferred by the launcher; use `/session load` after restarting
118
128
  Thwip to resume.
119
129
 
130
+ ## Live model lists
131
+
132
+ Model lists are not hardcoded. Each connected source supplies its own list:
133
+
134
+ - Installed CLIs report their models (Codex `model/list`, `agy models`, Claude Code aliases).
135
+ - Direct providers with a key are queried through their list-models endpoint (OpenAI,
136
+ Anthropic, Google, DeepSeek, Groq, OpenRouter) at startup, on `/models`, and right
137
+ after `/key`. Non-chat models (embeddings, speech, image, moderation) are filtered
138
+ out. Context sizes and prices are taken from the provider when it publishes them.
139
+ - A small bundled list per provider remains only as an offline fallback and is labelled
140
+ "bundled fallback" in `/models` until a key or connection is available.
141
+
142
+ ## Usage-limit failover
143
+
144
+ When the active provider reports an exhausted limit (Codex "You've hit your usage
145
+ limit", Claude Code "usage limit reached" or a rejected rate-limit event, Google
146
+ `RESOURCE_EXHAUSTED`, or HTTP 429 from a direct API), Thwip stops, shows the ready
147
+ alternatives with their default models, and offers to switch. Accepting switches the
148
+ provider, keeps the text conversation, and re-sends the unanswered message. Set
149
+ `[limits] auto_switch = true` to skip the question. The `[fallback] chain` order is
150
+ honored; a chain model the provider does not list falls back to that provider's default.
151
+
120
152
  ## Auditable handoffs
121
153
 
122
154
  ```text
@@ -1,7 +1,7 @@
1
1
  # Verification status
2
2
 
3
- Verified locally on 2026-09-24 for the unreleased v1.4.0 source. The Python suite
4
- passes 216 offline tests. Exhaustive behavior across every provider and
3
+ Verified locally on 2026-09-24 for v1.5.0. The Python suite
4
+ passes 240 offline tests. Exhaustive behavior across every provider and
5
5
  configuration has not been established.
6
6
 
7
7
  ## Native CLI connections (2026-09-24)
@@ -17,6 +17,10 @@ each using its existing sign-in. No API keys were configured.
17
17
  | Context across `/switch` | Codex and Antigravity both recalled the answer given by the previous provider |
18
18
  | Codex approval request | A write command outside the read-only sandbox produced a permission prompt; denial left the workspace unchanged |
19
19
  | Ctrl+C during a response | Turn cancelled, child process terminated, REPL continued, unanswered message removed |
20
+ | Ctrl+C at a Codex permission prompt | Turn cancelled, no file created, no leftover process, REPL continued |
21
+ | Usage-limit failover | With a test-only shim making Codex report "You've hit your usage limit", the real REPL showed the alternatives, switched to Claude Code on `1`, retried the message, and answered; history held one clean pair |
22
+ | Parity commands | Live REPL run: `!git log`, `/model` picker, `@file` mention answered by Codex, `/compact` summary, `/export`, `/copy`, `/diff`, `/new`, `/resume`, `/usage` |
23
+ | Live catalogs | OpenRouter public list fetched live (459 models with context and pricing); other providers covered by recorded-payload tests because no keys are present here |
20
24
  | `/session save` and `/session load` | Session with a native provider saved and reloaded in a fresh run |
21
25
  | `/models`, `/models <provider>`, `/models <tier>` | Live catalogs listed with `CLI account` in place of API pricing |
22
26
  | `thwip --version`, `--help`, `--project` | Handled without starting the REPL; invalid project exits with code 2 |
@@ -30,7 +34,7 @@ error. The protocol enum is `read-only`. A regression test now checks the reques
30
34
  Remaining limitations: the Antigravity CLI showed intermittent network resets to
31
35
  Google's backend during testing, which surface as turn errors; Claude Code's model
32
36
  aliases are a curated list because the CLI exposes no model listing; native usage
33
- limit failover is unit-tested from error text, not observed live; the real Gemini
37
+ limit failover was observed live only through an injected limit, not a real provider cap; the real Gemini
34
38
  CLI ACP path is covered by mocked tests only because it is not installed here.
35
39
 
36
40
  | Area | Evidence | Remaining limitation |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thwip-cli"
7
- version = "1.4.0"
7
+ version = "1.5.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"
@@ -6,7 +6,7 @@ import pytest
6
6
  from rich.console import Console
7
7
 
8
8
  from thwip.agents import AgentRegistry
9
- from thwip.agents.base import AgentDone, Capability, LimitHit, LimitStatus, TextDelta
9
+ from thwip.agents.base import AgentDone, Capability, LimitHit, LimitStatus, ModelInfo, TextDelta
10
10
  from thwip.cli import ThwipCLI
11
11
  from thwip.config import ThwipConfig, get_usage_path
12
12
  from thwip.limits import UsageTracker
@@ -203,3 +203,62 @@ async def test_failed_turn_removes_unanswered_user_message(cli, monkeypatch):
203
203
  cli.session.current_agent = 'openai'
204
204
  await cli.process_user_message('hello')
205
205
  assert cli.session.messages == []
206
+
207
+
208
+ @pytest.mark.asyncio
209
+ async def test_ctrl_c_at_native_permission_prompt_cancels_turn_and_closes_adapter(cli, monkeypatch):
210
+ from thwip.agents.base import NativePermission
211
+ from thwip.cli import TurnInterrupted
212
+
213
+ closed = []
214
+
215
+ class NativeAsking:
216
+ name = 'openai'
217
+ display_name = 'Native'
218
+ company = 'OpenAI'
219
+ native_tools = True
220
+ project = '.'
221
+ def is_configured(self):
222
+ return True
223
+ def is_installed(self):
224
+ return True
225
+ def get_capabilities_for_model(self, model):
226
+ return set()
227
+ async def chat(self, **kwargs):
228
+ try:
229
+ yield NativePermission('run something')
230
+ yield TextDelta(content='never reached')
231
+ finally:
232
+ closed.append(True)
233
+
234
+ async def interrupted(self, question):
235
+ raise TurnInterrupted
236
+
237
+ monkeypatch.setattr(ThwipCLI, '_ask_yes_no', interrupted)
238
+ cli.current_agent = NativeAsking()
239
+ cli.session.current_agent = 'openai'
240
+ await cli._run_interruptible(cli.process_user_message('hello'))
241
+ assert cli.session.messages == []
242
+ assert closed == [True]
243
+
244
+
245
+ @pytest.mark.asyncio
246
+ async def test_failover_chain_model_must_be_listed_by_provider(cli, monkeypatch):
247
+ """A chain entry like claude/claude-opus-5 must not force an unlisted model onto a native adapter."""
248
+ from thwip.agents.native_print import PrintAgent
249
+
250
+ native = PrintAgent('claude', '.')
251
+ native.ready = True
252
+ native.available_models = [ModelInfo(id='fable', name='Fable', is_default=True)]
253
+ monkeypatch.setattr(native, 'is_installed', lambda: True)
254
+ cli.registry._agents['claude'] = native
255
+ cli.config.fallback.chain = ['claude/claude-opus-5']
256
+ switched = []
257
+
258
+ async def fake_switch(name, model=''):
259
+ switched.append((name, model))
260
+ monkeypatch.setattr(cli, 'cmd_switch', fake_switch)
261
+ monkeypatch.setattr('builtins.input', lambda *args: '1')
262
+ cli.current_agent = cli.registry.get_agent('openai')
263
+ await cli.handle_limit_failover(LimitHit(error_type=LimitStatus.QUOTA_EXHAUSTED, message='usage limit'), {'openai'})
264
+ assert switched == [('claude', 'fable')]
@@ -0,0 +1,131 @@
1
+ """Live model catalogs, exercised against recorded provider payload shapes only."""
2
+
3
+
4
+ import httpx
5
+ import pytest
6
+
7
+ from thwip.agents import AgentRegistry
8
+ from thwip.agents.base import ModelInfo
9
+ from thwip.agents.catalog import fetch_live_models, merge_catalog, parse_models, supports_live_catalog, tier_for
10
+ from thwip.agents.openai_agent import OpenAIAgent
11
+ from thwip.config import ThwipConfig
12
+
13
+ PAYLOADS = {
14
+ "openai": {"data": [{"id": "gpt-5.6-sol"}, {"id": "gpt-5.7-astra"}, {"id": "text-embedding-3-large"},
15
+ {"id": "whisper-1"}, {"id": "gpt-4o-realtime-preview"}, {"id": "o4-mini"}]},
16
+ "claude": {"data": [{"id": "claude-fable-5-1", "display_name": "Claude Fable 5.1"}, {"id": "claude-haiku-4-5", "display_name": "Claude Haiku 4.5"}]},
17
+ "google": {"models": [
18
+ {"name": "models/gemini-3.7-flash", "displayName": "Gemini 3.7 Flash", "supportedGenerationMethods": ["generateContent"],
19
+ "inputTokenLimit": 1048576, "outputTokenLimit": 65536},
20
+ {"name": "models/text-embedding-004", "supportedGenerationMethods": ["embedContent"]},
21
+ {"name": "models/imagen-4", "supportedGenerationMethods": ["generateContent"]}]},
22
+ "deepseek": {"data": [{"id": "deepseek-chat"}, {"id": "deepseek-reasoner"}]},
23
+ "groq": {"data": [{"id": "openai/gpt-oss-120b", "context_window": 131072}, {"id": "whisper-large-v3"}, {"id": "llama-guard-4"}]},
24
+ "openrouter": {"data": [{"id": "anthropic/claude-opus-5", "name": "Anthropic: Claude Opus 5", "context_length": 200000,
25
+ "pricing": {"prompt": "0.000015", "completion": "0.000075"}, "top_provider": {"max_completion_tokens": 32000}}]},
26
+ }
27
+
28
+
29
+ @pytest.mark.parametrize("provider,expected", [
30
+ ("openai", ["gpt-5.6-sol", "gpt-5.7-astra", "o4-mini"]),
31
+ ("claude", ["claude-fable-5-1", "claude-haiku-4-5"]),
32
+ ("google", ["gemini-3.7-flash"]),
33
+ ("deepseek", ["deepseek-chat", "deepseek-reasoner"]),
34
+ ("groq", ["openai/gpt-oss-120b"]),
35
+ ("openrouter", ["anthropic/claude-opus-5"]),
36
+ ])
37
+ def test_parse_keeps_only_chat_models(provider, expected):
38
+ assert [m.id for m in parse_models(provider, PAYLOADS[provider])] == expected
39
+
40
+
41
+ def test_parse_carries_context_and_pricing():
42
+ google = parse_models("google", PAYLOADS["google"])[0]
43
+ assert (google.context_window, google.max_output, google.name) == (1048576, 65536, "Gemini 3.7 Flash")
44
+ router = parse_models("openrouter", PAYLOADS["openrouter"])[0]
45
+ assert (router.pricing_input, router.pricing_output, router.max_output) == (15.0, 75.0, 32000)
46
+ assert tier_for("gemini-3.1-pro-high") == "flagship" and tier_for("gpt-5-mini") == "fast"
47
+
48
+
49
+ @pytest.mark.asyncio
50
+ async def test_fetch_sends_provider_auth_and_rejects_bad_keys():
51
+ seen = {}
52
+
53
+ def handler(request):
54
+ seen["url"] = str(request.url)
55
+ seen["headers"] = dict(request.headers)
56
+ if "anthropic" in seen["url"]:
57
+ return httpx.Response(401, json={"error": "bad key"})
58
+ return httpx.Response(200, json=PAYLOADS["openai"])
59
+
60
+ async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
61
+ models = await fetch_live_models("openai", "sk-test", client)
62
+ assert [m.id for m in models][:2] == ["gpt-5.6-sol", "gpt-5.7-astra"]
63
+ assert seen["headers"]["authorization"] == "Bearer sk-test"
64
+ with pytest.raises(PermissionError, match="rejected"):
65
+ await fetch_live_models("claude", "bad", client)
66
+ assert seen["headers"]["x-api-key"] == "bad" and "anthropic-version" in seen["headers"]
67
+ assert supports_live_catalog("openai") and not supports_live_catalog("ollama")
68
+
69
+
70
+ def test_merge_uses_live_list_as_truth_and_keeps_known_metadata():
71
+ bundled = [ModelInfo(id="gpt-5.6-sol", name="GPT-5.6 Sol", tier="balanced", pricing_input=1.25, pricing_output=10.0, is_default=True),
72
+ ModelInfo(id="gpt-retired", name="Retired")]
73
+ live = [ModelInfo(id="gpt-5.7-astra", name="gpt-5.7-astra"), ModelInfo(id="gpt-5.6-sol", name="gpt-5.6-sol", context_window=400000)]
74
+ merged = merge_catalog(bundled, live)
75
+ assert [m.id for m in merged] == ["gpt-5.6-sol", "gpt-5.7-astra"]
76
+ sol = merged[0]
77
+ assert sol.is_default and sol.pricing_input == 1.25 and sol.context_window == 400000 and sol.name == "GPT-5.6 Sol"
78
+ assert not merged[1].is_default
79
+ # When the bundled default disappears, the first listed model becomes the default.
80
+ assert merge_catalog(bundled, [ModelInfo(id="gpt-new", name="new")])[0].is_default
81
+
82
+
83
+ @pytest.mark.asyncio
84
+ async def test_agent_refresh_replaces_bundled_catalog_when_key_is_set(monkeypatch):
85
+ async def fake_fetch(provider, key, client=None):
86
+ assert (provider, key) == ("openai", "sk-live")
87
+ return parse_models("openai", PAYLOADS["openai"])
88
+
89
+ monkeypatch.setattr("thwip.agents.catalog.fetch_live_models", fake_fetch)
90
+ agent = OpenAIAgent(api_key="sk-live")
91
+ assert agent.catalog_source == "bundled"
92
+ await agent.refresh_models()
93
+ assert agent.catalog_source == "live" and agent.get_model_info("gpt-5.7-astra") is not None
94
+ assert agent.get_model_info("gpt-5.6-luna") is None, "models the provider no longer lists are dropped"
95
+ assert OpenAIAgent.available_models[0].id == "gpt-5.6-sol", "class-level bundled fallback is untouched"
96
+
97
+ unkeyed = OpenAIAgent(api_key=None)
98
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
99
+ await unkeyed.refresh_models()
100
+ assert unkeyed.catalog_source == "bundled"
101
+
102
+
103
+ @pytest.mark.asyncio
104
+ async def test_agent_refresh_failure_keeps_fallback_with_message(monkeypatch):
105
+ async def failing(provider, key, client=None):
106
+ raise httpx.ConnectError("boom sk-" + "x" * 40)
107
+
108
+ monkeypatch.setattr("thwip.agents.catalog.fetch_live_models", failing)
109
+ agent = OpenAIAgent(api_key="sk-live")
110
+ await agent.refresh_models()
111
+ assert agent.catalog_source == "bundled" and "bundled fallback" in agent.discovery_error
112
+ assert "xxxx" not in agent.discovery_error and agent.available_models
113
+
114
+
115
+ @pytest.mark.asyncio
116
+ async def test_registry_refreshes_keyed_direct_adapters_at_connect(monkeypatch, tmp_path):
117
+ config = ThwipConfig(project=str(tmp_path))
118
+ config.keys = {"deepseek": "sk-deep"}
119
+ registry = AgentRegistry(config)
120
+ monkeypatch.setattr("thwip.agents.native_agent.shutil.which", lambda name: None)
121
+ monkeypatch.setattr("thwip.agents.native_print.shutil.which", lambda name: None)
122
+ calls = []
123
+
124
+ async def fake_fetch(provider, key, client=None):
125
+ calls.append(provider)
126
+ return parse_models(provider, PAYLOADS[provider])
127
+
128
+ monkeypatch.setattr("thwip.agents.catalog.fetch_live_models", fake_fetch)
129
+ await registry.connect_native_agents(str(tmp_path))
130
+ assert calls == ["deepseek"]
131
+ assert [m.id for m in registry.get_agent("deepseek").available_models] == ["deepseek-chat", "deepseek-reasoner"]
@@ -0,0 +1,173 @@
1
+ """Commands that mirror Codex, Claude Code, and Antigravity REPL flows, tested offline."""
2
+
3
+ import subprocess
4
+
5
+ import pytest
6
+
7
+ from thwip.agents import AgentRegistry
8
+ from thwip.agents.base import AgentDone, LimitHit, LimitStatus, ModelInfo, TextDelta
9
+ from thwip.cli import ThwipCLI
10
+ from thwip.config import ThwipConfig
11
+ from thwip.limits import UsageTracker
12
+ from thwip.session import Session
13
+ from thwip.tools import ToolManager
14
+
15
+
16
+ class FakeAgent:
17
+ name = "openai"
18
+ display_name = "Fake"
19
+ company = "OpenAI"
20
+ native_tools = False
21
+ capabilities = set()
22
+
23
+ def __init__(self, reply="- Context: built a CLI\n- Decisions: use pty\n- Open tasks: release"):
24
+ self.available_models = [ModelInfo(id="alpha", name="Alpha", is_default=True), ModelInfo(id="beta", name="Beta", tier="fast")]
25
+ self.reply = reply
26
+ self.requests = []
27
+
28
+ def is_configured(self):
29
+ return True
30
+
31
+ def is_installed(self):
32
+ return True
33
+
34
+ def get_model_info(self, model_id):
35
+ return next((m for m in self.available_models if m.id == model_id), None)
36
+
37
+ def get_default_model(self):
38
+ return "alpha"
39
+
40
+ def get_capabilities_for_model(self, model):
41
+ return set()
42
+
43
+ async def chat(self, **kwargs):
44
+ self.requests.append(kwargs)
45
+ if self.reply == "LIMIT":
46
+ yield LimitHit(error_type=LimitStatus.QUOTA_EXHAUSTED, message="usage limit")
47
+ return
48
+ yield TextDelta(content=self.reply)
49
+ yield AgentDone()
50
+
51
+
52
+ @pytest.fixture
53
+ def cli(tmp_path, monkeypatch):
54
+ monkeypatch.setenv("THWIP_CONFIG_DIR", str(tmp_path / "config"))
55
+ cli = ThwipCLI.__new__(ThwipCLI)
56
+ cli.config = ThwipConfig(project=str(tmp_path), auto_save=True)
57
+ cli.registry = AgentRegistry(cli.config)
58
+ cli.usage_tracker = UsageTracker()
59
+ cli.tool_manager = ToolManager(str(tmp_path))
60
+ cli.session = Session(project_path=str(tmp_path), current_agent="openai", current_model="alpha")
61
+ cli.current_agent = FakeAgent()
62
+ return cli
63
+
64
+
65
+ def answers(monkeypatch, cli, *values):
66
+ queue = list(values)
67
+
68
+ async def ask(question):
69
+ return queue.pop(0)
70
+ monkeypatch.setattr(cli, "_ask_text", ask)
71
+
72
+
73
+ @pytest.mark.asyncio
74
+ async def test_model_picker_by_number_id_and_unknown(cli, monkeypatch):
75
+ answers(monkeypatch, cli, "2")
76
+ await cli.cmd_model()
77
+ assert cli.session.current_model == "beta"
78
+ await cli.cmd_model("alpha")
79
+ assert cli.session.current_model == "alpha"
80
+ await cli.cmd_model("nope")
81
+ assert cli.session.current_model == "alpha"
82
+ answers(monkeypatch, cli, "")
83
+ await cli.cmd_model()
84
+ assert cli.session.current_model == "alpha"
85
+
86
+
87
+ @pytest.mark.asyncio
88
+ async def test_new_saves_previous_and_resume_restores_it(cli, monkeypatch):
89
+ cli.session.add_user_message("hello")
90
+ cli.session.add_assistant_message("hi", agent_name="openai", model="alpha")
91
+ old_name = cli.session.name
92
+ cli.cmd_new()
93
+ assert cli.session.messages == [] and cli.session.name != old_name
94
+ assert Session.load(old_name) is not None
95
+ answers(monkeypatch, cli, "1")
96
+ await cli.cmd_resume()
97
+ assert cli.session.name == old_name and len(cli.session.messages) == 2
98
+ cli.cmd_new()
99
+ await cli.cmd_resume(old_name)
100
+ assert cli.session.name == old_name
101
+
102
+
103
+ @pytest.mark.asyncio
104
+ async def test_compact_replaces_history_with_portable_summary(cli):
105
+ cli.session.add_user_message("build a cli")
106
+ cli.session.add_assistant_message("done with pty", agent_name="openai", model="alpha")
107
+ cli.session.add_user_message("now release")
108
+ cli.session.add_assistant_message("ok", agent_name="openai", model="alpha")
109
+ await cli.cmd_compact()
110
+ assert len(cli.session.messages) == 2
111
+ assert "Decisions: use pty" in cli.session.messages[0].content and cli.session.messages[0].role == "user"
112
+ assert cli.session.messages[1].role == "assistant" and cli.session.messages[1].model == "alpha"
113
+ sent = cli.current_agent.requests[0]
114
+ assert "now release" in sent["messages"][0]["content"] and sent["tools"] is None
115
+
116
+
117
+ @pytest.mark.asyncio
118
+ async def test_compact_keeps_history_on_limit_or_empty(cli):
119
+ cli.session.add_user_message("a")
120
+ cli.session.add_assistant_message("b", agent_name="openai", model="alpha")
121
+ cli.current_agent = FakeAgent(reply="LIMIT")
122
+ await cli.cmd_compact()
123
+ assert len(cli.session.messages) == 2
124
+ cli.current_agent = FakeAgent(reply=" ")
125
+ await cli.cmd_compact()
126
+ assert len(cli.session.messages) == 2
127
+
128
+
129
+ def test_diff_uses_project_repo(cli, tmp_path):
130
+ subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
131
+ (tmp_path / "a.txt").write_text("one\n")
132
+ subprocess.run(["git", "add", "a.txt"], cwd=tmp_path, check=True)
133
+ cli.cmd_diff("") # clean working tree message
134
+ (tmp_path / "a.txt").write_text("two\n")
135
+ cli.cmd_diff("") # renders a diff
136
+ cli.cmd_diff("staged") # staged view
137
+
138
+
139
+ def test_copy_uses_available_clipboard_tool(cli, monkeypatch):
140
+ calls = []
141
+ monkeypatch.setattr("thwip.cli.shutil.which", lambda name: "/usr/bin/pbcopy" if name == "pbcopy" else None)
142
+ monkeypatch.setattr(subprocess, "run", lambda command, **kwargs: calls.append((command, kwargs["input"])))
143
+ cli.cmd_copy()
144
+ assert calls == []
145
+ cli.session.add_assistant_message("copy me", agent_name="openai", model="alpha")
146
+ cli.cmd_copy()
147
+ assert calls == [(["pbcopy"], b"copy me")]
148
+
149
+
150
+ def test_export_writes_markdown_with_attribution(cli, tmp_path):
151
+ cli.session.add_user_message("question")
152
+ cli.session.add_assistant_message("answer", agent_name="openai", model="alpha", company="OpenAI")
153
+ cli.cmd_export("notes/out.md")
154
+ text = (tmp_path / "notes" / "out.md").read_text()
155
+ assert "## You\n\nquestion" in text and "## Assistant (OpenAI / alpha)\n\nanswer" in text
156
+ cli.cmd_export("")
157
+ assert (tmp_path / f"{cli.session.name}.md").exists()
158
+
159
+
160
+ @pytest.mark.asyncio
161
+ async def test_shell_escape_runs_in_project(cli, tmp_path, capsys):
162
+ (tmp_path / "marker.txt").write_text("x")
163
+ await cli.cmd_shell("ls")
164
+ assert cli.session.messages == []
165
+ await cli.cmd_shell("")
166
+
167
+
168
+ def test_mentions_attach_project_files_only(cli, tmp_path):
169
+ (tmp_path / "notes.md").write_text("secret plan")
170
+ expanded = cli._expand_mentions("Review @notes.md and @missing.txt and @../etc/passwd please")
171
+ assert "secret plan" in expanded and "[Attached file: notes.md]" in expanded
172
+ assert "missing.txt]" not in expanded and "passwd]" not in expanded
173
+ assert cli._expand_mentions("email me at user@example.com") == "email me at user@example.com"
@@ -1,4 +1,4 @@
1
1
  """thwip: Universal Coding Agent Multiplexer."""
2
2
 
3
- __version__ = "1.4.0"
3
+ __version__ = "1.5.0"
4
4
  __app_name__ = "thwip"
@@ -99,11 +99,16 @@ class AgentRegistry:
99
99
  self._agents[name] = native
100
100
  native.project = project
101
101
  natives.append(native)
102
- results = await asyncio.gather(*(native.refresh_models() for native in natives), return_exceptions=True)
103
- for native, result in zip(natives, results):
102
+ # Direct adapters with a key fetch their live model list at the same time.
103
+ direct = [agent for agent in self._agents.values()
104
+ if not getattr(agent, "native_tools", False) and agent.name != "ollama" and agent.is_configured()]
105
+ refreshing = natives + direct
106
+ results = await asyncio.gather(*(agent.refresh_models() for agent in refreshing), return_exceptions=True)
107
+ for agent, result in zip(refreshing, results):
104
108
  if isinstance(result, BaseException):
105
- native.ready = False
106
- native.discovery_error = f"Native discovery failed: {type(result).__name__}"
109
+ if getattr(agent, "native_tools", False):
110
+ agent.ready = False
111
+ agent.discovery_error = f"Model discovery failed: {type(result).__name__}"
107
112
 
108
113
  @staticmethod
109
114
  def _native_candidate(name: str, project: str) -> BaseAgent | None:
@@ -316,6 +316,34 @@ class BaseAgent(ABC):
316
316
  """Return local catalog metadata without requesting provider discovery."""
317
317
  return list(self.available_models)
318
318
 
319
+ # --- Live catalog ---
320
+
321
+ catalog_source: str = "bundled"
322
+ discovery_error: str = ""
323
+
324
+ async def refresh_models(self) -> None:
325
+ """Replace the bundled catalog with the provider's live list when a key is configured.
326
+
327
+ The bundled list stays as an offline fallback and is labelled as such.
328
+ """
329
+ from thwip.agents.catalog import fetch_live_models, merge_catalog, supports_live_catalog
330
+ from thwip.agents.native_common import scrub
331
+
332
+ getter = getattr(self, "_get_api_key", None)
333
+ key = getter() if callable(getter) else None
334
+ if not key or not supports_live_catalog(self.name):
335
+ return
336
+ try:
337
+ live = await fetch_live_models(self.name, key)
338
+ except Exception as exc:
339
+ self.discovery_error = f"Live model list unavailable; showing the bundled fallback. {scrub(str(exc), 160)}"
340
+ return
341
+ if live:
342
+ bundled = type(self).available_models if isinstance(type(self).__dict__.get("available_models"), list) else self.available_models
343
+ self.available_models = merge_catalog(list(bundled), live)
344
+ self.catalog_source = "live"
345
+ self.discovery_error = ""
346
+
319
347
  def has_capability(self, cap: Capability) -> bool:
320
348
  """Check if this agent supports a capability."""
321
349
  return cap in self.capabilities