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.
- klaude_code/app/runtime.py +2 -1
- klaude_code/auth/antigravity/oauth.py +0 -9
- klaude_code/auth/antigravity/token_manager.py +0 -18
- klaude_code/auth/base.py +53 -0
- klaude_code/auth/codex/exceptions.py +0 -4
- klaude_code/auth/codex/oauth.py +32 -28
- klaude_code/auth/codex/token_manager.py +0 -18
- klaude_code/cli/cost_cmd.py +128 -39
- klaude_code/cli/list_model.py +27 -10
- klaude_code/cli/main.py +14 -3
- klaude_code/config/assets/builtin_config.yaml +8 -24
- klaude_code/config/config.py +47 -25
- klaude_code/config/sub_agent_model_helper.py +18 -13
- klaude_code/config/thinking.py +0 -8
- klaude_code/const.py +1 -1
- klaude_code/core/agent_profile.py +10 -52
- klaude_code/core/compaction/overflow.py +0 -4
- klaude_code/core/executor.py +33 -5
- klaude_code/core/manager/llm_clients.py +9 -1
- klaude_code/core/prompts/prompt-claude-code.md +4 -4
- klaude_code/core/reminders.py +21 -23
- klaude_code/core/task.py +0 -4
- klaude_code/core/tool/__init__.py +3 -2
- klaude_code/core/tool/file/apply_patch.py +0 -27
- klaude_code/core/tool/file/read_tool.md +3 -2
- klaude_code/core/tool/file/read_tool.py +15 -2
- klaude_code/core/tool/offload.py +0 -35
- klaude_code/core/tool/sub_agent/__init__.py +6 -0
- klaude_code/core/tool/sub_agent/image_gen.md +16 -0
- klaude_code/core/tool/sub_agent/image_gen.py +146 -0
- klaude_code/core/tool/sub_agent/task.md +20 -0
- klaude_code/core/tool/sub_agent/task.py +205 -0
- klaude_code/core/tool/tool_registry.py +0 -16
- klaude_code/core/turn.py +1 -1
- klaude_code/llm/anthropic/input.py +6 -5
- klaude_code/llm/antigravity/input.py +14 -7
- klaude_code/llm/codex/client.py +22 -0
- klaude_code/llm/codex/prompt_sync.py +237 -0
- klaude_code/llm/google/client.py +8 -6
- klaude_code/llm/google/input.py +20 -12
- klaude_code/llm/image.py +18 -11
- klaude_code/llm/input_common.py +14 -6
- klaude_code/llm/json_stable.py +37 -0
- klaude_code/llm/openai_compatible/input.py +0 -10
- klaude_code/llm/openai_compatible/stream.py +16 -1
- klaude_code/llm/registry.py +0 -5
- klaude_code/llm/responses/input.py +15 -5
- klaude_code/llm/usage.py +0 -8
- klaude_code/protocol/events.py +2 -1
- klaude_code/protocol/message.py +2 -2
- klaude_code/protocol/model.py +20 -1
- klaude_code/protocol/op.py +13 -0
- klaude_code/protocol/op_handler.py +5 -0
- klaude_code/protocol/sub_agent/AGENTS.md +5 -5
- klaude_code/protocol/sub_agent/__init__.py +13 -34
- klaude_code/protocol/sub_agent/explore.py +7 -34
- klaude_code/protocol/sub_agent/image_gen.py +3 -74
- klaude_code/protocol/sub_agent/task.py +3 -47
- klaude_code/protocol/sub_agent/web.py +8 -52
- klaude_code/protocol/tools.py +2 -0
- klaude_code/session/session.py +58 -21
- klaude_code/session/store.py +0 -4
- klaude_code/skill/assets/deslop/SKILL.md +9 -0
- klaude_code/skill/system_skills.py +0 -20
- klaude_code/tui/command/fork_session_cmd.py +5 -2
- klaude_code/tui/command/resume_cmd.py +9 -2
- klaude_code/tui/command/sub_agent_model_cmd.py +85 -18
- klaude_code/tui/components/assistant.py +0 -26
- klaude_code/tui/components/command_output.py +3 -1
- klaude_code/tui/components/developer.py +3 -0
- klaude_code/tui/components/diffs.py +2 -208
- klaude_code/tui/components/errors.py +4 -0
- klaude_code/tui/components/mermaid_viewer.py +2 -2
- klaude_code/tui/components/rich/markdown.py +0 -54
- klaude_code/tui/components/rich/theme.py +2 -0
- klaude_code/tui/components/sub_agent.py +2 -46
- klaude_code/tui/components/thinking.py +0 -33
- klaude_code/tui/components/tools.py +43 -21
- klaude_code/tui/input/images.py +21 -18
- klaude_code/tui/input/key_bindings.py +2 -2
- klaude_code/tui/input/prompt_toolkit.py +49 -49
- klaude_code/tui/machine.py +15 -11
- klaude_code/tui/renderer.py +11 -20
- klaude_code/tui/runner.py +2 -1
- klaude_code/tui/terminal/image.py +6 -34
- klaude_code/ui/common.py +0 -70
- {klaude_code-2.8.1.dist-info → klaude_code-2.9.0.dist-info}/METADATA +3 -6
- {klaude_code-2.8.1.dist-info → klaude_code-2.9.0.dist-info}/RECORD +90 -86
- klaude_code/core/tool/sub_agent_tool.py +0 -126
- klaude_code/llm/openai_compatible/tool_call_accumulator.py +0 -108
- klaude_code/tui/components/rich/searchable_text.py +0 -68
- {klaude_code-2.8.1.dist-info → klaude_code-2.9.0.dist-info}/WHEEL +0 -0
- {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:
|
|
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
|
|
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.
|
|
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:
|
klaude_code/llm/codex/client.py
CHANGED
|
@@ -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()
|
klaude_code/llm/google/client.py
CHANGED
|
@@ -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 +
|
|
141
|
+
total = prompt + response + thoughts
|
|
140
142
|
|
|
141
143
|
return model.Usage(
|
|
142
|
-
input_tokens=prompt
|
|
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,
|
|
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,
|
|
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,
|
|
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)
|
klaude_code/llm/google/input.py
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
163
|
+
loaded: object = json.loads(part.arguments_json)
|
|
159
164
|
except json.JSONDecodeError:
|
|
160
|
-
|
|
165
|
+
loaded = {"_raw": part.arguments_json}
|
|
161
166
|
else:
|
|
162
|
-
|
|
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
|
|
103
|
-
"""Load an
|
|
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)
|
klaude_code/llm/input_common.py
CHANGED
|
@@ -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
|
-
|
|
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[
|
|
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[
|
|
27
|
+
def _extract_developer_content(msg: message.DeveloperMessage) -> tuple[str, list[ImagePart]]:
|
|
25
28
|
text_parts: list[str] = []
|
|
26
|
-
images: list[
|
|
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
|
-
|
|
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
|
|