klaude-code 1.2.23__py3-none-any.whl → 1.2.24__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.
- klaude_code/command/prompt-jj-describe.md +32 -0
- klaude_code/{const/__init__.py → const.py} +4 -1
- klaude_code/core/executor.py +1 -1
- klaude_code/core/tool/file/read_tool.py +23 -1
- klaude_code/core/tool/file/write_tool.py +7 -3
- klaude_code/llm/openai_compatible/client.py +29 -102
- klaude_code/llm/openai_compatible/stream.py +272 -0
- klaude_code/llm/openrouter/client.py +29 -109
- klaude_code/llm/openrouter/{reasoning_handler.py → reasoning.py} +24 -2
- klaude_code/protocol/model.py +13 -1
- klaude_code/ui/core/stage_manager.py +0 -3
- klaude_code/ui/modes/repl/event_handler.py +94 -46
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +13 -3
- klaude_code/ui/modes/repl/renderer.py +24 -17
- klaude_code/ui/renderers/assistant.py +1 -1
- klaude_code/ui/renderers/metadata.py +2 -6
- klaude_code/ui/renderers/sub_agent.py +28 -5
- klaude_code/ui/renderers/thinking.py +16 -10
- klaude_code/ui/renderers/tools.py +26 -2
- klaude_code/ui/rich/markdown.py +18 -3
- klaude_code/ui/rich/status.py +14 -6
- klaude_code/ui/rich/theme.py +63 -12
- {klaude_code-1.2.23.dist-info → klaude_code-1.2.24.dist-info}/METADATA +1 -1
- {klaude_code-1.2.23.dist-info → klaude_code-1.2.24.dist-info}/RECORD +26 -25
- klaude_code/llm/openai_compatible/stream_processor.py +0 -83
- {klaude_code-1.2.23.dist-info → klaude_code-1.2.24.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.23.dist-info → klaude_code-1.2.24.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Add description for current jj change
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Run `jj status` and `jj diff --git` to see the current changes and add a description for the it.
|
|
6
|
+
|
|
7
|
+
In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:<example>
|
|
8
|
+
jj describe -m "$(cat <<'EOF'
|
|
9
|
+
Commit message here.
|
|
10
|
+
EOF
|
|
11
|
+
)"
|
|
12
|
+
</example>
|
|
13
|
+
|
|
14
|
+
Follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
|
|
15
|
+
```
|
|
16
|
+
<type>(<scope>): <description>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Types:
|
|
20
|
+
- `feat`: New feature
|
|
21
|
+
- `fix`: Bug fix
|
|
22
|
+
- `docs`: Documentation changes
|
|
23
|
+
- `style`: Code style changes (formatting, no logic change)
|
|
24
|
+
- `refactor`: Code refactoring (no feature or fix)
|
|
25
|
+
- `test`: Adding or updating tests
|
|
26
|
+
- `chore`: Build process, dependencies, or tooling changes
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
- `feat(cli): add --verbose flag for debug output`
|
|
30
|
+
- `fix(llm): handle API timeout errors gracefully`
|
|
31
|
+
- `docs(readme): update installation instructions`
|
|
32
|
+
- `refactor(core): simplify session state management`
|
|
@@ -93,7 +93,7 @@ TRUNCATE_DISPLAY_MAX_LINE_LENGTH = 1000
|
|
|
93
93
|
TRUNCATE_DISPLAY_MAX_LINES = 8
|
|
94
94
|
|
|
95
95
|
# Maximum lines for sub-agent result display
|
|
96
|
-
SUB_AGENT_RESULT_MAX_LINES =
|
|
96
|
+
SUB_AGENT_RESULT_MAX_LINES = 50
|
|
97
97
|
|
|
98
98
|
|
|
99
99
|
# UI refresh rate (frames per second) for debounced content streaming
|
|
@@ -111,6 +111,9 @@ MARKDOWN_RIGHT_MARGIN = 2
|
|
|
111
111
|
# Status hint text shown after spinner status
|
|
112
112
|
STATUS_HINT_TEXT = " (esc to interrupt)"
|
|
113
113
|
|
|
114
|
+
# Default spinner status text when idle/thinking
|
|
115
|
+
STATUS_DEFAULT_TEXT = "Thinking …"
|
|
116
|
+
|
|
114
117
|
# Status shimmer animation
|
|
115
118
|
# Horizontal padding used when computing shimmer band position
|
|
116
119
|
STATUS_SHIMMER_PADDING = 10
|
klaude_code/core/executor.py
CHANGED
|
@@ -327,7 +327,7 @@ class ExecutorContext:
|
|
|
327
327
|
log_debug(traceback.format_exc(), style="red", debug_type=DebugType.EXECUTION)
|
|
328
328
|
await self.emit_event(
|
|
329
329
|
events.ErrorEvent(
|
|
330
|
-
error_message=f"Agent task failed: [{e.__class__.__name__}] {e!s}",
|
|
330
|
+
error_message=f"Agent task failed: [{e.__class__.__name__}] {e!s} {traceback.format_exc()}",
|
|
331
331
|
can_retry=False,
|
|
332
332
|
)
|
|
333
333
|
)
|
|
@@ -25,6 +25,18 @@ _IMAGE_MIME_TYPES: dict[str, str] = {
|
|
|
25
25
|
".webp": "image/webp",
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
_BINARY_CHECK_SIZE = 8192
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_binary_file(file_path: str) -> bool:
|
|
32
|
+
"""Check if a file is binary by looking for null bytes in the first chunk."""
|
|
33
|
+
try:
|
|
34
|
+
with open(file_path, "rb") as f:
|
|
35
|
+
chunk = f.read(_BINARY_CHECK_SIZE)
|
|
36
|
+
return b"\x00" in chunk
|
|
37
|
+
except OSError:
|
|
38
|
+
return False
|
|
39
|
+
|
|
28
40
|
|
|
29
41
|
def _format_numbered_line(line_no: int, content: str) -> str:
|
|
30
42
|
# 6-width right-aligned line number followed by a right arrow
|
|
@@ -218,12 +230,22 @@ class ReadTool(ToolABC):
|
|
|
218
230
|
),
|
|
219
231
|
)
|
|
220
232
|
|
|
233
|
+
is_image_file = _is_supported_image_file(file_path)
|
|
234
|
+
# Check for binary files (skip for images which are handled separately)
|
|
235
|
+
if not is_image_file and _is_binary_file(file_path):
|
|
236
|
+
return model.ToolResultItem(
|
|
237
|
+
status="error",
|
|
238
|
+
output=(
|
|
239
|
+
"<tool_use_error>This appears to be a binary file and cannot be read as text. "
|
|
240
|
+
"Use appropriate tools or libraries to handle binary files.</tool_use_error>"
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
|
|
221
244
|
try:
|
|
222
245
|
size_bytes = Path(file_path).stat().st_size
|
|
223
246
|
except OSError:
|
|
224
247
|
size_bytes = 0
|
|
225
248
|
|
|
226
|
-
is_image_file = _is_supported_image_file(file_path)
|
|
227
249
|
if is_image_file:
|
|
228
250
|
if size_bytes > const.READ_MAX_IMAGE_BYTES:
|
|
229
251
|
size_mb = size_bytes / (1024 * 1024)
|
|
@@ -124,9 +124,13 @@ class WriteTool(ToolABC):
|
|
|
124
124
|
is_memory=is_mem,
|
|
125
125
|
)
|
|
126
126
|
|
|
127
|
-
#
|
|
128
|
-
|
|
129
|
-
ui_extra
|
|
127
|
+
# For markdown files, use MarkdownDocUIExtra to render content as markdown
|
|
128
|
+
# Otherwise, build diff between previous and new content
|
|
129
|
+
ui_extra: model.ToolResultUIExtra | None
|
|
130
|
+
if file_path.endswith(".md"):
|
|
131
|
+
ui_extra = model.MarkdownDocUIExtra(file_path=file_path, content=args.content)
|
|
132
|
+
else:
|
|
133
|
+
ui_extra = build_structured_diff(before, args.content, file_path=file_path)
|
|
130
134
|
|
|
131
135
|
message = f"File {'overwritten' if exists else 'created'} successfully at: {file_path}"
|
|
132
136
|
return model.ToolResultItem(status="success", output=message, ui_extra=ui_extra)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
from collections.abc import AsyncGenerator
|
|
3
|
-
from typing import override
|
|
3
|
+
from typing import Any, override
|
|
4
4
|
|
|
5
5
|
import httpx
|
|
6
6
|
import openai
|
|
@@ -9,9 +9,9 @@ from openai.types.chat.completion_create_params import CompletionCreateParamsStr
|
|
|
9
9
|
from klaude_code.llm.client import LLMClientABC
|
|
10
10
|
from klaude_code.llm.input_common import apply_config_defaults
|
|
11
11
|
from klaude_code.llm.openai_compatible.input import convert_history_to_input, convert_tool_schema
|
|
12
|
-
from klaude_code.llm.openai_compatible.
|
|
12
|
+
from klaude_code.llm.openai_compatible.stream import DefaultReasoningHandler, parse_chat_completions_stream
|
|
13
13
|
from klaude_code.llm.registry import register
|
|
14
|
-
from klaude_code.llm.usage import MetadataTracker
|
|
14
|
+
from klaude_code.llm.usage import MetadataTracker
|
|
15
15
|
from klaude_code.protocol import llm_param, model
|
|
16
16
|
from klaude_code.trace import DebugType, log_debug
|
|
17
17
|
|
|
@@ -86,107 +86,34 @@ class OpenAICompatibleClient(LLMClientABC):
|
|
|
86
86
|
debug_type=DebugType.LLM_PAYLOAD,
|
|
87
87
|
)
|
|
88
88
|
|
|
89
|
-
stream = self.client.chat.completions.create(
|
|
90
|
-
**payload,
|
|
91
|
-
extra_body=extra_body,
|
|
92
|
-
extra_headers=extra_headers,
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
state = StreamStateManager(param_model=str(param.model))
|
|
96
|
-
|
|
97
89
|
try:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
)
|
|
104
|
-
if not state.response_id and event.id:
|
|
105
|
-
state.set_response_id(event.id)
|
|
106
|
-
yield model.StartItem(response_id=event.id)
|
|
107
|
-
if event.usage is not None:
|
|
108
|
-
metadata_tracker.set_usage(convert_usage(event.usage, param.context_limit, param.max_tokens))
|
|
109
|
-
if event.model:
|
|
110
|
-
metadata_tracker.set_model_name(event.model)
|
|
111
|
-
if provider := getattr(event, "provider", None):
|
|
112
|
-
metadata_tracker.set_provider(str(provider))
|
|
113
|
-
|
|
114
|
-
if len(event.choices) == 0:
|
|
115
|
-
continue
|
|
116
|
-
|
|
117
|
-
# Support Moonshot Kimi K2's usage field in choice
|
|
118
|
-
if usage := getattr(event.choices[0], "usage", None):
|
|
119
|
-
metadata_tracker.set_usage(
|
|
120
|
-
convert_usage(
|
|
121
|
-
openai.types.CompletionUsage.model_validate(usage),
|
|
122
|
-
param.context_limit,
|
|
123
|
-
param.max_tokens,
|
|
124
|
-
)
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
delta = event.choices[0].delta
|
|
128
|
-
|
|
129
|
-
# Reasoning
|
|
130
|
-
if (
|
|
131
|
-
reasoning_content := getattr(delta, "reasoning_content", None)
|
|
132
|
-
or getattr(delta, "reasoning", None)
|
|
133
|
-
or ""
|
|
134
|
-
):
|
|
135
|
-
metadata_tracker.record_token()
|
|
136
|
-
state.stage = "reasoning"
|
|
137
|
-
state.accumulated_reasoning.append(reasoning_content)
|
|
138
|
-
yield model.ReasoningTextDelta(
|
|
139
|
-
content=reasoning_content,
|
|
140
|
-
response_id=state.response_id,
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
# Assistant
|
|
144
|
-
if delta.content and (
|
|
145
|
-
state.stage == "assistant" or delta.content.strip()
|
|
146
|
-
): # Process all content in assistant stage, filter empty content in reasoning stage
|
|
147
|
-
metadata_tracker.record_token()
|
|
148
|
-
if state.stage == "reasoning":
|
|
149
|
-
for item in state.flush_reasoning():
|
|
150
|
-
yield item
|
|
151
|
-
elif state.stage == "tool":
|
|
152
|
-
for item in state.flush_tool_calls():
|
|
153
|
-
yield item
|
|
154
|
-
state.stage = "assistant"
|
|
155
|
-
state.accumulated_content.append(delta.content)
|
|
156
|
-
yield model.AssistantMessageDelta(
|
|
157
|
-
content=delta.content,
|
|
158
|
-
response_id=state.response_id,
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
# Tool
|
|
162
|
-
if delta.tool_calls and len(delta.tool_calls) > 0:
|
|
163
|
-
metadata_tracker.record_token()
|
|
164
|
-
if state.stage == "reasoning":
|
|
165
|
-
for item in state.flush_reasoning():
|
|
166
|
-
yield item
|
|
167
|
-
elif state.stage == "assistant":
|
|
168
|
-
for item in state.flush_assistant():
|
|
169
|
-
yield item
|
|
170
|
-
state.stage = "tool"
|
|
171
|
-
# Emit ToolCallStartItem for new tool calls
|
|
172
|
-
for tc in delta.tool_calls:
|
|
173
|
-
if tc.index not in state.emitted_tool_start_indices and tc.function and tc.function.name:
|
|
174
|
-
state.emitted_tool_start_indices.add(tc.index)
|
|
175
|
-
yield model.ToolCallStartItem(
|
|
176
|
-
response_id=state.response_id,
|
|
177
|
-
call_id=tc.id or "",
|
|
178
|
-
name=tc.function.name,
|
|
179
|
-
)
|
|
180
|
-
state.accumulated_tool_calls.add(delta.tool_calls)
|
|
90
|
+
stream = await self.client.chat.completions.create(
|
|
91
|
+
**payload,
|
|
92
|
+
extra_body=extra_body,
|
|
93
|
+
extra_headers=extra_headers,
|
|
94
|
+
)
|
|
181
95
|
except (openai.OpenAIError, httpx.HTTPError) as e:
|
|
182
96
|
yield model.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
|
|
97
|
+
yield metadata_tracker.finalize()
|
|
98
|
+
return
|
|
183
99
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
100
|
+
reasoning_handler = DefaultReasoningHandler(
|
|
101
|
+
param_model=str(param.model),
|
|
102
|
+
response_id=None,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def on_event(event: Any) -> None:
|
|
106
|
+
log_debug(
|
|
107
|
+
event.model_dump_json(exclude_none=True),
|
|
108
|
+
style="blue",
|
|
109
|
+
debug_type=DebugType.LLM_STREAM,
|
|
110
|
+
)
|
|
190
111
|
|
|
191
|
-
|
|
192
|
-
|
|
112
|
+
async for item in parse_chat_completions_stream(
|
|
113
|
+
stream,
|
|
114
|
+
param=param,
|
|
115
|
+
metadata_tracker=metadata_tracker,
|
|
116
|
+
reasoning_handler=reasoning_handler,
|
|
117
|
+
on_event=on_event,
|
|
118
|
+
):
|
|
119
|
+
yield item
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Shared stream processing utilities for Chat Completions streaming.
|
|
2
|
+
|
|
3
|
+
This module provides reusable primitives for OpenAI-compatible providers:
|
|
4
|
+
|
|
5
|
+
- ``StreamStateManager``: accumulates assistant content and tool calls.
|
|
6
|
+
- ``ReasoningHandlerABC``: provider-specific reasoning extraction + buffering.
|
|
7
|
+
- ``parse_chat_completions_stream``: shared stream loop that emits ConversationItems.
|
|
8
|
+
|
|
9
|
+
OpenRouter uses the same OpenAI Chat Completions API surface but differs in
|
|
10
|
+
how reasoning is represented (``reasoning_details`` vs ``reasoning_content``).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from collections.abc import AsyncGenerator, Callable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any, Literal, cast
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
import openai
|
|
22
|
+
import openai.types
|
|
23
|
+
from openai import AsyncStream
|
|
24
|
+
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
|
|
25
|
+
|
|
26
|
+
from klaude_code.llm.openai_compatible.tool_call_accumulator import BasicToolCallAccumulator, ToolCallAccumulatorABC
|
|
27
|
+
from klaude_code.llm.usage import MetadataTracker, convert_usage
|
|
28
|
+
from klaude_code.protocol import llm_param, model
|
|
29
|
+
|
|
30
|
+
StreamStage = Literal["waiting", "reasoning", "assistant", "tool"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class StreamStateManager:
|
|
34
|
+
"""Manages streaming state and provides flush operations for accumulated content.
|
|
35
|
+
|
|
36
|
+
This class encapsulates the common state management logic used by both
|
|
37
|
+
OpenAI-compatible and OpenRouter clients, reducing code duplication.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
param_model: str,
|
|
43
|
+
response_id: str | None = None,
|
|
44
|
+
reasoning_flusher: Callable[[], list[model.ConversationItem]] | None = None,
|
|
45
|
+
):
|
|
46
|
+
self.param_model = param_model
|
|
47
|
+
self.response_id = response_id
|
|
48
|
+
self.stage: StreamStage = "waiting"
|
|
49
|
+
self.accumulated_reasoning: list[str] = []
|
|
50
|
+
self.accumulated_content: list[str] = []
|
|
51
|
+
self.accumulated_tool_calls: ToolCallAccumulatorABC = BasicToolCallAccumulator()
|
|
52
|
+
self.emitted_tool_start_indices: set[int] = set()
|
|
53
|
+
self._reasoning_flusher = reasoning_flusher
|
|
54
|
+
|
|
55
|
+
def set_response_id(self, response_id: str) -> None:
|
|
56
|
+
"""Set the response ID once received from the stream."""
|
|
57
|
+
self.response_id = response_id
|
|
58
|
+
self.accumulated_tool_calls.response_id = response_id # pyright: ignore[reportAttributeAccessIssue]
|
|
59
|
+
|
|
60
|
+
def flush_reasoning(self) -> list[model.ConversationItem]:
|
|
61
|
+
"""Flush accumulated reasoning content and return items."""
|
|
62
|
+
if self._reasoning_flusher is not None:
|
|
63
|
+
return self._reasoning_flusher()
|
|
64
|
+
if not self.accumulated_reasoning:
|
|
65
|
+
return []
|
|
66
|
+
item = model.ReasoningTextItem(
|
|
67
|
+
content="".join(self.accumulated_reasoning),
|
|
68
|
+
response_id=self.response_id,
|
|
69
|
+
model=self.param_model,
|
|
70
|
+
)
|
|
71
|
+
self.accumulated_reasoning = []
|
|
72
|
+
return [item]
|
|
73
|
+
|
|
74
|
+
def flush_assistant(self) -> list[model.ConversationItem]:
|
|
75
|
+
"""Flush accumulated assistant content and return items."""
|
|
76
|
+
if not self.accumulated_content:
|
|
77
|
+
return []
|
|
78
|
+
item = model.AssistantMessageItem(
|
|
79
|
+
content="".join(self.accumulated_content),
|
|
80
|
+
response_id=self.response_id,
|
|
81
|
+
)
|
|
82
|
+
self.accumulated_content = []
|
|
83
|
+
return [item]
|
|
84
|
+
|
|
85
|
+
def flush_tool_calls(self) -> list[model.ToolCallItem]:
|
|
86
|
+
"""Flush accumulated tool calls and return items."""
|
|
87
|
+
items: list[model.ToolCallItem] = self.accumulated_tool_calls.get()
|
|
88
|
+
if items:
|
|
89
|
+
self.accumulated_tool_calls.chunks_by_step = [] # pyright: ignore[reportAttributeAccessIssue]
|
|
90
|
+
return items
|
|
91
|
+
|
|
92
|
+
def flush_all(self) -> list[model.ConversationItem]:
|
|
93
|
+
"""Flush all accumulated content in order: reasoning, assistant, tool calls."""
|
|
94
|
+
items: list[model.ConversationItem] = []
|
|
95
|
+
items.extend(self.flush_reasoning())
|
|
96
|
+
items.extend(self.flush_assistant())
|
|
97
|
+
if self.stage == "tool":
|
|
98
|
+
items.extend(self.flush_tool_calls())
|
|
99
|
+
return items
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(slots=True)
|
|
103
|
+
class ReasoningDeltaResult:
|
|
104
|
+
"""Result of processing a single provider delta for reasoning signals."""
|
|
105
|
+
|
|
106
|
+
handled: bool
|
|
107
|
+
outputs: list[str | model.ConversationItem]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ReasoningHandlerABC(ABC):
|
|
111
|
+
"""Provider-specific reasoning handler for Chat Completions streaming."""
|
|
112
|
+
|
|
113
|
+
@abstractmethod
|
|
114
|
+
def set_response_id(self, response_id: str | None) -> None:
|
|
115
|
+
"""Update the response identifier used for emitted items."""
|
|
116
|
+
|
|
117
|
+
@abstractmethod
|
|
118
|
+
def on_delta(self, delta: object) -> ReasoningDeltaResult:
|
|
119
|
+
"""Process a single delta and return ordered reasoning outputs."""
|
|
120
|
+
|
|
121
|
+
@abstractmethod
|
|
122
|
+
def flush(self) -> list[model.ConversationItem]:
|
|
123
|
+
"""Flush buffered reasoning content (usually at stage transition/finalize)."""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class DefaultReasoningHandler(ReasoningHandlerABC):
|
|
127
|
+
"""Handles OpenAI-compatible reasoning fields (reasoning_content / reasoning)."""
|
|
128
|
+
|
|
129
|
+
def __init__(
|
|
130
|
+
self,
|
|
131
|
+
*,
|
|
132
|
+
param_model: str,
|
|
133
|
+
response_id: str | None,
|
|
134
|
+
) -> None:
|
|
135
|
+
self._param_model = param_model
|
|
136
|
+
self._response_id = response_id
|
|
137
|
+
self._accumulated: list[str] = []
|
|
138
|
+
|
|
139
|
+
def set_response_id(self, response_id: str | None) -> None:
|
|
140
|
+
self._response_id = response_id
|
|
141
|
+
|
|
142
|
+
def on_delta(self, delta: object) -> ReasoningDeltaResult:
|
|
143
|
+
reasoning_content = getattr(delta, "reasoning_content", None) or getattr(delta, "reasoning", None) or ""
|
|
144
|
+
if not reasoning_content:
|
|
145
|
+
return ReasoningDeltaResult(handled=False, outputs=[])
|
|
146
|
+
text = str(reasoning_content)
|
|
147
|
+
self._accumulated.append(text)
|
|
148
|
+
return ReasoningDeltaResult(handled=True, outputs=[text])
|
|
149
|
+
|
|
150
|
+
def flush(self) -> list[model.ConversationItem]:
|
|
151
|
+
if not self._accumulated:
|
|
152
|
+
return []
|
|
153
|
+
item = model.ReasoningTextItem(
|
|
154
|
+
content="".join(self._accumulated),
|
|
155
|
+
response_id=self._response_id,
|
|
156
|
+
model=self._param_model,
|
|
157
|
+
)
|
|
158
|
+
self._accumulated = []
|
|
159
|
+
return [item]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def parse_chat_completions_stream(
|
|
163
|
+
stream: AsyncStream[ChatCompletionChunk],
|
|
164
|
+
*,
|
|
165
|
+
param: llm_param.LLMCallParameter,
|
|
166
|
+
metadata_tracker: MetadataTracker,
|
|
167
|
+
reasoning_handler: ReasoningHandlerABC,
|
|
168
|
+
on_event: Callable[[object], None] | None = None,
|
|
169
|
+
) -> AsyncGenerator[model.ConversationItem]:
|
|
170
|
+
"""Parse OpenAI Chat Completions stream into ConversationItems.
|
|
171
|
+
|
|
172
|
+
This is shared by OpenAI-compatible and OpenRouter clients.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
state = StreamStateManager(
|
|
176
|
+
param_model=str(param.model),
|
|
177
|
+
reasoning_flusher=reasoning_handler.flush,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
async for event in stream:
|
|
182
|
+
if on_event is not None:
|
|
183
|
+
on_event(event)
|
|
184
|
+
|
|
185
|
+
if not state.response_id and (event_id := getattr(event, "id", None)):
|
|
186
|
+
state.set_response_id(str(event_id))
|
|
187
|
+
reasoning_handler.set_response_id(str(event_id))
|
|
188
|
+
yield model.StartItem(response_id=str(event_id))
|
|
189
|
+
|
|
190
|
+
if (event_usage := getattr(event, "usage", None)) is not None:
|
|
191
|
+
metadata_tracker.set_usage(convert_usage(event_usage, param.context_limit, param.max_tokens))
|
|
192
|
+
if event_model := getattr(event, "model", None):
|
|
193
|
+
metadata_tracker.set_model_name(str(event_model))
|
|
194
|
+
if provider := getattr(event, "provider", None):
|
|
195
|
+
metadata_tracker.set_provider(str(provider))
|
|
196
|
+
|
|
197
|
+
choices = cast(Any, getattr(event, "choices", None))
|
|
198
|
+
if not choices:
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
# Support Moonshot Kimi K2's usage field in choice
|
|
202
|
+
choice0 = choices[0]
|
|
203
|
+
if choice_usage := getattr(choice0, "usage", None):
|
|
204
|
+
try:
|
|
205
|
+
usage = openai.types.CompletionUsage.model_validate(choice_usage)
|
|
206
|
+
metadata_tracker.set_usage(convert_usage(usage, param.context_limit, param.max_tokens))
|
|
207
|
+
except Exception:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
delta = cast(Any, getattr(choice0, "delta", None))
|
|
211
|
+
if delta is None:
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
# Reasoning
|
|
215
|
+
reasoning_result = reasoning_handler.on_delta(delta)
|
|
216
|
+
if reasoning_result.handled:
|
|
217
|
+
state.stage = "reasoning"
|
|
218
|
+
for output in reasoning_result.outputs:
|
|
219
|
+
if isinstance(output, str):
|
|
220
|
+
if not output:
|
|
221
|
+
continue
|
|
222
|
+
metadata_tracker.record_token()
|
|
223
|
+
yield model.ReasoningTextDelta(content=output, response_id=state.response_id)
|
|
224
|
+
else:
|
|
225
|
+
yield output
|
|
226
|
+
|
|
227
|
+
# Assistant
|
|
228
|
+
if (content := getattr(delta, "content", None)) and (state.stage == "assistant" or str(content).strip()):
|
|
229
|
+
metadata_tracker.record_token()
|
|
230
|
+
if state.stage == "reasoning":
|
|
231
|
+
for item in state.flush_reasoning():
|
|
232
|
+
yield item
|
|
233
|
+
elif state.stage == "tool":
|
|
234
|
+
for item in state.flush_tool_calls():
|
|
235
|
+
yield item
|
|
236
|
+
state.stage = "assistant"
|
|
237
|
+
state.accumulated_content.append(str(content))
|
|
238
|
+
yield model.AssistantMessageDelta(
|
|
239
|
+
content=str(content),
|
|
240
|
+
response_id=state.response_id,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Tool
|
|
244
|
+
if (tool_calls := getattr(delta, "tool_calls", None)) and len(tool_calls) > 0:
|
|
245
|
+
metadata_tracker.record_token()
|
|
246
|
+
if state.stage == "reasoning":
|
|
247
|
+
for item in state.flush_reasoning():
|
|
248
|
+
yield item
|
|
249
|
+
elif state.stage == "assistant":
|
|
250
|
+
for item in state.flush_assistant():
|
|
251
|
+
yield item
|
|
252
|
+
state.stage = "tool"
|
|
253
|
+
for tc in tool_calls:
|
|
254
|
+
if tc.index not in state.emitted_tool_start_indices and tc.function and tc.function.name:
|
|
255
|
+
state.emitted_tool_start_indices.add(tc.index)
|
|
256
|
+
yield model.ToolCallStartItem(
|
|
257
|
+
response_id=state.response_id,
|
|
258
|
+
call_id=tc.id or "",
|
|
259
|
+
name=tc.function.name,
|
|
260
|
+
)
|
|
261
|
+
state.accumulated_tool_calls.add(tool_calls)
|
|
262
|
+
except (openai.OpenAIError, httpx.HTTPError) as e:
|
|
263
|
+
yield model.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
|
|
264
|
+
|
|
265
|
+
flushed_items = state.flush_all()
|
|
266
|
+
if flushed_items:
|
|
267
|
+
metadata_tracker.record_token()
|
|
268
|
+
for item in flushed_items:
|
|
269
|
+
yield item
|
|
270
|
+
|
|
271
|
+
metadata_tracker.set_response_id(state.response_id)
|
|
272
|
+
yield metadata_tracker.finalize()
|