Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,8 @@ def _convert_messages_to_dict(messages: Union[str, List[BaseMessage], List[dict]
"arguments": tc.arguments
}
})
if tc.extra_content is not None:
tool_calls_list[-1]["extra_content"] = tc.extra_content
msg_dict["tool_calls"] = tool_calls_list

if msg.reasoning_content:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,18 @@ async def _astream_with_parser(
def _extract_reasoning_content(msg_or_delta: Any) -> Optional[str]:
return getattr(msg_or_delta, 'reasoning_content', None)

@staticmethod
def _extract_tool_call_extra_content(tool_call: Any) -> Optional[dict[str, Any]]:
"""Extract opaque provider metadata attached to an OpenAI-compatible tool call."""
extra_content = getattr(tool_call, 'extra_content', None)
if extra_content is None:
model_extra = getattr(tool_call, 'model_extra', None)
if isinstance(model_extra, dict):
extra_content = model_extra.get('extra_content')
if hasattr(extra_content, 'model_dump'):
extra_content = extra_content.model_dump()
return dict(extra_content) if isinstance(extra_content, dict) else None

async def _parse_response(
self,
response: Any,
Expand Down Expand Up @@ -590,7 +602,8 @@ async def _parse_response(
type="function",
name=function_name,
arguments=function_arguments,
index=getattr(tc, 'index', idx)
index=getattr(tc, 'index', idx),
extra_content=self._extract_tool_call_extra_content(tc),
)
tool_calls.append(tool_call)

Expand Down Expand Up @@ -754,7 +767,8 @@ def _parse_stream_chunk(self, chunk: Any) -> Optional[AssistantMessageChunk]:
type="function",
name=function_name,
arguments=function_arguments,
index=index
index=index,
extra_content=self._extract_tool_call_extra_content(tc_delta),
)
tool_calls.append(tool_call)

Expand Down
3 changes: 3 additions & 0 deletions openjiuwen/core/foundation/llm/schema/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ def convert_openai_tool_calls_format(cls, data: Any) -> Any:
'arguments': tc['function'].get('arguments', ''),
'index': tc.get('index'),
'response_item_id': tc.get('response_item_id'),
'extra_content': tc.get('extra_content'),
}
converted_tool_calls.append(converted_tc)
else:
Expand Down Expand Up @@ -99,6 +100,8 @@ def model_dump(self, **kwargs) -> dict[str, Any]:
})
if call.response_item_id is not None:
tool_calls[-1]["response_item_id"] = call.response_item_id
if call.extra_content is not None:
tool_calls[-1]["extra_content"] = call.extra_content
result["tool_calls"] = tool_calls
if self.usage_metadata is not None:
result["usage_metadata"] = self.usage_metadata.model_dump(**kwargs)
Expand Down
65 changes: 53 additions & 12 deletions openjiuwen/core/foundation/llm/schema/message_chunk.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,39 @@ def merge_pydantic_models(left: Any, right: Any) -> Any:
return right


def _find_tool_call_merge_index(
merged_tool_calls: list[ToolCall],
incoming: ToolCall,
) -> int | None:
"""Find the accumulated tool call that owns an incoming stream fragment."""
if incoming.id:
for position, existing in enumerate(merged_tool_calls):
if existing.id == incoming.id:
return position

# A provider may attach the ID after the first fragment. In that case,
# only use the stream index to complete an existing call without an ID;
# a different non-empty ID represents a new call even if its index was
# reused by the provider.
if incoming.index is not None:
for position, existing in enumerate(merged_tool_calls):
if existing.index == incoming.index and not existing.id:
return position
elif merged_tool_calls and not merged_tool_calls[-1].id:
return len(merged_tool_calls) - 1
return None

if incoming.index is not None:
for position, existing in enumerate(merged_tool_calls):
if existing.index == incoming.index:
return position
return None

# Some compatible providers omit both ID and index from continuation
# fragments. Preserve the existing single-call fallback in that case.
return len(merged_tool_calls) - 1 if merged_tool_calls else None


class BaseMessageChunk(BaseMessage):
model_config = ConfigDict(arbitrary_types_allowed=True, json_encoders={type(None): lambda _: None})

Expand Down Expand Up @@ -172,22 +205,29 @@ def __add__(self, other: Any) -> "AssistantMessageChunk":
arguments=tc.arguments,
index=tc.index,
response_item_id=tc.response_item_id,
extra_content=tc.extra_content,
))

if other.tool_calls:
for incoming in other.tool_calls:
if merged_tool_calls:
last = merged_tool_calls[-1]
same_id = (last.id and incoming.id and last.id == incoming.id) or (not last.id or not incoming.id)
if (same_id and hasattr(last, 'type') and last.type == 'function'
and hasattr(incoming, 'type') and incoming.type == 'function'):
merged_tool_calls[-1] = ToolCall(
id=last.id or incoming.id,
type=last.type or incoming.type,
name=(last.name if last.name else incoming.name) or "",
arguments=(last.arguments or "") + (incoming.arguments or ""),
index=last.index,
response_item_id=last.response_item_id or incoming.response_item_id,
merge_index = _find_tool_call_merge_index(merged_tool_calls, incoming)
if merge_index is not None:
existing = merged_tool_calls[merge_index]
if existing.type == 'function' and incoming.type == 'function':
merged_tool_calls[merge_index] = ToolCall(
id=existing.id or incoming.id,
type=existing.type or incoming.type,
name=(existing.name if existing.name else incoming.name) or "",
arguments=(existing.arguments or "") + (incoming.arguments or ""),
index=existing.index if existing.index is not None else incoming.index,
response_item_id=existing.response_item_id or incoming.response_item_id,
# Provider metadata is opaque: keep the latest value
# when present instead of merging its nested fields.
extra_content=(
incoming.extra_content
if incoming.extra_content is not None
else existing.extra_content
),
)
continue
# otherwise, push as a new tool_call
Expand All @@ -198,6 +238,7 @@ def __add__(self, other: Any) -> "AssistantMessageChunk":
arguments=incoming.arguments,
index=len(merged_tool_calls),
response_item_id=incoming.response_item_id,
extra_content=incoming.extra_content,
))

merged_finish_reason = other.finish_reason if other.finish_reason != "null" else self.finish_reason
Expand Down
5 changes: 4 additions & 1 deletion openjiuwen/core/foundation/llm/schema/tool_call.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# -*- coding: UTF-8 -*-
# Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved.
from typing import Optional
from typing import Any, Optional

from pydantic import BaseModel

Expand All @@ -16,10 +16,13 @@ class ToolCall(BaseModel):
index: Tool call index, used to distinguish multiple tool calls
response_item_id: Optional provider response item ID for protocols
that distinguish response item IDs from tool call IDs.
extra_content: Optional opaque provider metadata that must be preserved
when a tool call is sent back in conversation history.
"""
id: Optional[str]
type: str
name: str
arguments: str
index: Optional[int] = None
response_item_id: Optional[str] = None
extra_content: Optional[dict[str, Any]] = None
Loading