mosscode 0.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.
- mosscode/__init__.py +10 -0
- mosscode/__main__.py +3 -0
- mosscode/agent.py +486 -0
- mosscode/agents/__init__.py +249 -0
- mosscode/agents/builtin/explore.md +16 -0
- mosscode/agents/builtin/general.md +12 -0
- mosscode/agents/builtin/plan.md +16 -0
- mosscode/agents/runtime.py +685 -0
- mosscode/agents/tasks.py +193 -0
- mosscode/appearance.py +184 -0
- mosscode/builtin_commands.py +1363 -0
- mosscode/cli.py +547 -0
- mosscode/commands.py +254 -0
- mosscode/compaction.py +259 -0
- mosscode/config.py +337 -0
- mosscode/context.py +102 -0
- mosscode/conversation.py +1046 -0
- mosscode/coordinator.py +54 -0
- mosscode/doctor.py +303 -0
- mosscode/hooks/__init__.py +22 -0
- mosscode/hooks/actions.py +151 -0
- mosscode/hooks/conditions.py +170 -0
- mosscode/hooks/config.py +404 -0
- mosscode/hooks/engine.py +401 -0
- mosscode/hooks/events.py +48 -0
- mosscode/launcher.py +177 -0
- mosscode/mcp_client.py +460 -0
- mosscode/mcp_config.py +216 -0
- mosscode/member_ui.py +289 -0
- mosscode/memory.py +291 -0
- mosscode/memory_extract.py +245 -0
- mosscode/messages.py +216 -0
- mosscode/paths.py +31 -0
- mosscode/permissions.py +733 -0
- mosscode/prompts.py +483 -0
- mosscode/providers.py +587 -0
- mosscode/scrollback.py +106 -0
- mosscode/security.py +282 -0
- mosscode/serialization.py +121 -0
- mosscode/session.py +563 -0
- mosscode/setup.py +248 -0
- mosscode/skills/__init__.py +455 -0
- mosscode/skills/builtin/commit/SKILL.md +15 -0
- mosscode/skills/builtin/review/SKILL.md +15 -0
- mosscode/skills/builtin/test/SKILL.md +13 -0
- mosscode/skills/builtin/test/scripts/detect.py +96 -0
- mosscode/skills/builtin/test/tool.json +9 -0
- mosscode/teams/__init__.py +216 -0
- mosscode/teams/backends.py +259 -0
- mosscode/teams/board.py +230 -0
- mosscode/teams/lock.py +173 -0
- mosscode/teams/mailbox.py +172 -0
- mosscode/teams/member.py +427 -0
- mosscode/teams/merge.py +207 -0
- mosscode/teams/pane.py +20 -0
- mosscode/teams/pane_app.py +167 -0
- mosscode/teams/process.py +371 -0
- mosscode/teams/runtime.py +1355 -0
- mosscode/teams/view.py +182 -0
- mosscode/tool_budget.py +86 -0
- mosscode/tools/__init__.py +38 -0
- mosscode/tools/agent.py +127 -0
- mosscode/tools/base.py +384 -0
- mosscode/tools/diffing.py +16 -0
- mosscode/tools/execution.py +99 -0
- mosscode/tools/files.py +287 -0
- mosscode/tools/memory.py +65 -0
- mosscode/tools/plan.py +30 -0
- mosscode/tools/process.py +216 -0
- mosscode/tools/search.py +229 -0
- mosscode/tools/search_worker.py +243 -0
- mosscode/tools/shell.py +121 -0
- mosscode/tools/skill.py +118 -0
- mosscode/tools/team.py +447 -0
- mosscode/tools/windows_job.py +75 -0
- mosscode/tui.py +2325 -0
- mosscode/usage.py +68 -0
- mosscode/windows_inline.py +148 -0
- mosscode/worktree/__init__.py +29 -0
- mosscode/worktree/changes.py +59 -0
- mosscode/worktree/git.py +125 -0
- mosscode/worktree/manager.py +462 -0
- mosscode/worktree/meta.py +82 -0
- mosscode/worktree/names.py +52 -0
- mosscode/worktree/setup.py +119 -0
- mosscode-0.1.0.dist-info/METADATA +92 -0
- mosscode-0.1.0.dist-info/RECORD +91 -0
- mosscode-0.1.0.dist-info/WHEEL +5 -0
- mosscode-0.1.0.dist-info/entry_points.txt +3 -0
- mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
- mosscode-0.1.0.dist-info/top_level.txt +1 -0
mosscode/providers.py
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
"""Three wire protocols, arbitrary compatible endpoints, no tool execution."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import random
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import AsyncIterator, Sequence
|
|
8
|
+
from contextlib import aclosing
|
|
9
|
+
from copy import deepcopy
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .config import ProviderConfig
|
|
13
|
+
from .messages import (
|
|
14
|
+
Completed,
|
|
15
|
+
ContentBlock,
|
|
16
|
+
Delta,
|
|
17
|
+
Message,
|
|
18
|
+
ProviderError,
|
|
19
|
+
Retrying,
|
|
20
|
+
StreamEvent,
|
|
21
|
+
ToolCall,
|
|
22
|
+
ToolDefinition,
|
|
23
|
+
ToolProgress,
|
|
24
|
+
Usage,
|
|
25
|
+
)
|
|
26
|
+
from .prompts import PREFIX_KINDS, PromptRequest, as_prompt
|
|
27
|
+
from .usage import parse_usage, usage_data
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _blocks(message: Message, protocol: str) -> list[dict[str, Any]]:
|
|
31
|
+
if message.role == "tool":
|
|
32
|
+
return [
|
|
33
|
+
{"type": "tool_result", "tool_use_id": r.call_id, "content": r.model_text(), "is_error": r.is_error}
|
|
34
|
+
for r in message.tool_results
|
|
35
|
+
]
|
|
36
|
+
if message.protocol == protocol and message.native:
|
|
37
|
+
return list(deepcopy(message.native))
|
|
38
|
+
# Portable form, e.g. after switching models: text plus rebuilt tool_use blocks.
|
|
39
|
+
blocks = [{"type": "text", "text": message.text}] if message.text or not message.tool_calls else []
|
|
40
|
+
blocks.extend(
|
|
41
|
+
{"type": "tool_use", "id": c.id, "name": c.name, "input": _arguments(c.arguments)} for c in message.tool_calls
|
|
42
|
+
)
|
|
43
|
+
return blocks
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _arguments(arguments: str) -> dict[str, Any]:
|
|
47
|
+
try:
|
|
48
|
+
value = json.loads(arguments)
|
|
49
|
+
except ValueError:
|
|
50
|
+
return {}
|
|
51
|
+
return value if isinstance(value, dict) else {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _turns(messages: Sequence[Message], protocol: str) -> list[dict[str, Any]]:
|
|
55
|
+
"""Anthropic turns; adjacent same-role messages (tool results then queued text) merge."""
|
|
56
|
+
turns: list[dict[str, Any]] = []
|
|
57
|
+
for message in messages:
|
|
58
|
+
role = "user" if message.role == "tool" else message.role
|
|
59
|
+
if turns and turns[-1]["role"] == role:
|
|
60
|
+
turns[-1]["content"].extend(_blocks(message, protocol))
|
|
61
|
+
else:
|
|
62
|
+
turns.append({"role": role, "content": _blocks(message, protocol)})
|
|
63
|
+
return turns
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _tools(definitions: Sequence[ToolDefinition], protocol: str) -> list[dict[str, Any]]:
|
|
67
|
+
if protocol == "anthropic-messages":
|
|
68
|
+
return [
|
|
69
|
+
{"name": d.name, "description": d.description, "input_schema": deepcopy(d.parameters)} for d in definitions
|
|
70
|
+
]
|
|
71
|
+
functions = [
|
|
72
|
+
{
|
|
73
|
+
"type": "function",
|
|
74
|
+
"name": d.name,
|
|
75
|
+
"description": d.description,
|
|
76
|
+
"parameters": deepcopy(d.parameters),
|
|
77
|
+
"strict": False,
|
|
78
|
+
}
|
|
79
|
+
for d in definitions
|
|
80
|
+
]
|
|
81
|
+
if protocol == "openai-chat-completions":
|
|
82
|
+
return [{"type": "function", "function": {k: v for k, v in f.items() if k != "type"}} for f in functions]
|
|
83
|
+
return functions
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _valid_calls(calls: Sequence[ToolCall]) -> None:
|
|
87
|
+
ids = [call.id for call in calls]
|
|
88
|
+
if any(not c.id or not c.name for c in calls) or len(set(ids)) != len(ids):
|
|
89
|
+
raise ProviderError("工具调用缺少名称/ID 或出现重复 ID;未执行工具。")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _errors(name: str) -> tuple[type, ...]:
|
|
93
|
+
"""The SDKs load only with a provider that needs them, and one never loaded raised nothing."""
|
|
94
|
+
return tuple(getattr(sys.modules[sdk], name) for sdk in ("anthropic", "openai") if sdk in sys.modules)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
RETRYABLE_STATUS = {408, 409, 429, 500, 502, 503, 504, 529}
|
|
98
|
+
RETRYABLE_BODY_TYPES = {"overloaded_error", "api_error", "rate_limit_error"}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _body_type(exc) -> str | None:
|
|
102
|
+
body = getattr(exc, "body", None)
|
|
103
|
+
error = body.get("error", body) if isinstance(body, dict) else None
|
|
104
|
+
return error.get("type") if isinstance(error, dict) else None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _retry_reason(exc) -> str | None:
|
|
108
|
+
if isinstance(exc, _errors("APITimeoutError")):
|
|
109
|
+
return "请求超时"
|
|
110
|
+
if isinstance(exc, _errors("APIConnectionError")):
|
|
111
|
+
return "连接中断"
|
|
112
|
+
if isinstance(exc, _errors("APIStatusError")):
|
|
113
|
+
if exc.status_code in RETRYABLE_STATUS or exc.status_code >= 500:
|
|
114
|
+
return "服务限流" if exc.status_code == 429 else f"服务暂时不可用(HTTP {exc.status_code})"
|
|
115
|
+
if isinstance(exc, _errors("APIError")) and _body_type(exc) in RETRYABLE_BODY_TYPES:
|
|
116
|
+
return "服务过载" if _body_type(exc) == "overloaded_error" else "服务流返回临时错误"
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _retry_delay(exc, attempt: int, base: float) -> float:
|
|
121
|
+
response = getattr(exc, "response", None)
|
|
122
|
+
try:
|
|
123
|
+
advised = float(response.headers.get("retry-after")) if response is not None else None
|
|
124
|
+
except (TypeError, ValueError):
|
|
125
|
+
advised = None
|
|
126
|
+
if advised is not None and advised >= 0:
|
|
127
|
+
return min(advised, 60.0)
|
|
128
|
+
return min(base * 2**attempt, 60.0) * (1 + random.random() * 0.25)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
OVERFLOW_PATTERNS = (
|
|
132
|
+
"prompt is too long",
|
|
133
|
+
"context_length_exceeded",
|
|
134
|
+
"maximum context length",
|
|
135
|
+
"context length",
|
|
136
|
+
"context window",
|
|
137
|
+
"too many tokens",
|
|
138
|
+
"input is too long",
|
|
139
|
+
"exceeds the context",
|
|
140
|
+
"reduce the length",
|
|
141
|
+
"input too long",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _context_overflow(exc) -> bool:
|
|
146
|
+
"""Services word this differently; match the shared phrases, never echo the raw text."""
|
|
147
|
+
status = getattr(exc, "status_code", None)
|
|
148
|
+
if status not in {None, 400, 413, 422}:
|
|
149
|
+
return False
|
|
150
|
+
body = getattr(exc, "body", None)
|
|
151
|
+
text = (str(exc) + " " + (json.dumps(body, ensure_ascii=False, default=str) if body is not None else "")).lower()
|
|
152
|
+
return any(pattern in text for pattern in OVERFLOW_PATTERNS)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _safe_error(exc) -> ProviderError:
|
|
156
|
+
if isinstance(exc, ProviderError):
|
|
157
|
+
return exc
|
|
158
|
+
if isinstance(exc, _errors("APIError")) and _context_overflow(exc):
|
|
159
|
+
status = getattr(exc, "status_code", None)
|
|
160
|
+
suffix = f"(HTTP {status})" if status else ""
|
|
161
|
+
return ProviderError(f"请求超出模型的上下文窗口{suffix}。", code="context_overflow")
|
|
162
|
+
if isinstance(exc, _errors("APIStatusError")):
|
|
163
|
+
status = exc.status_code
|
|
164
|
+
if status in {401, 403}:
|
|
165
|
+
detail = "认证或权限失败,请检查凭据与端点。"
|
|
166
|
+
elif status == 429:
|
|
167
|
+
detail = "服务限流,请稍后重新发送。"
|
|
168
|
+
elif status in {400, 404, 422}:
|
|
169
|
+
detail = "请求不被服务接受,请检查协议、端点、模型与可选能力配置。"
|
|
170
|
+
else:
|
|
171
|
+
detail = "服务请求失败,请稍后重新发送。"
|
|
172
|
+
return ProviderError(f"{detail}(HTTP {status})")
|
|
173
|
+
if isinstance(exc, _errors("APITimeoutError")):
|
|
174
|
+
return ProviderError("请求超时,请检查网络或增大 timeout。")
|
|
175
|
+
if isinstance(exc, _errors("APIConnectionError")):
|
|
176
|
+
return ProviderError("连接中断或无法连接,请检查网络与 base_url。")
|
|
177
|
+
if isinstance(exc, _errors("APIError")):
|
|
178
|
+
return ProviderError("服务流返回错误或无法解析的响应,请检查协议和服务配置。")
|
|
179
|
+
if isinstance(exc, (KeyError, TypeError, ValueError, AttributeError)):
|
|
180
|
+
return ProviderError("服务流数据格式不合法;未执行不完整的工具调用。")
|
|
181
|
+
raise exc
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class SDKProvider:
|
|
185
|
+
def __init__(self, config: ProviderConfig, client: Any = None) -> None:
|
|
186
|
+
self.config = config
|
|
187
|
+
self.client = client if client is not None else self._client()
|
|
188
|
+
|
|
189
|
+
def _client(self) -> Any:
|
|
190
|
+
config = self.config
|
|
191
|
+
options: dict[str, Any] = {"timeout": config.timeout, "max_retries": 0}
|
|
192
|
+
if config.base_url:
|
|
193
|
+
options["base_url"] = config.base_url
|
|
194
|
+
if config.headers:
|
|
195
|
+
options["default_headers"] = config.headers
|
|
196
|
+
key = config.credential()
|
|
197
|
+
if config.protocol == "anthropic-messages":
|
|
198
|
+
import anthropic
|
|
199
|
+
|
|
200
|
+
if config.auth_type == "bearer":
|
|
201
|
+
return anthropic.AsyncAnthropic(api_key=None, auth_token=key, **options)
|
|
202
|
+
return anthropic.AsyncAnthropic(api_key=key, **options)
|
|
203
|
+
import openai
|
|
204
|
+
|
|
205
|
+
return openai.AsyncOpenAI(api_key=key, **options)
|
|
206
|
+
|
|
207
|
+
async def close(self) -> None:
|
|
208
|
+
await self.client.close()
|
|
209
|
+
|
|
210
|
+
retry_base_delay = 1.0
|
|
211
|
+
|
|
212
|
+
async def stream(
|
|
213
|
+
self,
|
|
214
|
+
messages: Sequence[Message],
|
|
215
|
+
system: str | PromptRequest,
|
|
216
|
+
tools: Sequence[ToolDefinition] = (),
|
|
217
|
+
*,
|
|
218
|
+
allow_tools: bool = True,
|
|
219
|
+
) -> AsyncIterator[StreamEvent]:
|
|
220
|
+
attempt = 0
|
|
221
|
+
while True:
|
|
222
|
+
produced = False
|
|
223
|
+
retry = None
|
|
224
|
+
try:
|
|
225
|
+
async with aclosing(self._stream(messages, system, tools, allow_tools=allow_tools)) as events:
|
|
226
|
+
async for event in events:
|
|
227
|
+
produced = True
|
|
228
|
+
yield event
|
|
229
|
+
return
|
|
230
|
+
except Exception as exc:
|
|
231
|
+
# Only a request that produced nothing is safe to repeat: no text
|
|
232
|
+
# was shown and no tool call can have been executed.
|
|
233
|
+
retry = None if produced or attempt >= self.config.max_retries else _retry_reason(exc)
|
|
234
|
+
if retry is None:
|
|
235
|
+
raise _safe_error(exc) from None
|
|
236
|
+
delay = _retry_delay(exc, attempt, self.retry_base_delay)
|
|
237
|
+
attempt += 1
|
|
238
|
+
yield Retrying(attempt, self.config.max_retries, delay, retry)
|
|
239
|
+
await asyncio.sleep(delay)
|
|
240
|
+
|
|
241
|
+
async def _stream(
|
|
242
|
+
self,
|
|
243
|
+
messages: Sequence[Message],
|
|
244
|
+
system: str | PromptRequest,
|
|
245
|
+
tools: Sequence[ToolDefinition] = (),
|
|
246
|
+
*,
|
|
247
|
+
allow_tools: bool = True,
|
|
248
|
+
) -> AsyncIterator[StreamEvent]:
|
|
249
|
+
raise NotImplementedError
|
|
250
|
+
yield # pragma: no cover
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class AnthropicProvider(SDKProvider):
|
|
254
|
+
async def _stream(
|
|
255
|
+
self,
|
|
256
|
+
messages: Sequence[Message],
|
|
257
|
+
system: str | PromptRequest,
|
|
258
|
+
tools: Sequence[ToolDefinition] = (),
|
|
259
|
+
*,
|
|
260
|
+
allow_tools: bool = True,
|
|
261
|
+
) -> AsyncIterator[StreamEvent]:
|
|
262
|
+
config = self.config
|
|
263
|
+
prompt = as_prompt(system)
|
|
264
|
+
options: dict[str, Any] = {
|
|
265
|
+
"model": config.model,
|
|
266
|
+
"max_tokens": config.max_output_tokens,
|
|
267
|
+
"messages": _turns(messages, config.protocol),
|
|
268
|
+
"stream": True,
|
|
269
|
+
}
|
|
270
|
+
blocks = [{"type": "text", "text": prompt.stable}] if prompt.stable else []
|
|
271
|
+
official = str(getattr(self.client, "base_url", "")).rstrip("/") == "https://api.anthropic.com"
|
|
272
|
+
cache = config.prompt_cache if config.prompt_cache is not None else official
|
|
273
|
+
if blocks and cache:
|
|
274
|
+
blocks[-1]["cache_control"] = {"type": "ephemeral"}
|
|
275
|
+
# Environment and active skills are stable within a run and belong to the cached prefix;
|
|
276
|
+
# reminders are request-only and follow the last history breakpoint.
|
|
277
|
+
blocks.extend(
|
|
278
|
+
{"type": "text", "text": item.render()} for item in prompt.supplements if item.kind in PREFIX_KINDS
|
|
279
|
+
)
|
|
280
|
+
reminders = [
|
|
281
|
+
{"type": "text", "text": item.render()} for item in prompt.supplements if item.kind not in PREFIX_KINDS
|
|
282
|
+
]
|
|
283
|
+
turns = options["messages"]
|
|
284
|
+
if turns and cache:
|
|
285
|
+
turns[-1]["content"][-1]["cache_control"] = {"type": "ephemeral"}
|
|
286
|
+
if turns and turns[-1]["role"] == "user":
|
|
287
|
+
turns[-1]["content"].extend(reminders)
|
|
288
|
+
else:
|
|
289
|
+
blocks.extend(reminders)
|
|
290
|
+
if blocks:
|
|
291
|
+
options["system"] = blocks
|
|
292
|
+
if tools:
|
|
293
|
+
options["tools"] = _tools(tools, config.protocol)
|
|
294
|
+
if not allow_tools:
|
|
295
|
+
options["tool_choice"] = {"type": "none"}
|
|
296
|
+
if config.thinking is not None:
|
|
297
|
+
options["thinking"] = {k: v for k, v in config.thinking.items() if k != "effort"}
|
|
298
|
+
if "effort" in config.thinking:
|
|
299
|
+
options["output_config"] = {"effort": config.thinking["effort"]}
|
|
300
|
+
ended = False
|
|
301
|
+
raw_blocks: dict[int, dict[str, Any]] = {}
|
|
302
|
+
arguments: dict[int, str] = {}
|
|
303
|
+
reason = None
|
|
304
|
+
usage_fields = {}
|
|
305
|
+
stream = await self.client.messages.create(**options)
|
|
306
|
+
async with stream:
|
|
307
|
+
async for event in stream:
|
|
308
|
+
if event.type == "message_start":
|
|
309
|
+
usage_fields = usage_data(event.message.usage)
|
|
310
|
+
elif event.type == "content_block_start":
|
|
311
|
+
raw_blocks[event.index] = event.content_block.model_dump(exclude_none=True)
|
|
312
|
+
block = raw_blocks[event.index]
|
|
313
|
+
if block["type"] == "tool_use":
|
|
314
|
+
arguments[event.index] = ""
|
|
315
|
+
yield ToolProgress(ToolCall(block["id"], block["name"], ""), "parsing")
|
|
316
|
+
elif event.type == "content_block_delta":
|
|
317
|
+
block = raw_blocks[event.index]
|
|
318
|
+
if event.delta.type == "text_delta":
|
|
319
|
+
block["text"] = block.get("text", "") + event.delta.text
|
|
320
|
+
yield Delta("text", event.delta.text)
|
|
321
|
+
elif event.delta.type == "thinking_delta":
|
|
322
|
+
block["thinking"] = block.get("thinking", "") + event.delta.thinking
|
|
323
|
+
yield Delta("thinking", event.delta.thinking)
|
|
324
|
+
elif event.delta.type == "signature_delta":
|
|
325
|
+
block["signature"] = block.get("signature", "") + event.delta.signature
|
|
326
|
+
elif event.delta.type == "input_json_delta":
|
|
327
|
+
arguments[event.index] += event.delta.partial_json
|
|
328
|
+
elif event.type == "message_delta":
|
|
329
|
+
reason = event.delta.stop_reason
|
|
330
|
+
# Fields are cumulative snapshots; missing delta fields retain start values.
|
|
331
|
+
usage_fields.update({k: v for k, v in usage_data(event.usage).items() if v is not None})
|
|
332
|
+
elif event.type == "message_stop":
|
|
333
|
+
ended = True
|
|
334
|
+
elif event.type == "error":
|
|
335
|
+
raise ProviderError("Anthropic 服务流返回错误。")
|
|
336
|
+
if not ended:
|
|
337
|
+
raise ProviderError("Anthropic 流在完成信号前中断,请重新发送。")
|
|
338
|
+
if reason not in {"end_turn", "stop_sequence", "refusal", "tool_use", "max_tokens"}:
|
|
339
|
+
raise ProviderError("回复未正常完成(出现本章不支持的响应)。")
|
|
340
|
+
if reason == "max_tokens":
|
|
341
|
+
# A thinking block cut before its signature cannot be replayed.
|
|
342
|
+
raw_blocks = {i: b for i, b in raw_blocks.items() if b["type"] != "thinking" or b.get("signature")}
|
|
343
|
+
calls = []
|
|
344
|
+
for index, block in raw_blocks.items():
|
|
345
|
+
if block["type"] == "tool_use":
|
|
346
|
+
args = arguments.get(index) or json.dumps(block.get("input", {}), ensure_ascii=False)
|
|
347
|
+
calls.append(ToolCall(block["id"], block["name"], args))
|
|
348
|
+
try:
|
|
349
|
+
parsed = json.loads(args)
|
|
350
|
+
block["input"] = parsed if isinstance(parsed, dict) else {}
|
|
351
|
+
except ValueError:
|
|
352
|
+
# Only for valid protocol replay; the registry still gets
|
|
353
|
+
# the malformed original string and returns invalid_json.
|
|
354
|
+
block["input"] = {}
|
|
355
|
+
_valid_calls(calls)
|
|
356
|
+
raw = tuple(raw_blocks.values())
|
|
357
|
+
if any(block["type"] not in {"text", "thinking", "redacted_thinking", "tool_use"} for block in raw):
|
|
358
|
+
raise ProviderError("服务返回了本章不支持的内容。")
|
|
359
|
+
content = tuple(
|
|
360
|
+
ContentBlock("text" if b["type"] == "text" else "thinking", b.get("text", b.get("thinking", "")))
|
|
361
|
+
for b in raw
|
|
362
|
+
if b["type"] in {"text", "thinking"}
|
|
363
|
+
)
|
|
364
|
+
yield Completed(
|
|
365
|
+
Message("assistant", content, config.protocol, raw, tuple(calls)),
|
|
366
|
+
reason,
|
|
367
|
+
parse_usage(usage_fields, config.protocol),
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _incomplete_reason(response) -> str | None:
|
|
372
|
+
details = getattr(response, "incomplete_details", None)
|
|
373
|
+
return getattr(details, "reason", None)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class ResponsesProvider(SDKProvider):
|
|
377
|
+
async def _stream(
|
|
378
|
+
self,
|
|
379
|
+
messages: Sequence[Message],
|
|
380
|
+
system: str | PromptRequest,
|
|
381
|
+
tools: Sequence[ToolDefinition] = (),
|
|
382
|
+
*,
|
|
383
|
+
allow_tools: bool = True,
|
|
384
|
+
) -> AsyncIterator[StreamEvent]:
|
|
385
|
+
prompt = as_prompt(system)
|
|
386
|
+
inputs: list[dict[str, Any]] = [
|
|
387
|
+
{"role": "developer", "content": s.render()} for s in prompt.supplements if s.kind in PREFIX_KINDS
|
|
388
|
+
]
|
|
389
|
+
for message in messages:
|
|
390
|
+
if message.role == "tool":
|
|
391
|
+
inputs.extend(
|
|
392
|
+
{"type": "function_call_output", "call_id": r.call_id, "output": r.model_text()}
|
|
393
|
+
for r in message.tool_results
|
|
394
|
+
)
|
|
395
|
+
elif message.role == "assistant" and message.protocol == self.config.protocol and message.native:
|
|
396
|
+
inputs.extend(deepcopy(message.native))
|
|
397
|
+
else:
|
|
398
|
+
if message.text or not message.tool_calls:
|
|
399
|
+
inputs.append({"role": message.role, "content": message.text})
|
|
400
|
+
inputs.extend(
|
|
401
|
+
{"type": "function_call", "call_id": c.id, "name": c.name, "arguments": c.arguments}
|
|
402
|
+
for c in message.tool_calls
|
|
403
|
+
)
|
|
404
|
+
inputs.extend(
|
|
405
|
+
{"role": "developer", "content": s.render()} for s in prompt.supplements if s.kind not in PREFIX_KINDS
|
|
406
|
+
)
|
|
407
|
+
options: dict[str, Any] = {
|
|
408
|
+
"model": self.config.model,
|
|
409
|
+
"input": inputs,
|
|
410
|
+
"store": False,
|
|
411
|
+
"max_output_tokens": self.config.max_output_tokens,
|
|
412
|
+
"stream": True,
|
|
413
|
+
}
|
|
414
|
+
if prompt.stable:
|
|
415
|
+
options["instructions"] = prompt.stable
|
|
416
|
+
if tools:
|
|
417
|
+
options["tools"] = _tools(tools, self.config.protocol)
|
|
418
|
+
if not allow_tools:
|
|
419
|
+
options["tool_choice"] = "none"
|
|
420
|
+
if self.config.include_encrypted_reasoning:
|
|
421
|
+
options["include"] = ["reasoning.encrypted_content"]
|
|
422
|
+
arguments: dict[str, str] = {}
|
|
423
|
+
final = None
|
|
424
|
+
stream = await self.client.responses.create(**options)
|
|
425
|
+
async with stream:
|
|
426
|
+
async for event in stream:
|
|
427
|
+
if event.type in {"response.output_text.delta", "response.refusal.delta"}:
|
|
428
|
+
yield Delta("text", event.delta)
|
|
429
|
+
elif event.type == "response.reasoning_summary_text.delta":
|
|
430
|
+
yield Delta("thinking", event.delta)
|
|
431
|
+
elif event.type == "response.output_item.added" and event.item.type == "function_call":
|
|
432
|
+
arguments[event.item.id] = event.item.arguments or ""
|
|
433
|
+
yield ToolProgress(ToolCall(event.item.call_id, event.item.name, event.item.arguments), "parsing")
|
|
434
|
+
elif event.type == "response.function_call_arguments.delta":
|
|
435
|
+
arguments[event.item_id] = arguments.get(event.item_id, "") + event.delta
|
|
436
|
+
elif event.type == "response.incomplete" and _incomplete_reason(event.response) == "max_output_tokens":
|
|
437
|
+
final, stop = event.response, "max_tokens"
|
|
438
|
+
elif event.type in {"error", "response.failed", "response.incomplete"}:
|
|
439
|
+
raise ProviderError("Responses 请求失败或回复不完整,请检查服务与输出预算。")
|
|
440
|
+
elif event.type == "response.completed":
|
|
441
|
+
final, stop = event.response, "completed"
|
|
442
|
+
if final.status != "completed":
|
|
443
|
+
raise ProviderError("Responses 回复未完成。")
|
|
444
|
+
if final is not None:
|
|
445
|
+
raw = tuple(item.model_dump(exclude_none=True) for item in final.output)
|
|
446
|
+
content: list[ContentBlock] = []
|
|
447
|
+
calls = []
|
|
448
|
+
for item in raw:
|
|
449
|
+
if item["type"] == "message":
|
|
450
|
+
for block in item["content"]:
|
|
451
|
+
if block["type"] == "output_text":
|
|
452
|
+
content.append(ContentBlock("text", block["text"]))
|
|
453
|
+
elif block["type"] == "refusal":
|
|
454
|
+
content.append(ContentBlock("text", block["refusal"]))
|
|
455
|
+
else:
|
|
456
|
+
raise ProviderError("服务返回了本章不支持的内容。")
|
|
457
|
+
elif item["type"] == "reasoning":
|
|
458
|
+
content.extend(ContentBlock("thinking", b["text"]) for b in item.get("summary", []))
|
|
459
|
+
elif item["type"] == "function_call":
|
|
460
|
+
streamed = arguments.get(item.get("id"))
|
|
461
|
+
if streamed:
|
|
462
|
+
if item["arguments"] and item["arguments"] != streamed:
|
|
463
|
+
raise ProviderError("工具参数分片与最终响应不一致;未执行工具。")
|
|
464
|
+
item["arguments"] = streamed
|
|
465
|
+
calls.append(ToolCall(item["call_id"], item["name"], item["arguments"]))
|
|
466
|
+
else:
|
|
467
|
+
raise ProviderError("服务返回了本章不支持的内容。")
|
|
468
|
+
usage = parse_usage(final.usage, self.config.protocol)
|
|
469
|
+
# Stateless replay needs encrypted data, not a bare reasoning ID.
|
|
470
|
+
retained = tuple(
|
|
471
|
+
item for item in raw if item["type"] != "reasoning" or item.get("encrypted_content")
|
|
472
|
+
)
|
|
473
|
+
_valid_calls(calls)
|
|
474
|
+
yield Completed(
|
|
475
|
+
Message("assistant", tuple(content), self.config.protocol, retained, tuple(calls)), stop, usage
|
|
476
|
+
)
|
|
477
|
+
return
|
|
478
|
+
raise ProviderError("Responses 流在完成信号前中断,请重新发送。")
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
class ChatCompletionsProvider(SDKProvider):
|
|
482
|
+
async def _stream(
|
|
483
|
+
self,
|
|
484
|
+
messages: Sequence[Message],
|
|
485
|
+
system: str | PromptRequest,
|
|
486
|
+
tools: Sequence[ToolDefinition] = (),
|
|
487
|
+
*,
|
|
488
|
+
allow_tools: bool = True,
|
|
489
|
+
) -> AsyncIterator[StreamEvent]:
|
|
490
|
+
prompt = as_prompt(system)
|
|
491
|
+
history = [{"role": "system", "content": s.render()} for s in prompt.supplements if s.kind in PREFIX_KINDS]
|
|
492
|
+
for message in messages:
|
|
493
|
+
if message.role == "tool":
|
|
494
|
+
history.extend(
|
|
495
|
+
{"role": "tool", "tool_call_id": r.call_id, "content": r.model_text()} for r in message.tool_results
|
|
496
|
+
)
|
|
497
|
+
elif message.protocol == self.config.protocol and message.native:
|
|
498
|
+
history.extend(deepcopy(message.native))
|
|
499
|
+
else:
|
|
500
|
+
entry = {"role": message.role, "content": message.text}
|
|
501
|
+
if message.tool_calls:
|
|
502
|
+
entry["tool_calls"] = [
|
|
503
|
+
{"id": c.id, "type": "function", "function": {"name": c.name, "arguments": c.arguments}}
|
|
504
|
+
for c in message.tool_calls
|
|
505
|
+
]
|
|
506
|
+
history.append(entry)
|
|
507
|
+
if prompt.stable:
|
|
508
|
+
history.insert(0, {"role": "system", "content": prompt.stable})
|
|
509
|
+
history.extend(
|
|
510
|
+
{"role": "system", "content": s.render()} for s in prompt.supplements if s.kind not in PREFIX_KINDS
|
|
511
|
+
)
|
|
512
|
+
options: dict[str, Any] = {
|
|
513
|
+
"model": self.config.model,
|
|
514
|
+
"messages": history,
|
|
515
|
+
self.config.max_tokens_field: self.config.max_output_tokens,
|
|
516
|
+
"stream": True,
|
|
517
|
+
}
|
|
518
|
+
if self.config.stream_usage:
|
|
519
|
+
options["stream_options"] = {"include_usage": True}
|
|
520
|
+
if tools:
|
|
521
|
+
options["tools"] = _tools(tools, self.config.protocol)
|
|
522
|
+
if not allow_tools:
|
|
523
|
+
options["tool_choice"] = "none"
|
|
524
|
+
stream = await self.client.chat.completions.create(**options)
|
|
525
|
+
text: list[str] = []
|
|
526
|
+
thinking: list[str] = []
|
|
527
|
+
reason = None
|
|
528
|
+
usage = Usage()
|
|
529
|
+
fragments: dict[int, dict[str, str]] = {}
|
|
530
|
+
async with stream:
|
|
531
|
+
async for chunk in stream:
|
|
532
|
+
if chunk.usage is not None:
|
|
533
|
+
usage = parse_usage(chunk.usage, self.config.protocol)
|
|
534
|
+
if not chunk.choices:
|
|
535
|
+
continue
|
|
536
|
+
choice = chunk.choices[0]
|
|
537
|
+
if getattr(choice.delta, "function_call", None):
|
|
538
|
+
raise ProviderError("不支持缺少调用 ID 的旧式 function_call。")
|
|
539
|
+
for fragment in choice.delta.tool_calls or ():
|
|
540
|
+
first = fragment.index not in fragments
|
|
541
|
+
call = fragments.setdefault(fragment.index, {"id": "", "name": "", "arguments": ""})
|
|
542
|
+
if fragment.id:
|
|
543
|
+
call["id"] += fragment.id
|
|
544
|
+
if fragment.function:
|
|
545
|
+
call["name"] += fragment.function.name or ""
|
|
546
|
+
call["arguments"] += fragment.function.arguments or ""
|
|
547
|
+
if first:
|
|
548
|
+
yield ToolProgress(ToolCall(call["id"], call["name"], call["arguments"]), "parsing")
|
|
549
|
+
if choice.delta.content:
|
|
550
|
+
text.append(choice.delta.content)
|
|
551
|
+
yield Delta("text", choice.delta.content)
|
|
552
|
+
if getattr(choice.delta, "refusal", None):
|
|
553
|
+
text.append(choice.delta.refusal)
|
|
554
|
+
yield Delta("text", choice.delta.refusal)
|
|
555
|
+
reasoning = getattr(choice.delta, "reasoning_content", None)
|
|
556
|
+
if isinstance(reasoning, str) and reasoning:
|
|
557
|
+
thinking.append(reasoning)
|
|
558
|
+
yield Delta("thinking", reasoning)
|
|
559
|
+
if choice.finish_reason:
|
|
560
|
+
reason = choice.finish_reason
|
|
561
|
+
if reason not in {"stop", "content_filter", "tool_calls", "length"}:
|
|
562
|
+
raise ProviderError("Chat Completions 回复未完成,请检查服务连接。")
|
|
563
|
+
content = [ContentBlock("text", "".join(text))]
|
|
564
|
+
if thinking:
|
|
565
|
+
content.insert(0, ContentBlock("thinking", "".join(thinking)))
|
|
566
|
+
calls = tuple(ToolCall(**value) for _, value in sorted(fragments.items()))
|
|
567
|
+
_valid_calls(calls)
|
|
568
|
+
if reason == "tool_calls" and not calls:
|
|
569
|
+
raise ProviderError("服务报告工具调用但未返回完整调用;未执行工具。")
|
|
570
|
+
native: dict[str, Any] = {"role": "assistant", "content": "".join(text)}
|
|
571
|
+
if thinking:
|
|
572
|
+
native["reasoning_content"] = "".join(thinking)
|
|
573
|
+
if calls:
|
|
574
|
+
native["tool_calls"] = [
|
|
575
|
+
{"id": c.id, "type": "function", "function": {"name": c.name, "arguments": c.arguments}} for c in calls
|
|
576
|
+
]
|
|
577
|
+
stop = "max_tokens" if reason == "length" else reason
|
|
578
|
+
yield Completed(Message("assistant", tuple(content), self.config.protocol, (native,), calls), stop, usage)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def create_provider(config: ProviderConfig) -> SDKProvider:
|
|
582
|
+
adapters = {
|
|
583
|
+
"anthropic-messages": AnthropicProvider,
|
|
584
|
+
"openai-responses": ResponsesProvider,
|
|
585
|
+
"openai-chat-completions": ChatCompletionsProvider,
|
|
586
|
+
}
|
|
587
|
+
return adapters[config.protocol](config)
|