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
23 changes: 18 additions & 5 deletions astrbot/core/provider/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -427,17 +427,30 @@ def to_openai_tool_calls_model(self) -> list[ToolCall]:
"""The same as to_openai_tool_calls but return pydantic model."""
ret = []
for idx, tool_call_arg in enumerate(self.tools_call_args):
# Some OpenAI-compatible gateways return tool_calls without a valid id
# (e.g. streaming snapshots misaligned by non-zero-based indexes).
# ToolCall.id is declared as str, so passing None would raise a pydantic
# ValidationError and fail the whole tool-calling turn.
raw_id = (
self.tools_call_ids[idx] if idx < len(self.tools_call_ids) else None
)
func_name = (
self.tools_call_name[idx] if idx < len(self.tools_call_name) else None
)
call_id = raw_id
if not isinstance(call_id, str) or not call_id:
call_id = f"call_{idx}"
ret.append(
ToolCall(
id=self.tools_call_ids[idx],
id=call_id,
function=ToolCall.FunctionBody(
name=self.tools_call_name[idx],
name=func_name or "__malformed_tool_name__",
arguments=json.dumps(tool_call_arg),
),
# the extra_content will not serialize if it's None when calling ToolCall.model_dump()
extra_content=self.tools_call_extra_content.get(
self.tools_call_ids[idx]
),
# Note: look up extra_content by the raw upstream id; the fallback id
# (call_{idx}) never exists in tools_call_extra_content.
extra_content=self.tools_call_extra_content.get(raw_id),
),
)
return ret
Expand Down
20 changes: 20 additions & 0 deletions astrbot/core/provider/sources/openai_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,11 @@ async def _query_stream(

state = ChatCompletionStreamState()

# 上游 tool_call.index -> 规范化后的 0 起始下标。
# 必须是每次请求独立的局部状态,不能挂在 self 上,
# 否则并发请求会互相污染下标映射。
tool_call_index_map: dict[int, int] = {}

async for chunk in stream:
choice = chunk.choices[0] if chunk.choices else None
delta = choice.delta if choice else None
Expand All @@ -651,6 +656,21 @@ async def _query_stream(
# Gemini and some OpenAI-compatible proxies omit this field
if not hasattr(tc, "index") or tc.index is None:
tc.index = idx
else:
# Some OpenAI-compatible gateways start tool_call.index at 1
# (the OpenAI spec requires 0-based indexing). The SDK streaming
# snapshot accumulator uses index directly as a list subscript,
# so index=1 on an empty list raises IndexError and the tool_call
# name/arguments get split across misaligned slots, eventually
# producing a malformed tool_call with id=None/name="" that fails
# downstream ToolCall pydantic validation.
# Remap upstream indexes to a contiguous 0-based sequence.
remapped = tool_call_index_map.setdefault(
tc.index,
len(tool_call_index_map),
)
if remapped != tc.index:
tc.index = remapped
# 跳过 delta=None 的 chunk,避免 SDK 内部 _convert_initial_chunk_into_snapshot
# 第 747 行 choice.delta.to_dict() 抛出 NoneType 错误。
# refs: AstrBot#6689 / openai-python#5069 / #5047
Expand Down
Loading