klaude-code 1.8.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 (142) hide show
  1. klaude_code/auth/base.py +97 -0
  2. klaude_code/auth/claude/__init__.py +6 -0
  3. klaude_code/auth/claude/exceptions.py +9 -0
  4. klaude_code/auth/claude/oauth.py +172 -0
  5. klaude_code/auth/claude/token_manager.py +26 -0
  6. klaude_code/auth/codex/token_manager.py +10 -50
  7. klaude_code/cli/auth_cmd.py +127 -46
  8. klaude_code/cli/config_cmd.py +4 -2
  9. klaude_code/cli/cost_cmd.py +14 -9
  10. klaude_code/cli/list_model.py +248 -200
  11. klaude_code/cli/main.py +1 -1
  12. klaude_code/cli/runtime.py +7 -5
  13. klaude_code/cli/self_update.py +1 -1
  14. klaude_code/cli/session_cmd.py +1 -1
  15. klaude_code/command/clear_cmd.py +6 -2
  16. klaude_code/command/command_abc.py +2 -2
  17. klaude_code/command/debug_cmd.py +4 -4
  18. klaude_code/command/export_cmd.py +2 -2
  19. klaude_code/command/export_online_cmd.py +12 -12
  20. klaude_code/command/fork_session_cmd.py +29 -23
  21. klaude_code/command/help_cmd.py +4 -4
  22. klaude_code/command/model_cmd.py +4 -4
  23. klaude_code/command/model_select.py +1 -1
  24. klaude_code/command/prompt-commit.md +82 -0
  25. klaude_code/command/prompt_command.py +3 -3
  26. klaude_code/command/refresh_cmd.py +2 -2
  27. klaude_code/command/registry.py +7 -5
  28. klaude_code/command/release_notes_cmd.py +4 -4
  29. klaude_code/command/resume_cmd.py +15 -11
  30. klaude_code/command/status_cmd.py +4 -4
  31. klaude_code/command/terminal_setup_cmd.py +8 -8
  32. klaude_code/command/thinking_cmd.py +4 -4
  33. klaude_code/config/assets/builtin_config.yaml +52 -3
  34. klaude_code/config/builtin_config.py +16 -5
  35. klaude_code/config/config.py +31 -7
  36. klaude_code/config/thinking.py +4 -4
  37. klaude_code/const.py +146 -91
  38. klaude_code/core/agent.py +3 -12
  39. klaude_code/core/executor.py +21 -13
  40. klaude_code/core/manager/sub_agent_manager.py +71 -7
  41. klaude_code/core/prompt.py +1 -1
  42. klaude_code/core/prompts/prompt-sub-agent-image-gen.md +1 -0
  43. klaude_code/core/prompts/prompt-sub-agent-web.md +27 -1
  44. klaude_code/core/reminders.py +88 -69
  45. klaude_code/core/task.py +44 -45
  46. klaude_code/core/tool/file/apply_patch_tool.py +9 -9
  47. klaude_code/core/tool/file/diff_builder.py +3 -5
  48. klaude_code/core/tool/file/edit_tool.py +23 -23
  49. klaude_code/core/tool/file/move_tool.py +43 -43
  50. klaude_code/core/tool/file/read_tool.py +44 -39
  51. klaude_code/core/tool/file/write_tool.py +14 -14
  52. klaude_code/core/tool/report_back_tool.py +4 -4
  53. klaude_code/core/tool/shell/bash_tool.py +23 -23
  54. klaude_code/core/tool/skill/skill_tool.py +7 -7
  55. klaude_code/core/tool/sub_agent_tool.py +38 -9
  56. klaude_code/core/tool/todo/todo_write_tool.py +8 -8
  57. klaude_code/core/tool/todo/update_plan_tool.py +6 -6
  58. klaude_code/core/tool/tool_abc.py +2 -2
  59. klaude_code/core/tool/tool_context.py +27 -0
  60. klaude_code/core/tool/tool_runner.py +88 -42
  61. klaude_code/core/tool/truncation.py +38 -20
  62. klaude_code/core/tool/web/mermaid_tool.py +6 -7
  63. klaude_code/core/tool/web/web_fetch_tool.py +68 -30
  64. klaude_code/core/tool/web/web_search_tool.py +15 -17
  65. klaude_code/core/turn.py +120 -73
  66. klaude_code/llm/anthropic/client.py +104 -44
  67. klaude_code/llm/anthropic/input.py +116 -108
  68. klaude_code/llm/bedrock/client.py +8 -5
  69. klaude_code/llm/claude/__init__.py +3 -0
  70. klaude_code/llm/claude/client.py +105 -0
  71. klaude_code/llm/client.py +4 -3
  72. klaude_code/llm/codex/client.py +16 -10
  73. klaude_code/llm/google/client.py +122 -60
  74. klaude_code/llm/google/input.py +94 -108
  75. klaude_code/llm/image.py +123 -0
  76. klaude_code/llm/input_common.py +136 -189
  77. klaude_code/llm/openai_compatible/client.py +17 -7
  78. klaude_code/llm/openai_compatible/input.py +36 -66
  79. klaude_code/llm/openai_compatible/stream.py +119 -67
  80. klaude_code/llm/openai_compatible/tool_call_accumulator.py +23 -11
  81. klaude_code/llm/openrouter/client.py +34 -9
  82. klaude_code/llm/openrouter/input.py +63 -64
  83. klaude_code/llm/openrouter/reasoning.py +22 -24
  84. klaude_code/llm/registry.py +20 -15
  85. klaude_code/llm/responses/client.py +107 -45
  86. klaude_code/llm/responses/input.py +115 -98
  87. klaude_code/llm/usage.py +52 -25
  88. klaude_code/protocol/__init__.py +1 -0
  89. klaude_code/protocol/events.py +16 -12
  90. klaude_code/protocol/llm_param.py +22 -3
  91. klaude_code/protocol/message.py +250 -0
  92. klaude_code/protocol/model.py +94 -281
  93. klaude_code/protocol/op.py +2 -2
  94. klaude_code/protocol/sub_agent/__init__.py +2 -2
  95. klaude_code/protocol/sub_agent/explore.py +10 -0
  96. klaude_code/protocol/sub_agent/image_gen.py +119 -0
  97. klaude_code/protocol/sub_agent/task.py +10 -0
  98. klaude_code/protocol/sub_agent/web.py +10 -0
  99. klaude_code/session/codec.py +6 -6
  100. klaude_code/session/export.py +261 -62
  101. klaude_code/session/selector.py +7 -24
  102. klaude_code/session/session.py +125 -53
  103. klaude_code/session/store.py +5 -32
  104. klaude_code/session/templates/export_session.html +1 -1
  105. klaude_code/session/templates/mermaid_viewer.html +1 -1
  106. klaude_code/trace/log.py +11 -6
  107. klaude_code/ui/core/input.py +1 -1
  108. klaude_code/ui/core/stage_manager.py +1 -8
  109. klaude_code/ui/modes/debug/display.py +2 -2
  110. klaude_code/ui/modes/repl/clipboard.py +2 -2
  111. klaude_code/ui/modes/repl/completers.py +18 -10
  112. klaude_code/ui/modes/repl/event_handler.py +136 -127
  113. klaude_code/ui/modes/repl/input_prompt_toolkit.py +1 -1
  114. klaude_code/ui/modes/repl/key_bindings.py +1 -1
  115. klaude_code/ui/modes/repl/renderer.py +107 -15
  116. klaude_code/ui/renderers/assistant.py +2 -2
  117. klaude_code/ui/renderers/common.py +65 -7
  118. klaude_code/ui/renderers/developer.py +7 -6
  119. klaude_code/ui/renderers/diffs.py +11 -11
  120. klaude_code/ui/renderers/mermaid_viewer.py +49 -2
  121. klaude_code/ui/renderers/metadata.py +39 -31
  122. klaude_code/ui/renderers/sub_agent.py +57 -16
  123. klaude_code/ui/renderers/thinking.py +37 -2
  124. klaude_code/ui/renderers/tools.py +180 -165
  125. klaude_code/ui/rich/live.py +3 -1
  126. klaude_code/ui/rich/markdown.py +39 -7
  127. klaude_code/ui/rich/quote.py +76 -1
  128. klaude_code/ui/rich/status.py +14 -8
  129. klaude_code/ui/rich/theme.py +13 -6
  130. klaude_code/ui/terminal/image.py +34 -0
  131. klaude_code/ui/terminal/notifier.py +2 -1
  132. klaude_code/ui/terminal/progress_bar.py +4 -4
  133. klaude_code/ui/terminal/selector.py +22 -4
  134. klaude_code/ui/utils/common.py +55 -0
  135. {klaude_code-1.8.0.dist-info → klaude_code-2.0.0.dist-info}/METADATA +28 -6
  136. klaude_code-2.0.0.dist-info/RECORD +229 -0
  137. klaude_code/command/prompt-jj-describe.md +0 -32
  138. klaude_code/core/prompts/prompt-sub-agent-oracle.md +0 -22
  139. klaude_code/protocol/sub_agent/oracle.py +0 -91
  140. klaude_code-1.8.0.dist-info/RECORD +0 -219
  141. {klaude_code-1.8.0.dist-info → klaude_code-2.0.0.dist-info}/WHEEL +0 -0
  142. {klaude_code-1.8.0.dist-info → klaude_code-2.0.0.dist-info}/entry_points.txt +0 -0
