agenthub-python 0.3.2__tar.gz → 0.4.0__tar.gz

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 (31) hide show
  1. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/PKG-INFO +4 -1
  2. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/__init__.py +9 -1
  3. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/auto_client.py +11 -5
  4. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/base_client.py +42 -9
  5. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/claude4_6/client.py +17 -6
  6. agenthub_python-0.4.0/agenthub/claude5/__init__.py +18 -0
  7. {agenthub_python-0.3.2/agenthub/claude4_8 → agenthub_python-0.4.0/agenthub/claude5}/client.py +20 -9
  8. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/deepseek_v4/client.py +22 -3
  9. agenthub_python-0.4.0/agenthub/errors.py +75 -0
  10. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/gemini3/client.py +38 -16
  11. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/glm5_1/client.py +45 -11
  12. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/gpt5_5/client.py +19 -12
  13. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/kimi_k2_6/client.py +45 -11
  14. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/openai/client.py +45 -11
  15. {agenthub_python-0.3.2/agenthub/claude4_8 → agenthub_python-0.4.0/agenthub/openai_embedding}/__init__.py +2 -2
  16. agenthub_python-0.4.0/agenthub/openai_embedding/client.py +80 -0
  17. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/types.py +11 -7
  18. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/pyproject.toml +6 -1
  19. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/README.md +0 -0
  20. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/abort_signal.py +0 -0
  21. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/claude4_6/__init__.py +0 -0
  22. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/deepseek_v4/__init__.py +0 -0
  23. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/gemini3/__init__.py +0 -0
  24. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/glm5_1/__init__.py +0 -0
  25. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/gpt5_5/__init__.py +0 -0
  26. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/integration/__init__.py +0 -0
  27. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/integration/playground.py +0 -0
  28. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/integration/tracer.py +0 -0
  29. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/kimi_k2_6/__init__.py +0 -0
  30. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/openai/__init__.py +0 -0
  31. {agenthub_python-0.3.2 → agenthub_python-0.4.0}/agenthub/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agenthub-python
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: AgentHub is the LLM API Hub for the Agent era, built for high-precision autonomous agents.
5
5
  Keywords: agent,llm,gemini,claude,gpt
6
6
  Author: PrismShadow
@@ -16,6 +16,9 @@ Requires-Dist: pytest-asyncio>=0.23.0 ; extra == 'dev'
16
16
  Requires-Dist: ruff>=0.14.3 ; extra == 'dev'
17
17
  Requires-Dist: pillow>=10.0.0 ; extra == 'dev'
18
18
  Requires-Python: >=3.11
19
+ Project-URL: Homepage, https://github.com/Prism-Shadow/agenthub
20
+ Project-URL: Repository, https://github.com/Prism-Shadow/agenthub
21
+ Project-URL: Issues, https://github.com/Prism-Shadow/agenthub/issues
19
22
  Provides-Extra: dev
20
23
  Description-Content-Type: text/markdown
21
24
 
@@ -13,7 +13,15 @@
13
13
  # limitations under the License.
14
14
 
15
15
  from .auto_client import AutoLLMClient
16
+ from .errors import AgentHubError, EmptyResponseError, ToolCallArgumentParseError
16
17
  from .types import PromptCaching, ThinkingLevel
17
18
 
18
19
 
19
- __all__ = ["AutoLLMClient", "PromptCaching", "ThinkingLevel"]
20
+ __all__ = [
21
+ "AgentHubError",
22
+ "AutoLLMClient",
23
+ "EmptyResponseError",
24
+ "PromptCaching",
25
+ "ThinkingLevel",
26
+ "ToolCallArgumentParseError",
27
+ ]
@@ -53,10 +53,12 @@ class AutoLLMClient(LLMClient):
53
53
  from .gemini3 import Gemini3Client
54
54
 
55
55
  return Gemini3Client(model=model, api_key=api_key, base_url=base_url)
56
- elif "claude" in client_type and ("4-7" in client_type or "4-8" in client_type): # e.g., claude-opus-4-7
57
- from .claude4_8 import Claude4_8Client
56
+ elif "claude" in client_type and (
57
+ "4-7" in client_type or "4-8" in client_type or "-5" in client_type
58
+ ): # e.g., claude-opus-4-7
59
+ from .claude5 import Claude5Client
58
60
 
59
- return Claude4_8Client(model=model, api_key=api_key, base_url=base_url)
61
+ return Claude5Client(model=model, api_key=api_key, base_url=base_url)
60
62
  elif "claude" in client_type and "4-6" in client_type: # e.g., claude-sonnet-4-6
61
63
  from .claude4_6 import Claude4_6Client
62
64
 
@@ -77,14 +79,18 @@ class AutoLLMClient(LLMClient):
77
79
  from .deepseek_v4 import DeepSeekV4Client
78
80
 
79
81
  return DeepSeekV4Client(model=model, api_key=api_key, base_url=base_url)
80
- elif "openai" in client_type:
82
+ elif "openai" in client_type and "embedding" in client_type:
83
+ from .openai_embedding import OpenaiEmbeddingClient
84
+
85
+ return OpenaiEmbeddingClient(model=model, api_key=api_key, base_url=base_url)
86
+ elif "openai" in client_type and "embedding" not in client_type:
81
87
  from .openai import OpenaiClient
82
88
 
83
89
  return OpenaiClient(model=model, api_key=api_key, base_url=base_url)
84
90
  else:
85
91
  raise ValueError(
86
92
  f"{client_type} is not supported. "
87
- "Supported client types: gemini-3, claude-4-8, claude-4-7, claude-4-6, gpt-5.5, gpt-5.4, glm-5.1, kimi-k2.6, kimi-k2.5, deepseek-v4, openai."
93
+ "Supported client types: gemini-3, claude-5, claude-4-8, claude-4-7, claude-4-6, gpt-5.5, gpt-5.4, glm-5.1, kimi-k2.6, kimi-k2.5, deepseek-v4, openai-embedding, openai."
88
94
  )
89
95
 
90
96
  def transform_uni_config_to_model_config(self, config: UniConfig) -> Any:
@@ -19,6 +19,7 @@ from contextlib import suppress
19
19
  from typing import Any, AsyncIterator
20
20
 
21
21
  from .abort_signal import AbortSignal
