klaude-code 1.9.0__py3-none-any.whl → 2.0.1__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 (132) hide show
  1. klaude_code/auth/base.py +2 -6
  2. klaude_code/cli/auth_cmd.py +4 -4
  3. klaude_code/cli/cost_cmd.py +1 -1
  4. klaude_code/cli/list_model.py +1 -1
  5. klaude_code/cli/main.py +1 -1
  6. klaude_code/cli/runtime.py +7 -5
  7. klaude_code/cli/self_update.py +1 -1
  8. klaude_code/cli/session_cmd.py +1 -1
  9. klaude_code/command/clear_cmd.py +6 -2
  10. klaude_code/command/command_abc.py +2 -2
  11. klaude_code/command/debug_cmd.py +4 -4
  12. klaude_code/command/export_cmd.py +2 -2
  13. klaude_code/command/export_online_cmd.py +12 -12
  14. klaude_code/command/fork_session_cmd.py +29 -23
  15. klaude_code/command/help_cmd.py +4 -4
  16. klaude_code/command/model_cmd.py +4 -4
  17. klaude_code/command/model_select.py +1 -1
  18. klaude_code/command/prompt-commit.md +11 -2
  19. klaude_code/command/prompt_command.py +3 -3
  20. klaude_code/command/refresh_cmd.py +2 -2
  21. klaude_code/command/registry.py +7 -5
  22. klaude_code/command/release_notes_cmd.py +4 -4
  23. klaude_code/command/resume_cmd.py +15 -11
  24. klaude_code/command/status_cmd.py +4 -4
  25. klaude_code/command/terminal_setup_cmd.py +8 -8
  26. klaude_code/command/thinking_cmd.py +4 -4
  27. klaude_code/config/assets/builtin_config.yaml +20 -0
  28. klaude_code/config/builtin_config.py +16 -5
  29. klaude_code/config/config.py +7 -2
  30. klaude_code/const.py +147 -91
  31. klaude_code/core/agent.py +3 -12
  32. klaude_code/core/executor.py +18 -39
  33. klaude_code/core/manager/sub_agent_manager.py +71 -7
  34. klaude_code/core/prompts/prompt-sub-agent-image-gen.md +1 -0
  35. klaude_code/core/prompts/prompt-sub-agent-web.md +27 -1
  36. klaude_code/core/reminders.py +88 -69
  37. klaude_code/core/task.py +44 -45
  38. klaude_code/core/tool/file/apply_patch_tool.py +9 -9
  39. klaude_code/core/tool/file/diff_builder.py +3 -5
  40. klaude_code/core/tool/file/edit_tool.py +23 -23
  41. klaude_code/core/tool/file/move_tool.py +43 -43
  42. klaude_code/core/tool/file/read_tool.py +44 -39
  43. klaude_code/core/tool/file/write_tool.py +14 -14
  44. klaude_code/core/tool/report_back_tool.py +4 -4
  45. klaude_code/core/tool/shell/bash_tool.py +23 -23
  46. klaude_code/core/tool/skill/skill_tool.py +7 -7
  47. klaude_code/core/tool/sub_agent_tool.py +38 -9
  48. klaude_code/core/tool/todo/todo_write_tool.py +9 -10
  49. klaude_code/core/tool/todo/update_plan_tool.py +6 -6
  50. klaude_code/core/tool/tool_abc.py +2 -2
  51. klaude_code/core/tool/tool_context.py +27 -0
  52. klaude_code/core/tool/tool_runner.py +88 -42
  53. klaude_code/core/tool/truncation.py +38 -20
  54. klaude_code/core/tool/web/mermaid_tool.py +6 -7
  55. klaude_code/core/tool/web/web_fetch_tool.py +68 -30
  56. klaude_code/core/tool/web/web_search_tool.py +15 -17
  57. klaude_code/core/turn.py +120 -73
  58. klaude_code/llm/anthropic/client.py +79 -44
  59. klaude_code/llm/anthropic/input.py +116 -108
  60. klaude_code/llm/bedrock/client.py +8 -5
  61. klaude_code/llm/claude/client.py +18 -8
  62. klaude_code/llm/client.py +4 -3
  63. klaude_code/llm/codex/client.py +15 -9
  64. klaude_code/llm/google/client.py +122 -60
  65. klaude_code/llm/google/input.py +94 -108
  66. klaude_code/llm/image.py +123 -0
  67. klaude_code/llm/input_common.py +136 -189
  68. klaude_code/llm/openai_compatible/client.py +17 -7
  69. klaude_code/llm/openai_compatible/input.py +36 -66
  70. klaude_code/llm/openai_compatible/stream.py +119 -67
  71. klaude_code/llm/openai_compatible/tool_call_accumulator.py +23 -11
  72. klaude_code/llm/openrouter/client.py +34 -9
  73. klaude_code/llm/openrouter/input.py +63 -64
  74. klaude_code/llm/openrouter/reasoning.py +22 -24
  75. klaude_code/llm/registry.py +20 -17
  76. klaude_code/llm/responses/client.py +107 -45
  77. klaude_code/llm/responses/input.py +115 -98
  78. klaude_code/llm/usage.py +52 -25
  79. klaude_code/protocol/__init__.py +1 -0
  80. klaude_code/protocol/events.py +16 -12
  81. klaude_code/protocol/llm_param.py +20 -2
  82. klaude_code/protocol/message.py +250 -0
  83. klaude_code/protocol/model.py +95 -285
  84. klaude_code/protocol/op.py +2 -15
  85. klaude_code/protocol/op_handler.py +0 -5
  86. klaude_code/protocol/sub_agent/__init__.py +1 -0
  87. klaude_code/protocol/sub_agent/explore.py +10 -0
  88. klaude_code/protocol/sub_agent/image_gen.py +119 -0
  89. klaude_code/protocol/sub_agent/task.py +10 -0
  90. klaude_code/protocol/sub_agent/web.py +10 -0
  91. klaude_code/session/codec.py +6 -6
  92. klaude_code/session/export.py +261 -62
  93. klaude_code/session/selector.py +7 -24
  94. klaude_code/session/session.py +126 -54
  95. klaude_code/session/store.py +5 -32
  96. klaude_code/session/templates/export_session.html +1 -1
  97. klaude_code/session/templates/mermaid_viewer.html +1 -1
  98. klaude_code/trace/log.py +11 -6
  99. klaude_code/ui/core/input.py +1 -1
  100. klaude_code/ui/core/stage_manager.py +1 -8
  101. klaude_code/ui/modes/debug/display.py +2 -2
  102. klaude_code/ui/modes/repl/clipboard.py +2 -2
  103. klaude_code/ui/modes/repl/completers.py +18 -10
  104. klaude_code/ui/modes/repl/event_handler.py +138 -132
  105. klaude_code/ui/modes/repl/input_prompt_toolkit.py +1 -1
  106. klaude_code/ui/modes/repl/key_bindings.py +136 -2
  107. klaude_code/ui/modes/repl/renderer.py +107 -15
  108. klaude_code/ui/renderers/assistant.py +2 -2
  109. klaude_code/ui/renderers/bash_syntax.py +36 -4
  110. klaude_code/ui/renderers/common.py +70 -10
  111. klaude_code/ui/renderers/developer.py +7 -6
  112. klaude_code/ui/renderers/diffs.py +11 -11
  113. klaude_code/ui/renderers/mermaid_viewer.py +49 -2
  114. klaude_code/ui/renderers/metadata.py +33 -5
  115. klaude_code/ui/renderers/sub_agent.py +57 -16
  116. klaude_code/ui/renderers/thinking.py +37 -2
  117. klaude_code/ui/renderers/tools.py +188 -178
  118. klaude_code/ui/rich/live.py +3 -1
  119. klaude_code/ui/rich/markdown.py +39 -7
  120. klaude_code/ui/rich/quote.py +76 -1
  121. klaude_code/ui/rich/status.py +14 -8
  122. klaude_code/ui/rich/theme.py +20 -14
  123. klaude_code/ui/terminal/image.py +34 -0
  124. klaude_code/ui/terminal/notifier.py +2 -1
  125. klaude_code/ui/terminal/progress_bar.py +4 -4
  126. klaude_code/ui/terminal/selector.py +22 -4
  127. klaude_code/ui/utils/common.py +11 -2
  128. {klaude_code-1.9.0.dist-info → klaude_code-2.0.1.dist-info}/METADATA +4 -2
  129. klaude_code-2.0.1.dist-info/RECORD +229 -0
  130. klaude_code-1.9.0.dist-info/RECORD +0 -224
  131. {klaude_code-1.9.0.dist-info → klaude_code-2.0.1.dist-info}/WHEEL +0 -0
  132. {klaude_code-1.9.0.dist-info → klaude_code-2.0.1.dist-info}/entry_points.txt +0 -0