@@ -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
 
@@ -0,0 +1,123 @@
1
+ """Image processing utilities for LLM responses.
2
+
3
+ This module provides reusable image handling primitives that can be shared
4
+ across different LLM providers and protocols (OpenAI, Anthropic, etc.).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import mimetypes
11
+ import time
12
+ from base64 import b64decode, b64encode
13
+ from binascii import Error as BinasciiError
14
+ from pathlib import Path
15
+
16
+ from klaude_code.const import (
17
+ IMAGE_OUTPUT_MAX_BYTES,
18
+ TOOL_OUTPUT_TRUNCATION_DIR,
19
+ ProjectPaths,
20
+ project_key_from_cwd,
21
+ )
22
+ from klaude_code.protocol import message
23
+
24
+ IMAGE_EXT_BY_MIME: dict[str, str] = {
25
+ "image/png": ".png",
26
+ "image/jpeg": ".jpg",
27
+ "image/jpg": ".jpg",
28
+ "image/webp": ".webp",
29
+ "image/gif": ".gif",
30
+ }
31
+
32
+
33
+ def parse_data_url(url: str) -> tuple[str, str, bytes]:
34
+ """Parse a base64 data URL and return (mime_type, base64_payload, decoded_bytes)."""
35
+
36
+ header_and_media = url.split(",", 1)
37
+ if len(header_and_media) != 2:
38
+ raise ValueError("Invalid data URL for image: missing comma separator")
39
+ header, base64_data = header_and_media
40
+ if not header.startswith("data:"):
41
+ raise ValueError("Invalid data URL for image: missing data: prefix")
42
+ if ";base64" not in header:
43
+ raise ValueError("Invalid data URL for image: missing base64 marker")
44
+
45
+ mime_type = header[5:].split(";", 1)[0]
46
+ base64_payload = base64_data.strip()
47
+ if base64_payload == "":
48
+ raise ValueError("Inline image data is empty")
49
+
50
+ try:
51
+ decoded = b64decode(base64_payload, validate=True)
52
+ except (BinasciiError, ValueError) as exc:
53
+ raise ValueError("Inline image data is not valid base64") from exc
54
+
55
+ return mime_type, base64_payload, decoded
56
+
57
+
58
+ def parse_data_url_image(url: str) -> tuple[str, bytes]:
59
+ """Parse a base64 data URL and return (mime_type, decoded_bytes)."""
60
+
61
+ mime_type, _, decoded = parse_data_url(url)
62
+ return mime_type, decoded
63
+
64
+
65
+ def get_assistant_image_output_dir(session_id: str | None) -> Path:
66
+ """Get the output directory for assistant-generated images."""
67
+ if session_id:
68
+ paths = ProjectPaths(project_key=project_key_from_cwd())
69
+ return paths.images_dir(session_id)
70
+ return Path(TOOL_OUTPUT_TRUNCATION_DIR) / "images"
71
+
72
+
73
+ def save_assistant_image(
74
+ *, data_url: str, session_id: str | None, response_id: str | None, image_index: int
75
+ ) -> message.ImageFilePart:
76
+ """Decode a data URL image and save it to the session image artifacts directory."""
77
+
78
+ mime_type, decoded = parse_data_url_image(data_url)
79
+
80
+ if len(decoded) > IMAGE_OUTPUT_MAX_BYTES:
81
+ decoded_mb = len(decoded) / (1024 * 1024)
82
+ limit_mb = IMAGE_OUTPUT_MAX_BYTES / (1024 * 1024)
83
+ raise ValueError(f"Image output size ({decoded_mb:.2f}MB) exceeds limit ({limit_mb:.2f}MB)")
84
+
85
+ output_dir = get_assistant_image_output_dir(session_id)
86
+ output_dir.mkdir(parents=True, exist_ok=True)
87
+
88
+ ext = IMAGE_EXT_BY_MIME.get(mime_type, ".bin")
89
+ response_part = (response_id or "unknown").replace("/", "_")
90
+ ts = time.time_ns()
91
+ file_path = output_dir / f"img-{response_part}-{image_index}-{ts}{ext}"
92
+ file_path.write_bytes(decoded)
93
+
94
+ return message.ImageFilePart(
95
+ file_path=str(file_path),
96
+ mime_type=mime_type,
97
+ byte_size=len(decoded),
98
+ sha256=hashlib.sha256(decoded).hexdigest(),
99
+ )
100
+
101
+
102
+ def assistant_image_to_data_url(image: message.ImageFilePart) -> str:
103
+ """Load an assistant image from disk and encode it as a base64 data URL.
104
+
105
+ This is primarily used for multi-turn image editing, where providers require
106
+ sending the previous assistant message (including images) back to the model.
107
+ """
108
+
109
+ file_path = Path(image.file_path)
110
+ decoded = file_path.read_bytes()
111
+
112
+ if len(decoded) > IMAGE_OUTPUT_MAX_BYTES:
113
+ decoded_mb = len(decoded) / (1024 * 1024)
114
+ limit_mb = IMAGE_OUTPUT_MAX_BYTES / (1024 * 1024)
115
+ raise ValueError(f"Assistant image size ({decoded_mb:.2f}MB) exceeds limit ({limit_mb:.2f}MB)")
116
+
117
+ mime_type = image.mime_type
118
+ if not mime_type:
119
+ guessed, _ = mimetypes.guess_type(str(file_path))
120
+ mime_type = guessed or "application/octet-stream"
121
+
122
+ encoded = b64encode(decoded).decode("ascii")
123
+ return f"data:{mime_type};base64,{encoded}"