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.
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/PKG-INFO +35 -3
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/README.md +34 -2
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/docs/verification.md +7 -3
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/pyproject.toml +1 -1
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_audit_regressions.py +60 -1
- thwip_cli-1.5.0/tests/test_catalog.py +131 -0
- thwip_cli-1.5.0/tests/test_parity_commands.py +173 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/__init__.py +1 -1
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/__init__.py +9 -4
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/base.py +28 -0
- thwip_cli-1.5.0/thwip/agents/catalog.py +141 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/native_common.py +5 -3
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/cli.py +370 -82
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/shortcuts.py +7 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/theme.py +5 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/uv.lock +1 -1
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/.github/workflows/publish.yml +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/.gitignore +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/LICENSE +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/docs/handoff-research.md +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/install.sh +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_agents.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_cli.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_compatible_streaming.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_config.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_detector.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_handoff.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_native_agents.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_native_launcher.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_native_print.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_native_rpc.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_repair_verification.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_session.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_tools.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/tests/test_utils.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/__main__.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/chat_messages.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/claude_agent.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/deepseek_agent.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/google_agent.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/groq_agent.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/native_agent.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/native_print.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/native_rpc.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/ollama_agent.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/openai_agent.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/agents/openrouter_agent.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/config.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/detector.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/handoff.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/limits.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/session.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/tools/__init__.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/tools/code_runner.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/tools/file_editor.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/tools/git_ops.py +0 -0
- {thwip_cli-1.4.0 → thwip_cli-1.5.0}/thwip/tools/terminal.py +0 -0
- {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.
|
|
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
|
-
- **
|
|
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,
|
|
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
|
-
- **
|
|
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,
|
|
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
|
|
4
|
-
passes
|
|
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
|
|
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 |
|
|
@@ -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"
|
|
@@ -99,11 +99,16 @@ class AgentRegistry:
|
|
|
99
99
|
self._agents[name] = native
|
|
100
100
|
native.project = project
|
|
101
101
|
natives.append(native)
|
|
102
|
-
|
|
103
|
-
for
|
|
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
|
-
|
|
106
|
-
|
|
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
|