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.
- klaude_code/auth/base.py +97 -0
- klaude_code/auth/claude/__init__.py +6 -0
- klaude_code/auth/claude/exceptions.py +9 -0
- klaude_code/auth/claude/oauth.py +172 -0
- klaude_code/auth/claude/token_manager.py +26 -0
- klaude_code/auth/codex/token_manager.py +10 -50
- klaude_code/cli/auth_cmd.py +127 -46
- klaude_code/cli/config_cmd.py +4 -2
- klaude_code/cli/cost_cmd.py +14 -9
- klaude_code/cli/list_model.py +248 -200
- klaude_code/cli/main.py +1 -1
- klaude_code/cli/runtime.py +7 -5
- klaude_code/cli/self_update.py +1 -1
- klaude_code/cli/session_cmd.py +1 -1
- klaude_code/command/clear_cmd.py +6 -2
- klaude_code/command/command_abc.py +2 -2
- klaude_code/command/debug_cmd.py +4 -4
- klaude_code/command/export_cmd.py +2 -2
- klaude_code/command/export_online_cmd.py +12 -12
- klaude_code/command/fork_session_cmd.py +29 -23
- klaude_code/command/help_cmd.py +4 -4
- klaude_code/command/model_cmd.py +4 -4
- klaude_code/command/model_select.py +1 -1
- klaude_code/command/prompt-commit.md +82 -0
- klaude_code/command/prompt_command.py +3 -3
- klaude_code/command/refresh_cmd.py +2 -2
- klaude_code/command/registry.py +7 -5
- klaude_code/command/release_notes_cmd.py +4 -4
- klaude_code/command/resume_cmd.py +15 -11
- klaude_code/command/status_cmd.py +4 -4
- klaude_code/command/terminal_setup_cmd.py +8 -8
- klaude_code/command/thinking_cmd.py +4 -4
- klaude_code/config/assets/builtin_config.yaml +52 -3
- klaude_code/config/builtin_config.py +16 -5
- klaude_code/config/config.py +31 -7
- klaude_code/config/thinking.py +4 -4
- klaude_code/const.py +146 -91
- klaude_code/core/agent.py +3 -12
- klaude_code/core/executor.py +21 -13
- klaude_code/core/manager/sub_agent_manager.py +71 -7
- klaude_code/core/prompt.py +1 -1
- klaude_code/core/prompts/prompt-sub-agent-image-gen.md +1 -0
- klaude_code/core/prompts/prompt-sub-agent-web.md +27 -1
- klaude_code/core/reminders.py +88 -69
- klaude_code/core/task.py +44 -45
- klaude_code/core/tool/file/apply_patch_tool.py +9 -9
- klaude_code/core/tool/file/diff_builder.py +3 -5
- klaude_code/core/tool/file/edit_tool.py +23 -23
- klaude_code/core/tool/file/move_tool.py +43 -43
- klaude_code/core/tool/file/read_tool.py +44 -39
- klaude_code/core/tool/file/write_tool.py +14 -14
- klaude_code/core/tool/report_back_tool.py +4 -4
- klaude_code/core/tool/shell/bash_tool.py +23 -23
- klaude_code/core/tool/skill/skill_tool.py +7 -7
- klaude_code/core/tool/sub_agent_tool.py +38 -9
- klaude_code/core/tool/todo/todo_write_tool.py +8 -8
- klaude_code/core/tool/todo/update_plan_tool.py +6 -6
- klaude_code/core/tool/tool_abc.py +2 -2
- klaude_code/core/tool/tool_context.py +27 -0
- klaude_code/core/tool/tool_runner.py +88 -42
- klaude_code/core/tool/truncation.py +38 -20
- klaude_code/core/tool/web/mermaid_tool.py +6 -7
- klaude_code/core/tool/web/web_fetch_tool.py +68 -30
- klaude_code/core/tool/web/web_search_tool.py +15 -17
- klaude_code/core/turn.py +120 -73
- klaude_code/llm/anthropic/client.py +104 -44
- klaude_code/llm/anthropic/input.py +116 -108
- klaude_code/llm/bedrock/client.py +8 -5
- klaude_code/llm/claude/__init__.py +3 -0
- klaude_code/llm/claude/client.py +105 -0
- klaude_code/llm/client.py +4 -3
- klaude_code/llm/codex/client.py +16 -10
- klaude_code/llm/google/client.py +122 -60
- klaude_code/llm/google/input.py +94 -108
- klaude_code/llm/image.py +123 -0
- klaude_code/llm/input_common.py +136 -189
- klaude_code/llm/openai_compatible/client.py +17 -7
- klaude_code/llm/openai_compatible/input.py +36 -66
- klaude_code/llm/openai_compatible/stream.py +119 -67
- klaude_code/llm/openai_compatible/tool_call_accumulator.py +23 -11
- klaude_code/llm/openrouter/client.py +34 -9
- klaude_code/llm/openrouter/input.py +63 -64
- klaude_code/llm/openrouter/reasoning.py +22 -24
- klaude_code/llm/registry.py +20 -15
- klaude_code/llm/responses/client.py +107 -45
- klaude_code/llm/responses/input.py +115 -98
- klaude_code/llm/usage.py +52 -25
- klaude_code/protocol/__init__.py +1 -0
- klaude_code/protocol/events.py +16 -12
- klaude_code/protocol/llm_param.py +22 -3
- klaude_code/protocol/message.py +250 -0
- klaude_code/protocol/model.py +94 -281
- klaude_code/protocol/op.py +2 -2
- klaude_code/protocol/sub_agent/__init__.py +2 -2
- klaude_code/protocol/sub_agent/explore.py +10 -0
- klaude_code/protocol/sub_agent/image_gen.py +119 -0
- klaude_code/protocol/sub_agent/task.py +10 -0
- klaude_code/protocol/sub_agent/web.py +10 -0
- klaude_code/session/codec.py +6 -6
- klaude_code/session/export.py +261 -62
- klaude_code/session/selector.py +7 -24
- klaude_code/session/session.py +125 -53
- klaude_code/session/store.py +5 -32
- klaude_code/session/templates/export_session.html +1 -1
- klaude_code/session/templates/mermaid_viewer.html +1 -1
- klaude_code/trace/log.py +11 -6
- klaude_code/ui/core/input.py +1 -1
- klaude_code/ui/core/stage_manager.py +1 -8
- klaude_code/ui/modes/debug/display.py +2 -2
- klaude_code/ui/modes/repl/clipboard.py +2 -2
- klaude_code/ui/modes/repl/completers.py +18 -10
- klaude_code/ui/modes/repl/event_handler.py +136 -127
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +1 -1
- klaude_code/ui/modes/repl/key_bindings.py +1 -1
- klaude_code/ui/modes/repl/renderer.py +107 -15
- klaude_code/ui/renderers/assistant.py +2 -2
- klaude_code/ui/renderers/common.py +65 -7
- klaude_code/ui/renderers/developer.py +7 -6
- klaude_code/ui/renderers/diffs.py +11 -11
- klaude_code/ui/renderers/mermaid_viewer.py +49 -2
- klaude_code/ui/renderers/metadata.py +39 -31
- klaude_code/ui/renderers/sub_agent.py +57 -16
- klaude_code/ui/renderers/thinking.py +37 -2
- klaude_code/ui/renderers/tools.py +180 -165
- klaude_code/ui/rich/live.py +3 -1
- klaude_code/ui/rich/markdown.py +39 -7
- klaude_code/ui/rich/quote.py +76 -1
- klaude_code/ui/rich/status.py +14 -8
- klaude_code/ui/rich/theme.py +13 -6
- klaude_code/ui/terminal/image.py +34 -0
- klaude_code/ui/terminal/notifier.py +2 -1
- klaude_code/ui/terminal/progress_bar.py +4 -4
- klaude_code/ui/terminal/selector.py +22 -4
- klaude_code/ui/utils/common.py +55 -0
- {klaude_code-1.8.0.dist-info → klaude_code-2.0.0.dist-info}/METADATA +28 -6
- klaude_code-2.0.0.dist-info/RECORD +229 -0
- klaude_code/command/prompt-jj-describe.md +0 -32
- klaude_code/core/prompts/prompt-sub-agent-oracle.md +0 -22
- klaude_code/protocol/sub_agent/oracle.py +0 -91
- klaude_code-1.8.0.dist-info/RECORD +0 -219
- {klaude_code-1.8.0.dist-info → klaude_code-2.0.0.dist-info}/WHEEL +0 -0
- {klaude_code-1.8.0.dist-info → klaude_code-2.0.0.dist-info}/entry_points.txt +0 -0
klaude_code/llm/google/input.py
CHANGED
|
@@ -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.
|
|
14
|
-
from klaude_code.
|
|
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
|
-
|
|
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:
|
|
41
|
-
url = image.
|
|
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
|
|
35
|
+
def _user_message_to_content(msg: message.UserMessage, attachment: DeveloperAttachment) -> types.Content:
|
|
49
36
|
parts: list[types.Part] = []
|
|
50
|
-
for
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
|
59
|
+
for msg, attachment in msgs:
|
|
66
60
|
merged_text = merge_reminder_text(
|
|
67
|
-
|
|
68
|
-
|
|
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 =
|
|
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=
|
|
89
|
-
name=
|
|
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
|
|
98
|
+
def _assistant_message_to_content(msg: message.AssistantMessage, model_name: str | None) -> types.Content | None:
|
|
108
99
|
parts: list[types.Part] = []
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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[
|
|
157
|
+
history: list[message.Message],
|
|
176
158
|
model_name: str | None,
|
|
177
159
|
) -> list[types.Content]:
|
|
178
160
|
contents: list[types.Content] = []
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
def
|
|
182
|
-
nonlocal
|
|
183
|
-
if
|
|
184
|
-
contents.extend(
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
for
|
|
188
|
-
match
|
|
189
|
-
case
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
case
|
|
195
|
-
|
|
196
|
-
content =
|
|
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
|
-
|
|
186
|
+
flush_tool_messages()
|
|
201
187
|
return contents
|
|
202
188
|
|
|
203
189
|
|
klaude_code/llm/image.py
ADDED
|
@@ -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}"
|