Add client-server Qwen OAuth bot skeleton

This commit is contained in:
mirivlad 2026-04-07 16:38:21 +08:00
commit 59792f5d5f
13 changed files with 1014 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
__pycache__/
*.pyc
.env
.new-qwen/

80
README.md Normal file
View File

@ -0,0 +1,80 @@
# new-qwen
Клиент-серверная замена локального агента `qwen-code`.
- `serv` отвечает за OAuth, сессии, работу с Qwen LLM и вызов инструментов.
- `bot` отвечает за Telegram и пересылку сообщений на сервер.
Проект написан на Python stdlib, чтобы не зависеть от Node/npm в текущем окружении.
## Архитектура
```text
Telegram User
|
v
bot/app.py
|
v
serv/app.py
|
v
Qwen OAuth + OpenAI-compatible endpoint
```
## Что уже реализовано
- Qwen OAuth Device Flow, совместимый с `qwen-code`
- хранение токенов в `~/.qwen/oauth_creds.json`
- HTTP API сервера
- агентный цикл с tool calling
- базовые инструменты: `list_files`, `read_file`, `write_file`, `exec_command`
- Telegram polling без внешних библиотек
- JSON-хранилище сессий
## Ограничения текущей реализации
- это упрощённая серверная архитектура, а не побайтный порт всего `qwen-code`
- пока нет MCP, skill system, subagents и rich-streaming UI
- Telegram-бот работает через long polling
## Переменные окружения
Сервер:
```bash
cp serv/.env.example serv/.env
```
Бот:
```bash
cp bot/.env.example bot/.env
```
## Запуск
Сервер:
```bash
python3 serv/app.py
```
Бот:
```bash
python3 bot/app.py
```
## Авторизация Qwen OAuth
1. Отправить боту `/auth`
2. Открыть ссылку подтверждения
3. После завершения авторизации писать обычные сообщения боту
Либо можно вызвать API сервера напрямую:
```bash
curl -X POST http://127.0.0.1:8080/api/v1/auth/device/start
```

4
bot/.env.example Normal file
View File

@ -0,0 +1,4 @@
NEW_QWEN_BOT_TOKEN=0000000000:telegram-token
NEW_QWEN_SERVER_URL=http://127.0.0.1:8080
NEW_QWEN_POLL_TIMEOUT=30

142
bot/app.py Normal file
View File

