klaude-code 1.2.6__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 (167) hide show
  1. klaude_code/__init__.py +0 -0
  2. klaude_code/cli/__init__.py +1 -0
  3. klaude_code/cli/main.py +298 -0
  4. klaude_code/cli/runtime.py +331 -0
  5. klaude_code/cli/session_cmd.py +80 -0
  6. klaude_code/command/__init__.py +43 -0
  7. klaude_code/command/clear_cmd.py +20 -0
  8. klaude_code/command/command_abc.py +92 -0
  9. klaude_code/command/diff_cmd.py +138 -0
  10. klaude_code/command/export_cmd.py +86 -0
  11. klaude_code/command/help_cmd.py +51 -0
  12. klaude_code/command/model_cmd.py +43 -0
  13. klaude_code/command/prompt-dev-docs-update.md +56 -0
  14. klaude_code/command/prompt-dev-docs.md +46 -0
  15. klaude_code/command/prompt-init.md +45 -0
  16. klaude_code/command/prompt_command.py +69 -0
  17. klaude_code/command/refresh_cmd.py +43 -0
  18. klaude_code/command/registry.py +110 -0
  19. klaude_code/command/status_cmd.py +111 -0
  20. klaude_code/command/terminal_setup_cmd.py +252 -0
  21. klaude_code/config/__init__.py +11 -0
  22. klaude_code/config/config.py +177 -0
  23. klaude_code/config/list_model.py +162 -0
  24. klaude_code/config/select_model.py +67 -0
  25. klaude_code/const/__init__.py +133 -0
  26. klaude_code/core/__init__.py +0 -0
  27. klaude_code/core/agent.py +165 -0
  28. klaude_code/core/executor.py +485 -0
  29. klaude_code/core/manager/__init__.py +19 -0
  30. klaude_code/core/manager/agent_manager.py +127 -0
  31. klaude_code/core/manager/llm_clients.py +42 -0
  32. klaude_code/core/manager/llm_clients_builder.py +49 -0
  33. klaude_code/core/manager/sub_agent_manager.py +86 -0
  34. klaude_code/core/prompt.py +89 -0
  35. klaude_code/core/prompts/prompt-claude-code.md +98 -0
  36. klaude_code/core/prompts/prompt-codex.md +331 -0
  37. klaude_code/core/prompts/prompt-gemini.md +43 -0
  38. klaude_code/core/prompts/prompt-subagent-explore.md +27 -0
  39. klaude_code/core/prompts/prompt-subagent-oracle.md +23 -0
  40. klaude_code/core/prompts/prompt-subagent-webfetch.md +46 -0
  41. klaude_code/core/prompts/prompt-subagent.md +8 -0
  42. klaude_code/core/reminders.py +445 -0
  43. klaude_code/core/task.py +237 -0
  44. klaude_code/core/tool/__init__.py +75 -0
  45. klaude_code/core/tool/file/__init__.py +0 -0
  46. klaude_code/core/tool/file/apply_patch.py +492 -0
  47. klaude_code/core/tool/file/apply_patch_tool.md +1 -0
  48. klaude_code/core/tool/file/apply_patch_tool.py +204 -0
  49. klaude_code/core/tool/file/edit_tool.md +9 -0
  50. klaude_code/core/tool/file/edit_tool.py +274 -0
  51. klaude_code/core/tool/file/multi_edit_tool.md +42 -0
  52. klaude_code/core/tool/file/multi_edit_tool.py +199 -0
  53. klaude_code/core/tool/file/read_tool.md +14 -0
  54. klaude_code/core/tool/file/read_tool.py +326 -0
  55. klaude_code/core/tool/file/write_tool.md +8 -0
  56. klaude_code/core/tool/file/write_tool.py +146 -0
  57. klaude_code/core/tool/memory/__init__.py +0 -0
  58. klaude_code/core/tool/memory/memory_tool.md +16 -0
  59. klaude_code/core/tool/memory/memory_tool.py +462 -0
  60. klaude_code/core/tool/memory/skill_loader.py +245 -0
  61. klaude_code/core/tool/memory/skill_tool.md +24 -0
  62. klaude_code/core/tool/memory/skill_tool.py +97 -0
  63. klaude_code/core/tool/shell/__init__.py +0 -0
  64. klaude_code/core/tool/shell/bash_tool.md +43 -0
  65. klaude_code/core/tool/shell/bash_tool.py +123 -0
  66. klaude_code/core/tool/shell/command_safety.py +363 -0
  67. klaude_code/core/tool/sub_agent_tool.py +83 -0
  68. klaude_code/core/tool/todo/__init__.py +0 -0
  69. klaude_code/core/tool/todo/todo_write_tool.md +182 -0
  70. klaude_code/core/tool/todo/todo_write_tool.py +121 -0
  71. klaude_code/core/tool/todo/update_plan_tool.md +3 -0
  72. klaude_code/core/tool/todo/update_plan_tool.py +104 -0
  73. klaude_code/core/tool/tool_abc.py +25 -0
  74. klaude_code/core/tool/tool_context.py +106 -0
  75. klaude_code/core/tool/tool_registry.py +78 -0
  76. klaude_code/core/tool/tool_runner.py +252 -0
  77. klaude_code/core/tool/truncation.py +170 -0
  78. klaude_code/core/tool/web/__init__.py +0 -0
  79. klaude_code/core/tool/web/mermaid_tool.md +21 -0
  80. klaude_code/core/tool/web/mermaid_tool.py +76 -0
  81. klaude_code/core/tool/web/web_fetch_tool.md +8 -0
  82. klaude_code/core/tool/web/web_fetch_tool.py +159 -0
  83. klaude_code/core/turn.py +220 -0
  84. klaude_code/llm/__init__.py +21 -0
  85. klaude_code/llm/anthropic/__init__.py +3 -0
  86. klaude_code/llm/anthropic/client.py +221 -0
  87. klaude_code/llm/anthropic/input.py +200 -0
  88. klaude_code/llm/client.py +49 -0
  89. klaude_code/llm/input_common.py +239 -0
  90. klaude_code/llm/openai_compatible/__init__.py +3 -0
  91. klaude_code/llm/openai_compatible/client.py +211 -0
  92. klaude_code/llm/openai_compatible/input.py +109 -0
  93. klaude_code/llm/openai_compatible/tool_call_accumulator.py +80 -0
  94. klaude_code/llm/openrouter/__init__.py +3 -0
  95. klaude_code/llm/openrouter/client.py +200 -0
  96. klaude_code/llm/openrouter/input.py +160 -0
  97. klaude_code/llm/openrouter/reasoning_handler.py +209 -0
  98. klaude_code/llm/registry.py +22 -0
  99. klaude_code/llm/responses/__init__.py +3 -0
  100. klaude_code/llm/responses/client.py +216 -0
  101. klaude_code/llm/responses/input.py +167 -0
  102. klaude_code/llm/usage.py +109 -0
  103. klaude_code/protocol/__init__.py +4 -0
  104. klaude_code/protocol/commands.py +21 -0
  105. klaude_code/protocol/events.py +163 -0
  106. klaude_code/protocol/llm_param.py +147 -0
  107. klaude_code/protocol/model.py +287 -0
  108. klaude_code/protocol/op.py +89 -0
  109. klaude_code/protocol/op_handler.py +28 -0
  110. klaude_code/protocol/sub_agent.py +348 -0
  111. klaude_code/protocol/tools.py +15 -0
  112. klaude_code/session/__init__.py +4 -0
  113. klaude_code/session/export.py +624 -0
  114. klaude_code/session/selector.py +76 -0
  115. klaude_code/session/session.py +474 -0
  116. klaude_code/session/templates/export_session.html +1434 -0
  117. klaude_code/trace/__init__.py +3 -0
  118. klaude_code/trace/log.py +168 -0
  119. klaude_code/ui/__init__.py +91 -0
  120. klaude_code/ui/core/__init__.py +1 -0
  121. klaude_code/ui/core/display.py +103 -0
  122. klaude_code/ui/core/input.py +71 -0
  123. klaude_code/ui/core/stage_manager.py +55 -0
  124. klaude_code/ui/modes/__init__.py +1 -0
  125. klaude_code/ui/modes/debug/__init__.py +1 -0
  126. klaude_code/ui/modes/debug/display.py +36 -0
  127. klaude_code/ui/modes/exec/__init__.py +1 -0
  128. klaude_code/ui/modes/exec/display.py +63 -0
  129. klaude_code/ui/modes/repl/__init__.py +51 -0
  130. klaude_code/ui/modes/repl/clipboard.py +152 -0
  131. klaude_code/ui/modes/repl/completers.py +429 -0
  132. klaude_code/ui/modes/repl/display.py +60 -0
  133. klaude_code/ui/modes/repl/event_handler.py +375 -0
  134. klaude_code/ui/modes/repl/input_prompt_toolkit.py +198 -0
  135. klaude_code/ui/modes/repl/key_bindings.py +170 -0
  136. klaude_code/ui/modes/repl/renderer.py +281 -0
  137. klaude_code/ui/renderers/__init__.py +0 -0
  138. klaude_code/ui/renderers/assistant.py +21 -0
  139. klaude_code/ui/renderers/common.py +8 -0
  140. klaude_code/ui/renderers/developer.py +158 -0
  141. klaude_code/ui/renderers/diffs.py +215 -0
  142. klaude_code/ui/renderers/errors.py +16 -0
  143. klaude_code/ui/renderers/metadata.py +190 -0
  144. klaude_code/ui/renderers/sub_agent.py +71 -0
  145. klaude_code/ui/renderers/thinking.py +39 -0
  146. klaude_code/ui/renderers/tools.py +551 -0
  147. klaude_code/ui/renderers/user_input.py +65 -0
  148. klaude_code/ui/rich/__init__.py +1 -0
  149. klaude_code/ui/rich/live.py +65 -0
  150. klaude_code/ui/rich/markdown.py +308 -0
  151. klaude_code/ui/rich/quote.py +34 -0
  152. klaude_code/ui/rich/searchable_text.py +71 -0
  153. klaude_code/ui/rich/status.py +240 -0
  154. klaude_code/ui/rich/theme.py +274 -0
  155. klaude_code/ui/terminal/__init__.py +1 -0
  156. klaude_code/ui/terminal/color.py +244 -0
  157. klaude_code/ui/terminal/control.py +147 -0
  158. klaude_code/ui/terminal/notifier.py +107 -0
  159. klaude_code/ui/terminal/progress_bar.py +87 -0
  160. klaude_code/ui/utils/__init__.py +1 -0
  161. klaude_code/ui/utils/common.py +108 -0
  162. klaude_code/ui/utils/debouncer.py +42 -0
  163. klaude_code/version.py +163 -0
  164. klaude_code-1.2.6.dist-info/METADATA +178 -0
  165. klaude_code-1.2.6.dist-info/RECORD +167 -0
  166. klaude_code-1.2.6.dist-info/WHEEL +4 -0
  167. klaude_code-1.2.6.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,200 @@
