@aliwey/bmo 2.0.0
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.
- package/README.md +90 -0
- package/bin/bmo.js +188 -0
- package/cli.py +1129 -0
- package/config/__init__.py +0 -0
- package/config/__pycache__/__init__.cpython-313.pyc +0 -0
- package/config/__pycache__/settings.cpython-313.pyc +0 -0
- package/config/__pycache__/system-prompt.cpython-313.pyc +0 -0
- package/config/settings.py +104 -0
- package/config/system-prompt.json +18 -0
- package/core/__init__.py +0 -0
- package/core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_a2a_bridge.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_agent.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_agent_card.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_connector.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_discovery.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_identity.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_tasks.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_transport.cpython-313.pyc +0 -0
- package/core/__pycache__/bmo_engine.cpython-313.pyc +0 -0
- package/core/__pycache__/bot_client.cpython-313.pyc +0 -0
- package/core/__pycache__/budget_tracker.cpython-313.pyc +0 -0
- package/core/__pycache__/cli_renderer.cpython-313.pyc +0 -0
- package/core/__pycache__/goal_runner.cpython-313.pyc +0 -0
- package/core/__pycache__/request_worker.cpython-313.pyc +0 -0
- package/core/__pycache__/security.cpython-313.pyc +0 -0
- package/core/__pycache__/shared_state.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_multiproc.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_protocol.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_subprocess.cpython-313.pyc +0 -0
- package/core/bfp_a2a_bridge.py +399 -0
- package/core/bfp_agent.py +98 -0
- package/core/bfp_agent_card.py +161 -0
- package/core/bfp_connector.py +177 -0
- package/core/bfp_discovery.py +105 -0
- package/core/bfp_identity.py +83 -0
- package/core/bfp_tasks.py +70 -0
- package/core/bfp_transport.py +368 -0
- package/core/bmo_engine.py +405 -0
- package/core/bot_client.py +838 -0
- package/core/budget_tracker.py +62 -0
- package/core/cli_renderer.py +177 -0
- package/core/goal_runner.py +129 -0
- package/core/request_worker.py +242 -0
- package/core/security.py +42 -0
- package/core/shared_state.py +4 -0
- package/core/worker_manager.py +71 -0
- package/core/worker_multiproc.py +155 -0
- package/core/worker_protocol.py +30 -0
- package/core/worker_subprocess.py +222 -0
- package/handlers/__init__.py +0 -0
- package/handlers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/handlers/__pycache__/messages.cpython-313.pyc +0 -0
- package/handlers/messages.py +2761 -0
- package/main.py +125 -0
- package/memory.md +43 -0
- package/models/__init__.py +0 -0
- package/models/__pycache__/__init__.cpython-313.pyc +0 -0
- package/models/__pycache__/chat_models.cpython-313.pyc +0 -0
- package/models/chat_models.py +143 -0
- package/package.json +50 -0
- package/registry/worker.js +108 -0
- package/registry/wrangler.toml +11 -0
- package/requirements.txt +13 -0
- package/scripts/bmo_init.js +115 -0
- package/scripts/postinstall.js +265 -0
- package/scripts/relay_cmd.js +276 -0
- package/scripts/web_cmd.js +136 -0
- package/setup.py +26 -0
- package/storage/__init__.py +0 -0
- package/storage/__pycache__/__init__.cpython-313.pyc +0 -0
- package/storage/__pycache__/sqlite_storage.cpython-313.pyc +0 -0
- package/storage/__pycache__/storage.cpython-313.pyc +0 -0
- package/storage/sqlite_storage.py +658 -0
- package/storage/storage.py +265 -0
- package/tools/__pycache__/bfp_relay.cpython-313.pyc +0 -0
- package/tools/__pycache__/get_session_summaries.cpython-313.pyc +0 -0
- package/tools/__pycache__/mcp_bridge.cpython-313.pyc +0 -0
- package/tools/__pycache__/mcp_server.cpython-313.pyc +0 -0
- package/tools/__pycache__/run_mcp_standalone.cpython-313.pyc +0 -0
- package/tools/__pycache__/task_registry.cpython-313.pyc +0 -0
- package/tools/__pycache__/test_mcp_connection.cpython-313.pyc +0 -0
- package/tools/bfp_relay.py +359 -0
- package/tools/bot.db +0 -0
- package/tools/get_session_summaries.py +45 -0
- package/tools/mcp_bridge.py +109 -0
- package/tools/mcp_server.py +531 -0
- package/tools/register_mcp_task.py +20 -0
- package/tools/run_detached.bat +32 -0
- package/tools/run_mcp_standalone.py +16 -0
- package/tools/task_registry.py +184 -0
- package/tools/test_mcp_connection.py +80 -0
- package/webchat/package-lock.json +1528 -0
- package/webchat/package.json +12 -0
- package/webchat/public/app.js +1293 -0
- package/webchat/public/index.html +226 -0
- package/webchat/public/index.html.bak +416 -0
- package/webchat/public/styles.css +2435 -0
- package/webchat/server.js +645 -0
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenCode integration client for the bot.
|
|
3
|
+
Communicates directly with a running `opencode serve` instance via HTTP REST API.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
import httpx
|
|
12
|
+
import json
|
|
13
|
+
from typing import Optional, Callable
|
|
14
|
+
|
|
15
|
+
from core.worker_manager import WorkerManager
|
|
16
|
+
|
|
17
|
+
from config.settings import OPENCODE_BASE_URL, OPENCODE_TIMEOUT, OPENCODE_POLL_INTERVAL, OPENCODE_POLL_TIMEOUT
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
HEADERS = {"Content-Type": "application/json"}
|
|
22
|
+
|
|
23
|
+
# ── Load shared system prompt config (single source of truth) ──
|
|
24
|
+
_config_path = os.path.join(os.path.dirname(__file__), "..", "config", "system-prompt.json")
|
|
25
|
+
with open(_config_path, "r", encoding="utf-8") as _f:
|
|
26
|
+
_system_config = json.load(_f)
|
|
27
|
+
|
|
28
|
+
TELEGRAM_SYSTEM_PROMPT = _system_config["base_prompt"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OpenCodeBotClient:
|
|
32
|
+
"""Client that talks directly to the opencode serve HTTP API."""
|
|
33
|
+
|
|
34
|
+
_CACHE_TTL = 60 # seconds
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self.is_connected = False
|
|
38
|
+
self.last_session_id: Optional[str] = None
|
|
39
|
+
self._http = httpx.AsyncClient(
|
|
40
|
+
base_url=OPENCODE_BASE_URL,
|
|
41
|
+
headers=HEADERS,
|
|
42
|
+
timeout=float(OPENCODE_TIMEOUT),
|
|
43
|
+
)
|
|
44
|
+
self._memory_cache: Optional[str] = None
|
|
45
|
+
self._memory_cache_time: float = 0
|
|
46
|
+
self._agents_cache: Optional[list] = None
|
|
47
|
+
self._agents_cache_time: float = 0
|
|
48
|
+
self._memory_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "data", "memory.md"))
|
|
49
|
+
self._worker: Optional[WorkerManager] = None
|
|
50
|
+
|
|
51
|
+
def set_worker_manager(self, worker: WorkerManager):
|
|
52
|
+
self._worker = worker
|
|
53
|
+
|
|
54
|
+
async def is_alive(self) -> bool:
|
|
55
|
+
"""Lightweight check if the server is responding."""
|
|
56
|
+
try:
|
|
57
|
+
r = await self._http.get("/session", timeout=5.0)
|
|
58
|
+
return r.status_code < 500
|
|
59
|
+
except Exception:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
async def connect(self) -> bool:
|
|
63
|
+
"""Verify that opencode serve is reachable."""
|
|
64
|
+
try:
|
|
65
|
+
r = await self._http.get("/session")
|
|
66
|
+
if r.status_code < 500:
|
|
67
|
+
self.is_connected = True
|
|
68
|
+
logger.info("Connected to opencode serve at %s", OPENCODE_BASE_URL)
|
|
69
|
+
else:
|
|
70
|
+
logger.warning("opencode serve returned %d", r.status_code)
|
|
71
|
+
self.is_connected = False
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.error("Cannot reach opencode serve: %s", e)
|
|
74
|
+
self.is_connected = False
|
|
75
|
+
return self.is_connected
|
|
76
|
+
|
|
77
|
+
async def create_session(self, provider_id: Optional[str] = None, model_id: Optional[str] = None, env: Optional[dict] = None) -> Optional[str]:
|
|
78
|
+
"""Create a new opencode session and return its ID."""
|
|
79
|
+
try:
|
|
80
|
+
# Force defaults if None/Empty
|
|
81
|
+
p_id = provider_id if (provider_id and str(provider_id) != "None") else "opencode"
|
|
82
|
+
m_id = model_id if (model_id and str(model_id) != "None") else "big-pickle"
|
|
83
|
+
|
|
84
|
+
# The server expects a nested 'model' object
|
|
85
|
+
payload = {
|
|
86
|
+
"model": {
|
|
87
|
+
"id": m_id,
|
|
88
|
+
"providerID": p_id
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if env:
|
|
93
|
+
payload["env"] = env
|
|
94
|
+
|
|
95
|
+
logger.info("--- OPENCODE SESSION REQUEST ---")
|
|
96
|
+
logger.info("Payload: %s", json.dumps(payload))
|
|
97
|
+
|
|
98
|
+
r = await self._http.post("/session", json=payload)
|
|
99
|
+
r.raise_for_status()
|
|
100
|
+
|
|
101
|
+
data = r.json()
|
|
102
|
+
session_id = data.get("id")
|
|
103
|
+
|
|
104
|
+
actual_model = data.get("model", {}).get("id", "Unknown")
|
|
105
|
+
logger.info("--- OPENCODE SESSION CREATED: %s (Model: %s) ---", session_id, actual_model)
|
|
106
|
+
return session_id
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.error("Failed to create session: %s", e)
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
async def get_session_info(self, session_id: str) -> Optional[dict]:
|
|
112
|
+
"""Fetch session details from the server to verify active model etc."""
|
|
113
|
+
try:
|
|
114
|
+
r = await self._http.get(f"/session/{session_id}")
|
|
115
|
+
if r.status_code == 200:
|
|
116
|
+
return r.json()
|
|
117
|
+
return None
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.debug("Failed to get session info: %s", e)
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
async def get_providers(self) -> dict:
|
|
123
|
+
"""Fetch available providers and their models."""
|
|
124
|
+
try:
|
|
125
|
+
r = await self._http.get("/provider", timeout=10)
|
|
126
|
+
r.raise_for_status()
|
|
127
|
+
return r.json()
|
|
128
|
+
except Exception as e:
|
|
129
|
+
logger.error("Failed to fetch providers: %s", e)
|
|
130
|
+
return {}
|
|
131
|
+
|
|
132
|
+
async def abort_session(self, session_id: str) -> bool:
|
|
133
|
+
"""Send abort signal to the server to stop the current in-progress LLM stream."""
|
|
134
|
+
try:
|
|
135
|
+
r = await self._http.post(f"/session/{session_id}/abort", timeout=5.0)
|
|
136
|
+
return r.status_code < 400
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logger.warning("abort_session failed (non-fatal): %s", e)
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
async def delete_messages(self, session_id: str) -> bool:
|
|
142
|
+
"""Clears all messages in a session (Reset History)."""
|
|
143
|
+
try:
|
|
144
|
+
r = await self._http.delete(f"/session/{session_id}/message")
|
|
145
|
+
return r.status_code == 200
|
|
146
|
+
except Exception as e:
|
|
147
|
+
logger.error("Failed to delete session messages: %s", e)
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
async def get_agents(self) -> list:
|
|
151
|
+
"""Fetch available agents (skills)."""
|
|
152
|
+
try:
|
|
153
|
+
r = await self._http.get("/agent", timeout=10)
|
|
154
|
+
r.raise_for_status()
|
|
155
|
+
return r.json()
|
|
156
|
+
except Exception as e:
|
|
157
|
+
logger.error("Failed to fetch agents: %s", e)
|
|
158
|
+
return []
|
|
159
|
+
|
|
160
|
+
async def get_mcp_tools(self) -> dict:
|
|
161
|
+
"""Fetch available MCP tools."""
|
|
162
|
+
try:
|
|
163
|
+
r = await self._http.get("/config", timeout=10)
|
|
164
|
+
r.raise_for_status()
|
|
165
|
+
return r.json().get("mcp", {})
|
|
166
|
+
except Exception as e:
|
|
167
|
+
logger.error("Failed to fetch MCP tools: %s", e)
|
|
168
|
+
return {}
|
|
169
|
+
|
|
170
|
+
async def _get_memory_content(self) -> str:
|
|
171
|
+
"""Read memory.md with caching to avoid disk I/O on every message."""
|
|
172
|
+
now = time.monotonic()
|
|
173
|
+
if self._memory_cache is not None and (now - self._memory_cache_time) < self._CACHE_TTL:
|
|
174
|
+
return self._memory_cache
|
|
175
|
+
try:
|
|
176
|
+
if os.path.exists(self._memory_path):
|
|
177
|
+
with open(self._memory_path, "r", encoding="utf-8") as f:
|
|
178
|
+
self._memory_cache = f.read()
|
|
179
|
+
else:
|
|
180
|
+
self._memory_cache = ""
|
|
181
|
+
except Exception as e:
|
|
182
|
+
logger.warning("Could not read memory file: %s", e)
|
|
183
|
+
self._memory_cache = ""
|
|
184
|
+
self._memory_cache_time = now
|
|
185
|
+
return self._memory_cache
|
|
186
|
+
|
|
187
|
+
async def _get_agents_cached(self) -> list:
|
|
188
|
+
"""Fetch agents/skills list with caching to avoid HTTP call on every message."""
|
|
189
|
+
now = time.monotonic()
|
|
190
|
+
if self._agents_cache is not None and (now - self._agents_cache_time) < self._CACHE_TTL:
|
|
191
|
+
return self._agents_cache
|
|
192
|
+
try:
|
|
193
|
+
r = await self._http.get("/agent", timeout=5)
|
|
194
|
+
if r.status_code == 200:
|
|
195
|
+
self._agents_cache = r.json()
|
|
196
|
+
else:
|
|
197
|
+
self._agents_cache = []
|
|
198
|
+
except Exception:
|
|
199
|
+
self._agents_cache = self._agents_cache or []
|
|
200
|
+
self._agents_cache_time = now
|
|
201
|
+
return self._agents_cache
|
|
202
|
+
|
|
203
|
+
async def _build_tool_context(self) -> str:
|
|
204
|
+
"""Discover available MCP tools and inject as context before each message."""
|
|
205
|
+
try:
|
|
206
|
+
tools = await self.get_mcp_tools()
|
|
207
|
+
if tools and "tools" in tools:
|
|
208
|
+
lines = ["\n\n[AVAILABLE MCP TOOLS — USE THESE, NOT BASH]"]
|
|
209
|
+
for t in tools["tools"]:
|
|
210
|
+
name = t.get("name", "")
|
|
211
|
+
desc = (t.get("description", "") or "")[:100]
|
|
212
|
+
lines.append(f"- {name}: {desc}")
|
|
213
|
+
lines.append("MANDATORY: For servers/tunnels/long-running processes, use these tools. NEVER run via bash.")
|
|
214
|
+
return "\n".join(lines)
|
|
215
|
+
except Exception as e:
|
|
216
|
+
logger.debug("Failed to build tool context: %s", e)
|
|
217
|
+
return ""
|
|
218
|
+
|
|
219
|
+
async def send_query(
|
|
220
|
+
self,
|
|
221
|
+
query: str,
|
|
222
|
+
session_id: Optional[str] = None,
|
|
223
|
+
files: Optional[list[dict]] = None,
|
|
224
|
+
provider_id: Optional[str] = None,
|
|
225
|
+
model_id: Optional[str] = None,
|
|
226
|
+
active_mode: str = "ask",
|
|
227
|
+
active_agent: str = "default",
|
|
228
|
+
provider_env: Optional[dict] = None,
|
|
229
|
+
chat_id: Optional[int] = None,
|
|
230
|
+
session_uuid: Optional[str] = None,
|
|
231
|
+
on_token: Optional[Callable[[str], None]] = None,
|
|
232
|
+
on_activity: Optional[Callable[[str, str], None]] = None,
|
|
233
|
+
on_permission: Optional[Callable[[str, str, list], str]] = None,
|
|
234
|
+
) -> str:
|
|
235
|
+
"""Send a query via worker process, falling back to direct HTTP."""
|
|
236
|
+
if self._worker and self._worker.is_alive:
|
|
237
|
+
return await self._send_via_worker(
|
|
238
|
+
query=query, session_id=session_id, files=files,
|
|
239
|
+
provider_id=provider_id, model_id=model_id,
|
|
240
|
+
active_mode=active_mode, active_agent=active_agent,
|
|
241
|
+
provider_env=provider_env, chat_id=chat_id,
|
|
242
|
+
session_uuid=session_uuid, on_token=on_token,
|
|
243
|
+
)
|
|
244
|
+
return await self._send_direct(
|
|
245
|
+
query=query, session_id=session_id, files=files,
|
|
246
|
+
provider_id=provider_id, model_id=model_id,
|
|
247
|
+
active_mode=active_mode, active_agent=active_agent,
|
|
248
|
+
provider_env=provider_env, chat_id=chat_id,
|
|
249
|
+
session_uuid=session_uuid, on_token=on_token,
|
|
250
|
+
on_activity=on_activity,
|
|
251
|
+
on_permission=on_permission,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
async def _send_direct(
|
|
255
|
+
self,
|
|
256
|
+
query: str,
|
|
257
|
+
session_id: Optional[str] = None,
|
|
258
|
+
files: Optional[list[dict]] = None,
|
|
259
|
+
provider_id: Optional[str] = None,
|
|
260
|
+
model_id: Optional[str] = None,
|
|
261
|
+
active_mode: str = "ask",
|
|
262
|
+
active_agent: str = "default",
|
|
263
|
+
provider_env: Optional[dict] = None,
|
|
264
|
+
chat_id: Optional[int] = None,
|
|
265
|
+
session_uuid: Optional[str] = None,
|
|
266
|
+
on_token: Optional[Callable[[str], None]] = None,
|
|
267
|
+
on_activity: Optional[Callable[[str, str], None]] = None,
|
|
268
|
+
on_permission: Optional[Callable[[str, str, list], str]] = None,
|
|
269
|
+
) -> str:
|
|
270
|
+
"""
|
|
271
|
+
Send a query directly via HTTP. Fallback when worker is unavailable.
|
|
272
|
+
Creates a new session if session_id is None.
|
|
273
|
+
Accepts optional files list: [{"path": "...", "mime": "..."}]
|
|
274
|
+
Returns the assistant's Telegram-HTML-formatted response.
|
|
275
|
+
"""
|
|
276
|
+
if not self.is_connected:
|
|
277
|
+
connected = await self.connect()
|
|
278
|
+
if not connected:
|
|
279
|
+
return "❌ Error: Could not connect to OpenCode backend. Is the server running?"
|
|
280
|
+
|
|
281
|
+
if not session_id:
|
|
282
|
+
# Inject keys if provided
|
|
283
|
+
session_id = await self.create_session(provider_id, model_id, env=provider_env)
|
|
284
|
+
if not session_id:
|
|
285
|
+
return "Error: Could not create OpenCode session."
|
|
286
|
+
|
|
287
|
+
self.last_session_id = session_id
|
|
288
|
+
|
|
289
|
+
memory_content = await self._get_memory_content()
|
|
290
|
+
|
|
291
|
+
# Mode-specific instructions
|
|
292
|
+
mode_prompts = _system_config["mode_prompts"]
|
|
293
|
+
mode_instruction = mode_prompts.get(active_mode, mode_prompts["execute"])
|
|
294
|
+
|
|
295
|
+
# Anti-loop directive: prevent re-reading old tasks from history
|
|
296
|
+
anti_loop = _system_config["anti_loop"]
|
|
297
|
+
|
|
298
|
+
# Agent-specific personas
|
|
299
|
+
agent_prompts = _system_config["agent_prompts"]
|
|
300
|
+
agent_instruction = agent_prompts.get(active_agent, "")
|
|
301
|
+
|
|
302
|
+
# Inject available skills/agents
|
|
303
|
+
skills_block = ""
|
|
304
|
+
try:
|
|
305
|
+
agents = await self._get_agents_cached()
|
|
306
|
+
if agents:
|
|
307
|
+
lines = ["\n\n<AVAILABLE SKILLS>"]
|
|
308
|
+
for a in agents[:10]:
|
|
309
|
+
name = a.get("name", "")
|
|
310
|
+
desc = a.get("description", "")
|
|
311
|
+
lines.append(f"- {name}: {desc}")
|
|
312
|
+
lines.append("</AVAILABLE SKILLS>\nUse these proactively when needed.")
|
|
313
|
+
skills_block = "\n".join(lines)
|
|
314
|
+
except Exception:
|
|
315
|
+
pass
|
|
316
|
+
|
|
317
|
+
# Build final system context with Memory and Tools
|
|
318
|
+
chat_context = ""
|
|
319
|
+
if chat_id:
|
|
320
|
+
chat_context = f"\n\n[USER_CONTEXT]\nCURRENT_CHAT_ID: {chat_id}\nCURRENT_SESSION_ID: {session_uuid or 'unknown'}\n[END USER_CONTEXT]"
|
|
321
|
+
chat_context += "\n\n<b>FILE STORAGE</b>: When creating files, save them inside <code>data/files/</code>. Use date-based subfolders: <code>data/files/{{YYYY-MM-DD}}/{{CURRENT_SESSION_ID}}_{{HHMMSS}}_{{filename}}</code> so files are linked to sessions and dates."
|
|
322
|
+
|
|
323
|
+
tool_instruction = _system_config["tool_instruction"]
|
|
324
|
+
|
|
325
|
+
# Inject available skills/agents
|
|
326
|
+
skills_block = ""
|
|
327
|
+
try:
|
|
328
|
+
agents = await self._get_agents_cached()
|
|
329
|
+
if agents:
|
|
330
|
+
lines = ["\n\n<AVAILABLE SKILLS>"]
|
|
331
|
+
for a in agents[:10]:
|
|
332
|
+
name = a.get("name", "")
|
|
333
|
+
desc = a.get("description", "")
|
|
334
|
+
lines.append(f"- {name}: {desc}")
|
|
335
|
+
lines.append("</AVAILABLE SKILLS>\nUse these proactively when needed.")
|
|
336
|
+
skills_block = "\n".join(lines)
|
|
337
|
+
except Exception:
|
|
338
|
+
pass
|
|
339
|
+
|
|
340
|
+
# Resolve project root dynamically for portability
|
|
341
|
+
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
342
|
+
system_base = TELEGRAM_SYSTEM_PROMPT.replace("{PROJECT_ROOT}", project_root)
|
|
343
|
+
|
|
344
|
+
# Proactive Security Guard: Detect external paths in query and remind the model
|
|
345
|
+
security_warning = ""
|
|
346
|
+
external_paths = re.findall(r'([a-zA-Z]:\\[^`"\'\s\n<>]+|/[^`"\'\s\n<>]+)', query)
|
|
347
|
+
for p in external_paths:
|
|
348
|
+
try:
|
|
349
|
+
p_abs = os.path.abspath(p)
|
|
350
|
+
if not p_abs.startswith(project_root):
|
|
351
|
+
security_warning = f"\n\n[SECURITY ALERT]\nUser requested access to external path: {p_abs}\nYou MUST call request_permission(chat_id={chat_id}, reason='...', scope='{p_abs}') before reading or processing this file.\n[END ALERT]"
|
|
352
|
+
break
|
|
353
|
+
except Exception:
|
|
354
|
+
continue
|
|
355
|
+
|
|
356
|
+
# Inject available MCP tools dynamically
|
|
357
|
+
tool_context = await self._build_tool_context()
|
|
358
|
+
|
|
359
|
+
memory_content = await self._get_memory_content()
|
|
360
|
+
memory_block = f"\n\n[LONG-TERM MEMORY]\n{memory_content}\n[END MEMORY]" if memory_content else ""
|
|
361
|
+
memory_instruction = _system_config["memory_instruction"]
|
|
362
|
+
|
|
363
|
+
full_system_context = system_base + memory_instruction + tool_instruction + chat_context + skills_block + memory_block + security_warning + tool_context + f"\n\nCURRENT PROTOCOL: {mode_instruction}" + _system_config["anti_loop"] + agent_instruction
|
|
364
|
+
|
|
365
|
+
async def _do_send(sid):
|
|
366
|
+
try:
|
|
367
|
+
payload = {
|
|
368
|
+
"parts": [
|
|
369
|
+
{
|
|
370
|
+
"type": "text",
|
|
371
|
+
"text": full_system_context, "synthetic": True,
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
"type": "text",
|
|
375
|
+
"text": query,
|
|
376
|
+
},
|
|
377
|
+
]
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if files:
|
|
381
|
+
for f in files:
|
|
382
|
+
file_path = f["path"].replace('\\', '/')
|
|
383
|
+
payload["parts"].append({
|
|
384
|
+
"type": "file",
|
|
385
|
+
"url": f"file:///{file_path}",
|
|
386
|
+
"mime": f["mime"]
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
r = await asyncio.wait_for(
|
|
390
|
+
self._http.post(f"/session/{sid}/message", json=payload),
|
|
391
|
+
timeout=float(OPENCODE_TIMEOUT),
|
|
392
|
+
)
|
|
393
|
+
return r, None
|
|
394
|
+
except asyncio.TimeoutError as e:
|
|
395
|
+
logger.error("Hard timeout (asyncio.wait_for) sending to session %s", sid)
|
|
396
|
+
return None, f"Request hard-cancelled after {OPENCODE_TIMEOUT}s (asyncio.wait_for)"
|
|
397
|
+
except httpx.TimeoutException as e:
|
|
398
|
+
logger.error("Timeout sending prompt to session %s [%ss]: %s", sid, self._http.timeout, e)
|
|
399
|
+
return None, f"Request timed out after {self._http.timeout}s"
|
|
400
|
+
except httpx.ConnectError as e:
|
|
401
|
+
logger.error("Cannot connect to opencode serve at %s: %s", OPENCODE_BASE_URL, e)
|
|
402
|
+
return None, f"Cannot reach OpenCode at {OPENCODE_BASE_URL} — is the server running?"
|
|
403
|
+
except Exception as e:
|
|
404
|
+
logger.error("Error in _do_send: %s [%s]", e, type(e).__name__)
|
|
405
|
+
return None, f"Network error ({type(e).__name__}): {e}"
|
|
406
|
+
|
|
407
|
+
r, err = await _do_send(session_id)
|
|
408
|
+
|
|
409
|
+
# Automatic recovery: if session not found, clear it and try once more
|
|
410
|
+
if r is not None and r.status_code == 404:
|
|
411
|
+
logger.warning("Session %s not found on server. Creating new session...", session_id)
|
|
412
|
+
session_id = await self.create_session(provider_id, model_id, env=provider_env)
|
|
413
|
+
if not session_id:
|
|
414
|
+
return "Error: Session lost and could not create a new one."
|
|
415
|
+
self.last_session_id = session_id
|
|
416
|
+
r, err = await _do_send(session_id)
|
|
417
|
+
|
|
418
|
+
if r is None or r.status_code not in (200, 201):
|
|
419
|
+
status_code = r.status_code if r else "None"
|
|
420
|
+
text = (r.text[:200] if r else (err or "Network error"))
|
|
421
|
+
logger.error("Prompt failed (%s): %s", status_code, text)
|
|
422
|
+
return f"Error: opencode returned status {status_code} - {text[:200]}"
|
|
423
|
+
|
|
424
|
+
# Poll for response instead of blocking on /wait
|
|
425
|
+
deadline = time.monotonic() + OPENCODE_POLL_TIMEOUT
|
|
426
|
+
poll_client = httpx.AsyncClient(
|
|
427
|
+
base_url=OPENCODE_BASE_URL,
|
|
428
|
+
headers=HEADERS,
|
|
429
|
+
timeout=10.0,
|
|
430
|
+
)
|
|
431
|
+
# Track which part indices we've already emitted activity for (non-thinking parts only)
|
|
432
|
+
_seen_part_indices: set = set()
|
|
433
|
+
# Track last thinking text emitted to avoid redundant fires
|
|
434
|
+
_last_thinking: str = ""
|
|
435
|
+
try:
|
|
436
|
+
while time.monotonic() < deadline:
|
|
437
|
+
await asyncio.sleep(OPENCODE_POLL_INTERVAL)
|
|
438
|
+
try:
|
|
439
|
+
r = await poll_client.get(
|
|
440
|
+
f"/session/{session_id}/message",
|
|
441
|
+
params={"limit": "20"},
|
|
442
|
+
)
|
|
443
|
+
if r.status_code != 200:
|
|
444
|
+
continue
|
|
445
|
+
messages = r.json()
|
|
446
|
+
if not messages:
|
|
447
|
+
continue
|
|
448
|
+
|
|
449
|
+
# Only check the LATEST message to avoid returning old responses
|
|
450
|
+
msg = messages[-1]
|
|
451
|
+
info = msg.get("info", {})
|
|
452
|
+
role = info.get("role")
|
|
453
|
+
|
|
454
|
+
# Skip if latest message is not from assistant
|
|
455
|
+
if role != "assistant":
|
|
456
|
+
continue
|
|
457
|
+
|
|
458
|
+
parts = msg.get("parts", [])
|
|
459
|
+
|
|
460
|
+
# Check completion status
|
|
461
|
+
has_finish = any(p.get("type") == "step-finish" for p in parts)
|
|
462
|
+
finish_reason = info.get("finish") # "stop", "error", etc.
|
|
463
|
+
is_complete = has_finish or finish_reason in ("stop", "error", "length")
|
|
464
|
+
|
|
465
|
+
# Check for error parts
|
|
466
|
+
error_parts = [p for p in parts if p.get("type") == "error"]
|
|
467
|
+
if error_parts:
|
|
468
|
+
error_msg = error_parts[0].get("message", "Unknown error")
|
|
469
|
+
logger.error("Model returned error: %s", error_msg)
|
|
470
|
+
return f"Error: {error_msg}"
|
|
471
|
+
|
|
472
|
+
# --- Activity tracking: emit events for new parts ---
|
|
473
|
+
if on_activity:
|
|
474
|
+
# Accumulate full thinking text across all reasoning parts on this poll
|
|
475
|
+
accumulated_thinking = "\n\n".join(
|
|
476
|
+
(p.get("text", "") or "").strip()
|
|
477
|
+
for p in parts
|
|
478
|
+
if p.get("type") in ("reasoning", "thinking")
|
|
479
|
+
and not p.get("synthetic")
|
|
480
|
+
and (p.get("text", "") or "").strip()
|
|
481
|
+
)
|
|
482
|
+
if accumulated_thinking and accumulated_thinking != _last_thinking:
|
|
483
|
+
_last_thinking = accumulated_thinking
|
|
484
|
+
on_activity("thinking", accumulated_thinking)
|
|
485
|
+
|
|
486
|
+
for idx, p in enumerate(parts):
|
|
487
|
+
if idx in _seen_part_indices:
|
|
488
|
+
continue
|
|
489
|
+
_seen_part_indices.add(idx)
|
|
490
|
+
p_type = p.get("type", "")
|
|
491
|
+
if p.get("synthetic"):
|
|
492
|
+
continue
|
|
493
|
+
|
|
494
|
+
if p_type == "step-start":
|
|
495
|
+
on_activity("step", "🔄 New reasoning step")
|
|
496
|
+
elif p_type == "tool-use" or p_type == "tool_use":
|
|
497
|
+
tool_name = p.get("name") or p.get("tool", {}).get("name", "unknown")
|
|
498
|
+
tool_input = p.get("input") or p.get("tool", {}).get("input", {})
|
|
499
|
+
detail = str(tool_input)[:120].replace("\n", " ") if tool_input else ""
|
|
500
|
+
on_activity("tool_call", f"🔧 {tool_name}({detail})")
|
|
501
|
+
elif p_type == "tool-result" or p_type == "tool_response":
|
|
502
|
+
tool_name = p.get("name", "tool")
|
|
503
|
+
is_error = p.get("isError", False)
|
|
504
|
+
icon = "❌" if is_error else "✅"
|
|
505
|
+
on_activity("tool_result", f"{icon} {tool_name} done")
|
|
506
|
+
elif p_type == "text" and not p.get("synthetic"):
|
|
507
|
+
on_activity("text_start", "✍️ Writing response...")
|
|
508
|
+
|
|
509
|
+
# Extract text from text-type parts only (not reasoning)
|
|
510
|
+
text_parts = []
|
|
511
|
+
for p in parts:
|
|
512
|
+
p_type = p.get("type")
|
|
513
|
+
p_text = p.get("text", "")
|
|
514
|
+
p_synthetic = p.get("synthetic")
|
|
515
|
+
|
|
516
|
+
# Skip synthetic parts
|
|
517
|
+
if p_synthetic:
|
|
518
|
+
continue
|
|
519
|
+
|
|
520
|
+
# Only collect actual text parts (not reasoning/step markers)
|
|
521
|
+
if p_type == "text" and p_text:
|
|
522
|
+
text_parts.append(p_text)
|
|
523
|
+
|
|
524
|
+
text = "\n".join(text_parts).strip()
|
|
525
|
+
|
|
526
|
+
# Fire streaming callback with partial text on every poll
|
|
527
|
+
if text and on_token:
|
|
528
|
+
on_token(text)
|
|
529
|
+
|
|
530
|
+
# Return if we have text and response is complete
|
|
531
|
+
if text and is_complete:
|
|
532
|
+
logger.info("Response received (%d chars, finish=%s)", len(text), finish_reason or "step-finish")
|
|
533
|
+
return text
|
|
534
|
+
elif text:
|
|
535
|
+
logger.debug("Response partial (%d chars, waiting for completion)", len(text))
|
|
536
|
+
|
|
537
|
+
except httpx.TimeoutException:
|
|
538
|
+
continue
|
|
539
|
+
except Exception as e:
|
|
540
|
+
logger.debug("Poll error (non-fatal): %s", e)
|
|
541
|
+
continue
|
|
542
|
+
|
|
543
|
+
logger.error("Polling timed out after %ds", OPENCODE_POLL_TIMEOUT)
|
|
544
|
+
# Abort server-side stream on timeout to prevent the server from
|
|
545
|
+
# continuing to process a request nobody is listening to anymore.
|
|
546
|
+
if session_id:
|
|
547
|
+
try:
|
|
548
|
+
await self.abort_session(session_id)
|
|
549
|
+
except Exception:
|
|
550
|
+
pass
|
|
551
|
+
return "Error: Request timed out after 30 minutes."
|
|
552
|
+
finally:
|
|
553
|
+
await poll_client.aclose()
|
|
554
|
+
|
|
555
|
+
async def _send_via_worker(
|
|
556
|
+
self,
|
|
557
|
+
query: str,
|
|
558
|
+
session_id: Optional[str] = None,
|
|
559
|
+
files: Optional[list[dict]] = None,
|
|
560
|
+
provider_id: Optional[str] = None,
|
|
561
|
+
model_id: Optional[str] = None,
|
|
562
|
+
active_mode: str = "ask",
|
|
563
|
+
active_agent: str = "default",
|
|
564
|
+
provider_env: Optional[dict] = None,
|
|
565
|
+
chat_id: Optional[int] = None,
|
|
566
|
+
session_uuid: Optional[str] = None,
|
|
567
|
+
on_token: Optional[Callable[[str], None]] = None,
|
|
568
|
+
) -> str:
|
|
569
|
+
"""Send query via worker process (fully async, never blocks the event loop)."""
|
|
570
|
+
if not self.is_connected:
|
|
571
|
+
connected = await self.connect()
|
|
572
|
+
if not connected:
|
|
573
|
+
return "❌ Error: Could not connect to OpenCode backend. Is the server running?"
|
|
574
|
+
|
|
575
|
+
if not session_id:
|
|
576
|
+
session_id = await self.create_session(provider_id, model_id, env=provider_env)
|
|
577
|
+
if not session_id:
|
|
578
|
+
return "Error: Could not create OpenCode session."
|
|
579
|
+
|
|
580
|
+
self.last_session_id = session_id
|
|
581
|
+
|
|
582
|
+
memory_content = await self._get_memory_content()
|
|
583
|
+
|
|
584
|
+
mode_prompts = _system_config["mode_prompts"]
|
|
585
|
+
mode_instruction = mode_prompts.get(active_mode, mode_prompts["execute"])
|
|
586
|
+
anti_loop = _system_config["anti_loop"]
|
|
587
|
+
agent_prompts = _system_config["agent_prompts"]
|
|
588
|
+
agent_instruction = agent_prompts.get(active_agent, "")
|
|
589
|
+
|
|
590
|
+
skills_block = ""
|
|
591
|
+
try:
|
|
592
|
+
agents = await self._get_agents_cached()
|
|
593
|
+
if agents:
|
|
594
|
+
lines = ["\n\n<AVAILABLE SKILLS>"]
|
|
595
|
+
for a in agents[:10]:
|
|
596
|
+
name = a.get("name", "")
|
|
597
|
+
desc = a.get("description", "")
|
|
598
|
+
lines.append(f"- {name}: {desc}")
|
|
599
|
+
lines.append("</AVAILABLE SKILLS>\nUse these proactively when needed.")
|
|
600
|
+
skills_block = "\n".join(lines)
|
|
601
|
+
except Exception:
|
|
602
|
+
pass
|
|
603
|
+
|
|
604
|
+
chat_context = ""
|
|
605
|
+
if chat_id:
|
|
606
|
+
chat_context = f"\n\n[USER_CONTEXT]\nCURRENT_CHAT_ID: {chat_id}\nCURRENT_SESSION_ID: {session_uuid or 'unknown'}\n[END USER_CONTEXT]"
|
|
607
|
+
chat_context += "\n\n<b>FILE STORAGE</b>: When creating files, save them inside <code>data/files/</code>. Use date-based subfolders: <code>data/files/{{YYYY-MM-DD}}/{{CURRENT_SESSION_ID}}_{{HHMMSS}}_{{filename}}</code> so files are linked to sessions and dates."
|
|
608
|
+
|
|
609
|
+
tool_instruction = _system_config["tool_instruction"]
|
|
610
|
+
|
|
611
|
+
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
612
|
+
system_base = TELEGRAM_SYSTEM_PROMPT.replace("{PROJECT_ROOT}", project_root)
|
|
613
|
+
|
|
614
|
+
security_warning = ""
|
|
615
|
+
external_paths = re.findall(r'([a-zA-Z]:\\[^`"\'\s\n<>]+|/[^`"\'\s\n<>]+)', query)
|
|
616
|
+
for p in external_paths:
|
|
617
|
+
try:
|
|
618
|
+
p_abs = os.path.abspath(p)
|
|
619
|
+
if not p_abs.startswith(project_root):
|
|
620
|
+
security_warning = f"\n\n[SECURITY ALERT]\nUser requested access to external path: {p_abs}\nYou MUST call request_permission(chat_id={chat_id}, reason='...', scope='{p_abs}') before reading or processing this file.\n[END ALERT]"
|
|
621
|
+
break
|
|
622
|
+
except Exception:
|
|
623
|
+
continue
|
|
624
|
+
|
|
625
|
+
tool_context = await self._build_tool_context()
|
|
626
|
+
memory_content = await self._get_memory_content()
|
|
627
|
+
memory_block = f"\n\n[LONG-TERM MEMORY]\n{memory_content}\n[END MEMORY]" if memory_content else ""
|
|
628
|
+
memory_instruction = _system_config["memory_instruction"]
|
|
629
|
+
|
|
630
|
+
full_system_context = system_base + memory_instruction + tool_instruction + chat_context + skills_block + memory_block + security_warning + tool_context + f"\n\nCURRENT PROTOCOL: {mode_instruction}" + anti_loop + agent_instruction
|
|
631
|
+
|
|
632
|
+
payload = {
|
|
633
|
+
"parts": [
|
|
634
|
+
{
|
|
635
|
+
"type": "text",
|
|
636
|
+
"text": full_system_context,
|
|
637
|
+
"synthetic": True,
|
|
638
|
+
},
|
|
639
|
+
{
|
|
640
|
+
"type": "text",
|
|
641
|
+
"text": query,
|
|
642
|
+
},
|
|
643
|
+
]
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if files:
|
|
647
|
+
for f in files:
|
|
648
|
+
file_path = f["path"].replace('\\', '/')
|
|
649
|
+
payload["parts"].append({
|
|
650
|
+
"type": "file",
|
|
651
|
+
"url": f"file:///{file_path}",
|
|
652
|
+
"mime": f["mime"]
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
base_url = OPENCODE_BASE_URL.rstrip("/")
|
|
656
|
+
|
|
657
|
+
return await self._worker.send_query(
|
|
658
|
+
session_id=session_id,
|
|
659
|
+
payload=payload,
|
|
660
|
+
base_url=base_url,
|
|
661
|
+
callback=on_token,
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
async def get_session_status(self, session_id: str) -> str:
|
|
665
|
+
"""Fetch current assistant status from the session. Returns an HTML status string."""
|
|
666
|
+
try:
|
|
667
|
+
# Get more messages to see the flow of tool calls/responses
|
|
668
|
+
r = await self._http.get(f"/session/{session_id}/message", params={"limit": "10"})
|
|
669
|
+
if r.status_code != 200:
|
|
670
|
+
return "🧠 <i>BMO is thinking...</i>"
|
|
671
|
+
|
|
672
|
+
msgs = r.json()
|
|
673
|
+
if not msgs:
|
|
674
|
+
return "🧠 <i>BMO is preparing...</i>"
|
|
675
|
+
|
|
676
|
+
# Only check the LATEST message for status
|
|
677
|
+
msg = msgs[-1]
|
|
678
|
+
info = msg.get("info", {})
|
|
679
|
+
role = info.get("role")
|
|
680
|
+
parts = msg.get("parts", [])
|
|
681
|
+
|
|
682
|
+
if role == "tool":
|
|
683
|
+
# This is a tool response message
|
|
684
|
+
for p in parts:
|
|
685
|
+
if p.get("type") == "tool_response":
|
|
686
|
+
name = p.get("name", "tool")
|
|
687
|
+
is_error = p.get("isError", False)
|
|
688
|
+
status = "❌ Failed" if is_error else "✅ Done"
|
|
689
|
+
status_str = f"⚙️ <b>{name}</b>: {status}"
|
|
690
|
+
|
|
691
|
+
# Log to terminal if changed
|
|
692
|
+
if not hasattr(self, '_last_log') or self._last_log != status_str:
|
|
693
|
+
self._last_log = status_str
|
|
694
|
+
print(f"[BMO STATUS] {status_str.replace('<b>','').replace('</b>','')}")
|
|
695
|
+
|
|
696
|
+
return status_str
|
|
697
|
+
|
|
698
|
+
if role == "assistant":
|
|
699
|
+
if not parts:
|
|
700
|
+
return "🧠 <i>BMO is thinking...</i>"
|
|
701
|
+
|
|
702
|
+
# Check for tool calls first (highest priority for status)
|
|
703
|
+
for p in reversed(parts):
|
|
704
|
+
if p.get("type") == "tool_call":
|
|
705
|
+
name = p.get("name")
|
|
706
|
+
args = p.get("arguments", "")
|
|
707
|
+
# Truncate args for display
|
|
708
|
+
arg_snippet = str(args)[:40] + "..." if len(str(args)) > 40 else str(args)
|
|
709
|
+
status_str = f"🛠️ <b>Using {name}</b>\n<code>{arg_snippet}</code>"
|
|
710
|
+
|
|
711
|
+
# Log to terminal if changed
|
|
712
|
+
if not hasattr(self, '_last_log') or self._last_log != status_str:
|
|
713
|
+
self._last_log = status_str
|
|
714
|
+
print(f"[BMO TOOL] {name}({arg_snippet})")
|
|
715
|
+
|
|
716
|
+
return status_str
|
|
717
|
+
|
|
718
|
+
# Show reasoning text for UX (user wants to see what model is thinking)
|
|
719
|
+
for p in reversed(parts):
|
|
720
|
+
if p.get("type") == "reasoning" and p.get("text"):
|
|
721
|
+
snippet = p.get("text").strip()[:80].replace("\n", " ")
|
|
722
|
+
status_str = f"🧠 <i>{snippet}...</i>"
|
|
723
|
+
return status_str
|
|
724
|
+
|
|
725
|
+
# Fallback to text snippets
|
|
726
|
+
for p in reversed(parts):
|
|
727
|
+
if p.get("type") == "text" and p.get("text") and not p.get("synthetic"):
|
|
728
|
+
snippet = p.get("text").strip()[:60].replace("\n", " ")
|
|
729
|
+
status_str = f"✍️ <i>{snippet}...</i>"
|
|
730
|
+
return status_str
|
|
731
|
+
|
|
732
|
+
return "🧠 <i>BMO is thinking...</i>"
|
|
733
|
+
except Exception as e:
|
|
734
|
+
logger.debug("Error in get_session_status: %s", e)
|
|
735
|
+
return "🧠 <i>BMO is thinking...</i>"
|
|
736
|
+
|
|
737
|
+
# ── Provider API verification map ──────────────────────────────────────────
|
|
738
|
+
# Maps provider_id → (api_base_url, env_key_name, test_endpoint, auth_header)
|
|
739
|
+
PROVIDER_API_MAP = {
|
|
740
|
+
"groq": ("https://api.groq.com/openai/v1", "GROQ_API_KEY", "/models", "Bearer"),
|
|
741
|
+
"openai": ("https://api.openai.com/v1", "OPENAI_API_KEY", "/models", "Bearer"),
|
|
742
|
+
"anthropic": ("https://api.anthropic.com/v1", "ANTHROPIC_API_KEY", "/models", "x-api-key"),
|
|
743
|
+
"deepseek": ("https://api.deepseek.com/v1", "DEEPSEEK_API_KEY", "/models", "Bearer"),
|
|
744
|
+
"openrouter": ("https://openrouter.ai/api/v1", "OPENROUTER_API_KEY", "/models", "Bearer"),
|
|
745
|
+
"google": ("https://generativelanguage.googleapis.com", "GOOGLE_API_KEY", "/v1beta/models", "query"),
|
|
746
|
+
"mistral": ("https://api.mistral.ai/v1", "MISTRAL_API_KEY", "/models", "Bearer"),
|
|
747
|
+
"xai": ("https://api.x.ai/v1", "XAI_API_KEY", "/models", "Bearer"),
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
async def _verify_key_directly(self, provider_id: str, env: dict) -> tuple[bool, str]:
|
|
751
|
+
"""Try to verify an API key via a direct call to the provider's API."""
|
|
752
|
+
provider_cfg = self.PROVIDER_API_MAP.get(provider_id)
|
|
753
|
+
if not provider_cfg:
|
|
754
|
+
return False, "" # No direct verification method for this provider
|
|
755
|
+
|
|
756
|
+
base_url, key_name, endpoint, auth_type = provider_cfg
|
|
757
|
+
api_key = env.get(key_name)
|
|
758
|
+
if not api_key:
|
|
759
|
+
return False, ""
|
|
760
|
+
|
|
761
|
+
try:
|
|
762
|
+
headers = {"Content-Type": "application/json"}
|
|
763
|
+
url = f"{base_url}{endpoint}"
|
|
764
|
+
|
|
765
|
+
if auth_type == "query":
|
|
766
|
+
url = f"{url}?key={api_key}"
|
|
767
|
+
elif auth_type == "x-api-key":
|
|
768
|
+
headers["x-api-key"] = api_key
|
|
769
|
+
else:
|
|
770
|
+
headers["Authorization"] = f"{auth_type} {api_key}"
|
|
771
|
+
|
|
772
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
773
|
+
r = await client.get(url, headers=headers)
|
|
774
|
+
if r.status_code == 200:
|
|
775
|
+
return True, "Verification successful."
|
|
776
|
+
elif r.status_code == 401:
|
|
777
|
+
return False, "Invalid API key — provider returned 401 Unauthorized."
|
|
778
|
+
elif r.status_code == 403:
|
|
779
|
+
return False, "API key lacks permission — provider returned 403 Forbidden."
|
|
780
|
+
else:
|
|
781
|
+
return False, f"Provider returned HTTP {r.status_code}."
|
|
782
|
+
except httpx.TimeoutException:
|
|
783
|
+
return False, "Provider API timed out."
|
|
784
|
+
except Exception as e:
|
|
785
|
+
logger.debug("Direct key verification failed for %s: %s", provider_id, e)
|
|
786
|
+
return False, ""
|
|
787
|
+
|
|
788
|
+
async def test_provider_key(self, provider_id: str, model_id: str, env: dict) -> tuple[bool, str]:
|
|
789
|
+
"""
|
|
790
|
+
Tests an API key by making a minimal request with a short timeout.
|
|
791
|
+
Returns (is_success, detail_message)
|
|
792
|
+
"""
|
|
793
|
+
# 1. Try direct provider API verification first (more reliable)
|
|
794
|
+
direct_success, direct_message = await self._verify_key_directly(provider_id, env)
|
|
795
|
+
if direct_message:
|
|
796
|
+
return direct_success, direct_message
|
|
797
|
+
|
|
798
|
+
# 2. Fall back to OpenCode session-based test
|
|
799
|
+
try:
|
|
800
|
+
session_id = await self.create_session(provider_id, model_id, env=env)
|
|
801
|
+
if not session_id:
|
|
802
|
+
return False, "Failed to initialize test session on OpenCode server."
|
|
803
|
+
|
|
804
|
+
payload = {
|
|
805
|
+
"parts": [{"type": "text", "text": "respond only with 'ok'"}]
|
|
806
|
+
}
|
|
807
|
+
r = await self._http.post(f"/session/{session_id}/message", json=payload, timeout=30.0)
|
|
808
|
+
if r.status_code not in (200, 201):
|
|
809
|
+
return False, f"Server error ({r.status_code}): {r.text[:100]}"
|
|
810
|
+
|
|
811
|
+
for _ in range(60):
|
|
812
|
+
await asyncio.sleep(1)
|
|
813
|
+
r_poll = await self._http.get(f"/session/{session_id}/message", params={"limit": "5"})
|
|
814
|
+
if r_poll.status_code == 200:
|
|
815
|
+
msgs = r_poll.json()
|
|
816
|
+
for m in msgs:
|
|
817
|
+
role = m.get("info", {}).get("role")
|
|
818
|
+
if role == "assistant":
|
|
819
|
+
parts = m.get("parts", [])
|
|
820
|
+
text_parts = [p.get("text", "") for p in parts if p.get("type") == "text" and not p.get("synthetic")]
|
|
821
|
+
if any(text_parts):
|
|
822
|
+
return True, "Verification successful."
|
|
823
|
+
|
|
824
|
+
error_parts = [p.get("message", "Unknown error") for p in parts if p.get("type") == "error"]
|
|
825
|
+
if error_parts:
|
|
826
|
+
return False, f"Provider Error: {error_parts[0]}"
|
|
827
|
+
|
|
828
|
+
return False, "Test timed out. The provider took too long to respond (likely invalid key or network issue)."
|
|
829
|
+
|
|
830
|
+
except httpx.TimeoutException:
|
|
831
|
+
return False, "Request timed out. Check your internet connection or the API key status."
|
|
832
|
+
except Exception as e:
|
|
833
|
+
logger.error("Test key error: %s", e)
|
|
834
|
+
return False, f"System error during test: {str(e)}"
|
|
835
|
+
|
|
836
|
+
async def close(self):
|
|
837
|
+
await self._http.aclose()
|
|
838
|
+
|