klaude-code 2.8.1__py3-none-any.whl → 2.9.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 (93) hide show
  1. klaude_code/app/runtime.py +2 -1
  2. klaude_code/auth/antigravity/oauth.py +0 -9
  3. klaude_code/auth/antigravity/token_manager.py +0 -18
  4. klaude_code/auth/base.py +53 -0
  5. klaude_code/auth/codex/exceptions.py +0 -4
  6. klaude_code/auth/codex/oauth.py +32 -28
  7. klaude_code/auth/codex/token_manager.py +0 -18
  8. klaude_code/cli/cost_cmd.py +128 -39
  9. klaude_code/cli/list_model.py +27 -10
  10. klaude_code/cli/main.py +14 -3
  11. klaude_code/config/assets/builtin_config.yaml +8 -24
  12. klaude_code/config/config.py +47 -25
  13. klaude_code/config/sub_agent_model_helper.py +18 -13
  14. klaude_code/config/thinking.py +0 -8
  15. klaude_code/const.py +1 -1
  16. klaude_code/core/agent_profile.py +10 -52
  17. klaude_code/core/compaction/overflow.py +0 -4
  18. klaude_code/core/executor.py +33 -5
  19. klaude_code/core/manager/llm_clients.py +9 -1
  20. klaude_code/core/prompts/prompt-claude-code.md +4 -4
  21. klaude_code/core/reminders.py +21 -23
  22. klaude_code/core/task.py +0 -4
  23. klaude_code/core/tool/__init__.py +3 -2
  24. klaude_code/core/tool/file/apply_patch.py +0 -27
  25. klaude_code/core/tool/file/read_tool.md +3 -2
  26. klaude_code/core/tool/file/read_tool.py +15 -2
  27. klaude_code/core/tool/offload.py +0 -35
  28. klaude_code/core/tool/sub_agent/__init__.py +6 -0
  29. klaude_code/core/tool/sub_agent/image_gen.md +16 -0
  30. klaude_code/core/tool/sub_agent/image_gen.py +146 -0
  31. klaude_code/core/tool/sub_agent/task.md +20 -0
  32. klaude_code/core/tool/sub_agent/task.py +205 -0
  33. klaude_code/core/tool/tool_registry.py +0 -16
  34. klaude_code/core/turn.py +1 -1
  35. klaude_code/llm/anthropic/input.py +6 -5
  36. klaude_code/llm/antigravity/input.py +14 -7
  37. klaude_code/llm/codex/client.py +22 -0
  38. klaude_code/llm/codex/prompt_sync.py +237 -0
  39. klaude_code/llm/google/client.py +8 -6
  40. klaude_code/llm/google/input.py +20 -12
  41. klaude_code/llm/image.py +18 -11
  42. klaude_code/llm/input_common.py +14 -6
  43. klaude_code/llm/json_stable.py +37 -0
  44. klaude_code/llm/openai_compatible/input.py +0 -10
  45. klaude_code/llm/openai_compatible/stream.py +16 -1
  46. klaude_code/llm/registry.py +0 -5
  47. klaude_code/llm/responses/input.py +15 -5
  48. klaude_code/llm/usage.py +0 -8
  49. klaude_code/protocol/events.py +2 -1
  50. klaude_code/protocol/message.py +2 -2
  51. klaude_code/protocol/model.py +20 -1
  52. klaude_code/protocol/op.py +13 -0
  53. klaude_code/protocol/op_handler.py +5 -0
  54. klaude_code/protocol/sub_agent/AGENTS.md +5 -5
  55. klaude_code/protocol/sub_agent/__init__.py +13 -34
  56. klaude_code/protocol/sub_agent/explore.py +7 -34
  57. klaude_code/protocol/sub_agent/image_gen.py +3 -74
  58. klaude_code/protocol/sub_agent/task.py +3 -47
  59. klaude_code/protocol/sub_agent/web.py +8 -52
  60. klaude_code/protocol/tools.py +2 -0
  61. klaude_code/session/session.py +58 -21
  62. klaude_code/session/store.py +0 -4
  63. klaude_code/skill/assets/deslop/SKILL.md +9 -0
  64. klaude_code/skill/system_skills.py +0 -20
  65. klaude_code/tui/command/fork_session_cmd.py +5 -2
  66. klaude_code/tui/command/resume_cmd.py +9 -2
  67. klaude_code/tui/command/sub_agent_model_cmd.py +85 -18
  68. klaude_code/tui/components/assistant.py +0 -26
  69. klaude_code/tui/components/command_output.py +3 -1
  70. klaude_code/tui/components/developer.py +3 -0
  71. klaude_code/tui/components/diffs.py +2 -208
  72. klaude_code/tui/components/errors.py +4 -0
  73. klaude_code/tui/components/mermaid_viewer.py +2 -2
  74. klaude_code/tui/components/rich/markdown.py +0 -54
  75. klaude_code/tui/components/rich/theme.py +2 -0
  76. klaude_code/tui/components/sub_agent.py +2 -46
  77. klaude_code/tui/components/thinking.py +0 -33
  78. klaude_code/tui/components/tools.py +43 -21
  79. klaude_code/tui/input/images.py +21 -18
  80. klaude_code/tui/input/key_bindings.py +2 -2
  81. klaude_code/tui/input/prompt_toolkit.py +49 -49
  82. klaude_code/tui/machine.py +15 -11
  83. klaude_code/tui/renderer.py +11 -20
  84. klaude_code/tui/runner.py +2 -1
  85. klaude_code/tui/terminal/image.py +6 -34
  86. klaude_code/ui/common.py +0 -70
  87. {klaude_code-2.8.1.dist-info → klaude_code-2.9.0.dist-info}/METADATA +3 -6
  88. {klaude_code-2.8.1.dist-info → klaude_code-2.9.0.dist-info}/RECORD +90 -86
  89. klaude_code/core/tool/sub_agent_tool.py +0 -126
  90. klaude_code/llm/openai_compatible/tool_call_accumulator.py +0 -108
  91. klaude_code/tui/components/rich/searchable_text.py +0 -68
  92. {klaude_code-2.8.1.dist-info → klaude_code-2.9.0.dist-info}/WHEEL +0 -0
  93. {klaude_code-2.8.1.dist-info → klaude_code-2.9.0.dist-info}/entry_points.txt +0 -0
