klaude-code 1.8.0__py3-none-any.whl → 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. klaude_code/auth/base.py +97 -0
  2. klaude_code/auth/claude/__init__.py +6 -0
  3. klaude_code/auth/claude/exceptions.py +9 -0
  4. klaude_code/auth/claude/oauth.py +172 -0
  5. klaude_code/auth/claude/token_manager.py +26 -0
  6. klaude_code/auth/codex/token_manager.py +10 -50
  7. klaude_code/cli/auth_cmd.py +127 -46
  8. klaude_code/cli/config_cmd.py +4 -2
  9. klaude_code/cli/cost_cmd.py +14 -9
  10. klaude_code/cli/list_model.py +248 -200
  11. klaude_code/cli/main.py +1 -1
  12. klaude_code/cli/runtime.py +7 -5
  13. klaude_code/cli/self_update.py +1 -1
  14. klaude_code/cli/session_cmd.py +1 -1
  15. klaude_code/command/clear_cmd.py +6 -2
  16. klaude_code/command/command_abc.py +2 -2
  17. klaude_code/command/debug_cmd.py +4 -4
  18. klaude_code/command/export_cmd.py +2 -2
  19. klaude_code/command/export_online_cmd.py +12 -12
  20. klaude_code/command/fork_session_cmd.py +29 -23
  21. klaude_code/command/help_cmd.py +4 -4
  22. klaude_code/command/model_cmd.py +4 -4
  23. klaude_code/command/model_select.py +1 -1
  24. klaude_code/command/prompt-commit.md +82 -0
  25. klaude_code/command/prompt_command.py +3 -3
  26. klaude_code/command/refresh_cmd.py +2 -2
  27. klaude_code/command/registry.py +7 -5
  28. klaude_code/command/release_notes_cmd.py +4 -4
  29. klaude_code/command/resume_cmd.py +15 -11
  30. klaude_code/command/status_cmd.py +4 -4
  31. klaude_code/command/terminal_setup_cmd.py +8 -8
  32. klaude_code/command/thinking_cmd.py +4 -4
  33. klaude_code/config/assets/builtin_config.yaml +52 -3
  34. klaude_code/config/builtin_config.py +16 -5
  35. klaude_code/config/config.py +31 -7
  36. klaude_code/config/thinking.py +4 -4
  37. klaude_code/const.py +146 -91
  38. klaude_code/core/agent.py +3 -12
  39. klaude_code/core/executor.py +21 -13
  40. klaude_code/core/manager/sub_agent_manager.py +71 -7
  41. klaude_code/core/prompt.py +1 -1
  42. klaude_code/core/prompts/prompt-sub-agent-image-gen.md +1 -0
  43. klaude_code/core/prompts/prompt-sub-agent-web.md +27 -1
  44. klaude_code/core/reminders.py +88 -69
  45. klaude_code/core/task.py +44 -45
  46. klaude_code/core/tool/file/apply_patch_tool.py +9 -9
  47. klaude_code/core/tool/file/diff_builder.py +3 -5
  48. klaude_code/core/tool/file/edit_tool.py +23 -23
  49. klaude_code/core/tool/file/move_tool.py +43 -43
  50. klaude_code/core/tool/file/read_tool.py +44 -39
  51. klaude_code/core/tool/file/write_tool.py +14 -14
  52. klaude_code/core/tool/report_back_tool.py +4 -4
  53. klaude_code/core/tool/shell/bash_tool.py +23 -23
  54. klaude_code/core/tool/skill/skill_tool.py +7 -7
  55. klaude_code/core/tool/sub_agent_tool.py +38 -9
  56. klaude_code/core/tool/todo/todo_write_tool.py +8 -8
  57. klaude_code/core/tool/todo/update_plan_tool.py +6 -6
  58. klaude_code/core/tool/tool_abc.py +2 -2
  59. klaude_code/core/tool/tool_context.py +27 -0
  60. klaude_code/core/tool/tool_runner.py +88 -42
  61. klaude_code/core/tool/truncation.py +38 -20
  62. klaude_code/core/tool/web/mermaid_tool.py +6 -7
  63. klaude_code/core/tool/web/web_fetch_tool.py +68 -30
  64. klaude_code/core/tool/web/web_search_tool.py +15 -17
  65. klaude_code/core/turn.py +120 -73
  66. klaude_code/llm/anthropic/client.py +104 -44
  67. klaude_code/llm/anthropic/input.py +116 -108
  68. klaude_code/llm/bedrock/client.py +8 -5
  69. klaude_code/llm/claude/__init__.py +3 -0
  70. klaude_code/llm/claude/client.py +105 -0
  71. klaude_code/llm/client.py +4 -3
  72. klaude_code/llm/codex/client.py +16 -10
  73. klaude_code/llm/google/client.py +122 -60
  74. klaude_code/llm/google/input.py +94 -108
  75. klaude_code/llm/image.py +123 -0
  76. klaude_code/llm/input_common.py +136 -189
  77. klaude_code/llm/openai_compatible/client.py +17 -7
  78. klaude_code/llm/openai_compatible/input.py +36 -66
  79. klaude_code/llm/openai_compatible/stream.py +119 -67
  80. klaude_code/llm/openai_compatible/tool_call_accumulator.py +23 -11
  81. klaude_code/llm/openrouter/client.py +34 -9
  82. klaude_code/llm/openrouter/input.py +63 -64
  83. klaude_code/llm/openrouter/reasoning.py +22 -24
  84. klaude_code/llm/registry.py +20 -15
  85. klaude_code/llm/responses/client.py +107 -45
  86. klaude_code/llm/responses/input.py +115 -98
  87. klaude_code/llm/usage.py +52 -25
  88. klaude_code/protocol/__init__.py +1 -0
  89. klaude_code/protocol/events.py +16 -12
  90. klaude_code/protocol/llm_param.py +22 -3
  91. klaude_code/protocol/message.py +250 -0
  92. klaude_code/protocol/model.py +94 -281
  93. klaude_code/protocol/op.py +2 -2
  94. klaude_code/protocol/sub_agent/__init__.py +2 -2
  95. klaude_code/protocol/sub_agent/explore.py +10 -0
  96. klaude_code/protocol/sub_agent/image_gen.py +119 -0
  97. klaude_code/protocol/sub_agent/task.py +10 -0
  98. klaude_code/protocol/sub_agent/web.py +10 -0
  99. klaude_code/session/codec.py +6 -6
  100. klaude_code/session/export.py +261 -62
  101. klaude_code/session/selector.py +7 -24
  102. klaude_code/session/session.py +125 -53
  103. klaude_code/session/store.py +5 -32
  104. klaude_code/session/templates/export_session.html +1 -1
  105. klaude_code/session/templates/mermaid_viewer.html +1 -1
  106. klaude_code/trace/log.py +11 -6
  107. klaude_code/ui/core/input.py +1 -1
  108. klaude_code/ui/core/stage_manager.py +1 -8
  109. klaude_code/ui/modes/debug/display.py +2 -2
  110. klaude_code/ui/modes/repl/clipboard.py +2 -2
  111. klaude_code/ui/modes/repl/completers.py +18 -10
  112. klaude_code/ui/modes/repl/event_handler.py +136 -127
  113. klaude_code/ui/modes/repl/input_prompt_toolkit.py +1 -1
  114. klaude_code/ui/modes/repl/key_bindings.py +1 -1
  115. klaude_code/ui/modes/repl/renderer.py +107 -15
  116. klaude_code/ui/renderers/assistant.py +2 -2
  117. klaude_code/ui/renderers/common.py +65 -7
  118. klaude_code/ui/renderers/developer.py +7 -6
  119. klaude_code/ui/renderers/diffs.py +11 -11
  120. klaude_code/ui/renderers/mermaid_viewer.py +49 -2
  121. klaude_code/ui/renderers/metadata.py +39 -31
  122. klaude_code/ui/renderers/sub_agent.py +57 -16
  123. klaude_code/ui/renderers/thinking.py +37 -2
  124. klaude_code/ui/renderers/tools.py +180 -165
  125. klaude_code/ui/rich/live.py +3 -1
  126. klaude_code/ui/rich/markdown.py +39 -7
  127. klaude_code/ui/rich/quote.py +76 -1
  128. klaude_code/ui/rich/status.py +14 -8
  129. klaude_code/ui/rich/theme.py +13 -6
  130. klaude_code/ui/terminal/image.py +34 -0
  131. klaude_code/ui/terminal/notifier.py +2 -1
  132. klaude_code/ui/terminal/progress_bar.py +4 -4
  133. klaude_code/ui/terminal/selector.py +22 -4
  134. klaude_code/ui/utils/common.py +55 -0
  135. {klaude_code-1.8.0.dist-info → klaude_code-2.0.0.dist-info}/METADATA +28 -6
  136. klaude_code-2.0.0.dist-info/RECORD +229 -0
  137. klaude_code/command/prompt-jj-describe.md +0 -32
  138. klaude_code/core/prompts/prompt-sub-agent-oracle.md +0 -22
  139. klaude_code/protocol/sub_agent/oracle.py +0 -91
  140. klaude_code-1.8.0.dist-info/RECORD +0 -219
  141. {klaude_code-1.8.0.dist-info → klaude_code-2.0.0.dist-info}/WHEEL +0 -0
  142. {klaude_code-1.8.0.dist-info → klaude_code-2.0.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,105 @@
1
+ import json
2
+ from collections.abc import AsyncGenerator
3
+ from typing import override
4
+
5
+ import anthropic
6
+ import httpx
7
+ from anthropic import APIError
8
+
9
+ from klaude_code.auth.claude.exceptions import ClaudeNotLoggedInError
10
+ from klaude_code.auth.claude.oauth import ClaudeOAuth
11
+ from klaude_code.auth.claude.token_manager import ClaudeTokenManager
12
+ from klaude_code.const import (
13
+ ANTHROPIC_BETA_FINE_GRAINED_TOOL_STREAMING,
14
+ ANTHROPIC_BETA_INTERLEAVED_THINKING,
15
+ ANTHROPIC_BETA_OAUTH,
16
+ LLM_HTTP_TIMEOUT_CONNECT,
17
+ LLM_HTTP_TIMEOUT_READ,
18
+ LLM_HTTP_TIMEOUT_TOTAL,
19
+ )
20
+ from klaude_code.llm.anthropic.client import build_payload, parse_anthropic_stream
21
+ from klaude_code.llm.client import LLMClientABC
22
+ from klaude_code.llm.input_common import apply_config_defaults
23
+ from klaude_code.llm.registry import register
24
+ from klaude_code.llm.usage import MetadataTracker, error_stream_items
25
+ from klaude_code.protocol import llm_param, message
26
+ from klaude_code.trace import DebugType, log_debug
27
+
28
+ _CLAUDE_OAUTH_REQUIRED_BETAS: tuple[str, ...] = (
29
+ ANTHROPIC_BETA_OAUTH,
30
+ ANTHROPIC_BETA_FINE_GRAINED_TOOL_STREAMING,
31
+ )
32
+
33
+
34
+ @register(llm_param.LLMClientProtocol.CLAUDE_OAUTH)
35
+ class ClaudeClient(LLMClientABC):
36
+ """Claude OAuth client using Anthropic messages API with Bearer auth token."""
37
+
38
+ def __init__(self, config: llm_param.LLMConfigParameter):
39
+ super().__init__(config)
40
+
41
+ if config.base_url:
42
+ raise ValueError("CLAUDE protocol does not support custom base_url")
43
+
44
+ self._token_manager = ClaudeTokenManager()
45
+ self._oauth = ClaudeOAuth(self._token_manager)
46
+
47
+ if not self._token_manager.is_logged_in():
48
+ raise ClaudeNotLoggedInError("Claude authentication required. Run 'klaude login claude' first.")
49
+
50
+ self.client = self._create_client()
51
+
52
+ def _create_client(self) -> anthropic.AsyncAnthropic:
53
+ token = self._oauth.ensure_valid_token()
54
+ return anthropic.AsyncAnthropic(
55
+ auth_token=token,
56
+ timeout=httpx.Timeout(LLM_HTTP_TIMEOUT_TOTAL, connect=LLM_HTTP_TIMEOUT_CONNECT, read=LLM_HTTP_TIMEOUT_READ),
57
+ )
58
+
59
+ def _ensure_valid_token(self) -> None:
60
+ state = self._token_manager.get_state()
61
+ if state is None:
62
+ raise ClaudeNotLoggedInError("Not logged in to Claude. Run 'klaude login claude' first.")
63
+
64
+ if state.is_expired():
65
+ self._oauth.refresh()
66
+ self.client = self._create_client()
67
+
68
+ @classmethod
69
+ @override
70
+ def create(cls, config: llm_param.LLMConfigParameter) -> "LLMClientABC":
71
+ return cls(config)
72
+
73
+ @override
74
+ async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[message.LLMStreamItem]:
75
+ self._ensure_valid_token()
76
+ param = apply_config_defaults(param, self.get_llm_config())
77
+
78
+ metadata_tracker = MetadataTracker(cost_config=self.get_llm_config().cost)
79
+
80
+ # Anthropic OAuth requires the oauth beta flag
81
+ extra_betas = list(_CLAUDE_OAUTH_REQUIRED_BETAS)
82
+ payload = build_payload(param, extra_betas=extra_betas)
83
+
84
+ # Keep the interleaved-thinking beta in sync with configured thinking.
85
+ if not (param.thinking and param.thinking.type == "enabled"):
86
+ payload["betas"] = [b for b in payload.get("betas", []) if b != ANTHROPIC_BETA_INTERLEAVED_THINKING]
87
+
88
+ log_debug(
89
+ json.dumps(payload, ensure_ascii=False, default=str),
90
+ style="yellow",
91
+ debug_type=DebugType.LLM_PAYLOAD,
92
+ )
93
+
94
+ stream = self.client.beta.messages.create(
95
+ **payload,
96
+ extra_headers={"extra": json.dumps({"session_id": param.session_id}, sort_keys=True)},
97
+ )
98
+
99
+ try:
100
+ async for item in parse_anthropic_stream(stream, param, metadata_tracker):
101
+ yield item
102
+ except (APIError, httpx.HTTPError) as e:
103
+ error_message = f"{e.__class__.__name__} {e!s}"
104
+ for item in error_stream_items(metadata_tracker, error=error_message):
105
+ yield item
klaude_code/llm/client.py CHANGED
@@ -2,7 +2,7 @@ from abc import ABC, abstractmethod
2
2
  from collections.abc import AsyncGenerator
3
3
  from typing import ParamSpec, TypeVar, cast
4
4
 
5
- from klaude_code.protocol import llm_param, model
5
+ from klaude_code.protocol import llm_param, message
6
6
 
7
7
 
8
8
  class LLMClientABC(ABC):
@@ -15,9 +15,10 @@ class LLMClientABC(ABC):
15
15
  pass
16
16
 
17
17
  @abstractmethod
18
- async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[model.ConversationItem]:
18
+ async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[message.LLMStreamItem]:
19
+ if False: # pragma: no cover
20
+ yield cast(message.LLMStreamItem, None)
19
21
  raise NotImplementedError
20
- yield cast(model.ConversationItem, None)
21
22
 
22
23
  def get_llm_config(self) -> llm_param.LLMConfigParameter:
23
24
  return self._config
@@ -12,13 +12,20 @@ from openai.types.responses.response_create_params import ResponseCreateParamsSt
12
12
  from klaude_code.auth.codex.exceptions import CodexNotLoggedInError
13
13
  from klaude_code.auth.codex.oauth import CodexOAuth
14
14
  from klaude_code.auth.codex.token_manager import CodexTokenManager
15
+ from klaude_code.const import (
16
+ CODEX_BASE_URL,
17
+ CODEX_USER_AGENT,
18
+ LLM_HTTP_TIMEOUT_CONNECT,
19
+ LLM_HTTP_TIMEOUT_READ,
20
+ LLM_HTTP_TIMEOUT_TOTAL,
21
+ )
15
22
  from klaude_code.llm.client import LLMClientABC
16
23
  from klaude_code.llm.input_common import apply_config_defaults
17
24
  from klaude_code.llm.registry import register
18
25
  from klaude_code.llm.responses.client import parse_responses_stream
19
26
  from klaude_code.llm.responses.input import convert_history_to_input, convert_tool_schema
20
- from klaude_code.llm.usage import MetadataTracker
21
- from klaude_code.protocol import llm_param, model
27
+ from klaude_code.llm.usage import MetadataTracker, error_stream_items
28
+ from klaude_code.protocol import llm_param, message
22
29
  from klaude_code.trace import DebugType, log_debug
23
30
 
24
31
 
@@ -57,17 +64,14 @@ def build_payload(param: llm_param.LLMCallParameter) -> ResponseCreateParamsStre
57
64
  return payload
58
65
 
59
66
 
60
- # Codex API configuration
61
- CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
62
67
  CODEX_HEADERS = {
63
68
  "originator": "codex_cli_rs",
64
- # Mocked Codex-style user agent string
65
- "User-Agent": "codex_cli_rs/0.0.0-klaude",
69
+ "User-Agent": CODEX_USER_AGENT,
66
70
  "OpenAI-Beta": "responses=experimental",
67
71
  }
68
72
 
69
73
 
70
- @register(llm_param.LLMClientProtocol.CODEX)
74
+ @register(llm_param.LLMClientProtocol.CODEX_OAUTH)
71
75
  class CodexClient(LLMClientABC):
72
76
  """LLM client for Codex API using ChatGPT subscription."""
73
77
 
@@ -90,7 +94,7 @@ class CodexClient(LLMClientABC):
90
94
  return AsyncOpenAI(
91
95
  api_key=state.access_token,
92
96
  base_url=CODEX_BASE_URL,
93
- timeout=httpx.Timeout(300.0, connect=15.0, read=285.0),
97
+ timeout=httpx.Timeout(LLM_HTTP_TIMEOUT_TOTAL, connect=LLM_HTTP_TIMEOUT_CONNECT, read=LLM_HTTP_TIMEOUT_READ),
94
98
  default_headers={
95
99
  **CODEX_HEADERS,
96
100
  "chatgpt-account-id": state.account_id,
@@ -114,7 +118,7 @@ class CodexClient(LLMClientABC):
114
118
  return cls(config)
115
119
 
116
120
  @override
117
- async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[model.ConversationItem]:
121
+ async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[message.LLMStreamItem]:
118
122
  # Ensure token is valid before API call
119
123
  self._ensure_valid_token()
120
124
 
@@ -142,7 +146,9 @@ class CodexClient(LLMClientABC):
142
146
  extra_headers=extra_headers,
143
147
  )
144
148
  except (openai.OpenAIError, httpx.HTTPError) as e:
145
- yield model.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
149
+ error_message = f"{e.__class__.__name__} {e!s}"
150
+ for item in error_stream_items(metadata_tracker, error=error_message):
151
+ yield item
146
152
  return
147
153
 
148
154
  async for item in parse_responses_stream(stream, param, metadata_tracker):
@@ -5,7 +5,7 @@
5
5
 
6
6
  import json
7
7
  from collections.abc import AsyncGenerator, AsyncIterator
8
- from typing import Any, cast, override
8
+ from typing import Any, Literal, cast, override
9
9
  from uuid import uuid4
10
10
 
11
11
  import httpx
@@ -26,7 +26,7 @@ from klaude_code.llm.google.input import convert_history_to_contents, convert_to
26
26
  from klaude_code.llm.input_common import apply_config_defaults
27
27
  from klaude_code.llm.registry import register
28
28
  from klaude_code.llm.usage import MetadataTracker
29
- from klaude_code.protocol import llm_param, model
29
+ from klaude_code.protocol import llm_param, message, model
30
30
  from klaude_code.trace import DebugType, log_debug
31
31
 
32
32
 
@@ -114,25 +114,74 @@ def _merge_partial_args(dst: dict[str, Any], partial_args: list[Any] | None) ->
114
114
  dst[key] = _partial_arg_value(partial)
115
115
 
116
116
 
117
+ def _map_finish_reason(reason: str) -> model.StopReason | None:
118
+ normalized = reason.strip().lower()
119
+ mapping: dict[str, model.StopReason] = {
120
+ "stop": "stop",
121
+ "end_turn": "stop",
122
+ "max_tokens": "length",
123
+ "length": "length",
124
+ "tool_use": "tool_use",
125
+ "safety": "error",
126
+ "recitation": "error",
127
+ "other": "error",
128
+ "content_filter": "error",
129
+ "blocked": "error",
130
+ "blocklist": "error",
131
+ "cancelled": "aborted",
132
+ "canceled": "aborted",
133
+ "aborted": "aborted",
134
+ }
135
+ return mapping.get(normalized)
136
+
137
+
117
138
  async def parse_google_stream(
118
139
  stream: AsyncIterator[Any],
119
140
  param: llm_param.LLMCallParameter,
120
141
  metadata_tracker: MetadataTracker,
121
- ) -> AsyncGenerator[model.ConversationItem]:
142
+ ) -> AsyncGenerator[message.LLMStreamItem]:
122
143
  response_id: str | None = None
123
- started = False
144
+ stage: Literal["waiting", "thinking", "assistant", "tool"] = "waiting"
124
145
 
125
146
  accumulated_text: list[str] = []
126
147
  accumulated_thoughts: list[str] = []
127
148
  thought_signature: str | None = None
149
+ assistant_parts: list[message.Part] = []
128
150
 
129
151
  # Track tool calls where args arrive as partial updates.
130
152
  partial_args_by_call: dict[str, dict[str, Any]] = {}
131
153
  started_tool_calls: dict[str, str] = {} # call_id -> name
132
154
  started_tool_items: set[str] = set()
133
- emitted_tool_items: set[str] = set()
155
+ completed_tool_items: set[str] = set()
134
156
 
135
157
  last_usage_metadata: UsageMetadata | None = None
158
+ stop_reason: model.StopReason | None = None
159
+
160
+ def flush_thinking() -> None:
161
+ nonlocal thought_signature
162
+ if accumulated_thoughts:
163
+ assistant_parts.append(
164
+ message.ThinkingTextPart(
165
+ text="".join(accumulated_thoughts),
166
+ model_id=str(param.model),
167
+ )
168
+ )
169
+ accumulated_thoughts.clear()
170
+ if thought_signature:
171
+ assistant_parts.append(
172
+ message.ThinkingSignaturePart(
173
+ signature=thought_signature,
174
+ model_id=str(param.model),
175
+ format="google_thought_signature",
176
+ )
177
+ )
178
+ thought_signature = None
179
+
180
+ def flush_text() -> None:
181
+ if not accumulated_text:
182
+ return
183
+ assistant_parts.append(message.TextPart(text="".join(accumulated_text)))
184
+ accumulated_text.clear()
136
185
 
137
186
  async for chunk in stream:
138
187
  log_debug(
@@ -143,33 +192,44 @@ async def parse_google_stream(
143
192
 
144
193
  if response_id is None:
145
194
  response_id = getattr(chunk, "response_id", None) or uuid4().hex
146
- assert response_id is not None
147
- if not started:
148
- started = True
149
- yield model.StartItem(response_id=response_id)
150
195
 
151
196
  if getattr(chunk, "usage_metadata", None) is not None:
152
197
  last_usage_metadata = chunk.usage_metadata
153
198
 
154
199
  candidates = getattr(chunk, "candidates", None) or []
155
200
  candidate0 = candidates[0] if candidates else None
201
+ finish_reason = getattr(candidate0, "finish_reason", None) if candidate0 else None
202
+ if finish_reason is not None:
203
+ if isinstance(finish_reason, str):
204
+ reason_value = finish_reason
205
+ else:
206
+ reason_value = getattr(finish_reason, "name", None) or str(finish_reason)
207
+ stop_reason = _map_finish_reason(reason_value)
156
208
  content = getattr(candidate0, "content", None) if candidate0 else None
157
- parts = getattr(content, "parts", None) if content else None
158
- if not parts:
209
+ content_parts = getattr(content, "parts", None) if content else None
210
+ if not content_parts:
159
211
  continue
160
212
 
161
- for part in parts:
213
+ for part in content_parts:
162
214
  if getattr(part, "text", None) is not None:
163
- metadata_tracker.record_token()
164
215
  text = part.text
216
+ if not text:
217
+ continue
218
+ metadata_tracker.record_token()
165
219
  if getattr(part, "thought", False) is True:
220
+ if stage == "assistant":
221
+ flush_text()
222
+ stage = "thinking"
166
223
  accumulated_thoughts.append(text)
167
224
  if getattr(part, "thought_signature", None):
168
225
  thought_signature = part.thought_signature
169
- yield model.ReasoningTextDelta(content=text, response_id=response_id)
226
+ yield message.ThinkingTextDelta(content=text, response_id=response_id)
170
227
  else:
228
+ if stage == "thinking":
229
+ flush_thinking()
230
+ stage = "assistant"
171
231
  accumulated_text.append(text)
172
- yield model.AssistantMessageDelta(content=text, response_id=response_id)
232
+ yield message.AssistantTextDelta(content=text, response_id=response_id)
173
233
 
174
234
  function_call = getattr(part, "function_call", None)
175
235
  if function_call is None:
@@ -182,17 +242,23 @@ async def parse_google_stream(
182
242
 
183
243
  if call_id not in started_tool_items:
184
244
  started_tool_items.add(call_id)
185
- yield model.ToolCallStartItem(response_id=response_id, call_id=call_id, name=name)
245
+ yield message.ToolCallStartItem(response_id=response_id, call_id=call_id, name=name)
186
246
 
187
247
  args_obj = getattr(function_call, "args", None)
188
248
  if args_obj is not None:
189
- emitted_tool_items.add(call_id)
190
- yield model.ToolCallItem(
191
- response_id=response_id,
192
- call_id=call_id,
193
- name=name,
194
- arguments=json.dumps(args_obj, ensure_ascii=False),
249
+ if stage == "thinking":
250
+ flush_thinking()
251
+ if stage == "assistant":
252
+ flush_text()
253
+ stage = "tool"
254
+ assistant_parts.append(
255
+ message.ToolCallPart(
256
+ call_id=call_id,
257
+ tool_name=name,
258
+ arguments_json=json.dumps(args_obj, ensure_ascii=False),
259
+ )
195
260
  )
261
+ completed_tool_items.add(call_id)
196
262
  continue
197
263
 
198
264
  partial_args = getattr(function_call, "partial_args", None)
@@ -201,53 +267,49 @@ async def parse_google_stream(
201
267
  _merge_partial_args(acc, partial_args)
202
268
 
203
269
  will_continue = getattr(function_call, "will_continue", None)
204
- if will_continue is False and call_id in partial_args_by_call and call_id not in emitted_tool_items:
205
- emitted_tool_items.add(call_id)
206
- yield model.ToolCallItem(
207
- response_id=response_id,
208
- call_id=call_id,
209
- name=name,
210
- arguments=json.dumps(partial_args_by_call[call_id], ensure_ascii=False),
270
+ if will_continue is False and call_id in partial_args_by_call and call_id not in completed_tool_items:
271
+ if stage == "thinking":
272
+ flush_thinking()
273
+ if stage == "assistant":
274
+ flush_text()
275
+ stage = "tool"
276
+ assistant_parts.append(
277
+ message.ToolCallPart(
278
+ call_id=call_id,
279
+ tool_name=name,
280
+ arguments_json=json.dumps(partial_args_by_call[call_id], ensure_ascii=False),
281
+ )
211
282
  )
283
+ completed_tool_items.add(call_id)
212
284
 
213
285
  # Flush any pending tool calls that never produced args.
214
286
  for call_id, name in started_tool_calls.items():
215
- if call_id in emitted_tool_items:
287
+ if call_id in completed_tool_items:
216
288
  continue
217
289
  args = partial_args_by_call.get(call_id, {})
218
- emitted_tool_items.add(call_id)
219
- yield model.ToolCallItem(
220
- response_id=response_id,
221
- call_id=call_id,
222
- name=name,
223
- arguments=json.dumps(args, ensure_ascii=False),
224
- )
225
-
226
- if accumulated_thoughts:
227
- metadata_tracker.record_token()
228
- yield model.ReasoningTextItem(
229
- content="".join(accumulated_thoughts),
230
- response_id=response_id,
231
- model=str(param.model),
232
- )
233
- if thought_signature:
234
- yield model.ReasoningEncryptedItem(
235
- encrypted_content=thought_signature,
236
- response_id=response_id,
237
- model=str(param.model),
238
- format="google_thought_signature",
290
+ assistant_parts.append(
291
+ message.ToolCallPart(
292
+ call_id=call_id,
293
+ tool_name=name,
294
+ arguments_json=json.dumps(args, ensure_ascii=False),
239
295
  )
296
+ )
240
297
 
241
- if accumulated_text:
242
- metadata_tracker.record_token()
243
- yield model.AssistantMessageItem(content="".join(accumulated_text), response_id=response_id)
298
+ flush_thinking()
299
+ flush_text()
244
300
 
245
301
  usage = _usage_from_metadata(last_usage_metadata, context_limit=param.context_limit, max_tokens=param.max_tokens)
246
302
  if usage is not None:
247
303
  metadata_tracker.set_usage(usage)
248
304
  metadata_tracker.set_model_name(str(param.model))
249
305
  metadata_tracker.set_response_id(response_id)
250
- yield metadata_tracker.finalize()
306
+ metadata = metadata_tracker.finalize()
307
+ yield message.AssistantMessage(
308
+ parts=assistant_parts,
309
+ response_id=response_id,
310
+ usage=metadata,
311
+ stop_reason=stop_reason,
312
+ )
251
313
 
252
314
 
253
315
  @register(llm_param.LLMClientProtocol.GOOGLE)
@@ -270,7 +332,7 @@ class GoogleClient(LLMClientABC):
270
332
  return cls(config)
271
333
 
272
334
  @override
273
- async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[model.ConversationItem]:
335
+ async def call(self, param: llm_param.LLMCallParameter) -> AsyncGenerator[message.LLMStreamItem]:
274
336
  param = apply_config_defaults(param, self.get_llm_config())
275
337
  metadata_tracker = MetadataTracker(cost_config=self.get_llm_config().cost)
276
338
 
@@ -297,13 +359,13 @@ class GoogleClient(LLMClientABC):
297
359
  config=config,
298
360
  )
299
361
  except (APIError, ClientError, ServerError, httpx.HTTPError) as e:
300
- yield model.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
301
- yield metadata_tracker.finalize()
362
+ yield message.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
363
+ yield message.AssistantMessage(parts=[], response_id=None, usage=metadata_tracker.finalize())
302
364
  return
303
365
 
304
366
  try:
305
367
  async for item in parse_google_stream(stream, param=param, metadata_tracker=metadata_tracker):
306
368
  yield item
307
369
  except (APIError, ClientError, ServerError, httpx.HTTPError) as e:
308
- yield model.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
309
- yield metadata_tracker.finalize()
370
+ yield message.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
371
+ yield message.AssistantMessage(parts=[], response_id=None, usage=metadata_tracker.finalize())