klaude-code 1.2.6__py3-none-any.whl → 1.8.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 (205) hide show
  1. klaude_code/auth/__init__.py +24 -0
  2. klaude_code/auth/codex/__init__.py +20 -0
  3. klaude_code/auth/codex/exceptions.py +17 -0
  4. klaude_code/auth/codex/jwt_utils.py +45 -0
  5. klaude_code/auth/codex/oauth.py +229 -0
  6. klaude_code/auth/codex/token_manager.py +84 -0
  7. klaude_code/cli/auth_cmd.py +73 -0
  8. klaude_code/cli/config_cmd.py +91 -0
  9. klaude_code/cli/cost_cmd.py +338 -0
  10. klaude_code/cli/debug.py +78 -0
  11. klaude_code/cli/list_model.py +307 -0
  12. klaude_code/cli/main.py +233 -134
  13. klaude_code/cli/runtime.py +309 -117
  14. klaude_code/{version.py → cli/self_update.py} +114 -5
  15. klaude_code/cli/session_cmd.py +37 -21
  16. klaude_code/command/__init__.py +88 -27
  17. klaude_code/command/clear_cmd.py +8 -7
  18. klaude_code/command/command_abc.py +31 -31
  19. klaude_code/command/debug_cmd.py +79 -0
  20. klaude_code/command/export_cmd.py +19 -53
  21. klaude_code/command/export_online_cmd.py +154 -0
  22. klaude_code/command/fork_session_cmd.py +267 -0
  23. klaude_code/command/help_cmd.py +7 -8
  24. klaude_code/command/model_cmd.py +60 -10
  25. klaude_code/command/model_select.py +84 -0
  26. klaude_code/command/prompt-jj-describe.md +32 -0
  27. klaude_code/command/prompt_command.py +19 -11
  28. klaude_code/command/refresh_cmd.py +8 -10
  29. klaude_code/command/registry.py +139 -40
  30. klaude_code/command/release_notes_cmd.py +84 -0
  31. klaude_code/command/resume_cmd.py +111 -0
  32. klaude_code/command/status_cmd.py +104 -60
  33. klaude_code/command/terminal_setup_cmd.py +7 -9
  34. klaude_code/command/thinking_cmd.py +98 -0
  35. klaude_code/config/__init__.py +14 -6
  36. klaude_code/config/assets/__init__.py +1 -0
  37. klaude_code/config/assets/builtin_config.yaml +303 -0
  38. klaude_code/config/builtin_config.py +38 -0
  39. klaude_code/config/config.py +378 -109
  40. klaude_code/config/select_model.py +117 -53
  41. klaude_code/config/thinking.py +269 -0
  42. klaude_code/{const/__init__.py → const.py} +50 -19
  43. klaude_code/core/agent.py +20 -28
  44. klaude_code/core/executor.py +327 -112
  45. klaude_code/core/manager/__init__.py +2 -4
  46. klaude_code/core/manager/llm_clients.py +1 -15
  47. klaude_code/core/manager/llm_clients_builder.py +10 -11
  48. klaude_code/core/manager/sub_agent_manager.py +37 -6
  49. klaude_code/core/prompt.py +63 -44
  50. klaude_code/core/prompts/prompt-claude-code.md +2 -13
  51. klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +117 -0
  52. klaude_code/core/prompts/prompt-codex-gpt-5-2-codex.md +117 -0
  53. klaude_code/core/prompts/prompt-codex.md +9 -42
  54. klaude_code/core/prompts/prompt-minimal.md +12 -0
  55. klaude_code/core/prompts/{prompt-subagent-explore.md → prompt-sub-agent-explore.md} +16 -3
  56. klaude_code/core/prompts/{prompt-subagent-oracle.md → prompt-sub-agent-oracle.md} +1 -2
  57. klaude_code/core/prompts/prompt-sub-agent-web.md +51 -0
  58. klaude_code/core/reminders.py +283 -95
  59. klaude_code/core/task.py +113 -75
  60. klaude_code/core/tool/__init__.py +24 -31
  61. klaude_code/core/tool/file/_utils.py +36 -0
  62. klaude_code/core/tool/file/apply_patch.py +17 -25
  63. klaude_code/core/tool/file/apply_patch_tool.py +57 -77
  64. klaude_code/core/tool/file/diff_builder.py +151 -0
  65. klaude_code/core/tool/file/edit_tool.py +50 -63
  66. klaude_code/core/tool/file/move_tool.md +41 -0
  67. klaude_code/core/tool/file/move_tool.py +435 -0
  68. klaude_code/core/tool/file/read_tool.md +1 -1
  69. klaude_code/core/tool/file/read_tool.py +86 -86
  70. klaude_code/core/tool/file/write_tool.py +59 -69
  71. klaude_code/core/tool/report_back_tool.py +84 -0
  72. klaude_code/core/tool/shell/bash_tool.py +265 -22
  73. klaude_code/core/tool/shell/command_safety.py +3 -6
  74. klaude_code/core/tool/{memory → skill}/skill_tool.py +16 -26
  75. klaude_code/core/tool/sub_agent_tool.py +13 -2
  76. klaude_code/core/tool/todo/todo_write_tool.md +0 -157
  77. klaude_code/core/tool/todo/todo_write_tool.py +1 -1
  78. klaude_code/core/tool/todo/todo_write_tool_raw.md +182 -0
  79. klaude_code/core/tool/todo/update_plan_tool.py +1 -1
  80. klaude_code/core/tool/tool_abc.py +18 -0
  81. klaude_code/core/tool/tool_context.py +27 -12
  82. klaude_code/core/tool/tool_registry.py +7 -7
  83. klaude_code/core/tool/tool_runner.py +44 -36
  84. klaude_code/core/tool/truncation.py +29 -14
  85. klaude_code/core/tool/web/mermaid_tool.md +43 -0
  86. klaude_code/core/tool/web/mermaid_tool.py +2 -5
  87. klaude_code/core/tool/web/web_fetch_tool.md +1 -1
  88. klaude_code/core/tool/web/web_fetch_tool.py +112 -22
  89. klaude_code/core/tool/web/web_search_tool.md +23 -0
  90. klaude_code/core/tool/web/web_search_tool.py +130 -0
  91. klaude_code/core/turn.py +168 -66
  92. klaude_code/llm/__init__.py +2 -10
  93. klaude_code/llm/anthropic/client.py +190 -178
  94. klaude_code/llm/anthropic/input.py +39 -15
  95. klaude_code/llm/bedrock/__init__.py +3 -0
  96. klaude_code/llm/bedrock/client.py +60 -0
  97. klaude_code/llm/client.py +7 -21
  98. klaude_code/llm/codex/__init__.py +5 -0
  99. klaude_code/llm/codex/client.py +149 -0
  100. klaude_code/llm/google/__init__.py +3 -0
  101. klaude_code/llm/google/client.py +309 -0
  102. klaude_code/llm/google/input.py +215 -0
  103. klaude_code/llm/input_common.py +3 -9
  104. klaude_code/llm/openai_compatible/client.py +72 -164
  105. klaude_code/llm/openai_compatible/input.py +6 -4
  106. klaude_code/llm/openai_compatible/stream.py +273 -0
  107. klaude_code/llm/openai_compatible/tool_call_accumulator.py +17 -1
  108. klaude_code/llm/openrouter/client.py +89 -160
  109. klaude_code/llm/openrouter/input.py +18 -30
  110. klaude_code/llm/openrouter/reasoning.py +118 -0
  111. klaude_code/llm/registry.py +39 -7
  112. klaude_code/llm/responses/client.py +184 -171
  113. klaude_code/llm/responses/input.py +20 -1
  114. klaude_code/llm/usage.py +17 -12
  115. klaude_code/protocol/commands.py +17 -1
  116. klaude_code/protocol/events.py +31 -4
  117. klaude_code/protocol/llm_param.py +13 -10
  118. klaude_code/protocol/model.py +232 -29
  119. klaude_code/protocol/op.py +90 -1
  120. klaude_code/protocol/op_handler.py +35 -1
  121. klaude_code/protocol/sub_agent/__init__.py +117 -0
  122. klaude_code/protocol/sub_agent/explore.py +63 -0
  123. klaude_code/protocol/sub_agent/oracle.py +91 -0
  124. klaude_code/protocol/sub_agent/task.py +61 -0
  125. klaude_code/protocol/sub_agent/web.py +79 -0
  126. klaude_code/protocol/tools.py +4 -2
  127. klaude_code/session/__init__.py +2 -2
  128. klaude_code/session/codec.py +71 -0
  129. klaude_code/session/export.py +293 -86
  130. klaude_code/session/selector.py +89 -67
  131. klaude_code/session/session.py +320 -309
  132. klaude_code/session/store.py +220 -0
  133. klaude_code/session/templates/export_session.html +595 -83
  134. klaude_code/session/templates/mermaid_viewer.html +926 -0
  135. klaude_code/skill/__init__.py +27 -0
  136. klaude_code/skill/assets/deslop/SKILL.md +17 -0
  137. klaude_code/skill/assets/dev-docs/SKILL.md +108 -0
  138. klaude_code/skill/assets/handoff/SKILL.md +39 -0
  139. klaude_code/skill/assets/jj-workspace/SKILL.md +20 -0
  140. klaude_code/skill/assets/skill-creator/SKILL.md +139 -0
  141. klaude_code/{core/tool/memory/skill_loader.py → skill/loader.py} +55 -15
  142. klaude_code/skill/manager.py +70 -0
  143. klaude_code/skill/system_skills.py +192 -0
  144. klaude_code/trace/__init__.py +20 -2
  145. klaude_code/trace/log.py +150 -5
  146. klaude_code/ui/__init__.py +4 -9
  147. klaude_code/ui/core/input.py +1 -1
  148. klaude_code/ui/core/stage_manager.py +7 -7
  149. klaude_code/ui/modes/debug/display.py +2 -1
  150. klaude_code/ui/modes/repl/__init__.py +3 -48
  151. klaude_code/ui/modes/repl/clipboard.py +5 -5
  152. klaude_code/ui/modes/repl/completers.py +487 -123
  153. klaude_code/ui/modes/repl/display.py +5 -4
  154. klaude_code/ui/modes/repl/event_handler.py +370 -117
  155. klaude_code/ui/modes/repl/input_prompt_toolkit.py +552 -105
  156. klaude_code/ui/modes/repl/key_bindings.py +146 -23
  157. klaude_code/ui/modes/repl/renderer.py +189 -99
  158. klaude_code/ui/renderers/assistant.py +9 -2
  159. klaude_code/ui/renderers/bash_syntax.py +178 -0
  160. klaude_code/ui/renderers/common.py +78 -0
  161. klaude_code/ui/renderers/developer.py +104 -48
  162. klaude_code/ui/renderers/diffs.py +87 -6
  163. klaude_code/ui/renderers/errors.py +11 -6
  164. klaude_code/ui/renderers/mermaid_viewer.py +57 -0
  165. klaude_code/ui/renderers/metadata.py +112 -76
  166. klaude_code/ui/renderers/sub_agent.py +92 -7
  167. klaude_code/ui/renderers/thinking.py +40 -18
  168. klaude_code/ui/renderers/tools.py +405 -227
  169. klaude_code/ui/renderers/user_input.py +73 -13
  170. klaude_code/ui/rich/__init__.py +10 -1
  171. klaude_code/ui/rich/cjk_wrap.py +228 -0
  172. klaude_code/ui/rich/code_panel.py +131 -0
  173. klaude_code/ui/rich/live.py +17 -0
  174. klaude_code/ui/rich/markdown.py +305 -170
  175. klaude_code/ui/rich/searchable_text.py +10 -13
  176. klaude_code/ui/rich/status.py +190 -49
  177. klaude_code/ui/rich/theme.py +135 -39
  178. klaude_code/ui/terminal/__init__.py +55 -0
  179. klaude_code/ui/terminal/color.py +1 -1
  180. klaude_code/ui/terminal/control.py +13 -22
  181. klaude_code/ui/terminal/notifier.py +44 -4
  182. klaude_code/ui/terminal/selector.py +658 -0
  183. klaude_code/ui/utils/common.py +0 -18
  184. klaude_code-1.8.0.dist-info/METADATA +377 -0
  185. klaude_code-1.8.0.dist-info/RECORD +219 -0
  186. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/entry_points.txt +1 -0
  187. klaude_code/command/diff_cmd.py +0 -138
  188. klaude_code/command/prompt-dev-docs-update.md +0 -56
  189. klaude_code/command/prompt-dev-docs.md +0 -46
  190. klaude_code/config/list_model.py +0 -162
  191. klaude_code/core/manager/agent_manager.py +0 -127
  192. klaude_code/core/prompts/prompt-subagent-webfetch.md +0 -46
  193. klaude_code/core/tool/file/multi_edit_tool.md +0 -42
  194. klaude_code/core/tool/file/multi_edit_tool.py +0 -199
  195. klaude_code/core/tool/memory/memory_tool.md +0 -16
  196. klaude_code/core/tool/memory/memory_tool.py +0 -462
  197. klaude_code/llm/openrouter/reasoning_handler.py +0 -209
  198. klaude_code/protocol/sub_agent.py +0 -348
  199. klaude_code/ui/utils/debouncer.py +0 -42
  200. klaude_code-1.2.6.dist-info/METADATA +0 -178
  201. klaude_code-1.2.6.dist-info/RECORD +0 -167
  202. /klaude_code/core/prompts/{prompt-subagent.md → prompt-sub-agent.md} +0 -0
  203. /klaude_code/core/tool/{memory → skill}/__init__.py +0 -0
  204. /klaude_code/core/tool/{memory → skill}/skill_tool.md +0 -0
  205. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/WHEEL +0 -0
