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
@@ -1,19 +1,69 @@
1
+ import json
1
2
  from collections.abc import AsyncGenerator
2
- from typing import Literal, override
3
+ from typing import Any, override
3
4
 
4
5
  import httpx
5
6
  import openai
7
+ from openai.types.chat.completion_create_params import CompletionCreateParamsStreaming
6
8
 
7
- from klaude_code.llm.client import LLMClientABC, call_with_logged_payload
9
+ from klaude_code.llm.client import LLMClientABC
8
10
  from klaude_code.llm.input_common import apply_config_defaults
9
11
  from klaude_code.llm.openai_compatible.input import convert_tool_schema
10
- from klaude_code.llm.openai_compatible.tool_call_accumulator import BasicToolCallAccumulator, ToolCallAccumulatorABC
12
+ from klaude_code.llm.openai_compatible.stream import parse_chat_completions_stream
11
13
  from klaude_code.llm.openrouter.input import convert_history_to_input, is_claude_model
12
- from klaude_code.llm.openrouter.reasoning_handler import ReasoningDetail, ReasoningStreamHandler
14
+ from klaude_code.llm.openrouter.reasoning import ReasoningStreamHandler
13
15
  from klaude_code.llm.registry import register
14
- from klaude_code.llm.usage import MetadataTracker, convert_usage
16
+ from klaude_code.llm.usage import MetadataTracker
15
17
  from klaude_code.protocol import llm_param, model
16
- from klaude_code.trace import DebugType, log, log_debug
18
+ from klaude_code.trace import DebugType, is_debug_enabled, log_debug
19
+
20
+
21
+ def build_payload(
22
+ param: llm_param.LLMCallParameter,
23
+ ) -> tuple[CompletionCreateParamsStreaming, dict[str, object], dict[str, str]]:
24
+ """Build OpenRouter API request parameters."""
25
+ messages = convert_history_to_input(param.input, param.system, param.model)
26
+ tools = convert_tool_schema(param.tools)
27
+
28
+ extra_body: dict[str, object] = {
29
+ "usage": {"include": True}, # To get the cache tokens at the end of the response
30
+ }
31
+ if is_debug_enabled():
32
+ extra_body["debug"] = {
33
+ "echo_upstream_body": True
34
+ } # https://openrouter.ai/docs/api/reference/errors-and-debugging#debug-option-shape
35
+ extra_headers: dict[str, str] = {}
36
+
37
+ if param.thinking:
38
+ if param.thinking.type != "disabled" and param.thinking.budget_tokens is not None:
39
+ extra_body["reasoning"] = {
40
+ "max_tokens": param.thinking.budget_tokens,
41
+ "enable": True,
42
+ } # OpenRouter: https://openrouter.ai/docs/use-cases/reasoning-tokens#anthropic-models-with-reasoning-tokens
43
+ elif param.thinking.reasoning_effort is not None:
44
+ extra_body["reasoning"] = {
45
+ "effort": param.thinking.reasoning_effort,
46
+ }
47
+
48
+ if param.provider_routing:
49
+ extra_body["provider"] = param.provider_routing.model_dump(exclude_none=True)
50
+
51
+ if is_claude_model(param.model):
52
+ extra_headers["x-anthropic-beta"] = "fine-grained-tool-streaming-2025-05-14,interleaved-thinking-2025-05-14"
53
+
54
+ payload: CompletionCreateParamsStreaming = {
55
+ "model": str(param.model),
56
+ "tool_choice": "auto",
57
+ "parallel_tool_calls": True,
58
+ "stream": True,
59
+ "messages": messages,
60
+ "temperature": param.temperature,
61
+ "max_tokens": param.max_tokens,
62
+ "tools": tools,
63
+ "verbosity": param.verbosity,
64
+ }
65
+
66
+ return payload, extra_body, extra_headers
17
67
 
18
68
 
19
69
  @register(llm_param.LLMClientProtocol.OPENROUTER)
@@ -33,168 +83,47 @@ class OpenRouterClient(LLMClientABC):
33
83
  return cls(config)
34
84
 
35
85
  @override
36
- async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[model.ConversationItem, None]:
86
+ async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[model.ConversationItem]:
37
87
  param = apply_config_defaults(param, self.get_llm_config())
