klaude-code 1.9.0__py3-none-any.whl → 2.0.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 (129) hide show
  1. klaude_code/auth/base.py +2 -6
  2. klaude_code/cli/auth_cmd.py +4 -4
  3. klaude_code/cli/list_model.py +1 -1
  4. klaude_code/cli/main.py +1 -1
  5. klaude_code/cli/runtime.py +7 -5
  6. klaude_code/cli/self_update.py +1 -1
  7. klaude_code/cli/session_cmd.py +1 -1
  8. klaude_code/command/clear_cmd.py +6 -2
  9. klaude_code/command/command_abc.py +2 -2
  10. klaude_code/command/debug_cmd.py +4 -4
  11. klaude_code/command/export_cmd.py +2 -2
  12. klaude_code/command/export_online_cmd.py +12 -12
  13. klaude_code/command/fork_session_cmd.py +29 -23
  14. klaude_code/command/help_cmd.py +4 -4
  15. klaude_code/command/model_cmd.py +4 -4
  16. klaude_code/command/model_select.py +1 -1
  17. klaude_code/command/prompt-commit.md +11 -2
  18. klaude_code/command/prompt_command.py +3 -3
  19. klaude_code/command/refresh_cmd.py +2 -2
  20. klaude_code/command/registry.py +7 -5
  21. klaude_code/command/release_notes_cmd.py +4 -4
  22. klaude_code/command/resume_cmd.py +15 -11
  23. klaude_code/command/status_cmd.py +4 -4
  24. klaude_code/command/terminal_setup_cmd.py +8 -8
  25. klaude_code/command/thinking_cmd.py +4 -4
  26. klaude_code/config/assets/builtin_config.yaml +16 -0
  27. klaude_code/config/builtin_config.py +16 -5
  28. klaude_code/config/config.py +7 -2
  29. klaude_code/const.py +146 -91
  30. klaude_code/core/agent.py +3 -12
  31. klaude_code/core/executor.py +21 -13
  32. klaude_code/core/manager/sub_agent_manager.py +71 -7
  33. klaude_code/core/prompts/prompt-sub-agent-image-gen.md +1 -0
  34. klaude_code/core/prompts/prompt-sub-agent-web.md +27 -1
  35. klaude_code/core/reminders.py +88 -69
  36. klaude_code/core/task.py +44 -45
  37. klaude_code/core/tool/file/apply_patch_tool.py +9 -9
  38. klaude_code/core/tool/file/diff_builder.py +3 -5
  39. klaude_code/core/tool/file/edit_tool.py +23 -23
  40. klaude_code/core/tool/file/move_tool.py +43 -43
  41. klaude_code/core/tool/file/read_tool.py +44 -39
  42. klaude_code/core/tool/file/write_tool.py +14 -14
  43. klaude_code/core/tool/report_back_tool.py +4 -4
  44. klaude_code/core/tool/shell/bash_tool.py +23 -23
  45. klaude_code/core/tool/skill/skill_tool.py +7 -7
  46. klaude_code/core/tool/sub_agent_tool.py +38 -9
  47. klaude_code/core/tool/todo/todo_write_tool.py +8 -8
  48. klaude_code/core/tool/todo/update_plan_tool.py +6 -6
  49. klaude_code/core/tool/tool_abc.py +2 -2
  50. klaude_code/core/tool/tool_context.py +27 -0
  51. klaude_code/core/tool/tool_runner.py +88 -42
  52. klaude_code/core/tool/truncation.py +38 -20
  53. klaude_code/core/tool/web/mermaid_tool.py +6 -7
  54. klaude_code/core/tool/web/web_fetch_tool.py +68 -30
  55. klaude_code/core/tool/web/web_search_tool.py +15 -17
  56. klaude_code/core/turn.py +120 -73
  57. klaude_code/llm/anthropic/client.py +79 -44
  58. klaude_code/llm/anthropic/input.py +116 -108
  59. klaude_code/llm/bedrock/client.py +8 -5
  60. klaude_code/llm/claude/client.py +18 -8
  61. klaude_code/llm/client.py +4 -3
  62. klaude_code/llm/codex/client.py +15 -9
  63. klaude_code/llm/google/client.py +122 -60
  64. klaude_code/llm/google/input.py +94 -108
  65. klaude_code/llm/image.py +123 -0
  66. klaude_code/llm/input_common.py +136 -189
  67. klaude_code/llm/openai_compatible/client.py +17 -7
  68. klaude_code/llm/openai_compatible/input.py +36 -66
  69. klaude_code/llm/openai_compatible/stream.py +119 -67
  70. klaude_code/llm/openai_compatible/tool_call_accumulator.py +23 -11
  71. klaude_code/llm/openrouter/client.py +34 -9
  72. klaude_code/llm/openrouter/input.py +63 -64
  73. klaude_code/llm/openrouter/reasoning.py +22 -24
  74. klaude_code/llm/registry.py +20 -17
  75. klaude_code/llm/responses/client.py +107 -45
  76. klaude_code/llm/responses/input.py +115 -98
  77. klaude_code/llm/usage.py +52 -25
  78. klaude_code/protocol/__init__.py +1 -0
  79. klaude_code/protocol/events.py +16 -12
  80. klaude_code/protocol/llm_param.py +20 -2
  81. klaude_code/protocol/message.py +250 -0
  82. klaude_code/protocol/model.py +94 -281
  83. klaude_code/protocol/op.py +2 -2
  84. klaude_code/protocol/sub_agent/__init__.py +1 -0
  85. klaude_code/protocol/sub_agent/explore.py +10 -0
  86. klaude_code/protocol/sub_agent/image_gen.py +119 -0
  87. klaude_code/protocol/sub_agent/task.py +10 -0
  88. klaude_code/protocol/sub_agent/web.py +10 -0
  89. klaude_code/session/codec.py +6 -6
  90. klaude_code/session/export.py +261 -62
  91. klaude_code/session/selector.py +7 -24
  92. klaude_code/session/session.py +126 -54
  93. klaude_code/session/store.py +5 -32
  94. klaude_code/session/templates/export_session.html +1 -1
  95. klaude_code/session/templates/mermaid_viewer.html +1 -1
  96. klaude_code/trace/log.py +11 -6
  97. klaude_code/ui/core/input.py +1 -1
  98. klaude_code/ui/core/stage_manager.py +1 -8
  99. klaude_code/ui/modes/debug/display.py +2 -2
  100. klaude_code/ui/modes/repl/clipboard.py +2 -2
  101. klaude_code/ui/modes/repl/completers.py +18 -10
  102. klaude_code/ui/modes/repl/event_handler.py +136 -127
  103. klaude_code/ui/modes/repl/input_prompt_toolkit.py +1 -1
  104. klaude_code/ui/modes/repl/key_bindings.py +1 -1
  105. klaude_code/ui/modes/repl/renderer.py +107 -15
  106. klaude_code/ui/renderers/assistant.py +2 -2
  107. klaude_code/ui/renderers/common.py +65 -7
  108. klaude_code/ui/renderers/developer.py +7 -6
  109. klaude_code/ui/renderers/diffs.py +11 -11
  110. klaude_code/ui/renderers/mermaid_viewer.py +49 -2
  111. klaude_code/ui/renderers/metadata.py +33 -5
  112. klaude_code/ui/renderers/sub_agent.py +57 -16
  113. klaude_code/ui/renderers/thinking.py +37 -2
  114. klaude_code/ui/renderers/tools.py +180 -165
  115. klaude_code/ui/rich/live.py +3 -1
  116. klaude_code/ui/rich/markdown.py +39 -7
  117. klaude_code/ui/rich/quote.py +76 -1
  118. klaude_code/ui/rich/status.py +14 -8
  119. klaude_code/ui/rich/theme.py +8 -2
  120. klaude_code/ui/terminal/image.py +34 -0
  121. klaude_code/ui/terminal/notifier.py +2 -1
  122. klaude_code/ui/terminal/progress_bar.py +4 -4
  123. klaude_code/ui/terminal/selector.py +22 -4
  124. klaude_code/ui/utils/common.py +11 -2
  125. {klaude_code-1.9.0.dist-info → klaude_code-2.0.0.dist-info}/METADATA +4 -2
  126. klaude_code-2.0.0.dist-info/RECORD +229 -0
  127. klaude_code-1.9.0.dist-info/RECORD +0 -224
  128. {klaude_code-1.9.0.dist-info → klaude_code-2.0.0.dist-info}/WHEEL +0 -0
  129. {klaude_code-1.9.0.dist-info → klaude_code-2.0.0.dist-info}/entry_points.txt +0 -0