@ -0,0 +1,142 @@
from __future__ import annotations
import json
import time
from pathlib import Path
from typing import Any
from urllib import request
from config import BotConfig
from telegram_api import TelegramAPI
STATE_FILE = Path(__file__).resolve().parent.parent / ".new-qwen" / "telegram-state.json"
def load_state() -> dict[str, Any]:
if not STATE_FILE.exists():
return {"offset": None, "sessions": {}}
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
def save_state(state: dict[str, Any]) -> None:
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
def post_json(url: str, payload: dict[str, Any]) -> dict[str, Any]:
data = json.dumps(payload).encode("utf-8")
req = request.Request(
url,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
with request.urlopen(req, timeout=300) as response:
return json.loads(response.read().decode("utf-8"))
def get_json(url: str) -> dict[str, Any]:
with request.urlopen(url, timeout=60) as response:
return json.loads(response.read().decode("utf-8"))
def ensure_auth(api: TelegramAPI, config: BotConfig, chat_id: int) -> bool:
status = get_json(f"{config.server_url}/api/v1/auth/status")
if status.get("authenticated"):
return True
started = post_json(f"{config.server_url}/api/v1/auth/device/start", {})
api.send_message(
chat_id,
"Qwen OAuth не настроен.\n"
f"Откройте ссылку:\n{started['verification_uri_complete']}\n\n"
f"Потом отправьте /auth_check {started['flow_id']}",
)
return False
def handle_message(api: TelegramAPI, config: BotConfig, state: dict[str, Any], message: dict[str, Any]) -> None:
chat_id = message["chat"]["id"]
user_id = str(message.get("from", {}).get("id", chat_id))
text = (message.get("text") or "").strip()
if not text:
api.send_message(chat_id, "Поддерживаются только текстовые сообщения.")
return
session_key = f"{chat_id}:{user_id}"
session_id = state.setdefault("sessions", {}).get(session_key)
if text == "/start":
api.send_message(chat_id, "new-qwen bot готов. Команды: /auth, /clear.")
return
if text == "/auth":
started = post_json(f"{config.server_url}/api/v1/auth/device/start", {})
api.send_message(
chat_id,
"Откройте ссылку для авторизации Qwen OAuth:\n"
f"{started['verification_uri_complete']}\n\n"
f"После подтверждения отправьте /auth_check {started['flow_id']}",
)
return
if text.startswith("/auth_check"):
parts = text.split(maxsplit=1)
if len(parts) != 2:
api.send_message(chat_id, "Использование: /auth_check <flow_id>")
return
result = post_json(
f"{config.server_url}/api/v1/auth/device/poll",
{"flow_id": parts[1]},
)
if result.get("done"):
api.send_message(chat_id, "Qwen OAuth успешно настроен.")
else:
api.send_message(chat_id, "Авторизация ещё не завершена. Повторите команду через пару секунд.")
return
if text == "/clear":
if session_id:
post_json(f"{config.server_url}/api/v1/session/clear", {"session_id": session_id})
state["sessions"].pop(session_key, None)
api.send_message(chat_id, "Контекст сессии очищен.")
return
if not ensure_auth(api, config, chat_id):
return
api.send_message(chat_id, "Обрабатываю запрос...")
result = post_json(
f"{config.server_url}/api/v1/chat",
{
"session_id": session_id,
"user_id": user_id,
"message": text,
},
)
state["sessions"][session_key] = result["session_id"]
answer = result.get("answer") or "Пустой ответ от модели."
api.send_message(chat_id, answer[:4000])
def main() -> None:
config = BotConfig.load()
api = TelegramAPI(config.token)
state = load_state()
print("new-qwen bot polling started")
while True:
try:
updates = api.get_updates(state.get("offset"), config.poll_timeout)
for update in updates:
state["offset"] = update["update_id"] + 1
message = update.get("message")
if message:
handle_message(api, config, state, message)
save_state(state)
except Exception as exc:
print(f"bot loop error: {exc}")
time.sleep(3)
if __name__ == "__main__":
main()

37
bot/config.py Normal file
View File

@ -0,0 +1,37 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
def load_env_file(path: Path) -> None:
if not path.exists():
return
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip())
@dataclass(slots=True)
class BotConfig:
token: str
server_url: str
poll_timeout: int
@classmethod
def load(cls) -> "BotConfig":
base_dir = Path(__file__).resolve().parent
load_env_file(base_dir / ".env")
token = os.environ.get("NEW_QWEN_BOT_TOKEN", "").strip()
if not token:
raise RuntimeError("NEW_QWEN_BOT_TOKEN is required")
return cls(
token=token,
server_url=os.environ.get("NEW_QWEN_SERVER_URL", "http://127.0.0.1:8080").rstrip("/"),
poll_timeout=int(os.environ.get("NEW_QWEN_POLL_TIMEOUT", "30")),
)

37
bot/telegram_api.py Normal file
View File