38
- messages = convert_history_to_input(param.input, param.system, param.model)
39
- tools = convert_tool_schema(param.tools)
40
-
41
- metadata_tracker = MetadataTracker(cost_config=self._config.cost)
42
-
43
- extra_body: dict[str, object] = {
44
- "usage": {"include": True} # To get the cache tokens at the end of the response
45
- }
46
- extra_headers = {}
47
-
48
- if param.thinking:
49
- if param.thinking.budget_tokens is not None:
50
- extra_body["reasoning"] = {
51
- "max_tokens": param.thinking.budget_tokens,
52
- "enable": True,
53
- } # OpenRouter: https://openrouter.ai/docs/use-cases/reasoning-tokens#anthropic-models-with-reasoning-tokens
54
- elif param.thinking.reasoning_effort is not None:
55
- extra_body["reasoning"] = {
56
- "effort": param.thinking.reasoning_effort,
57
- }
58
- if param.provider_routing:
59
- extra_body["provider"] = param.provider_routing.model_dump(exclude_none=True)
60
- if is_claude_model(param.model):
61
- extra_headers["anthropic-beta"] = (
62
- "interleaved-thinking-2025-05-14" # Not working yet, maybe OpenRouter's issue, or Anthropic: Interleaved thinking is only supported for tools used via the Messages API.
63
- )
64
88
 
