citrus-code 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.
- citrus_code/__init__.py +0 -0
- citrus_code/__main__.py +5 -0
- citrus_code/agent/__init__.py +0 -0
- citrus_code/agent/agent.py +255 -0
- citrus_code/agent/cancellation.py +66 -0
- citrus_code/agent/context.py +20 -0
- citrus_code/agent/events.py +97 -0
- citrus_code/agent/hooks.py +37 -0
- citrus_code/agent/loop.py +429 -0
- citrus_code/agent/message_queue.py +44 -0
- citrus_code/agent/state.py +13 -0
- citrus_code/agent/tool_runner.py +161 -0
- citrus_code/agent/types.py +34 -0
- citrus_code/ai/__init__.py +0 -0
- citrus_code/ai/completion.py +30 -0
- citrus_code/ai/events.py +78 -0
- citrus_code/ai/provider.py +20 -0
- citrus_code/ai/providers/__init__.py +0 -0
- citrus_code/ai/providers/chat_completions.py +503 -0
- citrus_code/ai/providers/chat_stream_types.py +47 -0
- citrus_code/ai/providers/deepseek.py +104 -0
- citrus_code/ai/providers/fake.py +101 -0
- citrus_code/ai/providers/tool_call_buffer.py +73 -0
- citrus_code/ai/registry.py +40 -0
- citrus_code/ai/types.py +142 -0
- citrus_code/cli/__init__.py +5 -0
- citrus_code/cli/main.py +258 -0
- citrus_code/cli/preview.py +55 -0
- citrus_code/cli/prompts.py +52 -0
- citrus_code/cli/render.py +306 -0
- citrus_code/cli/session_files.py +75 -0
- citrus_code/cli/terminal.py +280 -0
- citrus_code/cli/theme.py +51 -0
- citrus_code/coding/__init__.py +0 -0
- citrus_code/coding/coding_session.py +287 -0
- citrus_code/coding/compaction/__init__.py +5 -0
- citrus_code/coding/compaction/controller.py +168 -0
- citrus_code/coding/compaction/summarize.py +125 -0
- citrus_code/coding/compaction/tokens.py +48 -0
- citrus_code/coding/context_hooks.py +41 -0
- citrus_code/coding/history.py +47 -0
- citrus_code/coding/project_context.py +44 -0
- citrus_code/coding/session/__init__.py +30 -0
- citrus_code/coding/session/manager.py +200 -0
- citrus_code/coding/session/projection.py +50 -0
- citrus_code/coding/session/recovery.py +33 -0
- citrus_code/coding/session/storage.py +138 -0
- citrus_code/coding/session/types.py +108 -0
- citrus_code/coding/tools/__init__.py +35 -0
- citrus_code/coding/tools/bash.py +5 -0
- citrus_code/coding/tools/edit.py +101 -0
- citrus_code/coding/tools/edit_text.py +172 -0
- citrus_code/coding/tools/factory.py +57 -0
- citrus_code/coding/tools/find.py +86 -0
- citrus_code/coding/tools/grep.py +151 -0
- citrus_code/coding/tools/ls.py +61 -0
- citrus_code/coding/tools/mutation.py +39 -0
- citrus_code/coding/tools/paths.py +11 -0
- citrus_code/coding/tools/powershell.py +5 -0
- citrus_code/coding/tools/processes.py +103 -0
- citrus_code/coding/tools/read.py +133 -0
- citrus_code/coding/tools/shell.py +218 -0
- citrus_code/coding/tools/truncate.py +87 -0
- citrus_code/coding/tools/write.py +94 -0
- citrus_code/config.py +34 -0
- citrus_code-0.1.0.dist-info/METADATA +412 -0
- citrus_code-0.1.0.dist-info/RECORD +70 -0
- citrus_code-0.1.0.dist-info/WHEEL +4 -0
- citrus_code-0.1.0.dist-info/entry_points.txt +3 -0
- citrus_code-0.1.0.dist-info/licenses/LICENSE +22 -0
citrus_code/ai/events.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from typing import Annotated,Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel,ConfigDict,Field
|
|
4
|
+
|
|
5
|
+
from .types import AssistantMessage,ToolCall
|
|
6
|
+
|
|
7
|
+
class PartialEvent(BaseModel):
|
|
8
|
+
model_config = ConfigDict(extra="forbid")
|
|
9
|
+
|
|
10
|
+
partial: AssistantMessage
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StartEvent(PartialEvent):
|
|
14
|
+
type: Literal["start"] = "start"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TextStartEvent(PartialEvent):
|
|
18
|
+
type: Literal["text_start"]="text_start"
|
|
19
|
+
content_index: int = Field(ge=0)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TextDeltaEvent(PartialEvent):
|
|
23
|
+
type: Literal["text_delta"] = "text_delta"
|
|
24
|
+
content_index: int = Field(ge=0)
|
|
25
|
+
delta: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TextEndEvent(PartialEvent):
|
|
29
|
+
type: Literal["text_end"] = "text_end"
|
|
30
|
+
content_index: int = Field(ge=0)
|
|
31
|
+
content: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ToolCallStartEvent(PartialEvent):
|
|
35
|
+
type: Literal["toolcall_start"] = "toolcall_start"
|
|
36
|
+
content_index: int = Field(ge=0)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ToolCallDeltaEvent(PartialEvent):
|
|
40
|
+
type: Literal["toolcall_delta"] = "toolcall_delta"
|
|
41
|
+
content_index: int = Field(ge=0)
|
|
42
|
+
delta: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ToolCallEndEvent(PartialEvent):
|
|
46
|
+
type: Literal["toolcall_end"] = "toolcall_end"
|
|
47
|
+
content_index: int = Field(ge=0)
|
|
48
|
+
tool_call: ToolCall
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DoneEvent(BaseModel):
|
|
52
|
+
model_config = ConfigDict(extra="forbid")
|
|
53
|
+
|
|
54
|
+
type: Literal["done"] = "done"
|
|
55
|
+
reason: Literal["stop", "length", "toolUse"]
|
|
56
|
+
message: AssistantMessage
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ErrorEvent(BaseModel):
|
|
60
|
+
model_config = ConfigDict(extra="forbid")
|
|
61
|
+
|
|
62
|
+
type: Literal["error"] = "error"
|
|
63
|
+
reason: Literal["error", "aborted"]
|
|
64
|
+
error: AssistantMessage
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
AssistantMessageEvent = Annotated[
|
|
68
|
+
StartEvent
|
|
69
|
+
| TextStartEvent
|
|
70
|
+
| TextDeltaEvent
|
|
71
|
+
| TextEndEvent
|
|
72
|
+
| ToolCallStartEvent
|
|
73
|
+
| ToolCallDeltaEvent
|
|
74
|
+
| ToolCallEndEvent
|
|
75
|
+
| DoneEvent
|
|
76
|
+
| ErrorEvent,
|
|
77
|
+
Field(discriminator="type"),
|
|
78
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from collections.abc import AsyncIterator
|
|
2
|
+
from typing import Protocol
|
|
3
|
+
|
|
4
|
+
from .events import AssistantMessageEvent
|
|
5
|
+
from .types import ModelContext, ModelOptions, ModelSpec
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Provider(Protocol):
|
|
9
|
+
id: str
|
|
10
|
+
name: str
|
|
11
|
+
|
|
12
|
+
def get_models(self) -> list[ModelSpec]:
|
|
13
|
+
...
|
|
14
|
+
def stream(
|
|
15
|
+
self,
|
|
16
|
+
model: ModelSpec,
|
|
17
|
+
context: ModelContext,
|
|
18
|
+
options: ModelOptions | None = None,
|
|
19
|
+
) -> AsyncIterator[AssistantMessageEvent]:
|
|
20
|
+
...
|
|
File without changes
|
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import (
|
|
6
|
+
AsyncGenerator,
|
|
7
|
+
AsyncIterator,
|
|
8
|
+
Iterable,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from pydantic import JsonValue, SecretStr
|
|
13
|
+
|
|
14
|
+
from ..types import (
|
|
15
|
+
AssistantMessage,
|
|
16
|
+
ImageContent,
|
|
17
|
+
Message,
|
|
18
|
+
SystemMessage,
|
|
19
|
+
TextContent,
|
|
20
|
+
ToolCall,
|
|
21
|
+
ToolResultMessage,
|
|
22
|
+
UserMessage,
|
|
23
|
+
ModelContext,
|
|
24
|
+
ModelOptions,
|
|
25
|
+
ModelSpec,
|
|
26
|
+
Usage,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from ..events import (
|
|
30
|
+
AssistantMessageEvent,
|
|
31
|
+
DoneEvent,
|
|
32
|
+
ErrorEvent,
|
|
33
|
+
StartEvent,
|
|
34
|
+
TextDeltaEvent,
|
|
35
|
+
TextEndEvent,
|
|
36
|
+
TextStartEvent,
|
|
37
|
+
ToolCallDeltaEvent,
|
|
38
|
+
ToolCallEndEvent,
|
|
39
|
+
ToolCallStartEvent,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
from .tool_call_buffer import ToolCallBuffer
|
|
43
|
+
|
|
44
|
+
from .chat_stream_types import ChatStreamChunk
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def to_text(content: str | Iterable[TextContent | ImageContent]) -> str:
|
|
48
|
+
if isinstance(content, str):
|
|
49
|
+
return content
|
|
50
|
+
|
|
51
|
+
parts: list[str] = []
|
|
52
|
+
|
|
53
|
+
for block in content:
|
|
54
|
+
if isinstance(block, TextContent):
|
|
55
|
+
parts.append(block.text)
|
|
56
|
+
else:
|
|
57
|
+
parts.append("[Image attachment]")
|
|
58
|
+
|
|
59
|
+
return "".join(parts)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def image_part(block: ImageContent) -> dict[str, JsonValue]:
|
|
63
|
+
return {
|
|
64
|
+
"type": "image_url",
|
|
65
|
+
"image_url": {"url": f"data:{block.mime_type};base64,{block.data}"},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def to_chat_message(
|
|
70
|
+
message: Message, *, supports_images: bool = False
|
|
71
|
+
) -> dict[str, JsonValue]:
|
|
72
|
+
if isinstance(message, SystemMessage):
|
|
73
|
+
return {
|
|
74
|
+
"role": message.role,
|
|
75
|
+
"content": to_text(message.content)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if isinstance(message, UserMessage):
|
|
79
|
+
if isinstance(message.content, str):
|
|
80
|
+
return {"role": "user", "content": message.content}
|
|
81
|
+
has_images = any(isinstance(block, ImageContent) for block in message.content)
|
|
82
|
+
if not has_images:
|
|
83
|
+
return {"role": "user", "content": to_text(message.content)}
|
|
84
|
+
if not supports_images:
|
|
85
|
+
return {
|
|
86
|
+
"role": "user",
|
|
87
|
+
"content": to_text(message.content) + "\n[Images omitted: this model configuration does not support images.]",
|
|
88
|
+
}
|
|
89
|
+
parts: list[JsonValue] = []
|
|
90
|
+
for block in message.content:
|
|
91
|
+
if isinstance(block, TextContent):
|
|
92
|
+
parts.append({"type": "text", "text": block.text})
|
|
93
|
+
else:
|
|
94
|
+
parts.append(image_part(block))
|
|
95
|
+
return {"role": "user", "content": parts}
|
|
96
|
+
|
|
97
|
+
if isinstance(message, ToolResultMessage):
|
|
98
|
+
text = to_text(message.content)
|
|
99
|
+
if not supports_images and any(isinstance(block, ImageContent) for block in message.content):
|
|
100
|
+
text += "\n[Image omitted: this model configuration does not support images.]"
|
|
101
|
+
return {
|
|
102
|
+
"role": "tool",
|
|
103
|
+
"tool_call_id": message.tool_call_id,
|
|
104
|
+
"content": text,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if isinstance(message, AssistantMessage):
|
|
108
|
+
text_parts: list[str] = []
|
|
109
|
+
tool_calls: list[JsonValue] = []
|
|
110
|
+
|
|
111
|
+
for block in message.content:
|
|
112
|
+
if isinstance(block,TextContent):
|
|
113
|
+
text_parts.append(block.text)
|
|
114
|
+
|
|
115
|
+
elif isinstance(block, ToolCall):
|
|
116
|
+
tool_calls.append(
|
|
117
|
+
{
|
|
118
|
+
"id": block.id,
|
|
119
|
+
"type": "function",
|
|
120
|
+
"function": {
|
|
121
|
+
"name": block.name,
|
|
122
|
+
"arguments": json.dumps(
|
|
123
|
+
block.arguments,
|
|
124
|
+
ensure_ascii=False,
|
|
125
|
+
allow_nan=False,
|
|
126
|
+
),
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
else:
|
|
132
|
+
raise TypeError(
|
|
133
|
+
"Unsupported assistant content type"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if message.stop_reason == "length":
|
|
137
|
+
diagnostic = message.error_message or "本条回复因长度限制提前结束,内容不完整。"
|
|
138
|
+
text_parts.append(f"\n\n[运行时提示:{diagnostic}]")
|
|
139
|
+
|
|
140
|
+
result: dict[str, JsonValue] = {
|
|
141
|
+
"role": "assistant",
|
|
142
|
+
"content": "".join(text_parts),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if tool_calls:
|
|
146
|
+
result["tool_calls"] = tool_calls
|
|
147
|
+
|
|
148
|
+
return result
|
|
149
|
+
|
|
150
|
+
raise TypeError("Unsupported message type")
|
|
151
|
+
|
|
152
|
+
def build_chat_request(
|
|
153
|
+
model: ModelSpec,
|
|
154
|
+
context: ModelContext,
|
|
155
|
+
options: ModelOptions | None = None,
|
|
156
|
+
) -> dict[str, JsonValue]:
|
|
157
|
+
if not context.messages:
|
|
158
|
+
raise ValueError("At least one message is required")
|
|
159
|
+
|
|
160
|
+
messages: list[JsonValue] = []
|
|
161
|
+
|
|
162
|
+
pending_images: list[JsonValue] = []
|
|
163
|
+
for index, message in enumerate(context.messages):
|
|
164
|
+
messages.append(to_chat_message(message, supports_images=model.supports_images))
|
|
165
|
+
if isinstance(message, ToolResultMessage) and model.supports_images:
|
|
166
|
+
pending_images.extend(
|
|
167
|
+
image_part(block)
|
|
168
|
+
for block in message.content
|
|
169
|
+
if isinstance(block, ImageContent)
|
|
170
|
+
)
|
|
171
|
+
last_tool_result = (
|
|
172
|
+
index + 1 == len(context.messages)
|
|
173
|
+
or not isinstance(context.messages[index + 1], ToolResultMessage)
|
|
174
|
+
)
|
|
175
|
+
if pending_images and last_tool_result:
|
|
176
|
+
# All tool replies in a batch must precede the synthetic user
|
|
177
|
+
# image message, matching pi's Chat Completions conversion.
|
|
178
|
+
messages.append({
|
|
179
|
+
"role": "user",
|
|
180
|
+
"content": [
|
|
181
|
+
{"type": "text", "text": "Attached image(s) from tool results:"},
|
|
182
|
+
*pending_images,
|
|
183
|
+
],
|
|
184
|
+
})
|
|
185
|
+
pending_images = []
|
|
186
|
+
|
|
187
|
+
body: dict[str, JsonValue] = {
|
|
188
|
+
"model": model.id,
|
|
189
|
+
"messages": messages,
|
|
190
|
+
"stream": True,
|
|
191
|
+
"stream_options": {
|
|
192
|
+
"include_usage": True,
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if context.tools:
|
|
197
|
+
tools: list[JsonValue] = []
|
|
198
|
+
|
|
199
|
+
for tool in context.tools:
|
|
200
|
+
tools.append(
|
|
201
|
+
{
|
|
202
|
+
"type": "function",
|
|
203
|
+
"function": {
|
|
204
|
+
"name": tool.name,
|
|
205
|
+
"description": tool.description,
|
|
206
|
+
"parameters": tool.parameters,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
body["tools"] = tools
|
|
212
|
+
|
|
213
|
+
if options is not None:
|
|
214
|
+
if options.max_output_tokens is not None:
|
|
215
|
+
body["max_tokens"] = options.max_output_tokens
|
|
216
|
+
|
|
217
|
+
if options.temperature is not None:
|
|
218
|
+
body["temperature"] = options.temperature
|
|
219
|
+
|
|
220
|
+
return body
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
async def stream_chat_data(
|
|
224
|
+
client: httpx.AsyncClient,
|
|
225
|
+
model: ModelSpec,
|
|
226
|
+
api_key: SecretStr,
|
|
227
|
+
body: dict[str, JsonValue],
|
|
228
|
+
) -> AsyncIterator[str]:
|
|
229
|
+
url=model.base_url.rstrip("/") + "/chat/completions"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
headers = {
|
|
233
|
+
"Authorization": f"Bearer {api_key.get_secret_value()}",
|
|
234
|
+
"Accept": "text/event-stream",
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async with client.stream(
|
|
239
|
+
"POST",
|
|
240
|
+
url,
|
|
241
|
+
headers=headers,
|
|
242
|
+
json=body,
|
|
243
|
+
) as response:
|
|
244
|
+
response.raise_for_status()
|
|
245
|
+
response.encoding = "utf-8"
|
|
246
|
+
|
|
247
|
+
data_lines: list[str] = []
|
|
248
|
+
|
|
249
|
+
async for line in response.aiter_lines():
|
|
250
|
+
if line == "":
|
|
251
|
+
if not data_lines:
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
data = "\n".join(data_lines)
|
|
255
|
+
data_lines.clear()
|
|
256
|
+
|
|
257
|
+
if data == "[DONE]":
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
yield data
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
if line.startswith(":"):
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
field, _, value = line.partition(":")
|
|
267
|
+
|
|
268
|
+
if field == "data":
|
|
269
|
+
if value.startswith(" "):
|
|
270
|
+
value = value[1:]
|
|
271
|
+
|
|
272
|
+
data_lines.append(value)
|
|
273
|
+
|
|
274
|
+
raise RuntimeError(
|
|
275
|
+
"The chat stream ended without a [DONE] marker"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
async def stream_assistant_events(
|
|
279
|
+
model: ModelSpec,
|
|
280
|
+
data_stream: AsyncIterator[str],
|
|
281
|
+
) -> AsyncIterator[AssistantMessageEvent]:
|
|
282
|
+
message = AssistantMessage(
|
|
283
|
+
content=[],
|
|
284
|
+
api=model.api,
|
|
285
|
+
provider=model.provider,
|
|
286
|
+
model=model.id,
|
|
287
|
+
usage=None,
|
|
288
|
+
stop_reason="pending",
|
|
289
|
+
timestamp=int(time.time() * 1000),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
text_block: TextContent | None = None
|
|
293
|
+
text_content_index = 0
|
|
294
|
+
tool_buffers: dict[int, ToolCallBuffer] = {}
|
|
295
|
+
|
|
296
|
+
finish_reason: Literal[
|
|
297
|
+
"stop", "length", "tool_calls"
|
|
298
|
+
] | None = None
|
|
299
|
+
|
|
300
|
+
yield StartEvent(partial=message)
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
async for data in data_stream:
|
|
304
|
+
chunk = ChatStreamChunk.model_validate_json(data)
|
|
305
|
+
|
|
306
|
+
if chunk.error is not None:
|
|
307
|
+
raise RuntimeError(
|
|
308
|
+
f"The API returned an error: {chunk.error}"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
if chunk.usage is not None:
|
|
312
|
+
message.usage = Usage(
|
|
313
|
+
input_tokens=chunk.usage.prompt_tokens,
|
|
314
|
+
output_tokens=chunk.usage.completion_tokens,
|
|
315
|
+
total_tokens=chunk.usage.total_tokens,
|
|
316
|
+
cache_read_tokens=(
|
|
317
|
+
chunk.usage.prompt_cache_hit_tokens
|
|
318
|
+
),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
for choice in chunk.choices:
|
|
322
|
+
if choice.index != 0:
|
|
323
|
+
raise RuntimeError(
|
|
324
|
+
"Only one response choice is supported"
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
delta = choice.delta
|
|
328
|
+
|
|
329
|
+
if delta.reasoning_content:
|
|
330
|
+
raise NotImplementedError(
|
|
331
|
+
"Thinking content is not implemented yet"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
if finish_reason is not None:
|
|
335
|
+
if delta.content or delta.tool_calls:
|
|
336
|
+
raise RuntimeError(
|
|
337
|
+
"Received content after the response finished"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
if delta.content:
|
|
341
|
+
if text_block is None:
|
|
342
|
+
text_content_index = len(message.content)
|
|
343
|
+
text_block = TextContent(text="")
|
|
344
|
+
message.content.append(text_block)
|
|
345
|
+
|
|
346
|
+
yield TextStartEvent(
|
|
347
|
+
content_index=text_content_index,
|
|
348
|
+
partial=message,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
text_block.text += delta.content
|
|
352
|
+
|
|
353
|
+
yield TextDeltaEvent(
|
|
354
|
+
content_index=text_content_index,
|
|
355
|
+
delta=delta.content,
|
|
356
|
+
partial=message,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
if delta.tool_calls:
|
|
360
|
+
for tool_delta in delta.tool_calls:
|
|
361
|
+
buffer = tool_buffers.get(tool_delta.index)
|
|
362
|
+
is_new = buffer is None
|
|
363
|
+
|
|
364
|
+
if buffer is None:
|
|
365
|
+
buffer = ToolCallBuffer(
|
|
366
|
+
tool_index=tool_delta.index,
|
|
367
|
+
content_index=len(message.content),
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
tool_buffers[tool_delta.index] = buffer
|
|
371
|
+
message.content.append(buffer.tool_call)
|
|
372
|
+
|
|
373
|
+
fragment = buffer.append(tool_delta)
|
|
374
|
+
|
|
375
|
+
if is_new:
|
|
376
|
+
yield ToolCallStartEvent(
|
|
377
|
+
content_index=buffer.content_index,
|
|
378
|
+
partial=message,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
if fragment:
|
|
382
|
+
yield ToolCallDeltaEvent(
|
|
383
|
+
content_index=buffer.content_index,
|
|
384
|
+
delta=fragment,
|
|
385
|
+
partial=message,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
if choice.finish_reason is not None:
|
|
389
|
+
if choice.finish_reason not in (
|
|
390
|
+
"stop",
|
|
391
|
+
"length",
|
|
392
|
+
"tool_calls",
|
|
393
|
+
):
|
|
394
|
+
raise RuntimeError(
|
|
395
|
+
"Unsupported finish reason: "
|
|
396
|
+
f"{choice.finish_reason}"
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
finish_reason = choice.finish_reason
|
|
400
|
+
|
|
401
|
+
if finish_reason is None:
|
|
402
|
+
raise RuntimeError(
|
|
403
|
+
"The response ended without a finish reason"
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
reason: Literal["stop", "length", "toolUse"]
|
|
407
|
+
truncated_tool_calls = finish_reason == "length" and bool(tool_buffers)
|
|
408
|
+
|
|
409
|
+
if finish_reason == "tool_calls":
|
|
410
|
+
if not tool_buffers:
|
|
411
|
+
raise RuntimeError(
|
|
412
|
+
"The response finished with tool_calls "
|
|
413
|
+
"but contained no tool calls"
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
seen_ids: set[str] = set()
|
|
417
|
+
|
|
418
|
+
for buffer in tool_buffers.values():
|
|
419
|
+
tool_call = buffer.finish()
|
|
420
|
+
|
|
421
|
+
if tool_call.id in seen_ids:
|
|
422
|
+
raise ValueError("Duplicate tool call ID")
|
|
423
|
+
|
|
424
|
+
seen_ids.add(tool_call.id)
|
|
425
|
+
|
|
426
|
+
reason = "toolUse"
|
|
427
|
+
|
|
428
|
+
else:
|
|
429
|
+
if truncated_tool_calls:
|
|
430
|
+
names = ", ".join(
|
|
431
|
+
buffer.tool_call.name or "unknown"
|
|
432
|
+
for buffer in tool_buffers.values()
|
|
433
|
+
)
|
|
434
|
+
message.error_message = (
|
|
435
|
+
f"工具参数生成时回复被截断({names})。"
|
|
436
|
+
"本条回复中的工具调用均未执行。"
|
|
437
|
+
"请提高 --max-output-tokens 后重试,"
|
|
438
|
+
"或将较大的文件分批写入。"
|
|
439
|
+
)
|
|
440
|
+
elif tool_buffers:
|
|
441
|
+
raise RuntimeError(
|
|
442
|
+
"Tool calls did not finish normally: "
|
|
443
|
+
f"{finish_reason}"
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
reason = finish_reason
|
|
447
|
+
|
|
448
|
+
for content_index, block in enumerate(message.content):
|
|
449
|
+
if isinstance(block, TextContent):
|
|
450
|
+
yield TextEndEvent(
|
|
451
|
+
content_index=content_index,
|
|
452
|
+
content=block.text,
|
|
453
|
+
partial=message,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
elif isinstance(block, ToolCall) and not truncated_tool_calls:
|
|
457
|
+
yield ToolCallEndEvent(
|
|
458
|
+
content_index=content_index,
|
|
459
|
+
tool_call=block,
|
|
460
|
+
partial=message,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
if truncated_tool_calls:
|
|
464
|
+
# A length stop cannot authorize execution, even when one call in
|
|
465
|
+
# the batch happens to contain parseable JSON. Keep only text in
|
|
466
|
+
# the committed history, so no orphan or partial call is replayed.
|
|
467
|
+
# Close text blocks before removing calls to preserve event indices.
|
|
468
|
+
message.content = [
|
|
469
|
+
block for block in message.content if isinstance(block, TextContent)
|
|
470
|
+
]
|
|
471
|
+
|
|
472
|
+
message.stop_reason = reason
|
|
473
|
+
|
|
474
|
+
yield DoneEvent(
|
|
475
|
+
reason=reason,
|
|
476
|
+
message=message,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
except Exception as exc:
|
|
480
|
+
message.stop_reason = "error"
|
|
481
|
+
detail = str(exc).strip()
|
|
482
|
+
if isinstance(exc, httpx.ReadTimeout):
|
|
483
|
+
detail = (
|
|
484
|
+
"等待模型响应数据超时(ReadTimeout)。"
|
|
485
|
+
"这是模型接口读取超时,不代表上一条工具命令失败。"
|
|
486
|
+
"CLI 可通过 --model-timeout 增加等待时间。"
|
|
487
|
+
)
|
|
488
|
+
elif isinstance(exc, httpx.ConnectTimeout):
|
|
489
|
+
detail = "连接模型服务超时,请检查网络、代理和接口地址。"
|
|
490
|
+
elif isinstance(exc, httpx.WriteTimeout):
|
|
491
|
+
detail = "向模型服务发送请求超时,请检查网络及图片等请求内容的大小。"
|
|
492
|
+
elif isinstance(exc, httpx.PoolTimeout):
|
|
493
|
+
detail = "等待模型 HTTP 连接池超时。"
|
|
494
|
+
message.error_message = f"{type(exc).__name__}: {detail or '模型调用失败,异常未提供详细文字。'}"
|
|
495
|
+
|
|
496
|
+
yield ErrorEvent(
|
|
497
|
+
reason="error",
|
|
498
|
+
error=message,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
finally:
|
|
502
|
+
if isinstance(data_stream, AsyncGenerator):
|
|
503
|
+
await data_stream.aclose()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ApiModel(BaseModel):
|
|
6
|
+
model_config = ConfigDict(extra="ignore")
|
|
7
|
+
|
|
8
|
+
class ChatToolFunctionDelta(ApiModel):
|
|
9
|
+
name: str | None = None
|
|
10
|
+
arguments: str | None = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ChatToolCallDelta(ApiModel):
|
|
14
|
+
index: int = Field(ge=0)
|
|
15
|
+
id: str | None = None
|
|
16
|
+
type: Literal["function"] | None = None
|
|
17
|
+
function: ChatToolFunctionDelta | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ChatStreamDelta(ApiModel):
|
|
21
|
+
content: str | None = None
|
|
22
|
+
reasoning_content: str | None = None
|
|
23
|
+
tool_calls: list[ChatToolCallDelta] | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ChatStreamChoice(ApiModel):
|
|
27
|
+
index: int = Field(ge=0)
|
|
28
|
+
delta: ChatStreamDelta
|
|
29
|
+
finish_reason: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ChatStreamUsage(ApiModel):
|
|
33
|
+
prompt_tokens: int = Field(ge=0)
|
|
34
|
+
completion_tokens: int = Field(ge=0)
|
|
35
|
+
total_tokens: int = Field(ge=0)
|
|
36
|
+
prompt_cache_hit_tokens: int | None = Field(
|
|
37
|
+
default=None,
|
|
38
|
+
ge=0,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ChatStreamChunk(ApiModel):
|
|
43
|
+
choices: list[ChatStreamChoice] = Field(
|
|
44
|
+
default_factory=list,
|
|
45
|
+
)
|
|
46
|
+
usage: ChatStreamUsage | None = None
|
|
47
|
+
error: JsonValue = None
|