@@ -19,16 +19,38 @@ from anthropic.types.beta.beta_thinking_delta import BetaThinkingDelta
19
19
  from anthropic.types.beta.beta_tool_use_block import BetaToolUseBlock
20
20
  from anthropic.types.beta.message_create_params import MessageCreateParamsStreaming
21
21
 
22
- from klaude_code import const
22
+ from klaude_code.const import (
23
+ ANTHROPIC_BETA_INTERLEAVED_THINKING,
24
+ CLAUDE_CODE_IDENTITY,
25
+ DEFAULT_ANTHROPIC_THINKING_BUDGET_TOKENS,
26
+ DEFAULT_MAX_TOKENS,
27
+ DEFAULT_TEMPERATURE,
28
+ LLM_HTTP_TIMEOUT_CONNECT,
29
+ LLM_HTTP_TIMEOUT_READ,
30
+ LLM_HTTP_TIMEOUT_TOTAL,
31
+ )
23
32
  from klaude_code.llm.anthropic.input import convert_history_to_input, convert_system_to_input, convert_tool_schema
24
33
  from klaude_code.llm.client import LLMClientABC
25
34
  from klaude_code.llm.input_common import apply_config_defaults
26
35
  from klaude_code.llm.registry import register
27
- from klaude_code.llm.usage import MetadataTracker
28
- from klaude_code.protocol import llm_param, model
36
+ from klaude_code.llm.usage import MetadataTracker, error_stream_items
37
+ from klaude_code.protocol import llm_param, message, model
29
38
  from klaude_code.trace import DebugType, log_debug