65
- stream = call_with_logged_payload(
66
- self.client.chat.completions.create,
67
- model=str(param.model),
68
- tool_choice="auto",
69
- parallel_tool_calls=True,
70
- stream=True,
71
- messages=messages,
72
- temperature=param.temperature,
73
- max_tokens=param.max_tokens,
74
- tools=tools,
75
- verbosity=param.verbosity,
76
- extra_body=extra_body, # pyright: ignore[reportUnknownArgumentType]
77
- extra_headers=extra_headers, # pyright: ignore[reportUnknownArgumentType]
89
+ metadata_tracker = MetadataTracker(cost_config=self.get_llm_config().cost)
90
+
91
+ payload, extra_body, extra_headers = build_payload(param)
92
+
93
+ log_debug(
94
+ json.dumps({**payload, **extra_body}, ensure_ascii=False, default=str),
95
+ style="yellow",
96
+ debug_type=DebugType.LLM_PAYLOAD,
78
97
  )
79
98
 
80
- stage: Literal["waiting", "reasoning", "assistant", "tool", "done"] = "waiting"
81
- response_id: str | None = None
82
- accumulated_content: list[str] = []
83
- accumulated_tool_calls: ToolCallAccumulatorABC = BasicToolCallAccumulator()
84
- emitted_tool_start_indices: set[int] = set()
99
+ try:
100
+ stream = await self.client.chat.completions.create(
101
+ **payload,
102
+ extra_body=extra_body,
103
+ extra_headers=extra_headers,
104
+ )
105
+ except (openai.OpenAIError, httpx.HTTPError) as e:
106
+ yield model.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
107
+ yield metadata_tracker.finalize()
108
+ return
109
+
85
110
  reasoning_handler = ReasoningStreamHandler(
86
111
  param_model=str(param.model),
87
- response_id=response_id,
112
+ response_id=None,
88
113
  )
89
114
 
90
- def flush_reasoning_items() -> list[model.ConversationItem]:
91
- return reasoning_handler.flush()
92
-
93
- def flush_assistant_items() -> list[model.ConversationItem]:
94
- nonlocal accumulated_content
95
- if len(accumulated_content) == 0:
96
- return []
97
- item = model.AssistantMessageItem(
98
- content="".join(accumulated_content),
99
- response_id=response_id,
115
+ def on_event(event: Any) -> None:
116
+ log_debug(
117
+ event.model_dump_json(exclude_none=True),
118
+ style="blue",
119
+ debug_type=DebugType.LLM_STREAM,
100
120
  )
101
- accumulated_content = []
102
- return [item]
103
-
104
- def flush_tool_call_items() -> list[model.ToolCallItem]:
105
- nonlocal accumulated_tool_calls
106
- items: list[model.ToolCallItem] = accumulated_tool_calls.get()
107
- if items:
108
- accumulated_tool_calls.chunks_by_step = [] # pyright: ignore[reportAttributeAccessIssue]
109
- return items
110
121
 
111
- try:
112
- async for event in await stream:
113
- log_debug(
114
- event.model_dump_json(exclude_none=True),
115
- style="blue",
116
- debug_type=DebugType.LLM_STREAM,
117
- )
118
- if not response_id and event.id:
119
- response_id = event.id
120
- reasoning_handler.set_response_id(response_id)
121
- accumulated_tool_calls.response_id = response_id
122
- yield model.StartItem(response_id=response_id)
123
- if (
124
- event.usage is not None and event.usage.completion_tokens is not None # pyright: ignore[reportUnnecessaryComparison]
125
- ): # gcp gemini will return None usage field
126
- metadata_tracker.set_usage(convert_usage(event.usage, param.context_limit))
127
- if event.model:
128
- metadata_tracker.set_model_name(event.model)
129
- if provider := getattr(event, "provider", None):
130
- metadata_tracker.set_provider(str(provider))
131
-
132
- if len(event.choices) == 0:
133
- continue
134
- delta = event.choices[0].delta
135
-
136
- # Reasoning
137
- if hasattr(delta, "reasoning_details") and getattr(delta, "reasoning_details"):
138
- reasoning_details = getattr(delta, "reasoning_details")
139
- for item in reasoning_details:
140
- try:
141
- reasoning_detail = ReasoningDetail.model_validate(item)
142
- metadata_tracker.record_token()
143
- stage = "reasoning"
144
- for conversation_item in reasoning_handler.on_detail(reasoning_detail):
145
- yield conversation_item
146
- except Exception as e:
147
- log("reasoning_details error", str(e), style="red")
148
-
149
- # Assistant
150
- if delta.content and (
151
- stage == "assistant" or delta.content.strip()
152
- ): # Process all content in assistant stage, filter empty content in reasoning stage
153
- metadata_tracker.record_token()
154
- if stage == "reasoning":
155
- for item in flush_reasoning_items():
156
- yield item
157
- stage = "assistant"
158
- accumulated_content.append(delta.content)
159
- yield model.AssistantMessageDelta(
160
- content=delta.content,
161
- response_id=response_id,
162
- )
163
-
164
- # Tool
165
- if delta.tool_calls and len(delta.tool_calls) > 0:
166
- metadata_tracker.record_token()
167
- if stage == "reasoning":
168
- for item in flush_reasoning_items():
169
- yield item
170
- elif stage == "assistant":
171
- for item in flush_assistant_items():
172
- yield item
173
- stage = "tool"
174
- # Emit ToolCallStartItem for new tool calls
175
- for tc in delta.tool_calls:
176
- if tc.index not in emitted_tool_start_indices and tc.function and tc.function.name:
177
- emitted_tool_start_indices.add(tc.index)
178
- yield model.ToolCallStartItem(
179
- response_id=response_id,
180
- call_id=tc.id or "",
181
- name=tc.function.name,
182
- )
183
- accumulated_tool_calls.add(delta.tool_calls)
184
-
185
- except (openai.OpenAIError, httpx.HTTPError) as e:
186
- yield model.StreamErrorItem(error=f"{e.__class__.__name__} {str(e)}")
187
-
188
- # Finalize
189
- for item in flush_reasoning_items():
122
+ async for item in parse_chat_completions_stream(
123
+ stream,
124
+ param=param,
125
+ metadata_tracker=metadata_tracker,
126
+ reasoning_handler=reasoning_handler,
127
+ on_event=on_event,
128
+ ):
190
129
  yield item
191
-
192
- for item in flush_assistant_items():
193
- yield item
194
-
195
- if stage == "tool":
196
- for tool_call_item in flush_tool_call_items():
197
- yield tool_call_item
198
-
199
- metadata_tracker.set_response_id(response_id)
200
- yield metadata_tracker.finalize()
@@ -7,9 +7,9 @@
7
7
  # pyright: reportGeneralTypeIssues=false
8
8
 
9
9
  from openai.types import chat
10
- from openai.types.chat import ChatCompletionContentPartParam
11
10
 
12
- from klaude_code.llm.input_common import AssistantGroup, ToolGroup, UserGroup, merge_reminder_text, parse_message_groups
11
+ from klaude_code.llm.input_common import AssistantGroup, ToolGroup, UserGroup, parse_message_groups
12
+ from klaude_code.llm.openai_compatible.input import tool_group_to_openai_message, user_group_to_openai_message
13
13
  from klaude_code.protocol import model
14
14
 
15
15
 
@@ -25,35 +25,9 @@ def is_gemini_model(model_name: str | None) -> bool:
25
25
  return model_name is not None and model_name.startswith("google/gemini")
26
26
 
27
27
 
28
- def _user_group_to_message(group: UserGroup) -> chat.ChatCompletionMessageParam:
29
- parts: list[ChatCompletionContentPartParam] = []
30
- for text in group.text_parts:
31
- parts.append({"type": "text", "text": text + "\n"})
32
- for image in group.images:
33
- parts.append({"type": "image_url", "image_url": {"url": image.image_url.url}})
34
- if not parts:
35
- parts.append({"type": "text", "text": ""})
36
- return {"role": "user", "content": parts}
37
-
38
-
39
- def _tool_group_to_message(group: ToolGroup) -> chat.ChatCompletionMessageParam:
40
- merged_text = merge_reminder_text(
41
- group.tool_result.output or "<system-reminder>Tool ran without output or errors</system-reminder>",
42
- group.reminder_texts,
43
- )
44
- return {
45
- "role": "tool",
46
- "content": [{"type": "text", "text": merged_text}],
47
- "tool_call_id": group.tool_result.call_id,
48
- }
49
-
50
-
51
28
  def _assistant_group_to_message(group: AssistantGroup, model_name: str | None) -> chat.ChatCompletionMessageParam:
52
29
  assistant_message: dict[str, object] = {"role": "assistant"}
53
30
 
54
- if group.text_content:
55
- assistant_message["content"] = group.text_content
56
-
57
31
  if group.tool_calls:
58
32
  assistant_message["tool_calls"] = [
59
33
  {
@@ -71,9 +45,14 @@ def _assistant_group_to_message(group: AssistantGroup, model_name: str | None) -
71
45
  # The order of items in reasoning_details must match the original
72
46
  # stream order from the provider, so we iterate reasoning_items
73
47
  # instead of the separated reasoning_text / reasoning_encrypted lists.
48
+ # For cross-model scenarios, degrade thinking to plain text.
74
49
  reasoning_details: list[dict[str, object]] = []
50
+ degraded_thinking_texts: list[str] = []
75
51
  for item in group.reasoning_items:
76
52
  if model_name != item.model:
53
+ # Cross-model: collect thinking text for degradation
54
+ if isinstance(item, model.ReasoningTextItem) and item.content:
55
+ degraded_thinking_texts.append(item.content)
77
56
  continue
78
57
  if isinstance(item, model.ReasoningEncryptedItem):
79
58
  if item.encrypted_content and len(item.encrypted_content) > 0:
@@ -98,6 +77,15 @@ def _assistant_group_to_message(group: AssistantGroup, model_name: str | None) -
98
77
  if reasoning_details:
99
78
  assistant_message["reasoning_details"] = reasoning_details
100
79
 
80
+ # Build content with optional degraded thinking prefix
81
+ content_parts: list[str] = []
82
+ if degraded_thinking_texts:
83
+ content_parts.append("<thinking>\n" + "\n".join(degraded_thinking_texts) + "\n</thinking>")
84
+ if group.text_content:
85
+ content_parts.append(group.text_content)
86
+ if content_parts:
87
+ assistant_message["content"] = "\n".join(content_parts)
88
+
101
89
  return assistant_message
102
90
 
103
91
 
@@ -150,9 +138,9 @@ def convert_history_to_input(
150
138
  for group in parse_message_groups(history):
151
139
  match group:
152
140
  case UserGroup():
153
- messages.append(_user_group_to_message(group))
141
+ messages.append(user_group_to_openai_message(group))
154
142
  case ToolGroup():
155
- messages.append(_tool_group_to_message(group))
143
+ messages.append(tool_group_to_openai_message(group))
156
144
  case AssistantGroup():
157
145
  messages.append(_assistant_group_to_message(group, model_name))
158
146
 
@@ -0,0 +1,118 @@
1
+ from pydantic import BaseModel
2
+
3
+ from klaude_code.llm.openai_compatible.stream import ReasoningDeltaResult, ReasoningHandlerABC
4
+ from klaude_code.protocol import model
5
+ from klaude_code.trace import log
6
+
7
+
8
+ class ReasoningDetail(BaseModel):
9
+ """OpenRouter's https://openrouter.ai/docs/use-cases/reasoning-tokens#reasoning_details-array-structure"""
10
+
11
+ type: str
12
+ format: str
13
+ index: int
14
+ id: str | None = None
15
+ data: str | None = None # OpenAI's encrypted content
16
+ summary: str | None = None
17
+ text: str | None = None
18
+ signature: str | None = None # Claude's signature
19
+
20
+
21
+ class ReasoningStreamHandler(ReasoningHandlerABC):
22
+ """Accumulates OpenRouter reasoning details and emits ordered outputs."""
23
+
24
+ def __init__(
25
+ self,
26
+ param_model: str,
27
+ response_id: str | None,
28
+ ) -> None:
29
+ self._param_model = param_model
30
+ self._response_id = response_id
31
+
32
+ self._reasoning_id: str | None = None
33
+ self._accumulated_reasoning: list[str] = []
34
+
35
+ def set_response_id(self, response_id: str | None) -> None:
36
+ """Update the response identifier used for emitted items."""
37
+ self._response_id = response_id
38
+
39
+ def on_delta(self, delta: object) -> ReasoningDeltaResult:
40
+ """Parse OpenRouter's reasoning_details and return ordered stream outputs."""
41
+ reasoning_details = getattr(delta, "reasoning_details", None)
42
+ if not reasoning_details:
43
+ return ReasoningDeltaResult(handled=False, outputs=[])
44
+
45
+ outputs: list[str | model.ConversationItem] = []
46
+ for item in reasoning_details:
47
+ try:
48
+ reasoning_detail = ReasoningDetail.model_validate(item)
49
+ if reasoning_detail.text:
50
+ outputs.append(reasoning_detail.text)
51
+ if reasoning_detail.summary:
52
+ outputs.append(reasoning_detail.summary)
53
+ outputs.extend(self.on_detail(reasoning_detail))
54
+ except Exception as e:
55
+ log("reasoning_details error", str(e), style="red")
56
+
57
+ return ReasoningDeltaResult(handled=True, outputs=outputs)
58
+
59
+ def on_detail(self, detail: ReasoningDetail) -> list[model.ConversationItem]:
60
+ """Process a single reasoning detail and return streamable items."""
61
+ items: list[model.ConversationItem] = []
62
+
63
+ if detail.type == "reasoning.encrypted":
64
+ self._reasoning_id = detail.id
65
+ # Flush accumulated text before encrypted content
66
+ items.extend(self._flush_text())
67
+ if encrypted_item := self._build_encrypted_item(detail.data, detail):
68
+ items.append(encrypted_item)
69
+ return items
70
+
71
+ if detail.type in ("reasoning.text", "reasoning.summary"):
72
+ self._reasoning_id = detail.id
73
+ # Accumulate text
74
+ text = detail.text if detail.type == "reasoning.text" else detail.summary
75
+ if text:
76
+ self._accumulated_reasoning.append(text)
77
+ # Flush on signature (encrypted content)
78
+ if detail.signature:
79
+ items.extend(self._flush_text())
80
+ if encrypted_item := self._build_encrypted_item(detail.signature, detail):
81
+ items.append(encrypted_item)
82
+
83
+ return items
84
+
85
+ def flush(self) -> list[model.ConversationItem]:
86
+ """Flush buffered reasoning text on finalize."""
87
+ return self._flush_text()
88
+
89
+ def _flush_text(self) -> list[model.ConversationItem]:
90
+ """Flush accumulated reasoning text as a single item."""
91
+ if not self._accumulated_reasoning:
92
+ return []
93
+ item = self._build_text_item("".join(self._accumulated_reasoning))
94
+ self._accumulated_reasoning = []
95
+ return [item]
96
+
97
+ def _build_text_item(self, content: str) -> model.ReasoningTextItem:
98
+ return model.ReasoningTextItem(
99
+ id=self._reasoning_id,
100
+ content=content,
101
+ response_id=self._response_id,
102
+ model=self._param_model,
103
+ )
104
+
105
+ def _build_encrypted_item(
106
+ self,
107
+ content: str | None,
108
+ detail: ReasoningDetail,
109
+ ) -> model.ReasoningEncryptedItem | None:
110
+ if not content:
111
+ return None
112
+ return model.ReasoningEncryptedItem(
113
+ id=detail.id,
114
+ encrypted_content=content,
115
+ format=detail.format,
116
+ response_id=self._response_id,
117
+ model=self._param_model,
118
+ )
@@ -1,22 +1,54 @@
1
- from typing import Callable, TypeVar
1
+ import importlib
2
+ from collections.abc import Callable
3
+ from typing import TYPE_CHECKING, TypeVar
2
4
 
3
- from klaude_code.llm.client import LLMClientABC
4
5
  from klaude_code.protocol import llm_param
5
6
 
6
- _REGISTRY: dict[llm_param.LLMClientProtocol, type[LLMClientABC]] = {}
7
+ if TYPE_CHECKING:
8
+ from klaude_code.llm.client import LLMClientABC
7
9
 
8
- T = TypeVar("T", bound=LLMClientABC)
10
+ _T = TypeVar("_T", bound=type["LLMClientABC"])
9
11
 
12
+ # Track which protocols have been loaded
13
+ _loaded_protocols: set[llm_param.LLMClientProtocol] = set()
14
+ _REGISTRY: dict[llm_param.LLMClientProtocol, type["LLMClientABC"]] = {}
10
15
 
11
- def register(name: llm_param.LLMClientProtocol) -> Callable[[type[T]], type[T]]:
12
- def _decorator(cls: type[T]) -> type[T]:
16
+
17
+ def _load_protocol(protocol: llm_param.LLMClientProtocol) -> None:
18
+ """Load the module for a specific protocol on demand."""
19
+ if protocol in _loaded_protocols:
20
+ return
21
+ _loaded_protocols.add(protocol)
22
+
23
+ # Import only the needed module to trigger @register decorator
24
+ if protocol == llm_param.LLMClientProtocol.ANTHROPIC:
25
+ importlib.import_module("klaude_code.llm.anthropic")
26
+ elif protocol == llm_param.LLMClientProtocol.BEDROCK:
27
+ importlib.import_module("klaude_code.llm.bedrock")
28
+ elif protocol == llm_param.LLMClientProtocol.CODEX:
29
+ importlib.import_module("klaude_code.llm.codex")
30
+ elif protocol == llm_param.LLMClientProtocol.OPENAI:
31
+ importlib.import_module("klaude_code.llm.openai_compatible")
32
+ elif protocol == llm_param.LLMClientProtocol.OPENROUTER:
33
+ importlib.import_module("klaude_code.llm.openrouter")
34
+ elif protocol == llm_param.LLMClientProtocol.RESPONSES:
35
+ importlib.import_module("klaude_code.llm.responses")
36
+ elif protocol == llm_param.LLMClientProtocol.GOOGLE:
37
+ importlib.import_module("klaude_code.llm.google")
38
+
39
+
40
+ def register(name: llm_param.LLMClientProtocol) -> Callable[[_T], _T]:
41
+ """Decorator to register an LLM client class for a protocol."""
42
+
43
+ def _decorator(cls: _T) -> _T:
13
44
  _REGISTRY[name] = cls
14
45
  return cls
15
46
 
16
47
  return _decorator
17
48
 
18
49
 
19
- def create_llm_client(config: llm_param.LLMConfigParameter) -> LLMClientABC:
50
+ def create_llm_client(config: llm_param.LLMConfigParameter) -> "LLMClientABC":
51
+ _load_protocol(config.protocol)
20
52
  if config.protocol not in _REGISTRY:
21
53
  raise ValueError(f"Unknown LLMClient protocol: {config.protocol}")
22
54
  return _REGISTRY[config.protocol].create(config)