22
+ from .errors import EmptyResponseError
22
23
  from .types import (
23
24
  ContentItem,
24
25
  FinishReason,
@@ -100,28 +101,40 @@ class LLMClient(ABC):
100
101
  for event in events:
101
102
  # Merge content_items from all events
102
103
  for item in event["content_items"]:
104
+ last_fidelity = (content_items[-1].get("fidelity") or {}) if content_items else {}
105
+ item_fidelity = item.get("fidelity") or {}
103
106
  if item["type"] == "text":
107
+ # a delta announcing a different phase starts a new item; same-phase and
108
+ # phaseless deltas merge until a signature finishes the item
104
109
  if (
105
110
  content_items
106
111
  and content_items[-1]["type"] == "text"
107
- and content_items[-1].get("signature") is None # no signature yet
108
- and item.get("phase") is None # no new phase
112
+ and last_fidelity.get("signature") is None # not finished by a signature yet
113
+ and (
114
+ item_fidelity.get("phase") is None # phaseless deltas continue the item
115
+ or item_fidelity.get("phase") == last_fidelity.get("phase") # same phase merges
116
+ )
109
117
  ):
110
118
  content_items[-1]["text"] += item["text"]
111
- if "signature" in item: # finish the current item if signature is not None
112
- content_items[-1]["signature"] = item["signature"]
113
- elif item["text"] or item.get("phase") is not None: # text or new phase starts an item
119
+ if item_fidelity: # a signature finishes the current item
120
+ content_items[-1]["fidelity"] = {**last_fidelity, **item_fidelity}
121
+ elif item["text"] or item_fidelity.get("phase") is not None: # text or new phase starts an item
114
122
  content_items.append(item.copy())
115
123
  elif item["type"] == "thinking":
124
+ # a new item starts only when the open item's fidelity is non-empty and
125
+ # differs from the incoming delta's; everything else merges into it
116
126
  if (
117
127
  content_items
118
128
  and content_items[-1]["type"] == "thinking"
119
- and content_items[-1].get("signature") is None # no signature yet
129
+ and (
130
+ not last_fidelity # not finished by fidelity yet
131
+ or last_fidelity == item_fidelity # a run of equal fidelity is one item
132
+ )
120
133
  ):
121
134
  content_items[-1]["thinking"] += item["thinking"]
122
- if "signature" in item: # finish the current item if signature is not None
123
- content_items[-1]["signature"] = item["signature"]
124
- elif item["thinking"] or item.get("signature"): # omit empty thinking items
135
+ if item_fidelity: # fidelity finishes the current item
136
+ content_items[-1]["fidelity"] = item_fidelity
137
+ elif item["thinking"] or item_fidelity: # omit empty thinking items
125
138
  content_items.append(item.copy())
126
139
  elif item["type"] == "partial_tool_call":
127
140
  # Skip partial_tool_call items - they should already be converted to tool_call
@@ -244,6 +257,7 @@ class LLMClient(ABC):
244
257
  await stream.aclose()
245
258
 
246
259
  self._validate_last_event(last_event)
260
+ self._validate_non_thinking_output(events)
247
261
 
248
262
  # Save history to file if trace_id is specified
249
263
  if config.get("trace_id") and events:
@@ -312,6 +326,25 @@ class LLMClient(ABC):
312
326
  if last_event["finish_reason"] is None:
313
327
  raise ValueError(f"Last event must carry finish_reason, got: {last_event}")
314
328
 
329
+ def _validate_non_thinking_output(self, events: list[UniEvent]) -> None:
330
+ """Validate that the completed response carries content other than thinking.
331
+
332
+ Replaying a thinking-only assistant message on the next turn fails with a 400
333
+ error, so the response is rejected as soon as the stream completes.
334
+
335
+ Args:
336
+ events: All events yielded by streaming_response
337
+
338
+ Raises:
339
+ EmptyResponseError: If every content item in the response is thinking
340
+ """
341
+ thinking_only = all(
342
+ item["type"] in ("thinking", "inline_thinking") for event in events for item in event["content_items"]
343
+ )
344
+ if thinking_only:
345
+ finish_reason = events[-1]["finish_reason"] if events else None
346
+ raise EmptyResponseError(self.__class__.__name__, finish_reason)
347
+
315
348
  def clear_history(self) -> None:
316
349
  """Clear the message history."""
317
350
  self._history.clear()
@@ -13,7 +13,6 @@
13
13
  # limitations under the License.
14
14
 
15
15
  import base64
16
- import json
17
16
  import mimetypes
18
17
  import os
19
18
  import re
@@ -24,6 +23,7 @@ from anthropic import AsyncAnthropic, AsyncAnthropicBedrock
24
23
  from anthropic.types.beta import BetaMessageParam, BetaRawMessageStreamEvent
25
24
 
26
25
  from ..base_client import LLMClient
26
+ from ..errors import parse_tool_call_arguments
27
27
  from ..types import (
28
28
  EventType,
29
29
  FinishReason,
@@ -204,10 +204,14 @@ class Claude4_6Client(LLMClient):
204
204
  content_blocks.append(await self._convert_image_url_to_source(item["image_url"]))
205
205
  elif item["type"] == "thinking":
206
206
  if item["thinking"] == REDACTED_THINKING:
207
- content_blocks.append({"type": "redacted_thinking", "data": item["signature"]})
207
+ content_blocks.append({"type": "redacted_thinking", "data": item["fidelity"]["signature"]})
208
208
  else:
209
209
  content_blocks.append(
210
- {"type": "thinking", "thinking": item["thinking"], "signature": item["signature"]}
210
+ {
211
+ "type": "thinking",
212
+ "thinking": item["thinking"],
213
+ "signature": item["fidelity"]["signature"],
214
+ }
211
215
  )
212
216
  elif item["type"] == "tool_call":
213
217
  content_blocks.append(
@@ -263,7 +267,9 @@ class Claude4_6Client(LLMClient):
263
267
  {"type": "partial_tool_call", "name": block.name, "arguments": "", "tool_call_id": block.id}
264
268
  )
265
269
  elif block.type == "redacted_thinking":
266
- content_items.append({"type": "thinking", "thinking": REDACTED_THINKING, "signature": block.data})
270
+ content_items.append(
271
+ {"type": "thinking", "thinking": REDACTED_THINKING, "fidelity": {"signature": block.data}}
272
+ )
267
273
 
268
274
  elif claude_event_type == "content_block_delta":
269
275
  event_type = "delta"
@@ -277,7 +283,7 @@ class Claude4_6Client(LLMClient):
277
283
  {"type": "partial_tool_call", "name": "", "arguments": delta.partial_json, "tool_call_id": ""}
278
284
  )
279
285
  elif delta.type == "signature_delta":
280
- content_items.append({"type": "thinking", "thinking": "", "signature": delta.signature})
286
+ content_items.append({"type": "thinking", "thinking": "", "fidelity": {"signature": delta.signature}})
281
287
 
282
288
  elif claude_event_type == "content_block_stop":
283
289
  event_type = "stop"
@@ -403,7 +409,12 @@ class Claude4_6Client(LLMClient):
403
409
  {
404
410
  "type": "tool_call",
405
411
  "name": partial_tool_call["name"],
406
- "arguments": json.loads(partial_tool_call["arguments"]),
412
+ "arguments": parse_tool_call_arguments(
413
+ partial_tool_call["arguments"],
414
+ self.__class__.__name__,
415
+ partial_tool_call["name"],
416
+ partial_tool_call["tool_call_id"],
417
+ ),
407
418
  "tool_call_id": partial_tool_call["tool_call_id"],
408
419
  }
409
420
  ],
@@ -0,0 +1,18 @@
1
+ # Copyright 2025 Prism Shadow. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from .client import Claude5Client
16
+
17
+
18
+ __all__ = ["Claude5Client"]
@@ -13,7 +13,6 @@
13
13
  # limitations under the License.
