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
klaude_code/llm/client.py CHANGED
@@ -2,7 +2,7 @@ from abc import ABC, abstractmethod
2
2
  from collections.abc import AsyncGenerator
3
3
  from typing import ParamSpec, TypeVar, cast
4
4
 
5
- from klaude_code.protocol import llm_param, model
5
+ from klaude_code.protocol import llm_param, message
6
6
 
7
7
 
8
8
  class LLMClientABC(ABC):
@@ -15,9 +15,10 @@ class LLMClientABC(ABC):
15
15
  pass
16
16
 
17
17
  @abstractmethod
18
- async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[model.ConversationItem]:
18
+ async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[message.LLMStreamItem]:
19
+ if False: # pragma: no cover
20
+ yield cast(message.LLMStreamItem, None)
19
21
  raise NotImplementedError
20
- yield cast(model.ConversationItem, None)
21
22
 
22
23
  def get_llm_config(self) -> llm_param.LLMConfigParameter:
23
24
  return self._config
@@ -12,13 +12,20 @@ from openai.types.responses.response_create_params import ResponseCreateParamsSt
12
12
  from klaude_code.auth.codex.exceptions import CodexNotLoggedInError
13
13
  from klaude_code.auth.codex.oauth import CodexOAuth
14
14
  from klaude_code.auth.codex.token_manager import CodexTokenManager
15
+ from klaude_code.const import (
16
+ CODEX_BASE_URL,
17
+ CODEX_USER_AGENT,
18
+ LLM_HTTP_TIMEOUT_CONNECT,
19
+ LLM_HTTP_TIMEOUT_READ,
20
+ LLM_HTTP_TIMEOUT_TOTAL,
21
+ )
15
22
  from klaude_code.llm.client import LLMClientABC
16
23
  from klaude_code.llm.input_common import apply_config_defaults
17
24
  from klaude_code.llm.registry import register
18
25
  from klaude_code.llm.responses.client import parse_responses_stream
19
26
  from klaude_code.llm.responses.input import convert_history_to_input, convert_tool_schema
20
- from klaude_code.llm.usage import MetadataTracker
21
- from klaude_code.protocol import llm_param, model
27
+ from klaude_code.llm.usage import MetadataTracker, error_stream_items
28
+ from klaude_code.protocol import llm_param, message
22
29
  from klaude_code.trace import DebugType, log_debug
23
30
 
24
31
 
@@ -57,12 +64,9 @@ def build_payload(param: llm_param.LLMCallParameter) -> ResponseCreateParamsStre
57
64
  return payload
58
65
 
59
66
 
60
- # Codex API configuration
61
- CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
62
67
  CODEX_HEADERS = {
63
68
  "originator": "codex_cli_rs",
64
- # Mocked Codex-style user agent string
65
- "User-Agent": "codex_cli_rs/0.0.0-klaude",
69
+ "User-Agent": CODEX_USER_AGENT,
66
70
  "OpenAI-Beta": "responses=experimental",
67
71
  }
68
72
 
