klaude-code 1.2.8__py3-none-any.whl → 1.2.10__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 (82) hide show
  1. klaude_code/auth/codex/__init__.py +1 -1
  2. klaude_code/cli/main.py +12 -1
  3. klaude_code/cli/runtime.py +7 -11
  4. klaude_code/command/__init__.py +68 -21
  5. klaude_code/command/clear_cmd.py +6 -2
  6. klaude_code/command/command_abc.py +5 -2
  7. klaude_code/command/diff_cmd.py +5 -2
  8. klaude_code/command/export_cmd.py +7 -4
  9. klaude_code/command/help_cmd.py +6 -2
  10. klaude_code/command/model_cmd.py +5 -2
  11. klaude_code/command/prompt-deslop.md +14 -0
  12. klaude_code/command/prompt_command.py +8 -3
  13. klaude_code/command/refresh_cmd.py +6 -2
  14. klaude_code/command/registry.py +17 -5
  15. klaude_code/command/release_notes_cmd.py +89 -0
  16. klaude_code/command/status_cmd.py +98 -56
  17. klaude_code/command/terminal_setup_cmd.py +7 -4
  18. klaude_code/const/__init__.py +1 -1
  19. klaude_code/core/agent.py +66 -26
  20. klaude_code/core/executor.py +2 -2
  21. klaude_code/core/manager/agent_manager.py +6 -7
  22. klaude_code/core/manager/llm_clients.py +47 -22
  23. klaude_code/core/manager/llm_clients_builder.py +19 -7
  24. klaude_code/core/manager/sub_agent_manager.py +6 -2
  25. klaude_code/core/prompt.py +38 -28
  26. klaude_code/core/reminders.py +4 -7
  27. klaude_code/core/task.py +59 -40
  28. klaude_code/core/tool/__init__.py +2 -0
  29. klaude_code/core/tool/file/_utils.py +30 -0
  30. klaude_code/core/tool/file/apply_patch_tool.py +1 -1
  31. klaude_code/core/tool/file/edit_tool.py +6 -31
  32. klaude_code/core/tool/file/multi_edit_tool.py +7 -32
  33. klaude_code/core/tool/file/read_tool.py +6 -18
  34. klaude_code/core/tool/file/write_tool.py +6 -31
  35. klaude_code/core/tool/memory/__init__.py +5 -0
  36. klaude_code/core/tool/memory/memory_tool.py +2 -2
  37. klaude_code/core/tool/memory/skill_loader.py +2 -1
  38. klaude_code/core/tool/memory/skill_tool.py +13 -0
  39. klaude_code/core/tool/sub_agent_tool.py +2 -1
  40. klaude_code/core/tool/todo/todo_write_tool.py +1 -1
  41. klaude_code/core/tool/todo/update_plan_tool.py +1 -1
  42. klaude_code/core/tool/tool_context.py +21 -4
  43. klaude_code/core/tool/tool_runner.py +5 -8
  44. klaude_code/core/tool/web/mermaid_tool.py +1 -4
  45. klaude_code/core/turn.py +40 -37
  46. klaude_code/llm/__init__.py +2 -12
  47. klaude_code/llm/anthropic/client.py +14 -44
  48. klaude_code/llm/client.py +2 -2
  49. klaude_code/llm/codex/client.py +4 -3
  50. klaude_code/llm/input_common.py +0 -6
  51. klaude_code/llm/openai_compatible/client.py +31 -74
  52. klaude_code/llm/openai_compatible/input.py +6 -4
  53. klaude_code/llm/openai_compatible/stream_processor.py +82 -0
  54. klaude_code/llm/openrouter/client.py +32 -62
  55. klaude_code/llm/openrouter/input.py +4 -27
  56. klaude_code/llm/registry.py +33 -7
  57. klaude_code/llm/responses/client.py +16 -48
  58. klaude_code/llm/responses/input.py +1 -1
  59. klaude_code/llm/usage.py +61 -11
  60. klaude_code/protocol/commands.py +1 -0
  61. klaude_code/protocol/events.py +11 -2
  62. klaude_code/protocol/model.py +147 -24
  63. klaude_code/protocol/op.py +1 -0
  64. klaude_code/protocol/sub_agent.py +5 -1
  65. klaude_code/session/export.py +56 -32
  66. klaude_code/session/session.py +43 -21
  67. klaude_code/session/templates/export_session.html +4 -1
  68. klaude_code/ui/core/input.py +1 -1
  69. klaude_code/ui/modes/repl/__init__.py +1 -5
  70. klaude_code/ui/modes/repl/clipboard.py +5 -5
  71. klaude_code/ui/modes/repl/event_handler.py +153 -54
  72. klaude_code/ui/modes/repl/renderer.py +4 -4
  73. klaude_code/ui/renderers/developer.py +35 -25
  74. klaude_code/ui/renderers/metadata.py +68 -30
  75. klaude_code/ui/renderers/tools.py +53 -87
  76. klaude_code/ui/rich/markdown.py +5 -5
  77. klaude_code/ui/terminal/control.py +2 -2
  78. klaude_code/version.py +3 -3
  79. {klaude_code-1.2.8.dist-info → klaude_code-1.2.10.dist-info}/METADATA +1 -1
  80. {klaude_code-1.2.8.dist-info → klaude_code-1.2.10.dist-info}/RECORD +82 -78
  81. {klaude_code-1.2.8.dist-info → klaude_code-1.2.10.dist-info}/WHEEL +0 -0
  82. {klaude_code-1.2.8.dist-info → klaude_code-1.2.10.dist-info}/entry_points.txt +0 -0
