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.
Files changed (70) hide show
  1. citrus_code/__init__.py +0 -0
  2. citrus_code/__main__.py +5 -0
  3. citrus_code/agent/__init__.py +0 -0
  4. citrus_code/agent/agent.py +255 -0
  5. citrus_code/agent/cancellation.py +66 -0
  6. citrus_code/agent/context.py +20 -0
  7. citrus_code/agent/events.py +97 -0
  8. citrus_code/agent/hooks.py +37 -0
  9. citrus_code/agent/loop.py +429 -0
  10. citrus_code/agent/message_queue.py +44 -0
  11. citrus_code/agent/state.py +13 -0
  12. citrus_code/agent/tool_runner.py +161 -0
  13. citrus_code/agent/types.py +34 -0
  14. citrus_code/ai/__init__.py +0 -0
  15. citrus_code/ai/completion.py +30 -0
  16. citrus_code/ai/events.py +78 -0
  17. citrus_code/ai/provider.py +20 -0
  18. citrus_code/ai/providers/__init__.py +0 -0
  19. citrus_code/ai/providers/chat_completions.py +503 -0
  20. citrus_code/ai/providers/chat_stream_types.py +47 -0
  21. citrus_code/ai/providers/deepseek.py +104 -0
  22. citrus_code/ai/providers/fake.py +101 -0
  23. citrus_code/ai/providers/tool_call_buffer.py +73 -0
  24. citrus_code/ai/registry.py +40 -0
  25. citrus_code/ai/types.py +142 -0
  26. citrus_code/cli/__init__.py +5 -0
  27. citrus_code/cli/main.py +258 -0
  28. citrus_code/cli/preview.py +55 -0
  29. citrus_code/cli/prompts.py +52 -0
  30. citrus_code/cli/render.py +306 -0
  31. citrus_code/cli/session_files.py +75 -0
  32. citrus_code/cli/terminal.py +280 -0
  33. citrus_code/cli/theme.py +51 -0
  34. citrus_code/coding/__init__.py +0 -0
  35. citrus_code/coding/coding_session.py +287 -0
  36. citrus_code/coding/compaction/__init__.py +5 -0
  37. citrus_code/coding/compaction/controller.py +168 -0
  38. citrus_code/coding/compaction/summarize.py +125 -0
  39. citrus_code/coding/compaction/tokens.py +48 -0
  40. citrus_code/coding/context_hooks.py +41 -0
  41. citrus_code/coding/history.py +47 -0
  42. citrus_code/coding/project_context.py +44 -0
  43. citrus_code/coding/session/__init__.py +30 -0
  44. citrus_code/coding/session/manager.py +200 -0
  45. citrus_code/coding/session/projection.py +50 -0
  46. citrus_code/coding/session/recovery.py +33 -0
  47. citrus_code/coding/session/storage.py +138 -0
  48. citrus_code/coding/session/types.py +108 -0
  49. citrus_code/coding/tools/__init__.py +35 -0
  50. citrus_code/coding/tools/bash.py +5 -0
  51. citrus_code/coding/tools/edit.py +101 -0
  52. citrus_code/coding/tools/edit_text.py +172 -0
  53. citrus_code/coding/tools/factory.py +57 -0
  54. citrus_code/coding/tools/find.py +86 -0
  55. citrus_code/coding/tools/grep.py +151 -0
  56. citrus_code/coding/tools/ls.py +61 -0
  57. citrus_code/coding/tools/mutation.py +39 -0
  58. citrus_code/coding/tools/paths.py +11 -0
  59. citrus_code/coding/tools/powershell.py +5 -0
  60. citrus_code/coding/tools/processes.py +103 -0
  61. citrus_code/coding/tools/read.py +133 -0
  62. citrus_code/coding/tools/shell.py +218 -0
  63. citrus_code/coding/tools/truncate.py +87 -0
  64. citrus_code/coding/tools/write.py +94 -0
  65. citrus_code/config.py +34 -0
  66. citrus_code-0.1.0.dist-info/METADATA +412 -0
  67. citrus_code-0.1.0.dist-info/RECORD +70 -0
  68. citrus_code-0.1.0.dist-info/WHEEL +4 -0
  69. citrus_code-0.1.0.dist-info/entry_points.txt +3 -0
  70. citrus_code-0.1.0.dist-info/licenses/LICENSE +22 -0
@@ -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