1
+ from collections.abc import AsyncGenerator
2
+ from typing import Literal, override
3
+
4
+ import httpx
5
+ import openai
6
+
7
+ from klaude_code.llm.client import LLMClientABC, call_with_logged_payload
8
+ from klaude_code.llm.input_common import apply_config_defaults
9
+ from klaude_code.llm.openai_compatible.input import convert_tool_schema
10
+ from klaude_code.llm.openai_compatible.tool_call_accumulator import BasicToolCallAccumulator, ToolCallAccumulatorABC
11
+ 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
13
+ from klaude_code.llm.registry import register
14
+ from klaude_code.llm.usage import MetadataTracker, convert_usage
15
+ from klaude_code.protocol import llm_param, model
16
+ from klaude_code.trace import DebugType, log, log_debug
17
+
18
+
19
+ @register(llm_param.LLMClientProtocol.OPENROUTER)
20
+ class OpenRouterClient(LLMClientABC):
21
+ def __init__(self, config: llm_param.LLMConfigParameter):
22
+ super().__init__(config)
23
+ client = openai.AsyncOpenAI(
24
+ api_key=config.api_key,
25
+ base_url="https://openrouter.ai/api/v1",
26
+ timeout=httpx.Timeout(300.0, connect=15.0, read=285.0),
27
+ )
28
+ self.client: openai.AsyncOpenAI = client
29
+
30
+ @classmethod
31
+ @override
32
+ def create(cls, config: llm_param.LLMConfigParameter) -> "LLMClientABC":
33
+ return cls(config)
34
+
35
+ @override
36
+ async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[model.ConversationItem, None]:
37
+ 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
+
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]
78
+ )
79
+
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()
85
+ reasoning_handler = ReasoningStreamHandler(
86
+ param_model=str(param.model),
87
+ response_id=response_id,
88
+ )
89
+
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,
100
+ )
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
+
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():
190
+ 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()
@@ -0,0 +1,160 @@
1
+ # pyright: reportReturnType=false
2
+ # pyright: reportArgumentType=false
3
+ # pyright: reportUnknownMemberType=false
4
+ # pyright: reportAttributeAccessIssue=false
5
+ # pyright: reportAssignmentType=false
6
+ # pyright: reportUnnecessaryIsInstance=false
7
+ # pyright: reportGeneralTypeIssues=false
8
+
9
+ from openai.types import chat
10
+ from openai.types.chat import ChatCompletionContentPartParam
11
+
12
+ from klaude_code.llm.input_common import AssistantGroup, ToolGroup, UserGroup, merge_reminder_text, parse_message_groups
13
+ from klaude_code.protocol import model
14
+
15
+
16
+ def is_claude_model(model_name: str | None) -> bool:
17
+ """Return True if the model name represents an Anthropic Claude model."""
18
+
19
+ return model_name is not None and model_name.startswith("anthropic/claude")
20
+
21
+
22
+ def is_gemini_model(model_name: str | None) -> bool:
23
+ """Return True if the model name represents a Google Gemini model."""
24
+
25
+ return model_name is not None and model_name.startswith("google/gemini")
26
+
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
+ def _assistant_group_to_message(group: AssistantGroup, model_name: str | None) -> chat.ChatCompletionMessageParam:
52
+ assistant_message: dict[str, object] = {"role": "assistant"}
53
+
54
+ if group.text_content:
55
+ assistant_message["content"] = group.text_content
56
+
57
+ if group.tool_calls:
58
+ assistant_message["tool_calls"] = [
59
+ {
60
+ "id": tc.call_id,
61
+ "type": "function",
62
+ "function": {
63
+ "name": tc.name,
64
+ "arguments": tc.arguments,
65
+ },
66
+ }
67
+ for tc in group.tool_calls
68
+ ]
69
+
70
+ # Handle reasoning for OpenRouter (reasoning_details array).
71
+ # The order of items in reasoning_details must match the original
72
+ # stream order from the provider, so we iterate reasoning_items
73
+ # instead of the separated reasoning_text / reasoning_encrypted lists.
74
+ reasoning_details: list[dict[str, object]] = []
75
+ for item in group.reasoning_items:
76
+ if model_name != item.model:
77
+ continue
78
+ if isinstance(item, model.ReasoningEncryptedItem):
79
+ if item.encrypted_content and len(item.encrypted_content) > 0:
80
+ reasoning_details.append(
81
+ {
82
+ "id": item.id,
83
+ "type": "reasoning.encrypted",
84
+ "data": item.encrypted_content,
85
+ "format": item.format,
86
+ "index": len(reasoning_details),
87
+ }
88
+ )
89
+ elif isinstance(item, model.ReasoningTextItem):
90
+ reasoning_details.append(
91
+ {
92
+ "id": item.id,
93
+ "type": "reasoning.text",
94
+ "text": item.content,
95
+ "index": len(reasoning_details),
96
+ }
97
+ )
98
+ if reasoning_details:
99
+ assistant_message["reasoning_details"] = reasoning_details
100
+
101
+ return assistant_message
102
+
103
+
104
+ def _add_cache_control(messages: list[chat.ChatCompletionMessageParam], use_cache_control: bool) -> None:
105
+ if not use_cache_control or len(messages) == 0:
106
+ return
107
+ for msg in reversed(messages):
108
+ role = msg.get("role")
109
+ if role in ("user", "tool"):
110
+ content = msg.get("content")
111
+ if isinstance(content, list) and len(content) > 0:
112
+ last_part = content[-1]
113
+ if isinstance(last_part, dict) and last_part.get("type") == "text":
114
+ last_part["cache_control"] = {"type": "ephemeral"}
115
+ break
116
+
117
+
118
+ def convert_history_to_input(
119
+ history: list[model.ConversationItem],
120
+ system: str | None = None,
121
+ model_name: str | None = None,
122
+ ) -> list[chat.ChatCompletionMessageParam]:
123
+ """
124
+ Convert a list of conversation items to a list of chat completion message params.
125
+
126
+ Args:
127
+ history: List of conversation items.
128
+ system: System message.
129
+ model_name: Model name. Used to verify that signatures are valid for the same model.
130
+ """
131
+ use_cache_control = is_claude_model(model_name) or is_gemini_model(model_name)
132
+
133
+ messages: list[chat.ChatCompletionMessageParam] = (
134
+ [
135
+ {
136
+ "role": "system",
137
+ "content": [
138
+ {
139
+ "type": "text",
140
+ "text": system,
141
+ "cache_control": {"type": "ephemeral"},
142
+ }
143
+ ],
144
+ }
145
+ ]
146
+ if system and use_cache_control
147
+ else ([{"role": "system", "content": system}] if system else [])
148
+ )
149
+
150
+ for group in parse_message_groups(history):
151
+ match group:
152
+ case UserGroup():
153
+ messages.append(_user_group_to_message(group))
154
+ case ToolGroup():
155
+ messages.append(_tool_group_to_message(group))
156
+ case AssistantGroup():
157
+ messages.append(_assistant_group_to_message(group, model_name))
158
+
159
+ _add_cache_control(messages, use_cache_control)
160
+ return messages
@@ -0,0 +1,209 @@
1
+ from enum import Enum
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from klaude_code.protocol import model
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 ReasoningMode(str, Enum):
22
+ COMPLETE_CHUNK = "complete_chunk"
23
+ GPT5_SECTIONS = "gpt5_sections"
24
+ ACCUMULATE = "accumulate"
25
+
26
+
27
+ class ReasoningStreamHandler:
28
+ """Encapsulates reasoning stream handling across different model behaviors."""
29
+
30
+ def __init__(
31
+ self,
32
+ param_model: str,
33
+ response_id: str | None,
34
+ ) -> None:
35
+ self._param_model = param_model
36
+ self._response_id = response_id
37
+
38
+ self._reasoning_id: str | None = None
39
+ self._accumulated_reasoning: list[str] = []
40
+ self._gpt5_line_buffer: str = ""
41
+ self._gpt5_section_lines: list[str] = []
42
+
43
+ def set_response_id(self, response_id: str | None) -> None:
44
+ """Update the response identifier used for emitted items."""
45
+
46
+ self._response_id = response_id
47
+
48
+ def on_detail(self, detail: ReasoningDetail) -> list[model.ConversationItem]:
49
+ """Process a single reasoning detail and return streamable items."""
50
+
51
+ items: list[model.ConversationItem] = []
52
+
53
+ if detail.type == "reasoning.encrypted":
54
+ self._reasoning_id = detail.id
55
+ if encrypted_item := self._build_encrypted_item(detail.data, detail):
56
+ items.append(encrypted_item)
57
+ return items
58
+
59
+ if detail.type in ("reasoning.text", "reasoning.summary"):
60
+ self._reasoning_id = detail.id
61
+ if encrypted_item := self._build_encrypted_item(detail.signature, detail):
62
+ items.append(encrypted_item)
63
+ text = detail.text if detail.type == "reasoning.text" else detail.summary
64
+ if text:
65
+ items.extend(self._handle_text(text))
66
+
67
+ return items
68
+
69
+ def flush(self) -> list[model.ConversationItem]:
70
+ """Flush buffered reasoning text and encrypted payloads."""
71
+
72
+ items: list[model.ConversationItem] = []
73
+ mode = self._resolve_mode()
74
+
75
+ if mode is ReasoningMode.GPT5_SECTIONS:
76
+ for section in self._drain_gpt5_sections():
77
+ items.append(self._build_text_item(section))
78
+ elif self._accumulated_reasoning and mode is ReasoningMode.ACCUMULATE:
79
+ items.append(self._build_text_item("".join(self._accumulated_reasoning)))
80
+ self._accumulated_reasoning = []
81
+
82
+ return items
83
+
84
+ def _handle_text(self, text: str) -> list[model.ReasoningTextItem]:
85
+ mode = self._resolve_mode()
86
+ if mode is ReasoningMode.COMPLETE_CHUNK:
87
+ return [self._build_text_item(text)]
88
+ if mode is ReasoningMode.GPT5_SECTIONS:
89
+ sections = self._process_gpt5_text(text)
90
+ return [self._build_text_item(section) for section in sections]
91
+ self._accumulated_reasoning.append(text)
92
+ return []
93
+
94
+ def _build_text_item(self, content: str) -> model.ReasoningTextItem:
95
+ return model.ReasoningTextItem(
96
+ id=self._reasoning_id,
97
+ content=content,
98
+ response_id=self._response_id,
99
+ model=self._param_model,
100
+ )
101
+
102
+ def _build_encrypted_item(
103
+ self,
104
+ content: str | None,
105
+ detail: ReasoningDetail,
106
+ ) -> model.ReasoningEncryptedItem | None:
107
+ if not content:
108
+ return None
109
+ return model.ReasoningEncryptedItem(
110
+ id=detail.id,
111
+ encrypted_content=content,
112
+ format=detail.format,
113
+ response_id=self._response_id,
114
+ model=self._param_model,
115
+ )
116
+
117
+ def _process_gpt5_text(self, text: str) -> list[str]:
118
+ emitted_sections: list[str] = []
119
+ self._gpt5_line_buffer += text
120
+ while True:
121
+ newline_index = self._gpt5_line_buffer.find("\n")
122
+ if newline_index == -1:
123
+ break
124
+ line = self._gpt5_line_buffer[:newline_index]
125
+ self._gpt5_line_buffer = self._gpt5_line_buffer[newline_index + 1 :]
126
+ remainder = line
127
+ while True:
128
+ split_result = self._split_gpt5_title_line(remainder)
129
+ if split_result is None:
130
+ break
131
+ prefix_segment, title_segment, remainder = split_result
132
+ if prefix_segment:
133
+ if not self._gpt5_section_lines:
134
+ self._gpt5_section_lines = []
135
+ self._gpt5_section_lines.append(f"{prefix_segment}\n")
136
+ if self._gpt5_section_lines:
137
+ emitted_sections.append("".join(self._gpt5_section_lines))
138
+ self._gpt5_section_lines = [f"{title_segment} \n"] # Add two spaces for markdown line break
139
+ if remainder:
140
+ if not self._gpt5_section_lines:
141
+ self._gpt5_section_lines = []
142
+ self._gpt5_section_lines.append(f"{remainder}\n")
143
+ return emitted_sections
144
+
145
+ def _drain_gpt5_sections(self) -> list[str]:
146
+ sections: list[str] = []
147
+ if self._gpt5_line_buffer:
148
+ if not self._gpt5_section_lines:
149
+ self._gpt5_section_lines = [self._gpt5_line_buffer]
150
+ else:
151
+ self._gpt5_section_lines.append(self._gpt5_line_buffer)
152
+ self._gpt5_line_buffer = ""
153
+ if self._gpt5_section_lines:
154
+ sections.append("".join(self._gpt5_section_lines))
155
+ self._gpt5_section_lines = []
156
+ return sections
157
+
158
+ def _is_gpt5(self) -> bool:
159
+ return "gpt-5" in self._param_model.lower()
160
+
161
+ def _is_complete_chunk_reasoning_model(self) -> bool:
162
+ """Whether the current model emits reasoning in complete chunks (e.g. Gemini)."""
163
+
164
+ return self._param_model.startswith("google/gemini")
165
+
166
+ def _resolve_mode(self) -> ReasoningMode:
167
+ if self._is_complete_chunk_reasoning_model():
168
+ return ReasoningMode.COMPLETE_CHUNK
169
+ if self._is_gpt5():
170
+ return ReasoningMode.GPT5_SECTIONS
171
+ return ReasoningMode.ACCUMULATE
172
+
173
+ def _is_gpt5_title_line(self, line: str) -> bool:
174
+ stripped = line.strip()
175
+ if not stripped:
176
+ return False
177
+ return stripped.startswith("**") and stripped.endswith("**") and stripped.count("**") >= 2
178
+
179
+ def _split_gpt5_title_line(self, line: str) -> tuple[str | None, str, str] | None:
180
+ if not line:
181
+ return None
182
+ search_start = 0
183
+ while True:
184
+ opening_index = line.find("**", search_start)
185
+ if opening_index == -1:
186
+ return None
187
+ closing_index = line.find("**", opening_index + 2)
188
+ if closing_index == -1:
189
+ return None
190
+ title_candidate = line[opening_index : closing_index + 2]
191
+ stripped_title = title_candidate.strip()
192
+ if self._is_gpt5_title_line(stripped_title):
193
+ # Treat as a GPT-5 title only when everything after the
194
+ # bold segment is either whitespace or starts a new bold
195
+ # title. This prevents inline bold like `**xxx**yyyy`
196
+ # from being misclassified as a section title while
197
+ # preserving support for consecutive titles in one line.
198
+ after = line[closing_index + 2 :]
199
+ if after.strip() and not after.lstrip().startswith("**"):
200
+ search_start = closing_index + 2
201
+ continue
202
+ prefix_segment = line[:opening_index]
203
+ remainder_segment = after
204
+ return (
205
+ prefix_segment if prefix_segment else None,
206
+ stripped_title,
207
+ remainder_segment,
208
+ )
209
+ search_start = closing_index + 2
@@ -0,0 +1,22 @@
1
+ from typing import Callable, TypeVar
2
+
3
+ from klaude_code.llm.client import LLMClientABC
4
+ from klaude_code.protocol import llm_param
5
+
6
+ _REGISTRY: dict[llm_param.LLMClientProtocol, type[LLMClientABC]] = {}
7
+
8
+ T = TypeVar("T", bound=LLMClientABC)
9
+
10
+
11
+ def register(name: llm_param.LLMClientProtocol) -> Callable[[type[T]], type[T]]:
12
+ def _decorator(cls: type[T]) -> type[T]:
13
+ _REGISTRY[name] = cls
14
+ return cls
15
+
16
+ return _decorator
17
+
18
+
19
+ def create_llm_client(config: llm_param.LLMConfigParameter) -> LLMClientABC:
20
+ if config.protocol not in _REGISTRY:
21
+ raise ValueError(f"Unknown LLMClient protocol: {config.protocol}")
22
+ return _REGISTRY[config.protocol].create(config)
@@ -0,0 +1,3 @@
1
+ from .client import ResponsesClient
2
+
3
+ __all__ = ["ResponsesClient"]