@@ -7,38 +7,13 @@ from pathlib import Path
7
7
 
8
8
  from pydantic import BaseModel
9
9
 
10
+ from klaude_code.core.tool.file._utils import file_exists, is_directory, read_text, write_text
10
11
  from klaude_code.core.tool.tool_abc import ToolABC, load_desc
11
12
  from klaude_code.core.tool.tool_context import get_current_file_tracker
12
13
  from klaude_code.core.tool.tool_registry import register
13
14
  from klaude_code.protocol import llm_param, model, tools
14
15
 
15
16
 
16
- def _is_directory(path: str) -> bool:
17
- try:
18
- return Path(path).is_dir()
19
- except Exception:
20
- return False
21
-
22
-
23
- def _file_exists(path: str) -> bool:
24
- try:
25
- return Path(path).exists()
26
- except Exception:
27
- return False
28
-
29
-
30
- def _write_text(path: str, content: str) -> None:
31
- parent = Path(path).parent
32
- parent.mkdir(parents=True, exist_ok=True)
33
- with open(path, "w", encoding="utf-8") as f:
34
- f.write(content)
35
-
36
-
37
- def _read_text(path: str) -> str:
38
- with open(path, "r", encoding="utf-8", errors="replace") as f:
39
- return f.read()
40
-
41
-
42
17
  class WriteArguments(BaseModel):
43
18
  file_path: str
44
19
  content: str
@@ -78,14 +53,14 @@ class WriteTool(ToolABC):
78
53
 
79
54
  file_path = os.path.abspath(args.file_path)
80
55
 
81
- if _is_directory(file_path):
56
+ if is_directory(file_path):
82
57
  return model.ToolResultItem(
83
58
  status="error",
84
59
  output="<tool_use_error>Illegal operation on a directory. write</tool_use_error>",
85
60
  )
86
61
 
87
62
  file_tracker = get_current_file_tracker()
88
- exists = _file_exists(file_path)
63
+ exists = file_exists(file_path)
89
64
 
90
65
  if exists:
91
66
  tracked_mtime: float | None = None
@@ -113,12 +88,12 @@ class WriteTool(ToolABC):
113
88
  before = ""
114
89
  if exists:
115
90
  try:
116
- before = await asyncio.to_thread(_read_text, file_path)
91
+ before = await asyncio.to_thread(read_text, file_path)
117
92
  except Exception:
118
93
  before = ""
119
94
 
120
95
  try:
121
- await asyncio.to_thread(_write_text, file_path, args.content)
96
+ await asyncio.to_thread(write_text, file_path, args.content)
122
97
  except Exception as e: # pragma: no cover
123
98
  return model.ToolResultItem(status="error", output=f"<tool_use_error>{e}</tool_use_error>")
124
99
 
@@ -140,7 +115,7 @@ class WriteTool(ToolABC):
140
115
  )
141
116
  )
142
117
  diff_text = "\n".join(diff_lines)
143
- ui_extra = model.ToolResultUIExtra(type=model.ToolResultUIExtraType.DIFF_TEXT, diff_text=diff_text)
118
+ ui_extra = model.DiffTextUIExtra(diff_text=diff_text)
144
119
 
145
120
  message = f"File {'overwritten' if exists else 'created'} successfully at: {file_path}"
146
121
  return model.ToolResultItem(status="success", output=message, ui_extra=ui_extra)
@@ -0,0 +1,5 @@
1
+ from .skill_loader import SkillLoader
2
+ from .skill_tool import SkillTool
3
+
4
+ skill_loader = SkillLoader()
5
+ SkillTool.set_skill_loader(skill_loader)
@@ -100,7 +100,7 @@ def _format_numbered_line(line_no: int, content: str) -> str:
100
100
  return f"{line_no:>6}|{content}"
