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
Reviewed-on: #350
8723 lines
344 KiB
Rust
8723 lines
344 KiB
Rust
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
|
||
})
|
||
}
|
||
}
|