14
14
 
15
15
  import base64
16
- import json
17
16
  import mimetypes
18
17
  import os
19
18
  import re
@@ -24,6 +23,7 @@ from anthropic import AsyncAnthropic, AsyncAnthropicBedrock
24
23
  from anthropic.types.beta import BetaMessageParam, BetaRawMessageStreamEvent
25
24
 
26
25
  from ..base_client import LLMClient
26
+ from ..errors import parse_tool_call_arguments
27
27
  from ..types import (
28
28
  EventType,
29
29
  FinishReason,
@@ -41,11 +41,11 @@ from ..types import (
41
41
  REDACTED_THINKING = "_REDACTED_THINKING"
42
42
 
43
43
 
44
- class Claude4_8Client(LLMClient):
45
- """Claude 4.8-specific LLM client implementation."""
44
+ class Claude5Client(LLMClient):
45
+ """Claude 5-specific LLM client implementation."""
46
46
 
47
47
  def __init__(self, model: str, api_key: str | None = None, base_url: str | None = None):
48
- """Initialize Claude 4.8 client with model and API key."""
48
+ """Initialize Claude 5 client with model and API key."""
49
49
  self._model = model
50
50
  api_key = api_key or os.getenv("ANTHROPIC_API_KEY")
51
51
  base_url = base_url or os.getenv("ANTHROPIC_BASE_URL")
@@ -204,10 +204,14 @@ class Claude4_8Client(LLMClient):
204
204
  content_blocks.append(await self._convert_image_url_to_source(item["image_url"]))
205
205
  elif item["type"] == "thinking":
206
206
  if item["thinking"] == REDACTED_THINKING:
207
- content_blocks.append({"type": "redacted_thinking", "data": item["signature"]})
207
+ content_blocks.append({"type": "redacted_thinking", "data": item["fidelity"]["signature"]})
208
208
  else:
209
209
  content_blocks.append(
210
- {"type": "thinking", "thinking": item["thinking"], "signature": item["signature"]}
210
+ {
211
+ "type": "thinking",
212
+ "thinking": item["thinking"],
213
+ "signature": item["fidelity"]["signature"],
214
+ }
211
215
  )
212
216
  elif item["type"] == "tool_call":
213
217
  content_blocks.append(
@@ -263,7 +267,9 @@ class Claude4_8Client(LLMClient):
263
267
  {"type": "partial_tool_call", "name": block.name, "arguments": "", "tool_call_id": block.id}
264
268
  )
265
269
  elif block.type == "redacted_thinking":
266
- content_items.append({"type": "thinking", "thinking": REDACTED_THINKING, "signature": block.data})
270
+ content_items.append(
271
+ {"type": "thinking", "thinking": REDACTED_THINKING, "fidelity": {"signature": block.data}}
272
+ )
267
273
 
268
274
  elif claude_event_type == "content_block_delta":
269
275
  event_type = "delta"
@@ -277,7 +283,7 @@ class Claude4_8Client(LLMClient):
277
283
  {"type": "partial_tool_call", "name": "", "arguments": delta.partial_json, "tool_call_id": ""}
278
284
  )
279
285
  elif delta.type == "signature_delta":
280
- content_items.append({"type": "thinking", "thinking": "", "signature": delta.signature})
286
+ content_items.append({"type": "thinking", "thinking": "", "fidelity": {"signature": delta.signature}})
281
287
 
282
288
  elif claude_event_type == "content_block_stop":
283
289
  event_type = "stop"
@@ -403,7 +409,12 @@ class Claude4_8Client(LLMClient):
403
409
  {
404
410
  "type": "tool_call",
405
411
  "name": partial_tool_call["name"],
406
- "arguments": json.loads(partial_tool_call["arguments"]),
412
+ "arguments": parse_tool_call_arguments(
413
+ partial_tool_call["arguments"],
414
+ self.__class__.__name__,
415
+ partial_tool_call["name"],
416
+ partial_tool_call["tool_call_id"],
417
+ ),
407
418
  "tool_call_id": partial_tool_call["tool_call_id"],
408
419
  }
409
420
  ],
@@ -20,6 +20,7 @@ from openai import AsyncOpenAI
20
20
  from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam
21
21
 
22
22
  from ..base_client import LLMClient
23
+ from ..errors import parse_tool_call_arguments
23
24
  from ..types import (
24
25
  EventType,
25
26
  FinishReason,
@@ -200,7 +201,15 @@ class DeepSeekV4Client(LLMClient):
200
201
 
201
202
  if getattr(delta, "reasoning_content", None):
202
203
  event_type = "delta"
203
- content_items.append({"type": "thinking", "thinking": getattr(delta, "reasoning_content")})
204
+ # record the wire field so a replay through another OpenAI-compatible
205
+ # client reproduces the exact field DeepSeek produced
206
+ content_items.append(
207
+ {
208
+ "type": "thinking",
209
+ "thinking": getattr(delta, "reasoning_content"),
210
+ "fidelity": {"reasoning_field": "reasoning_content"},
211
+ }
212
+ )
204
213
 
205
214
  if delta.content:
206
215
  event_type = "delta"
@@ -289,7 +298,12 @@ class DeepSeekV4Client(LLMClient):
289
298
  {
290
299
  "type": "tool_call",
291
300
  "name": partial_tool_call["name"],
292
- "arguments": json.loads(partial_tool_call["arguments"] or "{}"),
301
+ "arguments": parse_tool_call_arguments(
302
+ partial_tool_call["arguments"],
303
+ self.__class__.__name__,
304
+ partial_tool_call["name"],
305
+ partial_tool_call["tool_call_id"],
306
+ ),
293
307
  "tool_call_id": partial_tool_call["tool_call_id"],
294
308
  }
295
309
  ],
@@ -317,7 +331,12 @@ class DeepSeekV4Client(LLMClient):
317
331
  {
318
332
  "type": "tool_call",
319
333
  "name": partial_tool_call["name"],
320
- "arguments": json.loads(partial_tool_call["arguments"] or "{}"),
334
+ "arguments": parse_tool_call_arguments(
335
+ partial_tool_call["arguments"],
336
+ self.__class__.__name__,
337
+ partial_tool_call["name"],
338
+ partial_tool_call["tool_call_id"],
339
+ ),
321
340
  "tool_call_id": partial_tool_call["tool_call_id"],
322
341
  }
323
342
  ],