@@ -6,9 +6,10 @@ from binascii import Error as BinasciiError
6
6
  from typing import Any, TypedDict
7
7
 
8
8
  from klaude_code.const import EMPTY_TOOL_OUTPUT_MESSAGE
9
- from klaude_code.llm.image import assistant_image_to_data_url, parse_data_url
9
+ from klaude_code.llm.image import assistant_image_to_data_url, image_file_to_data_url, parse_data_url
10
10
  from klaude_code.llm.input_common import (
11
11
  DeveloperAttachment,
12
+ ImagePart,
12
13
  attach_developer_messages,
13
14
  merge_reminder_text,
14
15
  split_thinking_parts,
@@ -66,9 +67,9 @@ def _data_url_to_inline_data(url: str) -> InlineData:
66
67
  return InlineData(mimeType=media_type, data=base64.b64encode(decoded).decode("ascii"))
67
68
 
68
69
 
69
- def _image_part_to_part(image: message.ImageURLPart) -> Part:
70
- """Convert ImageURLPart to Part dict."""
71
- url = image.url
70
+ def _image_part_to_part(image: ImagePart) -> Part:
71
+ """Convert ImageURLPart or ImageFilePart to Part dict."""
72
+ url = image_file_to_data_url(image) if isinstance(image, message.ImageFilePart) else image.url
72
73
  if url.startswith("data:"):
73
74
  return Part(inlineData=_data_url_to_inline_data(url))
74
75
  # For non-data URLs, best-effort using inline_data format
@@ -81,7 +82,7 @@ def _user_message_to_content(msg: message.UserMessage, attachment: DeveloperAtta
81
82
  for part in msg.parts:
82
83
  if isinstance(part, message.TextPart):
83
84
  parts.append(Part(text=part.text))
84
- elif isinstance(part, message.ImageURLPart):
85
+ elif isinstance(part, (message.ImageURLPart, message.ImageFilePart)):
85
86
  parts.append(_image_part_to_part(part))
86
87
  if attachment.text:
87
88
  parts.append(Part(text=attachment.text))
@@ -108,14 +109,20 @@ def _tool_messages_to_contents(
108
109
  )
109
110
  has_text = merged_text.strip() != ""
110
111
 
111
- images = [part for part in msg.parts if isinstance(part, message.ImageURLPart)] + attachment.images
112
+ images: list[ImagePart] = [
113
+ part for part in msg.parts if isinstance(part, (message.ImageURLPart, message.ImageFilePart))
114
+ ]
115
+ images.extend(attachment.images)
112
116
  image_parts: list[Part] = []
113
117
  function_response_parts: list[dict[str, Any]] = []
114
118
 
115
119
  for image in images:
116
120
  try:
117
121
  image_parts.append(_image_part_to_part(image))
118
- if image.url.startswith("data:"):
122
+ if isinstance(image, message.ImageFilePart):
123
+ inline_data = _data_url_to_inline_data(image_file_to_data_url(image))
124
+ function_response_parts.append({"inlineData": inline_data})
125
+ elif image.url.startswith("data:"):
119
126
  inline_data = _data_url_to_inline_data(image.url)
120
127
  function_response_parts.append({"inlineData": inline_data})
121
128
  except ValueError:
@@ -146,6 +146,28 @@ class CodexClient(LLMClientABC):
146
146
  )
147
147
  except (openai.OpenAIError, httpx.HTTPError) as e:
148
148
  error_message = f"{e.__class__.__name__} {e!s}"
149
+
150
+ # Check for invalid instruction error and invalidate prompt cache
151
+ if _is_invalid_instruction_error(e) and param.model_id:
152
+ _invalidate_prompt_cache_for_model(param.model_id)
153
+
149
154
  return error_llm_stream(metadata_tracker, error=error_message)
150
155
 
151
156
  return ResponsesLLMStream(stream, param=param, metadata_tracker=metadata_tracker)
157
+
158
+
159
+ def _is_invalid_instruction_error(e: Exception) -> bool:
160
+ """Check if the error is related to invalid instructions."""
161
+ error_str = str(e).lower()
162
+ return "invalid instruction" in error_str or "invalid_instruction" in error_str
163
+
164
+
165
+ def _invalidate_prompt_cache_for_model(model_id: str) -> None:
166
+ """Invalidate the cached prompt for a model to force refresh."""
167
+ from klaude_code.llm.codex.prompt_sync import invalidate_cache
168
+
169
+ log_debug(
170
+ f"Invalidating prompt cache for model {model_id} due to invalid instruction error",
171
+ debug_type=DebugType.GENERAL,
172
+ )
173
+ invalidate_cache(model_id)
@@ -0,0 +1,237 @@
1
+ """Dynamic prompt synchronization from OpenAI Codex GitHub repository."""
2
+
3
+ import json
4
+ import time
5
+ from functools import cache
6
+ from importlib.resources import files
7
+ from pathlib import Path
8
+ from typing import Any, Literal
9
+
10
+ import httpx
11
+
12
+ from klaude_code.log import DebugType, log_debug
13
+
14
+ GITHUB_API_RELEASES = "https://api.github.com/repos/openai/codex/releases/latest"
15
+ GITHUB_HTML_RELEASES = "https://github.com/openai/codex/releases/latest"
16
+ GITHUB_RAW_BASE = "https://raw.githubusercontent.com/openai/codex"
17
+
18
+ CACHE_DIR = Path.home() / ".klaude" / "codex-prompts"
19
+ CACHE_TTL_SECONDS = 24 * 60 * 60 # 24 hours
20
+
21
+ type ModelFamily = Literal["gpt-5.2-codex", "codex-max", "codex", "gpt-5.2", "gpt-5.1"]
22
+
23
+ PROMPT_FILES: dict[ModelFamily, str] = {
24
+ "gpt-5.2-codex": "gpt-5.2-codex_prompt.md",
25
+ "codex-max": "gpt-5.1-codex-max_prompt.md",
26
+ "codex": "gpt_5_codex_prompt.md",
27
+ "gpt-5.2": "gpt_5_2_prompt.md",
28
+ "gpt-5.1": "gpt_5_1_prompt.md",
29
+ }
30
+
31
+ CACHE_FILES: dict[ModelFamily, str] = {
32
+ "gpt-5.2-codex": "gpt-5.2-codex-instructions.md",
33
+ "codex-max": "codex-max-instructions.md",
34
+ "codex": "codex-instructions.md",
35
+ "gpt-5.2": "gpt-5.2-instructions.md",
36
+ "gpt-5.1": "gpt-5.1-instructions.md",
37
+ }
38
+
39
+
40
+ @cache
41
+ def _load_bundled_prompt(prompt_path: str) -> str:
42
+ """Load bundled prompt from package resources."""
43
+ return files("klaude_code.core").joinpath(prompt_path).read_text(encoding="utf-8").strip()
44
+
45
+
46
+ class CacheMetadata:
47
+ def __init__(self, etag: str | None, tag: str, last_checked: int, url: str):
48
+ self.etag = etag
49
+ self.tag = tag
50
+ self.last_checked = last_checked
51
+ self.url = url
52
+
53
+ def to_dict(self) -> dict[str, str | int | None]:
54
+ return {
55
+ "etag": self.etag,
56
+ "tag": self.tag,
57
+ "last_checked": self.last_checked,
58
+ "url": self.url,
59
+ }
60
+
61
+ @classmethod
62
+ def from_dict(cls, data: dict[str, object]) -> "CacheMetadata":
63
+ etag = data.get("etag")
64
+ last_checked = data.get("last_checked")
65
+ return cls(
66
+ etag=etag if isinstance(etag, str) else None,
67
+ tag=str(data.get("tag", "")),
68
+ last_checked=int(last_checked) if isinstance(last_checked, int | float) else 0,
69
+ url=str(data.get("url", "")),
70
+ )
71
+
72
+
73
+ def get_model_family(model: str) -> ModelFamily:
74
+ """Determine model family from model name."""
75
+ if "gpt-5.2-codex" in model or "gpt 5.2 codex" in model:
76
+ return "gpt-5.2-codex"
77
+ if "codex-max" in model:
78
+ return "codex-max"
79
+ if "codex" in model or model.startswith("codex-"):
80
+ return "codex"
81
+ if "gpt-5.2" in model:
82
+ return "gpt-5.2"
83
+ return "gpt-5.1"
84
+
85
+
86
+ def _get_latest_release_tag(client: httpx.Client) -> str:
87
+ """Get latest release tag from GitHub."""
88
+ try:
89
+ response = client.get(GITHUB_API_RELEASES)
90
+ if response.status_code == 200:
91
+ data: dict[str, Any] = response.json()
92
+ tag_name: Any = data.get("tag_name")
93
+ if isinstance(tag_name, str):
94
+ return tag_name
95
+ except httpx.HTTPError:
96
+ pass
97
+
98
+ # Fallback: follow redirect from releases/latest
99
+ response = client.get(GITHUB_HTML_RELEASES, follow_redirects=True)
100
+ if response.status_code == 200:
101
+ final_url = str(response.url)
102
+ if "/tag/" in final_url:
103
+ parts = final_url.split("/tag/")
104
+ if len(parts) > 1 and "/" not in parts[-1]:
105
+ return parts[-1]
106
+
107
+ raise RuntimeError("Failed to determine latest release tag from GitHub")
108
+
109
+
110
+ def _load_cache_metadata(meta_file: Path) -> CacheMetadata | None:
111
+ if not meta_file.exists():
112
+ return None
113
+ try:
114
+ data = json.loads(meta_file.read_text())
115
+ return CacheMetadata.from_dict(data)
116
+ except (json.JSONDecodeError, ValueError):
117
+ return None
118
+
119
+
120
+ def _save_cache_metadata(meta_file: Path, metadata: CacheMetadata) -> None:
121
+ meta_file.parent.mkdir(parents=True, exist_ok=True)
122
+ meta_file.write_text(json.dumps(metadata.to_dict(), indent=2))
123
+
124
+
125
+ def get_codex_instructions(model: str = "gpt-5.1-codex", force_refresh: bool = False) -> str:
126
+ """Get Codex instructions for the given model.
127
+
128
+ Args:
129
+ model: Model name to get instructions for.
130
+ force_refresh: If True, bypass cache TTL and fetch fresh instructions.
131
+
132
+ Returns:
133
+ The Codex system prompt instructions.
134
+ """
135
+ model_family = get_model_family(model)
136
+ prompt_file = PROMPT_FILES[model_family]
137
+ cache_file = CACHE_DIR / CACHE_FILES[model_family]
138
+ meta_file = CACHE_DIR / f"{CACHE_FILES[model_family].replace('.md', '-meta.json')}"
139
+
140
+ # Check cache unless force refresh
141
+ if not force_refresh:
142
+ metadata = _load_cache_metadata(meta_file)
143
+ if metadata and cache_file.exists():
144
+ age = int(time.time()) - metadata.last_checked
145
+ if age < CACHE_TTL_SECONDS:
146
+ log_debug(f"Using cached {model_family} instructions (age: {age}s)", debug_type=DebugType.GENERAL)
147
+ return cache_file.read_text()
148
+
149
+ try:
150
+ with httpx.Client(timeout=30.0) as client:
151
+ latest_tag = _get_latest_release_tag(client)
152
+ instructions_url = f"{GITHUB_RAW_BASE}/{latest_tag}/codex-rs/core/{prompt_file}"
153
+
154
+ # Load existing metadata for conditional request
155
+ metadata = _load_cache_metadata(meta_file)
156
+ headers: dict[str, str] = {}
157
+
158
+ # Only use ETag if tag matches (different release = different content)
159
+ if metadata and metadata.tag == latest_tag and metadata.etag:
160
+ headers["If-None-Match"] = metadata.etag
161
+
162
+ response = client.get(instructions_url, headers=headers)
163
+
164
+ if response.status_code == 304 and cache_file.exists():
165
+ # Not modified, update last_checked and return cached
166
+ if metadata:
167
+ metadata.last_checked = int(time.time())
168
+ _save_cache_metadata(meta_file, metadata)
169
+ log_debug(f"Codex {model_family} instructions not modified", debug_type=DebugType.GENERAL)
170
+ return cache_file.read_text()
171
+
172
+ if response.status_code == 200:
173
+ instructions = response.text
174
+ new_etag = response.headers.get("etag")
175
+
176
+ # Save to cache
177
+ cache_file.parent.mkdir(parents=True, exist_ok=True)
178
+ cache_file.write_text(instructions)
179
+ _save_cache_metadata(
180
+ meta_file,
181
+ CacheMetadata(
182
+ etag=new_etag,
183
+ tag=latest_tag,
184
+ last_checked=int(time.time()),
185
+ url=instructions_url,
186
+ ),
187
+ )
188
+
189
+ log_debug(f"Updated {model_family} instructions from GitHub", debug_type=DebugType.GENERAL)
190
+ return instructions
191
+
192
+ raise RuntimeError(f"HTTP {response.status_code}")
193
+
194
+ except Exception as e:
195
+ log_debug(f"Failed to fetch {model_family} instructions: {e}", debug_type=DebugType.GENERAL)
196
+
197
+ # Fallback to cached version
198
+ if cache_file.exists():
199
+ log_debug(f"Using cached {model_family} instructions (fallback)", debug_type=DebugType.GENERAL)
200
+ return cache_file.read_text()
201
+
202
+ # Last resort: use bundled prompt
203
+ bundled_path = _get_bundled_prompt_path(model_family)
204
+ if bundled_path:
205
+ log_debug(f"Using bundled {model_family} instructions (fallback)", debug_type=DebugType.GENERAL)
206
+ return _load_bundled_prompt(bundled_path)
207
+
208
+ raise RuntimeError(f"No Codex instructions available for {model_family}") from e
209
+
210
+
211
+ def _get_bundled_prompt_path(model_family: ModelFamily) -> str | None:
212
+ """Get bundled prompt path for model family."""
213
+ if model_family == "gpt-5.2-codex":
214
+ return "prompts/prompt-codex-gpt-5-2-codex.md"
215
+ if model_family == "gpt-5.2":
216
+ return "prompts/prompt-codex-gpt-5-2.md"
217
+ if model_family in ("codex", "codex-max", "gpt-5.1"):
218
+ return "prompts/prompt-codex.md"
219
+ return None
220
+
221
+
222
+ def invalidate_cache(model: str | None = None) -> None:
223
+ """Invalidate cached instructions to force refresh on next access.
224
+
225
+ Args:
226
+ model: If provided, only invalidate cache for this model's family.
227
+ If None, invalidate all cached instructions.
228
+ """
229
+ if model:
230
+ model_family = get_model_family(model)
231
+ meta_file = CACHE_DIR / f"{CACHE_FILES[model_family].replace('.md', '-meta.json')}"
232
+ if meta_file.exists():
233
+ meta_file.unlink()
234
+ else:
235
+ if CACHE_DIR.exists():
236
+ for meta_file in CACHE_DIR.glob("*-meta.json"):
237
+ meta_file.unlink()
@@ -3,7 +3,6 @@
3
3
  # pyright: reportUnknownArgumentType=false
4
4
  # pyright: reportAttributeAccessIssue=false
5
5
 
6
- import json
7
6
  from base64 import b64encode
8
7
  from collections.abc import AsyncGenerator, AsyncIterator
9
8
  from typing import Any, cast, override
@@ -33,6 +32,7 @@ from klaude_code.llm.client import LLMClientABC, LLMStreamABC
33
32
  from klaude_code.llm.google.input import convert_history_to_contents, convert_tool_schema
34
33
  from klaude_code.llm.image import save_assistant_image
35
34
  from klaude_code.llm.input_common import apply_config_defaults
35
+ from klaude_code.llm.json_stable import dumps_canonical_json
36
36
  from klaude_code.llm.registry import register
37
37
  from klaude_code.llm.stream_parts import (
38
38
  append_text_part,
@@ -122,6 +122,8 @@ def _usage_from_metadata(
122
122
  if usage is None:
123
123
  return None
124
124
 
125
+ # In Gemini usage metadata, prompt_token_count represents the full prompt tokens
126
+ # (including cached tokens). cached_content_token_count is a subset of prompt tokens.
125
127
  cached = usage.cached_content_token_count or 0
126
128
  prompt = usage.prompt_token_count or 0
127
129
  response = usage.candidates_token_count or 0
@@ -136,10 +138,10 @@ def _usage_from_metadata(
136
138
 
137
139
  total = usage.total_token_count
138
140
  if total is None:
139
- total = prompt + cached + response + thoughts
141
+ total = prompt + response + thoughts
140
142
 
141
143
  return model.Usage(
142
- input_tokens=prompt + cached,
144
+ input_tokens=prompt,
143
145
  cached_tokens=cached,
144
146
  output_tokens=response + thoughts,
145
147
  reasoning_tokens=thoughts,
@@ -385,7 +387,7 @@ async def parse_google_stream(
385
387
  args_obj = function_call.args
386
388
  if args_obj is not None:
387
389
  # Add ToolCallPart, then ThinkingSignaturePart after it
388
- state.append_tool_call(call_id, name, json.dumps(args_obj, ensure_ascii=False))
390
+ state.append_tool_call(call_id, name, dumps_canonical_json(args_obj))
389
391
  encoded_sig = _encode_thought_signature(thought_signature)
390
392
  if encoded_sig:
391
393
  state.append_thinking_signature(encoded_sig)
@@ -400,7 +402,7 @@ async def parse_google_stream(
400
402
  will_continue = function_call.will_continue
401
403
  if will_continue is False and call_id in partial_args_by_call and call_id not in completed_tool_items:
402
404
  # Add ToolCallPart, then ThinkingSignaturePart after it
403
- state.append_tool_call(call_id, name, json.dumps(partial_args_by_call[call_id], ensure_ascii=False))
405
+ state.append_tool_call(call_id, name, dumps_canonical_json(partial_args_by_call[call_id]))
404
406
  stored_sig = started_tool_calls.get(call_id, (name, None))[1]
405
407
  encoded_stored_sig = _encode_thought_signature(stored_sig)
406
408
  if encoded_stored_sig:
@@ -412,7 +414,7 @@ async def parse_google_stream(
412
414
  if call_id in completed_tool_items:
413
415
  continue
414
416
  args = partial_args_by_call.get(call_id, {})
415
- state.append_tool_call(call_id, name, json.dumps(args, ensure_ascii=False))
417
+ state.append_tool_call(call_id, name, dumps_canonical_json(args))
416
418
  encoded_stored_sig = _encode_thought_signature(stored_sig)
417
419
  if encoded_stored_sig:
418
420
  state.append_thinking_signature(encoded_stored_sig)
@@ -6,18 +6,20 @@
6
6
  import json
7
7
  from base64 import b64decode
8
8
  from binascii import Error as BinasciiError
9
- from typing import Any
9
+ from typing import Any, cast
10
10
 
11
11
  from google.genai import types
12
12
 
13
13
  from klaude_code.const import EMPTY_TOOL_OUTPUT_MESSAGE
14
- from klaude_code.llm.image import assistant_image_to_data_url, parse_data_url
14
+ from klaude_code.llm.image import assistant_image_to_data_url, image_file_to_data_url, parse_data_url
15
15
  from klaude_code.llm.input_common import (
16
16
  DeveloperAttachment,
17
+ ImagePart,
17
18
  attach_developer_messages,
18
19
  merge_reminder_text,
19
20
  split_thinking_parts,
20
21
  )
22
+ from klaude_code.llm.json_stable import canonicalize_json
21
23
  from klaude_code.protocol import llm_param, message
22
24
 
23
25
 
@@ -26,16 +28,16 @@ def _data_url_to_blob(url: str) -> types.Blob:
26
28
  return types.Blob(data=decoded, mime_type=media_type)
27
29
 
28
30
 
29
- def _image_part_to_part(image: message.ImageURLPart) -> types.Part:
30
- url = image.url
31
+ def _image_part_to_part(image: ImagePart) -> types.Part:
32
+ url = image_file_to_data_url(image) if isinstance(image, message.ImageFilePart) else image.url
31
33
  if url.startswith("data:"):
32
34
  return types.Part(inline_data=_data_url_to_blob(url))
33
35
  # Best-effort: Gemini supports file URIs, and may accept public HTTPS URLs.
34
36
  return types.Part(file_data=types.FileData(file_uri=url))
35
37
 
36
38
 
37
- def _image_part_to_function_response_part(image: message.ImageURLPart) -> types.FunctionResponsePart:
38
- url = image.url
39
+ def _image_part_to_function_response_part(image: ImagePart) -> types.FunctionResponsePart:
40
+ url = image_file_to_data_url(image) if isinstance(image, message.ImageFilePart) else image.url
39
41
  if url.startswith("data:"):
40
42
  media_type, _, decoded = parse_data_url(url)
41
43
  return types.FunctionResponsePart.from_bytes(data=decoded, mime_type=media_type)
@@ -47,7 +49,7 @@ def _user_message_to_content(msg: message.UserMessage, attachment: DeveloperAtta
47
49
  for part in msg.parts:
48
50
  if isinstance(part, message.TextPart):
49
51
  parts.append(types.Part(text=part.text))
50
- elif isinstance(part, message.ImageURLPart):
52
+ elif isinstance(part, (message.ImageURLPart, message.ImageFilePart)):
51
53
  parts.append(_image_part_to_part(part))
52
54
  if attachment.text:
53
55
  parts.append(types.Part(text=attachment.text))
@@ -73,7 +75,10 @@ def _tool_messages_to_contents(
73
75
  )
74
76
  has_text = merged_text.strip() != ""
75
77
 
76
- images = [part for part in msg.parts if isinstance(part, message.ImageURLPart)] + attachment.images
78
+ images: list[ImagePart] = [
79
+ part for part in msg.parts if isinstance(part, (message.ImageURLPart, message.ImageFilePart))
80
+ ]
81
+ images.extend(attachment.images)
77
82
  image_parts: list[types.Part] = []
78
83
  function_response_parts: list[types.FunctionResponsePart] = []
79
84
 
@@ -155,11 +160,14 @@ def _assistant_message_to_content(msg: message.AssistantMessage, model_name: str
155
160
  args: dict[str, Any]
156
161
  if part.arguments_json:
157
162
  try:
158
- args = json.loads(part.arguments_json)
163
+ loaded: object = json.loads(part.arguments_json)
159
164
  except json.JSONDecodeError:
160
- args = {"_raw": part.arguments_json}
165
+ loaded = {"_raw": part.arguments_json}
161
166
  else:
162
- args = {}
167
+ loaded = {}
168
+
169
+ canonical = canonicalize_json(loaded)
170
+ args = cast(dict[str, Any], canonical) if isinstance(canonical, dict) else {"_value": canonical}
163
171
  parts.append(
164
172
  types.Part(
165
173
  function_call=types.FunctionCall(id=part.call_id, name=part.tool_name, args=args),
@@ -223,7 +231,7 @@ def convert_tool_schema(tools: list[llm_param.ToolSchema] | None) -> list[types.
223
231
  types.FunctionDeclaration(
224
232
  name=tool.name,
225
233
  description=tool.description,
226
- parameters_json_schema=tool.parameters,
234
+ parameters_json_schema=canonicalize_json(tool.parameters),
227
235
  )
228
236
  for tool in tools
229
237
  ]
klaude_code/llm/image.py CHANGED
@@ -99,21 +99,12 @@ def save_assistant_image(
99
99
  )
100
100
 
101
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
- """
102
+ def image_file_to_data_url(image: message.ImageFilePart) -> str:
103
+ """Load an image file from disk and encode it as a base64 data URL."""
108
104
 
109
105
  file_path = Path(image.file_path)
110
106
  decoded = file_path.read_bytes()
111
107
 
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
108
  mime_type = image.mime_type
118
109
  if not mime_type:
119
110
  guessed, _ = mimetypes.guess_type(str(file_path))
@@ -121,3 +112,19 @@ def assistant_image_to_data_url(image: message.ImageFilePart) -> str:
121
112
 
122
113
  encoded = b64encode(decoded).decode("ascii")
123
114
  return f"data:{mime_type};base64,{encoded}"
115
+
116
+
117
+ def assistant_image_to_data_url(image: message.ImageFilePart) -> str:
118
+ """Load an assistant image from disk and encode it as a base64 data URL.
119
+
120
+ This is primarily used for multi-turn image editing, where providers require
121
+ sending the previous assistant message (including images) back to the model.
122
+ """
123
+
124
+ file_path = Path(image.file_path)
125
+ if file_path.stat().st_size > IMAGE_OUTPUT_MAX_BYTES:
126
+ size_mb = file_path.stat().st_size / (1024 * 1024)
127
+ limit_mb = IMAGE_OUTPUT_MAX_BYTES / (1024 * 1024)
128
+ raise ValueError(f"Assistant image size ({size_mb:.2f}MB) exceeds limit ({limit_mb:.2f}MB)")
129
+
130
+ return image_file_to_data_url(image)
@@ -8,26 +8,29 @@ if TYPE_CHECKING:
8
8
  from klaude_code.protocol.llm_param import LLMCallParameter, LLMConfigParameter
9
9
 
10
10
  from klaude_code.const import EMPTY_TOOL_OUTPUT_MESSAGE
11
+ from klaude_code.llm.image import image_file_to_data_url
11
12
  from klaude_code.protocol import message
12
13
 
14
+ ImagePart = message.ImageURLPart | message.ImageFilePart
13
15
 
14
- def _empty_image_parts() -> list[message.ImageURLPart]:
16
+
17
+ def _empty_image_parts() -> list[ImagePart]:
15
18
  return []
16
19
 
17
20
 
18
21
  @dataclass
19
22
  class DeveloperAttachment:
20
23
  text: str = ""
21
- images: list[message.ImageURLPart] = field(default_factory=_empty_image_parts)
24
+ images: list[ImagePart] = field(default_factory=_empty_image_parts)
22
25
 
23
26
 
24
- def _extract_developer_content(msg: message.DeveloperMessage) -> tuple[str, list[message.ImageURLPart]]:
27
+ def _extract_developer_content(msg: message.DeveloperMessage) -> tuple[str, list[ImagePart]]:
25
28
  text_parts: list[str] = []
26
- images: list[message.ImageURLPart] = []
29
+ images: list[ImagePart] = []
27
30
  for part in msg.parts:
28
31
  if isinstance(part, message.TextPart):
29
32
  text_parts.append(part.text + "\n")
30
- elif isinstance(part, message.ImageURLPart):
33
+ elif isinstance(part, (message.ImageURLPart, message.ImageFilePart)):
31
34
  images.append(part)
32
35
  return "".join(text_parts), images
33
36
 
@@ -87,10 +90,15 @@ def build_chat_content_parts(
87
90
  parts.append({"type": "text", "text": part.text})
88
91
  elif isinstance(part, message.ImageURLPart):
89
92
  parts.append({"type": "image_url", "image_url": {"url": part.url}})
93
+ elif isinstance(part, message.ImageFilePart):
94
+ parts.append({"type": "image_url", "image_url": {"url": image_file_to_data_url(part)}})
90
95
  if attachment.text:
91
96
  parts.append({"type": "text", "text": attachment.text})
92
97
  for image in attachment.images:
93
- parts.append({"type": "image_url", "image_url": {"url": image.url}})
98
+ if isinstance(image, message.ImageFilePart):
99
+ parts.append({"type": "image_url", "image_url": {"url": image_file_to_data_url(image)}})
100
+ else:
101
+ parts.append({"type": "image_url", "image_url": {"url": image.url}})
94
102
  if not parts:
95
103
  parts.append({"type": "text", "text": ""})
96
104
  return parts
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Mapping
5
+ from typing import cast
6
+
7
+ type JsonValue = str | int | float | bool | None | list["JsonValue"] | dict[str, "JsonValue"]
8
+
9
+
10
+ def canonicalize_json(value: object) -> JsonValue:
11
+ """Return a JSON-equivalent value with stable dict key ordering.
12
+
13
+ This is used to make provider payload serialization stable across runs so that
14
+ prefix caching has a better chance to hit.
15
+ """
16
+
17
+ if isinstance(value, Mapping):
18
+ items: list[tuple[str, JsonValue]] = []
19
+ for key, item_value in cast(Mapping[object, object], value).items():
20
+ items.append((str(key), canonicalize_json(item_value)))
21
+ items.sort(key=lambda kv: kv[0])
22
+ return {k: v for k, v in items}
23
+
24
+ if isinstance(value, list):
25
+ return [canonicalize_json(v) for v in cast(list[object], value)]
26
+
27
+ if isinstance(value, tuple):
28
+ return [canonicalize_json(v) for v in cast(tuple[object, ...], value)]
29
+
30
+ return cast(JsonValue, value)
31
+
32
+
33
+ def dumps_canonical_json(value: object) -> str:
34
+ """Dump JSON with stable key order and no insignificant whitespace."""
35
+
36
+ canonical = canonicalize_json(value)
37
+ return json.dumps(canonical, ensure_ascii=False, separators=(",", ":"), sort_keys=False)
@@ -6,7 +6,6 @@
6
6
  from typing import cast
7
7
 
8
8
  from openai.types import chat
9
- from openai.types.chat import ChatCompletionContentPartParam
10
9
 
11
10
  from klaude_code.llm.image import assistant_image_to_data_url
12
11
  from klaude_code.llm.input_common import (
@@ -30,15 +29,6 @@ def _assistant_message_to_openai(msg: message.AssistantMessage) -> chat.ChatComp
30
29
  return cast(chat.ChatCompletionMessageParam, assistant_message)
31
30
 
32
31
 
33
- def build_user_content_parts(
34
- images: list[message.ImageURLPart],
35
- ) -> list[ChatCompletionContentPartParam]:
36
- """Build content parts for images only. Used by OpenRouter."""
37
- return [
38
- cast(ChatCompletionContentPartParam, {"type": "image_url", "image_url": {"url": image.url}}) for image in images
39
- ]
40
-
41
-
42
32
  def convert_history_to_input(
43
33
  history: list[message.Message],
44
34
  system: str | None = None,
@@ -12,6 +12,7 @@ how reasoning is represented (``reasoning_details`` vs ``reasoning_content``).
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ import re
15
16
  from abc import ABC, abstractmethod
16
17
  from collections.abc import AsyncGenerator, Callable
17
18
  from dataclasses import dataclass
@@ -26,7 +27,6 @@ from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
26
27
 
27
28
  from klaude_code.llm.client import LLMStreamABC
28
29
  from klaude_code.llm.image import save_assistant_image
29
- from klaude_code.llm.openai_compatible.tool_call_accumulator import normalize_tool_name
30
30
  from klaude_code.llm.stream_parts import (
31
31
  append_text_part,
32
32
  append_thinking_text_part,
@@ -34,9 +34,24 @@ from klaude_code.llm.stream_parts import (
34
34
  build_partial_parts,
35
35
  )
36
36
  from klaude_code.llm.usage import MetadataTracker, convert_usage
37
+ from klaude_code.log import log_debug
37
38
  from klaude_code.protocol import llm_param, message, model
38
39
 
39
40
 
41
+ def normalize_tool_name(name: str) -> str:
42
+ """Normalize tool name from Gemini-3 format.
43
+
44
+ Gemini-3 sometimes returns tool names in format like 'tool_Edit_mUoY2p3W3r3z8uO5P2nZ'.
45
+ This function extracts the actual tool name (e.g., 'Edit').
46
+ """
47
+ match = re.match(r"^tool_([A-Za-z]+)_[A-Za-z0-9]+$", name)
48
+ if match:
49
+ normalized = match.group(1)
50
+ log_debug(f"Gemini-3 tool name normalized: {name} -> {normalized}", style="yellow")
51
+ return normalized
52
+ return name
53
+
54
+
40
55
  class StreamStateManager:
41
56
  """Manages streaming state and accumulates parts in stream order.
42
57