@@ -90,7 +94,7 @@ class CodexClient(LLMClientABC):
90
94
  return AsyncOpenAI(
91
95
  api_key=state.access_token,
92
96
  base_url=CODEX_BASE_URL,
93
- timeout=httpx.Timeout(300.0, connect=15.0, read=285.0),
97
+ timeout=httpx.Timeout(LLM_HTTP_TIMEOUT_TOTAL, connect=LLM_HTTP_TIMEOUT_CONNECT, read=LLM_HTTP_TIMEOUT_READ),
94
98
  default_headers={
95
99
  **CODEX_HEADERS,
96
100
  "chatgpt-account-id": state.account_id,
@@ -114,7 +118,7 @@ class CodexClient(LLMClientABC):
114
118
  return cls(config)
115
119
 
116
120
  @override
117
- async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[model.ConversationItem]:
121
+ async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[message.LLMStreamItem]:
118
122
  # Ensure token is valid before API call
119
123
  self._ensure_valid_token()
120
124
 
@@ -142,7 +146,9 @@ class CodexClient(LLMClientABC):
142
146
  extra_headers=extra_headers,
143
147
  )
144
148
  except (openai.OpenAIError, httpx.HTTPError) as e:
145
- yield model.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
149
+ error_message = f"{e.__class__.__name__} {e!s}"
150
+ for item in error_stream_items(metadata_tracker, error=error_message):
151
+ yield item
146
152
  return
147
153
 
148
154
  async for item in parse_responses_stream(stream, param, metadata_tracker):
@@ -5,7 +5,7 @@
5
5
 
6
6
  import json
7
7
  from collections.abc import AsyncGenerator, AsyncIterator
8
- from typing import Any, cast, override
8
+ from typing import Any, Literal, cast, override
9
9
  from uuid import uuid4
10
10
 
11
11
  import httpx
@@ -26,7 +26,7 @@ from klaude_code.llm.google.input import convert_history_to_contents, convert_to
26
26
  from klaude_code.llm.input_common import apply_config_defaults
27
27
  from klaude_code.llm.registry import register
28
28
  from klaude_code.llm.usage import MetadataTracker
29
- from klaude_code.protocol import llm_param, model
29
+ from klaude_code.protocol import llm_param, message, model
30
30
  from klaude_code.trace import DebugType, log_debug
31
31
 
32
32
 
@@ -114,25 +114,74 @@ def _merge_partial_args(dst: dict[str, Any], partial_args: list[Any] | None) ->
114
114
  dst[key] = _partial_arg_value(partial)
115
115
 
116
116
 
117
+ def _map_finish_reason(reason: str) -> model.StopReason | None:
118
+ normalized = reason.strip().lower()
119
+ mapping: dict[str, model.StopReason] = {
120
+ "stop": "stop",
121
+ "end_turn": "stop",
122
+ "max_tokens": "length",
123
+ "length": "length",
124
+ "tool_use": "tool_use",
125
+ "safety": "error",
126
+ "recitation": "error",
127
+ "other": "error",
128
+ "content_filter": "error",
129
+ "blocked": "error",
130
+ "blocklist": "error",
131
+ "cancelled": "aborted",
132
+ "canceled": "aborted",
133
+ "aborted": "aborted",
134
+ }
135
+ return mapping.get(normalized)
136
+
137
+
117
138
  async def parse_google_stream(
118
139
  stream: AsyncIterator[Any],
119
140
  param: llm_param.LLMCallParameter,
120
141
  metadata_tracker: MetadataTracker,
121
- ) -> AsyncGenerator[model.ConversationItem]:
142
+ ) -> AsyncGenerator[message.LLMStreamItem]:
122
143
  response_id: str | None = None
123
- started = False
144
+ stage: Literal["waiting", "thinking", "assistant", "tool"] = "waiting"
124
145
 
125
146
  accumulated_text: list[str] = []
126
147
  accumulated_thoughts: list[str] = []
127
148
  thought_signature: str | None = None
149
+ assistant_parts: list[message.Part] = []
128
150
 
129
151
  # Track tool calls where args arrive as partial updates.
130
152
  partial_args_by_call: dict[str, dict[str, Any]] = {}
131
153
  started_tool_calls: dict[str, str] = {} # call_id -> name
132
154
  started_tool_items: set[str] = set()
133
- emitted_tool_items: set[str] = set()
155
+ completed_tool_items: set[str] = set()
134
156
 
135
157
  last_usage_metadata: UsageMetadata | None = None
158
+ stop_reason: model.StopReason | None = None
159
+
160
+ def flush_thinking() -> None:
161
+ nonlocal thought_signature
162
+ if accumulated_thoughts:
163
+ assistant_parts.append(
164
+ message.ThinkingTextPart(
165
+ text="".join(accumulated_thoughts),
166
+ model_id=str(param.model),
167
+ )
168
+ )
169
+ accumulated_thoughts.clear()
170
+ if thought_signature:
171
+ assistant_parts.append(
172
+ message.ThinkingSignaturePart(
173
+ signature=thought_signature,
174
+ model_id=str(param.model),
175
+ format="google_thought_signature",
176
+ )
177
+ )
178
+ thought_signature = None
179
+
180
+ def flush_text() -> None:
181
+ if not accumulated_text:
182
+ return
183
+ assistant_parts.append(message.TextPart(text="".join(accumulated_text)))
184
+ accumulated_text.clear()
136
185
 
