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.
Files changed (91) hide show
  1. mosscode/__init__.py +10 -0
  2. mosscode/__main__.py +3 -0
  3. mosscode/agent.py +486 -0
  4. mosscode/agents/__init__.py +249 -0
  5. mosscode/agents/builtin/explore.md +16 -0
  6. mosscode/agents/builtin/general.md +12 -0
  7. mosscode/agents/builtin/plan.md +16 -0
  8. mosscode/agents/runtime.py +685 -0
  9. mosscode/agents/tasks.py +193 -0
  10. mosscode/appearance.py +184 -0
  11. mosscode/builtin_commands.py +1363 -0
  12. mosscode/cli.py +547 -0
  13. mosscode/commands.py +254 -0
  14. mosscode/compaction.py +259 -0
  15. mosscode/config.py +337 -0
  16. mosscode/context.py +102 -0
  17. mosscode/conversation.py +1046 -0
  18. mosscode/coordinator.py +54 -0
  19. mosscode/doctor.py +303 -0
  20. mosscode/hooks/__init__.py +22 -0
  21. mosscode/hooks/actions.py +151 -0
  22. mosscode/hooks/conditions.py +170 -0
  23. mosscode/hooks/config.py +404 -0
  24. mosscode/hooks/engine.py +401 -0
  25. mosscode/hooks/events.py +48 -0
  26. mosscode/launcher.py +177 -0
  27. mosscode/mcp_client.py +460 -0
  28. mosscode/mcp_config.py +216 -0
  29. mosscode/member_ui.py +289 -0
  30. mosscode/memory.py +291 -0
  31. mosscode/memory_extract.py +245 -0
  32. mosscode/messages.py +216 -0
  33. mosscode/paths.py +31 -0
  34. mosscode/permissions.py +733 -0
  35. mosscode/prompts.py +483 -0
  36. mosscode/providers.py +587 -0
  37. mosscode/scrollback.py +106 -0
  38. mosscode/security.py +282 -0
  39. mosscode/serialization.py +121 -0
  40. mosscode/session.py +563 -0
  41. mosscode/setup.py +248 -0
  42. mosscode/skills/__init__.py +455 -0
  43. mosscode/skills/builtin/commit/SKILL.md +15 -0
  44. mosscode/skills/builtin/review/SKILL.md +15 -0
  45. mosscode/skills/builtin/test/SKILL.md +13 -0
  46. mosscode/skills/builtin/test/scripts/detect.py +96 -0
  47. mosscode/skills/builtin/test/tool.json +9 -0
  48. mosscode/teams/__init__.py +216 -0
  49. mosscode/teams/backends.py +259 -0
  50. mosscode/teams/board.py +230 -0
  51. mosscode/teams/lock.py +173 -0
  52. mosscode/teams/mailbox.py +172 -0
  53. mosscode/teams/member.py +427 -0
  54. mosscode/teams/merge.py +207 -0
  55. mosscode/teams/pane.py +20 -0
  56. mosscode/teams/pane_app.py +167 -0
  57. mosscode/teams/process.py +371 -0
  58. mosscode/teams/runtime.py +1355 -0
  59. mosscode/teams/view.py +182 -0
  60. mosscode/tool_budget.py +86 -0
  61. mosscode/tools/__init__.py +38 -0
  62. mosscode/tools/agent.py +127 -0
  63. mosscode/tools/base.py +384 -0
  64. mosscode/tools/diffing.py +16 -0
  65. mosscode/tools/execution.py +99 -0
  66. mosscode/tools/files.py +287 -0
  67. mosscode/tools/memory.py +65 -0
  68. mosscode/tools/plan.py +30 -0
  69. mosscode/tools/process.py +216 -0
  70. mosscode/tools/search.py +229 -0
  71. mosscode/tools/search_worker.py +243 -0
  72. mosscode/tools/shell.py +121 -0
  73. mosscode/tools/skill.py +118 -0
  74. mosscode/tools/team.py +447 -0
  75. mosscode/tools/windows_job.py +75 -0
  76. mosscode/tui.py +2325 -0
  77. mosscode/usage.py +68 -0
  78. mosscode/windows_inline.py +148 -0
  79. mosscode/worktree/__init__.py +29 -0
  80. mosscode/worktree/changes.py +59 -0
  81. mosscode/worktree/git.py +125 -0
  82. mosscode/worktree/manager.py +462 -0
  83. mosscode/worktree/meta.py +82 -0
  84. mosscode/worktree/names.py +52 -0
  85. mosscode/worktree/setup.py +119 -0
  86. mosscode-0.1.0.dist-info/METADATA +92 -0
  87. mosscode-0.1.0.dist-info/RECORD +91 -0
  88. mosscode-0.1.0.dist-info/WHEEL +5 -0
  89. mosscode-0.1.0.dist-info/entry_points.txt +3 -0
  90. mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
  91. 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)