@@ -5,10 +5,10 @@ This module provides shared abstractions for providers that require message grou
5
5
  since it uses a flat item list matching our internal protocol.
6
6
  """
7
7
 
8
- from collections.abc import Iterator
8
+ from collections.abc import Iterable, Iterator
9
9
  from dataclasses import dataclass, field
10
10
  from enum import Enum
11
- from typing import TYPE_CHECKING, Iterable
11
+ from typing import TYPE_CHECKING
12
12
 
13
13
  from klaude_code import const
14
14
 
@@ -49,10 +49,6 @@ class AssistantGroup:
49
49
 
50
50
  text_content: str | None = None
51
51
  tool_calls: list[model.ToolCallItem] = field(default_factory=lambda: [])
52
- reasoning_text: list[model.ReasoningTextItem] = field(default_factory=lambda: [])
53
- reasoning_encrypted: list[model.ReasoningEncryptedItem] = field(default_factory=lambda: [])
54
- # Preserve original ordering of reasoning items for providers that
55
- # need to emit them as an ordered stream (e.g. OpenRouter).
56
52
  reasoning_items: list[model.ReasoningTextItem | model.ReasoningEncryptedItem] = field(default_factory=lambda: [])
57
53
 
58
54
 
@@ -153,7 +149,7 @@ def parse_message_groups(history: list[model.ConversationItem]) -> list[MessageG
153
149
  for item in items:
154
150
  if isinstance(item, (model.UserMessageItem, model.DeveloperMessageItem)):
155
151
  if item.content:
156
- group.text_parts.append(item.content)
152
+ group.text_parts.append(item.content + "\n")
157
153
  if item.images:
158
154
  group.images.extend(item.images)
159
155
  groups.append(group)
@@ -184,10 +180,8 @@ def parse_message_groups(history: list[model.ConversationItem]) -> list[MessageG
184
180
  case model.ToolCallItem():
185
181
  group.tool_calls.append(item)
186
182
  case model.ReasoningTextItem():
187
- group.reasoning_text.append(item)
188
183
  group.reasoning_items.append(item)
189
184
  case model.ReasoningEncryptedItem():
190
- group.reasoning_encrypted.append(item)
191
185
  group.reasoning_items.append(item)
192
186
  case _:
193
187
  pass
@@ -1,21 +1,50 @@
1
1
  import json
2
2
  from collections.abc import AsyncGenerator
3
- from typing import Literal, override
3
+ from typing import Any, override
4
4
 
5
5
  import httpx
6
6
  import openai
7
- from openai import APIError, RateLimitError
7
+ from openai.types.chat.completion_create_params import CompletionCreateParamsStreaming
8
8
 
9
- from klaude_code.llm.client import LLMClientABC, call_with_logged_payload
9
+ from klaude_code.llm.client import LLMClientABC
10
10
  from klaude_code.llm.input_common import apply_config_defaults
11
11
  from klaude_code.llm.openai_compatible.input import convert_history_to_input, convert_tool_schema
12
- from klaude_code.llm.openai_compatible.tool_call_accumulator import BasicToolCallAccumulator, ToolCallAccumulatorABC
12
+ from klaude_code.llm.openai_compatible.stream import DefaultReasoningHandler, parse_chat_completions_stream
13
13
  from klaude_code.llm.registry import register
14
- from klaude_code.llm.usage import MetadataTracker, convert_usage
14
+ from klaude_code.llm.usage import MetadataTracker
15
15
  from klaude_code.protocol import llm_param, model
16
16
  from klaude_code.trace import DebugType, log_debug
17
17
 
18
18
 
19
+ def build_payload(param: llm_param.LLMCallParameter) -> tuple[CompletionCreateParamsStreaming, dict[str, object]]:
20
+ """Build OpenAI API request parameters."""
21
+ messages = convert_history_to_input(param.input, param.system, param.model)
22
+ tools = convert_tool_schema(param.tools)
23
+
24
+ extra_body: dict[str, object] = {}
25
+
26
+ if param.thinking and param.thinking.type == "enabled":
27
+ extra_body["thinking"] = {
28
+ "type": param.thinking.type,
29
+ "budget": param.thinking.budget_tokens,
30
+ }
31
+
32
+ payload: CompletionCreateParamsStreaming = {
33
+ "model": str(param.model),
34
+ "tool_choice": "auto",
35
+ "parallel_tool_calls": True,
36
+ "stream": True,
37
+ "messages": messages,
38
+ "temperature": param.temperature,
39
+ "max_tokens": param.max_tokens,
40
+ "tools": tools,
41
+ "reasoning_effort": param.thinking.reasoning_effort if param.thinking else None,
42
+ "verbosity": param.verbosity,
43
+ }
44
+
45
+ return payload, extra_body
46
+
47
+
19
48
  @register(llm_param.LLMClientProtocol.OPENAI)
20
49
  class OpenAICompatibleClient(LLMClientABC):
21
50
  def __init__(self, config: llm_param.LLMConfigParameter):
@@ -43,169 +72,48 @@ class OpenAICompatibleClient(LLMClientABC):
43
72
  return cls(config)
44
73
 
45
74
  @override
46
- async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[model.ConversationItem, None]:
75
+ async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[model.ConversationItem]:
47
76
  param = apply_config_defaults(param, self.get_llm_config())
48
- messages = convert_history_to_input(param.input, param.system, param.model)
49
- tools = convert_tool_schema(param.tools)
50
-
51
- metadata_tracker = MetadataTracker(cost_config=self._config.cost)
52
-
53
- extra_body = {}
54
- extra_headers = {"extra": json.dumps({"session_id": param.session_id})}
55
-
56
- if param.thinking:
57
- extra_body["thinking"] = {
58
- "type": param.thinking.type,
59
- "budget": param.thinking.budget_tokens,
60
- }
61
- stream = call_with_logged_payload(
62
- self.client.chat.completions.create,
63
- model=str(param.model),
64
- tool_choice="auto",
65
- parallel_tool_calls=True,
66
- stream=True,
67
- messages=messages,
68
- temperature=param.temperature,
69
- max_tokens=param.max_tokens,
70
- tools=tools,
71
- reasoning_effort=param.thinking.reasoning_effort if param.thinking else None,
72
- verbosity=param.verbosity,
73
- extra_body=extra_body, # pyright: ignore[reportUnknownArgumentType]
74
- extra_headers=extra_headers,
75
- )
76
77
 
77
- stage: Literal["waiting", "reasoning", "assistant", "tool", "done"] = "waiting"
78
- accumulated_reasoning: list[str] = []
79
- accumulated_content: list[str] = []
80
- accumulated_tool_calls: ToolCallAccumulatorABC = BasicToolCallAccumulator()
81
- emitted_tool_start_indices: set[int] = set()
82
- response_id: str | None = None
83
-
84
- def flush_reasoning_items() -> list[model.ConversationItem]:
85
- nonlocal accumulated_reasoning
86
- if not accumulated_reasoning:
87
- return []
88
- item = model.ReasoningTextItem(
89
- content="".join(accumulated_reasoning),
90
- response_id=response_id,
91
- model=str(param.model),
92
- )
93
- accumulated_reasoning = []
94
- return [item]
95
-
96
- def flush_assistant_items() -> list[model.ConversationItem]:
97
- nonlocal accumulated_content
98
- if len(accumulated_content) == 0:
99
- return []
100
- item = model.AssistantMessageItem(
101
- content="".join(accumulated_content),
102
- response_id=response_id,
103
- )
104
- accumulated_content = []
105
- return [item]
78
+ metadata_tracker = MetadataTracker(cost_config=self.get_llm_config().cost)
106
79
 
107
- def flush_tool_call_items() -> list[model.ToolCallItem]:
108
- nonlocal accumulated_tool_calls
109
- items: list[model.ToolCallItem] = accumulated_tool_calls.get()
110
- if items:
111
- accumulated_tool_calls.chunks_by_step = [] # pyright: ignore[reportAttributeAccessIssue]
112
- return items
80
+ payload, extra_body = build_payload(param)
81
+ extra_headers: dict[str, str] = {"extra": json.dumps({"session_id": param.session_id}, sort_keys=True)}
113
82
 
114
- try:
115
- async for event in await stream:
116
- log_debug(
117
- event.model_dump_json(exclude_none=True),
118
- style="blue",
119
- debug_type=DebugType.LLM_STREAM,
120
- )
121
- if not response_id and event.id:
122
- response_id = event.id
123
- accumulated_tool_calls.response_id = response_id
124
- yield model.StartItem(response_id=response_id)
125
- if (
126
- event.usage is not None and event.usage.completion_tokens is not None # pyright: ignore[reportUnnecessaryComparison] gcp gemini will return None usage field
127
- ):
128
- metadata_tracker.set_usage(convert_usage(event.usage, param.context_limit))
129
- if event.model:
130
- metadata_tracker.set_model_name(event.model)
131
- if provider := getattr(event, "provider", None):
132
- metadata_tracker.set_provider(str(provider))
133
-
134
- if len(event.choices) == 0:
135
- continue
136
- delta = event.choices[0].delta
137
-
138
- # Support Kimi K2's usage field in choice
139
- if hasattr(event.choices[0], "usage") and getattr(event.choices[0], "usage"):
140
- metadata_tracker.set_usage(
141
- convert_usage(
142
- openai.types.CompletionUsage.model_validate(getattr(event.choices[0], "usage")),
143
- param.context_limit,
144
- )
145
- )
146
-
147
- # Reasoning
148
- reasoning_content = ""
149
- if hasattr(delta, "reasoning") and getattr(delta, "reasoning"):
150
- reasoning_content = getattr(delta, "reasoning")
151
- if hasattr(delta, "reasoning_content") and getattr(delta, "reasoning_content"):
152
- reasoning_content = getattr(delta, "reasoning_content")
153
- if reasoning_content:
154
- metadata_tracker.record_token()
155
- stage = "reasoning"
156
- accumulated_reasoning.append(reasoning_content)
157
-
158
- # Assistant
159
- if delta.content and (
160
- stage == "assistant" or delta.content.strip()
161
- ): # Process all content in assistant stage, filter empty content in reasoning stage
162
- metadata_tracker.record_token()
163
- if stage == "reasoning":
164
- for item in flush_reasoning_items():
165
- yield item
166
- elif stage == "tool":
167
- for item in flush_tool_call_items():
168
- yield item
169
- stage = "assistant"
170
- accumulated_content.append(delta.content)
171
- yield model.AssistantMessageDelta(
172
- content=delta.content,
173
- response_id=response_id,
174
- )
175
-
176
- # Tool
177
- if delta.tool_calls and len(delta.tool_calls) > 0:
178
- metadata_tracker.record_token()
179
- if stage == "reasoning":
180
- for item in flush_reasoning_items():
181
- yield item
182
- elif stage == "assistant":
183
- for item in flush_assistant_items():
184
- yield item
185
- stage = "tool"
186
- # Emit ToolCallStartItem for new tool calls
187
- for tc in delta.tool_calls:
188
- if tc.index not in emitted_tool_start_indices and tc.function and tc.function.name:
189
- emitted_tool_start_indices.add(tc.index)
190
- yield model.ToolCallStartItem(
191
- response_id=response_id,
192
- call_id=tc.id or "",
193
- name=tc.function.name,
194
- )
195
- accumulated_tool_calls.add(delta.tool_calls)
196
- except (RateLimitError, APIError) as e:
197
- yield model.StreamErrorItem(error=f"{e.__class__.__name__} {str(e)}")
198
-
199
- # Finalize
200
- for item in flush_reasoning_items():
201
- yield item
83
+ log_debug(
84
+ json.dumps({**payload, **extra_body}, ensure_ascii=False, default=str),
85
+ style="yellow",
86
+ debug_type=DebugType.LLM_PAYLOAD,
87
+ )
202
88
 
203
- for item in flush_assistant_items():
204
- yield item
89
+ try:
90
+ stream = await self.client.chat.completions.create(
91
+ **payload,
92
+ extra_body=extra_body,
93
+ extra_headers=extra_headers,
94
+ )
95
+ except (openai.OpenAIError, httpx.HTTPError) as e:
96
+ yield model.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
97
+ yield metadata_tracker.finalize()
98
+ return
99
+
100
+ reasoning_handler = DefaultReasoningHandler(
101
+ param_model=str(param.model),
102
+ response_id=None,
103
+ )
205
104
 
206
- if stage == "tool":
207
- for tool_call_item in flush_tool_call_items():
208
- yield tool_call_item
105
+ def on_event(event: Any) -> None:
106
+ log_debug(
107
+ event.model_dump_json(exclude_none=True),
108
+ style="blue",
109
+ debug_type=DebugType.LLM_STREAM,
110
+ )
209
111
 
210
- metadata_tracker.set_response_id(response_id)
211
- yield metadata_tracker.finalize()
112
+ async for item in parse_chat_completions_stream(
113
+ stream,
114
+ param=param,
115
+ metadata_tracker=metadata_tracker,
116
+ reasoning_handler=reasoning_handler,
117
+ on_event=on_event,
118
+ ):
119
+ yield item
@@ -10,7 +10,8 @@ from klaude_code.llm.input_common import AssistantGroup, ToolGroup, UserGroup, m
10
10
  from klaude_code.protocol import llm_param, model
11
11
 
12
12
 
13
- def _user_group_to_message(group: UserGroup) -> chat.ChatCompletionMessageParam:
13
+ def user_group_to_openai_message(group: UserGroup) -> chat.ChatCompletionMessageParam:
14
+ """Convert a UserGroup to an OpenAI-compatible chat message."""
14
15
  parts: list[ChatCompletionContentPartParam] = []
15
16
  for text in group.text_parts:
16
17
  parts.append({"type": "text", "text": text + "\n"})
@@ -21,7 +22,8 @@ def _user_group_to_message(group: UserGroup) -> chat.ChatCompletionMessageParam:
21
22
  return {"role": "user", "content": parts}
22
23
 
23
24
 
24
- def _tool_group_to_message(group: ToolGroup) -> chat.ChatCompletionMessageParam:
25
+ def tool_group_to_openai_message(group: ToolGroup) -> chat.ChatCompletionMessageParam:
26
+ """Convert a ToolGroup to an OpenAI-compatible chat message."""
25
27
  merged_text = merge_reminder_text(
26
28
  group.tool_result.output or "<system-reminder>Tool ran without output or errors</system-reminder>",
27
29
  group.reminder_texts,
@@ -82,9 +84,9 @@ def convert_history_to_input(
82
84
  for group in parse_message_groups(history):
83
85
  match group:
84
86
  case UserGroup():
85
- messages.append(_user_group_to_message(group))
87
+ messages.append(user_group_to_openai_message(group))
86
88
  case ToolGroup():
87
- messages.append(_tool_group_to_message(group))
89
+ messages.append(tool_group_to_openai_message(group))
88
90
  case AssistantGroup():
89
91
  messages.append(_assistant_group_to_message(group))
90
92
 
@@ -0,0 +1,273 @@
1
+ """Shared stream processing utilities for Chat Completions streaming.
2
+
3
+ This module provides reusable primitives for OpenAI-compatible providers:
4
+
5
+ - ``StreamStateManager``: accumulates assistant content and tool calls.
6
+ - ``ReasoningHandlerABC``: provider-specific reasoning extraction + buffering.
7
+ - ``parse_chat_completions_stream``: shared stream loop that emits ConversationItems.
8
+
9
+ OpenRouter uses the same OpenAI Chat Completions API surface but differs in
10
+ how reasoning is represented (``reasoning_details`` vs ``reasoning_content``).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from abc import ABC, abstractmethod
16
+ from collections.abc import AsyncGenerator, Callable
17
+ from dataclasses import dataclass
18
+ from typing import Any, Literal, cast
19
+
20
+ import httpx
21
+ import openai
22
+ import openai.types
23
+ import pydantic
24
+ from openai import AsyncStream
25
+ from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
26
+
27
+ from klaude_code.llm.openai_compatible.tool_call_accumulator import BasicToolCallAccumulator, ToolCallAccumulatorABC
28
+ from klaude_code.llm.usage import MetadataTracker, convert_usage
29
+ from klaude_code.protocol import llm_param, model
30
+
31
+ StreamStage = Literal["waiting", "reasoning", "assistant", "tool"]
32
+
33
+
34
+ class StreamStateManager:
35
+ """Manages streaming state and provides flush operations for accumulated content.
36
+
37
+ This class encapsulates the common state management logic used by both
38
+ OpenAI-compatible and OpenRouter clients, reducing code duplication.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ param_model: str,
44
+ response_id: str | None = None,
45
+ reasoning_flusher: Callable[[], list[model.ConversationItem]] | None = None,
46
+ ):
47
+ self.param_model = param_model
48
+ self.response_id = response_id
49
+ self.stage: StreamStage = "waiting"
50
+ self.accumulated_reasoning: list[str] = []
51
+ self.accumulated_content: list[str] = []
52
+ self.accumulated_tool_calls: ToolCallAccumulatorABC = BasicToolCallAccumulator()
53
+ self.emitted_tool_start_indices: set[int] = set()
54
+ self._reasoning_flusher = reasoning_flusher
55
+
56
+ def set_response_id(self, response_id: str) -> None:
57
+ """Set the response ID once received from the stream."""
58
+ self.response_id = response_id
59
+ self.accumulated_tool_calls.response_id = response_id # pyright: ignore[reportAttributeAccessIssue]
60
+
61
+ def flush_reasoning(self) -> list[model.ConversationItem]:
62
+ """Flush accumulated reasoning content and return items."""
63
+ if self._reasoning_flusher is not None:
64
+ return self._reasoning_flusher()
65
+ if not self.accumulated_reasoning:
66
+ return []
67
+ item = model.ReasoningTextItem(
68
+ content="".join(self.accumulated_reasoning),
69
+ response_id=self.response_id,
70
+ model=self.param_model,
71
+ )
72
+ self.accumulated_reasoning = []
73
+ return [item]
74
+
75
+ def flush_assistant(self) -> list[model.ConversationItem]:
76
+ """Flush accumulated assistant content and return items."""
77
+ if not self.accumulated_content:
78
+ return []
79
+ item = model.AssistantMessageItem(
80
+ content="".join(self.accumulated_content),
81
+ response_id=self.response_id,
82
+ )
83
+ self.accumulated_content = []
84
+ return [item]
85
+
86
+ def flush_tool_calls(self) -> list[model.ToolCallItem]:
87
+ """Flush accumulated tool calls and return items."""
88
+ items: list[model.ToolCallItem] = self.accumulated_tool_calls.get()
89
+ if items:
90
+ self.accumulated_tool_calls.chunks_by_step = [] # pyright: ignore[reportAttributeAccessIssue]
91
+ return items
92
+
93
+ def flush_all(self) -> list[model.ConversationItem]:
94
+ """Flush all accumulated content in order: reasoning, assistant, tool calls."""
95
+ items: list[model.ConversationItem] = []
96
+ items.extend(self.flush_reasoning())
97
+ items.extend(self.flush_assistant())
98
+ if self.stage == "tool":
99
+ items.extend(self.flush_tool_calls())
100
+ return items
101
+
102
+
103
+ @dataclass(slots=True)
104
+ class ReasoningDeltaResult:
105
+ """Result of processing a single provider delta for reasoning signals."""
106
+
107
+ handled: bool
108
+ outputs: list[str | model.ConversationItem]
109
+
110
+
111
+ class ReasoningHandlerABC(ABC):
112
+ """Provider-specific reasoning handler for Chat Completions streaming."""
113
+
114
+ @abstractmethod
115
+ def set_response_id(self, response_id: str | None) -> None:
116
+ """Update the response identifier used for emitted items."""
117
+
118
+ @abstractmethod
119
+ def on_delta(self, delta: object) -> ReasoningDeltaResult:
120
+ """Process a single delta and return ordered reasoning outputs."""
121
+
122
+ @abstractmethod
123
+ def flush(self) -> list[model.ConversationItem]:
124
+ """Flush buffered reasoning content (usually at stage transition/finalize)."""
125
+
126
+
127
+ class DefaultReasoningHandler(ReasoningHandlerABC):
128
+ """Handles OpenAI-compatible reasoning fields (reasoning_content / reasoning)."""
129
+
130
+ def __init__(
131
+ self,
132
+ *,
133
+ param_model: str,
134
+ response_id: str | None,
135
+ ) -> None:
136
+ self._param_model = param_model
137
+ self._response_id = response_id
138
+ self._accumulated: list[str] = []
139
+
140
+ def set_response_id(self, response_id: str | None) -> None:
141
+ self._response_id = response_id
142
+
143
+ def on_delta(self, delta: object) -> ReasoningDeltaResult:
144
+ reasoning_content = getattr(delta, "reasoning_content", None) or getattr(delta, "reasoning", None) or ""
145
+ if not reasoning_content:
146
+ return ReasoningDeltaResult(handled=False, outputs=[])
147
+ text = str(reasoning_content)
148
+ self._accumulated.append(text)
149
+ return ReasoningDeltaResult(handled=True, outputs=[text])
150
+
151
+ def flush(self) -> list[model.ConversationItem]:
152
+ if not self._accumulated:
153
+ return []
154
+ item = model.ReasoningTextItem(
155
+ content="".join(self._accumulated),
156
+ response_id=self._response_id,
157
+ model=self._param_model,
158
+ )
159
+ self._accumulated = []
160
+ return [item]
161
+
162
+
163
+ async def parse_chat_completions_stream(
164
+ stream: AsyncStream[ChatCompletionChunk],
165
+ *,
166
+ param: llm_param.LLMCallParameter,
167
+ metadata_tracker: MetadataTracker,
168
+ reasoning_handler: ReasoningHandlerABC,
169
+ on_event: Callable[[object], None] | None = None,
170
+ ) -> AsyncGenerator[model.ConversationItem]:
171
+ """Parse OpenAI Chat Completions stream into ConversationItems.
172
+
173
+ This is shared by OpenAI-compatible and OpenRouter clients.
174
+ """
175
+
176
+ state = StreamStateManager(
177
+ param_model=str(param.model),
178
+ reasoning_flusher=reasoning_handler.flush,
179
+ )
180
+
181
+ try:
182
+ async for event in stream:
183
+ if on_event is not None:
184
+ on_event(event)
185
+
186
+ if not state.response_id and (event_id := getattr(event, "id", None)):
187
+ state.set_response_id(str(event_id))
188
+ reasoning_handler.set_response_id(str(event_id))
189
+ yield model.StartItem(response_id=str(event_id))
190
+
191
+ if (event_usage := getattr(event, "usage", None)) is not None:
192
+ metadata_tracker.set_usage(convert_usage(event_usage, param.context_limit, param.max_tokens))
193
+ if event_model := getattr(event, "model", None):
194
+ metadata_tracker.set_model_name(str(event_model))
195
+ if provider := getattr(event, "provider", None):
196
+ metadata_tracker.set_provider(str(provider))
197
+
198
+ choices = cast(Any, getattr(event, "choices", None))
199
+ if not choices:
200
+ continue
201
+
202
+ # Support Moonshot Kimi K2's usage field in choice
203
+ choice0 = choices[0]
204
+ if choice_usage := getattr(choice0, "usage", None):
205
+ try:
206
+ usage = openai.types.CompletionUsage.model_validate(choice_usage)
207
+ metadata_tracker.set_usage(convert_usage(usage, param.context_limit, param.max_tokens))
208
+ except pydantic.ValidationError:
209
+ pass
210
+
211
+ delta = cast(Any, getattr(choice0, "delta", None))
212
+ if delta is None:
213
+ continue
214
+
215
+ # Reasoning
216
+ reasoning_result = reasoning_handler.on_delta(delta)
217
+ if reasoning_result.handled:
218
+ state.stage = "reasoning"
219
+ for output in reasoning_result.outputs:
220
+ if isinstance(output, str):
221
+ if not output:
222
+ continue
223
+ metadata_tracker.record_token()
224
+ yield model.ReasoningTextDelta(content=output, response_id=state.response_id)
225
+ else:
226
+ yield output
227
+
228
+ # Assistant
229
+ if (content := getattr(delta, "content", None)) and (state.stage == "assistant" or str(content).strip()):
230
+ metadata_tracker.record_token()
231
+ if state.stage == "reasoning":
232
+ for item in state.flush_reasoning():
233
+ yield item
234
+ elif state.stage == "tool":
235
+ for item in state.flush_tool_calls():
236
+ yield item
237
+ state.stage = "assistant"
238
+ state.accumulated_content.append(str(content))
239
+ yield model.AssistantMessageDelta(
240
+ content=str(content),
241
+ response_id=state.response_id,
242
+ )
243
+
244
+ # Tool
245
+ if (tool_calls := getattr(delta, "tool_calls", None)) and len(tool_calls) > 0:
246
+ metadata_tracker.record_token()
247
+ if state.stage == "reasoning":
248
+ for item in state.flush_reasoning():
249
+ yield item
250
+ elif state.stage == "assistant":
251
+ for item in state.flush_assistant():
252
+ yield item
253
+ state.stage = "tool"
254
+ for tc in tool_calls:
255
+ if tc.index not in state.emitted_tool_start_indices and tc.function and tc.function.name:
256
+ state.emitted_tool_start_indices.add(tc.index)
257
+ yield model.ToolCallStartItem(
258
+ response_id=state.response_id,
259
+ call_id=tc.id or "",
260
+ name=tc.function.name,
261
+ )
262
+ state.accumulated_tool_calls.add(tool_calls)
263
+ except (openai.OpenAIError, httpx.HTTPError) as e:
264
+ yield model.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
265
+
266
+ flushed_items = state.flush_all()
267
+ if flushed_items:
268
+ metadata_tracker.record_token()
269
+ for item in flushed_items:
270
+ yield item
271
+
272
+ metadata_tracker.set_response_id(state.response_id)
273
+ yield metadata_tracker.finalize()
@@ -1,9 +1,25 @@
1
+ import re
1
2
  from abc import ABC, abstractmethod
2
3
 
3
4
  from openai.types.chat.chat_completion_chunk import ChoiceDeltaToolCall
4
5
  from pydantic import BaseModel, Field
5
6
 
6
7
  from klaude_code.protocol import model
8
+ from klaude_code.trace.log import log_debug
9
+
10
+
11
+ def normalize_tool_name(name: str) -> str:
12
+ """Normalize tool name from Gemini-3 format.
13
+
14
+ Gemini-3 sometimes returns tool names in format like 'tool_Edit_mUoY2p3W3r3z8uO5P2nZ'.
15
+ This function extracts the actual tool name (e.g., 'Edit').
16
+ """
17
+ match = re.match(r"^tool_([A-Za-z]+)_[A-Za-z0-9]+$", name)
18
+ if match:
19
+ normalized = match.group(1)
20
+ log_debug(f"Gemini-3 tool name normalized: {name} -> {normalized}", style="yellow")
21
+ return normalized
22
+ return name
7
23
 
8
24
 
9
25
  class ToolCallAccumulatorABC(ABC):
@@ -74,7 +90,7 @@ class BasicToolCallAccumulator(ToolCallAccumulatorABC, BaseModel):
74
90
  if first_chunk.function is None:
75
91
  continue
76
92
  if first_chunk.function.name:
77
- result[-1].name = first_chunk.function.name
93
+ result[-1].name = normalize_tool_name(first_chunk.function.name)
78
94
  if first_chunk.function.arguments:
79
95
  result[-1].arguments += first_chunk.function.arguments
80
96
  return result