101
101
 
102
102
 
103
- def _make_diff_ui_extra(before: str, after: str, path: str) -> model.ToolResultUIExtra:
103
+ def _make_diff_ui_extra(before: str, after: str, path: str) -> model.DiffTextUIExtra:
104
104
  diff_lines = list(
105
105
  difflib.unified_diff(
106
106
  before.splitlines(),
@@ -111,7 +111,7 @@ def _make_diff_ui_extra(before: str, after: str, path: str) -> model.ToolResultU
111
111
  )
112
112
  )
113
113
  diff_text = "\n".join(diff_lines)
114
- return model.ToolResultUIExtra(type=model.ToolResultUIExtraType.DIFF_TEXT, diff_text=diff_text)
114
+ return model.DiffTextUIExtra(diff_text=diff_text)
115
115
 
116
116
 
117
117
  @register(tools.MEMORY)
@@ -115,7 +115,8 @@ class SkillLoader:
115
115
 
116
116
  return skill
117
117
 
118
- except Exception:
118
+ except (OSError, yaml.YAMLError) as e:
119
+ log_debug(f"Failed to load skill from {skill_path}: {e}")
119
120
  return None
120
121
 
121
122
  def discover_skills(self) -> list[Skill]:
@@ -13,15 +13,26 @@ class SkillTool(ToolABC):
13
13
  """Tool to execute/load a skill within the main conversation"""
14
14
 
15
15
  _skill_loader: SkillLoader | None = None
16
+ _discovery_done: bool = False
16
17
 
17
18
  @classmethod
18
19
  def set_skill_loader(cls, loader: SkillLoader) -> None:
19
20
  """Set the skill loader instance"""
20
21
  cls._skill_loader = loader
22
+ cls._discovery_done = False
23
+
24
+ @classmethod
25
+ def _ensure_skills_discovered(cls) -> None:
26
+ if cls._discovery_done:
27
+ return
28
+ if cls._skill_loader is not None:
29
+ cls._skill_loader.discover_skills()
30
+ cls._discovery_done = True
21
31
 
22
32
  @classmethod
23
33
  def schema(cls) -> llm_param.ToolSchema:
24
34
  """Generate schema with embedded available skills metadata"""
35
+ cls._ensure_skills_discovered()
25
36
  skills_xml = cls._generate_skills_xml()
26
37
 
27
38
  return llm_param.ToolSchema(
@@ -69,6 +80,8 @@ class SkillTool(ToolABC):
69
80
  output=f"Invalid arguments: {e}",
70
81
  )
71
82
 
83
+ cls._ensure_skills_discovered()
84
+
72
85
  if not cls._skill_loader:
