klaude-code 2.8.1__py3-none-any.whl → 2.9.1__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 +33 -38
- klaude_code/auth/antigravity/token_manager.py +0 -18
- klaude_code/auth/base.py +53 -0
- klaude_code/auth/claude/oauth.py +34 -49
- 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 +25 -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 +11 -56
- 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 +1 -5
- 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 +27 -3
- klaude_code/core/tool/offload.py +0 -35
- klaude_code/core/tool/shell/bash_tool.py +1 -1
- 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/bedrock_anthropic/__init__.py +3 -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 +32 -6
- klaude_code/llm/json_stable.py +37 -0
- klaude_code/llm/{codex → openai_codex}/__init__.py +1 -1
- klaude_code/llm/{codex → openai_codex}/client.py +24 -2
- klaude_code/llm/openai_codex/prompt_sync.py +237 -0
- klaude_code/llm/openai_compatible/client.py +3 -1
- klaude_code/llm/openai_compatible/input.py +0 -10
- klaude_code/llm/openai_compatible/stream.py +35 -10
- klaude_code/llm/{responses → openai_responses}/client.py +1 -1
- klaude_code/llm/{responses → openai_responses}/input.py +15 -5
- klaude_code/llm/registry.py +3 -8
- klaude_code/llm/stream_parts.py +3 -1
- klaude_code/llm/usage.py +1 -9
- klaude_code/protocol/events.py +2 -2
- klaude_code/protocol/message.py +3 -2
- klaude_code/protocol/model.py +34 -2
- 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 +80 -22
- 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/bash_syntax.py +4 -0
- klaude_code/tui/components/command_output.py +3 -1
- klaude_code/tui/components/developer.py +3 -0
- klaude_code/tui/components/diffs.py +4 -209
- klaude_code/tui/components/errors.py +4 -0
- klaude_code/tui/components/mermaid_viewer.py +2 -2
- klaude_code/tui/components/metadata.py +0 -3
- klaude_code/tui/components/rich/markdown.py +120 -87
- klaude_code/tui/components/rich/status.py +2 -2
- klaude_code/tui/components/rich/theme.py +11 -6
- klaude_code/tui/components/sub_agent.py +2 -46
- klaude_code/tui/components/thinking.py +0 -33
- klaude_code/tui/components/tools.py +65 -21
- klaude_code/tui/components/user_input.py +2 -0
- 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 +29 -47
- klaude_code/tui/renderer.py +48 -33
- klaude_code/tui/runner.py +2 -1
- klaude_code/tui/terminal/image.py +27 -34
- klaude_code/ui/common.py +0 -70
- {klaude_code-2.8.1.dist-info → klaude_code-2.9.1.dist-info}/METADATA +3 -6
- {klaude_code-2.8.1.dist-info → klaude_code-2.9.1.dist-info}/RECORD +103 -99
- klaude_code/core/tool/sub_agent_tool.py +0 -126
- klaude_code/llm/bedrock/__init__.py +0 -3
- klaude_code/llm/openai_compatible/tool_call_accumulator.py +0 -108
- klaude_code/tui/components/rich/searchable_text.py +0 -68
- /klaude_code/llm/{bedrock → bedrock_anthropic}/client.py +0 -0
- /klaude_code/llm/{responses → openai_responses}/__init__.py +0 -0
- {klaude_code-2.8.1.dist-info → klaude_code-2.9.1.dist-info}/WHEEL +0 -0
- {klaude_code-2.8.1.dist-info → klaude_code-2.9.1.dist-info}/entry_points.txt +0 -0
|
@@ -20,9 +20,9 @@ from klaude_code.const import (
|
|
|
20
20
|
)
|
|
21
21
|
from klaude_code.llm.client import LLMClientABC, LLMStreamABC
|
|
22
22
|
from klaude_code.llm.input_common import apply_config_defaults
|
|
23
|
+
from klaude_code.llm.openai_responses.client import ResponsesLLMStream
|
|
24
|
+
from klaude_code.llm.openai_responses.input import convert_history_to_input, convert_tool_schema
|
|
23
25
|
from klaude_code.llm.registry import register
|
|
24
|
-
from klaude_code.llm.responses.client import ResponsesLLMStream
|
|
25
|
-
from klaude_code.llm.responses.input import convert_history_to_input, convert_tool_schema
|
|
26
26
|
from klaude_code.llm.usage import MetadataTracker, error_llm_stream
|
|
27
27
|
from klaude_code.log import DebugType, log_debug
|
|
28
28
|
from klaude_code.protocol import llm_param
|
|
@@ -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.openai_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()
|
|
@@ -39,9 +39,11 @@ def build_payload(param: llm_param.LLMCallParameter) -> tuple[CompletionCreatePa
|
|
|
39
39
|
"max_tokens": param.max_tokens,
|
|
40
40
|
"tools": tools,
|
|
41
41
|
"reasoning_effort": param.thinking.reasoning_effort if param.thinking else None,
|
|
42
|
-
"verbosity": param.verbosity,
|
|
43
42
|
}
|
|
44
43
|
|
|
44
|
+
if param.verbosity:
|
|
45
|
+
payload["verbosity"] = param.verbosity
|
|
46
|
+
|
|
45
47
|
return payload, extra_body
|
|
46
48
|
|
|
47
49
|
|
|
@@ -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
|
|
|
@@ -61,9 +76,11 @@ class StreamStateManager:
|
|
|
61
76
|
"""Set the response ID once received from the stream."""
|
|
62
77
|
self.response_id = response_id
|
|
63
78
|
|
|
64
|
-
def append_thinking_text(self, text: str) -> None:
|
|
79
|
+
def append_thinking_text(self, text: str, *, reasoning_field: str | None = None) -> None:
|
|
65
80
|
"""Append thinking text, merging with the previous ThinkingTextPart when possible."""
|
|
66
|
-
append_thinking_text_part(
|
|
81
|
+
append_thinking_text_part(
|
|
82
|
+
self.assistant_parts, text, model_id=self.param_model, reasoning_field=reasoning_field
|
|
83
|
+
)
|
|
67
84
|
|
|
68
85
|
def append_text(self, text: str) -> None:
|
|
69
86
|
"""Append assistant text, merging with the previous TextPart when possible."""
|
|
@@ -135,6 +152,7 @@ class ReasoningDeltaResult:
|
|
|
135
152
|
|
|
136
153
|
handled: bool
|
|
137
154
|
outputs: list[str | message.Part]
|
|
155
|
+
reasoning_field: str | None = None # Original field name: reasoning_content, reasoning, reasoning_text
|
|
138
156
|
|
|
139
157
|
|
|
140
158
|
class ReasoningHandlerABC(ABC):
|
|
@@ -153,8 +171,11 @@ class ReasoningHandlerABC(ABC):
|
|
|
153
171
|
"""Flush buffered reasoning content (usually at stage transition/finalize)."""
|
|
154
172
|
|
|
155
173
|
|
|
174
|
+
REASONING_FIELDS = ("reasoning_content", "reasoning", "reasoning_text")
|
|
175
|
+
|
|
176
|
+
|
|
156
177
|
class DefaultReasoningHandler(ReasoningHandlerABC):
|
|
157
|
-
"""Handles OpenAI-compatible reasoning fields (reasoning_content / reasoning)."""
|
|
178
|
+
"""Handles OpenAI-compatible reasoning fields (reasoning_content / reasoning / reasoning_text)."""
|
|
158
179
|
|
|
159
180
|
def __init__(
|
|
160
181
|
self,
|
|
@@ -164,16 +185,20 @@ class DefaultReasoningHandler(ReasoningHandlerABC):
|
|
|
164
185
|
) -> None:
|
|
165
186
|
self._param_model = param_model
|
|
166
187
|
self._response_id = response_id
|
|
188
|
+
self._reasoning_field: str | None = None
|
|
167
189
|
|
|
168
190
|
def set_response_id(self, response_id: str | None) -> None:
|
|
169
191
|
self._response_id = response_id
|
|
170
192
|
|
|
171
193
|
def on_delta(self, delta: object) -> ReasoningDeltaResult:
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
194
|
+
for field_name in REASONING_FIELDS:
|
|
195
|
+
content = getattr(delta, field_name, None)
|
|
196
|
+
if content:
|
|
197
|
+
if self._reasoning_field is None:
|
|
198
|
+
self._reasoning_field = field_name
|
|
199
|
+
text = str(content)
|
|
200
|
+
return ReasoningDeltaResult(handled=True, outputs=[text], reasoning_field=self._reasoning_field)
|
|
201
|
+
return ReasoningDeltaResult(handled=False, outputs=[])
|
|
177
202
|
|
|
178
203
|
def flush(self) -> list[message.Part]:
|
|
179
204
|
return []
|
|
@@ -267,7 +292,7 @@ async def parse_chat_completions_stream(
|
|
|
267
292
|
if not output:
|
|
268
293
|
continue
|
|
269
294
|
metadata_tracker.record_token()
|
|
270
|
-
state.append_thinking_text(output)
|
|
295
|
+
state.append_thinking_text(output, reasoning_field=reasoning_result.reasoning_field)
|
|
271
296
|
yield message.ThinkingTextDelta(content=output, response_id=state.response_id)
|
|
272
297
|
else:
|
|
273
298
|
state.assistant_parts.append(output)
|
|
@@ -11,8 +11,8 @@ from openai.types.responses.response_create_params import ResponseCreateParamsSt
|
|
|
11
11
|
from klaude_code.const import LLM_HTTP_TIMEOUT_CONNECT, LLM_HTTP_TIMEOUT_READ, LLM_HTTP_TIMEOUT_TOTAL
|
|
12
12
|
from klaude_code.llm.client import LLMClientABC, LLMStreamABC
|
|
13
13
|
from klaude_code.llm.input_common import apply_config_defaults
|
|
14
|
+
from klaude_code.llm.openai_responses.input import convert_history_to_input, convert_tool_schema
|
|
14
15
|
from klaude_code.llm.registry import register
|
|
15
|
-
from klaude_code.llm.responses.input import convert_history_to_input, convert_tool_schema
|
|
16
16
|
from klaude_code.llm.stream_parts import (
|
|
17
17
|
append_text_part,
|
|
18
18
|
append_thinking_text_part,
|
|
@@ -7,6 +7,7 @@ from typing import Any, cast
|
|
|
7
7
|
from openai.types import responses
|
|
8
8
|
|
|
9
9
|
from klaude_code.const import EMPTY_TOOL_OUTPUT_MESSAGE
|
|
10
|
+
from klaude_code.llm.image import image_file_to_data_url
|
|
10
11
|
from klaude_code.llm.input_common import (
|
|
11
12
|
DeveloperAttachment,
|
|
12
13
|
attach_developer_messages,
|
|
@@ -16,6 +17,12 @@ from klaude_code.llm.input_common import (
|
|
|
16
17
|
from klaude_code.protocol import llm_param, message
|
|
17
18
|
|
|
18
19
|
|
|
20
|
+
def _image_to_url(image: message.ImageURLPart | message.ImageFilePart) -> str:
|
|
21
|
+
if isinstance(image, message.ImageFilePart):
|
|
22
|
+
return image_file_to_data_url(image)
|
|
23
|
+
return image.url
|
|
24
|
+
|
|
25
|
+
|
|
19
26
|
def _build_user_content_parts(
|
|
20
27
|
user: message.UserMessage,
|
|
21
28
|
attachment: DeveloperAttachment,
|
|
@@ -24,11 +31,11 @@ def _build_user_content_parts(
|
|
|
24
31
|
for part in user.parts:
|
|
25
32
|
if isinstance(part, message.TextPart):
|
|
26
33
|
parts.append(cast(responses.ResponseInputContentParam, {"type": "input_text", "text": part.text}))
|
|
27
|
-
elif isinstance(part, message.ImageURLPart):
|
|
34
|
+
elif isinstance(part, (message.ImageURLPart, message.ImageFilePart)):
|
|
28
35
|
parts.append(
|
|
29
36
|
cast(
|
|
30
37
|
responses.ResponseInputContentParam,
|
|
31
|
-
{"type": "input_image", "detail": "auto", "image_url": part
|
|
38
|
+
{"type": "input_image", "detail": "auto", "image_url": _image_to_url(part)},
|
|
32
39
|
)
|
|
33
40
|
)
|
|
34
41
|
if attachment.text:
|
|
@@ -37,7 +44,7 @@ def _build_user_content_parts(
|
|
|
37
44
|
parts.append(
|
|
38
45
|
cast(
|
|
39
46
|
responses.ResponseInputContentParam,
|
|
40
|
-
{"type": "input_image", "detail": "auto", "image_url": image
|
|
47
|
+
{"type": "input_image", "detail": "auto", "image_url": _image_to_url(image)},
|
|
41
48
|
)
|
|
42
49
|
)
|
|
43
50
|
if not parts:
|
|
@@ -56,12 +63,15 @@ def _build_tool_result_item(
|
|
|
56
63
|
)
|
|
57
64
|
if text_output:
|
|
58
65
|
content_parts.append(cast(responses.ResponseInputContentParam, {"type": "input_text", "text": text_output}))
|
|
59
|
-
images
|
|
66
|
+
images: list[message.ImageURLPart | message.ImageFilePart] = [
|
|
67
|
+
part for part in tool.parts if isinstance(part, (message.ImageURLPart, message.ImageFilePart))
|
|
68
|
+
]
|
|
69
|
+
images.extend(attachment.images)
|
|
60
70
|
for image in images:
|
|
61
71
|
content_parts.append(
|
|
62
72
|
cast(
|
|
63
73
|
responses.ResponseInputContentParam,
|
|
64
|
-
{"type": "input_image", "detail": "auto", "image_url": image
|
|
74
|
+
{"type": "input_image", "detail": "auto", "image_url": _image_to_url(image)},
|
|
65
75
|
)
|
|
66
76
|
)
|
|
67
77
|
|
klaude_code/llm/registry.py
CHANGED
|
@@ -15,11 +15,11 @@ _REGISTRY: dict[llm_param.LLMClientProtocol, type["LLMClientABC"]] = {}
|
|
|
15
15
|
_PROTOCOL_MODULES: dict[llm_param.LLMClientProtocol, str] = {
|
|
16
16
|
llm_param.LLMClientProtocol.ANTHROPIC: "klaude_code.llm.anthropic",
|
|
17
17
|
llm_param.LLMClientProtocol.CLAUDE_OAUTH: "klaude_code.llm.claude",
|
|
18
|
-
llm_param.LLMClientProtocol.BEDROCK: "klaude_code.llm.
|
|
19
|
-
llm_param.LLMClientProtocol.CODEX_OAUTH: "klaude_code.llm.
|
|
18
|
+
llm_param.LLMClientProtocol.BEDROCK: "klaude_code.llm.bedrock_anthropic",
|
|
19
|
+
llm_param.LLMClientProtocol.CODEX_OAUTH: "klaude_code.llm.openai_codex",
|
|
20
20
|
llm_param.LLMClientProtocol.OPENAI: "klaude_code.llm.openai_compatible",
|
|
21
21
|
llm_param.LLMClientProtocol.OPENROUTER: "klaude_code.llm.openrouter",
|
|
22
|
-
llm_param.LLMClientProtocol.RESPONSES: "klaude_code.llm.
|
|
22
|
+
llm_param.LLMClientProtocol.RESPONSES: "klaude_code.llm.openai_responses",
|
|
23
23
|
llm_param.LLMClientProtocol.GOOGLE: "klaude_code.llm.google",
|
|
24
24
|
llm_param.LLMClientProtocol.ANTIGRAVITY: "klaude_code.llm.antigravity",
|
|
25
25
|
}
|
|
@@ -38,11 +38,6 @@ def _load_protocol(protocol: llm_param.LLMClientProtocol) -> None:
|
|
|
38
38
|
_loaded_protocols.add(protocol)
|
|
39
39
|
|
|
40
40
|
|
|
41
|
-
def load_protocol(protocol: llm_param.LLMClientProtocol) -> None:
|
|
42
|
-
"""Load the module for a specific protocol on demand."""
|
|
43
|
-
_load_protocol(protocol)
|
|
44
|
-
|
|
45
|
-
|
|
46
41
|
def register(name: llm_param.LLMClientProtocol) -> Callable[[_T], _T]:
|
|
47
42
|
"""Decorator to register an LLM client class for a protocol."""
|
|
48
43
|
|
klaude_code/llm/stream_parts.py
CHANGED
|
@@ -24,6 +24,7 @@ def append_thinking_text_part(
|
|
|
24
24
|
text: str,
|
|
25
25
|
*,
|
|
26
26
|
model_id: str,
|
|
27
|
+
reasoning_field: str | None = None,
|
|
27
28
|
force_new: bool = False,
|
|
28
29
|
) -> int | None:
|
|
29
30
|
if not text:
|
|
@@ -35,10 +36,11 @@ def append_thinking_text_part(
|
|
|
35
36
|
parts[-1] = message.ThinkingTextPart(
|
|
36
37
|
text=last.text + text,
|
|
37
38
|
model_id=model_id,
|
|
39
|
+
reasoning_field=reasoning_field or last.reasoning_field,
|
|
38
40
|
)
|
|
39
41
|
return len(parts) - 1
|
|
40
42
|
|
|
41
|
-
parts.append(message.ThinkingTextPart(text=text, model_id=model_id))
|
|
43
|
+
parts.append(message.ThinkingTextPart(text=text, model_id=model_id, reasoning_field=reasoning_field))
|
|
42
44
|
return len(parts) - 1
|
|
43
45
|
|
|
44
46
|
|
klaude_code/llm/usage.py
CHANGED
|
@@ -28,7 +28,7 @@ def calculate_cost(usage: model.Usage, cost_config: llm_param.Cost | None) -> No
|
|
|
28
28
|
usage.output_cost = (usage.output_tokens / 1_000_000) * cost_config.output
|
|
29
29
|
|
|
30
30
|
# Cache read cost
|
|
31
|
-
usage.cache_read_cost = (usage.cached_tokens / 1_000_000) * cost_config.cache_read
|
|
31
|
+
usage.cache_read_cost = (usage.cached_tokens / 1_000_000) * (cost_config.cache_read or cost_config.input)
|
|
32
32
|
|
|
33
33
|
# Image generation cost
|
|
34
34
|
usage.image_cost = (usage.image_tokens / 1_000_000) * cost_config.image
|
|
@@ -44,14 +44,6 @@ class MetadataTracker:
|
|
|
44
44
|
self._usage = model.Usage()
|
|
45
45
|
self._cost_config = cost_config
|
|
46
46
|
|
|
47
|
-
@property
|
|
48
|
-
def first_token_time(self) -> float | None:
|
|
49
|
-
return self._first_token_time
|
|
50
|
-
|
|
51
|
-
@property
|
|
52
|
-
def last_token_time(self) -> float | None:
|
|
53
|
-
return self._last_token_time
|
|
54
|
-
|
|
55
47
|
def record_token(self) -> None:
|
|
56
48
|
"""Record a token arrival, updating first/last token times."""
|
|
57
49
|
now = time.time()
|
klaude_code/protocol/events.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import time
|
|
4
|
+
from collections.abc import Sequence
|
|
4
5
|
from typing import Literal
|
|
5
6
|
|
|
6
7
|
from pydantic import BaseModel, Field
|
|
@@ -58,7 +59,7 @@ class ResponseEvent(Event):
|
|
|
58
59
|
|
|
59
60
|
class UserMessageEvent(Event):
|
|
60
61
|
content: str
|
|
61
|
-
images:
|
|
62
|
+
images: Sequence[message.ImageURLPart | message.ImageFilePart] | None = None
|
|
62
63
|
|
|
63
64
|
|
|
64
65
|
class DeveloperMessageEvent(Event):
|
|
@@ -118,7 +119,6 @@ class UsageEvent(ResponseEvent):
|
|
|
118
119
|
|
|
119
120
|
class TaskMetadataEvent(Event):
|
|
120
121
|
metadata: model.TaskMetadataItem
|
|
121
|
-
cancelled: bool = False
|
|
122
122
|
|
|
123
123
|
|
|
124
124
|
class ThinkingStartEvent(ResponseEvent):
|
klaude_code/protocol/message.py
CHANGED
|
@@ -112,6 +112,7 @@ class ThinkingTextPart(BaseModel):
|
|
|
112
112
|
id: str | None = None
|
|
113
113
|
text: str
|
|
114
114
|
model_id: str | None = None
|
|
115
|
+
reasoning_field: str | None = None # Original field name: reasoning_content, reasoning, reasoning_text
|
|
115
116
|
|
|
116
117
|
|
|
117
118
|
class ThinkingSignaturePart(BaseModel):
|
|
@@ -213,7 +214,7 @@ class UserInputPayload(BaseModel):
|
|
|
213
214
|
"""
|
|
214
215
|
|
|
215
216
|
text: str
|
|
216
|
-
images:
|
|
217
|
+
images: Sequence[ImageURLPart | ImageFilePart] | None = None
|
|
217
218
|
|
|
218
219
|
|
|
219
220
|
# Helper functions
|
|
@@ -225,7 +226,7 @@ def text_parts_from_str(text: str | None) -> list[Part]:
|
|
|
225
226
|
return [TextPart(text=text)]
|
|
226
227
|
|
|
227
228
|
|
|
228
|
-
def parts_from_text_and_images(text: str | None, images:
|
|
229
|
+
def parts_from_text_and_images(text: str | None, images: Sequence[ImageURLPart | ImageFilePart] | None) -> list[Part]:
|
|
229
230
|
parts: list[Part] = []
|
|
230
231
|
if text:
|
|
231
232
|
parts.append(TextPart(text=text))
|