thwip-cli 1.6.0__tar.gz → 1.6.2__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.6.0 → thwip_cli-1.6.2}/PKG-INFO +1 -1
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/docs/verification.md +5 -2
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/pyproject.toml +1 -1
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/fake_providers.py +27 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_direct_providers_e2e.py +28 -1
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/__init__.py +1 -1
- thwip_cli-1.6.2/thwip/agents/ollama_agent.py +218 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/cli.py +35 -1
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/uv.lock +1 -1
- thwip_cli-1.6.0/thwip/agents/ollama_agent.py +0 -212
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/.github/workflows/publish.yml +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/.gitignore +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/LICENSE +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/README.md +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/docs/handoff-research.md +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/install.sh +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/__init__.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_agents.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_audit_regressions.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_catalog.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_cli.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_compatible_streaming.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_config.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_detector.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_handoff.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_native_agents.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_native_launcher.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_native_print.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_native_rpc.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_parity_commands.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_repair_verification.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_session.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_tools.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/tests/test_utils.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/__main__.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/__init__.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/base.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/catalog.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/chat_messages.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/claude_agent.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/deepseek_agent.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/google_agent.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/groq_agent.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/native_agent.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/native_common.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/native_print.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/native_rpc.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/openai_agent.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/agents/openrouter_agent.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/config.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/detector.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/endpoints.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/handoff.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/limits.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/session.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/shortcuts.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/theme.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/tools/__init__.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/tools/code_runner.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/tools/file_editor.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/tools/git_ops.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/tools/terminal.py +0 -0
- {thwip_cli-1.6.0 → thwip_cli-1.6.2}/thwip/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: thwip-cli
|
|
3
|
-
Version: 1.6.
|
|
3
|
+
Version: 1.6.2
|
|
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
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Verification status
|
|
2
2
|
|
|
3
|
-
Verified locally on 2026-09-24 for v1.6.
|
|
4
|
-
passes
|
|
3
|
+
Verified locally on 2026-09-24 for v1.6.2. The Python suite
|
|
4
|
+
passes 265 offline tests on Python 3.11, 3.12, and 3.13. Exhaustive behavior across every provider and
|
|
5
5
|
configuration has not been established.
|
|
6
6
|
|
|
7
7
|
## Native CLI connections (2026-09-24)
|
|
@@ -19,6 +19,9 @@ each using its existing sign-in. No API keys were configured.
|
|
|
19
19
|
| Ctrl+C during a response | Turn cancelled, child process terminated, REPL continued, unanswered message removed |
|
|
20
20
|
| Ctrl+C at a Codex permission prompt | Turn cancelled, no file created, no leftover process, REPL continued |
|
|
21
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
|
+
| Terminal hygiene (v1.6.2) | Cursor-position queries disabled, keyboard echo off during turns, stray input flushed before each prompt; fixes `^[`/`^R` noise and phantom empty prompts seen in a real Ghostty session during a slow Antigravity reply. Permission prompt and chat re-verified live afterwards |
|
|
23
|
+
| Ollama adapter (v1.6.1) | End to end against the fake Ollama routes: model list, streamed text with usage, tool call, HTTP 500 and unreachable server now raise clear errors instead of ending the turn silently |
|
|
24
|
+
| Dependency audits (v1.6.1) | pip-audit reports no known vulnerabilities; npm audit reports zero |
|
|
22
25
|
| Direct API adapters (v1.6.0) | All six (OpenAI, Anthropic, Google, DeepSeek, Groq, OpenRouter) run end to end through their real SDKs over HTTP against `tests/fake_providers.py`: live catalog, streamed text with usage, tool call and result round trip, HTTP 429 to LimitHit. The real REPL was also driven against the fake with direct keys: startup, live `/models`, chat, read_file tool round, provider switches, and 429 failover |
|
|
23
26
|
| Full command sweep (v1.5.1) | Every slash command with invalid arguments, native launcher decline, key picker cancel, Ctrl+T, Ctrl+C inside pickers and confirmations, Backspace editing; found and fixed Backspace triggering `/history` via the Ctrl+H binding |
|
|
24
27
|
| Parity commands | Live REPL run: `!git log`, `/model` picker, `@file` mention answered by Codex, `/compact` summary, `/export`, `/copy`, `/diff`, `/new`, `/resume`, `/usage` |
|
|
@@ -17,6 +17,7 @@ from urllib.parse import urlparse
|
|
|
17
17
|
REPLY = "fake reply from local provider"
|
|
18
18
|
CHAT_MODELS = ["gpt-fake-chat", "gpt-fake-limited"]
|
|
19
19
|
GEMINI_MODELS = ["gemini-fake-chat", "gemini-fake-limited"]
|
|
20
|
+
OLLAMA_MODELS = ["fake-local:latest", "fake-broken:latest"]
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
def _wants_tool(body: dict) -> bool:
|
|
@@ -76,6 +77,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
76
77
|
"inputTokenLimit": 32000, "outputTokenLimit": 8000} for m in GEMINI_MODELS]})
|
|
77
78
|
if path.endswith("/models"):
|
|
78
79
|
return self._json(200, {"data": [{"id": m, "display_name": m, "context_window": 32000} for m in CHAT_MODELS]})
|
|
80
|
+
if path.endswith("/api/tags"):
|
|
81
|
+
return self._json(200, {"models": [{"name": m, "model": m, "size": 1, "details": {"parameter_size": "1B"}} for m in OLLAMA_MODELS]})
|
|
79
82
|
return self._json(404, {"error": "not found"})
|
|
80
83
|
|
|
81
84
|
def do_POST(self):
|
|
@@ -86,6 +89,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
86
89
|
if _is_limited(body, path):
|
|
87
90
|
return self._json(429, {"error": {"message": "Rate limit reached for model; quota exhausted", "type": "rate_limit_error"}},
|
|
88
91
|
{"retry-after": "0"})
|
|
92
|
+
if path.endswith("/api/chat"):
|
|
93
|
+
return self._ollama(body)
|
|
89
94
|
if path.endswith("/chat/completions"):
|
|
90
95
|
return self._chat_completions(body)
|
|
91
96
|
if path.endswith("/responses"):
|
|
@@ -130,6 +135,28 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
130
135
|
"output": output, "usage": {"input_tokens": 11, "output_tokens": 5, "total_tokens": 16},
|
|
131
136
|
"parallel_tool_calls": True, "tool_choice": "auto", "tools": []})
|
|
132
137
|
|
|
138
|
+
# --- Ollama ---
|
|
139
|
+
def _ollama(self, body):
|
|
140
|
+
if "broken" in str(body.get("model", "")):
|
|
141
|
+
return self._json(500, {"error": "model runner crashed"})
|
|
142
|
+
if _wants_tool(body):
|
|
143
|
+
message = {"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "read_file", "arguments": {"file_path": "note.txt"}}}]}
|
|
144
|
+
else:
|
|
145
|
+
message = {"role": "assistant", "content": REPLY}
|
|
146
|
+
final = {"model": body["model"], "message": message, "done": True, "prompt_eval_count": 11, "eval_count": 5}
|
|
147
|
+
if body.get("stream", True):
|
|
148
|
+
self.send_response(200)
|
|
149
|
+
self.send_header("Content-Type", "application/x-ndjson")
|
|
150
|
+
self.end_headers()
|
|
151
|
+
if not _wants_tool(body):
|
|
152
|
+
for piece in ["fake reply ", "from local provider"]:
|
|
153
|
+
self.wfile.write((json.dumps({"model": body["model"], "message": {"role": "assistant", "content": piece}, "done": False}) + "\n").encode())
|
|
154
|
+
final["message"] = {"role": "assistant", "content": ""}
|
|
155
|
+
self.wfile.write((json.dumps(final) + "\n").encode())
|
|
156
|
+
self.wfile.flush()
|
|
157
|
+
return None
|
|
158
|
+
return self._json(200, final)
|
|
159
|
+
|
|
133
160
|
# --- Anthropic ---
|
|
134
161
|
def _anthropic(self, body):
|
|
135
162
|
usage = {"input_tokens": 11, "output_tokens": 5}
|
|
@@ -97,7 +97,7 @@ async def test_http_429_becomes_limit_hit(agent):
|
|
|
97
97
|
|
|
98
98
|
|
|
99
99
|
def test_default_urls_are_used_without_override(monkeypatch):
|
|
100
|
-
for
|
|
100
|
+
for env in ENV.values():
|
|
101
101
|
monkeypatch.delenv(env, raising=False)
|
|
102
102
|
from thwip import endpoints
|
|
103
103
|
endpoints.configure({})
|
|
@@ -105,3 +105,30 @@ def test_default_urls_are_used_without_override(monkeypatch):
|
|
|
105
105
|
endpoints.configure({"openai": "https://proxy.example/v1/"})
|
|
106
106
|
assert base_url("openai") == "https://proxy.example/v1" and is_overridden("openai")
|
|
107
107
|
endpoints.configure({})
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.fixture
|
|
111
|
+
def ollama(fake):
|
|
112
|
+
from thwip.agents.ollama_agent import OllamaAgent
|
|
113
|
+
return OllamaAgent(host=fake.url)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_ollama_lists_models_from_server(ollama):
|
|
117
|
+
assert ollama.is_configured()
|
|
118
|
+
assert [m.id for m in ollama.available_models] == ["fake-local:latest", "fake-broken:latest"]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@pytest.mark.asyncio
|
|
122
|
+
async def test_ollama_stream_tool_round_and_errors(ollama):
|
|
123
|
+
events = await run(ollama, messages=[{"role": "user", "content": "hello"}], model="fake-local:latest", stream=True)
|
|
124
|
+
assert "".join(e.content for e in events if isinstance(e, TextDelta)) == REPLY
|
|
125
|
+
assert isinstance(events[-1], AgentDone) and events[-1].usage.input_tokens == 11
|
|
126
|
+
events = await run(ollama, messages=[{"role": "user", "content": "Please read the file note.txt"}], model="fake-local:latest",
|
|
127
|
+
tools=TOOLS, stream=False)
|
|
128
|
+
calls = [e for e in events if isinstance(e, ToolUseStart)]
|
|
129
|
+
assert calls and calls[0].tool_name == "read_file" and calls[0].args == {"file_path": "note.txt"}
|
|
130
|
+
with pytest.raises(RuntimeError, match="HTTP 500"):
|
|
131
|
+
await run(ollama, messages=[{"role": "user", "content": "hello"}], model="fake-broken:latest", stream=True)
|
|
132
|
+
from thwip.agents.ollama_agent import OllamaAgent
|
|
133
|
+
with pytest.raises(RuntimeError, match="unreachable"):
|
|
134
|
+
await run(OllamaAgent(host="http://127.0.0.1:9"), messages=[{"role": "user", "content": "hello"}], model="x", stream=True)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ollama agent adapter.
|
|
3
|
+
|
|
4
|
+
Local offline models with unlimited quota, zero cost, and full privacy.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import shutil
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
from collections.abc import AsyncIterator
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from thwip.agents.base import (
|
|
19
|
+
AgentDone,
|
|
20
|
+
AgentEvent,
|
|
21
|
+
BaseAgent,
|
|
22
|
+
Capability,
|
|
23
|
+
LimitStatus,
|
|
24
|
+
ModelInfo,
|
|
25
|
+
SubscriptionInfo,
|
|
26
|
+
SubscriptionTier,
|
|
27
|
+
TextDelta,
|
|
28
|
+
TokenUsage,
|
|
29
|
+
ToolUseStart,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OllamaAgent(BaseAgent):
|
|
34
|
+
"""
|
|
35
|
+
Ollama Local Agent.
|
|
36
|
+
|
|
37
|
+
Runs entirely on-device with zero rate limits and unlimited usage.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
name = "ollama"
|
|
41
|
+
display_name = "Ollama (Local)"
|
|
42
|
+
company = "Ollama"
|
|
43
|
+
description = "Local models running entirely on-device (zero API limits, private)"
|
|
44
|
+
website = "https://ollama.com"
|
|
45
|
+
|
|
46
|
+
capabilities = {
|
|
47
|
+
Capability.CHAT,
|
|
48
|
+
Capability.FILE_EDIT,
|
|
49
|
+
Capability.FILE_READ,
|
|
50
|
+
Capability.CODE_RUN,
|
|
51
|
+
Capability.TERMINAL,
|
|
52
|
+
Capability.GIT,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
def __init__(self, host: str = "http://localhost:11434") -> None:
|
|
56
|
+
self.host = host.rstrip("/")
|
|
57
|
+
self._cached_models: list[ModelInfo] | None = None
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def available_models(self) -> list[ModelInfo]:
|
|
61
|
+
if self._cached_models is not None:
|
|
62
|
+
return self._cached_models
|
|
63
|
+
|
|
64
|
+
# Try to query running Ollama server for downloaded models
|
|
65
|
+
models = []
|
|
66
|
+
try:
|
|
67
|
+
req = urllib.request.Request(f"{self.host}/api/tags")
|
|
68
|
+
with urllib.request.urlopen(req, timeout=1.5) as resp:
|
|
69
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
70
|
+
for m in data.get("models", []):
|
|
71
|
+
name = m.get("name", "")
|
|
72
|
+
models.append(
|
|
73
|
+
ModelInfo(
|
|
74
|
+
id=name,
|
|
75
|
+
name=f"{name} (Local)",
|
|
76
|
+
context_window=32_768,
|
|
77
|
+
max_output=8_192,
|
|
78
|
+
supports_tools=True,
|
|
79
|
+
supports_streaming=True,
|
|
80
|
+
pricing_input=0.0,
|
|
81
|
+
pricing_output=0.0,
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
if not models:
|
|
88
|
+
# Defaults
|
|
89
|
+
models = [
|
|
90
|
+
ModelInfo(id="llama3.3", name="Llama 3.3", is_default=True),
|
|
91
|
+
ModelInfo(id="qwen2.5-coder", name="Qwen 2.5 Coder"),
|
|
92
|
+
ModelInfo(id="deepseek-r1", name="DeepSeek R1 Distill"),
|
|
93
|
+
ModelInfo(id="codellama", name="CodeLlama"),
|
|
94
|
+
]
|
|
95
|
+
self._cached_models = models
|
|
96
|
+
return models
|
|
97
|
+
|
|
98
|
+
def is_installed(self) -> bool:
|
|
99
|
+
return shutil.which("ollama") is not None or self._is_server_reachable()
|
|
100
|
+
|
|
101
|
+
def get_handoff_models(self) -> list[ModelInfo]:
|
|
102
|
+
"""Never query even a remote configured Ollama host during a preview."""
|
|
103
|
+
if self._cached_models is not None:
|
|
104
|
+
return list(self._cached_models)
|
|
105
|
+
return [
|
|
106
|
+
ModelInfo(id="llama3.3", name="Llama 3.3", is_default=True),
|
|
107
|
+
ModelInfo(id="qwen2.5-coder", name="Qwen 2.5 Coder"),
|
|
108
|
+
ModelInfo(id="deepseek-r1", name="DeepSeek R1 Distill"),
|
|
109
|
+
ModelInfo(id="codellama", name="CodeLlama"),
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
def _is_server_reachable(self) -> bool:
|
|
113
|
+
try:
|
|
114
|
+
req = urllib.request.Request(f"{self.host}/api/tags")
|
|
115
|
+
with urllib.request.urlopen(req, timeout=1.0) as resp:
|
|
116
|
+
return resp.status == 200
|
|
117
|
+
except Exception:
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
def is_configured(self) -> bool:
|
|
121
|
+
return self._is_server_reachable()
|
|
122
|
+
|
|
123
|
+
def get_install_info(self) -> dict[str, str]:
|
|
124
|
+
path = shutil.which("ollama") or ""
|
|
125
|
+
return {
|
|
126
|
+
"method": "CLI / Local Daemon" if path else "Local Server",
|
|
127
|
+
"path": path or self.host,
|
|
128
|
+
"version": "Local",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
def get_subscription_info(self) -> SubscriptionInfo:
|
|
132
|
+
return SubscriptionInfo(
|
|
133
|
+
tier=SubscriptionTier.UNLIMITED,
|
|
134
|
+
is_active=self.is_configured(),
|
|
135
|
+
message="Unlimited offline local compute",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
async def chat(
|
|
139
|
+
self,
|
|
140
|
+
messages: list[dict[str, Any]],
|
|
141
|
+
model: str | None = None,
|
|
142
|
+
system_prompt: str | None = None,
|
|
143
|
+
tools: list[dict[str, Any]] | None = None,
|
|
144
|
+
stream: bool = True,
|
|
145
|
+
) -> AsyncIterator[AgentEvent]:
|
|
146
|
+
model = model or self.get_default_model()
|
|
147
|
+
formatted_messages = []
|
|
148
|
+
if system_prompt:
|
|
149
|
+
formatted_messages.append({"role": "system", "content": system_prompt})
|
|
150
|
+
formatted_messages.extend(messages)
|
|
151
|
+
|
|
152
|
+
payload: dict[str, Any] = {
|
|
153
|
+
"model": model,
|
|
154
|
+
"messages": formatted_messages,
|
|
155
|
+
"stream": stream,
|
|
156
|
+
}
|
|
157
|
+
if tools:
|
|
158
|
+
payload["tools"] = tools
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
async with httpx.AsyncClient(timeout=120.0) as client:
|
|
162
|
+
if stream:
|
|
163
|
+
async with client.stream(
|
|
164
|
+
"POST", f"{self.host}/api/chat", json=payload
|
|
165
|
+
) as resp:
|
|
166
|
+
if resp.status_code != 200:
|
|
167
|
+
body = (await resp.aread()).decode("utf-8", "replace")[:200]
|
|
168
|
+
raise RuntimeError(f"Ollama returned HTTP {resp.status_code} for {model}: {body or 'no details'}")
|
|
169
|
+
async for line in resp.aiter_lines():
|
|
170
|
+
if not line:
|
|
171
|
+
continue
|
|
172
|
+
try:
|
|
173
|
+
data = json.loads(line)
|
|
174
|
+
msg = data.get("message", {})
|
|
175
|
+
content = msg.get("content", "")
|
|
176
|
+
if content:
|
|
177
|
+
yield TextDelta(content=content)
|
|
178
|
+
for call in msg.get("tool_calls", []):
|
|
179
|
+
function = call.get("function", {})
|
|
180
|
+
yield ToolUseStart(
|
|
181
|
+
tool_id=call.get("id", function.get("name", "tool")),
|
|
182
|
+
tool_name=function.get("name", ""),
|
|
183
|
+
args=function.get("arguments", {}),
|
|
184
|
+
)
|
|
185
|
+
if data.get("done"):
|
|
186
|
+
prompt_eval = data.get("prompt_eval_count", 0)
|
|
187
|
+
eval_count = data.get("eval_count", 0)
|
|
188
|
+
yield AgentDone(
|
|
189
|
+
usage=TokenUsage(
|
|
190
|
+
input_tokens=prompt_eval,
|
|
191
|
+
output_tokens=eval_count,
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
except json.JSONDecodeError:
|
|
195
|
+
continue
|
|
196
|
+
else:
|
|
197
|
+
resp = await client.post(f"{self.host}/api/chat", json=payload)
|
|
198
|
+
if resp.status_code != 200:
|
|
199
|
+
raise RuntimeError(f"Ollama returned HTTP {resp.status_code} for {model}: {resp.text[:200] or 'no details'}")
|
|
200
|
+
data = resp.json()
|
|
201
|
+
message = data.get("message", {})
|
|
202
|
+
if message.get("content"):
|
|
203
|
+
yield TextDelta(content=message["content"])
|
|
204
|
+
for call in message.get("tool_calls", []):
|
|
205
|
+
function = call.get("function", {})
|
|
206
|
+
yield ToolUseStart(
|
|
207
|
+
tool_id=call.get("id", function.get("name", "tool")),
|
|
208
|
+
tool_name=function.get("name", ""),
|
|
209
|
+
args=function.get("arguments", {}),
|
|
210
|
+
)
|
|
211
|
+
yield AgentDone(usage=TokenUsage(input_tokens=data.get("prompt_eval_count", 0) or 0,
|
|
212
|
+
output_tokens=data.get("eval_count", 0) or 0))
|
|
213
|
+
except httpx.HTTPError as exc:
|
|
214
|
+
raise RuntimeError(f"Ollama server at {self.host} is unreachable: {type(exc).__name__}. "
|
|
215
|
+
"Start it with `ollama serve` or /switch to another agent.") from exc
|
|
216
|
+
|
|
217
|
+
def check_limits(self) -> LimitStatus:
|
|
218
|
+
return LimitStatus.OK if self.is_configured() else LimitStatus.NO_KEY
|
|
@@ -68,6 +68,39 @@ from thwip.theme import (
|
|
|
68
68
|
)
|
|
69
69
|
from thwip.tools import ToolManager
|
|
70
70
|
|
|
71
|
+
# prompt_toolkit's cursor-position query is answered by the terminal with an escape sequence.
|
|
72
|
+
# During a long native turn nobody reads stdin, so the answer would be echoed as ^[ ... R and later
|
|
73
|
+
# swallowed as input. The layout does not need it, so disable the query.
|
|
74
|
+
os.environ.setdefault("PROMPT_TOOLKIT_NO_CPR", "1")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class QuietTerminal:
|
|
78
|
+
"""Turn off keyboard echo while a turn runs and drop stray input before the next prompt."""
|
|
79
|
+
|
|
80
|
+
def __enter__(self):
|
|
81
|
+
self._saved = None
|
|
82
|
+
try:
|
|
83
|
+
import termios
|
|
84
|
+
self._fd = sys.stdin.fileno()
|
|
85
|
+
if sys.stdin.isatty():
|
|
86
|
+
self._saved = termios.tcgetattr(self._fd)
|
|
87
|
+
quiet = termios.tcgetattr(self._fd)
|
|
88
|
+
quiet[3] &= ~termios.ECHO
|
|
89
|
+
termios.tcsetattr(self._fd, termios.TCSANOW, quiet)
|
|
90
|
+
except (ImportError, OSError, ValueError, AttributeError):
|
|
91
|
+
self._saved = None
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
def __exit__(self, *exc):
|
|
95
|
+
try:
|
|
96
|
+
import termios
|
|
97
|
+
if self._saved is not None:
|
|
98
|
+
termios.tcflush(self._fd, termios.TCIFLUSH)
|
|
99
|
+
termios.tcsetattr(self._fd, termios.TCSANOW, self._saved)
|
|
100
|
+
except (ImportError, OSError, ValueError, AttributeError):
|
|
101
|
+
pass
|
|
102
|
+
return False
|
|
103
|
+
|
|
71
104
|
|
|
72
105
|
class TurnInterrupted(Exception):
|
|
73
106
|
"""Raised when the user presses Ctrl+C at a prompt shown during a turn."""
|
|
@@ -217,7 +250,8 @@ class ThwipCLI:
|
|
|
217
250
|
continue
|
|
218
251
|
|
|
219
252
|
# Process chat message with agent; Ctrl+C interrupts the turn, not the REPL.
|
|
220
|
-
|
|
253
|
+
with QuietTerminal():
|
|
254
|
+
await self._run_interruptible(self.process_user_message(self._expand_mentions(user_input)))
|
|
221
255
|
|
|
222
256
|
except (KeyboardInterrupt, EOFError):
|
|
223
257
|
console.print("\n[dim]Exiting thwip. Goodbye![/dim]")
|
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Ollama agent adapter.
|
|
3
|
-
|
|
4
|
-
Local offline models with unlimited quota, zero cost, and full privacy.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import json
|
|
10
|
-
import shutil
|
|
11
|
-
import urllib.error
|
|
12
|
-
import urllib.request
|
|
13
|
-
from collections.abc import AsyncIterator
|
|
14
|
-
from typing import Any
|
|
15
|
-
|
|
16
|
-
import httpx
|
|
17
|
-
|
|
18
|
-
from thwip.agents.base import (
|
|
19
|
-
AgentDone,
|
|
20
|
-
AgentEvent,
|
|
21
|
-
BaseAgent,
|
|
22
|
-
Capability,
|
|
23
|
-
LimitStatus,
|
|
24
|
-
ModelInfo,
|
|
25
|
-
SubscriptionInfo,
|
|
26
|
-
SubscriptionTier,
|
|
27
|
-
TextDelta,
|
|
28
|
-
TokenUsage,
|
|
29
|
-
ToolUseStart,
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
class OllamaAgent(BaseAgent):
|
|
34
|
-
"""
|
|
35
|
-
Ollama Local Agent.
|
|
36
|
-
|
|
37
|
-
Runs entirely on-device with zero rate limits and unlimited usage.
|
|
38
|
-
"""
|
|
39
|
-
|
|
40
|
-
name = "ollama"
|
|
41
|
-
display_name = "Ollama (Local)"
|
|
42
|
-
company = "Ollama"
|
|
43
|
-
description = "Local models running entirely on-device (zero API limits, private)"
|
|
44
|
-
website = "https://ollama.com"
|
|
45
|
-
|
|
46
|
-
capabilities = {
|
|
47
|
-
Capability.CHAT,
|
|
48
|
-
Capability.FILE_EDIT,
|
|
49
|
-
Capability.FILE_READ,
|
|
50
|
-
Capability.CODE_RUN,
|
|
51
|
-
Capability.TERMINAL,
|
|
52
|
-
Capability.GIT,
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
def __init__(self, host: str = "http://localhost:11434") -> None:
|
|
56
|
-
self.host = host.rstrip("/")
|
|
57
|
-
self._cached_models: list[ModelInfo] | None = None
|
|
58
|
-
|
|
59
|
-
@property
|
|
60
|
-
def available_models(self) -> list[ModelInfo]:
|
|
61
|
-
if self._cached_models is not None:
|
|
62
|
-
return self._cached_models
|
|
63
|
-
|
|
64
|
-
# Try to query running Ollama server for downloaded models
|
|
65
|
-
models = []
|
|
66
|
-
try:
|
|
67
|
-
req = urllib.request.Request(f"{self.host}/api/tags")
|
|
68
|
-
with urllib.request.urlopen(req, timeout=1.5) as resp:
|
|
69
|
-
data = json.loads(resp.read().decode("utf-8"))
|
|
70
|
-
for m in data.get("models", []):
|
|
71
|
-
name = m.get("name", "")
|
|
72
|
-
models.append(
|
|
73
|
-
ModelInfo(
|
|
74
|
-
id=name,
|
|
75
|
-
name=f"{name} (Local)",
|
|
76
|
-
context_window=32_768,
|
|
77
|
-
max_output=8_192,
|
|
78
|
-
supports_tools=True,
|
|
79
|
-
supports_streaming=True,
|
|
80
|
-
pricing_input=0.0,
|
|
81
|
-
pricing_output=0.0,
|
|
82
|
-
)
|
|
83
|
-
)
|
|
84
|
-
except Exception:
|
|
85
|
-
pass
|
|
86
|
-
|
|
87
|
-
if not models:
|
|
88
|
-
# Defaults
|
|
89
|
-
models = [
|
|
90
|
-
ModelInfo(id="llama3.3", name="Llama 3.3", is_default=True),
|
|
91
|
-
ModelInfo(id="qwen2.5-coder", name="Qwen 2.5 Coder"),
|
|
92
|
-
ModelInfo(id="deepseek-r1", name="DeepSeek R1 Distill"),
|
|
93
|
-
ModelInfo(id="codellama", name="CodeLlama"),
|
|
94
|
-
]
|
|
95
|
-
self._cached_models = models
|
|
96
|
-
return models
|
|
97
|
-
|
|
98
|
-
def is_installed(self) -> bool:
|
|
99
|
-
return shutil.which("ollama") is not None or self._is_server_reachable()
|
|
100
|
-
|
|
101
|
-
def get_handoff_models(self) -> list[ModelInfo]:
|
|
102
|
-
"""Never query even a remote configured Ollama host during a preview."""
|
|
103
|
-
if self._cached_models is not None:
|
|
104
|
-
return list(self._cached_models)
|
|
105
|
-
return [
|
|
106
|
-
ModelInfo(id="llama3.3", name="Llama 3.3", is_default=True),
|
|
107
|
-
ModelInfo(id="qwen2.5-coder", name="Qwen 2.5 Coder"),
|
|
108
|
-
ModelInfo(id="deepseek-r1", name="DeepSeek R1 Distill"),
|
|
109
|
-
ModelInfo(id="codellama", name="CodeLlama"),
|
|
110
|
-
]
|
|
111
|
-
|
|
112
|
-
def _is_server_reachable(self) -> bool:
|
|
113
|
-
try:
|
|
114
|
-
req = urllib.request.Request(f"{self.host}/api/tags")
|
|
115
|
-
with urllib.request.urlopen(req, timeout=1.0) as resp:
|
|
116
|
-
return resp.status == 200
|
|
117
|
-
except Exception:
|
|
118
|
-
return False
|
|
119
|
-
|
|
120
|
-
def is_configured(self) -> bool:
|
|
121
|
-
return self._is_server_reachable()
|
|
122
|
-
|
|
123
|
-
def get_install_info(self) -> dict[str, str]:
|
|
124
|
-
path = shutil.which("ollama") or ""
|
|
125
|
-
return {
|
|
126
|
-
"method": "CLI / Local Daemon" if path else "Local Server",
|
|
127
|
-
"path": path or self.host,
|
|
128
|
-
"version": "Local",
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
def get_subscription_info(self) -> SubscriptionInfo:
|
|
132
|
-
return SubscriptionInfo(
|
|
133
|
-
tier=SubscriptionTier.UNLIMITED,
|
|
134
|
-
is_active=self.is_configured(),
|
|
135
|
-
message="Unlimited offline local compute",
|
|
136
|
-
)
|
|
137
|
-
|
|
138
|
-
async def chat(
|
|
139
|
-
self,
|
|
140
|
-
messages: list[dict[str, Any]],
|
|
141
|
-
model: str | None = None,
|
|
142
|
-
system_prompt: str | None = None,
|
|
143
|
-
tools: list[dict[str, Any]] | None = None,
|
|
144
|
-
stream: bool = True,
|
|
145
|
-
) -> AsyncIterator[AgentEvent]:
|
|
146
|
-
model = model or self.get_default_model()
|
|
147
|
-
formatted_messages = []
|
|
148
|
-
if system_prompt:
|
|
149
|
-
formatted_messages.append({"role": "system", "content": system_prompt})
|
|
150
|
-
formatted_messages.extend(messages)
|
|
151
|
-
|
|
152
|
-
payload: dict[str, Any] = {
|
|
153
|
-
"model": model,
|
|
154
|
-
"messages": formatted_messages,
|
|
155
|
-
"stream": stream,
|
|
156
|
-
}
|
|
157
|
-
if tools:
|
|
158
|
-
payload["tools"] = tools
|
|
159
|
-
|
|
160
|
-
async with httpx.AsyncClient(timeout=120.0) as client:
|
|
161
|
-
if stream:
|
|
162
|
-
async with client.stream(
|
|
163
|
-
"POST", f"{self.host}/api/chat", json=payload
|
|
164
|
-
) as resp:
|
|
165
|
-
if resp.status_code != 200:
|
|
166
|
-
yield AgentDone()
|
|
167
|
-
return
|
|
168
|
-
async for line in resp.aiter_lines():
|
|
169
|
-
if not line:
|
|
170
|
-
continue
|
|
171
|
-
try:
|
|
172
|
-
data = json.loads(line)
|
|
173
|
-
msg = data.get("message", {})
|
|
174
|
-
content = msg.get("content", "")
|
|
175
|
-
if content:
|
|
176
|
-
yield TextDelta(content=content)
|
|
177
|
-
for call in msg.get("tool_calls", []):
|
|
178
|
-
function = call.get("function", {})
|
|
179
|
-
yield ToolUseStart(
|
|
180
|
-
tool_id=call.get("id", function.get("name", "tool")),
|
|
181
|
-
tool_name=function.get("name", ""),
|
|
182
|
-
args=function.get("arguments", {}),
|
|
183
|
-
)
|
|
184
|
-
if data.get("done"):
|
|
185
|
-
prompt_eval = data.get("prompt_eval_count", 0)
|
|
186
|
-
eval_count = data.get("eval_count", 0)
|
|
187
|
-
yield AgentDone(
|
|
188
|
-
usage=TokenUsage(
|
|
189
|
-
input_tokens=prompt_eval,
|
|
190
|
-
output_tokens=eval_count,
|
|
191
|
-
)
|
|
192
|
-
)
|
|
193
|
-
except json.JSONDecodeError:
|
|
194
|
-
continue
|
|
195
|
-
else:
|
|
196
|
-
resp = await client.post(f"{self.host}/api/chat", json=payload)
|
|
197
|
-
if resp.status_code == 200:
|
|
198
|
-
data = resp.json()
|
|
199
|
-
message = data.get("message", {})
|
|
200
|
-
if message.get("content"):
|
|
201
|
-
yield TextDelta(content=message["content"])
|
|
202
|
-
for call in message.get("tool_calls", []):
|
|
203
|
-
function = call.get("function", {})
|
|
204
|
-
yield ToolUseStart(
|
|
205
|
-
tool_id=call.get("id", function.get("name", "tool")),
|
|
206
|
-
tool_name=function.get("name", ""),
|
|
207
|
-
args=function.get("arguments", {}),
|
|
208
|
-
)
|
|
209
|
-
yield AgentDone()
|
|
210
|
-
|
|
211
|
-
def check_limits(self) -> LimitStatus:
|
|
212
|
-
return LimitStatus.OK if self.is_configured() else LimitStatus.NO_KEY
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|