Files
Genarrative/server-rs/crates/platform-llm/src/lib.rs
T
lhk229 9aa6f5efea
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 4m44s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m9s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m33s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m50s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 3m53s
Project CI / AI game creator shell Rust crates (push) Successful in 2m51s
Project CI / Frontend tests (push) Successful in 4m58s
Project CI / Repository checks (push) Successful in 3m18s
Project CI / Native shell tests (push) Successful in 6m10s
Project CI / Backend tests (push) Successful in 7m7s
Project CI / AI game creator shell web tests (push) Successful in 2m16s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
解决platform-llm和策划agent reasoning问题,并修复若干bug (#350)
Reviewed-on: #350
2026-09-15 00:50:36 +08:00

8723 lines
344 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::{
collections::BTreeMap,
env,
error::Error,
fmt, fs,
net::IpAddr,
path::PathBuf,
str as std_str,
sync::atomic::{AtomicU64, Ordering},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use log::{debug, warn};
use reqwest::{Client, StatusCode, redirect::Policy};
use serde::{Deserialize, Serialize};
use tokio::time::sleep;
mod provider_adapter;
pub mod router_billing;
pub use provider_adapter::{
ANTHROPIC_PROVIDER_INSTANCE_ID, ANTHROPIC_PROVIDER_PROTOCOL_ID, AnthropicProviderAdapter,
OPENAI_CHAT_PROVIDER_INSTANCE_ID, OPENAI_CHAT_PROVIDER_PROTOCOL_ID,
OPENAI_RESPONSES_PROVIDER_INSTANCE_ID, OPENAI_RESPONSES_PROVIDER_PROTOCOL_ID,
OpenAiChatProviderAdapter, OpenAiResponsesProviderAdapter, PlatformLlmProviderRegistryBuilder,
build_platform_llm_provider_registry, build_platform_llm_provider_registry_for_api_kind,
llm_response_from_provider_response, provider_request_from_llm_request,
};
pub const DEFAULT_ARK_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/v3";
pub const EDITOR_AGENT_GPT5_MODEL: &str = "gpt-5.4-mini";
pub const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 30_000;
pub const DEFAULT_MAX_RETRIES: u32 = 1;
pub const DEFAULT_RETRY_BACKOFF_MS: u64 = 500;
pub const CHAT_COMPLETIONS_PATH: &str = "/chat/completions";
pub const RESPONSES_PATH: &str = "/responses";
pub const ANTHROPIC_MESSAGES_PATH: &str = "/v1/messages";
const ANTHROPIC_VERSION: &str = "2023-06-01";
const DEFAULT_ANTHROPIC_MAX_OUTPUT_TOKENS: u32 = 1024;
const DEFAULT_LLM_RAW_LOG_DIR: &str = "logs/llm-raw";
static LLM_RAW_LOG_SEQUENCE: AtomicU64 = AtomicU64::new(1);
// 冻结平台来源,避免上层继续散落 provider 字符串。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LlmProvider {
Ark,
DashScope,
OpenAiCompatible,
}
/// OpenAI Chat Completions 的生成预算字段方言。
///
/// `max_completion_tokens` 是当前 OpenAI 契约,包含可见输出与隐藏 reasoning token;
/// `max_tokens` 仅用于尚未支持新字段的兼容网关。能力必须由调用方按 endpoint 显式声明,
/// 不能根据模型名或请求级 model override 猜测。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OpenAiChatTokenBudgetField {
MaxCompletionTokens,
LegacyMaxTokens,
}
// 统一收口文本模型网关配置,避免 api-server 和业务模块各自重复解析环境变量。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LlmConfig {
provider: LlmProvider,
base_url: String,
api_key: String,
model: String,
raw_log_dir: PathBuf,
request_timeout_ms: u64,
max_retries: u32,
retry_backoff_ms: u64,
official_fallback: bool,
agc_client_marker: bool,
anthropic_strict_tool_support: bool,
openai_chat_token_budget_field: OpenAiChatTokenBudgetField,
}
// 首版只冻结当前项目已稳定使用的 system/user/assistant 三种消息角色。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LlmMessageRole {
System,
User,
Assistant,
}
// 单条消息保持 OpenAI 兼容格式,供统一请求体直接序列化。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LlmMessage {
pub role: LlmMessageRole,
// 中文注释:保留纯文本字段兼容 Chat Completions 和既有调用;Responses 多模态请求读取 content_parts。
pub content: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub content_parts: Vec<LlmMessageContentPart>,
}
// Responses 多模态内容块。字段名按上游 OpenAI 兼容协议保持 snake_case。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum LlmMessageContentPart {
InputText { text: String },
InputImage { image_url: String },
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LlmFunctionTool {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
#[serde(default)]
pub strict: bool,
}
impl LlmFunctionTool {
pub fn new(
name: impl Into<String>,
description: impl Into<String>,
parameters: serde_json::Value,
) -> Self {
Self {
name: name.into(),
description: description.into(),
parameters,
strict: false,
}
}
pub fn with_strict(mut self, strict: bool) -> Self {
self.strict = strict;
self
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LlmToolChoice {
Auto,
Required,
}
impl LlmToolChoice {
fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Required => "required",
}
}
// Anthropic 用 any 表达“必须调用某个工具”,与 OpenAI 的 required 同义。
fn as_anthropic_type(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Required => "any",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LlmToolCall {
pub id: String,
pub name: String,
pub arguments: String,
}
// 统一请求同时承载消息、输出参数与 OpenAI 原生 function tools。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LlmRunRequest {
pub model: Option<String>,
pub messages: Vec<LlmMessage>,
/// Responses 原生输入,完整替代 messages;由调用方管理消息、工具结果与推理项。
pub responses_input: Option<Vec<serde_json::Value>>,
/// 生成侧 token 预算,包含可见输出与 Provider 可能使用的隐藏 reasoning token;
/// 不包含输入 token,也不保证可见正文长度。
pub max_output_tokens: Option<u32>,
pub enable_web_search: bool,
pub api_kind: LlmApiKind,
pub request_timeout_ms: Option<u64>,
pub response_reasoning_effort: Option<LlmResponseReasoningEffort>,
pub response_text_verbosity: Option<LlmResponseTextVerbosity>,
/// 是否把 Provider 返回的内部 reasoning 作为独立旁路字段暴露给调用方;默认关闭。
pub capture_reasoning: bool,
pub function_tools: Vec<LlmFunctionTool>,
pub tool_choice: Option<LlmToolChoice>,
}
// 默认走 OpenAI Responses;旧 OpenAI Chat Completions 兼容入口显式选择。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LlmApiKind {
#[serde(rename = "openai_chat")]
OpenAiChat,
#[serde(rename = "openai_responses")]
OpenAiResponses,
#[serde(rename = "anthropic")]
Anthropic,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LlmResponseReasoningEffort {
Low,
Medium,
High,
Max,
}
impl LlmResponseReasoningEffort {
fn as_str(self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Max => "max",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LlmResponseTextVerbosity {
Low,
Medium,
High,
}
impl LlmResponseTextVerbosity {
fn as_str(self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
}
}
}
// 上层在流式消费时拿到的是“累计文本 + 当前增量”,避免每层重新自己拼接。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LlmStreamDelta {
pub accumulated_text: String,
pub delta_text: String,
/// 与正文分离的 Provider 推理文本;未捕获或没有数据时为空。
pub accumulated_reasoning: String,
/// 当前回调的推理增量;不得追加到正文。
pub reasoning_delta: String,
pub finish_reason: Option<String>,
}
// 用于保留 token 计数,后续模块可以决定是否写入审计或成本统计。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LlmTokenUsage {
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
}
// 统一文本与工具调用响应,避免业务层重复解析不同 OpenAI 协议。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LlmRunResponse {
pub provider: LlmProvider,
pub model: String,
pub text: String,
/// 与正文分离的 Provider 推理文本;未捕获或没有数据时为空。
pub reasoning: String,
pub finish_reason: Option<String>,
pub response_id: Option<String>,
pub usage: Option<LlmTokenUsage>,
pub tool_calls: Vec<LlmToolCall>,
/// Responses 的完整 output 项,可原样加入下一轮 input;其他协议为空。
pub responses_output: Vec<serde_json::Value>,
}
// 将上游错误归一到稳定的领域枚举,后续 api-server 可以直接映射成 HTTP error contract。
#[derive(Debug, PartialEq, Eq)]
pub enum LlmError {
InvalidConfig(String),
InvalidRequest(String),
Timeout { attempts: u32 },
Connectivity { attempts: u32, message: String },
Upstream { status_code: u16, message: String },
StreamUnavailable,
EmptyResponse,
Transport(String),
Deserialize(String),
}
// 平台层只暴露稳定错误分类,HTTP status 和业务文案由 api-server 再映射。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LlmErrorKind {
InvalidConfig,
InvalidRequest,
Timeout,
Connectivity,
Upstream,
StreamUnavailable,
EmptyResponse,
Transport,
Deserialize,
}
// 统一 OpenAI 兼容文本网关 client。
#[derive(Clone, Debug)]
pub struct LlmClient {
config: LlmConfig,
http_client: Client,
}
struct LlmAttemptResponse {
response: reqwest::Response,
attempt: u32,
}
#[derive(Serialize)]
#[serde(untagged)]
enum LlmRequestBody {
ChatCompletions(ChatCompletionsRequestBody),
Responses(ResponsesRequestBody),
Anthropic(AnthropicMessagesRequestBody),
}
#[derive(Serialize)]
struct ChatCompletionsRequestBody {
model: String,
messages: Vec<ChatCompletionsInputMessage>,
stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
official_fallback: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
max_completion_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
web_search_options: Option<ChatCompletionsWebSearchOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<ChatCompletionsFunctionTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<&'static str>,
}
#[derive(Serialize)]
struct ChatCompletionsWebSearchOptions {}
#[derive(Serialize)]
struct ChatCompletionsFunctionTool {
#[serde(rename = "type")]
tool_type: &'static str,
function: LlmFunctionTool,
}
#[derive(Serialize)]
struct ChatCompletionsInputMessage {
role: &'static str,
content: ChatCompletionsInputContent,
}
#[derive(Serialize)]
#[serde(untagged)]
enum ChatCompletionsInputContent {
Text(String),
Parts(Vec<ChatCompletionsInputContentPart>),
}
#[derive(Serialize)]
#[serde(tag = "type")]
enum ChatCompletionsInputContentPart {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "image_url")]
ImageUrl { image_url: ChatCompletionsImageUrl },
}
#[derive(Serialize)]
struct ChatCompletionsImageUrl {
url: String,
}
#[derive(Serialize)]
struct ResponsesRequestBody {
model: String,
stream: bool,
input: ResponsesInput,
#[serde(skip_serializing_if = "Option::is_none")]
store: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
include: Option<Vec<&'static str>>,
#[serde(skip_serializing_if = "Option::is_none")]
official_fallback: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
max_output_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<ResponsesTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning: Option<ResponsesReasoningOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
text: Option<ResponsesTextOptions>,
}
#[derive(Serialize)]
#[serde(untagged)]
enum ResponsesInput {
Messages(Vec<ResponsesInputMessage>),
Native(Vec<serde_json::Value>),
}
#[derive(Serialize)]
struct ResponsesInputMessage {
role: &'static str,
content: Vec<ResponsesInputContentPart>,
}
#[derive(Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ResponsesInputContentPart {
InputText { text: String },
OutputText { text: String },
InputImage { image_url: String },
}
#[derive(Serialize)]
struct ResponsesWebSearchTool {
#[serde(rename = "type")]
tool_type: &'static str,
max_keyword: u8,
}
#[derive(Serialize)]
#[serde(untagged)]
enum ResponsesTool {
WebSearch(ResponsesWebSearchTool),
Function(ResponsesFunctionTool),
}
#[derive(Serialize)]
struct ResponsesFunctionTool {
#[serde(rename = "type")]
tool_type: &'static str,
#[serde(flatten)]
function: LlmFunctionTool,
}
#[derive(Serialize)]
struct AnthropicMessagesRequestBody {
model: String,
max_tokens: u32,
stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
system: Option<String>,
messages: Vec<AnthropicInputMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<AnthropicTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<AnthropicToolChoice>,
}
#[derive(Serialize)]
struct AnthropicInputMessage {
role: &'static str,
content: String,
}
// Anthropic 工具与 OpenAI 的差异:schema 字段名为 input_schema,且没有 function 包装层。
#[derive(Serialize)]
struct AnthropicTool {
name: String,
description: String,
input_schema: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
strict: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
cache_control: Option<AnthropicCacheControl>,
}
#[derive(Serialize)]
struct AnthropicCacheControl {
#[serde(rename = "type")]
cache_type: &'static str,
}
// Anthropic 的 tool_choice 必须是对象,发送裸字符串会被上游拒绝。
#[derive(Serialize)]
struct AnthropicToolChoice {
#[serde(rename = "type")]
choice_type: &'static str,
}
#[derive(Serialize)]
struct ResponsesReasoningOptions {
effort: &'static str,
}
#[derive(Serialize)]
struct ResponsesTextOptions {
verbosity: &'static str,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct LlmRawFailureInputLog<'a> {
provider: &'static str,
api_kind: &'static str,
model: &'a str,
stream: bool,
attempt: u32,
max_output_tokens: Option<u32>,
messages: &'a [LlmMessage],
}
#[derive(Deserialize)]
#[serde(untagged)]
enum ChatCompletionsResponsePayload {
Direct(ChatCompletionsResponseEnvelope),
Wrapped {
data: ChatCompletionsResponseEnvelope,
},
}
#[derive(Deserialize)]
struct ChatCompletionsResponseEnvelope {
id: Option<String>,
model: Option<String>,
#[serde(default, deserialize_with = "deserialize_nullable_vec")]
choices: Vec<ChatCompletionsChoice>,
usage: Option<LlmTokenUsage>,
}
fn deserialize_nullable_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de>,
{
Ok(Option::<Vec<T>>::deserialize(deserializer)?.unwrap_or_default())
}
fn deserialize_optional_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<serde_json::Value>::deserialize(deserializer)?
.and_then(|value| value.as_str().map(str::to_string)))
}
#[derive(Deserialize)]
struct ChatCompletionsChoice {
#[serde(default)]
message: Option<ChatCompletionsMessage>,
#[serde(default)]
delta: Option<ChatCompletionsMessage>,
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Deserialize)]
struct ChatCompletionsMessage {
#[serde(default)]
content: Option<ChatCompletionsContent>,
#[serde(default)]
tool_calls: Option<Vec<ChatCompletionsToolCall>>,
#[serde(default, deserialize_with = "deserialize_optional_string")]
reasoning: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_string")]
reasoning_content: Option<String>,
}
// 流式分片只有首片带 id / name,后续片仅有 index 与 arguments 片段,因此字段全部可选。
#[derive(Deserialize)]
struct ChatCompletionsToolCall {
#[serde(default)]
id: Option<String>,
#[serde(default)]
index: Option<u64>,
#[serde(default)]
function: Option<ChatCompletionsFunctionCall>,
}
#[derive(Deserialize)]
struct ChatCompletionsFunctionCall {
#[serde(default)]
name: Option<String>,
#[serde(default)]
arguments: Option<String>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum ChatCompletionsContent {
Text(String),
Parts(Vec<ChatCompletionsContentPart>),
}
#[derive(Deserialize)]
struct ChatCompletionsContentPart {
#[serde(rename = "type")]
part_type: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_string")]
text: Option<String>,
}
#[derive(Deserialize)]
struct ResponsesResponseEnvelope {
id: Option<String>,
model: Option<String>,
#[serde(default)]
output_text: Option<String>,
#[serde(default)]
output: Vec<ResponsesOutputItem>,
#[serde(default)]
status: Option<String>,
usage: Option<ResponsesUsage>,
}
#[derive(Deserialize)]
struct ResponsesOutputItem {
#[serde(rename = "type")]
#[serde(default)]
item_type: Option<String>,
#[serde(default)]
content: Vec<ResponsesOutputContentPart>,
#[serde(default)]
id: Option<String>,
#[serde(default)]
call_id: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
arguments: Option<String>,
#[serde(default)]
summary: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct ResponsesOutputContentPart {
#[serde(rename = "type")]
part_type: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_string")]
text: Option<String>,
}
#[derive(Deserialize)]
struct ResponsesUsage {
#[serde(default)]
input_tokens: u64,
#[serde(default)]
output_tokens: u64,
#[serde(default)]
total_tokens: u64,
}
#[derive(Deserialize)]
struct AnthropicResponseEnvelope {
id: Option<String>,
model: Option<String>,
#[serde(default)]
content: Vec<AnthropicContentBlock>,
#[serde(default)]
stop_reason: Option<String>,
usage: Option<AnthropicUsage>,
}
#[derive(Deserialize)]
struct AnthropicContentBlock {
#[serde(rename = "type")]
block_type: Option<String>,
#[serde(default)]
text: Option<String>,
#[serde(default)]
thinking: Option<String>,
// tool_use block 字段:id 与 name 标识调用,input 是已解析的 JSON object。
#[serde(default)]
id: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
input: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct AnthropicUsage {
#[serde(default)]
input_tokens: u64,
#[serde(default)]
cache_creation_input_tokens: u64,
#[serde(default)]
cache_read_input_tokens: u64,
#[serde(default)]
output_tokens: u64,
}
fn map_anthropic_usage(usage: AnthropicUsage) -> LlmTokenUsage {
let prompt_tokens = usage
.input_tokens
.saturating_add(usage.cache_creation_input_tokens)
.saturating_add(usage.cache_read_input_tokens);
LlmTokenUsage {
prompt_tokens,
completion_tokens: usage.output_tokens,
total_tokens: prompt_tokens.saturating_add(usage.output_tokens),
}
}
struct OpenAiCompatibleSseParser {
buffer: String,
raw_text: String,
api_kind: LlmApiKind,
terminated: bool,
}
#[derive(Debug, Default)]
struct ParsedStreamEvent {
delta_text: Option<String>,
reasoning_delta: Option<String>,
// Responses 的 summary part 还必须绑定 item_id;summary_index 只在单个 reasoning item 内唯一。
reasoning_summary_item_id: Option<String>,
reasoning_summary_index: Option<u64>,
reasoning_snapshot: Option<String>,
responses_output: Option<Vec<serde_json::Value>>,
// 终态事件携带的完整正文快照。必须与 delta_text 分开:它不是增量,按增量累加会让
// 正文翻倍。只有 Responses 的 completed / incomplete 会填——Chat 的 [DONE] 与
// Anthropic 的 message_stop 都不带载荷,那两条协议恒为 None。
text_snapshot: Option<String>,
finish_reason: Option<String>,
usage: Option<LlmTokenUsage>,
is_terminal: bool,
// 本事件是协议层的收尾信号。它和 is_terminal 不同:Chat 的非空 finish_reason
// 与 Anthropic 带 stop_reason 的 message_delta 能证明流已收尾,但不会直接终止读取;
// Chat 的 [DONE]、Responses 的 completed / incomplete 与 Anthropic 的 message_stop
// 才会同时置 is_terminal。它也和 finish_reason 分开:message_stop 不带 stop_reason,
// 不能借它写 finish_reason,否则会覆盖 message_delta 给出的真实 end_turn。
is_completion: bool,
tool_fragments: Vec<ToolCallFragment>,
// Responses 增量 output item。completed 若未带完整 output[],靠这些槽位拼出可回放的原生数组。
output_items: Vec<(u64, serde_json::Value)>,
}
// 三种协议的工具调用增量归一:slot 是协议各自的索引(Chat/Anthropic 的 index、
// Responses 的 output_index),id 与 name 只在首个分片出现,参数按到达顺序拼接。
#[derive(Debug, Default)]
struct ToolCallFragment {
slot: u64,
id: Option<String>,
name: Option<String>,
arguments_delta: Option<String>,
// 上游给出完整参数时(Responses 的 .done)直接覆盖,避免依赖分片拼接结果。
arguments_complete: Option<String>,
// 本分片来自终态快照(Responses 的 response.completed / incomplete 载荷),是对**已宣告
// 调用的重述**,而不是一次新宣告。只有这种分片允许按 id 重绑到已有槽位——快照没有
// output_index 字段,只能按数组下标重建槽位,会与增量事件错位,必须靠 id 纠回去。
// 增量事件(output_item.added / content_block_start)永远是在宣告新调用,绝不能重绑:
// 上游若在两次宣告里重复用了同一个 call id,重绑会把两次调用并成一条、静默丢掉一次。
from_terminal_snapshot: bool,
}
#[derive(Debug)]
struct PendingToolCall {
slot: u64,
id: Option<String>,
name: Option<String>,
arguments: String,
}
/// 判断上游是否明确给出了截断、过滤或失败的终态。
///
/// 这里刻意使用按 API 类型区分的黑名单而不是白名单:兼容网关常常不发这个字段或发
/// 自定义值,白名单会把它们全部误杀。调用方应继续兼容缺失、空白和未知 reason。
pub fn is_incomplete_finish_reason(api_kind: LlmApiKind, finish_reason: &str) -> bool {
let reason = finish_reason.trim().to_ascii_lowercase();
match api_kind {
LlmApiKind::OpenAiChat => matches!(reason.as_str(), "length" | "content_filter"),
LlmApiKind::OpenAiResponses => {
matches!(reason.as_str(), "incomplete" | "failed" | "cancelled")
}
LlmApiKind::Anthropic => {
matches!(reason.as_str(), "max_tokens" | "pause_turn" | "refusal")
}
}
}
// 上游已经说了这一轮没写完,工具调用就不可信:参数恰好闭合成合法 JSON 只说明字节完整,
// 不代表模型把本轮工具计划表达完了,而下游拿到 tool_calls 就会真的去执行。仅在存在工具
// 调用时拒绝——正文被 max_tokens 截断仍是可用的降级结果,砍掉它会打死所有长文本回答。
//
// 与畸形参数透传(非流式)的关系:两者同时成立时本检查优先。畸形参数交给调用方的格式
// 修复循环是因为那时“不完整”可被察觉;截断且参数恰好合法时修复循环察觉不到,只会直接执行。
fn reject_incomplete_tool_calls(
api_kind: LlmApiKind,
finish_reason: Option<&str>,
tool_calls: &[LlmToolCall],
context: &str,
) -> Result<(), LlmError> {
if tool_calls.is_empty() {
return Ok(());
}
let Some(reason) = finish_reason else {
return Ok(());
};
if !is_incomplete_finish_reason(api_kind, reason) {
return Ok(());
}
Err(LlmError::Deserialize(format!(
"LLM {context}工具调用来自未完成的响应:finish_reason={reason}, calls={}",
tool_calls.len()
)))
}
// 三协议、流式与非流式共用的工具调用中间形态。协议层只负责把自己的 DTO 映射成它,
// 不做任何取舍判断;要不要接受、缺省怎么补,全部由 normalize_tool_calls 决定。
#[derive(Debug)]
struct RawToolCall {
// 流式为协议槽位(Chat / Anthropic 的 index、Responses 的 output_index),
// 非流式为所在数组的下标,仅用于定位报错。
slot: u64,
id: Option<String>,
name: Option<String>,
arguments: Option<String>,
}
// 唯一的归一策略点。已经被识别为工具调用却字段不全时必须显式失败:静默丢弃会把
// “上游给了工具调用但我们没解出来”伪装成“上游只回了正文”,调用方完全无从察觉,
// 而带 tool_choice=required 的请求还会因此退化成格式修复循环,审计里看不出真正成因。
//
// require_complete_arguments_json 区分流式与非流式,两者的“参数不完整”语义不同:
// 流式意味着流被截断,是传输层事实,平台层必须报错;非流式的外层 body 已经完整,
// 参数半截只说明模型输出有问题,属于内容层事实,应当原样交给调用方——调用方的格式
// 修复循环会把畸形响应回灌给模型重写,比平台层硬报错再重跑整轮 Provider 更有效,
// 平台层拦下来反而会毁掉修复所需的 call id、函数名和原始参数。
fn normalize_tool_calls(
raw: Vec<RawToolCall>,
context: &str,
require_complete_arguments_json: bool,
) -> Result<Vec<LlmToolCall>, LlmError> {
raw.into_iter()
.map(|call| {
let RawToolCall {
slot,
id,
name,
arguments,
} = call;
let id = id
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty())
.ok_or_else(|| {
LlmError::Deserialize(format!("LLM {context}工具调用缺少 id:slot={slot}"))
})?;
let name = name
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty())
.ok_or_else(|| {
LlmError::Deserialize(format!("LLM {context}工具调用缺少函数名:slot={slot}"))
})?;
// 缺省或空白参数归一为空对象(零参函数合法)。
let arguments = arguments.unwrap_or_default();
let arguments = arguments.trim();
if arguments.is_empty() {
return Ok(LlmToolCall {
id,
name,
arguments: "{}".to_string(),
});
}
if require_complete_arguments_json {
serde_json::from_str::<serde_json::Value>(arguments).map_err(|error| {
LlmError::Deserialize(format!(
"LLM {context}工具调用参数不是完整 JSON:name={name}, error={error}"
))
})?;
}
Ok(LlmToolCall {
id,
name,
arguments: arguments.to_string(),
})
})
.collect()
}
// 流式累加状态:文本、终止原因、用量与按槽位聚合的工具调用。
#[derive(Debug, Default)]
struct StreamAccumulation {
text: String,
reasoning: String,
// Responses summary part 的流式累计,key 必须同时包含 reasoning item 与 part 索引。
reasoning_summary_parts: BTreeMap<(String, u64), String>,
reasoning_summary_item_order: Vec<String>,
responses_output: Vec<serde_json::Value>,
finish_reason: Option<String>,
usage: Option<LlmTokenUsage>,
tool_calls: Vec<PendingToolCall>,
// 是否观察到过协议收尾信号。字节流干净结束不等于协议收尾:代理超时、网关自行掐断
// 和 HTTP/2 提前 END_STREAM 都表现为干净 EOF,与正常收尾无法区分。
completion_observed: bool,
}
// 身份字段只允许「从缺失变已知」或「重复同一个值」。同槽位换身份说明上游把两个不同调用
// 挤进了一个槽,此时覆盖身份却追加参数并不自洽:前一个调用参数为空时拼接结果仍是合法
// JSON,截断断言兜不住,调用方只会看到后一个工具,前一个无声消失;Responses 的
// arguments_complete 还会整段覆盖,产出「A 的身份配 B 的参数」。两种结果都会直接交给
// Runtime 执行,所以必须失败关闭。
//
// 空白值按缺失处理,不算冲突:部分 OpenAI 兼容网关在续传分片里回发完整 function 对象,
// name / id 是空串,按「不等即冲突」会把它们整批误杀。这也与 normalize_tool_calls 的
// 空白即缺失约定一致。
fn merge_tool_identity(
current: &mut Option<String>,
incoming: Option<String>,
field: &str,
slot: u64,
) -> Result<(), LlmError> {
let Some(incoming) = incoming.filter(|value| !value.trim().is_empty()) else {
return Ok(());
};
match current {
Some(existing) if existing.trim() == incoming.trim() => Ok(()),
Some(existing) => Err(LlmError::Deserialize(format!(
"LLM 流式工具分片槽位 {slot} 的 {field} 冲突:已有 {existing},又收到 {incoming}"
))),
None => {
*current = Some(incoming);
Ok(())
}
}
}
// 单个事件内重复出现的非空 id。这些 id 不参与按 id 归并——见 push_tool_fragment 的成因二。
// 判定边界刻意取「同一个事件」:跨事件的同 id 是我们终态兜底造成的槽位错位,必须归并;
// 同事件内的同 id 是上游载荷自己就坏了,必须原样保留。
fn tool_fragment_ids_repeated_in_event(fragments: &[ToolCallFragment]) -> Vec<String> {
let mut seen: Vec<&str> = Vec::new();
let mut repeated: Vec<String> = Vec::new();
for fragment in fragments {
let Some(id) = fragment
.id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty())
else {
continue;
};
if seen.contains(&id) {
if !repeated.iter().any(|value| value == id) {
repeated.push(id.to_string());
}
} else {
seen.push(id);
}
}
repeated
}
impl StreamAccumulation {
fn push_tool_fragment(
&mut self,
fragment: ToolCallFragment,
ids_repeated_in_event: &[String],
) -> Result<(), LlmError> {
// 槽位只是传输层的归并键,真正的身份是 id。但**不能**因此一律按 id 归并——同一个 id
// 落到两个槽位有两种成因,处置完全相反:
//
// 一、我们自己的终态兜底造成的槽位基准错位。快照没有 output_index,只能按 output[]
// 数组下标重建槽位,网关若在快照里省掉此前占用过某个 output_index 的 reasoning /
// message 条目就会错位。此时按新槽位新建会产出两条 id 完全相同的重复调用,而且
// 落进的是空槽位、merge_tool_identity 的冲突检测(只在同槽位已有身份时比对)根本
// 不触发,全程无告警。必须按 id 归位。
//
// 二、上游自己重复使用了 call id,两次宣告本就是两次调用。按 id 归并会把它们并成
// 一条、后到的参数覆盖先到的,静默丢掉一次;还会绕过调用方的 call id 唯一性校验
// ——非流式路径原样返回两条由调用方拒绝,流式却悄悄放行,两条路径契约就此分叉。
//
// 判据是 from_terminal_snapshot 而不是「是否跨事件」:只有终态快照是对已宣告调用的
// **重述**,才有重绑的正当性;增量宣告永远是新调用。用「跨事件」当判据会漏掉成因二
// 的跨事件形态——两次 output_item.added 用同一个 id 时,第二次会被重绑走,它自己的
// 参数事件随后落到一个没有身份的空槽位上,最终报出「缺少 id:slot=N」这种完全指错
// 方向的错误。
//
// 快照内部自己重复的 id 仍要排除:那同样是上游违反唯一性,不是错位。
//
// 名字冲突仍由 merge_tool_identity 拦截:归位之后两侧函数名不同会照常失败关闭。
let slot = fragment
.id
.as_deref()
.filter(|_| fragment.from_terminal_snapshot)
.map(str::trim)
.filter(|id| !id.is_empty())
.filter(|id| !ids_repeated_in_event.iter().any(|repeated| repeated == id))
.and_then(|id| {
self.tool_calls
.iter()
.find(|pending| {
pending.id.as_deref().map(str::trim) == Some(id)
&& pending.slot != fragment.slot
})
.map(|pending| pending.slot)
})
.unwrap_or(fragment.slot);
if !self.tool_calls.iter().any(|pending| pending.slot == slot) {
self.tool_calls.push(PendingToolCall {
slot,
id: None,
name: None,
arguments: String::new(),
});
}
let entry = self
.tool_calls
.iter_mut()
.find(|pending| pending.slot == slot)
.expect("slot was just ensured");
// 身份先校验:冲突时连参数都不能并进去,累加状态已经不可信。
merge_tool_identity(&mut entry.id, fragment.id, "id", slot)?;
merge_tool_identity(&mut entry.name, fragment.name, "函数名", slot)?;
if let Some(delta) = fragment.arguments_delta {
entry.arguments.push_str(delta.as_str());
}
// 上游给出的完整参数是权威值,直接覆盖分片拼接结果。
if let Some(complete) = fragment.arguments_complete {
entry.arguments = complete;
}
Ok(())
}
// 流结束后固化,走与非流式相同的归一:缺 id / 函数名报错,空参数归一为 {},
// 非空参数必须是完整 JSON,否则说明流被截断,不能把半截参数交给业务层。
fn finish_tool_calls(&self) -> Result<Vec<LlmToolCall>, LlmError> {
normalize_tool_calls(
self.tool_calls
.iter()
.map(|pending| RawToolCall {
slot: pending.slot,
id: pending.id.clone(),
name: pending.name.clone(),
arguments: Some(pending.arguments.clone()),
})
.collect(),
"流式",
true,
)
}
}
#[derive(Debug)]
struct SseEventDrainError {
parsed_events: Vec<ParsedStreamEvent>,
error: LlmError,
}
impl LlmProvider {
pub fn as_str(&self) -> &'static str {
match self {
Self::Ark => "ark",
Self::DashScope => "dash_scope",
Self::OpenAiCompatible => "openai_compatible",
}
}
}
impl LlmConfig {
#[allow(clippy::too_many_arguments)]
pub fn new(
provider: LlmProvider,
base_url: String,
api_key: String,
model: String,
request_timeout_ms: u64,
max_retries: u32,
retry_backoff_ms: u64,
) -> Result<Self, LlmError> {
let base_url = normalize_non_empty(base_url, "LLM base_url 不能为空")?;
let api_key = normalize_non_empty(api_key, "LLM api_key 不能为空")?;
let model = normalize_non_empty(model, "LLM model 不能为空")?;
let raw_log_dir = env::var("LLM_RAW_LOG_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_LLM_RAW_LOG_DIR));
if request_timeout_ms == 0 {
return Err(LlmError::InvalidConfig(
"LLM request_timeout_ms 必须大于 0".to_string(),
));
}
Ok(Self {
provider,
base_url,
api_key,
model,
raw_log_dir,
request_timeout_ms,
max_retries,
retry_backoff_ms,
official_fallback: false,
agc_client_marker: false,
anthropic_strict_tool_support: false,
openai_chat_token_budget_field: OpenAiChatTokenBudgetField::LegacyMaxTokens,
})
}
pub fn with_official_fallback(mut self, official_fallback: bool) -> Self {
self.official_fallback = official_fallback;
self
}
/// 标记为 AGC 主站客户端请求,使 api-server 按客户端传入的模型目录 ID 解析模型。
pub fn with_agc_client_marker(mut self, enabled: bool) -> Self {
self.agc_client_marker = enabled;
self
}
/// 显式声明当前 Anthropic endpoint 与 model 组合支持 strict tool use。
///
/// 该能力不能由 `api_kind` 推断:旧 Claude 模型和 Anthropic-compatible
/// 网关未必接受 `strict: true`。因此默认关闭,仅允许已验证的配置启用。
pub fn with_anthropic_strict_tool_support(mut self, supported: bool) -> Self {
self.anthropic_strict_tool_support = supported;
self
}
/// 显式选择当前 Chat Completions endpoint 接受的生成预算字段。
pub fn with_openai_chat_token_budget_field(
mut self,
field: OpenAiChatTokenBudgetField,
) -> Self {
self.openai_chat_token_budget_field = field;
self
}
pub fn with_raw_log_dir(mut self, raw_log_dir: impl Into<PathBuf>) -> Self {
self.raw_log_dir = raw_log_dir.into();
self
}
pub fn ark_default(api_key: String, model: String) -> Result<Self, LlmError> {
Self::new(
LlmProvider::Ark,
DEFAULT_ARK_BASE_URL.to_string(),
api_key,
model,
DEFAULT_REQUEST_TIMEOUT_MS,
DEFAULT_MAX_RETRIES,
DEFAULT_RETRY_BACKOFF_MS,
)
}
pub fn provider(&self) -> LlmProvider {
self.provider
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn api_key(&self) -> &str {
&self.api_key
}
pub fn model(&self) -> &str {
&self.model
}
fn raw_log_dir(&self) -> &PathBuf {
&self.raw_log_dir
}
pub fn request_timeout_ms(&self) -> u64 {
self.request_timeout_ms
}
pub fn max_retries(&self) -> u32 {
self.max_retries
}
pub fn retry_backoff_ms(&self) -> u64 {
self.retry_backoff_ms
}
pub fn official_fallback(&self) -> bool {
self.official_fallback
}
pub fn anthropic_strict_tool_support(&self) -> bool {
self.anthropic_strict_tool_support
}
pub fn openai_chat_token_budget_field(&self) -> OpenAiChatTokenBudgetField {
self.openai_chat_token_budget_field
}
pub fn chat_completions_url(&self) -> String {
format!(
"{}/{}",
self.base_url.trim_end_matches('/'),
CHAT_COMPLETIONS_PATH.trim_start_matches('/')
)
}
pub fn responses_url(&self) -> String {
format!(
"{}/{}",
self.base_url.trim_end_matches('/'),
RESPONSES_PATH.trim_start_matches('/')
)
}
pub fn anthropic_messages_url(&self) -> String {
let base_url = self.base_url.trim_end_matches('/');
if base_url.ends_with("/v1") {
return format!("{base_url}/messages");
}
format!(
"{base_url}/{}",
ANTHROPIC_MESSAGES_PATH.trim_start_matches('/')
)
}
}
impl LlmMessage {
pub fn new(role: LlmMessageRole, content: impl Into<String>) -> Self {
Self {
role,
content: content.into(),
content_parts: Vec::new(),
}
}
pub fn system(content: impl Into<String>) -> Self {
Self::new(LlmMessageRole::System, content)
}
pub fn user(content: impl Into<String>) -> Self {
Self::new(LlmMessageRole::User, content)
}
pub fn assistant(content: impl Into<String>) -> Self {
Self::new(LlmMessageRole::Assistant, content)
}
pub fn multimodal(role: LlmMessageRole, content_parts: Vec<LlmMessageContentPart>) -> Self {
let content = content_parts
.iter()
.filter_map(|part| match part {
LlmMessageContentPart::InputText { text } => Some(text.as_str()),
LlmMessageContentPart::InputImage { .. } => None,
})
.collect::<Vec<_>>()
.join("\n");
Self {
role,
content,
content_parts,
}
}
pub fn user_multimodal(content_parts: Vec<LlmMessageContentPart>) -> Self {
Self::multimodal(LlmMessageRole::User, content_parts)
}
pub fn with_image_url(mut self, image_url: impl Into<String>) -> Self {
if self.content_parts.is_empty() && !self.content.trim().is_empty() {
self.content_parts.push(LlmMessageContentPart::InputText {
text: self.content.clone(),
});
}
self.content_parts.push(LlmMessageContentPart::InputImage {
image_url: image_url.into(),
});
self
}
}
impl LlmRunRequest {
pub fn new(messages: Vec<LlmMessage>) -> Self {
Self {
model: None,
messages,
responses_input: None,
max_output_tokens: None,
enable_web_search: false,
api_kind: LlmApiKind::OpenAiResponses,
request_timeout_ms: None,
response_reasoning_effort: None,
response_text_verbosity: None,
capture_reasoning: false,
function_tools: Vec::new(),
tool_choice: None,
}
}
pub fn single_turn(system_prompt: impl Into<String>, user_prompt: impl Into<String>) -> Self {
Self::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
])
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
pub fn with_api_kind(mut self, api_kind: LlmApiKind) -> Self {
self.api_kind = api_kind;
self
}
pub fn with_max_output_tokens(mut self, max_output_tokens: u32) -> Self {
self.max_output_tokens = Some(max_output_tokens);
self
}
pub fn with_web_search(mut self, enabled: bool) -> Self {
self.enable_web_search = enabled;
self
}
pub fn with_openai_responses(mut self) -> Self {
self.api_kind = LlmApiKind::OpenAiResponses;
self
}
/// 原样发送完整 Responses input,并以 store=false 请求可续轮的 encrypted_content。
/// input 需包含本轮 system 指令;messages 不会追加到 input。
pub fn with_responses_input(mut self, input: Vec<serde_json::Value>) -> Self {
self.api_kind = LlmApiKind::OpenAiResponses;
self.responses_input = Some(input);
self
}
pub fn with_openai_chat(mut self) -> Self {
self.api_kind = LlmApiKind::OpenAiChat;
self
}
pub fn with_anthropic(mut self) -> Self {
self.api_kind = LlmApiKind::Anthropic;
self
}
pub fn with_response_reasoning_effort(mut self, effort: LlmResponseReasoningEffort) -> Self {
self.response_reasoning_effort = Some(effort);
self
}
pub fn with_response_text_verbosity(mut self, verbosity: LlmResponseTextVerbosity) -> Self {
self.response_text_verbosity = Some(verbosity);
self
}
/// 只设置本地捕获意图,不改变请求的模型、推理档位和协议请求体。
pub fn with_reasoning_capture(mut self, enabled: bool) -> Self {
self.capture_reasoning = enabled;
self
}
pub fn with_function_tools(mut self, function_tools: Vec<LlmFunctionTool>) -> Self {
self.function_tools = function_tools;
self
}
pub fn with_tool_choice(mut self, tool_choice: LlmToolChoice) -> Self {
self.tool_choice = Some(tool_choice);
self
}
pub fn with_request_timeout_ms(mut self, request_timeout_ms: u64) -> Self {
self.request_timeout_ms = Some(request_timeout_ms);
self
}
/// Validate a request before handing it to any transport adapter.
///
/// Runtime adapters such as Codex app-server do not use `LlmClient::run`
/// and therefore must still enforce the exact same message/tool contract
/// before opening an upstream request.
pub fn validate_for_transport(&self) -> Result<(), LlmError> {
if let Some(input) = &self.responses_input {
if self.api_kind != LlmApiKind::OpenAiResponses {
return Err(LlmError::InvalidRequest(
"responses_input 仅支持 OpenAI Responses".to_string(),
));
}
if input.is_empty() || input.iter().any(|item| !item.is_object()) {
return Err(LlmError::InvalidRequest(
"responses_input 必须是非空 JSON object 数组".to_string(),
));
}
}
if self.responses_input.is_none() && self.messages.is_empty() {
return Err(LlmError::InvalidRequest(
"LLM messages 不能为空".to_string(),
));
}
for message in self
.messages
.iter()
.filter(|_| self.responses_input.is_none())
{
let has_text = !message.content.trim().is_empty()
|| message.content_parts.iter().any(|part| match part {
LlmMessageContentPart::InputText { text } => !text.trim().is_empty(),
LlmMessageContentPart::InputImage { .. } => false,
});
let has_image = message.content_parts.iter().any(|part| match part {
LlmMessageContentPart::InputImage { image_url } => !image_url.trim().is_empty(),
LlmMessageContentPart::InputText { .. } => false,
});
if !has_text && !has_image {
return Err(LlmError::InvalidRequest(
"LLM message content 不能为空".to_string(),
));
}
if message.content_parts.iter().any(|part| match part {
LlmMessageContentPart::InputText { text } => text.trim().is_empty(),
LlmMessageContentPart::InputImage { image_url } => image_url.trim().is_empty(),
}) {
return Err(LlmError::InvalidRequest(
"LLM message content part 不能为空".to_string(),
));
}
if matches!(
message.role,
LlmMessageRole::System | LlmMessageRole::Assistant
) && message
.content_parts
.iter()
.any(|part| matches!(part, LlmMessageContentPart::InputImage { .. }))
{
return Err(LlmError::InvalidRequest(
"system/assistant 消息不支持 input_image;图片必须放在 user 消息".to_string(),
));
}
}
if let Some(model) = &self.model
&& model.trim().is_empty()
{
return Err(LlmError::InvalidRequest(
"LLM request.model 不能为空字符串".to_string(),
));
}
if let Some(request_timeout_ms) = self.request_timeout_ms
&& request_timeout_ms == 0
{
return Err(LlmError::InvalidRequest(
"LLM request_timeout_ms 必须大于 0".to_string(),
));
}
if self.tool_choice.is_some() && self.function_tools.is_empty() {
return Err(LlmError::InvalidRequest(
"LLM tool_choice 必须与 function_tools 一起使用".to_string(),
));
}
for tool in &self.function_tools {
if tool.name.trim().is_empty() {
return Err(LlmError::InvalidRequest(
"LLM function tool name 不能为空".to_string(),
));
}
if !tool.parameters.is_object() {
return Err(LlmError::InvalidRequest(format!(
"LLM function tool {} parameters 必须是 JSON object",
tool.name
)));
}
}
if self.api_kind == LlmApiKind::Anthropic {
if self.enable_web_search {
return Err(LlmError::InvalidRequest(
"Anthropic api_kind 暂不支持 web_search".to_string(),
));
}
if self.messages.iter().any(|message| {
message
.content_parts
.iter()
.any(|part| matches!(part, LlmMessageContentPart::InputImage { .. }))
}) {
return Err(LlmError::InvalidRequest(
"Anthropic api_kind 暂不支持图片内容".to_string(),
));
}
if !self.messages.iter().any(|message| {
message.role != LlmMessageRole::System
&& message_text_for_anthropic(message)
.is_some_and(|text| !text.trim().is_empty())
}) {
return Err(LlmError::InvalidRequest(
"Anthropic api_kind 至少需要一条 user 或 assistant 消息".to_string(),
));
}
}
Ok(())
}
fn validate(&self) -> Result<(), LlmError> {
self.validate_for_transport()
}
fn resolved_model<'a>(&'a self, fallback_model: &'a str) -> &'a str {
self.model
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(fallback_model)
}
fn resolved_request_timeout_ms(&self, fallback_timeout_ms: u64) -> u64 {
self.request_timeout_ms
.filter(|value| *value > 0)
.unwrap_or(fallback_timeout_ms)
}
}
impl LlmApiKind {
fn as_str(self) -> &'static str {
match self {
Self::OpenAiChat => "openai_chat",
Self::OpenAiResponses => "openai_responses",
Self::Anthropic => "anthropic",
}
}
}
impl fmt::Display for LlmError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidConfig(message)
| Self::InvalidRequest(message)
| Self::Transport(message)
| Self::Deserialize(message) => write!(f, "{message}"),
Self::Timeout { attempts } => {
write!(f, "LLM 请求超时,累计尝试 {attempts} 次")
}
Self::Connectivity { attempts, message } => {
write!(f, "LLM 连接失败,累计尝试 {attempts} 次:{message}")
}
Self::Upstream {
status_code,
message,
} => write!(f, "LLM 上游返回 {status_code}:{message}"),
Self::StreamUnavailable => write!(f, "LLM 流式响应体不可用"),
Self::EmptyResponse => write!(f, "LLM 返回内容为空"),
}
}
}
impl Error for LlmError {}
fn format_error_chain(error: &dyn Error) -> String {
let mut messages = vec![error.to_string()];
let mut source = error.source();
while let Some(error) = source {
messages.push(error.to_string());
source = error.source();
}
messages.join(": ")
}
impl LlmError {
pub fn kind(&self) -> LlmErrorKind {
match self {
Self::InvalidConfig(_) => LlmErrorKind::InvalidConfig,
Self::InvalidRequest(_) => LlmErrorKind::InvalidRequest,
Self::Timeout { .. } => LlmErrorKind::Timeout,
Self::Connectivity { .. } => LlmErrorKind::Connectivity,
Self::Upstream { .. } => LlmErrorKind::Upstream,
Self::StreamUnavailable => LlmErrorKind::StreamUnavailable,
Self::EmptyResponse => LlmErrorKind::EmptyResponse,
Self::Transport(_) => LlmErrorKind::Transport,
Self::Deserialize(_) => LlmErrorKind::Deserialize,
}
}
}
impl LlmClient {
pub fn new(config: LlmConfig) -> Result<Self, LlmError> {
let mut builder = Client::builder();
if llm_base_url_is_loopback(config.base_url()) {
// 本地 mock/sidecar 必须直连。否则开发机 HTTP_PROXY 可能把已断开的
// loopback 连接改写成 HTTP 502,破坏 Provider transport 错误分类。
builder = builder.no_proxy();
}
let http_client = builder
.use_rustls_tls()
.http1_only()
.build()
.map_err(|error| {
LlmError::InvalidConfig(format!("构建 reqwest client 失败:{error}"))
})?;
Ok(Self {
config,
http_client,
})
}
pub fn new_without_redirects(config: LlmConfig) -> Result<Self, LlmError> {
let mut builder = Client::builder();
if llm_base_url_is_loopback(config.base_url()) {
builder = builder.no_proxy();
}
let http_client = builder
.use_rustls_tls()
.http1_only()
.redirect(Policy::none())
.build()
.map_err(|error| {
LlmError::InvalidConfig(format!("构建 reqwest client 失败:{error}"))
})?;
Ok(Self {
config,
http_client,
})
}
pub fn config(&self) -> &LlmConfig {
&self.config
}
pub fn with_max_retries(mut self, max_retries: u32) -> Self {
self.config.max_retries = max_retries;
self
}
pub async fn run(&self, request: LlmRunRequest) -> Result<LlmRunResponse, LlmError> {
request.validate()?;
let resolved_model = request.resolved_model(self.config.model()).to_string();
let LlmAttemptResponse { response, attempt } =
self.execute_request(&request, false).await?;
let raw_text = response.text().await.map_err(|error| {
let llm_error = map_stream_read_error(error, attempt);
log_llm_raw_failure(
&self.config,
&request,
false,
attempt,
"read_response_failed",
llm_error.to_string().as_str(),
);
llm_error
})?;
parse_text_response(
request.api_kind,
self.config.provider(),
&resolved_model,
request.capture_reasoning,
raw_text.as_str(),
)
.map_err(|error| {
log_llm_raw_failure(
&self.config,
&request,
false,
attempt,
"parse_response_failed",
raw_text.as_str(),
);
error
})
}
pub async fn run_single_message(
&self,
system_prompt: impl Into<String>,
user_prompt: impl Into<String>,
) -> Result<LlmRunResponse, LlmError> {
self.run(LlmRunRequest::single_turn(system_prompt, user_prompt))
.await
}
pub async fn stream_run<F>(
&self,
request: LlmRunRequest,
mut on_delta: F,
) -> Result<LlmRunResponse, LlmError>
where
F: FnMut(&LlmStreamDelta),
{
request.validate()?;
let resolved_model = request.resolved_model(self.config.model()).to_string();
let LlmAttemptResponse {
mut response,
attempt,
} = self.execute_request(&request, true).await?;
let response_id = response
.headers()
.get("x-request-id")
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let mut parser = OpenAiCompatibleSseParser::new(request.api_kind);
let mut accumulation = StreamAccumulation::default();
let mut undecoded_chunk_bytes = Vec::new();
let emit_finish_only_delta = request.api_kind == LlmApiKind::OpenAiChat;
let mut stream_terminated = false;
loop {
let next_chunk = match response.chunk().await {
Ok(chunk) => chunk,
Err(error) => {
let llm_error = map_stream_read_error(error, attempt);
if retain_completed_stream_after_tail_error(
&accumulation,
"read_stream_failed",
&llm_error,
) {
stream_terminated = true;
break;
}
log_llm_raw_failure(
&self.config,
&request,
true,
attempt,
"read_stream_failed",
parser.raw_text().as_str(),
);
return Err(llm_error);
}
};
let Some(chunk) = next_chunk else {
break;
};
undecoded_chunk_bytes.extend_from_slice(chunk.as_ref());
let (chunk_text, remaining_bytes) =
match decode_utf8_stream_chunk(undecoded_chunk_bytes.as_slice()) {
Ok(decoded) => decoded,
Err(error) => {
if retain_completed_stream_after_tail_error(
&accumulation,
"decode_stream_failed",
&error,
) {
stream_terminated = true;
break;
}
log_llm_raw_failure(
&self.config,
&request,
true,
attempt,
"decode_stream_failed",
parser.raw_text().as_str(),
);
return Err(error);
}
};
undecoded_chunk_bytes = remaining_bytes;
if chunk_text.is_empty() {
continue;
}
stream_terminated = consume_stream_parser_result(
parser.push_chunk(chunk_text.as_ref()),
&mut accumulation,
request.capture_reasoning,
emit_finish_only_delta,
&mut on_delta,
)
.map_err(|error| {
log_llm_raw_failure(
&self.config,
&request,
true,
attempt,
"parse_stream_failed",
parser.raw_text().as_str(),
);
error
})?;
if stream_terminated {
break;
}
}
if !stream_terminated && !undecoded_chunk_bytes.is_empty() {
let trailing_text = match std_str::from_utf8(undecoded_chunk_bytes.as_slice()) {
Ok(text) => text,
Err(error) => {
let llm_error =
LlmError::Deserialize(format!("解析 LLM 流式 UTF-8 响应失败:{error}"));
if retain_completed_stream_after_tail_error(
&accumulation,
"decode_stream_failed",
&llm_error,
) {
stream_terminated = true;
""
} else {
log_llm_raw_failure(
&self.config,
&request,
true,
attempt,
"decode_stream_failed",
parser.raw_text().as_str(),
);
return Err(llm_error);
}
}
};
if !stream_terminated && !trailing_text.is_empty() {
stream_terminated = consume_stream_parser_result(
parser.push_chunk(trailing_text),
&mut accumulation,
request.capture_reasoning,
emit_finish_only_delta,
&mut on_delta,
)
.map_err(|error| {
log_llm_raw_failure(
&self.config,
&request,
true,
attempt,
"parse_stream_failed",
parser.raw_text().as_str(),
);
error
})?;
}
}
if !stream_terminated {
consume_stream_parser_result(
parser.finish(),
&mut accumulation,
request.capture_reasoning,
emit_finish_only_delta,
&mut on_delta,
)
.map_err(|error| {
log_llm_raw_failure(
&self.config,
&request,
true,
attempt,
"parse_stream_failed",
parser.raw_text().as_str(),
);
error
})?;
}
// 截断门禁:出现过工具分片就必须已观察到协议收尾信号。字节流干净结束不构成
// 收尾证明,参数恰好是合法 JSON 同样不构成——顶层花括号闭合只说明这一个参数
// 对象字节完整,说明不了模型是否还要发下一个工具块,也说明不了上游随后会不会
// 报 max_tokens 或 error。这里用未固化的槽位判断,使"参数恰好闭合"的截断仍按
// 截断归因。
//
// 残留缺口:流在任何工具分片到达前就断掉时槽位为空,本门禁无从触发;堵它需要
// 同时收严纯文本路径,本轮不做,只在下方留 warn 攒线上口径。
if !accumulation.completion_observed && !accumulation.tool_calls.is_empty() {
log_llm_raw_failure(
&self.config,
&request,
true,
attempt,
"stream_tool_calls_truncated",
parser.raw_text().as_str(),
);
return Err(LlmError::Deserialize(format!(
"LLM 流式工具调用在协议完成信号前截断:slots={}, api_kind={:?}",
accumulation.tool_calls.len(),
request.api_kind
)));
}
if !accumulation.completion_observed && !accumulation.text.trim().is_empty() {
warn!(
"platform-llm stream ended without protocol completion signal: api_kind={:?}, text_chars={}",
request.api_kind,
accumulation.text.chars().count()
);
}
let tool_calls = accumulation.finish_tool_calls().map_err(|error| {
log_llm_raw_failure(
&self.config,
&request,
true,
attempt,
"parse_stream_tool_calls_failed",
parser.raw_text().as_str(),
);
error
})?;
reject_incomplete_tool_calls(
request.api_kind,
accumulation.finish_reason.as_deref(),
&tool_calls,
"流式",
)
.map_err(|error| {
log_llm_raw_failure(
&self.config,
&request,
true,
attempt,
"stream_tool_calls_incomplete_finish",
parser.raw_text().as_str(),
);
error
})?;
// 一致性断言:上游已表明本轮是工具调用,却一个都没累加出来,说明该网关的事件形状
// 不在已支持范围内。此时必须显式失败让调用方回退非流式,不能静默丢掉调用。
if tool_calls.is_empty()
&& accumulation
.finish_reason
.as_deref()
.is_some_and(|reason| reason == "tool_use" || reason == "tool_calls")
{
log_llm_raw_failure(
&self.config,
&request,
true,
attempt,
"stream_tool_calls_missing",
parser.raw_text().as_str(),
);
return Err(LlmError::StreamUnavailable);
}
let content = accumulation.text.trim().to_string();
if content.is_empty() && tool_calls.is_empty() {
log_llm_raw_failure(
&self.config,
&request,
true,
attempt,
"empty_stream_response",
parser.raw_text().as_str(),
);
return Err(LlmError::EmptyResponse);
}
Ok(LlmRunResponse {
provider: self.config.provider(),
model: resolved_model,
text: content,
reasoning: if request.capture_reasoning {
accumulation.reasoning
} else {
String::new()
},
finish_reason: accumulation.finish_reason,
response_id,
usage: accumulation.usage,
tool_calls,
responses_output: compact_responses_output(accumulation.responses_output),
})
}
pub async fn stream_single_message<F>(
&self,
system_prompt: impl Into<String>,
user_prompt: impl Into<String>,
on_delta: F,
) -> Result<LlmRunResponse, LlmError>
where
F: FnMut(&LlmStreamDelta),
{
self.stream_run(
LlmRunRequest::single_turn(system_prompt, user_prompt),
on_delta,
)
.await
}
async fn execute_request(
&self,
request: &LlmRunRequest,
stream: bool,
) -> Result<LlmAttemptResponse, LlmError> {
let request_body = build_request_body(request, &self.config, stream);
let model = request.resolved_model(self.config.model());
let url = match request.api_kind {
LlmApiKind::OpenAiChat => self.config.chat_completions_url(),
LlmApiKind::OpenAiResponses => self.config.responses_url(),
LlmApiKind::Anthropic => self.config.anthropic_messages_url(),
};
let max_attempts = self.config.max_retries().saturating_add(1);
for attempt in 1..=max_attempts {
debug!(
"platform-llm request started: provider={}, api_kind={}, stream={}, attempt={}, model={}",
self.config.provider().as_str(),
request.api_kind.as_str(),
stream,
attempt,
model
);
let mut request_builder = self.http_client.post(url.as_str());
if self.config.agc_client_marker {
request_builder = request_builder.header("x-genarrative-client", "agc");
}
request_builder = match request.api_kind {
LlmApiKind::OpenAiChat | LlmApiKind::OpenAiResponses => {
request_builder.bearer_auth(self.config.api_key())
}
LlmApiKind::Anthropic => request_builder
.header("x-api-key", self.config.api_key())
.header("anthropic-version", ANTHROPIC_VERSION),
};
let send_result = request_builder
.json(&request_body)
.timeout(Duration::from_millis(
request.resolved_request_timeout_ms(self.config.request_timeout_ms()),
))
.send()
.await;
match send_result {
Ok(response) if response.status().is_success() => {
debug!(
"platform-llm request succeeded: provider={}, api_kind={}, stream={}, attempt={}, status={}",
self.config.provider().as_str(),
request.api_kind.as_str(),
stream,
attempt,
response.status().as_u16()
);
return Ok(LlmAttemptResponse { response, attempt });
}
Ok(response) => {
let status = response.status();
let raw_text = response.text().await.unwrap_or_default();
let message = extract_api_error_message(&raw_text, "LLM 上游请求失败");
if should_retry_status(status) && attempt < max_attempts {
warn!(
"platform-llm request retrying after upstream status: provider={}, api_kind={}, attempt={}, status={}, message={}",
self.config.provider().as_str(),
request.api_kind.as_str(),
attempt,
status.as_u16(),
message
);
self.sleep_before_retry(attempt).await;
continue;
}
log_llm_raw_failure(
&self.config,
request,
stream,
attempt,
"upstream_status_failed",
raw_text.as_str(),
);
return Err(LlmError::Upstream {
status_code: status.as_u16(),
message,
});
}
Err(error) if error.is_timeout() => {
if attempt < max_attempts {
warn!(
"platform-llm request retrying after timeout: provider={}, api_kind={}, attempt={}",
self.config.provider().as_str(),
request.api_kind.as_str(),
attempt
);
self.sleep_before_retry(attempt).await;
continue;
}
let error = LlmError::Timeout { attempts: attempt };
log_llm_raw_failure(
&self.config,
request,
stream,
attempt,
"request_timeout",
error.to_string().as_str(),
);
return Err(error);
}
Err(error) if error.is_connect() => {
let message = format_error_chain(&error);
if attempt < max_attempts {
warn!(
"platform-llm request retrying after connectivity failure: provider={}, api_kind={}, attempt={}, error={}",
self.config.provider().as_str(),
request.api_kind.as_str(),
attempt,
message
);
self.sleep_before_retry(attempt).await;
continue;
}
let error = LlmError::Connectivity {
attempts: attempt,
message,
};
log_llm_raw_failure(
&self.config,
request,
stream,
attempt,
"request_connectivity_failed",
error.to_string().as_str(),
);
return Err(error);
}
Err(error) => {
let error = LlmError::Transport(format_error_chain(&error));
log_llm_raw_failure(
&self.config,
request,
stream,
attempt,
"request_transport_failed",
error.to_string().as_str(),
);
return Err(error);
}
}
}
Err(LlmError::Transport(
"LLM 请求在重试循环后仍未返回结果".to_string(),
))
}
async fn sleep_before_retry(&self, attempt: u32) {
let backoff_ms = self
.config
.retry_backoff_ms()
.saturating_mul(u64::from(attempt));
if backoff_ms > 0 {
sleep(Duration::from_millis(backoff_ms)).await;
}
}
}
fn llm_base_url_is_loopback(base_url: &str) -> bool {
let Ok(url) = reqwest::Url::parse(base_url) else {
return false;
};
match url.host_str() {
Some("localhost") => true,
Some(host) => host.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback()),
None => false,
}
}
impl OpenAiCompatibleSseParser {
fn new(api_kind: LlmApiKind) -> Self {
Self {
buffer: String::new(),
raw_text: String::new(),
api_kind,
terminated: false,
}
}
fn push_chunk(&mut self, chunk: &str) -> Result<Vec<ParsedStreamEvent>, SseEventDrainError> {
self.raw_text.push_str(chunk);
if self.terminated {
return Ok(Vec::new());
}
self.buffer.push_str(chunk);
self.buffer = self.buffer.replace("\r\n", "\n");
self.drain_complete_events()
}
fn raw_text(&self) -> String {
self.raw_text.clone()
}
fn finish(&mut self) -> Result<Vec<ParsedStreamEvent>, SseEventDrainError> {
if self.terminated || self.buffer.trim().is_empty() {
return Ok(Vec::new());
}
self.buffer.push_str("\n\n");
self.drain_complete_events()
}
fn drain_complete_events(&mut self) -> Result<Vec<ParsedStreamEvent>, SseEventDrainError> {
let mut events = Vec::new();
while let Some(boundary) = self.buffer.find("\n\n") {
let block = self.buffer[..boundary].to_string();
self.buffer = self.buffer[(boundary + 2)..].to_string();
let parsed_event = match parse_sse_event_block(self.api_kind, block.as_str()) {
Ok(event) => event,
Err(error) => {
return Err(SseEventDrainError {
parsed_events: events,
error,
});
}
};
if let Some(event) = parsed_event {
let is_terminal = event.is_terminal;
events.push(event);
if is_terminal {
self.terminated = true;
self.buffer.clear();
break;
}
}
}
Ok(events)
}
}
fn consume_stream_parser_result<F>(
result: Result<Vec<ParsedStreamEvent>, SseEventDrainError>,
accumulation: &mut StreamAccumulation,
capture_reasoning: bool,
emit_finish_only_delta: bool,
on_delta: &mut F,
) -> Result<bool, LlmError>
where
F: FnMut(&LlmStreamDelta),
{
let (events, tail_error) = match result {
Ok(events) => (events, None),
Err(error) => (error.parsed_events, Some(error.error)),
};
// 槽位身份冲突比尾部错误更根本:累加出的工具调用已不可信,不能再走保留路径。
let stream_terminated = consume_stream_events(
events,
accumulation,
capture_reasoning,
emit_finish_only_delta,
on_delta,
)?;
if stream_terminated {
return Ok(true);
}
if let Some(error) = tail_error {
if retain_completed_stream_after_tail_error(accumulation, "parse_stream_failed", &error) {
return Ok(true);
}
return Err(error);
}
Ok(false)
}
fn retain_completed_stream_after_tail_error(
accumulation: &StreamAccumulation,
stage: &str,
error: &LlmError,
) -> bool {
let is_tolerable_tail_error = matches!(
error.kind(),
LlmErrorKind::Timeout
| LlmErrorKind::Connectivity
| LlmErrorKind::Transport
| LlmErrorKind::Deserialize
);
// 工具调用尚未拼完整时不能保留:半截参数比直接失败更危险。
let tool_calls_complete = accumulation.finish_tool_calls().is_ok();
// 判断“有没有值得保留的东西”不能只看正文:纯工具调用响应的正文本来就是空的
// (Anthropic 工具流恒定如此),只看正文会让每一次这样的响应都在尾部错误时被丢掉,
// 白白多跑一轮 Provider 往返。保留的安全性由下面三项保证——协议收尾信号已到、
// finish_reason 已到、工具参数完整,与正常路径的判据完全一致。
let has_retainable_payload =
!accumulation.text.trim().is_empty() || !accumulation.tool_calls.is_empty();
let retain_response = has_retainable_payload
&& accumulation.completion_observed
&& accumulation.finish_reason.is_some()
&& tool_calls_complete
&& is_tolerable_tail_error;
if retain_response {
warn!(
"platform-llm retained completed stream after trailing failure: stage={stage}, error_kind={:?}, error={error}",
error.kind()
);
}
retain_response
}
fn consume_stream_events<F>(
events: Vec<ParsedStreamEvent>,
accumulation: &mut StreamAccumulation,
capture_reasoning: bool,
emit_finish_only_delta: bool,
on_delta: &mut F,
) -> Result<bool, LlmError>
where
F: FnMut(&LlmStreamDelta),
{
for event in events {
let ParsedStreamEvent {
delta_text,
reasoning_delta,
reasoning_summary_item_id,
reasoning_summary_index,
reasoning_snapshot,
responses_output,
text_snapshot,
finish_reason: event_finish_reason,
usage: event_usage,
is_terminal,
is_completion,
tool_fragments,
output_items,
} = event;
if let Some(output) = responses_output {
accumulation.responses_output = output;
}
for (slot, item) in output_items {
upsert_responses_output_item(&mut accumulation.responses_output, slot, item)?;
}
if is_completion {
accumulation.completion_observed = true;
}
let mut reasoning_delta = if capture_reasoning {
reasoning_delta.unwrap_or_default()
} else {
String::new()
};
let mut reasoning_snapshot_corrected = false;
if capture_reasoning {
if let Some(summary_index) = reasoning_summary_index {
let item_id = reasoning_summary_item_id
.unwrap_or_else(|| "__default_reasoning_item__".to_string());
if !accumulation
.reasoning_summary_item_order
.iter()
.any(|known| known == &item_id)
{
accumulation
.reasoning_summary_item_order
.push(item_id.clone());
}
let key = (item_id, summary_index);
if !reasoning_delta.is_empty() {
accumulation
.reasoning_summary_parts
.entry(key.clone())
.or_default()
.push_str(reasoning_delta.as_str());
accumulation.reasoning = accumulation
.reasoning_summary_item_order
.iter()
.flat_map(|item_id| {
accumulation
.reasoning_summary_parts
.iter()
.filter(move |((known_id, _), _)| known_id == item_id)
.map(|(_, text)| text.as_str())
})
.collect::<String>();
}
if let Some(snapshot) = reasoning_snapshot.filter(|text| !text.trim().is_empty()) {
let current_part = accumulation
.reasoning_summary_parts
.get(&key)
.cloned()
.unwrap_or_default();
if snapshot != current_part {
reasoning_delta = if current_part.is_empty() {
snapshot.clone()
} else {
snapshot
.strip_prefix(current_part.as_str())
.unwrap_or_default()
.to_string()
};
accumulation.reasoning_summary_parts.insert(key, snapshot);
accumulation.reasoning = accumulation
.reasoning_summary_item_order
.iter()
.flat_map(|item_id| {
accumulation
.reasoning_summary_parts
.iter()
.filter(move |((known_id, _), _)| known_id == item_id)
.map(|(_, text)| text.as_str())
})
.collect::<String>();
reasoning_snapshot_corrected = true;
} else {
reasoning_delta.clear();
}
}
} else {
let mut reasoning_snapshot_applied = false;
if let Some(snapshot) = reasoning_snapshot.filter(|text| !text.trim().is_empty()) {
if snapshot != accumulation.reasoning {
reasoning_delta = if accumulation.reasoning.is_empty() {
snapshot.clone()
} else {
snapshot
.strip_prefix(accumulation.reasoning.as_str())
.unwrap_or_default()
.to_string()
};
accumulation.reasoning = snapshot;
reasoning_snapshot_applied = true;
}
}
if !reasoning_snapshot_applied && !reasoning_delta.is_empty() {
accumulation.reasoning.push_str(reasoning_delta.as_str());
}
}
}
if let Some(event_usage) = event_usage {
accumulation.usage = Some(match accumulation.usage.take() {
Some(previous) => {
let prompt_tokens = previous.prompt_tokens.max(event_usage.prompt_tokens);
let completion_tokens = previous
.completion_tokens
.max(event_usage.completion_tokens);
LlmTokenUsage {
prompt_tokens,
completion_tokens,
total_tokens: previous
.total_tokens
.max(event_usage.total_tokens)
.max(prompt_tokens.saturating_add(completion_tokens)),
}
}
None => event_usage,
});
}
// 工具调用只累加,不进 on_delta:调用方的流式通道仍然只承载文本。
let ids_repeated_in_event = tool_fragment_ids_repeated_in_event(&tool_fragments);
for fragment in tool_fragments {
if let Some(arguments) = fragment.arguments_complete.as_deref() {
patch_responses_output_arguments(
&mut accumulation.responses_output,
fragment.slot,
arguments,
);
}
accumulation.push_tool_fragment(fragment, &ids_repeated_in_event)?;
}
let mut delta_text = delta_text.unwrap_or_default();
let mut has_delta = !delta_text.is_empty();
if has_delta {
accumulation.text.push_str(delta_text.as_str());
}
// 终态快照是上游给出的权威完整正文,覆盖语义与工具参数的 arguments_complete 一致。
// 但只改累加值不够:调用方最后收到的累计正文会停在增量拼接结果上,而
// LlmRunResponse.text 已经是完整值,两者在同一次调用里分叉。按 accumulated_text
// 取值的消费者(单 Agent 流式在 stream_run 返回 Err 时的抢救路径)会因此拿到半截
// 回复——「incomplete + 有工具调用」正好会走到那里。所以不一致时必须补一次回调。
//
// delta_text 尽量给成「新增的那一截」,让按 delta_text 累加的消费者也能自愈:
// - 累加去空白后为空:整个快照就是增量。这一支不能并进 strip_prefix——纯空白累加
// 值匹配不上前缀会退化成空 delta,反而让那类消费者丢内容。
// - 快照是增量的延长(绝大多数情况):给后缀。
// - 两者非前缀关系(增量与终态载荷不同源):无法表达成增量,只能给空串靠
// accumulated_text 纠正,按 delta_text 累加的那份副本修不了,是已知残留。
//
// 相等时不补回调,避免正文在调用方侧翻倍;这也意味着本改动不给 Responses 打开
// emit_finish_only_delta——那会让每一条流都多一次终态回调,是另一个决定。
let mut snapshot_corrected = false;
if let Some(snapshot) = text_snapshot.filter(|text| !text.trim().is_empty()) {
if snapshot != accumulation.text {
delta_text = if accumulation.text.trim().is_empty() {
snapshot.clone()
} else {
snapshot
.strip_prefix(accumulation.text.as_str())
.unwrap_or_default()
.to_string()
};
accumulation.text = snapshot;
has_delta = !delta_text.is_empty();
snapshot_corrected = true;
}
}
if let Some(event_finish_reason) = event_finish_reason {
accumulation.finish_reason = Some(event_finish_reason.clone());
if has_delta
|| !reasoning_delta.is_empty()
|| reasoning_snapshot_corrected
|| emit_finish_only_delta
|| snapshot_corrected
{
let update = LlmStreamDelta {
accumulated_text: accumulation.text.clone(),
delta_text,
accumulated_reasoning: accumulation.reasoning.clone(),
reasoning_delta,
finish_reason: Some(event_finish_reason),
};
on_delta(&update);
}
} else if has_delta || !reasoning_delta.is_empty() || reasoning_snapshot_corrected {
let update = LlmStreamDelta {
accumulated_text: accumulation.text.clone(),
delta_text,
accumulated_reasoning: accumulation.reasoning.clone(),
reasoning_delta,
finish_reason: None,
};
on_delta(&update);
}
if is_terminal {
return Ok(true);
}
}
Ok(false)
}
fn normalize_non_empty(value: String, error_message: &str) -> Result<String, LlmError> {
let trimmed = value.trim().to_string();
if trimmed.is_empty() {
return Err(LlmError::InvalidConfig(error_message.to_string()));
}
Ok(trimmed)
}
fn build_request_body(request: &LlmRunRequest, config: &LlmConfig, stream: bool) -> LlmRequestBody {
let fallback_model = config.model();
let official_fallback = config.official_fallback().then_some(true);
match request.api_kind {
LlmApiKind::OpenAiChat => {
let (max_completion_tokens, max_tokens) = match config.openai_chat_token_budget_field()
{
OpenAiChatTokenBudgetField::MaxCompletionTokens => {
(request.max_output_tokens, None)
}
OpenAiChatTokenBudgetField::LegacyMaxTokens => (None, request.max_output_tokens),
};
LlmRequestBody::ChatCompletions(ChatCompletionsRequestBody {
model: request.resolved_model(fallback_model).to_string(),
messages: map_chat_completions_input_messages(request.messages.as_slice()),
stream,
official_fallback,
max_completion_tokens,
max_tokens,
reasoning_effort: request
.response_reasoning_effort
.map(LlmResponseReasoningEffort::as_str),
web_search_options: request
.enable_web_search
.then_some(ChatCompletionsWebSearchOptions {}),
tools: (!request.function_tools.is_empty()).then(|| {
request
.function_tools
.iter()
.cloned()
.map(|function| ChatCompletionsFunctionTool {
tool_type: "function",
function,
})
.collect()
}),
tool_choice: request.tool_choice.map(LlmToolChoice::as_str),
})
}
LlmApiKind::OpenAiResponses => LlmRequestBody::Responses(ResponsesRequestBody {
model: request.resolved_model(fallback_model).to_string(),
stream,
input: match &request.responses_input {
Some(input) => ResponsesInput::Native(input.clone()),
None => ResponsesInput::Messages(map_responses_input_messages(&request.messages)),
},
store: request.responses_input.as_ref().map(|_| false),
include: request
.responses_input
.as_ref()
.map(|_| vec!["reasoning.encrypted_content"]),
official_fallback,
max_output_tokens: request.max_output_tokens,
tools: build_responses_tools(request),
tool_choice: request.tool_choice.map(LlmToolChoice::as_str),
reasoning: request
.response_reasoning_effort
.map(|effort| ResponsesReasoningOptions {
effort: effort.as_str(),
}),
text: request
.response_text_verbosity
.map(|verbosity| ResponsesTextOptions {
verbosity: verbosity.as_str(),
}),
}),
LlmApiKind::Anthropic => LlmRequestBody::Anthropic(build_anthropic_messages_request_body(
request,
fallback_model,
stream,
config.anthropic_strict_tool_support(),
)),
}
}
fn build_responses_tools(request: &LlmRunRequest) -> Option<Vec<ResponsesTool>> {
let mut tools = Vec::new();
if request.enable_web_search {
tools.push(ResponsesTool::WebSearch(ResponsesWebSearchTool {
tool_type: "web_search",
max_keyword: 3,
}));
}
tools.extend(request.function_tools.iter().cloned().map(|function| {
ResponsesTool::Function(ResponsesFunctionTool {
tool_type: "function",
function,
})
}));
(!tools.is_empty()).then_some(tools)
}
fn build_anthropic_messages_request_body(
request: &LlmRunRequest,
fallback_model: &str,
stream: bool,
strict_tool_support: bool,
) -> AnthropicMessagesRequestBody {
// capability 绑定 LlmConfig 的 endpoint/model 组合;请求级 model override 没有经过
// 同一轮能力确认,即使协议仍是 Anthropic 也必须 fail closed。
let strict_tool_support =
strict_tool_support && request.resolved_model(fallback_model) == fallback_model;
let system = request
.messages
.iter()
.filter(|message| message.role == LlmMessageRole::System)
.filter_map(message_text_for_anthropic)
.collect::<Vec<_>>()
.join("\n\n");
let messages = request
.messages
.iter()
.filter(|message| message.role != LlmMessageRole::System)
.filter_map(|message| {
let content = message_text_for_anthropic(message)?;
Some(AnthropicInputMessage {
role: map_anthropic_message_role(message.role),
content,
})
})
.collect();
let tools = (!request.function_tools.is_empty()).then(|| {
let strict_schemas =
anthropic_strict_transport_schemas(&request.function_tools, strict_tool_support);
let last_index = request.function_tools.len().saturating_sub(1);
request
.function_tools
.iter()
.enumerate()
.map(|(index, function)| {
let strict_schema = strict_schemas[index].as_ref();
AnthropicTool {
name: function.name.clone(),
description: function.description.clone(),
input_schema: strict_schema
.cloned()
.unwrap_or_else(|| function.parameters.clone()),
strict: strict_schema.is_some().then_some(true),
cache_control: (index == last_index).then_some(AnthropicCacheControl {
cache_type: "ephemeral",
}),
}
})
.collect()
});
AnthropicMessagesRequestBody {
model: request.resolved_model(fallback_model).to_string(),
max_tokens: request
.max_output_tokens
.unwrap_or(DEFAULT_ANTHROPIC_MAX_OUTPUT_TOKENS),
stream,
system: (!system.is_empty()).then_some(system),
messages,
tools,
tool_choice: request.tool_choice.map(|choice| AnthropicToolChoice {
choice_type: choice.as_anthropic_type(),
}),
}
}
#[derive(Default)]
struct AnthropicStrictSchemaComplexity {
optional_parameters: usize,
union_parameters: usize,
}
// Anthropic 官方 SDK 同样会先把调用方 schema 转成服务端可编译的传输 schema,再用
// 原 schema 做本地校验。这里绝不修改 LlmFunctionTool.parameters,只剥离 strict grammar
// 不支持的约束。未显式列入支持或可安全剥离集合的 keyword 一律拒绝 strict,避免新 keyword
// 穿透有限黑名单后把整轮请求变成 400。
fn anthropic_strict_transport_schema(schema: &serde_json::Value) -> Option<serde_json::Value> {
const BASIC_TYPES: &[&str] = &[
"object", "array", "string", "integer", "number", "boolean", "null",
];
const SUPPORTED_FORMATS: &[&str] = &[
"date-time",
"time",
"date",
"duration",
"email",
"hostname",
"uri",
"ipv4",
"ipv6",
"uuid",
];
fn transform_schema_map(
object: &serde_json::Map<String, serde_json::Value>,
) -> Option<serde_json::Map<String, serde_json::Value>> {
let mut transformed = serde_json::Map::new();
for (keyword, value) in object {
match keyword.as_str() {
"type" => {
let supported = match value {
serde_json::Value::String(value) => {
BASIC_TYPES.contains(&value.as_str())
}
serde_json::Value::Array(values) => {
!values.is_empty()
&& values.iter().all(|value| {
value.as_str().is_some_and(|value| BASIC_TYPES.contains(&value))
})
&& values
.iter()
.filter_map(serde_json::Value::as_str)
.collect::<std::collections::BTreeSet<_>>()
.len()
== values.len()
}
_ => false,
};
if !supported {
return None;
}
transformed.insert(keyword.clone(), value.clone());
}
"properties" | "$defs" | "definitions" => {
let children = value.as_object()?;
let mut transformed_children = serde_json::Map::new();
for (name, child) in children {
transformed_children
.insert(name.clone(), anthropic_strict_transport_schema(child)?);
}
transformed.insert(
keyword.clone(),
serde_json::Value::Object(transformed_children),
);
}
"items" => {
transformed.insert(
keyword.clone(),
anthropic_strict_transport_schema(value)?,
);
}
"anyOf" | "allOf" => {
let children = value.as_array().filter(|children| !children.is_empty())?;
let transformed_children = children
.iter()
.map(anthropic_strict_transport_schema)
.collect::<Option<Vec<_>>>()?;
// Anthropic 明确不支持 allOf 中的 $ref;该组合不能靠删除 $ref
// 降级,否则会丢失整个结构定义。
if keyword == "allOf"
&& transformed_children.iter().any(anthropic_schema_uses_ref)
{
return None;
}
transformed.insert(
keyword.clone(),
serde_json::Value::Array(transformed_children),
);
}
"$ref" => {
if !value
.as_str()
.is_some_and(|reference| reference.starts_with("#/"))
{
return None;
}
transformed.insert(keyword.clone(), value.clone());
}
"required" => {
let values = value.as_array()?;
let names = values
.iter()
.map(serde_json::Value::as_str)
.collect::<Option<Vec<_>>>()?;
if names.iter().collect::<std::collections::BTreeSet<_>>().len()
!= names.len()
{
return None;
}
transformed.insert(keyword.clone(), value.clone());
}
"additionalProperties" => {
if value != &serde_json::Value::Bool(false) {
return None;
}
transformed.insert(keyword.clone(), value.clone());
}
"enum" => {
let values = value.as_array().filter(|values| !values.is_empty())?;
if values.len() > 100
|| values
.iter()
.any(|value| value.is_array() || value.is_object())
{
return None;
}
transformed.insert(keyword.clone(), value.clone());
}
"const" => {
if value.is_array() || value.is_object() {
return None;
}
transformed.insert(keyword.clone(), value.clone());
}
"title" | "description" => {
value.as_str()?;
transformed.insert(keyword.clone(), value.clone());
}
"default" => {
// default 是普通 JSON 数据,不递归解释其中可能出现的 `$ref`。
transformed.insert(keyword.clone(), value.clone());
}
"format" => {
if value
.as_str()
.is_some_and(|format| SUPPORTED_FORMATS.contains(&format))
{
transformed.insert(keyword.clone(), value.clone());
}
}
"minItems" => {
if value.as_u64().is_some_and(|minimum| minimum <= 1) {
transformed.insert(keyword.clone(), value.clone());
}
}
// 这些是 Anthropic strict grammar 不支持的约束,或(如 pattern)带有
// 本适配器未完整校验的编译器子集。传输时剥离;调用方持有的原 schema
// 不变,仍可用于 ToolHost 入参校验。
"minimum"
| "maximum"
| "exclusiveMinimum"
| "exclusiveMaximum"
| "multipleOf"
| "minLength"
| "maxLength"
| "maxItems"
| "uniqueItems"
| "contains"
| "minContains"
| "maxContains"
| "minProperties"
| "maxProperties"
| "pattern"
// Anthropic 未列这些 annotation 为传输 schema 支持项;安全删除不会
// 改变结构,且不会递归误读其中的普通 JSON 数据。
| "examples"
| "$comment"
| "deprecated"
| "readOnly"
| "writeOnly" => {}
// `$id`、`$anchor`、dependentRequired 等所有未声明 keyword 都在这里
// fail closed,不能靠 apiKind 或“看起来像 JSON Schema”发送 strict。
_ => return None,
}
}
Some(transformed)
}
Some(serde_json::Value::Object(transform_schema_map(
schema.as_object()?,
)?))
}
fn anthropic_schema_uses_ref(schema: &serde_json::Value) -> bool {
let Some(object) = schema.as_object() else {
return false;
};
if object.contains_key("$ref") {
return true;
}
["items"]
.into_iter()
.filter_map(|keyword| object.get(keyword))
.any(anthropic_schema_uses_ref)
|| ["properties", "$defs", "definitions"]
.into_iter()
.filter_map(|keyword| object.get(keyword).and_then(serde_json::Value::as_object))
.flat_map(|children| children.values())
.any(anthropic_schema_uses_ref)
|| ["anyOf", "allOf"]
.into_iter()
.filter_map(|keyword| object.get(keyword).and_then(serde_json::Value::as_array))
.flat_map(|children| children.iter())
.any(anthropic_schema_uses_ref)
}
fn collect_anthropic_strict_schema_complexity(
schema: &serde_json::Value,
complexity: &mut AnthropicStrictSchemaComplexity,
) -> bool {
let Some(object) = schema.as_object() else {
return false;
};
if object
.get("type")
.and_then(serde_json::Value::as_array)
.is_some_and(|types| types.len() > 1)
|| object.contains_key("anyOf")
{
complexity.union_parameters = complexity.union_parameters.saturating_add(1);
}
let is_object_schema = object.get("type").and_then(serde_json::Value::as_str) == Some("object")
|| object
.get("type")
.and_then(serde_json::Value::as_array)
.is_some_and(|types| types.iter().any(|value| value.as_str() == Some("object")))
|| object.contains_key("properties");
if is_object_schema
&& object.get("additionalProperties") != Some(&serde_json::Value::Bool(false))
{
return false;
}
if let Some(properties) = object
.get("properties")
.and_then(serde_json::Value::as_object)
{
let required = object
.get("required")
.and_then(serde_json::Value::as_array)
.map(|values| {
values
.iter()
.filter_map(serde_json::Value::as_str)
.collect::<std::collections::BTreeSet<_>>()
})
.unwrap_or_default();
if required.len()
!= object
.get("required")
.and_then(serde_json::Value::as_array)
.map(Vec::len)
.unwrap_or_default()
|| required.iter().any(|name| !properties.contains_key(*name))
{
return false;
}
complexity.optional_parameters = complexity.optional_parameters.saturating_add(
properties
.keys()
.filter(|name| !required.contains(name.as_str()))
.count(),
);
}
["items"]
.into_iter()
.filter_map(|keyword| object.get(keyword))
.all(|child| collect_anthropic_strict_schema_complexity(child, complexity))
&& ["properties", "$defs", "definitions"]
.into_iter()
.filter_map(|keyword| object.get(keyword).and_then(serde_json::Value::as_object))
.flat_map(|children| children.values())
.all(|child| collect_anthropic_strict_schema_complexity(child, complexity))
&& ["anyOf", "allOf"]
.into_iter()
.filter_map(|keyword| object.get(keyword).and_then(serde_json::Value::as_array))
.flat_map(|children| children.iter())
.all(|child| collect_anthropic_strict_schema_complexity(child, complexity))
}
fn anthropic_strict_schema_refs_are_supported(schema: &serde_json::Value) -> bool {
fn visit(
value: &serde_json::Value,
root: &serde_json::Value,
active_refs: &mut std::collections::BTreeSet<String>,
) -> bool {
match value {
serde_json::Value::Object(object) => {
if let Some(reference) = object.get("$ref").and_then(serde_json::Value::as_str) {
// Anthropic strict 不支持递归 schema;为避免把命名 anchor 或外部
// resource 误判为可编译,只接受能在当前 document 内解析的 Pointer。
if !reference.starts_with("#/") {
return false;
}
let Some(target) = root.pointer(reference.trim_start_matches('#')) else {
return false;
};
if !active_refs.insert(reference.to_string()) {
return false;
}
let target_is_supported = visit(target, root, active_refs);
active_refs.remove(reference);
if !target_is_supported {
return false;
}
}
["items"]
.into_iter()
.filter_map(|keyword| object.get(keyword))
.all(|child| visit(child, root, active_refs))
&& ["properties", "$defs", "definitions"]
.into_iter()
.filter_map(|keyword| {
object.get(keyword).and_then(serde_json::Value::as_object)
})
.flat_map(|children| children.values())
.all(|child| visit(child, root, active_refs))
&& ["anyOf", "allOf"]
.into_iter()
.filter_map(|keyword| {
object.get(keyword).and_then(serde_json::Value::as_array)
})
.flat_map(|children| children.iter())
.all(|child| visit(child, root, active_refs))
}
_ => false,
}
}
visit(schema, schema, &mut std::collections::BTreeSet::new())
}
fn anthropic_strict_transport_schemas(
functions: &[LlmFunctionTool],
strict_tool_support: bool,
) -> Vec<Option<serde_json::Value>> {
const MAX_STRICT_TOOLS: usize = 20;
const MAX_OPTIONAL_PARAMETERS: usize = 24;
const MAX_UNION_PARAMETERS: usize = 16;
let mut strict_count = 0usize;
let mut optional_parameters = 0usize;
let mut union_parameters = 0usize;
functions
.iter()
.map(|function| {
if !strict_tool_support || !function.strict || strict_count >= MAX_STRICT_TOOLS {
return None;
}
let transport_schema = anthropic_strict_transport_schema(&function.parameters)?;
let mut complexity = AnthropicStrictSchemaComplexity::default();
if !anthropic_strict_schema_refs_are_supported(&transport_schema)
|| !collect_anthropic_strict_schema_complexity(&transport_schema, &mut complexity)
|| optional_parameters.saturating_add(complexity.optional_parameters)
> MAX_OPTIONAL_PARAMETERS
|| union_parameters.saturating_add(complexity.union_parameters)
> MAX_UNION_PARAMETERS
{
return None;
}
strict_count = strict_count.saturating_add(1);
optional_parameters =
optional_parameters.saturating_add(complexity.optional_parameters);
union_parameters = union_parameters.saturating_add(complexity.union_parameters);
Some(transport_schema)
})
.collect()
}
fn map_chat_completions_input_messages(
messages: &[LlmMessage],
) -> Vec<ChatCompletionsInputMessage> {
messages
.iter()
.map(|message| ChatCompletionsInputMessage {
role: map_llm_message_role(message.role),
content: map_chat_completions_content(message),
})
.collect()
}
fn map_chat_completions_content(message: &LlmMessage) -> ChatCompletionsInputContent {
if message.content_parts.is_empty() {
return ChatCompletionsInputContent::Text(message.content.clone());
}
ChatCompletionsInputContent::Parts(
message
.content_parts
.iter()
.map(|part| match part {
LlmMessageContentPart::InputText { text } => {
ChatCompletionsInputContentPart::Text { text: text.clone() }
}
LlmMessageContentPart::InputImage { image_url } => {
ChatCompletionsInputContentPart::ImageUrl {
image_url: ChatCompletionsImageUrl {
url: image_url.clone(),
},
}
}
})
.collect(),
)
}
fn map_responses_input_messages(messages: &[LlmMessage]) -> Vec<ResponsesInputMessage> {
messages
.iter()
.map(|message| ResponsesInputMessage {
role: map_llm_message_role(message.role),
content: map_responses_content_parts(message),
})
.collect()
}
fn map_llm_message_role(role: LlmMessageRole) -> &'static str {
match role {
LlmMessageRole::System => "system",
LlmMessageRole::User => "user",
LlmMessageRole::Assistant => "assistant",
}
}
fn map_anthropic_message_role(role: LlmMessageRole) -> &'static str {
match role {
LlmMessageRole::System | LlmMessageRole::User => "user",
LlmMessageRole::Assistant => "assistant",
}
}
fn message_text_for_anthropic(message: &LlmMessage) -> Option<String> {
if message.content_parts.is_empty() {
return (!message.content.trim().is_empty()).then(|| message.content.clone());
}
let text = message
.content_parts
.iter()
.filter_map(|part| match part {
LlmMessageContentPart::InputText { text } => Some(text.as_str()),
LlmMessageContentPart::InputImage { .. } => None,
})
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n");
(!text.is_empty()).then_some(text)
}
fn map_responses_content_parts(message: &LlmMessage) -> Vec<ResponsesInputContentPart> {
if message.content_parts.is_empty() {
return vec![map_responses_text_content_part(
message.role,
message.content.clone(),
)];
}
message
.content_parts
.iter()
.map(|part| match part {
LlmMessageContentPart::InputText { text } => {
map_responses_text_content_part(message.role, text.clone())
}
LlmMessageContentPart::InputImage { image_url } => {
ResponsesInputContentPart::InputImage {
image_url: image_url.clone(),
}
}
})
.collect()
}
fn map_responses_text_content_part(
role: LlmMessageRole,
text: String,
) -> ResponsesInputContentPart {
match role {
LlmMessageRole::System | LlmMessageRole::User => {
ResponsesInputContentPart::InputText { text }
}
LlmMessageRole::Assistant => ResponsesInputContentPart::OutputText { text },
}
}
fn log_llm_raw_failure(
config: &LlmConfig,
request: &LlmRunRequest,
stream: bool,
attempt: u32,
failure_stage: &str,
raw_output: &str,
) {
if let Err(error) =
write_llm_raw_failure(config, request, stream, attempt, failure_stage, raw_output)
{
warn!(
"LLM 失败原文日志落盘失败,主错误流程继续执行: failure_stage={}, error={}",
failure_stage, error
);
}
}
fn write_llm_raw_failure(
config: &LlmConfig,
request: &LlmRunRequest,
stream: bool,
attempt: u32,
failure_stage: &str,
raw_output: &str,
) -> Result<(), String> {
let log_dir = config.raw_log_dir();
fs::create_dir_all(&log_dir).map_err(|error| format!("创建日志目录失败:{error}"))?;
let prefix = build_llm_raw_log_prefix(failure_stage);
let model = request.resolved_model(config.model());
let input_text = build_llm_raw_failure_input_log(config, request, stream, attempt, model)?;
fs::write(log_dir.join(format!("{prefix}.input.json")), input_text)
.map_err(|error| format!("写入模型输入日志失败:{error}"))?;
fs::write(
log_dir.join(format!("{prefix}.output.txt")),
redact_inline_image_data_urls(raw_output),
)
.map_err(|error| format!("写入模型输出日志失败:{error}"))?;
Ok(())
}
fn build_llm_raw_failure_input_log(
config: &LlmConfig,
request: &LlmRunRequest,
stream: bool,
attempt: u32,
model: &str,
) -> Result<String, String> {
let has_image = request.messages.iter().any(|message| {
message
.content_parts
.iter()
.any(|part| matches!(part, LlmMessageContentPart::InputImage { .. }))
});
if has_image {
return serde_json::to_string_pretty(&serde_json::json!({
"provider": config.provider().as_str(),
"api_kind": request.api_kind.as_str(),
"model": model,
"stream": stream,
"attempt": attempt,
"max_output_tokens": request.max_output_tokens,
"messages_omitted": "multimodal-sensitive-input",
}))
.map_err(|error| format!("序列化模型输入日志失败:{error}"));
}
let input_log = LlmRawFailureInputLog {
provider: config.provider().as_str(),
api_kind: request.api_kind.as_str(),
model,
stream,
attempt,
max_output_tokens: request.max_output_tokens,
messages: request.messages.as_slice(),
};
serde_json::to_string_pretty(&input_log)
.map_err(|error| format!("序列化模型输入日志失败:{error}"))
}
fn redact_inline_image_data_urls(value: &str) -> String {
const PREFIX: &str = "data:image/";
let mut output = String::with_capacity(value.len());
let mut remaining = value;
while let Some(index) = remaining.find(PREFIX) {
output.push_str(&remaining[..index]);
output.push_str("<image-data-omitted>");
let tail = &remaining[index + PREFIX.len()..];
let end = tail
.find(|character: char| {
character.is_ascii_whitespace() || matches!(character, '"' | '\'' | ')' | ']' | '}')
})
.unwrap_or(tail.len());
remaining = &tail[end..];
}
output.push_str(remaining);
output
}
fn build_llm_raw_log_prefix(failure_stage: &str) -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default();
let sequence = LLM_RAW_LOG_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let safe_stage = sanitize_log_file_segment(failure_stage);
format!("{millis}-{}-{sequence:06}-{safe_stage}", std::process::id())
}
fn sanitize_log_file_segment(value: &str) -> String {
let sanitized = value
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || character == '-' || character == '_' {
character
} else {
'_'
}
})
.collect::<String>();
if sanitized.is_empty() {
"unknown".to_string()
} else {
sanitized
}
}
fn parse_text_response(
api_kind: LlmApiKind,
provider: LlmProvider,
fallback_model: &str,
capture_reasoning: bool,
raw_text: &str,
) -> Result<LlmRunResponse, LlmError> {
match api_kind {
LlmApiKind::OpenAiChat => parse_chat_completions_response_with_capture(
provider,
fallback_model,
capture_reasoning,
raw_text,
),
LlmApiKind::OpenAiResponses => parse_responses_response_with_capture(
provider,
fallback_model,
capture_reasoning,
raw_text,
),
LlmApiKind::Anthropic => {
parse_anthropic_response(provider, fallback_model, capture_reasoning, raw_text)
}
}
}
fn parse_chat_completions_response_with_capture(
provider: LlmProvider,
fallback_model: &str,
capture_reasoning: bool,
raw_text: &str,
) -> Result<LlmRunResponse, LlmError> {
let parsed: ChatCompletionsResponsePayload = serde_json::from_str(raw_text)
.map_err(|error| LlmError::Deserialize(format!("解析 LLM JSON 响应失败:{error}")))?;
let parsed = match parsed {
ChatCompletionsResponsePayload::Direct(envelope) => envelope,
ChatCompletionsResponsePayload::Wrapped { data } => data,
};
let first_choice = parsed
.choices
.first()
.ok_or_else(|| LlmError::Deserialize("LLM 响应缺少 choices[0]".to_string()))?;
let content = extract_message_text(first_choice)
.unwrap_or_default()
.trim()
.to_string();
let tool_calls = extract_chat_tool_calls(first_choice)?;
reject_incomplete_tool_calls(
LlmApiKind::OpenAiChat,
first_choice.finish_reason.as_deref(),
&tool_calls,
"Chat 非流式",
)?;
if content.is_empty() && tool_calls.is_empty() {
return Err(LlmError::EmptyResponse);
}
Ok(LlmRunResponse {
provider,
model: parsed
.model
.clone()
.unwrap_or_else(|| fallback_model.to_string()),
text: content,
reasoning: if capture_reasoning {
extract_message_reasoning(first_choice).unwrap_or_default()
} else {
String::new()
},
finish_reason: first_choice.finish_reason.clone(),
response_id: parsed.id,
usage: parsed.usage,
tool_calls,
responses_output: Vec::new(),
})
}
fn parse_responses_response_with_capture(
provider: LlmProvider,
fallback_model: &str,
capture_reasoning: bool,
raw_text: &str,
) -> Result<LlmRunResponse, LlmError> {
let raw: serde_json::Value = serde_json::from_str(raw_text).map_err(|error| {
LlmError::Deserialize(format!("解析 LLM Responses JSON 响应失败:{error}"))
})?;
let responses_output = raw
.get("output")
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default();
let parsed: ResponsesResponseEnvelope = serde_json::from_value(raw).map_err(|error| {
LlmError::Deserialize(format!("解析 LLM Responses JSON 响应失败:{error}"))
})?;
let content = extract_responses_text(&parsed)
.unwrap_or_default()
.trim()
.to_string();
let tool_calls = extract_responses_tool_calls(&parsed)?;
reject_incomplete_tool_calls(
LlmApiKind::OpenAiResponses,
parsed.status.as_deref(),
&tool_calls,
"Responses 非流式",
)?;
if content.is_empty() && tool_calls.is_empty() {
return Err(LlmError::EmptyResponse);
}
Ok(LlmRunResponse {
provider,
model: parsed
.model
.clone()
.unwrap_or_else(|| fallback_model.to_string()),
text: content,
reasoning: if capture_reasoning {
extract_responses_reasoning(&parsed).unwrap_or_default()
} else {
String::new()
},
finish_reason: parsed.status,
response_id: parsed.id,
usage: parsed.usage.map(|usage| LlmTokenUsage {
prompt_tokens: usage.input_tokens,
completion_tokens: usage.output_tokens,
total_tokens: usage.total_tokens,
}),
tool_calls,
responses_output,
})
}
fn parse_anthropic_response(
provider: LlmProvider,
fallback_model: &str,
capture_reasoning: bool,
raw_text: &str,
) -> Result<LlmRunResponse, LlmError> {
let parsed: AnthropicResponseEnvelope = serde_json::from_str(raw_text).map_err(|error| {
LlmError::Deserialize(format!("解析 LLM Anthropic JSON 响应失败:{error}"))
})?;
let tool_calls = extract_anthropic_tool_calls(&parsed)?;
reject_incomplete_tool_calls(
LlmApiKind::Anthropic,
parsed.stop_reason.as_deref(),
&tool_calls,
"Anthropic 非流式",
)?;
let content = extract_anthropic_text(&parsed)
.unwrap_or_default()
.trim()
.to_string();
// 纯 tool_use 响应没有 text block,此时不能按空响应处理。
if content.is_empty() && tool_calls.is_empty() {
return Err(LlmError::EmptyResponse);
}
Ok(LlmRunResponse {
provider,
model: parsed
.model
.clone()
.unwrap_or_else(|| fallback_model.to_string()),
text: content,
reasoning: if capture_reasoning {
extract_anthropic_reasoning(&parsed).unwrap_or_default()
} else {
String::new()
},
finish_reason: parsed.stop_reason,
response_id: parsed.id,
usage: parsed.usage.map(map_anthropic_usage),
tool_calls,
responses_output: Vec::new(),
})
}
fn extract_responses_text(parsed: &ResponsesResponseEnvelope) -> Option<String> {
parsed
.output_text
.as_deref()
.map(str::to_string)
.filter(|text| !text.is_empty())
.or_else(|| {
let text = parsed
.output
.iter()
.flat_map(|item| item.content.iter())
.filter(|part| !is_hidden_reasoning_part(part.part_type.as_deref()))
.filter_map(|part| part.text.as_deref())
.collect::<Vec<_>>()
.join("");
if text.is_empty() { None } else { Some(text) }
})
}
fn append_reasoning(target: &mut String, value: Option<&str>) {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return;
};
target.push_str(value);
}
fn extract_responses_reasoning(parsed: &ResponsesResponseEnvelope) -> Option<String> {
let mut reasoning = String::new();
for item in &parsed.output {
if item.item_type.as_deref() != Some("reasoning") {
continue;
}
if let Some(parts) = item.summary.as_ref().and_then(serde_json::Value::as_array) {
for part in parts {
append_reasoning(
&mut reasoning,
part.get("text").and_then(serde_json::Value::as_str),
);
}
}
for part in &item.content {
if is_hidden_reasoning_part(part.part_type.as_deref()) {
append_reasoning(&mut reasoning, part.text.as_deref());
}
}
}
(!reasoning.is_empty()).then_some(reasoning)
}
fn extract_responses_tool_calls(
parsed: &ResponsesResponseEnvelope,
) -> Result<Vec<LlmToolCall>, LlmError> {
let raw = parsed
.output
.iter()
.enumerate()
.filter(|(_, item)| item.item_type.as_deref() == Some("function_call"))
.map(|(index, item)| RawToolCall {
slot: index as u64,
id: item.call_id.clone().or_else(|| item.id.clone()),
name: item.name.clone(),
arguments: item.arguments.clone(),
})
.collect();
normalize_tool_calls(raw, "Responses 非流式", false)
}
fn extract_anthropic_tool_calls(
parsed: &AnthropicResponseEnvelope,
) -> Result<Vec<LlmToolCall>, LlmError> {
let raw = parsed
.content
.iter()
.enumerate()
.filter(|(_, block)| block.block_type.as_deref() == Some("tool_use"))
.map(|(index, block)| RawToolCall {
slot: index as u64,
id: block.id.clone(),
name: block.name.clone(),
// input 是已解析的 JSON object,缺省时由归一层补空对象。
arguments: block.input.as_ref().map(serde_json::Value::to_string),
})
.collect();
normalize_tool_calls(raw, "Anthropic 非流式", false)
}
fn extract_anthropic_text(parsed: &AnthropicResponseEnvelope) -> Option<String> {
let text = parsed
.content
.iter()
.filter(|block| block.block_type.as_deref().unwrap_or("text") == "text")
.filter_map(|block| block.text.as_deref())
.collect::<Vec<_>>()
.join("");
if text.is_empty() { None } else { Some(text) }
}
fn extract_anthropic_reasoning(parsed: &AnthropicResponseEnvelope) -> Option<String> {
let mut reasoning = String::new();
for block in &parsed.content {
if block.block_type.as_deref() == Some("thinking") {
append_reasoning(&mut reasoning, block.thinking.as_deref());
}
}
(!reasoning.is_empty()).then_some(reasoning)
}
fn extract_message_text(choice: &ChatCompletionsChoice) -> Option<String> {
choice
.message
.as_ref()
.and_then(|message| message.content.as_ref())
.and_then(extract_content_text)
.or_else(|| {
choice
.delta
.as_ref()
.and_then(|message| message.content.as_ref())
.and_then(extract_content_text)
})
}
fn extract_message_reasoning(choice: &ChatCompletionsChoice) -> Option<String> {
let mut reasoning = String::new();
for message in [choice.message.as_ref(), choice.delta.as_ref()]
.into_iter()
.flatten()
{
append_reasoning(&mut reasoning, message.reasoning.as_deref());
append_reasoning(&mut reasoning, message.reasoning_content.as_deref());
if let Some(ChatCompletionsContent::Parts(parts)) = message.content.as_ref() {
for part in parts {
if is_hidden_reasoning_part(part.part_type.as_deref()) {
append_reasoning(&mut reasoning, part.text.as_deref());
}
}
}
}
(!reasoning.is_empty()).then_some(reasoning)
}
fn extract_chat_tool_calls(choice: &ChatCompletionsChoice) -> Result<Vec<LlmToolCall>, LlmError> {
let raw = choice
.message
.as_ref()
.and_then(|message| message.tool_calls.as_deref())
.filter(|tool_calls| !tool_calls.is_empty())
.or_else(|| {
choice
.delta
.as_ref()
.and_then(|message| message.tool_calls.as_deref())
})
.unwrap_or_default()
.iter()
.enumerate()
.map(|(index, tool_call)| RawToolCall {
slot: tool_call.index.unwrap_or(index as u64),
id: tool_call.id.clone(),
name: tool_call
.function
.as_ref()
.and_then(|function| function.name.clone()),
arguments: tool_call
.function
.as_ref()
.and_then(|function| function.arguments.clone()),
})
.collect();
normalize_tool_calls(raw, "Chat 非流式", false)
}
fn extract_content_text(content: &ChatCompletionsContent) -> Option<String> {
match content {
ChatCompletionsContent::Text(text) => Some(text.clone()),
ChatCompletionsContent::Parts(parts) => {
let text = parts
.iter()
.filter(|part| !is_hidden_reasoning_part(part.part_type.as_deref()))
.filter_map(|part| part.text.as_deref())
.collect::<Vec<_>>()
.join("");
if text.is_empty() { None } else { Some(text) }
}
}
}
fn is_hidden_reasoning_part(part_type: Option<&str>) -> bool {
let Some(part_type) = part_type.map(str::trim) else {
return false;
};
[
"reasoning",
"reasoning_content",
"reasoning_text",
"analysis",
"thinking",
]
.iter()
.any(|hidden_type| part_type.eq_ignore_ascii_case(hidden_type))
}
fn decode_utf8_stream_chunk(bytes: &[u8]) -> Result<(String, Vec<u8>), LlmError> {
match std_str::from_utf8(bytes) {
Ok(text) => Ok((text.to_string(), Vec::new())),
Err(error) => {
let valid_up_to = error.valid_up_to();
let Some(_) = error.error_len() else {
let decoded = std_str::from_utf8(&bytes[..valid_up_to]).map_err(|inner_error| {
LlmError::Deserialize(format!("解析 LLM 流式 UTF-8 响应失败:{inner_error}"))
})?;
return Ok((decoded.to_string(), bytes[valid_up_to..].to_vec()));
};
Err(LlmError::Deserialize(format!(
"解析 LLM 流式 UTF-8 响应失败:{error}"
)))
}
}
}
fn parse_sse_event_block(
api_kind: LlmApiKind,
block: &str,
) -> Result<Option<ParsedStreamEvent>, LlmError> {
let data_lines = block
.lines()
.filter_map(|line| line.trim().strip_prefix("data:"))
.map(str::trim_start)
.collect::<Vec<_>>();
if data_lines.is_empty() {
return Ok(None);
}
let data = data_lines.join("\n");
if data.trim().is_empty() {
return Ok(None);
}
if data.trim() == "[DONE]" {
return if api_kind == LlmApiKind::OpenAiChat {
Ok(Some(ParsedStreamEvent {
is_terminal: true,
is_completion: true,
..Default::default()
}))
} else {
Ok(None)
};
}
if api_kind == LlmApiKind::OpenAiResponses {
return parse_responses_sse_event(data.as_str());
}
if api_kind == LlmApiKind::Anthropic {
return parse_anthropic_sse_event(data.as_str());
}
let parsed: serde_json::Value = serde_json::from_str(data.as_str())
.map_err(|error| LlmError::Deserialize(format!("解析 LLM SSE 事件失败:{error}")))?;
if let Some(error) = parsed.get("error").filter(|error| error.is_object()) {
return Err(LlmError::Upstream {
status_code: 502,
message: error
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("LLM Chat SSE 返回失败事件")
.to_string(),
});
}
let parsed: ChatCompletionsResponseEnvelope = serde_json::from_value(parsed)
.map_err(|error| LlmError::Deserialize(format!("解析 LLM SSE 事件失败:{error}")))?;
let Some(first_choice) = parsed.choices.first() else {
return if let Some(usage) = parsed.usage {
Ok(Some(ParsedStreamEvent {
usage: Some(usage),
..Default::default()
}))
} else {
// OpenAI-compatible gateways may emit heartbeat or metadata-only
// chunks with an empty choices array before the next text delta.
Ok(None)
};
};
Ok(Some(ParsedStreamEvent {
delta_text: extract_message_text(first_choice),
reasoning_delta: extract_message_reasoning(first_choice),
finish_reason: first_choice.finish_reason.clone(),
usage: parsed.usage,
// Chat 的收尾信号是非空 finish_reason,不能只认 [DONE]:部分兼容网关(MiniMax)
// 只发前者。真 OpenAI 两者都发,这里任一到达即视为已收尾。
is_completion: first_choice
.finish_reason
.as_deref()
.is_some_and(|reason| !reason.trim().is_empty()),
tool_fragments: extract_chat_tool_fragments(first_choice)?,
..Default::default()
}))
}
// Chat 分片:首片带 index + id + function.name,后续片只有 index + function.arguments。
fn extract_chat_tool_fragments(
choice: &ChatCompletionsChoice,
) -> Result<Vec<ToolCallFragment>, LlmError> {
let Some(tool_calls) = choice
.delta
.as_ref()
.and_then(|delta| delta.tool_calls.as_deref())
else {
return Ok(Vec::new());
};
tool_calls
.iter()
.map(|tool_call| {
// 不能退回事件内位置:两个各含一个无 index 调用的事件都会落到槽位 0,
// 后者的 id / name 覆盖前者,arguments 还会被拼在一起。
let slot = tool_call
.index
.ok_or_else(|| missing_tool_slot_error("Chat", "delta.tool_calls[]", "index"))?;
Ok(ToolCallFragment {
slot,
id: tool_call.id.clone(),
name: tool_call
.function
.as_ref()
.and_then(|function| function.name.clone()),
arguments_delta: tool_call
.function
.as_ref()
.and_then(|function| function.arguments.clone()),
arguments_complete: None,
// Chat 没有终态快照事件,[DONE] 不带载荷,永远是增量宣告。
from_terminal_snapshot: false,
})
})
.collect()
}
fn parse_responses_sse_event(data: &str) -> Result<Option<ParsedStreamEvent>, LlmError> {
let parsed: serde_json::Value = serde_json::from_str(data).map_err(|error| {
LlmError::Deserialize(format!("解析 LLM Responses SSE 事件失败:{error}"))
})?;
let event_type = parsed
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
match event_type {
"response.output_text.delta" => Ok(Some(ParsedStreamEvent {
delta_text: parsed
.get("delta")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
..Default::default()
})),
"response.reasoning_summary_text.delta" => Ok(Some(ParsedStreamEvent {
reasoning_delta: parsed
.get("delta")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
reasoning_summary_item_id: parsed
.get("item_id")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
reasoning_summary_index: parsed
.get("summary_index")
.and_then(serde_json::Value::as_u64),
..Default::default()
})),
"response.reasoning_summary_text.done" => Ok(Some(ParsedStreamEvent {
reasoning_snapshot: parsed
.get("text")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
reasoning_summary_item_id: parsed
.get("item_id")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
reasoning_summary_index: parsed
.get("summary_index")
.and_then(serde_json::Value::as_u64),
..Default::default()
})),
// completed 事件携带完整 output;有的网关只发它而不发增量事件,这里再取一遍,
// 槽位沿用 output 数组下标,与 output_index 语义一致,可安全覆盖增量拼接结果。
// 整体收尾信号只有 completed 与 incomplete 两个;单个 item 的
// function_call_arguments.done 不算,它只说明该 item 的参数发完了。
//
// incomplete 与 completed 同构:撞到 max_output_tokens 时上游只发 incomplete、
// 不发 completed(真实端点抓包确认),载荷同样带完整 output[],item 上标
// status=incomplete,response.incomplete_details.reason 给出原因。这里照常收口
// 并给出 finish_reason,工具调用交由 reject_incomplete_tool_calls 统一拒绝,
// 正文仍按降级结果返回,与 Chat 的 length 口径一致。忽略它会同时造成三件事:
// 不终止读取循环(网关不关连接就等到调用方超时)、流式 Responses 永远产生不出
// incomplete 这个 finish_reason(截断拒绝规则形同虚设)、只在整体终态事件中携带
// 工具调用的网关响应被静默丢掉。
"response.completed" | "response.incomplete" => Ok(Some(ParsedStreamEvent {
finish_reason: Some(
if event_type == "response.incomplete" {
"incomplete"
} else {
"completed"
}
.to_string(),
),
is_completion: true,
is_terminal: true,
// 终态载荷既是工具调用的恢复源,也是正文的恢复源,两者必须对称:只恢复
// 工具会让纯文本的 completed-only 响应变成 EmptyResponse,让「正文 + 工具」
// 响应静默丢掉模型的前置说明。
text_snapshot: extract_responses_terminal_text(&parsed),
reasoning_snapshot: extract_responses_terminal_reasoning(&parsed),
responses_output: extract_responses_output_array(&parsed),
tool_fragments: extract_responses_completed_tool_fragments(&parsed)?,
..Default::default()
})),
// 工具调用先由 output_item.added 宣告身份,再用 arguments delta 拼参数;
// .done 给出权威完整参数,用它覆盖拼接结果。三个事件共用 output_index 作为槽位。
// output_item.added / done 同时把原生 item 按槽位写入,供 completed 未带
// output[] 时回放;function_call 仍走 tool_fragments 归并。
"response.output_item.added" | "response.output_item.done" => {
parse_responses_output_item_event(&parsed, event_type)
}
"response.function_call_arguments.delta" => {
let slot = responses_output_slot(&parsed)
.ok_or_else(|| missing_tool_slot_error("Responses", event_type, "output_index"))?;
Ok(Some(ParsedStreamEvent {
tool_fragments: vec![ToolCallFragment {
slot,
arguments_delta: tool_argument_str(&parsed, "delta", "Responses", event_type)?,
..Default::default()
}],
..Default::default()
}))
}
"response.function_call_arguments.done" => {
let slot = responses_output_slot(&parsed)
.ok_or_else(|| missing_tool_slot_error("Responses", event_type, "output_index"))?;
Ok(Some(ParsedStreamEvent {
tool_fragments: vec![ToolCallFragment {
slot,
arguments_complete: tool_argument_str(
&parsed,
"arguments",
"Responses",
event_type,
)?,
..Default::default()
}],
..Default::default()
}))
}
"response.failed" | "error" => {
let message = parsed
.get("error")
.and_then(|error| error.get("message"))
.and_then(serde_json::Value::as_str)
.or_else(|| {
parsed
.pointer("/response/error/message")
.and_then(serde_json::Value::as_str)
})
.or_else(|| parsed.get("message").and_then(serde_json::Value::as_str))
.unwrap_or("LLM Responses SSE 返回失败事件")
.to_string();
Err(LlmError::Upstream {
status_code: 502,
message,
})
}
_ => Ok(None),
}
}
// 终态事件的 response 字段就是一个完整 Response 对象,直接反序列化后复用非流式的正文
// 提取:它已经处理了 output_text 优先、output[].content[] 回退,以及 reasoning /
// reasoning_content / reasoning_text / analysis / thinking 这些隐藏 part 的过滤。另写裸 JSON 提取器必然
// 漏掉过滤层,会把思维链当正文吐给调用方。
fn extract_responses_terminal_text(parsed: &serde_json::Value) -> Option<String> {
let response = parsed.get("response")?;
// 反序列化失败按「没有快照」处理而不是报错:这是兜底恢复路径,网关发出我们没建模
// 的形状时应当退回增量累加结果。这与槽位缺失必须失败关闭的口径不同——那里放过会
// 造成静默的身份/参数错配,这里放过只是回到本次修复前的行为。
let envelope: ResponsesResponseEnvelope = serde_json::from_value(response.clone()).ok()?;
extract_responses_text(&envelope).filter(|text| !text.trim().is_empty())
}
fn extract_responses_terminal_reasoning(parsed: &serde_json::Value) -> Option<String> {
let response = parsed.get("response")?;
let envelope: ResponsesResponseEnvelope = serde_json::from_value(response.clone()).ok()?;
extract_responses_reasoning(&envelope)
}
fn extract_responses_output_array(parsed: &serde_json::Value) -> Option<Vec<serde_json::Value>> {
parsed
.pointer("/response/output")
.or_else(|| parsed.get("output"))
.and_then(serde_json::Value::as_array)
.filter(|items| !items.is_empty())
.cloned()
}
fn upsert_responses_output_item(
output: &mut Vec<serde_json::Value>,
slot: u64,
item: serde_json::Value,
) -> Result<(), LlmError> {
const MAX_RESPONSES_OUTPUT_SLOT: u64 = 4096;
if slot >= MAX_RESPONSES_OUTPUT_SLOT {
return Err(LlmError::Deserialize(format!(
"解析 LLM Responses SSE 事件失败:output_index 超出安全上限({slot} >= {MAX_RESPONSES_OUTPUT_SLOT})"
)));
}
let index = slot as usize;
if index >= output.len() {
output.resize(index + 1, serde_json::Value::Null);
}
output[index] = item;
Ok(())
}
fn patch_responses_output_arguments(output: &mut [serde_json::Value], slot: u64, arguments: &str) {
let Some(serde_json::Value::Object(map)) = output.get_mut(slot as usize) else {
return;
};
map.insert(
"arguments".to_string(),
serde_json::Value::String(arguments.to_string()),
);
}
fn compact_responses_output(mut output: Vec<serde_json::Value>) -> Vec<serde_json::Value> {
output.retain(|item| !item.is_null());
output
}
fn parse_responses_output_item_event(
parsed: &serde_json::Value,
event_type: &str,
) -> Result<Option<ParsedStreamEvent>, LlmError> {
let Some(item) = parsed.get("item").filter(|item| item.is_object()).cloned() else {
return Ok(None);
};
let is_function_call =
item.get("type").and_then(serde_json::Value::as_str) == Some("function_call");
let Some(slot) = responses_output_slot(parsed) else {
if !is_function_call {
return Ok(None);
}
return Err(missing_tool_slot_error(
"Responses",
event_type,
"output_index",
));
};
let mut event = ParsedStreamEvent {
output_items: vec![(slot, item.clone())],
..Default::default()
};
if item.get("type").and_then(serde_json::Value::as_str) == Some("function_call") {
event.tool_fragments = vec![ToolCallFragment {
slot,
id: item
.get("call_id")
.or_else(|| item.get("id"))
.and_then(serde_json::Value::as_str)
.map(str::to_string),
name: item
.get("name")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
arguments_complete: item
.get("arguments")
.and_then(serde_json::Value::as_str)
.filter(|arguments| !arguments.is_empty())
.map(str::to_string),
..Default::default()
}];
}
Ok(Some(event))
}
fn extract_responses_completed_tool_fragments(
parsed: &serde_json::Value,
) -> Result<Vec<ToolCallFragment>, LlmError> {
let Some(items) = extract_responses_output_array(parsed) else {
return Ok(Vec::new());
};
items
.iter()
.enumerate()
.filter(|(_, item)| {
item.get("type").and_then(serde_json::Value::as_str) == Some("function_call")
})
.map(|(index, item)| {
Ok(ToolCallFragment {
slot: index as u64,
id: item
.get("call_id")
.or_else(|| item.get("id"))
.and_then(serde_json::Value::as_str)
.map(str::to_string),
name: item
.get("name")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
arguments_complete: tool_argument_str(
item,
"arguments",
"Responses",
"response.completed/incomplete",
)?
.filter(|arguments| !arguments.is_empty()),
from_terminal_snapshot: true,
..Default::default()
})
})
.collect()
}
// 工具参数字段只接受字符串:字段不存在或为 null 按缺省返回 None,存在但类型不对返回
// Deserialize。不能用 as_str 把两者混为一谈——类型非法时它返回 None,参数被当成缺省,
// 归一层再把空参数补成 {},于是一个身份完整、参数是合法 JSON 的调用就直接交给下游执行,
// 既有校验全都拦不住:零参函数的 {} 和「参数类型错了所以变成 {}」在归一层无法区分。
// Chat 走强类型 DTO,同样的输入本来就会反序列化失败,这里是把三协议口径拉齐。
//
// 只覆盖参数字段。id / 函数名即使类型不对也只会退化成缺失,随后被归一层按缺 id / 缺
// 函数名拒绝,本来就是失败关闭,不需要另做处理。
fn tool_argument_str(
parent: &serde_json::Value,
field: &str,
protocol: &str,
event: &str,
) -> Result<Option<String>, LlmError> {
let Some(value) = parent.get(field).filter(|value| !value.is_null()) else {
return Ok(None);
};
value.as_str().map(str::to_string).map(Some).ok_or_else(|| {
LlmError::Deserialize(format!(
"LLM {protocol} 流式工具事件字段 {field} 不是字符串:event={event}"
))
})
}
fn responses_output_slot(parsed: &serde_json::Value) -> Option<u64> {
parsed
.get("output_index")
.and_then(serde_json::Value::as_u64)
}
fn anthropic_block_slot(parsed: &serde_json::Value) -> Option<u64> {
parsed.get("index").and_then(serde_json::Value::as_u64)
}
// 槽位是并行工具分片唯一的归并依据,缺失时必须失败关闭而不是跳过或猜测:跳过会静默
// 丢掉整个调用(只剩一个调用时才可能被 StreamUnavailable 断言兜住,丢一半就毫无察觉;
// Responses 的整体终态原因是 completed / incomplete,也不会触发只识别 tool_use /
// tool_calls 的那道断言),猜测则会把两个不同调用合并成一个混合体。
fn missing_tool_slot_error(protocol: &str, event: &str, field: &str) -> LlmError {
LlmError::Deserialize(format!(
"LLM {protocol} 流式工具事件缺少槽位字段 {field}:event={event}"
))
}
fn parse_anthropic_sse_event(data: &str) -> Result<Option<ParsedStreamEvent>, LlmError> {
let parsed: serde_json::Value = serde_json::from_str(data).map_err(|error| {
LlmError::Deserialize(format!("解析 LLM Anthropic SSE 事件失败:{error}"))
})?;
let event_type = parsed
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
match event_type {
"message_start" => Ok(parsed
.get("message")
.and_then(|message| message.get("usage"))
.cloned()
.map(serde_json::from_value::<AnthropicUsage>)
.transpose()
.map_err(|error| {
LlmError::Deserialize(format!(
"解析 LLM Anthropic message_start usage 失败:{error}"
))
})?
.map(|usage| ParsedStreamEvent {
usage: Some(map_anthropic_usage(usage)),
..Default::default()
})),
// tool_use block 的 id 与 name 只在 content_block_start 出现;此时 input 恒为空对象,
// 不能拿它初始化参数,否则会和后续 input_json_delta 拼出非法 JSON。
"content_block_start" => {
let block = parsed.get("content_block");
if block
.and_then(|block| block.get("type"))
.and_then(serde_json::Value::as_str)
!= Some("tool_use")
{
return Ok(None);
}
let slot = anthropic_block_slot(&parsed)
.ok_or_else(|| missing_tool_slot_error("Anthropic", event_type, "index"))?;
Ok(Some(ParsedStreamEvent {
tool_fragments: vec![ToolCallFragment {
slot,
id: block
.and_then(|block| block.get("id"))
.and_then(serde_json::Value::as_str)
.map(str::to_string),
name: block
.and_then(|block| block.get("name"))
.and_then(serde_json::Value::as_str)
.map(str::to_string),
..Default::default()
}],
..Default::default()
}))
}
"content_block_delta" => {
let delta = parsed.get("delta");
let delta_type = delta
.and_then(|value| value.get("type"))
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if delta_type == "input_json_delta" {
let slot = anthropic_block_slot(&parsed)
.ok_or_else(|| missing_tool_slot_error("Anthropic", event_type, "index"))?;
let arguments_delta = match delta {
Some(delta) => {
tool_argument_str(delta, "partial_json", "Anthropic", event_type)?
}
None => None,
};
return Ok(Some(ParsedStreamEvent {
tool_fragments: vec![ToolCallFragment {
slot,
arguments_delta,
..Default::default()
}],
..Default::default()
}));
}
if delta_type == "thinking_delta" {
return Ok(Some(ParsedStreamEvent {
reasoning_delta: delta
.and_then(|value| value.get("thinking"))
.and_then(serde_json::Value::as_str)
.map(str::to_string),
..Default::default()
}));
}
if delta_type != "text_delta" {
return Ok(None);
}
Ok(Some(ParsedStreamEvent {
delta_text: delta
.and_then(|value| value.get("text"))
.and_then(serde_json::Value::as_str)
.map(str::to_string),
..Default::default()
}))
}
"message_delta" => {
let stop_reason = parsed
.get("delta")
.and_then(|value| value.get("stop_reason"))
.and_then(serde_json::Value::as_str)
.map(str::to_string);
let usage = parsed
.get("usage")
.cloned()
.map(serde_json::from_value::<AnthropicUsage>)
.transpose()
.map_err(|error| {
LlmError::Deserialize(format!(
"解析 LLM Anthropic message_delta usage 失败:{error}"
))
})?
.map(map_anthropic_usage);
Ok(Some(ParsedStreamEvent {
is_completion: stop_reason
.as_deref()
.is_some_and(|reason| !reason.trim().is_empty()),
finish_reason: stop_reason,
usage,
..Default::default()
}))
}
// message_stop 只是流终止信号;真正的 stop_reason 已由 message_delta 提供,
// 这里不要伪造 finish_reason,否则会覆盖掉 end_turn 等真实值。但它确实是协议
// 收尾信号,所以单独用 is_completion 记录:兼容网关可能只发它而漏 stop_reason。
"message_stop" => Ok(Some(ParsedStreamEvent {
is_completion: true,
is_terminal: true,
..Default::default()
})),
"error" => {
let message = parsed
.get("error")
.and_then(|error| error.get("message"))
.and_then(serde_json::Value::as_str)
.or_else(|| parsed.get("message").and_then(serde_json::Value::as_str))
.unwrap_or("LLM Anthropic SSE 返回失败事件")
.to_string();
Err(LlmError::Upstream {
status_code: 502,
message,
})
}
_ => Ok(None),
}
}
fn should_retry_status(status: StatusCode) -> bool {
status == StatusCode::REQUEST_TIMEOUT
|| status == StatusCode::TOO_MANY_REQUESTS
|| status.is_server_error()
}
fn extract_api_error_message(raw_text: &str, fallback_message: &str) -> String {
let trimmed = raw_text.trim();
if trimmed.is_empty() {
return fallback_message.to_string();
}
let parsed = serde_json::from_str::<serde_json::Value>(trimmed);
if let Ok(value) = parsed {
if let Some(message) = value
.get("error")
.and_then(|error| error.get("message"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|message| !message.is_empty())
{
return message.to_string();
}
if let Some(message) = value
.get("message")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|message| !message.is_empty())
{
return message.to_string();
}
}
trimmed.to_string()
}
fn map_stream_read_error(error: reqwest::Error, attempts: u32) -> LlmError {
if error.is_timeout() {
return LlmError::Timeout { attempts };
}
if error.is_connect() {
return LlmError::Connectivity {
attempts,
message: error.to_string(),
};
}
LlmError::Transport(error.to_string())
}
#[cfg(test)]
mod tests {
use std::{
io::{Read, Write},
net::TcpListener,
thread,
time::Duration as StdDuration,
};
use super::*;
#[test]
fn responses_output_slot_safety_fuse_rejects_only_out_of_range_indices() {
let mut output = Vec::new();
upsert_responses_output_item(&mut output, 4095, serde_json::json!({"type": "message"}))
.expect("slot below the safety fuse should be accepted");
assert_eq!(output.len(), 4096);
let error =
upsert_responses_output_item(&mut output, 4096, serde_json::json!({"type": "message"}))
.expect_err("slot at the safety fuse should be rejected");
assert!(
matches!(error, LlmError::Deserialize(message) if message.contains("output_index"))
);
assert_eq!(output.len(), 4096);
}
#[test]
fn llm_error_kind_is_stable_for_adapter_mapping() {
assert_eq!(
LlmError::InvalidConfig("bad config".to_string()).kind(),
LlmErrorKind::InvalidConfig
);
assert_eq!(
LlmError::Upstream {
status_code: 429,
message: "too many requests".to_string(),
}
.kind(),
LlmErrorKind::Upstream
);
assert_eq!(LlmError::EmptyResponse.kind(), LlmErrorKind::EmptyResponse);
}
#[test]
fn incomplete_finish_reason_is_api_kind_aware_and_normalized() {
for reason in ["length", " CONTENT_FILTER ", "LeNgTh"] {
assert!(
is_incomplete_finish_reason(LlmApiKind::OpenAiChat, reason),
"OpenAI Chat should reject {reason:?}"
);
}
for reason in ["incomplete", " FAILED ", "CaNcElLeD"] {
assert!(
is_incomplete_finish_reason(LlmApiKind::OpenAiResponses, reason),
"OpenAI Responses should reject {reason:?}"
);
}
for reason in ["max_tokens", " PAUSE_TURN ", "ReFuSaL"] {
assert!(
is_incomplete_finish_reason(LlmApiKind::Anthropic, reason),
"Anthropic should reject {reason:?}"
);
}
for api_kind in [
LlmApiKind::OpenAiChat,
LlmApiKind::OpenAiResponses,
LlmApiKind::Anthropic,
] {
assert!(!is_incomplete_finish_reason(api_kind, ""));
assert!(!is_incomplete_finish_reason(
api_kind,
" vendor_specific_done "
));
}
assert!(!is_incomplete_finish_reason(
LlmApiKind::OpenAiChat,
"max_tokens"
));
assert!(!is_incomplete_finish_reason(
LlmApiKind::OpenAiResponses,
"length"
));
assert!(!is_incomplete_finish_reason(
LlmApiKind::Anthropic,
"content_filter"
));
}
struct MockResponse {
status_line: &'static str,
content_type: &'static str,
body: String,
extra_headers: Vec<(&'static str, &'static str)>,
}
#[test]
fn llm_config_rejects_blank_api_key() {
let error = LlmConfig::new(
LlmProvider::Ark,
DEFAULT_ARK_BASE_URL.to_string(),
" ".to_string(),
"model-a".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
DEFAULT_MAX_RETRIES,
DEFAULT_RETRY_BACKOFF_MS,
)
.expect_err("blank api key should be rejected");
assert_eq!(
error,
LlmError::InvalidConfig("LLM api_key 不能为空".to_string())
);
}
#[test]
fn llm_chat_completion_url_normalizes_trailing_slash() {
let config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
"https://example.com/base///".to_string(),
"secret".to_string(),
"model-a".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
DEFAULT_MAX_RETRIES,
DEFAULT_RETRY_BACKOFF_MS,
)
.expect("config should be valid");
assert_eq!(
config.chat_completions_url(),
"https://example.com/base/chat/completions"
);
assert_eq!(config.responses_url(), "https://example.com/base/responses");
}
#[test]
fn llm_config_official_fallback_is_opt_in() {
let config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
"https://example.com/base".to_string(),
"secret".to_string(),
"model-a".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
DEFAULT_MAX_RETRIES,
DEFAULT_RETRY_BACKOFF_MS,
)
.expect("config should be valid");
assert!(!config.official_fallback());
assert!(config.with_official_fallback(true).official_fallback());
}
#[test]
fn llm_config_anthropic_strict_tool_support_is_opt_in() {
let config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
"https://api.anthropic.com".to_string(),
"secret".to_string(),
"claude-sonnet-4-5".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
DEFAULT_MAX_RETRIES,
DEFAULT_RETRY_BACKOFF_MS,
)
.expect("config should be valid");
assert!(!config.anthropic_strict_tool_support());
assert!(
config
.with_anthropic_strict_tool_support(true)
.anthropic_strict_tool_support()
);
}
#[test]
fn llm_config_chat_token_budget_field_defaults_to_legacy_and_is_explicitly_selectable() {
let config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
"https://example.com/v1".to_string(),
"secret".to_string(),
"model-a".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
DEFAULT_MAX_RETRIES,
DEFAULT_RETRY_BACKOFF_MS,
)
.expect("config should be valid");
assert_eq!(
config.openai_chat_token_budget_field(),
OpenAiChatTokenBudgetField::LegacyMaxTokens
);
assert_eq!(
config
.with_openai_chat_token_budget_field(
OpenAiChatTokenBudgetField::MaxCompletionTokens,
)
.openai_chat_token_budget_field(),
OpenAiChatTokenBudgetField::MaxCompletionTokens
);
}
#[test]
fn run_request_defaults_to_openai_responses_api_kind() {
let request = LlmRunRequest::single_turn("系统", "用户");
assert_eq!(request.api_kind, LlmApiKind::OpenAiResponses);
assert!(!request.capture_reasoning);
assert!(
request
.clone()
.with_reasoning_capture(true)
.capture_reasoning
);
assert_eq!(request.with_openai_chat().api_kind, LlmApiKind::OpenAiChat);
}
#[test]
fn reasoning_capture_switch_does_not_change_provider_request_body() {
let config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
"https://example.com/v1".to_string(),
"secret".to_string(),
"model-a".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
DEFAULT_MAX_RETRIES,
DEFAULT_RETRY_BACKOFF_MS,
)
.expect("config should be valid");
let request = LlmRunRequest::single_turn("系统", "用户");
let normal = serde_json::to_value(build_request_body(&request, &config, false))
.expect("normal body should serialize");
let capture = serde_json::to_value(build_request_body(
&request.clone().with_reasoning_capture(true),
&config,
false,
))
.expect("capture body should serialize");
assert_eq!(normal, capture);
}
fn native_responses_output_fixture() -> Vec<serde_json::Value> {
vec![
serde_json::json!({
"type": "reasoning", "id": "rs_1", "summary": [],
"encrypted_content": "encrypted-reasoning-payload"
}),
serde_json::json!({
"type": "function_call", "id": "fc_1", "status": "completed",
"call_id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"杭州\"}"
}),
]
}
#[test]
fn native_responses_history_round_trips_reasoning_and_tool_result() {
let output = native_responses_output_fixture();
let response = parse_responses_response_with_capture(
LlmProvider::OpenAiCompatible,
"model",
false,
&serde_json::json!({"id":"resp_1", "status":"completed", "output":output}).to_string(),
)
.expect("native response");
assert_eq!(response.responses_output, output);
let mut input = vec![serde_json::json!({"role":"system", "content":"本轮新阶段指令"})];
input.extend(response.responses_output);
input.push(
serde_json::json!({"type":"function_call_output", "call_id":"call_1", "output":"晴"}),
);
let request = LlmRunRequest::new(Vec::new()).with_responses_input(input.clone());
request
.validate_for_transport()
.expect("native input needs no legacy messages");
let config = build_test_config("https://example.com/v1".to_string(), 0);
let body = serde_json::to_value(build_request_body(&request, &config, false))
.expect("request JSON");
assert_eq!(body["input"], serde_json::json!(input));
assert_eq!(body["store"], false);
assert_eq!(
body["include"],
serde_json::json!(["reasoning.encrypted_content"])
);
assert!(body.get("previous_response_id").is_none());
let legacy = serde_json::to_value(build_request_body(
&LlmRunRequest::single_turn("系统", "用户"),
&config,
false,
))
.expect("legacy request JSON");
assert!(legacy.get("store").is_none());
assert!(legacy.get("include").is_none());
}
#[tokio::test]
async fn native_responses_stream_preserves_terminal_output_for_replay() {
let mut output = native_responses_output_fixture();
output.push(serde_json::json!({
"type":"message", "id":"msg_1", "role":"assistant", "status":"completed",
"content":[{"type":"output_text", "text":"正在查询", "annotations":[]}]
}));
let body = format!(
"data: {}\n\ndata: {}\n\n",
serde_json::json!({"type":"response.output_text.delta", "delta":"正在"}),
serde_json::json!({"type":"response.completed", "response":{"output":output}})
);
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream",
body,
extra_headers: Vec::new(),
}]);
let response = build_test_client(server_url, 0)
.stream_run(
weather_tool_request(LlmApiKind::OpenAiResponses).with_responses_input(vec![
serde_json::json!({"role":"user", "content":"查询天气"}),
]),
|_| {},
)
.await
.expect("native stream");
assert_eq!(response.responses_output, output);
assert_eq!(response.tool_calls[0].id, "call_1");
assert_eq!(response.text, "正在查询");
}
#[test]
fn native_responses_input_rejects_other_transports_and_invalid_items() {
let request = LlmRunRequest::new(Vec::new()).with_responses_input(vec![
serde_json::json!({"role":"user", "content":"查询天气"}),
]);
assert!(
request
.clone()
.with_openai_chat()
.validate_for_transport()
.is_err()
);
assert!(provider_request_from_llm_request("native", request).is_err());
assert!(
LlmRunRequest::new(Vec::new())
.with_responses_input(Vec::new())
.validate_for_transport()
.is_err()
);
assert!(
LlmRunRequest::new(Vec::new())
.with_responses_input(vec![serde_json::Value::Null])
.validate_for_transport()
.is_err()
);
}
#[test]
fn chat_request_body_uses_configured_token_budget_field_without_model_guessing() {
let legacy_config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
"https://legacy-gateway.example/v1".to_string(),
"secret".to_string(),
"legacy-chat-model".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
DEFAULT_MAX_RETRIES,
DEFAULT_RETRY_BACKOFF_MS,
)
.expect("config should be valid");
let modern_config = legacy_config
.clone()
.with_openai_chat_token_budget_field(OpenAiChatTokenBudgetField::MaxCompletionTokens);
let request = LlmRunRequest::single_turn("系统", "用户")
.with_openai_chat()
.with_model("gpt-5.4-mini")
.with_max_output_tokens(256);
let legacy_json = serde_json::to_value(build_request_body(&request, &legacy_config, false))
.expect("legacy body should serialize");
assert_eq!(legacy_json["model"], "gpt-5.4-mini");
assert_eq!(legacy_json["max_tokens"], 256);
assert!(legacy_json.get("max_completion_tokens").is_none());
let modern_json = serde_json::to_value(build_request_body(&request, &modern_config, false))
.expect("modern body should serialize");
assert_eq!(modern_json["model"], "gpt-5.4-mini");
assert_eq!(modern_json["max_completion_tokens"], 256);
assert!(modern_json.get("max_tokens").is_none());
}
#[test]
fn responses_and_anthropic_token_budget_wire_fields_remain_unchanged() {
let config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
"https://example.com/v1".to_string(),
"secret".to_string(),
"model-a".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
DEFAULT_MAX_RETRIES,
DEFAULT_RETRY_BACKOFF_MS,
)
.expect("config should be valid")
.with_openai_chat_token_budget_field(OpenAiChatTokenBudgetField::MaxCompletionTokens);
let base_request = LlmRunRequest::single_turn("系统", "用户").with_max_output_tokens(384);
let responses_json = serde_json::to_value(build_request_body(
&base_request.clone().with_openai_responses(),
&config,
false,
))
.expect("Responses body should serialize");
assert_eq!(responses_json["max_output_tokens"], 384);
assert!(responses_json.get("max_completion_tokens").is_none());
assert!(responses_json.get("max_tokens").is_none());
let anthropic_json = serde_json::to_value(build_request_body(
&base_request.with_anthropic(),
&config,
false,
))
.expect("Anthropic body should serialize");
assert_eq!(anthropic_json["max_tokens"], 384);
assert!(anthropic_json.get("max_completion_tokens").is_none());
assert!(anthropic_json.get("max_output_tokens").is_none());
}
#[test]
fn run_request_rejects_tool_choice_without_function_tools() {
let error = LlmRunRequest::single_turn("系统", "用户")
.with_tool_choice(LlmToolChoice::Required)
.validate()
.expect_err("tool choice without tools should fail");
assert_eq!(
error,
LlmError::InvalidRequest("LLM tool_choice 必须与 function_tools 一起使用".to_string())
);
}
#[test]
fn anthropic_request_body_maps_function_tools_to_input_schema() {
let config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
"https://example.com/anthropic".to_string(),
"secret".to_string(),
"model-a".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
DEFAULT_MAX_RETRIES,
DEFAULT_RETRY_BACKOFF_MS,
)
.expect("config should be valid");
let request = LlmRunRequest::single_turn("系统", "用户")
.with_anthropic()
.with_function_tools(vec![
LlmFunctionTool::new(
"get_weather",
"查询天气",
serde_json::json!({
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"],
"additionalProperties": false
}),
)
.with_strict(true),
])
.with_tool_choice(LlmToolChoice::Required);
request.validate().expect("anthropic tools should validate");
let body = build_request_body(&request, &config, false);
let json = serde_json::to_value(&body).expect("body should serialize");
assert_eq!(json["tools"][0]["name"], "get_weather");
assert_eq!(json["tools"][0]["description"], "查询天气");
assert_eq!(json["tools"][0]["input_schema"]["type"], "object");
// apiKind 不能证明 endpoint/model 支持 strict,未显式声明 capability 时必须关闭。
assert!(json["tools"][0].get("parameters").is_none());
assert!(json["tools"][0].get("strict").is_none());
assert_eq!(
json["tools"][0]["cache_control"],
serde_json::json!({"type": "ephemeral"})
);
// tool_choice 必须是对象;Required 对应 Anthropic 的 any。
assert_eq!(json["tool_choice"], serde_json::json!({ "type": "any" }));
}
#[test]
fn anthropic_strict_uses_transformed_schema_without_mutating_the_original() {
let config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
"https://example.com/anthropic".to_string(),
"secret".to_string(),
"model-a".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
DEFAULT_MAX_RETRIES,
DEFAULT_RETRY_BACKOFF_MS,
)
.expect("config should be valid")
.with_anthropic_strict_tool_support(true);
let request = LlmRunRequest::single_turn("系统", "用户")
.with_anthropic()
.with_function_tools(vec![
LlmFunctionTool::new(
"bounded_text",
"包含 Anthropic strict 暂不支持的长度约束",
serde_json::json!({
"type": "object",
"properties": {
"value": {"type": "string", "minLength": 1},
"steps": {
"type": "array",
"minItems": 1,
"maxItems": 8,
"items": {"type": "string", "maxLength": 240}
}
},
"required": ["value", "steps"],
"additionalProperties": false
}),
)
.with_strict(true),
LlmFunctionTool::new(
"plain_text",
"支持严格模式的简单 schema",
serde_json::json!({
"type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"],
"additionalProperties": false
}),
)
.with_strict(true),
]);
let json = serde_json::to_value(build_request_body(&request, &config, false))
.expect("body should serialize");
assert_eq!(json["tools"][0]["strict"], true);
assert!(
json["tools"][0]["input_schema"]["properties"]["value"]
.get("minLength")
.is_none()
);
assert_eq!(
request.function_tools[0].parameters["properties"]["value"]["minLength"],
1
);
assert_eq!(
json["tools"][0]["input_schema"]["properties"]["steps"]["minItems"],
1
);
assert!(
json["tools"][0]["input_schema"]["properties"]["steps"]
.get("maxItems")
.is_none()
);
assert_eq!(
request.function_tools[0].parameters["properties"]["steps"]["maxItems"],
8
);
assert!(json["tools"][0].get("cache_control").is_none());
assert_eq!(json["tools"][1]["strict"], true);
assert_eq!(
json["tools"][1]["cache_control"],
serde_json::json!({"type": "ephemeral"})
);
}
#[test]
fn anthropic_request_model_override_does_not_reuse_config_scoped_strict_capability() {
let config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
"https://api.anthropic.com".to_string(),
"secret".to_string(),
"claude-sonnet-4-5".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
DEFAULT_MAX_RETRIES,
DEFAULT_RETRY_BACKOFF_MS,
)
.expect("config should be valid")
.with_anthropic_strict_tool_support(true);
let request = LlmRunRequest::single_turn("系统", "用户")
.with_anthropic()
.with_model("claude-3-5-sonnet-latest")
.with_function_tools(vec![
LlmFunctionTool::new(
"plain_text",
"simple schema",
serde_json::json!({
"type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"],
"additionalProperties": false
}),
)
.with_strict(true),
]);
let json = serde_json::to_value(build_request_body(&request, &config, false))
.expect("body should serialize");
assert!(json["tools"][0].get("strict").is_none());
}
#[test]
fn anthropic_strict_rejects_unclosed_objects_recursive_or_missing_refs_and_complex_enums() {
let schemas = [
serde_json::json!({"type": "object"}),
serde_json::json!({
"type": "object",
"$defs": {
"Node": {
"type": "object",
"properties": {"next": {"$ref": "#/$defs/Node"}},
"additionalProperties": false
}
},
"properties": {"node": {"$ref": "#/$defs/Node"}},
"required": ["node"],
"additionalProperties": false
}),
serde_json::json!({
"type": "object",
"properties": {"value": {"$ref": "#/$defs/Missing"}},
"required": ["value"],
"additionalProperties": false
}),
serde_json::json!({
"type": "object",
"properties": {"value": {"enum": [{"nested": true}]}},
"required": ["value"],
"additionalProperties": false
}),
];
for schema in schemas {
let tools = vec![LlmFunctionTool::new("unsafe", "unsafe", schema).with_strict(true)];
assert_eq!(anthropic_strict_transport_schemas(&tools, true), vec![None]);
}
let valid_ref = LlmFunctionTool::new(
"valid_ref",
"valid ref",
serde_json::json!({
"type": "object",
"$defs": {"Value": {"type": "string"}},
"properties": {"value": {"$ref": "#/$defs/Value"}},
"required": ["value"],
"additionalProperties": false
}),
)
.with_strict(true);
assert!(anthropic_strict_transport_schemas(&[valid_ref], true)[0].is_some());
}
#[test]
fn anthropic_strict_rejects_unknown_or_scope_changing_keywords() {
for (keyword, value) in [
("$id", serde_json::json!("nested.json")),
("$anchor", serde_json::json!("node")),
("dependentRequired", serde_json::json!({"value": ["other"]})),
] {
let mut schema = serde_json::json!({
"type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"],
"additionalProperties": false
});
schema
.as_object_mut()
.expect("schema should be an object")
.insert(keyword.to_string(), value);
let tools = vec![LlmFunctionTool::new("unsafe", "unsafe", schema).with_strict(true)];
assert_eq!(
anthropic_strict_transport_schemas(&tools, true),
vec![None],
"{keyword} must fail closed"
);
}
}
#[test]
fn anthropic_strict_does_not_interpret_refs_inside_default_data() {
let tool = LlmFunctionTool::new(
"default_payload",
"default payload",
serde_json::json!({
"type": "object",
"properties": {
"value": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false,
"default": {"$ref": "#/literal-data"}
}
},
"required": ["value"],
"additionalProperties": false
}),
)
.with_strict(true);
let transformed = anthropic_strict_transport_schemas(&[tool], true)[0]
.clone()
.expect("data-valued ref must not disable strict");
assert_eq!(
transformed["properties"]["value"]["default"]["$ref"],
"#/literal-data"
);
}
#[test]
fn anthropic_request_body_omits_tool_fields_without_tools() {
let config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
"https://example.com/anthropic".to_string(),
"secret".to_string(),
"model-a".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
DEFAULT_MAX_RETRIES,
DEFAULT_RETRY_BACKOFF_MS,
)
.expect("config should be valid");
let request = LlmRunRequest::single_turn("系统", "用户").with_anthropic();
let json = serde_json::to_value(build_request_body(&request, &config, false))
.expect("body should serialize");
assert!(json.get("tools").is_none());
assert!(json.get("tool_choice").is_none());
}
#[test]
fn anthropic_response_parses_tool_use_blocks_without_text() {
let raw = r#"{
"id": "msg_1",
"model": "model-a",
"stop_reason": "tool_use",
"content": [
{ "type": "tool_use", "id": "call_1", "name": "get_weather", "input": { "city": "杭州" } }
]
}"#;
let response =
parse_anthropic_response(LlmProvider::OpenAiCompatible, "fallback", false, raw)
.expect("tool-only response should parse");
assert_eq!(response.text, "");
assert!(response.reasoning.is_empty());
assert_eq!(response.finish_reason.as_deref(), Some("tool_use"));
assert_eq!(
response.tool_calls,
vec![LlmToolCall {
id: "call_1".to_string(),
name: "get_weather".to_string(),
arguments: r#"{"city":"杭州"}"#.to_string(),
}]
);
}
#[test]
fn anthropic_response_keeps_text_alongside_tool_use() {
let raw = r#"{
"id": "msg_2",
"model": "model-a",
"stop_reason": "tool_use",
"content": [
{ "type": "text", "text": "我来帮你查询。" },
{ "type": "tool_use", "id": "call_2", "name": "get_weather", "input": {} }
]
}"#;
let response =
parse_anthropic_response(LlmProvider::OpenAiCompatible, "fallback", false, raw)
.expect("mixed response should parse");
assert_eq!(response.text, "我来帮你查询。");
assert_eq!(response.tool_calls.len(), 1);
assert_eq!(response.tool_calls[0].arguments, "{}");
}
#[test]
fn anthropic_response_captures_thinking_only_when_enabled() {
let raw = r#"{
"id": "msg_thinking",
"model": "model-a",
"content": [
{ "type": "thinking", "thinking": "先分析需求。" },
{ "type": "text", "text": "最终答案" }
],
"stop_reason": "end_turn"
}"#;
let captured =
parse_anthropic_response(LlmProvider::OpenAiCompatible, "fallback", true, raw)
.expect("Anthropic thinking should parse");
assert_eq!(captured.text, "最终答案");
assert_eq!(captured.reasoning, "先分析需求。");
let hidden =
parse_anthropic_response(LlmProvider::OpenAiCompatible, "fallback", false, raw)
.expect("Anthropic response should parse without capture");
assert!(hidden.reasoning.is_empty());
}
#[test]
fn anthropic_response_without_text_or_tool_calls_is_empty() {
let raw = r#"{ "id": "msg_3", "model": "model-a", "content": [] }"#;
let error = parse_anthropic_response(LlmProvider::OpenAiCompatible, "fallback", false, raw)
.expect_err("empty content should fail");
assert_eq!(error, LlmError::EmptyResponse);
}
#[test]
fn responses_request_rejects_assistant_input_image() {
let error = LlmRunRequest::new(vec![LlmMessage::multimodal(
LlmMessageRole::Assistant,
vec![LlmMessageContentPart::InputImage {
image_url: "https://example.com/assistant.png".to_string(),
}],
)])
.with_openai_responses()
.validate()
.expect_err("Responses assistant image should fail locally");
assert_eq!(
error,
LlmError::InvalidRequest(
"system/assistant 消息不支持 input_image;图片必须放在 user 消息".to_string()
)
);
}
#[tokio::test]
async fn run_sends_official_fallback_for_openai_compatible_clients() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
let server_handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("request should connect");
let request_text = read_request(&mut stream);
write_response(
&mut stream,
MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: r#"{"id":"resp_openai_compatible","model":"gpt-5","output_text":"兼容成功","status":"completed"}"#.to_string(),
extra_headers: Vec::new(),
},
);
request_text
});
let config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
format!("http://{address}"),
"test-key".to_string(),
"gpt-5".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
0,
1,
)
.expect("config should be valid")
.with_official_fallback(true);
let client = LlmClient::new(config).expect("client should be created");
let response = client
.run(LlmRunRequest::single_turn("系统", "用户").with_openai_responses())
.await
.expect("run should succeed");
let request_text = server_handle.join().expect("server thread should join");
let request_body = request_text
.split("\r\n\r\n")
.nth(1)
.expect("request body should exist");
let request_json: serde_json::Value =
serde_json::from_str(request_body).expect("request body should be json");
assert_eq!(response.text, "兼容成功");
assert_eq!(request_json["official_fallback"], serde_json::json!(true));
}
#[tokio::test]
async fn client_without_redirects_does_not_replay_post_on_307() {
let redirect_listener = TcpListener::bind("127.0.0.1:0").expect("redirect listener");
let redirect_address = redirect_listener.local_addr().expect("redirect address");
let target_listener = TcpListener::bind("127.0.0.1:0").expect("target listener");
let target_address = target_listener.local_addr().expect("target address");
target_listener
.set_nonblocking(true)
.expect("target listener nonblocking");
let server_handle = thread::spawn(move || {
let (mut stream, _) = redirect_listener.accept().expect("redirect request");
let request = read_request(&mut stream);
write!(
stream,
"HTTP/1.1 307 Temporary Redirect\r\nLocation: http://{target_address}/responses\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
.expect("write redirect response");
request
});
let config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
format!("http://{redirect_address}"),
"test-key".to_string(),
"gpt-5".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
0,
1,
)
.expect("redirect test config");
let client =
LlmClient::new_without_redirects(config).expect("redirect-disabled client builds");
let error = client
.run(LlmRunRequest::single_turn("系统", "用户").with_openai_responses())
.await
.expect_err("307 must remain an upstream response");
assert!(matches!(
error,
LlmError::Upstream {
status_code: 307,
..
}
));
let source_request = server_handle.join().expect("redirect server joins");
assert_eq!(
source_request.matches("POST /responses HTTP/1.1").count(),
1
);
assert!(matches!(
target_listener.accept(),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock
));
}
#[test]
fn sse_parser_handles_split_chunks_and_done_marker() {
let mut parser = OpenAiCompatibleSseParser::new(LlmApiKind::OpenAiChat);
let events_a = parser
.push_chunk("data: {\"choices\":[{\"delta\":{\"content\":\"你\"}}]}\r\n\r\n")
.expect("first chunk should parse");
let events_b = parser
.push_chunk("data: {\"choices\":[{\"delta\":{\"content\":\"好\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")
.expect("second chunk should parse");
assert_eq!(events_a.len(), 1);
assert_eq!(events_a[0].delta_text.as_deref(), Some("你"));
assert_eq!(events_b.len(), 2);
assert_eq!(events_b[0].delta_text.as_deref(), Some("好"));
assert_eq!(events_b[0].finish_reason.as_deref(), Some("stop"));
assert!(!events_b[0].is_terminal);
assert!(events_b[1].is_terminal);
}
#[test]
fn sse_parser_preserves_events_before_malformed_tail_in_same_chunk() {
let mut parser = OpenAiCompatibleSseParser::new(LlmApiKind::OpenAiChat);
let error = parser
.push_chunk(concat!(
"data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\n",
"data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n",
"data: {\"choices\":[malformed]}\n\n"
))
.expect_err("malformed tail should retain the earlier parsed events");
assert_eq!(error.parsed_events.len(), 2);
assert_eq!(error.parsed_events[0].delta_text.as_deref(), Some("你好"));
assert_eq!(
error.parsed_events[1].finish_reason.as_deref(),
Some("stop")
);
assert!(matches!(error.error, LlmError::Deserialize(_)));
}
#[test]
fn responses_sse_parser_only_emits_output_text_delta() {
let mut parser = OpenAiCompatibleSseParser::new(LlmApiKind::OpenAiResponses);
let events = parser
.push_chunk(concat!(
"data: {\"type\":\"response.created\"}\n\n",
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"你\"}\n\n",
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"好\"}\n\n",
"data: {\"type\":\"response.completed\"}\n\n",
))
.expect("responses stream should parse");
assert_eq!(events.len(), 3);
assert_eq!(events[0].delta_text.as_deref(), Some("你"));
assert_eq!(events[1].delta_text.as_deref(), Some("好"));
assert_eq!(events[2].finish_reason.as_deref(), Some("completed"));
}
#[test]
fn responses_sse_parser_reads_failure_message_from_response_error() {
let error = parse_responses_sse_event(
r#"{"type":"response.failed","response":{"status":"failed","error":{"code":"insufficient_mud_points","message":"泥点余额不足"}}}"#,
)
.expect_err("response.failed should become an upstream error");
assert_eq!(
error,
LlmError::Upstream {
status_code: 502,
message: "泥点余额不足".to_string(),
}
);
}
#[test]
fn decode_utf8_stream_chunk_preserves_incomplete_multibyte_suffix() {
let full_bytes = "你好".as_bytes();
let first_result = decode_utf8_stream_chunk(&full_bytes[..2])
.expect("incomplete utf-8 chunk should be buffered");
assert_eq!(first_result.0, "");
assert_eq!(first_result.1, full_bytes[..2].to_vec());
let mut combined = first_result.1;
combined.extend_from_slice(&full_bytes[2..]);
let second_result = decode_utf8_stream_chunk(combined.as_slice())
.expect("completed utf-8 bytes should decode");
assert_eq!(second_result.0, "你好");
assert!(second_result.1.is_empty());
}
#[tokio::test]
async fn run_parses_chat_completions_non_stream_response() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: r#"{"id":"resp_01","model":"ark-test-model","choices":[{"message":{"content":"测试成功"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":6,"total_tokens":16}}"#.to_string(),
extra_headers: Vec::new(),
}]);
let client = build_test_client(server_url, 0);
let response = client
.run(LlmRunRequest::single_turn("系统", "用户").with_openai_chat())
.await
.expect("run should succeed");
assert_eq!(response.provider, LlmProvider::Ark);
assert_eq!(response.model, "ark-test-model");
assert_eq!(response.text, "测试成功");
assert_eq!(response.finish_reason.as_deref(), Some("stop"));
assert_eq!(response.response_id.as_deref(), Some("resp_01"));
assert_eq!(
response.usage,
Some(LlmTokenUsage {
prompt_tokens: 10,
completion_tokens: 6,
total_tokens: 16,
})
);
}
#[test]
fn chat_response_excludes_standalone_reasoning_fields_from_text() {
let response = parse_chat_completions_response_with_capture(
LlmProvider::OpenAiCompatible,
"fallback-model",
false,
r#"{"id":"chat_reasoning_fields","choices":[{"message":{"reasoning_content":"内部推理","reasoning":"内部分析","content":null,"tool_calls":[{"id":"call_noop","type":"function","function":{"name":"noop","arguments":"{}"}}]},"finish_reason":"tool_calls"}]}"#,
)
.expect("tool call should keep the response valid without visible content");
assert_eq!(response.text, "");
assert!(response.reasoning.is_empty());
assert_eq!(response.tool_calls.len(), 1);
}
#[test]
fn chat_response_captures_reasoning_without_mixing_into_text() {
let response = parse_chat_completions_response_with_capture(
LlmProvider::OpenAiCompatible,
"fallback-model",
true,
r#"{"choices":[{"message":{"reasoning_content":"先分析。","content":[{"type":"reasoning","text":"再检查。"},{"type":"text","text":"答案"}]},"finish_reason":"stop"}]}"#,
)
.expect("chat reasoning should parse");
assert_eq!(response.text, "答案");
assert_eq!(response.reasoning, "先分析。再检查。");
}
#[test]
fn chat_response_filters_reasoning_parts_and_preserves_visible_parts() {
let response = parse_chat_completions_response_with_capture(
LlmProvider::OpenAiCompatible,
"fallback-model",
false,
r#"{"id":"chat_content_parts","choices":[{"message":{"content":[{"type":"reasoning","text":"内部推理"},{"type":"analysis","text":"内部分析"},{"type":"reasoning_content","text":"内部推理补充"},{"type":"thinking","text":"内部思考"},{"type":"text","text":"可见"},{"type":"output_text","text":"答案"}]},"finish_reason":"stop"}]}"#,
)
.expect("visible chat content parts should parse");
assert_eq!(response.text, "可见答案");
}
#[test]
fn chat_response_preserves_visible_content_with_tool_calls() {
let response = parse_chat_completions_response_with_capture(
LlmProvider::OpenAiCompatible,
"fallback-model",
false,
r#"{"id":"chat_visible_tool_call","choices":[{"message":{"content":[{"type":"analysis","text":"内部分析"},{"type":"text","text":"先检查项目。"}],"tool_calls":[{"id":"call_project_index","type":"function","function":{"name":"project_index","arguments":"{\"path\":\"/tmp/game\"}"}}]},"finish_reason":"tool_calls"}]}"#,
)
.expect("chat response with visible content and tool calls should parse");
assert_eq!(response.text, "先检查项目。");
assert_eq!(
response.tool_calls,
vec![LlmToolCall {
id: "call_project_index".to_string(),
name: "project_index".to_string(),
arguments: r#"{"path":"/tmp/game"}"#.to_string(),
}]
);
}
#[test]
fn responses_response_filters_reasoning_parts_and_preserves_output_text() {
let response = parse_responses_response_with_capture(
LlmProvider::OpenAiCompatible,
"fallback-model",
false,
r#"{"id":"responses_content_parts","output":[{"type":"message","content":[{"type":"analysis","text":"内部分析"},{"type":"output_text","text":"最终答案"}]}],"status":"completed"}"#,
)
.expect("visible Responses content parts should parse");
assert_eq!(response.text, "最终答案");
}
#[test]
fn responses_response_captures_reasoning_summary() {
let response = parse_responses_response_with_capture(
LlmProvider::OpenAiCompatible,
"fallback-model",
true,
r#"{"id":"resp_reasoning","output":[{"type":"reasoning","summary":[{"type":"summary_text","text":"先判断。"},{"type":"summary_text","text":"再回答。"}]},{"type":"message","content":[{"type":"output_text","text":"答案"}]}],"status":"completed"}"#,
)
.expect("Responses reasoning should parse");
assert_eq!(response.text, "答案");
assert_eq!(response.reasoning, "先判断。再回答。");
}
#[test]
fn responses_response_captures_reasoning_text_content_from_persisted_output() {
let response = parse_responses_response_with_capture(
LlmProvider::OpenAiCompatible,
"fallback-model",
true,
r#"{"id":"resp_reasoning_text","output":[{"type":"reasoning","summary":[],"content":[{"type":"reasoning_text","text":"先分析需求,再组织方案。"}],"encrypted_content":"opaque"},{"type":"message","content":[{"type":"output_text","text":"正文"}]}],"status":"completed"}"#,
)
.expect("Responses reasoning_text should parse");
assert_eq!(response.text, "正文");
assert_eq!(response.reasoning, "先分析需求,再组织方案。");
}
#[test]
fn responses_response_captures_reasoning_alongside_tool_call() {
let response = parse_responses_response_with_capture(
LlmProvider::OpenAiCompatible,
"fallback-model",
true,
r#"{"id":"resp_reasoning_tool","output":[{"type":"reasoning","summary":[{"type":"summary_text","text":"先分析工具需求。"}]},{"type":"message","content":[{"type":"output_text","text":"我先查询。"}]},{"type":"function_call","call_id":"call_lookup","name":"lookup","arguments":"{\"query\":\"项目\"}"}],"status":"completed"}"#,
)
.expect("Responses reasoning plus tool call should parse");
assert_eq!(response.text, "我先查询。");
assert_eq!(response.reasoning, "先分析工具需求。");
assert_eq!(
response.tool_calls,
vec![LlmToolCall {
id: "call_lookup".to_string(),
name: "lookup".to_string(),
arguments: r#"{"query":"项目"}"#.to_string(),
}]
);
}
#[test]
fn stream_events_capture_reasoning_delta_and_terminal_snapshot() {
let chat = parse_sse_event_block(
LlmApiKind::OpenAiChat,
r#"data: {"choices":[{"delta":{"reasoning_content":"思考","content":"答案"}}]}"#,
)
.expect("chat SSE should parse")
.expect("chat event should exist");
assert_eq!(chat.reasoning_delta.as_deref(), Some("思考"));
assert_eq!(chat.delta_text.as_deref(), Some("答案"));
let responses = parse_sse_event_block(
LlmApiKind::OpenAiResponses,
r#"data: {"type":"response.reasoning_summary_text.delta","summary_index":2,"delta":"推理"}"#,
)
.expect("Responses SSE should parse")
.expect("Responses event should exist");
assert_eq!(responses.reasoning_delta.as_deref(), Some("推理"));
assert_eq!(responses.reasoning_summary_index, Some(2));
let terminal = parse_sse_event_block(
LlmApiKind::OpenAiResponses,
r#"data: {"type":"response.completed","response":{"output":[{"type":"reasoning","summary":[{"type":"summary_text","text":"完整推理"}]},{"type":"message","content":[{"type":"output_text","text":"正文"}]}]}}"#,
)
.expect("terminal SSE should parse")
.expect("terminal event should exist");
assert_eq!(terminal.reasoning_snapshot.as_deref(), Some("完整推理"));
assert_eq!(terminal.text_snapshot.as_deref(), Some("正文"));
}
#[tokio::test]
async fn run_accepts_chat_tool_calls_without_text_content() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: r#"{"id":"chat_tool_01","model":"gpt-5","choices":[{"message":{"content":null,"tool_calls":[{"id":"call_project_index","type":"function","function":{"name":"project_index","arguments":"{\"path\":\"/tmp/game\"}"}}]},"finish_reason":"tool_calls"}]}"#.to_string(),
extra_headers: Vec::new(),
}]);
let client = build_test_client(server_url, 0);
let response = client
.run(
LlmRunRequest::single_turn("系统", "索引项目")
.with_openai_chat()
.with_function_tools(vec![LlmFunctionTool::new(
"project_index",
"索引指定项目目录",
serde_json::json!({ "type": "object" }),
)]),
)
.await
.expect("tool-call-only chat response should succeed");
assert_eq!(response.text, "");
assert_eq!(response.finish_reason.as_deref(), Some("tool_calls"));
assert_eq!(
response.tool_calls,
vec![LlmToolCall {
id: "call_project_index".to_string(),
name: "project_index".to_string(),
arguments: r#"{"path":"/tmp/game"}"#.to_string(),
}]
);
}
#[tokio::test]
async fn run_retries_after_upstream_500() {
let server_url = spawn_mock_server(vec![
MockResponse {
status_line: "500 Internal Server Error",
content_type: "application/json; charset=utf-8",
body: r#"{"error":{"message":"temporary upstream failure"}}"#.to_string(),
extra_headers: Vec::new(),
},
MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: r#"{"id":"resp_retry","choices":[{"message":{"content":"第二次成功"},"finish_reason":"stop"}]}"#.to_string(),
extra_headers: Vec::new(),
},
]);
let client = build_test_client(server_url, 1);
let response = client
.run(LlmRunRequest::single_turn("系统", "用户").with_openai_chat())
.await
.expect("second attempt should succeed");
assert_eq!(response.text, "第二次成功");
assert_eq!(response.response_id.as_deref(), Some("resp_retry"));
}
#[tokio::test]
async fn run_reports_the_successful_attempt_when_response_body_times_out() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
let server_handle = thread::spawn(move || {
let (mut first_stream, _) = listener.accept().expect("first request should connect");
let _ = read_request(&mut first_stream);
write_response(
&mut first_stream,
MockResponse {
status_line: "500 Internal Server Error",
content_type: "application/json; charset=utf-8",
body: r#"{"error":{"message":"temporary upstream failure"}}"#.to_string(),
extra_headers: Vec::new(),
},
);
let (mut second_stream, _) = listener.accept().expect("second request should connect");
let _ = read_request(&mut second_stream);
second_stream
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 128\r\nConnection: close\r\n\r\n",
)
.expect("response headers should be written");
second_stream
.flush()
.expect("response headers should be flushed");
thread::sleep(StdDuration::from_millis(200));
});
let config = LlmConfig::new(
LlmProvider::Ark,
format!("http://{address}"),
"test-key".to_string(),
"test-model".to_string(),
50,
1,
1,
)
.expect("config should be valid");
let client = LlmClient::new(config).expect("client should be created");
let error = client
.run(LlmRunRequest::single_turn("系统", "用户").with_openai_chat())
.await
.expect_err("the second response body should time out");
assert_eq!(error, LlmError::Timeout { attempts: 2 });
server_handle.join().expect("mock server should finish");
}
#[tokio::test]
async fn run_uses_request_level_timeout_override() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("request should connect");
let _ = read_request(&mut stream);
thread::sleep(StdDuration::from_millis(200));
write_response(
&mut stream,
MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body:
r#"{"choices":[{"message":{"content":"too late"},"finish_reason":"stop"}]}"#
.to_string(),
extra_headers: Vec::new(),
},
);
});
let config = LlmConfig::new(
LlmProvider::Ark,
format!("http://{address}"),
"test-key".to_string(),
"test-model".to_string(),
10_000,
0,
1,
)
.expect("config should be valid");
let client = LlmClient::new(config).expect("client should be created");
let error = client
.run(
LlmRunRequest::single_turn("系统", "用户")
.with_openai_chat()
.with_request_timeout_ms(20),
)
.await
.expect_err("request override should timeout before the global timeout");
assert_eq!(error, LlmError::Timeout { attempts: 1 });
}
#[tokio::test]
async fn run_sends_web_search_options_when_enabled() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
let server_handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("request should connect");
let request_text = read_request(&mut stream);
write_response(
&mut stream,
MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: r#"{"id":"resp_search","model":"test-model","choices":[{"message":{"content":"搜索成功"},"finish_reason":"stop"}]}"#.to_string(),
extra_headers: Vec::new(),
},
);
request_text
});
let client = build_test_client(format!("http://{address}"), 0);
let response = client
.run(
LlmRunRequest::single_turn("系统", "用户")
.with_openai_chat()
.with_web_search(true)
.with_max_output_tokens(128),
)
.await
.expect("run should succeed");
let request_text = server_handle.join().expect("server thread should join");
let request_body = request_text
.split("\r\n\r\n")
.nth(1)
.expect("request body should exist");
let request_json: serde_json::Value =
serde_json::from_str(request_body).expect("request body should be json");
assert_eq!(response.text, "搜索成功");
assert_eq!(request_json["web_search_options"], serde_json::json!({}));
assert_eq!(request_json["max_tokens"], 128);
assert!(request_json.get("max_completion_tokens").is_none());
assert!(request_json.get("official_fallback").is_none());
}
#[tokio::test]
async fn chat_completions_request_sends_native_function_tools_and_choice() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
let server_handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("request should connect");
let request_text = read_request(&mut stream);
write_response(
&mut stream,
MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: r#"{"choices":[{"message":{"content":"工具请求已接收"},"finish_reason":"stop"}]}"#
.to_string(),
extra_headers: Vec::new(),
},
);
request_text
});
let client = build_test_client(format!("http://{address}"), 0);
let response = client
.run(
LlmRunRequest::single_turn("系统", "索引项目")
.with_openai_chat()
.with_function_tools(vec![
LlmFunctionTool::new(
"project_index",
"索引指定项目目录",
serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string" }
},
"required": ["path"],
"additionalProperties": false
}),
)
.with_strict(true),
])
.with_tool_choice(LlmToolChoice::Required),
)
.await
.expect("chat function request should succeed");
let request_text = server_handle.join().expect("server thread should join");
let request_body = request_text
.split("\r\n\r\n")
.nth(1)
.expect("request body should exist");
let request_json: serde_json::Value =
serde_json::from_str(request_body).expect("request body should be json");
assert_eq!(response.text, "工具请求已接收");
assert_eq!(request_json["tool_choice"], "required");
assert_eq!(
request_json["tools"],
serde_json::json!([{
"type": "function",
"function": {
"name": "project_index",
"description": "索引指定项目目录",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string" }
},
"required": ["path"],
"additionalProperties": false
},
"strict": true
}
}])
);
}
#[tokio::test]
async fn chat_completions_multimodal_request_sends_text_and_image_url_parts() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
let server_handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("request should connect");
let request_text = read_request(&mut stream);
write_response(
&mut stream,
MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: r#"{"id":"chat_multimodal","model":"gpt-4o-mini","choices":[{"message":{"content":"{\"levelName\":\"雨夜猫街\"}"},"finish_reason":"stop"}]}"#.to_string(),
extra_headers: Vec::new(),
},
);
request_text
});
let config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
format!("http://{address}"),
"test-key".to_string(),
"gpt-4o-mini".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
0,
1,
)
.expect("config should be valid")
.with_openai_chat_token_budget_field(OpenAiChatTokenBudgetField::MaxCompletionTokens)
.with_official_fallback(true);
let client = LlmClient::new(config).expect("client should be created");
let response = client
.run(
LlmRunRequest::new(vec![
LlmMessage::system("你是拼图关卡命名编辑"),
LlmMessage::user_multimodal(vec![
LlmMessageContentPart::InputText {
text: "画面描述:一只猫在雨夜灯牌下回头。".to_string(),
},
LlmMessageContentPart::InputImage {
image_url: "data:image/png;base64,abcd".to_string(),
},
]),
])
.with_openai_chat()
.with_max_output_tokens(256)
.with_response_reasoning_effort(LlmResponseReasoningEffort::Low),
)
.await
.expect("run should succeed");
let request_text = server_handle.join().expect("server thread should join");
let request_line = request_text.lines().next().unwrap_or_default();
let request_body = request_text
.split("\r\n\r\n")
.nth(1)
.expect("request body should exist");
let request_json: serde_json::Value =
serde_json::from_str(request_body).expect("request body should be json");
assert!(request_line.contains("POST /chat/completions HTTP/1.1"));
assert_eq!(response.model, "gpt-4o-mini");
assert_eq!(response.text, r#"{"levelName":"雨夜猫街"}"#);
assert_eq!(request_json["official_fallback"], serde_json::json!(true));
assert_eq!(request_json["max_completion_tokens"], 256);
assert!(request_json.get("max_tokens").is_none());
assert_eq!(request_json["reasoning_effort"], "low");
assert_eq!(
request_json["messages"][1]["content"],
serde_json::json!([
{ "type": "text", "text": "画面描述:一只猫在雨夜灯牌下回头。" },
{ "type": "image_url", "image_url": { "url": "data:image/png;base64,abcd" } }
])
);
}
#[tokio::test]
async fn run_sends_responses_body_with_web_search_and_function_tools() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
let server_handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("request should connect");
let request_text = read_request(&mut stream);
write_response(
&mut stream,
MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: r#"{"id":"resp_responses","model":"deepseek-v3-2-251201","output_text":"Responses 成功","status":"completed","usage":{"input_tokens":9,"output_tokens":4,"total_tokens":13}}"#.to_string(),
extra_headers: Vec::new(),
},
);
request_text
});
let client = build_test_client(format!("http://{address}"), 0);
let response = client
.run(
LlmRunRequest::single_turn("系统", "用户")
.with_model("deepseek-v3-2-251201")
.with_openai_responses()
.with_web_search(true)
.with_function_tools(vec![
LlmFunctionTool::new(
"asset_list",
"列出项目素材",
serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
)
.with_strict(true),
])
.with_tool_choice(LlmToolChoice::Auto)
.with_response_reasoning_effort(LlmResponseReasoningEffort::Max)
.with_response_text_verbosity(LlmResponseTextVerbosity::Low)
.with_max_output_tokens(128),
)
.await
.expect("responses run should succeed");
let request_text = server_handle.join().expect("server thread should join");
let request_line = request_text.lines().next().unwrap_or_default();
let request_body = request_text
.split("\r\n\r\n")
.nth(1)
.expect("request body should exist");
let request_json: serde_json::Value =
serde_json::from_str(request_body).expect("request body should be json");
assert!(request_line.contains("POST /responses HTTP/1.1"));
assert_eq!(response.text, "Responses 成功");
assert_eq!(response.model, "deepseek-v3-2-251201");
assert_eq!(
response.usage,
Some(LlmTokenUsage {
prompt_tokens: 9,
completion_tokens: 4,
total_tokens: 13,
})
);
assert_eq!(
request_json["model"],
serde_json::json!("deepseek-v3-2-251201")
);
assert_eq!(request_json["stream"], serde_json::json!(false));
assert_eq!(
request_json["tools"],
serde_json::json!([
{ "type": "web_search", "max_keyword": 3 },
{
"type": "function",
"name": "asset_list",
"description": "列出项目素材",
"parameters": {
"type": "object",
"properties": {},
"additionalProperties": false
},
"strict": true
}
])
);
assert_eq!(request_json["tool_choice"], "auto");
assert_eq!(
request_json["reasoning"],
serde_json::json!({ "effort": "max" })
);
assert_eq!(
request_json["text"],
serde_json::json!({ "verbosity": "low" })
);
assert!(request_json.get("official_fallback").is_none());
assert_eq!(
request_json["input"][0]["content"][0],
serde_json::json!({ "type": "input_text", "text": "系统" })
);
}
#[tokio::test]
async fn responses_request_maps_assistant_text_to_output_text() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
let server_handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("request should connect");
let request_text = read_request(&mut stream);
write_response(
&mut stream,
MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: r#"{"id":"resp_repair","model":"gpt-5","output_text":"修复成功","status":"completed"}"#
.to_string(),
extra_headers: Vec::new(),
},
);
request_text
});
let client = build_test_client(format!("http://{address}"), 0);
client
.run(
LlmRunRequest::new(vec![
LlmMessage::system("系统约束"),
LlmMessage::user("原始请求"),
LlmMessage::assistant("需要修复的计划预览"),
LlmMessage::user("请修复格式"),
])
.with_openai_responses(),
)
.await
.expect("Responses repair request should succeed");
let request_text = server_handle.join().expect("server thread should join");
let request_body = request_text
.split("\r\n\r\n")
.nth(1)
.expect("request body should exist");
let request_json: serde_json::Value =
serde_json::from_str(request_body).expect("request body should be json");
assert_eq!(
request_json["input"],
serde_json::json!([
{
"role": "system",
"content": [{ "type": "input_text", "text": "系统约束" }]
},
{
"role": "user",
"content": [{ "type": "input_text", "text": "原始请求" }]
},
{
"role": "assistant",
"content": [{ "type": "output_text", "text": "需要修复的计划预览" }]
},
{
"role": "user",
"content": [{ "type": "input_text", "text": "请修复格式" }]
}
])
);
}
#[tokio::test]
async fn run_accepts_responses_function_call_without_output_text() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: r#"{"id":"resp_tool_01","model":"gpt-5","output":[{"type":"function_call","id":"fc_asset_list","call_id":"call_asset_list","name":"asset_list","arguments":"{}","status":"completed"}],"status":"completed"}"#.to_string(),
extra_headers: Vec::new(),
}]);
let client = build_test_client(server_url, 0);
let response = client
.run(
LlmRunRequest::single_turn("系统", "列出素材")
.with_openai_responses()
.with_function_tools(vec![LlmFunctionTool::new(
"asset_list",
"列出项目素材",
serde_json::json!({ "type": "object" }),
)]),
)
.await
.expect("function-call-only Responses output should succeed");
assert_eq!(response.text, "");
assert_eq!(response.finish_reason.as_deref(), Some("completed"));
assert_eq!(
response.tool_calls,
vec![LlmToolCall {
id: "call_asset_list".to_string(),
name: "asset_list".to_string(),
arguments: "{}".to_string(),
}]
);
}
#[tokio::test]
async fn pure_text_chat_and_responses_outputs_remain_backward_compatible() {
let server_url = spawn_mock_server(vec![
MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: r#"{"id":"chat_text","model":"gpt-5","choices":[{"message":{"content":"Chat 纯文本","reasoning_content":null,"tool_calls":null},"finish_reason":"stop","native_finish_reason":"stop"}]}"#.to_string(),
extra_headers: Vec::new(),
},
MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: r#"{"id":"responses_text","model":"gpt-5","output_text":"Responses 纯文本","status":"completed"}"#.to_string(),
extra_headers: Vec::new(),
},
]);
let client = build_test_client(server_url, 0);
let chat_response = client
.run(LlmRunRequest::single_turn("系统", "用户").with_openai_chat())
.await
.expect("plain chat response should succeed");
let responses_response = client
.run(LlmRunRequest::single_turn("系统", "用户").with_openai_responses())
.await
.expect("plain Responses response should succeed");
assert_eq!(chat_response.text, "Chat 纯文本");
assert!(chat_response.tool_calls.is_empty());
assert_eq!(responses_response.text, "Responses 纯文本");
assert!(responses_response.tool_calls.is_empty());
}
#[tokio::test]
async fn responses_multimodal_request_sends_input_text_and_input_image() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
let server_handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("request should connect");
let request_text = read_request(&mut stream);
write_response(
&mut stream,
MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: r#"{"id":"resp_multimodal","model":"gpt-5","output_text":"多模态成功","status":"completed"}"#.to_string(),
extra_headers: Vec::new(),
},
);
request_text
});
let client = build_test_client(format!("http://{address}"), 0);
let response = client
.run(
LlmRunRequest::new(vec![
LlmMessage::system("你是创意互动内容生成 Agent"),
LlmMessage::user_multimodal(vec![
LlmMessageContentPart::InputText {
text: "把这张图做成拼图".to_string(),
},
LlmMessageContentPart::InputImage {
image_url: "https://example.com/ref.png".to_string(),
},
]),
])
.with_model("gpt-5")
.with_openai_responses(),
)
.await
.expect("responses multimodal run should succeed");
let request_text = server_handle.join().expect("server thread should join");
let request_body = request_text
.split("\r\n\r\n")
.nth(1)
.expect("request body should exist");
let request_json: serde_json::Value =
serde_json::from_str(request_body).expect("request body should be json");
assert_eq!(response.model, "gpt-5");
assert_eq!(request_json["model"], serde_json::json!("gpt-5"));
assert!(request_json.get("official_fallback").is_none());
assert_eq!(
request_json["input"][1]["content"],
serde_json::json!([
{ "type": "input_text", "text": "把这张图做成拼图" },
{ "type": "input_image", "image_url": "https://example.com/ref.png" }
])
);
}
#[tokio::test]
async fn stream_run_accumulates_sse_response() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
"data: {\"choices\":[{\"delta\":{\"content\":\"你\"}}]}\n\n",
"data: {\"choices\":[]}\n\n",
"data: {\"choices\":null}\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"好\"}}]}\n\n",
"data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n",
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":2,\"total_tokens\":4}}\n\n",
"data: [DONE]\n\n"
)
.to_string(),
extra_headers: vec![("x-request-id", "req_stream_01")],
}]);
let client = build_test_client(server_url, 0);
let mut updates = Vec::new();
let response = client
.stream_run(
LlmRunRequest::single_turn("系统", "用户").with_openai_chat(),
|delta| {
updates.push(delta.accumulated_text.clone());
},
)
.await
.expect("stream_run should succeed");
assert_eq!(
updates,
vec!["你".to_string(), "你好".to_string(), "你好".to_string()]
);
assert_eq!(response.text, "你好");
assert_eq!(response.finish_reason.as_deref(), Some("stop"));
assert_eq!(response.response_id.as_deref(), Some("req_stream_01"));
assert_eq!(
response.usage,
Some(LlmTokenUsage {
prompt_tokens: 2,
completion_tokens: 2,
total_tokens: 4,
})
);
}
#[tokio::test]
async fn stream_run_keeps_completed_chat_response_before_malformed_tail() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
"data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\n",
"data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n",
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":1,\"total_tokens\":3}}\n\n",
"data: {\"choices\":[malformed]}\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let client = build_test_client(server_url, 0);
let response = client
.stream_run(
LlmRunRequest::single_turn("系统", "用户").with_openai_chat(),
|_| {},
)
.await
.expect("completed response should survive a malformed SSE tail");
assert_eq!(response.text, "你好");
assert_eq!(response.finish_reason.as_deref(), Some("stop"));
assert_eq!(
response.usage,
Some(LlmTokenUsage {
prompt_tokens: 2,
completion_tokens: 1,
total_tokens: 3,
})
);
}
#[tokio::test]
async fn stream_run_keeps_completed_chat_response_after_body_read_tail_error() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
let server_handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("request should connect");
read_request(&mut stream);
let completed_sse = concat!(
"data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\n",
"data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n"
);
let raw_response = format!(
concat!(
"HTTP/1.1 200 OK\r\n",
"Content-Type: text/event-stream; charset=utf-8\r\n",
"Transfer-Encoding: chunked\r\n",
"Connection: close\r\n\r\n",
"{:X}\r\n{}\r\n",
"not-a-chunk-size\r\n"
),
completed_sse.len(),
completed_sse
);
stream
.write_all(raw_response.as_bytes())
.expect("malformed chunked response should be written");
stream.flush().expect("stream response should flush");
});
let client = build_test_client(format!("http://{address}"), 0);
let response = client
.stream_run(
LlmRunRequest::single_turn("系统", "用户").with_openai_chat(),
|_| {},
)
.await
.expect("completed response should survive a body-read tail error");
assert_eq!(response.text, "你好");
assert_eq!(response.finish_reason.as_deref(), Some("stop"));
server_handle.join().expect("server thread should join");
}
#[tokio::test]
async fn stream_run_keeps_completed_pure_tool_call_after_body_read_tail_error() {
// 纯工具调用响应的正文为空——MiniMax 的 Anthropic 工具流恒定如此。收尾信号、
// finish_reason 和完整参数都已到手时,尾部传输错误不能让这份可证完整的结果被丢掉,
// 否则每一次这样的响应都要白跑一轮 Provider 重试。
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
let server_handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("request should connect");
read_request(&mut stream);
let completed_sse = concat!(
r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"call_1","name":"get_weather","input":{}}}"#,
"\n\n",
r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"city\":\"杭州\"}"}}"#,
"\n\n",
r#"data: {"type":"content_block_stop","index":0}"#,
"\n\n",
// 刻意不发 message_stop:它是终止事件,会让读取循环在尾部错误之前就收口,
// 本用例要验证的正是“收尾信号已到、流却没干净结束”时的保留行为。
r#"data: {"type":"message_delta","delta":{"stop_reason":"tool_use"}}"#,
"\n\n"
);
let raw_response = format!(
concat!(
"HTTP/1.1 200 OK\r\n",
"Content-Type: text/event-stream; charset=utf-8\r\n",
"Transfer-Encoding: chunked\r\n",
"Connection: close\r\n\r\n",
"{:X}\r\n{}\r\n",
"not-a-chunk-size\r\n"
),
completed_sse.len(),
completed_sse
);
stream
.write_all(raw_response.as_bytes())
.expect("malformed chunked response should be written");
stream.flush().expect("stream response should flush");
});
let client = build_test_client(format!("http://{address}"), 0);
let response = client
.stream_run(weather_tool_request(LlmApiKind::Anthropic), |_| {})
.await
.expect("completed pure tool call should survive a body-read tail error");
assert!(response.text.is_empty());
assert_eq!(response.finish_reason.as_deref(), Some("tool_use"));
assert_eq!(
response.tool_calls,
vec![LlmToolCall {
id: "call_1".to_string(),
name: "get_weather".to_string(),
arguments: r#"{"city":"杭州"}"#.to_string(),
}]
);
server_handle.join().expect("server thread should join");
}
#[tokio::test]
async fn stream_run_emits_chat_finish_only_delta_without_repeating_text() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
"data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\n",
"data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n",
"data: [DONE]\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let client = build_test_client(server_url, 0);
let mut updates = Vec::new();
let response = client
.stream_run(
LlmRunRequest::single_turn("系统", "用户").with_openai_chat(),
|delta| updates.push(delta.clone()),
)
.await
.expect("stream_run should succeed");
assert_eq!(updates.len(), 2);
assert_eq!(updates[0].accumulated_text, "你好");
assert_eq!(updates[0].delta_text, "你好");
assert_eq!(updates[0].finish_reason, None);
assert_eq!(updates[1].accumulated_text, "你好");
assert_eq!(updates[1].delta_text, "");
assert_eq!(updates[1].finish_reason.as_deref(), Some("stop"));
assert_eq!(response.text, "你好");
}
#[tokio::test]
async fn stream_run_stops_at_chat_done_without_waiting_for_eof() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
let (stream_done_sender, stream_done_receiver) = std::sync::mpsc::channel();
let server_handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("request should connect");
read_request(&mut stream);
stream
.write_all(
concat!(
"HTTP/1.1 200 OK\r\n",
"Content-Type: text/event-stream; charset=utf-8\r\n",
"Connection: keep-alive\r\n\r\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\n",
"data: [DONE]\n\n"
)
.as_bytes(),
)
.expect("stream response should be written");
stream.flush().expect("stream response should flush");
stream_done_receiver
.recv_timeout(StdDuration::from_secs(1))
.expect("stream_run should complete before upstream closes");
});
let client = build_test_client(format!("http://{address}"), 0);
let response = tokio::time::timeout(
StdDuration::from_secs(1),
client.stream_run(
LlmRunRequest::single_turn("系统", "用户").with_openai_chat(),
|_| {},
),
)
.await
.expect("chat done should finish before upstream closes")
.expect("stream_run should succeed");
assert_eq!(response.text, "你好");
stream_done_sender
.send(())
.expect("server should still keep the stream open");
server_handle.join().expect("server thread should join");
}
#[test]
fn chat_sse_error_preserves_upstream_message() {
let error = parse_sse_event_block(
LlmApiKind::OpenAiChat,
"data: {\"error\":{\"message\":\"上游余额不足\"}}",
)
.expect_err("chat error event should fail");
assert_eq!(
error,
LlmError::Upstream {
status_code: 502,
message: "上游余额不足".to_string(),
}
);
}
#[test]
fn chat_sse_ignores_empty_choices_metadata_event() {
let event = parse_sse_event_block(LlmApiKind::OpenAiChat, "data: {\"choices\":[]}")
.expect("empty choices metadata should not fail the stream");
assert!(event.is_none());
}
#[test]
fn chat_sse_ignores_null_choices_metadata_event() {
let event = parse_sse_event_block(LlmApiKind::OpenAiChat, "data: {\"choices\":null}")
.expect("null choices metadata should not fail the stream");
assert!(event.is_none());
}
#[tokio::test]
async fn stream_run_accumulates_responses_sse_response() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"你\"}\n\n",
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"好\"}\n\n",
"data: {\"type\":\"response.completed\"}\n\n"
)
.to_string(),
extra_headers: vec![("x-request-id", "req_responses_stream_01")],
}]);
let client = build_test_client(server_url, 0);
let mut updates = Vec::new();
let response = client
.stream_run(
LlmRunRequest::single_turn("系统", "用户").with_openai_responses(),
|delta| {
updates.push(delta.accumulated_text.clone());
},
)
.await
.expect("responses stream_run should succeed");
assert_eq!(updates, vec!["你".to_string(), "你好".to_string()]);
assert_eq!(response.text, "你好");
assert_eq!(response.finish_reason.as_deref(), Some("completed"));
assert_eq!(
response.response_id.as_deref(),
Some("req_responses_stream_01")
);
}
#[tokio::test]
async fn run_writes_raw_failure_logs_after_parse_error() {
let log_dir = std::env::temp_dir().join(format!(
"platform-llm-raw-log-test-{}",
build_llm_raw_log_prefix("parse_error")
));
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: "不是合法 JSON".to_string(),
extra_headers: Vec::new(),
}]);
let config = LlmConfig::new(
LlmProvider::Ark,
server_url,
"test-key".to_string(),
"test-model".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
0,
1,
)
.expect("config should be valid")
.with_raw_log_dir(log_dir.clone());
let client = LlmClient::new(config).expect("client should be created");
let error = client
.run(LlmRunRequest::single_turn("系统原文", "用户原文").with_openai_chat())
.await
.expect_err("invalid json should fail");
assert!(matches!(error, LlmError::Deserialize(_)));
let mut input_logs = Vec::new();
let mut output_logs = Vec::new();
for entry in fs::read_dir(&log_dir).expect("log dir should exist") {
let path = entry.expect("log entry should be readable").path();
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_string();
if file_name.ends_with(".input.json") {
input_logs.push(path);
} else if file_name.ends_with(".output.txt") {
output_logs.push(path);
}
}
assert_eq!(input_logs.len(), 1);
assert_eq!(output_logs.len(), 1);
let input_text = fs::read_to_string(&input_logs[0]).expect("input log should be readable");
let output_text =
fs::read_to_string(&output_logs[0]).expect("output log should be readable");
assert!(input_text.contains("系统原文"));
assert!(input_text.contains("用户原文"));
assert!(!input_text.contains("test-key"));
assert_eq!(output_text, "不是合法 JSON");
fs::remove_dir_all(log_dir).expect("log dir should be removed");
}
#[tokio::test]
async fn run_sends_anthropic_messages_request_and_parses_response() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
let server_handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("request should connect");
let request_text = read_request(&mut stream);
write_response(
&mut stream,
MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: r#"{"id":"msg_01","model":"claude-test","content":[{"type":"text","text":"Anthropic 成功"}],"stop_reason":"end_turn","usage":{"input_tokens":5,"cache_creation_input_tokens":4,"cache_read_input_tokens":3,"output_tokens":3}}"#.to_string(),
extra_headers: Vec::new(),
},
);
request_text
});
let client = build_test_client(format!("http://{address}"), 0);
let response = client
.run(LlmRunRequest::single_turn("系统", "用户").with_anthropic())
.await
.expect("anthropic run should succeed");
let request_text = server_handle.join().expect("server thread should join");
let request_body = request_text
.split("\r\n\r\n")
.nth(1)
.expect("request body should exist");
let request_json: serde_json::Value =
serde_json::from_str(request_body).expect("request body should be json");
assert!(request_text.contains("POST /v1/messages HTTP/1.1"));
assert!(request_text.contains("x-api-key: test-key"));
assert!(request_text.contains("anthropic-version: 2023-06-01"));
assert_eq!(response.text, "Anthropic 成功");
assert_eq!(response.finish_reason.as_deref(), Some("end_turn"));
assert_eq!(
response.usage,
Some(LlmTokenUsage {
prompt_tokens: 12,
completion_tokens: 3,
total_tokens: 15,
})
);
assert_eq!(request_json["model"], serde_json::json!("test-model"));
assert_eq!(request_json["system"], serde_json::json!("系统"));
assert_eq!(
request_json["messages"],
serde_json::json!([{ "role": "user", "content": "用户" }])
);
}
#[tokio::test]
async fn stream_run_accumulates_anthropic_sse_response() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
"data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":5,\"cache_creation_input_tokens\":4,\"cache_read_input_tokens\":3,\"output_tokens\":0}}}\n\n",
"data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"你\"}}\n\n",
"data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"好\"}}\n\n",
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":3}}\n\n",
"data: {\"type\":\"message_stop\"}\n\n"
)
.to_string(),
extra_headers: vec![("x-request-id", "req_anthropic_stream_01")],
}]);
let client = build_test_client(server_url, 0);
let mut updates = Vec::new();
let response = client
.stream_run(
LlmRunRequest::single_turn("系统", "用户").with_anthropic(),
|delta| {
updates.push(delta.accumulated_text.clone());
},
)
.await
.expect("anthropic stream_run should succeed");
assert_eq!(updates, vec!["你".to_string(), "你好".to_string()]);
assert_eq!(response.text, "你好");
assert_eq!(response.finish_reason.as_deref(), Some("end_turn"));
assert_eq!(
response.response_id.as_deref(),
Some("req_anthropic_stream_01")
);
assert_eq!(
response.usage,
Some(LlmTokenUsage {
prompt_tokens: 12,
completion_tokens: 3,
total_tokens: 15,
})
);
}
// 以下三个流式工具用例使用取自真实端点的 checked-in SSE fixture:Anthropic 与
// Chat/Responses 分别来自 MiniMax 的 anthropic 兼容层和 api.openai.com
//(gpt-4.1 / gpt-5.5)。fixture 只作为 parser 输入,不证明原始抓包转录无偏差。
fn weather_tool_request(api_kind: LlmApiKind) -> LlmRunRequest {
LlmRunRequest::single_turn("系统", "用户")
.with_api_kind(api_kind)
.with_function_tools(vec![LlmFunctionTool::new(
"get_weather",
"查询天气",
serde_json::json!({ "type": "object" }),
)])
.with_tool_choice(LlmToolChoice::Auto)
}
#[tokio::test]
async fn stream_run_accumulates_anthropic_tool_use_alongside_text() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, "\n\n",
r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"我来"}}"#, "\n\n",
r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"为您查询。"}}"#, "\n\n",
r#"data: {"type":"content_block_stop","index":0}"#, "\n\n",
r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_019f98be1099","name":"get_weather","input":{}}}"#, "\n\n",
r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""}}"#, "\n\n",
r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{"}}"#, "\n\n",
r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"city\":\"杭州\""}}"#, "\n\n",
r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"}"}}"#, "\n\n",
r#"data: {"type":"content_block_stop","index":1}"#, "\n\n",
r#"data: {"type":"message_delta","delta":{"stop_reason":"tool_use"}}"#, "\n\n",
r#"data: {"type":"message_stop"}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let client = build_test_client(server_url, 0);
let mut updates = Vec::new();
let response = client
.stream_run(weather_tool_request(LlmApiKind::Anthropic), |delta| {
updates.push(delta.delta_text.clone());
})
.await
.expect("anthropic tool stream should succeed");
// 工具增量不进 on_delta,回调里只应看到文本。
assert_eq!(updates, vec!["我来".to_string(), "为您查询。".to_string()]);
assert_eq!(response.text, "我来为您查询。");
assert_eq!(response.finish_reason.as_deref(), Some("tool_use"));
assert_eq!(
response.tool_calls,
vec![LlmToolCall {
id: "call_019f98be1099".to_string(),
name: "get_weather".to_string(),
arguments: r#"{"city":"杭州"}"#.to_string(),
}]
);
}
#[tokio::test]
async fn stream_run_accumulates_chat_tool_call_fragments() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_7gOveph","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}"#, "\n\n",
r#"data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\""}}]},"finish_reason":null}]}"#, "\n\n",
r#"data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"city"}}]},"finish_reason":null}]}"#, "\n\n",
r#"data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\""}}]},"finish_reason":null}]}"#, "\n\n",
r#"data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"杭州"}}]},"finish_reason":null}]}"#, "\n\n",
r#"data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"}"}}]},"finish_reason":null}]}"#, "\n\n",
r#"data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#, "\n\n",
"data: [DONE]\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let client = build_test_client(server_url, 0);
let response = client
.stream_run(weather_tool_request(LlmApiKind::OpenAiChat), |_| {})
.await
.expect("chat tool stream should succeed");
// 纯工具调用没有文本,放宽后的空响应判定必须放行。
assert_eq!(response.text, "");
assert_eq!(response.finish_reason.as_deref(), Some("tool_calls"));
assert_eq!(
response.tool_calls,
vec![LlmToolCall {
id: "call_7gOveph".to_string(),
name: "get_weather".to_string(),
arguments: r#"{"city":"杭州"}"#.to_string(),
}]
);
}
#[tokio::test]
async fn stream_run_accumulates_responses_function_call() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"response.output_item.added","item":{"id":"fc_0","type":"function_call","status":"in_progress","arguments":"","call_id":"call_EkOU4","name":"get_weather"},"output_index":0,"sequence_number":2}"#, "\n\n",
r#"data: {"type":"response.function_call_arguments.delta","delta":"{\"","item_id":"fc_0","output_index":0,"sequence_number":3}"#, "\n\n",
r#"data: {"type":"response.function_call_arguments.delta","delta":"city\":\"杭州","item_id":"fc_0","output_index":0,"sequence_number":4}"#, "\n\n",
r#"data: {"type":"response.function_call_arguments.delta","delta":"\"}","item_id":"fc_0","output_index":0,"sequence_number":5}"#, "\n\n",
r#"data: {"type":"response.function_call_arguments.done","item_id":"fc_0","output_index":0,"arguments":"{\"city\":\"杭州\"}","sequence_number":6}"#, "\n\n",
r#"data: {"type":"response.completed"}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let client = build_test_client(server_url, 0);
let response = client
.stream_run(weather_tool_request(LlmApiKind::OpenAiResponses), |_| {})
.await
.expect("responses tool stream should succeed");
assert_eq!(response.finish_reason.as_deref(), Some("completed"));
assert_eq!(
response.tool_calls,
vec![LlmToolCall {
// call_id 优先于 item id,与非流式解析保持一致。
id: "call_EkOU4".to_string(),
name: "get_weather".to_string(),
arguments: r#"{"city":"杭州"}"#.to_string(),
}]
);
assert_eq!(
response.responses_output,
vec![serde_json::json!({
"id":"fc_0",
"type":"function_call",
"status":"in_progress",
"arguments":"{\"city\":\"杭州\"}",
"call_id":"call_EkOU4",
"name":"get_weather"
})]
);
}
#[tokio::test]
async fn stream_run_replays_incremental_native_output_when_completed_omits_it() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"response.output_item.added","item":{"type":"reasoning","id":"rs_1","summary":[],"encrypted_content":"encrypted-reasoning-payload"},"output_index":0}"#, "\n\n",
r#"data: {"type":"response.output_item.added","item":{"id":"fc_1","type":"function_call","status":"in_progress","arguments":"","call_id":"call_1","name":"get_weather"},"output_index":1}"#, "\n\n",
r#"data: {"type":"response.function_call_arguments.done","item_id":"fc_1","output_index":1,"arguments":"{\"city\":\"杭州\"}"}"#, "\n\n",
r#"data: {"type":"response.output_item.done","item":{"id":"fc_1","type":"function_call","status":"completed","arguments":"{\"city\":\"杭州\"}","call_id":"call_1","name":"get_weather"},"output_index":1}"#, "\n\n",
r#"data: {"type":"response.completed"}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let response = build_test_client(server_url, 0)
.stream_run(
weather_tool_request(LlmApiKind::OpenAiResponses).with_responses_input(vec![
serde_json::json!({"role":"user", "content":"查询天气"}),
]),
|_| {},
)
.await
.expect("incremental native output");
assert_eq!(
response.responses_output,
vec![
serde_json::json!({
"type":"reasoning",
"id":"rs_1",
"summary":[],
"encrypted_content":"encrypted-reasoning-payload"
}),
serde_json::json!({
"id":"fc_1",
"type":"function_call",
"status":"completed",
"arguments":"{\"city\":\"杭州\"}",
"call_id":"call_1",
"name":"get_weather"
}),
]
);
assert_eq!(response.tool_calls[0].id, "call_1");
assert_eq!(response.tool_calls[0].arguments, r#"{"city":"杭州"}"#);
}
#[tokio::test]
async fn stream_run_uses_completed_top_level_output_when_response_wrapper_missing() {
let output = native_responses_output_fixture();
let body = format!(
"data: {}\n\n",
serde_json::json!({"type":"response.completed", "output":output})
);
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream",
body,
extra_headers: Vec::new(),
}]);
let response = build_test_client(server_url, 0)
.stream_run(
weather_tool_request(LlmApiKind::OpenAiResponses).with_responses_input(vec![
serde_json::json!({"role":"user", "content":"查询天气"}),
]),
|_| {},
)
.await
.expect("top-level completed output");
assert_eq!(response.responses_output, output);
assert_eq!(response.tool_calls[0].id, "call_1");
}
// 同一个终态载荷里两条相同 call_id、相同函数名、不同参数:上游违反了 call id 唯一性。
// 平台层不承担唯一性判定,必须原样保留两条交给调用方拒绝——按 id 归并会把它们并成
// 一条、后到的参数覆盖先到的,静默丢掉一次调用,还会绕过调用方的唯一性校验。
const DUPLICATE_CALL_ID_OUTPUT: &str = concat!(
r#"{"id":"fc_0","type":"function_call","call_id":"call_a","name":"get_weather","arguments":"{\"city\":\"杭州\"}"},"#,
r#"{"id":"fc_1","type":"function_call","call_id":"call_a","name":"get_weather","arguments":"{\"city\":\"苏州\"}"}"#
);
fn duplicate_call_id_expectation() -> Vec<LlmToolCall> {
vec![
LlmToolCall {
id: "call_a".to_string(),
name: "get_weather".to_string(),
arguments: r#"{"city":"杭州"}"#.to_string(),
},
LlmToolCall {
id: "call_a".to_string(),
name: "get_weather".to_string(),
arguments: r#"{"city":"苏州"}"#.to_string(),
},
]
}
#[tokio::test]
async fn stream_run_keeps_duplicate_call_ids_within_one_event_separate() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: format!(
r#"data: {{"type":"response.completed","response":{{"output":[{DUPLICATE_CALL_ID_OUTPUT}]}}}}"#
) + "\n\n",
extra_headers: Vec::new(),
}]);
let response = build_test_client(server_url, 0)
.stream_run(weather_tool_request(LlmApiKind::OpenAiResponses), |_| {})
.await
.expect("同事件内重复 id 不应被平台层拒绝,交由调用方判定");
assert_eq!(response.tool_calls, duplicate_call_id_expectation());
}
#[test]
fn non_stream_responses_keeps_duplicate_call_ids_separate() {
// 与上一条成对:同构载荷走非流式解析必须给出同样的两条,两条路径契约不能分叉。
let response = parse_responses_response_with_capture(
LlmProvider::OpenAiCompatible,
"fallback",
false,
&format!(
r#"{{"id":"resp_1","output":[{DUPLICATE_CALL_ID_OUTPUT}],"status":"completed"}}"#
),
)
.expect("非流式同样原样透传重复 id");
assert_eq!(response.tool_calls, duplicate_call_id_expectation());
}
#[tokio::test]
async fn stream_run_keeps_duplicate_call_ids_across_events_separate() {
// 两次 output_item.added 用了同一个 call_id:上游重复使用 id,两次宣告本就是两次调用。
// 增量宣告不允许按 id 重绑——否则第二次会被绑到第一个槽位,它自己的参数事件随后落到
// 一个没有身份的空槽位上,最终报出「缺少 id:slot=1」这种完全指错方向的错误。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"response.output_item.added","item":{"id":"fc_0","type":"function_call","call_id":"call_a","name":"get_weather"},"output_index":0}"#, "\n\n",
r#"data: {"type":"response.function_call_arguments.done","item_id":"fc_0","output_index":0,"arguments":"{\"city\":\"杭州\"}"}"#, "\n\n",
r#"data: {"type":"response.output_item.added","item":{"id":"fc_1","type":"function_call","call_id":"call_a","name":"get_weather"},"output_index":1}"#, "\n\n",
r#"data: {"type":"response.function_call_arguments.done","item_id":"fc_1","output_index":1,"arguments":"{\"city\":\"苏州\"}"}"#, "\n\n",
r#"data: {"type":"response.completed"}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let response = build_test_client(server_url, 0)
.stream_run(weather_tool_request(LlmApiKind::OpenAiResponses), |_| {})
.await
.expect("跨事件重复 id 不应被平台层拒绝,交由调用方判定");
assert_eq!(response.tool_calls, duplicate_call_id_expectation());
}
#[tokio::test]
async fn stream_run_merges_completed_event_rebased_slot_by_tool_call_id() {
// completed 载荷按 output[] 数组下标重建槽位,网关若省掉此前占用 output_index=0 的
// reasoning 条目,重建出的下标 0 就与增量事件用的 output_index=1 错位。落进的是空
// 槽位,同槽位身份冲突检测不会触发,旧实现因此静默产出两条 id 完全相同的调用。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"response.output_item.added","item":{"id":"fc_0","type":"function_call","call_id":"call_a","name":"get_weather"},"output_index":1}"#, "\n\n",
r#"data: {"type":"response.function_call_arguments.done","item_id":"fc_0","output_index":1,"arguments":"{\"city\":\"杭州\"}"}"#, "\n\n",
r#"data: {"type":"response.completed","response":{"output":[{"id":"fc_0","type":"function_call","call_id":"call_a","name":"get_weather","arguments":"{\"city\":\"杭州\"}"}]}}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let response = build_test_client(server_url, 0)
.stream_run(weather_tool_request(LlmApiKind::OpenAiResponses), |_| {})
.await
.expect("槽位基准错位时应按 id 归并而不是新建");
assert_eq!(
response.tool_calls,
vec![LlmToolCall {
id: "call_a".to_string(),
name: "get_weather".to_string(),
arguments: r#"{"city":"杭州"}"#.to_string(),
}]
);
}
#[tokio::test]
async fn stream_run_rejects_rebased_slot_when_tool_call_name_disagrees() {
// 按 id 归并不放松身份校验:并进去之后函数名不一致仍须失败关闭,
// 否则会拿一个调用的 id 配另一个调用的名字。
expect_stream_slot_identity_conflict_error(
LlmApiKind::OpenAiResponses,
concat!(
r#"data: {"type":"response.output_item.added","item":{"id":"fc_0","type":"function_call","call_id":"call_a","name":"get_weather"},"output_index":1}"#, "\n\n",
r#"data: {"type":"response.completed","response":{"output":[{"id":"fc_0","type":"function_call","call_id":"call_a","name":"get_air_quality","arguments":"{\"city\":\"杭州\"}"}]}}"#, "\n\n"
),
)
.await;
}
#[tokio::test]
async fn stream_run_keeps_parallel_responses_tool_calls_with_distinct_ids() {
// 作用域守卫:按 id 归并只在 id 相同时生效,不同 id 的并行调用必须保持两条。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"response.output_item.added","item":{"id":"fc_0","type":"function_call","call_id":"call_a","name":"get_weather"},"output_index":0}"#, "\n\n",
r#"data: {"type":"response.function_call_arguments.done","item_id":"fc_0","output_index":0,"arguments":"{\"city\":\"杭州\"}"}"#, "\n\n",
r#"data: {"type":"response.output_item.added","item":{"id":"fc_1","type":"function_call","call_id":"call_b","name":"get_air_quality"},"output_index":1}"#, "\n\n",
r#"data: {"type":"response.function_call_arguments.done","item_id":"fc_1","output_index":1,"arguments":"{\"city\":\"苏州\"}"}"#, "\n\n",
r#"data: {"type":"response.completed"}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let response = build_test_client(server_url, 0)
.stream_run(weather_tool_request(LlmApiKind::OpenAiResponses), |_| {})
.await
.expect("不同 id 的并行调用必须各自保留");
assert_eq!(
response.tool_calls,
vec![
LlmToolCall {
id: "call_a".to_string(),
name: "get_weather".to_string(),
arguments: r#"{"city":"杭州"}"#.to_string(),
},
LlmToolCall {
id: "call_b".to_string(),
name: "get_air_quality".to_string(),
arguments: r#"{"city":"苏州"}"#.to_string(),
},
]
);
}
#[tokio::test]
async fn stream_run_recovers_responses_tool_calls_from_completed_event_only() {
// 只发 completed、不发增量事件的网关也必须能解出工具调用。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"response.completed","response":{"output":[{"id":"msg_0","type":"message","content":[{"type":"output_text","text":"我来查询。"}]},{"id":"fc_0","type":"function_call","call_id":"call_only","name":"get_weather","arguments":"{\"city\":\"杭州\"}"}]}}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let client = build_test_client(server_url, 0);
let response = client
.stream_run(weather_tool_request(LlmApiKind::OpenAiResponses), |_| {})
.await
.expect("completed-only stream should succeed");
assert_eq!(
response.tool_calls,
vec![LlmToolCall {
id: "call_only".to_string(),
name: "get_weather".to_string(),
arguments: r#"{"city":"杭州"}"#.to_string(),
}]
);
// 载荷里一直带着这句正文,但过去只断言工具调用,缺陷被自己的测试盖住了:
// 终态事件既是工具调用恢复源也是正文恢复源,两者必须对称。
assert_eq!(response.text, "我来查询。");
}
// 只发终态事件的网关:正文只存在于 response.output[],没有任何增量事件。
async fn expect_responses_terminal_only_text(event_type: &str, finish_reason: &str) {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: format!(
r#"data: {{"type":"{event_type}","response":{{"output":[{{"id":"msg_0","type":"message","content":[{{"type":"output_text","text":"杭州今天多云。"}}]}}]}}}}"#
) + "\n\n",
extra_headers: Vec::new(),
}]);
// 只断言 response.text 抓不到「调用方流式通道收不到文本」这个坑:Responses 的
// emit_finish_only_delta 是 false,只覆盖累加值的话回调根本不会触发。
let mut streamed: Vec<String> = Vec::new();
let response = build_test_client(server_url, 0)
.stream_run(weather_tool_request(LlmApiKind::OpenAiResponses), |delta| {
streamed.push(delta.accumulated_text.clone());
})
.await
.expect("终态事件携带的正文必须能恢复");
assert_eq!(response.text, "杭州今天多云。");
assert_eq!(response.finish_reason.as_deref(), Some(finish_reason));
assert!(response.tool_calls.is_empty());
assert_eq!(streamed, vec!["杭州今天多云。".to_string()]);
}
#[tokio::test]
async fn stream_run_recovers_responses_text_from_completed_event_only() {
// 过去这里返回 EmptyResponse,上层会白跑一轮重试或降级。
expect_responses_terminal_only_text("response.completed", "completed").await;
}
#[tokio::test]
async fn stream_run_recovers_responses_text_from_incomplete_event_only() {
// 撞 max_output_tokens 时上游只发 incomplete。截断正文是可用的降级结果,
// 过去同样退化成 EmptyResponse,连降级回复都给不出来。
expect_responses_terminal_only_text("response.incomplete", "incomplete").await;
}
#[tokio::test]
async fn stream_run_does_not_duplicate_text_when_terminal_event_repeats_deltas() {
// 终态快照按覆盖而不是追加处理,否则同时发增量和完整 output 的网关会让正文翻倍。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"response.output_text.delta","delta":"杭州今天"}"#, "\n\n",
r#"data: {"type":"response.output_text.delta","delta":"多云。"}"#, "\n\n",
r#"data: {"type":"response.completed","response":{"output":[{"id":"msg_0","type":"message","content":[{"type":"output_text","text":"杭州今天多云。"}]}]}}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let mut streamed: Vec<(String, String)> = Vec::new();
let response = build_test_client(server_url, 0)
.stream_run(weather_tool_request(LlmApiKind::OpenAiResponses), |delta| {
streamed.push((delta.delta_text.clone(), delta.accumulated_text.clone()));
})
.await
.expect("增量与终态并存时不应重复正文");
assert_eq!(response.text, "杭州今天多云。");
// 快照与增量拼接结果一致时不补发回调,调用方侧同样不能翻倍。这里必须连回调次数
// 一起断言:只断言内容序列的话,补不补这一次都可能是绿的。
assert_eq!(streamed.len(), 2);
assert_eq!(
streamed,
vec![
("杭州今天".to_string(), "杭州今天".to_string()),
("多云。".to_string(), "杭州今天多云。".to_string()),
]
);
}
#[tokio::test]
async fn stream_run_corrects_streamed_text_when_terminal_snapshot_extends_deltas() {
// 增量只流出半截、终态载荷才是完整正文。只改累加值的话 LlmRunResponse.text 对了,
// 但调用方最后收到的累计正文停在半截,两者在同一次调用里分叉。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"response.output_text.delta","delta":"杭州今天"}"#, "\n\n",
r#"data: {"type":"response.completed","response":{"output":[{"id":"msg_0","type":"message","content":[{"type":"output_text","text":"杭州今天多云。"}]}]}}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let mut streamed: Vec<(String, String)> = Vec::new();
let response = build_test_client(server_url, 0)
.stream_run(weather_tool_request(LlmApiKind::OpenAiResponses), |delta| {
streamed.push((delta.delta_text.clone(), delta.accumulated_text.clone()));
})
.await
.expect("终态快照延长增量时应补发回调");
assert_eq!(response.text, "杭州今天多云。");
// 快照是增量的延长时给出后缀,按 delta_text 累加的消费者也能自愈。
assert_eq!(
streamed,
vec![
("杭州今天".to_string(), "杭州今天".to_string()),
("多云。".to_string(), "杭州今天多云。".to_string()),
]
);
}
#[tokio::test]
async fn stream_run_corrects_streamed_text_when_terminal_snapshot_diverges_from_deltas() {
// 增量与终态载荷不同源时无法表达成增量:delta_text 只能给空串,靠 accumulated_text
// 纠正。按 accumulated_text 取值的消费者自愈,按 delta_text 累加的那份修不了,
// 是契约文档里记着的已知残留。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"response.output_text.delta","delta":"临安"}"#, "\n\n",
r#"data: {"type":"response.completed","response":{"output":[{"id":"msg_0","type":"message","content":[{"type":"output_text","text":"杭州今天多云。"}]}]}}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let mut streamed: Vec<(String, String)> = Vec::new();
let response = build_test_client(server_url, 0)
.stream_run(weather_tool_request(LlmApiKind::OpenAiResponses), |delta| {
streamed.push((delta.delta_text.clone(), delta.accumulated_text.clone()));
})
.await
.expect("终态快照与增量分叉时应补发回调");
assert_eq!(response.text, "杭州今天多云。");
assert_eq!(
streamed,
vec![
("临安".to_string(), "临安".to_string()),
(String::new(), "杭州今天多云。".to_string()),
]
);
}
#[tokio::test]
async fn stream_run_keeps_responses_terminal_reasoning_out_of_text() {
// 锁住「必须复用 extract_responses_text」这个决定:它带隐藏 part 过滤,
// 换成裸 JSON 提取会把思维链当正文吐给调用方。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"response.completed","response":{"output":[{"id":"rs_0","type":"reasoning","content":[{"type":"reasoning","text":"先判断用户问的是哪座城市。"}]},{"id":"msg_0","type":"message","content":[{"type":"output_text","text":"杭州今天多云。"}]}]}}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let response = build_test_client(server_url, 0)
.stream_run(weather_tool_request(LlmApiKind::OpenAiResponses), |_| {})
.await
.expect("终态正文恢复必须过滤隐藏推理 part");
assert_eq!(response.text, "杭州今天多云。");
}
#[tokio::test]
async fn stream_run_captures_reasoning_separately_when_enabled() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"response.reasoning_summary_text.delta","delta":"先判断。"}"#, "\n\n",
r#"data: {"type":"response.output_text.delta","delta":"答案"}"#, "\n\n",
r#"data: {"type":"response.completed","response":{"output":[{"type":"reasoning","summary":[{"type":"summary_text","text":"先判断。"}]},{"type":"message","content":[{"type":"output_text","text":"答案"}]}]}}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let mut updates = Vec::new();
let response = build_test_client(server_url, 0)
.stream_run(
LlmRunRequest::single_turn("系统", "用户")
.with_openai_responses()
.with_reasoning_capture(true),
|delta| updates.push((delta.delta_text.clone(), delta.reasoning_delta.clone())),
)
.await
.expect("stream reasoning should parse");
assert_eq!(response.text, "答案");
assert_eq!(response.reasoning, "先判断。");
assert!(updates.iter().any(|(_, reasoning)| reasoning == "先判断。"));
assert!(
updates
.iter()
.all(|(text, reasoning)| !(text.contains("先判断") || reasoning.contains("答案")))
);
}
#[tokio::test]
async fn stream_run_captures_anthropic_thinking_separately_when_enabled() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"先分析。"}}"#, "\n\n",
r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"答案"}}"#, "\n\n",
r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}"#, "\n\n",
r#"data: {"type":"message_stop"}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let mut updates = Vec::new();
let response = build_test_client(server_url, 0)
.stream_run(
LlmRunRequest::single_turn("系统", "用户")
.with_anthropic()
.with_reasoning_capture(true),
|delta| updates.push((delta.delta_text.clone(), delta.reasoning_delta.clone())),
)
.await
.expect("Anthropic thinking stream should parse");
assert_eq!(response.text, "答案");
assert_eq!(response.reasoning, "先分析。");
assert!(
updates
.iter()
.any(|(text, reasoning)| text.is_empty() && reasoning == "先分析。")
);
assert!(
updates
.iter()
.any(|(text, reasoning)| text == "答案" && reasoning.is_empty())
);
}
#[tokio::test]
async fn stream_run_keeps_multiple_responses_reasoning_summary_parts() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"response.reasoning_summary_text.delta","summary_index":0,"delta":"第一段"}"#, "\n\n",
r#"data: {"type":"response.reasoning_summary_text.done","summary_index":0,"text":"第一段"}"#, "\n\n",
r#"data: {"type":"response.reasoning_summary_text.delta","summary_index":1,"delta":"第二段"}"#, "\n\n",
r#"data: {"type":"response.reasoning_summary_text.done","summary_index":1,"text":"第二段"}"#, "\n\n",
// 终态故意不带 reasoning,验证不能依赖 response.completed 恢复前面的 summary part。
r#"data: {"type":"response.completed","response":{"output":[{"type":"message","content":[{"type":"output_text","text":"答案"}]}]}}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let mut updates = Vec::new();
let response = build_test_client(server_url, 0)
.stream_run(
LlmRunRequest::single_turn("系统", "用户")
.with_openai_responses()
.with_reasoning_capture(true),
|delta| {
updates.push((
delta.accumulated_reasoning.clone(),
delta.reasoning_delta.clone(),
))
},
)
.await
.expect("multiple reasoning summary parts should parse");
assert_eq!(response.reasoning, "第一段第二段");
assert!(updates.iter().any(|(accumulated, delta)| {
accumulated == "第一段第二段" && delta == "第二段"
}));
}
#[tokio::test]
async fn stream_run_keeps_reasoning_parts_separate_across_items() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"response.reasoning_summary_text.delta","item_id":"item-a","summary_index":0,"delta":"前置"}"#, "\n\n",
r#"data: {"type":"response.reasoning_summary_text.delta","item_id":"item-b","summary_index":0,"delta":"后置"}"#, "\n\n",
r#"data: {"type":"response.completed","response":{"output":[{"type":"message","content":[{"type":"output_text","text":"答案"}]}]}}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let response = build_test_client(server_url, 0)
.stream_run(
LlmRunRequest::single_turn("系统", "用户")
.with_openai_responses()
.with_reasoning_capture(true),
|_| {},
)
.await
.expect("reasoning items should remain separate");
assert_eq!(response.reasoning, "前置后置");
}
#[tokio::test]
async fn stream_run_notifies_when_reasoning_snapshot_replaces_non_prefix_part() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"response.reasoning_summary_text.delta","item_id":"item-a","summary_index":0,"delta":"旧内容"}"#, "\n\n",
r#"data: {"type":"response.reasoning_summary_text.done","item_id":"item-a","summary_index":0,"text":"新内容"}"#, "\n\n",
r#"data: {"type":"response.completed","response":{"output":[{"type":"message","content":[{"type":"output_text","text":"答案"}]}]}}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let mut updates = Vec::new();
let response = build_test_client(server_url, 0)
.stream_run(
LlmRunRequest::single_turn("系统", "用户")
.with_openai_responses()
.with_reasoning_capture(true),
|delta| {
updates.push((
delta.accumulated_reasoning.clone(),
delta.reasoning_delta.clone(),
))
},
)
.await
.expect("replacement snapshot should parse");
assert_eq!(response.reasoning, "新内容");
assert!(
updates
.iter()
.any(|(accumulated, delta)| accumulated == "新内容" && delta.is_empty())
);
}
#[tokio::test]
async fn stream_run_accumulates_parallel_anthropic_tool_calls() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_a","name":"get_weather","input":{}}}"#, "\n\n",
r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"city\":\"杭州\"}"}}"#, "\n\n",
r#"data: {"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"call_b","name":"get_air_quality","input":{}}}"#, "\n\n",
r#"data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"city\":\"杭州\"}"}}"#, "\n\n",
r#"data: {"type":"message_delta","delta":{"stop_reason":"tool_use"}}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let client = build_test_client(server_url, 0);
let response = client
.stream_run(weather_tool_request(LlmApiKind::Anthropic), |_| {})
.await
.expect("parallel tool stream should succeed");
let names = response
.tool_calls
.iter()
.map(|call| call.name.as_str())
.collect::<Vec<_>>();
assert_eq!(names, vec!["get_weather", "get_air_quality"]);
assert_eq!(response.tool_calls[1].id, "call_b");
}
#[tokio::test]
async fn stream_run_rejects_incomplete_tool_arguments_json() {
// 收尾信号齐全,但参数只拼到一半,半截 JSON 不能交给业务层。
// 与下面几个"参数完整但没有收尾信号"的用例是两条独立防线。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_1","name":"get_weather","input":{}}}"#, "\n\n",
r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"city\":"}}"#, "\n\n",
r#"data: {"type":"message_delta","delta":{"stop_reason":"tool_use"}}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let client = build_test_client(server_url, 0);
let error = client
.stream_run(weather_tool_request(LlmApiKind::Anthropic), |_| {})
.await
.expect_err("truncated arguments should fail");
assert!(matches!(error, LlmError::Deserialize(_)));
}
#[tokio::test]
async fn stream_run_rejects_anthropic_tool_calls_without_completion_signal() {
// 参数字节完整,但没有 message_delta / message_stop:代理超时或网关掐断都长这样,
// 光凭"JSON 能解析"就执行工具调用是危险的。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_1","name":"get_weather","input":{}}}"#, "\n\n",
r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"city\":\"杭州\"}"}}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let client = build_test_client(server_url, 0);
let error = client
.stream_run(weather_tool_request(LlmApiKind::Anthropic), |_| {})
.await
.expect_err("tool stream without completion signal should fail");
let LlmError::Deserialize(message) = error else {
panic!("应按截断失败,实际 {error:?}");
};
assert!(message.contains("协议完成信号前截断"), "{message}");
}
#[tokio::test]
async fn stream_run_accepts_anthropic_tool_calls_with_message_stop_only() {
// 兼容网关可能漏发 stop_reason 但仍发 message_stop;后者是合法收尾信号,
// 且不能借它伪造 finish_reason。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_1","name":"get_weather","input":{}}}"#, "\n\n",
r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"city\":\"杭州\"}"}}"#, "\n\n",
r#"data: {"type":"content_block_stop","index":1}"#, "\n\n",
r#"data: {"type":"message_stop"}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let client = build_test_client(server_url, 0);
let response = client
.stream_run(weather_tool_request(LlmApiKind::Anthropic), |_| {})
.await
.expect("message_stop should count as completion");
assert_eq!(response.tool_calls.len(), 1);
assert_eq!(response.tool_calls[0].name, "get_weather");
assert_eq!(response.finish_reason, None);
}
#[tokio::test]
async fn stream_run_rejects_chat_tool_calls_without_completion_signal() {
// Chat 分片拼出了完整 arguments,但既无 finish_reason 也无 [DONE]。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":""}}]}}]}"#, "\n\n",
r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":\"杭州\"}"}}]}}]}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let client = build_test_client(server_url, 0);
let error = client
.stream_run(weather_tool_request(LlmApiKind::OpenAiChat), |_| {})
.await
.expect_err("chat tool stream without completion signal should fail");
let LlmError::Deserialize(message) = error else {
panic!("应按截断失败,实际 {error:?}");
};
assert!(message.contains("协议完成信号前截断"), "{message}");
}
#[tokio::test]
async fn stream_run_rejects_responses_tool_calls_without_completion_signal() {
// Responses 的整体收尾只有 response.completed / response.incomplete;单 item
// 的 function_call_arguments.done 不能顶替它。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"get_weather"}}"#, "\n\n",
r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"city\":\"杭州\"}"}"#, "\n\n",
r#"data: {"type":"response.function_call_arguments.done","output_index":0,"arguments":"{\"city\":\"杭州\"}"}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let client = build_test_client(server_url, 0);
let error = client
.stream_run(weather_tool_request(LlmApiKind::OpenAiResponses), |_| {})
.await
.expect_err("responses tool stream without completion signal should fail");
let LlmError::Deserialize(message) = error else {
panic!("应按截断失败,实际 {error:?}");
};
assert!(message.contains("协议完成信号前截断"), "{message}");
}
#[tokio::test]
async fn stream_run_rejects_parallel_tool_calls_truncated_between_blocks() {
// 第一个工具块字节完整,流在第二个 content_block_start 到达前断掉。
// 旧实现会返回"看起来完整"的单调用结果,静默丢掉模型本要发的第二个调用。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_a","name":"get_weather","input":{}}}"#, "\n\n",
r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"city\":\"杭州\"}"}}"#, "\n\n",
r#"data: {"type":"content_block_stop","index":1}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let client = build_test_client(server_url, 0);
let error = client
.stream_run(weather_tool_request(LlmApiKind::Anthropic), |_| {})
.await
.expect_err("truncation between tool blocks should fail");
let LlmError::Deserialize(message) = error else {
panic!("应按截断失败,实际 {error:?}");
};
assert!(message.contains("协议完成信号前截断"), "{message}");
}
#[tokio::test]
async fn stream_run_keeps_text_only_response_without_completion_signal() {
// 作用域反向守卫:本轮只收严工具路径。纯文本流缺收尾信号仍按成功返回,
// 只打 warn。改这条断言前必须先确认所有在用网关的文本收尾行为。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"杭州今天"}}"#, "\n\n",
r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"多云。"}}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let client = build_test_client(server_url, 0);
let response = client
.stream_run(weather_tool_request(LlmApiKind::Anthropic), |_| {})
.await
.expect("text-only stream should still succeed");
assert_eq!(response.text, "杭州今天多云。");
assert!(response.tool_calls.is_empty());
assert_eq!(response.finish_reason, None);
}
async fn run_non_stream_tool_body(
api_kind: LlmApiKind,
body: &str,
) -> Result<LlmRunResponse, LlmError> {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: body.to_string(),
extra_headers: Vec::new(),
}]);
build_test_client(server_url, 0)
.run(weather_tool_request(api_kind))
.await
}
fn expect_tool_call_deserialize_error(error: LlmError, expected_fragment: &str) {
let LlmError::Deserialize(message) = error else {
panic!("应报 Deserialize,实际 {error:?}");
};
assert!(message.contains(expected_fragment), "{message}");
}
#[tokio::test]
async fn non_stream_chat_tool_call_missing_id_fails_instead_of_returning_plain_text() {
// 回归锁:DTO 为兼容流式分片改成可选字段后,缺 id 的工具调用会被 filter_map 静默丢掉;
// 又因为正文非空,整个响应曾被当作普通文本回复成功返回,调用方完全察觉不到工具调用丢失。
let error = run_non_stream_tool_body(
LlmApiKind::OpenAiChat,
r#"{"id":"resp_01","choices":[{"message":{"content":"我来帮你查一下。","tool_calls":[{"index":0,"function":{"name":"get_weather","arguments":"{\"city\":\"杭州\"}"}}]},"finish_reason":"tool_calls"}]}"#,
)
.await
.expect_err("缺 id 的工具调用不能退化成纯文本回复");
expect_tool_call_deserialize_error(error, "Chat 非流式工具调用缺少 id");
}
#[tokio::test]
async fn non_stream_chat_tool_call_missing_function_fails() {
let error = run_non_stream_tool_body(
LlmApiKind::OpenAiChat,
r#"{"id":"resp_01","choices":[{"message":{"content":"正文","tool_calls":[{"index":0,"id":"call_1"}]},"finish_reason":"tool_calls"}]}"#,
)
.await
.expect_err("缺 function 的工具调用必须失败");
expect_tool_call_deserialize_error(error, "Chat 非流式工具调用缺少函数名");
}
#[tokio::test]
async fn non_stream_chat_tool_call_missing_arguments_normalizes_to_empty_object() {
// 零参函数合法;空参数归一为 {},不能像以前那样给出空串让下游 from_str 炸。
let response = run_non_stream_tool_body(
LlmApiKind::OpenAiChat,
r#"{"id":"resp_01","choices":[{"message":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_time"}}]},"finish_reason":"tool_calls"}]}"#,
)
.await
.expect("缺 arguments 的零参调用应归一成功");
assert_eq!(
response.tool_calls,
vec![LlmToolCall {
id: "call_1".to_string(),
name: "get_time".to_string(),
arguments: "{}".to_string(),
}]
);
}
#[tokio::test]
async fn non_stream_chat_tool_call_passes_incomplete_arguments_through() {
// 上游 max_tokens 截断会给出合法外层 JSON 加半截 arguments 字符串。非流式的
// 外层 body 已完整,参数半截是模型输出问题而非流被截断,平台层必须原样透传:
// 调用方的格式修复循环要靠 call id、函数名和原始参数把畸形响应回灌给模型重写,
// 在这里报错会把这些信息全部丢掉,退化成一轮无谓的 Provider 重试。
// finish_reason 用 tool_calls 而非 length:本用例只验证参数透传。上游明确报截断
// 时由 reject_incomplete_tool_calls 优先拦截,那条由下面的用例单独锁定。
let response = run_non_stream_tool_body(
LlmApiKind::OpenAiChat,
r#"{"id":"resp_01","choices":[{"message":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"city\":"}}]},"finish_reason":"tool_calls"}]}"#,
)
.await
.expect("半截 arguments 必须原样透传给调用方");
assert_eq!(
response.tool_calls,
vec![LlmToolCall {
id: "call_1".to_string(),
name: "get_weather".to_string(),
arguments: r#"{"city":"#.to_string(),
}]
);
}
#[tokio::test]
async fn non_stream_chat_rejects_tool_calls_truncated_by_length() {
// 参数恰好闭合成合法 JSON,但上游已明确报 length:这正是修复循环察觉不到、
// 会被直接执行的危险形态,必须在平台层拦下。
let error = run_non_stream_tool_body(
LlmApiKind::OpenAiChat,
r#"{"id":"resp_01","choices":[{"message":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"city\":\"杭州\"}"}}]},"finish_reason":"length"}]}"#,
)
.await
.expect_err("length 截断的工具调用必须失败");
expect_tool_call_deserialize_error(error, "工具调用来自未完成的响应");
}
#[tokio::test]
async fn non_stream_responses_rejects_tool_calls_with_incomplete_status() {
let error = run_non_stream_tool_body(
LlmApiKind::OpenAiResponses,
r#"{"id":"resp_01","status":"incomplete","output":[{"type":"function_call","call_id":"call_1","name":"get_weather","arguments":"{\"city\":\"杭州\"}"}]}"#,
)
.await
.expect_err("incomplete 状态的工具调用必须失败");
expect_tool_call_deserialize_error(error, "工具调用来自未完成的响应");
}
#[tokio::test]
async fn non_stream_anthropic_rejects_tool_calls_stopped_by_max_tokens() {
let error = run_non_stream_tool_body(
LlmApiKind::Anthropic,
r#"{"id":"msg_01","content":[{"type":"tool_use","id":"call_1","name":"get_weather","input":{"city":"杭州"}}],"stop_reason":"max_tokens"}"#,
)
.await
.expect_err("max_tokens 截断的工具调用必须失败");
expect_tool_call_deserialize_error(error, "工具调用来自未完成的响应");
}
#[tokio::test]
async fn non_stream_truncated_text_without_tool_calls_still_succeeds() {
// 作用域守卫:截断拦截只针对工具调用。正文被 max_tokens 砍断仍是可用的降级结果,
// 一并拒绝会打死所有触及输出上限的长文本回答。
let response = run_non_stream_tool_body(
LlmApiKind::OpenAiChat,
r#"{"id":"resp_01","choices":[{"message":{"content":"杭州今天多云,气温"},"finish_reason":"length"}]}"#,
)
.await
.expect("截断的纯文本回复仍应成功返回");
assert_eq!(response.text, "杭州今天多云,气温");
assert!(response.tool_calls.is_empty());
assert_eq!(response.finish_reason.as_deref(), Some("length"));
}
#[tokio::test]
async fn non_stream_tool_calls_with_unknown_finish_reason_still_succeed() {
// 兼容网关路径:只拒绝已知的截断 / 过滤 / 失败终态,未知值与缺失一律放行。
let response = run_non_stream_tool_body(
LlmApiKind::OpenAiChat,
r#"{"id":"resp_01","choices":[{"message":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"city\":\"杭州\"}"}}]},"finish_reason":"vendor_specific_done"}]}"#,
)
.await
.expect("未知 finish_reason 不应被误杀");
assert_eq!(response.tool_calls.len(), 1);
}
#[tokio::test]
async fn stream_chat_rejects_tool_calls_truncated_by_length() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"city\":\"杭州\"}"}}]}}]}"#, "\n\n",
r#"data: {"choices":[{"finish_reason":"length"}]}"#, "\n\n",
"data: [DONE]\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let client = build_test_client(server_url, 0);
let error = client
.stream_run(weather_tool_request(LlmApiKind::OpenAiChat), |_| {})
.await
.expect_err("流式 length 截断的工具调用必须失败");
expect_tool_call_deserialize_error(error, "流式工具调用来自未完成的响应");
}
// 终止事件:服务端在最终事件之后保持连接不关时,stream_run 必须立即收口,
// 否则会一路等到调用方超时。既有覆盖只有 Chat 的 [DONE]。
async fn assert_stream_stops_at_terminal_event(
api_kind: LlmApiKind,
sse_body: &'static str,
expected_text: &str,
) {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
let (stream_done_sender, stream_done_receiver) = std::sync::mpsc::channel();
let server_handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("request should connect");
read_request(&mut stream);
stream
.write_all(
format!(
concat!(
"HTTP/1.1 200 OK\r\n",
"Content-Type: text/event-stream; charset=utf-8\r\n",
"Connection: keep-alive\r\n\r\n",
"{}"
),
sse_body
)
.as_bytes(),
)
.expect("stream response should be written");
stream.flush().expect("stream response should flush");
stream_done_receiver
.recv_timeout(StdDuration::from_secs(1))
.expect("stream_run should complete before upstream closes");
});
let client = build_test_client(format!("http://{address}"), 0);
let response = tokio::time::timeout(
StdDuration::from_secs(1),
client.stream_run(
LlmRunRequest::single_turn("系统", "用户").with_api_kind(api_kind),
|_| {},
),
)
.await
.expect("最终事件应在上游关闭前收口")
.expect("stream_run should succeed");
assert_eq!(response.text, expected_text);
stream_done_sender
.send(())
.expect("server should still keep the stream open");
server_handle.join().expect("server thread should join");
}
#[tokio::test]
async fn stream_run_stops_at_responses_completed_without_waiting_for_eof() {
assert_stream_stops_at_terminal_event(
LlmApiKind::OpenAiResponses,
concat!(
r#"data: {"type":"response.output_text.delta","delta":"你好"}"#,
"\n\n",
r#"data: {"type":"response.completed"}"#,
"\n\n"
),
"你好",
)
.await;
}
#[tokio::test]
async fn stream_run_rejects_responses_tool_calls_from_incomplete_event() {
// 撞到 max_output_tokens 时上游只发 response.incomplete,不发 completed。
// 它是终态且带完整 output[],其中的工具调用必须按未完成响应拒绝。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"get_weather"}}"#, "\n\n",
r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"city\":\"杭州\"}"}"#, "\n\n",
r#"data: {"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output":[{"id":"fc_0","type":"function_call","status":"incomplete","call_id":"call_1","name":"get_weather","arguments":"{\"city\":\"杭州\"}"}]}}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let error = build_test_client(server_url, 0)
.stream_run(weather_tool_request(LlmApiKind::OpenAiResponses), |_| {})
.await
.expect_err("incomplete 终态的工具调用必须失败");
expect_tool_call_deserialize_error(error, "流式工具调用来自未完成的响应");
}
#[tokio::test]
async fn stream_run_keeps_truncated_responses_text_from_incomplete_event() {
// 作用域守卫:截断拒绝只针对工具调用,被 max_output_tokens 砍断的正文仍是
// 可用的降级结果。事件序列转录自真实端点抓包。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"response.output_text.delta","delta":"杭州古称"}"#, "\n\n",
r#"data: {"type":"response.output_text.delta","delta":"临安"}"#, "\n\n",
r#"data: {"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output":[{"id":"msg_0","type":"message","status":"incomplete"}]}}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let response = build_test_client(server_url, 0)
.stream_run(weather_tool_request(LlmApiKind::OpenAiResponses), |_| {})
.await
.expect("截断的正文仍应返回");
assert_eq!(response.text, "杭州古称临安");
assert!(response.tool_calls.is_empty());
assert_eq!(response.finish_reason.as_deref(), Some("incomplete"));
}
#[tokio::test]
async fn stream_run_stops_at_responses_incomplete_without_waiting_for_eof() {
assert_stream_stops_at_terminal_event(
LlmApiKind::OpenAiResponses,
concat!(
r#"data: {"type":"response.output_text.delta","delta":"杭州古称"}"#, "\n\n",
r#"data: {"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output":[]}}"#, "\n\n"
),
"杭州古称",
)
.await;
}
#[tokio::test]
async fn stream_run_stops_at_anthropic_message_stop_without_waiting_for_eof() {
assert_stream_stops_at_terminal_event(
LlmApiKind::Anthropic,
concat!(
r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"你好"}}"#, "\n\n",
r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}"#, "\n\n",
r#"data: {"type":"message_stop"}"#, "\n\n"
),
"你好",
)
.await;
}
// 槽位缺失:并行分片唯一的归并依据没了,跳过会静默丢调用,按事件内位置猜会把两个
// 不同调用合并成混合体,两者都比直接失败危险。
async fn expect_stream_missing_slot_error(api_kind: LlmApiKind, body: &str) {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: body.to_string(),
extra_headers: Vec::new(),
}]);
let error = build_test_client(server_url, 0)
.stream_run(weather_tool_request(api_kind), |_| {})
.await
.expect_err("缺少协议槽位必须失败关闭");
expect_tool_call_deserialize_error(error, "流式工具事件缺少槽位字段");
}
#[tokio::test]
async fn stream_run_rejects_chat_tool_fragments_without_index() {
// 两个事件各带一个无 index 调用:旧实现都归到槽位 0,后者覆盖前者的身份。
expect_stream_missing_slot_error(
LlmApiKind::OpenAiChat,
concat!(
r#"data: {"choices":[{"delta":{"tool_calls":[{"id":"call_a","function":{"name":"get_weather","arguments":"{\"city\":\"杭州\"}"}}]}}]}"#, "\n\n",
r#"data: {"choices":[{"delta":{"tool_calls":[{"id":"call_b","function":{"name":"get_air_quality","arguments":"{\"city\":\"杭州\"}"}}]}}]}"#, "\n\n",
r#"data: {"choices":[{"finish_reason":"tool_calls"}]}"#, "\n\n",
"data: [DONE]\n\n"
),
)
.await;
}
#[tokio::test]
async fn stream_run_rejects_responses_function_call_without_output_index() {
expect_stream_missing_slot_error(
LlmApiKind::OpenAiResponses,
concat!(
r#"data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_1","name":"get_weather"}}"#, "\n\n",
r#"data: {"type":"response.completed"}"#, "\n\n"
),
)
.await;
}
#[tokio::test]
async fn stream_run_ignores_non_tool_output_item_without_output_index() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"response.output_item.added","item":{"type":"reasoning","id":"rs_1"}}"#, "\n\n",
r#"data: {"type":"response.output_text.delta","delta":"杭州今天多云。"}"#, "\n\n",
r#"data: {"type":"response.completed"}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let response = build_test_client(server_url, 0)
.stream_run(weather_tool_request(LlmApiKind::OpenAiResponses), |_| {})
.await
.expect("非工具 output item 缺少 output_index 不应中断流");
assert_eq!(response.text, "杭州今天多云。");
assert!(response.tool_calls.is_empty());
}
#[tokio::test]
async fn stream_run_rejects_anthropic_tool_use_without_block_index() {
expect_stream_missing_slot_error(
LlmApiKind::Anthropic,
concat!(
r#"data: {"type":"content_block_start","content_block":{"type":"tool_use","id":"call_1","name":"get_weather","input":{}}}"#, "\n\n",
r#"data: {"type":"message_delta","delta":{"stop_reason":"tool_use"}}"#, "\n\n"
),
)
.await;
}
// 槽位身份冲突:槽位在但被两个不同调用共用。比缺槽位更隐蔽——身份被覆盖、参数却是
// 追加/整段覆盖,两者不自洽,产出的调用会直接交给 Runtime 执行。
async fn expect_stream_slot_identity_conflict_error(api_kind: LlmApiKind, body: &str) {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: body.to_string(),
extra_headers: Vec::new(),
}]);
let error = build_test_client(server_url, 0)
.stream_run(weather_tool_request(api_kind), |_| {})
.await
.expect_err("同槽位身份冲突必须失败关闭");
expect_tool_call_deserialize_error(error, "流式工具分片槽位");
}
#[tokio::test]
async fn stream_run_rejects_chat_tool_fragments_reusing_slot_for_another_call() {
// 兼容网关把 index 恒置 0 时两个串行调用挤进同一槽位。这里刻意让前一个调用参数为空:
// 拼接结果是后者的合法 JSON,截断断言兜不住,旧实现会静默丢掉 get_weather,
// 只把 get_air_quality 交出去。
expect_stream_slot_identity_conflict_error(
LlmApiKind::OpenAiChat,
concat!(
r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_a","function":{"name":"get_weather","arguments":""}}]}}]}"#, "\n\n",
r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_b","function":{"name":"get_air_quality","arguments":"{\"city\":\"杭州\"}"}}]}}]}"#, "\n\n",
r#"data: {"choices":[{"finish_reason":"tool_calls"}]}"#, "\n\n",
"data: [DONE]\n\n"
),
)
.await;
}
#[tokio::test]
async fn stream_run_rejects_chat_tool_fragments_reusing_slot_for_same_tool_name() {
// 并行调用同一个工具是最常见的并行场景:name 相同,只有 id 能区分。
// 只查 name 的实现会把这两个调用合并成一个混合参数体。
expect_stream_slot_identity_conflict_error(
LlmApiKind::OpenAiChat,
concat!(
r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_a","function":{"name":"get_weather","arguments":""}}]}}]}"#, "\n\n",
r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_b","function":{"name":"get_weather","arguments":"{\"city\":\"苏州\"}"}}]}}]}"#, "\n\n",
r#"data: {"choices":[{"finish_reason":"tool_calls"}]}"#, "\n\n",
"data: [DONE]\n\n"
),
)
.await;
}
#[tokio::test]
async fn stream_run_rejects_responses_completed_event_contradicting_streamed_slot() {
// completed 回退按 output[] 下标重建槽位,前提是它与 output_index 同义。网关若在
// completed 载荷里省掉 reasoning item,基准就错位:槽位 0 会拿到另一个工具的身份,
// arguments_complete 再整段覆盖,产出「A 的身份配 B 的参数」且是合法 JSON。
expect_stream_slot_identity_conflict_error(
LlmApiKind::OpenAiResponses,
concat!(
r#"data: {"type":"response.output_item.added","item":{"id":"fc_0","type":"function_call","call_id":"call_a","name":"get_weather"},"output_index":0}"#, "\n\n",
r#"data: {"type":"response.function_call_arguments.done","item_id":"fc_0","output_index":0,"arguments":"{\"city\":\"杭州\"}"}"#, "\n\n",
r#"data: {"type":"response.completed","response":{"output":[{"id":"fc_1","type":"function_call","call_id":"call_b","name":"get_air_quality","arguments":"{\"city\":\"苏州\"}"}]}}"#, "\n\n"
),
)
.await;
}
#[tokio::test]
async fn stream_run_rejects_anthropic_content_block_reusing_index() {
expect_stream_slot_identity_conflict_error(
LlmApiKind::Anthropic,
concat!(
r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_a","name":"get_weather","input":{}}}"#, "\n\n",
r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_b","name":"get_air_quality","input":{}}}"#, "\n\n",
r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"city\":\"杭州\"}"}}"#, "\n\n",
r#"data: {"type":"message_delta","delta":{"stop_reason":"tool_use"}}"#, "\n\n"
),
)
.await;
}
#[tokio::test]
async fn stream_run_accepts_chat_gateway_repeating_tool_identity_every_chunk() {
// 兼容性守卫:不少网关每个续传分片都回发完整 function 对象,身份要么是同一个值、
// 要么是空串。前者不算冲突,后者按缺失跳过——否则这批网关会被整批误杀。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_7gOveph","function":{"name":"get_weather","arguments":"{\"city\""}}]}}]}"#, "\n\n",
r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_7gOveph","function":{"name":"get_weather","arguments":":\"杭州"}}]}}]}"#, "\n\n",
r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"","function":{"name":"","arguments":"\"}"}}]}}]}"#, "\n\n",
r#"data: {"choices":[{"finish_reason":"tool_calls"}]}"#, "\n\n",
"data: [DONE]\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let response = build_test_client(server_url, 0)
.stream_run(weather_tool_request(LlmApiKind::OpenAiChat), |_| {})
.await
.expect("重复回发同一身份不算冲突");
assert_eq!(
response.tool_calls,
vec![LlmToolCall {
id: "call_7gOveph".to_string(),
name: "get_weather".to_string(),
arguments: r#"{"city":"杭州"}"#.to_string(),
}]
);
}
#[tokio::test]
async fn stream_run_keeps_responses_completed_event_reconfirming_streamed_slot() {
// 作用域守卫:completed 回退与增量事件槽位一致时是正常路径,身份重复不能报错,
// arguments_complete 仍要覆盖拼接结果。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"response.output_item.added","item":{"id":"fc_0","type":"function_call","call_id":"call_a","name":"get_weather"},"output_index":0}"#, "\n\n",
r#"data: {"type":"response.function_call_arguments.delta","delta":"{\"city","item_id":"fc_0","output_index":0}"#, "\n\n",
r#"data: {"type":"response.completed","response":{"output":[{"id":"fc_0","type":"function_call","call_id":"call_a","name":"get_weather","arguments":"{\"city\":\"杭州\"}"}]}}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let response = build_test_client(server_url, 0)
.stream_run(weather_tool_request(LlmApiKind::OpenAiResponses), |_| {})
.await
.expect("同槽位重复确认身份不算冲突");
assert_eq!(
response.tool_calls,
vec![LlmToolCall {
id: "call_a".to_string(),
name: "get_weather".to_string(),
arguments: r#"{"city":"杭州"}"#.to_string(),
}]
);
}
// 参数字段类型非法:as_str 会把它当成字段缺失,归一层再把空参数补成 {},于是一个
// 身份完整、参数是合法 JSON 的调用直接交给下游执行。Chat 走强类型 DTO 本来就会
// 反序列化失败,这几条把 Responses / Anthropic 拉齐到同一口径。
async fn expect_stream_non_string_argument_error(api_kind: LlmApiKind, body: &str) {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: body.to_string(),
extra_headers: Vec::new(),
}]);
let error = build_test_client(server_url, 0)
.stream_run(weather_tool_request(api_kind), |_| {})
.await
.expect_err("参数字段类型非法必须失败关闭");
expect_tool_call_deserialize_error(error, "不是字符串");
}
#[tokio::test]
async fn stream_run_rejects_responses_non_string_argument_delta() {
expect_stream_non_string_argument_error(
LlmApiKind::OpenAiResponses,
concat!(
r#"data: {"type":"response.output_item.added","item":{"id":"fc_0","type":"function_call","call_id":"call_a","name":"get_weather"},"output_index":0}"#, "\n\n",
r#"data: {"type":"response.function_call_arguments.delta","delta":{"city":"杭州"},"output_index":0}"#, "\n\n",
r#"data: {"type":"response.completed"}"#, "\n\n"
),
)
.await;
}
#[tokio::test]
async fn stream_run_rejects_responses_non_string_argument_done() {
expect_stream_non_string_argument_error(
LlmApiKind::OpenAiResponses,
concat!(
r#"data: {"type":"response.output_item.added","item":{"id":"fc_0","type":"function_call","call_id":"call_a","name":"get_weather"},"output_index":0}"#, "\n\n",
r#"data: {"type":"response.function_call_arguments.done","arguments":{"city":"杭州"},"output_index":0}"#, "\n\n",
r#"data: {"type":"response.completed"}"#, "\n\n"
),
)
.await;
}
#[tokio::test]
async fn stream_run_rejects_responses_non_string_argument_in_terminal_payload() {
// 只发终态事件的网关同样要拦:这条路径过去连 Result 都不返回。
expect_stream_non_string_argument_error(
LlmApiKind::OpenAiResponses,
concat!(
r#"data: {"type":"response.completed","response":{"output":[{"id":"fc_0","type":"function_call","call_id":"call_a","name":"get_weather","arguments":{"city":"杭州"}}]}}"#, "\n\n"
),
)
.await;
}
#[tokio::test]
async fn stream_run_rejects_anthropic_non_string_partial_json() {
expect_stream_non_string_argument_error(
LlmApiKind::Anthropic,
concat!(
r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_a","name":"get_weather","input":{}}}"#, "\n\n",
r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":{"city":"杭州"}}}"#, "\n\n",
r#"data: {"type":"message_delta","delta":{"stop_reason":"tool_use"}}"#, "\n\n"
),
)
.await;
}
#[tokio::test]
async fn stream_run_rejects_anthropic_numeric_partial_json() {
// 不只是对象:任何非字符串都算类型非法,数字同样不能被当成缺省。
expect_stream_non_string_argument_error(
LlmApiKind::Anthropic,
concat!(
r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_a","name":"get_weather","input":{}}}"#, "\n\n",
r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":42}}"#, "\n\n",
r#"data: {"type":"message_delta","delta":{"stop_reason":"tool_use"}}"#, "\n\n"
),
)
.await;
}
#[tokio::test]
async fn stream_run_keeps_null_and_absent_tool_arguments_as_defaults() {
// 作用域守卫:字段缺失和显式 null 都是合法缺省,必须继续归一为 {},
// 否则零参函数会被这条新规则误杀。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"response.output_item.added","item":{"id":"fc_0","type":"function_call","call_id":"call_a","name":"get_weather"},"output_index":0}"#, "\n\n",
r#"data: {"type":"response.function_call_arguments.delta","output_index":0}"#, "\n\n",
r#"data: {"type":"response.function_call_arguments.done","arguments":null,"output_index":0}"#, "\n\n",
r#"data: {"type":"response.completed"}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let response = build_test_client(server_url, 0)
.stream_run(weather_tool_request(LlmApiKind::OpenAiResponses), |_| {})
.await
.expect("缺省参数仍应归一为空对象");
assert_eq!(
response.tool_calls,
vec![LlmToolCall {
id: "call_a".to_string(),
name: "get_weather".to_string(),
arguments: "{}".to_string(),
}]
);
}
#[tokio::test]
async fn stream_run_keeps_text_only_anthropic_events_without_block_index() {
// 作用域守卫:只有工具事件收紧。text_delta 不依赖槽位,缺 index 不应受影响。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"杭州多云。"}}"#, "\n\n",
r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}"#, "\n\n",
r#"data: {"type":"message_stop"}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let response = build_test_client(server_url, 0)
.stream_run(weather_tool_request(LlmApiKind::Anthropic), |_| {})
.await
.expect("纯文本事件不依赖槽位");
assert_eq!(response.text, "杭州多云。");
assert!(response.tool_calls.is_empty());
}
#[tokio::test]
async fn stream_chat_tool_call_with_incomplete_arguments_json_still_fails() {
// 与上一条成对:流式的参数半截意味着流被截断,是传输层事实,必须报错。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"city\":"}}]}}]}"#, "\n\n",
r#"data: {"choices":[{"finish_reason":"length"}]}"#, "\n\n",
"data: [DONE]\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let client = build_test_client(server_url, 0);
let error = client
.stream_run(weather_tool_request(LlmApiKind::OpenAiChat), |_| {})
.await
.expect_err("流式半截 arguments 必须失败");
expect_tool_call_deserialize_error(error, "流式工具调用参数不是完整 JSON");
}
#[tokio::test]
async fn non_stream_responses_tool_call_missing_name_fails() {
let error = run_non_stream_tool_body(
LlmApiKind::OpenAiResponses,
r#"{"id":"resp_01","output":[{"type":"message","content":[{"type":"output_text","text":"我来查。"}]},{"type":"function_call","call_id":"call_1","arguments":"{}"}]}"#,
)
.await
.expect_err("缺函数名的 function_call 必须失败");
expect_tool_call_deserialize_error(error, "Responses 非流式工具调用缺少函数名");
}
#[tokio::test]
async fn non_stream_responses_tool_call_missing_arguments_normalizes_to_empty_object() {
// 旧实现把缺 arguments 的整条 function_call 丢掉,零参函数因此无法送达。
let response = run_non_stream_tool_body(
LlmApiKind::OpenAiResponses,
r#"{"id":"resp_01","output":[{"type":"function_call","call_id":"call_1","name":"get_time"}]}"#,
)
.await
.expect("缺 arguments 的零参调用应归一成功");
assert_eq!(
response.tool_calls,
vec![LlmToolCall {
id: "call_1".to_string(),
name: "get_time".to_string(),
arguments: "{}".to_string(),
}]
);
}
#[tokio::test]
async fn non_stream_anthropic_tool_use_missing_id_fails() {
let error = run_non_stream_tool_body(
LlmApiKind::Anthropic,
r#"{"id":"msg_01","content":[{"type":"text","text":"我来查。"},{"type":"tool_use","name":"get_weather","input":{"city":"杭州"}}],"stop_reason":"tool_use"}"#,
)
.await
.expect_err("缺 id 的 tool_use 必须失败");
expect_tool_call_deserialize_error(error, "Anthropic 非流式工具调用缺少 id");
}
#[tokio::test]
async fn non_stream_anthropic_tool_use_missing_input_normalizes_to_empty_object() {
let response = run_non_stream_tool_body(
LlmApiKind::Anthropic,
r#"{"id":"msg_01","content":[{"type":"tool_use","id":"call_1","name":"get_time"}],"stop_reason":"tool_use"}"#,
)
.await
.expect("缺 input 的零参调用应归一成功");
assert_eq!(
response.tool_calls,
vec![LlmToolCall {
id: "call_1".to_string(),
name: "get_time".to_string(),
arguments: "{}".to_string(),
}]
);
}
#[tokio::test]
async fn non_stream_tool_call_field_loss_fails_consistently_across_protocols() {
// 三个协议共用同一套归一策略,同一种缺失形状必须给出同一类错误,
// 不能出现"Chat 报错、Responses 静默丢弃"这种口径分裂。
let bodies = [
(
LlmApiKind::OpenAiChat,
r#"{"id":"r","choices":[{"message":{"content":"正文","tool_calls":[{"index":0,"function":{"name":"get_weather","arguments":"{}"}}]},"finish_reason":"tool_calls"}]}"#,
),
(
LlmApiKind::OpenAiResponses,
r#"{"id":"r","output":[{"type":"message","content":[{"type":"output_text","text":"正文"}]},{"type":"function_call","name":"get_weather","arguments":"{}"}]}"#,
),
(
LlmApiKind::Anthropic,
r#"{"id":"r","content":[{"type":"text","text":"正文"},{"type":"tool_use","name":"get_weather","input":{}}],"stop_reason":"tool_use"}"#,
),
];
for (api_kind, body) in bodies {
let error = run_non_stream_tool_body(api_kind, body)
.await
.err()
.unwrap_or_else(|| panic!("{api_kind:?} 缺 id 的工具调用必须失败,实际成功返回"));
expect_tool_call_deserialize_error(error, "工具调用缺少 id");
}
}
#[tokio::test]
async fn stream_run_falls_back_when_tool_use_yields_no_fragments() {
// 上游说了本轮是工具调用,但事件形状不在已支持范围内,一个分片都没解出来。
// 这时必须显式失败让调用方回退非流式,不能把解说文本当成最终回复返回。
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"我来帮你查询。"}}"#, "\n\n",
r#"data: {"type":"unknown_vendor_tool_event","index":9,"payload":{"name":"get_weather"}}"#, "\n\n",
r#"data: {"type":"message_delta","delta":{"stop_reason":"tool_use"}}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let client = build_test_client(server_url, 0);
let error = client
.stream_run(weather_tool_request(LlmApiKind::Anthropic), |_| {})
.await
.expect_err("unparsed tool call should fall back");
assert_eq!(error, LlmError::StreamUnavailable);
}
#[test]
fn multimodal_raw_failure_log_omits_request_and_image_data() {
let config = LlmConfig::new(
LlmProvider::Ark,
"https://example.invalid/v1".to_string(),
"test-key".to_string(),
"test-model".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
0,
1,
)
.expect("config should be valid");
let request = LlmRunRequest::new(vec![LlmMessage::user_multimodal(vec![
LlmMessageContentPart::InputText {
text: "视觉检查私有问题".to_string(),
},
LlmMessageContentPart::InputImage {
image_url: "data:image/png;base64,TOP_SECRET_IMAGE_BYTES".to_string(),
},
])]);
let input_log = build_llm_raw_failure_input_log(&config, &request, false, 1, "test-model")
.expect("build omitted input log");
assert!(input_log.contains("multimodal-sensitive-input"));
assert!(!input_log.contains("视觉检查私有问题"));
assert!(!input_log.contains("data:image"));
assert!(!input_log.contains("TOP_SECRET_IMAGE_BYTES"));
let output = redact_inline_image_data_urls(
"provider echoed data:image/png;base64,TOP_SECRET_IMAGE_BYTES\" done",
);
assert_eq!(output, "provider echoed <image-data-omitted>\" done");
}
fn build_test_client(base_url: String, max_retries: u32) -> LlmClient {
LlmClient::new(build_test_config(base_url, max_retries)).expect("client should be created")
}
fn build_test_config(base_url: String, max_retries: u32) -> LlmConfig {
LlmConfig::new(
LlmProvider::Ark,
base_url,
"test-key".to_string(),
"test-model".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
max_retries,
1,
)
.expect("config should be valid")
}
fn spawn_mock_server(responses: Vec<MockResponse>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
thread::spawn(move || {
for response in responses {
let (mut stream, _) = listener.accept().expect("request should connect");
read_request(&mut stream);
write_response(&mut stream, response);
}
});
format!("http://{address}")
}
fn read_request(stream: &mut std::net::TcpStream) -> String {
stream
.set_read_timeout(Some(StdDuration::from_secs(1)))
.expect("read timeout should be set");
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
let mut expected_total = None;
loop {
match stream.read(&mut chunk) {
Ok(0) => break,
Ok(bytes_read) => {
buffer.extend_from_slice(&chunk[..bytes_read]);
if expected_total.is_none()
&& let Some(header_end) = find_header_end(&buffer)
{
let content_length =
read_content_length(&buffer[..header_end]).unwrap_or(0);
expected_total = Some(header_end + content_length);
}
if let Some(total_bytes) = expected_total
&& buffer.len() >= total_bytes
{
break;
}
}
Err(error)
if error.kind() == std::io::ErrorKind::WouldBlock
|| error.kind() == std::io::ErrorKind::TimedOut =>
{
break;
}
Err(error) => panic!("mock server failed to read request: {error}"),
}
}
String::from_utf8_lossy(buffer.as_slice()).to_string()
}
fn write_response(stream: &mut std::net::TcpStream, response: MockResponse) {
let body = response.body;
let mut raw_response = format!(
"HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n",
response.status_line,
response.content_type,
body.len()
);
for (name, value) in response.extra_headers {
raw_response.push_str(format!("{name}: {value}\r\n").as_str());
}
raw_response.push_str("\r\n");
raw_response.push_str(body.as_str());
stream
.write_all(raw_response.as_bytes())
.expect("mock response should be written");
stream.flush().expect("mock response should flush");
}
fn find_header_end(buffer: &[u8]) -> Option<usize> {
buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|index| index + 4)
}
fn read_content_length(headers: &[u8]) -> Option<usize> {
let text = String::from_utf8_lossy(headers);
text.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.eq_ignore_ascii_case("content-length") {
return value.trim().parse::<usize>().ok();
}
None
})
}
}