137
186
  async for chunk in stream:
138
187
  log_debug(
@@ -143,33 +192,44 @@ async def parse_google_stream(
143
192
 
144
193
  if response_id is None:
145
194
  response_id = getattr(chunk, "response_id", None) or uuid4().hex
146
- assert response_id is not None
147
- if not started:
148
- started = True
149
- yield model.StartItem(response_id=response_id)
150
195
 
151
196
  if getattr(chunk, "usage_metadata", None) is not None:
152
197
  last_usage_metadata = chunk.usage_metadata
153
198
 
154
199
  candidates = getattr(chunk, "candidates", None) or []
155
200
  candidate0 = candidates[0] if candidates else None
201
+ finish_reason = getattr(candidate0, "finish_reason", None) if candidate0 else None
202
+ if finish_reason is not None:
203
+ if isinstance(finish_reason, str):
204
+ reason_value = finish_reason
205
+ else:
206
+ reason_value = getattr(finish_reason, "name", None) or str(finish_reason)
207
+ stop_reason = _map_finish_reason(reason_value)
156
208
  content = getattr(candidate0, "content", None) if candidate0 else None
157
- parts = getattr(content, "parts", None) if content else None
158
- if not parts:
209
+ content_parts = getattr(content, "parts", None) if content else None
210
+ if not content_parts:
159
211
  continue
160
212
 
161
- for part in parts:
213
+ for part in content_parts:
162
214
  if getattr(part, "text", None) is not None:
163
- metadata_tracker.record_token()
164
215
  text = part.text
216
+ if not text:
217
+ continue
218
+ metadata_tracker.record_token()
165
219
  if getattr(part, "thought", False) is True:
220
+ if stage == "assistant":
221
+ flush_text()
222
+ stage = "thinking"
166
223
  accumulated_thoughts.append(text)
167
224
  if getattr(part, "thought_signature", None):
168
225
  thought_signature = part.thought_signature
169
- yield model.ReasoningTextDelta(content=text, response_id=response_id)
226
+ yield message.ThinkingTextDelta(content=text, response_id=response_id)
170
227
  else:
228
+ if stage == "thinking":
229
+ flush_thinking()
230
+ stage = "assistant"
171
231
  accumulated_text.append(text)
172
- yield model.AssistantMessageDelta(content=text, response_id=response_id)
232
+ yield message.AssistantTextDelta(content=text, response_id=response_id)
173
233
 
174
234
  function_call = getattr(part, "function_call", None)
175
235
  if function_call is None:
@@ -182,17 +242,23 @@ async def parse_google_stream(
182
242
 
183
243
  if call_id not in started_tool_items:
184
244
  started_tool_items.add(call_id)
185
- yield model.ToolCallStartItem(response_id=response_id, call_id=call_id, name=name)
245
+ yield message.ToolCallStartItem(response_id=response_id, call_id=call_id, name=name)
186
246
 
187
247
  args_obj = getattr(function_call, "args", None)
188
248
  if args_obj is not None:
189
- emitted_tool_items.add(call_id)
190
- yield model.ToolCallItem(
191
- response_id=response_id,
192
- call_id=call_id,
193
- name=name,
194
- arguments=json.dumps(args_obj, ensure_ascii=False),
249
+ if stage == "thinking":
250
+ flush_thinking()
251
+ if stage == "assistant":
252
+ flush_text()
253
+ stage = "tool"
254
+ assistant_parts.append(
255
+ message.ToolCallPart(
256
+ call_id=call_id,
257
+ tool_name=name,
258
+ arguments_json=json.dumps(args_obj, ensure_ascii=False),
259
+ )
195
260
  )
261
+ completed_tool_items.add(call_id)
196
262
  continue
197
263
 
198
264
  partial_args = getattr(function_call, "partial_args", None)
@@ -201,53 +267,49 @@ async def parse_google_stream(
201
267
  _merge_partial_args(acc, partial_args)
202
268
 
203
269
  will_continue = getattr(function_call, "will_continue", None)
204
- if will_continue is False and call_id in partial_args_by_call and call_id not in emitted_tool_items:
205
- emitted_tool_items.add(call_id)
206
- yield model.ToolCallItem(
207
- response_id=response_id,
208
- call_id=call_id,
209
- name=name,
210
- arguments=json.dumps(partial_args_by_call[call_id], ensure_ascii=False),
270
+ if will_continue is False and call_id in partial_args_by_call and call_id not in completed_tool_items:
271
+ if stage == "thinking":
272
+ flush_thinking()
273
+ if stage == "assistant":
274
+ flush_text()
275
+ stage = "tool"
276
+ assistant_parts.append(
277
+ message.ToolCallPart(
278
+ call_id=call_id,
279
+ tool_name=name,
280
+ arguments_json=json.dumps(partial_args_by_call[call_id], ensure_ascii=False),
281
+ )
211
282
  )
283
+ completed_tool_items.add(call_id)
212
284
 
213
285
  # Flush any pending tool calls that never produced args.
214
286
  for call_id, name in started_tool_calls.items():
215
- if call_id in emitted_tool_items:
287
+ if call_id in completed_tool_items:
216
288
  continue
217
289
  args = partial_args_by_call.get(call_id, {})
218
- emitted_tool_items.add(call_id)
219
- yield model.ToolCallItem(
220
- response_id=response_id,
221
- call_id=call_id,
222
- name=name,
223
- arguments=json.dumps(args, ensure_ascii=False),
224
- )
225
-
226
- if accumulated_thoughts:
227
- metadata_tracker.record_token()
228
- yield model.ReasoningTextItem(
229
- content="".join(accumulated_thoughts),
230
- response_id=response_id,
231
- model=str(param.model),
232
- )
233
- if thought_signature:
234
- yield model.ReasoningEncryptedItem(
235
- encrypted_content=thought_signature,
236
- response_id=response_id,
237
- model=str(param.model),
238
- format="google_thought_signature",
290
+ assistant_parts.append(
291
+ message.ToolCallPart(
292
+ call_id=call_id,
293
+ tool_name=name,
294
+ arguments_json=json.dumps(args, ensure_ascii=False),
239
295
  )
296
+ )
240
297
 
241
- if accumulated_text:
242
- metadata_tracker.record_token()
243
- yield model.AssistantMessageItem(content="".join(accumulated_text), response_id=response_id)
298
+ flush_thinking()
299
+ flush_text()
244
300
 
245
301
  usage = _usage_from_metadata(last_usage_metadata, context_limit=param.context_limit, max_tokens=param.max_tokens)
246
302
  if usage is not None:
247
303
  metadata_tracker.set_usage(usage)
248
304
  metadata_tracker.set_model_name(str(param.model))
249
305
  metadata_tracker.set_response_id(response_id)
250
- yield metadata_tracker.finalize()
306
+ metadata = metadata_tracker.finalize()
307
+ yield message.AssistantMessage(
308
+ parts=assistant_parts,
309
+ response_id=response_id,
310
+ usage=metadata,
311
+ stop_reason=stop_reason,
312
+ )
251
313
 
252
314
 
253
315
  @register(llm_param.LLMClientProtocol.GOOGLE)
@@ -270,7 +332,7 @@ class GoogleClient(LLMClientABC):
270
332
  return cls(config)
271
333
 
272
334
  @override
273
- async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[model.ConversationItem]:
335
+ async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[message.LLMStreamItem]:
274
336
  param = apply_config_defaults(param, self.get_llm_config())
275
337
  metadata_tracker = MetadataTracker(cost_config=self.get_llm_config().cost)
276
338
 
@@ -297,13 +359,13 @@ class GoogleClient(LLMClientABC):
297
359
  config=config,
298
360
  )
299
361
  except (APIError, ClientError, ServerError, httpx.HTTPError) as e:
300
- yield model.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
301
- yield metadata_tracker.finalize()
362
+ yield message.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
363
+ yield message.AssistantMessage(parts=[], response_id=None, usage=metadata_tracker.finalize())
302
364
  return
303
365
 
304
366
  try:
305
367
  async for item in parse_google_stream(stream, param=param, metadata_tracker=metadata_tracker):
306
368
  yield item
307
369
  except (APIError, ClientError, ServerError, httpx.HTTPError) as e:
308
- yield model.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
309
- yield metadata_tracker.finalize()
370
+ yield message.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
371
+ yield message.AssistantMessage(parts=[], response_id=None, usage=metadata_tracker.finalize())
@@ -4,89 +4,80 @@
4
4
  # pyright: reportAttributeAccessIssue=false
5
5
 
6
6
  import json
7
- from base64 import b64decode
8
- from binascii import Error as BinasciiError
9
7
  from typing import Any
10
8
 
11
9
  from google.genai import types
12
10
 
13
- from klaude_code.llm.input_common import AssistantGroup, ToolGroup, UserGroup, merge_reminder_text, parse_message_groups
14
- from klaude_code.protocol import llm_param, model
11
+ from klaude_code.const import EMPTY_TOOL_OUTPUT_MESSAGE
12
+ from klaude_code.llm.image import parse_data_url
13
+ from klaude_code.llm.input_common import (
14
+ DeveloperAttachment,
15
+ attach_developer_messages,
16
+ merge_reminder_text,
17
+ split_thinking_parts,
18
+ )
19
+ from klaude_code.protocol import llm_param, message
15
20
 
16
21
 
17
22
  def _data_url_to_blob(url: str) -> types.Blob:
18
- header_and_media = url.split(",", 1)
19
- if len(header_and_media) != 2:
20
- raise ValueError("Invalid data URL for image: missing comma separator")
21
- header, base64_data = header_and_media
22
- if not header.startswith("data:"):
23
- raise ValueError("Invalid data URL for image: missing data: prefix")
24
- if ";base64" not in header:
25
- raise ValueError("Invalid data URL for image: missing base64 marker")
26
-
27
- media_type = header[5:].split(";", 1)[0]
28
- base64_payload = base64_data.strip()
29
- if base64_payload == "":
30
- raise ValueError("Inline image data is empty")
31
-
32
- try:
33
- decoded = b64decode(base64_payload, validate=True)
34
- except (BinasciiError, ValueError) as exc:
35
- raise ValueError("Inline image data is not valid base64") from exc
36
-
23
+ media_type, _, decoded = parse_data_url(url)
37
24
  return types.Blob(data=decoded, mime_type=media_type)
38
25
 
39
26
 
40
- def _image_part_to_part(image: model.ImageURLPart) -> types.Part:
41
- url = image.image_url.url
27
+ def _image_part_to_part(image: message.ImageURLPart) -> types.Part:
28
+ url = image.url
42
29
  if url.startswith("data:"):
43
30
  return types.Part(inline_data=_data_url_to_blob(url))
44
31
  # Best-effort: Gemini supports file URIs, and may accept public HTTPS URLs.
45
32
  return types.Part(file_data=types.FileData(file_uri=url))
46
33
 
47
34
 
48
- def _user_group_to_content(group: UserGroup) -> types.Content:
35
+ def _user_message_to_content(msg: message.UserMessage, attachment: DeveloperAttachment) -> types.Content:
49
36
  parts: list[types.Part] = []
50
- for text in group.text_parts:
51
- parts.append(types.Part(text=text + "\n"))
52
- for image in group.images:
37
+ for part in msg.parts:
38
+ if isinstance(part, message.TextPart):
39
+ parts.append(types.Part(text=part.text))
40
+ elif isinstance(part, message.ImageURLPart):
41
+ parts.append(_image_part_to_part(part))
42
+ if attachment.text:
43
+ parts.append(types.Part(text=attachment.text))
44
+ for image in attachment.images:
53
45
  parts.append(_image_part_to_part(image))
54
46
  if not parts:
55
47
  parts.append(types.Part(text=""))
56
48
  return types.Content(role="user", parts=parts)
57
49
 
58
50
 
59
- def _tool_groups_to_content(groups: list[ToolGroup], model_name: str | None) -> list[types.Content]:
51
+ def _tool_messages_to_contents(
52
+ msgs: list[tuple[message.ToolResultMessage, DeveloperAttachment]], model_name: str | None
53
+ ) -> list[types.Content]:
60
54
  supports_multimodal_function_response = bool(model_name and "gemini-3" in model_name.lower())
61
55
 
62
56
  response_parts: list[types.Part] = []
63
57
  extra_image_contents: list[types.Content] = []
64
58
 
65
- for group in groups:
59
+ for msg, attachment in msgs:
66
60
  merged_text = merge_reminder_text(
67
- group.tool_result.output or "<system-reminder>Tool ran without output or errors</system-reminder>",
68
- group.reminder_texts,
61
+ msg.output_text or EMPTY_TOOL_OUTPUT_MESSAGE,
62
+ attachment.text,
69
63
  )
70
64
  has_text = merged_text.strip() != ""
71
65
 
72
- images = list(group.tool_result.images or []) + list(group.reminder_images)
66
+ images = [part for part in msg.parts if isinstance(part, message.ImageURLPart)] + attachment.images
73
67
  image_parts: list[types.Part] = []
74
68
  for image in images:
75
69
  try:
76
70
  image_parts.append(_image_part_to_part(image))
77
71
  except ValueError:
78
- # Skip invalid data URLs
79
72
  continue
80
73
 
81
74
  has_images = len(image_parts) > 0
82
75
  response_value = merged_text if has_text else "(see attached image)" if has_images else ""
83
- response_payload = (
84
- {"error": response_value} if group.tool_result.status == "error" else {"output": response_value}
85
- )
76
+ response_payload = {"error": response_value} if msg.status != "success" else {"output": response_value}
86
77
 
87
78
  function_response = types.FunctionResponse(
88
- id=group.tool_result.call_id,
89
- name=group.tool_result.tool_name or "",
79
+ id=msg.call_id,
80
+ name=msg.tool_name,
90
81
  response=response_payload,
91
82
  parts=image_parts if (has_images and supports_multimodal_function_response) else None,
92
83
  )
@@ -104,100 +95,95 @@ def _tool_groups_to_content(groups: list[ToolGroup], model_name: str | None) ->
104
95
  return contents
105
96
 
106
97
 
107
- def _assistant_group_to_content(group: AssistantGroup, model_name: str | None) -> types.Content | None:
98
+ def _assistant_message_to_content(msg: message.AssistantMessage, model_name: str | None) -> types.Content | None:
108
99
  parts: list[types.Part] = []
109
-
110
- degraded_thinking_texts: list[str] = []
100
+ native_thinking_parts, degraded_thinking_texts = split_thinking_parts(msg, model_name)
101
+ native_thinking_ids = {id(part) for part in native_thinking_parts}
111
102
  pending_thought_text: str | None = None
112
103
  pending_thought_signature: str | None = None
113
104
 
114
- for item in group.reasoning_items:
115
- match item:
116
- case model.ReasoningTextItem():
117
- if not item.content:
118
- continue
119
- if model_name is not None and item.model is not None and item.model != model_name:
120
- degraded_thinking_texts.append(item.content)
121
- else:
122
- pending_thought_text = item.content
123
- case model.ReasoningEncryptedItem():
124
- if not (
125
- model_name is not None
126
- and item.model == model_name
127
- and item.encrypted_content
128
- and (item.format or "").startswith("google")
129
- and pending_thought_text
130
- ):
131
- continue
132
- pending_thought_signature = item.encrypted_content
133
- parts.append(
134
- types.Part(
135
- text=pending_thought_text,
136
- thought=True,
137
- thought_signature=pending_thought_signature,
138
- )
139
- )
140
- pending_thought_text = None
141
- pending_thought_signature = None
142
-
143
- if pending_thought_text:
105
+ def flush_thought() -> None:
106
+ nonlocal pending_thought_text, pending_thought_signature
107
+ if pending_thought_text is None and pending_thought_signature is None:
108
+ return
144
109
  parts.append(
145
110
  types.Part(
146
- text=pending_thought_text,
111
+ text=pending_thought_text or "",
147
112
  thought=True,
148
113
  thought_signature=pending_thought_signature,
149
114
  )
150
115
  )
116
+ pending_thought_text = None
117
+ pending_thought_signature = 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
+ pending_thought_text = 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 and (part.format or "").startswith("google"):
129
+ pending_thought_signature = part.signature
130
+ continue
131
+
132
+ flush_thought()
133
+ if isinstance(part, message.TextPart):
134
+ parts.append(types.Part(text=part.text))
135
+ elif isinstance(part, message.ToolCallPart):
136
+ args: dict[str, Any]
137
+ if part.arguments_json:
138
+ try:
139
+ args = json.loads(part.arguments_json)
140
+ except json.JSONDecodeError:
141
+ args = {"_raw": part.arguments_json}
142
+ else:
143
+ args = {}
144
+ parts.append(types.Part(function_call=types.FunctionCall(id=part.call_id, name=part.tool_name, args=args)))
145
+
146
+ flush_thought()
151
147
 
152
148
  if degraded_thinking_texts:
153
149
  parts.insert(0, types.Part(text="<thinking>\n" + "\n".join(degraded_thinking_texts) + "\n</thinking>"))
154
150
 
155
- if group.text_content:
156
- parts.append(types.Part(text=group.text_content))
157
-
158
- for tc in group.tool_calls:
159
- args: dict[str, Any]
160
- if tc.arguments:
161
- try:
162
- args = json.loads(tc.arguments)
163
- except json.JSONDecodeError:
164
- args = {"_raw": tc.arguments}
165
- else:
166
- args = {}
167
- parts.append(types.Part(function_call=types.FunctionCall(id=tc.call_id, name=tc.name, args=args)))
168
-
169
151
  if not parts:
170
152
  return None
171
153
  return types.Content(role="model", parts=parts)
172
154
 
173
155
 
174
156
  def convert_history_to_contents(
175
- history: list[model.ConversationItem],
157
+ history: list[message.Message],
176
158
  model_name: str | None,
177
159
  ) -> list[types.Content]:
178
160
  contents: list[types.Content] = []
179
- pending_tool_groups: list[ToolGroup] = []
180
-
181
- def flush_tool_groups() -> None:
182
- nonlocal pending_tool_groups
183
- if pending_tool_groups:
184
- contents.extend(_tool_groups_to_content(pending_tool_groups, model_name=model_name))
185
- pending_tool_groups = []
186
-
187
- for group in parse_message_groups(history):
188
- match group:
189
- case UserGroup():
190
- flush_tool_groups()
191
- contents.append(_user_group_to_content(group))
192
- case ToolGroup():
193
- pending_tool_groups.append(group)
194
- case AssistantGroup():
195
- flush_tool_groups()
196
- content = _assistant_group_to_content(group, model_name=model_name)
161
+ pending_tool_messages: list[tuple[message.ToolResultMessage, DeveloperAttachment]] = []
162
+
163
+ def flush_tool_messages() -> None:
164
+ nonlocal pending_tool_messages
165
+ if pending_tool_messages:
166
+ contents.extend(_tool_messages_to_contents(pending_tool_messages, model_name=model_name))
167
+ pending_tool_messages = []
168
+
169
+ for msg, attachment in attach_developer_messages(history):
170
+ match msg:
171
+ case message.ToolResultMessage():
172
+ pending_tool_messages.append((msg, attachment))
173
+ case message.UserMessage():
174
+ flush_tool_messages()
175
+ contents.append(_user_message_to_content(msg, attachment))
176
+ case message.AssistantMessage():
177
+ flush_tool_messages()
178
+ content = _assistant_message_to_content(msg, model_name=model_name)
197
179
  if content is not None:
198
180
  contents.append(content)
181
+ case message.SystemMessage():
182
+ continue
183
+ case _:
184
+ continue
199
185
 
200
- flush_tool_groups()
186
+ flush_tool_messages()
201
187
  return contents
202
188
 
203
189