@ -0,0 +1,37 @@
from __future__ import annotations
import json
from typing import Any
from urllib import parse, request
class TelegramAPI:
def __init__(self, token: str) -> None:
self.base_url = f"https://api.telegram.org/bot{token}"
def _post(self, method: str, payload: dict[str, Any]) -> dict[str, Any]:
data = json.dumps(payload).encode("utf-8")
req = request.Request(
f"{self.base_url}/{method}",
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
with request.urlopen(req, timeout=90) as response:
return json.loads(response.read().decode("utf-8"))
def _get(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
query = parse.urlencode(params)
with request.urlopen(f"{self.base_url}/{method}?{query}", timeout=90) as response:
return json.loads(response.read().decode("utf-8"))
def get_updates(self, offset: int | None, timeout: int) -> list[dict[str, Any]]:
params: dict[str, Any] = {"timeout": timeout}
if offset is not None:
params["offset"] = offset
response = self._get("getUpdates", params)
return response.get("result", [])
def send_message(self, chat_id: int, text: str) -> None:
self._post("sendMessage", {"chat_id": chat_id, "text": text})

8
serv/.env.example Normal file
View File

@ -0,0 +1,8 @@
NEW_QWEN_HOST=127.0.0.1
NEW_QWEN_PORT=8080
NEW_QWEN_MODEL=qwen3.6-plus
NEW_QWEN_WORKSPACE_ROOT=/home/mirivlad/git
NEW_QWEN_SESSION_DIR=/home/mirivlad/git/new-qwen/.new-qwen/sessions
NEW_QWEN_SYSTEM_PROMPT=
NEW_QWEN_MAX_TOOL_ROUNDS=8

166
serv/app.py Normal file
View File

@ -0,0 +1,166 @@
from __future__ import annotations
import json
import threading
import time
import uuid
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from config import ServerConfig
from llm import QwenAgent
from oauth import DeviceAuthState, OAuthError, QwenOAuthManager
from sessions import SessionStore
from tools import ToolRegistry
class AppState:
def __init__(self, config: ServerConfig) -> None:
self.config = config
self.oauth = QwenOAuthManager()
self.sessions = SessionStore(config.session_dir)
self.tools = ToolRegistry(config.workspace_root)
self.agent = QwenAgent(config, self.oauth, self.tools)
self.pending_device_flows: dict[str, DeviceAuthState] = {}
self.lock = threading.Lock()
def auth_status(self) -> dict[str, Any]:
creds = self.oauth.load_credentials()
if not creds:
return {"authenticated": False}
return {
"authenticated": True,
"resource_url": creds.get("resource_url"),
"expires_at": creds.get("expiry_date"),
}
class RequestHandler(BaseHTTPRequestHandler):
server_version = "new-qwen-serv/0.1"
def _json_body(self) -> dict[str, Any]:
length = int(self.headers.get("Content-Length", "0"))
if length <= 0:
return {}
raw = self.rfile.read(length).decode("utf-8")
return json.loads(raw) if raw else {}
def _send(self, status: int, payload: dict[str, Any]) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
@property
def app(self) -> AppState:
return self.server.app_state # type: ignore[attr-defined]
def do_GET(self) -> None:
if self.path == "/health":
self._send(HTTPStatus.OK, {"ok": True, "time": int(time.time())})
return
if self.path == "/api/v1/auth/status":
self._send(HTTPStatus.OK, self.app.auth_status())
return
self._send(HTTPStatus.NOT_FOUND, {"error": "Not found"})
def do_POST(self) -> None:
try:
if self.path == "/api/v1/auth/device/start":
flow_id = uuid.uuid4().hex
state = self.app.oauth.start_device_flow(open_browser=False)
with self.app.lock:
self.app.pending_device_flows[flow_id] = state
self._send(
HTTPStatus.OK,
{
"flow_id": flow_id,
"user_code": state.user_code,
"verification_uri": state.verification_uri,
"verification_uri_complete": state.verification_uri_complete,
"expires_at": state.expires_at,
"interval_seconds": state.interval_seconds,
},
)
return
if self.path == "/api/v1/auth/device/poll":
body = self._json_body()
flow_id = body["flow_id"]
with self.app.lock:
state = self.app.pending_device_flows.get(flow_id)
if not state:
self._send(HTTPStatus.NOT_FOUND, {"error": "Unknown flow_id"})
return
creds = self.app.oauth.poll_device_flow(state)
if creds is None:
self._send(HTTPStatus.OK, {"done": False, "interval_seconds": state.interval_seconds})
return
with self.app.lock:
self.app.pending_device_flows.pop(flow_id, None)
self._send(HTTPStatus.OK, {"done": True, "credentials": {"resource_url": creds.get("resource_url")}})
return
if self.path == "/api/v1/chat":
body = self._json_body()
session_id = body.get("session_id") or uuid.uuid4().hex
user_id = str(body.get("user_id") or "anonymous")
message = body["message"]
session = self.app.sessions.load(session_id)
history = session.get("messages", [])
result = self.app.agent.run(history, message)
persisted_messages = result["messages"][1:]
self.app.sessions.save(
session_id,
{
"session_id": session_id,
"user_id": user_id,
"updated_at": int(time.time()),
"messages": persisted_messages,
},
)
self._send(
HTTPStatus.OK,
{
"session_id": session_id,
"answer": result["answer"],
"events": result["events"],
"usage": result.get("usage"),
},
)
return
if self.path == "/api/v1/session/clear":
body = self._json_body()
session_id = body["session_id"]
self.app.sessions.clear(session_id)
self._send(HTTPStatus.OK, {"ok": True, "session_id": session_id})
return
self._send(HTTPStatus.NOT_FOUND, {"error": "Not found"})
except OAuthError as exc:
self._send(HTTPStatus.BAD_GATEWAY, {"error": str(exc)})
except KeyError as exc:
self._send(HTTPStatus.BAD_REQUEST, {"error": f"Missing field: {exc}"})
except Exception as exc:
self._send(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": str(exc)})
def log_message(self, fmt: str, *args: Any) -> None:
return
def main() -> None:
config = ServerConfig.load()
config.session_dir.mkdir(parents=True, exist_ok=True)
httpd = ThreadingHTTPServer((config.host, config.port), RequestHandler)
httpd.app_state = AppState(config) # type: ignore[attr-defined]
print(f"new-qwen serv listening on http://{config.host}:{config.port}")
httpd.serve_forever()
if __name__ == "__main__":
main()

51
serv/config.py Normal file
View File

@ -0,0 +1,51 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
def load_env_file(path: Path) -> None:
if not path.exists():
return
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip())
@dataclass(slots=True)
class ServerConfig:
host: str
port: int
model: str
workspace_root: Path
session_dir: Path
system_prompt: str
max_tool_rounds: int
@classmethod
def load(cls) -> "ServerConfig":
base_dir = Path(__file__).resolve().parent
load_env_file(base_dir / ".env")
workspace_root = Path(
os.environ.get("NEW_QWEN_WORKSPACE_ROOT", str(Path.cwd()))
).expanduser()
session_dir = Path(
os.environ.get(
"NEW_QWEN_SESSION_DIR",
str(base_dir.parent / ".new-qwen" / "sessions"),
)
).expanduser()
return cls(
host=os.environ.get("NEW_QWEN_HOST", "127.0.0.1"),
port=int(os.environ.get("NEW_QWEN_PORT", "8080")),
model=os.environ.get("NEW_QWEN_MODEL", "qwen3.6-plus"),
workspace_root=workspace_root.resolve(),
session_dir=session_dir.resolve(),
system_prompt=os.environ.get("NEW_QWEN_SYSTEM_PROMPT", "").strip(),
max_tool_rounds=int(os.environ.get("NEW_QWEN_MAX_TOOL_ROUNDS", "8")),
)

104
serv/llm.py Normal file
View File

@ -0,0 +1,104 @@
from __future__ import annotations
import json
from typing import Any
from urllib import request
from config import ServerConfig
from oauth import OAuthError, QwenOAuthManager
from tools import ToolError, ToolRegistry
DEFAULT_SYSTEM_PROMPT = """You are new-qwen-serv, a server-side coding agent.
You help the user through a remote client.
Use tools when they are necessary.
Be concise, precise and action-oriented.
When you modify files, explain what changed.
Do not claim to have executed tools unless a tool result confirms it."""
class QwenAgent:
def __init__(self, config: ServerConfig, oauth: QwenOAuthManager, tools: ToolRegistry) -> None:
self.config = config
self.oauth = oauth
self.tools = tools
def _request_completion(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
creds = self.oauth.get_valid_credentials()
base_url = self.oauth.get_openai_base_url(creds)
payload = {
"model": self.config.model,
"messages": messages,
"tools": self.tools.schemas(),
"tool_choice": "auto",
}
data = json.dumps(payload).encode("utf-8")
req = request.Request(
f"{base_url}/chat/completions",
data=data,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {creds['access_token']}",
},
method="POST",
)
with request.urlopen(req, timeout=180) as response:
return json.loads(response.read().decode("utf-8"))
def run(self, history: list[dict[str, Any]], user_message: str) -> dict[str, Any]:
system_prompt = self.config.system_prompt or DEFAULT_SYSTEM_PROMPT
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
messages.extend(history)
messages.append({"role": "user", "content": user_message})
events: list[dict[str, Any]] = []
for _ in range(self.config.max_tool_rounds):
response = self._request_completion(messages)
choice = response["choices"][0]["message"]
tool_calls = choice.get("tool_calls") or []
content = choice.get("content")
if content:
events.append({"type": "assistant", "content": content})
if not tool_calls:
return {
"answer": content or "",
"events": events,
"usage": response.get("usage"),
"messages": messages + [{"role": "assistant", "content": content or ""}],
}
messages.append(
{
"role": "assistant",
"content": content or "",
"tool_calls": tool_calls,
}
)
for call in tool_calls:
tool_name = call["function"]["name"]
try:
arguments = json.loads(call["function"]["arguments"] or "{}")
except json.JSONDecodeError:
arguments = {}
events.append({"type": "tool_call", "name": tool_name, "arguments": arguments})
try:
result = self.tools.execute(tool_name, arguments)
except ToolError as exc:
result = {"error": str(exc)}
except Exception as exc:
result = {"error": f"Unexpected tool failure: {exc}"}
events.append({"type": "tool_result", "name": tool_name, "result": result})
messages.append(
{
"role": "tool",
"tool_call_id": call["id"],
"content": self.tools.encode_result(result),
}
)
raise OAuthError("Max tool rounds exceeded")

184
serv/oauth.py Normal file
View File

@ -0,0 +1,184 @@
from __future__ import annotations
import base64
import hashlib
import json
import secrets
import time
import uuid
import webbrowser
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib import error, parse, request
QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai"
QWEN_DEVICE_CODE_ENDPOINT = f"{QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code"
QWEN_TOKEN_ENDPOINT = f"{QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token"
QWEN_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56"
QWEN_SCOPE = "openid profile email model.completion"
QWEN_DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
class OAuthError(RuntimeError):
pass
@dataclass(slots=True)
class DeviceAuthState:
device_code: str
code_verifier: str
user_code: str
verification_uri: str
verification_uri_complete: str
expires_at: float
interval_seconds: float
class QwenOAuthManager:
def __init__(self, creds_path: Path | None = None) -> None:
self.creds_path = creds_path or Path.home() / ".qwen" / "oauth_creds.json"
self.creds_path.parent.mkdir(parents=True, exist_ok=True)
def _post_form(self, url: str, payload: dict[str, str]) -> dict[str, Any]:
data = parse.urlencode(payload).encode("utf-8")
req = request.Request(
url,
data=data,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
"x-request-id": str(uuid.uuid4()),
},
method="POST",
)
try:
with request.urlopen(req, timeout=60) as response:
return json.loads(response.read().decode("utf-8"))
except error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
try:
payload = json.loads(body)
except json.JSONDecodeError:
raise OAuthError(f"HTTP {exc.code}: {body}") from exc
message = payload.get("error_description") or payload.get("error") or body
raise OAuthError(message) from exc
def load_credentials(self) -> dict[str, Any] | None:
if not self.creds_path.exists():
return None
return json.loads(self.creds_path.read_text(encoding="utf-8"))
def save_credentials(self, payload: dict[str, Any]) -> None:
self.creds_path.write_text(
json.dumps(payload, ensure_ascii=True, indent=2),
encoding="utf-8",
)
def clear_credentials(self) -> None:
if self.creds_path.exists():
self.creds_path.unlink()
def start_device_flow(self, open_browser: bool = False) -> DeviceAuthState:
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("ascii").rstrip("=")
code_challenge = (
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("utf-8")).digest())
.decode("ascii")
.rstrip("=")
)
response = self._post_form(
QWEN_DEVICE_CODE_ENDPOINT,
{
"client_id": QWEN_CLIENT_ID,
"scope": QWEN_SCOPE,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
},
)
state = DeviceAuthState(
device_code=response["device_code"],
code_verifier=code_verifier,
user_code=response["user_code"],
verification_uri=response["verification_uri"],
verification_uri_complete=response["verification_uri_complete"],
expires_at=time.time() + float(response.get("expires_in", 600)),
interval_seconds=2.0,
)
if open_browser:
try:
webbrowser.open(state.verification_uri_complete)
except Exception:
pass
return state
def poll_device_flow(self, state: DeviceAuthState) -> dict[str, Any] | None:
if time.time() >= state.expires_at:
raise OAuthError("Device authorization expired")
try:
response = self._post_form(
QWEN_TOKEN_ENDPOINT,
{
"grant_type": QWEN_DEVICE_GRANT,
"client_id": QWEN_CLIENT_ID,
"device_code": state.device_code,
"code_verifier": state.code_verifier,
},
)
except OAuthError as exc:
text = str(exc)
if "authorization_pending" in text:
return None
if "slow_down" in text:
state.interval_seconds = min(state.interval_seconds * 1.5, 10.0)
return None
raise
creds = {
"access_token": response["access_token"],
"refresh_token": response.get("refresh_token"),
"token_type": response.get("token_type", "Bearer"),
"resource_url": response.get("resource_url"),
"expiry_date": int(time.time() * 1000) + int(response.get("expires_in", 3600)) * 1000,
}
self.save_credentials(creds)
return creds
def refresh_credentials(self, creds: dict[str, Any]) -> dict[str, Any]:
refresh_token = creds.get("refresh_token")
if not refresh_token:
raise OAuthError("No refresh token available")
response = self._post_form(
QWEN_TOKEN_ENDPOINT,
{
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": QWEN_CLIENT_ID,
},
)
updated = {
"access_token": response["access_token"],
"refresh_token": response.get("refresh_token") or refresh_token,
"token_type": response.get("token_type", "Bearer"),
"resource_url": response.get("resource_url") or creds.get("resource_url"),
"expiry_date": int(time.time() * 1000) + int(response.get("expires_in", 3600)) * 1000,
}
self.save_credentials(updated)
return updated
def get_valid_credentials(self) -> dict[str, Any]:
creds = self.load_credentials()
if not creds:
raise OAuthError("Qwen OAuth is not configured. Start device flow first.")
expiry = int(creds.get("expiry_date", 0))
if expiry and expiry - int(time.time() * 1000) > 30_000:
return creds
return self.refresh_credentials(creds)
def get_openai_base_url(self, creds: dict[str, Any]) -> str:
resource_url = creds.get("resource_url") or "https://dashscope.aliyuncs.com/compatible-mode"
if not resource_url.startswith("http"):
resource_url = f"https://{resource_url}"
if resource_url.endswith("/v1"):
return resource_url
return resource_url.rstrip("/") + "/v1"

