合并主分支
Project CI / Frontend tests (pull_request) Successful in 3m29s
Project CI / Backend tests (pull_request) Successful in 3m47s
Project CI / Repository checks (pull_request) Successful in 1m6s
Project CI / Native shell tests (pull_request) Successful in 12m4s

解决简单合并冲突
This commit is contained in:
2026-08-07 13:05:47 +08:00
11 changed files with 217 additions and 55 deletions
@@ -840,7 +840,7 @@
## 2026-07-09 角色动作视频生成背景色统一为多色自动决策 + 阿里云抠帧
- 背景:角色动作视频抽帧过去固定 legacy `#00FF00` 绿幕 + 本地 `editor_green_screen`,与生图链路的多色自动决策不一致;实测出现背景色与前景 / 皮肤撞色(蓝撞蓝、桃 / 黄撞肤色)以及图生视频背景变白的问题。
- 决策:角色动作视频背景色与生图统一。`screenColor=auto` 时由视觉 LLM`gpt-5-mini`Responses 协议、`reasoning_effort=low``max_tokens=1024`)读源角色图自动决策,并经硬过滤器(Lab 危险质量 + 皮肤专属三判据:ΔE 距离 / 色调投影 / RGB 分离)剔除与前景及皮肤撞色的候选,手动 hex 仍尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景确定性等于抠图键色。抽帧后逐帧优先走 BgFilter(固定 `seg_model=birefnet``cross_check=on`),失败依次降级阿里云通用抠图和本地 `editor_green_screen` 键色兜底(按生成时选定的背景色,而非固定 `#00FF00`)。BgFilter 与阿里云抠图失败均写入 `external_api_call_failure` 失败审计。调色板新增中明度低饱和「灰竹绿 `#A0BBA0`」补齐冷区绿色段。
- 决策:角色动作视频背景色与生图统一。`screenColor=auto` 时由视觉 LLM`gpt-5-mini`Responses 协议、`reasoning_effort=low``max_output_tokens=1024`)读源角色图自动决策,并经硬过滤器(Lab 危险质量 + 皮肤专属三判据:ΔE 距离 / 色调投影 / RGB 分离)剔除与前景及皮肤撞色的候选,手动 hex 仍尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景确定性等于抠图键色。抽帧后逐帧优先走 BgFilter(固定 `seg_model=birefnet``cross_check=on`),失败依次降级阿里云通用抠图和本地 `editor_green_screen` 键色兜底(按生成时选定的背景色,而非固定 `#00FF00`)。BgFilter 与阿里云抠图失败均写入 `external_api_call_failure` 失败审计。调色板新增中明度低饱和「灰竹绿 `#A0BBA0`」补齐冷区绿色段。
- 影响范围:`server-rs/crates/api-server/src/character_animation_assets.rs``editor_screen_background_decision.rs``editor_screen_background_filter.rs`(新增硬过滤模块)、`editor_green_screen.rs`(调色板)、`external_api_audit.rs``llm_model_routing.rs`、图片画布 MVP 与后端数据契约文档。
- 验证方式:`cargo test -p api-server editor_screen_background character_animation --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml`、真机对源角色图跑视觉决策与候选危险度表、抽帧后采样序列帧背景色确认落在冷区安全集。
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
@@ -877,7 +877,7 @@
- 2026-07-11 调整,2026-07-12 更新:后台单 Agent 的 planning loop 每 6 轮形成一个上下文压缩窗口,每轮工具动作上限仍为 3;6 轮不再是整个 run 的固定上限。`loopIteration` 在同一 run 内连续递增,`maxLoopIterations` 指向当前窗口结束轮次,跨重启待确认动作按 context bundle 的 `nextLoopIndex` 继续。每个窗口结束时压缩已有 observation;窗口产生新的独立观察时继续同一 run,最近 6 轮没有独立进展或相邻窗口指纹重复时才进入 `failed / budget-exhausted`,并记录 `loop-budget-exhausted`,不会伪装完成。该调整只作用于后台单 Agent Runtime,不改变游戏草案 Generator/Evaluator 的 3 轮上限;旧实施摘要中“后台 3 轮后整理最终回复”或“整个 run 最多 6 轮”的描述由本条取代。
- 2026-07-12 补充:后台 Agent 每个 run 的可恢复 planning 上下文使用 `.agent/runtime/context-bundles/<agentId>/<runId>.json`。Runtime 通过临时文件替换原子写入,绑定 Agent、Task、Session、Run 和任务正文,保存 `nextLoopIndex`、当前窗口、计划、fallback response、压缩后的 observation 与上一窗口指纹;单文件最多 64 KiB、最多 12 条 observation。写入前统一截断并过滤敏感内容和项目绝对路径,安全校验失败时拒绝落盘;读取时要求普通文件,并校验 schema、Agent、Session、Run、任务正文和 observation 数量,身份不一致时拒绝续跑。该路径属于 Runtime 私有控制面,与根级 `.agent/context.bundle.json` 的旧 run-control 辅助文件不是同一契约,通用文件工具不得暴露。
- 2026-07-11 调整,2026-07-15 收口:开发者投递的后台任务从队列记录、Runtime `currentTask/currentGoal` 到待确认动作私有账本统一保留最多 4,000 字符,不再在入队时截成 180 字符。必要的 180 字符可见摘要只用于私有执行界面;公共 event、Agent DB、receipt、activity、output 和报告不再保存任务预览或正文,只保存 `taskSha256 / taskChars` 等身份、哈希和计数。LLM planning、显式恢复、确认续跑和重启恢复继续使用私有完整任务字段,避免丢失长需求末尾的验收条件、禁止项或输出格式。
- 2026-07-11 调整,2026-07-15 收口:后台结构化 planning 使用独立的 4,000 输出 token 上限,最终回复使用 2,400;两者显式请求 low reasoning effort 和 low text verbosity。`platform-llm` 会把 reasoning effort 同时映射到 OpenAI Responses 的 `reasoning.effort` 与 Chat Completions 的 `reasoning_effort`,未设置时不新增字段。低推理强度和较大的可见输出余量只用于降低空响应概率;`EmptyResponse` 仍按单次 lifecycle 的歧义失败处理,不再自动原样重放。
- 2026-07-11 调整,2026-07-15 收口2026-08-06 修正 token 契约:后台结构化 planning 使用独立的 4,000 生成 token 预算,最终回复使用 2,400;两者显式请求 low reasoning effort 和 low text verbosity。生成预算包含可见输出与隐藏 reasoning token,不是可见输出余量,也不是输入加输出总量。`platform-llm` 会把 reasoning effort 同时映射到 OpenAI Responses 的 `reasoning.effort` 与 Chat Completions 的 `reasoning_effort`,未设置时不新增字段;协议中立预算按 endpoint 能力映射为 Chat `max_completion_tokens` 或 legacy `max_tokens`、Responses `max_output_tokens`、Anthropic `max_tokens`。AGC 配置、Provider 请求序列化和重试 / handoff 指纹中的既有 `maxOutputTokens` 键冻结不变,避免升级后进入 reconciliation。低推理强度和较大的生成预算只用于降低空响应概率;`EmptyResponse` 仍按单次 lifecycle 的歧义失败处理,不再自动原样重放。
- 2026-07-11 补充:后台单 Agent 的工具计划响应只接受可反序列化为计划 schema 的 JSON object。解析器提取模型输出中的首个完整对象并允许对象后带普通说明;未找到完整 JSON 对象,或提取对象无法反序列化为工具计划时,Runtime 最多追加 2 次自动格式修复请求。每次修复只携带限长、脱敏后的上一次无效输出,并写入 `agent.runtime.tool_plan.repair` 审计。两次修复后仍无有效对象则按工具规划失败处理;工具规划阶段的普通文本不得转换为默认的空 actions + response,也不得据此把任务标记为完成。
- 2026-07-11 调整:工具计划顶层 `thinkingSummary / plan / actions / response` 四个字段必须同时存在,未知顶层字段、空 thinkingSummary 和空 tool 均属于协议错误并进入同一格式修复预算,`{}` 或前置无关 JSON 对象不能再触发空计划收束。空 actions 表示 planning 收束;response 非空时直接采用,response 为空时进入独立的最终回复生成。`agent.runtime.project.verify` 审计同时保存 `runId / actionId / actionFingerprint`,使并行 Agent 的失败与通过记录能够精确归属到发起动作。
- 2026-07-12 补充,2026-07-27 更正:OpenAI Chat / Responses 的后台 Agent 工具 planning 改用唯一 `submit_agent_tool_plan` 原生 function tool,字符串 `tool_choice=required` 和 strict schema;只接受恰好一次同名调用,arguments 继续经过本地计划 schema、工具白名单和权限策略校验,错误函数、多调用或非法 arguments 进入原有两次格式修复预算且不产生副作用。本条原写「Anthropic 保留文本 JSON 回退;planning 非流式」,已由 2026-07-27「Anthropic 与流式统一使用 Provider 原生工具」取代——Anthropic 同样发送原生工具目录,planning 不再因协议强制非流式。每轮成功协议写 `agent.runtime.tool_plan.protocol`,修复审计记录 protocol、callId 和 functionName。
@@ -14,6 +14,14 @@
- 关联:相关文件、文档、提交或 Issue
```
## Chat 生成预算字段不能按模型名猜测或失败后自动重放
- 现象:同一个 OpenAI-compatible Chat endpoint 调用 reasoning 模型时返回 `Unsupported parameter: max_tokens`;直接把全局请求字段改成 `max_completion_tokens` 后,旧兼容网关又可能拒绝新字段。
- 原因:内部生成预算语义与上游 wire dialect 被混在一起。Chat 当前字段是 `max_completion_tokens`,旧兼容层仍只接受 `max_tokens`Responses 和 Anthropic 又分别使用自己的字段。模型名、base URL 和 `OpenAiCompatible` 标签都不能证明 endpoint 能力,收到 `400` 后重发还可能重复计费。
- 处理:在 `LlmConfig` 上显式声明 Chat token budget field capability;通用兼容配置默认 legacy,已验证的 VectorEngine 专用 client opt-in `max_completion_tokens`,每次只发送一个字段。内部 `max_output_tokens` 与 AGC 持久指纹键 `maxOutputTokens` 保持不变。
- 验证:序列化测试分别断言 modern / legacy Chat 只出现选定字段,请求级 model override 不改变字段;Responses 继续只发 `max_output_tokens`Anthropic 继续只发 `max_tokens`AppState 测试断言 VectorEngine client 已显式启用 modern capability。
- 关联:`server-rs/crates/platform-llm/src/lib.rs``server-rs/crates/api-server/src/state.rs``scripts/test-ve-llm.mjs`、Issue #143
## Runtime 状态写失败不能发生在公开失败消息之前
- 现象:用户提交长任务后只看到运行失败或任务直接消失,聊天里一条有用消息都没有;另一些失败又同时出现 Runtime event 和 conversation 两条近似提示。
@@ -872,7 +872,7 @@ V1.21 对标 Codex CLI 的 `model_context_window`、`model_auto_compact_token_li
### 配置与预算
- `llm` 新增 `contextWindowTokens / autoCompactTokenLimit / toolOutputTokenLimit`,发布默认分别为 `128000 / 64000 / 12000``agentLlm.<agentId>` 复用现有 patch 继承,显式 Agent 值覆盖全局。三项都必须大于 0,自动阈值必须小于 context window,并为当前请求的 `maxOutputTokens` 与固定安全余量留下空间
- `llm` 新增 `contextWindowTokens / autoCompactTokenLimit / toolOutputTokenLimit`,发布默认分别为 `128000 / 64000 / 12000``agentLlm.<agentId>` 复用现有 patch 继承,显式 Agent 值覆盖全局。三项都必须大于 0,自动阈值必须小于 context window,并为当前请求的生成 token 预算与固定安全余量留下空间。既有配置和持久协议键 `maxOutputTokens` 保持冻结以兼容恢复;它表示包含可见输出与隐藏 reasoning token 的生成侧预算,不表示输入加输出总量,也不保证可见正文长度
- Runtime 在发送 tool-plan、context-compaction 或 final-reply 前,按消息、multimodal 文本和 function schema 的规范序列化字符数做保守 token 估算;Provider 返回 usage 时再记录真实 `prompt/completion/total`。估算只用于提前门禁,不能伪装成 Provider 计费事实。
- 单条 observation 进入模型上下文前按 `toolOutputTokenLimit` 收紧;完整命令输出仍留在 owning Agent 的私有 sidecar,通过既有分页工具读取。公共状态只显示估算 token、最近真实 usage、阈值、压缩次数和时间,不显示被压缩正文。
@@ -258,7 +258,7 @@ Agent Runtime 负责:
- v4 在压缩状态校验通过后迁移为当前格式;v3 在原身份、任务、project revision、verification gate 和结构化计划校验通过后,从当前 Runtime/Goal sidecar 补齐 Goal 快照并继续;v2 先按 V1.17 规则补齐结构化计划,再补 Goal;后续 checkpoint 统一写 v5。v1、缺失既有 gate 关联或无法证明 Goal 快照的记录不自动迁移。Provider pause 中断/返回边界先持久化 continuation;该恢复快照把 `goalStatus` 设为恢复后的 `active`,避免 resume 后用 paused 上下文自相矛盾。
- stale continuation 必须清空旧 actions 与 fallback response,保留 blocker、loop 位置、窗口进度和结构化计划;`contextStalled` 一旦成立,同 run 重规划和重启不得清除。动态 revision 数字、时间戳与验证命令输出不构成独立进展;重复 stale 最迟在相邻窗口指纹重复时以 `loop-budget-exhausted` 终止。单文件最多 128 KiB、最多 12 条 observation;写入前统一限长并过滤敏感内容和项目绝对路径。revision 与验证资格仍以锁内独立文件为准,bundle 只是 Runtime 私有恢复上下文,不等同于根级 `.agent/context.bundle.json`,不得由通用文件工具暴露。
- 2026-07-11 调整:后台任务的可执行正文上限统一为 4,000 字符。入队 JSONL、启动后的 `currentTask/currentGoal`、planning prompt、待确认动作 task context、确认续跑和重启恢复都保留同一份正文;对话仍保存用户原始消息。状态事件、列表卡片和 `agent.db` 摘要可继续使用较短安全预览,但不能再反向作为后续 LLM 执行输入。这样长任务末尾的验收标记和输出格式要求不会在队列边界被 180 字符截断。
- 2026-07-11 调整,2026-07-12 由 Runtime V1.2 更新:后台 planning 使用 4,000 输出 token,最终回复使用 2,400,并继续叠加最多 3 次 EmptyResponse 重试。推理档位不再硬编码为 `low`planning、普通单 Agent 聊天和最终回复统一使用解析后的 `llm.reasoningEffort``agentLlm.<agentId>.reasoningEffort` 有值时覆盖全局、缺省时继承全局;取值只允许 `default / low / medium / high`,发布默认 `high``default` 表示不向 Provider 发送推理档位。
- 2026-07-11 调整,2026-07-12 由 Runtime V1.2 更新2026-08-06 仅澄清 token 口径:后台 planning 使用 4,000 生成 token 预算,最终回复使用 2,400;预算包含可见输出与 Provider 可能使用的隐藏 reasoning token,不等于可见正文长度。既有配置与持久协议键 `maxOutputTokens` 保持冻结,Provider adapter 再按协议映射为 Chat endpoint capability 选定的 `max_completion_tokens` 或 legacy `max_tokens`、Responses `max_output_tokens`、Anthropic `max_tokens`;AGC 通用自定义网关当前保持 legacy 默认。本条原有“最多 3 次 EmptyResponse 重试”已由本文后续 2026-07-15 的 V1.18 收口条目取代。推理档位不再硬编码为 `low`planning、普通单 Agent 聊天和最终回复统一使用解析后的 `llm.reasoningEffort``agentLlm.<agentId>.reasoningEffort` 有值时覆盖全局、缺省时继承全局;取值只允许 `default / low / medium / high`,发布默认 `high``default` 表示不向 Provider 发送推理档位。
- 2026-07-11 补充,2026-07-15 由 V1.17 更新:后台单 Agent 的工具 planning 响应必须提供可反序列化为 `thinkingSummary / planUpdate / plan / actions / response` schema 的 JSON object。Runtime 从模型输出中解析首个完整对象,因此对象后的尾随说明可以忽略;只有普通文本、没有完整对象,或对象无法反序列化时都不构成有效工具计划。对于这两类无效输出,Runtime 最多追加 2 次自动格式修复请求;同一次 planning 的私有 repair 请求可携带限长且经过统一敏感信息过滤的上一条模型输出或 function call 预览与协议错误,以便 Provider 真正修正格式。`.agent/agent.db``agent.runtime.tool_plan.repair` 公共审计只写 attempt/maxAttempts、protocol,以及错误、输出/调用体预览、callId 和 functionName 的 SHA-256、字符数或计数,不保存原始模型正文、错误或 function arguments。修复预算耗尽后进入既有工具规划失败路径,不得把普通文本折算为空 actions + response,也不得因此进入 completed;最终回复阶段仍按其独立的普通文本契约处理。旧文本协议可省略 `planUpdate`,但只能继续走 legacy `plan` fallback。
- 2026-07-12 补充,2026-07-15 由 V1.17 更新,2026-07-27 由「Anthropic 与流式统一使用 Provider 原生工具」更新,2026-08-03 收紧 strict 边界:OpenAI Chat / Responses 的后台工具 planning 优先注册唯一的 `submit_agent_tool_plan` function tool,并使用字符串形式 `tool_choice=required` 和 strict schemaRuntime 只接受恰好一次同名 function call,并把 arguments 复用现有 `AgentRuntimeToolPlan` 校验与两次格式修复循环。strict arguments 中 `planUpdate` 必须出现但可为 `null`,使用结构化更新时 legacy `plan` 必须为空。错误函数名、多次调用和非法 arguments 都不得执行工具。Anthropic 自 2026-07-27 起与另外两种协议一致发送原生工具目录:请求体顶层携带 `tools`schema 字段名为 `input_schema`)。`strict` 能力不从 `apiKind` 推断:AGC 只对无凭据 / 自定义端口 / 路径的官方 HTTPS endpoint 和 Claude 4.5+ 版本化 model id 显式开启,旧模型、未知别名和兼容网关默认关闭。开启后使用官方支持关键词白名单生成 Anthropic 专用传输 schema,已知不支持约束只从传输副本剔除,调用方原 schema 保持不变;未知关键词、不可解析 / 递归 `$ref` 和 strict 工具 / optional / union 请求级复杂度超限时该工具保持 non-strict,不能因完整 AGC 工具集超限让整次请求被上游拒绝。工具数组最后一项携带 `cache_control: {"type":"ephemeral"}` 作为 prompt cache breakpoint;非流式和流式 usage 都将 `input_tokens + cache_creation_input_tokens + cache_read_input_tokens` 合并为 prompt tokens。`tool_choice` 使用对象形态(`Auto → {"type":"auto"}``Required → {"type":"any"}`,裸字符串会被上游拒绝),响应解析 `tool_use` block 并把 `input` 序列化为 `arguments`。planning 不再因协议强制非流式,最终普通回复继续按 Agent 配置决定是否流式。`platform-llm` 仍在本地拒绝无 function tools 的 tool choice,但不再拒绝 Anthropic function tools;协议类型继续写入 `agent.runtime.tool_plan.protocol` 审计,Anthropic 正常路径的取值为 `native_runtime_tools` 而不是 `text_json`
- 2026-07-11 调整,2026-07-15 由 V1.17 更新:工具计划五个顶层字段均为必填并拒绝未知顶层字段;`thinkingSummary`、结构化计划的 `explanation / step``action.tool` 必须非空。`planUpdate` 只接受 `null` 或最多 8 个唯一步骤,状态限于 `pending / in_progress / completed` 且至多一个 `in_progress`。这样 `{}`、前置无关 JSON 或结构不完整对象会触发格式修复,不会成为假完成信号。空 actions 只有在 verification、process/join/delivery 和结构化计划完成门禁都通过后才表示 planning 收束;response 非空时直接采用,response 为空时进入独立最终回复生成。`agent.runtime.project.verify` 记录补充 `runId / actionId / actionFingerprint`,用于在多 Agent 并行验证时把命令终态与具体 Runtime 动作关联。
@@ -81,7 +81,7 @@ npm run check:server-rs-ddd
- 画布 handler 的 18 分钟总 deadline 通过 runtime 提供的 deadline future 下沉到公共 runnercompletion await 可被 deadline 终止;effectful tool 在开始前检查 deadline,开始后不被中途 drop,返回后再携带结果收口为 `PromptRunError`。禁止用外层 timeout 直接 drop 整个 prompt future 并伪造空 `partial_outputs`
- `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 秒。消息规划生命周期从 handler 入口开始计入 18 分钟总 deadline,进入 `agent.prompt(...)` 时只使用剩余预算;该 deadline 覆盖会话锁/上下文准备与最多 3 轮规划,并为错误持久化/HTTP 返回预留约 2 分钟,不允许多轮规划绕过前端 20 分钟 timeout。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,并使用该成功响应所属的真实 attempt 记录错误。重试只包围 LLM 规划请求并发生在任何待确认工具执行之前,因此不会重复提交生成任务或扣费。
- 画布 Agent 的 `gpt-5.4-mini` Chat Completions 规划使用 1024 生成 token 预算;VectorEngine 专用 client 显式发送当前字段 `max_completion_tokens`,其预算包含可见输出和隐藏 reasoning token。通用 OpenAI-compatible client 默认保留旧 `max_tokens`,只有确认 endpoint 能力后才 opt-in,禁止按模型名猜测或在 `400` 后自动重放。前端在 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 或生成工具使用。
- `edit-image` 只接受当前图片上下文中的 `object_image_id``source_image_id` 不是现役 schema 字段,prompt、tool args、确认执行和测试中都不得生成或兼容该字段。
- 画布 Agent 工具复用既有编辑器图片生成 / 修改 / 图标 spritesheet BFF,并继续使用后端模型定价和 `execute_billable_asset_operation_with_cost`;前端不提交 `priceMudPoints`
@@ -111,7 +111,7 @@
- 工具参数中的图片 ID 是由真实 object key 或图片地址计算的稳定 SHA-256 标识;真实 data key 仅存于 api-server 的工具上下文映射,所有图片工具在执行时查表恢复,不能把 object key 或图片地址作为 LLM 可见的工具 ID。
- 规划 prompt 必须显式区分“规范展板”和“实际素材产出”:规范图、视觉规范图、风格规范图、素材规范展板、角色规范图等规范展板请求走 `generate-image`,并补齐统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等要求;实际角色立绘才走 `generate-character`,多个图标素材 / 图集才走 `generate-icon-spritesheet`
- 用户的当前消息确实在确认或取消一条已存在且仍为 pending 的工具调用时,画布 Agent 只引导使用该卡片的确认 / 取消按钮,本条确认 / 取消意图不产生新 tool call。这条边界必须使用“匹配 pending 调用时如何处理”的正向、条件化描述,不得改写成“不得重新发起相同工具调用”一类全局否定话术:实测中模型会把这类否定句过度泛化为拒绝后续新请求。已 cancelled 的卡片不再处理;用户明确要求修改、重做或发起新任务时必须允许新 tool call,pending 卡片也不阻塞无关的新请求。
- 画布 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 必须作为 runner 内部 deadline future 参与 completion await,并在每个 tool 开始前、返回后检查,不能用外层 `tokio::timeout` 丢弃整个 prompt future,也不能中途 drop 已开始的工具。工具一旦开始就等待其返回,再按 deadline 携带结果收口;当前八类画布工具只做同步参数校验并返回待确认,因此不会延长正式生成链。deadline 命中时仍按 `PromptRunError` 返回已经完成的工具结果、提交对应 staged memory 并追加终态错误。该 deadline 覆盖非法 JSON/工具校验失败触发的后续规划轮,并为错误持久化和 HTTP 返回保留约 2 分钟,不再让前端 20 分钟 transport timeout 先触发。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,错误计数/日志使用该响应所属的真实 attempt。规划重试发生在任何生成工具执行之前,不会重复提交生成任务或扣费;生成图片/编辑图片仍走对应生成工具和模型计费。
- 画布 Agent 规划请求使用 Chat Completions 和 1024 生成 token 预算;VectorEngine 专用 client 发送 `max_completion_tokens`,预算包含可见输出与隐藏 reasoning token,不等于可见正文长度。发送后 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 必须作为 runner 内部 deadline future 参与 completion await,并在每个 tool 开始前、返回后检查,不能用外层 `tokio::timeout` 丢弃整个 prompt future,也不能中途 drop 已开始的工具。工具一旦开始就等待其返回,再按 deadline 携带结果收口;当前八类画布工具只做同步参数校验并返回待确认,因此不会延长正式生成链。deadline 命中时仍按 `PromptRunError` 返回已经完成的工具结果、提交对应 staged memory 并追加终态错误。该 deadline 覆盖非法 JSON/工具校验失败触发的后续规划轮,并为错误持久化和 HTTP 返回保留约 2 分钟,不再让前端 20 分钟 transport timeout 先触发。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,错误计数/日志使用该响应所属的真实 attempt。规划重试发生在任何生成工具执行之前,不会重复提交生成任务或扣费;生成图片/编辑图片仍走对应生成工具和模型计费。
- function-calling runner 必须把“等待用户确认”作为显式工具语义:当本批所有工具都校验成功并进入待确认状态时,立即以成功结果结束当前规划回合并持久化助手文本与待确认卡,不得继续依赖 LLM 自行停止;未知工具、参数错误、普通连续工具和不可解析响应仍受 `max_turns` 保护。
- runner 失败必须返回显式的 `PromptRunError { error, partial_outputs }`,不得只返回终态错误而丢弃本轮已产生的文本或工具事实。prompt 执行使用 `AgentMemory::begin_staged` 创建行为等价且写入隔离的 `StagedAgentMemory` 事务,限长、摘要、脱敏等 append 规则必须在本轮 completion 前生效;成功或已发生工具活动时必须显式调用 `commit()`,直接 drop staged transaction 表示回滚,不得统一复制成 `VecMemory` 或仅替换 box 冒充持久化提交。本轮无工具活动失败时回滚 staged 用户消息、助手文本和不可解析响应;已有工具活动时在末尾追加 terminal error closure 后提交。外部 drop / abort 若尚无工具活动则回滚并保持原 committed memory;若工具已完成则提交结果与取消闭环,若工具仍在执行则提交“已启动、结果未知”事实与取消闭环,后续必须先 reconcile 再决定是否重试。
- `ToolFailure` 必须以结构化工具失败输出暴露给 harness 调用方:调用方能读取 `kind``retryable``fatal` 和工具返回的原始 `output`;不得把它们压成单一错误字符串。这些字段只提供流程决策与诊断事实,是否重试、如何展示或持久化仍由业务调用方决定。
+3 -3
View File
@@ -87,7 +87,7 @@ const tests = [
body: {
model: 'gpt-5.4-mini',
messages: [{ role: 'user', content: '回复 ok,不要解释' }],
max_tokens: 10,
max_completion_tokens: 10,
},
},
@@ -112,7 +112,7 @@ const tests = [
body: {
model: 'gpt-5.4-mini',
messages: [{ role: 'user', content: '回复 ok' }],
max_tokens: 10,
max_completion_tokens: 10,
},
},
@@ -127,7 +127,7 @@ const tests = [
{ role: 'system', content: '你是抓大鹅游戏编辑,只返回 JSON。' },
{ role: 'user', content: '题材:水果。请生成 JSON{"gameName":"水果切切乐","items":[{"name":"苹果","itemSize":"中"},{"name":"西瓜","itemSize":"大"}]}' },
],
max_tokens: 200,
max_completion_tokens: 200,
},
},
];
@@ -887,6 +887,11 @@ mod tests {
requests[0].body["model"],
BACKGROUND_MUSIC_PROMPT_ASSIST_MODEL
);
assert_eq!(
requests[0].body["max_completion_tokens"],
BACKGROUND_MUSIC_PROMPT_ASSIST_MAX_OUTPUT_TOKENS
);
assert!(requests[0].body.get("max_tokens").is_none());
assert_eq!(requests[0].body["reasoning_effort"], "medium");
assert!(requests[0].body.get("tools").is_none());
assert!(requests[0].body.get("tool_choice").is_none());
+7 -2
View File
@@ -21,7 +21,7 @@ use platform_auth::{
RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, SmsAuthConfig, SmsAuthProvider,
SmsAuthProviderKind, SmsProviderError, WechatProvider, sign_access_token, verify_access_token,
};
use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider};
use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider, OpenAiChatTokenBudgetField};
use platform_matting::{MattingClient, MattingConfig};
use platform_oss::{OssClient, OssConfig, OssError};
use platform_wechat::{WechatClient, WechatConfig, pay::WechatPayClient};
@@ -2187,7 +2187,8 @@ fn build_vector_engine_llm_client(
config
.llm_retry_backoff_ms
.min(EDITOR_AGENT_LLM_MAX_RETRY_BACKOFF_MS),
)?;
)?
.with_openai_chat_token_budget_field(OpenAiChatTokenBudgetField::MaxCompletionTokens);
Ok(Some(LlmClient::new(llm_config)?))
}
@@ -2508,6 +2509,10 @@ mod tests {
"https://api.vectorengine.test/v1/chat/completions"
);
assert!(!client.config().official_fallback());
assert_eq!(
client.config().openai_chat_token_budget_field(),
OpenAiChatTokenBudgetField::MaxCompletionTokens
);
assert_eq!(client.config().max_retries(), 1);
assert_eq!(client.config().retry_backoff_ms(), 60_000);
}
+19 -17
View File
@@ -1,6 +1,6 @@
# platform-llm 平台适配 crate
日期`2026-07-27`
更新`2026-08-06`
## 1. crate 职责
@@ -36,10 +36,11 @@ Responses 如果只发送 `response.completed` 或 `response.incomplete`,解
## 4. 流式与参数契约
1. `LlmStreamDelta` 只包含 `accumulated_text``delta_text``finish_reason`,工具调用不会进入 `on_delta`;纯工具响应允许 `text` 为空
2. 工具片段按协议索引聚合:Chat 使用 `delta.tool_calls[].index`Responses 使用 `output_index`Anthropic 使用 content block `index`。Responses 的 `.done``response.completed``response.incomplete` 中的完整 arguments 是权威值,可以覆盖之前的分片拼接
3. 流结束固化工具调用时,缺少 id 或函数名返回 `Deserialize`;空参数默认保存为 `{}`;非空参数必须能反序列化为完整 JSON,截断或半截 JSON 不会交给业务层。这里是 JSON 语法完整性检查,不是针对 `parameters` 的 JSON Schema 业务校验
4. 非流式工具调用采用不同的参数边界:缺失或空白 `arguments` 统一归一为 `{}`Chat / Responses 的非空畸形 `arguments` 不在平台层做 JSON 校验、修复或静默丢弃,而是保留参数内容(仅按统一归一策略去除首尾空白),连同 call id 和函数名交给调用方的 repair 循环。Anthropic `tool_use.input` 缺失时同样按 `{}` 归一;调用方不能把非流式参数自动假定为统一 schema 校验通过
1. `LlmRunRequest.max_output_tokens` 是协议中立的生成预算,包含可见输出与 Provider 可能使用的隐藏 reasoning token,不包含输入 token,也不保证可见正文长度。Responses 映射为 `max_output_tokens`Anthropic 映射为 `max_tokens`Chat 由 `LlmConfig.openai_chat_token_budget_field` 显式映射为当前 `max_completion_tokens` 或兼容网关旧字段 `max_tokens`,每次只发送一个。默认保留 legacy,已验证支持新字段的 endpoint 必须显式 opt-in;禁止按模型名猜测或收到 `400` 后自动重放
2. `LlmStreamDelta` 只包含 `accumulated_text``delta_text` `finish_reason`,工具调用不会进入 `on_delta`;纯工具响应允许 `text` 为空
3. 工具片段按协议索引聚合:Chat 使用 `delta.tool_calls[].index`Responses 使用 `output_index`Anthropic 使用 content block `index`。Responses 的 `.done``response.completed``response.incomplete` 中的完整 arguments 是权威值,可以覆盖之前的分片拼接
4. 流结束固化工具调用时,缺少 id 或函数名返回 `Deserialize`;空参数默认保存为 `{}`;非空参数必须能反序列化为完整 JSON,截断或半截 JSON 不会交给业务层。这里是 JSON 语法完整性检查,不是针对 `parameters` 的 JSON Schema 业务校验。
5. 非流式工具调用采用不同的参数边界:缺失或空白 `arguments` 统一归一为 `{}`Chat / Responses 的非空畸形 `arguments` 不在平台层做 JSON 校验、修复或静默丢弃,而是保留参数内容(仅按统一归一策略去除首尾空白),连同 call id 和函数名交给调用方的 repair 循环。Anthropic `tool_use.input` 缺失时同样按 `{}` 归一;调用方不能把非流式参数自动假定为统一 schema 校验通过。
## 5. 错误边界
@@ -54,18 +55,19 @@ Responses 如果只发送 `response.completed` 或 `response.incomplete`,解
1. `LlmProvider`
2. `LlmConfig`
3. `LlmMessageRole`
4. `LlmMessage`
5. `LlmRunRequest`
6. `LlmApiKind`
7. `LlmStreamDelta`
8. `LlmFunctionTool`
9. `LlmToolChoice`
10. `LlmToolCall`
11. `LlmRunResponse`
12. `LlmTokenUsage`
13. `LlmClient`
14. `LlmError`
3. `OpenAiChatTokenBudgetField`
4. `LlmMessageRole`
5. `LlmMessage`
6. `LlmRunRequest`
7. `LlmApiKind`
8. `LlmStreamDelta`
9. `LlmFunctionTool`
10. `LlmToolChoice`
11. `LlmToolCall`
12. `LlmRunResponse`
13. `LlmTokenUsage`
14. `LlmClient`
15. `LlmError`
## 7. 设计文档
+169 -27
View File
@@ -47,6 +47,18 @@ pub enum LlmProvider {
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 {
@@ -60,6 +72,7 @@ pub struct LlmConfig {
retry_backoff_ms: u64,
official_fallback: bool,
anthropic_strict_tool_support: bool,
openai_chat_token_budget_field: OpenAiChatTokenBudgetField,
}
// 首版只冻结当前项目已稳定使用的 system/user/assistant 三种消息角色。
@@ -154,6 +167,8 @@ pub struct LlmToolCall {
pub struct LlmRunRequest {
pub model: Option<String>,
pub messages: Vec<LlmMessage>,
/// 生成侧 token 预算,包含可见输出与 Provider 可能使用的隐藏 reasoning token
/// 不包含输入 token,也不保证可见正文长度。
pub max_output_tokens: Option<u32>,
pub enable_web_search: bool,
pub api_kind: LlmApiKind,
@@ -294,8 +309,9 @@ struct ChatCompletionsRequestBody {
#[serde(skip_serializing_if = "Option::is_none")]
official_fallback: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "max_tokens")]
max_output_tokens: Option<u32>,
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")]
@@ -1023,6 +1039,7 @@ impl LlmConfig {
retry_backoff_ms,
official_fallback: false,
anthropic_strict_tool_support: false,
openai_chat_token_budget_field: OpenAiChatTokenBudgetField::LegacyMaxTokens,
})
}
@@ -1040,6 +1057,15 @@ impl LlmConfig {
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
@@ -1097,6 +1123,10 @@ impl LlmConfig {
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!(
"{}/{}",
@@ -2257,31 +2287,41 @@ fn build_request_body(request: &LlmRunRequest, config: &LlmConfig, stream: bool)
let fallback_model = config.model();
let official_fallback = config.official_fallback().then_some(true);
match request.api_kind {
LlmApiKind::OpenAiChat => 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_output_tokens: request.max_output_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::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,
@@ -4022,6 +4062,33 @@ mod tests {
);
}
#[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("系统", "用户");
@@ -4030,6 +4097,75 @@ mod tests {
assert_eq!(request.with_openai_chat().api_kind, LlmApiKind::OpenAiChat);
}
#[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("系统", "用户")
@@ -4870,6 +5006,8 @@ mod tests {
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());
}
@@ -4978,6 +5116,7 @@ mod tests {
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
@@ -4994,6 +5133,7 @@ mod tests {
]),
])
.with_openai_chat()
.with_max_output_tokens(256)
.with_response_reasoning_effort(LlmResponseReasoningEffort::Low),
)
.await
@@ -5012,6 +5152,8 @@ mod tests {
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"],