@@ -0,0 +1,75 @@
1
+ # Copyright 2025 Prism Shadow. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import json
16
+ from typing import Any
17
+
18
+
19
+ def _preview_tool_call_arguments(raw: str) -> str:
20
+ max_length = 160
21
+ if len(raw) <= max_length:
22
+ return raw
23
+
24
+ edge_length = 72
25
+ return f"{raw[:edge_length]}...[truncated]...{raw[-edge_length:]}"
26
+
27
+
28
+ class AgentHubError(ValueError):
29
+ """Base class for errors raised by AgentHub clients."""
30
+
31
+
32
+ class EmptyResponseError(AgentHubError):
33
+ """Raised when a completed response carries no non-thinking content and no tool calls.
34
+
35
+ Models occasionally finish a turn with thinking output only (reasoning models in
36
+ particular); replaying such an assistant message on the next turn fails with a 400
37
+ error, so the response is rejected as soon as the stream completes.
38
+ """
39
+
40
+ def __init__(self, client: str, finish_reason: str | None) -> None:
41
+ self.client = client
42
+ self.finish_reason = finish_reason
43
+ super().__init__(f"{client} returned no content other than thinking (finish_reason={finish_reason!r}).")
44
+
45
+
46
+ class ToolCallArgumentParseError(AgentHubError):
47
+ def __init__(self, client: str, tool_name: str, tool_call_id: str, raw_arguments: str, reason: str) -> None:
48
+ self.client = client
49
+ self.tool_name = tool_name
50
+ self.tool_call_id = tool_call_id
51
+ self.raw_arguments_length = len(raw_arguments)
52
+ self.raw_arguments_preview = _preview_tool_call_arguments(raw_arguments)
53
+ super().__init__(
54
+ f'Invalid streamed tool call arguments from {client} for tool "{tool_name}" '
55
+ f'(tool_call_id="{tool_call_id}", length={self.raw_arguments_length}, '
56
+ f"preview={self.raw_arguments_preview!r}): {reason}"
57
+ )
58
+
59
+
60
+ def parse_tool_call_arguments(
61
+ raw_arguments: str | None,
62
+ client: str,
63
+ tool_name: str,
64
+ tool_call_id: str,
65
+ ) -> dict[str, Any]:
66
+ raw = raw_arguments or "{}"
67
+ try:
68
+ parsed = json.loads(raw)
69
+ except (TypeError, ValueError) as exc:
70
+ raise ToolCallArgumentParseError(client, tool_name, tool_call_id, raw, str(exc)) from exc
71
+
72
+ if not isinstance(parsed, dict):
73
+ raise ToolCallArgumentParseError(client, tool_name, tool_call_id, raw, "Expected a JSON object.")
74
+
75
+ return parsed
@@ -26,7 +26,9 @@ from google.oauth2 import service_account
26
26
 
27
27
  from ..base_client import LLMClient