43
serv/sessions.py Normal file
View File

@ -0,0 +1,43 @@
from __future__ import annotations
import json
import threading
from pathlib import Path
from typing import Any
class SessionStore:
def __init__(self, base_dir: Path) -> None:
self.base_dir = base_dir
self.base_dir.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
def _path(self, session_id: str) -> Path:
return self.base_dir / f"{session_id}.json"
def load(self, session_id: str) -> dict[str, Any]:
path = self._path(session_id)
if not path.exists():
return {"session_id": session_id, "messages": []}
return json.loads(path.read_text(encoding="utf-8"))
def save(self, session_id: str, payload: dict[str, Any]) -> None:
with self._lock:
self._path(session_id).write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def append_message(self, session_id: str, message: dict[str, Any]) -> dict[str, Any]:
with self._lock:
payload = self.load(session_id)
payload.setdefault("messages", []).append(message)
self.save(session_id, payload)
return payload
def clear(self, session_id: str) -> None:
with self._lock:
path = self._path(session_id)
if path.exists():
path.unlink()

153
serv/tools.py Normal file
View File

@ -0,0 +1,153 @@
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from typing import Any, Callable
class ToolError(RuntimeError):
pass
class ToolRegistry:
def __init__(self, workspace_root: Path) -> None:
self.workspace_root = workspace_root.resolve()
self._handlers: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
"list_files": self.list_files,
"read_file": self.read_file,
"write_file": self.write_file,
"exec_command": self.exec_command,
}
def schemas(self) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": "list_files",
"description": "List files in a directory inside the workspace.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string"},
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a UTF-8 text file from the workspace.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string"},
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "write_file",
"description": "Write UTF-8 text into a file inside the workspace.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
},
"required": ["path", "content"],
},
},
},
{
"type": "function",
"function": {
"name": "exec_command",
"description": "Run a shell command inside the workspace and return stdout, stderr and exit code.",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string"},
"cwd": {"type": "string"},
},
"required": ["command"],
},
},
},
]
def _resolve(self, raw_path: str) -> Path:
candidate = Path(raw_path)
if not candidate.is_absolute():
candidate = self.workspace_root / candidate
resolved = candidate.resolve()
if self.workspace_root not in resolved.parents and resolved != self.workspace_root:
raise ToolError("Path escapes workspace root")
return resolved
def execute(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
handler = self._handlers.get(name)
if not handler:
raise ToolError(f"Unknown tool: {name}")
return handler(arguments)
def list_files(self, arguments: dict[str, Any]) -> dict[str, Any]:
target = self._resolve(arguments["path"])
if not target.exists():
raise ToolError("Directory does not exist")
if not target.is_dir():
raise ToolError("Path is not a directory")
items = []
for item in sorted(target.iterdir(), key=lambda value: value.name):
items.append(
{
"name": item.name,
"path": str(item.relative_to(self.workspace_root)),
"type": "dir" if item.is_dir() else "file",
}
)
return {"items": items}
def read_file(self, arguments: dict[str, Any]) -> dict[str, Any]:
target = self._resolve(arguments["path"])
if not target.exists():
raise ToolError("File does not exist")
if not target.is_file():
raise ToolError("Path is not a file")
return {"path": str(target.relative_to(self.workspace_root)), "content": target.read_text(encoding="utf-8")}
def write_file(self, arguments: dict[str, Any]) -> dict[str, Any]:
target = self._resolve(arguments["path"])
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(arguments["content"], encoding="utf-8")
return {"path": str(target.relative_to(self.workspace_root)), "bytes_written": len(arguments["content"].encode("utf-8"))}
def exec_command(self, arguments: dict[str, Any]) -> dict[str, Any]:
cwd = self._resolve(arguments.get("cwd", "."))
command = arguments["command"]
completed = subprocess.run(
command,
cwd=str(cwd),
shell=True,
capture_output=True,
text=True,
timeout=120,
)
return {
"command": command,
"cwd": str(cwd.relative_to(self.workspace_root)),
"returncode": completed.returncode,
"stdout": completed.stdout[-12000:],
"stderr": completed.stderr[-12000:],
}
@staticmethod
def encode_result(result: dict[str, Any]) -> str:
return json.dumps(result, ensure_ascii=False)