30
39
 
31
- _IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude."
40
+
41
+ def _map_anthropic_stop_reason(reason: str) -> model.StopReason | None:
42
+ mapping: dict[str, model.StopReason] = {
43
+ "end_turn": "stop",
44
+ "stop_sequence": "stop",
45
+ "max_tokens": "length",
46
+ "tool_use": "tool_use",
47
+ "content_filter": "error",
48
+ "error": "error",
49
+ "cancelled": "aborted",
50
+ "canceled": "aborted",
51
+ "aborted": "aborted",
52
+ }
53
+ return mapping.get(reason)
32
54
 
33
55
 
34
56
  def build_payload(
@@ -44,17 +66,18 @@ def build_payload(
44
66
  """
45
67
  messages = convert_history_to_input(param.input, param.model)
46
68
  tools = convert_tool_schema(param.tools)
47
- system = convert_system_to_input(param.system)
69
+ system_messages = [msg for msg in param.input if isinstance(msg, message.SystemMessage)]
70
+ system = convert_system_to_input(param.system, system_messages)
48
71
 
49
72
  # Add identity block at the beginning of the system prompt
50
73
  identity_block: BetaTextBlockParam = {
51
74
  "type": "text",
52
- "text": _IDENTITY,
75
+ "text": CLAUDE_CODE_IDENTITY,
53
76
  "cache_control": {"type": "ephemeral"},
54
77
  }
55
78
  system = [identity_block, *system]
56
79
 
57
- betas = ["interleaved-thinking-2025-05-14"]
80
+ betas = [ANTHROPIC_BETA_INTERLEAVED_THINKING]
58
81
  if extra_betas:
59
82
  # Prepend extra betas, avoiding duplicates
60
83
  betas = [b for b in extra_betas if b not in betas] + betas
@@ -66,8 +89,8 @@ def build_payload(
66
89
  "disable_parallel_tool_use": False,
67
90
  },
68
91
  "stream": True,
69
- "max_tokens": param.max_tokens or const.DEFAULT_MAX_TOKENS,
70
- "temperature": param.temperature or const.DEFAULT_TEMPERATURE,
92
+ "max_tokens": param.max_tokens or DEFAULT_MAX_TOKENS,
93
+ "temperature": param.temperature or DEFAULT_TEMPERATURE,
71
94
  "messages": messages,
72
95
  "system": system,
73
96
  "tools": tools,
@@ -77,7 +100,7 @@ def build_payload(
77
100
  if param.thinking and param.thinking.type == "enabled":
78
101
  payload["thinking"] = anthropic.types.ThinkingConfigEnabledParam(
79
102
  type="enabled",
80
- budget_tokens=param.thinking.budget_tokens or const.DEFAULT_ANTHROPIC_THINKING_BUDGET_TOKENS,
103
+ budget_tokens=param.thinking.budget_tokens or DEFAULT_ANTHROPIC_THINKING_BUDGET_TOKENS,
81
104
  )
82
105
 
83
106
  return payload
@@ -87,14 +110,14 @@ async def parse_anthropic_stream(
87
110
  stream: Any,
88
111
  param: llm_param.LLMCallParameter,
89
112
  metadata_tracker: MetadataTracker,
90
- ) -> AsyncGenerator[model.ConversationItem]:
91
- """Parse Anthropic beta messages stream and yield conversation items.
92
-
93
- This function is shared between AnthropicClient and BedrockClient.
94
- """
113
+ ) -> AsyncGenerator[message.LLMStreamItem]:
114
+ """Parse Anthropic beta messages stream and yield stream items."""
95
115
  accumulated_thinking: list[str] = []
96
116
  accumulated_content: list[str] = []
117
+ parts: list[message.Part] = []
97
118
  response_id: str | None = None
119
+ stop_reason: model.StopReason | None = None
120
+ pending_signature: str | None = None
98
121
 
99
122
  current_tool_name: str | None = None
100
123
  current_tool_call_id: str | None = None
@@ -115,28 +138,23 @@ async def parse_anthropic_stream(
115
138
  response_id = event.message.id
116
139
  cached_token = event.message.usage.cache_read_input_tokens or 0
117
140
  input_token = event.message.usage.input_tokens
118
- yield model.StartItem(response_id=response_id)
119
141
  case BetaRawContentBlockDeltaEvent() as event:
120
142
  match event.delta:
121
143
  case BetaThinkingDelta() as delta:
122
144
  if delta.thinking:
123
145
  metadata_tracker.record_token()
124
146
  accumulated_thinking.append(delta.thinking)
125
- yield model.ReasoningTextDelta(
147
+ yield message.ThinkingTextDelta(
126
148
  content=delta.thinking,
127
149
  response_id=response_id,
128
150
  )
129
151
  case BetaSignatureDelta() as delta:
130
- yield model.ReasoningEncryptedItem(
131
- encrypted_content=delta.signature,
132
- response_id=response_id,
133
- model=str(param.model),
134
- )
152
+ pending_signature = delta.signature
135
153
  case BetaTextDelta() as delta:
136
154
  if delta.text:
137
155
  metadata_tracker.record_token()
138
156
  accumulated_content.append(delta.text)
139
- yield model.AssistantMessageDelta(
157
+ yield message.AssistantTextDelta(
140
158
  content=delta.text,
141
159
  response_id=response_id,
142
160
  )
@@ -151,7 +169,7 @@ async def parse_anthropic_stream(
151
169
  match event.content_block:
152
170
  case BetaToolUseBlock() as block:
153
171
  metadata_tracker.record_token()
154
- yield model.ToolCallStartItem(
172
+ yield message.ToolCallStartItem(
155
173
  response_id=response_id,
156
174
  call_id=block.id,
157
175
  name=block.name,
@@ -162,29 +180,32 @@ async def parse_anthropic_stream(
162
180
  case _:
163
181
  pass
164
182
  case BetaRawContentBlockStopEvent():
165
- if len(accumulated_thinking) > 0:
183
+ if accumulated_thinking:
166
184
  metadata_tracker.record_token()
167
185
  full_thinking = "".join(accumulated_thinking)
168
- yield model.ReasoningTextItem(
169
- content=full_thinking,
170
- response_id=response_id,
171
- model=str(param.model),
172
- )
186
+ parts.append(message.ThinkingTextPart(text=full_thinking, model_id=str(param.model)))
187
+ if pending_signature:
188
+ parts.append(
189
+ message.ThinkingSignaturePart(
190
+ signature=pending_signature,
191
+ model_id=str(param.model),
192
+ format="anthropic",
193
+ )
194
+ )
173
195
  accumulated_thinking.clear()
174
- if len(accumulated_content) > 0:
196
+ pending_signature = None
197
+ if accumulated_content:
175
198
  metadata_tracker.record_token()
176
- yield model.AssistantMessageItem(
177
- content="".join(accumulated_content),
178
- response_id=response_id,
179
- )
199
+ parts.append(message.TextPart(text="".join(accumulated_content)))
180
200
  accumulated_content.clear()
181
201
  if current_tool_name and current_tool_call_id:
182
202
  metadata_tracker.record_token()
183
- yield model.ToolCallItem(
184
- name=current_tool_name,
185
- call_id=current_tool_call_id,
186
- arguments="".join(current_tool_inputs) if current_tool_inputs else "",
187
- response_id=response_id,
203
+ parts.append(
204
+ message.ToolCallPart(
205
+ call_id=current_tool_call_id,
206
+ tool_name=current_tool_name,
207
+ arguments_json="".join(current_tool_inputs) if current_tool_inputs else "",
208
+ )
188
209
  )
189
210
  current_tool_name = None
190
211
  current_tool_call_id = None
@@ -202,10 +223,20 @@ async def parse_anthropic_stream(
202
223
  )
203
224
  metadata_tracker.set_model_name(str(param.model))
204
225
  metadata_tracker.set_response_id(response_id)
205
- yield metadata_tracker.finalize()
226
+ raw_stop_reason = getattr(event, "stop_reason", None)
227
+ if isinstance(raw_stop_reason, str):
228
+ stop_reason = _map_anthropic_stop_reason(raw_stop_reason)
206
229
  case _:
207
230
  pass
208
231
 
232
+ metadata = metadata_tracker.finalize()
233
+ yield message.AssistantMessage(
234
+ parts=parts,
235
+ response_id=response_id,
236
+ usage=metadata,
237
+ stop_reason=stop_reason,
238
+ )
239
+
209
240
 
210
241
  @register(llm_param.LLMClientProtocol.ANTHROPIC)
211
242
  class AnthropicClient(LLMClientABC):
@@ -220,7 +251,9 @@ class AnthropicClient(LLMClientABC):
220
251
  client = anthropic.AsyncAnthropic(
221
252
  api_key=config.api_key,
222
253
  base_url=config.base_url,
223
- timeout=httpx.Timeout(300.0, connect=15.0, read=285.0),
254
+ timeout=httpx.Timeout(
255
+ LLM_HTTP_TIMEOUT_TOTAL, connect=LLM_HTTP_TIMEOUT_CONNECT, read=LLM_HTTP_TIMEOUT_READ
256
+ ),
224
257
  )
225
258
  finally:
226
259
  if saved_auth_token is not None:
@@ -233,7 +266,7 @@ class AnthropicClient(LLMClientABC):
233
266
  return cls(config)
234
267
 
235
268
  @override
236
- async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[model.ConversationItem]:
269
+ async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[message.LLMStreamItem]:
237
270
  param = apply_config_defaults(param, self.get_llm_config())
238
271
 
239
272
  metadata_tracker = MetadataTracker(cost_config=self.get_llm_config().cost)
@@ -255,4 +288,6 @@ class AnthropicClient(LLMClientABC):
255
288
  async for item in parse_anthropic_stream(stream, param, metadata_tracker):
256
289
  yield item
257
290
  except (APIError, httpx.HTTPError) as e:
258
- yield model.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
291
+ error_message = f"{e.__class__.__name__} {e!s}"
292
+ for item in error_stream_items(metadata_tracker, error=error_message):
293
+ yield item
@@ -4,10 +4,7 @@
4
4
  # pyright: reportAttributeAccessIssue=false
5
5
  # pyright: reportUnknownVariableType=false
6
6
 
7
-
8
7
  import json
9
- from base64 import b64decode
10
- from binascii import Error as BinasciiError
11
8
  from typing import Literal, cast
12
9
 
13
10
  from anthropic.types.beta.beta_base64_image_source_param import BetaBase64ImageSourceParam
@@ -17,8 +14,15 @@ from anthropic.types.beta.beta_text_block_param import BetaTextBlockParam
17
14
  from anthropic.types.beta.beta_tool_param import BetaToolParam
18
15
  from anthropic.types.beta.beta_url_image_source_param import BetaURLImageSourceParam
19
16
 
20
- from klaude_code.llm.input_common import AssistantGroup, ToolGroup, UserGroup, merge_reminder_text, parse_message_groups
21
- from klaude_code.protocol import llm_param, model
17
+ from klaude_code.const import EMPTY_TOOL_OUTPUT_MESSAGE
18
+ from klaude_code.llm.image import parse_data_url
19
+ from klaude_code.llm.input_common import (
20
+ DeveloperAttachment,
21
+ attach_developer_messages,
22
+ merge_reminder_text,
23
+ split_thinking_parts,
24
+ )
25
+ from klaude_code.protocol import llm_param, message
22
26
 
23
27
  AllowedMediaType = Literal["image/png", "image/jpeg", "image/gif", "image/webp"]
24
28
  _INLINE_IMAGE_MEDIA_TYPES: tuple[AllowedMediaType, ...] = (
@@ -29,25 +33,12 @@ _INLINE_IMAGE_MEDIA_TYPES: tuple[AllowedMediaType, ...] = (
29
33
  )
30
34
 
31
35
 
32
- def _image_part_to_block(image: model.ImageURLPart) -> BetaImageBlockParam:
33
- url = image.image_url.url
36
+ def _image_part_to_block(image: message.ImageURLPart) -> BetaImageBlockParam:
37
+ url = image.url
34
38
  if url.startswith("data:"):
35
- header_and_media = url.split(",", 1)
36
- if len(header_and_media) != 2:
37
- raise ValueError("Invalid data URL for image: missing comma separator")
38
- header, base64_data = header_and_media
39
- if ";base64" not in header:
40
- raise ValueError("Invalid data URL for image: missing base64 marker")
41
- media_type = header[5:].split(";", 1)[0]
39
+ media_type, base64_payload, _ = parse_data_url(url)
42
40
  if media_type not in _INLINE_IMAGE_MEDIA_TYPES:
43
41
  raise ValueError(f"Unsupported inline image media type: {media_type}")
44
- base64_payload = base64_data.strip()
45
- if base64_payload == "":
46
- raise ValueError("Inline image data is empty")
47
- try:
48
- b64decode(base64_payload, validate=True)
49
- except (BinasciiError, ValueError) as exc:
50
- raise ValueError("Inline image data is not valid base64") from exc
51
42
  source = cast(
52
43
  BetaBase64ImageSourceParam,
53
44
  {
@@ -62,97 +53,108 @@ def _image_part_to_block(image: model.ImageURLPart) -> BetaImageBlockParam:
62
53
  return {"type": "image", "source": source_url}
63
54
 
64
55
 
65
- def _user_group_to_message(group: UserGroup) -> BetaMessageParam:
56
+ def _user_message_to_message(
57
+ msg: message.UserMessage,
58
+ attachment: DeveloperAttachment,
59
+ ) -> BetaMessageParam:
66
60
  blocks: list[BetaTextBlockParam | BetaImageBlockParam] = []
67
- for text in group.text_parts:
68
- blocks.append({"type": "text", "text": text + "\n"})
69
- for image in group.images:
61
+ for part in msg.parts:
62
+ if isinstance(part, message.TextPart):
63
+ blocks.append({"type": "text", "text": part.text})
64
+ elif isinstance(part, message.ImageURLPart):
65
+ blocks.append(_image_part_to_block(part))
66
+ if attachment.text:
67
+ blocks.append({"type": "text", "text": attachment.text})
68
+ for image in attachment.images:
70
69
  blocks.append(_image_part_to_block(image))
71
70
  if not blocks:
72
71
  blocks.append({"type": "text", "text": ""})
73
72
  return {"role": "user", "content": blocks}
74
73
 
75
74
 
76
- def _tool_group_to_block(group: ToolGroup) -> dict[str, object]:
77
- """Convert a single ToolGroup to a tool_result block."""
75
+ def _tool_message_to_block(
76
+ msg: message.ToolResultMessage,
77
+ attachment: DeveloperAttachment,
78
+ ) -> dict[str, object]:
79
+ """Convert a single tool result message to a tool_result block."""
78
80
  tool_content: list[BetaTextBlockParam | BetaImageBlockParam] = []
79
81
  merged_text = merge_reminder_text(
80
- group.tool_result.output or "<system-reminder>Tool ran without output or errors</system-reminder>",
81
- group.reminder_texts,
82
+ msg.output_text or EMPTY_TOOL_OUTPUT_MESSAGE,
83
+ attachment.text,
82
84
  )
83
85
  tool_content.append({"type": "text", "text": merged_text})
84
- for image in group.tool_result.images or []:
86
+ for image in [part for part in msg.parts if isinstance(part, message.ImageURLPart)]:
85
87
  tool_content.append(_image_part_to_block(image))
86
- for image in group.reminder_images:
88
+ for image in attachment.images:
87
89
  tool_content.append(_image_part_to_block(image))
88
90
  return {
89
91
  "type": "tool_result",
90
- "tool_use_id": group.tool_result.call_id,
91
- "is_error": group.tool_result.status == "error",
92
+ "tool_use_id": msg.call_id,
93
+ "is_error": msg.status != "success",
92
94
  "content": tool_content,
93
95
  }
94
96
 
95
97
 
96
- def _tool_groups_to_message(groups: list[ToolGroup]) -> BetaMessageParam:
97
- """Convert one or more ToolGroups to a single user message with multiple tool_result blocks."""
98
+ def _tool_blocks_to_message(blocks: list[dict[str, object]]) -> BetaMessageParam:
99
+ """Convert one or more tool_result blocks to a single user message."""
98
100
  return {
99
101
  "role": "user",
100
- "content": [_tool_group_to_block(group) for group in groups],
102
+ "content": blocks,
101
103
  }
102
104
 
103
105
 
104
- def _assistant_group_to_message(group: AssistantGroup, model_name: str | None) -> BetaMessageParam:
106
+ def _assistant_message_to_message(msg: message.AssistantMessage, model_name: str | None) -> BetaMessageParam:
105
107
  content: list[dict[str, object]] = []
106
- current_reasoning_content: str | None = None
107
- degraded_thinking_texts: list[str] = []
108
-
109
- # Process reasoning items in original order so that text and
110
- # encrypted parts are paired correctly for the given model.
111
- # For cross-model scenarios, degrade thinking to plain text.
112
- for item in group.reasoning_items:
113
- if isinstance(item, model.ReasoningTextItem):
114
- if model_name != item.model:
115
- # Cross-model: collect thinking text for degradation
116
- if item.content:
117
- degraded_thinking_texts.append(item.content)
118
- else:
119
- current_reasoning_content = item.content
120
- else:
121
- # Same model: preserve signature
122
- if model_name == item.model and item.encrypted_content and len(item.encrypted_content) > 0:
108
+ current_thinking_content: str | None = None
109
+ native_thinking_parts, degraded_thinking_texts = split_thinking_parts(msg, model_name)
110
+ native_thinking_ids = {id(part) for part in native_thinking_parts}
111
+
112
+ def _flush_thinking() -> None:
113
+ nonlocal current_thinking_content
114
+ if current_thinking_content is None:
115
+ return
116
+ content.append({"type": "thinking", "thinking": current_thinking_content})
117
+ current_thinking_content = None
118
+
119
+ for part in msg.parts:
120
+ if isinstance(part, message.ThinkingTextPart):
121
+ if id(part) not in native_thinking_ids:
122
+ continue
123
+ current_thinking_content = part.text
124
+ continue
125
+ if isinstance(part, message.ThinkingSignaturePart):
126
+ if id(part) not in native_thinking_ids:
127
+ continue
128
+ if part.signature:
123
129
  content.append(
124
130
  {
125
131
  "type": "thinking",
126
- "thinking": current_reasoning_content or "",
127
- "signature": item.encrypted_content,
132
+ "thinking": current_thinking_content or "",
133
+ "signature": part.signature,
128
134
  }
129
135
  )
130
- current_reasoning_content = None
136
+ current_thinking_content = None
137
+ continue
138
+
139
+ _flush_thinking()
140
+ if isinstance(part, message.TextPart):
141
+ content.append({"type": "text", "text": part.text})
142
+ elif isinstance(part, message.ToolCallPart):
143
+ content.append(
144
+ {
145
+ "type": "tool_use",
146
+ "id": part.call_id,
147
+ "name": part.tool_name,
148
+ "input": json.loads(part.arguments_json) if part.arguments_json else None,
149
+ }
150
+ )
151
+
152
+ _flush_thinking()
131
153
 
132
- # Moonshot.ai's Kimi does not always send reasoning signatures;
133
- # if we saw reasoning text without any matching encrypted item,
134
- # emit it as a plain thinking block.
135
- if len(current_reasoning_content or "") > 0:
136
- content.insert(0, {"type": "thinking", "thinking": current_reasoning_content})
137
-
138
- # Cross-model: degrade thinking to plain text with <thinking> tags
139
154
  if degraded_thinking_texts:
140
155
  degraded_text = "<thinking>\n" + "\n".join(degraded_thinking_texts) + "\n</thinking>"
141
156
  content.insert(0, {"type": "text", "text": degraded_text})
142
157
 
143
- if group.text_content:
144
- content.append({"type": "text", "text": group.text_content})
145
-
146
- for tc in group.tool_calls:
147
- content.append(
148
- {
149
- "type": "tool_use",
150
- "id": tc.call_id,
151
- "name": tc.name,
152
- "input": json.loads(tc.arguments) if tc.arguments else None,
153
- }
154
- )
155
-
156
158
  return {"role": "assistant", "content": content}
157
159
 
158
160
 
@@ -167,45 +169,51 @@ def _add_cache_control(messages: list[BetaMessageParam]) -> None:
167
169
 
168
170
 
169
171
  def convert_history_to_input(
170
- history: list[model.ConversationItem],
172
+ history: list[message.Message],
171
173
  model_name: str | None,
172
174
  ) -> list[BetaMessageParam]:
173
- """
174
- Convert a list of conversation items to a list of beta message params.
175
-
176
- Args:
177
- history: List of conversation items.
178
- model_name: Model name. Used to verify that signatures are valid for the same model
179
- """
175
+ """Convert a list of messages to beta message params."""
180
176
  messages: list[BetaMessageParam] = []
181
- pending_tool_groups: list[ToolGroup] = []
182
-
183
- def flush_tool_groups() -> None:
184
- nonlocal pending_tool_groups
185
- if pending_tool_groups:
186
- messages.append(_tool_groups_to_message(pending_tool_groups))
187
- pending_tool_groups = []
188
-
189
- for group in parse_message_groups(history):
190
- match group:
191
- case UserGroup():
192
- flush_tool_groups()
193
- messages.append(_user_group_to_message(group))
194
- case ToolGroup():
195
- pending_tool_groups.append(group)
196
- case AssistantGroup():
197
- flush_tool_groups()
198
- messages.append(_assistant_group_to_message(group, model_name))
199
-
200
- flush_tool_groups()
177
+ pending_tool_blocks: list[dict[str, object]] = []
178
+
179
+ def flush_tool_blocks() -> None:
180
+ nonlocal pending_tool_blocks
181
+ if pending_tool_blocks:
182
+ messages.append(_tool_blocks_to_message(pending_tool_blocks))
183
+ pending_tool_blocks = []
184
+
185
+ for msg, attachment in attach_developer_messages(history):
186
+ match msg:
187
+ case message.ToolResultMessage():
188
+ pending_tool_blocks.append(_tool_message_to_block(msg, attachment))
189
+ case message.UserMessage():
190
+ flush_tool_blocks()
191
+ messages.append(_user_message_to_message(msg, attachment))
192
+ case message.AssistantMessage():
193
+ flush_tool_blocks()
194
+ messages.append(_assistant_message_to_message(msg, model_name))
195
+ case message.SystemMessage():
196
+ continue
197
+ case _:
198
+ continue
199
+
200
+ flush_tool_blocks()
201
201
  _add_cache_control(messages)
202
202
  return messages
203
203
 
204
204
 
205
- def convert_system_to_input(system: str | None) -> list[BetaTextBlockParam]:
206
- if system is None:
205
+ def convert_system_to_input(
206
+ system: str | None, system_messages: list[message.SystemMessage] | None = None
207
+ ) -> list[BetaTextBlockParam]:
208
+ parts: list[str] = []
209
+ if system:
210
+ parts.append(system)
211
+ if system_messages:
212
+ for msg in system_messages:
213
+ parts.append("\n".join(part.text for part in msg.parts))
214
+ if not parts:
207
215
  return []
208
- return [{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}]
216
+ return [{"type": "text", "text": "\n".join(parts), "cache_control": {"type": "ephemeral"}}]
209
217
 
210
218
 
211
219
  def convert_tool_schema(
@@ -8,12 +8,13 @@ import anthropic
8
8
  import httpx
9
9
  from anthropic import APIError
10
10
 
11
+ from klaude_code.const import LLM_HTTP_TIMEOUT_CONNECT, LLM_HTTP_TIMEOUT_READ, LLM_HTTP_TIMEOUT_TOTAL
11
12
  from klaude_code.llm.anthropic.client import build_payload, parse_anthropic_stream
12
13
  from klaude_code.llm.client import LLMClientABC
13
14
  from klaude_code.llm.input_common import apply_config_defaults
14
15
  from klaude_code.llm.registry import register
15
- from klaude_code.llm.usage import MetadataTracker
16
- from klaude_code.protocol import llm_param, model
16
+ from klaude_code.llm.usage import MetadataTracker, error_stream_items
17
+ from klaude_code.protocol import llm_param, message
17
18
  from klaude_code.trace import DebugType, log_debug
18
19
 
19
20
 
@@ -29,7 +30,7 @@ class BedrockClient(LLMClientABC):
29
30
  aws_region=config.aws_region,
30
31
  aws_session_token=config.aws_session_token,
31
32
  aws_profile=config.aws_profile,
32
- timeout=httpx.Timeout(300.0, connect=15.0, read=285.0),
33
+ timeout=httpx.Timeout(LLM_HTTP_TIMEOUT_TOTAL, connect=LLM_HTTP_TIMEOUT_CONNECT, read=LLM_HTTP_TIMEOUT_READ),
33
34
  )
34
35
 
35
36
  @classmethod
@@ -38,7 +39,7 @@ class BedrockClient(LLMClientABC):
38
39
  return cls(config)
39
40
 
40
41
  @override
41
- async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[model.ConversationItem]:
42
+ async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[message.LLMStreamItem]:
42
43
  param = apply_config_defaults(param, self.get_llm_config())
43
44
 
44
45
  metadata_tracker = MetadataTracker(cost_config=self.get_llm_config().cost)
@@ -57,4 +58,6 @@ class BedrockClient(LLMClientABC):
57
58
  async for item in parse_anthropic_stream(stream, param, metadata_tracker):
58
59
  yield item
59
60
  except (APIError, httpx.HTTPError) as e:
60
- yield model.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
61
+ error_message = f"{e.__class__.__name__} {e!s}"
62
+ for item in error_stream_items(metadata_tracker, error=error_message):
63
+ yield item
@@ -9,17 +9,25 @@ from anthropic import APIError
9
9
  from klaude_code.auth.claude.exceptions import ClaudeNotLoggedInError
10
10
  from klaude_code.auth.claude.oauth import ClaudeOAuth
11
11
  from klaude_code.auth.claude.token_manager import ClaudeTokenManager
12
+ from klaude_code.const import (
13
+ ANTHROPIC_BETA_FINE_GRAINED_TOOL_STREAMING,
14
+ ANTHROPIC_BETA_INTERLEAVED_THINKING,
15
+ ANTHROPIC_BETA_OAUTH,
16
+ LLM_HTTP_TIMEOUT_CONNECT,
17
+ LLM_HTTP_TIMEOUT_READ,
18
+ LLM_HTTP_TIMEOUT_TOTAL,
19
+ )
12
20
  from klaude_code.llm.anthropic.client import build_payload, parse_anthropic_stream
13
21
  from klaude_code.llm.client import LLMClientABC
14
22
  from klaude_code.llm.input_common import apply_config_defaults
15
23
  from klaude_code.llm.registry import register
16
- from klaude_code.llm.usage import MetadataTracker
17
- from klaude_code.protocol import llm_param, model
24
+ from klaude_code.llm.usage import MetadataTracker, error_stream_items
25
+ from klaude_code.protocol import llm_param, message
18
26
  from klaude_code.trace import DebugType, log_debug
19
27
 
20
28
  _CLAUDE_OAUTH_REQUIRED_BETAS: tuple[str, ...] = (
21
- "oauth-2025-04-20",
22
- "fine-grained-tool-streaming-2025-05-14",
29
+ ANTHROPIC_BETA_OAUTH,
30
+ ANTHROPIC_BETA_FINE_GRAINED_TOOL_STREAMING,
23
31
  )
24
32
 
25
33
 
@@ -45,7 +53,7 @@ class ClaudeClient(LLMClientABC):
45
53
  token = self._oauth.ensure_valid_token()
46
54
  return anthropic.AsyncAnthropic(
47
55
  auth_token=token,
48
- timeout=httpx.Timeout(300.0, connect=15.0, read=285.0),
56
+ timeout=httpx.Timeout(LLM_HTTP_TIMEOUT_TOTAL, connect=LLM_HTTP_TIMEOUT_CONNECT, read=LLM_HTTP_TIMEOUT_READ),
49
57
  )
50
58
 
51
59
  def _ensure_valid_token(self) -> None:
@@ -63,7 +71,7 @@ class ClaudeClient(LLMClientABC):
63
71
  return cls(config)
64
72
 
65
73
  @override
66
- async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[model.ConversationItem]:
74
+ async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[message.LLMStreamItem]:
67
75
  self._ensure_valid_token()
68
76
  param = apply_config_defaults(param, self.get_llm_config())
69
77
 
@@ -75,7 +83,7 @@ class ClaudeClient(LLMClientABC):
75
83
 
76
84
  # Keep the interleaved-thinking beta in sync with configured thinking.
77
85
  if not (param.thinking and param.thinking.type == "enabled"):
78
- payload["betas"] = [b for b in payload.get("betas", []) if b != "interleaved-thinking-2025-05-14"]
86
+ payload["betas"] = [b for b in payload.get("betas", []) if b != ANTHROPIC_BETA_INTERLEAVED_THINKING]
79
87
 
80
88
  log_debug(
81
89
  json.dumps(payload, ensure_ascii=False, default=str),
@@ -92,4 +100,6 @@ class ClaudeClient(LLMClientABC):
92
100
  async for item in parse_anthropic_stream(stream, param, metadata_tracker):
93
101
  yield item
94
102
  except (APIError, httpx.HTTPError) as e:
95
- yield model.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
103
+ error_message = f"{e.__class__.__name__} {e!s}"
104
+ for item in error_stream_items(metadata_tracker, error=error_message):
105
+ yield item