28
28
  from ..types import (
29
+ ContentItem,
29
30
  EventType,
31
+ Fidelity,
30
32
  FinishReason,
31
33
  PartialContentItem,
32
34
  PromptCaching,
@@ -188,6 +190,19 @@ class Gemini3Client(LLMClient):
188
190
 
189
191
  return types.GenerateContentConfig(**config_params) if config_params else None
190
192
 
193
+ @staticmethod
194
+ def _part_fidelity(part: types.Part) -> dict[str, Fidelity]:
195
+ """Wrap a part's thought signature as a fidelity payload, or nothing when absent."""
196
+ if part.thought_signature is None:
197
+ return {}
198
+
199
+ return {"fidelity": {"signature": part.thought_signature}}
200
+
201
+ @staticmethod
202
+ def _item_thought_signature(item: ContentItem) -> str | bytes | None:
203
+ """Read the thought signature recorded in an item's fidelity payload."""
204
+ return (item.get("fidelity") or {}).get("signature")
205
+
191
206
  async def transform_uni_message_to_model_input(self, messages: list[UniMessage]) -> list[types.Content]:
192
207
  """
193
208
  Transform universal message format to Gemini's Content format.
@@ -204,26 +219,34 @@ class Gemini3Client(LLMClient):
204
219
  parts = []
205
220
  for item in msg["content_items"]:
206
221
  if item["type"] == "text":
207
- parts.append(types.Part(text=item["text"], thought_signature=item.get("signature")))
222
+ parts.append(types.Part(text=item["text"], thought_signature=self._item_thought_signature(item)))
208
223
  elif item["type"] == "image_url":
209
224
  image_url = item["image_url"]
210
225
  image_data = await self._get_image_bytes_and_mime_type(image_url)
211
226
  parts.append(types.Part.from_bytes(**image_data))
212
227
  elif item["type"] == "inline_data":
213
228
  inline_data = types.Blob(data=item["data"], mime_type=item["mime_type"])
214
- parts.append(types.Part(inline_data=inline_data, thought_signature=item.get("signature")))
229
+ parts.append(
230
+ types.Part(inline_data=inline_data, thought_signature=self._item_thought_signature(item))
231
+ )
215
232
  elif item["type"] == "thinking":
216
233
  parts.append(
217
- types.Part(text=item["thinking"], thought=True, thought_signature=item.get("signature"))
234
+ types.Part(
235
+ text=item["thinking"], thought=True, thought_signature=self._item_thought_signature(item)
236
+ )
218
237
  )
219
238
  elif item["type"] == "inline_thinking":
220
239
  inline_data = types.Blob(data=item["data"], mime_type=item["mime_type"])
221
240
  parts.append(
222
- types.Part(inline_data=inline_data, thought=True, thought_signature=item.get("signature"))
241
+ types.Part(
242
+ inline_data=inline_data, thought=True, thought_signature=self._item_thought_signature(item)
243
+ )
223
244
  )
224
245
  elif item["type"] == "tool_call":
225
246
  function_call = types.FunctionCall(name=item["name"], args=item["arguments"])
226
- parts.append(types.Part(function_call=function_call, thought_signature=item.get("signature")))
247
+ parts.append(
248
+ types.Part(function_call=function_call, thought_signature=self._item_thought_signature(item))
249
+ )
227
250
  elif item["type"] == "tool_result":
228
251
  if "tool_call_id" not in item:
229
252
  raise ValueError("tool_call_id is required for tool result.")
@@ -266,31 +289,30 @@ class Gemini3Client(LLMClient):
266
289
  usage_metadata: UsageMetadata | None = None
267
290
  finish_reason: FinishReason | None = None
268
291
 
269
- if len(model_output.candidates) > 0:
292
+ if model_output.candidates:
270
293
  candidate = model_output.candidates[0]
271
- for part in candidate.content.parts:
294
+ content = getattr(candidate, "content", None)
295
+ for part in getattr(content, "parts", None) or []:
272
296
  if part.function_call is not None:
273
297
  content_items.append(
274
298
  {
275
299
  "type": "tool_call",
276
300
  "name": part.function_call.name,
277
- "arguments": part.function_call.args,
301
+ "arguments": part.function_call.args or {},
278
302
  "tool_call_id": part.function_call.name,
279
- "signature": part.thought_signature,
303
+ **self._part_fidelity(part),
280
304
  }
281
305
  )
282
306
  elif part.thought:
283
307
  if part.text is not None:
284
- content_items.append(
285
- {"type": "thinking", "thinking": part.text, "signature": part.thought_signature}
286
- )
308
+ content_items.append({"type": "thinking", "thinking": part.text, **self._part_fidelity(part)})
287
309
  elif part.inline_data is not None:
288
310
  content_items.append(
289
311
  {
290
312
  "type": "inline_thinking",
291
313
  "data": part.inline_data.data,
292
314
  "mime_type": part.inline_data.mime_type,
293
- "signature": part.thought_signature,
315
+ **self._part_fidelity(part),
294
316
  }
295
317
  )
296
318
  elif part.inline_data is not None:
@@ -299,11 +321,11 @@ class Gemini3Client(LLMClient):
299
321
  "type": "inline_data",
300
322
  "data": part.inline_data.data,
301
323
  "mime_type": part.inline_data.mime_type,
302
- "signature": part.thought_signature,
324
+ **self._part_fidelity(part),
303
325
  }
304
326
  )
305
327
  elif part.text is not None:
306
- content_items.append({"type": "text", "text": part.text, "signature": part.thought_signature})
328
+ content_items.append({"type": "text", "text": part.text, **self._part_fidelity(part)})
307
329
  else:
308
330
  raise ValueError(f"Unknown output: {part}")
309
331
 
@@ -414,7 +436,7 @@ class Gemini3Client(LLMClient):
414
436
  "name": item["name"],
415
437
  "arguments": json.dumps(item["arguments"], ensure_ascii=False),
416
438
  "tool_call_id": item["tool_call_id"],
417
- "signature": item.get("signature"),
439
+ "fidelity": item.get("fidelity"),
418
440
  }
419
441
  ],
420
442
  "usage_metadata": None,
@@ -20,6 +20,7 @@ from openai import AsyncOpenAI
20
20
  from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam
21
21
 
22
22
  from ..base_client import LLMClient
23
+ from ..errors import parse_tool_call_arguments
23
24
  from ..types import (
24
25
  EventType,
25
26
  FinishReason,
@@ -115,6 +116,7 @@ class GLM5_1Client(LLMClient):
115
116
  content_parts = [] # may be empty for tool results
116
117
  tool_calls = [] # may be empty for no tool calls
117
118
  thinking = ""
119
+ thinking_fields: set[str | None] = set()
118
120
  for item in msg["content_items"]:
119
121
  if item["type"] == "text":
120
122
  content_parts.append({"type": "text", "text": item["text"]})
@@ -122,6 +124,7 @@ class GLM5_1Client(LLMClient):
122
124
  raise ValueError("GLM-5 does not support image inputs.")
123
125
  elif item["type"] == "thinking":
124
126
  thinking += item["thinking"]
127
+ thinking_fields.add((item.get("fidelity") or {}).get("reasoning_field"))
125
128
  elif item["type"] == "tool_call":
126
129
  tool_calls.append(
127
130
  {
@@ -159,8 +162,15 @@ class GLM5_1Client(LLMClient):
159
162
  message["tool_calls"] = tool_calls
160
163
 
161
164
  if thinking:
162
- message["reasoning_content"] = thinking # vLLM & siliconflow compatibility
163
- message["reasoning"] = thinking # openrouter compatibility
165
+ # send thinking back through the exact field the upstream produced (recorded
166
+ # in the item fidelity); servers may reject the spelling they did not emit
167
+ if thinking_fields == {"reasoning_content"}:
168
+ message["reasoning_content"] = thinking
169
+ elif thinking_fields == {"reasoning"}:
170
+ message["reasoning"] = thinking
171
+ else:
172
+ message["reasoning_content"] = thinking # vLLM & siliconflow compatibility
173
+ message["reasoning"] = thinking # openrouter compatibility
164
174
 
165
175
  # message may be empty for tool results
166
176
  if len(message.keys()) > 1:
@@ -191,15 +201,29 @@ class GLM5_1Client(LLMClient):
191
201
  event_type = "delta"
192
202
  content_items.append({"type": "text", "text": delta.content})
193
203
 
194
- # vLLM & siliconflow compatibility
195
- if getattr(delta, "reasoning_content", None):
204
+ # the thinking field name differs by server: vLLM & siliconflow use reasoning_content
205
+ # while openrouter uses reasoning; record the wire field that carried each delta
206
+ # so a replay can reproduce exactly the field the upstream produced
207
+ reasoning_content = getattr(delta, "reasoning_content", None)
208
+ reasoning = getattr(delta, "reasoning", None)
209
+ if reasoning_content and reasoning:
196
210
  event_type = "delta"
197
- content_items.append({"type": "thinking", "thinking": getattr(delta, "reasoning_content")})
198
-
199
- # openrouter compatibility
200
- elif getattr(delta, "reasoning", None):
211
+ # ambiguous origin: record no fidelity so a replay sends both fields back
212
+ content_items.append({"type": "thinking", "thinking": reasoning_content})
213
+ elif reasoning_content:
214
+ event_type = "delta"
215
+ content_items.append(
216
+ {
217
+ "type": "thinking",
218
+ "thinking": reasoning_content,
219
+ "fidelity": {"reasoning_field": "reasoning_content"},
220
+ }
221
+ )
222
+ elif reasoning:
201
223
  event_type = "delta"
202
- content_items.append({"type": "thinking", "thinking": getattr(delta, "reasoning")})
224
+ content_items.append(
225
+ {"type": "thinking", "thinking": reasoning, "fidelity": {"reasoning_field": "reasoning"}}
226
+ )
203
227
 
204
228
  if delta.tool_calls:
205
229
  event_type = "delta"
@@ -307,7 +331,12 @@ class GLM5_1Client(LLMClient):
307
331
  {
308
332
  "type": "tool_call",
309
333
  "name": partial_tool_call["name"],
310
- "arguments": json.loads(partial_tool_call["arguments"] or "{}"),
334
+ "arguments": parse_tool_call_arguments(
335
+ partial_tool_call["arguments"],
336
+ self.__class__.__name__,
337
+ partial_tool_call["name"],
338
+ partial_tool_call["tool_call_id"],
339
+ ),
311
340
  "tool_call_id": partial_tool_call["tool_call_id"],
312
341
  }
313
342
  ],
@@ -335,7 +364,12 @@ class GLM5_1Client(LLMClient):
335
364
  {
336
365
  "type": "tool_call",
337
366
  "name": partial_tool_call["name"],
338
- "arguments": json.loads(partial_tool_call["arguments"] or "{}"),
367
+ "arguments": parse_tool_call_arguments(
368
+ partial_tool_call["arguments"],
369
+ self.__class__.__name__,
370
+ partial_tool_call["name"],
371
+ partial_tool_call["tool_call_id"],
372
+ ),
339
373
  "tool_call_id": partial_tool_call["tool_call_id"],
340
374
  }
341
375
  ],
@@ -20,6 +20,7 @@ from openai import AsyncOpenAI
20
20
  from openai.types.responses import ResponseInputParam, ResponseStreamEvent
21
21
 
22
22
  from ..base_client import LLMClient
23
+ from ..errors import parse_tool_call_arguments
23
24
  from ..types import (
24
25
  EventType,
25
26
  FinishReason,
@@ -119,12 +120,13 @@ class GPT5_5Client(LLMClient):
119
120
 
120
121
  for item in msg["content_items"]:
121
122
  if item["type"] == "text":
122
- if msg["role"] == "assistant" and item.get("phase"): # split different phases
123
- if last_phase is not None and content_items:
123
+ phase = (item.get("fidelity") or {}).get("phase")
124
+ if msg["role"] == "assistant" and phase: # split different phases
125
+ if last_phase is not None and last_phase != phase and content_items:
124
126
  input_list.append({"role": msg["role"], "content": content_items, "phase": last_phase})
125
127
  content_items = []
126
128
 
127
- last_phase = item["phase"]
129
+ last_phase = phase
128
130
 
129
131
  if msg["role"] == "user":
130
132
  content_items.append({"type": "input_text", "text": item["text"]})
@@ -133,15 +135,15 @@ class GPT5_5Client(LLMClient):
133
135
  elif item["type"] == "image_url":
134
136
  content_items.append({"type": "input_image", "image_url": item["image_url"]})
135
137
  elif item["type"] == "thinking":
136
- signature = json.loads(item["signature"])
138
+ fidelity = item["fidelity"]
137
139
  input_list.append(
138
140
  {
139
141
  "type": "reasoning",
140
- "id": signature["id"],
142
+ "id": fidelity["id"],
141
143
  "summary": [{"type": "summary_text", "text": item["thinking"]}]
142
144
  if item["thinking"]
143
145
  else [],
144
- "encrypted_content": signature["encrypted_content"],
146
+ "encrypted_content": fidelity["encrypted_content"],
145
147
  }
146
148
  )
147
149
  elif item["type"] == "tool_call":
@@ -216,16 +218,16 @@ class GPT5_5Client(LLMClient):
216
218
  # adding the following thinking item leads to 400 invalid request error, why?
217
219
  # elif model_output.item.type == "reasoning":
218
220
  # event_type = "delta"
219
- # signature = {
221
+ # fidelity = {
220
222
  # "id": model_output.item.id,
221
223
  # "encrypted_content": model_output.item.encrypted_content,
222
224
  # }
223
- # content_items.append({"type": "thinking", "thinking": "", "signature": json.dumps(signature)})
225
+ # content_items.append({"type": "thinking", "thinking": "", "fidelity": fidelity})
224
226
  elif model_output.item.type == "message":
225
227
  if hasattr(model_output.item, "phase"):
226
228
  event_type = "delta"
227
229
  content_items.append(
228
- {"type": "text", "text": "", "phase": getattr(model_output.item, "phase", None)}
230
+ {"type": "text", "text": "", "fidelity": {"phase": getattr(model_output.item, "phase", None)}}
229
231
  )
230
232
  else:
231
233
  event_type = "unused"
@@ -236,11 +238,11 @@ class GPT5_5Client(LLMClient):
236
238
  # not sure about the signature of openai, need to check
237
239
  if model_output.item.type == "reasoning":
238
240
  event_type = "delta"
239
- signature = {
241
+ fidelity = {
240
242
  "id": model_output.item.id,
241
243
  "encrypted_content": model_output.item.encrypted_content,
242
244
  }
243
- content_items.append({"type": "thinking", "thinking": "", "signature": json.dumps(signature)})
245
+ content_items.append({"type": "thinking", "thinking": "", "fidelity": fidelity})
244
246
  else:
245
247
  event_type = "unused"
246
248
 
@@ -341,7 +343,12 @@ class GPT5_5Client(LLMClient):
341
343
  {
342
344
  "type": "tool_call",
343
345
  "name": partial_tool_call["name"],
344
- "arguments": json.loads(partial_tool_call["arguments"]),
346
+ "arguments": parse_tool_call_arguments(
347
+ partial_tool_call["arguments"],
348
+ self.__class__.__name__,
349
+ partial_tool_call["name"],
350
+ partial_tool_call["tool_call_id"],
351
+ ),
345
352
  "tool_call_id": partial_tool_call["tool_call_id"],
346
353
  }
347
354
  ],
@@ -23,6 +23,7 @@ from openai import AsyncOpenAI
23
23
  from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam
24
24
 
25
25
  from ..base_client import LLMClient
26
+ from ..errors import parse_tool_call_arguments
26
27
  from ..types import (
27
28
  EventType,
28
29
  FinishReason,
@@ -143,6 +144,7 @@ class KimiK2_6Client(LLMClient):
143
144
  content_parts = [] # may be empty for tool results
144
145
  tool_calls = [] # may be empty for no tool calls
145
146
  thinking = ""
147
+ thinking_fields: set[str | None] = set()
146
148
  for item in msg["content_items"]:
147
149
  if item["type"] == "text":
148
150
  content_parts.append({"type": "text", "text": item["text"]})
@@ -151,6 +153,7 @@ class KimiK2_6Client(LLMClient):
151
153
  content_parts.append({"type": "image_url", "image_url": {"url": base64_image}})
152
154
  elif item["type"] == "thinking":
153
155
  thinking += item["thinking"]
156
+ thinking_fields.add((item.get("fidelity") or {}).get("reasoning_field"))
154
157
  elif item["type"] == "tool_call":
155
158
  tool_calls.append(
156
159
  {
@@ -196,8 +199,15 @@ class KimiK2_6Client(LLMClient):
196
199
  message["tool_calls"] = tool_calls
197
200
 
198
201
  if thinking:
199
- message["reasoning_content"] = thinking # vLLM & siliconflow compatibility
200
- message["reasoning"] = thinking # openrouter compatibility
202
+ # send thinking back through the exact field the upstream produced (recorded
203
+ # in the item fidelity); servers may reject the spelling they did not emit
204
+ if thinking_fields == {"reasoning_content"}:
205
+ message["reasoning_content"] = thinking
206
+ elif thinking_fields == {"reasoning"}:
207
+ message["reasoning"] = thinking
208
+ else:
209
+ message["reasoning_content"] = thinking # vLLM & siliconflow compatibility
210
+ message["reasoning"] = thinking # openrouter compatibility
201
211
 
202
212
  # message may be empty for tool results
203
213
  if len(message.keys()) > 1:
@@ -228,15 +238,29 @@ class KimiK2_6Client(LLMClient):
228
238
  event_type = "delta"
229
239
  content_items.append({"type": "text", "text": delta.content})
230
240
 
231
- # vLLM & siliconflow compatibility
232
- if getattr(delta, "reasoning_content", None):
241
+ # the thinking field name differs by server: vLLM & siliconflow use reasoning_content
242
+ # while openrouter uses reasoning; record the wire field that carried each delta
243
+ # so a replay can reproduce exactly the field the upstream produced
244
+ reasoning_content = getattr(delta, "reasoning_content", None)
245
+ reasoning = getattr(delta, "reasoning", None)
246
+ if reasoning_content and reasoning:
233
247
  event_type = "delta"
234
- content_items.append({"type": "thinking", "thinking": getattr(delta, "reasoning_content")})
235
-
236
- # openrouter compatibility
237
- elif getattr(delta, "reasoning", None):
248
+ # ambiguous origin: record no fidelity so a replay sends both fields back
249
+ content_items.append({"type": "thinking", "thinking": reasoning_content})
250
+ elif reasoning_content:
251
+ event_type = "delta"
252
+ content_items.append(
253
+ {
254
+ "type": "thinking",
255
+ "thinking": reasoning_content,
256
+ "fidelity": {"reasoning_field": "reasoning_content"},
257
+ }
258
+ )
259
+ elif reasoning:
238
260
  event_type = "delta"
239
- content_items.append({"type": "thinking", "thinking": getattr(delta, "reasoning")})
261
+ content_items.append(
262
+ {"type": "thinking", "thinking": reasoning, "fidelity": {"reasoning_field": "reasoning"}}
263
+ )
240
264
 
241
265
  if delta.tool_calls:
242
266
  event_type = "delta"
@@ -341,7 +365,12 @@ class KimiK2_6Client(LLMClient):
341
365
  {
342
366
  "type": "tool_call",
343
367
  "name": partial_tool_call["name"],
344
- "arguments": json.loads(partial_tool_call["arguments"] or "{}"),
368
+ "arguments": parse_tool_call_arguments(
369
+ partial_tool_call["arguments"],
370
+ self.__class__.__name__,
371
+ partial_tool_call["name"],
372
+ partial_tool_call["tool_call_id"],
373
+ ),
345
374
  "tool_call_id": partial_tool_call["tool_call_id"],
346
375
  }
347
376
  ],
@@ -369,7 +398,12 @@ class KimiK2_6Client(LLMClient):
369
398
  {
370
399
  "type": "tool_call",
371
400
  "name": partial_tool_call["name"],
372
- "arguments": json.loads(partial_tool_call["arguments"] or "{}"),
401
+ "arguments": parse_tool_call_arguments(
402
+ partial_tool_call["arguments"],
403
+ self.__class__.__name__,
404
+ partial_tool_call["name"],
405
+ partial_tool_call["tool_call_id"],
406
+ ),
373
407
  "tool_call_id": partial_tool_call["tool_call_id"],
374
408
  }
375
409
  ],
@@ -23,6 +23,7 @@ from openai import AsyncOpenAI
23
23
  from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam
24
24
 
25
25
  from ..base_client import LLMClient
26
+ from ..errors import parse_tool_call_arguments
26
27
  from ..types import (
27
28
  EventType,
28
29
  FinishReason,
@@ -128,6 +129,7 @@ class OpenaiClient(LLMClient):
128
129
  content_parts = [] # may be empty for tool results
129
130
  tool_calls = [] # may be empty for no tool calls
130
131
  thinking = ""
132
+ thinking_fields: set[str | None] = set()
131
133
  for item in msg["content_items"]:
132
134
  if item["type"] == "text":
133
135
  content_parts.append({"type": "text", "text": item["text"]})
@@ -136,6 +138,7 @@ class OpenaiClient(LLMClient):
136
138
  content_parts.append({"type": "image_url", "image_url": {"url": base64_image}})
137
139
  elif item["type"] == "thinking":
138
140
  thinking += item["thinking"]
141
+ thinking_fields.add((item.get("fidelity") or {}).get("reasoning_field"))
139
142
  elif item["type"] == "tool_call":
140
143
  tool_calls.append(
141
144
  {
@@ -181,8 +184,15 @@ class OpenaiClient(LLMClient):
181
184
  message["tool_calls"] = tool_calls
182
185
 
183
186
  if thinking:
184
- message["reasoning_content"] = thinking # vLLM & siliconflow compatibility
185
- message["reasoning"] = thinking # openrouter compatibility
187
+ # send thinking back through the exact field the upstream produced (recorded
188
+ # in the item fidelity); servers may reject the spelling they did not emit
189
+ if thinking_fields == {"reasoning_content"}:
190
+ message["reasoning_content"] = thinking
191
+ elif thinking_fields == {"reasoning"}:
192
+ message["reasoning"] = thinking
193
+ else:
194
+ message["reasoning_content"] = thinking # vLLM & siliconflow compatibility
195
+ message["reasoning"] = thinking # openrouter compatibility
186
196
 
187
197
  # message may be empty for tool results
188
198
  if len(message.keys()) > 1:
@@ -213,15 +223,29 @@ class OpenaiClient(LLMClient):
213
223
  event_type = "delta"
214
224
  content_items.append({"type": "text", "text": delta.content})
215
225
 
216
- # vLLM & siliconflow compatibility
217
- if getattr(delta, "reasoning_content", None):
226
+ # the thinking field name differs by server: vLLM & siliconflow use reasoning_content
227
+ # while openrouter uses reasoning; record the wire field that carried each delta
228
+ # so a replay can reproduce exactly the field the upstream produced
229
+ reasoning_content = getattr(delta, "reasoning_content", None)
230
+ reasoning = getattr(delta, "reasoning", None)
231
+ if reasoning_content and reasoning:
218
232
  event_type = "delta"
219
- content_items.append({"type": "thinking", "thinking": getattr(delta, "reasoning_content")})
220
-
221
- # openrouter compatibility
222
- elif getattr(delta, "reasoning", None):
233
+ # ambiguous origin: record no fidelity so a replay sends both fields back
234
+ content_items.append({"type": "thinking", "thinking": reasoning_content})
235
+ elif reasoning_content:
236
+ event_type = "delta"
237
+ content_items.append(
238
+ {
239
+ "type": "thinking",
240
+ "thinking": reasoning_content,
241
+ "fidelity": {"reasoning_field": "reasoning_content"},
242
+ }
243
+ )
244
+ elif reasoning:
223
245
  event_type = "delta"
224
- content_items.append({"type": "thinking", "thinking": getattr(delta, "reasoning")})
246
+ content_items.append(
247
+ {"type": "thinking", "thinking": reasoning, "fidelity": {"reasoning_field": "reasoning"}}
248
+ )
225
249
 
226
250
  if delta.tool_calls:
227
251
  for tool_call in delta.tool_calls:
@@ -325,7 +349,12 @@ class OpenaiClient(LLMClient):
325
349
  {
326
350
  "type": "tool_call",
327
351
  "name": partial_tool_call["name"],
328
- "arguments": json.loads(partial_tool_call["arguments"] or "{}"),
352
+ "arguments": parse_tool_call_arguments(
353
+ partial_tool_call["arguments"],
354
+ self.__class__.__name__,
355
+ partial_tool_call["name"],
356
+ partial_tool_call["tool_call_id"],
357
+ ),
329
358
  "tool_call_id": partial_tool_call["tool_call_id"],
330
359
  }
331
360
  ],
@@ -353,7 +382,12 @@ class OpenaiClient(LLMClient):
353
382
  {
354
383
  "type": "tool_call",
355
384
  "name": partial_tool_call["name"],
356
- "arguments": json.loads(partial_tool_call["arguments"] or "{}"),
385
+ "arguments": parse_tool_call_arguments(
386
+ partial_tool_call["arguments"],
387
+ self.__class__.__name__,
388
+ partial_tool_call["name"],
389
+ partial_tool_call["tool_call_id"],
390
+ ),
357
391
  "tool_call_id": partial_tool_call["tool_call_id"],
358
392
  }
359
393
  ],
@@ -12,7 +12,7 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
- from .client import Claude4_8Client
15
+ from .client import OpenaiEmbeddingClient
16
16
 
17
17
 
18
- __all__ = ["Claude4_8Client"]
18
+ __all__ = ["OpenaiEmbeddingClient"]
@@ -0,0 +1,80 @@
1
+ # Copyright 2025 Prism Shadow. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import os
16
+ from typing import Any, AsyncIterator
17
+
18
+ from openai import AsyncOpenAI
19
+
20
+ from ..base_client import LLMClient
21
+ from ..types import UniConfig, UniEvent, UniMessage
22
+
23
+
24
+ class OpenaiEmbeddingClient(LLMClient):
25
+ """OpenAI Embeddings-compatible client implementation."""
26
+
27
+ def __init__(self, model: str, api_key: str | None = None, base_url: str | None = None):
28
+ """Initialize OpenAI-compatible embedding client with model, API key, and base URL."""
29
+ self._model = model
30
+ api_key = api_key or os.getenv("OPENAI_API_KEY")
31
+ base_url = base_url or os.getenv("OPENAI_BASE_URL")
32
+ self._client = AsyncOpenAI(api_key=api_key, base_url=base_url)
33
+ self._history: list[UniMessage] = []
34
+
35
+ def transform_uni_config_to_model_config(self, config: UniConfig) -> dict[str, Any]:
36
+ """Transform universal configuration to OpenAI Embeddings configuration."""
37
+ params: dict[str, Any] = {"model": self._model}
38
+ embedding_config = config.get("embedding_config") or {}
39
+ if embedding_config.get("dimensions") is not None:
40
+ params["dimensions"] = embedding_config["dimensions"]
41
+ return params
42
+
43
+ def transform_uni_message_to_model_input(self, messages: list[UniMessage]) -> list[str]:
44
+ """Transform universal messages to OpenAI Embeddings input strings."""
45
+ texts = []
46
+ for msg in messages:
47
+ msg_text = ""
48
+ for item in msg["content_items"]:
49
+ if item["type"] != "text":
50
+ raise ValueError("OpenAI embeddings only support text content items.")
51
+ msg_text += item["text"]
52
+ texts.append(msg_text or " ")
53
+ return texts
54
+
55
+ def transform_model_output_to_uni_event(self, model_output: Any) -> UniEvent:
56
+ """Transform OpenAI Embeddings response to universal event format."""
57
+ usage = getattr(model_output, "usage", None)
58
+ return {
59
+ "role": "assistant",
60
+ "event_type": "stop",
61
+ "content_items": [{"type": "embedding", "embedding": item.embedding} for item in model_output.data],
62
+ "usage_metadata": {
63
+ "cached_tokens": None,
64
+ "prompt_tokens": usage.prompt_tokens if usage else None,
65
+ "thoughts_tokens": None,
66
+ "response_tokens": None,
67
+ },
68
+ "finish_reason": "stop",
69
+ }
70
+
71
+ async def _streaming_response_internal(
72
+ self,
73
+ messages: list[UniMessage],
74
+ config: UniConfig,
75
+ ) -> AsyncIterator[UniEvent]:
76
+ """Generate embeddings using OpenAI Embeddings-compatible API."""
77
+ params = self.transform_uni_config_to_model_config(config)
78
+ params["input"] = self.transform_uni_message_to_model_input(messages)
79
+ result = await self._client.embeddings.create(**params)
80
+ yield self.transform_model_output_to_uni_event(result)
@@ -42,12 +42,16 @@ FinishReason = Literal["stop", "length", "tool_call", "unknown"]
42
42
  AspectRatio = Literal["1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9", "21:9"]
43
43
  ImageSize = Literal["1K", "2K"]
44
44
 
45
+ # Arbitrary JSON-style payload of wire-fidelity data recorded by a client, such as
46
+ # thinking signatures, phase labels, or the upstream reasoning field name. Opaque to
47
+ # consumers: pass it back unchanged so a replay reproduces the original wire message.
48
+ Fidelity = dict[str, Any]
49
+
45
50
 
46
51
  class TextContentItem(TypedDict):
47
52
  type: Literal["text"]
48
53
  text: str
49
- phase: NotRequired[str | None]
50
- signature: NotRequired[str | bytes]
54
+ fidelity: NotRequired[Fidelity]
51
55
 
52
56
 
53
57
  class ImageContentItem(TypedDict):
@@ -59,20 +63,20 @@ class InlineDataContentItem(TypedDict):
59
63
  type: Literal["inline_data"]
60
64
  data: bytes
61
65
  mime_type: str
62
- signature: NotRequired[str | bytes]
66
+ fidelity: NotRequired[Fidelity]
63
67
 
64
68
 
65
69
  class ThinkingContentItem(TypedDict):
66
70
  type: Literal["thinking"]
67
71
  thinking: str
68
- signature: NotRequired[str | bytes]
72
+ fidelity: NotRequired[Fidelity]
69
73
 
70
74
 
71
75
  class InlineThinkingContentItem(TypedDict):
72
76
  type: Literal["inline_thinking"]
73
77
  data: bytes
74
78
  mime_type: str
75
- signature: NotRequired[str | bytes]
79
+ fidelity: NotRequired[Fidelity]
76
80
 
77
81
 
78
82
  class ToolCallContentItem(TypedDict):
@@ -80,7 +84,7 @@ class ToolCallContentItem(TypedDict):
80
84
  name: str
81
85
  arguments: dict[str, Any]
82
86
  tool_call_id: str
83
- signature: NotRequired[str | bytes]
87
+ fidelity: NotRequired[Fidelity]
84
88
 
85
89
 
86
90
  class PartialToolCallContentItem(TypedDict):
@@ -88,7 +92,7 @@ class PartialToolCallContentItem(TypedDict):
88
92
  name: str
89
93
  arguments: str
90
94
  tool_call_id: str
91
- signature: NotRequired[str | bytes]
95
+ fidelity: NotRequired[Fidelity]
92
96
 
93
97
 
94
98
  class ToolResultContentItem(TypedDict):
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agenthub-python"
3
- version = "0.3.2"
3
+ version = "0.4.0"
4
4
  description = "AgentHub is the LLM API Hub for the Agent era, built for high-precision autonomous agents."
5
5
  keywords = ["agent", "llm", "gemini", "claude", "gpt"]
6
6
  readme = "README.md"
@@ -11,6 +11,11 @@ authors = [
11
11
  {name = "PrismShadow"},
12
12
  ]
13
13
 
14
+ [project.urls]
15
+ Homepage = "https://github.com/Prism-Shadow/agenthub"
16
+ Repository = "https://github.com/Prism-Shadow/agenthub"
17
+ Issues = "https://github.com/Prism-Shadow/agenthub/issues"
18
+
14
19
  [project.optional-dependencies]
15
20
  dev = ["httpx[socks]", "pytest>=8.4.2", "pytest-asyncio>=0.23.0", "ruff>=0.14.3", "pillow>=10.0.0"]
16
21