@@ -6,29 +6,49 @@ from typing import Any
6
6
 
7
7
  from openai.types import responses
8
8
 
9
- from klaude_code.protocol import llm_param, model
9
+ from klaude_code.const import EMPTY_TOOL_OUTPUT_MESSAGE
10
+ from klaude_code.llm.input_common import (
11
+ DeveloperAttachment,
12
+ attach_developer_messages,
13
+ merge_reminder_text,
14
+ split_thinking_parts,
15
+ )
16
+ from klaude_code.protocol import llm_param, message
10
17
 
11
18
 
12
19
  def _build_user_content_parts(
13
- user: model.UserMessageItem,
20
+ user: message.UserMessage,
21
+ attachment: DeveloperAttachment,
14
22
  ) -> list[responses.ResponseInputContentParam]:
15
23
  parts: list[responses.ResponseInputContentParam] = []
16
- if user.content is not None:
17
- parts.append({"type": "input_text", "text": user.content})
18
- for image in user.images or []:
19
- parts.append({"type": "input_image", "detail": "auto", "image_url": image.image_url.url})
24
+ for part in user.parts:
25
+ if isinstance(part, message.TextPart):
26
+ parts.append({"type": "input_text", "text": part.text})
27
+ elif isinstance(part, message.ImageURLPart):
28
+ parts.append({"type": "input_image", "detail": "auto", "image_url": part.url})
29
+ if attachment.text:
30
+ parts.append({"type": "input_text", "text": attachment.text})
31
+ for image in attachment.images:
32
+ parts.append({"type": "input_image", "detail": "auto", "image_url": image.url})
20
33
  if not parts:
21
34
  parts.append({"type": "input_text", "text": ""})
22
35
  return parts
23
36
 
24
37
 
25
- def _build_tool_result_item(tool: model.ToolResultItem) -> responses.ResponseInputItemParam:
38
+ def _build_tool_result_item(
39
+ tool: message.ToolResultMessage,
40
+ attachment: DeveloperAttachment,
41
+ ) -> responses.ResponseInputItemParam:
26
42
  content_parts: list[responses.ResponseInputContentParam] = []
27
- text_output = tool.output or "<system-reminder>Tool ran without output or errors</system-reminder>"
43
+ text_output = merge_reminder_text(
44
+ tool.output_text or EMPTY_TOOL_OUTPUT_MESSAGE,
45
+ attachment.text,
46
+ )
28
47
  if text_output:
29
48
  content_parts.append({"type": "input_text", "text": text_output})
30
- for image in tool.images or []:
31
- content_parts.append({"type": "input_image", "detail": "auto", "image_url": image.image_url.url})
49
+ images = [part for part in tool.parts if isinstance(part, message.ImageURLPart)] + attachment.images
50
+ for image in images:
51
+ content_parts.append({"type": "input_image", "detail": "auto", "image_url": image.url})
32
52
 
33
53
  item: dict[str, Any] = {
34
54
  "type": "function_call_output",
@@ -39,103 +59,105 @@ def _build_tool_result_item(tool: model.ToolResultItem) -> responses.ResponseInp
39
59
 
40
60
 
41
61
  def convert_history_to_input(
42
- history: list[model.ConversationItem],
62
+ history: list[message.Message],
43
63
  model_name: str | None = None,
44
64
  ) -> responses.ResponseInputParam:
45
- """
46
- Convert a list of conversation items to a list of response input params.
47
-
48
- Args:
49
- history: List of conversation items.
50
- model_name: Model name. Used to verify that signatures are valid for the same model.
51
- """
65
+ """Convert a list of messages to response input params."""
52
66
  items: list[responses.ResponseInputItemParam] = []
53
67
 
54
- pending_reasoning_text: str | None = None
55
68
  degraded_thinking_texts: list[str] = []
56
69
 
57
- for item in history:
58
- match item:
59
- case model.ReasoningTextItem() as item:
60
- # For now, we only store the text. We wait for the encrypted item to output both.
61
- # If no encrypted item follows (e.g. incomplete stream?), this text might be lost
62
- # or we can choose to output it if the next item is NOT reasoning?
63
- # For now, based on instructions, we pair them.
64
- if model_name != item.model:
65
- # Cross-model: collect thinking text for degradation
66
- if item.content:
67
- degraded_thinking_texts.append(item.content)
68
- continue
69
- pending_reasoning_text = item.content
70
-
71
- case model.ReasoningEncryptedItem() as item:
72
- if item.encrypted_content and len(item.encrypted_content) > 0 and model_name == item.model:
73
- items.append(convert_reasoning_inputs(pending_reasoning_text, item))
74
- # Reset pending text after consumption
75
- pending_reasoning_text = None
76
-
77
- case model.ToolCallItem() as t:
78
- items.append(
79
- {
80
- "type": "function_call",
81
- "name": t.name,
82
- "arguments": t.arguments,
83
- "call_id": t.call_id,
84
- "id": t.id,
85
- }
86
- )
87
- case model.ToolResultItem() as t:
88
- items.append(_build_tool_result_item(t))
89
- case model.AssistantMessageItem() as a:
90
- items.append(
91
- {
92
- "type": "message",
93
- "role": "assistant",
94
- "id": a.id,
95
- "content": [
96
- {
97
- "type": "output_text",
98
- "text": a.content,
99
- }
100
- ],
101
- }
102
- )
103
- case model.UserMessageItem() as u:
70
+ for msg, attachment in attach_developer_messages(history):
71
+ match msg:
72
+ case message.SystemMessage():
73
+ system_text = "\n".join(part.text for part in msg.parts)
74
+ if system_text:
75
+ items.append(
76
+ {
77
+ "type": "message",
78
+ "role": "system",
79
+ "content": [
80
+ {
81
+ "type": "input_text",
82
+ "text": system_text,
83
+ }
84
+ ],
85
+ }
86
+ )
87
+ case message.UserMessage():
104
88
  items.append(
105
89
  {
106
90
  "type": "message",
107
91
  "role": "user",
108
- "id": u.id,
109
- "content": _build_user_content_parts(u),
92
+ "id": msg.id,
93
+ "content": _build_user_content_parts(msg, attachment),
110
94
  }
111
95
  )
112
- case model.DeveloperMessageItem() as d:
113
- dev_parts: list[responses.ResponseInputContentParam] = []
114
- if d.content is not None:
115
- dev_parts.append({"type": "input_text", "text": d.content})
116
- for image in d.images or []:
117
- dev_parts.append(
96
+ case message.ToolResultMessage():
97
+ items.append(_build_tool_result_item(msg, attachment))
98
+ case message.AssistantMessage():
99
+ assistant_text_parts: list[responses.ResponseInputContentParam] = []
100
+ pending_thinking_text: str | None = None
101
+ pending_signature: str | None = None
102
+ native_thinking_parts, degraded_for_message = split_thinking_parts(msg, model_name)
103
+ native_thinking_ids = {id(part) for part in native_thinking_parts}
104
+ degraded_thinking_texts.extend(degraded_for_message)
105
+
106
+ def flush_text(*, _message_id: str = msg.id) -> None:
107
+ nonlocal assistant_text_parts
108
+ if not assistant_text_parts:
109
+ return
110
+ items.append(
118
111
  {
119
- "type": "input_image",
120
- "detail": "auto",
121
- "image_url": image.image_url.url,
112
+ "type": "message",
113
+ "role": "assistant",
114
+ "id": _message_id,
115
+ "content": assistant_text_parts,
122
116
  }
123
117
  )
124
- if not dev_parts:
125
- dev_parts.append({"type": "input_text", "text": ""})
126
- items.append(
127
- {
128
- "type": "message",
129
- "role": "user", # GPT-5 series do not support image in "developer" role, so we set it to "user"
130
- "id": d.id,
131
- "content": dev_parts,
132
- }
133
- )
118
+ assistant_text_parts = []
119
+
120
+ def emit_reasoning() -> None:
121
+ nonlocal pending_thinking_text, pending_signature
122
+ if pending_thinking_text is None and pending_signature is None:
123
+ return
124
+ items.append(convert_reasoning_inputs(pending_thinking_text, pending_signature))
125
+ pending_thinking_text = None
126
+ pending_signature = None
127
+
128
+ for part in msg.parts:
129
+ if isinstance(part, message.ThinkingTextPart):
130
+ if id(part) not in native_thinking_ids:
131
+ continue
132
+ emit_reasoning()
133
+ pending_thinking_text = part.text
134
+ continue
135
+ if isinstance(part, message.ThinkingSignaturePart):
136
+ if id(part) not in native_thinking_ids:
137
+ continue
138
+ pending_signature = part.signature
139
+ continue
140
+
141
+ emit_reasoning()
142
+ if isinstance(part, message.TextPart):
143
+ assistant_text_parts.append({"type": "output_text", "text": part.text})
144
+ elif isinstance(part, message.ToolCallPart):
145
+ flush_text()
146
+ items.append(
147
+ {
148
+ "type": "function_call",
149
+ "name": part.tool_name,
150
+ "arguments": part.arguments_json,
151
+ "call_id": part.call_id,
152
+ "id": part.id,
153
+ }
154
+ )
155
+
156
+ emit_reasoning()
157
+ flush_text()
134
158
  case _:
135
- # Other items may be Metadata
136
159
  continue
137
160
 
138
- # Cross-model: degrade thinking to plain text with <thinking> tags
139
161
  if degraded_thinking_texts:
140
162
  degraded_item: responses.ResponseInputItemParam = {
141
163
  "type": "message",
@@ -152,21 +174,16 @@ def convert_history_to_input(
152
174
  return items
153
175
 
154
176
 
155
- def convert_reasoning_inputs(
156
- text_content: str | None, encrypted_item: model.ReasoningEncryptedItem
157
- ) -> responses.ResponseInputItemParam:
158
- result = {"type": "reasoning", "content": None}
159
-
177
+ def convert_reasoning_inputs(text_content: str | None, signature: str | None) -> responses.ResponseInputItemParam:
178
+ result: dict[str, Any] = {"type": "reasoning", "content": None}
160
179
  result["summary"] = [
161
180
  {
162
181
  "type": "summary_text",
163
182
  "text": text_content or "",
164
183
  }
165
184
  ]
166
- if encrypted_item.encrypted_content:
167
- result["encrypted_content"] = encrypted_item.encrypted_content
168
- if encrypted_item.id is not None:
169
- result["id"] = encrypted_item.id
185
+ if signature:
186
+ result["encrypted_content"] = signature
170
187
  return result
171
188
 
172
189
 
klaude_code/llm/usage.py CHANGED
@@ -2,7 +2,8 @@ import time
2
2
 
3
3
  import openai.types
4
4
 
5
- from klaude_code.protocol import llm_param, model
5
+ from klaude_code.const import THROUGHPUT_MIN_DURATION_SEC
6
+ from klaude_code.protocol import llm_param, message, model
6
7
 
7
8
 
8
9
  def calculate_cost(usage: model.Usage, cost_config: llm_param.Cost | None) -> None:
@@ -18,7 +19,7 @@ def calculate_cost(usage: model.Usage, cost_config: llm_param.Cost | None) -> No
18
19
  usage.currency = cost_config.currency
19
20
 
20
21
  # Non-cached input tokens cost
21
- non_cached_input = usage.input_tokens - usage.cached_tokens
22
+ non_cached_input = max(0, usage.input_tokens - usage.cached_tokens)
22
23
  usage.input_cost = (non_cached_input / 1_000_000) * cost_config.input
23
24
 
24
25
  # Output tokens cost (includes reasoning tokens)
@@ -27,6 +28,9 @@ def calculate_cost(usage: model.Usage, cost_config: llm_param.Cost | None) -> No
27
28
  # Cache read cost
28
29
  usage.cache_read_cost = (usage.cached_tokens / 1_000_000) * cost_config.cache_read
29
30
 
31
+ # Image generation cost
32
+ usage.image_cost = (usage.image_tokens / 1_000_000) * cost_config.image
33
+
30
34
 
31
35
  class MetadataTracker:
32
36
  """Tracks timing and metadata for LLM responses."""
@@ -35,13 +39,9 @@ class MetadataTracker:
35
39
  self._request_start_time: float = time.time()
36
40
  self._first_token_time: float | None = None
37
41
  self._last_token_time: float | None = None
38
- self._metadata_item = model.ResponseMetadataItem()
42
+ self._usage = model.Usage()
39
43
  self._cost_config = cost_config
40
44
 
41
- @property
42
- def metadata_item(self) -> model.ResponseMetadataItem:
43
- return self._metadata_item
44
-
45
45
  @property
46
46
  def first_token_time(self) -> float | None:
47
47
  return self._first_token_time
@@ -59,37 +59,59 @@ class MetadataTracker:
59
59
 
60
60
  def set_usage(self, usage: model.Usage) -> None:
61
61
  """Set the usage information."""
62
- self._metadata_item.usage = usage
62
+ preserved = {
63
+ "response_id": self._usage.response_id,
64
+ "model_name": self._usage.model_name,
65
+ "provider": self._usage.provider,
66
+ "task_duration_s": self._usage.task_duration_s,
67
+ "created_at": self._usage.created_at,
68
+ }
69
+ self._usage = usage.model_copy(update=preserved)
63
70
 
64
71
  def set_model_name(self, model_name: str) -> None:
65
72
  """Set the model name."""
66
- self._metadata_item.model_name = model_name
73
+ self._usage.model_name = model_name
67
74
 
68
75
  def set_provider(self, provider: str) -> None:
69
76
  """Set the provider name."""
70
- self._metadata_item.provider = provider
77
+ self._usage.provider = provider
71
78
 
72
79
  def set_response_id(self, response_id: str | None) -> None:
73
80
  """Set the response ID."""
74
- self._metadata_item.response_id = response_id
81
+ self._usage.response_id = response_id
75
82
 
76
- def finalize(self) -> model.ResponseMetadataItem:
77
- """Finalize and return the metadata item with calculated performance metrics."""
78
- if self._metadata_item.usage and self._first_token_time is not None:
79
- self._metadata_item.usage.first_token_latency_ms = (
80
- self._first_token_time - self._request_start_time
81
- ) * 1000
83
+ def finalize(self) -> model.Usage:
84
+ """Finalize and return the usage item with calculated performance metrics."""
85
+ if self._first_token_time is not None:
86
+ self._usage.first_token_latency_ms = (self._first_token_time - self._request_start_time) * 1000
82
87
 
83
- if self._last_token_time is not None and self._metadata_item.usage.output_tokens > 0:
88
+ if self._last_token_time is not None and self._usage.output_tokens > 0:
84
89
  time_duration = self._last_token_time - self._request_start_time
85
- if time_duration >= 0.15:
86
- self._metadata_item.usage.throughput_tps = self._metadata_item.usage.output_tokens / time_duration
90
+ if time_duration >= THROUGHPUT_MIN_DURATION_SEC:
91
+ self._usage.throughput_tps = self._usage.output_tokens / time_duration
87
92
 
88
93
  # Calculate cost if config is available
89
- if self._metadata_item.usage:
90
- calculate_cost(self._metadata_item.usage, self._cost_config)
94
+ calculate_cost(self._usage, self._cost_config)
95
+
96
+ return self._usage
97
+
98
+ @property
99
+ def usage(self) -> model.Usage:
100
+ return self._usage
91
101
 
92
- return self._metadata_item
102
+
103
+ def error_stream_items(
104
+ metadata_tracker: MetadataTracker,
105
+ *,
106
+ error: str,
107
+ response_id: str | None = None,
108
+ ) -> list[message.LLMStreamItem]:
109
+ metadata_tracker.set_response_id(response_id)
110
+ metadata = metadata_tracker.finalize()
111
+ return [
112
+ message.StreamErrorItem(error=error),
113
+ message.AssistantMessage(parts=[], response_id=response_id, usage=metadata),
114
+ ]
93
115
 
94
116
 
95
117
  def convert_usage(
@@ -102,12 +124,17 @@ def convert_usage(
102
124
  context_token is set to total_tokens from the API response,
103
125
  representing the actual context window usage for this turn.
104
126
  """
127
+ completion_details = usage.completion_tokens_details
128
+ image_tokens = 0
129
+ if completion_details is not None:
130
+ image_tokens = getattr(completion_details, "image_tokens", 0) or 0
131
+
105
132
  return model.Usage(
106
133
  input_tokens=usage.prompt_tokens,
107
134
  cached_tokens=(usage.prompt_tokens_details.cached_tokens if usage.prompt_tokens_details else 0) or 0,
108
- reasoning_tokens=(usage.completion_tokens_details.reasoning_tokens if usage.completion_tokens_details else 0)
109
- or 0,
135
+ reasoning_tokens=(completion_details.reasoning_tokens if completion_details else 0) or 0,
110
136
  output_tokens=usage.completion_tokens,
137
+ image_tokens=image_tokens,
111
138
  context_size=usage.total_tokens,
112
139
  context_limit=context_limit,
113
140
  max_tokens=max_tokens,
@@ -1,4 +1,5 @@
1
1
  from klaude_code.protocol import commands as commands
2
2
  from klaude_code.protocol import events as events
3
+ from klaude_code.protocol import message as message
3
4
  from klaude_code.protocol import model as model
4
5
  from klaude_code.protocol import op as op
@@ -2,7 +2,7 @@ from typing import Literal
2
2
 
3
3
  from pydantic import BaseModel
4
4
 
5
- from klaude_code.protocol import llm_param, model
5
+ from klaude_code.protocol import llm_param, message, model
6
6
 
7
7
  """
8
8
  Event is how Agent Executor and UI Display communicate.
@@ -50,35 +50,36 @@ class TurnToolCallStartEvent(BaseModel):
50
50
  arguments: str
51
51
 
52
52
 
53
- class ThinkingEvent(BaseModel):
53
+ class ThinkingDeltaEvent(BaseModel):
54
54
  session_id: str
55
55
  response_id: str | None = None
56
56
  content: str
57
57
 
58
58
 
59
- class ThinkingDeltaEvent(BaseModel):
59
+ class AssistantTextDeltaEvent(BaseModel):
60
60
  session_id: str
61
61
  response_id: str | None = None
62
62
  content: str
63
63
 
64
64
 
65
- class AssistantMessageDeltaEvent(BaseModel):
65
+ class AssistantImageDeltaEvent(BaseModel):
66
66
  session_id: str
67
67
  response_id: str | None = None
68
- content: str
68
+ file_path: str
69
69
 
70
70
 
71
71
  class AssistantMessageEvent(BaseModel):
72
72
  response_id: str | None = None
73
73
  session_id: str
74
74
  content: str
75
+ thinking_text: str | None = None
75
76
 
76
77
 
77
78
  class DeveloperMessageEvent(BaseModel):
78
79
  """DeveloperMessages are reminders in user messages or tool results, see: core/reminders.py"""
79
80
 
80
81
  session_id: str
81
- item: model.DeveloperMessageItem
82
+ item: message.DeveloperMessage
82
83
 
83
84
 
84
85
  class ToolCallEvent(BaseModel):
@@ -98,13 +99,16 @@ class ToolResultEvent(BaseModel):
98
99
  ui_extra: model.ToolResultUIExtra | None = None
99
100
  status: Literal["success", "error"]
100
101
  task_metadata: model.TaskMetadata | None = None # Sub-agent task metadata
102
+ # Whether this tool result is the last one emitted in the current turn.
103
+ # Used by UI renderers to close tree-style prefixes.
104
+ is_last_in_turn: bool = True
101
105
 
102
106
 
103
107
  class ResponseMetadataEvent(BaseModel):
104
108
  """Internal event for turn-level metadata. Not exposed to UI directly."""
105
109
 
106
110
  session_id: str
107
- metadata: model.ResponseMetadataItem
111
+ metadata: model.Usage
108
112
 
109
113
 
110
114
  class TaskMetadataEvent(BaseModel):
@@ -117,7 +121,7 @@ class TaskMetadataEvent(BaseModel):
117
121
  class UserMessageEvent(BaseModel):
118
122
  session_id: str
119
123
  content: str
120
- images: list[model.ImageURLPart] | None = None
124
+ images: list[message.ImageURLPart] | None = None
121
125
 
122
126
 
123
127
  class WelcomeEvent(BaseModel):
@@ -142,10 +146,10 @@ class ContextUsageEvent(BaseModel):
142
146
 
143
147
 
144
148
  HistoryItemEvent = (
145
- ThinkingEvent
146
- | TaskStartEvent
149
+ TaskStartEvent
147
150
  | TaskFinishEvent
148
151
  | TurnStartEvent # This event is used for UI to print new empty line
152
+ | AssistantImageDeltaEvent
149
153
  | AssistantMessageEvent
150
154
  | ToolCallEvent
151
155
  | ToolResultEvent
@@ -167,9 +171,9 @@ class ReplayHistoryEvent(BaseModel):
167
171
  Event = (
168
172
  TaskStartEvent
169
173
  | TaskFinishEvent
170
- | ThinkingEvent
171
174
  | ThinkingDeltaEvent
172
- | AssistantMessageDeltaEvent
175
+ | AssistantTextDeltaEvent
176
+ | AssistantImageDeltaEvent
173
177
  | AssistantMessageEvent
174
178
  | ToolCallEvent
175
179
  | ToolResultEvent
@@ -4,7 +4,7 @@ from typing import Any, Literal
4
4
  from pydantic import BaseModel
5
5
  from pydantic.json_schema import JsonSchemaValue
6
6
 
7
- from klaude_code.protocol.model import ConversationItem
7
+ from klaude_code.protocol.message import Message
8
8
 
9
9
 
10
10
  class LLMClientProtocol(Enum):
@@ -39,6 +39,18 @@ class Thinking(BaseModel):
39
39
  budget_tokens: int | None = None
40
40
 
41
41
 
42
+ class ImageConfig(BaseModel):
43
+ """Image generation config (OpenRouter-compatible fields).
44
+
45
+ This is intentionally small and extensible. Additional vendor/model
46
+ parameters can be stored in `extra`.
47
+ """
48
+
49
+ aspect_ratio: str | None = None
50
+ image_size: Literal["1K", "2K", "4K"] | None = None
51
+ extra: dict[str, Any] | None = None
52
+
53
+
42
54
  class Cost(BaseModel):
43
55
  """Cost configuration per million tokens."""
44
56
 
@@ -46,6 +58,7 @@ class Cost(BaseModel):
46
58
  output: float # Output token price per million tokens
47
59
  cache_read: float = 0.0 # Cache read price per million tokens
48
60
  cache_write: float = 0.0 # Cache write price per million tokens (ignored in calculation for now)
61
+ image: float = 0.0 # Image generation token price per million tokens
49
62
  currency: Literal["USD", "CNY"] = "USD" # Currency for cost display
50
63
 
51
64
 
@@ -114,6 +127,11 @@ class LLMConfigModelParameter(BaseModel):
114
127
  # OpenAI GPT-5
115
128
  verbosity: Literal["low", "medium", "high"] | None = None
116
129
 
130
+ # Multimodal output control (OpenRouter image generation)
131
+ modalities: list[Literal["text", "image"]] | None = None
132
+
133
+ image_config: ImageConfig | None = None
134
+
117
135
  # Unified Thinking & Reasoning
118
136
  thinking: Thinking | None = None
119
137
 
@@ -145,7 +163,7 @@ class LLMCallParameter(LLMConfigModelParameter):
145
163
  """
146
164
 
147
165
  # Agent
148
- input: list[ConversationItem]
166
+ input: list[Message]
149
167
  system: str | None = None
150
168
  tools: list[ToolSchema] | None = None
151
169
  session_id: str | None = None