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.
@@ -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 = 20
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
@@ -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
- # Build diff between previous and new content
128
- after = args.content
129
- ui_extra = build_structured_diff(before, after, file_path=file_path)
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.stream_processor import StreamStateManager
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, convert_usage
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
- async for event in await stream:
99
- log_debug(
100
- event.model_dump_json(exclude_none=True),
101
- style="blue",
102
- debug_type=DebugType.LLM_STREAM,
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
- # Finalize
185
- flushed_items = state.flush_all()
186
- if flushed_items:
187
- metadata_tracker.record_token()
188
- for item in flushed_items:
189
- yield item
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
- metadata_tracker.set_response_id(state.response_id)
192
- yield metadata_tracker.finalize()
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()