收口美术 Agent 整体超时预算
为消息规划链路增加 18 分钟总 deadline。 修正 LLM 重试响应体错误的 attempt 计数。 统一配置、定价失败中文文案并补齐测试文档。
This commit is contained in:
@@ -3238,8 +3238,8 @@
|
||||
|
||||
- 现象:美术 Agent 对话返回红色错误气泡 `completion error: LLM 请求超时,累计尝试 1 次`;HTTP 本身仍返回 200,前端 20 分钟 transport timeout 没有触发。
|
||||
- 原因:规划请求虽然有 Agent 专用单次 timeout,但 `editor_agent_llm_client` 把 `max_retries` 硬编码为 0;VectorEngine `gpt-5.4-mini` 的偶发长尾、连接超时或可重试上游状态会在第一次失败后直接持久化成 system error。framework 的英文 `completion error` 前缀也被原样暴露给用户。
|
||||
- 处理:120 秒改为前端软提示阈值:POST 仍 pending 时显示不入库的“仍在处理中,请耐心等待”;provider 明确断开/失败才写正式错误。专用 provider 单 attempt 使用 8 分钟 hard timeout,请求发起阶段读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次且重试退避最多 60 秒,保证理论上限小于前端 20 分钟 transport timeout;响应头后的体读取/解析错误按明确失败收口。规划错误对用户统一为中文。重试发生在任何生成工具执行前,不会重复提交生成任务或扣费,不要通过提高前端 timeout 或 runner `max_turns` 掩盖 provider 重试缺失。
|
||||
- 验证:`platform-editor-agent` 测试锁定 8 分钟 hard timeout 与中文错误;前端 fake timer 用例锁定 120 秒前只显示思考动画、到点后显示耐心等待、成功/失败后移除;`api-server` AppState 测试锁定专用 client 透传 retry 次数。运行态排障按同一 request id 对齐 `platform_llm` failure stage 与 `/messages` 总耗时,并确认仍 pending 的请求不再在 120 秒形成错误气泡。
|
||||
- 处理:120 秒改为前端软提示阈值:POST 仍 pending 时显示不入库的“仍在处理中,请耐心等待”;provider 明确断开/失败才写正式错误。专用 provider 单 attempt 使用 8 分钟 hard timeout,请求发起阶段读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次且重试退避最多 60 秒。不要只计算单次 complete 的最坏时间:runner 还可因非法 JSON/工具校验失败进入后续轮次,必须从 handler 入口开始计算 18 分钟总 deadline,进入 `agent.prompt(...)` 时扣除会话锁/上下文准备已用时间,为持久化和前端 20 分钟 timeout 留出余量。响应头后的体读取/解析错误按明确失败收口,必须使用真实 attempt 计数;规划、配置和定价错误对用户统一为中文,原始诊断只记后端日志。重试发生在任何生成工具执行前,不会重复提交生成任务或扣费,不要通过提高前端 timeout 或 runner `max_turns` 掩盖 provider 重试缺失。
|
||||
- 验证:`platform-editor-agent` 测试锁定 8 分钟 hard timeout 与中文错误;前端 fake timer 用例锁定 120 秒前只显示思考动画、到点后显示耐心等待、成功/失败后移除;`platform-llm` 回归用例锁定第二次 attempt 成功响应头后的 body timeout 仍报累计 2 次;`api-server` 测试锁定专用 client retry、18 分钟整体 deadline 与中文直达错误。运行态排障按同一 request id 对齐 `platform_llm` failure stage 与 `/messages` 总耗时,并确认仍 pending 的请求不再在 120 秒形成错误气泡。
|
||||
- 关联:`server-rs/crates/platform-editor-agent/src/agent/agent.rs`、`server-rs/crates/platform-editor-agent/src/framework/error.rs`、`server-rs/crates/api-server/src/state.rs`、`src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts`、`src/components/image-editor/EditorAgentConversation/MessageBubble.tsx`、`src/services/image-editor/editorAgentClient.ts`。
|
||||
|
||||
## 前端退役目录不能只靠扫描和 ignore 隔离
|
||||
|
||||
@@ -75,8 +75,8 @@ npm run check:server-rs-ddd
|
||||
- `/api/editor/projects/{projectId}/agent-conversations` 负责当前工程会话列表和新建;`/api/editor/agent-conversations/{conversationId}` 负责详情读取、终态工具消息懒回填和软删;`POST /api/editor/agent-conversations/{conversationId}/messages` 负责发送消息并返回普通 JSON `EditorAgentMessageResponse`,画布 Agent 不提供 `/messages/stream` SSE 路由。消息请求必须携带最长 128 字符的 `clientMessageId`;前端对该 POST 显式启用 1 次瞬时 transport 重试,并复用同一个序列化 body、`clientMessageId` 和 `x-request-id`。同一会话在锁内按该键幂等,重复键同内容返回已有回合或从已保存用户消息继续,异内容返回 `409`。数字 `EditorAgentMessage.id` 仍只作为工具确认 / 取消的后端消息定位符,不能复用为客户端幂等键。
|
||||
- `module-editor-agent` 只承载纯领域校验:标题派生、附件上限、消息输入规则和会话软删访问规则;不直接依赖 Axum、SpacetimeDB、OSS、LLM 或 Tokio。
|
||||
- `spacetime-module` 的 `editor_agent_conversation` 只保存元数据;创建、列表、读取、更新时间和软删通过 `create_editor_agent_conversation_and_return`、`list_editor_agent_conversations_and_return`、`get_editor_agent_conversation_and_return`、`touch_editor_agent_conversation_and_return`、`delete_editor_agent_conversation_and_return` procedure 完成,`api-server` 只能经 `spacetime-client` facade 访问。
|
||||
- 完整消息文档存 OSS `editor-agent/{conversationId}.json`,由 `api-server` 负责 2 MiB 上限、会话内串行锁、读改写、消息与工具结果持久化和 `touch` 元数据更新时间;该 JSON 不进入 `editor_canvas.layers_json`,也不作为画布布局真相。LLM 未配置、连接已经断开、请求明确失败、达到最终安全上限或规划不可解析时,必须写入 `role=system`、正文以 `ERROR ` 开头的消息,并通过 `deltaMessages` 返回,`errorMessage` 保持为空;前端隐藏前缀并显示红色错误气泡,面向用户的错误正文使用中文语义,不暴露 `completion error` 等 framework 内部前缀,后端仍把该 system 消息注入后续 LLM memory,使 Agent 能读取失败上下文。普通 JSON POST 尚未结束不形成持久化消息;工具失败同样必须形成可回读记录,不能只返回瞬时错误。
|
||||
- 画布 Agent 的 `gpt-5.4-mini` Chat Completions 规划使用 1024 `max_tokens`。前端在 POST pending 120 秒后显示不入库的耐心等待提示;provider request future 明确返回 connect/timeout/HTTP/transport 错误时立即进入正式失败,尚未返回则继续等待。专用 provider 单 attempt hard timeout 为 8 分钟;请求发起阶段的 timeout、连接失败、`408`、`429` 与 `5xx` 读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次,显式配置 0 仍可关闭,专用重试退避最多 60 秒,使两次 attempt 的理论上限仍早于前端消息 POST 的 20 分钟 timeout;已收到成功响应头后的响应体读取或解析失败直接按明确失败收口。重试只包围 LLM 规划请求并发生在任何待确认工具执行之前,因此不会重复提交生成任务或扣费。
|
||||
- 完整消息文档存 OSS `editor-agent/{conversationId}.json`,由 `api-server` 负责 2 MiB 上限、会话内串行锁、读改写、消息与工具结果持久化和 `touch` 元数据更新时间;该 JSON 不进入 `editor_canvas.layers_json`,也不作为画布布局真相。LLM 未配置、连接已经断开、请求明确失败、达到最终安全上限或规划不可解析时,必须写入 `role=system`、正文以 `ERROR ` 开头的消息,并通过 `deltaMessages` 返回,`errorMessage` 保持为空;前端隐藏前缀并显示红色错误气泡,面向用户的错误正文使用中文语义,不暴露 `completion error` 等 framework 内部前缀或原始配置/定价错误;原始诊断只写后端结构化日志。后端仍把该 system 消息注入后续 LLM memory,使 Agent 能读取失败上下文。普通 JSON POST 尚未结束不形成持久化消息;工具失败同样必须形成可回读记录,不能只返回瞬时错误。
|
||||
- 画布 Agent 的 `gpt-5.4-mini` Chat Completions 规划使用 1024 `max_tokens`。前端在 POST pending 120 秒后显示不入库的耐心等待提示;provider request future 明确返回 connect/timeout/HTTP/transport 错误时立即进入正式失败,尚未返回则继续等待。专用 provider 单 attempt hard timeout 为 8 分钟;请求发起阶段的 timeout、连接失败、`408`、`429` 与 `5xx` 读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次,显式配置 0 仍可关闭,专用重试退避最多 60 秒。消息规划生命周期从 handler 入口开始计入 18 分钟总 deadline,进入 `agent.prompt(...)` 时只使用剩余预算;该 deadline 覆盖会话锁/上下文准备与最多 3 轮规划,并为错误持久化/HTTP 返回预留约 2 分钟,不允许多轮规划绕过前端 20 分钟 timeout。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,并使用该成功响应所属的真实 attempt 记录错误。重试只包围 LLM 规划请求并发生在任何待确认工具执行之前,因此不会重复提交生成任务或扣费。
|
||||
- 对话附件只允许引用当前工程 `editor_project_resource` 或当前账号 `editor_asset` 的图片;前端可提交展示用 `imageSrc` / `thumbnailSrc`,后端必须按 `resourceId` / `assetId` 重新归一、校验 owner / project 和 `objectKey`,再给 LLM 或生成工具使用。
|
||||
- 画布 Agent 工具复用既有编辑器图片生成 / 修改 / 图标 spritesheet BFF,并继续使用后端模型定价和 `execute_billable_asset_operation_with_cost`;前端不提交 `priceMudPoints`。
|
||||
- `/messages/{messageId}/confirm` 与 `/messages/{messageId}/cancel` 只返回成功确认;前端成功后立即重新读取整个会话,以会话详情中的权威消息状态和 `externalJobId` 驱动气泡展示与任务轮询。
|
||||
|
||||
@@ -95,12 +95,12 @@
|
||||
## LLM 与计费
|
||||
|
||||
- 编排复用 `creative_agent_gpt5_client` 的 LLM 接入配置(同 provider/env,独立用途标识),画布 Agent 规划请求固定使用 VectorEngine `gpt-5.4-mini` Chat Completions;function-calling 注册八类工具。
|
||||
- 每个用户回合必须由 LLM 返回结构化计划;LLM 未配置、连接已经断开、请求明确失败、达到最终安全上限或返回格式不可解析时,后端写入正文为 `ERROR <错误内容>` 的 system 消息,不使用本地关键词或“收到:...”回显兜底。面向用户的规划错误使用中文语义,不暴露 `completion error` 等 framework 内部前缀。该错误消息与其它 system 消息一样进入后续 LLM memory,使 Agent 能看到上一轮失败上下文。普通 JSON POST 尚未结束只表示 provider request future 仍在等待,不能伪装成已持久化失败。
|
||||
- 每个用户回合必须由 LLM 返回结构化计划;LLM 未配置、连接已经断开、请求明确失败、达到最终安全上限或返回格式不可解析时,后端写入正文为 `ERROR <错误内容>` 的 system 消息,不使用本地关键词或“收到:...”回显兜底。面向用户的规划错误使用中文语义,不暴露 `completion error` 等 framework 内部前缀或原始配置/定价诊断;原始错误只记录在后端日志。该错误消息与其它 system 消息一样进入后续 LLM memory,使 Agent 能看到上一轮失败上下文。普通 JSON POST 尚未结束只表示 provider request future 仍在等待,不能伪装成已持久化失败。
|
||||
- 规划 prompt 必须自动带入上一条已完成生成结果的 `latestGeneratedImage` 引用,内容只包含上一轮 generation 的 `toolName` / `resourceId` / `objectKey` / `assetObjectId` 等轻量元数据,不把私有签名 URL 或大图内容塞进 prompt。
|
||||
- 工具参数中的图片 ID 是由真实 object key 或图片地址计算的稳定 SHA-256 标识;真实 data key 仅存于 api-server 的工具上下文映射,所有图片工具在执行时查表恢复,不能把 object key 或图片地址作为 LLM 可见的工具 ID。
|
||||
- 用户使用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代或编辑上一张结果图时,LLM 默认选择 `edit_image` 并引用 `latestGeneratedImage` 作为源图;除非用户明确要求全新生成,否则不能因为本轮没有重新上传附件而降级为 `generate_image`。
|
||||
- 规划 prompt 必须显式区分“规范展板”和“实际素材产出”:规范图、视觉规范图、风格规范图、素材规范展板、角色规范图等规范展板请求走 `generate_image`,并补齐统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等要求;实际角色立绘才走 `generate_character`,多个图标素材 / 图集才走 `generate_icon_spritesheet`。
|
||||
- 画布 Agent 规划请求使用 Chat Completions 和 1024 `max_tokens`。发送后 120 秒是前端软提示阈值,不是 provider 失败 deadline:若普通 JSON POST 仍 pending,消息流临时显示“仍在处理中,请耐心等待”并继续等待,提示不写入 OSS 消息历史;连接或请求明确失败则立即按正式错误收口。provider 单 attempt 保留 8 分钟 hard timeout;请求发起阶段的 timeout、连接失败、`408`、`429` 与 `5xx` 读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次,专用重试退避最多 60 秒,使两次 attempt 的理论最坏等待仍早于前端 20 分钟 transport timeout;已收到成功响应头后的响应体读取或解析失败直接按明确失败收口。规划重试发生在任何生成工具执行之前,不会重复提交生成任务或扣费;生成图片/编辑图片仍走对应生成工具和模型计费。
|
||||
- 画布 Agent 规划请求使用 Chat Completions 和 1024 `max_tokens`。发送后 120 秒是前端软提示阈值,不是 provider 失败 deadline:若普通 JSON POST 仍 pending,消息流临时显示“仍在处理中,请耐心等待”并继续等待,提示不写入 OSS 消息历史;连接或请求明确失败则立即按正式错误收口。provider 单 attempt 保留 8 分钟 hard timeout;请求发起阶段的 timeout、连接失败、`408`、`429` 与 `5xx` 读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次,专用重试退避最多 60 秒。消息规划生命周期从 handler 入口开始计入 18 分钟总 deadline,进入 `agent.prompt(...)` 时使用扣除会话锁和上下文准备后的剩余预算;该 deadline 覆盖非法 JSON/工具校验失败触发的后续规划轮,并为错误持久化和 HTTP 返回保留约 2 分钟,不再让前端 20 分钟 transport timeout 先触发。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,错误计数/日志使用该响应所属的真实 attempt。规划重试发生在任何生成工具执行之前,不会重复提交生成任务或扣费;生成图片/编辑图片仍走对应生成工具和模型计费。
|
||||
- function-calling runner 必须把“等待用户确认”作为显式工具语义:当本批所有工具都校验成功并进入待确认状态时,立即以成功结果结束当前规划回合并持久化助手文本与待确认卡,不得继续依赖 LLM 自行停止;未知工具、参数错误、普通连续工具和不可解析响应仍受 `max_turns` 保护。
|
||||
- **对话回合免费**(聊天、分析回复不扣泥点),仅 Agent 实际触发生成工具时按对应模型定价扣泥点。
|
||||
- 工具调用前后端校验泥点余额;不足时该次生成失败并在对话中以明确错误气泡告知,对话本身可继续。
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
use std::future::IntoFuture;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::{Extension, Json};
|
||||
use module_editor_agent::{
|
||||
@@ -75,8 +78,13 @@ use platform_editor_agent::agent::tools::generate_video::{
|
||||
GenerateVideoTool, GenerateVideoToolArgs,
|
||||
};
|
||||
use shared_kernel::{build_prefixed_uuid_id, normalize_optional_string, normalize_required_string};
|
||||
use tokio::time::{Instant, timeout};
|
||||
|
||||
const EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS: usize = 128;
|
||||
const EDITOR_AGENT_PROMPT_TIMEOUT_MS: u64 = 18 * 60_000;
|
||||
const EDITOR_AGENT_PROMPT_TIMEOUT_MESSAGE: &str = "规划总时长已达到 18 分钟安全上限";
|
||||
const EDITOR_AGENT_LLM_UNAVAILABLE_MESSAGE: &str = "美术 Agent 服务暂不可用,请稍后重试";
|
||||
const EDITOR_AGENT_PRICING_UNAVAILABLE_MESSAGE: &str = "美术 Agent 生成定价暂不可用,请稍后重试";
|
||||
|
||||
pub async fn editor_agent_message(
|
||||
State(state): State<AppState>,
|
||||
@@ -85,6 +93,7 @@ pub async fn editor_agent_message(
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<EditorAgentMessageRequest>,
|
||||
) -> Result<Json<EditorAgentMessageResponse>, AppError> {
|
||||
let message_started_at = Instant::now();
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?;
|
||||
let client_message_id = validate_editor_agent_message_request(&payload)?;
|
||||
@@ -211,12 +220,16 @@ pub async fn editor_agent_message(
|
||||
|
||||
// Build and run agent
|
||||
let Some(llm_client) = state.editor_agent_llm_client() else {
|
||||
tracing::warn!(
|
||||
conversation_id = %conversation.conversation_id,
|
||||
"美术 Agent LLM 客户端未配置"
|
||||
);
|
||||
return persist_editor_agent_planning_error(
|
||||
&state,
|
||||
&conversation,
|
||||
&mut document,
|
||||
conversation_summary,
|
||||
"Editor Agent LLM client not configured",
|
||||
EDITOR_AGENT_LLM_UNAVAILABLE_MESSAGE,
|
||||
)
|
||||
.await;
|
||||
};
|
||||
@@ -224,12 +237,17 @@ pub async fn editor_agent_message(
|
||||
let pricing = match state.editor_generation_pricing().await {
|
||||
Ok(pricing) => pricing,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
conversation_id = %conversation.conversation_id,
|
||||
error = %error,
|
||||
"读取美术 Agent 生成定价失败"
|
||||
);
|
||||
return persist_editor_agent_planning_error(
|
||||
&state,
|
||||
&conversation,
|
||||
&mut document,
|
||||
conversation_summary,
|
||||
format!("failed to load editor generation pricing: {error}"),
|
||||
EDITOR_AGENT_PRICING_UNAVAILABLE_MESSAGE,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -264,9 +282,13 @@ pub async fn editor_agent_message(
|
||||
.memory(memory)
|
||||
.build();
|
||||
|
||||
let agent_result = agent
|
||||
.prompt(LlmMessage::user(user_message.text.clone()))
|
||||
.await;
|
||||
let remaining_prompt_duration =
|
||||
remaining_editor_agent_prompt_duration(message_started_at.elapsed());
|
||||
let agent_result = run_editor_agent_prompt_with_timeout(
|
||||
agent.prompt(LlmMessage::user(user_message.text.clone())),
|
||||
remaining_prompt_duration,
|
||||
)
|
||||
.await;
|
||||
|
||||
let assistant_now = now_rfc3339();
|
||||
|
||||
@@ -303,6 +325,26 @@ pub async fn editor_agent_message(
|
||||
}
|
||||
}
|
||||
|
||||
fn remaining_editor_agent_prompt_duration(elapsed: Duration) -> Duration {
|
||||
Duration::from_millis(EDITOR_AGENT_PROMPT_TIMEOUT_MS).saturating_sub(elapsed)
|
||||
}
|
||||
|
||||
async fn run_editor_agent_prompt_with_timeout<F>(
|
||||
future: F,
|
||||
duration: Duration,
|
||||
) -> Result<Vec<PromptOutput>, PromptError>
|
||||
where
|
||||
F: IntoFuture<Output = Result<Vec<PromptOutput>, PromptError>>,
|
||||
{
|
||||
timeout(duration, future.into_future())
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
Err(PromptError::CompletionError(
|
||||
EDITOR_AGENT_PROMPT_TIMEOUT_MESSAGE.to_string(),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn build_editor_agent_error_message(
|
||||
message_id: usize,
|
||||
error: impl std::fmt::Display,
|
||||
@@ -512,6 +554,42 @@ mod tests {
|
||||
assert_eq!(message.text, "ERROR planning failed");
|
||||
assert!(message.tool_call.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_planning_failures_use_user_facing_chinese_copy() {
|
||||
assert_eq!(
|
||||
build_editor_agent_error_message(1, EDITOR_AGENT_LLM_UNAVAILABLE_MESSAGE).text,
|
||||
"ERROR 美术 Agent 服务暂不可用,请稍后重试"
|
||||
);
|
||||
assert_eq!(
|
||||
build_editor_agent_error_message(2, EDITOR_AGENT_PRICING_UNAVAILABLE_MESSAGE).text,
|
||||
"ERROR 美术 Agent 生成定价暂不可用,请稍后重试"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prompt_timeout_applies_to_the_whole_agent_run() {
|
||||
let error = run_editor_agent_prompt_with_timeout(
|
||||
std::future::pending::<Result<Vec<PromptOutput>, PromptError>>(),
|
||||
Duration::from_millis(1),
|
||||
)
|
||||
.await
|
||||
.expect_err("pending agent run should hit the prompt deadline");
|
||||
|
||||
assert_eq!(EDITOR_AGENT_PROMPT_TIMEOUT_MS, 1_080_000);
|
||||
assert_eq!(
|
||||
remaining_editor_agent_prompt_duration(Duration::from_secs(17 * 60)),
|
||||
Duration::from_secs(60)
|
||||
);
|
||||
assert_eq!(
|
||||
remaining_editor_agent_prompt_duration(Duration::from_secs(18 * 60)),
|
||||
Duration::ZERO
|
||||
);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"美术 Agent 规划失败:规划总时长已达到 18 分钟安全上限"
|
||||
);
|
||||
}
|
||||
}
|
||||
fn editor_agent_system_prompt() -> &'static str {
|
||||
r#"
|
||||
|
||||
@@ -189,6 +189,11 @@ pub struct LlmClient {
|
||||
http_client: Client,
|
||||
}
|
||||
|
||||
struct LlmAttemptResponse {
|
||||
response: reqwest::Response,
|
||||
attempt: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(untagged)]
|
||||
enum LlmRequestBody {
|
||||
@@ -753,14 +758,15 @@ impl LlmClient {
|
||||
pub async fn request_text(&self, request: LlmTextRequest) -> Result<LlmTextResponse, LlmError> {
|
||||
request.validate()?;
|
||||
let resolved_model = request.resolved_model(self.config.model()).to_string();
|
||||
let response = self.execute_request(&request, false).await?;
|
||||
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, 1);
|
||||
let llm_error = map_stream_read_error(error, attempt);
|
||||
log_llm_raw_failure(
|
||||
&self.config,
|
||||
&request,
|
||||
false,
|
||||
1,
|
||||
attempt,
|
||||
"read_response_failed",
|
||||
llm_error.to_string().as_str(),
|
||||
);
|
||||
@@ -778,7 +784,7 @@ impl LlmClient {
|
||||
&self.config,
|
||||
&request,
|
||||
false,
|
||||
1,
|
||||
attempt,
|
||||
"parse_response_failed",
|
||||
raw_text.as_str(),
|
||||
);
|
||||
@@ -805,7 +811,10 @@ impl LlmClient {
|
||||
{
|
||||
request.validate()?;
|
||||
let resolved_model = request.resolved_model(self.config.model()).to_string();
|
||||
let mut response = self.execute_request(&request, true).await?;
|
||||
let LlmAttemptResponse {
|
||||
mut response,
|
||||
attempt,
|
||||
} = self.execute_request(&request, true).await?;
|
||||
let response_id = response
|
||||
.headers()
|
||||
.get("x-request-id")
|
||||
@@ -819,12 +828,12 @@ impl LlmClient {
|
||||
|
||||
loop {
|
||||
let next_chunk = response.chunk().await.map_err(|error| {
|
||||
let llm_error = map_stream_read_error(error, 1);
|
||||
let llm_error = map_stream_read_error(error, attempt);
|
||||
log_llm_raw_failure(
|
||||
&self.config,
|
||||
&request,
|
||||
true,
|
||||
1,
|
||||
attempt,
|
||||
"read_stream_failed",
|
||||
parser.raw_text().as_str(),
|
||||
);
|
||||
@@ -842,7 +851,7 @@ impl LlmClient {
|
||||
&self.config,
|
||||
&request,
|
||||
true,
|
||||
1,
|
||||
attempt,
|
||||
"decode_stream_failed",
|
||||
parser.raw_text().as_str(),
|
||||
);
|
||||
@@ -857,7 +866,7 @@ impl LlmClient {
|
||||
&self.config,
|
||||
&request,
|
||||
true,
|
||||
1,
|
||||
attempt,
|
||||
"parse_stream_failed",
|
||||
parser.raw_text().as_str(),
|
||||
);
|
||||
@@ -889,7 +898,7 @@ impl LlmClient {
|
||||
&self.config,
|
||||
&request,
|
||||
true,
|
||||
1,
|
||||
attempt,
|
||||
"decode_stream_failed",
|
||||
parser.raw_text().as_str(),
|
||||
);
|
||||
@@ -901,7 +910,7 @@ impl LlmClient {
|
||||
&self.config,
|
||||
&request,
|
||||
true,
|
||||
1,
|
||||
attempt,
|
||||
"parse_stream_failed",
|
||||
parser.raw_text().as_str(),
|
||||
);
|
||||
@@ -932,7 +941,7 @@ impl LlmClient {
|
||||
&self.config,
|
||||
&request,
|
||||
true,
|
||||
1,
|
||||
attempt,
|
||||
"parse_stream_failed",
|
||||
parser.raw_text().as_str(),
|
||||
);
|
||||
@@ -962,7 +971,7 @@ impl LlmClient {
|
||||
&self.config,
|
||||
&request,
|
||||
true,
|
||||
1,
|
||||
attempt,
|
||||
"empty_stream_response",
|
||||
parser.raw_text().as_str(),
|
||||
);
|
||||
@@ -999,7 +1008,7 @@ impl LlmClient {
|
||||
&self,
|
||||
request: &LlmTextRequest,
|
||||
stream: bool,
|
||||
) -> Result<reqwest::Response, LlmError> {
|
||||
) -> 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.protocol {
|
||||
@@ -1039,7 +1048,7 @@ impl LlmClient {
|
||||
attempt,
|
||||
response.status().as_u16()
|
||||
);
|
||||
return Ok(response);
|
||||
return Ok(LlmAttemptResponse { response, attempt });
|
||||
}
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
@@ -1936,6 +1945,57 @@ mod tests {
|
||||
assert_eq!(response.response_id.as_deref(), Some("resp_retry"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_text_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
|
||||
.request_single_message_text("系统", "用户")
|
||||
.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 request_text_uses_request_level_timeout_override() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
|
||||
|
||||
Reference in New Issue
Block a user