xg-cli 1.0__py3-none-any.whl
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.
- xg/__init__.py +3 -0
- xg/__main__.py +3 -0
- xg/adaptive/__init__.py +27 -0
- xg/adaptive/calibrate.py +191 -0
- xg/adaptive/feedback.py +170 -0
- xg/adaptive/learned_rules.py +284 -0
- xg/adaptive/signals.py +125 -0
- xg/adaptive/store.py +164 -0
- xg/agent/__init__.py +0 -0
- xg/agent/plan.py +631 -0
- xg/agent/react.py +268 -0
- xg/agent/team.py +1793 -0
- xg/assets/router.lgb +0 -0
- xg/assets/router_semantics.json +21278 -0
- xg/assets/router_semantics.onnx +0 -0
- xg/cli/__init__.py +0 -0
- xg/cli/app.py +1372 -0
- xg/cli/commands.py +921 -0
- xg/cli/completion.py +740 -0
- xg/cli/help.py +185 -0
- xg/cli/train.py +200 -0
- xg/config/__init__.py +0 -0
- xg/config/env_writer.py +121 -0
- xg/config/manager.py +446 -0
- xg/config/mcp.py +229 -0
- xg/config/provider_service.py +267 -0
- xg/config/providers.py +46 -0
- xg/config/settings.py +258 -0
- xg/config/skills.py +100 -0
- xg/config/smart_router_service.py +121 -0
- xg/config/web.py +140 -0
- xg/input_history/__init__.py +7 -0
- xg/input_history/models.py +28 -0
- xg/input_history/persistence.py +126 -0
- xg/input_history/policy.py +39 -0
- xg/input_history/prompt_toolkit.py +36 -0
- xg/input_history/store.py +118 -0
- xg/llm/__init__.py +0 -0
- xg/llm/client.py +49 -0
- xg/llm/factory.py +40 -0
- xg/llm/openai_compat.py +275 -0
- xg/llm/types.py +98 -0
- xg/mcp/__init__.py +4 -0
- xg/mcp/http.py +192 -0
- xg/mcp/manager.py +726 -0
- xg/mcp/models.py +86 -0
- xg/mcp/protocol.py +62 -0
- xg/mcp/resources.py +72 -0
- xg/mcp/schema.py +137 -0
- xg/mcp/stdio.py +210 -0
- xg/mcp/transport.py +66 -0
- xg/memory/__init__.py +15 -0
- xg/memory/context.py +327 -0
- xg/memory/manager.py +111 -0
- xg/memory/models.py +41 -0
- xg/memory/project.py +187 -0
- xg/memory/store.py +144 -0
- xg/router/__init__.py +124 -0
- xg/router/features.py +66 -0
- xg/router/keywords.py +50 -0
- xg/router/ml_router.py +178 -0
- xg/router/model_tiers.py +73 -0
- xg/router/postprocess.py +167 -0
- xg/router/rule_router.py +77 -0
- xg/router/semantic.py +138 -0
- xg/safety/__init__.py +0 -0
- xg/safety/audit.py +96 -0
- xg/safety/guards.py +106 -0
- xg/safety/hitl.py +73 -0
- xg/skill/__init__.py +9 -0
- xg/skill/errors.py +45 -0
- xg/skill/loader.py +42 -0
- xg/skill/models.py +57 -0
- xg/skill/parser.py +93 -0
- xg/skill/policy.py +40 -0
- xg/skill/prompt.py +45 -0
- xg/skill/registry.py +169 -0
- xg/tool/__init__.py +0 -0
- xg/tool/builtin.py +356 -0
- xg/tool/registry.py +228 -0
- xg/tui/__init__.py +34 -0
- xg/tui/app.py +612 -0
- xg/tui/controller.py +1296 -0
- xg/tui/diagrams/__init__.py +22 -0
- xg/tui/diagrams/layout.py +110 -0
- xg/tui/diagrams/markdown.py +39 -0
- xg/tui/diagrams/model.py +36 -0
- xg/tui/diagrams/parser.py +119 -0
- xg/tui/diagrams/renderer.py +551 -0
- xg/tui/i18n.py +169 -0
- xg/tui/messages.py +45 -0
- xg/tui/plan_renderables.py +147 -0
- xg/tui/reducer.py +1029 -0
- xg/tui/renderables.py +252 -0
- xg/tui/state.py +240 -0
- xg/tui/theme.tcss +208 -0
- xg/tui/widgets/__init__.py +1 -0
- xg/tui/widgets/action_card.py +94 -0
- xg/tui/widgets/agent_group_card.py +39 -0
- xg/tui/widgets/approval_modal.py +59 -0
- xg/tui/widgets/collapsible_card.py +40 -0
- xg/tui/widgets/command_suggestions.py +128 -0
- xg/tui/widgets/composer.py +151 -0
- xg/tui/widgets/config_panel.py +198 -0
- xg/tui/widgets/confirm_modal.py +31 -0
- xg/tui/widgets/footer.py +9 -0
- xg/tui/widgets/header.py +118 -0
- xg/tui/widgets/inspector.py +378 -0
- xg/tui/widgets/plan_modal.py +53 -0
- xg/tui/widgets/provider_form.py +141 -0
- xg/tui/widgets/queue_status.py +30 -0
- xg/tui/widgets/smart_router_form.py +95 -0
- xg/tui/widgets/transcript.py +354 -0
- xg/tui/workers.py +14 -0
- xg/web/__init__.py +21 -0
- xg/web/errors.py +57 -0
- xg/web/extract.py +127 -0
- xg/web/fetch.py +106 -0
- xg/web/markdown.py +77 -0
- xg/web/models.py +91 -0
- xg/web/providers.py +79 -0
- xg/web/search.py +118 -0
- xg/web/searxng.py +27 -0
- xg/web/serpapi.py +29 -0
- xg/web/url_policy.py +110 -0
- xg/web/zhipu.py +27 -0
- xg_cli-1.0.dist-info/METADATA +284 -0
- xg_cli-1.0.dist-info/RECORD +130 -0
- xg_cli-1.0.dist-info/WHEEL +4 -0
- xg_cli-1.0.dist-info/entry_points.txt +2 -0
xg/llm/factory.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""LlmClient 工厂:按解析后的 api_base / key / model 组装客户端。
|
|
2
|
+
|
|
3
|
+
URL 与 Key 的解析(环境变量、配置合并)由 ConfigManager 负责,
|
|
4
|
+
工厂只负责组装客户端,是后续 provider 差异化(能力、协议)的扩展点。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from xg.llm.client import LlmClient
|
|
10
|
+
from xg.llm.openai_compat import OpenAICompatClient
|
|
11
|
+
|
|
12
|
+
DEFAULT_TIMEOUT = 120.0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def create_client(
|
|
16
|
+
api_base: str,
|
|
17
|
+
api_key: str,
|
|
18
|
+
model: str,
|
|
19
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
20
|
+
retry_enabled: bool = True,
|
|
21
|
+
max_retries: int = 2,
|
|
22
|
+
retry_base_delay: float = 1.0,
|
|
23
|
+
retry_max_delay: float = 8.0,
|
|
24
|
+
retry_jitter: float = 0.25,
|
|
25
|
+
retry_total_timeout: float = 30.0,
|
|
26
|
+
respect_retry_after: bool = True,
|
|
27
|
+
) -> LlmClient:
|
|
28
|
+
return OpenAICompatClient(
|
|
29
|
+
api_base=api_base,
|
|
30
|
+
api_key=api_key,
|
|
31
|
+
model=model,
|
|
32
|
+
timeout=timeout,
|
|
33
|
+
retry_enabled=retry_enabled,
|
|
34
|
+
max_retries=max_retries,
|
|
35
|
+
retry_base_delay=retry_base_delay,
|
|
36
|
+
retry_max_delay=retry_max_delay,
|
|
37
|
+
retry_jitter=retry_jitter,
|
|
38
|
+
retry_total_timeout=retry_total_timeout,
|
|
39
|
+
respect_retry_after=respect_retry_after,
|
|
40
|
+
)
|
xg/llm/openai_compat.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""OpenAI-compatible Chat Completions 实现(httpx SSE 流式 + tool calling 增量聚合)。
|
|
2
|
+
|
|
3
|
+
所有 provider(GLM / DeepSeek / Kimi / OpenAI 兼容)共享此模板。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import random
|
|
11
|
+
import time
|
|
12
|
+
from email.utils import parsedate_to_datetime
|
|
13
|
+
from typing import Any, AsyncIterator
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from xg.llm.client import LlmClient, LlmError
|
|
18
|
+
from xg.llm.types import Message, StreamEvent, ToolCall, Usage
|
|
19
|
+
|
|
20
|
+
DEFAULT_TIMEOUT = 120.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _looks_like_html_error(content_type: str = "", body: str = "") -> bool:
|
|
24
|
+
"""Identify gateway/proxy error pages, which often use HTTP 400 generically."""
|
|
25
|
+
media_type = content_type.split(";", 1)[0].strip().lower()
|
|
26
|
+
prefix = body.lstrip()[:512].lower()
|
|
27
|
+
return media_type == "text/html" or prefix.startswith(("<!doctype html", "<html", "<head", "<body"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _status_retry_policy(
|
|
31
|
+
status_code: int,
|
|
32
|
+
*,
|
|
33
|
+
content_type: str = "",
|
|
34
|
+
body: str = "",
|
|
35
|
+
) -> tuple[bool, str]:
|
|
36
|
+
if status_code == 408:
|
|
37
|
+
return True, "transient_api_error"
|
|
38
|
+
if status_code == 429:
|
|
39
|
+
return True, "rate_limited"
|
|
40
|
+
if status_code == 503:
|
|
41
|
+
return True, "service_unavailable"
|
|
42
|
+
if status_code == 504:
|
|
43
|
+
return True, "gateway_timeout"
|
|
44
|
+
if 500 <= status_code < 600:
|
|
45
|
+
return True, "transient_api_error"
|
|
46
|
+
if status_code in {400, 422}:
|
|
47
|
+
if _looks_like_html_error(content_type, body):
|
|
48
|
+
return True, "proxy_bad_request"
|
|
49
|
+
return False, "invalid_request"
|
|
50
|
+
if status_code == 401:
|
|
51
|
+
return False, "authentication_error"
|
|
52
|
+
if status_code == 403:
|
|
53
|
+
return False, "permission_error"
|
|
54
|
+
if status_code == 404:
|
|
55
|
+
return False, "endpoint_not_found"
|
|
56
|
+
if status_code == 413:
|
|
57
|
+
return False, "request_too_large"
|
|
58
|
+
return False, "api_error"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_retry_after(value: str | None) -> float | None:
|
|
62
|
+
if not value:
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
return max(0.0, float(value.strip()))
|
|
66
|
+
except ValueError:
|
|
67
|
+
try:
|
|
68
|
+
parsed = parsedate_to_datetime(value)
|
|
69
|
+
return max(0.0, parsed.timestamp() - time.time())
|
|
70
|
+
except (TypeError, ValueError, OverflowError):
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class OpenAICompatClient(LlmClient):
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
api_base: str,
|
|
78
|
+
api_key: str,
|
|
79
|
+
model: str,
|
|
80
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
81
|
+
retry_enabled: bool = True,
|
|
82
|
+
max_retries: int = 2,
|
|
83
|
+
retry_base_delay: float = 1.0,
|
|
84
|
+
retry_max_delay: float = 8.0,
|
|
85
|
+
retry_jitter: float = 0.25,
|
|
86
|
+
retry_total_timeout: float = 30.0,
|
|
87
|
+
respect_retry_after: bool = True,
|
|
88
|
+
) -> None:
|
|
89
|
+
# F5:允许以空配置构造(启动不阻断),真正调用时再拦截并给出引导。
|
|
90
|
+
self.api_base = (api_base or "").rstrip("/")
|
|
91
|
+
self.api_key = api_key
|
|
92
|
+
self.model = model
|
|
93
|
+
self.timeout = timeout
|
|
94
|
+
self.retry_enabled = retry_enabled
|
|
95
|
+
self.max_retries = max(0, max_retries)
|
|
96
|
+
self.retry_base_delay = max(0.0, retry_base_delay)
|
|
97
|
+
self.retry_max_delay = max(0.0, retry_max_delay)
|
|
98
|
+
self.retry_jitter = max(0.0, min(1.0, retry_jitter))
|
|
99
|
+
self.retry_total_timeout = max(0.0, retry_total_timeout)
|
|
100
|
+
self.respect_retry_after = respect_retry_after
|
|
101
|
+
|
|
102
|
+
def stream_chat(
|
|
103
|
+
self, messages: list[Message], tools: list[dict] | None = None
|
|
104
|
+
) -> AsyncIterator[StreamEvent]:
|
|
105
|
+
return self._stream_with_retry(messages, tools)
|
|
106
|
+
|
|
107
|
+
async def _stream_with_retry(
|
|
108
|
+
self, messages: list[Message], tools: list[dict] | None
|
|
109
|
+
) -> AsyncIterator[StreamEvent]:
|
|
110
|
+
started_at = time.monotonic()
|
|
111
|
+
max_attempts = 1 + (self.max_retries if self.retry_enabled else 0)
|
|
112
|
+
for attempt in range(1, max_attempts + 1):
|
|
113
|
+
semantic_event_seen = False
|
|
114
|
+
try:
|
|
115
|
+
async for event in self._stream_once(messages, tools):
|
|
116
|
+
if event.kind in {"content", "thinking", "tool_call"}:
|
|
117
|
+
semantic_event_seen = True
|
|
118
|
+
yield event
|
|
119
|
+
return
|
|
120
|
+
except LlmError as error:
|
|
121
|
+
error.response_started = error.response_started or semantic_event_seen
|
|
122
|
+
error.attempt = attempt
|
|
123
|
+
error.max_attempts = max_attempts
|
|
124
|
+
if (
|
|
125
|
+
not self.retry_enabled
|
|
126
|
+
or attempt >= max_attempts
|
|
127
|
+
or not error.retryable
|
|
128
|
+
or error.response_started
|
|
129
|
+
):
|
|
130
|
+
raise
|
|
131
|
+
delay = self._retry_delay(error, attempt)
|
|
132
|
+
elapsed = time.monotonic() - started_at
|
|
133
|
+
if elapsed + delay > self.retry_total_timeout:
|
|
134
|
+
error.category = f"{error.category}_retry_timeout"
|
|
135
|
+
raise
|
|
136
|
+
yield StreamEvent(
|
|
137
|
+
kind="retrying",
|
|
138
|
+
text="API 临时故障,正在重试",
|
|
139
|
+
attempt=attempt + 1,
|
|
140
|
+
max_attempts=max_attempts,
|
|
141
|
+
retry_after=delay,
|
|
142
|
+
)
|
|
143
|
+
await asyncio.sleep(delay)
|
|
144
|
+
|
|
145
|
+
def _retry_delay(self, error: LlmError, attempt: int) -> float:
|
|
146
|
+
delay = min(self.retry_max_delay, self.retry_base_delay * (2 ** (attempt - 1)))
|
|
147
|
+
if self.respect_retry_after and error.retry_after is not None:
|
|
148
|
+
delay = max(delay, error.retry_after)
|
|
149
|
+
if self.retry_jitter:
|
|
150
|
+
delay *= random.uniform(1.0 - self.retry_jitter, 1.0 + self.retry_jitter)
|
|
151
|
+
return max(0.0, min(delay, self.retry_max_delay))
|
|
152
|
+
|
|
153
|
+
async def _stream_once(
|
|
154
|
+
self, messages: list[Message], tools: list[dict] | None
|
|
155
|
+
) -> AsyncIterator[StreamEvent]:
|
|
156
|
+
if not self.api_base or not self.api_key or not self.model:
|
|
157
|
+
# F5:仅调用 LLM 时拦截,提示在会话内配置即可用,无需重启。
|
|
158
|
+
raise LlmError(
|
|
159
|
+
"缺少可用的 base provider / API Key。请在会话内用 /provider 配置,"
|
|
160
|
+
"例如 /provider add <name> <api_base> --model M --key K --set-base。"
|
|
161
|
+
)
|
|
162
|
+
payload: dict[str, Any] = {
|
|
163
|
+
"model": self.model,
|
|
164
|
+
"messages": [m.to_api() for m in messages],
|
|
165
|
+
"stream": True,
|
|
166
|
+
"stream_options": {"include_usage": True},
|
|
167
|
+
}
|
|
168
|
+
if tools:
|
|
169
|
+
payload["tools"] = [
|
|
170
|
+
{"type": "function", "function": t} for t in tools
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
url = f"{self.api_base}/chat/completions"
|
|
174
|
+
headers = {
|
|
175
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
176
|
+
"Content-Type": "application/json",
|
|
177
|
+
"Accept": "text/event-stream",
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
182
|
+
async with client.stream("POST", url, json=payload, headers=headers) as resp:
|
|
183
|
+
if resp.status_code != 200:
|
|
184
|
+
body = (await resp.aread()).decode("utf-8", errors="replace")
|
|
185
|
+
status = resp.status_code
|
|
186
|
+
retryable, category = _status_retry_policy(
|
|
187
|
+
status,
|
|
188
|
+
content_type=resp.headers.get("Content-Type", ""),
|
|
189
|
+
body=body,
|
|
190
|
+
)
|
|
191
|
+
raise LlmError(
|
|
192
|
+
f"API 返回 {status}: {body[:500]}",
|
|
193
|
+
status_code=status,
|
|
194
|
+
category=category,
|
|
195
|
+
retryable=retryable,
|
|
196
|
+
retry_after=_parse_retry_after(resp.headers.get("Retry-After")),
|
|
197
|
+
request_id=resp.headers.get("x-request-id", ""),
|
|
198
|
+
)
|
|
199
|
+
async for event in self._parse_sse(resp):
|
|
200
|
+
yield event
|
|
201
|
+
except httpx.TimeoutException as e:
|
|
202
|
+
raise LlmError(
|
|
203
|
+
f"请求超时({self.timeout}s): {e}",
|
|
204
|
+
category="network_timeout", retryable=True,
|
|
205
|
+
) from e
|
|
206
|
+
except httpx.HTTPError as e:
|
|
207
|
+
raise LlmError(
|
|
208
|
+
f"网络错误: {e}", category="network_error", retryable=True,
|
|
209
|
+
) from e
|
|
210
|
+
|
|
211
|
+
async def _parse_sse(self, resp: httpx.Response) -> AsyncIterator[StreamEvent]:
|
|
212
|
+
"""解析 SSE 流,聚合 content 与 tool_call 增量分片。"""
|
|
213
|
+
# 按 index 聚合中的 tool_call 分片
|
|
214
|
+
pending: dict[int, dict[str, str]] = {}
|
|
215
|
+
finish_reason = ""
|
|
216
|
+
usage: Usage | None = None
|
|
217
|
+
|
|
218
|
+
async for line in resp.aiter_lines():
|
|
219
|
+
line = line.strip()
|
|
220
|
+
if not line.startswith("data:"):
|
|
221
|
+
continue
|
|
222
|
+
data = line[len("data:") :].strip()
|
|
223
|
+
if data == "[DONE]":
|
|
224
|
+
break
|
|
225
|
+
try:
|
|
226
|
+
chunk = json.loads(data)
|
|
227
|
+
except json.JSONDecodeError:
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
if chunk.get("usage"):
|
|
231
|
+
u = chunk["usage"]
|
|
232
|
+
usage = Usage(
|
|
233
|
+
prompt_tokens=u.get("prompt_tokens", 0),
|
|
234
|
+
completion_tokens=u.get("completion_tokens", 0),
|
|
235
|
+
total_tokens=u.get("total_tokens", 0),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
for choice in chunk.get("choices", []):
|
|
239
|
+
delta = choice.get("delta") or {}
|
|
240
|
+
# 不同 OpenAI-compatible provider 对显式 reasoning 的字段名
|
|
241
|
+
# 不统一;在 LLM 边界归一化,UI 不读取 provider 私有字段。
|
|
242
|
+
reasoning = delta.get("reasoning_content") or delta.get("reasoning")
|
|
243
|
+
if reasoning:
|
|
244
|
+
yield StreamEvent(kind="thinking", text=str(reasoning))
|
|
245
|
+
if delta.get("content"):
|
|
246
|
+
yield StreamEvent(kind="content", text=delta["content"])
|
|
247
|
+
|
|
248
|
+
for tc in delta.get("tool_calls") or []:
|
|
249
|
+
idx = tc.get("index", 0)
|
|
250
|
+
frag = pending.setdefault(idx, {"id": "", "name": "", "arguments": ""})
|
|
251
|
+
if tc.get("id"):
|
|
252
|
+
frag["id"] = tc["id"]
|
|
253
|
+
fn = tc.get("function") or {}
|
|
254
|
+
if fn.get("name"):
|
|
255
|
+
frag["name"] = frag["name"] + fn["name"]
|
|
256
|
+
if fn.get("arguments"):
|
|
257
|
+
frag["arguments"] = frag["arguments"] + fn["arguments"]
|
|
258
|
+
|
|
259
|
+
if choice.get("finish_reason"):
|
|
260
|
+
finish_reason = choice["finish_reason"]
|
|
261
|
+
|
|
262
|
+
# 流结束:统一发出聚合后的 tool_call,再发 done
|
|
263
|
+
for idx in sorted(pending):
|
|
264
|
+
frag = pending[idx]
|
|
265
|
+
if not frag["name"] and not frag["arguments"]:
|
|
266
|
+
continue
|
|
267
|
+
yield StreamEvent(
|
|
268
|
+
kind="tool_call",
|
|
269
|
+
tool_call=ToolCall(
|
|
270
|
+
id=frag["id"] or f"call_{idx}",
|
|
271
|
+
name=frag["name"],
|
|
272
|
+
arguments=frag["arguments"],
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
yield StreamEvent(kind="done", finish_reason=finish_reason, usage=usage)
|
xg/llm/types.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""LLM 层通用类型:消息、工具调用、流式事件。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ToolCall:
|
|
11
|
+
"""一次工具调用请求(模型侧发起)。"""
|
|
12
|
+
|
|
13
|
+
id: str
|
|
14
|
+
name: str
|
|
15
|
+
arguments: str # JSON 字符串
|
|
16
|
+
|
|
17
|
+
def parsed_arguments(self) -> dict[str, Any]:
|
|
18
|
+
import json
|
|
19
|
+
|
|
20
|
+
if not self.arguments:
|
|
21
|
+
return {}
|
|
22
|
+
try:
|
|
23
|
+
return json.loads(self.arguments)
|
|
24
|
+
except json.JSONDecodeError:
|
|
25
|
+
return {"_raw": self.arguments}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ToolResult:
|
|
30
|
+
"""一次工具执行结果(工具侧返回,回灌给模型)。"""
|
|
31
|
+
|
|
32
|
+
tool_call_id: str
|
|
33
|
+
name: str
|
|
34
|
+
ok: bool
|
|
35
|
+
output: str = ""
|
|
36
|
+
error: str = ""
|
|
37
|
+
|
|
38
|
+
def to_message_content(self) -> str:
|
|
39
|
+
if self.ok:
|
|
40
|
+
return self.output or "(no output)"
|
|
41
|
+
return f"ERROR: {self.error or 'unknown error'}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Message:
|
|
46
|
+
"""对话消息。role ∈ system / user / assistant / tool。"""
|
|
47
|
+
|
|
48
|
+
role: Literal["system", "user", "assistant", "tool"]
|
|
49
|
+
content: str = ""
|
|
50
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
51
|
+
tool_call_id: str = "" # role=tool 时对应的调用 id
|
|
52
|
+
|
|
53
|
+
def to_api(self) -> dict[str, Any]:
|
|
54
|
+
"""转为 OpenAI Chat Completions API 格式。"""
|
|
55
|
+
msg: dict[str, Any] = {"role": self.role}
|
|
56
|
+
if self.role == "assistant" and self.tool_calls:
|
|
57
|
+
msg["content"] = self.content or None
|
|
58
|
+
msg["tool_calls"] = [
|
|
59
|
+
{
|
|
60
|
+
"id": tc.id,
|
|
61
|
+
"type": "function",
|
|
62
|
+
"function": {"name": tc.name, "arguments": tc.arguments},
|
|
63
|
+
}
|
|
64
|
+
for tc in self.tool_calls
|
|
65
|
+
]
|
|
66
|
+
elif self.role == "tool":
|
|
67
|
+
msg["content"] = self.content
|
|
68
|
+
msg["tool_call_id"] = self.tool_call_id
|
|
69
|
+
else:
|
|
70
|
+
msg["content"] = self.content
|
|
71
|
+
return msg
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Usage:
|
|
76
|
+
prompt_tokens: int = 0
|
|
77
|
+
completion_tokens: int = 0
|
|
78
|
+
total_tokens: int = 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class StreamEvent:
|
|
83
|
+
"""流式事件。
|
|
84
|
+
|
|
85
|
+
- kind="content": text 为普通/最终回答增量文本
|
|
86
|
+
- kind="thinking": text 为 Provider 明确返回的思考增量文本
|
|
87
|
+
- kind="tool_call": tool_call 为本轮聚合完成的 ToolCall(一次性发出,不再分片)
|
|
88
|
+
- kind="done": 结束,含 finish_reason 与 usage
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
kind: Literal["content", "thinking", "tool_call", "done", "retrying"]
|
|
92
|
+
text: str = ""
|
|
93
|
+
tool_call: ToolCall | None = None
|
|
94
|
+
finish_reason: str = ""
|
|
95
|
+
usage: Usage | None = None
|
|
96
|
+
attempt: int = 0
|
|
97
|
+
max_attempts: int = 0
|
|
98
|
+
retry_after: float | None = None
|
xg/mcp/__init__.py
ADDED
xg/mcp/http.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""MCP Streamable HTTP transport using httpx."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from contextlib import suppress
|
|
8
|
+
from typing import AsyncIterator
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from xg.mcp.models import McpServerConfig
|
|
13
|
+
from xg.mcp.protocol import McpProtocolError, McpRequestError, McpUnavailableError, notification_message, request_message
|
|
14
|
+
from xg.mcp.transport import McpTransport
|
|
15
|
+
from xg.safety.audit import redact_text
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StreamableHttpTransport(McpTransport):
|
|
19
|
+
def __init__(self, config: McpServerConfig, client: httpx.AsyncClient | None = None) -> None:
|
|
20
|
+
super().__init__(log_lines=config.log_lines)
|
|
21
|
+
self.config = config
|
|
22
|
+
self._external_client = client is not None
|
|
23
|
+
self.client = client
|
|
24
|
+
self.session_id: str | None = None
|
|
25
|
+
self._next_id = 1
|
|
26
|
+
self._notification_task: asyncio.Task | None = None
|
|
27
|
+
self._closed = False
|
|
28
|
+
|
|
29
|
+
async def connect(self) -> None:
|
|
30
|
+
if self.client is None:
|
|
31
|
+
self.client = httpx.AsyncClient(
|
|
32
|
+
timeout=httpx.Timeout(self.config.request_timeout),
|
|
33
|
+
follow_redirects=True,
|
|
34
|
+
max_redirects=5,
|
|
35
|
+
)
|
|
36
|
+
self._closed = False
|
|
37
|
+
|
|
38
|
+
def _headers(self) -> dict[str, str]:
|
|
39
|
+
headers = {
|
|
40
|
+
"Accept": "application/json, text/event-stream",
|
|
41
|
+
"Content-Type": "application/json",
|
|
42
|
+
**dict(self.config.headers),
|
|
43
|
+
}
|
|
44
|
+
if self.session_id:
|
|
45
|
+
headers["Mcp-Session-Id"] = self.session_id
|
|
46
|
+
if self.protocol_version:
|
|
47
|
+
headers["MCP-Protocol-Version"] = self.protocol_version
|
|
48
|
+
return headers
|
|
49
|
+
|
|
50
|
+
async def request(self, method: str, params: dict | None = None) -> dict:
|
|
51
|
+
if self.client is None or self._closed or not self.config.url:
|
|
52
|
+
raise McpUnavailableError("MCP HTTP Server 未连接")
|
|
53
|
+
request_id = self._next_id
|
|
54
|
+
self._next_id += 1
|
|
55
|
+
response = await self._post(request_message(request_id, method, params))
|
|
56
|
+
messages = await self._messages(response)
|
|
57
|
+
for message in messages:
|
|
58
|
+
if message.get("id") == request_id:
|
|
59
|
+
error = message.get("error")
|
|
60
|
+
if isinstance(error, dict):
|
|
61
|
+
raise McpRequestError(error.get("code", -32000), str(error.get("message", "unknown error")), error.get("data"))
|
|
62
|
+
result = message.get("result", {})
|
|
63
|
+
if result is None:
|
|
64
|
+
return {}
|
|
65
|
+
if not isinstance(result, dict):
|
|
66
|
+
raise McpProtocolError("JSON-RPC result 必须是对象")
|
|
67
|
+
return result
|
|
68
|
+
await self._dispatch_unsolicited(message)
|
|
69
|
+
raise McpProtocolError(f"MCP HTTP 响应缺少 request id {request_id}")
|
|
70
|
+
|
|
71
|
+
async def notify(self, method: str, params: dict | None = None) -> None:
|
|
72
|
+
response = await self._post(notification_message(method, params))
|
|
73
|
+
if response.status_code not in (200, 202, 204):
|
|
74
|
+
raise McpUnavailableError(f"MCP HTTP notification 失败: HTTP {response.status_code}")
|
|
75
|
+
if response.content:
|
|
76
|
+
for message in await self._messages(response):
|
|
77
|
+
await self._dispatch_unsolicited(message)
|
|
78
|
+
|
|
79
|
+
async def _post(self, payload: dict) -> httpx.Response:
|
|
80
|
+
assert self.client is not None and self.config.url
|
|
81
|
+
encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
82
|
+
if len(encoded) > self.config.max_message_bytes:
|
|
83
|
+
raise McpProtocolError("JSON-RPC 请求超过消息上限")
|
|
84
|
+
try:
|
|
85
|
+
response = await self.client.post(self.config.url, headers=self._headers(), content=encoded)
|
|
86
|
+
except httpx.TimeoutException as exc:
|
|
87
|
+
raise McpUnavailableError("MCP HTTP 请求超时") from exc
|
|
88
|
+
except httpx.HTTPError as exc:
|
|
89
|
+
raise McpUnavailableError(f"MCP HTTP 连接失败: {exc}") from exc
|
|
90
|
+
if response.status_code >= 400:
|
|
91
|
+
preview = redact_text(response.text[:300].replace("\n", " "))
|
|
92
|
+
raise McpUnavailableError(f"MCP HTTP {response.status_code}: {preview}")
|
|
93
|
+
session_id = response.headers.get("mcp-session-id")
|
|
94
|
+
if session_id:
|
|
95
|
+
self.session_id = session_id
|
|
96
|
+
if len(response.content) > self.config.max_message_bytes:
|
|
97
|
+
raise McpProtocolError("MCP HTTP 响应超过消息上限")
|
|
98
|
+
return response
|
|
99
|
+
|
|
100
|
+
async def _messages(self, response: httpx.Response) -> list[dict]:
|
|
101
|
+
if not response.content:
|
|
102
|
+
return []
|
|
103
|
+
content_type = response.headers.get("content-type", "").lower()
|
|
104
|
+
raw_messages: list[object]
|
|
105
|
+
if "text/event-stream" in content_type:
|
|
106
|
+
raw_messages = []
|
|
107
|
+
data_lines: list[str] = []
|
|
108
|
+
for line in response.text.splitlines():
|
|
109
|
+
if line.startswith("data:"):
|
|
110
|
+
data_lines.append(line[5:].lstrip())
|
|
111
|
+
elif not line and data_lines:
|
|
112
|
+
raw_messages.append(json.loads("\n".join(data_lines)))
|
|
113
|
+
data_lines = []
|
|
114
|
+
if data_lines:
|
|
115
|
+
raw_messages.append(json.loads("\n".join(data_lines)))
|
|
116
|
+
else:
|
|
117
|
+
try:
|
|
118
|
+
parsed = response.json()
|
|
119
|
+
except (ValueError, json.JSONDecodeError) as exc:
|
|
120
|
+
raise McpProtocolError(f"MCP HTTP 返回非法 JSON: {exc}") from exc
|
|
121
|
+
raw_messages = parsed if isinstance(parsed, list) else [parsed]
|
|
122
|
+
messages: list[dict] = []
|
|
123
|
+
for item in raw_messages:
|
|
124
|
+
if not isinstance(item, dict) or item.get("jsonrpc") != "2.0":
|
|
125
|
+
raise McpProtocolError("MCP HTTP 返回非法 JSON-RPC envelope")
|
|
126
|
+
messages.append(item)
|
|
127
|
+
return messages
|
|
128
|
+
|
|
129
|
+
async def _dispatch_unsolicited(self, message: dict) -> None:
|
|
130
|
+
method = message.get("method")
|
|
131
|
+
if isinstance(method, str) and "id" not in message:
|
|
132
|
+
await self.dispatch_notification(method, message.get("params"))
|
|
133
|
+
elif isinstance(method, str) and "id" in message:
|
|
134
|
+
response = (
|
|
135
|
+
{"jsonrpc": "2.0", "id": message["id"], "result": {}}
|
|
136
|
+
if method == "ping"
|
|
137
|
+
else {
|
|
138
|
+
"jsonrpc": "2.0",
|
|
139
|
+
"id": message["id"],
|
|
140
|
+
"error": {"code": -32601, "message": "Method not found"},
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
await self._post(response)
|
|
144
|
+
|
|
145
|
+
async def start_notifications(self) -> None:
|
|
146
|
+
if self._notification_task is None and self.client is not None and self.session_id:
|
|
147
|
+
self._notification_task = asyncio.create_task(
|
|
148
|
+
self._listen_notifications(), name=f"mcp-{self.config.name}-http-events"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
async def _listen_notifications(self) -> None:
|
|
152
|
+
assert self.client is not None and self.config.url
|
|
153
|
+
try:
|
|
154
|
+
async with self.client.stream("GET", self.config.url, headers=self._headers()) as response:
|
|
155
|
+
if response.status_code in (405, 404):
|
|
156
|
+
return
|
|
157
|
+
response.raise_for_status()
|
|
158
|
+
data_lines: list[str] = []
|
|
159
|
+
async for line in response.aiter_lines():
|
|
160
|
+
if line.startswith("data:"):
|
|
161
|
+
data_lines.append(line[5:].lstrip())
|
|
162
|
+
elif not line and data_lines:
|
|
163
|
+
raw = "\n".join(data_lines)
|
|
164
|
+
data_lines = []
|
|
165
|
+
if len(raw.encode("utf-8")) > self.config.max_message_bytes:
|
|
166
|
+
self.log("notification message exceeds limit")
|
|
167
|
+
continue
|
|
168
|
+
message = json.loads(raw)
|
|
169
|
+
if isinstance(message, dict):
|
|
170
|
+
await self._dispatch_unsolicited(message)
|
|
171
|
+
except asyncio.CancelledError:
|
|
172
|
+
raise
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
if not self._closed:
|
|
175
|
+
self.log(f"notification stream closed: {type(exc).__name__}: {exc}")
|
|
176
|
+
|
|
177
|
+
async def close(self) -> None:
|
|
178
|
+
if self._closed:
|
|
179
|
+
return
|
|
180
|
+
self._closed = True
|
|
181
|
+
task = self._notification_task
|
|
182
|
+
if task is not None and not task.done():
|
|
183
|
+
task.cancel()
|
|
184
|
+
with suppress(asyncio.CancelledError):
|
|
185
|
+
await task
|
|
186
|
+
if self.client is not None and self.config.url and self.session_id:
|
|
187
|
+
with suppress(httpx.HTTPError):
|
|
188
|
+
await self.client.delete(self.config.url, headers=self._headers())
|
|
189
|
+
self.session_id = None
|
|
190
|
+
if self.client is not None and not self._external_client:
|
|
191
|
+
await self.client.aclose()
|
|
192
|
+
self.client = None
|