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