73
86
  return model.ToolResultItem(
74
87
  status="error",
@@ -79,5 +79,6 @@ class SubAgentTool(ToolABC):
79
79
  return model.ToolResultItem(
80
80
  status="success" if not result.error else "error",
81
81
  output=result.task_result or "",
82
- ui_extra=model.ToolResultUIExtra(type=model.ToolResultUIExtraType.SESSION_ID, session_id=result.session_id),
82
+ ui_extra=model.SessionIdUIExtra(session_id=result.session_id),
83
+ task_metadata=result.task_metadata,
83
84
  )
@@ -116,6 +116,6 @@ Your todo list has changed. DO NOT mention this explicitly to the user. Here are
116
116
  return model.ToolResultItem(
117
117
  status="success",
118
118
  output=response,
119
- ui_extra=model.ToolResultUIExtra(type=model.ToolResultUIExtraType.TODO_LIST, todo_list=ui_extra),
119
+ ui_extra=model.TodoListUIExtra(todo_list=ui_extra),
120
120
  side_effects=[model.ToolSideEffect.TODO_CHANGE],
121
121
  )
@@ -99,6 +99,6 @@ class UpdatePlanTool(ToolABC):
99
99
  return model.ToolResultItem(
100
100
  status="success",
101
101
  output="Plan updated",
102
- ui_extra=model.ToolResultUIExtra(type=model.ToolResultUIExtraType.TODO_LIST, todo_list=ui_extra),
102
+ ui_extra=model.TodoListUIExtra(todo_list=ui_extra),
103
103
  side_effects=[model.ToolSideEffect.TODO_CHANGE],
104
104
  )
@@ -22,6 +22,19 @@ class TodoContext:
22
22
  set_todos: Callable[[list[model.TodoItem]], None]
23
23
 
24
24
 
25
+ @dataclass
26
+ class SessionTodoStore:
27
+ """Adapter exposing session todos through an explicit interface."""
28
+
29
+ session: Session
30
+
31
+ def get(self) -> list[model.TodoItem]:
32
+ return self.session.todos
33
+
34
+ def set(self, todos: list[model.TodoItem]) -> None:
35
+ self.session.todos = todos
36
+
37
+
25
38
  @dataclass
26
39
  class ToolContextToken:
27
40
  """Tokens used to restore tool execution context.
@@ -55,10 +68,7 @@ def set_tool_context_from_session(session: Session) -> ToolContextToken:
55
68
  """
56
69
 
57
70
  file_tracker_token = current_file_tracker_var.set(session.file_tracker)
58
- todo_ctx = TodoContext(
59
- get_todos=lambda: session.todos,
60
- set_todos=lambda todos: setattr(session, "todos", todos),
61
- )
71
+ todo_ctx = build_todo_context(session)
62
72
  todo_token = current_todo_context_var.set(todo_ctx)
63
73
  return ToolContextToken(file_tracker_token=file_tracker_token, todo_token=todo_token)
64
74
 
@@ -87,6 +97,13 @@ def tool_context(
87
97
  reset_tool_context(token)
88
98
 
89
99
 
100
+ def build_todo_context(session: Session) -> TodoContext:
101
+ """Create a TodoContext backed by the given session."""
102
+
103
+ store = SessionTodoStore(session)
104
+ return TodoContext(get_todos=store.get, set_todos=store.set)
105
+
106
+
90
107
  def get_current_file_tracker() -> MutableMapping[str, float] | None:
91
108
  """Return the current file tracker mapping for this tool context."""
92
109
 
@@ -34,13 +34,10 @@ async def run_tool(tool_call: model.ToolCallItem, registry: dict[str, type[ToolA
34
34
  truncation_result = truncate_tool_output(tool_result.output, tool_call)
35
35
  tool_result.output = truncation_result.output
36
36
  if truncation_result.was_truncated and truncation_result.saved_file_path:
37
- tool_result.ui_extra = model.ToolResultUIExtra(
38
- type=model.ToolResultUIExtraType.TRUNCATION,
39
- truncation=model.TruncationUIExtra(
40
- saved_file_path=truncation_result.saved_file_path,
41
- original_length=truncation_result.original_length,
42
- truncated_length=truncation_result.truncated_length,
43
- ),
37
+ tool_result.ui_extra = model.TruncationUIExtra(
38
+ saved_file_path=truncation_result.saved_file_path,
39
+ original_length=truncation_result.original_length,
40
+ truncated_length=truncation_result.truncated_length,
44
41
  )
45
42
  return tool_result
46
43
  except asyncio.CancelledError:
@@ -244,7 +241,7 @@ class ToolExecutor:
244
241
  for side_effect in side_effects:
245
242
  if side_effect == model.ToolSideEffect.TODO_CHANGE:
246
243
  todos: list[model.TodoItem] | None = None
247
- if tool_result.ui_extra is not None and tool_result.ui_extra.todo_list is not None:
244
+ if isinstance(tool_result.ui_extra, model.TodoListUIExtra):
248
245
  todos = tool_result.ui_extra.todo_list.todos
249
246
  if todos is not None:
250
247
  side_effect_events.append(ToolExecutionTodoChange(todos=todos))
@@ -49,10 +49,7 @@ class MermaidTool(ToolABC):
49
49
 
50
50
  link = cls._build_link(args.code)
51
51
  line_count = cls._count_lines(args.code)
52
- ui_extra = model.ToolResultUIExtra(
53
- type=model.ToolResultUIExtraType.MERMAID_LINK,
54
- mermaid_link=model.MermaidLinkUIExtra(link=link, line_count=line_count),
55
- )
52
+ ui_extra = model.MermaidLinkUIExtra(link=link, line_count=line_count)
56
53
  output = f"Mermaid diagram rendered successfully ({line_count} lines)."
57
54
  return model.ToolResultItem(status="success", output=output, ui_extra=ui_extra)
58
55
 
klaude_code/core/turn.py CHANGED
@@ -1,9 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
- from collections.abc import AsyncGenerator, Callable, MutableMapping, Sequence
3
+ from collections.abc import AsyncGenerator
4
4
  from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING
6
+
7
+ from klaude_code.core.tool import ToolABC, tool_context
8
+
9
+ if TYPE_CHECKING:
10
+ from klaude_code.core.task import SessionContext
5
11
 
6
- from klaude_code.core.tool import TodoContext, ToolABC, tool_context
7
12
  from klaude_code.core.tool.tool_runner import (
8
13
  ToolExecutionCallStarted,
9
14
  ToolExecutionResult,
@@ -26,16 +31,11 @@ class TurnError(Exception):
26
31
  class TurnExecutionContext:
27
32
  """Execution context required to run a single turn."""
28
33
 
29
- session_id: str
30
- get_conversation_history: Callable[[], list[model.ConversationItem]]
31
- append_history: Callable[[Sequence[model.ConversationItem]], None]
34
+ session_ctx: SessionContext
32
35
  llm_client: LLMClientABC
33
36
  system_prompt: str | None
34
37
  tools: list[llm_param.ToolSchema]
35
38
  tool_registry: dict[str, type[ToolABC]]
36
- # For tool context
37
- file_tracker: MutableMapping[str, float]
38
- todo_context: TodoContext
39
39
 
40
40
 
41
41
  @dataclass
@@ -74,6 +74,7 @@ def build_events_from_tool_executor_event(session_id: str, event: ToolExecutorEv
74
74
  result=tool_result.output or "",
75
75
  ui_extra=tool_result.ui_extra,
76
76
  status=tool_result.status,
77
+ task_metadata=tool_result.task_metadata,
77
78
  )
78
79
  )
79
80
  case ToolExecutionTodoChange(todos=todos):
@@ -97,18 +98,18 @@ class TurnExecutor:
97
98
  def __init__(self, context: TurnExecutionContext) -> None:
98
99
  self._context = context
99
100
  self._tool_executor: ToolExecutor | None = None
100
- self._has_tool_call: bool = False
101
+ self._turn_result: TurnResult | None = None
101
102
 
102
103
  @property
103
104
  def has_tool_call(self) -> bool:
104
- return self._has_tool_call
105
+ return bool(self._turn_result and self._turn_result.tool_calls)
105
106
 
106
107
  def cancel(self) -> list[events.Event]:
107
108
  """Cancel running tools and return any resulting events."""
108
109
  ui_events: list[events.Event] = []
109
110
  if self._tool_executor is not None:
110
111
  for exec_event in self._tool_executor.cancel():
111
- for ui_event in build_events_from_tool_executor_event(self._context.session_id, exec_event):
112
+ for ui_event in build_events_from_tool_executor_event(self._context.session_ctx.session_id, exec_event):
112
113
  ui_events.append(ui_event)
113
114
  self._tool_executor = None
114
115
  return ui_events
@@ -120,44 +121,45 @@ class TurnExecutor:
120
121
  TurnError: If the turn fails (stream error or non-completed status).
121
122
  """
122
123
  ctx = self._context
124
+ session_ctx = ctx.session_ctx
123
125
 
124
- yield events.TurnStartEvent(session_id=ctx.session_id)
126
+ yield events.TurnStartEvent(session_id=session_ctx.session_id)
125
127
 
126
- turn_result = TurnResult(
128
+ self._turn_result = TurnResult(
127
129
  reasoning_items=[],
128
130
  assistant_message=None,
129
131
  tool_calls=[],
130
132
  stream_error=None,
131
133
  )
132
134
 
133
- async for event in self._consume_llm_stream(turn_result):
135
+ async for event in self._consume_llm_stream(self._turn_result):
134
136
  yield event
135
137
 
136
- if turn_result.stream_error is not None:
137
- ctx.append_history([turn_result.stream_error])
138
- yield events.TurnEndEvent(session_id=ctx.session_id)
139
- raise TurnError(turn_result.stream_error.error)
138
+ if self._turn_result.stream_error is not None:
139
+ session_ctx.append_history([self._turn_result.stream_error])
140
+ yield events.TurnEndEvent(session_id=session_ctx.session_id)
141
+ raise TurnError(self._turn_result.stream_error.error)
140
142
 
141
- self._append_success_history(turn_result)
142
- self._has_tool_call = bool(turn_result.tool_calls)
143
+ self._append_success_history(self._turn_result)
143
144
 
144
- if turn_result.tool_calls:
145
- async for ui_event in self._run_tool_executor(turn_result.tool_calls):
145
+ if self._turn_result.tool_calls:
146
+ async for ui_event in self._run_tool_executor(self._turn_result.tool_calls):
146
147
  yield ui_event
147
148
 
148
- yield events.TurnEndEvent(session_id=ctx.session_id)
149
+ yield events.TurnEndEvent(session_id=session_ctx.session_id)
149
150
 
150
151
  async def _consume_llm_stream(self, turn_result: TurnResult) -> AsyncGenerator[events.Event, None]:
151
152
  """Stream events from LLM and update turn_result in place."""
152
153
 
153
154
  ctx = self._context
155
+ session_ctx = ctx.session_ctx
154
156
  async for response_item in ctx.llm_client.call(
155
157
  llm_param.LLMCallParameter(
156
- input=ctx.get_conversation_history(),
158
+ input=session_ctx.get_conversation_history(),
157
159
  system=ctx.system_prompt,
158
160
  tools=ctx.tools,
159
161
  store=False,
160
- session_id=ctx.session_id,
162
+ session_id=session_ctx.session_id,
161
163
  )
162
164
  ):
163
165
  log_debug(
@@ -174,7 +176,7 @@ class TurnExecutor:
174
176
  yield events.ThinkingEvent(
175
177
  content=item.content,
176
178
  response_id=item.response_id,
177
- session_id=ctx.session_id,
179
+ session_id=session_ctx.session_id,
178
180
  )
179
181
  case model.ReasoningEncryptedItem() as item:
180
182
  turn_result.reasoning_items.append(item)
@@ -182,18 +184,18 @@ class TurnExecutor:
182
184
  yield events.AssistantMessageDeltaEvent(
183
185
  content=item.content,
184
186
  response_id=item.response_id,
185
- session_id=ctx.session_id,
187
+ session_id=session_ctx.session_id,
186
188
  )
187
189
  case model.AssistantMessageItem() as item:
188
190
  turn_result.assistant_message = item
189
191
  yield events.AssistantMessageEvent(
190
192
  content=item.content or "",
191
193
  response_id=item.response_id,
192
- session_id=ctx.session_id,
194
+ session_id=session_ctx.session_id,
193
195
  )
194
196
  case model.ResponseMetadataItem() as item:
195
197
  yield events.ResponseMetadataEvent(
196
- session_id=ctx.session_id,
198
+ session_id=session_ctx.session_id,
197
199
  metadata=item,
198
200
  )
199
201
  case model.StreamErrorItem() as item:
@@ -206,7 +208,7 @@ class TurnExecutor:
206
208
  )
207
209
  case model.ToolCallStartItem() as item:
208
210
  yield events.TurnToolCallStartEvent(
209
- session_id=ctx.session_id,
211
+ session_id=session_ctx.session_id,
210
212
  response_id=item.response_id,
211
213
  tool_call_id=item.call_id,
212
214
  tool_name=item.name,
@@ -219,27 +221,28 @@ class TurnExecutor:
219
221
 
220
222
  def _append_success_history(self, turn_result: TurnResult) -> None:
221
223
  """Persist successful turn artifacts to conversation history."""
222
- ctx = self._context
224
+ session_ctx = self._context.session_ctx
223
225
  if turn_result.reasoning_items:
224
- ctx.append_history(turn_result.reasoning_items)
226
+ session_ctx.append_history(turn_result.reasoning_items)
225
227
  if turn_result.assistant_message:
226
- ctx.append_history([turn_result.assistant_message])
228
+ session_ctx.append_history([turn_result.assistant_message])
227
229
  if turn_result.tool_calls:
228
- ctx.append_history(turn_result.tool_calls)
230
+ session_ctx.append_history(turn_result.tool_calls)
229
231
 
230
232
  async def _run_tool_executor(self, tool_calls: list[model.ToolCallItem]) -> AsyncGenerator[events.Event, None]:
231
233
  """Run tools for the turn and translate executor events to UI events."""
232
234
 
233
235
  ctx = self._context
234
- with tool_context(ctx.file_tracker, ctx.todo_context):
236
+ session_ctx = ctx.session_ctx
237
+ with tool_context(session_ctx.file_tracker, session_ctx.todo_context):
235
238
  executor = ToolExecutor(
236
239
  registry=ctx.tool_registry,
237
- append_history=ctx.append_history,
240
+ append_history=session_ctx.append_history,
238
241
  )
239
242
  self._tool_executor = executor
240
243
  try:
241
244
  async for exec_event in executor.run_tools(tool_calls):
242
- for ui_event in build_events_from_tool_executor_event(ctx.session_id, exec_event):
245
+ for ui_event in build_events_from_tool_executor_event(session_ctx.session_id, exec_event):
243
246
  yield ui_event
244
247
  finally:
245
248
  self._tool_executor = None
@@ -1,23 +1,13 @@
1
1
  """LLM package init.
2
2
 
3
- Imports built-in LLM clients so their ``@register`` decorators run and they
4
- become available via the registry.
3
+ LLM clients are lazily loaded to avoid heavy imports at module load time.
4
+ Only LLMClientABC and create_llm_client are exposed.
5
5
  """
6
6
 
7
- from .anthropic import AnthropicClient
8
7
  from .client import LLMClientABC
9
- from .codex import CodexClient
10
- from .openai_compatible import OpenAICompatibleClient
11
- from .openrouter import OpenRouterClient
12
8
  from .registry import create_llm_client
13
- from .responses import ResponsesClient
14
9
 
15
10
  __all__ = [
16
11
  "LLMClientABC",
17
- "ResponsesClient",
18
- "OpenAICompatibleClient",
19
- "OpenRouterClient",
20
- "AnthropicClient",
21
- "CodexClient",
22
12
  "create_llm_client",
23
13
  ]
@@ -1,5 +1,4 @@
1
1
  import json
2
- import time
3
2
  from collections.abc import AsyncGenerator
4
3
  from typing import override
5
4
 
@@ -22,7 +21,7 @@ from klaude_code.llm.anthropic.input import convert_history_to_input, convert_sy
22
21
  from klaude_code.llm.client import LLMClientABC, call_with_logged_payload
23
22
  from klaude_code.llm.input_common import apply_config_defaults
24
23
  from klaude_code.llm.registry import register
25
- from klaude_code.llm.usage import calculate_cost
24
+ from klaude_code.llm.usage import MetadataTracker, convert_anthropic_usage
26
25
  from klaude_code.protocol import llm_param, model
27
26
  from klaude_code.trace import DebugType, log_debug
28
27
 
@@ -47,9 +46,7 @@ class AnthropicClient(LLMClientABC):
47
46
  async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[model.ConversationItem, None]:
48
47
  param = apply_config_defaults(param, self.get_llm_config())
49
48
 
50
- request_start_time = time.time()
51
- first_token_time: float | None = None
52
- last_token_time: float | None = None
49
+ metadata_tracker = MetadataTracker(cost_config=self.get_llm_config().cost)
53
50
 
54
51
  messages = convert_history_to_input(param.input, param.model)
55
52
  tools = convert_tool_schema(param.tools)
@@ -77,7 +74,7 @@ class AnthropicClient(LLMClientABC):
77
74
  else anthropic.types.ThinkingConfigDisabledParam(
78
75
  type="disabled",
79
76
  ),
80
- extra_headers={"extra": json.dumps({"session_id": param.session_id})},
77
+ extra_headers={"extra": json.dumps({"session_id": param.session_id}, sort_keys=True)},
81
78
  )
82
79
 
83
80
  accumulated_thinking: list[str] = []
@@ -112,32 +109,24 @@ class AnthropicClient(LLMClientABC):
112
109
  case BetaRawContentBlockDeltaEvent() as event:
113
110
  match event.delta:
114
111
  case BetaThinkingDelta() as delta:
115
- if first_token_time is None:
116
- first_token_time = time.time()
117
- last_token_time = time.time()
112
+ metadata_tracker.record_token()
118
113
  accumulated_thinking.append(delta.thinking)
119
114
  case BetaSignatureDelta() as delta:
120
- if first_token_time is None:
121
- first_token_time = time.time()
122
- last_token_time = time.time()
115
+ metadata_tracker.record_token()
123
116
  yield model.ReasoningEncryptedItem(
124
117
  encrypted_content=delta.signature,
125
118
  response_id=response_id,
126
119
  model=str(param.model),
127
120
  )
128
121
  case BetaTextDelta() as delta:
129
- if first_token_time is None:
130
- first_token_time = time.time()
131
- last_token_time = time.time()
122
+ metadata_tracker.record_token()
132
123
  accumulated_content.append(delta.text)
133
124
  yield model.AssistantMessageDelta(
134
125
  content=delta.text,
135
126
  response_id=response_id,
136
127
  )
137
128
  case BetaInputJSONDelta() as delta:
138
- if first_token_time is None:
139
- first_token_time = time.time()
140
- last_token_time = time.time()
129
+ metadata_tracker.record_token()
141
130
  if current_tool_inputs is not None:
142
131
  current_tool_inputs.append(delta.partial_json)
143
132
  case _:
@@ -184,37 +173,18 @@ class AnthropicClient(LLMClientABC):
184
173
  input_tokens += (event.usage.input_tokens or 0) + (event.usage.cache_creation_input_tokens or 0)
185
174
  output_tokens += event.usage.output_tokens or 0
186
175
  cached_tokens += event.usage.cache_read_input_tokens or 0
187
- total_tokens = input_tokens + cached_tokens + output_tokens
188
- context_usage_percent = (
189
- (total_tokens / param.context_limit) * 100 if param.context_limit else None
190
- )
191
-
192
- throughput_tps: float | None = None
193
- first_token_latency_ms: float | None = None
194
-
195
- if first_token_time is not None:
196
- first_token_latency_ms = (first_token_time - request_start_time) * 1000
197
176
 
198
- if first_token_time is not None and last_token_time is not None and output_tokens > 0:
199
- time_duration = last_token_time - first_token_time
200
- if time_duration >= 0.15:
201
- throughput_tps = output_tokens / time_duration
202
-
203
- usage = model.Usage(
177
+ usage = convert_anthropic_usage(
204
178
  input_tokens=input_tokens,
205
179
  output_tokens=output_tokens,
206
180
  cached_tokens=cached_tokens,
207
- total_tokens=total_tokens,
208
- context_usage_percent=context_usage_percent,
209
- throughput_tps=throughput_tps,
210
- first_token_latency_ms=first_token_latency_ms,
211
- )
212
- calculate_cost(usage, self._config.cost)
213
- yield model.ResponseMetadataItem(
214
- usage=usage,
215
- response_id=response_id,
216
- model_name=str(param.model),
181
+ context_limit=param.context_limit,
182
+ max_tokens=param.max_tokens,
217
183
  )
184
+ metadata_tracker.set_usage(usage)
185
+ metadata_tracker.set_model_name(str(param.model))
186
+ metadata_tracker.set_response_id(response_id)
187
+ yield metadata_tracker.finalize()
218
188
  case _:
219
189
  pass
220
190
  except (APIError, httpx.HTTPError) as e:
klaude_code/llm/client.py CHANGED
@@ -19,7 +19,7 @@ class LLMClientABC(ABC):
19
19
  @abstractmethod
20
20
  async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[model.ConversationItem, None]:
21
21
  raise NotImplementedError
22
- yield cast(model.ConversationItem, None) # pyright: ignore[reportUnreachable]
22
+ yield cast(model.ConversationItem, None)
23
23
 
24
24
  def get_llm_config(self) -> llm_param.LLMConfigParameter:
25
25
  return self._config
@@ -42,7 +42,7 @@ def call_with_logged_payload(func: Callable[P, R], *args: P.args, **kwargs: P.kw
42
42
 
43
43
  payload = {k: v for k, v in kwargs.items() if v is not None}
44
44
  log_debug(
45
- json.dumps(payload, ensure_ascii=False, default=str),
45
+ json.dumps(payload, ensure_ascii=False, default=str, sort_keys=True),
46
46
  style="yellow",
47
47
  debug_type=DebugType.LLM_PAYLOAD,
48
48
  )
@@ -1,6 +1,5 @@
1
1
  """Codex LLM client using ChatGPT subscription via OAuth."""
2
2
 
3
- import time
4
3
  from collections.abc import AsyncGenerator
5
4
  from typing import override
6
5
 
@@ -16,6 +15,7 @@ from klaude_code.llm.input_common import apply_config_defaults
16
15
  from klaude_code.llm.registry import register
17
16
  from klaude_code.llm.responses.client import parse_responses_stream
18
17
  from klaude_code.llm.responses.input import convert_history_to_input, convert_tool_schema
18
+ from klaude_code.llm.usage import MetadataTracker
19
19
  from klaude_code.protocol import llm_param, model
20
20
 
21
21
  # Codex API configuration
@@ -24,6 +24,7 @@ CODEX_HEADERS = {
24
24
  "originator": "codex_cli_rs",
25
25
  # Mocked Codex-style user agent string
26
26
  "User-Agent": "codex_cli_rs/0.0.0-klaude",
27
+ "OpenAI-Beta": "responses=experimental",
27
28
  }
28
29
 
29
30
 
@@ -83,7 +84,7 @@ class CodexClient(LLMClientABC):
83
84
  # Codex API requires store=False
84
85
  param.store = False
85
86
 
86
- request_start_time = time.time()
87
+ metadata_tracker = MetadataTracker(cost_config=self.get_llm_config().cost)
87
88
 
88
89
  inputs = convert_history_to_input(param.input, param.model)
89
90
  tools = convert_tool_schema(param.tools)
@@ -125,5 +126,5 @@ class CodexClient(LLMClientABC):
125
126
  yield model.StreamErrorItem(error=f"{e.__class__.__name__} {str(e)}")
126
127
  return
127
128
 
128
- async for item in parse_responses_stream(stream, param, self._config.cost, request_start_time):
129
+ async for item in parse_responses_stream(stream, param, metadata_tracker):
129
130
  yield item