klaude-code 2.8.0__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 (100) 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 +15 -4
  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 +2 -2
  16. klaude_code/core/agent_profile.py +11 -53
  17. klaude_code/core/compaction/compaction.py +4 -6
  18. klaude_code/core/compaction/overflow.py +0 -4
  19. klaude_code/core/executor.py +51 -5
  20. klaude_code/core/manager/llm_clients.py +9 -1
  21. klaude_code/core/prompts/prompt-claude-code.md +4 -4
  22. klaude_code/core/reminders.py +21 -23
  23. klaude_code/core/task.py +0 -4
  24. klaude_code/core/tool/__init__.py +3 -2
  25. klaude_code/core/tool/file/apply_patch.py +0 -27
  26. klaude_code/core/tool/file/edit_tool.py +1 -2
  27. klaude_code/core/tool/file/read_tool.md +3 -2
  28. klaude_code/core/tool/file/read_tool.py +15 -2
  29. klaude_code/core/tool/offload.py +0 -35
  30. klaude_code/core/tool/sub_agent/__init__.py +6 -0
  31. klaude_code/core/tool/sub_agent/image_gen.md +16 -0
  32. klaude_code/core/tool/sub_agent/image_gen.py +146 -0
  33. klaude_code/core/tool/sub_agent/task.md +20 -0
  34. klaude_code/core/tool/sub_agent/task.py +205 -0
  35. klaude_code/core/tool/tool_registry.py +0 -16
  36. klaude_code/core/turn.py +1 -1
  37. klaude_code/llm/anthropic/input.py +6 -5
  38. klaude_code/llm/antigravity/input.py +14 -7
  39. klaude_code/llm/codex/client.py +22 -0
  40. klaude_code/llm/codex/prompt_sync.py +237 -0
  41. klaude_code/llm/google/client.py +8 -6
  42. klaude_code/llm/google/input.py +20 -12
  43. klaude_code/llm/image.py +18 -11
  44. klaude_code/llm/input_common.py +14 -6
  45. klaude_code/llm/json_stable.py +37 -0
  46. klaude_code/llm/openai_compatible/input.py +0 -10
  47. klaude_code/llm/openai_compatible/stream.py +16 -1
  48. klaude_code/llm/registry.py +0 -5
  49. klaude_code/llm/responses/input.py +15 -5
  50. klaude_code/llm/usage.py +0 -8
  51. klaude_code/protocol/commands.py +1 -0
  52. klaude_code/protocol/events.py +2 -1
  53. klaude_code/protocol/message.py +2 -2
  54. klaude_code/protocol/model.py +20 -1
  55. klaude_code/protocol/op.py +27 -0
  56. klaude_code/protocol/op_handler.py +10 -0
  57. klaude_code/protocol/sub_agent/AGENTS.md +5 -5
  58. klaude_code/protocol/sub_agent/__init__.py +13 -34
  59. klaude_code/protocol/sub_agent/explore.py +7 -34
  60. klaude_code/protocol/sub_agent/image_gen.py +3 -74
  61. klaude_code/protocol/sub_agent/task.py +3 -47
  62. klaude_code/protocol/sub_agent/web.py +8 -52
  63. klaude_code/protocol/tools.py +2 -0
  64. klaude_code/session/export.py +308 -299
  65. klaude_code/session/session.py +58 -21
  66. klaude_code/session/store.py +0 -4
  67. klaude_code/session/templates/export_session.html +430 -134
  68. klaude_code/skill/assets/deslop/SKILL.md +9 -0
  69. klaude_code/skill/system_skills.py +0 -20
  70. klaude_code/tui/command/__init__.py +3 -0
  71. klaude_code/tui/command/continue_cmd.py +34 -0
  72. klaude_code/tui/command/fork_session_cmd.py +5 -2
  73. klaude_code/tui/command/resume_cmd.py +9 -2
  74. klaude_code/tui/command/sub_agent_model_cmd.py +85 -18
  75. klaude_code/tui/components/assistant.py +0 -26
  76. klaude_code/tui/components/command_output.py +3 -1
  77. klaude_code/tui/components/developer.py +3 -0
  78. klaude_code/tui/components/diffs.py +2 -208
  79. klaude_code/tui/components/errors.py +4 -0
  80. klaude_code/tui/components/mermaid_viewer.py +2 -2
  81. klaude_code/tui/components/rich/markdown.py +60 -63
  82. klaude_code/tui/components/rich/theme.py +2 -0
  83. klaude_code/tui/components/sub_agent.py +2 -46
  84. klaude_code/tui/components/thinking.py +0 -33
  85. klaude_code/tui/components/tools.py +43 -21
  86. klaude_code/tui/input/images.py +21 -18
  87. klaude_code/tui/input/key_bindings.py +2 -2
  88. klaude_code/tui/input/prompt_toolkit.py +49 -49
  89. klaude_code/tui/machine.py +15 -11
  90. klaude_code/tui/renderer.py +12 -20
  91. klaude_code/tui/runner.py +2 -1
  92. klaude_code/tui/terminal/image.py +6 -34
  93. klaude_code/ui/common.py +0 -70
  94. {klaude_code-2.8.0.dist-info → klaude_code-2.9.0.dist-info}/METADATA +3 -6
  95. {klaude_code-2.8.0.dist-info → klaude_code-2.9.0.dist-info}/RECORD +97 -92
  96. klaude_code/core/tool/sub_agent_tool.py +0 -126
  97. klaude_code/llm/openai_compatible/tool_call_accumulator.py +0 -108
  98. klaude_code/tui/components/rich/searchable_text.py +0 -68
  99. {klaude_code-2.8.0.dist-info → klaude_code-2.9.0.dist-info}/WHEEL +0 -0
  100. {klaude_code-2.8.0.dist-info → klaude_code-2.9.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,205 @@
1
+ """Task tool implementation for running sub-agents by type."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, cast
8
+
9
+ from klaude_code.core.tool.context import ToolContext
10
+ from klaude_code.core.tool.tool_abc import ToolABC, ToolConcurrencyPolicy, ToolMetadata, load_desc
11
+ from klaude_code.core.tool.tool_registry import register
12
+ from klaude_code.protocol import llm_param, message, model, tools
13
+ from klaude_code.protocol.sub_agent import get_sub_agent_profile, iter_sub_agent_profiles
14
+ from klaude_code.session.session import Session
15
+
16
+ TASK_TYPE_TO_SUB_AGENT: dict[str, str] = {
17
+ "general-purpose": "Task",
18
+ "explore": "Explore",
19
+ "web": "Web",
20
+ }
21
+
22
+
23
+ def _task_description() -> str:
24
+ summaries: dict[str, str] = {}
25
+ for profile in iter_sub_agent_profiles():
26
+ if profile.invoker_type:
27
+ summaries[profile.invoker_type] = profile.invoker_summary.strip()
28
+
29
+ type_lines: list[str] = []
30
+ for invoker_type in TASK_TYPE_TO_SUB_AGENT:
31
+ summary = summaries.get(invoker_type, "")
32
+ if summary:
33
+ type_lines.append(f"- {invoker_type}: {summary}")
34
+ else:
35
+ type_lines.append(f"- {invoker_type}")
36
+
37
+ types_section = "\n".join(type_lines) if type_lines else "- general-purpose"
38
+
39
+ return load_desc(Path(__file__).parent / "task.md", {"types_section": types_section})
40
+
41
+
42
+ TASK_SCHEMA = llm_param.ToolSchema(
43
+ name=tools.TASK,
44
+ type="function",
45
+ description=_task_description(),
46
+ parameters={
47
+ "type": "object",
48
+ "properties": {
49
+ "type": {
50
+ "type": "string",
51
+ "enum": list(TASK_TYPE_TO_SUB_AGENT.keys()),
52
+ "description": "Sub-agent type selector.",
53
+ },
54
+ "description": {
55
+ "type": "string",
56
+ "description": "A short (3-5 word) description of the task.",
57
+ },
58
+ "prompt": {
59
+ "type": "string",
60
+ "description": "The task for the agent to perform.",
61
+ },
62
+ "output_schema": {
63
+ "type": "object",
64
+ "description": "Optional JSON Schema for structured output.",
65
+ },
66
+ "resume": {
67
+ "type": "string",
68
+ "description": "Optional agent ID to resume from.",
69
+ },
70
+ },
71
+ "required": ["description", "prompt"],
72
+ "additionalProperties": False,
73
+ },
74
+ )
75
+
76
+
77
+ @register(tools.TASK)
78
+ class TaskTool(ToolABC):
79
+ """Run a sub-agent based on the requested type."""
80
+
81
+ @classmethod
82
+ def metadata(cls) -> ToolMetadata:
83
+ return ToolMetadata(concurrency_policy=ToolConcurrencyPolicy.CONCURRENT, has_side_effects=True)
84
+
85
+ @classmethod
86
+ def schema(cls) -> llm_param.ToolSchema:
87
+ return TASK_SCHEMA
88
+
89
+ @classmethod
90
+ async def call(cls, arguments: str, context: ToolContext) -> message.ToolResultMessage:
91
+ try:
92
+ args = json.loads(arguments)
93
+ except json.JSONDecodeError as exc:
94
+ return message.ToolResultMessage(status="error", output_text=f"Invalid JSON arguments: {exc}")
95
+
96
+ if not isinstance(args, dict):
97
+ return message.ToolResultMessage(status="error", output_text="Invalid arguments: expected object")
98
+
99
+ typed_args = cast(dict[str, Any], args)
100
+
101
+ runner = context.run_subtask
102
+ if runner is None:
103
+ return message.ToolResultMessage(status="error", output_text="No subtask runner available in this context")
104
+
105
+ description = str(typed_args.get("description") or "")
106
+
107
+ resume_raw = typed_args.get("resume")
108
+ resume_session_id: str | None = None
109
+ resume_sub_agent_type: str | None = None
110
+ if isinstance(resume_raw, str) and resume_raw.strip():
111
+ try:
112
+ resume_session_id = Session.resolve_sub_agent_session_id(resume_raw)
113
+ except ValueError as exc:
114
+ return message.ToolResultMessage(status="error", output_text=str(exc))
115
+
116
+ try:
117
+ resume_session = Session.load(resume_session_id)
118
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
119
+ return message.ToolResultMessage(
120
+ status="error",
121
+ output_text=f"Failed to resume sub-agent session '{resume_session_id}': {exc}",
122
+ )
123
+
124
+ if resume_session.sub_agent_state is None:
125
+ return message.ToolResultMessage(
126
+ status="error",
127
+ output_text=f"Invalid resume id '{resume_session_id}': target session is not a sub-agent session",
128
+ )
129
+
130
+ resume_sub_agent_type = resume_session.sub_agent_state.sub_agent_type
131
+ if resume_sub_agent_type == tools.IMAGE_GEN:
132
+ return message.ToolResultMessage(
133
+ status="error",
134
+ output_text="This resume id belongs to ImageGen; use the ImageGen tool to resume it.",
135
+ )
136
+
137
+ claims = context.sub_agent_resume_claims
138
+ if claims is not None:
139
+ ok = await claims.claim(resume_session_id)
140
+ if not ok:
141
+ return message.ToolResultMessage(
142
+ status="error",
143
+ output_text=(
144
+ "Duplicate sub-agent resume in the same response: "
145
+ f"resume='{resume_raw.strip()}' (resolved='{resume_session_id[:7]}…'). "
146
+ "Merge into a single call or resume in a later turn."
147
+ ),
148
+ )
149
+
150
+ type_raw = typed_args.get("type")
151
+ requested_type = str(type_raw).strip() if isinstance(type_raw, str) else ""
152
+
153
+ if resume_session_id and not requested_type:
154
+ sub_agent_type = resume_sub_agent_type or TASK_TYPE_TO_SUB_AGENT["general-purpose"]
155
+ else:
156
+ if not requested_type:
157
+ requested_type = "general-purpose"
158
+ sub_agent_type = TASK_TYPE_TO_SUB_AGENT.get(requested_type)
159
+ if sub_agent_type is None:
160
+ return message.ToolResultMessage(
161
+ status="error",
162
+ output_text=f"Unknown Task type '{requested_type}'.",
163
+ )
164
+
165
+ if resume_session_id and resume_sub_agent_type and resume_sub_agent_type != sub_agent_type:
166
+ return message.ToolResultMessage(
167
+ status="error",
168
+ output_text=(
169
+ "Invalid resume id: sub-agent type mismatch. "
170
+ f"Expected '{sub_agent_type}', got '{resume_sub_agent_type}'."
171
+ ),
172
+ )
173
+
174
+ try:
175
+ profile = get_sub_agent_profile(sub_agent_type)
176
+ except KeyError as exc:
177
+ return message.ToolResultMessage(status="error", output_text=str(exc))
178
+
179
+ sub_agent_prompt = profile.prompt_builder(typed_args)
180
+
181
+ output_schema_raw = typed_args.get("output_schema")
182
+ output_schema = cast(dict[str, Any], output_schema_raw) if isinstance(output_schema_raw, dict) else None
183
+
184
+ try:
185
+ result = await runner(
186
+ model.SubAgentState(
187
+ sub_agent_type=profile.name,
188
+ sub_agent_desc=description,
189
+ sub_agent_prompt=sub_agent_prompt,
190
+ resume=resume_session_id,
191
+ output_schema=output_schema,
192
+ generation=None,
193
+ ),
194
+ context.record_sub_agent_session_id,
195
+ context.register_sub_agent_metadata_getter,
196
+ )
197
+ except Exception as exc:
198
+ return message.ToolResultMessage(status="error", output_text=f"Failed to run subtask: {exc}")
199
+
200
+ return message.ToolResultMessage(
201
+ status="success" if not result.error else "error",
202
+ output_text=result.task_result,
203
+ ui_extra=model.SessionIdUIExtra(session_id=result.session_id),
204
+ task_metadata=result.task_metadata,
205
+ )
@@ -1,10 +1,8 @@
1
1
  from collections.abc import Callable
2
2
  from typing import TypeVar
3
3
 
4
- from klaude_code.core.tool.sub_agent_tool import SubAgentTool
5
4
  from klaude_code.core.tool.tool_abc import ToolABC
6
5
  from klaude_code.protocol import llm_param
7
- from klaude_code.protocol.sub_agent import iter_sub_agent_profiles
8
6
 
9
7
  _REGISTRY: dict[str, type[ToolABC]] = {}
10
8
 
@@ -19,20 +17,6 @@ def register(name: str) -> Callable[[type[T]], type[T]]:
19
17
  return _decorator
20
18
 
21
19
 
22
- def _register_sub_agent_tools() -> None:
23
- """Automatically register all sub-agent tools based on their profiles."""
24
- for profile in iter_sub_agent_profiles():
25
- tool_cls = SubAgentTool.for_profile(profile)
26
- _REGISTRY[profile.name] = tool_cls
27
-
28
-
29
- _register_sub_agent_tools()
30
-
31
-
32
- def list_tools() -> list[str]:
33
- return list(_REGISTRY.keys())
34
-
35
-
36
20
  def get_tool_schemas(tool_names: list[str]) -> list[llm_param.ToolSchema]:
37
21
  schemas: list[llm_param.ToolSchema] = []
38
22
  for tool_name in tool_names:
klaude_code/core/turn.py CHANGED
@@ -243,7 +243,7 @@ class TurnExecutor:
243
243
  )
244
244
 
245
245
  # ImageGen per-call overrides (tool-level `generation` parameters)
246
- if ctx.sub_agent_state is not None and ctx.sub_agent_state.sub_agent_type == "ImageGen":
246
+ if ctx.sub_agent_state is not None and ctx.sub_agent_state.sub_agent_type == tools.IMAGE_GEN:
247
247
  call_param.modalities = ["image", "text"]
248
248
  generation = ctx.sub_agent_state.generation or {}
249
249
  image_config = llm_param.ImageConfig()
@@ -18,9 +18,10 @@ from anthropic.types.beta.beta_tool_use_block_param import BetaToolUseBlockParam
18
18
  from anthropic.types.beta.beta_url_image_source_param import BetaURLImageSourceParam
19
19
 
20
20
  from klaude_code.const import EMPTY_TOOL_OUTPUT_MESSAGE
21
- from klaude_code.llm.image import parse_data_url
21
+ from klaude_code.llm.image import image_file_to_data_url, parse_data_url
22
22
  from klaude_code.llm.input_common import (
23
23
  DeveloperAttachment,
24
+ ImagePart,
24
25
  attach_developer_messages,
25
26
  merge_reminder_text,
26
27
  split_thinking_parts,
@@ -36,8 +37,8 @@ _INLINE_IMAGE_MEDIA_TYPES: tuple[AllowedMediaType, ...] = (
36
37
  )
37
38
 
38
39
 
39
- def _image_part_to_block(image: message.ImageURLPart) -> BetaImageBlockParam:
40
- url = image.url
40
+ def _image_part_to_block(image: ImagePart) -> BetaImageBlockParam:
41
+ url = image_file_to_data_url(image) if isinstance(image, message.ImageFilePart) else image.url
41
42
  if url.startswith("data:"):
42
43
  media_type, base64_payload, _ = parse_data_url(url)
43
44
  if media_type not in _INLINE_IMAGE_MEDIA_TYPES:
@@ -64,7 +65,7 @@ def _user_message_to_message(
64
65
  for part in msg.parts:
65
66
  if isinstance(part, message.TextPart):
66
67
  blocks.append(cast(BetaTextBlockParam, {"type": "text", "text": part.text}))
67
- elif isinstance(part, message.ImageURLPart):
68
+ elif isinstance(part, (message.ImageURLPart, message.ImageFilePart)):
68
69
  blocks.append(_image_part_to_block(part))
69
70
  if attachment.text:
70
71
  blocks.append(cast(BetaTextBlockParam, {"type": "text", "text": attachment.text}))
@@ -86,7 +87,7 @@ def _tool_message_to_block(
86
87
  attachment.text,
87
88
  )
88
89
  tool_content.append(cast(BetaTextBlockParam, {"type": "text", "text": merged_text}))
89
- for image in [part for part in msg.parts if isinstance(part, message.ImageURLPart)]:
90
+ for image in [part for part in msg.parts if isinstance(part, (message.ImageURLPart, message.ImageFilePart))]:
90
91
  tool_content.append(_image_part_to_block(image))
91
92
  for image in attachment.images:
92
93
  tool_content.append(_image_part_to_block(image))
@@ -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)