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.
- klaude_code/__init__.py +0 -0
- klaude_code/cli/__init__.py +1 -0
- klaude_code/cli/main.py +298 -0
- klaude_code/cli/runtime.py +331 -0
- klaude_code/cli/session_cmd.py +80 -0
- klaude_code/command/__init__.py +43 -0
- klaude_code/command/clear_cmd.py +20 -0
- klaude_code/command/command_abc.py +92 -0
- klaude_code/command/diff_cmd.py +138 -0
- klaude_code/command/export_cmd.py +86 -0
- klaude_code/command/help_cmd.py +51 -0
- klaude_code/command/model_cmd.py +43 -0
- klaude_code/command/prompt-dev-docs-update.md +56 -0
- klaude_code/command/prompt-dev-docs.md +46 -0
- klaude_code/command/prompt-init.md +45 -0
- klaude_code/command/prompt_command.py +69 -0
- klaude_code/command/refresh_cmd.py +43 -0
- klaude_code/command/registry.py +110 -0
- klaude_code/command/status_cmd.py +111 -0
- klaude_code/command/terminal_setup_cmd.py +252 -0
- klaude_code/config/__init__.py +11 -0
- klaude_code/config/config.py +177 -0
- klaude_code/config/list_model.py +162 -0
- klaude_code/config/select_model.py +67 -0
- klaude_code/const/__init__.py +133 -0
- klaude_code/core/__init__.py +0 -0
- klaude_code/core/agent.py +165 -0
- klaude_code/core/executor.py +485 -0
- klaude_code/core/manager/__init__.py +19 -0
- klaude_code/core/manager/agent_manager.py +127 -0
- klaude_code/core/manager/llm_clients.py +42 -0
- klaude_code/core/manager/llm_clients_builder.py +49 -0
- klaude_code/core/manager/sub_agent_manager.py +86 -0
- klaude_code/core/prompt.py +89 -0
- klaude_code/core/prompts/prompt-claude-code.md +98 -0
- klaude_code/core/prompts/prompt-codex.md +331 -0
- klaude_code/core/prompts/prompt-gemini.md +43 -0
- klaude_code/core/prompts/prompt-subagent-explore.md +27 -0
- klaude_code/core/prompts/prompt-subagent-oracle.md +23 -0
- klaude_code/core/prompts/prompt-subagent-webfetch.md +46 -0
- klaude_code/core/prompts/prompt-subagent.md +8 -0
- klaude_code/core/reminders.py +445 -0
- klaude_code/core/task.py +237 -0
- klaude_code/core/tool/__init__.py +75 -0
- klaude_code/core/tool/file/__init__.py +0 -0
- klaude_code/core/tool/file/apply_patch.py +492 -0
- klaude_code/core/tool/file/apply_patch_tool.md +1 -0
- klaude_code/core/tool/file/apply_patch_tool.py +204 -0
- klaude_code/core/tool/file/edit_tool.md +9 -0
- klaude_code/core/tool/file/edit_tool.py +274 -0
- klaude_code/core/tool/file/multi_edit_tool.md +42 -0
- klaude_code/core/tool/file/multi_edit_tool.py +199 -0
- klaude_code/core/tool/file/read_tool.md +14 -0
- klaude_code/core/tool/file/read_tool.py +326 -0
- klaude_code/core/tool/file/write_tool.md +8 -0
- klaude_code/core/tool/file/write_tool.py +146 -0
- klaude_code/core/tool/memory/__init__.py +0 -0
- klaude_code/core/tool/memory/memory_tool.md +16 -0
- klaude_code/core/tool/memory/memory_tool.py +462 -0
- klaude_code/core/tool/memory/skill_loader.py +245 -0
- klaude_code/core/tool/memory/skill_tool.md +24 -0
- klaude_code/core/tool/memory/skill_tool.py +97 -0
- klaude_code/core/tool/shell/__init__.py +0 -0
- klaude_code/core/tool/shell/bash_tool.md +43 -0
- klaude_code/core/tool/shell/bash_tool.py +123 -0
- klaude_code/core/tool/shell/command_safety.py +363 -0
- klaude_code/core/tool/sub_agent_tool.py +83 -0
- klaude_code/core/tool/todo/__init__.py +0 -0
- klaude_code/core/tool/todo/todo_write_tool.md +182 -0
- klaude_code/core/tool/todo/todo_write_tool.py +121 -0
- klaude_code/core/tool/todo/update_plan_tool.md +3 -0
- klaude_code/core/tool/todo/update_plan_tool.py +104 -0
- klaude_code/core/tool/tool_abc.py +25 -0
- klaude_code/core/tool/tool_context.py +106 -0
- klaude_code/core/tool/tool_registry.py +78 -0
- klaude_code/core/tool/tool_runner.py +252 -0
- klaude_code/core/tool/truncation.py +170 -0
- klaude_code/core/tool/web/__init__.py +0 -0
- klaude_code/core/tool/web/mermaid_tool.md +21 -0
- klaude_code/core/tool/web/mermaid_tool.py +76 -0
- klaude_code/core/tool/web/web_fetch_tool.md +8 -0
- klaude_code/core/tool/web/web_fetch_tool.py +159 -0
- klaude_code/core/turn.py +220 -0
- klaude_code/llm/__init__.py +21 -0
- klaude_code/llm/anthropic/__init__.py +3 -0
- klaude_code/llm/anthropic/client.py +221 -0
- klaude_code/llm/anthropic/input.py +200 -0
- klaude_code/llm/client.py +49 -0
- klaude_code/llm/input_common.py +239 -0
- klaude_code/llm/openai_compatible/__init__.py +3 -0
- klaude_code/llm/openai_compatible/client.py +211 -0
- klaude_code/llm/openai_compatible/input.py +109 -0
- klaude_code/llm/openai_compatible/tool_call_accumulator.py +80 -0
- klaude_code/llm/openrouter/__init__.py +3 -0
- klaude_code/llm/openrouter/client.py +200 -0
- klaude_code/llm/openrouter/input.py +160 -0
- klaude_code/llm/openrouter/reasoning_handler.py +209 -0
- klaude_code/llm/registry.py +22 -0
- klaude_code/llm/responses/__init__.py +3 -0
- klaude_code/llm/responses/client.py +216 -0
- klaude_code/llm/responses/input.py +167 -0
- klaude_code/llm/usage.py +109 -0
- klaude_code/protocol/__init__.py +4 -0
- klaude_code/protocol/commands.py +21 -0
- klaude_code/protocol/events.py +163 -0
- klaude_code/protocol/llm_param.py +147 -0
- klaude_code/protocol/model.py +287 -0
- klaude_code/protocol/op.py +89 -0
- klaude_code/protocol/op_handler.py +28 -0
- klaude_code/protocol/sub_agent.py +348 -0
- klaude_code/protocol/tools.py +15 -0
- klaude_code/session/__init__.py +4 -0
- klaude_code/session/export.py +624 -0
- klaude_code/session/selector.py +76 -0
- klaude_code/session/session.py +474 -0
- klaude_code/session/templates/export_session.html +1434 -0
- klaude_code/trace/__init__.py +3 -0
- klaude_code/trace/log.py +168 -0
- klaude_code/ui/__init__.py +91 -0
- klaude_code/ui/core/__init__.py +1 -0
- klaude_code/ui/core/display.py +103 -0
- klaude_code/ui/core/input.py +71 -0
- klaude_code/ui/core/stage_manager.py +55 -0
- klaude_code/ui/modes/__init__.py +1 -0
- klaude_code/ui/modes/debug/__init__.py +1 -0
- klaude_code/ui/modes/debug/display.py +36 -0
- klaude_code/ui/modes/exec/__init__.py +1 -0
- klaude_code/ui/modes/exec/display.py +63 -0
- klaude_code/ui/modes/repl/__init__.py +51 -0
- klaude_code/ui/modes/repl/clipboard.py +152 -0
- klaude_code/ui/modes/repl/completers.py +429 -0
- klaude_code/ui/modes/repl/display.py +60 -0
- klaude_code/ui/modes/repl/event_handler.py +375 -0
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +198 -0
- klaude_code/ui/modes/repl/key_bindings.py +170 -0
- klaude_code/ui/modes/repl/renderer.py +281 -0
- klaude_code/ui/renderers/__init__.py +0 -0
- klaude_code/ui/renderers/assistant.py +21 -0
- klaude_code/ui/renderers/common.py +8 -0
- klaude_code/ui/renderers/developer.py +158 -0
- klaude_code/ui/renderers/diffs.py +215 -0
- klaude_code/ui/renderers/errors.py +16 -0
- klaude_code/ui/renderers/metadata.py +190 -0
- klaude_code/ui/renderers/sub_agent.py +71 -0
- klaude_code/ui/renderers/thinking.py +39 -0
- klaude_code/ui/renderers/tools.py +551 -0
- klaude_code/ui/renderers/user_input.py +65 -0
- klaude_code/ui/rich/__init__.py +1 -0
- klaude_code/ui/rich/live.py +65 -0
- klaude_code/ui/rich/markdown.py +308 -0
- klaude_code/ui/rich/quote.py +34 -0
- klaude_code/ui/rich/searchable_text.py +71 -0
- klaude_code/ui/rich/status.py +240 -0
- klaude_code/ui/rich/theme.py +274 -0
- klaude_code/ui/terminal/__init__.py +1 -0
- klaude_code/ui/terminal/color.py +244 -0
- klaude_code/ui/terminal/control.py +147 -0
- klaude_code/ui/terminal/notifier.py +107 -0
- klaude_code/ui/terminal/progress_bar.py +87 -0
- klaude_code/ui/utils/__init__.py +1 -0
- klaude_code/ui/utils/common.py +108 -0
- klaude_code/ui/utils/debouncer.py +42 -0
- klaude_code/version.py +163 -0
- klaude_code-1.2.6.dist-info/METADATA +178 -0
- klaude_code-1.2.6.dist-info/RECORD +167 -0
- klaude_code-1.2.6.dist-info/WHEEL +4 -0
- 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)
|