初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
from __future__ import annotations
from app.ai_provider.llm.anthropic_format import (
AnthropicFormatAdapter,
AnthropicFormatAdapterFactory,
)
from app.ai_provider.llm.openai_format import OpenAIFormatAdapter, OpenAIFormatAdapterFactory
from app.ai_provider.llm.stream import StreamFactory, StreamHandler, ToolCallAccumulator
__all__ = [
"AnthropicFormatAdapter",
"AnthropicFormatAdapterFactory",
"OpenAIFormatAdapter",
"OpenAIFormatAdapterFactory",
"StreamFactory",
"StreamHandler",
"ToolCallAccumulator",
]
@@ -0,0 +1,433 @@
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from typing import Any
import httpx
from app.ai_provider.config.models import ModelConfig, ProviderConfig
from app.ai_provider.pipeline import ParameterPipeline, PipelineContext, PipelineFactory
from app.ai_provider.types.chat import ChatChunk, ChatResult, TokenUsage
from app.ai_provider.types.errors import AIProviderError
from app.ai_provider.types.messages import (
AIMessage,
BaseMessage,
SystemMessage,
ToolMessage,
)
from app.core.logging import logger
class AnthropicFormatAdapter:
"""兼容 Anthropic Messages API 格式的适配器。
覆盖通过 Anthropic API 访问的 Claude 模型,以及任何镜像
Anthropic 消息格式的提供商。
"""
API_VERSION = "2023-06-01"
def __init__(
self,
provider_config: ProviderConfig,
pipeline: ParameterPipeline | None = None,
) -> None:
self._provider = provider_config
self._pipeline = pipeline or PipelineFactory.create(provider_config)
self._client: httpx.AsyncClient | None = None
@property
def _http(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(
base_url=self._provider.base_url or "",
timeout=self._provider.timeout_seconds,
)
return self._client
# ------------------------------------------------------------------
# 内部方法:消息转换
# ------------------------------------------------------------------
def _convert_message(self, msg: BaseMessage) -> dict[str, Any]:
"""将单条消息转换为 Anthropic 消息格式。
Anthropic 使用 content blocks 数组而非纯字符串。
System 消息**不在此处处理** —— 它们由 :meth:`build_request`
提取到顶层的 ``system`` 字段中。
"""
if isinstance(msg, ToolMessage):
return {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": msg.tool_call_id,
"content": msg.content,
}
],
}
if isinstance(msg, AIMessage):
if msg.raw_content_blocks:
return {"role": "assistant", "content": msg.raw_content_blocks}
blocks: list[dict[str, Any]] = []
if msg.content:
blocks.append({"type": "text", "text": msg.content})
for tc in msg.tool_calls:
blocks.append(
{
"type": "tool_use",
"id": tc.get("id", f"tu_{tc['name']}"),
"name": tc["name"],
"input": tc.get("args", {}),
}
)
return {"role": "assistant", "content": blocks}
# HumanMessage
return {
"role": "user",
"content": [{"type": "text", "text": msg.content}],
}
@staticmethod
def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""将 OpenAI 兼容格式的工具转换为 Anthropic 格式。
OpenAI 格式::
{"type": "function", "function": {"name": ..., "parameters": ...}}
Anthropic 格式::
{"name": ..., "input_schema": ...}
"""
result: list[dict[str, Any]] = []
for tool in tools:
fn = tool.get("function", tool)
result.append(
{
"name": fn.get("name", ""),
"description": fn.get("description", ""),
"input_schema": fn.get("parameters", {"type": "object"}),
}
)
return result
# ------------------------------------------------------------------
# 请求构建
# ------------------------------------------------------------------
def build_request(
self,
model: ModelConfig,
messages: list[BaseMessage],
params: dict[str, Any] | None = None,
tools: list[dict[str, Any]] | None = None,
tool_choice: str | dict[str, Any] | None = None,
stream: bool = False,
) -> dict[str, Any]:
ctx = PipelineContext(
params=params or {},
model_config=model,
provider_config=self._provider,
)
pipe_result = self._pipeline.run(ctx)
# Extract system messages — Anthropic uses a top-level "system" field
system_parts: list[str] = []
non_system: list[BaseMessage] = []
for m in messages:
if isinstance(m, SystemMessage):
system_parts.append(m.content)
else:
non_system.append(m)
body: dict[str, Any] = {
"model": model.model,
"messages": [self._convert_message(m) for m in non_system],
"stream": stream,
}
if system_parts:
body["system"] = "\n".join(system_parts)
body.update(pipe_result.params)
if tools:
body["tools"] = self._convert_tools(tools)
if tool_choice:
body["tool_choice"] = tool_choice
# Anthropic requires explicit max_tokens
if "max_tokens" not in body:
body["max_tokens"] = model.max_output_tokens or 4096
return body
def bind_tools(
self,
request_body: dict[str, Any],
tools: list[dict[str, Any]],
tool_choice: str | dict[str, Any] | None = None,
) -> None:
request_body["tools"] = self._convert_tools(tools)
if tool_choice:
request_body["tool_choice"] = tool_choice
# ------------------------------------------------------------------
# 认证头
# ------------------------------------------------------------------
def _headers(self, api_key: str) -> dict[str, str]:
return {
"x-api-key": api_key,
"anthropic-version": self.API_VERSION,
"Content-Type": "application/json",
}
# ------------------------------------------------------------------
# 响应解析 —— 非流式
# ------------------------------------------------------------------
@staticmethod
def _parse_content_blocks(raw: dict[str, Any]) -> tuple[str, list[dict[str, Any]], str]:
"""解析 Anthropic content blocks 为 (text, tool_calls, reasoning)。"""
content_blocks = raw.get("content", [])
text_parts: list[str] = []
tool_calls: list[dict[str, Any]] = []
reasoning = ""
for block in content_blocks:
block_type = block.get("type", "")
if block_type == "text":
text_parts.append(block.get("text", ""))
elif block_type == "tool_use":
tool_calls.append(
{
"name": block.get("name", ""),
"args": block.get("input", {}),
"id": block.get("id", ""),
}
)
elif block_type == "thinking":
reasoning += block.get("thinking", "")
# signature 块是 thinking 的加密签名,不是 reasoning 内容,忽略
return "".join(text_parts), tool_calls, reasoning
def parse_nonstreaming_response(
self,
raw: dict[str, Any],
model_name: str,
) -> ChatResult:
content, tool_calls, reasoning = self._parse_content_blocks(raw)
result = ChatResult(
content=content,
model_name=model_name,
)
if tool_calls:
result.tool_calls = tool_calls
if reasoning:
result.reasoning = reasoning
usage = raw.get("usage")
if usage:
result.usage = TokenUsage(
prompt_tokens=usage.get("input_tokens", 0),
prompt_tokens_cached=usage.get("cache_read_input_tokens", 0),
completion_tokens=usage.get("output_tokens", 0),
total_tokens=usage.get("input_tokens", 0) + usage.get("output_tokens", 0),
)
return result
# ------------------------------------------------------------------
# 响应解析 —— 流式(SSE 事件行协议)
# ------------------------------------------------------------------
@staticmethod
def _parse_stream_event(event_type: str, data: dict[str, Any]) -> ChatChunk:
"""解析单条 SSE 流事件为 ChatChunk。"""
chunk = ChatChunk()
if event_type == "message_start":
message = data.get("message", {})
usage = message.get("usage")
if usage:
chunk.usage = TokenUsage(
prompt_tokens=usage.get("input_tokens", 0),
prompt_tokens_cached=usage.get("cache_read_input_tokens", 0),
completion_tokens=usage.get("output_tokens", 0),
)
elif event_type == "content_block_start":
block = data.get("content_block", {})
chunk.content_block_index = data.get("index", 0)
chunk.content_block_start = block
block_type = block.get("type", "")
if block_type == "text":
chunk.delta_content = block.get("text", "")
elif block_type == "thinking":
chunk.delta_reasoning = block.get("thinking", "")
elif block_type == "tool_use":
chunk.tool_call_chunks.append(
{
"index": data.get("index", 0),
"id": block.get("id"),
"name": block.get("name"),
"args": "",
}
)
elif event_type == "content_block_delta":
delta = data.get("delta", {})
chunk.content_block_index = data.get("index", 0)
chunk.content_block_delta = delta
delta_type = delta.get("type", "")
if delta_type == "text_delta":
chunk.delta_content = delta.get("text", "")
elif delta_type == "thinking_delta":
chunk.delta_reasoning = delta.get("thinking", "")
elif delta_type == "signature_delta":
# signature 不展示,但由 Agent Runtime 原样保留供下一轮回放。
pass
elif delta_type == "input_json_delta":
chunk.tool_call_chunks.append(
{
"index": data.get("index", 0),
"id": None,
"name": None,
"args": delta.get("partial_json", ""),
}
)
elif event_type == "message_delta":
delta = data.get("delta", {})
finish_reason_map = {
"end_turn": "stop",
"max_tokens": "length",
"stop_sequence": "stop",
"tool_use": "tool_calls",
}
stop_reason = delta.get("stop_reason")
if stop_reason:
chunk.finish_reason = finish_reason_map.get(stop_reason, stop_reason)
usage = data.get("usage")
if usage:
chunk.usage = TokenUsage(
prompt_tokens=0,
completion_tokens=usage.get("output_tokens", 0),
)
# content_block_stop, message_stop, ping — no content to extract
return chunk
# ------------------------------------------------------------------
# 公开 API:chat
# ------------------------------------------------------------------
async def chat(
self,
model: ModelConfig,
messages: list[BaseMessage],
api_key: str,
base_url: str | None = None,
params: dict[str, Any] | None = None,
tools: list[dict[str, Any]] | None = None,
tool_choice: str | dict[str, Any] | None = None,
) -> ChatResult:
url = f"{base_url or self._provider.base_url}/v1/messages"
body = self.build_request(
model,
messages,
params=params,
tools=tools,
tool_choice=tool_choice,
)
try:
response = await self._http.post(url, json=body, headers=self._headers(api_key))
response.raise_for_status()
data = response.json()
return self.parse_nonstreaming_response(data, model.name)
except httpx.HTTPStatusError as exc:
raise AIProviderError.from_provider(
message=f"Anthropic API error: {exc.response.status_code} {exc.response.text[:200]}",
code="PROVIDER_ERROR",
provider=self._provider.name,
original=exc,
) from exc
async def chat_stream(
self,
model: ModelConfig,
messages: list[BaseMessage],
api_key: str,
base_url: str | None = None,
params: dict[str, Any] | None = None,
tools: list[dict[str, Any]] | None = None,
tool_choice: str | dict[str, Any] | None = None,
) -> AsyncIterator[ChatChunk]:
url = f"{base_url or self._provider.base_url}/v1/messages"
body = self.build_request(
model,
messages,
params=params,
tools=tools,
tool_choice=tool_choice,
stream=True,
)
async with self._http.stream(
"POST",
url,
json=body,
headers=self._headers(api_key),
) as response:
if response.status_code != 200:
error_text = await response.aread()
raise AIProviderError.from_provider(
message=f"Anthropic stream error: {response.status_code} {error_text[:200]}",
code="PROVIDER_ERROR",
provider=self._provider.name,
)
current_event = ""
async for line in response.aiter_lines():
line = line.strip()
if not line:
continue
if line.startswith("event: "):
current_event = line[7:]
elif line.startswith("data: "):
data_str = line[6:]
if data_str and current_event:
try:
raw = json.loads(data_str)
yield self._parse_stream_event(current_event, raw)
except json.JSONDecodeError:
logger.warning(
"anthropic_stream_parse_error",
event=current_event,
raw=data_str[:100],
)
current_event = ""
async def close(self) -> None:
if self._client:
await self._client.aclose()
self._client = None
class AnthropicFormatAdapterFactory:
"""工厂:创建 Anthropic 格式适配器。"""
@staticmethod
def create(provider_config: ProviderConfig) -> AnthropicFormatAdapter:
return AnthropicFormatAdapter(provider_config=provider_config)
@@ -0,0 +1,251 @@
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from typing import Any
import httpx
from app.ai_provider.config.models import ModelConfig, ProviderConfig
from app.ai_provider.pipeline import ParameterPipeline, PipelineContext, PipelineFactory
from app.ai_provider.types.chat import ChatChunk, ChatResult, TokenUsage
from app.ai_provider.types.errors import AIProviderError
from app.ai_provider.types.messages import AIMessage, BaseMessage, ToolMessage
from app.core.logging import logger
class OpenAIFormatAdapter:
"""兼容 OpenAI API 格式的适配器(覆盖 deepseek、xiaomi 等)。"""
def __init__(
self,
provider_config: ProviderConfig,
pipeline: ParameterPipeline | None = None,
) -> None:
self._provider = provider_config
self._pipeline = pipeline or PipelineFactory.create(provider_config)
self._client: httpx.AsyncClient | None = None
@property
def _http(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(
base_url=self._provider.base_url or "",
timeout=self._provider.timeout_seconds,
)
return self._client
def _convert_message(self, msg: BaseMessage) -> dict[str, Any]:
if isinstance(msg, ToolMessage):
return {
"role": "tool",
"content": msg.content,
"tool_call_id": msg.tool_call_id,
}
if isinstance(msg, AIMessage):
d: dict[str, Any] = {"role": "assistant", "content": msg.content}
if msg.tool_calls:
d["tool_calls"] = [
{
"id": tc.get("id", f"call_{i}"),
"type": "function",
"function": {
"name": tc["name"],
"arguments": json.dumps(tc.get("args", {})),
},
}
for i, tc in enumerate(msg.tool_calls)
]
return d
return {"role": msg.role, "content": msg.content}
def build_request(
self,
model: ModelConfig,
messages: list[BaseMessage],
params: dict[str, Any] | None = None,
tools: list[dict[str, Any]] | None = None,
tool_choice: str | dict[str, Any] | None = None,
stream: bool = False,
) -> dict[str, Any]:
ctx = PipelineContext(
params=params or {},
model_config=model,
provider_config=self._provider,
)
pipe_result = self._pipeline.run(ctx)
body: dict[str, Any] = {
"model": model.model,
"messages": [self._convert_message(m) for m in messages],
"stream": stream,
}
body.update(pipe_result.params)
if tools:
body["tools"] = tools
if tool_choice:
body["tool_choice"] = tool_choice
return body
def bind_tools(
self,
request_body: dict[str, Any],
tools: list[dict[str, Any]],
tool_choice: str | dict[str, Any] | None = None,
) -> None:
request_body["tools"] = tools
if tool_choice:
request_body["tool_choice"] = tool_choice
def parse_chunk(self, raw: dict[str, Any]) -> ChatChunk:
chunk = ChatChunk()
choices = raw.get("choices", [])
if choices:
delta = choices[0].get("delta", {})
chunk.delta_content = delta.get("content") or ""
chunk.delta_reasoning = delta.get("reasoning_content") or ""
chunk.finish_reason = choices[0].get("finish_reason")
if delta.get("tool_calls"):
for tc in delta["tool_calls"]:
fn = tc.get("function", {})
chunk.tool_call_chunks.append(
{
"index": tc.get("index", 0),
"id": tc.get("id"),
"name": fn.get("name"),
"args": fn.get("arguments", ""),
}
)
usage = raw.get("usage")
if usage:
chunk.usage = TokenUsage(
prompt_tokens=usage.get("prompt_tokens", 0),
completion_tokens=usage.get("completion_tokens", 0),
total_tokens=usage.get("total_tokens", 0),
prompt_tokens_cached=usage.get("prompt_tokens_cached", 0),
)
return chunk
def parse_nonstreaming_response(
self,
raw: dict[str, Any],
model_name: str,
) -> ChatResult:
choice = raw.get("choices", [{}])[0]
message = choice.get("message", {})
content = message.get("content", "")
tool_calls_raw = message.get("tool_calls", [])
result = ChatResult(
content=content,
reasoning=message.get("reasoning_content", ""),
model_name=model_name,
)
if tool_calls_raw:
result.tool_calls = [
{
"name": tc["function"]["name"],
"args": json.loads(tc["function"].get("arguments", "{}")),
"id": tc["id"],
}
for tc in tool_calls_raw
]
usage = raw.get("usage")
if usage:
result.usage = TokenUsage(
prompt_tokens=usage.get("prompt_tokens", 0),
completion_tokens=usage.get("completion_tokens", 0),
total_tokens=usage.get("total_tokens", 0),
prompt_tokens_cached=usage.get("prompt_tokens_cached", 0),
)
return result
async def chat(
self,
model: ModelConfig,
messages: list[BaseMessage],
api_key: str,
base_url: str | None = None,
params: dict[str, Any] | None = None,
tools: list[dict[str, Any]] | None = None,
tool_choice: str | dict[str, Any] | None = None,
) -> ChatResult:
url = f"{base_url or self._provider.base_url}/chat/completions"
body = self.build_request(
model, messages, params=params, tools=tools, tool_choice=tool_choice
)
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
try:
response = await self._http.post(url, json=body, headers=headers)
response.raise_for_status()
data = response.json()
return self.parse_nonstreaming_response(data, model.name)
except httpx.HTTPStatusError as exc:
raise AIProviderError.from_provider(
message=f"OpenAI API error: {exc.response.status_code} {exc.response.text[:200]}",
code="PROVIDER_ERROR",
provider=self._provider.name,
original=exc,
) from exc
async def chat_stream(
self,
model: ModelConfig,
messages: list[BaseMessage],
api_key: str,
base_url: str | None = None,
params: dict[str, Any] | None = None,
tools: list[dict[str, Any]] | None = None,
tool_choice: str | dict[str, Any] | None = None,
) -> AsyncIterator[ChatChunk]:
url = f"{base_url or self._provider.base_url}/chat/completions"
body = self.build_request(
model, messages, params=params, tools=tools, tool_choice=tool_choice, stream=True
)
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
async with self._http.stream("POST", url, json=body, headers=headers) as response:
if response.status_code != 200:
error_text = await response.aread()
raise AIProviderError.from_provider(
message=f"OpenAI stream error: {response.status_code} {error_text[:200]}",
code="PROVIDER_ERROR",
provider=self._provider.name,
)
async for line in response.aiter_lines():
if line.startswith("data: "):
data_str = line[6:].strip()
if data_str == "[DONE]":
break
if data_str:
try:
raw = json.loads(data_str)
yield self.parse_chunk(raw)
except json.JSONDecodeError:
logger.warning("openai_stream_parse_error", raw=data_str[:100])
async def close(self) -> None:
if self._client:
await self._client.aclose()
self._client = None
class OpenAIFormatAdapterFactory:
"""工厂:创建 OpenAI 格式适配器。"""
@staticmethod
def create(provider_config: ProviderConfig) -> OpenAIFormatAdapter:
return OpenAIFormatAdapter(provider_config=provider_config)
+164
View File
@@ -0,0 +1,164 @@
from __future__ import annotations
import random
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any, Protocol
@dataclass
class PoolContext:
request_id: str = ""
excluded_endpoints: set[str] = field(default_factory=set)
class SchedulingStrategy(Protocol):
def select(self, excluded: set[str]) -> dict[str, Any] | None: ...
class WeightedRoundRobinStrategy:
"""策略 1:在所有活跃端点间加权轮询。"""
def __init__(self, endpoints: list[dict[str, Any]]) -> None:
self._endpoints = endpoints
self._index = 0
def select(self, excluded: set[str]) -> dict[str, Any] | None:
available = [
e
for e in self._endpoints
if e.get("name") not in excluded and e.get("status", "active") == "active"
]
if not available:
return None
weights = [e.get("weight", 100) for e in available]
self._index = (self._index + 1) % len(available)
return random.choices(available, weights=weights, k=1)[0]
class PriorityFailoverStrategy:
"""策略 2:选择最高优先级;当排除时顺延。"""
def __init__(self, endpoints: list[dict[str, Any]]) -> None:
self._endpoints = sorted(endpoints, key=lambda e: e.get("priority", 100))
def select(self, excluded: set[str]) -> dict[str, Any] | None:
for ep in self._endpoints:
if ep.get("name") not in excluded and ep.get("status", "active") == "active":
return ep
return None
class LowestLatencyStrategy:
"""策略 3:选择最近延迟最低的端点。"""
def __init__(self, endpoints: list[dict[str, Any]]) -> None:
self._endpoints = endpoints
def select(self, excluded: set[str]) -> dict[str, Any] | None:
available = [
e
for e in self._endpoints
if e.get("name") not in excluded and e.get("status", "active") == "active"
]
if not available:
return None
return min(available, key=lambda e: e.get("latency_ms", 1000))
class LeastConnectionsStrategy:
"""策略 4:选择活跃连接数最少的端点。"""
def __init__(self, endpoints: list[dict[str, Any]]) -> None:
self._endpoints = endpoints
def select(self, excluded: set[str]) -> dict[str, Any] | None:
available = [
e
for e in self._endpoints
if e.get("name") not in excluded and e.get("status", "active") == "active"
]
if not available:
return None
return min(available, key=lambda e: e.get("connections", 0))
class CostAwareStrategy:
"""策略 5:按每 token 成本选择最便宜的端点。"""
def __init__(
self, endpoints: list[dict[str, Any]], get_cost: Callable[[str], float] | None = None
) -> None:
self._endpoints = endpoints
self._get_cost = get_cost or (lambda _: 0.0)
def select(self, excluded: set[str]) -> dict[str, Any] | None:
available = [
e
for e in self._endpoints
if e.get("name") not in excluded and e.get("status", "active") == "active"
]
if not available:
return None
return min(available, key=lambda e: self._get_cost(e.get("name", "")))
_STRATEGIES = {
"weighted_round_robin": WeightedRoundRobinStrategy,
"priority_failover": PriorityFailoverStrategy,
"lowest_latency": LowestLatencyStrategy,
"least_connections": LeastConnectionsStrategy,
"cost_aware": CostAwareStrategy,
}
class EndpointPool:
"""模型池,带可插拔的调度策略。"""
def __init__(
self,
logical_model_name: str,
endpoints_configs: list[dict[str, Any]],
strategy: str = "weighted_round_robin",
) -> None:
self.logical_model_name = logical_model_name
self._endpoints = endpoints_configs
strategy_cls = _STRATEGIES.get(strategy, WeightedRoundRobinStrategy)
self.strategy = (
strategy_cls(endpoints_configs)
if strategy == "priority_failover"
else strategy_cls(endpoints_configs)
)
def select(self, excluded: set[str]) -> dict[str, Any] | None:
"""使用配置的策略选择一个端点。"""
return self.strategy.select(excluded)
def list_endpoints(self) -> list[dict[str, Any]]:
return list(self._endpoints)
class PoolFactory:
"""工厂:创建端点池。"""
@staticmethod
def create_pool(
logical_model_name: str,
endpoints: list[dict[str, Any]],
strategy_name: str = "weighted_round_robin",
) -> EndpointPool:
return EndpointPool(
logical_model_name=logical_model_name,
endpoints_configs=endpoints,
strategy=strategy_name,
)
@staticmethod
def create_strategy(
name: str,
endpoints: list[dict[str, Any]],
) -> SchedulingStrategy:
strategy_cls = _STRATEGIES.get(name)
if strategy_cls is None:
raise ValueError(f"Unknown scheduling strategy: {name}")
return strategy_cls(endpoints)
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
import json
from collections import defaultdict
from typing import Any
from app.ai_provider.types.chat import ChatChunk, ChatResult, TokenUsage
class ToolCallAccumulator:
"""将流式 tool call 分片累积为完整的 tool call。
处理流式场景中常见的多分片 tool call 参数,
按索引拼接部分 JSON 字符串。
"""
def __init__(self) -> None:
self.content: str = ""
self.reasoning: str = ""
self._tool_call_slots: dict[int, dict[str, Any]] = defaultdict(
lambda: {"index": 0, "id": "", "name": "", "args": ""}
)
self.usage: TokenUsage | None = None
def add_chunk(self, chunk: ChatChunk) -> None:
"""处理一个 ChatChunk,累积 content/reasoning/tool-calls/usage。"""
if chunk.delta_content:
self.content += chunk.delta_content
if chunk.delta_reasoning:
self.reasoning += chunk.delta_reasoning
if chunk.usage is not None:
self.usage = chunk.usage
for tc in chunk.tool_call_chunks:
idx = tc.get("index", 0)
if idx not in self._tool_call_slots:
self._tool_call_slots[idx] = {"index": idx, "id": "", "name": "", "args": ""}
slot = self._tool_call_slots[idx]
# The first chunk for a given index carries id/name; subsequent
# arg-only chunks have them as None and should NOT overwrite.
if tc.get("id"):
slot["id"] = tc["id"]
if tc.get("name"):
slot["name"] = tc["name"]
slot["args"] += tc.get("args", "")
def drain(self) -> list[dict[str, Any]]:
"""返回所有已累积的 tool call 并重置内部槽位。"""
result: list[dict[str, Any]] = []
for idx in sorted(self._tool_call_slots.keys()):
slot = self._tool_call_slots[idx]
result.append(
{
"index": slot["index"],
"id": slot["id"] or f"call_{idx}",
"name": slot["name"],
"args": slot["args"],
}
)
self._tool_call_slots.clear()
return result
class StreamHandler:
"""收集流式分片并生成最终的 ChatResult。"""
def __init__(self) -> None:
self._accumulator = ToolCallAccumulator()
self._chunks: list[ChatChunk] = []
def add_chunk(self, chunk: ChatChunk) -> None:
"""收集一个流式分片。"""
self._chunks.append(chunk)
self._accumulator.add_chunk(chunk)
@property
def chunks(self) -> list[ChatChunk]:
"""返回所有已收集分片的副本。"""
return list(self._chunks)
def to_result(self, model_name: str = "") -> ChatResult:
"""从所有累积分片构建最终的 ChatResult。"""
raw_tool_calls = self._accumulator.drain()
parsed_calls: list[dict[str, Any]] = []
for tc in raw_tool_calls:
args_raw = tc.get("args", "{}")
try:
parsed_args = json.loads(args_raw)
except json.JSONDecodeError:
parsed_args = {"_raw": args_raw}
parsed_calls.append(
{
"name": tc["name"],
"args": parsed_args,
"id": tc["id"],
}
)
return ChatResult(
content=self._accumulator.content,
reasoning=self._accumulator.reasoning,
tool_calls=parsed_calls,
usage=self._accumulator.usage,
model_name=model_name,
)
class StreamFactory:
"""流处理器和累积器的工厂。"""
@staticmethod
def create_handler() -> StreamHandler:
return StreamHandler()
@staticmethod
def create_accumulator() -> ToolCallAccumulator:
return ToolCallAccumulator()